From 4ce42fb52b0148e5a9910856a3975d70118df3ad Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 14:25:43 +0200 Subject: [PATCH] perf(web): restore last known dashboard tiles --- apps/web/src/App.test.tsx | 41 +++- apps/web/src/App.tsx | 98 ++++++++- .../src/lib/client/dashboard-refresh.test.ts | 86 ++++++++ apps/web/src/lib/client/dashboard-refresh.ts | 195 ++++++++++++++++++ 4 files changed, 412 insertions(+), 8 deletions(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 6124538..4db0cbc 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -2,7 +2,11 @@ import { renderToString } from "react-dom/server"; import { describe, expect, test } from "vitest"; import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { AppStateView, dashboardHydrationTiles } from "./App"; +import { + AppStateView, + dashboardHydrationTiles, + restoreDashboardTileSnapshots, +} from "./App"; describe("React app dashboard state view", () => { test("renders loading dashboard state", () => { @@ -120,4 +124,39 @@ describe("React app dashboard state view", () => { ), ); }); + + test("applies restored tile snapshots without marking them as loading", () => { + const restored = restoreDashboardTileSnapshots( + { + state: "ready", + document: dimensionLabDashboardFixture, + schemaVersion: "dashboard.v1", + currentRevisionId: "revision-a", + }, + [ + { + ageMs: 60_000, + response: { + state: "ready", + tile: { kind: "telemetry", id: "infra-ram" }, + item: { + ...dimensionLabDashboardFixture.telemetry[0], + detail: "cached - stale 60s", + severity: "stale", + }, + }, + }, + ], + ); + + expect(restored.restoredItemIds).toEqual(new Set(["telemetry:infra-ram"])); + if (restored.dashboard.state !== "ready") { + throw new Error("Expected dashboard to be ready"); + } + expect(restored.dashboard.document.telemetry[0]).toMatchObject({ + id: "infra-ram", + detail: "cached - stale 60s", + severity: "stale", + }); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c2f890e..bea1a69 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,10 +9,13 @@ import type { import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { attachDashboardRefreshLifecycle, + createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, runDashboardHydrationQueue, shouldPauseDashboardRefresh, + type DashboardTileSnapshotStoreContext, + type RestoredDashboardTileSnapshot, } from "$lib/client/dashboard-refresh"; import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer"; import { @@ -47,6 +50,7 @@ type DashboardTileResponse = type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; const dashboardTileHydrationConcurrency = 6; +type DashboardTileSnapshotStore = ReturnType; export function AppStateView({ dashboard, @@ -164,6 +168,9 @@ export default function App() { let hydrationRun = 0; const requestAborter = createDashboardRequestAborter(); const tileBackoff = createDashboardTileBackoff(); + const tileSnapshotStore = createDashboardTileSnapshotStore( + getTileSnapshotStorage(), + ); function refreshPaused() { return shouldPauseDashboardRefresh({ @@ -195,19 +202,42 @@ export default function App() { const nextDashboard = (await response.json()) as DashboardRuntimeState; if (cancelled || shellSignal.aborted || refreshPaused()) return; - setDashboard(nextDashboard); + const restored = restoreDashboardTileSnapshots( + nextDashboard, + nextDashboard.state === "ready" + ? tileSnapshotStore.restore({ + currentRevisionId: nextDashboard.currentRevisionId, + schemaVersion: nextDashboard.schemaVersion, + }) + : [], + ); + setDashboard(restored.dashboard); const currentRun = ++hydrationRun; if ( - nextDashboard.state === "ready" && - nextDashboard.liveDatasourceHydration?.enabled !== false + restored.dashboard.state === "ready" && + restored.dashboard.liveDatasourceHydration?.enabled !== false ) { - const tiles = dashboardHydrationTiles(nextDashboard.document).filter( + const tiles = dashboardHydrationTiles(restored.dashboard.document).filter( (tile) => tileBackoff.canAttempt(dashboardTileKey(tile)), ); const tileSignal = requestAborter.beginTileRun(); - setHydratingItemIds(new Set(tiles.map(dashboardTileKey))); - hydrateDashboardTiles(tiles, currentRun, tileSignal); + setHydratingItemIds( + new Set( + tiles + .map(dashboardTileKey) + .filter((key) => !restored.restoredItemIds.has(key)), + ), + ); + hydrateDashboardTiles( + tiles, + currentRun, + tileSignal, + { + currentRevisionId: restored.dashboard.currentRevisionId, + schemaVersion: restored.dashboard.schemaVersion, + }, + ); } else { setHydratingItemIds(new Set()); } @@ -236,12 +266,18 @@ export default function App() { tiles: DashboardTileReference[], run: number, signal: AbortSignal, + snapshotContext: DashboardTileSnapshotStoreContext, ) { void runDashboardHydrationQueue({ concurrency: dashboardTileHydrationConcurrency, hydrate: async (tile) => { const key = dashboardTileKey(tile); - const result = await hydrateDashboardTile(tile, run, signal); + const result = await hydrateDashboardTile( + tile, + run, + signal, + snapshotContext, + ); if (result === "ready") { tileBackoff.recordSuccess(key); @@ -258,6 +294,7 @@ export default function App() { tile: DashboardTileReference, run: number, signal: AbortSignal, + snapshotContext: DashboardTileSnapshotStoreContext, ): Promise { const key = dashboardTileKey(tile); @@ -286,6 +323,10 @@ export default function App() { } : current, ); + tileSnapshotStore.saveReadyTile({ + ...snapshotContext, + response: readyTileResponse, + }); return "ready"; } catch (error) { if (!isAbortError(error) && !cancelled) { @@ -352,6 +393,14 @@ function getThemeStorage(): Storage | undefined { } } +function getTileSnapshotStorage(): Storage | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} + function isAbortError(error: unknown): boolean { return ( typeof error === "object" && @@ -361,6 +410,41 @@ function isAbortError(error: unknown): boolean { ); } +export function restoreDashboardTileSnapshots( + dashboard: DashboardRuntimeState, + snapshots: RestoredDashboardTileSnapshot[], +): { + dashboard: DashboardRuntimeState; + restoredItemIds: Set; +} { + if (dashboard.state !== "ready" || snapshots.length === 0) { + return { + dashboard, + restoredItemIds: new Set(), + }; + } + + return snapshots.reduce( + (current, snapshot) => ({ + dashboard: { + ...current.dashboard, + document: applyDashboardTile( + current.dashboard.document, + snapshot.response as Extract, + ), + }, + restoredItemIds: new Set([ + ...current.restoredItemIds, + dashboardTileKey(snapshot.response.tile), + ]), + }), + { + dashboard, + restoredItemIds: new Set(), + }, + ); +} + function markHydratingItems( dashboard: UiDashboardPreview, hydratingItemIds?: ReadonlySet, diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index 1cdb872..69dd9b6 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { attachDashboardRefreshLifecycle, + createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, runDashboardHydrationQueue, @@ -131,6 +132,67 @@ describe("dashboard refresh lifecycle", () => { backoff.recordSuccess("telemetry:infra-ram"); expect(backoff.canAttempt("telemetry:infra-ram", 107_000)).toBe(true); }); + + test("stores only ready tile snapshots and restores them for matching revisions", () => { + const storage = createMemoryStorage(); + let now = 1_000; + const store = createDashboardTileSnapshotStore(storage, { + now: () => now, + }); + + store.saveReadyTile({ + currentRevisionId: "revision-a", + response: { + state: "ready", + tile: { kind: "telemetry", id: "infra-ram" }, + item: { + id: "infra-ram", + label: "Infra RAM", + value: { kind: "percent", value: 42 }, + detail: "live", + severity: "ok", + }, + }, + schemaVersion: "dashboard.v1", + }); + store.saveTile({ + currentRevisionId: "revision-a", + response: { + state: "not_found", + tile: { kind: "telemetry", id: "missing" }, + message: "Missing", + }, + schemaVersion: "dashboard.v1", + }); + + now = 61_000; + + const restored = store.restore({ + currentRevisionId: "revision-a", + schemaVersion: "dashboard.v1", + }); + + expect(restored).toEqual([ + { + ageMs: 60_000, + response: { + state: "ready", + tile: { kind: "telemetry", id: "infra-ram" }, + item: { + id: "infra-ram", + label: "Infra RAM", + value: { kind: "percent", value: 42 }, + detail: "live - stale 60s", + severity: "stale", + }, + }, + }, + ]); + expect(store.restore({ + currentRevisionId: "revision-b", + schemaVersion: "dashboard.v1", + })).toEqual([]); + }); }); async function waitFor(predicate: () => boolean) { @@ -141,3 +203,27 @@ async function waitFor(predicate: () => boolean) { throw new Error("condition was not met"); } + +function createMemoryStorage(): Storage { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear() { + values.clear(); + }, + getItem(key) { + return values.get(key) ?? null; + }, + key(index) { + return [...values.keys()][index] ?? null; + }, + removeItem(key) { + values.delete(key); + }, + setItem(key, value) { + values.set(key, value); + }, + }; +} diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index 998de5f..4d0582f 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -3,6 +3,39 @@ export interface DashboardRefreshPauseState { online: boolean; } +export type DashboardTileReference = + | { kind: "telemetry"; id: string } + | { kind: "service"; groupId: string; id: string } + | { kind: "module"; id: string } + | { kind: "status"; stripId: string; id: string }; + +type SnapshotMetricValue = Record; + +export type DashboardTileSnapshotItem = { + detail?: string; + id: string; + label?: string; + severity?: string; + value?: SnapshotMetricValue | string; +} & Record; + +export type DashboardTileSnapshotResponse = + | { + state: "ready"; + tile: DashboardTileReference; + item: DashboardTileSnapshotItem; + } + | { + state: "not_found"; + tile: DashboardTileReference; + message: string; + } + | { + state: "disabled"; + tile: DashboardTileReference; + message: string; + }; + export function shouldPauseDashboardRefresh( state: DashboardRefreshPauseState, ): boolean { @@ -145,3 +178,165 @@ export function createDashboardTileBackoff() { }, }; } + +interface DashboardTileSnapshotRecord { + item: DashboardTileSnapshotItem; + savedAt: number; + tile: DashboardTileReference; +} + +interface DashboardTileSnapshotPayload { + currentRevisionId: string; + schemaVersion: string; + tiles: DashboardTileSnapshotRecord[]; + version: 1; +} + +export interface DashboardTileSnapshotStoreContext { + currentRevisionId: string; + schemaVersion: string; +} + +export interface DashboardTileSnapshotStoreOptions { + now?: () => number; +} + +export interface RestoredDashboardTileSnapshot { + ageMs: number; + response: Extract; +} + +const dashboardTileSnapshotStorageKey = "dimensionlab.dashboard.tiles.v1"; + +export function createDashboardTileSnapshotStore( + storage: Storage | undefined, + options: DashboardTileSnapshotStoreOptions = {}, +) { + const now = options.now || Date.now; + + function read(): DashboardTileSnapshotPayload | null { + if (!storage) return null; + + try { + const serialized = storage.getItem(dashboardTileSnapshotStorageKey); + if (!serialized) return null; + + const payload = JSON.parse(serialized) as Partial; + if ( + payload.version !== 1 || + typeof payload.currentRevisionId !== "string" || + typeof payload.schemaVersion !== "string" || + !Array.isArray(payload.tiles) + ) { + return null; + } + + return payload as DashboardTileSnapshotPayload; + } catch { + return null; + } + } + + function write(payload: DashboardTileSnapshotPayload): void { + if (!storage) return; + + try { + storage.setItem(dashboardTileSnapshotStorageKey, JSON.stringify(payload)); + } catch { + // Best-effort warm-start cache; quota and privacy failures are non-fatal. + } + } + + function matchingPayload( + context: DashboardTileSnapshotStoreContext, + ): DashboardTileSnapshotPayload { + const payload = read(); + if ( + payload && + payload.currentRevisionId === context.currentRevisionId && + payload.schemaVersion === context.schemaVersion + ) { + return payload; + } + + return { + currentRevisionId: context.currentRevisionId, + schemaVersion: context.schemaVersion, + tiles: [], + version: 1, + }; + } + + function saveReadyTile( + input: DashboardTileSnapshotStoreContext & { + response: Extract; + }, + ): void { + const payload = matchingPayload(input); + const key = dashboardTileSnapshotKey(input.response.tile); + const nextRecord: DashboardTileSnapshotRecord = { + item: input.response.item, + savedAt: now(), + tile: input.response.tile, + }; + payload.tiles = [ + nextRecord, + ...payload.tiles.filter((record) => + dashboardTileSnapshotKey(record.tile) !== key + ), + ]; + write(payload); + } + + return { + restore(context: DashboardTileSnapshotStoreContext): RestoredDashboardTileSnapshot[] { + const payload = read(); + if ( + !payload || + payload.currentRevisionId !== context.currentRevisionId || + payload.schemaVersion !== context.schemaVersion + ) { + return []; + } + + const restoredAt = now(); + return payload.tiles.map((record) => ({ + ageMs: Math.max(0, restoredAt - record.savedAt), + response: { + state: "ready", + tile: record.tile, + item: { + ...record.item, + detail: staleDashboardTileDetail(record.item.detail, restoredAt - record.savedAt), + severity: "stale", + }, + }, + })); + }, + saveReadyTile, + saveTile(input: DashboardTileSnapshotStoreContext & { + response: DashboardTileSnapshotResponse; + }): void { + if (input.response.state === "ready") { + saveReadyTile({ + currentRevisionId: input.currentRevisionId, + response: input.response, + schemaVersion: input.schemaVersion, + }); + } + }, + }; +} + +function dashboardTileSnapshotKey(tile: DashboardTileReference): string { + return JSON.stringify(tile); +} + +function staleDashboardTileDetail( + detail: string | undefined, + ageMs: number, +): string | undefined { + if (!detail) return detail; + const ageSeconds = Math.max(0, Math.floor(ageMs / 1_000)); + return ageSeconds > 0 ? `${detail} - stale ${ageSeconds}s` : detail; +}