602 lines
16 KiB
TypeScript
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,
|
|
),
|
|
};
|
|
}
|