dimensionlab-website/apps/web/src/lib/client/dashboard-refresh.ts

392 lines
11 KiB
TypeScript

export interface DashboardRefreshPauseState {
visibilityState: DocumentVisibilityState;
online: boolean;
}
export type DashboardTileReference =
| { kind: "telemetry"; id: string }
| { kind: "service"; groupId: string; id: string }
| { kind: "module"; id: string }
| { kind: "status"; stripId: string; id: string };
type SnapshotMetricValue = Record<string, unknown>;
export type DashboardTileSnapshotItem = {
detail?: string;
id: string;
label?: string;
severity?: string;
value?: SnapshotMetricValue | string;
} & Record<string, unknown>;
export type DashboardTileSnapshotResponse =
| {
state: "ready";
tile: DashboardTileReference;
item: DashboardTileSnapshotItem;
}
| {
state: "not_found";
tile: DashboardTileReference;
message: string;
}
| {
state: "disabled";
tile: DashboardTileReference;
message: string;
};
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);
};
}
export interface DashboardHydrationQueueOptions<TItem> {
concurrency: number;
hydrate: (item: TItem) => Promise<void> | void;
items: TItem[];
signal: AbortSignal;
}
export async function runDashboardHydrationQueue<TItem>(
options: DashboardHydrationQueueOptions<TItem>,
): Promise<void> {
const concurrency = Math.max(1, Math.floor(options.concurrency));
let nextIndex = 0;
async function worker() {
while (!options.signal.aborted) {
const item = options.items[nextIndex];
nextIndex += 1;
if (item === undefined) return;
await options.hydrate(item);
}
}
const workerCount = Math.min(concurrency, options.items.length);
await Promise.all(
Array.from({ length: workerCount }, () => worker()),
);
}
const dashboardTileBackoffDelaysMs = [15_000, 30_000, 60_000, 120_000];
export function createDashboardTileBackoff() {
const failures = new Map<string, { attempts: number; nextAttemptAt: number }>();
return {
canAttempt(key: string, now = Date.now()): boolean {
const failure = failures.get(key);
return !failure || now >= failure.nextAttemptAt;
},
recordFailure(key: string, now = Date.now()): void {
const previousAttempts = failures.get(key)?.attempts || 0;
const attempts = previousAttempts + 1;
const delay =
dashboardTileBackoffDelaysMs[
Math.min(attempts - 1, dashboardTileBackoffDelaysMs.length - 1)
];
failures.set(key, {
attempts,
nextAttemptAt: now + delay,
});
},
recordSuccess(key: string): void {
failures.delete(key);
},
};
}
interface DashboardTileSnapshotRecord {
item: DashboardTileSnapshotItem;
savedAt: number;
tile: DashboardTileReference;
}
interface DashboardTileSnapshotPayload {
currentRevisionId: string;
schemaVersion: string;
tiles: DashboardTileSnapshotRecord[];
version: 1;
}
export interface DashboardTileSnapshotStoreContext {
currentRevisionId: string;
schemaVersion: string;
}
export interface DashboardTileSnapshotStoreOptions {
now?: () => number;
}
export interface RestoredDashboardTileSnapshot {
ageMs: number;
response: Extract<DashboardTileSnapshotResponse, { state: "ready" }>;
}
const dashboardTileSnapshotStorageKey = "dimensionlab.dashboard.tiles.v1";
export function createDashboardTileSnapshotStore(
storage: Storage | undefined,
options: DashboardTileSnapshotStoreOptions = {},
) {
const now = options.now || Date.now;
function read(): DashboardTileSnapshotPayload | null {
if (!storage) return null;
try {
const serialized = storage.getItem(dashboardTileSnapshotStorageKey);
if (!serialized) return null;
const payload = JSON.parse(serialized) as Partial<DashboardTileSnapshotPayload>;
if (
payload.version !== 1 ||
typeof payload.currentRevisionId !== "string" ||
typeof payload.schemaVersion !== "string" ||
!Array.isArray(payload.tiles)
) {
return null;
}
return {
currentRevisionId: payload.currentRevisionId,
schemaVersion: payload.schemaVersion,
tiles: payload.tiles.filter(isDashboardTileSnapshotRecord),
version: 1,
};
} catch {
return null;
}
}
function write(payload: DashboardTileSnapshotPayload): void {
if (!storage) return;
try {
storage.setItem(dashboardTileSnapshotStorageKey, JSON.stringify(payload));
} catch {
// Best-effort warm-start cache; quota and privacy failures are non-fatal.
}
}
function matchingPayload(
context: DashboardTileSnapshotStoreContext,
): DashboardTileSnapshotPayload {
const payload = read();
if (
payload &&
payload.currentRevisionId === context.currentRevisionId &&
payload.schemaVersion === context.schemaVersion
) {
return payload;
}
return {
currentRevisionId: context.currentRevisionId,
schemaVersion: context.schemaVersion,
tiles: [],
version: 1,
};
}
function saveReadyTile(
input: DashboardTileSnapshotStoreContext & {
response: Extract<DashboardTileSnapshotResponse, { state: "ready" }>;
},
): void {
const payload = matchingPayload(input);
const key = dashboardTileSnapshotKey(input.response.tile);
const nextRecord: DashboardTileSnapshotRecord = {
item: input.response.item,
savedAt: now(),
tile: input.response.tile,
};
payload.tiles = [
nextRecord,
...payload.tiles.filter((record) =>
dashboardTileSnapshotKey(record.tile) !== key
),
];
write(payload);
}
return {
restore(context: DashboardTileSnapshotStoreContext): RestoredDashboardTileSnapshot[] {
const payload = read();
if (
!payload ||
payload.currentRevisionId !== context.currentRevisionId ||
payload.schemaVersion !== context.schemaVersion
) {
return [];
}
const restoredAt = now();
return payload.tiles.map((record) => ({
ageMs: Math.max(0, restoredAt - record.savedAt),
response: {
state: "ready",
tile: record.tile,
item: {
...record.item,
detail: staleDashboardTileDetail(record.item.detail, restoredAt - record.savedAt),
severity: "stale",
},
},
}));
},
saveReadyTile,
saveTile(input: DashboardTileSnapshotStoreContext & {
response: DashboardTileSnapshotResponse;
}): void {
if (input.response.state === "ready") {
saveReadyTile({
currentRevisionId: input.currentRevisionId,
response: input.response,
schemaVersion: input.schemaVersion,
});
}
},
};
}
function dashboardTileSnapshotKey(tile: DashboardTileReference): string {
return JSON.stringify(tile);
}
function isDashboardTileSnapshotRecord(
value: unknown,
): value is DashboardTileSnapshotRecord {
if (!isSnapshotRecord(value)) return false;
return (
typeof value.savedAt === "number" &&
Number.isFinite(value.savedAt) &&
isDashboardTileReference(value.tile) &&
isDashboardTileSnapshotItem(value.item)
);
}
function isDashboardTileReference(
value: unknown,
): value is DashboardTileReference {
if (!isSnapshotRecord(value) || typeof value.kind !== "string") return false;
switch (value.kind) {
case "telemetry":
case "module":
return typeof value.id === "string";
case "service":
return typeof value.groupId === "string" && typeof value.id === "string";
case "status":
return typeof value.stripId === "string" && typeof value.id === "string";
default:
return false;
}
}
function isDashboardTileSnapshotItem(
value: unknown,
): value is DashboardTileSnapshotItem {
return (
isSnapshotRecord(value) &&
typeof value.id === "string" &&
(value.detail === undefined || typeof value.detail === "string")
);
}
function isSnapshotRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function staleDashboardTileDetail(
detail: string | undefined,
ageMs: number,
): string | undefined {
if (!detail) return detail;
const ageSeconds = Math.max(0, Math.floor(ageMs / 1_000));
return ageSeconds > 0 ? `${detail} - stale ${ageSeconds}s` : detail;
}