From 2a1d94195e12f20341e5bae66730b52833943779 Mon Sep 17 00:00:00 2001 From: vince Date: Sat, 20 Jun 2026 12:34:52 +0200 Subject: [PATCH] fix(web): scope dashboard tile hydration --- apps/web/src/App.test.tsx | 13 ++++- apps/web/src/App.tsx | 34 +++++++---- apps/web/src/lib/server/datasources/index.ts | 17 ++++-- apps/web/src/server/routes/dashboard.test.ts | 59 ++++++++++++++++++++ apps/web/src/server/routes/dashboard.ts | 23 +++++++- 5 files changed, 124 insertions(+), 22 deletions(-) diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 71353f4..c319cc6 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,7 +1,8 @@ import { renderToString } from "react-dom/server"; import { describe, expect, test } from "vitest"; import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; -import { AppStateView } from "./App"; +import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; +import { AppStateView, dashboardHydrationTiles } from "./App"; describe("React app dashboard state view", () => { test("renders loading dashboard state", () => { @@ -54,7 +55,7 @@ describe("React app dashboard state view", () => { }} hydratingItemIds={new Set([ "telemetry:service-uptime", - "service:identity", + "service:core-services:identity", "module:ambient", "status:runtime:status", ])} @@ -73,4 +74,12 @@ describe("React app dashboard state view", () => { expect(html).toContain('data-severity="loading" data-model-id="ambient"'); expect(html).toContain('data-severity="loading" data-model-id="runtime:status"'); }); + + test("hydrates every status cell from the Dimension Lab shell", () => { + expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({ + kind: "status", + stripId: "footer-status", + id: "auto-refresh", + }); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6a19bd5..206b74f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,7 +21,7 @@ import { type DashboardTileReference = | { kind: "telemetry"; id: string } - | { kind: "service"; id: string } + | { kind: "service"; groupId: string; id: string } | { kind: "module"; id: string } | { kind: "status"; stripId: string; id: string }; @@ -295,7 +295,7 @@ function markHydratingItems( serviceGroups: dashboard.serviceGroups.map((group) => ({ ...group, services: group.services.map((service) => - hydratingItemIds.has(`service:${service.id}`) + hydratingItemIds.has(`service:${group.id}:${service.id}`) ? { ...service, severity: "loading", @@ -325,14 +325,20 @@ function markHydratingItems( }; } -function dashboardHydrationTiles(document: DashboardDocument): DashboardTileReference[] { +export function dashboardHydrationTiles( + document: DashboardDocument, +): DashboardTileReference[] { const telemetry = document.telemetry .filter((card) => card.datasource?.type === "external") .map((card): DashboardTileReference => ({ kind: "telemetry", id: card.id })); const services = document.serviceGroups.flatMap((group) => group.services .filter((service) => service.datasource?.type === "external") - .map((service): DashboardTileReference => ({ kind: "service", id: service.id })), + .map((service): DashboardTileReference => ({ + kind: "service", + groupId: group.id, + id: service.id, + })), ); const modules = (document.modules || []) .filter((module) => @@ -342,7 +348,6 @@ function dashboardHydrationTiles(document: DashboardDocument): DashboardTileRefe .map((module): DashboardTileReference => ({ kind: "module", id: module.id })); const status = document.statusStrips.flatMap((strip) => strip.items - .filter((item) => item.id !== "auto-refresh") .map((item): DashboardTileReference => ({ kind: "status", stripId: strip.id, @@ -354,15 +359,17 @@ function dashboardHydrationTiles(document: DashboardDocument): DashboardTileRefe } function dashboardTileKey(tile: DashboardTileReference): string { - return tile.kind === "status" - ? `${tile.kind}:${tile.stripId}:${tile.id}` - : `${tile.kind}:${tile.id}`; + if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`; + if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`; + return `${tile.kind}:${tile.id}`; } function dashboardTileUrl(tile: DashboardTileReference): string { const parts = tile.kind === "status" ? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id] - : ["api", "dashboard", "tile", tile.kind, tile.id]; + : tile.kind === "service" + ? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id] + : ["api", "dashboard", "tile", tile.kind, tile.id]; return `/${parts.map(encodeURIComponent).join("/")}`; } @@ -380,13 +387,16 @@ function applyDashboardTile( } if (response.tile.kind === "service") { + const tile = response.tile; return { ...document, serviceGroups: document.serviceGroups.map((group) => ({ ...group, - services: group.services.map((service) => - service.id === response.tile.id ? response.item as ServiceEntry : service, - ), + services: group.id === tile.groupId + ? group.services.map((service) => + service.id === tile.id ? response.item as ServiceEntry : service, + ) + : group.services, })), }; } diff --git a/apps/web/src/lib/server/datasources/index.ts b/apps/web/src/lib/server/datasources/index.ts index ff0c675..ee24bcd 100644 --- a/apps/web/src/lib/server/datasources/index.ts +++ b/apps/web/src/lib/server/datasources/index.ts @@ -12,7 +12,7 @@ import type { export type DashboardTileReference = | { kind: "telemetry"; id: string } - | { kind: "service"; id: string } + | { kind: "service"; groupId: string; id: string } | { kind: "module"; id: string } | { kind: "status"; stripId: string; id: string }; @@ -32,6 +32,11 @@ export type DashboardTileResolution = state: "not_found"; tile: DashboardTileReference; message: string; + } + | { + state: "disabled"; + tile: DashboardTileReference; + message: string; }; export interface DatasourceResolutionOptions { @@ -90,8 +95,8 @@ export async function resolveDashboardTile( if (tile.kind === "service") { const service = document.serviceGroups - .flatMap((group) => group.services) - .find((item) => item.id === tile.id); + .find((group) => group.id === tile.groupId) + ?.services.find((item) => item.id === tile.id); if (!service) return missingTile(tile); return { @@ -832,7 +837,7 @@ function missingTile(tile: DashboardTileReference): DashboardTileResolution { } function tileKey(tile: DashboardTileReference): string { - return tile.kind === "status" - ? `${tile.kind}:${tile.stripId}:${tile.id}` - : `${tile.kind}:${tile.id}`; + if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`; + if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`; + return `${tile.kind}:${tile.id}`; } diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index ae68e78..095ee48 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -113,6 +113,65 @@ describe("dashboard API route", () => { expect.objectContaining({ cache: "no-store" }), ); }); + + test("hydrates a service tile with its service group identity", async () => { + const fetch = vi.fn(async () => + jsonResponse({ + status: "UP", + ping: 42, + }), + ); + + const response = await loadDashboardTileResponse( + { kind: "service", groupId: "essentials", id: "vaultwarden" }, + { + fetch, + refreshSeedDocument: true, + seedIfEmpty: true, + }, + ); + + expect(response.state).toBe("ready"); + if (response.state !== "ready") throw new Error("expected ready tile"); + expect(response.tile).toEqual({ + kind: "service", + groupId: "essentials", + id: "vaultwarden", + }); + expect(response.item).toMatchObject({ + id: "vaultwarden", + severity: "ok", + detail: "42 ms", + }); + }); + + test("does not hydrate tile routes when live datasources are disabled", async () => { + const previous = process.env.DISABLE_LIVE_DATASOURCES; + process.env.DISABLE_LIVE_DATASOURCES = "1"; + const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("live datasource fetch should not run when disabled"), + ); + + try { + const response = await loadDashboardTileResponse( + { kind: "telemetry", id: "infra-ram" }, + { + refreshSeedDocument: true, + seedIfEmpty: true, + }, + ); + + expect(response.state).toBe("disabled"); + expect(fetch).not.toHaveBeenCalled(); + } finally { + fetch.mockRestore(); + if (previous === undefined) { + delete process.env.DISABLE_LIVE_DATASOURCES; + } else { + process.env.DISABLE_LIVE_DATASOURCES = previous; + } + } + }); }); function jsonResponse(payload: unknown): Response { diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index d4a6b72..fd0196e 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -63,6 +63,17 @@ export async function loadDashboardTileResponse( tile: DashboardTileReference, options: LoadDashboardResponseOptions = {}, ): Promise { + if ( + options.disableLiveDatasources || + process.env.DISABLE_LIVE_DATASOURCES === "1" + ) { + return { + state: "disabled", + tile, + message: "Live datasource hydration is disabled.", + }; + } + const dashboard = loadDashboardRuntime(undefined, { refreshSeedDocument: options.refreshSeedDocument ?? true, seedIfEmpty: options.seedIfEmpty ?? true, @@ -95,7 +106,7 @@ export async function handleDashboardTileRoute(pathname: string): Promise