perf(web): stream dashboard tile events

This commit is contained in:
vince 2026-06-20 15:23:14 +02:00
parent a66f1fdd57
commit 41fb964ebb
7 changed files with 315 additions and 0 deletions

View file

@ -17,6 +17,7 @@ import {
createDashboardRequestAborter, createDashboardRequestAborter,
runViewportAwareDashboardHydrationQueue, runViewportAwareDashboardHydrationQueue,
shouldPauseDashboardRefresh, shouldPauseDashboardRefresh,
subscribeToDashboardTileEvents,
waitForDashboardHydrationIdle, waitForDashboardHydrationIdle,
type DashboardIntersectionObserverFactory, type DashboardIntersectionObserverFactory,
type DashboardTileReference, type DashboardTileReference,
@ -177,6 +178,7 @@ export default function App() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let refreshTimer: number | undefined; let refreshTimer: number | undefined;
let unsubscribeTileEvents: (() => void) | undefined;
let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs; let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs;
let hydrationRun = 0; let hydrationRun = 0;
const requestAborter = createDashboardRequestAborter(); const requestAborter = createDashboardRequestAborter();
@ -213,6 +215,8 @@ export default function App() {
function pauseRefreshes() { function pauseRefreshes() {
clearRefreshTimer(); clearRefreshTimer();
unsubscribeTileEvents?.();
unsubscribeTileEvents = undefined;
requestAborter.abortActiveRequests(); requestAborter.abortActiveRequests();
setHydratingItemIds(new Set()); setHydratingItemIds(new Set());
} }
@ -266,6 +270,14 @@ export default function App() {
schemaVersion: restored.dashboard.schemaVersion, schemaVersion: restored.dashboard.schemaVersion,
}, },
); );
subscribeDashboardTileEvents(
currentRun,
tileSignal,
{
currentRevisionId: restored.dashboard.currentRevisionId,
schemaVersion: restored.dashboard.schemaVersion,
},
);
} else { } else {
setHydratingItemIds(new Set()); setHydratingItemIds(new Set());
} }
@ -358,6 +370,30 @@ export default function App() {
}); });
} }
function subscribeDashboardTileEvents(
run: number,
signal: AbortSignal,
snapshotContext: DashboardTileSnapshotStoreContext,
) {
unsubscribeTileEvents?.();
unsubscribeTileEvents = subscribeToDashboardTileEvents({
onTile: (event) => {
const tileResponse = event as DashboardTileResponse;
if (!isDashboardTileResponse(tileResponse)) return;
applyDashboardTileHydrationResponse(
tileResponse.tile,
tileResponse,
run,
signal,
snapshotContext,
);
},
onUnavailable: () => {
unsubscribeTileEvents = undefined;
},
});
}
async function hydrateDashboardTileBatch( async function hydrateDashboardTileBatch(
tiles: DashboardTileReference[], tiles: DashboardTileReference[],
run: number, run: number,
@ -526,6 +562,7 @@ export default function App() {
return () => { return () => {
cancelled = true; cancelled = true;
clearRefreshTimer(); clearRefreshTimer();
unsubscribeTileEvents?.();
requestAborter.abortActiveRequests(); requestAborter.abortActiveRequests();
detachRefreshLifecycle(); detachRefreshLifecycle();
}; };
@ -625,6 +662,20 @@ function isAbortError(error: unknown): boolean {
); );
} }
function isDashboardTileResponse(value: unknown): value is DashboardTileResponse {
return (
typeof value === "object" &&
value !== null &&
"state" in value &&
(value.state === "ready" ||
value.state === "not_found" ||
value.state === "disabled") &&
"tile" in value &&
typeof value.tile === "object" &&
value.tile !== null
);
}
export function restoreDashboardTileSnapshots( export function restoreDashboardTileSnapshots(
dashboard: DashboardRuntimeState, dashboard: DashboardRuntimeState,
snapshots: RestoredDashboardTileSnapshot[], snapshots: RestoredDashboardTileSnapshot[],

View file

@ -13,6 +13,7 @@ import {
runViewportAwareDashboardHydrationQueue, runViewportAwareDashboardHydrationQueue,
shouldPauseDashboardRefresh, shouldPauseDashboardRefresh,
splitDashboardHydrationItemsByVisibility, splitDashboardHydrationItemsByVisibility,
subscribeToDashboardTileEvents,
waitForDashboardHydrationIdle, waitForDashboardHydrationIdle,
type DashboardIntersectionEntry, type DashboardIntersectionEntry,
} from "./dashboard-refresh"; } from "./dashboard-refresh";
@ -302,6 +303,69 @@ describe("dashboard refresh lifecycle", () => {
]); ]);
}); });
test("subscribes to dashboard tile events", () => {
const received: unknown[] = [];
let listener: ((event: MessageEvent<string>) => void) | undefined;
let closed = false;
const unsubscribe = subscribeToDashboardTileEvents({
createEventSource: (url) => {
expect(url).toBe("/api/dashboard/events");
return {
addEventListener(_type, eventListener) {
listener = eventListener;
},
close() {
closed = true;
},
onerror: null,
};
},
onTile: (tile) => received.push(tile),
});
listener?.({ data: JSON.stringify({ state: "ready" }) } as MessageEvent<string>);
expect(received).toEqual([{ state: "ready" }]);
unsubscribe();
expect(closed).toBe(true);
});
test("falls back when dashboard tile events are unavailable or fail", () => {
let unavailableCount = 0;
subscribeToDashboardTileEvents({
createEventSource: undefined,
onTile: () => undefined,
onUnavailable: () => {
unavailableCount += 1;
},
});
let errorHandler: (() => void) | null = null;
const unsubscribe = subscribeToDashboardTileEvents({
createEventSource: () => ({
addEventListener: () => undefined,
close: () => undefined,
get onerror() {
return errorHandler;
},
set onerror(handler) {
errorHandler = handler;
},
}),
onTile: () => undefined,
onUnavailable: () => {
unavailableCount += 1;
},
});
if (!errorHandler) throw new Error("expected error handler");
const triggerError = errorHandler as unknown as () => void;
triggerError();
unsubscribe();
expect(unavailableCount).toBe(2);
});
test("backs off failed tile keys and resets after success", () => { test("backs off failed tile keys and resets after success", () => {
const backoff = createDashboardTileBackoff(); const backoff = createDashboardTileBackoff();

View file

@ -489,6 +489,51 @@ export function createDashboardPerformanceMarks(
}; };
} }
export interface DashboardTileEventSource {
addEventListener(
type: "dashboard-tile",
listener: (event: MessageEvent<string>) => void,
): void;
close(): void;
onerror: (() => void) | null;
}
export interface DashboardTileEventSubscriptionOptions {
createEventSource?: (url: string) => DashboardTileEventSource;
onTile: (data: unknown) => void;
onUnavailable?: () => void;
url?: string;
}
export function subscribeToDashboardTileEvents(
options: DashboardTileEventSubscriptionOptions,
): () => void {
const createEventSource = options.createEventSource ||
(typeof globalThis.EventSource !== "undefined"
? (url: string) => new globalThis.EventSource(url)
: undefined);
if (!createEventSource) {
options.onUnavailable?.();
return () => undefined;
}
const source = createEventSource(options.url || "/api/dashboard/events");
source.addEventListener("dashboard-tile", (event) => {
try {
options.onTile(JSON.parse(event.data));
} catch {
// Ignore malformed diagnostics from an optional live transport.
}
});
source.onerror = () => {
source.close();
options.onUnavailable?.();
};
return () => source.close();
}
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000]; const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
export function createDashboardTileBackoff() { export function createDashboardTileBackoff() {

View file

@ -50,4 +50,28 @@ describe("server request routing", () => {
expect(response.status).toBe(405); expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("POST"); expect(response.headers.get("allow")).toBe("POST");
}); });
test("routes dashboard event stream requests", async () => {
const response = await handleRequest(
new Request("https://example.test/api/dashboard/events", {
method: "GET",
}),
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(
"text/event-stream; charset=utf-8",
);
});
test("rejects non-get dashboard event stream requests", async () => {
const response = await handleRequest(
new Request("https://example.test/api/dashboard/events", {
method: "POST",
}),
);
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET");
});
}); });

View file

@ -1,6 +1,7 @@
import { extname, normalize } from "node:path"; import { extname, normalize } from "node:path";
import { handleAgentDashboardRoute } from "./routes/agent-dashboard"; import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
import { import {
handleDashboardEventsRoute,
handleDashboardRoute, handleDashboardRoute,
handleDashboardTileRoute, handleDashboardTileRoute,
handleDashboardTilesRoute, handleDashboardTilesRoute,
@ -32,6 +33,11 @@ export async function handleRequest(request: Request): Promise<Response> {
return handleDashboardTilesRoute(request); return handleDashboardTilesRoute(request);
} }
if (url.pathname === "/api/dashboard/events") {
if (request.method !== "GET") return methodNotAllowed(["GET"]);
return handleDashboardEventsRoute({ signal: request.signal });
}
if (url.pathname.startsWith("/api/dashboard/tile/")) { if (url.pathname.startsWith("/api/dashboard/tile/")) {
if (request.method !== "GET") return methodNotAllowed(["GET"]); if (request.method !== "GET") return methodNotAllowed(["GET"]);
return handleDashboardTileRoute(url.pathname); return handleDashboardTileRoute(url.pathname);

View file

@ -3,6 +3,7 @@ import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import { import {
createDashboardTileCache, createDashboardTileCache,
dashboardTileCacheKey, dashboardTileCacheKey,
handleDashboardEventsRoute,
handleDashboardTilesRoute, handleDashboardTilesRoute,
handleDashboardTileRoute, handleDashboardTileRoute,
loadDashboardResponse, loadDashboardResponse,
@ -605,6 +606,45 @@ describe("dashboard API route", () => {
}); });
}); });
test("streams ready dashboard tile events", async () => {
const controller = new AbortController();
const response = await handleDashboardEventsRoute({
refreshSeedDocument: true,
seedIfEmpty: true,
signal: controller.signal,
tileCache: {
async resolve(key) {
controller.abort();
const tile = JSON.parse(key);
return {
cache: "miss",
coalesced: false,
response: {
state: "ready",
tile,
item: {
id: tile.id,
label: "Status",
severity: "ok",
value: "ok",
},
},
};
},
},
});
expect(response.headers.get("content-type")).toBe(
"text/event-stream; charset=utf-8",
);
expect(response.headers.get("cache-control")).toBe("no-cache");
const body = await response.text();
expect(body).toContain("event: dashboard-tile");
expect(body).toContain('"state":"ready"');
expect(body).toContain('"tile"');
});
test("rejects invalid batch tile requests", async () => { test("rejects invalid batch tile requests", async () => {
const response = await handleDashboardTilesRoute( const response = await handleDashboardTilesRoute(
new Request("https://example.test/api/dashboard/tiles", { new Request("https://example.test/api/dashboard/tiles", {

View file

@ -25,6 +25,10 @@ export interface LoadDashboardResponseOptions
tileCache?: DashboardTileCache; tileCache?: DashboardTileCache;
} }
export interface DashboardEventsRouteOptions extends LoadDashboardResponseOptions {
signal?: AbortSignal;
}
interface DashboardTileCacheEntry { interface DashboardTileCacheEntry {
expiresAt: number; expiresAt: number;
response: DashboardTileResolution; response: DashboardTileResolution;
@ -293,6 +297,48 @@ export async function handleDashboardTilesRoute(
}); });
} }
export async function handleDashboardEventsRoute(
options: DashboardEventsRouteOptions = {},
): Promise<Response> {
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const dashboard = await loadDashboardResponse({
...options,
hydrateLiveDatasources: false,
});
if (dashboard.state !== "ready" || options.signal?.aborted) {
controller.close();
return;
}
try {
for (const tile of dashboardEventTiles(dashboard.document)) {
if (options.signal?.aborted) break;
const resolution = await loadDashboardTileResponse(tile, options);
if (resolution.state === "ready") {
controller.enqueue(
encoder.encode(dashboardTileEventChunk(resolution)),
);
}
}
} finally {
controller.close();
}
},
});
return new Response(stream, {
headers: {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Content-Type": "text/event-stream; charset=utf-8",
"X-Accel-Buffering": "no",
},
});
}
export function dashboardTileCacheKey(tile: DashboardTileReference): string { export function dashboardTileCacheKey(tile: DashboardTileReference): string {
return JSON.stringify(tile); return JSON.stringify(tile);
} }
@ -422,6 +468,45 @@ function parseDashboardTileReference(value: unknown): DashboardTileReference | n
return null; return null;
} }
function dashboardEventTiles(document: DashboardDocument): DashboardTileReference[] {
const status = document.statusStrips.flatMap((strip) =>
strip.items.map((item): DashboardTileReference => ({
kind: "status",
stripId: strip.id,
id: item.id,
})),
);
const telemetry = document.telemetry
.filter((card) => card.datasource?.type === "external")
.map((card): DashboardTileReference => ({ kind: "telemetry", id: card.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 services = document.serviceGroups.flatMap((group) =>
group.services
.filter((service) => service.datasource?.type === "external")
.map((service): DashboardTileReference => ({
kind: "service",
groupId: group.id,
id: service.id,
})),
);
return [...status, ...telemetry, ...modules, ...services];
}
function dashboardTileEventChunk(resolution: DashboardTileResolution): string {
return [
"event: dashboard-tile",
`data: ${JSON.stringify(resolution)}`,
"",
"",
].join("\n");
}
class DashboardTileCacheResolutionError extends Error { class DashboardTileCacheResolutionError extends Error {
readonly cache: "hit" | "miss"; readonly cache: "hit" | "miss";
readonly coalesced: boolean; readonly coalesced: boolean;