diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index bea1a69..f8a30e2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -45,6 +45,11 @@ type DashboardTileResponse = state: "not_found"; tile: DashboardTileReference; message: string; + } + | { + state: "disabled"; + tile: DashboardTileReference; + message: string; }; type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index 69dd9b6..f5712d6 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -164,6 +164,15 @@ describe("dashboard refresh lifecycle", () => { }, schemaVersion: "dashboard.v1", }); + store.saveTile({ + currentRevisionId: "revision-a", + response: { + state: "disabled", + tile: { kind: "module", id: "disabled-module" }, + message: "Disabled", + }, + schemaVersion: "dashboard.v1", + }); now = 61_000; @@ -193,6 +202,53 @@ describe("dashboard refresh lifecycle", () => { schemaVersion: "dashboard.v1", })).toEqual([]); }); + + test("ignores malformed stored tile snapshots", () => { + const storage = createMemoryStorage(); + storage.setItem( + "dimensionlab.dashboard.tiles.v1", + JSON.stringify({ + currentRevisionId: "revision-a", + schemaVersion: "dashboard.v1", + tiles: [ + {}, + { + item: { id: "infra-ram", detail: "live" }, + savedAt: 1_000, + tile: { kind: "telemetry", id: "infra-ram" }, + }, + { + item: { id: "broken-detail", detail: 42 }, + savedAt: 1_000, + tile: { kind: "telemetry", id: "broken-detail" }, + }, + ], + version: 1, + }), + ); + + const store = createDashboardTileSnapshotStore(storage, { + now: () => 16_000, + }); + + expect(store.restore({ + currentRevisionId: "revision-a", + schemaVersion: "dashboard.v1", + })).toEqual([ + { + ageMs: 15_000, + response: { + state: "ready", + tile: { kind: "telemetry", id: "infra-ram" }, + item: { + id: "infra-ram", + detail: "live - stale 15s", + severity: "stale", + }, + }, + }, + ]); + }); }); async function waitFor(predicate: () => boolean) { diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index 4d0582f..ba68dcc 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -231,7 +231,12 @@ export function createDashboardTileSnapshotStore( return null; } - return payload as DashboardTileSnapshotPayload; + return { + currentRevisionId: payload.currentRevisionId, + schemaVersion: payload.schemaVersion, + tiles: payload.tiles.filter(isDashboardTileSnapshotRecord), + version: 1, + }; } catch { return null; } @@ -332,6 +337,51 @@ function dashboardTileSnapshotKey(tile: DashboardTileReference): string { return JSON.stringify(tile); } +function isDashboardTileSnapshotRecord( + value: unknown, +): value is DashboardTileSnapshotRecord { + if (!isSnapshotRecord(value)) return false; + + return ( + typeof value.savedAt === "number" && + Number.isFinite(value.savedAt) && + isDashboardTileReference(value.tile) && + isDashboardTileSnapshotItem(value.item) + ); +} + +function isDashboardTileReference( + value: unknown, +): value is DashboardTileReference { + if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false; + + switch (value.kind) { + case "telemetry": + case "module": + return typeof value.id === "string"; + case "service": + return typeof value.groupId === "string" && typeof value.id === "string"; + case "status": + return typeof value.stripId === "string" && typeof value.id === "string"; + default: + return false; + } +} + +function isDashboardTileSnapshotItem( + value: unknown, +): value is DashboardTileSnapshotItem { + return ( + isSnapshotRecord(value) && + typeof value.id === "string" && + (value.detail === undefined || typeof value.detail === "string") + ); +} + +function isSnapshotRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function staleDashboardTileDetail( detail: string | undefined, ageMs: number,