Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/console-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
76 changes: 50 additions & 26 deletions web/src/modules/inferences/dashboard-api.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
},
);
});
22 changes: 4 additions & 18 deletions web/src/modules/inferences/dashboard-api.ts
Original file line number Diff line number Diff line change
@@ -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<DeploymentTimeline> {
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<Omit<DeploymentTimeline, "complete">>("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
Expand Down
Loading