feat(web): hydrate dashboard tiles independently

This commit is contained in:
vince 2026-06-20 12:21:36 +02:00
parent 021f2cf20e
commit 23c8f0964c
7 changed files with 642 additions and 20 deletions

View file

@ -1,4 +1,11 @@
import { useEffect, useState } from "react";
import type {
DashboardDocument,
DashboardModule,
ServiceEntry,
StatusItem,
TelemetryCard,
} from "@dimensionlab/dashboard-model";
import type { DashboardRuntimeState } from "$lib/server/dashboard";
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
import {
@ -9,21 +16,35 @@ import {
resolveInitialUiTheme,
type UiTheme,
type UiSeverity,
type UiDashboardPreview,
} from "@dimensionlab/ui";
const loadingDashboardState: DashboardRuntimeState = {
state: "loading",
title: "Loading Dashboard",
subtitle: "Fetching active model",
message: "Waiting for the active dashboard document.",
};
type DashboardTileReference =
| { kind: "telemetry"; id: string }
| { kind: "service"; 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;
};
export function AppStateView({
dashboard,
onThemeChange,
theme,
hydratingItemIds,
}: {
dashboard: DashboardRuntimeState;
hydratingItemIds?: ReadonlySet<string>;
onThemeChange?: (theme: UiTheme) => void;
theme?: UiTheme;
}) {
@ -33,9 +54,14 @@ export function AppStateView({
) : null;
if (dashboard.state === "ready") {
const uiDashboard = markHydratingItems(
dashboardDocumentToUiDashboard(dashboard.document),
hydratingItemIds,
);
return (
<DashboardFrame
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
dashboard={uiDashboard}
actions={themeToggle}
/>
);
@ -89,7 +115,10 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
export default function App() {
const [dashboard, setDashboard] =
useState<DashboardRuntimeState>(loadingDashboardState);
useState<DashboardRuntimeState | null>(null);
const [hydratingItemIds, setHydratingItemIds] = useState<Set<string>>(
() => new Set(),
);
const [theme, setTheme] = useState<UiTheme>(() => {
if (typeof window === "undefined") return "dark";
@ -97,6 +126,8 @@ export default function App() {
});
useEffect(() => {
if (!dashboard) return;
const metadata = resolveDocumentMetadata(dashboard);
document.title = metadata.title;
@ -119,6 +150,7 @@ export default function App() {
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;
let hydrationRun = 0;
async function loadDashboard() {
const response = await fetch("/api/dashboard");
@ -127,6 +159,18 @@ export default function App() {
if (cancelled) return;
setDashboard(nextDashboard);
const currentRun = ++hydrationRun;
if (
nextDashboard.state === "ready" &&
nextDashboard.liveDatasourceHydration?.enabled !== false
) {
const tiles = dashboardHydrationTiles(nextDashboard.document);
setHydratingItemIds(new Set(tiles.map(dashboardTileKey)));
hydrateDashboardTiles(tiles, currentRun);
} else {
setHydratingItemIds(new Set());
}
if (refreshTimer) {
window.clearInterval(refreshTimer);
refreshTimer = undefined;
@ -145,6 +189,54 @@ export default function App() {
}
}
function hydrateDashboardTiles(
tiles: DashboardTileReference[],
run: number,
) {
tiles.forEach((tile) => {
void hydrateDashboardTile(tile, run);
});
}
async function hydrateDashboardTile(
tile: DashboardTileReference,
run: number,
) {
const key = dashboardTileKey(tile);
try {
const response = await fetch(dashboardTileUrl(tile));
const tileResponse = (await response.json()) as DashboardTileResponse;
if (
cancelled ||
run !== hydrationRun ||
!response.ok ||
tileResponse.state !== "ready"
) {
return;
}
const readyTileResponse = tileResponse;
setDashboard((current) =>
current?.state === "ready"
? {
...current,
document: applyDashboardTile(current.document, readyTileResponse),
}
: current,
);
} finally {
if (!cancelled && run === hydrationRun) {
setHydratingItemIds((current) => {
const next = new Set(current);
next.delete(key);
return next;
});
}
}
}
void loadDashboard();
return () => {
@ -153,13 +245,14 @@ export default function App() {
};
}, []);
return (
return dashboard ? (
<AppStateView
dashboard={dashboard}
hydratingItemIds={hydratingItemIds}
theme={theme}
onThemeChange={setTheme}
/>
);
) : null;
}
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
@ -181,3 +274,144 @@ function getThemeStorage(): Storage | undefined {
return undefined;
}
}
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:${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,
),
};
}
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", 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
.filter((item) => item.id !== "auto-refresh")
.map((item): DashboardTileReference => ({
kind: "status",
stripId: strip.id,
id: item.id,
})),
);
return [...telemetry, ...services, ...modules, ...status];
}
function dashboardTileKey(tile: DashboardTileReference): string {
return tile.kind === "status"
? `${tile.kind}:${tile.stripId}:${tile.id}`
: `${tile.kind}:${tile.id}`;
}
function dashboardTileUrl(tile: DashboardTileReference): string {
const parts = tile.kind === "status"
? ["api", "dashboard", "tile", tile.kind, tile.stripId, 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") {
return {
...document,
serviceGroups: document.serviceGroups.map((group) => ({
...group,
services: group.services.map((service) =>
service.id === response.tile.id ? response.item as ServiceEntry : service,
),
})),
};
}
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,
),
};
}