From 5c17ceaf82f718adf0651a65f9aa90320d0f6f9e Mon Sep 17 00:00:00 2001 From: nfebe Date: Sat, 18 Jul 2026 22:31:33 +0100 Subject: [PATCH] feat(ui): Build the dashboard view over container and serving metrics The dashboard endpoints and a typed client shipped with the observability work but nothing consumed them, so the stored dashboards were unreachable. This adds the view they were staged for. Dashboards can be created, renamed, and deleted, and each one holds panels the operator arranges on a twelve-column grid. A panel plots a container metric (CPU, memory, network) or a serving metric (requests, errors, latency) for one deployment or all of them, as a line chart or a single stat, over a selectable time range. Panel data is read from the metric endpoints that already serve the observability screens, so nothing new is collected. --- src/components/dashboards/PanelCard.test.ts | 130 +++++++++ src/components/dashboards/PanelCard.vue | 199 ++++++++++++++ .../dashboards/PanelEditorModal.vue | 200 ++++++++++++++ src/layouts/DashboardLayout.vue | 9 + src/router/index.ts | 12 + src/views/DashboardDetailView.vue | 211 +++++++++++++++ src/views/DashboardsView.test.ts | 58 ++++ src/views/DashboardsView.vue | 249 ++++++++++++++++++ 8 files changed, 1068 insertions(+) create mode 100644 src/components/dashboards/PanelCard.test.ts create mode 100644 src/components/dashboards/PanelCard.vue create mode 100644 src/components/dashboards/PanelEditorModal.vue create mode 100644 src/views/DashboardDetailView.vue create mode 100644 src/views/DashboardsView.test.ts create mode 100644 src/views/DashboardsView.vue diff --git a/src/components/dashboards/PanelCard.test.ts b/src/components/dashboards/PanelCard.test.ts new file mode 100644 index 0000000..c1663fc --- /dev/null +++ b/src/components/dashboards/PanelCard.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import PanelCard from "./PanelCard.vue"; +import { servingApi } from "@/services/api"; +import { observabilityApi } from "@/services/observability"; + +vi.mock("@/services/api", () => ({ + servingApi: { series: vi.fn() }, +})); +vi.mock("@/services/observability", () => ({ + observabilityApi: { timeseries: vi.fn() }, +})); + +// Stub the canvas chart; the test only checks the data handed to it. +const TimeSeriesChartStub = { + name: "TimeSeriesChart", + props: ["containers", "timestamps", "values", "unit", "area"], + template: "
", +}; + +const mountPanel = (panel: any) => + mount(PanelCard, { + props: { panel, since: "1h" }, + global: { stubs: { TimeSeriesChart: TimeSeriesChartStub, Icon: true } }, + }); + +describe("PanelCard", () => { + beforeEach(() => vi.clearAllMocks()); + + it("adapts serving RED points into the chart's series shape", async () => { + vi.mocked(servingApi.series).mockResolvedValue({ + data: { + deployment: "shop", + since: "1h", + points: [ + { time: "2026-07-17T10:00:00Z", requests: 10, errors: 1, avg_time_ms: 40, p95_time_ms: 80 }, + { time: "2026-07-17T10:01:00Z", requests: 20, errors: 0, avg_time_ms: 55, p95_time_ms: 90 }, + ], + }, + } as any); + + const wrapper = mountPanel({ + title: "Requests", + source: "serving", + series: "requests", + deployment: "shop", + type: "line", + width: 6, + }); + await flushPromises(); + + expect(servingApi.series).toHaveBeenCalledWith("shop", "1h"); + const chart = wrapper.findComponent(TimeSeriesChartStub); + expect(chart.exists()).toBe(true); + expect(chart.props("containers")).toEqual(["shop"]); + expect(chart.props("timestamps")).toEqual([ + Date.parse("2026-07-17T10:00:00Z") / 1000, + Date.parse("2026-07-17T10:01:00Z") / 1000, + ]); + expect(chart.props("values")).toEqual([[10, 20]]); + expect(chart.props("unit")).toBe("count"); + }); + + it("selects the latency field and ms unit for a latency panel", async () => { + vi.mocked(servingApi.series).mockResolvedValue({ + data: { points: [{ time: "2026-07-17T10:00:00Z", requests: 5, errors: 0, avg_time_ms: 42, p95_time_ms: 90 }] }, + } as any); + + const wrapper = mountPanel({ + title: "Latency", + source: "serving", + series: "latency", + deployment: "shop", + type: "line", + width: 6, + }); + await flushPromises(); + + const chart = wrapper.findComponent(TimeSeriesChartStub); + expect(chart.props("values")).toEqual([[42]]); + expect(chart.props("unit")).toBe("ms"); + }); + + it("pulls the named metric series out of a container timeseries response", async () => { + vi.mocked(observabilityApi.timeseries).mockResolvedValue({ + data: { + metrics: { + "container.cpu.usage": { containers: ["shop-web"], timestamps: [1, 2], values: [[3.5, 4.0]] }, + }, + }, + } as any); + + const wrapper = mountPanel({ + title: "CPU", + source: "container", + series: "container.cpu.usage", + deployment: "shop", + type: "line", + width: 6, + }); + await flushPromises(); + + expect(observabilityApi.timeseries).toHaveBeenCalledWith("shop", "1h"); + const chart = wrapper.findComponent(TimeSeriesChartStub); + expect(chart.props("values")).toEqual([[3.5, 4.0]]); + expect(chart.props("unit")).toBe("percent"); + }); + + it("shows the latest value as a stat", async () => { + vi.mocked(observabilityApi.timeseries).mockResolvedValue({ + data: { + metrics: { + "container.cpu.usage": { containers: ["shop-web"], timestamps: [1, 2], values: [[3.5, 7.25]] }, + }, + }, + } as any); + + const wrapper = mountPanel({ + title: "CPU now", + source: "container", + series: "container.cpu.usage", + deployment: "shop", + type: "stat", + width: 3, + }); + await flushPromises(); + + expect(wrapper.find(".stat-value").text()).toBe("7.3%"); + }); +}); diff --git a/src/components/dashboards/PanelCard.vue b/src/components/dashboards/PanelCard.vue new file mode 100644 index 0000000..2107665 --- /dev/null +++ b/src/components/dashboards/PanelCard.vue @@ -0,0 +1,199 @@ + + + + + diff --git a/src/components/dashboards/PanelEditorModal.vue b/src/components/dashboards/PanelEditorModal.vue new file mode 100644 index 0000000..ad6c79d --- /dev/null +++ b/src/components/dashboards/PanelEditorModal.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 7a8d6da..581152d 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -203,6 +203,15 @@ Observability + + + Dashboards + import("@/views/PluginsView.vue"), meta: { permission: "templates:read" }, }, + { + path: "dashboards", + name: "dashboards", + component: () => import("@/views/DashboardsView.vue"), + meta: { permission: "deployments:read" }, + }, + { + path: "dashboards/:id", + name: "dashboard-detail", + component: () => import("@/views/DashboardDetailView.vue"), + meta: { permission: "deployments:read" }, + }, { path: "observability", name: "observability", diff --git a/src/views/DashboardDetailView.vue b/src/views/DashboardDetailView.vue new file mode 100644 index 0000000..a0b0a2a --- /dev/null +++ b/src/views/DashboardDetailView.vue @@ -0,0 +1,211 @@ + + + + + diff --git a/src/views/DashboardsView.test.ts b/src/views/DashboardsView.test.ts new file mode 100644 index 0000000..e785280 --- /dev/null +++ b/src/views/DashboardsView.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, flushPromises, type VueWrapper } from "@vue/test-utils"; +import DashboardsView from "./DashboardsView.vue"; +import { dashboardsApi } from "@/services/api"; + +const push = vi.fn(); +vi.mock("vue-router", () => ({ useRouter: () => ({ push }) })); +vi.mock("@/services/api", () => ({ + dashboardsApi: { list: vi.fn(), save: vi.fn(), remove: vi.fn() }, +})); + +let wrapper: VueWrapper; +const mountView = () => { + wrapper = mount(DashboardsView, { attachTo: document.body, global: { stubs: { Icon: true } } }); + return wrapper; +}; + +describe("DashboardsView", () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => { + wrapper?.unmount(); + document.body.innerHTML = ""; + }); + + it("lists the stored dashboards", async () => { + vi.mocked(dashboardsApi.list).mockResolvedValue({ + data: { dashboards: [{ id: "a", name: "Prod overview", panels: [{}, {}] }] }, + } as any); + + const wrapper = mountView(); + await flushPromises(); + + expect(wrapper.text()).toContain("Prod overview"); + expect(wrapper.text()).toContain("2 panels"); + }); + + it("creates a dashboard and navigates to it", async () => { + vi.mocked(dashboardsApi.list).mockResolvedValue({ data: { dashboards: [] } } as any); + vi.mocked(dashboardsApi.save).mockResolvedValue({ data: { id: "new1", name: "Fresh", panels: [] } } as any); + + const w = mountView(); + await flushPromises(); + + await w.find(".btn-primary").trigger("click"); // open create modal (teleported to body) + + const input = document.querySelector(".modal-dialog input")!; + input.value = "Fresh"; + input.dispatchEvent(new Event("input")); + await flushPromises(); + + const buttons = Array.from(document.querySelectorAll(".modal-footer .btn-primary")); + buttons[buttons.length - 1].click(); // confirm create + await flushPromises(); + + expect(dashboardsApi.save).toHaveBeenCalledWith({ name: "Fresh", panels: [] }); + expect(push).toHaveBeenCalledWith("/dashboards/new1"); + }); +}); diff --git a/src/views/DashboardsView.vue b/src/views/DashboardsView.vue new file mode 100644 index 0000000..09e6467 --- /dev/null +++ b/src/views/DashboardsView.vue @@ -0,0 +1,249 @@ + + + + +