diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 16e5c5e..f8a30e2 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,16 +9,11 @@ import type { import type { DashboardRuntimeState } from "$lib/server/dashboard"; import { attachDashboardRefreshLifecycle, - collectVisibleDashboardModelIds, - createDashboardRefreshDelay, createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, - runViewportAwareDashboardHydrationQueue, + runDashboardHydrationQueue, shouldPauseDashboardRefresh, - waitForDashboardHydrationIdle, - type DashboardIntersectionObserverFactory, - type DashboardTileReference, type DashboardTileSnapshotStoreContext, type RestoredDashboardTileSnapshot, } from "$lib/client/dashboard-refresh"; @@ -34,6 +29,12 @@ import { type UiDashboardPreview, } from "@dimensionlab/ui"; +type DashboardTileReference = + | { kind: "telemetry"; id: string } + | { kind: "service"; groupId: string; id: string } + | { kind: "module"; id: string } + | { kind: "status"; stripId: string; id: string }; + type DashboardTileResponse = | { state: "ready"; @@ -54,8 +55,6 @@ type DashboardTileResponse = type DashboardTileHydrationResult = "aborted" | "failed" | "ready"; const dashboardTileHydrationConcurrency = 6; -const dashboardFallbackRefreshIntervalMs = 30_000; -const dashboardViewportObservationTimeoutMs = 80; type DashboardTileSnapshotStore = ReturnType; export function AppStateView({ @@ -171,10 +170,8 @@ export default function App() { useEffect(() => { let cancelled = false; let refreshTimer: number | undefined; - let lastRefreshIntervalMs = dashboardFallbackRefreshIntervalMs; let hydrationRun = 0; const requestAborter = createDashboardRequestAborter(); - const refreshDelay = createDashboardRefreshDelay(); const tileBackoff = createDashboardTileBackoff(); const tileSnapshotStore = createDashboardTileSnapshotStore( getTileSnapshotStorage(), @@ -188,22 +185,12 @@ export default function App() { } function clearRefreshTimer() { - if (refreshTimer !== undefined) { - window.clearTimeout(refreshTimer); + if (refreshTimer) { + window.clearInterval(refreshTimer); refreshTimer = undefined; } } - function scheduleNextRefresh(baseDelayMs: number) { - clearRefreshTimer(); - if (cancelled || refreshPaused()) return; - - refreshTimer = window.setTimeout(() => { - refreshTimer = undefined; - void loadDashboard(); - }, refreshDelay.nextDelayMs(baseDelayMs)); - } - function pauseRefreshes() { clearRefreshTimer(); requestAborter.abortActiveRequests(); @@ -213,7 +200,6 @@ export default function App() { async function loadDashboard() { if (cancelled || refreshPaused()) return; - clearRefreshTimer(); const shellSignal = requestAborter.beginShellRun(); try { @@ -221,7 +207,6 @@ export default function App() { const nextDashboard = (await response.json()) as DashboardRuntimeState; if (cancelled || shellSignal.aborted || refreshPaused()) return; - refreshDelay.recordSuccess(); const restored = restoreDashboardTileSnapshots( nextDashboard, nextDashboard.state === "ready" @@ -262,20 +247,22 @@ export default function App() { setHydratingItemIds(new Set()); } + clearRefreshTimer(); + const refreshIntervalSeconds = nextDashboard.state === "ready" ? nextDashboard.document.metadata.refreshIntervalSeconds : undefined; if (refreshIntervalSeconds && !refreshPaused()) { - lastRefreshIntervalMs = refreshIntervalSeconds * 1000; - scheduleNextRefresh(lastRefreshIntervalMs); + refreshTimer = window.setInterval( + () => void loadDashboard(), + refreshIntervalSeconds * 1000, + ); } } catch (error) { if (!isAbortError(error) && !cancelled) { - refreshDelay.recordFailure(); console.error("Dashboard refresh failed", error); - scheduleNextRefresh(lastRefreshIntervalMs); } } } @@ -286,23 +273,8 @@ export default function App() { signal: AbortSignal, snapshotContext: DashboardTileSnapshotStoreContext, ) { - const modelIds = tiles.map(dashboardTileModelId); - - void runViewportAwareDashboardHydrationQueue({ - collectVisibleModelIds: async () => { - await waitForDashboardRenderFrame(signal); - return collectVisibleDashboardModelIds({ - clearTimeout: window.clearTimeout.bind(window), - createObserver: getDashboardIntersectionObserverFactory(), - documentTarget: document, - modelIds, - setTimeout: window.setTimeout.bind(window), - signal, - timeoutMs: dashboardViewportObservationTimeoutMs, - }); - }, + void runDashboardHydrationQueue({ concurrency: dashboardTileHydrationConcurrency, - getModelId: dashboardTileModelId, hydrate: async (tile) => { const key = dashboardTileKey(tile); const result = await hydrateDashboardTile( @@ -320,13 +292,6 @@ export default function App() { }, items: tiles, signal, - waitForIdle: () => - waitForDashboardHydrationIdle({ - ...getDashboardIdleCallbacks(), - clearTimeout: window.clearTimeout.bind(window), - setTimeout: window.setTimeout.bind(window), - signal, - }), }); } @@ -441,53 +406,6 @@ function getTileSnapshotStorage(): Storage | undefined { } } -type DashboardIdleWindow = Window & { - cancelIdleCallback?: (handle: number) => void; - requestIdleCallback?: ( - callback: () => void, - options?: { timeout?: number }, - ) => number; -}; - -function getDashboardIdleCallbacks() { - const idleWindow = window as DashboardIdleWindow; - return { - cancelIdleCallback: idleWindow.cancelIdleCallback?.bind(idleWindow), - requestIdleCallback: idleWindow.requestIdleCallback?.bind(idleWindow), - }; -} - -function getDashboardIntersectionObserverFactory(): - | DashboardIntersectionObserverFactory - | undefined { - if (typeof window.IntersectionObserver === "undefined") return undefined; - - return (callback) => - new window.IntersectionObserver((entries) => { - callback(entries); - }); -} - -async function waitForDashboardRenderFrame(signal: AbortSignal): Promise { - if (signal.aborted) return; - - await new Promise((resolve) => { - let settled = false; - let frame: number | undefined; - - function finish() { - if (settled) return; - settled = true; - if (frame !== undefined) window.cancelAnimationFrame(frame); - signal.removeEventListener("abort", finish); - resolve(); - } - - signal.addEventListener("abort", finish, { once: true }); - frame = window.requestAnimationFrame(finish); - }); -} - function isAbortError(error: unknown): boolean { return ( typeof error === "object" && @@ -621,11 +539,6 @@ function dashboardTileKey(tile: DashboardTileReference): string { return `${tile.kind}:${tile.id}`; } -function dashboardTileModelId(tile: DashboardTileReference): string { - if (tile.kind === "status") return `${tile.stripId}:${tile.id}`; - return tile.id; -} - function dashboardTileUrl(tile: DashboardTileReference): string { const parts = tile.kind === "status" ? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id] diff --git a/apps/web/src/lib/client/dashboard-refresh.test.ts b/apps/web/src/lib/client/dashboard-refresh.test.ts index d94b0c4..a232560 100644 --- a/apps/web/src/lib/client/dashboard-refresh.test.ts +++ b/apps/web/src/lib/client/dashboard-refresh.test.ts @@ -1,17 +1,11 @@ import { describe, expect, test } from "vitest"; import { attachDashboardRefreshLifecycle, - collectVisibleDashboardModelIds, - createDashboardRefreshDelay, createDashboardTileSnapshotStore, createDashboardTileBackoff, createDashboardRequestAborter, runDashboardHydrationQueue, - runViewportAwareDashboardHydrationQueue, shouldPauseDashboardRefresh, - splitDashboardHydrationItemsByVisibility, - waitForDashboardHydrationIdle, - type DashboardIntersectionEntry, } from "./dashboard-refresh"; describe("dashboard refresh lifecycle", () => { @@ -119,134 +113,6 @@ describe("dashboard refresh lifecycle", () => { expect(maxActive).toBe(2); }); - test("hydrates visible items before deferred items and waits for idle", async () => { - const calls: string[] = []; - const idleReleases: Array<() => void> = []; - - const hydration = runViewportAwareDashboardHydrationQueue({ - collectVisibleModelIds: async () => new Set(["b"]), - concurrency: 1, - getModelId: (item) => item, - hydrate: async (item) => { - calls.push(item); - }, - items: ["a", "b", "c"], - signal: new AbortController().signal, - waitForIdle: async () => { - calls.push("idle"); - await new Promise((resolve) => idleReleases.push(resolve)); - }, - }); - - await waitFor(() => calls.includes("idle")); - expect(calls).toEqual(["b", "idle"]); - - idleReleases.shift()?.(); - await hydration; - expect(calls).toEqual(["b", "idle", "a", "c"]); - }); - - test("splits visible hydration items while preserving document order", () => { - expect(splitDashboardHydrationItemsByVisibility({ - getModelId: (item) => item.id, - items: [{ id: "status" }, { id: "telemetry" }, { id: "service" }], - visibleModelIds: new Set(["service", "status"]), - })).toEqual({ - visible: [{ id: "status" }, { id: "service" }], - deferred: [{ id: "telemetry" }], - }); - }); - - test("collects visible data-model-id elements with IntersectionObserver", async () => { - const elements = [ - modelElement("status"), - modelElement("telemetry"), - modelElement("unrelated"), - ]; - let callback: - | ((entries: DashboardIntersectionEntry[]) => void) - | undefined; - let finishObservation: (() => void) | undefined; - let disconnected = false; - const observed: string[] = []; - - const visible = collectVisibleDashboardModelIds({ - createObserver: (observerCallback) => { - callback = observerCallback; - return { - disconnect() { - disconnected = true; - }, - observe(element) { - observed.push(element.getAttribute("data-model-id") || ""); - }, - }; - }, - documentTarget: { - querySelectorAll: () => elements, - }, - modelIds: ["status", "telemetry"], - setTimeout: (handler) => { - finishObservation = handler; - return 1 as unknown as ReturnType; - }, - clearTimeout: () => undefined, - signal: new AbortController().signal, - }); - - callback?.([ - { - isIntersecting: false, - intersectionRatio: 0, - target: elements[0], - }, - { - isIntersecting: true, - target: elements[1], - }, - ]); - finishObservation?.(); - - expect(await visible).toEqual(new Set(["telemetry"])); - expect(observed).toEqual(["status", "telemetry"]); - expect(disconnected).toBe(true); - }); - - test("waits for requestIdleCallback when available", async () => { - let idleCallback: (() => void) | undefined; - let cancelledIdle: number | undefined; - const wait = waitForDashboardHydrationIdle({ - cancelIdleCallback: (handle) => { - cancelledIdle = handle; - }, - requestIdleCallback: (callback) => { - idleCallback = callback; - return 7; - }, - signal: new AbortController().signal, - }); - - expect(cancelledIdle).toBeUndefined(); - idleCallback?.(); - await wait; - expect(cancelledIdle).toBe(7); - }); - - test("jitters refresh delays and slows repeated failures", () => { - const delay = createDashboardRefreshDelay({ - jitterRatio: 0.1, - random: () => 1, - }); - - expect(delay.nextDelayMs(1_000)).toBe(1_100); - delay.recordFailure(); - expect(delay.nextDelayMs(1_000)).toBe(2_200); - delay.recordFailure(); - expect(delay.nextDelayMs(1_000)).toBe(4_400); - delay.recordSuccess(); - expect(delay.nextDelayMs(1_000)).toBe(1_100); - }); - test("backs off failed tile keys and resets after success", () => { const backoff = createDashboardTileBackoff(); @@ -433,11 +299,3 @@ function createMemoryStorage(): Storage { }, }; } - -function modelElement(modelId: string) { - return { - getAttribute(name: string) { - return name === "data-model-id" ? modelId : null; - }, - }; -} diff --git a/apps/web/src/lib/client/dashboard-refresh.ts b/apps/web/src/lib/client/dashboard-refresh.ts index a77fdd1..aaf3cd3 100644 --- a/apps/web/src/lib/client/dashboard-refresh.ts +++ b/apps/web/src/lib/client/dashboard-refresh.ts @@ -151,263 +151,6 @@ export async function runDashboardHydrationQueue( ); } -export interface DashboardViewportHydrationQueueOptions { - collectVisibleModelIds: () => Promise>; - concurrency: number; - getModelId: (item: TItem) => string; - hydrate: (item: TItem) => Promise | void; - items: TItem[]; - signal: AbortSignal; - waitForIdle: () => Promise; -} - -export async function runViewportAwareDashboardHydrationQueue( - options: DashboardViewportHydrationQueueOptions, -): Promise { - const visibleModelIds = await options.collectVisibleModelIds(); - if (options.signal.aborted) return; - - const { visible, deferred } = splitDashboardHydrationItemsByVisibility({ - getModelId: options.getModelId, - items: options.items, - visibleModelIds, - }); - - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items: visible, - signal: options.signal, - }); - - if (!deferred.length || options.signal.aborted) return; - - await options.waitForIdle(); - if (options.signal.aborted) return; - - await runDashboardHydrationQueue({ - concurrency: options.concurrency, - hydrate: options.hydrate, - items: deferred, - signal: options.signal, - }); -} - -export function splitDashboardHydrationItemsByVisibility(options: { - getModelId: (item: TItem) => string; - items: TItem[]; - visibleModelIds: ReadonlySet; -}): { - deferred: TItem[]; - visible: TItem[]; -} { - const visible: TItem[] = []; - const deferred: TItem[] = []; - - for (const item of options.items) { - if (options.visibleModelIds.has(options.getModelId(item))) { - visible.push(item); - } else { - deferred.push(item); - } - } - - return { visible, deferred }; -} - -export interface DashboardModelElement { - getAttribute(name: string): string | null; -} - -export interface DashboardViewportElementSource { - querySelectorAll(selector: string): ArrayLike; -} - -export interface DashboardIntersectionEntry { - intersectionRatio?: number; - isIntersecting: boolean; - target: DashboardModelElement; -} - -export interface DashboardIntersectionObserver { - disconnect(): void; - observe(element: DashboardModelElement): void; -} - -export type DashboardIntersectionObserverFactory = ( - callback: (entries: DashboardIntersectionEntry[]) => void, -) => DashboardIntersectionObserver; - -type DashboardTimerHandle = ReturnType; - -export interface DashboardVisibleModelIdCollectorOptions { - clearTimeout?: (handle: DashboardTimerHandle) => void; - createObserver?: DashboardIntersectionObserverFactory; - documentTarget: DashboardViewportElementSource; - modelIds: Iterable; - setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; - signal: AbortSignal; - timeoutMs?: number; -} - -export async function collectVisibleDashboardModelIds( - options: DashboardVisibleModelIdCollectorOptions, -): Promise> { - const targetIds = new Set(options.modelIds); - if (!targetIds.size || options.signal.aborted) return new Set(); - - const elements = Array.from( - options.documentTarget.querySelectorAll("[data-model-id]"), - ).filter((element) => { - const modelId = element.getAttribute("data-model-id"); - return modelId ? targetIds.has(modelId) : false; - }); - - if (!elements.length) return new Set(); - if (!options.createObserver) return targetIds; - const createObserver = options.createObserver; - - const setTimer = - options.setTimeout || - ((callback: () => void, timeoutMs: number) => - globalThis.setTimeout(callback, timeoutMs)); - const clearTimer = - options.clearTimeout || - ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); - const timeoutMs = options.timeoutMs ?? 80; - - return new Promise((resolve) => { - const visibleModelIds = new Set(); - let settled = false; - let timeoutHandle: DashboardTimerHandle | undefined; - let observer: DashboardIntersectionObserver | undefined; - - function cleanup() { - if (timeoutHandle !== undefined) { - clearTimer(timeoutHandle); - } - observer?.disconnect(); - options.signal.removeEventListener("abort", finish); - } - - function finish() { - if (settled) return; - settled = true; - cleanup(); - resolve(visibleModelIds); - } - - observer = createObserver((entries) => { - for (const entry of entries) { - const modelId = entry.target.getAttribute("data-model-id"); - if ( - modelId && - targetIds.has(modelId) && - (entry.isIntersecting || (entry.intersectionRatio ?? 0) > 0) - ) { - visibleModelIds.add(modelId); - } - } - }); - - for (const element of elements) { - observer.observe(element); - } - - options.signal.addEventListener("abort", finish, { once: true }); - timeoutHandle = setTimer(finish, timeoutMs); - }); -} - -export interface DashboardHydrationIdleOptions { - cancelIdleCallback?: (handle: number) => void; - clearTimeout?: (handle: DashboardTimerHandle) => void; - requestIdleCallback?: ( - callback: () => void, - options?: { timeout?: number }, - ) => number; - setTimeout?: (callback: () => void, timeoutMs: number) => DashboardTimerHandle; - signal: AbortSignal; - timeoutMs?: number; -} - -export async function waitForDashboardHydrationIdle( - options: DashboardHydrationIdleOptions, -): Promise { - if (options.signal.aborted) return; - - const setTimer = - options.setTimeout || - ((callback: () => void, timeoutMs: number) => - globalThis.setTimeout(callback, timeoutMs)); - const clearTimer = - options.clearTimeout || - ((handle: DashboardTimerHandle) => globalThis.clearTimeout(handle)); - - await new Promise((resolve) => { - let settled = false; - let idleHandle: number | undefined; - let timeoutHandle: DashboardTimerHandle | undefined; - - function cleanup() { - if (idleHandle !== undefined) { - options.cancelIdleCallback?.(idleHandle); - } - if (timeoutHandle !== undefined) { - clearTimer(timeoutHandle); - } - options.signal.removeEventListener("abort", finish); - } - - function finish() { - if (settled) return; - settled = true; - cleanup(); - resolve(); - } - - options.signal.addEventListener("abort", finish, { once: true }); - if (options.requestIdleCallback) { - idleHandle = options.requestIdleCallback(finish, { - timeout: options.timeoutMs ?? 1_000, - }); - } else { - timeoutHandle = setTimer(finish, 0); - } - }); -} - -export interface DashboardRefreshDelayOptions { - failureMultiplierLimit?: number; - jitterRatio?: number; - random?: () => number; -} - -export function createDashboardRefreshDelay( - options: DashboardRefreshDelayOptions = {}, -) { - const random = options.random || Math.random; - const jitterRatio = options.jitterRatio ?? 0.1; - const failureMultiplierLimit = options.failureMultiplierLimit ?? 8; - let consecutiveFailures = 0; - - return { - nextDelayMs(baseDelayMs: number): number { - const failureMultiplier = consecutiveFailures - ? Math.min(2 ** consecutiveFailures, failureMultiplierLimit) - : 1; - const jitterFactor = 1 + ((random() * 2) - 1) * jitterRatio; - return Math.max(0, Math.round(baseDelayMs * failureMultiplier * jitterFactor)); - }, - recordFailure(): void { - consecutiveFailures += 1; - }, - recordSuccess(): void { - consecutiveFailures = 0; - }, - }; -} - const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000]; export function createDashboardTileBackoff() {