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 @@ + + + + +