diff --git a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts index 61acdd3..d24b48b 100644 --- a/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts +++ b/apps/web/src/lib/server/datasources/dashboard-datasources.test.ts @@ -3,7 +3,7 @@ import { DASHBOARD_SCHEMA_VERSION, type DashboardDocument, } from "@dimensionlab/dashboard-model"; -import { resolveDashboardDatasources } from "."; +import { resolveDashboardDatasources, resolveDashboardTile } from "."; describe("dashboard datasource resolution", () => { test("hydrates telemetry, service health, weather, and summary data from live adapters", async () => { @@ -124,6 +124,100 @@ describe("dashboard datasource resolution", () => { expect(resolved).not.toBe(testDocument); expect(testDocument.telemetry[0].value.value).toBe(1); }); + + test("shares service health snapshots across aggregate tile hydration", async () => { + let resolveFetch: ((response: Response) => void) | undefined; + const fetch = vi.fn((input: RequestInfo | URL) => { + const url = String(input); + if (url !== "https://service.example/health") { + throw new Error(`Unhandled test request: ${url}`); + } + + return new Promise((resolve) => { + resolveFetch = resolve; + }); + }); + const document = testDashboard(); + + const moduleTile = resolveDashboardTile( + document, + { kind: "module", id: "runtime-health-summary" }, + { fetch }, + ); + const statusTile = resolveDashboardTile( + document, + { kind: "status", stripId: "footer", id: "system-status" }, + { fetch }, + ); + + await Promise.resolve(); + expect(fetch).toHaveBeenCalledTimes(1); + + resolveFetch?.(jsonResponse({ status: "UP", ping: 42 })); + expect(await moduleTile).toMatchObject({ + state: "ready", + item: { + id: "runtime-health-summary", + severity: "ok", + value: "all systems operational", + }, + }); + expect(await statusTile).toMatchObject({ + state: "ready", + item: { + id: "system-status", + severity: "ok", + value: "All systems operational", + }, + }); + }); + + test("isolates service health snapshots by datasource fetch context", async () => { + const firstFetch = vi.fn(async () => + jsonResponse({ + status: "UP", + ping: 42, + }) + ); + const secondFetch = vi.fn(async () => + jsonResponse({ + status: "DOWN", + ping: 0, + }) + ); + const document = testDashboard(); + document.serviceGroups[0].services[0] = { + ...document.serviceGroups[0].services[0], + id: "api-isolated", + datasource: { + type: "external", + adapter: "http-status", + reference: "GET https://service.example/isolated", + }, + }; + + await resolveDashboardTile( + document, + { kind: "status", stripId: "footer", id: "system-status" }, + { fetch: firstFetch }, + ); + const second = await resolveDashboardTile( + document, + { kind: "status", stripId: "footer", id: "system-status" }, + { fetch: secondFetch }, + ); + + expect(firstFetch).toHaveBeenCalledTimes(1); + expect(secondFetch).toHaveBeenCalledTimes(1); + expect(second).toMatchObject({ + state: "ready", + item: { + id: "system-status", + severity: "danger", + value: "1 service down", + }, + }); + }); }); const testDocument = testDashboard(); diff --git a/apps/web/src/lib/server/datasources/index.ts b/apps/web/src/lib/server/datasources/index.ts index ee24bcd..62df13a 100644 --- a/apps/web/src/lib/server/datasources/index.ts +++ b/apps/web/src/lib/server/datasources/index.ts @@ -41,6 +41,7 @@ export type DashboardTileResolution = export interface DatasourceResolutionOptions { fetch?: DatasourceFetch; + now?: () => number; prometheusBaseUrl?: string; prometheusRangeSeconds?: number; prometheusStepSeconds?: number; @@ -113,9 +114,7 @@ export async function resolveDashboardTile( const item = module.id === "runtime-health-summary" ? runtimeHealthSummary( module, - await Promise.all( - document.serviceGroups.map((group) => resolveServiceGroup(group, context)), - ), + await serviceGroupsSnapshot(document, context), ) : await resolveModule(module, context); @@ -132,7 +131,7 @@ export async function resolveDashboardTile( item: await resolveStatusTile( statusItem, document.metadata.refreshIntervalSeconds, - document.serviceGroups, + document, context, ), }; @@ -140,14 +139,27 @@ export async function resolveDashboardTile( interface DatasourceContext { fetch: DatasourceFetch; + fetchIdentity: number; + now: () => number; prometheusBaseUrl: string; prometheusRangeSeconds: number; prometheusStepSeconds: number; requestTimeoutMs: number; + serviceGroupsSnapshot?: Promise; } type DatasourceFetch = (input: string, init?: RequestInit) => Promise; +interface ServiceGroupsSnapshotEntry { + expiresAt: number; + snapshot: Promise; +} + +const serviceGroupsSnapshotTtlMs = 30_000; +const serviceGroupsSnapshotCache = new Map(); +const datasourceFetchIdentities = new WeakMap(); +let nextDatasourceFetchIdentity = 1; + interface PrometheusVectorResult { metric?: Record; value?: [number, string]; @@ -159,8 +171,12 @@ interface PrometheusMatrixResult { } function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext { + const fetch = options.fetch || globalThis.fetch; + return { - fetch: options.fetch || globalThis.fetch, + fetch, + fetchIdentity: datasourceFetchIdentity(fetch), + now: options.now || Date.now, prometheusBaseUrl: options.prometheusBaseUrl || process.env.PROMETHEUS_BASE_URL || @@ -171,6 +187,68 @@ function datasourceContext(options: DatasourceResolutionOptions): DatasourceCont }; } +function datasourceFetchIdentity(fetch: DatasourceFetch): number { + const existing = datasourceFetchIdentities.get(fetch); + if (existing) return existing; + + const next = nextDatasourceFetchIdentity; + nextDatasourceFetchIdentity += 1; + datasourceFetchIdentities.set(fetch, next); + return next; +} + +function serviceGroupsSnapshot( + document: DashboardDocument, + context: DatasourceContext, +): Promise { + if (context.serviceGroupsSnapshot) return context.serviceGroupsSnapshot; + + const key = serviceGroupsSnapshotKey(document, context); + const now = context.now(); + const cached = serviceGroupsSnapshotCache.get(key); + if (cached && cached.expiresAt > now) { + context.serviceGroupsSnapshot = cached.snapshot; + return cached.snapshot; + } + + const snapshot = Promise.all( + document.serviceGroups.map((group) => resolveServiceGroup(group, context)), + ); + context.serviceGroupsSnapshot = snapshot; + serviceGroupsSnapshotCache.set(key, { + expiresAt: now + serviceGroupsSnapshotTtlMs, + snapshot, + }); + snapshot.catch(() => { + if (serviceGroupsSnapshotCache.get(key)?.snapshot === snapshot) { + serviceGroupsSnapshotCache.delete(key); + } + }); + return snapshot; +} + +function serviceGroupsSnapshotKey( + document: DashboardDocument, + context: DatasourceContext, +): string { + return JSON.stringify( + { + fetchIdentity: context.fetchIdentity, + prometheusBaseUrl: context.prometheusBaseUrl, + prometheusRangeSeconds: context.prometheusRangeSeconds, + prometheusStepSeconds: context.prometheusStepSeconds, + requestTimeoutMs: context.requestTimeoutMs, + serviceGroups: document.serviceGroups.map((group) => ({ + id: group.id, + services: group.services.map((service) => ({ + datasource: service.datasource, + id: service.id, + })), + })), + }, + ); +} + async function resolveTelemetryCard( card: TelemetryCard, context: DatasourceContext, @@ -485,13 +563,11 @@ function resolveStatusItem( async function resolveStatusTile( item: StatusItem, refreshIntervalSeconds: number | undefined, - serviceGroups: ServiceGroup[], + document: DashboardDocument, context: DatasourceContext, ): Promise { if (item.id === "system-status") { - const resolvedGroups = await Promise.all( - serviceGroups.map((group) => resolveServiceGroup(group, context)), - ); + const resolvedGroups = await serviceGroupsSnapshot(document, context); const health = serviceHealthSummary(resolvedGroups); return { ...structuredClone(item),