feat(web): hydrate dashboard tiles independently #42
7 changed files with 642 additions and 20 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
|
||||
import { AppStateView } from "./App";
|
||||
|
||||
describe("React app dashboard state view", () => {
|
||||
|
|
@ -41,4 +42,35 @@ describe("React app dashboard state view", () => {
|
|||
expect(html).toContain("Dark");
|
||||
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 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export interface DashboardRuntimeReady {
|
|||
document: DashboardDocument;
|
||||
schemaVersion: string;
|
||||
currentRevisionId: string;
|
||||
liveDatasourceHydration?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DashboardRuntimeEmpty {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,30 @@ import type {
|
|||
TelemetryCard,
|
||||
} 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 {
|
||||
fetch?: DatasourceFetch;
|
||||
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 {
|
||||
fetch: DatasourceFetch;
|
||||
prometheusBaseUrl: string;
|
||||
|
|
@ -390,6 +477,68 @@ function resolveStatusItem(
|
|||
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[]): {
|
||||
severity: Severity;
|
||||
value: string;
|
||||
|
|
@ -673,3 +822,17 @@ function formatDuration(totalSeconds: number): string {
|
|||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
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 { 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 port = Number(process.env.PORT || 3000);
|
||||
|
|
@ -23,6 +23,11 @@ export async function handleRequest(request: Request): Promise<Response> {
|
|||
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 (request.method !== "POST") return methodNotAllowed(["POST"]);
|
||||
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 { loadDashboardResponse } from "./dashboard";
|
||||
import { loadDashboardResponse, loadDashboardTileResponse } from "./dashboard";
|
||||
|
||||
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({
|
||||
disableLiveDatasources: true,
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
});
|
||||
|
|
@ -15,5 +18,105 @@ describe("dashboard API route", () => {
|
|||
expect(response.document.metadata.title).toBe(
|
||||
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 DashboardRuntimeState,
|
||||
} 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
|
||||
extends Pick<
|
||||
DashboardRuntimeOptions,
|
||||
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
||||
> {
|
||||
>,
|
||||
DatasourceResolutionOptions {
|
||||
disableLiveDatasources?: boolean;
|
||||
hydrateLiveDatasources?: boolean;
|
||||
}
|
||||
|
||||
export async function loadDashboardResponse(
|
||||
options: LoadDashboardResponseOptions = {},
|
||||
): Promise<DashboardRuntimeState> {
|
||||
const liveHydrationEnabled =
|
||||
!options.disableLiveDatasources &&
|
||||
process.env.DISABLE_LIVE_DATASOURCES !== "1";
|
||||
const dashboard = loadDashboardRuntime(undefined, {
|
||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||
|
|
@ -26,16 +37,87 @@ export async function loadDashboardResponse(
|
|||
return dashboard;
|
||||
}
|
||||
|
||||
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
|
||||
return dashboard;
|
||||
if (
|
||||
!options.hydrateLiveDatasources ||
|
||||
options.disableLiveDatasources ||
|
||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
||||
) {
|
||||
return {
|
||||
...dashboard,
|
||||
liveDatasourceHydration: {
|
||||
enabled: liveHydrationEnabled,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...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> {
|
||||
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