Compare commits
No commits in common. "9acaa18e6d7a03d3a2fb292b144cf80cf8be96d5" and "73a15a2b8ba7df3ee6353a1cbe6fcfdf902ac55b" have entirely different histories.
9acaa18e6d
...
73a15a2b8b
7 changed files with 21 additions and 745 deletions
|
|
@ -1,8 +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 { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
|
||||||
import { AppStateView, dashboardHydrationTiles } from "./App";
|
|
||||||
|
|
||||||
describe("React app dashboard state view", () => {
|
describe("React app dashboard state view", () => {
|
||||||
test("renders loading dashboard state", () => {
|
test("renders loading dashboard state", () => {
|
||||||
|
|
@ -43,43 +41,4 @@ 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:core-services: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"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hydrates every status cell from the Dimension Lab shell", () => {
|
|
||||||
expect(dashboardHydrationTiles(dimensionLabDashboardFixture)).toContainEqual({
|
|
||||||
kind: "status",
|
|
||||||
stripId: "footer-status",
|
|
||||||
id: "auto-refresh",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,4 @@
|
||||||
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 {
|
||||||
|
|
@ -16,35 +9,21 @@ import {
|
||||||
resolveInitialUiTheme,
|
resolveInitialUiTheme,
|
||||||
type UiTheme,
|
type UiTheme,
|
||||||
type UiSeverity,
|
type UiSeverity,
|
||||||
type UiDashboardPreview,
|
|
||||||
} from "@dimensionlab/ui";
|
} from "@dimensionlab/ui";
|
||||||
|
|
||||||
type DashboardTileReference =
|
const loadingDashboardState: DashboardRuntimeState = {
|
||||||
| { kind: "telemetry"; id: string }
|
state: "loading",
|
||||||
| { kind: "service"; groupId: string; id: string }
|
title: "Loading Dashboard",
|
||||||
| { kind: "module"; id: string }
|
subtitle: "Fetching active model",
|
||||||
| { kind: "status"; stripId: string; id: string };
|
message: "Waiting for the active dashboard document.",
|
||||||
|
};
|
||||||
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;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -54,14 +33,9 @@ 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={uiDashboard}
|
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
||||||
actions={themeToggle}
|
actions={themeToggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -115,10 +89,7 @@ export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [dashboard, setDashboard] =
|
const [dashboard, setDashboard] =
|
||||||
useState<DashboardRuntimeState | null>(null);
|
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||||
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";
|
||||||
|
|
||||||
|
|
@ -126,8 +97,6 @@ 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;
|
||||||
|
|
||||||
|
|
@ -150,7 +119,6 @@ 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");
|
||||||
|
|
@ -159,18 +127,6 @@ 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;
|
||||||
|
|
@ -189,54 +145,6 @@ 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 () => {
|
||||||
|
|
@ -245,14 +153,13 @@ export default function App() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return dashboard ? (
|
return (
|
||||||
<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 {
|
||||||
|
|
@ -274,154 +181,3 @@ 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:${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 [...telemetry, ...services, ...modules, ...status];
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,6 @@ 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,35 +10,6 @@ import type {
|
||||||
TelemetryCard,
|
TelemetryCard,
|
||||||
} from "@dimensionlab/dashboard-model";
|
} from "@dimensionlab/dashboard-model";
|
||||||
|
|
||||||
export type DashboardTileReference =
|
|
||||||
| { kind: "telemetry"; id: string }
|
|
||||||
| { kind: "service"; groupId: string; 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;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
state: "disabled";
|
|
||||||
tile: DashboardTileReference;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface DatasourceResolutionOptions {
|
export interface DatasourceResolutionOptions {
|
||||||
fetch?: DatasourceFetch;
|
fetch?: DatasourceFetch;
|
||||||
prometheusBaseUrl?: string;
|
prometheusBaseUrl?: string;
|
||||||
|
|
@ -75,69 +46,6 @@ 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
|
|
||||||
.find((group) => group.id === tile.groupId)
|
|
||||||
?.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;
|
||||||
|
|
@ -482,68 +390,6 @@ 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;
|
||||||
|
|
@ -827,17 +673,3 @@ 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 {
|
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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, handleDashboardTileRoute } from "./routes/dashboard";
|
import { handleDashboardRoute } 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,11 +23,6 @@ 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,14 +1,11 @@
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
|
||||||
import { loadDashboardResponse, loadDashboardTileResponse } from "./dashboard";
|
import { loadDashboardResponse } from "./dashboard";
|
||||||
|
|
||||||
describe("dashboard API route", () => {
|
describe("dashboard API route", () => {
|
||||||
test("returns the ready dashboard shell without hydrating live datasources", async () => {
|
test("returns ready dashboard runtime state from the existing model loader", 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,
|
||||||
});
|
});
|
||||||
|
|
@ -18,164 +15,5 @@ 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" }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hydrates a service tile with its service group identity", async () => {
|
|
||||||
const fetch = vi.fn(async () =>
|
|
||||||
jsonResponse({
|
|
||||||
status: "UP",
|
|
||||||
ping: 42,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await loadDashboardTileResponse(
|
|
||||||
{ kind: "service", groupId: "essentials", id: "vaultwarden" },
|
|
||||||
{
|
|
||||||
fetch,
|
|
||||||
refreshSeedDocument: true,
|
|
||||||
seedIfEmpty: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.state).toBe("ready");
|
|
||||||
if (response.state !== "ready") throw new Error("expected ready tile");
|
|
||||||
expect(response.tile).toEqual({
|
|
||||||
kind: "service",
|
|
||||||
groupId: "essentials",
|
|
||||||
id: "vaultwarden",
|
|
||||||
});
|
|
||||||
expect(response.item).toMatchObject({
|
|
||||||
id: "vaultwarden",
|
|
||||||
severity: "ok",
|
|
||||||
detail: "42 ms",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not hydrate tile routes when live datasources are disabled", async () => {
|
|
||||||
const previous = process.env.DISABLE_LIVE_DATASOURCES;
|
|
||||||
process.env.DISABLE_LIVE_DATASOURCES = "1";
|
|
||||||
const fetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
|
||||||
new Error("live datasource fetch should not run when disabled"),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await loadDashboardTileResponse(
|
|
||||||
{ kind: "telemetry", id: "infra-ram" },
|
|
||||||
{
|
|
||||||
refreshSeedDocument: true,
|
|
||||||
seedIfEmpty: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.state).toBe("disabled");
|
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
fetch.mockRestore();
|
|
||||||
if (previous === undefined) {
|
|
||||||
delete process.env.DISABLE_LIVE_DATASOURCES;
|
|
||||||
} else {
|
|
||||||
process.env.DISABLE_LIVE_DATASOURCES = previous;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function jsonResponse(payload: unknown): Response {
|
|
||||||
return new Response(JSON.stringify(payload), {
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,30 +3,19 @@ import {
|
||||||
type DashboardRuntimeOptions,
|
type DashboardRuntimeOptions,
|
||||||
type DashboardRuntimeState,
|
type DashboardRuntimeState,
|
||||||
} from "$lib/server/dashboard";
|
} from "$lib/server/dashboard";
|
||||||
import {
|
import { resolveDashboardDatasources } from "$lib/server/datasources";
|
||||||
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,
|
||||||
|
|
@ -37,106 +26,16 @@ export async function loadDashboardResponse(
|
||||||
return dashboard;
|
return dashboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
|
||||||
!options.hydrateLiveDatasources ||
|
return dashboard;
|
||||||
options.disableLiveDatasources ||
|
|
||||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...dashboard,
|
|
||||||
liveDatasourceHydration: {
|
|
||||||
enabled: liveHydrationEnabled,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dashboard,
|
...dashboard,
|
||||||
document: await resolveDashboardDatasources(dashboard.document, options),
|
document: await resolveDashboardDatasources(dashboard.document),
|
||||||
liveDatasourceHydration: {
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadDashboardTileResponse(
|
|
||||||
tile: DashboardTileReference,
|
|
||||||
options: LoadDashboardResponseOptions = {},
|
|
||||||
): Promise<DashboardTileResolution> {
|
|
||||||
if (
|
|
||||||
options.disableLiveDatasources ||
|
|
||||||
process.env.DISABLE_LIVE_DATASOURCES === "1"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
state: "disabled",
|
|
||||||
tile,
|
|
||||||
message: "Live datasource hydration is disabled.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 === "not_found" ? 404 : 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 === "module") {
|
|
||||||
return { kind, id };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "service" && secondId) {
|
|
||||||
return {
|
|
||||||
kind,
|
|
||||||
groupId: id,
|
|
||||||
id: decodeURIComponent(secondId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kind === "status" && secondId) {
|
|
||||||
return {
|
|
||||||
kind,
|
|
||||||
stripId: id,
|
|
||||||
id: decodeURIComponent(secondId),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue