feat(logs): merge Dozzle Cloud alerts into the log stream (#4915)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Amir Raminfar
2026-08-16 15:09:03 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 13270eed8b
commit 4b7b3d2221
9 changed files with 684 additions and 12 deletions
+9
View File
@@ -62,6 +62,7 @@ declare global {
const effectScope: typeof import('vue').effectScope
const escapeHtml: typeof import('./utils/index').escapeHtml
const extendRef: typeof import('@vueuse/core').extendRef
const fetchAlerts: typeof import('./composable/cloudAlerts').fetchAlerts
const flattenJSON: typeof import('./utils/index').flattenJSON
const flattenJSONToMap: typeof import('./utils/index').flattenJSONToMap
const formatBytes: typeof import('./utils/index').formatBytes
@@ -102,6 +103,7 @@ declare global {
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const menuWidth: typeof import('./stores/settings').menuWidth
const mergeAlerts: typeof import('./composable/cloudAlerts').mergeAlerts
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
@@ -218,6 +220,7 @@ declare global {
const useClipboard: typeof import('@vueuse/core').useClipboard
const useClipboardItems: typeof import('@vueuse/core').useClipboardItems
const useCloned: typeof import('@vueuse/core').useCloned
const useCloudAlerts: typeof import('./composable/cloudAlerts').useCloudAlerts
const useCloudConfig: typeof import('./composable/cloudConfig').useCloudConfig
const useCloudLogSearch: typeof import('./composable/cloudLogSearch').useCloudLogSearch
const useColorMode: typeof import('@vueuse/core').useColorMode
@@ -425,6 +428,9 @@ declare global {
export type { AlertFormOptions, ContainerResult } from './composable/alertForm'
import('./composable/alertForm')
// @ts-ignore
export type { CloudAlert } from './composable/cloudAlerts'
import('./composable/cloudAlerts')
// @ts-ignore
export type { CloudLogHit } from './composable/cloudLogSearch'
import('./composable/cloudLogSearch')
// @ts-ignore
@@ -512,6 +518,7 @@ declare module 'vue' {
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
readonly extendRef: UnwrapRef<typeof import('@vueuse/core')['extendRef']>
readonly fetchAlerts: UnwrapRef<typeof import('./composable/cloudAlerts')['fetchAlerts']>
readonly flattenJSON: UnwrapRef<typeof import('./utils/index')['flattenJSON']>
readonly flattenJSONToMap: UnwrapRef<typeof import('./utils/index')['flattenJSONToMap']>
readonly formatBytes: UnwrapRef<typeof import('./utils/index')['formatBytes']>
@@ -551,6 +558,7 @@ declare module 'vue' {
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
readonly menuWidth: UnwrapRef<typeof import('./stores/settings')['menuWidth']>
readonly mergeAlerts: UnwrapRef<typeof import('./composable/cloudAlerts')['mergeAlerts']>
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
@@ -664,6 +672,7 @@ declare module 'vue' {
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
readonly useCloudAlerts: UnwrapRef<typeof import('./composable/cloudAlerts')['useCloudAlerts']>
readonly useCloudConfig: UnwrapRef<typeof import('./composable/cloudConfig')['useCloudConfig']>
readonly useCloudLogSearch: UnwrapRef<typeof import('./composable/cloudLogSearch')['useCloudLogSearch']>
readonly useColorMode: UnwrapRef<typeof import('@vueuse/core')['useColorMode']>
+1
View File
@@ -13,6 +13,7 @@ declare module 'vue' {
export interface GlobalComponents {
AlertCard: typeof import('./components/Notification/AlertCard.vue')['default']
AlertForm: typeof import('./components/Notification/AlertForm.vue')['default']
AlertLogItem: typeof import('./components/LogViewer/AlertLogItem.vue')['default']
Announcements: typeof import('./components/Announcements.vue')['default']
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:add': typeof import('~icons/carbon/add')['default']
@@ -0,0 +1,125 @@
/**
* @vitest-environment jsdom
*/
import { mount } from "@vue/test-utils";
import { describe, expect, test, vi } from "vitest";
import AlertLogItem from "./AlertLogItem.vue";
import { AlertLogEntry } from "@/models/LogEntry";
import type { CloudAlert } from "@/composable/cloudAlerts";
vi.mock("@/stores/config", () => ({
__esModule: true,
default: { base: "", hosts: [{ name: "localhost", id: "localhost" }] },
withBase: (path: string) => path,
}));
const ns = (n: number) => n * 1_000_000;
function mountAlert(overrides: Partial<CloudAlert> = {}) {
const alert: CloudAlert = {
alertId: 1,
containerId: "abc",
hostId: "h",
ts: ns(1_000_000),
headline: "Pool exhausted",
level: "error",
eventCount: 47,
createdAt: ns(1_000_000),
isOrigin: true,
...overrides,
};
return mount(AlertLogItem, {
props: { logEntry: new AlertLogEntry(alert, new Date(alert.ts / 1_000_000)) },
global: {
stubs: { LogItem: { template: "<div><slot /></div>" } },
mocks: { $t: (key: string, arg?: unknown) => `${key}:${JSON.stringify(arg)}` },
},
});
}
describe("<AlertLogItem />", () => {
test("renders headline and event count on one line at the origin", () => {
const wrapper = mountAlert();
expect(wrapper.text()).toContain("Pool exhausted");
expect(wrapper.text()).toContain("label.alert-events");
expect(wrapper.find("[data-origin='true']").exists()).toBe(true);
});
// A long incident would bury the logs if every window it touched drew a full
// row, so follow-ups get one quiet line instead.
test("renders a compact line at a follow-up anchor", () => {
const wrapper = mountAlert({ isOrigin: false });
expect(wrapper.text()).toContain("label.alert-still-firing");
expect(wrapper.text()).not.toContain("label.alert-events");
});
// The whole point of the one-line treatment: detail costs an interaction, so
// an alert never takes more than a row of the log stream uninvited.
describe("details", () => {
test("keeps the investigation collapsed until asked", async () => {
const wrapper = mountAlert({ investigation: "pool exhausted under retry storm" });
expect(wrapper.text()).not.toContain("retry storm");
await wrapper.get("button").trigger("click");
expect(wrapper.text()).toContain("retry storm");
});
test("collapses again on a second press", async () => {
const wrapper = mountAlert({ investigation: "pool exhausted under retry storm" });
await wrapper.get("button").trigger("click");
await wrapper.get("button").trigger("click");
expect(wrapper.text()).not.toContain("retry storm");
});
// A control that opens an empty panel is worse than no control.
test("offers no button when there is nothing to expand", () => {
expect(mountAlert().find("button").exists()).toBe(false);
});
test("offers the button for held-back counts and multi-container incidents", () => {
expect(mountAlert({ suppressedCount: 34 }).find("button").exists()).toBe(true);
expect(mountAlert({ containerCount: 3 }).find("button").exists()).toBe(true);
});
});
// alerts.level is free text and defaults to '' for anything that never went
// through a summarizer. A neutral fallback made the common case look like an
// ordinary log line, which is the one thing this row must not look like.
describe("level styling", () => {
test("treats an empty level as an error", () => {
expect(mountAlert({ level: "" }).find("[data-level='error']").exists()).toBe(true);
});
test("treats an unrecognised level as an error", () => {
expect(mountAlert({ level: "mystery" }).find("[data-level='error']").exists()).toBe(true);
});
test("keeps warn and info distinct", () => {
expect(mountAlert({ level: "warning" }).find("[data-level='warn']").exists()).toBe(true);
expect(mountAlert({ level: "debug" }).find("[data-level='info']").exists()).toBe(true);
});
});
test("shows the container count only when the incident is wider than one", async () => {
expect(mountAlert({ containerCount: 1 }).text()).not.toContain("label.alert-containers");
const wide = mountAlert({ containerCount: 3 });
await wide.get("button").trigger("click");
expect(wide.text()).toContain("label.alert-containers");
});
test("shows held-back count only when triage suppressed follow-ups", async () => {
expect(mountAlert({ suppressedCount: 0 }).text()).not.toContain("label.alert-held-back");
const held = mountAlert({ suppressedCount: 34 });
await held.get("button").trigger("click");
expect(held.text()).toContain("label.alert-held-back");
});
test("links out to cloud only when the key has an app url", () => {
expect(mountAlert({ url: undefined }).find("a").exists()).toBe(false);
expect(mountAlert({ url: "https://app.example.com/alerts/1" }).find("a").attributes("href")).toBe(
"https://app.example.com/alerts/1",
);
});
});
@@ -0,0 +1,123 @@
<template>
<LogItem :logEntry>
<div class="alert-row w-full border-l-3 pl-2" :data-level="level" :data-origin="alert.isOrigin">
<!-- Follow-up anchor: the incident was already open and still firing
here. One quiet line — a long incident must never draw two cards. -->
<div v-if="!alert.isOrigin" class="flex items-center gap-2 text-xs opacity-60">
<span class="dot"></span>
<span>{{ $t("label.alert-still-firing") }} &mdash; {{ alert.headline }}</span>
<a v-if="alert.url" :href="alert.url" target="_blank" rel="noopener" class="link">
{{ $t("label.alert-open") }}
</a>
</div>
<template v-else>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="chip">{{ $t("label.alert") }}</span>
<span class="font-medium">{{ alert.headline }}</span>
<!-- Only the count rides the one-line summary. Everything else —
containers, what triage held back, the investigation — sits behind
Details, so the row costs exactly one line until asked otherwise. -->
<span class="text-xs opacity-60">{{ $t("label.alert-events", alert.eventCount) }}</span>
<!-- Right-aligned so the headline starts at the same x-position on
every alert down the stream, which is what makes a column of
them scannable. -->
<span class="ml-auto flex shrink-0 items-center gap-1">
<button v-if="hasDetail" type="button" class="btn btn-ghost btn-xs" @click="expanded = !expanded">
{{ $t("label.alert-details") }}
<span class="caret" :data-open="expanded">&rsaquo;</span>
</button>
<a v-if="alert.url" :href="alert.url" target="_blank" rel="noopener" class="btn btn-xs">
{{ $t("label.alert-view-in-cloud") }}
</a>
</span>
</div>
<div v-if="expanded" class="mt-1 flex flex-col gap-1 text-xs opacity-70">
<div class="flex flex-wrap gap-x-4 gap-y-0.5">
<span v-if="alert.containerCount && alert.containerCount > 1">
{{ $t("label.alert-containers", alert.containerCount) }}
</span>
<span v-if="alert.suppressedCount">
{{ $t("label.alert-held-back", alert.suppressedCount) }}
</span>
</div>
<div v-if="alert.investigation" class="max-w-prose whitespace-pre-wrap">
{{ alert.investigation }}
</div>
</div>
</template>
</div>
</LogItem>
</template>
<script lang="ts" setup>
import { AlertLogEntry } from "@/models/LogEntry";
const { logEntry } = defineProps<{
logEntry: AlertLogEntry;
showContainerName?: boolean;
}>();
const alert = computed(() => logEntry.alert);
const expanded = ref(false);
/**
* alerts.level is free text and defaults to '' — an alert that never went
* through a summarizer has no level at all. A neutral fallback made the common
* case indistinguishable from a log line, which is the one thing this row must
* not be. Unknown reads as error.
*/
const level = computed(() => {
const l = alert.value.level;
if (l === "warn" || l === "warning") return "warn";
if (l === "info" || l === "debug" || l === "trace") return "info";
return "error";
});
const hasDetail = computed(
() => !!alert.value.investigation || !!alert.value.suppressedCount || (alert.value.containerCount ?? 0) > 1,
);
</script>
<style scoped>
@reference "@/main.css";
/* No fill: a rail and a chip mark the row, and the log background is left
alone. Colour is spent where the eye finds it fastest — a small,
high-contrast marker against a calm surface. */
.alert-row {
border-color: var(--tint);
}
.alert-row[data-level="error"] {
--tint: var(--color-error);
--tint-content: var(--color-error-content);
}
.alert-row[data-level="warn"] {
--tint: var(--color-warning);
--tint-content: var(--color-base-300);
}
.alert-row[data-level="info"] {
--tint: var(--color-info);
--tint-content: var(--color-info-content);
}
.chip {
background-color: var(--tint);
color: var(--tint-content);
@apply shrink-0 rounded-xs px-1 text-[0.65rem] font-bold tracking-wider uppercase;
}
.dot {
background-color: var(--tint);
@apply size-1.5 shrink-0 rounded-full;
}
.caret {
@apply inline-block transition-transform;
}
.caret[data-open="true"] {
@apply rotate-90;
}
</style>
+172
View File
@@ -0,0 +1,172 @@
/**
* @vitest-environment jsdom
*/
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { mergeAlerts, fetchAlerts, type CloudAlert } from "./cloudAlerts";
import { AlertLogEntry, SimpleLogEntry, type LogEntry, type LogMessage } from "@/models/LogEntry";
vi.mock("@/composable/cloudConfig", () => ({ useCloudConfig: () => ({ cloudConfig: { value: null } }) }));
const ms = (n: number) => new Date(n);
const ns = (n: number) => n * 1_000_000;
function log(id: number, at: number, containerID = "abc"): LogEntry<LogMessage> {
return new SimpleLogEntry(`line ${id}`, containerID, id, ms(at), "info", "stdout", `line ${id}`);
}
function alert(overrides: Partial<CloudAlert> = {}): CloudAlert {
return {
alertId: 1,
containerId: "abc",
hostId: "h",
ts: ns(100),
headline: "Pool exhausted",
level: "error",
eventCount: 3,
createdAt: ns(100),
isOrigin: true,
...overrides,
};
}
const shapeOf = (entries: LogEntry<LogMessage>[]) =>
entries.map((e) => (e instanceof AlertLogEntry ? `alert:${e.alert.alertId}` : `log:${e.id}`));
describe("mergeAlerts", () => {
test("anchors an alert immediately after its trigger line", () => {
const logs = [log(10, 100), log(11, 200), log(12, 300)];
const merged = mergeAlerts(logs, [alert({ logId: 11, ts: ns(200) })], new Set());
expect(shapeOf(merged)).toEqual(["log:10", "log:11", "alert:1", "log:12"]);
});
// The whole reason for matching on id rather than sorting by time: an alert
// shares a millisecond with the line that caused it, so ordering by
// timestamp alone would place it either side depending on sort stability.
test("lands after the trigger line even when timestamps are identical", () => {
const logs = [log(10, 100), log(11, 100), log(12, 100)];
const merged = mergeAlerts(logs, [alert({ logId: 11, ts: ns(100) })], new Set());
expect(shapeOf(merged)).toEqual(["log:10", "log:11", "alert:1", "log:12"]);
});
test("falls back to timestamp position when the trigger line is not loaded", () => {
const logs = [log(10, 100), log(11, 300)];
// logId 99 is not in this run — e.g. the line is outside the window.
const merged = mergeAlerts(logs, [alert({ logId: 99, ts: ns(200) })], new Set());
expect(shapeOf(merged)).toEqual(["log:10", "alert:1", "log:11"]);
});
test("positions metric alerts, which carry no trigger line, by timestamp", () => {
const logs = [log(10, 100), log(11, 300)];
const merged = mergeAlerts(logs, [alert({ logId: undefined, ts: ns(200) })], new Set());
expect(shapeOf(merged)).toEqual(["log:10", "alert:1", "log:11"]);
});
test("ignores a trigger line belonging to a different container", () => {
const logs = [log(10, 100), log(11, 300, "other")];
// Same FNV id on a different container must not steal the anchor.
const merged = mergeAlerts(logs, [alert({ logId: 11, ts: ns(400) })], new Set());
expect(shapeOf(merged)).toEqual(["log:10", "log:11", "alert:1"]);
});
test("appends alerts that fall past the end of the run", () => {
const logs = [log(10, 100)];
const merged = mergeAlerts(logs, [alert({ ts: ns(900) })], new Set());
expect(shapeOf(merged)).toEqual(["log:10", "alert:1"]);
});
test("keeps time-anchored alerts in order among themselves", () => {
const logs = [log(10, 500)];
const merged = mergeAlerts(
logs,
[alert({ alertId: 2, ts: ns(300) }), alert({ alertId: 1, ts: ns(100) })],
new Set(),
);
expect(shapeOf(merged)).toEqual(["alert:1", "alert:2", "log:10"]);
});
describe("dedupe", () => {
test("does not place the same anchor twice across overlapping windows", () => {
const seen = new Set<string>();
const first = mergeAlerts([log(10, 100)], [alert({ logId: 10, ts: ns(100) })], seen);
expect(shapeOf(first)).toEqual(["log:10", "alert:1"]);
// Same alert returned again by an overlapping scroll window.
const second = mergeAlerts([log(10, 100)], [alert({ logId: 10, ts: ns(100) })], seen);
expect(shapeOf(second)).toEqual(["log:10"]);
});
// Keying the seen-set on alertId alone would swallow this: scrolling
// newest -> oldest loads the follow-up first, and the origin — the thing
// the user is actually scrolling back to find — would never render.
test("still places the origin after a follow-up anchor of the same incident", () => {
const seen = new Set<string>();
const followUp = alert({ ts: ns(900), isOrigin: false });
const origin = alert({ ts: ns(100), isOrigin: true });
mergeAlerts([log(20, 900)], [followUp], seen);
const older = mergeAlerts([log(10, 100)], [origin], seen);
expect(shapeOf(older)).toEqual(["alert:1", "log:10"]);
expect((older[0] as AlertLogEntry).alert.isOrigin).toBe(true);
});
});
test("returns the original array when there is nothing to merge", () => {
const logs = [log(10, 100)];
expect(mergeAlerts(logs, [], new Set())).toBe(logs);
});
});
describe("fetchAlerts", () => {
const originalFetch = global.fetch;
beforeEach(() => vi.stubGlobal("withBase", (s: string) => s));
afterEach(() => {
global.fetch = originalFetch;
vi.unstubAllGlobals();
});
test("does not call cloud when not linked", async () => {
const spy = vi.fn();
global.fetch = spy;
expect(await fetchAlerts(["abc"], ms(0), ms(1), { linked: false })).toEqual([]);
expect(spy).not.toHaveBeenCalled();
});
test("does not call cloud with no containers", async () => {
const spy = vi.fn();
global.fetch = spy;
expect(await fetchAlerts([], ms(0), ms(1), { linked: true })).toEqual([]);
expect(spy).not.toHaveBeenCalled();
});
// This rides the scroll path, where the logs have already rendered. A cloud
// outage must cost the user their alerts, never their logs.
test("degrades to no alerts when cloud fails", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("cloud down"));
expect(await fetchAlerts(["abc"], ms(0), ms(1), { linked: true })).toEqual([]);
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 502 });
expect(await fetchAlerts(["abc"], ms(0), ms(1), { linked: true })).toEqual([]);
});
test("sends the window as unix nanoseconds", async () => {
const spy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ hits: [] }) });
global.fetch = spy;
await fetchAlerts(["abc", "def"], ms(1000), ms(2000), { linked: true });
const url = spy.mock.calls[0][0] as string;
expect(url).toContain("containerIds=abc%2Cdef");
expect(url).toContain(`from=${ns(1000)}`);
expect(url).toContain(`to=${ns(2000)}`);
});
});
+164
View File
@@ -0,0 +1,164 @@
import { useCloudConfig } from "@/composable/cloudConfig";
import { AlertLogEntry, 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 {
alertId: number;
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;
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;
}
interface CloudAlertsResponse {
hits: CloudAlert[];
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 }: { linked: boolean; signal?: AbortSignal },
): Promise<CloudAlert[]> {
if (!linked || containerIDs.length === 0) return [];
const params = new URLSearchParams({
containerIds: containerIDs.join(","),
from: String(from.getTime() * 1_000_000),
to: String(to.getTime() * 1_000_000),
});
try {
const res = await fetch(withBase(`/api/cloud/alerts?${params}`), { signal });
if (!res.ok) return [];
const body = (await res.json()) as CloudAlertsResponse;
return body.hits ?? [];
} catch {
return [];
}
}
/**
* 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, signal?: AbortSignal) =>
fetchAlerts(containerIDs, from, to, { linked: available.value, signal }),
};
}
/**
* 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;
const fresh = alerts.filter((a) => !seen.has(anchorKey(a)));
if (fresh.length === 0) return logs;
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 && logs.some((l) => l.id === alert.logId && l.containerID === alert.containerId)) {
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}`;
}
+33 -2
View File
@@ -2,6 +2,7 @@ import { ShallowRef, type Ref } from "vue";
import { type LogMessage, LogEntry, LoadMoreLogEntry, SkippedLogsEntry } from "@/models/LogEntry";
import { Container } from "@/models/Container";
import { loadBetween } from "@/composable/loadBetween";
import { useCloudAlerts, mergeAlerts } from "@/composable/cloudAlerts";
// Matches the rolling window size used for stats history
const LOG_WINDOW_FOR_DELTA = 300;
@@ -12,6 +13,15 @@ export function useLogLoader(
params: Ref<URLSearchParams>,
loadingMore: Ref<boolean>,
) {
const { fetchAlerts } = useCloudAlerts();
// Anchor keys already placed, so overlapping scroll windows don't duplicate.
// Keyed on (alertId, anchor) rather than alertId: one incident legitimately
// marks every window it was active in.
const placedAlerts = new Set<string>();
// The viewer clears its messages when the stream changes (container switch,
// filter change). Without this the seen-set would outlive the entries it was
// tracking, and alerts already scrolled past would never render again.
watch([params, containers], () => placedAlerts.clear());
async function loadOlderLogs(entry: LoadMoreLogEntry) {
if (!(messages.value[0] instanceof LoadMoreLogEntry)) throw new Error("No loadMoreLogEntry on first item");
if (containers.value.length === 0) return;
@@ -60,7 +70,7 @@ export function useLogLoader(
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (allNewLogs.length > 0) {
messages.value = [loader, ...allNewLogs, ...existingLogs];
messages.value = [loader, ...(await withAlerts(allNewLogs)), ...existingLogs];
}
} catch (err) {
console.error(err);
@@ -90,7 +100,8 @@ export function useLogLoader(
.sort((a, b) => a.date.getTime() - b.date.getTime());
if (allLogs.length > 0) {
const updated = messages.value.flatMap((log) => (log === entry ? allLogs : [log]));
const withAlertsApplied = await withAlerts(allLogs);
const updated = messages.value.flatMap((log) => (log === entry ? withAlertsApplied : [log]));
messages.value = updated.length > config.maxLogs ? updated.slice(-config.maxLogs) : updated;
}
} catch (err) {
@@ -100,5 +111,25 @@ export function useLogLoader(
}
}
/**
* Decorates a freshly loaded, time-sorted run of logs with any Dozzle Cloud
* alerts that fired inside it.
*
* fetchAlerts never rejects, but the guard is kept regardless: this runs on
* the scroll path, where the log lines have already been fetched. A cloud
* problem must degrade to "no alerts", never cost the user their logs.
*/
async function withAlerts(logs: LogEntry<LogMessage>[]): Promise<LogEntry<LogMessage>[]> {
if (logs.length === 0) return logs;
try {
const ids = containers.value.map((c) => c.id);
const alerts = await fetchAlerts(ids, logs[0].date, new Date(logs[logs.length - 1].date.getTime() + 1));
return mergeAlerts(logs, alerts, placedAlerts);
} catch (err) {
console.error(err);
return logs;
}
}
return { loadOlderLogs, loadSkippedLogs };
}
+48 -10
View File
@@ -1,4 +1,5 @@
import { Component, ComputedRef, Ref } from "vue";
import type { CloudAlert } from "@/composable/cloudAlerts";
import { flattenJSON } from "@/utils";
import ComplexLogItem from "@/components/LogViewer/ComplexLogItem.vue";
import SimpleLogItem from "@/components/LogViewer/SimpleLogItem.vue";
@@ -6,6 +7,7 @@ import GroupedLogItem from "@/components/LogViewer/GroupedLogItem.vue";
import ContainerEventLogItem from "@/components/LogViewer/ContainerEventLogItem.vue";
import SkippedEntriesLogItem from "@/components/LogViewer/SkippedEntriesLogItem.vue";
import LoadMoreLogItem from "@/components/LogViewer/LoadMoreLogItem.vue";
import AlertLogItem from "@/components/LogViewer/AlertLogItem.vue";
export type JSONValue = string | number | boolean | JSONObject | Array<JSONValue>;
export type JSONObject = { [x: string]: JSONValue };
@@ -14,16 +16,7 @@ export type LogType = "single" | "group" | "complex";
export type Position = "start" | "end" | "middle" | undefined;
export type LogMessage = string | string[] | JSONObject;
export type Level =
| "error"
| "warn"
| "warning"
| "info"
| "debug"
| "trace"
| "severe"
| "critical"
| "fatal"
| "unknown";
"error" | "warn" | "warning" | "info" | "debug" | "trace" | "severe" | "critical" | "fatal" | "unknown";
export interface LogFragment {
readonly m: string;
@@ -176,6 +169,51 @@ export class ContainerEventLogEntry extends LogEntry<string> {
}
}
/**
* An alert from Dozzle Cloud, merged into the log stream at the point it fired.
*
* Cloud stores the FNV-32a hash of the line that triggered each alert — the
* same id LogEvent.Id carries — so where that line is on screen the alert can
* be spliced in directly after it rather than positioned by timestamp. Metric
* and event alerts have no triggering line and carry logId 0, falling back to
* their timestamp.
*
* An alert is an incident, not a single moment: Cloud folds follow-up batches
* into the original alert, so one incident can have activity across many
* windows. `isOrigin` distinguishes where it first fired from where it was
* merely still firing.
*/
export class AlertLogEntry extends LogEntry<string> {
constructor(
public readonly alert: CloudAlert,
date: Date,
) {
// std/level feed the shared LogItem chrome. Alerts are not stream output,
// but they are unmistakably not stdout either, and the level is the one
// triage assigned.
super(alert.headline, alert.containerId, alert.alertId, date, "stderr", alert.headline, alertLevel(alert.level));
}
getComponent(): Component {
return AlertLogItem;
}
/**
* Stable identity for dedupe across overlapping scroll windows. Keyed on the
* ANCHOR as well as the alert: one incident legitimately marks every window
* it was active in, and keying on alertId alone would let a follow-up anchor
* loaded first swallow the origin loaded later.
*/
public get anchorKey(): string {
return `${this.alert.alertId}:${this.alert.ts}`;
}
}
function alertLevel(level: string): Level {
const known: Level[] = ["error", "warn", "warning", "info", "debug", "trace", "severe", "critical", "fatal"];
return (known.find((l) => l === level) ?? "unknown") as Level;
}
export class SkippedLogsEntry extends LogEntry<string> {
private _totalSkipped = ref(0);
private lastSkipped: LogEntry<string | JSONObject>;
+9
View File
@@ -34,6 +34,15 @@ action:
label:
containers: Containers
container: No containers | 1 container | {count} containers
alert-events: No events | 1 event | {count} events
alert-containers: 1 container | {count} containers
alert-held-back: "{count} similar held back"
alert-investigation: Investigation
alert-view-in-cloud: View in Dozzle Cloud
alert-still-firing: still firing
alert-open: open
alert: Alert
alert-details: Details
host-count: No Hosts | 1 Host | {count} Hosts
service: No services | 1 service | {count} services
services: Services