feat(web): hydrate dashboard tiles independently
This commit is contained in:
parent
021f2cf20e
commit
23c8f0964c
7 changed files with 642 additions and 20 deletions
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue