refactor(web): move website into turbo app workspace
This commit is contained in:
parent
2664804e91
commit
b4e626a868
66 changed files with 318 additions and 298 deletions
44
apps/web/src/App.test.tsx
Normal file
44
apps/web/src/App.test.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { AppStateView } from "./App";
|
||||
|
||||
describe("React app dashboard state view", () => {
|
||||
test("renders loading dashboard state", () => {
|
||||
const html = renderToString(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("Loading Dashboard");
|
||||
expect(html).toContain("Fetching active model");
|
||||
});
|
||||
|
||||
test("renders an accessible theme toggle with the active theme", () => {
|
||||
const html = renderToString(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
}}
|
||||
theme="dark"
|
||||
onThemeChange={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-ui-theme-toggle="true"');
|
||||
expect(html).toContain('data-ui-theme-current="dark"');
|
||||
expect(html).toContain('aria-label="Light theme"');
|
||||
expect(html).toContain('aria-pressed="false"');
|
||||
expect(html).toContain("Theme");
|
||||
expect(html).toContain("Dark");
|
||||
expect(html).toContain("Light");
|
||||
});
|
||||
});
|
||||
183
apps/web/src/App.tsx
Normal file
183
apps/web/src/App.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||
import { dashboardDocumentToUiDashboard } from "$lib/ui-adapter/model-renderer";
|
||||
import {
|
||||
DashboardFrame,
|
||||
SystemState,
|
||||
ThemeToggle,
|
||||
persistUiTheme,
|
||||
resolveInitialUiTheme,
|
||||
type UiTheme,
|
||||
type UiSeverity,
|
||||
} from "@dimensionlab/ui";
|
||||
|
||||
const loadingDashboardState: DashboardRuntimeState = {
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
};
|
||||
|
||||
export function AppStateView({
|
||||
dashboard,
|
||||
onThemeChange,
|
||||
theme,
|
||||
}: {
|
||||
dashboard: DashboardRuntimeState;
|
||||
onThemeChange?: (theme: UiTheme) => void;
|
||||
theme?: UiTheme;
|
||||
}) {
|
||||
const themeToggle =
|
||||
theme && onThemeChange ? (
|
||||
<ThemeToggle theme={theme} onThemeChange={onThemeChange} />
|
||||
) : null;
|
||||
|
||||
if (dashboard.state === "ready") {
|
||||
return (
|
||||
<DashboardFrame
|
||||
dashboard={dashboardDocumentToUiDashboard(dashboard.document)}
|
||||
actions={themeToggle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = `${dashboard.subtitle}: ${dashboard.message}`;
|
||||
const errors = dashboard.state === "invalid" ? dashboard.errors : [];
|
||||
|
||||
return (
|
||||
<main className="state-shell" data-dashboard-state={dashboard.state}>
|
||||
{themeToggle ? (
|
||||
<div className="state-shell__actions">{themeToggle}</div>
|
||||
) : null}
|
||||
<SystemState
|
||||
title={dashboard.title}
|
||||
detail={detail}
|
||||
severity={stateSeverity(dashboard.state)}
|
||||
icon={stateIcon(dashboard.state)}
|
||||
/>
|
||||
{errors.length ? (
|
||||
<ul aria-label="Validation errors">
|
||||
{errors.map((error) => (
|
||||
<li key={error}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
|
||||
description: string;
|
||||
title: string;
|
||||
} {
|
||||
if (dashboard.state === "ready") {
|
||||
const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document);
|
||||
return {
|
||||
title: uiDashboard.title,
|
||||
description:
|
||||
uiDashboard.subtitle ||
|
||||
dashboard.document.metadata.description ||
|
||||
uiDashboard.title,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: dashboard.title,
|
||||
description: dashboard.subtitle || dashboard.message,
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [dashboard, setDashboard] =
|
||||
useState<DashboardRuntimeState>(loadingDashboardState);
|
||||
const [theme, setTheme] = useState<UiTheme>(() => {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
|
||||
return resolveInitialUiTheme(getThemeStorage());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const metadata = resolveDocumentMetadata(dashboard);
|
||||
document.title = metadata.title;
|
||||
|
||||
let description = document.querySelector<HTMLMetaElement>(
|
||||
'meta[name="description"]',
|
||||
);
|
||||
if (!description) {
|
||||
description = document.createElement("meta");
|
||||
description.name = "description";
|
||||
document.head.append(description);
|
||||
}
|
||||
description.content = metadata.description;
|
||||
}, [dashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.uiTheme = theme;
|
||||
persistUiTheme(theme, getThemeStorage());
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let refreshTimer: number | undefined;
|
||||
|
||||
async function loadDashboard() {
|
||||
const response = await fetch("/api/dashboard");
|
||||
const nextDashboard = (await response.json()) as DashboardRuntimeState;
|
||||
|
||||
if (cancelled) return;
|
||||
setDashboard(nextDashboard);
|
||||
|
||||
if (refreshTimer) {
|
||||
window.clearInterval(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
|
||||
const refreshIntervalSeconds =
|
||||
nextDashboard.state === "ready"
|
||||
? nextDashboard.document.metadata.refreshIntervalSeconds
|
||||
: undefined;
|
||||
|
||||
if (refreshIntervalSeconds) {
|
||||
refreshTimer = window.setInterval(
|
||||
() => void loadDashboard(),
|
||||
refreshIntervalSeconds * 1000,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void loadDashboard();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (refreshTimer) window.clearInterval(refreshTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppStateView
|
||||
dashboard={dashboard}
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function stateSeverity(state: DashboardRuntimeState["state"]): UiSeverity {
|
||||
if (state === "invalid") return "danger";
|
||||
if (state === "loading") return "loading";
|
||||
return "stale";
|
||||
}
|
||||
|
||||
function stateIcon(state: DashboardRuntimeState["state"]): string {
|
||||
if (state === "invalid") return "mdi:file-alert-outline";
|
||||
if (state === "loading") return "mdi:progress-clock";
|
||||
return "mdi:tray";
|
||||
}
|
||||
|
||||
function getThemeStorage(): Storage | undefined {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
170
apps/web/src/app.css
Normal file
170
apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
@import "tailwindcss";
|
||||
@import "@dimensionlab/ui/styles.css";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root,
|
||||
.dark {
|
||||
--background: var(--ui-color-canvas);
|
||||
--foreground: var(--ui-color-text);
|
||||
--card: var(--ui-color-surface);
|
||||
--card-foreground: var(--ui-color-text);
|
||||
--popover: var(--ui-color-surface-raised);
|
||||
--popover-foreground: var(--ui-color-text);
|
||||
--primary: var(--ui-color-accent);
|
||||
--primary-foreground: var(--ui-color-canvas);
|
||||
--secondary: var(--ui-color-surface-raised);
|
||||
--secondary-foreground: var(--ui-color-text);
|
||||
--muted: var(--ui-color-surface-raised);
|
||||
--muted-foreground: var(--ui-color-muted);
|
||||
--accent: var(--ui-color-accent);
|
||||
--accent-foreground: var(--ui-color-canvas);
|
||||
--destructive: var(--ui-color-danger);
|
||||
--border: var(--ui-color-border);
|
||||
--input: var(--ui-color-border-strong);
|
||||
--ring: var(--ui-color-accent);
|
||||
--chart-1: var(--ui-color-accent);
|
||||
--chart-2: var(--ui-color-ok);
|
||||
--chart-3: var(--ui-color-warning);
|
||||
--chart-4: var(--ui-color-danger);
|
||||
--chart-5: var(--ui-color-stale);
|
||||
--radius: 0.5rem;
|
||||
--sidebar: var(--ui-color-surface);
|
||||
--sidebar-foreground: var(--ui-color-text);
|
||||
--sidebar-primary: var(--ui-color-accent);
|
||||
--sidebar-primary-foreground: var(--ui-color-canvas);
|
||||
--sidebar-accent: var(--ui-color-surface-raised);
|
||||
--sidebar-accent-foreground: var(--ui-color-text);
|
||||
--sidebar-border: rgba(244, 244, 244, 0.16);
|
||||
--sidebar-ring: var(--ui-color-accent);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--ui-color-canvas);
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 50% 12%, transparent 0 42%, var(--ui-color-backdrop-vignette) 100%),
|
||||
linear-gradient(var(--ui-color-grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--ui-color-grid-line) 1px, transparent 1px),
|
||||
var(--ui-color-canvas);
|
||||
background-size: auto, 40px 40px, 40px 40px, auto;
|
||||
color: var(--ui-color-text);
|
||||
font-family: var(--ui-font-mono);
|
||||
text-rendering: geometricPrecision;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: var(--ui-focus-ring);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.state-shell {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
align-content: center;
|
||||
gap: var(--ui-space-3);
|
||||
padding: var(--ui-space-4);
|
||||
}
|
||||
|
||||
.state-shell__actions {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.state-shell ul {
|
||||
display: grid;
|
||||
max-width: 56rem;
|
||||
gap: var(--ui-space-2);
|
||||
margin: 0;
|
||||
border: var(--ui-border);
|
||||
background: var(--ui-color-surface-module);
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.76rem;
|
||||
list-style-position: inside;
|
||||
padding: var(--ui-space-3);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
148
apps/web/src/lib/model/fixtures/dimensionlab.test.ts
Normal file
148
apps/web/src/lib/model/fixtures/dimensionlab.test.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
dimensionLabDashboardFixture,
|
||||
} from "./dimensionlab";
|
||||
import type {
|
||||
DashboardDocument,
|
||||
DatasourceReference,
|
||||
ServiceEntry,
|
||||
TelemetryCard,
|
||||
} from "../schema";
|
||||
|
||||
describe("Dimension Lab dashboard seed", () => {
|
||||
test("defines the primary first-screen sections as model data", () => {
|
||||
expect(dimensionLabDashboardFixture.metadata.title).toBe("System Overview");
|
||||
expect(dimensionLabDashboardFixture.layout.telemetry).toHaveLength(16);
|
||||
expect(dimensionLabDashboardFixture.layout.serviceGroups).toEqual([
|
||||
"essentials",
|
||||
"monitoring",
|
||||
"ai-automation",
|
||||
"systems",
|
||||
"runtime-health",
|
||||
]);
|
||||
expect(dimensionLabDashboardFixture.layout.statusStrips).toEqual([
|
||||
"footer-status",
|
||||
]);
|
||||
expect(dimensionLabDashboardFixture.layout.modules).toEqual([
|
||||
"weather-amsterdam",
|
||||
"runtime-health-summary",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps icon, link, and datasource references in seed data", () => {
|
||||
const iconIds = [
|
||||
...dimensionLabDashboardFixture.telemetry.map((card) => card.icon),
|
||||
...allServices(dimensionLabDashboardFixture).map((service) => service.icon),
|
||||
...(dimensionLabDashboardFixture.modules || []).map((module) => module.icon),
|
||||
].filter((icon): icon is string => Boolean(icon));
|
||||
|
||||
expect(
|
||||
dimensionLabDashboardFixture.telemetry.every(
|
||||
(card) => card.icon && card.datasource,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
dimensionLabDashboardFixture.telemetry.some(
|
||||
(card) => card.datasource?.type === "external" &&
|
||||
card.datasource.adapter === "prometheus",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
allServices(dimensionLabDashboardFixture).some(
|
||||
(service) => service.datasource?.type === "external" &&
|
||||
service.datasource.adapter === "http-status",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
for (const service of allServices(dimensionLabDashboardFixture)) {
|
||||
expect(service.icon, `${service.id} must declare an Iconify id`).toBeTruthy();
|
||||
expect(verifiedSeedIconIds.has(service.icon || ""), `${service.id} icon must be verified`).toBe(true);
|
||||
expect(service.datasource, `${service.id} must declare health source`).toBeTruthy();
|
||||
if (service.link) {
|
||||
expect(service.link.label, `${service.id} link needs an accessible label`).toBeTruthy();
|
||||
expect(service.link.external).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
for (const icon of iconIds) {
|
||||
expect(verifiedSeedIconIds.has(icon), `${icon} must be verified`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("labels fallback values and unresolved adapters explicitly", () => {
|
||||
const placeholders = collectDatasources(dimensionLabDashboardFixture).filter(
|
||||
(datasource) => datasource.type === "placeholder",
|
||||
);
|
||||
const fallbackText = [
|
||||
...dimensionLabDashboardFixture.telemetry.map((card) => card.detail || ""),
|
||||
...allServices(dimensionLabDashboardFixture).map((service) => service.detail || ""),
|
||||
...dimensionLabDashboardFixture.statusStrips.flatMap((strip) =>
|
||||
strip.items.map((item) => item.value),
|
||||
),
|
||||
...(dimensionLabDashboardFixture.modules || []).map((module) => module.detail || ""),
|
||||
].join("\n").toLowerCase();
|
||||
|
||||
expect(fallbackText).toContain("fallback");
|
||||
for (const service of allServices(dimensionLabDashboardFixture)) {
|
||||
expect(service.detail?.toLowerCase(), `${service.id} detail must label fallback state`).toContain("fallback");
|
||||
}
|
||||
for (const statusItem of dimensionLabDashboardFixture.statusStrips.flatMap((strip) => strip.items)) {
|
||||
expect(statusItem.value.toLowerCase(), `${statusItem.id} value must label fallback state`).toContain("fallback");
|
||||
}
|
||||
expect(placeholders.length).toBeGreaterThan(0);
|
||||
for (const datasource of placeholders) {
|
||||
expect(datasource.reason.toLowerCase()).toMatch(/fallback|pending|unresolved/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function allServices(document: DashboardDocument): ServiceEntry[] {
|
||||
return document.serviceGroups.flatMap((group) => group.services);
|
||||
}
|
||||
|
||||
function collectDatasources(document: DashboardDocument): DatasourceReference[] {
|
||||
const telemetry = document.telemetry
|
||||
.map((card: TelemetryCard) => card.datasource)
|
||||
.filter((datasource): datasource is DatasourceReference => Boolean(datasource));
|
||||
const services = allServices(document)
|
||||
.map((service) => service.datasource)
|
||||
.filter((datasource): datasource is DatasourceReference => Boolean(datasource));
|
||||
const modules = (document.modules || [])
|
||||
.map((module) => module.datasource)
|
||||
.filter((datasource): datasource is DatasourceReference => Boolean(datasource));
|
||||
|
||||
return [...telemetry, ...services, ...modules];
|
||||
}
|
||||
|
||||
const verifiedSeedIconIds = new Set([
|
||||
"mdi:account-hard-hat-outline",
|
||||
"mdi:backup-restore",
|
||||
"mdi:brain",
|
||||
"mdi:cpu-64-bit",
|
||||
"mdi:docker",
|
||||
"mdi:expansion-card",
|
||||
"mdi:fan",
|
||||
"mdi:harddisk",
|
||||
"mdi:image-edit-outline",
|
||||
"mdi:memory",
|
||||
"mdi:pulse",
|
||||
"mdi:robot-outline",
|
||||
"mdi:router-network",
|
||||
"mdi:text-box-search",
|
||||
"mdi:thermometer",
|
||||
"mdi:web",
|
||||
"mdi:weather-sunny",
|
||||
"simple-icons:adguard",
|
||||
"simple-icons:adminer",
|
||||
"simple-icons:amazonwebservices",
|
||||
"simple-icons:cockpit",
|
||||
"simple-icons:forgejo",
|
||||
"simple-icons:grafana",
|
||||
"simple-icons:n8n",
|
||||
"simple-icons:ollama",
|
||||
"simple-icons:postgresql",
|
||||
"simple-icons:prometheus",
|
||||
"simple-icons:uptimekuma",
|
||||
"simple-icons:vaultwarden",
|
||||
"simple-icons:wikidotjs",
|
||||
]);
|
||||
681
apps/web/src/lib/model/fixtures/dimensionlab.ts
Normal file
681
apps/web/src/lib/model/fixtures/dimensionlab.ts
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
type DashboardDocument,
|
||||
type DatasourceReference,
|
||||
type MetricValue,
|
||||
type ServiceEntry,
|
||||
type ServiceGroup,
|
||||
type Severity,
|
||||
type TelemetryCard,
|
||||
} from "../schema";
|
||||
|
||||
type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number";
|
||||
|
||||
interface MetricSeed {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
kind: NumericValueKind;
|
||||
value: number;
|
||||
detail: string;
|
||||
severity: Severity;
|
||||
datasource: DatasourceReference;
|
||||
thresholds?: TelemetryCard["thresholds"];
|
||||
sparkline?: number[];
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
interface ServiceSeed {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
datasource: DatasourceReference;
|
||||
href?: string;
|
||||
severity?: Severity;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
const REAL_FILESYSTEM_FILTER =
|
||||
'fstype!~"tmpfs|overlay|squashfs|nsfs|tracefs|autofs|proc|sysfs|cgroup2|devtmpfs|securityfs|debugfs|pstore|bpf|configfs|selinuxfs|mqueue|hugetlbfs|fusectl|ramfs"';
|
||||
const USER_MOUNT_FILTER =
|
||||
'mountpoint=~"^/home($|/)|^/srv($|/)|^/data($|/)|^/mnt($|/)|^/media($|/)",mountpoint!~"^/(boot|boot/efi|efi|var|usr|opt|run|dev|proc|sys)($|/)"';
|
||||
const SYSTEM_MOUNT_FILTER =
|
||||
'mountpoint=~"^/$|^/boot($|/)|^/boot/efi$|^/var($|/)|^/usr($|/)|^/opt($|/)",mountpoint!~"^/home($|/)|^/srv($|/)|^/data($|/)|^/mnt($|/)|^/media($|/)"';
|
||||
|
||||
const diskUsedQuery = (mountFilter: string) => {
|
||||
const selector = `job="node",${REAL_FILESYSTEM_FILTER},${mountFilter}`;
|
||||
return `topk(1, max by (host, mountpoint) (100 * (1 - node_filesystem_avail_bytes{${selector}} / node_filesystem_size_bytes{${selector}})))`;
|
||||
};
|
||||
const ramQuery = (host: string) =>
|
||||
`100 * (1 - node_memory_MemAvailable_bytes{job="node",host="${host}"} / node_memory_MemTotal_bytes{job="node",host="${host}"})`;
|
||||
const gpuQuery = (name: string, metric: string) =>
|
||||
`${metric}{job="node",name=~".*${name}.*"}`;
|
||||
const gpuVramQuery = (name: string) =>
|
||||
`100 * nvidia_gpu_memory_used_bytes{job="node",name=~".*${name}.*"} / nvidia_gpu_memory_total_bytes{job="node",name=~".*${name}.*"}`;
|
||||
const hostCpuQuery =
|
||||
'topk(1, 100 * (1 - avg by (host) (rate(node_cpu_seconds_total{job="node",mode="idle"}[5m]))))';
|
||||
const topCpuQuery =
|
||||
'topk(1, 100 * rate(podman_container_cpu_seconds_total{job=~"podman-.*"}[5m]) * on(host,id) group_left(name) podman_container_info{job=~"podman-.*"})';
|
||||
const topRamQuery =
|
||||
'topk(1, podman_container_mem_usage_bytes{job=~"podman-.*"} * on(host,id) group_left(name) podman_container_info{job=~"podman-.*"})';
|
||||
|
||||
export const dimensionLabDashboardFixture: DashboardDocument = {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
metadata: {
|
||||
title: "System Overview",
|
||||
subtitle: "Capacity, noise & top consumers",
|
||||
description:
|
||||
"Initial Dimension Lab dashboard seed data. Runtime values are fallback values until datasource adapters are enabled.",
|
||||
timezone: "Europe/Amsterdam",
|
||||
refreshIntervalSeconds: 15,
|
||||
},
|
||||
layout: {
|
||||
density: "dense",
|
||||
telemetry: [
|
||||
"infra-ram",
|
||||
"gpu-host-ram",
|
||||
"network-ram",
|
||||
"user-disk-peak",
|
||||
"system-disk-peak",
|
||||
"peak-cpu-busy",
|
||||
"top-cpu-container",
|
||||
"top-ram-container",
|
||||
"gpu-3060-load",
|
||||
"gpu-3060-vram",
|
||||
"gpu-3060-temp",
|
||||
"gpu-3060-fan",
|
||||
"gpu-3090-load",
|
||||
"gpu-3090-vram",
|
||||
"gpu-3090-temp",
|
||||
"gpu-3090-fan",
|
||||
],
|
||||
serviceGroups: ["essentials", "monitoring", "ai-automation", "systems", "runtime-health"],
|
||||
statusStrips: ["footer-status"],
|
||||
modules: ["weather-amsterdam", "runtime-health-summary"],
|
||||
},
|
||||
telemetry: [
|
||||
metric({
|
||||
id: "infra-ram",
|
||||
label: "Infra RAM",
|
||||
icon: "mdi:memory",
|
||||
kind: "percent",
|
||||
value: 19,
|
||||
detail: "fallback - linux-infra memory used",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(),
|
||||
datasource: prometheus(
|
||||
ramQuery("linux-infra"),
|
||||
),
|
||||
sparkline: [18, 18, 19, 19, 18, 19],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-host-ram",
|
||||
label: "GPU Host RAM",
|
||||
icon: "mdi:memory",
|
||||
kind: "percent",
|
||||
value: 17,
|
||||
detail: "fallback - GPU host memory used",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(),
|
||||
datasource: prometheus(
|
||||
ramQuery("linux"),
|
||||
),
|
||||
sparkline: [16, 16, 17, 17, 17, 17],
|
||||
}),
|
||||
metric({
|
||||
id: "network-ram",
|
||||
label: "Network RAM",
|
||||
icon: "mdi:router-network",
|
||||
kind: "percent",
|
||||
value: 5,
|
||||
detail: "fallback - network core memory used",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(),
|
||||
datasource: prometheus(
|
||||
ramQuery("network-core"),
|
||||
),
|
||||
sparkline: [5, 5, 5, 6, 5, 5],
|
||||
}),
|
||||
metric({
|
||||
id: "user-disk-peak",
|
||||
label: "User Disk Peak",
|
||||
icon: "mdi:harddisk",
|
||||
kind: "percent",
|
||||
value: 29,
|
||||
detail: "fallback - /home peak usage",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(80, 92),
|
||||
datasource: prometheus(
|
||||
diskUsedQuery(USER_MOUNT_FILTER),
|
||||
),
|
||||
sparkline: [27, 27, 28, 29, 29, 29],
|
||||
}),
|
||||
metric({
|
||||
id: "system-disk-peak",
|
||||
label: "System Disk Peak",
|
||||
icon: "mdi:harddisk",
|
||||
kind: "percent",
|
||||
value: 49,
|
||||
detail: "fallback - /boot peak usage",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(80, 92),
|
||||
datasource: prometheus(
|
||||
diskUsedQuery(SYSTEM_MOUNT_FILTER),
|
||||
),
|
||||
sparkline: [48, 48, 49, 49, 49, 49],
|
||||
}),
|
||||
metric({
|
||||
id: "peak-cpu-busy",
|
||||
label: "Peak CPU Busy",
|
||||
icon: "mdi:cpu-64-bit",
|
||||
kind: "percent",
|
||||
value: 3,
|
||||
detail: "fallback - linux-infra 5m CPU busy",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(75, 90),
|
||||
datasource: prometheus(
|
||||
hostCpuQuery,
|
||||
),
|
||||
sparkline: [2, 3, 3, 4, 3, 3],
|
||||
}),
|
||||
metric({
|
||||
id: "top-cpu-container",
|
||||
label: "Top CPU Container",
|
||||
icon: "mdi:docker",
|
||||
kind: "percent",
|
||||
value: 7.9,
|
||||
precision: 1,
|
||||
detail: "fallback - top container CPU pending label mapping",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(70, 90),
|
||||
datasource: prometheus(topCpuQuery),
|
||||
sparkline: [6.1, 6.4, 7.0, 7.5, 7.2, 7.9],
|
||||
}),
|
||||
metric({
|
||||
id: "top-ram-container",
|
||||
label: "Top RAM Container",
|
||||
icon: "mdi:docker",
|
||||
kind: "bytes",
|
||||
value: Math.round(5.2 * 1024 ** 3),
|
||||
precision: 1,
|
||||
detail: "fallback - top container RAM pending label mapping",
|
||||
severity: "danger",
|
||||
thresholds: { warning: 3 * 1024 ** 3, danger: 5 * 1024 ** 3 },
|
||||
datasource: prometheus(topRamQuery),
|
||||
sparkline: [
|
||||
3.1 * 1024 ** 3,
|
||||
3.5 * 1024 ** 3,
|
||||
4.1 * 1024 ** 3,
|
||||
4.8 * 1024 ** 3,
|
||||
5.0 * 1024 ** 3,
|
||||
5.2 * 1024 ** 3,
|
||||
],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3060-load",
|
||||
label: "3060 GPU Load",
|
||||
icon: "mdi:expansion-card",
|
||||
kind: "percent",
|
||||
value: 0,
|
||||
detail: "fallback - RTX 3060 GPU utilization",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(85, 95),
|
||||
datasource: prometheus(gpuQuery("3060", "nvidia_gpu_utilization_percent")),
|
||||
sparkline: [0, 0, 0, 0, 0, 0],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3060-vram",
|
||||
label: "3060 VRAM",
|
||||
icon: "mdi:memory",
|
||||
kind: "percent",
|
||||
value: 0,
|
||||
detail: "fallback - RTX 3060 VRAM used",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(80, 92),
|
||||
datasource: prometheus(
|
||||
gpuVramQuery("3060"),
|
||||
),
|
||||
sparkline: [0, 0, 0, 0, 0, 0],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3060-temp",
|
||||
label: "3060 Temp",
|
||||
icon: "mdi:thermometer",
|
||||
kind: "temperature",
|
||||
value: 54,
|
||||
detail: "fallback - RTX 3060 temperature",
|
||||
severity: "ok",
|
||||
thresholds: { warning: 75, danger: 85 },
|
||||
datasource: prometheus(gpuQuery("3060", "nvidia_gpu_temperature_celsius")),
|
||||
sparkline: [52, 53, 54, 54, 53, 54],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3060-fan",
|
||||
label: "3060 Fan Spin",
|
||||
icon: "mdi:fan",
|
||||
kind: "percent",
|
||||
value: 0,
|
||||
detail: "fallback - RTX 3060 fan duty",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(80, 95),
|
||||
datasource: prometheus(gpuQuery("3060", "nvidia_gpu_fan_speed_percent")),
|
||||
sparkline: [0, 0, 0, 0, 0, 0],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3090-load",
|
||||
label: "3090 GPU Load",
|
||||
icon: "mdi:expansion-card",
|
||||
kind: "percent",
|
||||
value: 0,
|
||||
detail: "fallback - RTX 3090 GPU utilization",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(85, 95),
|
||||
datasource: prometheus(gpuQuery("3090", "nvidia_gpu_utilization_percent")),
|
||||
sparkline: [0, 0, 0, 0, 0, 0],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3090-vram",
|
||||
label: "3090 VRAM",
|
||||
icon: "mdi:memory",
|
||||
kind: "percent",
|
||||
value: 74,
|
||||
detail: "fallback - RTX 3090 VRAM used",
|
||||
severity: "warning",
|
||||
thresholds: percentThresholds(70, 90),
|
||||
datasource: prometheus(
|
||||
gpuVramQuery("3090"),
|
||||
),
|
||||
sparkline: [68, 70, 72, 74, 73, 74],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3090-temp",
|
||||
label: "3090 Temp",
|
||||
icon: "mdi:thermometer",
|
||||
kind: "temperature",
|
||||
value: 50,
|
||||
detail: "fallback - RTX 3090 temperature",
|
||||
severity: "ok",
|
||||
thresholds: { warning: 75, danger: 85 },
|
||||
datasource: prometheus(gpuQuery("3090", "nvidia_gpu_temperature_celsius")),
|
||||
sparkline: [49, 50, 50, 51, 50, 50],
|
||||
}),
|
||||
metric({
|
||||
id: "gpu-3090-fan",
|
||||
label: "3090 Fan Spin",
|
||||
icon: "mdi:fan",
|
||||
kind: "percent",
|
||||
value: 0,
|
||||
detail: "fallback - RTX 3090 fan duty",
|
||||
severity: "ok",
|
||||
thresholds: percentThresholds(80, 95),
|
||||
datasource: prometheus(gpuQuery("3090", "nvidia_gpu_fan_speed_percent")),
|
||||
sparkline: [0, 0, 0, 0, 0, 0],
|
||||
}),
|
||||
],
|
||||
serviceGroups: [
|
||||
group("essentials", "Essentials", [
|
||||
service({
|
||||
id: "vaultwarden",
|
||||
label: "Vaultwarden",
|
||||
description: "Password manager",
|
||||
icon: "simple-icons:vaultwarden",
|
||||
href: "https://vault.dimensionlab.net",
|
||||
datasource: uptimeMonitor(1),
|
||||
}),
|
||||
service({
|
||||
id: "forgejo",
|
||||
label: "Forgejo",
|
||||
description: "Git repositories",
|
||||
icon: "simple-icons:forgejo",
|
||||
href: "https://git.dimensionlab.net",
|
||||
datasource: uptimeMonitor(2),
|
||||
}),
|
||||
service({
|
||||
id: "wiki",
|
||||
label: "Wiki",
|
||||
description: "Internal documentation",
|
||||
icon: "simple-icons:wikidotjs",
|
||||
href: "https://wiki.dimensionlab.net",
|
||||
datasource: uptimeMonitor(3),
|
||||
}),
|
||||
service({
|
||||
id: "aws-start",
|
||||
label: "AWS Start",
|
||||
description: "AWS access portal",
|
||||
icon: "simple-icons:amazonwebservices",
|
||||
href: "https://dimensionlab.awsapps.com/start",
|
||||
datasource: uptimeMonitor(21),
|
||||
}),
|
||||
service({
|
||||
id: "adguard-primary",
|
||||
label: "AdGuard Primary",
|
||||
description: "DNS filtering and DHCP",
|
||||
icon: "simple-icons:adguard",
|
||||
href: "https://control.dimensionlab.net",
|
||||
datasource: uptimeMonitor(14),
|
||||
}),
|
||||
service({
|
||||
id: "adguard-secondary",
|
||||
label: "AdGuard Secondary",
|
||||
description: "Fallback DNS",
|
||||
icon: "simple-icons:adguard",
|
||||
href: "https://control-secondary.dimensionlab.net",
|
||||
datasource: uptimeMonitor(18),
|
||||
}),
|
||||
]),
|
||||
group("monitoring", "Monitoring", [
|
||||
service({
|
||||
id: "grafana",
|
||||
label: "Grafana",
|
||||
description: "Capacity and noise dashboard",
|
||||
icon: "simple-icons:grafana",
|
||||
href: "https://grafana.dimensionlab.net",
|
||||
datasource: uptimeMonitor(11),
|
||||
}),
|
||||
service({
|
||||
id: "uptime-kuma",
|
||||
label: "Uptime Kuma",
|
||||
description: "Service uptime checks",
|
||||
icon: "simple-icons:uptimekuma",
|
||||
href: "https://uptime.dimensionlab.net",
|
||||
datasource: uptimeMonitor(10),
|
||||
}),
|
||||
service({
|
||||
id: "prometheus",
|
||||
label: "Prometheus",
|
||||
description: "Metrics database",
|
||||
icon: "simple-icons:prometheus",
|
||||
href: "https://prometheus.dimensionlab.net",
|
||||
datasource: uptimeMonitor(12),
|
||||
}),
|
||||
service({
|
||||
id: "backrest",
|
||||
label: "Backrest",
|
||||
description: "Restic backup manager",
|
||||
icon: "mdi:backup-restore",
|
||||
href: "https://backups.dimensionlab.net",
|
||||
datasource: uptimeMonitor(13),
|
||||
}),
|
||||
]),
|
||||
group("ai-automation", "AI & Automation", [
|
||||
service({
|
||||
id: "n8n",
|
||||
label: "n8n",
|
||||
description: "Workflow automation",
|
||||
icon: "simple-icons:n8n",
|
||||
href: "https://workflows.dimensionlab.net",
|
||||
datasource: uptimeMonitor(4),
|
||||
}),
|
||||
service({
|
||||
id: "open-webui",
|
||||
label: "Open WebUI",
|
||||
description: "Chat and model interface",
|
||||
icon: "mdi:web",
|
||||
href: "https://webui.dimensionlab.net",
|
||||
datasource: uptimeMonitor(5),
|
||||
}),
|
||||
service({
|
||||
id: "comfyui",
|
||||
label: "ComfyUI",
|
||||
description: "Image generation workflows",
|
||||
icon: "mdi:image-edit-outline",
|
||||
href: "https://comfy.dimensionlab.net",
|
||||
datasource: uptimeMonitor(6),
|
||||
}),
|
||||
service({
|
||||
id: "models",
|
||||
label: "Models",
|
||||
description: "Local model management",
|
||||
icon: "mdi:brain",
|
||||
href: "https://models.dimensionlab.net",
|
||||
datasource: uptimeMonitor(7),
|
||||
}),
|
||||
service({
|
||||
id: "prompt-registry",
|
||||
label: "Prompt Registry",
|
||||
description: "Shared prompts, traces, evals",
|
||||
icon: "mdi:text-box-search",
|
||||
href: "https://prompts.dimensionlab.net",
|
||||
datasource: uptimeMonitor(20),
|
||||
}),
|
||||
]),
|
||||
group("systems", "Systems", [
|
||||
service({
|
||||
id: "adminer",
|
||||
label: "Adminer",
|
||||
description: "PostgreSQL database browser",
|
||||
icon: "simple-icons:adminer",
|
||||
href: "https://db.dimensionlab.net",
|
||||
datasource: uptimeMonitor(17),
|
||||
}),
|
||||
service({
|
||||
id: "assistant",
|
||||
label: "Assistant",
|
||||
description: "Personal AI agent gateway",
|
||||
icon: "mdi:robot-outline",
|
||||
href: "https://assistant.dimensionlab.net",
|
||||
datasource: uptimeMonitor(8),
|
||||
}),
|
||||
service({
|
||||
id: "suna",
|
||||
label: "Suna",
|
||||
description: "AI command center",
|
||||
icon: "mdi:account-hard-hat-outline",
|
||||
href: "https://suna.dimensionlab.net",
|
||||
datasource: uptimeMonitor(9),
|
||||
}),
|
||||
service({
|
||||
id: "cockpit-infra",
|
||||
label: "Cockpit Infra",
|
||||
description: "linux-infra server console",
|
||||
icon: "simple-icons:cockpit",
|
||||
href: "https://infra-cockpit.dimensionlab.net",
|
||||
datasource: uptimeMonitor(15),
|
||||
}),
|
||||
service({
|
||||
id: "cockpit-gpu",
|
||||
label: "Cockpit GPU",
|
||||
description: "Linux GPU server console",
|
||||
icon: "simple-icons:cockpit",
|
||||
href: "https://linux-cockpit.dimensionlab.net",
|
||||
datasource: uptimeMonitor(16),
|
||||
}),
|
||||
service({
|
||||
id: "cockpit-network-core",
|
||||
label: "Cockpit Network Core",
|
||||
description: "i3 NUC DNS/DHCP console",
|
||||
icon: "simple-icons:cockpit",
|
||||
href: "https://network-core.dimensionlab.net",
|
||||
datasource: uptimeMonitor(19),
|
||||
}),
|
||||
]),
|
||||
group("runtime-health", "Runtime Health", [
|
||||
service({
|
||||
id: "forgejo-ssh-relay",
|
||||
label: "Forgejo SSH Relay",
|
||||
description: "Public Git SSH relay",
|
||||
icon: "simple-icons:forgejo",
|
||||
href: "https://uptime.dimensionlab.net/status/dimensionlab",
|
||||
datasource: uptimeMonitor(28),
|
||||
detail: "fallback - uptime monitor pending",
|
||||
}),
|
||||
service({
|
||||
id: "postgresql",
|
||||
label: "PostgreSQL",
|
||||
description: "Shared application database",
|
||||
icon: "simple-icons:postgresql",
|
||||
datasource: uptimeMonitor(22),
|
||||
detail: "fallback - postgres exporter pending",
|
||||
}),
|
||||
service({
|
||||
id: "ollama-api",
|
||||
label: "Ollama API",
|
||||
description: "Local model API",
|
||||
icon: "simple-icons:ollama",
|
||||
datasource: uptimeMonitor(23),
|
||||
detail: "fallback - internal health check pending",
|
||||
}),
|
||||
service({
|
||||
id: "node-exporter",
|
||||
label: "Node Exporter",
|
||||
description: "Host metrics exporter",
|
||||
icon: "simple-icons:prometheus",
|
||||
datasource: uptimeMonitor(24),
|
||||
detail: "fallback - exporter health from Uptime Kuma",
|
||||
}),
|
||||
service({
|
||||
id: "podman-user-exporter",
|
||||
label: "Podman User Exporter",
|
||||
description: "Rootless container metrics",
|
||||
icon: "simple-icons:prometheus",
|
||||
datasource: uptimeMonitor(25),
|
||||
detail: "fallback - exporter health from Uptime Kuma",
|
||||
}),
|
||||
service({
|
||||
id: "podman-system-exporter",
|
||||
label: "Podman System Exporter",
|
||||
description: "System container metrics",
|
||||
icon: "simple-icons:prometheus",
|
||||
datasource: uptimeMonitor(26),
|
||||
detail: "fallback - exporter health from Uptime Kuma",
|
||||
}),
|
||||
service({
|
||||
id: "network-core-node-exporter",
|
||||
label: "Network Core Node Exporter",
|
||||
description: "i3 DNS/DHCP metrics",
|
||||
icon: "simple-icons:prometheus",
|
||||
datasource: uptimeMonitor(27),
|
||||
detail: "fallback - exporter health from Prometheus",
|
||||
}),
|
||||
], "grid"),
|
||||
],
|
||||
statusStrips: [
|
||||
{
|
||||
id: "footer-status",
|
||||
items: [
|
||||
{ id: "system-status", label: "System Status", value: "Fallback operational", severity: "ok" },
|
||||
{ id: "last-sync", label: "Last Sync", value: "fallback: 2 minutes ago", severity: "stale" },
|
||||
{ id: "uptime", label: "Uptime", value: "fallback: 28d 14h 32m", severity: "ok" },
|
||||
{ id: "load-avg", label: "Load Avg", value: "fallback: 0.47 0.53 0.59", severity: "neutral" },
|
||||
{ id: "auto-refresh", label: "Auto Refresh", value: "fallback: 15s", severity: "neutral" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "weather-amsterdam",
|
||||
kind: "weather",
|
||||
title: "Amsterdam",
|
||||
value: "28.3 C",
|
||||
detail: "fallback - weather adapter pending",
|
||||
icon: "mdi:weather-sunny",
|
||||
severity: "ok",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "weather",
|
||||
reference: "open-meteo:latitude=52.3676&longitude=4.9041",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "runtime-health-summary",
|
||||
kind: "summary",
|
||||
title: "Runtime Health",
|
||||
value: "fallback",
|
||||
detail: "fallback - health adapters pending",
|
||||
icon: "mdi:pulse",
|
||||
severity: "stale",
|
||||
datasource: placeholder("summary pending live health aggregation"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function metric(seed: MetricSeed): TelemetryCard {
|
||||
return {
|
||||
id: seed.id,
|
||||
label: seed.label,
|
||||
icon: seed.icon,
|
||||
value: metricValue(seed.kind, seed.value, seed.precision),
|
||||
detail: seed.detail,
|
||||
severity: seed.severity,
|
||||
thresholds: seed.thresholds,
|
||||
datasource: seed.datasource,
|
||||
sparkline: seed.sparkline,
|
||||
};
|
||||
}
|
||||
|
||||
function metricValue(
|
||||
kind: NumericValueKind,
|
||||
value: number,
|
||||
precision?: number,
|
||||
): MetricValue {
|
||||
return precision === undefined ? { kind, value } : { kind, value, precision };
|
||||
}
|
||||
|
||||
function service(seed: ServiceSeed): ServiceEntry {
|
||||
const entry: ServiceEntry = {
|
||||
id: seed.id,
|
||||
label: seed.label,
|
||||
description: seed.description,
|
||||
icon: seed.icon,
|
||||
severity: seed.severity || "ok",
|
||||
detail: seed.detail || "fallback - health check pending",
|
||||
datasource: seed.datasource,
|
||||
};
|
||||
|
||||
if (!seed.href) return entry;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
link: {
|
||||
href: seed.href,
|
||||
label: `Open ${seed.label}`,
|
||||
external: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function group(
|
||||
id: string,
|
||||
title: string,
|
||||
services: ServiceEntry[],
|
||||
layout: ServiceGroup["layout"] = "list",
|
||||
): ServiceGroup {
|
||||
return {
|
||||
id,
|
||||
layout,
|
||||
services,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
function percentThresholds(warning = 80, danger = 92) {
|
||||
return { warning, danger };
|
||||
}
|
||||
|
||||
function prometheus(reference: string): DatasourceReference {
|
||||
return {
|
||||
type: "external",
|
||||
adapter: "prometheus",
|
||||
reference,
|
||||
};
|
||||
}
|
||||
|
||||
function httpStatus(url: string): DatasourceReference {
|
||||
return {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: `GET ${url}`,
|
||||
};
|
||||
}
|
||||
|
||||
function placeholder(reason: string): DatasourceReference {
|
||||
return {
|
||||
type: "placeholder",
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
function uptimeMonitor(id: number): DatasourceReference {
|
||||
return httpStatus(`https://uptime.dimensionlab.net/_homepage-badge/${id}`);
|
||||
}
|
||||
91
apps/web/src/lib/model/fixtures/generic.ts
Normal file
91
apps/web/src/lib/model/fixtures/generic.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
type DashboardDocument,
|
||||
} from "../schema";
|
||||
|
||||
export const genericDashboardFixture: DashboardDocument = {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
metadata: {
|
||||
title: "Operations Console",
|
||||
subtitle: "Generic environment",
|
||||
description: "Portable fixture for component and renderer tests.",
|
||||
timezone: "UTC",
|
||||
refreshIntervalSeconds: 30,
|
||||
},
|
||||
layout: {
|
||||
density: "dense",
|
||||
telemetry: ["service-uptime", "queue-depth"],
|
||||
serviceGroups: ["core-services"],
|
||||
statusStrips: ["runtime"],
|
||||
modules: ["ambient"],
|
||||
},
|
||||
telemetry: [
|
||||
{
|
||||
id: "service-uptime",
|
||||
label: "Service Uptime",
|
||||
value: { kind: "percent", value: 99.9, precision: 1 },
|
||||
detail: "last 30 days",
|
||||
severity: "ok",
|
||||
datasource: { type: "static", label: "fixture" },
|
||||
sparkline: [99.7, 99.8, 99.9, 99.9],
|
||||
},
|
||||
{
|
||||
id: "queue-depth",
|
||||
label: "Queue Depth",
|
||||
value: { kind: "number", value: 18 },
|
||||
detail: "pending jobs",
|
||||
severity: "warning",
|
||||
thresholds: { warning: 15, danger: 50 },
|
||||
datasource: { type: "placeholder", reason: "adapter pending" },
|
||||
sparkline: [6, 11, 13, 18],
|
||||
},
|
||||
],
|
||||
serviceGroups: [
|
||||
{
|
||||
id: "core-services",
|
||||
title: "Core Services",
|
||||
layout: "list",
|
||||
services: [
|
||||
{
|
||||
id: "identity",
|
||||
label: "Identity",
|
||||
description: "Authentication and profile service",
|
||||
icon: "mdi:account-key-outline",
|
||||
severity: "ok",
|
||||
detail: "ready",
|
||||
datasource: { type: "static", label: "fixture" },
|
||||
},
|
||||
{
|
||||
id: "scheduler",
|
||||
label: "Scheduler",
|
||||
description: "Background task coordinator",
|
||||
icon: "mdi:calendar-clock",
|
||||
severity: "warning",
|
||||
detail: "delayed",
|
||||
datasource: { type: "placeholder", reason: "health adapter pending" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
statusStrips: [
|
||||
{
|
||||
id: "runtime",
|
||||
items: [
|
||||
{ id: "status", label: "System Status", value: "Degraded", severity: "warning" },
|
||||
{ id: "sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "ambient",
|
||||
kind: "summary",
|
||||
title: "Environment",
|
||||
value: "Nominal",
|
||||
detail: "static fixture",
|
||||
icon: "mdi:radar",
|
||||
severity: "ok",
|
||||
datasource: { type: "static", label: "fixture" },
|
||||
},
|
||||
],
|
||||
};
|
||||
2
apps/web/src/lib/model/fixtures/index.ts
Normal file
2
apps/web/src/lib/model/fixtures/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { dimensionLabDashboardFixture } from "./dimensionlab";
|
||||
export { genericDashboardFixture } from "./generic";
|
||||
29
apps/web/src/lib/model/index.ts
Normal file
29
apps/web/src/lib/model/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
DashboardDocumentSchema,
|
||||
DatasourceReferenceSchema,
|
||||
ServiceEntrySchema,
|
||||
ServiceGroupSchema,
|
||||
TelemetryCardSchema,
|
||||
ThresholdSchema,
|
||||
dashboardDocumentJsonSchema,
|
||||
type DashboardDocument,
|
||||
type DashboardModule,
|
||||
type DatasourceReference,
|
||||
type MetricValue,
|
||||
type ServiceEntry,
|
||||
type ServiceGroup,
|
||||
type Severity,
|
||||
type StatusItem,
|
||||
type StatusStrip,
|
||||
type TelemetryCard,
|
||||
} from "./schema";
|
||||
export {
|
||||
assertDashboardDocument,
|
||||
formatValidationErrors,
|
||||
isDashboardDocument,
|
||||
validateDashboardDocument,
|
||||
type DashboardValidationFailure,
|
||||
type DashboardValidationResult,
|
||||
type DashboardValidationSuccess,
|
||||
} from "./validation";
|
||||
204
apps/web/src/lib/model/schema.test.ts
Normal file
204
apps/web/src/lib/model/schema.test.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
dashboardDocumentJsonSchema,
|
||||
validateDashboardDocument,
|
||||
} from ".";
|
||||
import {
|
||||
dimensionLabDashboardFixture,
|
||||
genericDashboardFixture,
|
||||
} from "./fixtures";
|
||||
|
||||
describe("dashboard model validation", () => {
|
||||
it("accepts the generic dashboard fixture", () => {
|
||||
const result = validateDashboardDocument(genericDashboardFixture);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.data.schemaVersion).toBe(DASHBOARD_SCHEMA_VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the Dimension Lab dashboard fixture", () => {
|
||||
const result = validateDashboardDocument(dimensionLabDashboardFixture);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects documents with an unsupported schema version", () => {
|
||||
const invalid = {
|
||||
...genericDashboardFixture,
|
||||
schemaVersion: "dashboard.v0",
|
||||
};
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("must be equal to constant");
|
||||
expect(result.details.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns actionable field paths for invalid documents", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
delete invalid.metadata.title;
|
||||
invalid.telemetry[0].severity = "fine";
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((error) => error.includes("/metadata"))).toBe(true);
|
||||
expect(result.errors.some((error) => error.includes("/telemetry/0/severity"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("includes offending additional property names in errors", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.metadata.unexpected = true;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("unexpected");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-finite numbers that cannot roundtrip through JSON", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].value.value = Number.NaN;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("finite JSON number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects dangling layout references", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.layout.telemetry.push("missing-card");
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("missing-card");
|
||||
expect(result.errors.join(" ")).toContain("must reference an existing item");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate IDs within collections", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[1].id = invalid.telemetry[0].id;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("must be unique");
|
||||
expect(result.errors.join(" ")).toContain(invalid.telemetry[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate status strip item IDs", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.statusStrips[0].items.push({
|
||||
...invalid.statusStrips[0].items[0],
|
||||
});
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("must be unique");
|
||||
expect(result.errors.join(" ")).toContain(invalid.statusStrips[0].items[0].id);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects string values for numeric metric kinds", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].value.value = "not a percent";
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects percent values outside 0 to 100", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].value.value = 150;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects negative values for nonnegative metric kinds", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].value = { kind: "latency", value: -20 };
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects contradictory warning and danger thresholds", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].thresholds = { warning: 90, danger: 80 };
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("warning threshold");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects percent thresholds above 100", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].thresholds = { warning: 99, danger: 999 };
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("percent thresholds");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects thresholds on text metric values", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].value = { kind: "text", value: "available" };
|
||||
invalid.telemetry[0].thresholds = { warning: 10 };
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("text metric values");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects undefined properties because they are not JSON values", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.serviceGroups[0].services[0].link = undefined;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("must be omitted instead of undefined");
|
||||
}
|
||||
});
|
||||
|
||||
it("exports JSON Schema for external tool contracts", () => {
|
||||
expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("telemetry");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("serviceGroups");
|
||||
});
|
||||
});
|
||||
252
apps/web/src/lib/model/schema.ts
Normal file
252
apps/web/src/lib/model/schema.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const DASHBOARD_SCHEMA_VERSION = "dashboard.v1" as const;
|
||||
|
||||
const IdentifierSchema = Type.String({
|
||||
minLength: 1,
|
||||
pattern: "^[a-z0-9][a-z0-9-_.:]*$",
|
||||
});
|
||||
|
||||
const SeveritySchema = Type.Union([
|
||||
Type.Literal("neutral"),
|
||||
Type.Literal("ok"),
|
||||
Type.Literal("warning"),
|
||||
Type.Literal("danger"),
|
||||
Type.Literal("stale"),
|
||||
Type.Literal("unavailable"),
|
||||
]);
|
||||
|
||||
const IconReferenceSchema = Type.String({ minLength: 1 });
|
||||
|
||||
const LinkSchema = Type.Object(
|
||||
{
|
||||
href: Type.String({ format: "uri" }),
|
||||
label: Type.Optional(Type.String({ minLength: 1 })),
|
||||
external: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const StaticDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("static"),
|
||||
label: Type.Optional(Type.String({ minLength: 1 })),
|
||||
updatedAt: Type.Optional(Type.String({ format: "date-time" })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const PlaceholderDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("placeholder"),
|
||||
reason: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ExternalDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("external"),
|
||||
adapter: Type.Union([
|
||||
Type.Literal("prometheus"),
|
||||
Type.Literal("http-status"),
|
||||
Type.Literal("weather"),
|
||||
Type.Literal("custom"),
|
||||
]),
|
||||
reference: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DatasourceReferenceSchema = Type.Union([
|
||||
StaticDatasourceSchema,
|
||||
PlaceholderDatasourceSchema,
|
||||
ExternalDatasourceSchema,
|
||||
]);
|
||||
|
||||
const PercentMetricValueSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Literal("percent"),
|
||||
value: Type.Number({ minimum: 0, maximum: 100 }),
|
||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const NonNegativeMetricValueSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Union([
|
||||
Type.Literal("bytes"),
|
||||
Type.Literal("temperature"),
|
||||
Type.Literal("latency"),
|
||||
Type.Literal("number"),
|
||||
]),
|
||||
value: Type.Number({ minimum: 0 }),
|
||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const TextMetricValueSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Literal("text"),
|
||||
value: Type.String(),
|
||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const MetricValueSchema = Type.Union([
|
||||
PercentMetricValueSchema,
|
||||
NonNegativeMetricValueSchema,
|
||||
TextMetricValueSchema,
|
||||
]);
|
||||
|
||||
export const ThresholdSchema = Type.Object(
|
||||
{
|
||||
warning: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
danger: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
},
|
||||
{ additionalProperties: false, minProperties: 1 },
|
||||
);
|
||||
|
||||
export const TelemetryCardSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
label: Type.String({ minLength: 1 }),
|
||||
description: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(IconReferenceSchema),
|
||||
value: MetricValueSchema,
|
||||
detail: Type.Optional(Type.String()),
|
||||
severity: SeveritySchema,
|
||||
thresholds: Type.Optional(ThresholdSchema),
|
||||
datasource: Type.Optional(DatasourceReferenceSchema),
|
||||
sparkline: Type.Optional(Type.Array(Type.Number())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ServiceEntrySchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
label: Type.String({ minLength: 1 }),
|
||||
description: Type.String(),
|
||||
icon: Type.Optional(IconReferenceSchema),
|
||||
link: Type.Optional(LinkSchema),
|
||||
severity: SeveritySchema,
|
||||
detail: Type.Optional(Type.String()),
|
||||
datasource: Type.Optional(DatasourceReferenceSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ServiceGroupSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
title: Type.String({ minLength: 1 }),
|
||||
layout: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("grid")])),
|
||||
services: Type.Array(ServiceEntrySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const StatusItemSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
label: Type.String({ minLength: 1 }),
|
||||
value: Type.String(),
|
||||
severity: Type.Optional(SeveritySchema),
|
||||
link: Type.Optional(LinkSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const StatusStripSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
title: Type.Optional(Type.String({ minLength: 1 })),
|
||||
items: Type.Array(StatusItemSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DashboardModuleSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
kind: Type.Union([
|
||||
Type.Literal("summary"),
|
||||
Type.Literal("weather"),
|
||||
Type.Literal("custom"),
|
||||
]),
|
||||
title: Type.Optional(Type.String({ minLength: 1 })),
|
||||
label: Type.Optional(Type.String()),
|
||||
value: Type.Optional(Type.String()),
|
||||
detail: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(IconReferenceSchema),
|
||||
severity: Type.Optional(SeveritySchema),
|
||||
datasource: Type.Optional(DatasourceReferenceSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const LayoutSchema = Type.Object(
|
||||
{
|
||||
density: Type.Optional(Type.Union([Type.Literal("compact"), Type.Literal("dense")])),
|
||||
telemetry: Type.Array(IdentifierSchema),
|
||||
serviceGroups: Type.Array(IdentifierSchema),
|
||||
statusStrips: Type.Array(IdentifierSchema),
|
||||
modules: Type.Optional(Type.Array(IdentifierSchema)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const MetadataSchema = Type.Object(
|
||||
{
|
||||
title: Type.String({ minLength: 1 }),
|
||||
subtitle: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
timezone: Type.Optional(Type.String({ minLength: 1 })),
|
||||
refreshIntervalSeconds: Type.Optional(Type.Integer({ minimum: 5 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DashboardDocumentSchema = Type.Object(
|
||||
{
|
||||
schemaVersion: Type.Literal(DASHBOARD_SCHEMA_VERSION),
|
||||
metadata: MetadataSchema,
|
||||
layout: LayoutSchema,
|
||||
telemetry: Type.Array(TelemetryCardSchema),
|
||||
serviceGroups: Type.Array(ServiceGroupSchema),
|
||||
statusStrips: Type.Array(StatusStripSchema),
|
||||
modules: Type.Optional(Type.Array(DashboardModuleSchema)),
|
||||
migration: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
previousVersion: Type.Optional(Type.String({ minLength: 1 })),
|
||||
notes: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
$id: "https://dimensionlab.net/schemas/dashboard-document.v1.json",
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
|
||||
export type Severity = Static<typeof SeveritySchema>;
|
||||
export type DatasourceReference = Static<typeof DatasourceReferenceSchema>;
|
||||
export type MetricValue = Static<typeof MetricValueSchema>;
|
||||
export type TelemetryCard = Static<typeof TelemetryCardSchema>;
|
||||
export type ServiceEntry = Static<typeof ServiceEntrySchema>;
|
||||
export type ServiceGroup = Static<typeof ServiceGroupSchema>;
|
||||
export type StatusItem = Static<typeof StatusItemSchema>;
|
||||
export type StatusStrip = Static<typeof StatusStripSchema>;
|
||||
export type DashboardModule = Static<typeof DashboardModuleSchema>;
|
||||
export type DashboardDocument = Static<typeof DashboardDocumentSchema>;
|
||||
|
||||
export const dashboardDocumentJsonSchema = DashboardDocumentSchema;
|
||||
322
apps/web/src/lib/model/validation.ts
Normal file
322
apps/web/src/lib/model/validation.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
import Ajv, { type ErrorObject } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import {
|
||||
DashboardDocumentSchema,
|
||||
type DashboardDocument,
|
||||
} from "./schema";
|
||||
|
||||
type DashboardValidationIssue = ErrorObject | SemanticValidationIssue;
|
||||
|
||||
interface SemanticValidationIssue {
|
||||
instancePath: string;
|
||||
schemaPath: string;
|
||||
keyword: "semantic";
|
||||
params: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardValidationFailure {
|
||||
valid: false;
|
||||
errors: string[];
|
||||
details: DashboardValidationIssue[];
|
||||
}
|
||||
|
||||
export interface DashboardValidationSuccess {
|
||||
valid: true;
|
||||
data: DashboardDocument;
|
||||
}
|
||||
|
||||
export type DashboardValidationResult =
|
||||
| DashboardValidationFailure
|
||||
| DashboardValidationSuccess;
|
||||
|
||||
const ajv = addFormats(
|
||||
new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
strictNumbers: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
||||
|
||||
export function formatValidationErrors(
|
||||
errors: DashboardValidationIssue[] = [],
|
||||
): string[] {
|
||||
return errors.map((error) => {
|
||||
const path = error.instancePath || "/";
|
||||
const suffix = formatErrorParams(error);
|
||||
const message = error.message || "is invalid";
|
||||
return `${path} ${message}${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDashboardDocument(
|
||||
value: unknown,
|
||||
): DashboardValidationResult {
|
||||
const finiteNumberIssues: SemanticValidationIssue[] = [];
|
||||
const undefinedIssues: SemanticValidationIssue[] = [];
|
||||
collectFiniteNumberIssues(value, "", finiteNumberIssues);
|
||||
collectUndefinedIssues(value, "", undefinedIssues);
|
||||
|
||||
if (validateDashboard(value)) {
|
||||
const semanticIssues = [
|
||||
...finiteNumberIssues,
|
||||
...undefinedIssues,
|
||||
...validateSemanticRules(value),
|
||||
];
|
||||
if (semanticIssues.length === 0) {
|
||||
return { valid: true, data: value };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(semanticIssues),
|
||||
details: semanticIssues,
|
||||
};
|
||||
}
|
||||
|
||||
const details = [
|
||||
...(validateDashboard.errors || []),
|
||||
...finiteNumberIssues,
|
||||
...undefinedIssues,
|
||||
];
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(details),
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertDashboardDocument(
|
||||
value: unknown,
|
||||
): asserts value is DashboardDocument {
|
||||
const result = validateDashboardDocument(value);
|
||||
if (!result.valid) {
|
||||
throw new Error(`Invalid dashboard document: ${result.errors.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isDashboardDocument(value: unknown): value is DashboardDocument {
|
||||
return validateDashboardDocument(value).valid;
|
||||
}
|
||||
|
||||
function formatErrorParams(error: DashboardValidationIssue): string {
|
||||
if (error.keyword === "additionalProperties") {
|
||||
const additionalProperty = error.params.additionalProperty;
|
||||
return typeof additionalProperty === "string"
|
||||
? `: ${additionalProperty}`
|
||||
: "";
|
||||
}
|
||||
|
||||
if (error.keyword === "semantic") {
|
||||
const id = error.params.id;
|
||||
const ref = error.params.ref;
|
||||
if (typeof id === "string") return `: ${id}`;
|
||||
if (typeof ref === "string") return `: ${ref}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateSemanticRules(document: DashboardDocument): SemanticValidationIssue[] {
|
||||
const issues: SemanticValidationIssue[] = [];
|
||||
|
||||
collectDuplicateIdIssues("telemetry", document.telemetry, issues);
|
||||
collectDuplicateIdIssues("serviceGroups", document.serviceGroups, issues);
|
||||
collectDuplicateIdIssues("statusStrips", document.statusStrips, issues);
|
||||
collectDuplicateIdIssues("modules", document.modules || [], issues);
|
||||
|
||||
document.serviceGroups.forEach((group, groupIndex) => {
|
||||
collectDuplicateIdIssues(
|
||||
`serviceGroups/${groupIndex}/services`,
|
||||
group.services,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
|
||||
document.statusStrips.forEach((strip, stripIndex) => {
|
||||
collectDuplicateIdIssues(
|
||||
`statusStrips/${stripIndex}/items`,
|
||||
strip.items,
|
||||
issues,
|
||||
);
|
||||
});
|
||||
|
||||
collectMissingReferenceIssues(
|
||||
"layout/telemetry",
|
||||
document.layout.telemetry,
|
||||
new Set(document.telemetry.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/serviceGroups",
|
||||
document.layout.serviceGroups,
|
||||
new Set(document.serviceGroups.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/statusStrips",
|
||||
document.layout.statusStrips,
|
||||
new Set(document.statusStrips.map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
collectMissingReferenceIssues(
|
||||
"layout/modules",
|
||||
document.layout.modules || [],
|
||||
new Set((document.modules || []).map((item) => item.id)),
|
||||
issues,
|
||||
);
|
||||
|
||||
document.telemetry.forEach((card, index) => {
|
||||
if (card.value.kind === "text" && card.thresholds !== undefined) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"must not be set for text metric values",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
card.value.kind === "percent" &&
|
||||
((card.thresholds?.warning !== undefined && card.thresholds.warning > 100) ||
|
||||
(card.thresholds?.danger !== undefined && card.thresholds.danger > 100))
|
||||
) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"percent thresholds must be between 0 and 100",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const warning = card.thresholds?.warning;
|
||||
const danger = card.thresholds?.danger;
|
||||
if (warning !== undefined && danger !== undefined && warning > danger) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/telemetry/${index}/thresholds`,
|
||||
"warning threshold must be less than or equal to danger threshold",
|
||||
{ id: card.id },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function collectUndefinedIssues(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
if (value === undefined) {
|
||||
issues.push(
|
||||
semanticIssue(path || "/", "must be omitted instead of undefined", {}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
collectUndefinedIssues(item, `${path}/${index}`, issues);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
collectUndefinedIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectFiniteNumberIssues(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) {
|
||||
issues.push(
|
||||
semanticIssue(path || "/", "must be a finite JSON number", {}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
collectFiniteNumberIssues(item, `${path}/${index}`, issues);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && typeof value === "object") {
|
||||
Object.entries(value).forEach(([key, item]) => {
|
||||
collectFiniteNumberIssues(item, `${path}/${escapeJsonPointer(key)}`, issues);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function collectDuplicateIdIssues(
|
||||
collectionPath: string,
|
||||
items: Array<{ id: string }>,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
const seen = new Set<string>();
|
||||
items.forEach((item, index) => {
|
||||
if (seen.has(item.id)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/${collectionPath}/${index}/id`,
|
||||
"must be unique within its collection",
|
||||
{ id: item.id },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
seen.add(item.id);
|
||||
});
|
||||
}
|
||||
|
||||
function collectMissingReferenceIssues(
|
||||
layoutPath: string,
|
||||
refs: string[],
|
||||
validIds: Set<string>,
|
||||
issues: SemanticValidationIssue[],
|
||||
) {
|
||||
refs.forEach((ref, index) => {
|
||||
if (!validIds.has(ref)) {
|
||||
issues.push(
|
||||
semanticIssue(
|
||||
`/${layoutPath}/${index}`,
|
||||
"must reference an existing item",
|
||||
{ ref },
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function semanticIssue(
|
||||
instancePath: string,
|
||||
message: string,
|
||||
params: Record<string, unknown>,
|
||||
): SemanticValidationIssue {
|
||||
return {
|
||||
instancePath,
|
||||
schemaPath: "",
|
||||
keyword: "semantic",
|
||||
params,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeJsonPointer(value: string): string {
|
||||
return value.replaceAll("~", "~0").replaceAll("/", "~1");
|
||||
}
|
||||
78
apps/web/src/lib/presentation-boundary.test.ts
Normal file
78
apps/web/src/lib/presentation-boundary.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const appRoot = process.cwd().endsWith(`${join("apps", "web")}`)
|
||||
? process.cwd()
|
||||
: join(process.cwd(), "apps", "web");
|
||||
const repoRoot = existsSync(join(process.cwd(), "turbo.json"))
|
||||
? process.cwd()
|
||||
: join(appRoot, "..", "..");
|
||||
const presentationRoots = [
|
||||
join(repoRoot, "packages", "ui", "src"),
|
||||
join(appRoot, "src", "App.tsx"),
|
||||
join(appRoot, "src", "app.css"),
|
||||
join(appRoot, "src", "lib", "ui-adapter"),
|
||||
];
|
||||
|
||||
const forbiddenTerms = [
|
||||
"dimensionlab",
|
||||
"dimension lab",
|
||||
"vaultwarden",
|
||||
"forgejo",
|
||||
"grafana",
|
||||
"uptime kuma",
|
||||
"prometheus",
|
||||
"backrest",
|
||||
"open webui",
|
||||
"comfyui",
|
||||
"adminer",
|
||||
"cockpit",
|
||||
"ollama",
|
||||
"dimensionlab.net",
|
||||
];
|
||||
|
||||
describe("presentation content boundary", () => {
|
||||
test("keeps environment-specific content out of route and UI implementation", () => {
|
||||
const source = withoutInternalPackageScope(
|
||||
presentationRoots.map(readPresentationSource).join("\n").toLowerCase(),
|
||||
);
|
||||
|
||||
expect(forbiddenTerms.filter((term) => source.includes(term))).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not keep legacy presentation component files in the React runtime", () => {
|
||||
const legacyExtension = [".sve", "lte"].join("");
|
||||
|
||||
expect(findFiles(join(appRoot, "src"), legacyExtension)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function readPresentationSource(path: string): string {
|
||||
if (!existsSync(path)) return "";
|
||||
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) {
|
||||
if (path.endsWith(".test.ts")) return "";
|
||||
if (path.includes(`${join("packages", "ui", "src", "stories")}${"/"}`)) {
|
||||
return "";
|
||||
}
|
||||
if (!/\.(tsx|ts|js|mjs|css|json)$/.test(path)) return "";
|
||||
return readFileSync(path, "utf8");
|
||||
}
|
||||
|
||||
return readdirSync(path)
|
||||
.map((entry) => readPresentationSource(join(path, entry)))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function findFiles(path: string, extension: string): string[] {
|
||||
const stats = statSync(path);
|
||||
if (stats.isFile()) return path.endsWith(extension) ? [path] : [];
|
||||
|
||||
return readdirSync(path).flatMap((entry) => findFiles(join(path, entry), extension));
|
||||
}
|
||||
|
||||
function withoutInternalPackageScope(source: string): string {
|
||||
return source.replaceAll("@dimensionlab/ui", "@internal/ui");
|
||||
}
|
||||
595
apps/web/src/lib/server/agent-config/agent-config.test.ts
Normal file
595
apps/web/src/lib/server/agent-config/agent-config.test.ts
Normal file
|
|
@ -0,0 +1,595 @@
|
|||
import { existsSync, rmSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type { DashboardDocument } from "$lib/model";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store";
|
||||
import {
|
||||
AgentConfigAuthorizationError,
|
||||
authorizeAgentConfigRequest,
|
||||
handleAgentDashboardRequest,
|
||||
previewDashboardChanges,
|
||||
publishDashboardChanges,
|
||||
rollbackDashboardRevision,
|
||||
type AgentDashboardOperation,
|
||||
type JsonPatchOperation,
|
||||
} from ".";
|
||||
|
||||
const stores: DashboardStore[] = [];
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
stores.splice(0).forEach((store) => store.close());
|
||||
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
|
||||
});
|
||||
|
||||
describe("agent dashboard configuration API", () => {
|
||||
test("previews typed dashboard operations with an RFC 6902-compatible patch", () => {
|
||||
const operations = exampleOperations();
|
||||
|
||||
const result = previewDashboardChanges(genericDashboardFixture, operations);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected preview success");
|
||||
expect(result.document.serviceGroups[0]?.id).toBe("edge");
|
||||
expect(result.document.serviceGroups[0]?.services[0]).toMatchObject({
|
||||
id: "edge-router",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://edge.example.test/api/status",
|
||||
},
|
||||
});
|
||||
expect(result.document.layout.telemetry).toEqual([
|
||||
"service-uptime",
|
||||
"edge-latency",
|
||||
"queue-depth",
|
||||
]);
|
||||
expect(genericDashboardFixture.serviceGroups.map((group) => group.id)).toEqual([
|
||||
"core-services",
|
||||
]);
|
||||
expect(result.patch.length).toBeGreaterThan(0);
|
||||
expect(result.patch.every((operation) => operation.path.startsWith("/"))).toBe(true);
|
||||
expect(result.patch.map((operation) => operation.op)).toContain("add");
|
||||
});
|
||||
|
||||
test("returns structured repairable errors for invalid operations", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "add_service",
|
||||
groupId: "missing-group",
|
||||
service: exampleService(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected preview failure");
|
||||
expect(result.errors).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "group_not_found",
|
||||
operationIndex: 0,
|
||||
path: "/serviceGroups",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects unsupported target-specific mutations", () => {
|
||||
const datasourceResult = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "connect_datasource",
|
||||
target: { kind: "statusItem", stripId: "runtime", id: "status" },
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://status.example.test/api",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(datasourceResult.ok).toBe(false);
|
||||
if (datasourceResult.ok) throw new Error("expected datasource failure");
|
||||
expect(datasourceResult.errors[0]).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
operationIndex: 0,
|
||||
path: "/statusStrips",
|
||||
});
|
||||
|
||||
const thresholdResult = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "set_status_rule",
|
||||
target: { kind: "service", id: "identity" },
|
||||
thresholds: { warning: 1 },
|
||||
},
|
||||
]);
|
||||
expect(thresholdResult.ok).toBe(false);
|
||||
if (thresholdResult.ok) throw new Error("expected threshold failure");
|
||||
expect(thresholdResult.errors[0]).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
operationIndex: 0,
|
||||
path: "/serviceGroups",
|
||||
});
|
||||
});
|
||||
|
||||
test("removes status strip items through the shared remove operation", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "statusItem", stripId: "runtime", id: "sync" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected remove success");
|
||||
expect(result.document.statusStrips[0]?.items.map((item) => item.id)).toEqual([
|
||||
"status",
|
||||
]);
|
||||
expect(result.patch.map((operation) => operation.op)).toContain("remove");
|
||||
});
|
||||
|
||||
test("returns RFC 6902-applicable patches for multiple array removals", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "statusItem", stripId: "runtime", id: "status" },
|
||||
},
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "statusItem", stripId: "runtime", id: "sync" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected remove success");
|
||||
expect(
|
||||
result.patch
|
||||
.filter((operation) => operation.op === "remove")
|
||||
.map((operation) => operation.path),
|
||||
).toEqual(["/statusStrips/0/items/1", "/statusStrips/0/items/0"]);
|
||||
expect(applyJsonPatch(genericDashboardFixture, result.patch)).toMatchObject({
|
||||
statusStrips: [{ items: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects ambiguous service and status targets", () => {
|
||||
const duplicateDocument = documentWithDuplicateNestedIds();
|
||||
|
||||
const connect = previewDashboardChanges(duplicateDocument, [
|
||||
{
|
||||
type: "connect_datasource",
|
||||
target: { kind: "service", id: "identity" },
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://identity.example.test/health",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(connect.ok).toBe(false);
|
||||
if (connect.ok) throw new Error("expected ambiguous service failure");
|
||||
expect(connect.errors[0]).toMatchObject({
|
||||
code: "ambiguous_target",
|
||||
operationIndex: 0,
|
||||
path: "/serviceGroups",
|
||||
});
|
||||
|
||||
const remove = previewDashboardChanges(duplicateDocument, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "service", id: "identity" },
|
||||
},
|
||||
]);
|
||||
expect(remove.ok).toBe(false);
|
||||
if (remove.ok) throw new Error("expected ambiguous removal failure");
|
||||
expect(remove.errors[0]).toMatchObject({
|
||||
code: "ambiguous_target",
|
||||
operationIndex: 0,
|
||||
path: "/serviceGroups",
|
||||
});
|
||||
|
||||
const status = previewDashboardChanges(duplicateDocument, [
|
||||
{
|
||||
type: "set_status_rule",
|
||||
target: { kind: "statusItem", id: "status" },
|
||||
value: "Healthy",
|
||||
},
|
||||
]);
|
||||
expect(status.ok).toBe(false);
|
||||
if (status.ok) throw new Error("expected ambiguous status failure");
|
||||
expect(status.errors[0]).toMatchObject({
|
||||
code: "ambiguous_target",
|
||||
operationIndex: 0,
|
||||
path: "/statusStrips",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects create_dashboard when it is not the first operation", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "telemetry", id: "queue-depth" },
|
||||
},
|
||||
{
|
||||
type: "create_dashboard",
|
||||
document: genericDashboardFixture,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected sequence failure");
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
code: "invalid_operation_sequence",
|
||||
operationIndex: 1,
|
||||
path: "/1",
|
||||
});
|
||||
});
|
||||
|
||||
test("publishes valid operations as a persisted dashboard revision", async () => {
|
||||
const { dbPath, store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
message: "initial dashboard",
|
||||
});
|
||||
|
||||
const result = publishDashboardChanges(store, exampleOperations(), {
|
||||
actor: "agent",
|
||||
message: "add edge router",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected publish success");
|
||||
expect(existsSync(dbPath)).toBe(true);
|
||||
expect(result.revision.operation).toBe("commit");
|
||||
expect(result.revision.actor).toBe("agent");
|
||||
expect(result.revision.message).toBe("add edge router");
|
||||
expect(result.previousRevisionId).toBe(seed.id);
|
||||
expect(result.patch.length).toBeGreaterThan(0);
|
||||
expect(store.getActiveDashboard()?.document.serviceGroups[0]?.id).toBe("edge");
|
||||
expect(store.listRevisions()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("does not publish invalid operations", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
|
||||
const result = publishDashboardChanges(store, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "telemetry", id: "missing-metric" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected publish failure");
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
code: "item_not_found",
|
||||
operationIndex: 0,
|
||||
});
|
||||
expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id);
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rolls back through the agent-safe revision path", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
const publish = publishDashboardChanges(store, exampleOperations(), {
|
||||
actor: "agent",
|
||||
});
|
||||
expect(publish.ok).toBe(true);
|
||||
|
||||
const rollback = rollbackDashboardRevision(store, seed.id, {
|
||||
actor: "agent",
|
||||
message: "restore previous dashboard",
|
||||
});
|
||||
|
||||
expect(rollback.operation).toBe("rollback");
|
||||
expect(rollback.actor).toBe("agent");
|
||||
expect(rollback.sourceRevisionId).toBe(seed.id);
|
||||
expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.listRevisions().map((revision) => revision.operation)).toEqual([
|
||||
"rollback",
|
||||
"commit",
|
||||
"seed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("authenticates agent configuration requests with a shared token", () => {
|
||||
const authorized = new Request("https://dimensionlab.test/api/agent/dashboard", {
|
||||
headers: { authorization: "Bearer shared-secret" },
|
||||
});
|
||||
const rejected = new Request("https://dimensionlab.test/api/agent/dashboard");
|
||||
|
||||
expect(authorizeAgentConfigRequest(authorized, "shared-secret")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(() => authorizeAgentConfigRequest(rejected, "shared-secret")).toThrow(
|
||||
AgentConfigAuthorizationError,
|
||||
);
|
||||
expect(() => authorizeAgentConfigRequest(authorized, "")).toThrow(
|
||||
AgentConfigAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("handles preview, publish, and rollback requests through the HTTP adapter", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
|
||||
const preview = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "preview_changes",
|
||||
operations: exampleOperations(),
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
expect(preview.status).toBe(200);
|
||||
expect(await preview.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "preview_changes",
|
||||
patch: expect.any(Array),
|
||||
});
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
|
||||
const publish = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "publish_changes",
|
||||
actor: "agent",
|
||||
message: "publish edge router",
|
||||
operations: exampleOperations(),
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
expect(publish.status).toBe(200);
|
||||
expect(await publish.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "publish_changes",
|
||||
revision: { operation: "commit", actor: "agent" },
|
||||
});
|
||||
expect(store.listRevisions()).toHaveLength(2);
|
||||
|
||||
const rollback = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "rollback_revision",
|
||||
actor: "agent",
|
||||
revisionId: seed.id,
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
expect(rollback.status).toBe(200);
|
||||
expect(await rollback.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "rollback_revision",
|
||||
revision: { operation: "rollback", sourceRevisionId: seed.id },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a structured error for unknown rollback revisions", async () => {
|
||||
const { store } = await createTestStore();
|
||||
store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
|
||||
const rollback = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "rollback_revision",
|
||||
actor: "agent",
|
||||
revisionId: "missing-revision",
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
|
||||
expect(rollback.status).toBe(404);
|
||||
expect(await rollback.json()).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "revision_not_found",
|
||||
path: "/revisionId",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("previews create_dashboard requests against an empty store", async () => {
|
||||
const { store } = await createTestStore();
|
||||
|
||||
const preview = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "preview_changes",
|
||||
operations: [
|
||||
{
|
||||
type: "create_dashboard",
|
||||
document: genericDashboardFixture,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
|
||||
expect(preview.status).toBe(200);
|
||||
expect(await preview.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "preview_changes",
|
||||
document: {
|
||||
metadata: {
|
||||
title: "Operations Console",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(store.listRevisions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function createTestStore() {
|
||||
const root = await mkdtemp(join(tmpdir(), "dimensionlab-agent-config-"));
|
||||
tempRoots.push(root);
|
||||
const dbPath = join(root, "dashboard.sqlite");
|
||||
const store = createDashboardStore({
|
||||
databaseUrl: `file:${dbPath}`,
|
||||
});
|
||||
stores.push(store);
|
||||
|
||||
return { dbPath, store };
|
||||
}
|
||||
|
||||
function exampleOperations(): AgentDashboardOperation[] {
|
||||
return [
|
||||
{
|
||||
type: "add_section",
|
||||
section: {
|
||||
id: "edge",
|
||||
title: "Edge",
|
||||
layout: "list",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "add_service",
|
||||
groupId: "edge",
|
||||
service: exampleService(),
|
||||
},
|
||||
{
|
||||
type: "add_metric_card",
|
||||
card: {
|
||||
id: "edge-latency",
|
||||
label: "Edge Latency",
|
||||
value: { kind: "latency", value: 12, precision: 0 },
|
||||
severity: "ok",
|
||||
detail: "p95",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "prometheus",
|
||||
reference: "histogram_quantile(0.95, edge_request_duration_seconds_bucket)",
|
||||
},
|
||||
},
|
||||
position: { afterId: "service-uptime" },
|
||||
},
|
||||
{
|
||||
type: "connect_datasource",
|
||||
target: {
|
||||
kind: "service",
|
||||
groupId: "edge",
|
||||
id: "edge-router",
|
||||
},
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://edge.example.test/api/status",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "set_status_rule",
|
||||
target: { kind: "telemetry", id: "edge-latency" },
|
||||
thresholds: { warning: 50, danger: 100 },
|
||||
},
|
||||
{
|
||||
type: "arrange_item",
|
||||
area: "serviceGroups",
|
||||
id: "edge",
|
||||
index: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function exampleService() {
|
||||
return {
|
||||
id: "edge-router",
|
||||
label: "Edge Router",
|
||||
description: "Ingress and routing",
|
||||
icon: "mdi:router-network",
|
||||
severity: "ok" as const,
|
||||
detail: "pending datasource",
|
||||
datasource: { type: "placeholder" as const, reason: "health adapter pending" },
|
||||
link: {
|
||||
href: "https://edge.example.test",
|
||||
label: "Open Edge Router",
|
||||
external: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function documentWithDuplicateNestedIds(): DashboardDocument {
|
||||
const document = structuredClone(genericDashboardFixture);
|
||||
document.layout.serviceGroups.push("secondary-services");
|
||||
document.serviceGroups.push({
|
||||
id: "secondary-services",
|
||||
title: "Secondary Services",
|
||||
layout: "list",
|
||||
services: [
|
||||
{
|
||||
...document.serviceGroups[0].services[0],
|
||||
label: "Shadow Identity",
|
||||
},
|
||||
],
|
||||
});
|
||||
document.layout.statusStrips.push("secondary-runtime");
|
||||
document.statusStrips.push({
|
||||
id: "secondary-runtime",
|
||||
items: [
|
||||
{
|
||||
...document.statusStrips[0].items[0],
|
||||
value: "Healthy",
|
||||
},
|
||||
],
|
||||
});
|
||||
return document;
|
||||
}
|
||||
|
||||
function applyJsonPatch<T>(value: T, patch: JsonPatchOperation[]): T {
|
||||
const next = structuredClone(value);
|
||||
for (const operation of patch) {
|
||||
const { parent, key } = jsonPointerTarget(next, operation.path);
|
||||
if (operation.op === "remove") {
|
||||
if (Array.isArray(parent)) {
|
||||
parent.splice(Number(key), 1);
|
||||
} else {
|
||||
delete parent[key];
|
||||
}
|
||||
} else if (operation.op === "add") {
|
||||
if (Array.isArray(parent)) {
|
||||
parent.splice(Number(key), 0, operation.value);
|
||||
} else {
|
||||
parent[key] = operation.value;
|
||||
}
|
||||
} else if (operation.op === "replace") {
|
||||
if (Array.isArray(parent)) {
|
||||
parent[Number(key)] = operation.value;
|
||||
} else {
|
||||
parent[key] = operation.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function jsonPointerTarget(value: unknown, path: string) {
|
||||
const segments = path
|
||||
.split("/")
|
||||
.slice(1)
|
||||
.map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
|
||||
const key = segments.pop();
|
||||
if (key === undefined) throw new Error(`Invalid JSON pointer: ${path}`);
|
||||
|
||||
let parent = value as Record<string, unknown> | unknown[];
|
||||
for (const segment of segments) {
|
||||
parent = Array.isArray(parent)
|
||||
? (parent[Number(segment)] as Record<string, unknown> | unknown[])
|
||||
: (parent[segment] as Record<string, unknown> | unknown[]);
|
||||
}
|
||||
return { parent, key };
|
||||
}
|
||||
|
||||
function jsonRequest(body: unknown) {
|
||||
return new Request("https://dimensionlab.test/api/agent/dashboard", {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
authorization: "Bearer shared-secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
1292
apps/web/src/lib/server/agent-config/index.ts
Normal file
1292
apps/web/src/lib/server/agent-config/index.ts
Normal file
File diff suppressed because it is too large
Load diff
150
apps/web/src/lib/server/dashboard.test.ts
Normal file
150
apps/web/src/lib/server/dashboard.test.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import { rmSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
|
||||
import { loadDashboardRuntime } from "./dashboard";
|
||||
|
||||
const stores: DashboardStore[] = [];
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
stores.splice(0).forEach((store) => store.close());
|
||||
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
|
||||
});
|
||||
|
||||
describe("dashboard runtime loader", () => {
|
||||
test("seeds and loads the active dashboard from sqlite", async () => {
|
||||
const store = await createTestStore();
|
||||
|
||||
const runtime = loadDashboardRuntime(store, { seedIfEmpty: true });
|
||||
|
||||
expect(runtime.state).toBe("ready");
|
||||
if (runtime.state !== "ready") throw new Error("expected ready dashboard");
|
||||
expect(runtime.document.metadata.title).toBe(dimensionLabDashboardFixture.metadata.title);
|
||||
expect(runtime.document.metadata.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle);
|
||||
expect(runtime.schemaVersion).toBe(dimensionLabDashboardFixture.schemaVersion);
|
||||
expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId);
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("loads an existing active dashboard without reseeding", async () => {
|
||||
const store = await createTestStore();
|
||||
const seed = store.commitDashboard(genericDashboardFixture, {
|
||||
actor: "test",
|
||||
message: "existing dashboard",
|
||||
});
|
||||
|
||||
const runtime = loadDashboardRuntime(store);
|
||||
|
||||
expect(runtime.state).toBe("ready");
|
||||
if (runtime.state !== "ready") throw new Error("expected ready dashboard");
|
||||
expect(runtime.currentRevisionId).toBe(seed.id);
|
||||
expect(runtime.document.metadata.title).toBe(genericDashboardFixture.metadata.title);
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("refreshes an existing initial seed when the bundled seed changes", async () => {
|
||||
const store = await createTestStore();
|
||||
const oldSeed = olderDimensionLabSeed();
|
||||
store.seedDashboardIfEmpty(oldSeed, {
|
||||
actor: "initial-seed",
|
||||
message: "load initial dashboard document",
|
||||
});
|
||||
|
||||
const runtime = loadDashboardRuntime(store, {
|
||||
refreshSeedDocument: true,
|
||||
seedDocument: dimensionLabDashboardFixture,
|
||||
seedIfEmpty: true,
|
||||
});
|
||||
|
||||
expect(runtime.state).toBe("ready");
|
||||
if (runtime.state !== "ready") throw new Error("expected ready dashboard");
|
||||
expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).toContain(
|
||||
"auto-refresh",
|
||||
);
|
||||
expect(runtime.document.serviceGroups.flatMap((group) => group.services).map((service) => service.id)).toContain(
|
||||
"prompt-registry",
|
||||
);
|
||||
expect(store.listRevisions()).toHaveLength(2);
|
||||
expect(store.getActiveDashboard()?.revision.actor).toBe("initial-seed");
|
||||
});
|
||||
|
||||
test("does not refresh a dashboard after a user-authored revision", async () => {
|
||||
const store = await createTestStore();
|
||||
const oldSeed = olderDimensionLabSeed();
|
||||
store.seedDashboardIfEmpty(oldSeed, {
|
||||
actor: "initial-seed",
|
||||
message: "load initial dashboard document",
|
||||
});
|
||||
store.commitDashboard(
|
||||
{
|
||||
...oldSeed,
|
||||
metadata: {
|
||||
...oldSeed.metadata,
|
||||
title: "Custom Dashboard",
|
||||
},
|
||||
},
|
||||
{
|
||||
actor: "agent",
|
||||
message: "customize dashboard",
|
||||
},
|
||||
);
|
||||
|
||||
const runtime = loadDashboardRuntime(store, {
|
||||
refreshSeedDocument: true,
|
||||
seedDocument: dimensionLabDashboardFixture,
|
||||
seedIfEmpty: true,
|
||||
});
|
||||
|
||||
expect(runtime.state).toBe("ready");
|
||||
if (runtime.state !== "ready") throw new Error("expected ready dashboard");
|
||||
expect(runtime.document.metadata.title).toBe("Custom Dashboard");
|
||||
expect(runtime.document.statusStrips[0]?.items.map((item) => item.id)).not.toContain(
|
||||
"auto-refresh",
|
||||
);
|
||||
expect(store.listRevisions()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("returns empty state when no dashboard is active and seeding is disabled", async () => {
|
||||
const store = await createTestStore();
|
||||
|
||||
const runtime = loadDashboardRuntime(store);
|
||||
|
||||
expect(runtime.state).toBe("empty");
|
||||
if (runtime.state !== "empty") throw new Error("expected empty dashboard");
|
||||
expect(runtime.title).toBe("No Dashboard Model");
|
||||
expect(store.listRevisions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function createTestStore() {
|
||||
const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-runtime-"));
|
||||
tempRoots.push(root);
|
||||
const store = createDashboardStore({
|
||||
databaseUrl: `file:${join(root, "dashboard.sqlite")}`,
|
||||
});
|
||||
stores.push(store);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
function olderDimensionLabSeed() {
|
||||
const document = structuredClone(dimensionLabDashboardFixture);
|
||||
document.statusStrips = document.statusStrips.map((strip) => ({
|
||||
...strip,
|
||||
items: strip.items.filter((item) => item.id !== "auto-refresh"),
|
||||
}));
|
||||
document.serviceGroups = document.serviceGroups.map((group) =>
|
||||
group.id === "ai-automation"
|
||||
? {
|
||||
...group,
|
||||
services: group.services.filter((service) => service.id !== "prompt-registry"),
|
||||
}
|
||||
: group,
|
||||
);
|
||||
return document;
|
||||
}
|
||||
147
apps/web/src/lib/server/dashboard.ts
Normal file
147
apps/web/src/lib/server/dashboard.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||
import type { DashboardDocument } from "$lib/model";
|
||||
import {
|
||||
createDashboardStore,
|
||||
DashboardPersistenceValidationError,
|
||||
type DashboardStore,
|
||||
} from "$lib/server/db/dashboard-store";
|
||||
import { UnsupportedDashboardModelVersionError } from "$lib/server/db/model-migrations";
|
||||
|
||||
export type DashboardRuntimeState =
|
||||
| DashboardRuntimeEmpty
|
||||
| DashboardRuntimeInvalid
|
||||
| DashboardRuntimeLoading
|
||||
| DashboardRuntimeReady;
|
||||
|
||||
export interface DashboardRuntimeReady {
|
||||
state: "ready";
|
||||
document: DashboardDocument;
|
||||
schemaVersion: string;
|
||||
currentRevisionId: string;
|
||||
}
|
||||
|
||||
export interface DashboardRuntimeEmpty {
|
||||
state: "empty";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardRuntimeLoading {
|
||||
state: "loading";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DashboardRuntimeInvalid {
|
||||
state: "invalid";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
message: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface DashboardRuntimeOptions {
|
||||
refreshSeedDocument?: boolean;
|
||||
seedIfEmpty?: boolean;
|
||||
seedDocument?: DashboardDocument;
|
||||
}
|
||||
|
||||
export function loadDashboardRuntime(
|
||||
store?: DashboardStore,
|
||||
options: DashboardRuntimeOptions = {},
|
||||
): DashboardRuntimeState {
|
||||
const dashboardStore = store || createDashboardStore();
|
||||
|
||||
try {
|
||||
const seedDocument = options.seedDocument || dimensionLabDashboardFixture;
|
||||
const active = dashboardStore.getActiveDashboard();
|
||||
if (active) {
|
||||
if (
|
||||
options.refreshSeedDocument &&
|
||||
shouldRefreshSeedDashboard(active, seedDocument)
|
||||
) {
|
||||
const refreshed = dashboardStore.commitDashboard(seedDocument, {
|
||||
actor: "initial-seed",
|
||||
message: "refresh bundled dashboard document",
|
||||
});
|
||||
return readyRuntimeState(refreshed.document, refreshed.id);
|
||||
}
|
||||
|
||||
return readyRuntimeState(active.document, active.currentRevisionId);
|
||||
}
|
||||
|
||||
if (!options.seedIfEmpty) {
|
||||
return {
|
||||
state: "empty",
|
||||
title: "No Dashboard Model",
|
||||
subtitle: "No active document",
|
||||
message: "No validated dashboard document is active yet.",
|
||||
};
|
||||
}
|
||||
|
||||
const seeded = dashboardStore.seedDashboardIfEmpty(
|
||||
seedDocument,
|
||||
{
|
||||
actor: "initial-seed",
|
||||
message: "load initial dashboard document",
|
||||
},
|
||||
);
|
||||
|
||||
return readyRuntimeState(seeded.document, seeded.id);
|
||||
} catch (error) {
|
||||
if (error instanceof DashboardPersistenceValidationError) {
|
||||
return invalidRuntimeState(error.failure.errors);
|
||||
}
|
||||
|
||||
if (error instanceof UnsupportedDashboardModelVersionError) {
|
||||
return invalidRuntimeState([error.message]);
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (!store) dashboardStore.close();
|
||||
}
|
||||
}
|
||||
|
||||
function readyRuntimeState(
|
||||
document: DashboardDocument,
|
||||
currentRevisionId: string,
|
||||
): DashboardRuntimeReady {
|
||||
return {
|
||||
state: "ready",
|
||||
document,
|
||||
schemaVersion: document.schemaVersion,
|
||||
currentRevisionId,
|
||||
};
|
||||
}
|
||||
|
||||
function invalidRuntimeState(errors: string[]): DashboardRuntimeInvalid {
|
||||
return {
|
||||
state: "invalid",
|
||||
title: "Invalid Dashboard Model",
|
||||
subtitle: "Validation failed",
|
||||
message: "The active dashboard document could not be validated.",
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRefreshSeedDashboard(
|
||||
active: { document: DashboardDocument; revision: { actor: string } },
|
||||
seedDocument: DashboardDocument,
|
||||
): boolean {
|
||||
if (active.revision.actor !== "initial-seed") return false;
|
||||
if (!isBundledDimensionLabSeed(active.document, seedDocument)) return false;
|
||||
return JSON.stringify(active.document) !== JSON.stringify(seedDocument);
|
||||
}
|
||||
|
||||
function isBundledDimensionLabSeed(
|
||||
document: DashboardDocument,
|
||||
seedDocument: DashboardDocument,
|
||||
): boolean {
|
||||
return (
|
||||
document.metadata.title === seedDocument.metadata.title &&
|
||||
document.metadata.description === seedDocument.metadata.description
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
type DashboardDocument,
|
||||
} from "$lib/model";
|
||||
import { resolveDashboardDatasources } from ".";
|
||||
|
||||
describe("dashboard datasource resolution", () => {
|
||||
test("hydrates telemetry, service health, weather, and summary data from live adapters", 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, "40"],
|
||||
[1771430120, "30"],
|
||||
],
|
||||
},
|
||||
{
|
||||
metric: { host: "linux-gpu" },
|
||||
values: [
|
||||
[1771430000, "12"],
|
||||
[1771430060, "24"],
|
||||
[1771430120, "36"],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.startsWith("https://prometheus.example/api/v1/query")) {
|
||||
const query = new URL(url).searchParams.get("query") || "";
|
||||
if (query.includes("node_boot_time_seconds")) {
|
||||
return prometheusVector("90061");
|
||||
}
|
||||
if (query.includes("node_load15")) return prometheusVector("0.40");
|
||||
if (query.includes("node_load5")) return prometheusVector("0.46");
|
||||
if (query.includes("node_load1")) return prometheusVector("0.34");
|
||||
|
||||
return jsonResponse({
|
||||
status: "success",
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { host: "linux-infra", mountpoint: "/home" },
|
||||
value: [1771430400, "88"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.startsWith("https://api.open-meteo.com/v1/forecast")) {
|
||||
return jsonResponse({
|
||||
current: {
|
||||
apparent_temperature: 20.9,
|
||||
temperature_2m: 21.4,
|
||||
weather_code: 0,
|
||||
wind_speed_10m: 12,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url === "https://service.example/health") {
|
||||
return jsonResponse({
|
||||
status: "UP",
|
||||
ping: 42,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled test request: ${url}`);
|
||||
});
|
||||
|
||||
const resolved = await resolveDashboardDatasources(testDashboard(), {
|
||||
fetch,
|
||||
prometheusBaseUrl: "https://prometheus.example",
|
||||
});
|
||||
|
||||
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" }),
|
||||
);
|
||||
|
||||
const telemetry = resolved.telemetry[0];
|
||||
expect(telemetry.value).toEqual({ kind: "percent", value: 88 });
|
||||
expect(telemetry.severity).toBe("warning");
|
||||
expect(telemetry.detail).toBe("linux-infra /home");
|
||||
expect(telemetry.sparkline).toEqual([12, 40, 36]);
|
||||
|
||||
const service = resolved.serviceGroups[0].services[0];
|
||||
expect(service.severity).toBe("ok");
|
||||
expect(service.detail).toBe("42 ms");
|
||||
|
||||
const weather = resolved.modules?.find((module) => module.id === "weather-amsterdam");
|
||||
expect(weather?.value).toBe("21.4 C");
|
||||
expect(weather?.detail).toBe("Clear - feels 20.9 C - wind 12 km/h");
|
||||
expect(weather?.severity).toBe("ok");
|
||||
|
||||
const summary = resolved.modules?.find((module) => module.id === "runtime-health-summary");
|
||||
expect(summary?.value).toBe("all systems operational");
|
||||
expect(summary?.detail).toBe("1 service ok");
|
||||
expect(summary?.severity).toBe("ok");
|
||||
|
||||
expect(resolved.statusStrips[0].items).toEqual([
|
||||
{ id: "system-status", label: "System Status", value: "All systems operational", severity: "ok" },
|
||||
{ id: "last-sync", label: "Last Sync", value: "just now", severity: "ok" },
|
||||
{ id: "uptime", label: "Uptime", value: "1d 1h 1m", severity: "ok" },
|
||||
{ id: "load-avg", label: "Load Avg", value: "0.34 0.46 0.40", severity: "neutral" },
|
||||
{ id: "auto-refresh", label: "Auto Refresh", value: "15s", severity: "neutral" },
|
||||
]);
|
||||
|
||||
expect(resolved).not.toBe(testDocument);
|
||||
expect(testDocument.telemetry[0].value.value).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
const testDocument = testDashboard();
|
||||
|
||||
function testDashboard(): DashboardDocument {
|
||||
return {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
metadata: {
|
||||
title: "System Overview",
|
||||
refreshIntervalSeconds: 15,
|
||||
},
|
||||
layout: {
|
||||
telemetry: ["cpu"],
|
||||
serviceGroups: ["services"],
|
||||
statusStrips: ["footer"],
|
||||
modules: ["weather-amsterdam", "runtime-health-summary"],
|
||||
},
|
||||
telemetry: [
|
||||
{
|
||||
id: "cpu",
|
||||
label: "CPU",
|
||||
value: { kind: "percent", value: 1 },
|
||||
detail: "fallback",
|
||||
severity: "stale",
|
||||
thresholds: { warning: 70, danger: 90 },
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "prometheus",
|
||||
reference: "fixture_cpu_query",
|
||||
},
|
||||
sparkline: [1],
|
||||
},
|
||||
],
|
||||
serviceGroups: [
|
||||
{
|
||||
id: "services",
|
||||
title: "Services",
|
||||
services: [
|
||||
{
|
||||
id: "api",
|
||||
label: "API",
|
||||
description: "Example API",
|
||||
severity: "stale",
|
||||
detail: "fallback",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://service.example/health",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
statusStrips: [
|
||||
{
|
||||
id: "footer",
|
||||
items: [
|
||||
{ id: "system-status", label: "System Status", value: "fallback", severity: "ok" },
|
||||
{ id: "last-sync", label: "Last Sync", value: "fallback", severity: "stale" },
|
||||
{ id: "uptime", label: "Uptime", value: "fallback", severity: "ok" },
|
||||
{ id: "load-avg", label: "Load Avg", value: "fallback", severity: "neutral" },
|
||||
{ id: "auto-refresh", label: "Auto Refresh", value: "fallback", severity: "neutral" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "weather-amsterdam",
|
||||
kind: "weather",
|
||||
title: "Amsterdam",
|
||||
value: "fallback",
|
||||
detail: "fallback",
|
||||
severity: "stale",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "weather",
|
||||
reference: "open-meteo:latitude=52.3676&longitude=4.9041",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "runtime-health-summary",
|
||||
kind: "summary",
|
||||
title: "Runtime Health",
|
||||
value: "fallback",
|
||||
detail: "fallback",
|
||||
severity: "stale",
|
||||
datasource: {
|
||||
type: "placeholder",
|
||||
reason: "summary pending live health aggregation",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function prometheusVector(value: string): Response {
|
||||
return jsonResponse({
|
||||
status: "success",
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { host: "linux-infra" },
|
||||
value: [1771430400, value],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
675
apps/web/src/lib/server/datasources/index.ts
Normal file
675
apps/web/src/lib/server/datasources/index.ts
Normal file
|
|
@ -0,0 +1,675 @@
|
|||
import type {
|
||||
DashboardDocument,
|
||||
DashboardModule,
|
||||
MetricValue,
|
||||
ServiceEntry,
|
||||
ServiceGroup,
|
||||
Severity,
|
||||
StatusItem,
|
||||
StatusStrip,
|
||||
TelemetryCard,
|
||||
} from "$lib/model";
|
||||
|
||||
export interface DatasourceResolutionOptions {
|
||||
fetch?: DatasourceFetch;
|
||||
prometheusBaseUrl?: string;
|
||||
prometheusRangeSeconds?: number;
|
||||
prometheusStepSeconds?: number;
|
||||
requestTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export async function resolveDashboardDatasources(
|
||||
document: DashboardDocument,
|
||||
options: DatasourceResolutionOptions = {},
|
||||
): Promise<DashboardDocument> {
|
||||
const context = datasourceContext(options);
|
||||
const telemetry = await Promise.all(
|
||||
document.telemetry.map((card) => resolveTelemetryCard(card, context)),
|
||||
);
|
||||
const serviceGroups = await Promise.all(
|
||||
document.serviceGroups.map((group) => resolveServiceGroup(group, context)),
|
||||
);
|
||||
const modules = await resolveModules(document.modules || [], serviceGroups, context);
|
||||
const statusStrips = await resolveStatusStrips(
|
||||
document.statusStrips,
|
||||
document.metadata.refreshIntervalSeconds,
|
||||
serviceGroups,
|
||||
context,
|
||||
);
|
||||
|
||||
return {
|
||||
...structuredClone(document),
|
||||
telemetry,
|
||||
serviceGroups,
|
||||
modules,
|
||||
statusStrips,
|
||||
};
|
||||
}
|
||||
|
||||
interface DatasourceContext {
|
||||
fetch: DatasourceFetch;
|
||||
prometheusBaseUrl: string;
|
||||
prometheusRangeSeconds: number;
|
||||
prometheusStepSeconds: number;
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
type DatasourceFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
interface PrometheusVectorResult {
|
||||
metric?: Record<string, string>;
|
||||
value?: [number, string];
|
||||
}
|
||||
|
||||
interface PrometheusMatrixResult {
|
||||
metric?: Record<string, string>;
|
||||
values?: Array<[number, string]>;
|
||||
}
|
||||
|
||||
function datasourceContext(options: DatasourceResolutionOptions): DatasourceContext {
|
||||
return {
|
||||
fetch: options.fetch || globalThis.fetch,
|
||||
prometheusBaseUrl:
|
||||
options.prometheusBaseUrl ||
|
||||
process.env.PROMETHEUS_BASE_URL ||
|
||||
"https://prometheus.dimensionlab.net",
|
||||
prometheusRangeSeconds: options.prometheusRangeSeconds || 60 * 60,
|
||||
prometheusStepSeconds: options.prometheusStepSeconds || 120,
|
||||
requestTimeoutMs: options.requestTimeoutMs || 2_500,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTelemetryCard(
|
||||
card: TelemetryCard,
|
||||
context: DatasourceContext,
|
||||
): Promise<TelemetryCard> {
|
||||
if (card.datasource?.type !== "external" || card.datasource.adapter !== "prometheus") {
|
||||
return structuredClone(card);
|
||||
}
|
||||
|
||||
try {
|
||||
const [instant, range] = await Promise.all([
|
||||
prometheusQuery(card.datasource.reference, context),
|
||||
prometheusRangeQuery(card.datasource.reference, context),
|
||||
]);
|
||||
const current = pickMaxVectorResult(instant);
|
||||
const currentValue = Number(current?.value?.[1]);
|
||||
const sparkline = matrixPoints(range);
|
||||
|
||||
if (!Number.isFinite(currentValue)) return markStale(card, "no telemetry data");
|
||||
|
||||
return {
|
||||
...structuredClone(card),
|
||||
value: metricValueWithLiveNumber(card.value, currentValue),
|
||||
severity: severityForValue(currentValue, card.thresholds),
|
||||
detail: prometheusMetricDetail(current?.metric || {}, card.detail),
|
||||
sparkline: sparkline.length ? sparkline : card.sparkline,
|
||||
};
|
||||
} catch {
|
||||
return markStale(card, card.detail || "telemetry unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveServiceGroup(
|
||||
group: ServiceGroup,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceGroup> {
|
||||
return {
|
||||
...structuredClone(group),
|
||||
services: await Promise.all(
|
||||
group.services.map((service) => resolveService(service, context)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveService(
|
||||
service: ServiceEntry,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceEntry> {
|
||||
const datasource = service.datasource;
|
||||
if (datasource?.type !== "external") return structuredClone(service);
|
||||
|
||||
if (datasource.adapter === "http-status") {
|
||||
return resolveHttpStatusService(service, datasource.reference, context);
|
||||
}
|
||||
|
||||
if (datasource.adapter === "prometheus") {
|
||||
return resolvePrometheusService(service, datasource.reference, context);
|
||||
}
|
||||
|
||||
return structuredClone(service);
|
||||
}
|
||||
|
||||
async function resolveHttpStatusService(
|
||||
service: ServiceEntry,
|
||||
reference: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceEntry> {
|
||||
const request = parseHttpStatusReference(reference);
|
||||
if (!request) return structuredClone(service);
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const response = await fetchWithTimeout(
|
||||
context.fetch,
|
||||
request.url,
|
||||
{
|
||||
cache: "no-store",
|
||||
method: request.method,
|
||||
},
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
const elapsedMs = Math.max(0, Math.round(Date.now() - startedAt));
|
||||
const badge = await uptimeBadge(response);
|
||||
if (badge) {
|
||||
const ping = badge.ping;
|
||||
const status = badge.status || "UNKNOWN";
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: uptimeBadgeSeverity(status),
|
||||
detail: Number.isFinite(ping)
|
||||
? `${Math.round(ping as number)} ms`
|
||||
: status.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: response.ok ? "ok" : response.status >= 500 ? "danger" : "warning",
|
||||
detail: response.ok ? `${elapsedMs} ms` : `HTTP ${response.status}`,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: "unavailable",
|
||||
detail: "unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePrometheusService(
|
||||
service: ServiceEntry,
|
||||
reference: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<ServiceEntry> {
|
||||
try {
|
||||
const result = pickMaxVectorResult(await prometheusQuery(reference, context));
|
||||
const value = Number(result?.value?.[1]);
|
||||
const ok = Number.isFinite(value) && value > 0;
|
||||
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: ok ? "ok" : "unavailable",
|
||||
detail: ok ? "up" : "down",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...structuredClone(service),
|
||||
severity: "unavailable",
|
||||
detail: "unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveModules(
|
||||
modules: DashboardModule[],
|
||||
serviceGroups: ServiceGroup[],
|
||||
context: DatasourceContext,
|
||||
): Promise<DashboardModule[]> {
|
||||
const resolved = await Promise.all(
|
||||
modules.map((module) => resolveModule(module, context)),
|
||||
);
|
||||
return resolved.map((module) =>
|
||||
module.id === "runtime-health-summary"
|
||||
? runtimeHealthSummary(module, serviceGroups)
|
||||
: module,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveModule(
|
||||
module: DashboardModule,
|
||||
context: DatasourceContext,
|
||||
): Promise<DashboardModule> {
|
||||
if (module.datasource?.type !== "external" || module.datasource.adapter !== "weather") {
|
||||
return structuredClone(module);
|
||||
}
|
||||
|
||||
try {
|
||||
const weather = await requestJson<OpenMeteoResponse>(
|
||||
context.fetch,
|
||||
openMeteoUrl(module.datasource.reference),
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
const current = weather.current;
|
||||
const temperature = Number(current?.temperature_2m);
|
||||
if (!Number.isFinite(temperature)) {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
severity: "stale",
|
||||
detail: "weather unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
const apparent = Number(current?.apparent_temperature);
|
||||
const wind = Number(current?.wind_speed_10m);
|
||||
const condition = weatherCondition(Number(current?.weather_code));
|
||||
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: `${temperature.toFixed(1)} C`,
|
||||
detail: [
|
||||
condition,
|
||||
Number.isFinite(apparent) ? `feels ${apparent.toFixed(1)} C` : "",
|
||||
Number.isFinite(wind) ? `wind ${Math.round(wind)} km/h` : "",
|
||||
].filter(Boolean).join(" - "),
|
||||
severity: "ok",
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
severity: "stale",
|
||||
detail: "weather unavailable",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function runtimeHealthSummary(
|
||||
module: DashboardModule,
|
||||
serviceGroups: ServiceGroup[],
|
||||
): DashboardModule {
|
||||
const services = serviceGroups.flatMap((group) => group.services);
|
||||
const down = services.filter((service) =>
|
||||
service.severity === "danger" || service.severity === "unavailable"
|
||||
).length;
|
||||
const warning = services.filter((service) => service.severity === "warning").length;
|
||||
const ok = services.filter((service) => service.severity === "ok").length;
|
||||
|
||||
if (down > 0) {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: `${down} service${down === 1 ? "" : "s"} down`,
|
||||
detail: `${warning} warning${warning === 1 ? "" : "s"} - ${ok} service${ok === 1 ? "" : "s"} ok`,
|
||||
severity: "danger",
|
||||
};
|
||||
}
|
||||
|
||||
if (warning > 0) {
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: `${warning} service${warning === 1 ? "" : "s"} warning`,
|
||||
detail: `${ok} service${ok === 1 ? "" : "s"} ok`,
|
||||
severity: "warning",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...structuredClone(module),
|
||||
value: "all systems operational",
|
||||
detail: `${ok} service${ok === 1 ? "" : "s"} ok`,
|
||||
severity: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveStatusStrips(
|
||||
strips: StatusStrip[],
|
||||
refreshIntervalSeconds: number | undefined,
|
||||
serviceGroups: ServiceGroup[],
|
||||
context: DatasourceContext,
|
||||
): Promise<StatusStrip[]> {
|
||||
const [uptime, loadAverage] = await Promise.all([
|
||||
prometheusScalar(
|
||||
'time() - node_boot_time_seconds{job="node",host="linux-infra"}',
|
||||
context,
|
||||
).catch(() => null),
|
||||
prometheusLoadAverage(context).catch(() => null),
|
||||
]);
|
||||
const health = serviceHealthSummary(serviceGroups);
|
||||
|
||||
return strips.map((strip) => ({
|
||||
...structuredClone(strip),
|
||||
items: strip.items.map((item) =>
|
||||
resolveStatusItem(item, {
|
||||
health,
|
||||
loadAverage,
|
||||
refreshIntervalSeconds,
|
||||
uptime,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveStatusItem(
|
||||
item: StatusItem,
|
||||
values: {
|
||||
health: { severity: Severity; value: string };
|
||||
loadAverage: string | null;
|
||||
refreshIntervalSeconds?: number;
|
||||
uptime: number | null;
|
||||
},
|
||||
): StatusItem {
|
||||
if (item.id === "system-status") {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: values.health.value,
|
||||
severity: values.health.severity,
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "last-sync") {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: "just now",
|
||||
severity: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "uptime" && values.uptime !== null) {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: formatDuration(values.uptime),
|
||||
severity: "ok",
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "load-avg" && values.loadAverage) {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: values.loadAverage,
|
||||
severity: "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
if (item.id === "auto-refresh" && values.refreshIntervalSeconds) {
|
||||
return {
|
||||
...structuredClone(item),
|
||||
value: `${values.refreshIntervalSeconds}s`,
|
||||
severity: "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
return structuredClone(item);
|
||||
}
|
||||
|
||||
function serviceHealthSummary(serviceGroups: ServiceGroup[]): {
|
||||
severity: Severity;
|
||||
value: string;
|
||||
} {
|
||||
const services = serviceGroups.flatMap((group) => group.services);
|
||||
const down = services.filter((service) =>
|
||||
service.severity === "danger" || service.severity === "unavailable"
|
||||
).length;
|
||||
const warning = services.filter((service) => service.severity === "warning").length;
|
||||
|
||||
if (down > 0) {
|
||||
return {
|
||||
severity: "danger",
|
||||
value: `${down} service${down === 1 ? "" : "s"} down`,
|
||||
};
|
||||
}
|
||||
|
||||
if (warning > 0) {
|
||||
return {
|
||||
severity: "warning",
|
||||
value: `${warning} service${warning === 1 ? "" : "s"} warning`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
severity: "ok",
|
||||
value: "All systems operational",
|
||||
};
|
||||
}
|
||||
|
||||
async function prometheusQuery(
|
||||
query: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<PrometheusVectorResult[]> {
|
||||
const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query", {
|
||||
query,
|
||||
});
|
||||
const payload = await requestJson<PrometheusResponse<PrometheusVectorResult>>(
|
||||
context.fetch,
|
||||
url,
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
return payload.status === "success" ? payload.data.result || [] : [];
|
||||
}
|
||||
|
||||
async function prometheusRangeQuery(
|
||||
query: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<PrometheusMatrixResult[]> {
|
||||
const end = Math.floor(Date.now() / 1000);
|
||||
const start = end - context.prometheusRangeSeconds;
|
||||
const url = prometheusApiUrl(context.prometheusBaseUrl, "/api/v1/query_range", {
|
||||
query,
|
||||
start: String(start),
|
||||
end: String(end),
|
||||
step: String(context.prometheusStepSeconds),
|
||||
});
|
||||
const payload = await requestJson<PrometheusResponse<PrometheusMatrixResult>>(
|
||||
context.fetch,
|
||||
url,
|
||||
context.requestTimeoutMs,
|
||||
);
|
||||
return payload.status === "success" ? payload.data.result || [] : [];
|
||||
}
|
||||
|
||||
async function prometheusScalar(
|
||||
query: string,
|
||||
context: DatasourceContext,
|
||||
): Promise<number | null> {
|
||||
const result = pickMaxVectorResult(await prometheusQuery(query, context));
|
||||
const value = Number(result?.value?.[1]);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
async function prometheusLoadAverage(context: DatasourceContext): Promise<string | null> {
|
||||
const [one, five, fifteen] = await Promise.all([
|
||||
prometheusScalar('node_load1{job="node",host="linux-infra"}', context),
|
||||
prometheusScalar('node_load5{job="node",host="linux-infra"}', context),
|
||||
prometheusScalar('node_load15{job="node",host="linux-infra"}', context),
|
||||
]);
|
||||
|
||||
if (one === null || five === null || fifteen === null) return null;
|
||||
return [one, five, fifteen].map((value) => value.toFixed(2)).join(" ");
|
||||
}
|
||||
|
||||
async function requestJson<T>(
|
||||
fetch: DatasourceFetch,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const response = await fetchWithTimeout(
|
||||
fetch,
|
||||
url,
|
||||
{ cache: "no-store" },
|
||||
timeoutMs,
|
||||
);
|
||||
if (!response.ok) throw new Error(`request failed: ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetch: DatasourceFetch,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function prometheusApiUrl(
|
||||
baseUrl: string,
|
||||
pathname: string,
|
||||
params: Record<string, string>,
|
||||
): string {
|
||||
const url = new URL(pathname, ensureTrailingSlash(baseUrl));
|
||||
Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(value: string): string {
|
||||
return value.endsWith("/") ? value : `${value}/`;
|
||||
}
|
||||
|
||||
function pickMaxVectorResult(
|
||||
results: PrometheusVectorResult[],
|
||||
): PrometheusVectorResult | null {
|
||||
return results.reduce<PrometheusVectorResult | null>((winner, item) => {
|
||||
if (!winner) return item;
|
||||
return Number(item.value?.[1]) > Number(winner.value?.[1]) ? item : winner;
|
||||
}, null);
|
||||
}
|
||||
|
||||
function matrixPoints(results: PrometheusMatrixResult[]): number[] {
|
||||
const byTimestamp = new Map<number, number>();
|
||||
|
||||
for (const series of results) {
|
||||
for (const [timestamp, value] of series.values || []) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) continue;
|
||||
const previous = byTimestamp.get(timestamp);
|
||||
if (previous === undefined || numeric > previous) {
|
||||
byTimestamp.set(timestamp, numeric);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...byTimestamp.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, value]) => value);
|
||||
}
|
||||
|
||||
function metricValueWithLiveNumber(value: MetricValue, nextValue: number): MetricValue {
|
||||
if (value.kind === "text") return value;
|
||||
return {
|
||||
...value,
|
||||
value: value.kind === "percent" ? clamp(nextValue, 0, 100) : Math.max(0, nextValue),
|
||||
};
|
||||
}
|
||||
|
||||
function severityForValue(
|
||||
value: number,
|
||||
thresholds: TelemetryCard["thresholds"],
|
||||
): Severity {
|
||||
if (thresholds?.danger !== undefined && value >= thresholds.danger) return "danger";
|
||||
if (thresholds?.warning !== undefined && value >= thresholds.warning) return "warning";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function prometheusMetricDetail(
|
||||
metric: Record<string, string>,
|
||||
fallback = "",
|
||||
): string {
|
||||
const host = metric.host || metric.instance || metric.job;
|
||||
const detail = metric.mountpoint || metric.name || metric.container || metric.id;
|
||||
const parts = [host, detail].filter(Boolean);
|
||||
if (parts.length) return parts.join(" ");
|
||||
return fallback.replace(/^fallback\s*-\s*/i, "") || "telemetry";
|
||||
}
|
||||
|
||||
function markStale(card: TelemetryCard, detail: string): TelemetryCard {
|
||||
return {
|
||||
...structuredClone(card),
|
||||
severity: "stale",
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHttpStatusReference(reference: string): { method: string; url: string } | null {
|
||||
const match = /^(GET|HEAD|POST)\s+(.+)$/i.exec(reference.trim());
|
||||
if (!match) return null;
|
||||
return { method: match[1].toUpperCase(), url: match[2] };
|
||||
}
|
||||
|
||||
interface PrometheusResponse<T> {
|
||||
status: string;
|
||||
data: {
|
||||
result?: T[];
|
||||
};
|
||||
}
|
||||
|
||||
interface OpenMeteoResponse {
|
||||
current?: {
|
||||
apparent_temperature?: number;
|
||||
temperature_2m?: number;
|
||||
weather_code?: number;
|
||||
wind_speed_10m?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UptimeBadgeResponse {
|
||||
status?: string;
|
||||
ping?: number;
|
||||
}
|
||||
|
||||
async function uptimeBadge(response: Response): Promise<UptimeBadgeResponse | null> {
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.includes("json")) return null;
|
||||
|
||||
try {
|
||||
const payload = await response.clone().json() as UptimeBadgeResponse;
|
||||
return typeof payload.status === "string" ? payload : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function uptimeBadgeSeverity(status = ""): Severity {
|
||||
const normalized = status.toUpperCase();
|
||||
if (normalized === "UP") return "ok";
|
||||
if (normalized === "PENDING" || normalized === "MAINTENANCE") return "warning";
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
function openMeteoUrl(reference: string): string {
|
||||
const params = new URLSearchParams();
|
||||
const serialized = reference.startsWith("open-meteo:")
|
||||
? reference.slice("open-meteo:".length)
|
||||
: reference;
|
||||
|
||||
new URLSearchParams(serialized).forEach((value, key) => {
|
||||
params.set(key, value);
|
||||
});
|
||||
params.set(
|
||||
"current",
|
||||
"temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
|
||||
);
|
||||
params.set("timezone", "Europe/Amsterdam");
|
||||
|
||||
return `https://api.open-meteo.com/v1/forecast?${params.toString()}`;
|
||||
}
|
||||
|
||||
function weatherCondition(code: number): string {
|
||||
if (code === 0) return "Clear";
|
||||
if ([1, 2].includes(code)) return "Partly cloudy";
|
||||
if (code === 3) return "Cloudy";
|
||||
if ([45, 48].includes(code)) return "Fog";
|
||||
if (code >= 51 && code <= 67) return "Rain";
|
||||
if (code >= 71 && code <= 77) return "Snow";
|
||||
if (code >= 80 && code <= 82) return "Showers";
|
||||
if (code >= 95) return "Thunderstorm";
|
||||
return "Mixed";
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function formatDuration(totalSeconds: number): string {
|
||||
const seconds = Math.max(0, Math.floor(totalSeconds));
|
||||
const days = Math.floor(seconds / 86_400);
|
||||
const hours = Math.floor((seconds % 86_400) / 3_600);
|
||||
const minutes = Math.floor((seconds % 3_600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
47
apps/web/src/lib/server/db/connection.ts
Normal file
47
apps/web/src/lib/server/db/connection.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { mkdirSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
import { dashboardDbSchema } from "./schema";
|
||||
import { applyDashboardMigrations } from "./migrations";
|
||||
|
||||
export const DEFAULT_DATABASE_URL = "file:./data/dimensionlab.sqlite";
|
||||
|
||||
export type DashboardDatabase = BunSQLiteDatabase<typeof dashboardDbSchema> & {
|
||||
$client: Database;
|
||||
};
|
||||
|
||||
export interface DashboardDatabaseConnection {
|
||||
db: DashboardDatabase;
|
||||
sqlite: Database;
|
||||
filename: string;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export function openDashboardDatabase(
|
||||
databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
|
||||
): DashboardDatabaseConnection {
|
||||
const filename = resolveSqliteFilename(databaseUrl);
|
||||
mkdirSync(dirname(filename), { recursive: true });
|
||||
|
||||
const sqlite = new Database(filename, { create: true, readwrite: true });
|
||||
const db = drizzle(sqlite, { schema: dashboardDbSchema }) as DashboardDatabase;
|
||||
applyDashboardMigrations(db);
|
||||
|
||||
return {
|
||||
db,
|
||||
sqlite,
|
||||
filename,
|
||||
close() {
|
||||
sqlite.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSqliteFilename(databaseUrl: string): string {
|
||||
if (!databaseUrl.startsWith("file:")) {
|
||||
throw new Error(`Only file: SQLite DATABASE_URL values are supported: ${databaseUrl}`);
|
||||
}
|
||||
|
||||
return resolve(databaseUrl.slice("file:".length));
|
||||
}
|
||||
205
apps/web/src/lib/server/db/dashboard-store.test.ts
Normal file
205
apps/web/src/lib/server/db/dashboard-store.test.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
import { existsSync, rmSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import type { DashboardDocument } from "$lib/model";
|
||||
import {
|
||||
DashboardPersistenceValidationError,
|
||||
createDashboardStore,
|
||||
type DashboardStore,
|
||||
} from "./dashboard-store";
|
||||
|
||||
const stores: DashboardStore[] = [];
|
||||
const tempRoots: string[] = [];
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
process.chdir(originalCwd);
|
||||
stores.splice(0).forEach((store) => store.close());
|
||||
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
|
||||
});
|
||||
|
||||
describe("dashboard persistence store", () => {
|
||||
test("seeds the first active dashboard and creates the sqlite file", async () => {
|
||||
const { dbPath, store } = await createTestStore();
|
||||
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "test-seed",
|
||||
message: "initial fixture",
|
||||
});
|
||||
|
||||
expect(existsSync(dbPath)).toBe(true);
|
||||
expect(seed.operation).toBe("seed");
|
||||
expect(seed.actor).toBe("test-seed");
|
||||
expect(seed.schemaVersion).toBe(genericDashboardFixture.schemaVersion);
|
||||
expect(seed.document.metadata.title).toBe("Operations Console");
|
||||
|
||||
const active = store.getActiveDashboard();
|
||||
expect(active?.currentRevisionId).toBe(seed.id);
|
||||
expect(active?.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("commits validated updates and records revision metadata", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "test-seed",
|
||||
});
|
||||
const updated = cloneDashboard({
|
||||
...genericDashboardFixture,
|
||||
metadata: {
|
||||
...genericDashboardFixture.metadata,
|
||||
title: "Operations Console Updated",
|
||||
},
|
||||
});
|
||||
|
||||
const revision = store.commitDashboard(updated, {
|
||||
actor: "agent",
|
||||
message: "rename dashboard",
|
||||
});
|
||||
|
||||
expect(revision.operation).toBe("commit");
|
||||
expect(revision.actor).toBe("agent");
|
||||
expect(revision.message).toBe("rename dashboard");
|
||||
expect(revision.document.metadata.title).toBe("Operations Console Updated");
|
||||
expect(store.getActiveDashboard()?.currentRevisionId).toBe(revision.id);
|
||||
expect(store.getRevision(seed.id)?.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.listRevisions().map((item) => item.id)).toEqual([
|
||||
revision.id,
|
||||
seed.id,
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects invalid writes without changing the active revision", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "test-seed",
|
||||
});
|
||||
const invalid = cloneDashboard({
|
||||
...genericDashboardFixture,
|
||||
layout: {
|
||||
...genericDashboardFixture.layout,
|
||||
telemetry: ["missing-card"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
store.commitDashboard(invalid, {
|
||||
actor: "agent",
|
||||
message: "invalid update",
|
||||
}),
|
||||
).toThrow(DashboardPersistenceValidationError);
|
||||
|
||||
const active = store.getActiveDashboard();
|
||||
expect(active?.currentRevisionId).toBe(seed.id);
|
||||
expect(active?.document.layout.telemetry).toEqual(genericDashboardFixture.layout.telemetry);
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("loads specific revisions and rolls back to a prior valid document", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "test-seed",
|
||||
});
|
||||
const updated = cloneDashboard({
|
||||
...genericDashboardFixture,
|
||||
metadata: {
|
||||
...genericDashboardFixture.metadata,
|
||||
title: "Changed Dashboard",
|
||||
},
|
||||
});
|
||||
const change = store.commitDashboard(updated, {
|
||||
actor: "agent",
|
||||
message: "change title",
|
||||
});
|
||||
|
||||
const rollback = store.rollbackToRevision(seed.id, {
|
||||
actor: "operator",
|
||||
message: "restore seed",
|
||||
});
|
||||
|
||||
expect(store.getRevision(change.id)?.document.metadata.title).toBe("Changed Dashboard");
|
||||
expect(rollback.operation).toBe("rollback");
|
||||
expect(rollback.sourceRevisionId).toBe(seed.id);
|
||||
expect(rollback.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.getActiveDashboard()?.currentRevisionId).toBe(rollback.id);
|
||||
expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.listRevisions().map((item) => item.operation)).toEqual([
|
||||
"rollback",
|
||||
"commit",
|
||||
"seed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("assigns monotonic revision timestamps for stable history ordering", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-06-18T12:00:00.000Z"));
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "test-seed",
|
||||
});
|
||||
const updated = cloneDashboard({
|
||||
...genericDashboardFixture,
|
||||
metadata: {
|
||||
...genericDashboardFixture.metadata,
|
||||
title: "Changed Dashboard",
|
||||
},
|
||||
});
|
||||
|
||||
const commit = store.commitDashboard(updated, { actor: "agent" });
|
||||
const rollback = store.rollbackToRevision(seed.id, { actor: "operator" });
|
||||
|
||||
expect([
|
||||
seed.createdAt.toISOString(),
|
||||
commit.createdAt.toISOString(),
|
||||
rollback.createdAt.toISOString(),
|
||||
]).toEqual([
|
||||
"2026-06-18T12:00:00.000Z",
|
||||
"2026-06-18T12:00:00.001Z",
|
||||
"2026-06-18T12:00:00.002Z",
|
||||
]);
|
||||
expect(store.listRevisions().map((item) => item.id)).toEqual([
|
||||
rollback.id,
|
||||
commit.id,
|
||||
seed.id,
|
||||
]);
|
||||
});
|
||||
|
||||
test("applies migrations when launched outside the repository root", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-cwd-"));
|
||||
tempRoots.push(root);
|
||||
process.chdir(root);
|
||||
vi.resetModules();
|
||||
const { createDashboardStore: createStore } = await import("./dashboard-store");
|
||||
const store = createStore({
|
||||
databaseUrl: `file:${join(root, "dashboard.sqlite")}`,
|
||||
});
|
||||
stores.push(store);
|
||||
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "test-seed",
|
||||
});
|
||||
|
||||
expect(seed.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id);
|
||||
});
|
||||
});
|
||||
|
||||
async function createTestStore() {
|
||||
const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-store-"));
|
||||
tempRoots.push(root);
|
||||
const dbPath = join(root, "nested", "dashboard.sqlite");
|
||||
const store = createDashboardStore({
|
||||
databaseUrl: `file:${dbPath}`,
|
||||
});
|
||||
stores.push(store);
|
||||
|
||||
return { dbPath, store };
|
||||
}
|
||||
|
||||
function cloneDashboard(document: DashboardDocument): DashboardDocument {
|
||||
return structuredClone(document);
|
||||
}
|
||||
261
apps/web/src/lib/server/db/dashboard-store.ts
Normal file
261
apps/web/src/lib/server/db/dashboard-store.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
type DashboardDocument,
|
||||
type DashboardValidationFailure,
|
||||
} from "$lib/model";
|
||||
import {
|
||||
type DashboardDatabaseConnection,
|
||||
openDashboardDatabase,
|
||||
} from "./connection";
|
||||
import {
|
||||
dashboardDocuments,
|
||||
dashboardRevisions,
|
||||
type DashboardRevisionOperation,
|
||||
} from "./schema";
|
||||
import { migrateDashboardDocumentForPersistence } from "./model-migrations";
|
||||
|
||||
const DEFAULT_DASHBOARD_ID = "primary";
|
||||
const DEFAULT_ACTOR = "system";
|
||||
|
||||
export interface DashboardRevision {
|
||||
id: string;
|
||||
dashboardId: string;
|
||||
schemaVersion: string;
|
||||
document: DashboardDocument;
|
||||
actor: string;
|
||||
message: string | null;
|
||||
operation: DashboardRevisionOperation;
|
||||
sourceRevisionId: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ActiveDashboard {
|
||||
dashboardId: string;
|
||||
currentRevisionId: string;
|
||||
document: DashboardDocument;
|
||||
revision: DashboardRevision;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface DashboardWriteMetadata {
|
||||
actor?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DashboardStoreOptions {
|
||||
databaseUrl?: string;
|
||||
dashboardId?: string;
|
||||
}
|
||||
|
||||
export interface DashboardStore {
|
||||
getActiveDashboard(): ActiveDashboard | null;
|
||||
getRevision(revisionId: string): DashboardRevision | null;
|
||||
listRevisions(limit?: number): DashboardRevision[];
|
||||
seedDashboardIfEmpty(
|
||||
document: DashboardDocument,
|
||||
metadata?: DashboardWriteMetadata,
|
||||
): DashboardRevision;
|
||||
commitDashboard(
|
||||
document: DashboardDocument,
|
||||
metadata?: DashboardWriteMetadata,
|
||||
): DashboardRevision;
|
||||
rollbackToRevision(
|
||||
revisionId: string,
|
||||
metadata?: DashboardWriteMetadata,
|
||||
): DashboardRevision;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export class DashboardPersistenceValidationError extends Error {
|
||||
readonly failure: DashboardValidationFailure;
|
||||
|
||||
constructor(failure: DashboardValidationFailure) {
|
||||
super(`Invalid dashboard document: ${failure.errors.join("; ")}`);
|
||||
this.name = "DashboardPersistenceValidationError";
|
||||
this.failure = failure;
|
||||
}
|
||||
}
|
||||
|
||||
export class DashboardRevisionNotFoundError extends Error {
|
||||
constructor(revisionId: string) {
|
||||
super(`Dashboard revision not found: ${revisionId}`);
|
||||
this.name = "DashboardRevisionNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export function createDashboardStore(
|
||||
options: DashboardStoreOptions = {},
|
||||
): DashboardStore {
|
||||
const connection = openDashboardDatabase(options.databaseUrl);
|
||||
return new SqliteDashboardStore(connection, options.dashboardId || DEFAULT_DASHBOARD_ID);
|
||||
}
|
||||
|
||||
class SqliteDashboardStore implements DashboardStore {
|
||||
constructor(
|
||||
private readonly connection: DashboardDatabaseConnection,
|
||||
private readonly dashboardId: string,
|
||||
) {}
|
||||
|
||||
getActiveDashboard(): ActiveDashboard | null {
|
||||
const dashboard = this.connection.db
|
||||
.select()
|
||||
.from(dashboardDocuments)
|
||||
.where(eq(dashboardDocuments.id, this.dashboardId))
|
||||
.get();
|
||||
|
||||
if (!dashboard?.currentRevisionId) return null;
|
||||
|
||||
const revision = this.getRevision(dashboard.currentRevisionId);
|
||||
if (!revision) return null;
|
||||
|
||||
return {
|
||||
dashboardId: dashboard.id,
|
||||
currentRevisionId: dashboard.currentRevisionId,
|
||||
document: revision.document,
|
||||
revision,
|
||||
createdAt: dashboard.createdAt,
|
||||
updatedAt: dashboard.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
getRevision(revisionId: string): DashboardRevision | null {
|
||||
const row = this.connection.db
|
||||
.select()
|
||||
.from(dashboardRevisions)
|
||||
.where(eq(dashboardRevisions.id, revisionId))
|
||||
.get();
|
||||
|
||||
return row ? toRevision(row) : null;
|
||||
}
|
||||
|
||||
listRevisions(limit = 50): DashboardRevision[] {
|
||||
return this.connection.db
|
||||
.select()
|
||||
.from(dashboardRevisions)
|
||||
.where(eq(dashboardRevisions.dashboardId, this.dashboardId))
|
||||
.orderBy(desc(dashboardRevisions.createdAt))
|
||||
.limit(limit)
|
||||
.all()
|
||||
.map(toRevision);
|
||||
}
|
||||
|
||||
seedDashboardIfEmpty(
|
||||
document: DashboardDocument,
|
||||
metadata: DashboardWriteMetadata = {},
|
||||
): DashboardRevision {
|
||||
const active = this.getActiveDashboard();
|
||||
if (active) return active.revision;
|
||||
|
||||
return this.writeRevision(document, "seed", metadata);
|
||||
}
|
||||
|
||||
commitDashboard(
|
||||
document: DashboardDocument,
|
||||
metadata: DashboardWriteMetadata = {},
|
||||
): DashboardRevision {
|
||||
return this.writeRevision(document, "commit", metadata);
|
||||
}
|
||||
|
||||
rollbackToRevision(
|
||||
revisionId: string,
|
||||
metadata: DashboardWriteMetadata = {},
|
||||
): DashboardRevision {
|
||||
const revision = this.getRevision(revisionId);
|
||||
if (!revision || revision.dashboardId !== this.dashboardId) {
|
||||
throw new DashboardRevisionNotFoundError(revisionId);
|
||||
}
|
||||
|
||||
return this.writeRevision(revision.document, "rollback", metadata, revision.id);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.connection.close();
|
||||
}
|
||||
|
||||
private writeRevision(
|
||||
document: DashboardDocument,
|
||||
operation: DashboardRevisionOperation,
|
||||
metadata: DashboardWriteMetadata,
|
||||
sourceRevisionId: string | null = null,
|
||||
): DashboardRevision {
|
||||
const migration = migrateDashboardDocumentForPersistence(document);
|
||||
if (!migration.valid) {
|
||||
throw new DashboardPersistenceValidationError(migration.failure);
|
||||
}
|
||||
|
||||
const now = this.nextRevisionTimestamp();
|
||||
const revision: DashboardRevision = {
|
||||
id: randomUUID(),
|
||||
dashboardId: this.dashboardId,
|
||||
schemaVersion: migration.document.schemaVersion,
|
||||
document: structuredClone(migration.document),
|
||||
actor: metadata.actor || DEFAULT_ACTOR,
|
||||
message: metadata.message || null,
|
||||
operation,
|
||||
sourceRevisionId,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
this.connection.db.transaction((tx) => {
|
||||
tx.insert(dashboardRevisions).values({
|
||||
id: revision.id,
|
||||
dashboardId: revision.dashboardId,
|
||||
schemaVersion: revision.schemaVersion,
|
||||
document: revision.document,
|
||||
actor: revision.actor,
|
||||
message: revision.message,
|
||||
operation: revision.operation,
|
||||
sourceRevisionId: revision.sourceRevisionId,
|
||||
createdAt: revision.createdAt,
|
||||
}).run();
|
||||
|
||||
tx.insert(dashboardDocuments)
|
||||
.values({
|
||||
id: this.dashboardId,
|
||||
currentRevisionId: revision.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: dashboardDocuments.id,
|
||||
set: {
|
||||
currentRevisionId: revision.id,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
});
|
||||
|
||||
return revision;
|
||||
}
|
||||
|
||||
private nextRevisionTimestamp(): Date {
|
||||
const active = this.getActiveDashboard();
|
||||
const activeUpdatedAtMs = active?.updatedAt.getTime() || 0;
|
||||
|
||||
return new Date(Math.max(Date.now(), activeUpdatedAtMs + 1));
|
||||
}
|
||||
}
|
||||
|
||||
type DashboardRevisionRow = typeof dashboardRevisions.$inferSelect;
|
||||
|
||||
function toRevision(row: DashboardRevisionRow): DashboardRevision {
|
||||
const migration = migrateDashboardDocumentForPersistence(row.document);
|
||||
if (!migration.valid) {
|
||||
throw new DashboardPersistenceValidationError(migration.failure);
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
dashboardId: row.dashboardId,
|
||||
schemaVersion: row.schemaVersion,
|
||||
document: migration.document,
|
||||
actor: row.actor,
|
||||
message: row.message,
|
||||
operation: row.operation as DashboardRevisionOperation,
|
||||
sourceRevisionId: row.sourceRevisionId,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
}
|
||||
52
apps/web/src/lib/server/db/migrations.ts
Normal file
52
apps/web/src/lib/server/db/migrations.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
||||
|
||||
const MIGRATION_JOURNAL_PATH = join("meta", "_journal.json");
|
||||
|
||||
export function applyDashboardMigrations(
|
||||
database: BunSQLiteDatabase<Record<string, unknown>>,
|
||||
migrationsFolder = resolveDashboardMigrationsFolder(),
|
||||
) {
|
||||
migrate(database, { migrationsFolder });
|
||||
}
|
||||
|
||||
export function resolveDashboardMigrationsFolder(
|
||||
explicitFolder = process.env.DASHBOARD_MIGRATIONS_DIR,
|
||||
): string {
|
||||
if (explicitFolder) return assertMigrationFolder(resolve(explicitFolder));
|
||||
|
||||
const cwdFolder = findMigrationFolder(process.cwd());
|
||||
if (cwdFolder) return cwdFolder;
|
||||
|
||||
const moduleFolder = findMigrationFolder(dirname(fileURLToPath(import.meta.url)));
|
||||
if (moduleFolder) return moduleFolder;
|
||||
|
||||
throw new Error(
|
||||
"Unable to locate dashboard Drizzle migrations. Set DASHBOARD_MIGRATIONS_DIR.",
|
||||
);
|
||||
}
|
||||
|
||||
function findMigrationFolder(startPath: string): string | null {
|
||||
let currentPath = resolve(startPath);
|
||||
|
||||
while (true) {
|
||||
const candidate = join(currentPath, "drizzle");
|
||||
if (hasMigrationJournal(candidate)) return candidate;
|
||||
|
||||
const parentPath = dirname(currentPath);
|
||||
if (parentPath === currentPath) return null;
|
||||
currentPath = parentPath;
|
||||
}
|
||||
}
|
||||
|
||||
function assertMigrationFolder(folder: string): string {
|
||||
if (hasMigrationJournal(folder)) return folder;
|
||||
throw new Error(`Dashboard migrations not found in ${folder}`);
|
||||
}
|
||||
|
||||
function hasMigrationJournal(folder: string): boolean {
|
||||
return existsSync(join(folder, MIGRATION_JOURNAL_PATH));
|
||||
}
|
||||
31
apps/web/src/lib/server/db/model-migrations.test.ts
Normal file
31
apps/web/src/lib/server/db/model-migrations.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { DASHBOARD_SCHEMA_VERSION } from "$lib/model";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import {
|
||||
UnsupportedDashboardModelVersionError,
|
||||
migrateDashboardDocumentForPersistence,
|
||||
} from "./model-migrations";
|
||||
|
||||
describe("dashboard model migrations", () => {
|
||||
test("accepts the current dashboard model version without migration", () => {
|
||||
const result = migrateDashboardDocumentForPersistence(genericDashboardFixture);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (!result.valid) throw new Error("expected current fixture to be valid");
|
||||
expect(result.document).toEqual(genericDashboardFixture);
|
||||
expect(result.fromVersion).toBe(DASHBOARD_SCHEMA_VERSION);
|
||||
expect(result.toVersion).toBe(DASHBOARD_SCHEMA_VERSION);
|
||||
expect(result.migrated).toBe(false);
|
||||
});
|
||||
|
||||
test("fails unsupported model versions with an explicit migration error", () => {
|
||||
const previousVersion = {
|
||||
...genericDashboardFixture,
|
||||
schemaVersion: "dashboard.v0",
|
||||
};
|
||||
|
||||
expect(() => migrateDashboardDocumentForPersistence(previousVersion)).toThrow(
|
||||
UnsupportedDashboardModelVersionError,
|
||||
);
|
||||
});
|
||||
});
|
||||
62
apps/web/src/lib/server/db/model-migrations.ts
Normal file
62
apps/web/src/lib/server/db/model-migrations.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
validateDashboardDocument,
|
||||
type DashboardDocument,
|
||||
type DashboardValidationFailure,
|
||||
} from "$lib/model";
|
||||
|
||||
export interface DashboardModelMigrationSuccess {
|
||||
valid: true;
|
||||
document: DashboardDocument;
|
||||
fromVersion: string;
|
||||
toVersion: typeof DASHBOARD_SCHEMA_VERSION;
|
||||
migrated: boolean;
|
||||
}
|
||||
|
||||
export interface DashboardModelMigrationFailure {
|
||||
valid: false;
|
||||
failure: DashboardValidationFailure;
|
||||
}
|
||||
|
||||
export type DashboardModelMigrationResult =
|
||||
| DashboardModelMigrationFailure
|
||||
| DashboardModelMigrationSuccess;
|
||||
|
||||
export class UnsupportedDashboardModelVersionError extends Error {
|
||||
constructor(
|
||||
readonly fromVersion: string,
|
||||
readonly toVersion: string,
|
||||
) {
|
||||
super(`No dashboard model migration from ${fromVersion} to ${toVersion}`);
|
||||
this.name = "UnsupportedDashboardModelVersionError";
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateDashboardDocumentForPersistence(
|
||||
value: unknown,
|
||||
): DashboardModelMigrationResult {
|
||||
const version = readSchemaVersion(value);
|
||||
if (version && version !== DASHBOARD_SCHEMA_VERSION) {
|
||||
throw new UnsupportedDashboardModelVersionError(version, DASHBOARD_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
const validation = validateDashboardDocument(value);
|
||||
if (!validation.valid) {
|
||||
return { valid: false, failure: validation };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
document: validation.data,
|
||||
fromVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
toVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
migrated: false,
|
||||
};
|
||||
}
|
||||
|
||||
function readSchemaVersion(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion;
|
||||
return typeof schemaVersion === "string" ? schemaVersion : null;
|
||||
}
|
||||
39
apps/web/src/lib/server/db/schema.ts
Normal file
39
apps/web/src/lib/server/db/schema.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { DashboardDocument } from "$lib/model";
|
||||
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const dashboardDocuments = sqliteTable("dashboard_documents", {
|
||||
id: text("id").primaryKey(),
|
||||
currentRevisionId: text("current_revision_id"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
|
||||
});
|
||||
|
||||
export const dashboardRevisions = sqliteTable(
|
||||
"dashboard_revisions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
dashboardId: text("dashboard_id").notNull(),
|
||||
schemaVersion: text("schema_version").notNull(),
|
||||
document: text("document", { mode: "json" }).$type<DashboardDocument>().notNull(),
|
||||
actor: text("actor").notNull(),
|
||||
message: text("message"),
|
||||
operation: text("operation", {
|
||||
enum: ["seed", "commit", "rollback"],
|
||||
}).notNull(),
|
||||
sourceRevisionId: text("source_revision_id"),
|
||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("idx_dashboard_revisions_dashboard_created").on(
|
||||
table.dashboardId,
|
||||
table.createdAt,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
export const dashboardDbSchema = {
|
||||
dashboardDocuments,
|
||||
dashboardRevisions,
|
||||
};
|
||||
|
||||
export type DashboardRevisionOperation = "seed" | "commit" | "rollback";
|
||||
55
apps/web/src/lib/testing/external-api-mocks.test.ts
Normal file
55
apps/web/src/lib/testing/external-api-mocks.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { setupServer } from "msw/node";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, test } from "vitest";
|
||||
import { externalApiHandlers } from "./external-api-mocks";
|
||||
|
||||
const server = setupServer(...externalApiHandlers);
|
||||
|
||||
describe("external API mocks", () => {
|
||||
beforeAll(() => {
|
||||
server.listen({ onUnhandledRequest: "error" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("defines deterministic handlers for deferred datasource adapters", () => {
|
||||
expect(externalApiHandlers).toHaveLength(3);
|
||||
expect(externalApiHandlers.map((handler) => handler.info.header)).toEqual([
|
||||
"GET https://prometheus.dimensionlab.net/api/v1/query",
|
||||
"GET https://uptime.dimensionlab.net/api/status-page/*",
|
||||
"GET https://api.open-meteo.com/v1/forecast",
|
||||
]);
|
||||
});
|
||||
|
||||
test("intercepts deferred datasource requests without live services", async () => {
|
||||
const prometheus = await fetch(
|
||||
"https://prometheus.dimensionlab.net/api/v1/query",
|
||||
).then((response) => response.json());
|
||||
const status = await fetch(
|
||||
"https://uptime.dimensionlab.net/api/status-page/dimensionlab",
|
||||
).then((response) => response.json());
|
||||
const weather = await fetch(
|
||||
"https://api.open-meteo.com/v1/forecast?latitude=52.37&longitude=4.9",
|
||||
).then((response) => response.json());
|
||||
|
||||
expect(prometheus).toMatchObject({
|
||||
status: "success",
|
||||
data: { result: [{ value: [1771430400, "1"] }] },
|
||||
});
|
||||
expect(status).toMatchObject({
|
||||
status: "ok",
|
||||
incidents: [],
|
||||
});
|
||||
expect(weather).toMatchObject({
|
||||
current: {
|
||||
temperature_2m: 21.4,
|
||||
weather_code: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
33
apps/web/src/lib/testing/external-api-mocks.ts
Normal file
33
apps/web/src/lib/testing/external-api-mocks.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { http, HttpResponse } from "msw";
|
||||
|
||||
export const externalApiHandlers = [
|
||||
http.get("https://prometheus.dimensionlab.net/api/v1/query", () =>
|
||||
HttpResponse.json({
|
||||
status: "success",
|
||||
data: {
|
||||
resultType: "vector",
|
||||
result: [
|
||||
{
|
||||
metric: { instance: "fixture" },
|
||||
value: [1771430400, "1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
http.get("https://uptime.dimensionlab.net/api/status-page/*", () =>
|
||||
HttpResponse.json({
|
||||
status: "ok",
|
||||
incidents: [],
|
||||
monitors: [{ name: "fixture", status: "up" }],
|
||||
}),
|
||||
),
|
||||
http.get("https://api.open-meteo.com/v1/forecast", () =>
|
||||
HttpResponse.json({
|
||||
current: {
|
||||
temperature_2m: 21.4,
|
||||
weather_code: 0,
|
||||
},
|
||||
}),
|
||||
),
|
||||
];
|
||||
102
apps/web/src/lib/ui-adapter/model-renderer.test.ts
Normal file
102
apps/web/src/lib/ui-adapter/model-renderer.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { type DashboardDocument } from "$lib/model";
|
||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||
|
||||
const appRoot = process.cwd().endsWith(`${join("apps", "web")}`)
|
||||
? process.cwd()
|
||||
: join(process.cwd(), "apps", "web");
|
||||
|
||||
describe("dashboard model renderer", () => {
|
||||
test("projects the Dimension Lab model into UI component props", () => {
|
||||
const dashboard = dashboardDocumentToUiDashboard(dimensionLabDashboardFixture);
|
||||
|
||||
expect(dashboard.title).toBe(dimensionLabDashboardFixture.metadata.title);
|
||||
expect(dashboard.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle);
|
||||
expect(dashboard.telemetry.map((card) => card.id)).toEqual(
|
||||
dimensionLabDashboardFixture.layout.telemetry,
|
||||
);
|
||||
expect(dashboard.telemetry[0]).toMatchObject({
|
||||
label: "Infra RAM",
|
||||
icon: "mdi:memory",
|
||||
severity: "ok",
|
||||
});
|
||||
expect(dashboard.serviceGroups.map((group) => group.id)).toEqual(
|
||||
dimensionLabDashboardFixture.layout.serviceGroups,
|
||||
);
|
||||
expect(dashboard.serviceGroups.find((group) => group.id === "runtime-health")?.layout).toBe(
|
||||
"grid",
|
||||
);
|
||||
expect(dashboard.modules.map((module) => module.id)).toEqual([
|
||||
"weather-amsterdam",
|
||||
"runtime-health-summary",
|
||||
]);
|
||||
expect(dashboard.statusItems.map((item) => item.id)).toEqual([
|
||||
"footer-status:system-status",
|
||||
"footer-status:last-sync",
|
||||
"footer-status:uptime",
|
||||
"footer-status:load-avg",
|
||||
"footer-status:auto-refresh",
|
||||
]);
|
||||
expect(dashboard.statusStripId).toBe("footer-status");
|
||||
});
|
||||
|
||||
test("projects a second fixture through the same mapper", () => {
|
||||
const dashboard = dashboardDocumentToUiDashboard(genericDashboardFixture);
|
||||
|
||||
expect(dashboard.title).toBe("Operations Console");
|
||||
expect(dashboard.telemetry.map((card) => card.id)).toEqual([
|
||||
"service-uptime",
|
||||
"queue-depth",
|
||||
]);
|
||||
expect(dashboard.serviceGroups[0]?.services.map((service) => service.id)).toEqual([
|
||||
"identity",
|
||||
"scheduler",
|
||||
]);
|
||||
expect(dashboard.modules[0]).toMatchObject({
|
||||
id: "ambient",
|
||||
title: "Environment",
|
||||
icon: "mdi:radar",
|
||||
});
|
||||
});
|
||||
|
||||
test("returns intentional empty arrays for missing optional sections", () => {
|
||||
const emptyModel: DashboardDocument = {
|
||||
...genericDashboardFixture,
|
||||
layout: {
|
||||
density: "compact",
|
||||
telemetry: [],
|
||||
serviceGroups: [],
|
||||
statusStrips: [],
|
||||
modules: [],
|
||||
},
|
||||
telemetry: [],
|
||||
serviceGroups: [],
|
||||
statusStrips: [],
|
||||
modules: [],
|
||||
};
|
||||
|
||||
const dashboard = dashboardDocumentToUiDashboard(emptyModel);
|
||||
|
||||
expect(dashboard.telemetry).toEqual([]);
|
||||
expect(dashboard.serviceGroups).toEqual([]);
|
||||
expect(dashboard.statusItems).toEqual([]);
|
||||
expect(dashboard.modules).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not hardcode environment-specific content in mapper source", () => {
|
||||
const source = readFileSync(
|
||||
join(appRoot, "src/lib/ui-adapter/model-renderer.ts"),
|
||||
"utf8",
|
||||
)
|
||||
.toLowerCase()
|
||||
.replaceAll("@dimensionlab/ui", "@internal/ui");
|
||||
|
||||
expect(source).not.toContain("dimension");
|
||||
expect(source).not.toContain("vaultwarden");
|
||||
expect(source).not.toContain("forgejo");
|
||||
});
|
||||
});
|
||||
111
apps/web/src/lib/ui-adapter/model-renderer.ts
Normal file
111
apps/web/src/lib/ui-adapter/model-renderer.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type {
|
||||
DashboardDocument,
|
||||
DashboardModule,
|
||||
ServiceEntry,
|
||||
ServiceGroup,
|
||||
StatusItem,
|
||||
StatusStrip,
|
||||
TelemetryCard,
|
||||
} from "$lib/model";
|
||||
import type {
|
||||
UiDashboardPreview,
|
||||
UiModuleBlock,
|
||||
UiServiceGroup,
|
||||
UiServiceRow,
|
||||
UiStatusItem,
|
||||
UiTelemetryCard,
|
||||
} from "@dimensionlab/ui";
|
||||
|
||||
export function dashboardDocumentToUiDashboard(
|
||||
document: DashboardDocument,
|
||||
): UiDashboardPreview {
|
||||
return {
|
||||
eyebrow: document.schemaVersion,
|
||||
title: document.metadata.title,
|
||||
subtitle: document.metadata.subtitle,
|
||||
telemetry: orderedItems(document.layout.telemetry, document.telemetry).map(
|
||||
telemetryToUi,
|
||||
),
|
||||
serviceGroups: orderedItems(
|
||||
document.layout.serviceGroups,
|
||||
document.serviceGroups,
|
||||
).map(serviceGroupToUi),
|
||||
modules: orderedItems(
|
||||
document.layout.modules || [],
|
||||
document.modules || [],
|
||||
).map(moduleToUi),
|
||||
statusItems: orderedItems(
|
||||
document.layout.statusStrips,
|
||||
document.statusStrips,
|
||||
).flatMap(statusStripToUiItems),
|
||||
statusStripId: document.layout.statusStrips[0],
|
||||
};
|
||||
}
|
||||
|
||||
function telemetryToUi(card: TelemetryCard): UiTelemetryCard {
|
||||
return {
|
||||
id: card.id,
|
||||
label: card.label,
|
||||
description: card.description,
|
||||
icon: card.icon,
|
||||
value: card.value,
|
||||
detail: card.detail,
|
||||
severity: card.severity,
|
||||
sparkline: card.sparkline,
|
||||
};
|
||||
}
|
||||
|
||||
function serviceGroupToUi(group: ServiceGroup): UiServiceGroup {
|
||||
return {
|
||||
id: group.id,
|
||||
layout: group.layout,
|
||||
title: group.title,
|
||||
services: group.services.map(serviceToUi),
|
||||
};
|
||||
}
|
||||
|
||||
function serviceToUi(service: ServiceEntry): UiServiceRow {
|
||||
return {
|
||||
id: service.id,
|
||||
label: service.label,
|
||||
description: service.description,
|
||||
icon: service.icon,
|
||||
severity: service.severity,
|
||||
detail: service.detail,
|
||||
link: service.link,
|
||||
};
|
||||
}
|
||||
|
||||
function moduleToUi(module: DashboardModule): UiModuleBlock {
|
||||
return {
|
||||
id: module.id,
|
||||
title: module.title,
|
||||
label: module.label,
|
||||
value: module.value,
|
||||
detail: module.detail,
|
||||
icon: module.icon,
|
||||
severity: module.severity,
|
||||
};
|
||||
}
|
||||
|
||||
function statusStripToUiItems(strip: StatusStrip): UiStatusItem[] {
|
||||
return strip.items.map((item) => statusItemToUi(strip.id, item));
|
||||
}
|
||||
|
||||
function statusItemToUi(stripId: string, item: StatusItem): UiStatusItem {
|
||||
return {
|
||||
id: `${stripId}:${item.id}`,
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
severity: item.severity,
|
||||
link: item.link,
|
||||
};
|
||||
}
|
||||
|
||||
function orderedItems<T extends { id: string }>(ids: string[], items: T[]): T[] {
|
||||
const byId = new Map(items.map((item) => [item.id, item]));
|
||||
return ids.flatMap((id) => {
|
||||
const item = byId.get(id);
|
||||
return item ? [item] : [];
|
||||
});
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ import { existsSync, readFileSync } from "node:fs";
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
const root = existsSync(join(process.cwd(), "turbo.json"))
|
||||
? process.cwd()
|
||||
: join(process.cwd(), "..", "..");
|
||||
|
||||
describe("workspace boundaries", () => {
|
||||
test("declares the root as a turbo-managed bun workspace", () => {
|
||||
|
|
|
|||
13
apps/web/src/main.tsx
Normal file
13
apps/web/src/main.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./app.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (!root) throw new Error("Missing React root element");
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
97
apps/web/src/page.test.tsx
Normal file
97
apps/web/src/page.test.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import { AppStateView, resolveDocumentMetadata } from "./App";
|
||||
|
||||
describe("home page model renderer", () => {
|
||||
test("renders the active dashboard model from the runtime state", () => {
|
||||
const body = renderToString(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "ready",
|
||||
document: genericDashboardFixture,
|
||||
schemaVersion: "dashboard.v1",
|
||||
currentRevisionId: "revision-1234567890",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(body).toContain("Operations Console");
|
||||
expect(body).toContain("Service Uptime");
|
||||
expect(body).toContain("Identity");
|
||||
expect(body).toContain("data-model-id=\"service-uptime\"");
|
||||
expect(body).not.toContain("primary");
|
||||
expect(body).not.toContain("secondary");
|
||||
});
|
||||
|
||||
test("renders invalid model state without crashing", () => {
|
||||
const body = renderToString(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "invalid",
|
||||
title: "Invalid Dashboard",
|
||||
subtitle: "Validation failed",
|
||||
message: "Dashboard document is invalid.",
|
||||
errors: ["/metadata/title is required"],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(body).toContain("Invalid Dashboard");
|
||||
expect(body).toContain("Validation failed");
|
||||
expect(body).toContain("Dashboard document is invalid.");
|
||||
expect(body).toContain("/metadata/title is required");
|
||||
});
|
||||
|
||||
test("derives browser metadata from ready and fallback runtime states", () => {
|
||||
const ready = resolveDocumentMetadata({
|
||||
state: "ready",
|
||||
document: genericDashboardFixture,
|
||||
schemaVersion: "dashboard.v1",
|
||||
currentRevisionId: "revision-1234567890",
|
||||
});
|
||||
const empty = resolveDocumentMetadata({
|
||||
state: "empty",
|
||||
title: "No Dashboard Model",
|
||||
subtitle: "No active document",
|
||||
message: "No validated dashboard document is active yet.",
|
||||
});
|
||||
|
||||
expect(ready).toEqual({
|
||||
title: "Operations Console",
|
||||
description: "Generic environment",
|
||||
});
|
||||
expect(empty).toEqual({
|
||||
title: "No Dashboard Model",
|
||||
description: "No active document",
|
||||
});
|
||||
});
|
||||
|
||||
test("renders empty and loading model states without crashing", () => {
|
||||
const empty = renderToString(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "empty",
|
||||
title: "No Dashboard Model",
|
||||
subtitle: "No active document",
|
||||
message: "No validated dashboard document is active yet.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const loading = renderToString(
|
||||
<AppStateView
|
||||
dashboard={{
|
||||
state: "loading",
|
||||
title: "Loading Dashboard",
|
||||
subtitle: "Fetching active model",
|
||||
message: "Waiting for the active dashboard document.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(empty).toContain("No Dashboard Model");
|
||||
expect(empty).toContain("No active document");
|
||||
expect(loading).toContain("Loading Dashboard");
|
||||
expect(loading).toContain("Fetching active model");
|
||||
});
|
||||
});
|
||||
25
apps/web/src/server/dev.test.ts
Normal file
25
apps/web/src/server/dev.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createDevServerConfig } from "../../vite.config";
|
||||
|
||||
describe("local development runtime", () => {
|
||||
test("starts the Bun API server together with the Vite dev server", () => {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(process.cwd(), "package.json"), "utf8"),
|
||||
) as { scripts?: Record<string, string> };
|
||||
|
||||
expect(packageJson.scripts?.dev).toBe("bun src/server/dev.ts");
|
||||
});
|
||||
|
||||
test("proxies dashboard API requests from Vite to the Bun API server", () => {
|
||||
const server = createDevServerConfig({
|
||||
DASHBOARD_DEV_API_TARGET: "http://127.0.0.1:5174",
|
||||
});
|
||||
|
||||
expect(server?.proxy?.["/api"]).toMatchObject({
|
||||
target: "http://127.0.0.1:5174",
|
||||
changeOrigin: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
72
apps/web/src/server/dev.ts
Normal file
72
apps/web/src/server/dev.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
const webHost = process.env.HOST || "0.0.0.0";
|
||||
const webPort = process.env.PORT || "5173";
|
||||
const apiHost = process.env.DASHBOARD_DEV_API_HOST || "127.0.0.1";
|
||||
const apiPort = process.env.DASHBOARD_DEV_API_PORT || "5174";
|
||||
const apiTarget = `http://${apiHost}:${apiPort}`;
|
||||
|
||||
if (import.meta.main) {
|
||||
runDevServers();
|
||||
}
|
||||
|
||||
export function runDevServers(): void {
|
||||
const children: Array<ReturnType<typeof Bun.spawn>> = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
function spawn(
|
||||
label: string,
|
||||
command: string[],
|
||||
env: Record<string, string> = {},
|
||||
): void {
|
||||
const child = Bun.spawn(command, {
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
children.push(child);
|
||||
|
||||
void child.exited.then((code) => {
|
||||
if (shuttingDown) return;
|
||||
console.error(`${label} exited with status ${code}`);
|
||||
shutdown(code || 1);
|
||||
});
|
||||
}
|
||||
|
||||
function shutdown(code = 0): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
|
||||
for (const child of children) {
|
||||
child.kill();
|
||||
}
|
||||
|
||||
void Promise.allSettled(children.map((child) => child.exited)).then(() => {
|
||||
process.exit(code);
|
||||
});
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
spawn("api server", [process.execPath, "src/server/index.ts"], {
|
||||
HOST: apiHost,
|
||||
PORT: apiPort,
|
||||
});
|
||||
|
||||
spawn("vite dev server", [
|
||||
process.execPath,
|
||||
"x",
|
||||
"vite",
|
||||
"--host",
|
||||
webHost,
|
||||
"--port",
|
||||
webPort,
|
||||
], {
|
||||
DASHBOARD_DEV_API_TARGET: apiTarget,
|
||||
});
|
||||
|
||||
console.info(`Dashboard API proxy target: ${apiTarget}`);
|
||||
}
|
||||
84
apps/web/src/server/index.ts
Normal file
84
apps/web/src/server/index.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { extname, normalize } from "node:path";
|
||||
import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
|
||||
import { handleDashboardRoute } from "./routes/dashboard";
|
||||
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const distRoot = `${process.cwd()}/dist`;
|
||||
|
||||
const contentTypes = new Map([
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
[".wasm", "application/wasm"],
|
||||
]);
|
||||
|
||||
export async function handleRequest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/api/dashboard") {
|
||||
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
||||
return handleDashboardRoute();
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/agent/dashboard") {
|
||||
if (request.method !== "POST") return methodNotAllowed(["POST"]);
|
||||
return handleAgentDashboardRoute(request);
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return Response.json({ ok: false, message: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return serveStaticAsset(url.pathname);
|
||||
}
|
||||
|
||||
async function serveStaticAsset(pathname: string): Promise<Response> {
|
||||
const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
const assetPath = safePath === "/" || safePath === "." ? "/index.html" : safePath;
|
||||
const file = Bun.file(`${distRoot}${assetPath}`);
|
||||
|
||||
if (await file.exists()) {
|
||||
return new Response(file, {
|
||||
headers: contentTypeHeaders(assetPath),
|
||||
});
|
||||
}
|
||||
|
||||
const fallback = Bun.file(`${distRoot}/index.html`);
|
||||
if (await fallback.exists()) {
|
||||
return new Response(fallback, {
|
||||
headers: contentTypeHeaders(".html"),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Build output not found", { status: 404 });
|
||||
}
|
||||
|
||||
function methodNotAllowed(allowedMethods: string[]): Response {
|
||||
return Response.json(
|
||||
{ ok: false, message: "Method not allowed" },
|
||||
{
|
||||
status: 405,
|
||||
headers: {
|
||||
Allow: allowedMethods.join(", "),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function contentTypeHeaders(pathname: string): HeadersInit {
|
||||
const contentType = contentTypes.get(extname(pathname));
|
||||
return contentType ? { "Content-Type": contentType } : {};
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
fetch: handleRequest,
|
||||
});
|
||||
|
||||
console.info(`Dimension Lab website listening on http://${host}:${port}`);
|
||||
}
|
||||
12
apps/web/src/server/routes/agent-dashboard.test.ts
Normal file
12
apps/web/src/server/routes/agent-dashboard.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { handleAgentDashboardRoute } from "./agent-dashboard";
|
||||
|
||||
describe("agent dashboard API route", () => {
|
||||
test("delegates unauthorized requests to the existing agent handler", async () => {
|
||||
const response = await handleAgentDashboardRoute(
|
||||
new Request("http://localhost/api/agent/dashboard", { method: "POST" }),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
||||
5
apps/web/src/server/routes/agent-dashboard.ts
Normal file
5
apps/web/src/server/routes/agent-dashboard.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { handleAgentDashboardRequest } from "$lib/server/agent-config";
|
||||
|
||||
export function handleAgentDashboardRoute(request: Request): Promise<Response> {
|
||||
return handleAgentDashboardRequest(request);
|
||||
}
|
||||
19
apps/web/src/server/routes/dashboard.test.ts
Normal file
19
apps/web/src/server/routes/dashboard.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||
import { loadDashboardResponse } from "./dashboard";
|
||||
|
||||
describe("dashboard API route", () => {
|
||||
test("returns ready dashboard runtime state from the existing model loader", async () => {
|
||||
const response = await loadDashboardResponse({
|
||||
disableLiveDatasources: true,
|
||||
refreshSeedDocument: true,
|
||||
seedIfEmpty: true,
|
||||
});
|
||||
|
||||
expect(response.state).toBe("ready");
|
||||
if (response.state !== "ready") throw new Error("expected ready dashboard");
|
||||
expect(response.document.metadata.title).toBe(
|
||||
dimensionLabDashboardFixture.metadata.title,
|
||||
);
|
||||
});
|
||||
});
|
||||
41
apps/web/src/server/routes/dashboard.ts
Normal file
41
apps/web/src/server/routes/dashboard.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
loadDashboardRuntime,
|
||||
type DashboardRuntimeOptions,
|
||||
type DashboardRuntimeState,
|
||||
} from "$lib/server/dashboard";
|
||||
import { resolveDashboardDatasources } from "$lib/server/datasources";
|
||||
|
||||
export interface LoadDashboardResponseOptions
|
||||
extends Pick<
|
||||
DashboardRuntimeOptions,
|
||||
"refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
|
||||
> {
|
||||
disableLiveDatasources?: boolean;
|
||||
}
|
||||
|
||||
export async function loadDashboardResponse(
|
||||
options: LoadDashboardResponseOptions = {},
|
||||
): Promise<DashboardRuntimeState> {
|
||||
const dashboard = loadDashboardRuntime(undefined, {
|
||||
refreshSeedDocument: options.refreshSeedDocument ?? true,
|
||||
seedIfEmpty: options.seedIfEmpty ?? true,
|
||||
seedDocument: options.seedDocument,
|
||||
});
|
||||
|
||||
if (dashboard.state !== "ready") {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
return {
|
||||
...dashboard,
|
||||
document: await resolveDashboardDatasources(dashboard.document),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleDashboardRoute(): Promise<Response> {
|
||||
return Response.json(await loadDashboardResponse());
|
||||
}
|
||||
3
apps/web/src/vite-env.d.ts
vendored
Normal file
3
apps/web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.css" {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue