dimensionlab-website/apps/web/src/App.tsx

602 lines
16 KiB
TypeScript

import { useEffect, useState } from "react";
import type {
DashboardDocument,
DashboardModule,
ServiceEntry,
StatusItem,
TelemetryCard,
} from "@dimensionlab/dashboard-model";
import type { DashboardRuntimeState } from "$lib/server/dashboard";
import {
attachDashboardRefreshLifecycle,
createDashboardTileSnapshotStore,
createDashboardTileBackoff,
createDashboardRequestAborter,
runDashboardHydrationQueue,
shouldPauseDashboardRefresh,
type DashboardTileSnapshotStoreContext,
type RestoredDashboardTileSnapshot,
} from "$lib/client/dashboard-refresh";
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
import {
DashboardFrame,
SystemState,
ThemeToggle,
persistUiTheme,
resolveInitialUiTheme,
type UiTheme,
type UiSeverity,
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";
tile: DashboardTileReference;
item: DashboardModule | ServiceEntry | StatusItem | TelemetryCard;
}
| {
state: "not_found";
tile: DashboardTileReference;
message: string;
}
| {
state: "disabled";
tile: DashboardTileReference;
message: string;
};
type DashboardTileHydrationResult = "aborted" | "failed" | "ready";
const dashboardTileHydrationConcurrency = 6;
type DashboardTileSnapshotStore = ReturnType<typeof createDashboardTileSnapshotStore>;
export function AppStateView({
dashboard,
onThemeChange,
theme,
hydratingItemIds,
}: {
dashboard: DashboardRuntimeState;
hydratingItemIds?: ReadonlySet<string>;
onThemeChange?: (theme: UiTheme) => void;
theme?: UiTheme;
}) {
const themeToggle =
theme && onThemeChange ? (
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
) : null;
if (dashboard.state === "ready") {
const uiDashboard = markHydratingItems(
dashboardDocumentToUiDashboard(dashboard.document),
hydratingItemIds,
);
return (
<DashboardFrame
dashboard={uiDashboard}
actions={themeToggle}
/>
);
}
const detail = `${dashboard.subtitle}: ${dashboard.message}`;
const errors = dashboard.state === "invalid" ? dashboard.errors : [];
return (
<main className="state-shell" data-dashboard-state={dashboard.state}>
{themeToggle ? (
<div className="state-shell__actions">{themeToggle}</div>
) : null}
<SystemState
title={dashboard.title}
detail={detail}
severity={stateSeverity(dashboard.state)}
icon={stateIcon(dashboard.state)}
/>
{errors.length ? (
<ul aria-label="Validation errors">
{errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null}
</main>
);
}
export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
description: string;
title: string;
} {
if (dashboard.state === "ready") {
const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document);
return {
title: uiDashboard.title,
description:
uiDashboard.subtitle ||
dashboard.document.metadata.description ||
uiDashboard.title,
};
}
return {
title: dashboard.title,
description: dashboard.subtitle || dashboard.message,
};
}
export default function App() {
const [dashboard, setDashboard] =
useState<DashboardRuntimeState | null>(null);
const [hydratingItemIds, setHydratingItemIds] = useState<Set<string>>(
() => new Set(),
);
const [theme, setTheme] = useState<UiTheme>(() => {
if (typeof window === "undefined") return "dark";
return resolveInitialUiTheme(getThemeStorage());
});
useEffect(() => {
if (!dashboard) return;
const metadata = resolveDocumentMetadata(dashboard);
document.title = metadata.title;
let description = document.querySelector<HTMLMetaElement>(
'meta[name="description"]',
);
if (!description) {
description = document.createElement("meta");
description.name = "description";
document.head.append(description);
}
description.content = metadata.description;
}, [dashboard]);
useEffect(() => {
document.documentElement.dataset.uiTheme = theme;
persistUiTheme(theme, getThemeStorage());
}, [theme]);
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;
let hydrationRun = 0;
const requestAborter = createDashboardRequestAborter();
const tileBackoff = createDashboardTileBackoff();
const tileSnapshotStore = createDashboardTileSnapshotStore(
getTileSnapshotStorage(),
);
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() {
if (cancelled || refreshPaused()) return;
const shellSignal = requestAborter.beginShellRun();
try {
const response = await fetch("/api/dashboard", { signal: shellSignal });
const nextDashboard = (await response.json()) as DashboardRuntimeState;
if (cancelled || shellSignal.aborted || refreshPaused()) return;
const restored = restoreDashboardTileSnapshots(
nextDashboard,
nextDashboard.state === "ready"
? tileSnapshotStore.restore({
currentRevisionId: nextDashboard.currentRevisionId,
schemaVersion: nextDashboard.schemaVersion,
})
: [],
);
setDashboard(restored.dashboard);
const currentRun = ++hydrationRun;
if (
restored.dashboard.state === "ready" &&
restored.dashboard.liveDatasourceHydration?.enabled !== false
) {
const tiles = dashboardHydrationTiles(restored.dashboard.document).filter(
(tile) => tileBackoff.canAttempt(dashboardTileKey(tile)),
);
const tileSignal = requestAborter.beginTileRun();
setHydratingItemIds(
new Set(
tiles
.map(dashboardTileKey)
.filter((key) => !restored.restoredItemIds.has(key)),
),
);
hydrateDashboardTiles(
tiles,
currentRun,
tileSignal,
{
currentRevisionId: restored.dashboard.currentRevisionId,
schemaVersion: restored.dashboard.schemaVersion,
},
);
} else {
setHydratingItemIds(new Set());
}
clearRefreshTimer();
const refreshIntervalSeconds =
nextDashboard.state === "ready"
? nextDashboard.document.metadata.refreshIntervalSeconds
: undefined;
if (refreshIntervalSeconds && !refreshPaused()) {
refreshTimer = window.setInterval(
() => void loadDashboard(),
refreshIntervalSeconds * 1000,
);
}
} catch (error) {
if (!isAbortError(error) && !cancelled) {
console.error("Dashboard refresh failed", error);
}
}
}
function hydrateDashboardTiles(
tiles: DashboardTileReference[],
run: number,
signal: AbortSignal,
snapshotContext: DashboardTileSnapshotStoreContext,
) {
void runDashboardHydrationQueue({
concurrency: dashboardTileHydrationConcurrency,
hydrate: async (tile) => {
const key = dashboardTileKey(tile);
const result = await hydrateDashboardTile(
tile,
run,
signal,
snapshotContext,
);
if (result === "ready") {
tileBackoff.recordSuccess(key);
} else if (result === "failed") {
tileBackoff.recordFailure(key);
}
},
items: tiles,
signal,
});
}
async function hydrateDashboardTile(
tile: DashboardTileReference,
run: number,
signal: AbortSignal,
snapshotContext: DashboardTileSnapshotStoreContext,
): Promise<DashboardTileHydrationResult> {
const key = dashboardTileKey(tile);
try {
const response = await fetch(dashboardTileUrl(tile), { signal });
const tileResponse = (await response.json()) as DashboardTileResponse;
if (
cancelled ||
signal.aborted ||
run !== hydrationRun ||
!response.ok ||
tileResponse.state !== "ready"
) {
return signal.aborted || cancelled || run !== hydrationRun
? "aborted"
: "failed";
}
const readyTileResponse = tileResponse;
setDashboard((current) =>
current?.state === "ready"
? {
...current,
document: applyDashboardTile(current.document, readyTileResponse),
}
: current,
);
tileSnapshotStore.saveReadyTile({
...snapshotContext,
response: readyTileResponse,
});
return "ready";
} catch (error) {
if (!isAbortError(error) && !cancelled) {
console.error("Dashboard tile hydration failed", error);
return "failed";
}
return "aborted";
} finally {
if (!cancelled && !signal.aborted && run === hydrationRun) {
setHydratingItemIds((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
}
const detachRefreshLifecycle = attachDashboardRefreshLifecycle({
documentTarget: document,
windowTarget: window,
loadDashboard,
pauseRefreshes,
refreshPaused: () => cancelled || refreshPaused(),
});
void loadDashboard();
return () => {
cancelled = true;
clearRefreshTimer();
requestAborter.abortActiveRequests();
detachRefreshLifecycle();
};
}, []);
return dashboard ? (
<AppStateView
dashboard={dashboard}
hydratingItemIds={hydratingItemIds}
theme={theme}
onThemeChange={setTheme}
/>
) : null;
}
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
if (state === "invalid") return "danger";
if (state === "loading") return "loading";
return "stale";
}
function stateIcon(state: DashboardRuntimeState["state"]): string {
if (state === "invalid") return "mdi:file-alert-outline";
if (state === "loading") return "mdi:progress-clock";
return "mdi:tray";
}
function getThemeStorage(): Storage | undefined {
try {
return window.localStorage;
} catch {
return undefined;
}
}
function getTileSnapshotStorage(): Storage | undefined {
try {
return window.localStorage;
} catch {
return undefined;
}
}
function isAbortError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
error.name === "AbortError"
);
}
export function restoreDashboardTileSnapshots(
dashboard: DashboardRuntimeState,
snapshots: RestoredDashboardTileSnapshot[],
): {
dashboard: DashboardRuntimeState;
restoredItemIds: Set<string>;
} {
if (dashboard.state !== "ready" || snapshots.length === 0) {
return {
dashboard,
restoredItemIds: new Set(),
};
}
return snapshots.reduce(
(current, snapshot) => ({
dashboard: {
...current.dashboard,
document: applyDashboardTile(
current.dashboard.document,
snapshot.response as Extract<DashboardTileResponse, { state: "ready" }>,
),
},
restoredItemIds: new Set([
...current.restoredItemIds,
dashboardTileKey(snapshot.response.tile),
]),
}),
{
dashboard,
restoredItemIds: new Set<string>(),
},
);
}
function markHydratingItems(
dashboard: UiDashboardPreview,
hydratingItemIds?: ReadonlySet<string>,
): UiDashboardPreview {
if (!hydratingItemIds?.size) return dashboard;
return {
...dashboard,
telemetry: dashboard.telemetry.map((card) =>
hydratingItemIds.has(`telemetry:${card.id}`)
? {
...card,
severity: "loading",
detail: "loading live telemetry",
}
: card,
),
serviceGroups: dashboard.serviceGroups.map((group) => ({
...group,
services: group.services.map((service) =>
hydratingItemIds.has(`service:${group.id}:${service.id}`)
? {
...service,
severity: "loading",
detail: "loading",
}
: service,
),
})),
modules: dashboard.modules.map((module) =>
hydratingItemIds.has(`module:${module.id}`)
? {
...module,
severity: "loading",
detail: "loading live data",
}
: module,
),
statusItems: dashboard.statusItems.map((item) =>
hydratingItemIds.has(`status:${item.id}`)
? {
...item,
severity: "loading",
value: "loading",
}
: item,
),
};
}
export function dashboardHydrationTiles(
document: DashboardDocument,
): DashboardTileReference[] {
const telemetry = document.telemetry
.filter((card) => card.datasource?.type === "external")
.map((card): DashboardTileReference => ({ kind: "telemetry", id: card.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,
})),
);
const modules = (document.modules || [])
.filter((module) =>
module.datasource?.type === "external" ||
module.id === "runtime-health-summary"
)
.map((module): DashboardTileReference => ({ kind: "module", id: module.id }));
const status = document.statusStrips.flatMap((strip) =>
strip.items
.map((item): DashboardTileReference => ({
kind: "status",
stripId: strip.id,
id: item.id,
})),
);
return [...status, ...telemetry, ...modules, ...services];
}
function dashboardTileKey(tile: DashboardTileReference): string {
if (tile.kind === "status") return `${tile.kind}:${tile.stripId}:${tile.id}`;
if (tile.kind === "service") return `${tile.kind}:${tile.groupId}:${tile.id}`;
return `${tile.kind}:${tile.id}`;
}
function dashboardTileUrl(tile: DashboardTileReference): string {
const parts = tile.kind === "status"
? ["api", "dashboard", "tile", tile.kind, tile.stripId, tile.id]
: tile.kind === "service"
? ["api", "dashboard", "tile", tile.kind, tile.groupId, tile.id]
: ["api", "dashboard", "tile", tile.kind, tile.id];
return `/${parts.map(encodeURIComponent).join("/")}`;
}
function applyDashboardTile(
document: DashboardDocument,
response: Extract<DashboardTileResponse, { state: "ready" }>,
): DashboardDocument {
if (response.tile.kind === "telemetry") {
return {
...document,
telemetry: document.telemetry.map((card) =>
card.id === response.tile.id ? response.item as TelemetryCard : card,
),
};
}
if (response.tile.kind === "service") {
const tile = response.tile;
return {
...document,
serviceGroups: document.serviceGroups.map((group) => ({
...group,
services: group.id === tile.groupId
? group.services.map((service) =>
service.id === tile.id ? response.item as ServiceEntry : service,
)
: group.services,
})),
};
}
if (response.tile.kind === "module") {
return {
...document,
modules: (document.modules || []).map((module) =>
module.id === response.tile.id ? response.item as DashboardModule : module,
),
};
}
const tile = response.tile;
return {
...document,
statusStrips: document.statusStrips.map((strip) =>
strip.id === tile.stripId
? {
...strip,
items: strip.items.map((item) =>
item.id === tile.id ? response.item as StatusItem : item,
),
}
: strip,
),
};
}