feat: render dashboard from model
This commit is contained in:
parent
13e3ff867b
commit
fede33e8a7
11 changed files with 372 additions and 139 deletions
|
|
@ -4,6 +4,7 @@ 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";
|
||||
|
||||
|
|
@ -21,13 +22,30 @@ describe("dashboard runtime loader", () => {
|
|||
|
||||
const runtime = loadDashboardRuntime(store);
|
||||
|
||||
expect(runtime.title).toBe(dimensionLabDashboardFixture.metadata.title);
|
||||
expect(runtime.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle);
|
||||
expect(runtime.status).toBe("building");
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
async function createTestStore() {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,42 @@
|
|||
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 PlaceholderStatus = "ready" | "building";
|
||||
export type DashboardRuntimeState =
|
||||
| DashboardRuntimeEmpty
|
||||
| DashboardRuntimeInvalid
|
||||
| DashboardRuntimeReady;
|
||||
|
||||
export interface PlaceholderDashboard {
|
||||
export interface DashboardRuntimeReady {
|
||||
state: "ready";
|
||||
document: DashboardDocument;
|
||||
schemaVersion: string;
|
||||
currentRevisionId: string;
|
||||
}
|
||||
|
||||
export interface DashboardRuntimeEmpty {
|
||||
state: "empty";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
status: PlaceholderStatus;
|
||||
message: string;
|
||||
schemaVersion?: string;
|
||||
currentRevisionId?: string;
|
||||
}
|
||||
|
||||
export function loadPlaceholderDashboard(): PlaceholderDashboard {
|
||||
return {
|
||||
title: "Dashboard Runtime",
|
||||
subtitle: "Runtime scaffold",
|
||||
status: "building",
|
||||
message:
|
||||
"SvelteKit is running. Later issues will replace this placeholder with the validated dashboard model.",
|
||||
};
|
||||
export interface DashboardRuntimeInvalid {
|
||||
state: "invalid";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
message: string;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export function loadDashboardRuntime(store?: DashboardStore): PlaceholderDashboard {
|
||||
export function loadDashboardRuntime(
|
||||
store?: DashboardStore,
|
||||
): DashboardRuntimeState {
|
||||
const dashboardStore = store || createDashboardStore();
|
||||
|
||||
try {
|
||||
|
|
@ -37,16 +48,42 @@ export function loadDashboardRuntime(store?: DashboardStore): PlaceholderDashboa
|
|||
const document = active?.document || seeded.document;
|
||||
const currentRevisionId = active?.currentRevisionId || seeded.id;
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
state: "empty",
|
||||
title: "No Dashboard Model",
|
||||
subtitle: "No active document",
|
||||
message: "No validated dashboard document is active yet.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: document.metadata.title,
|
||||
subtitle: document.metadata.subtitle || "",
|
||||
status: "building",
|
||||
message:
|
||||
"Active dashboard document loaded from SQLite. Later issues will replace this placeholder with model-driven rendering.",
|
||||
state: "ready",
|
||||
document,
|
||||
schemaVersion: document.schemaVersion,
|
||||
currentRevisionId,
|
||||
};
|
||||
} 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 invalidRuntimeState(errors: string[]): DashboardRuntimeInvalid {
|
||||
return {
|
||||
state: "invalid",
|
||||
title: "Invalid Dashboard Model",
|
||||
subtitle: "Validation failed",
|
||||
message: "The active dashboard document could not be validated.",
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
<a
|
||||
class="footer-cell"
|
||||
data-severity={item.severity || "neutral"}
|
||||
data-model-id={item.id}
|
||||
href={item.link.href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
|
|
@ -24,7 +25,7 @@
|
|||
{@render cellContent()}
|
||||
</a>
|
||||
{:else}
|
||||
<div class="footer-cell" data-severity={item.severity || "neutral"}>
|
||||
<div class="footer-cell" data-severity={item.severity || "neutral"} data-model-id={item.id}>
|
||||
{@render cellContent()}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
let { module }: { module: UiModuleBlock } = $props();
|
||||
</script>
|
||||
|
||||
<aside class="module-card" data-severity={module.severity || "neutral"}>
|
||||
<aside class="module-card" data-severity={module.severity || "neutral"} data-model-id={module.id}>
|
||||
<div>
|
||||
{#if module.title}
|
||||
<h2>{module.title}</h2>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
<a
|
||||
class="service-row"
|
||||
data-severity={service.severity}
|
||||
data-model-id={service.id}
|
||||
href={service.link.href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
|
|
@ -32,7 +33,7 @@
|
|||
{@render rowContent()}
|
||||
</a>
|
||||
{:else}
|
||||
<article class="service-row" data-severity={service.severity}>
|
||||
<article class="service-row" data-severity={service.severity} data-model-id={service.id}>
|
||||
{@render rowContent()}
|
||||
</article>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
let value = $derived(formatMetricValue(card.value));
|
||||
</script>
|
||||
|
||||
<article class="telemetry-card" data-severity={card.severity}>
|
||||
<article class="telemetry-card" data-severity={card.severity} data-model-id={card.id}>
|
||||
<header>
|
||||
{#if card.icon}
|
||||
<IconGlyph name={card.icon} size="sm" />
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export { default as TelemetryGrid } from "./components/TelemetryGrid.svelte";
|
|||
export { default as TelemetryStrip } from "./components/TelemetryStrip.svelte";
|
||||
export { default as WeatherModule } from "./components/WeatherModule.svelte";
|
||||
export { dashboardPreviewFixtures } from "./fixtures";
|
||||
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||
export type {
|
||||
UiDashboardPreview,
|
||||
UiLink,
|
||||
|
|
|
|||
90
src/lib/ui/model-renderer.test.ts
Normal file
90
src/lib/ui/model-renderer.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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";
|
||||
|
||||
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.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",
|
||||
]);
|
||||
});
|
||||
|
||||
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(process.cwd(), "src/lib/ui/model-renderer.ts"), "utf8").toLowerCase();
|
||||
|
||||
expect(source).not.toContain("dimension");
|
||||
expect(source).not.toContain("vaultwarden");
|
||||
expect(source).not.toContain("forgejo");
|
||||
});
|
||||
});
|
||||
109
src/lib/ui/model-renderer.ts
Normal file
109
src/lib/ui/model-renderer.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import type {
|
||||
DashboardDocument,
|
||||
DashboardModule,
|
||||
ServiceEntry,
|
||||
ServiceGroup,
|
||||
StatusItem,
|
||||
StatusStrip,
|
||||
TelemetryCard,
|
||||
} from "$lib/model";
|
||||
import type {
|
||||
UiDashboardPreview,
|
||||
UiModuleBlock,
|
||||
UiServiceGroup,
|
||||
UiServiceRow,
|
||||
UiStatusItem,
|
||||
UiTelemetryCard,
|
||||
} from "./types";
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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] : [];
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue