Compare commits
No commits in common. "83c2c0f4a48021260d2e6533be9e3f38db7fa926" and "b0b09eb8ad4c420a40f3799d12496d3874c47ffd" have entirely different histories.
83c2c0f4a4
...
b0b09eb8ad
3 changed files with 31 additions and 257 deletions
|
|
@ -7,11 +7,6 @@ import type {
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "@dimensionlab/dashboard-model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import {
|
|
||||||
attachDashboardRefreshLifecycle,
|
|
||||||
createDashboardRequestAborter,
|
|
||||||
shouldPauseDashboardRefresh,
|
|
||||||
} from "$lib/client/dashboard-refresh";
|
|
||||||
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||||
import {
|
import {
|
||||||
DashboardFrame,
|
DashboardFrame,
|
||||||
|
|
@ -156,38 +151,12 @@ export default function App() {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
let hydrationRun = 0;
|
let hydrationRun = 0;
|
||||||
const requestAborter = createDashboardRequestAborter();
|
|
||||||
|
|
||||||
function refreshPaused() {
|
|
||||||
return shouldPauseDashboardRefresh({
|
|
||||||
visibilityState: document.visibilityState,
|
|
||||||
online: navigator.onLine,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRefreshTimer() {
|
|
||||||
if (refreshTimer) {
|
|
||||||
window.clearInterval(refreshTimer);
|
|
||||||
refreshTimer = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function pauseRefreshes() {
|
|
||||||
clearRefreshTimer();
|
|
||||||
requestAborter.abortActiveRequests();
|
|
||||||
setHydratingItemIds(new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
if (cancelled || refreshPaused()) return;
|
const response = await fetch("/api/dashboard");
|
||||||
|
|
||||||
const shellSignal = requestAborter.beginShellRun();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/dashboard", { signal: shellSignal });
|
|
||||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||||
|
|
||||||
if (cancelled || shellSignal.aborted || refreshPaused()) return;
|
if (cancelled) return;
|
||||||
setDashboard(nextDashboard);
|
setDashboard(nextDashboard);
|
||||||
|
|
||||||
const currentRun = ++hydrationRun;
|
const currentRun = ++hydrationRun;
|
||||||
|
|
@ -196,57 +165,51 @@ export default function App() {
|
||||||
nextDashboard.liveDatasourceHydration?.enabled !== false
|
nextDashboard.liveDatasourceHydration?.enabled !== false
|
||||||
) {
|
) {
|
||||||
const tiles = dashboardHydrationTiles(nextDashboard.document);
|
const tiles = dashboardHydrationTiles(nextDashboard.document);
|
||||||
const tileSignal = requestAborter.beginTileRun();
|
|
||||||
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
|
||||||
hydrateDashboardTiles(tiles, currentRun, tileSignal);
|
hydrateDashboardTiles(tiles, currentRun);
|
||||||
} else {
|
} else {
|
||||||
setHydratingItemIds(new Set());
|
setHydratingItemIds(new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
clearRefreshTimer();
|
if (refreshTimer) {
|
||||||
|
window.clearInterval(refreshTimer);
|
||||||
|
refreshTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const refreshIntervalSeconds =
|
const refreshIntervalSeconds =
|
||||||
nextDashboard.state === "ready"
|
nextDashboard.state === "ready"
|
||||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (refreshIntervalSeconds && !refreshPaused()) {
|
if (refreshIntervalSeconds) {
|
||||||
refreshTimer = window.setInterval(
|
refreshTimer = window.setInterval(
|
||||||
() => void loadDashboard(),
|
() => void loadDashboard(),
|
||||||
refreshIntervalSeconds * 1000,
|
refreshIntervalSeconds * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
if (!isAbortError(error) && !cancelled) {
|
|
||||||
console.error("Dashboard refresh failed", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateDashboardTiles(
|
function hydrateDashboardTiles(
|
||||||
tiles: DashboardTileReference[],
|
tiles: DashboardTileReference[],
|
||||||
run: number,
|
run: number,
|
||||||
signal: AbortSignal,
|
|
||||||
) {
|
) {
|
||||||
tiles.forEach((tile) => {
|
tiles.forEach((tile) => {
|
||||||
void hydrateDashboardTile(tile, run, signal);
|
void hydrateDashboardTile(tile, run);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hydrateDashboardTile(
|
async function hydrateDashboardTile(
|
||||||
tile: DashboardTileReference,
|
tile: DashboardTileReference,
|
||||||
run: number,
|
run: number,
|
||||||
signal: AbortSignal,
|
|
||||||
) {
|
) {
|
||||||
const key = dashboardTileKey(tile);
|
const key = dashboardTileKey(tile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(dashboardTileUrl(tile), { signal });
|
const response = await fetch(dashboardTileUrl(tile));
|
||||||
const tileResponse = (await response.json()) as DashboardTileResponse;
|
const tileResponse = (await response.json()) as DashboardTileResponse;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
cancelled ||
|
cancelled ||
|
||||||
signal.aborted ||
|
|
||||||
run !== hydrationRun ||
|
run !== hydrationRun ||
|
||||||
!response.ok ||
|
!response.ok ||
|
||||||
tileResponse.state !== "ready"
|
tileResponse.state !== "ready"
|
||||||
|
|
@ -263,12 +226,8 @@ export default function App() {
|
||||||
}
|
}
|
||||||
: current,
|
: current,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
|
||||||
if (!isAbortError(error) && !cancelled) {
|
|
||||||
console.error("Dashboard tile hydration failed", error);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled && !signal.aborted && run === hydrationRun) {
|
if (!cancelled && run === hydrationRun) {
|
||||||
setHydratingItemIds((current) => {
|
setHydratingItemIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
next.delete(key);
|
next.delete(key);
|
||||||
|
|
@ -278,21 +237,11 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const detachRefreshLifecycle = attachDashboardRefreshLifecycle({
|
|
||||||
documentTarget: document,
|
|
||||||
windowTarget: window,
|
|
||||||
loadDashboard,
|
|
||||||
pauseRefreshes,
|
|
||||||
refreshPaused: () => cancelled || refreshPaused(),
|
|
||||||
});
|
|
||||||
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearRefreshTimer();
|
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||||
requestAborter.abortActiveRequests();
|
|
||||||
detachRefreshLifecycle();
|
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -326,15 +275,6 @@ function getThemeStorage(): Storage | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAbortError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === "object" &&
|
|
||||||
error !== null &&
|
|
||||||
"name" in error &&
|
|
||||||
error.name === "AbortError"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function markHydratingItems(
|
function markHydratingItems(
|
||||||
dashboard: UiDashboardPreview,
|
dashboard: UiDashboardPreview,
|
||||||
hydratingItemIds?: ReadonlySet<string>,
|
hydratingItemIds?: ReadonlySet<string>,
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import {
|
|
||||||
attachDashboardRefreshLifecycle,
|
|
||||||
createDashboardRequestAborter,
|
|
||||||
shouldPauseDashboardRefresh,
|
|
||||||
} from "./dashboard-refresh";
|
|
||||||
|
|
||||||
describe("dashboard refresh lifecycle", () => {
|
|
||||||
test("pauses refreshes when the document is hidden or the browser is offline", () => {
|
|
||||||
expect(
|
|
||||||
shouldPauseDashboardRefresh({ visibilityState: "visible", online: true }),
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
shouldPauseDashboardRefresh({ visibilityState: "hidden", online: true }),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
shouldPauseDashboardRefresh({ visibilityState: "visible", online: false }),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aborts shell and tile requests when a new shell run starts", () => {
|
|
||||||
const aborter = createDashboardRequestAborter();
|
|
||||||
const shellSignal = aborter.beginShellRun();
|
|
||||||
const tileSignal = aborter.beginTileRun();
|
|
||||||
|
|
||||||
const nextShellSignal = aborter.beginShellRun();
|
|
||||||
|
|
||||||
expect(shellSignal.aborted).toBe(true);
|
|
||||||
expect(tileSignal.aborted).toBe(true);
|
|
||||||
expect(nextShellSignal.aborted).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("aborts active requests when refreshes are paused", () => {
|
|
||||||
const aborter = createDashboardRequestAborter();
|
|
||||||
const shellSignal = aborter.beginShellRun();
|
|
||||||
const tileSignal = aborter.beginTileRun();
|
|
||||||
|
|
||||||
aborter.abortActiveRequests();
|
|
||||||
|
|
||||||
expect(shellSignal.aborted).toBe(true);
|
|
||||||
expect(tileSignal.aborted).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("pauses on hidden, offline, and pagehide events then resumes immediately when visible", () => {
|
|
||||||
const documentTarget = new EventTarget();
|
|
||||||
const windowTarget = new EventTarget();
|
|
||||||
const calls: string[] = [];
|
|
||||||
let paused = true;
|
|
||||||
|
|
||||||
const detach = attachDashboardRefreshLifecycle({
|
|
||||||
documentTarget,
|
|
||||||
windowTarget,
|
|
||||||
loadDashboard: () => {
|
|
||||||
calls.push("load");
|
|
||||||
},
|
|
||||||
pauseRefreshes: () => {
|
|
||||||
calls.push("pause");
|
|
||||||
},
|
|
||||||
refreshPaused: () => paused,
|
|
||||||
});
|
|
||||||
|
|
||||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
windowTarget.dispatchEvent(new Event("offline"));
|
|
||||||
windowTarget.dispatchEvent(new Event("pagehide"));
|
|
||||||
|
|
||||||
paused = false;
|
|
||||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
windowTarget.dispatchEvent(new Event("online"));
|
|
||||||
|
|
||||||
detach();
|
|
||||||
documentTarget.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
windowTarget.dispatchEvent(new Event("online"));
|
|
||||||
|
|
||||||
expect(calls).toEqual(["pause", "pause", "pause", "load", "load"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
export interface DashboardRefreshPauseState {
|
|
||||||
visibilityState: DocumentVisibilityState;
|
|
||||||
online: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shouldPauseDashboardRefresh(
|
|
||||||
state: DashboardRefreshPauseState,
|
|
||||||
): boolean {
|
|
||||||
return state.visibilityState !== "visible" || !state.online;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDashboardRequestAborter() {
|
|
||||||
let shellController: AbortController | undefined;
|
|
||||||
let tileController: AbortController | undefined;
|
|
||||||
|
|
||||||
function abortController(controller: AbortController | undefined) {
|
|
||||||
if (controller && !controller.signal.aborted) {
|
|
||||||
controller.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
beginShellRun(): AbortSignal {
|
|
||||||
abortController(shellController);
|
|
||||||
abortController(tileController);
|
|
||||||
shellController = new AbortController();
|
|
||||||
tileController = undefined;
|
|
||||||
return shellController.signal;
|
|
||||||
},
|
|
||||||
beginTileRun(): AbortSignal {
|
|
||||||
abortController(tileController);
|
|
||||||
tileController = new AbortController();
|
|
||||||
return tileController.signal;
|
|
||||||
},
|
|
||||||
abortActiveRequests(): void {
|
|
||||||
abortController(shellController);
|
|
||||||
abortController(tileController);
|
|
||||||
shellController = undefined;
|
|
||||||
tileController = undefined;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type DashboardRefreshEventTarget = Pick<
|
|
||||||
EventTarget,
|
|
||||||
"addEventListener" | "removeEventListener"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export interface DashboardRefreshLifecycleOptions {
|
|
||||||
documentTarget: DashboardRefreshEventTarget;
|
|
||||||
windowTarget: DashboardRefreshEventTarget;
|
|
||||||
loadDashboard: () => void | Promise<void>;
|
|
||||||
pauseRefreshes: () => void;
|
|
||||||
refreshPaused: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function attachDashboardRefreshLifecycle(
|
|
||||||
options: DashboardRefreshLifecycleOptions,
|
|
||||||
): () => void {
|
|
||||||
function handleRefreshLifecycleChange() {
|
|
||||||
if (options.refreshPaused()) {
|
|
||||||
options.pauseRefreshes();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void options.loadDashboard();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePageHide() {
|
|
||||||
options.pauseRefreshes();
|
|
||||||
}
|
|
||||||
|
|
||||||
options.documentTarget.addEventListener(
|
|
||||||
"visibilitychange",
|
|
||||||
handleRefreshLifecycleChange,
|
|
||||||
);
|
|
||||||
options.windowTarget.addEventListener("online", handleRefreshLifecycleChange);
|
|
||||||
options.windowTarget.addEventListener("offline", handleRefreshLifecycleChange);
|
|
||||||
options.windowTarget.addEventListener("pagehide", handlePageHide);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
options.documentTarget.removeEventListener(
|
|
||||||
"visibilitychange",
|
|
||||||
handleRefreshLifecycleChange,
|
|
||||||
);
|
|
||||||
options.windowTarget.removeEventListener("online", handleRefreshLifecycleChange);
|
|
||||||
options.windowTarget.removeEventListener("offline", handleRefreshLifecycleChange);
|
|
||||||
options.windowTarget.removeEventListener("pagehide", handlePageHide);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue