diff --git a/package-lock.json b/package-lock.json index a6cc5af..beed835 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "vue-router": "^4.3.0" }, "devDependencies": { + "@iconify-json/solar": "^1.2.5", "@pinia/testing": "^0.1.6", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", @@ -818,6 +819,16 @@ "@iconify/types": "*" } }, + "node_modules/@iconify-json/solar": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@iconify-json/solar/-/solar-1.2.5.tgz", + "integrity": "sha512-WMAiNwchU8zhfrySww6KQBRIBbsQ6SvgIu2yA+CHGyMima/0KQwT5MXogrZPJGoQF+1Ye3Qj6K+1CiyNn3YkoA==", + "dev": true, + "license": "CC-BY-4.0", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", diff --git a/package.json b/package.json index 985375c..da1b00d 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "vue-router": "^4.3.0" }, "devDependencies": { + "@iconify-json/solar": "^1.2.5", "@pinia/testing": "^0.1.6", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", diff --git a/scripts/build-solar-icons.mjs b/scripts/build-solar-icons.mjs new file mode 100644 index 0000000..502c7b2 --- /dev/null +++ b/scripts/build-solar-icons.mjs @@ -0,0 +1,45 @@ +// Regenerates src/lib/solar-icons.ts from @iconify-json/solar. +// +// The published set is 7,000 icons and about 6MB of JSON in one object, so importing it puts +// all of it in the bundle whatever is registered afterwards. This writes out only the icons +// the app renders. Add a name below, run `node scripts/build-solar-icons.mjs`, and commit the +// result. +import { writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { icons, width, height } = require("@iconify-json/solar/icons.json"); + +const used = [ + "check-circle-bold", + "danger-triangle-bold", + "clock-circle-bold", + "stop-circle-bold", + "alt-arrow-right-linear", + "cpu-bold-duotone", + "ssd-square-bold-duotone", + "global-bold-duotone", + "danger-circle-bold-duotone", + "stopwatch-bold-duotone", + "chart-2-bold-duotone", +]; + +const missing = used.filter((name) => !icons[name]); +if (missing.length) { + console.error(`These are not in the Solar set: ${missing.join(", ")}`); + process.exit(1); +} + +const subset = { prefix: "solar", width, height, icons: Object.fromEntries(used.map((n) => [n, icons[n]])) }; + +writeFileSync( + new URL("../src/lib/solar-icons.ts", import.meta.url), + `// Generated by scripts/build-solar-icons.mjs. Do not edit by hand. +// +// Only the icons this app renders are here: importing the published set pulls its whole 6MB +// of JSON into the bundle, and no amount of filtering afterwards gets it back out. +export const solarSubset = ${JSON.stringify(subset, null, 2)} as const; +`, +); + +console.log(`Wrote ${used.length} Solar icons.`); diff --git a/src/components/AlertRulesPanel.test.ts b/src/components/AlertRulesPanel.test.ts new file mode 100644 index 0000000..84c9064 --- /dev/null +++ b/src/components/AlertRulesPanel.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import AlertRulesPanel from "./AlertRulesPanel.vue"; +import { observabilityApi } from "@/services/observability"; + +vi.mock("@/services/observability", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + observabilityApi: { alertRules: vi.fn(), saveAlertRules: vi.fn(), firingAlerts: vi.fn() }, + }; +}); + +const rule = { + id: "r1", + name: "CPU high", + deployment: "shop", + metric: "container.cpu.usage", + comparison: "above" as const, + threshold: 80, + for_seconds: 60, + enabled: true, +}; + +const mountPanel = () => + mount(AlertRulesPanel, { + props: { deployments: ["shop", "blog"] }, + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + stubs: { + BaseModal: { template: "
", props: ["visible"] }, + }, + }, + }); + +describe("AlertRulesPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(observabilityApi.alertRules).mockResolvedValue({ data: [rule] } as any); + vi.mocked(observabilityApi.firingAlerts).mockResolvedValue({ data: [] } as any); + vi.mocked(observabilityApi.saveAlertRules).mockResolvedValue({ data: [rule] } as any); + }); + + it("says what each rule watches in words", async () => { + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("CPU high"); + expect(wrapper.text()).toContain("CPU usage above 80% for 60s, on shop"); + }); + + it("marks a rule that is currently firing", async () => { + vi.mocked(observabilityApi.firingAlerts).mockResolvedValue({ + data: [{ rule_id: "r1", rule_name: "CPU high", state: "firing" }], + } as any); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("Firing"); + expect(wrapper.find(".arp-state--firing").exists()).toBe(true); + }); + + it("sends the threshold as a number, not the string the input holds", async () => { + const wrapper = mountPanel(); + await flushPromises(); + + (wrapper.vm as any).openNew(); + // An input hands back a string whatever its type is. + (wrapper.vm as any).draft.name = "Memory"; + (wrapper.vm as any).draft.threshold = "90"; + (wrapper.vm as any).draft.for_seconds = "30"; + await (wrapper.vm as any).save(); + await flushPromises(); + + const sent = vi.mocked(observabilityApi.saveAlertRules).mock.calls[0][0]; + const added = sent[sent.length - 1]; + expect(added.threshold).toBe(90); + expect(added.for_seconds).toBe(30); + }); + + it("shows why the agent refused a rule", async () => { + vi.mocked(observabilityApi.saveAlertRules).mockRejectedValue({ + response: { data: { error: 'unknown metric "container.disk.usage"' } }, + }); + + const wrapper = mountPanel(); + await flushPromises(); + (wrapper.vm as any).openNew(); + await (wrapper.vm as any).save(); + await flushPromises(); + + expect(wrapper.text()).toContain("unknown metric"); + }); + + it("deletes a rule by saving the set without it", async () => { + vi.mocked(observabilityApi.saveAlertRules).mockResolvedValue({ data: [] } as any); + + const wrapper = mountPanel(); + await flushPromises(); + await (wrapper.vm as any).remove(rule); + + expect(observabilityApi.saveAlertRules).toHaveBeenCalledWith([]); + }); + + it("invites the first rule when there are none", async () => { + vi.mocked(observabilityApi.alertRules).mockResolvedValue({ data: [] } as any); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("Nothing is watched yet"); + }); +}); diff --git a/src/components/AlertRulesPanel.vue b/src/components/AlertRulesPanel.vue new file mode 100644 index 0000000..7878a3f --- /dev/null +++ b/src/components/AlertRulesPanel.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/src/components/DeploymentHealthSummary.test.ts b/src/components/DeploymentHealthSummary.test.ts new file mode 100644 index 0000000..e426ed5 --- /dev/null +++ b/src/components/DeploymentHealthSummary.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import DeploymentHealthSummary from "./DeploymentHealthSummary.vue"; +import { observabilityApi } from "@/services/observability"; +import { servingApi } from "@/services/api"; + +vi.mock("@/services/observability", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + observabilityApi: { health: vi.fn(), latest: vi.fn() }, + }; +}); + +vi.mock("@/services/api", () => ({ + servingApi: { series: vi.fn() }, +})); + +const healthy = [{ container: "shop-web", deployment: "shop", status: "healthy" }]; + +const metrics = [ + { + deployment: "shop", + containers: [ + { + container: "shop-web", + metrics: { + "container.cpu.usage": 12.5, + "container.memory.usage": 500, + "container.memory.limit": 1000, + }, + updated: "2026-07-15T10:00:00Z", + }, + ], + }, +]; + +const quiet = [{ time: "2026-07-15T10:00:00Z", requests: 100, errors: 0, avg_time_ms: 20, p95_time_ms: 80 }]; + +const mountSummary = (props: Record = {}) => + mount(DeploymentHealthSummary, { + props: { deploymentName: "shop", status: "running", ...props }, + }); + +describe("DeploymentHealthSummary", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(observabilityApi.health).mockResolvedValue({ data: healthy } as any); + vi.mocked(observabilityApi.latest).mockResolvedValue({ data: metrics } as any); + vi.mocked(servingApi.series).mockResolvedValue({ data: { deployment: "shop", since: "1h", points: quiet } } as any); + }); + + it("leads with a verdict, not a grid of numbers", async () => { + const wrapper = mountSummary(); + await flushPromises(); + + expect(wrapper.text()).toContain("Healthy"); + expect(wrapper.text()).toContain("100 requests in the last hour"); + // The numbers are still there, under the verdict. + expect(wrapper.text()).toContain("12.5%"); + expect(wrapper.text()).toContain("50%"); + }); + + it("names the container failing its health check", async () => { + vi.mocked(observabilityApi.health).mockResolvedValue({ + data: [{ container: "shop-db", deployment: "shop", status: "unhealthy" }], + } as any); + + const wrapper = mountSummary(); + await flushPromises(); + + expect(wrapper.text()).toContain("Needs attention"); + expect(wrapper.text()).toContain("shop-db is failing its health check"); + }); + + // A container can pass its health check while every request it answers fails. + it("reports errors even when the containers are healthy", async () => { + vi.mocked(servingApi.series).mockResolvedValue({ + data: { + deployment: "shop", + since: "1h", + points: [{ time: "2026-07-15T10:00:00Z", requests: 100, errors: 40, avg_time_ms: 20, p95_time_ms: 80 }], + }, + } as any); + + const wrapper = mountSummary(); + await flushPromises(); + + expect(wrapper.text()).toContain("Serving errors"); + expect(wrapper.text()).toContain("40.0% of requests failed"); + }); + + it("says a stopped deployment is stopped rather than healthy", async () => { + const wrapper = mountSummary({ status: "stopped" }); + await flushPromises(); + + expect(wrapper.text()).toContain("Not running"); + }); + + it("still summarises when a source is unavailable", async () => { + // Traffic logging is optional; the rest of the summary should survive it being off. + vi.mocked(servingApi.series).mockRejectedValue({ response: { status: 503 } }); + + const wrapper = mountSummary(); + await flushPromises(); + + expect(wrapper.text()).toContain("Healthy"); + expect(wrapper.text()).toContain("12.5%"); + }); +}); diff --git a/src/components/DeploymentHealthSummary.vue b/src/components/DeploymentHealthSummary.vue new file mode 100644 index 0000000..79dc89d --- /dev/null +++ b/src/components/DeploymentHealthSummary.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/src/components/ServingPanel.test.ts b/src/components/ServingPanel.test.ts new file mode 100644 index 0000000..1779bd4 --- /dev/null +++ b/src/components/ServingPanel.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import ServingPanel from "./ServingPanel.vue"; +import { servingApi } from "@/services/api"; + +vi.mock("@/services/api", () => ({ + servingApi: { series: vi.fn() }, +})); + +// Shaped like what the agent returns from the proxy's own record of each request. +const points = [ + { time: "2026-07-15T10:00:00Z", requests: 100, errors: 0, avg_time_ms: 20, p95_time_ms: 50 }, + { time: "2026-07-15T10:01:00Z", requests: 100, errors: 4, avg_time_ms: 30, p95_time_ms: 1200 }, +]; + +const mountPanel = () => + mount(ServingPanel, { + props: { deploymentName: "shop" }, + global: { stubs: { TimeSeriesChart: { template: "
" } } }, + }); + +describe("ServingPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(servingApi.series).mockResolvedValue({ data: { deployment: "shop", since: "1h", points } } as any); + }); + + it("summarises requests, error rate and the worst p95", async () => { + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("200"); + // 4 errors in 200 requests. + expect(wrapper.text()).toContain("2.0%"); + // The worst p95 in the window, rendered in seconds once it passes a second. + expect(wrapper.text()).toContain("1.20s"); + }); + + it("asks for the range the user picks", async () => { + const wrapper = mountPanel(); + await flushPromises(); + expect(servingApi.series).toHaveBeenCalledWith("shop", "1h"); + + // Driven through the shared range picker, the same control the container metrics use. + const day = wrapper.findAll(".range-seg").find((b) => b.text() === "24h"); + await day!.trigger("click"); + await flushPromises(); + + expect(servingApi.series).toHaveBeenLastCalledWith("shop", "24h"); + }); + + it("says traffic logging is off rather than drawing an empty chart", async () => { + vi.mocked(servingApi.series).mockRejectedValue({ response: { status: 503 } }); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("Traffic logging is not enabled"); + }); + + it("explains an empty window", async () => { + vi.mocked(servingApi.series).mockResolvedValue({ data: { deployment: "shop", since: "1h", points: [] } } as any); + + const wrapper = mountPanel(); + await flushPromises(); + + expect(wrapper.text()).toContain("No requests in this window"); + }); +}); diff --git a/src/components/ServingPanel.vue b/src/components/ServingPanel.vue new file mode 100644 index 0000000..54ee260 --- /dev/null +++ b/src/components/ServingPanel.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/src/components/charts/TimeSeriesChart.vue b/src/components/charts/TimeSeriesChart.vue index 8a06007..5cdc38e 100644 --- a/src/components/charts/TimeSeriesChart.vue +++ b/src/components/charts/TimeSeriesChart.vue @@ -11,7 +11,7 @@ const props = defineProps<{ containers: string[]; timestamps: number[]; // unix seconds values: (number | null)[][]; // [container][time] - unit?: "percent" | "bytes"; + unit?: "percent" | "bytes" | "ms" | "count"; area?: boolean; }>(); @@ -32,6 +32,15 @@ function fmtValue(v: number | null): string { if (v >= 1 << 10) return `${(v / (1 << 10)).toFixed(0)}K`; return `${v}B`; } + if (props.unit === "ms") { + // A request measured in seconds reads better than one in four digits of milliseconds. + if (v >= 1000) return `${(v / 1000).toFixed(2)}s`; + return `${v.toFixed(0)}ms`; + } + if (props.unit === "count") { + if (v >= 1000) return `${(v / 1000).toFixed(1)}k`; + return v.toFixed(v < 10 && !Number.isInteger(v) ? 1 : 0); + } return String(v); } diff --git a/src/components/plugins/kinds/MetricsPanel.vue b/src/components/plugins/kinds/MetricsPanel.vue index 738173a..0009fec 100644 --- a/src/components/plugins/kinds/MetricsPanel.vue +++ b/src/components/plugins/kinds/MetricsPanel.vue @@ -1,49 +1,55 @@ @@ -52,6 +58,8 @@ import { ref, computed, onMounted, onUnmounted } from "vue"; import Icon from "@/components/base/Icon.vue"; import TimeSeriesChart from "@/components/charts/TimeSeriesChart.vue"; import TimeRangePicker from "@/components/base/TimeRangePicker.vue"; +import SubTabs from "@/components/base/SubTabs.vue"; +import ServingPanel from "@/components/ServingPanel.vue"; import { pluginApi } from "@/services/pluginApi"; const props = defineProps<{ @@ -69,6 +77,14 @@ interface MetricSeries { const api = pluginApi(props.pluginName); const deployment = String(props.context?.deployment ?? ""); +// Containers say what the deployment is doing; serving says whether anyone is getting +// anything out of it. Both belong under metrics and health. +const views = [ + { id: "containers", label: "Containers" }, + { id: "serving", label: "Serving" }, +]; +const view = ref("containers"); + const ranges = ["15m", "1h", "6h", "24h"]; const since = ref("15m"); diff --git a/src/composables/useLogStream.test.ts b/src/composables/useLogStream.test.ts new file mode 100644 index 0000000..2603afb --- /dev/null +++ b/src/composables/useLogStream.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useLogStream } from "./useLogStream"; + +class FakeSocket { + static last: FakeSocket | null = null; + onopen: (() => void) | null = null; + onmessage: ((e: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: (() => void) | null = null; + sent: string[] = []; + closed = false; + + constructor(public url: string) { + FakeSocket.last = this; + } + send(data: string) { + this.sent.push(data); + } + close() { + this.closed = true; + } +} + +describe("useLogStream", () => { + beforeEach(() => { + vi.stubGlobal("WebSocket", FakeSocket as unknown as typeof WebSocket); + localStorage.setItem("auth_token", "token-123"); + FakeSocket.last = null; + }); + + it("authenticates as its first message, since a websocket carries no headers", () => { + const stream = useLogStream(); + stream.start("shop", { tail: 50 }); + + FakeSocket.last!.onopen!(); + + expect(JSON.parse(FakeSocket.last!.sent[0])).toEqual({ type: "auth", token: "token-123" }); + expect(FakeSocket.last!.url).toContain("/api/deployments/shop/logs/stream"); + expect(FakeSocket.last!.url).toContain("tail=50"); + }); + + it("asks the agent to filter rather than filtering in the browser", () => { + const stream = useLogStream(); + stream.start("shop", { filter: "error" }); + + expect(FakeSocket.last!.url).toContain("filter=error"); + }); + + it("collects lines as they arrive", () => { + const stream = useLogStream(); + stream.start("shop"); + FakeSocket.last!.onopen!(); + + FakeSocket.last!.onmessage!({ data: JSON.stringify({ type: "log", line: "starting" }) }); + FakeSocket.last!.onmessage!({ data: JSON.stringify({ type: "log", line: "ready" }) }); + + expect(stream.lines.value).toEqual(["starting", "ready"]); + expect(stream.following.value).toBe(true); + }); + + it("reports an error the agent sends and stops following", () => { + const stream = useLogStream(); + stream.start("shop"); + FakeSocket.last!.onopen!(); + + FakeSocket.last!.onmessage!({ data: JSON.stringify({ type: "error", error: "No access to this deployment" }) }); + + expect(stream.error.value).toBe("No access to this deployment"); + expect(stream.following.value).toBe(false); + }); + + it("survives a frame it cannot read", () => { + const stream = useLogStream(); + stream.start("shop"); + FakeSocket.last!.onopen!(); + + FakeSocket.last!.onmessage!({ data: "not json" }); + + expect(stream.lines.value).toEqual([]); + expect(stream.following.value).toBe(true); + }); + + it("stops following when told to", () => { + const stream = useLogStream(); + stream.start("shop"); + FakeSocket.last!.onopen!(); + const socket = FakeSocket.last!; + + stream.stop(); + + expect(socket.closed).toBe(true); + expect(stream.following.value).toBe(false); + }); + + it("bounds what it keeps, so a chatty container cannot grow it forever", () => { + const stream = useLogStream(); + stream.start("shop"); + FakeSocket.last!.onopen!(); + + for (let i = 0; i < 5200; i++) { + FakeSocket.last!.onmessage!({ data: JSON.stringify({ type: "log", line: `line ${i}` }) }); + } + + expect(stream.lines.value.length).toBe(5000); + // The oldest go first: someone watching a live log is at the bottom of it. + expect(stream.lines.value[stream.lines.value.length - 1]).toBe("line 5199"); + }); +}); diff --git a/src/composables/useLogStream.ts b/src/composables/useLogStream.ts new file mode 100644 index 0000000..0af9a8c --- /dev/null +++ b/src/composables/useLogStream.ts @@ -0,0 +1,76 @@ +import { ref, onUnmounted } from "vue"; +import { deploymentLogsWsUrl } from "@/services/api"; + +// maxBufferedLines bounds what a follow keeps. A container that logs a line a millisecond +// would otherwise grow the buffer until the tab dies; the oldest lines go first, which is +// what a viewer scrolled to the bottom is not looking at anyway. +const maxBufferedLines = 5000; + +/** + * Follows a deployment's logs over a websocket, handing back the text as it arrives. + * + * A tail only ever says what was true when it was asked, so watching a container start means + * asking again and again. Following gives the line when the container writes it. + */ +export function useLogStream() { + const lines = ref([]); + const following = ref(false); + const error = ref(""); + + let socket: WebSocket | null = null; + + const stop = () => { + following.value = false; + if (socket) { + socket.onclose = null; + socket.close(); + socket = null; + } + }; + + const start = (deployment: string, opts: { tail?: number; filter?: string } = {}) => { + stop(); + lines.value = []; + error.value = ""; + + socket = new WebSocket(deploymentLogsWsUrl(deployment, opts)); + + socket.onopen = () => { + following.value = true; + // A browser cannot set headers on a websocket, so the token goes as the first message, + // the same way the terminal sends it. + const token = localStorage.getItem("auth_token"); + if (token) socket?.send(JSON.stringify({ type: "auth", token })); + }; + + socket.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + if (message.type === "log") { + lines.value.push(message.line); + if (lines.value.length > maxBufferedLines) { + lines.value = lines.value.slice(-maxBufferedLines); + } + } else if (message.type === "error") { + error.value = message.error || message.message || "The log stream stopped"; + stop(); + } + } catch { + // A frame that is not JSON is not something this viewer can show. + } + }; + + socket.onerror = () => { + error.value = "Lost the connection to the log stream"; + }; + + socket.onclose = () => { + following.value = false; + socket = null; + }; + }; + + onUnmounted(stop); + + return { lines, following, error, start, stop }; +} diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index e4f48f7..7a8d6da 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -203,6 +203,15 @@ Observability + + + Alerts + { const titles: Record = { home: "Dashboard", observability: "Observability", + alerts: "Alerts", deployments: "Deployments", "deployment-detail": "Deployment Details", containers: "Containers", diff --git a/src/lib/icons.ts b/src/lib/icons.ts index dbafa78..1b7da3a 100644 --- a/src/lib/icons.ts +++ b/src/lib/icons.ts @@ -1,7 +1,13 @@ import { addCollection } from "@iconify/vue"; import { icons } from "@iconify-json/lucide"; +import { solarSubset } from "./solar-icons"; // Register the full lucide set once, offline, so never hits the Iconify // API (this panel may run without internet). The set is ~85KB gzipped; it can // be subset later (e.g. unplugin-icons) if bundle size becomes a concern. addCollection(icons); + +// Solar's filled and duotone glyphs carry weight lucide's hairlines cannot at small sizes. +// Only the icons the app renders are compiled in: importing the published set would put its +// whole 6MB of JSON in the bundle. See scripts/build-solar-icons.mjs. +addCollection(solarSubset); diff --git a/src/lib/solar-icons.ts b/src/lib/solar-icons.ts new file mode 100644 index 0000000..b7545b9 --- /dev/null +++ b/src/lib/solar-icons.ts @@ -0,0 +1,44 @@ +// Generated by scripts/build-solar-icons.mjs. Do not edit by hand. +// +// Only the icons this app renders are here: importing the published set pulls its whole 6MB +// of JSON into the bundle, and no amount of filtering afterwards gets it back out. +export const solarSubset = { + prefix: "solar", + width: 24, + height: 24, + icons: { + "check-circle-bold": { + body: '', + }, + "danger-triangle-bold": { + body: '', + }, + "clock-circle-bold": { + body: '', + }, + "stop-circle-bold": { + body: '', + }, + "alt-arrow-right-linear": { + body: '', + }, + "cpu-bold-duotone": { + body: '', + }, + "ssd-square-bold-duotone": { + body: '', + }, + "global-bold-duotone": { + body: '', + }, + "danger-circle-bold-duotone": { + body: '', + }, + "stopwatch-bold-duotone": { + body: '', + }, + "chart-2-bold-duotone": { + body: '', + }, + }, +} as const; diff --git a/src/router/index.ts b/src/router/index.ts index ce4b3b3..aed780a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -118,6 +118,12 @@ const routes: RouteRecordRaw[] = [ component: () => import("@/views/ObservabilityView.vue"), meta: { permission: "deployments:read" }, }, + { + path: "observability/alerts", + name: "alerts", + component: () => import("@/views/AlertsView.vue"), + meta: { permission: "deployments:read" }, + }, { path: "templates", name: "templates", diff --git a/src/services/api.ts b/src/services/api.ts index e42a77c..be4204a 100755 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -133,6 +133,23 @@ export const deploymentJobWsUrl = (name: string, jobId: string): string => { return `${protocol}//${window.location.host}${path}`; }; +// Follows a deployment's logs. Filtering happens on the agent, so a noisy container does not +// push everything it writes down the socket for the browser to discard. +export const deploymentLogsWsUrl = (name: string, opts: { tail?: number; filter?: string } = {}): string => { + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const apiUrl = import.meta.env.VITE_API_URL || ""; + const params = new URLSearchParams(); + if (opts.tail !== undefined) params.set("tail", String(opts.tail)); + if (opts.filter) params.set("filter", opts.filter); + const query = params.toString(); + const path = `/api/deployments/${name}/logs/stream${query ? `?${query}` : ""}`; + if (apiUrl.startsWith("http")) { + const url = new URL(apiUrl); + return `${protocol}//${url.host}${path}`; + } + return `${protocol}//${window.location.host}${path}`; +}; + export const deploymentsApi = { list: () => apiClient.get<{ deployments: Deployment[] }>("/deployments"), get: (name: string) => apiClient.get(`/deployments/${name}`), @@ -662,6 +679,46 @@ export interface FilesInfo { file_count: number; } +// How a deployment is serving its users, measured at the proxy every request already +// crosses. Nothing is installed in the container to produce this. +export interface REDPoint { + time: string; + requests: number; + errors: number; + avg_time_ms: number; + p95_time_ms: number; +} + +export interface DashboardPanel { + id?: string; + title: string; + source: "container" | "serving"; + series: string; + deployment?: string; + type: "line" | "stat"; + width: number; +} + +export interface Dashboard { + id?: string; + name: string; + panels: DashboardPanel[]; +} + +export const dashboardsApi = { + list: () => apiClient.get<{ dashboards: Dashboard[] }>("/dashboards"), + get: (id: string) => apiClient.get(`/dashboards/${id}`), + save: (dashboard: Dashboard) => apiClient.post("/dashboards", dashboard), + remove: (id: string) => apiClient.delete(`/dashboards/${id}`), +}; + +export const servingApi = { + series: (deploymentName: string, since = "1h") => + apiClient.get<{ deployment: string; since: string; points: REDPoint[] }>(`/deployments/${deploymentName}/serving`, { + params: { since }, + }), +}; + export interface ContainerFile { name: string; path: string; diff --git a/src/services/observability.ts b/src/services/observability.ts index 0c2ec22..ddcfa73 100644 --- a/src/services/observability.ts +++ b/src/services/observability.ts @@ -46,8 +46,37 @@ export interface MetricSeries { values: (number | null)[][]; } +export interface AlertRule { + id?: string; + name: string; + deployment?: string; + metric: string; + comparison: "above" | "below"; + threshold: number; + for_seconds: number; + enabled: boolean; +} + +export interface AlertEvent { + rule_id: string; + rule_name: string; + deployment: string; + container: string; + metric: string; + value: number; + threshold: number; + comparison: "above" | "below"; + state: "ok" | "pending" | "firing"; + at: string; +} + export const observabilityApi = { latest: () => apiClient.get(`${base}/metrics/latest`), + alertRules: () => apiClient.get(`${base}/alerts/rules`), + saveAlertRules: (rules: AlertRule[]) => apiClient.put(`${base}/alerts/rules`, rules), + // Rules currently breached, which is what needs attention now rather than what happened. + firingAlerts: () => apiClient.get(`${base}/alerts/firing`), + alertEvents: () => apiClient.get(`${base}/alerts/events`), timeseries: (deployment: string, sinceRange = "15m") => apiClient.get<{ metrics: Record }>(`${base}/metrics/timeseries`, { params: { deployment, since: sinceRange }, diff --git a/src/views/AlertsView.test.ts b/src/views/AlertsView.test.ts new file mode 100644 index 0000000..a63973f --- /dev/null +++ b/src/views/AlertsView.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createTestingPinia } from "@pinia/testing"; +import AlertsView from "./AlertsView.vue"; +import { observabilityApi } from "@/services/observability"; + +vi.mock("@/services/observability", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + observabilityApi: { + firingAlerts: vi.fn(), + alertEvents: vi.fn(), + alertRules: vi.fn().mockResolvedValue({ data: [] }), + saveAlertRules: vi.fn(), + }, + }; +}); + +const breach = { + rule_id: "r1", + rule_name: "CPU high", + deployment: "shop", + container: "shop-web", + metric: "container.cpu.usage", + value: 95, + threshold: 80, + comparison: "above" as const, + state: "firing" as const, + at: "2026-07-15T10:00:00Z", +}; + +const recovered = { ...breach, state: "ok" as const, value: 10, at: "2026-07-15T09:00:00Z" }; + +const mountView = () => + mount(AlertsView, { + global: { + plugins: [createTestingPinia({ createSpy: vi.fn })], + stubs: { RouterLink: { template: "", props: ["to"] }, AlertRulesPanel: true }, + }, + }); + +describe("AlertsView", () => { + beforeEach(() => vi.clearAllMocks()); + + it("does not repeat a firing alert in the recent list", async () => { + // The engine keeps every state change, so the current breach is also the newest event. + vi.mocked(observabilityApi.firingAlerts).mockResolvedValue({ data: [breach] } as any); + vi.mocked(observabilityApi.alertEvents).mockResolvedValue({ data: [recovered, breach] } as any); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.findAll(".av-firing-row")).toHaveLength(1); + // Recent shows what happened since, not the thing already named above it. + const rows = wrapper.findAll(".av-history-row"); + expect(rows).toHaveLength(1); + expect(rows[0].text()).toContain("ok"); + }); + + it("shows nothing firing when nothing is", async () => { + vi.mocked(observabilityApi.firingAlerts).mockResolvedValue({ data: [] } as any); + vi.mocked(observabilityApi.alertEvents).mockResolvedValue({ data: [recovered] } as any); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.find(".av-firing").exists()).toBe(false); + expect(wrapper.findAll(".av-history-row")).toHaveLength(1); + }); +}); diff --git a/src/views/AlertsView.vue b/src/views/AlertsView.vue new file mode 100644 index 0000000..4f60664 --- /dev/null +++ b/src/views/AlertsView.vue @@ -0,0 +1,217 @@ + + + + + diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts index 5ab4634..b903426 100644 --- a/src/views/DeploymentDetailView.test.ts +++ b/src/views/DeploymentDetailView.test.ts @@ -95,8 +95,23 @@ vi.mock("@/services/api", () => ({ list: vi.fn().mockResolvedValue({ data: { credentials: [] } }), get: vi.fn().mockResolvedValue({ data: { credential: null } }), }, + servingApi: { + series: vi.fn().mockResolvedValue({ data: { deployment: "test-app", since: "1h", points: [] } }), + }, + deploymentLogsWsUrl: vi.fn(() => "ws://localhost/api/deployments/test-app/logs/stream"), })); +vi.mock("@/services/observability", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + observabilityApi: { + health: vi.fn().mockResolvedValue({ data: [] }), + latest: vi.fn().mockResolvedValue({ data: [] }), + }, + }; +}); + vi.mock("@/composables/useNotifications", () => ({ useNotifications: () => ({ success: vi.fn(), diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue index 02f7d2f..8b93f01 100755 --- a/src/views/DeploymentDetailView.vue +++ b/src/views/DeploymentDetailView.vue @@ -103,6 +103,13 @@
+ +
@@ -491,69 +498,6 @@
- -
-
- -

Resource Usage

-
-
-
-
-
CPU Usage
-
-
-
-
- {{ resourceUsage.cpu }}% -
-
-
-
Memory Usage
-
-
-
-
- {{ resourceUsage.memory }}% -
-
-
-
Disk I/O
-
-
-
-
- {{ resourceUsage.disk }}% -
-
-
-
Network I/O
-
-
-
-
- {{ resourceUsage.network }}% -
-
-
-
-
@@ -601,6 +545,10 @@ @refresh="fetchLogs" >