From d15e9b194041d26ff7b4d0456f2f716c47ab6cca Mon Sep 17 00:00:00 2001 From: imneov Date: Mon, 12 Oct 2026 07:02:54 -0700 Subject: [PATCH] fix(web): keep dashboard on published swissd APIs Read the existing run log directly instead of probing the unmerged timeline prototype. Preserve unknown history and capped-response handling, and require one published API request in regression tests. Server-side aggregation and serving metrics remain separate backend work. Refs #55 --- docs/console-design.md | 2 +- .../modules/inferences/dashboard-api.test.ts | 76 ++++++++++++------- web/src/modules/inferences/dashboard-api.ts | 22 +----- 3 files changed, 55 insertions(+), 45 deletions(-) diff --git a/docs/console-design.md b/docs/console-design.md index db1fccd..f442650 100644 --- a/docs/console-design.md +++ b/docs/console-design.md @@ -252,7 +252,7 @@ The home overview is gated by `swiss.view` and does not run `SwissScope`, so an | Service total | server summary, not the six-row preview count | | Capability totals | all configured catalogs; non-deprecated models deduplicated by model/source/revision identity, recipes from all listed versions deduplicated by that identity/version/digest/variant id; any unreadable catalog keeps totals unknown | | Deployment curve | last 24 hours of completed audit operations by `startedAt`; changed successful installs are starts, changed successful apply/rollback are updates, failed install/apply/rollback are failures; not a history of Pods serving | -| Timeline completeness | use the exact backend `/api/runs/timeline` aggregate; old servers may use uncapped-length responses below 500 rows, but reaching that legacy cap is incomplete and never shown as a complete zero curve; no database remains unknown | +| Timeline completeness | current Console reads only the published `/api/runs?limit=500` endpoint and aggregates on the client; reaching that cap is incomplete and never shown as a complete zero curve; no database remains unknown. It does not call the unmerged `/api/runs/timeline` prototype. Complete server-side aggregation and serving metrics require separately reviewed backend work before Console integration | | Cluster sidebar | name, namespace, deployment mode and swissd version from cluster info; distinct Kubernetes node versions from `Node.Kubelet`, never from the swissd version; ready/schedulable counts from node inventory | | Service state and instances | live release status for each preview row; a deployed Helm release alone is not serving | | Allocated GPUs | pod resource allocation, not GPU utilisation; `usageError` means unknown | diff --git a/web/src/modules/inferences/dashboard-api.test.ts b/web/src/modules/inferences/dashboard-api.test.ts index 32466da..088d886 100644 --- a/web/src/modules/inferences/dashboard-api.test.ts +++ b/web/src/modules/inferences/dashboard-api.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { readDeploymentTimeline } from "./dashboard-api"; -import { HISTORY_LIMIT, recordedTimeline } from "./dashboard-lib"; +import { HISTORY_LIMIT, timelineTotals } from "./dashboard-lib"; const from = "2026-10-10T12:15:00.000Z"; const to = "2026-10-11T12:15:00.000Z"; @@ -16,50 +16,74 @@ beforeEach(() => { }); afterEach(() => vi.unstubAllGlobals()); -describe("deployment timeline API compatibility", () => { - it("uses the complete aggregate without downloading individual history rows", async () => { - const data = recordedTimeline([], from, to, true); - data.buckets[2].started = 601; - fetchMock.mockResolvedValue(json(data)); +describe("deployment history using published swissd APIs", () => { + it("reads only the existing run log without probing an unmerged endpoint", async () => { + fetchMock.mockResolvedValue( + json({ + hasStore: true, + runs: [ + { + id: 1, + namespace: "models", + release: "example", + action: "install", + planHash: "hash", + changed: true, + startedAt: from, + endedAt: from, + }, + ], + }), + ); const result = await readDeploymentTimeline(now); + expect(result.hasStore).toBe(true); expect(result.complete).toBe(true); - expect(result.buckets[2].started).toBe(601); + expect(timelineTotals(result)).toEqual({ started: 1, updated: 0, failed: 0 }); expect(fetchMock).toHaveBeenCalledTimes(1); - expect(String(fetchMock.mock.calls[0][0])).toContain("/api/deploy/runs/timeline?from="); + expect(String(fetchMock.mock.calls[0][0])).toBe(`/api/deploy/runs?limit=${HISTORY_LIMIT}`); }); - it("supports the old server's run-id error and requests its actual 500-row limit", async () => { - fetchMock - .mockResolvedValueOnce(json({ error: "run id must be a number" }, 400)) - .mockResolvedValueOnce(json({ hasStore: true, runs: [] })); + + it("zero-fills a known empty persisted history over the requested window", async () => { + fetchMock.mockResolvedValue(json({ hasStore: true, runs: [] })); const result = await readDeploymentTimeline(now); - expect(result.hasStore).toBe(true); + expect(result.from).toBe(from); + expect(result.to).toBe(to); expect(result.complete).toBe(true); expect(result.buckets).toHaveLength(25); - expect(String(fetchMock.mock.calls[1][0])).toContain("limit=500"); + expect(timelineTotals(result)).toEqual({ started: 0, updated: 0, failed: 0 }); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("marks old capped data incomplete instead of returning a fake complete zero chart", async () => { - fetchMock - .mockResolvedValueOnce(json({ error: "not found" }, 404)) - .mockResolvedValueOnce(json({ hasStore: true, runs: Array.from({ length: HISTORY_LIMIT }, () => ({})) })); + + it("marks capped history incomplete instead of returning a fake complete zero chart", async () => { + fetchMock.mockResolvedValue(json({ hasStore: true, runs: Array.from({ length: HISTORY_LIMIT }, () => ({})) })); const result = await readDeploymentTimeline(now); expect(result.complete).toBe(false); expect(result.buckets).toEqual([]); + expect(timelineTotals(result)).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("keeps a server without persistence distinct from zero operations", async () => { - fetchMock - .mockResolvedValueOnce(json({ error: "no database: this swissd keeps no operation log" }, 503)) - .mockResolvedValueOnce(json({ hasStore: false, runs: [] })); + fetchMock.mockResolvedValue(json({ hasStore: false, runs: [] })); const result = await readDeploymentTimeline(now); expect(result.hasStore).toBe(false); expect(result.buckets).toEqual([]); + expect(timelineTotals(result)).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); - it.each([403, 500, 503])("does not mask unrelated HTTP %s failures", async (status) => { + + it.each([400, 403, 404, 500, 503])("does not hide HTTP %s failures with another endpoint request", async (status) => { fetchMock.mockResolvedValue(json({ error: "real backend failure" }, status)); await expect(readDeploymentTimeline(now)).rejects.toThrow("real backend failure"); expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("does not treat malformed successful responses as valid history", async () => { - fetchMock.mockResolvedValue(json({ ok: true })); - await expect(readDeploymentTimeline(now)).rejects.toThrow("Invalid deployment timeline"); - }); + + it.each([{ ok: true }, { hasStore: true }, { runs: [] }, { hasStore: true, runs: null }])( + "rejects malformed history responses", + async (body) => { + fetchMock.mockResolvedValue(json(body)); + await expect(readDeploymentTimeline(now)).rejects.toThrow("Invalid deployment history response"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, + ); }); diff --git a/web/src/modules/inferences/dashboard-api.ts b/web/src/modules/inferences/dashboard-api.ts index e4b5d0b..f9fff98 100644 --- a/web/src/modules/inferences/dashboard-api.ts +++ b/web/src/modules/inferences/dashboard-api.ts @@ -1,28 +1,14 @@ -import { ApiError, request } from "@/shell"; import { api, type DeploymentsResponse } from "@swiss/lib/api"; -import { apiPath } from "@swiss/lib/host"; import { HISTORY_LIMIT, HOUR_MS, recordedTimeline, type DeploymentTimeline } from "@/modules/inferences/dashboard-lib"; export async function readDeploymentTimeline(now = Date.now()): Promise { const from = new Date(now - 24 * HOUR_MS).toISOString(); const to = new Date(now).toISOString(); - const query = new URLSearchParams({ from, to }); - try { - const data = await request>("GET", apiPath(`/api/runs/timeline?${query}`)); - if (typeof data.hasStore !== "boolean" || !Array.isArray(data.buckets)) - throw new Error("Invalid deployment timeline response"); - return { ...data, complete: true }; - } catch (error) { - const unsupported = - error instanceof ApiError && - (error.status === 404 || - error.status === 405 || - (error.status === 400 && error.message === "run id must be a number") || - (error.status === 503 && error.message === "no database: this swissd keeps no operation log")); - if (!unsupported) throw error; - const data = await api.runs({ limit: HISTORY_LIMIT }); - return recordedTimeline(data.runs, from, to, data.hasStore); + const data = await api.runs({ limit: HISTORY_LIMIT }); + if (typeof data.hasStore !== "boolean" || !Array.isArray(data.runs)) { + throw new Error("Invalid deployment history response"); } + return recordedTimeline(data.runs, from, to, data.hasStore); } // swissd caps a page at 100; health counts must cover every service, so keep