Files
dozzle/assets/composable/cloudAlerts.ts
T

290 lines
9.7 KiB
TypeScript

import { useCloudConfig } from "@/composable/cloudConfig";
import { AlertLogEntry, CloudEventLogEntry, LogEntry, type LogMessage } from "@/models/LogEntry";
/**
* One alert anchored inside a scroll window, as returned by
* /api/cloud/alerts. Mirrors cloud.AlertHit on the Go side.
*/
export interface CloudAlert {
/** Opaque, sqids-encoded. Never a raw row id. */
alertId: string;
containerId: string;
hostId: string;
/**
* Dozzle's FNV-32a hash of the line that triggered the alert — the same id
* LogEntry.id carries. Absent for metric and event alerts, which have no
* triggering line and are positioned by `ts` alone.
*/
logId?: number;
/** Anchor time, unix nanoseconds. */
ts: number;
headline: string;
level: string;
eventCount: number;
suppressedCount?: number;
/** > 1 means the incident is wider than the container being viewed. */
containerCount?: number;
/** The alert's description, shown in Details. Present even when triage
* wrote no investigation. */
summary?: string;
investigation?: string;
triageAction?: string;
createdAt: number;
lastActivityAt?: number;
/**
* True where the incident FIRST fired. Cloud appends every folded batch's
* events to the original alert, so one incident can have activity in many
* windows; follow-up anchors render as a compact marker rather than a card.
*/
isOrigin: boolean;
url?: string;
}
/** One matched event, as returned by /api/cloud/alerts?events=1. */
export interface CloudEvent {
ts: number;
logId?: number;
containerId: string;
level?: string;
message?: string;
type?: string;
/** Human-readable summary; the only renderable text a non-log event has. */
detail?: string;
/** Opaque id of the alert this event reached, empty when it reached none. */
alertId?: string;
suppressed: boolean;
}
interface CloudAlertsResponse {
hits: CloudAlert[];
events?: CloudEvent[];
truncated?: boolean;
}
/**
* fetchAlerts asks Cloud which alerts fired on these containers inside a
* window. Gated on `linked` only — NOT on streamLogs, which log search
* requires: alerts live in Cloud's database rather than the log store, so they
* exist for anyone who linked cloud and configured a subscription.
*
* Never throws. This rides the scroll path, where the log lines have already
* loaded and alerts are a decoration on top — a cloud outage must degrade to
* "no alerts", never to "no logs".
*/
export async function fetchAlerts(
containerIDs: string[],
from: Date,
to: Date,
{
linked,
signal,
followUps,
events,
}: { linked: boolean; signal?: AbortSignal; followUps?: boolean; events?: boolean },
): Promise<{ alerts: CloudAlert[]; events: CloudEvent[] }> {
const empty = { alerts: [], events: [] };
// Backstop, not the primary gate — callers check availability before doing
// any of the work of assembling a window. Kept so a new caller that forgets
// cannot put traffic on an unlinked instance.
if (!linked || containerIDs.length === 0) return empty;
const params = new URLSearchParams({
containerIds: containerIDs.join(","),
from: String(from.getTime() * 1_000_000),
to: String(to.getTime() * 1_000_000),
});
if (followUps) params.set("followUps", "1");
if (events) params.set("events", "1");
try {
const res = await fetch(withBase(`/api/cloud/alerts?${params}`), { signal });
if (!res.ok) return empty;
const body = (await res.json()) as CloudAlertsResponse;
return { alerts: body.hits ?? [], events: body.events ?? [] };
} catch {
return empty;
}
}
/**
* useCloudAlerts exposes the fetch bound to the app's cloud-linked state, so
* callers don't each have to read the shared config.
*/
export function useCloudAlerts() {
const { cloudConfig } = useCloudConfig();
const available = computed(() => !!cloudConfig.value?.linked);
return {
available,
fetchAlerts: (
containerIDs: string[],
from: Date,
to: Date,
opts: { followUps?: boolean; events?: boolean; signal?: AbortSignal } = {},
) => fetchAlerts(containerIDs, from, to, { linked: available.value, ...opts }),
};
}
/**
* mergeAlerts splices alerts into a time-sorted run of log entries.
*
* An alert whose trigger line is present lands immediately AFTER that line,
* matched on id — not by timestamp. Sorting alone is not enough: an alert
* shares a millisecond with the line that caused it, so a stable sort would
* place it either side by luck of input order, and the whole point is to show
* which line tripped the rule.
*
* Alerts with no matching line (metric alerts, or a trigger line outside the
* loaded range) fall back to timestamp position.
*
* `seen` carries anchor keys already placed by earlier loads, so overlapping
* scroll windows don't duplicate. It is mutated as alerts are placed.
*/
export function mergeAlerts(
logs: LogEntry<LogMessage>[],
alerts: CloudAlert[],
seen: Set<string>,
): LogEntry<LogMessage>[] {
if (alerts.length === 0) return logs;
// Origins only. A follow-up anchor marks an incident that was already open,
// and the per-line badges say that with more precision — drawing a second
// block for it would claim a delivery that never happened.
const fresh = alerts.filter((a) => a.isOrigin && !seen.has(anchorKey(a)));
if (fresh.length === 0) return logs;
// Indexed once rather than scanned per alert: logs runs into the thousands
// after scrollback, so the nested `logs.some` this replaces was O(alerts x
// logs) on the main thread every time new alerts arrived.
const linesPresent = new Set<string>();
for (const l of logs) linesPresent.add(`${l.containerID}:${l.id}`);
const byLogId = new Map<number, CloudAlert[]>();
const byTime: CloudAlert[] = [];
for (const alert of fresh) {
// Only trust logId when that line is actually in this run; otherwise the
// alert would silently vanish rather than fall back to its timestamp.
if (alert.logId && linesPresent.has(`${alert.containerId}:${alert.logId}`)) {
const bucket = byLogId.get(alert.logId);
if (bucket) bucket.push(alert);
else byLogId.set(alert.logId, [alert]);
} else {
byTime.push(alert);
}
}
const place = (alert: CloudAlert) => {
seen.add(anchorKey(alert));
return new AlertLogEntry(alert, new Date(alert.ts / 1_000_000));
};
byTime.sort((a, b) => a.ts - b.ts);
const merged: LogEntry<LogMessage>[] = [];
let pending = 0;
for (const log of logs) {
// Timestamp-anchored alerts go in ahead of the first line at or past them.
while (pending < byTime.length && byTime[pending].ts / 1_000_000 <= log.date.getTime()) {
merged.push(place(byTime[pending++]));
}
merged.push(log);
const triggered = byLogId.get(log.id);
if (triggered) {
for (const alert of triggered) {
if (alert.containerId === log.containerID) merged.push(place(alert));
}
byLogId.delete(log.id);
}
}
while (pending < byTime.length) merged.push(place(byTime[pending++]));
return merged;
}
function anchorKey(alert: CloudAlert): string {
return `${alert.alertId}:${alert.ts}`;
}
/**
* Marks the log lines Cloud reports an event on.
*
* Matching is by log id — the FNV-32a hash both sides stamp on the same raw
* line — so a line only gets badged when it really is the one that matched.
* Metric and event notifications carry no line and are skipped rather than
* guessed at by timestamp.
*
* Returns whether anything changed, so the caller can skip re-rendering the
* list when it did not.
*/
export function attachEvents(logs: LogEntry<LogMessage>[], events: CloudEvent[]): boolean {
if (events.length === 0) return false;
// Only suppressed log events are badged. One that produced an alert is
// already represented by the alert block, and a second marker on the same
// line would say the same thing twice.
const byLogId = new Map<number, CloudEvent>();
for (const e of events) {
if (e.logId && e.suppressed && isLogEvent(e)) byLogId.set(e.logId, e);
}
if (byLogId.size === 0) return false;
let changed = false;
for (const log of logs) {
if (log.matchedEvent) continue;
const event = byLogId.get(log.id);
if (!event || event.containerId !== log.containerID) continue;
log.matchedEvent = { suppressed: event.suppressed };
changed = true;
}
return changed;
}
/** A log event has a line in the stream; metric and container events do not. */
function isLogEvent(event: CloudEvent): boolean {
return (event.type ?? "log") === "log";
}
/**
* Splices metric and container events into the stream as their own rows.
*
* These have no log line to badge — they are the notification itself — so
* unlike log events they cannot be shown by marking something already on
* screen. Positioned by timestamp, since there is no id to anchor to.
*
* Events that produced an alert are skipped: the alert block already stands
* for them.
*/
export function mergeCloudEvents(
logs: LogEntry<LogMessage>[],
events: CloudEvent[],
seen: Set<string>,
): LogEntry<LogMessage>[] {
const fresh = events.filter((e) => !isLogEvent(e) && e.suppressed && !seen.has(eventKey(e)));
if (fresh.length === 0) return logs;
fresh.sort((a, b) => a.ts - b.ts);
const merged: LogEntry<LogMessage>[] = [];
let pending = 0;
const place = (event: CloudEvent) => {
seen.add(eventKey(event));
return new CloudEventLogEntry(event, new Date(event.ts / 1_000_000));
};
for (const log of logs) {
while (pending < fresh.length && fresh[pending].ts / 1_000_000 <= log.date.getTime()) {
merged.push(place(fresh[pending++]));
}
merged.push(log);
}
while (pending < fresh.length) merged.push(place(fresh[pending++]));
return merged;
}
function eventKey(event: CloudEvent): string {
return `event:${event.containerId}:${event.ts}`;
}