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,5 +1,6 @@
|
||||||
import { renderToString } from "react-dom/server";
|
import { renderToString } from "react-dom/server";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||||
import { AppStateView } from "./App";
|
import { AppStateView } from "./App";
|
||||||
|
|
||||||
describe("React app dashboard state view", () => {
|
describe("React app dashboard state view", () => {
|
||||||
|
|
@ -41,4 +42,35 @@ describe("React app dashboard state view", () => {
|
||||||
expect(html).toContain("Dark");
|
expect(html).toContain("Dark");
|
||||||
expect(html).toContain("Light");
|
expect(html).toContain("Light");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders the dashboard shell while individual items hydrate", () => {
|
||||||
|
const html = renderToString(
|
||||||
|
<AppStateView
|
||||||
|
dashboard={{
|
||||||
|
state: "ready",
|
||||||
|
document: genericDashboardFixture,
|
||||||
|
schemaVersion: "dashboard.v1",
|
||||||
|
currentRevisionId: "revision-1234567890",
|
||||||
|
}}
|
||||||
|
hydratingItemIds={new Set([
|
||||||
|
"telemetry:service-uptime",
|
||||||
|
"service:identity",
|
||||||
|
"module:ambient",
|
||||||
|
"status:runtime:status",
|
||||||
|
])}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Operations Console");
|
||||||
|
expect(html).toContain("Service Uptime");
|
||||||
|
expect(html).toContain("Identity");
|
||||||
|
expect(html).toContain("Environment");
|
||||||
|
expect(html).not.toContain("Loading Dashboard");
|
||||||
|
expect(html).toContain(
|
||||||
|
'data-severity="loading" data-model-id="service-uptime"',
|
||||||
|
);
|
||||||
|
expect(html).toContain('data-severity="loading" data-model-id="identity"');
|
||||||
|
expect(html).toContain('data-severity="loading" data-model-id="ambient"');
|
||||||
|
expect(html).toContain('data-severity="loading" data-model-id="runtime:status"');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import type {
|
||||||
|
DashboardDocument,
|
||||||
|
DashboardModule,
|
||||||
|
ServiceEntry,
|
||||||
|
StatusItem,
|
||||||
|
TelemetryCard,
|
||||||
|
} from "@dimensionlab/dashboard-model";
|
||||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,21 +16,35 @@ import {
|
||||||
resolveInitialUiTheme,
|
resolveInitialUiTheme,
|
||||||
type UiTheme,
|
type UiTheme,
|
||||||
type UiSeverity,
|
type UiSeverity,
|
||||||
|
type UiDashboardPreview,
|
||||||
} from "@dimensionlab/ui";
|
} from "@dimensionlab/ui";
|
||||||
|
|
||||||
const loadingDashboardState: DashboardRuntimeState = {
|
type DashboardTileReference =
|
||||||
state: "loading",
|
| { kind: "telemetry"; id: string }
|
||||||
title: "Loading Dashboard",
|
| { kind: "service"; id: string }
|
||||||
subtitle: "Fetching active model",
|
| { kind: "module"; id: string }
|
||||||
message: "Waiting for the active dashboard document.",
|
| { 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({
|
export function AppStateView({
|
||||||
dashboard,
|
dashboard,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
theme,
|
theme,
|
||||||
|
hydratingItemIds,
|
||||||
}: {
|
}: {
|
||||||
dashboard: DashboardRuntimeState;
|
dashboard: DashboardRuntimeState;
|
||||||
|
hydratingItemIds?: ReadonlySet<string>;
|
||||||
onThemeChange?: (theme: UiTheme) => void;
|
onThemeChange?: (theme: UiTheme) => void;
|
||||||
theme?: UiTheme;
|
theme?: UiTheme;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -33,9 +54,14 @@ export function AppStateView({
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (dashboard.state === "ready") {
|
if (dashboard.state === "ready") {
|
||||||
|
const uiDashboard = markHydratingItems(
|
||||||
|
dashboardDocumentToUiDashboard(dashboard.document),
|
||||||
|
hydratingItemIds,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardFrame
|
<DashboardFrame
|
||||||
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
dashboard={uiDashboard}
|
||||||
actions={themeToggle}
|
actions={themeToggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -89,7 +115,10 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [dashboard, setDashboard] =
|
const [dashboard, setDashboard] =
|
||||||
useState<DashboardRuntimeState>(loadingDashboardState);
|
useState<DashboardRuntimeState | null>(null);
|
||||||
|
const [hydratingItemIds, setHydratingItemIds] = useState<Set<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
const [theme, setTheme] = useState<UiTheme>(() => {
|
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||||
if (typeof window === "undefined") return "dark";
|
if (typeof window === "undefined") return "dark";
|
||||||
|
|
||||||
|
|
@ -97,6 +126,8 @@ export default function App() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!dashboard) return;
|
||||||
|
|
||||||
const metadata = resolveDocumentMetadata(dashboard);
|
const metadata = resolveDocumentMetadata(dashboard);
|
||||||
document.title = metadata.title;
|
document.title = metadata.title;
|
||||||
|
|
||||||
|
|
@ -119,6 +150,7 @@ export default function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let refreshTimer: number | undefined;
|
let refreshTimer: number | undefined;
|
||||||
|
let hydrationRun = 0;
|
||||||
|
|
||||||
async function loadDashboard() {
|
async function loadDashboard() {
|
||||||
const response = await fetch("/api/dashboard");
|
const response = await fetch("/api/dashboard");
|
||||||
|
|
@ -127,6 +159,18 @@ export default function App() {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDashboard(nextDashboard);
|
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) {
|
if (refreshTimer) {
|
||||||
window.clearInterval(refreshTimer);
|
window.clearInterval(refreshTimer);
|
||||||
refreshTimer = undefined;
|
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();
|
void loadDashboard();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -153,13 +245,14 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return dashboard ? (
|
||||||
<AppStateView
|
<AppStateView
|
||||||
dashboard={dashboard}
|
dashboard={dashboard}
|
||||||
|
hydratingItemIds={hydratingItemIds}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onThemeChange={setTheme}
|
onThemeChange={setTheme}
|
||||||
/>
|
/>
|
||||||
);
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
|
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
|
||||||
|
|
@ -181,3 +274,144 @@ function getThemeStorage(): Storage | undefined {
|
||||||
return 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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ export interface DashboardRuntimeReady {
|
||||||
document: DashboardDocument;
|
document: DashboardDocument;
|
||||||
schemaVersion: string;
|
schemaVersion: string;
|
||||||
currentRevisionId: string;
|
currentRevisionId: string;
|
||||||
|
liveDatasourceHydration?: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardRuntimeEmpty {
|
export interface DashboardRuntimeEmpty {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,30 @@ import type {
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "@dimensionlab/dashboard-model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
|
|
||||||
|
export type DashboardTileReference =
|
||||||
|
| { kind: "telemetry"; id: string }
|
||||||
|
| { kind: "service"; id: string }
|
||||||
|
| { kind: "module"; id: string }
|
||||||
|
| { kind: "status"; stripId: string; id: string };
|
||||||
|
|
||||||
|
export type DashboardTileItem =
|
||||||
|
| DashboardModule
|
||||||
|
| ServiceEntry
|
||||||
|
| StatusItem
|
||||||
|
| TelemetryCard;
|
||||||
|
|
||||||
|
export type DashboardTileResolution =
|
||||||
|
| {
|
||||||
|
state: "ready";
|
||||||
|
tile: DashboardTileReference;
|
||||||
|
item: DashboardTileItem;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
state: "not_found";
|
||||||
|
tile: DashboardTileReference;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface DatasourceResolutionOptions {
|
export interface DatasourceResolutionOptions {
|
||||||
fetch?: DatasourceFetch;
|
fetch?: DatasourceFetch;
|
||||||
prometheusBaseUrl?: string;
|
prometheusBaseUrl?: string;
|
||||||
|
|
@ -46,6 +70,69 @@ export async function resolveDashboardDatasources(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveDashboardTile(
|
||||||
|
document: DashboardDocument,
|
||||||
|
tile: DashboardTileReference,
|
||||||
|
options: DatasourceResolutionOptions = {},
|
||||||
|
): Promise<DashboardTileResolution> {
|
||||||
|
const context = datasourceContext(options);
|
||||||
|
|
||||||
|
if (tile.kind === "telemetry") {
|
||||||
|
const card = document.telemetry.find((item) => item.id === tile.id);
|
||||||
|
if (!card) return missingTile(tile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "ready",
|
||||||
|
tile,
|
||||||
|
item: await resolveTelemetryCard(card, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tile.kind === "service") {
|
||||||
|
const service = document.serviceGroups
|
||||||
|
.flatMap((group) => group.services)
|
||||||
|
.find((item) => item.id === tile.id);
|
||||||
|
if (!service) return missingTile(tile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "ready",
|
||||||
|
tile,
|
||||||
|
item: await resolveService(service, context),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tile.kind === "module") {
|
||||||
|
const module = document.modules?.find((item) => item.id === tile.id);
|
||||||
|
if (!module) return missingTile(tile);
|
||||||
|
|
||||||
|
const item = module.id === "runtime-health-summary"
|
||||||
|
? runtimeHealthSummary(
|
||||||
|
module,
|
||||||
|
await Promise.all(
|
||||||
|
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: await resolveModule(module, context);
|
||||||
|
|
||||||
|
return { state: "ready", tile, item };
|
||||||
|
}
|
||||||
|
|
||||||
|
const strip = document.statusStrips.find((item) => item.id === tile.stripId);
|
||||||
|
const statusItem = strip?.items.find((item) => item.id === tile.id);
|
||||||
|
if (!strip || !statusItem) return missingTile(tile);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: "ready",
|
||||||
|
tile,
|
||||||
|
item: await resolveStatusTile(
|
||||||
|
statusItem,
|
||||||
|
document.metadata.refreshIntervalSeconds,
|
||||||
|
document.serviceGroups,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface DatasourceContext {
|
interface DatasourceContext {
|
||||||
fetch: DatasourceFetch;
|
fetch: DatasourceFetch;
|
||||||
prometheusBaseUrl: string;
|
prometheusBaseUrl: string;
|
||||||
|
|
@ -390,6 +477,68 @@ function resolveStatusItem(
|
||||||
return structuredClone(item);
|
return structuredClone(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveStatusTile(
|
||||||
|
item: StatusItem,
|
||||||
|
refreshIntervalSeconds: number | undefined,
|
||||||
|
serviceGroups: ServiceGroup[],
|
||||||
|
context: DatasourceContext,
|
||||||
|
): Promise<StatusItem> {
|
||||||
|
if (item.id === "system-status") {
|
||||||
|
const resolvedGroups = await Promise.all(
|
||||||
|
serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
||||||
|
);
|
||||||
|
const health = serviceHealthSummary(resolvedGroups);
|
||||||
|
return {
|
||||||
|
...structuredClone(item),
|
||||||
|
value: health.value,
|
||||||
|
severity: health.severity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "last-sync") {
|
||||||
|
return {
|
||||||
|
...structuredClone(item),
|
||||||
|
value: "just now",
|
||||||
|
severity: "ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "uptime") {
|
||||||
|
const uptime = await prometheusScalar(
|
||||||
|
'time() - node_boot_time_seconds{job="node",host="linux-infra"}',
|
||||||
|
context,
|
||||||
|
).catch(() => null);
|
||||||
|
return uptime === null
|
||||||
|
? structuredClone(item)
|
||||||
|
: {
|
||||||
|
...structuredClone(item),
|
||||||
|
value: formatDuration(uptime),
|
||||||
|
severity: "ok",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "load-avg") {
|
||||||
|
const loadAverage = await prometheusLoadAverage(context).catch(() => null);
|
||||||
|
return loadAverage
|
||||||
|
? {
|
||||||
|
...structuredClone(item),
|
||||||
|
value: loadAverage,
|
||||||
|
severity: "neutral",
|
||||||
|
}
|
||||||
|
: structuredClone(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.id === "auto-refresh" && refreshIntervalSeconds) {
|
||||||
|
return {
|
||||||
|
...structuredClone(item),
|
||||||
|
value: `${refreshIntervalSeconds}s`,
|
||||||
|
severity: "neutral",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return structuredClone(item);
|
||||||
|
}
|
||||||
|
|
||||||
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
|
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
|
||||||
severity: Severity;
|
severity: Severity;
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -673,3 +822,17 @@ function formatDuration(totalSeconds: number): string {
|
||||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||||
return `${days}d ${hours}h ${minutes}m`;
|
return `${days}d ${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function missingTile(tile: DashboardTileReference): DashboardTileResolution {
|
||||||
|
return {
|
||||||
|
state: "not_found",
|
||||||
|
tile,
|
||||||
|
message: `Dashboard tile not found: ${tileKey(tile)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileKey(tile: DashboardTileReference): string {
|
||||||
|
return tile.kind === "status"
|
||||||
|
? `${tile.kind}:${tile.stripId}:${tile.id}`
|
||||||
|
: `${tile.kind}:${tile.id}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { extname, normalize } from "node:path";
|
import { extname, normalize } from "node:path";
|
||||||
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
||||||
import { handleDashboardRoute } from "./routes/dashboard";
|
import { handleDashboardRoute, handleDashboardTileRoute } from "./routes/dashboard";
|
||||||
|
|
||||||
const host = process.env.HOST || "0.0.0.0";
|
const host = process.env.HOST || "0.0.0.0";
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
|
@ -23,6 +23,11 @@ export async function handleRequest(request: Request): Promise<Response> {
|
||||||
return handleDashboardRoute();
|
return handleDashboardRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/api/dashboard/tile/")) {
|
||||||
|
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
||||||
|
return handleDashboardTileRoute(url.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/agent/dashboard") {
|
if (url.pathname === "/api/agent/dashboard") {
|
||||||
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
||||||
return handleAgentDashboardRoute(request);
|
return handleAgentDashboardRoute(request);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import { loadDashboardResponse } from "./dashboard";
|
import { loadDashboardResponse, loadDashboardTileResponse } from "./dashboard";
|
||||||
|
|
||||||
describe("dashboard API route", () => {
|
describe("dashboard API route", () => {
|
||||||
test("returns ready dashboard runtime state from the existing model loader", async () => {
|
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
||||||
|
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||||
|
new Error("live datasource fetch should not run for the shell response"),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await loadDashboardResponse({
|
const response = await loadDashboardResponse({
|
||||||
disableLiveDatasources: true,
|
|
||||||
refreshSeedDocument: true,
|
refreshSeedDocument: true,
|
||||||
seedIfEmpty: true,
|
seedIfEmpty: true,
|
||||||
});
|
});
|
||||||
|
|
@ -15,5 +18,105 @@ describe("dashboard API route", () => {
|
||||||
expect(response.document.metadata.title).toBe(
|
expect(response.document.metadata.title).toBe(
|
||||||
dimensionLabDashboardFixture.metadata.title,
|
dimensionLabDashboardFixture.metadata.title,
|
||||||
);
|
);
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetch.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports when client-side live hydration is disabled", async () => {
|
||||||
|
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
||||||
|
process.env.DISABLE_LIVE_DATASOURCES = "1";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await loadDashboardResponse({
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.state).toBe("ready");
|
||||||
|
if (response.state !== "ready") throw new Error("expected ready dashboard");
|
||||||
|
expect(response.liveDatasourceHydration).toEqual({ enabled: false });
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env.DISABLE_LIVE_DATASOURCES;
|
||||||
|
} else {
|
||||||
|
process.env.DISABLE_LIVE_DATASOURCES = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrates a telemetry tile independently from the dashboard shell", async () => {
|
||||||
|
const fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
|
||||||
|
if (url.startsWith("https://prometheus.example/api/v1/query_range")) {
|
||||||
|
return jsonResponse({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
metric: { host: "linux-infra" },
|
||||||
|
values: [
|
||||||
|
[1771430000, "10"],
|
||||||
|
[1771430060, "20"],
|
||||||
|
[1771430120, "42"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("https://prometheus.example/api/v1/query")) {
|
||||||
|
return jsonResponse({
|
||||||
|
status: "success",
|
||||||
|
data: {
|
||||||
|
result: [
|
||||||
|
{
|
||||||
|
metric: { host: "linux-infra" },
|
||||||
|
value: [1771430400, "42"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unhandled test request: ${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await loadDashboardTileResponse(
|
||||||
|
{ kind: "telemetry", id: "infra-ram" },
|
||||||
|
{
|
||||||
|
fetch,
|
||||||
|
prometheusBaseUrl: "https://prometheus.example",
|
||||||
|
refreshSeedDocument: true,
|
||||||
|
seedIfEmpty: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.state).toBe("ready");
|
||||||
|
if (response.state !== "ready") throw new Error("expected ready tile");
|
||||||
|
expect(response.tile).toEqual({ kind: "telemetry", id: "infra-ram" });
|
||||||
|
expect(response.item).toMatchObject({
|
||||||
|
id: "infra-ram",
|
||||||
|
value: { kind: "percent", value: 42 },
|
||||||
|
severity: "ok",
|
||||||
|
detail: "linux-infra",
|
||||||
|
sparkline: [10, 20, 42],
|
||||||
|
});
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/query?"),
|
||||||
|
expect.objectContaining({ cache: "no-store" }),
|
||||||
|
);
|
||||||
|
expect(fetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/query_range?"),
|
||||||
|
expect.objectContaining({ cache: "no-store" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function jsonResponse(payload: unknown): Response {
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,30 @@ import {
|
||||||
type DashboardRuntimeOptions,
|
type DashboardRuntimeOptions,
|
||||||
type DashboardRuntimeState,
|
type DashboardRuntimeState,
|
||||||
} from "$lib/server/dashboard";
|
} from "$lib/server/dashboard";
|
||||||
import { resolveDashboardDatasources } from "$lib/server/datasources";
|
import {
|
||||||
|
resolveDashboardDatasources,
|
||||||
|
resolveDashboardTile,
|
||||||
|
type DashboardTileReference,
|
||||||
|
type DashboardTileResolution,
|
||||||
|
type DatasourceResolutionOptions,
|
||||||
|
} from "$lib/server/datasources";
|
||||||
|
|
||||||
export interface LoadDashboardResponseOptions
|
export interface LoadDashboardResponseOptions
|
||||||
extends Pick<
|
extends Pick<
|
||||||
DashboardRuntimeOptions,
|
DashboardRuntimeOptions,
|
||||||
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
||||||
> {
|
>,
|
||||||
|
DatasourceResolutionOptions {
|
||||||
disableLiveDatasources?: boolean;
|
disableLiveDatasources?: boolean;
|
||||||
|
hydrateLiveDatasources?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDashboardResponse(
|
export async function loadDashboardResponse(
|
||||||
options: LoadDashboardResponseOptions = {},
|
options: LoadDashboardResponseOptions = {},
|
||||||
): Promise<DashboardRuntimeState> {
|
): Promise<DashboardRuntimeState> {
|
||||||
|
const liveHydrationEnabled =
|
||||||
|
!options.disableLiveDatasources &&
|
||||||
|
process.env.DISABLE_LIVE_DATASOURCES !== "1";
|
||||||
const dashboard = loadDashboardRuntime(undefined, {
|
const dashboard = loadDashboardRuntime(undefined, {
|
||||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||||
|
|
@ -26,16 +37,87 @@ export async function loadDashboardResponse(
|
||||||
return dashboard;
|
return dashboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
|
if (
|
||||||
return dashboard;
|
!options.hydrateLiveDatasources ||
|
||||||
|
options.disableLiveDatasources ||
|
||||||
|
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...dashboard,
|
||||||
|
liveDatasourceHydration: {
|
||||||
|
enabled: liveHydrationEnabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dashboard,
|
...dashboard,
|
||||||
document: await resolveDashboardDatasources(dashboard.document),
|
document: await resolveDashboardDatasources(dashboard.document, options),
|
||||||
|
liveDatasourceHydration: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadDashboardTileResponse(
|
||||||
|
tile: DashboardTileReference,
|
||||||
|
options: LoadDashboardResponseOptions = {},
|
||||||
|
): Promise<DashboardTileResolution> {
|
||||||
|
const dashboard = loadDashboardRuntime(undefined, {
|
||||||
|
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||||
|
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||||
|
seedDocument: options.seedDocument,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dashboard.state !== "ready") {
|
||||||
|
return {
|
||||||
|
state: "not_found",
|
||||||
|
tile,
|
||||||
|
message: `Dashboard is not ready: ${dashboard.state}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveDashboardTile(dashboard.document, tile, options);
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleDashboardRoute(): Promise<Response> {
|
export async function handleDashboardRoute(): Promise<Response> {
|
||||||
return Response.json(await loadDashboardResponse());
|
return Response.json(await loadDashboardResponse());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleDashboardTileRoute(pathname: string): Promise<Response> {
|
||||||
|
const tile = parseDashboardTilePath(pathname);
|
||||||
|
if (!tile) {
|
||||||
|
return Response.json(
|
||||||
|
{ ok: false, message: "Invalid dashboard tile route" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await loadDashboardTileResponse(tile);
|
||||||
|
return Response.json(response, {
|
||||||
|
status: response.state === "ready" ? 200 : 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDashboardTilePath(pathname: string): DashboardTileReference | null {
|
||||||
|
const parts = pathname.split("/").filter(Boolean);
|
||||||
|
const [, dashboard, tileRoot, kind, firstId, secondId] = parts;
|
||||||
|
if (dashboard !== "dashboard" || tileRoot !== "tile" || !kind || !firstId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = decodeURIComponent(firstId);
|
||||||
|
if (kind === "telemetry" || kind === "service" || kind === "module") {
|
||||||
|
return { kind, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === "status" && secondId) {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
stripId: id,
|
||||||
|
id: decodeURIComponent(secondId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue