perf(web): stream dashboard tile events #52
7 changed files with 315 additions and 0 deletions
|
|
@ -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[],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue