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:
co-authored by
Claude Opus 5
parent
13270eed8b
commit
4b7b3d2221
Vendored
+9
@@ -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']>
|
||||
|
||||
Vendored
+1
@@ -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") }} — {{ 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">›</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>
|
||||
@@ -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)}`);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user