diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index fbb133d..71353f4 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -1,5 +1,6 @@ import { renderToString } from "react-dom/server"; import { describe, expect, test } from "vitest"; +import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures"; import { AppStateView } from "./App"; describe("React app dashboard state view", () => { @@ -41,4 +42,35 @@ describe("React app dashboard state view", () => { expect(html).toContain("Dark"); expect(html).toContain("Light"); }); + + test("renders the dashboard shell while individual items hydrate", () => { + const html = renderToString( + , + ); + + expect(html).toContain("Operations Console"); + expect(html).toContain("Service Uptime"); + expect(html).toContain("Identity"); + expect(html).toContain("Environment"); + expect(html).not.toContain("Loading Dashboard"); + expect(html).toContain( + 'data-severity="loading" data-model-id="service-uptime"', + ); + expect(html).toContain('data-severity="loading" data-model-id="identity"'); + expect(html).toContain('data-severity="loading" data-model-id="ambient"'); + expect(html).toContain('data-severity="loading" data-model-id="runtime:status"'); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0099173..6a19bd5 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,11 @@ import { useEffect, useState } from "react"; +import type { + DashboardDocument, + DashboardModule, + ServiceEntry, + StatusItem, + TelemetryCard, +} from "@dimensionlab/dashboard-model"; import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer"; import { @@ -9,21 +16,35 @@ import { resolveInitialUiTheme, type UiTheme, type UiSeverity, + type UiDashboardPreview, } from "@dimensionlab/ui"; -const loadingDashboardState: DashboardRuntimeState = { - state: "loading", - title: "Loading Dashboard", - subtitle: "Fetching active model", - message: "Waiting for the active dashboard document.", -}; +type DashboardTileReference = + | { kind: "telemetry"; id: string } + | { kind: "service"; id: string } + | { kind: "module"; id: string } + | { kind: "status"; stripId: string; id: string }; + +type DashboardTileResponse = + | { + state: "ready"; + tile: DashboardTileReference; + item: DashboardModule | ServiceEntry | StatusItem | TelemetryCard; + } + | { + state: "not_found"; + tile: DashboardTileReference; + message: string; + }; export function AppStateView({ dashboard, onThemeChange, theme, + hydratingItemIds, }: { dashboard: DashboardRuntimeState; + hydratingItemIds?: ReadonlySet; onThemeChange?: (theme: UiTheme) => void; theme?: UiTheme; }) { @@ -33,9 +54,14 @@ export function AppStateView({ ) : null; if (dashboard.state === "ready") { + const uiDashboard = markHydratingItems( + dashboardDocumentToUiDashboard(dashboard.document), + hydratingItemIds, + ); + return ( ); @@ -89,7 +115,10 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): { export default function App() { const [dashboard, setDashboard] = - useState(loadingDashboardState); + useState(null); + const [hydratingItemIds, setHydratingItemIds] = useState>( + () => new Set(), + ); const [theme, setTheme] = useState(() => { if (typeof window === "undefined") return "dark"; @@ -97,6 +126,8 @@ export default function App() { }); useEffect(() => { + if (!dashboard) return; + const metadata = resolveDocumentMetadata(dashboard); document.title = metadata.title; @@ -119,6 +150,7 @@ export default function App() { useEffect(() => { let cancelled = false; let refreshTimer: number | undefined; + let hydrationRun = 0; async function loadDashboard() { const response = await fetch("/api/dashboard"); @@ -127,6 +159,18 @@ export default function App() { if (cancelled) return; setDashboard(nextDashboard); + const currentRun = ++hydrationRun; + if ( + nextDashboard.state === "ready" && + nextDashboard.liveDatasourceHydration?.enabled !== false + ) { + const tiles = dashboardHydrationTiles(nextDashboard.document); + setHydratingItemIds(new Set(tiles.map(dashboardTileKey))); + hydrateDashboardTiles(tiles, currentRun); + } else { + setHydratingItemIds(new Set()); + } + if (refreshTimer) { window.clearInterval(refreshTimer); refreshTimer = undefined; @@ -145,6 +189,54 @@ export default function App() { } } + function hydrateDashboardTiles( + tiles: DashboardTileReference[], + run: number, + ) { + tiles.forEach((tile) => { + void hydrateDashboardTile(tile, run); + }); + } + + async function hydrateDashboardTile( + tile: DashboardTileReference, + run: number, + ) { + const key = dashboardTileKey(tile); + + try { + const response = await fetch(dashboardTileUrl(tile)); + const tileResponse = (await response.json()) as DashboardTileResponse; + + if ( + cancelled || + run !== hydrationRun || + !response.ok || + tileResponse.state !== "ready" + ) { + return; + } + + const readyTileResponse = tileResponse; + setDashboard((current) => + current?.state === "ready" + ? { + ...current, + document: applyDashboardTile(current.document, readyTileResponse), + } + : current, + ); + } finally { + if (!cancelled && run === hydrationRun) { + setHydratingItemIds((current) => { + const next = new Set(current); + next.delete(key); + return next; + }); + } + } + } + void loadDashboard(); return () => { @@ -153,13 +245,14 @@ export default function App() { }; }, []); - return ( + return dashboard ? ( - ); + ) : null; } function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity { @@ -181,3 +274,144 @@ function getThemeStorage(): Storage | undefined { return undefined; } } + +function markHydratingItems( + dashboard: UiDashboardPreview, + hydratingItemIds?: ReadonlySet, +): UiDashboardPreview { + if (!hydratingItemIds?.size) return dashboard; + + return { + ...dashboard, + telemetry: dashboard.telemetry.map((card) => + hydratingItemIds.has(`telemetry:${card.id}`) + ? { + ...card, + severity: "loading", + detail: "loading live telemetry", + } + : card, + ), + serviceGroups: dashboard.serviceGroups.map((group) => ({ + ...group, + services: group.services.map((service) => + hydratingItemIds.has(`service:${service.id}`) + ? { + ...service, + severity: "loading", + detail: "loading", + } + : service, + ), + })), + modules: dashboard.modules.map((module) => + hydratingItemIds.has(`module:${module.id}`) + ? { + ...module, + severity: "loading", + detail: "loading live data", + } + : module, + ), + statusItems: dashboard.statusItems.map((item) => + hydratingItemIds.has(`status:${item.id}`) + ? { + ...item, + severity: "loading", + value: "loading", + } + : item, + ), + }; +} + +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 })), + ); + const modules = (document.modules || []) + .filter((module) => + module.datasource?.type === "external" || + module.id === "runtime-health-summary" + ) + .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, + id: item.id, + })), + ); + + return [...telemetry, ...services, ...modules, ...status]; +} + +function dashboardTileKey(tile: DashboardTileReference): string { + return tile.kind === "status" + ? `${tile.kind}:${tile.stripId}:${tile.id}` + : `${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]; + return `/${parts.map(encodeURIComponent).join("/")}`; +} + +function applyDashboardTile( + document: DashboardDocument, + response: Extract, +): DashboardDocument { + if (response.tile.kind === "telemetry") { + return { + ...document, + telemetry: document.telemetry.map((card) => + card.id === response.tile.id ? response.item as TelemetryCard : card, + ), + }; + } + + if (response.tile.kind === "service") { + return { + ...document, + serviceGroups: document.serviceGroups.map((group) => ({ + ...group, + services: group.services.map((service) => + service.id === response.tile.id ? response.item as ServiceEntry : service, + ), + })), + }; + } + + if (response.tile.kind === "module") { + return { + ...document, + modules: (document.modules || []).map((module) => + module.id === response.tile.id ? response.item as DashboardModule : module, + ), + }; + } + + const tile = response.tile; + return { + ...document, + statusStrips: document.statusStrips.map((strip) => + strip.id === tile.stripId + ? { + ...strip, + items: strip.items.map((item) => + item.id === tile.id ? response.item as StatusItem : item, + ), + } + : strip, + ), + }; +} diff --git a/apps/web/src/lib/server/dashboard.ts b/apps/web/src/lib/server/dashboard.ts index 47ebfa2..33afa3a 100644 --- a/apps/web/src/lib/server/dashboard.ts +++ b/apps/web/src/lib/server/dashboard.ts @@ -18,6 +18,9 @@ export interface DashboardRuntimeReady { document: DashboardDocument; schemaVersion: string; currentRevisionId: string; + liveDatasourceHydration?: { + enabled: boolean; + }; } export interface DashboardRuntimeEmpty { diff --git a/apps/web/src/lib/server/datasources/index.ts b/apps/web/src/lib/server/datasources/index.ts index d69db86..ff0c675 100644 --- a/apps/web/src/lib/server/datasources/index.ts +++ b/apps/web/src/lib/server/datasources/index.ts @@ -10,6 +10,30 @@ import type { TelemetryCard, } from "@dimensionlab/dashboard-model"; +export type DashboardTileReference = + | { kind: "telemetry"; id: string } + | { kind: "service"; id: string } + | { kind: "module"; id: string } + | { kind: "status"; stripId: string; id: string }; + +export type DashboardTileItem = + | DashboardModule + | ServiceEntry + | StatusItem + | TelemetryCard; + +export type DashboardTileResolution = + | { + state: "ready"; + tile: DashboardTileReference; + item: DashboardTileItem; + } + | { + state: "not_found"; + tile: DashboardTileReference; + message: string; + }; + export interface DatasourceResolutionOptions { fetch?: DatasourceFetch; prometheusBaseUrl?: string; @@ -46,6 +70,69 @@ export async function resolveDashboardDatasources( }; } +export async function resolveDashboardTile( + document: DashboardDocument, + tile: DashboardTileReference, + options: DatasourceResolutionOptions = {}, +): Promise { + const context = datasourceContext(options); + + if (tile.kind === "telemetry") { + const card = document.telemetry.find((item) => item.id === tile.id); + if (!card) return missingTile(tile); + + return { + state: "ready", + tile, + item: await resolveTelemetryCard(card, context), + }; + } + + if (tile.kind === "service") { + const service = document.serviceGroups + .flatMap((group) => group.services) + .find((item) => item.id === tile.id); + if (!service) return missingTile(tile); + + return { + state: "ready", + tile, + item: await resolveService(service, context), + }; + } + + if (tile.kind === "module") { + const module = document.modules?.find((item) => item.id === tile.id); + if (!module) return missingTile(tile); + + const item = module.id === "runtime-health-summary" + ? runtimeHealthSummary( + module, + await Promise.all( + document.serviceGroups.map((group) => resolveServiceGroup(group, context)), + ), + ) + : await resolveModule(module, context); + + return { state: "ready", tile, item }; + } + + const strip = document.statusStrips.find((item) => item.id === tile.stripId); + const statusItem = strip?.items.find((item) => item.id === tile.id); + if (!strip || !statusItem) return missingTile(tile); + + return { + state: "ready", + tile, + item: await resolveStatusTile( + statusItem, + document.metadata.refreshIntervalSeconds, + document.serviceGroups, + context, + ), + }; +} + interface DatasourceContext { fetch: DatasourceFetch; prometheusBaseUrl: string; @@ -390,6 +477,68 @@ function resolveStatusItem( return structuredClone(item); } +async function resolveStatusTile( + item: StatusItem, + refreshIntervalSeconds: number | undefined, + serviceGroups: ServiceGroup[], + context: DatasourceContext, +): Promise { + if (item.id === "system-status") { + const resolvedGroups = await Promise.all( + serviceGroups.map((group) => resolveServiceGroup(group, context)), + ); + const health = serviceHealthSummary(resolvedGroups); + return { + ...structuredClone(item), + value: health.value, + severity: health.severity, + }; + } + + if (item.id === "last-sync") { + return { + ...structuredClone(item), + value: "just now", + severity: "ok", + }; + } + + if (item.id === "uptime") { + const uptime = await prometheusScalar( + 'time() - node_boot_time_seconds{job="node",host="linux-infra"}', + context, + ).catch(() => null); + return uptime === null + ? structuredClone(item) + : { + ...structuredClone(item), + value: formatDuration(uptime), + severity: "ok", + }; + } + + if (item.id === "load-avg") { + const loadAverage = await prometheusLoadAverage(context).catch(() => null); + return loadAverage + ? { + ...structuredClone(item), + value: loadAverage, + severity: "neutral", + } + : structuredClone(item); + } + + if (item.id === "auto-refresh" && refreshIntervalSeconds) { + return { + ...structuredClone(item), + value: `${refreshIntervalSeconds}s`, + severity: "neutral", + }; + } + + return structuredClone(item); +} + function serviceHealthSummary(serviceGroups: ServiceGroup[]): { severity: Severity; value: string; @@ -673,3 +822,17 @@ function formatDuration(totalSeconds: number): string { const minutes = Math.floor((seconds % 3_600) / 60); return `${days}d ${hours}h ${minutes}m`; } + +function missingTile(tile: DashboardTileReference): DashboardTileResolution { + return { + state: "not_found", + tile, + message: `Dashboard tile not found: ${tileKey(tile)}`, + }; +} + +function tileKey(tile: DashboardTileReference): string { + return tile.kind === "status" + ? `${tile.kind}:${tile.stripId}:${tile.id}` + : `${tile.kind}:${tile.id}`; +} diff --git a/apps/web/src/server/index.ts b/apps/web/src/server/index.ts index 860d042..ba2135f 100644 --- a/apps/web/src/server/index.ts +++ b/apps/web/src/server/index.ts @@ -1,6 +1,6 @@ import { extname, normalize } from "node:path"; import { handleAgentDashboardRoute } from "./routes/agent-dashboard"; -import { handleDashboardRoute } from "./routes/dashboard"; +import { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard"; const host = process.env.HOST || "0.0.0.0"; const port = Number(process.env.PORT || 3000); @@ -23,6 +23,11 @@ export async function handleRequest(request: Request): Promise { return handleDashboardRoute(); } + if (url.pathname.startsWith("/api/dashboard/tile/")) { + if (request.method !== "GET") return methodNotAllowed(["GET"]); + return handleDashboardTileRoute(url.pathname); + } + if (url.pathname === "/api/agent/dashboard") { if (request.method !== "POST") return methodNotAllowed(["POST"]); return handleAgentDashboardRoute(request); diff --git a/apps/web/src/server/routes/dashboard.test.ts b/apps/web/src/server/routes/dashboard.test.ts index 21ad58d..ae68e78 100644 --- a/apps/web/src/server/routes/dashboard.test.ts +++ b/apps/web/src/server/routes/dashboard.test.ts @@ -1,11 +1,14 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab"; -import { loadDashboardResponse } from "./dashboard"; +import { loadDashboardResponse, loadDashboardTileResponse } from "./dashboard"; describe("dashboard API route", () => { - test("returns ready dashboard runtime state from the existing model loader", async () => { + test("returns the ready dashboard shell without hydrating live datasources", async () => { + const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue( + new Error("live datasource fetch should not run for the shell response"), + ); + const response = await loadDashboardResponse({ - disableLiveDatasources: true, refreshSeedDocument: true, seedIfEmpty: true, }); @@ -15,5 +18,105 @@ describe("dashboard API route", () => { expect(response.document.metadata.title).toBe( dimensionLabDashboardFixture.metadata.title, ); + expect(fetch).not.toHaveBeenCalled(); + + fetch.mockRestore(); + }); + + test("reports when client-side live hydration is disabled", async () => { + const previous = process.env.DISABLE_LIVE_DATASOURCES; + process.env.DISABLE_LIVE_DATASOURCES = "1"; + + try { + const response = await loadDashboardResponse({ + refreshSeedDocument: true, + seedIfEmpty: true, + }); + + expect(response.state).toBe("ready"); + if (response.state !== "ready") throw new Error("expected ready dashboard"); + expect(response.liveDatasourceHydration).toEqual({ enabled: false }); + } finally { + if (previous === undefined) { + delete process.env.DISABLE_LIVE_DATASOURCES; + } else { + process.env.DISABLE_LIVE_DATASOURCES = previous; + } + } + }); + + test("hydrates a telemetry tile independently from the dashboard shell", async () => { + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.startsWith("https://prometheus.example/api/v1/query_range")) { + return jsonResponse({ + status: "success", + data: { + result: [ + { + metric: { host: "linux-infra" }, + values: [ + [1771430000, "10"], + [1771430060, "20"], + [1771430120, "42"], + ], + }, + ], + }, + }); + } + + if (url.startsWith("https://prometheus.example/api/v1/query")) { + return jsonResponse({ + status: "success", + data: { + result: [ + { + metric: { host: "linux-infra" }, + value: [1771430400, "42"], + }, + ], + }, + }); + } + + throw new Error(`Unhandled test request: ${url}`); + }); + + const response = await loadDashboardTileResponse( + { kind: "telemetry", id: "infra-ram" }, + { + fetch, + prometheusBaseUrl: "https://prometheus.example", + refreshSeedDocument: true, + seedIfEmpty: true, + }, + ); + + expect(response.state).toBe("ready"); + if (response.state !== "ready") throw new Error("expected ready tile"); + expect(response.tile).toEqual({ kind: "telemetry", id: "infra-ram" }); + expect(response.item).toMatchObject({ + id: "infra-ram", + value: { kind: "percent", value: 42 }, + severity: "ok", + detail: "linux-infra", + sparkline: [10, 20, 42], + }); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/query?"), + expect.objectContaining({ cache: "no-store" }), + ); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/api/v1/query_range?"), + expect.objectContaining({ cache: "no-store" }), + ); }); }); + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + headers: { "content-type": "application/json" }, + }); +} diff --git a/apps/web/src/server/routes/dashboard.ts b/apps/web/src/server/routes/dashboard.ts index 21c30e4..d4a6b72 100644 --- a/apps/web/src/server/routes/dashboard.ts +++ b/apps/web/src/server/routes/dashboard.ts @@ -3,19 +3,30 @@ import { type DashboardRuntimeOptions, type DashboardRuntimeState, } from "$lib/server/dashboard"; -import { resolveDashboardDatasources } from "$lib/server/datasources"; +import { + resolveDashboardDatasources, + resolveDashboardTile, + type DashboardTileReference, + type DashboardTileResolution, + type DatasourceResolutionOptions, +} from "$lib/server/datasources"; export interface LoadDashboardResponseOptions extends Pick< DashboardRuntimeOptions, "refreshSeedDocument" | "seedIfEmpty" | "seedDocument" - > { + >, + DatasourceResolutionOptions { disableLiveDatasources?: boolean; + hydrateLiveDatasources?: boolean; } export async function loadDashboardResponse( options: LoadDashboardResponseOptions = {}, ): Promise { + const liveHydrationEnabled = + !options.disableLiveDatasources && + process.env.DISABLE_LIVE_DATASOURCES !== "1"; const dashboard = loadDashboardRuntime(undefined, { refreshSeedDocument: options.refreshSeedDocument ?? true, seedIfEmpty: options.seedIfEmpty ?? true, @@ -26,16 +37,87 @@ export async function loadDashboardResponse( return dashboard; } - if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") { - return dashboard; + if ( + !options.hydrateLiveDatasources || + options.disableLiveDatasources || + process.env.DISABLE_LIVE_DATASOURCES === "1" + ) { + return { + ...dashboard, + liveDatasourceHydration: { + enabled: liveHydrationEnabled, + }, + }; } return { ...dashboard, - document: await resolveDashboardDatasources(dashboard.document), + document: await resolveDashboardDatasources(dashboard.document, options), + liveDatasourceHydration: { + enabled: false, + }, }; } +export async function loadDashboardTileResponse( + tile: DashboardTileReference, + options: LoadDashboardResponseOptions = {}, +): Promise { + const dashboard = loadDashboardRuntime(undefined, { + refreshSeedDocument: options.refreshSeedDocument ?? true, + seedIfEmpty: options.seedIfEmpty ?? true, + seedDocument: options.seedDocument, + }); + + if (dashboard.state !== "ready") { + return { + state: "not_found", + tile, + message: `Dashboard is not ready: ${dashboard.state}`, + }; + } + + return resolveDashboardTile(dashboard.document, tile, options); +} + export async function handleDashboardRoute(): Promise { return Response.json(await loadDashboardResponse()); } + +export async function handleDashboardTileRoute(pathname: string): Promise { + const tile = parseDashboardTilePath(pathname); + if (!tile) { + return Response.json( + { ok: false, message: "Invalid dashboard tile route" }, + { status: 404 }, + ); + } + + const response = await loadDashboardTileResponse(tile); + return Response.json(response, { + status: response.state === "ready" ? 200 : 404, + }); +} + +function parseDashboardTilePath(pathname: string): DashboardTileReference | null { + const parts = pathname.split("/").filter(Boolean); + const [, dashboard, tileRoot, kind, firstId, secondId] = parts; + if (dashboard !== "dashboard" || tileRoot !== "tile" || !kind || !firstId) { + return null; + } + + const id = decodeURIComponent(firstId); + if (kind === "telemetry" || kind === "service" || kind === "module") { + return { kind, id }; + } + + if (kind === "status" && secondId) { + return { + kind, + stripId: id, + id: decodeURIComponent(secondId), + }; + } + + return null; +}