Merge pull request #20 from codex/issue-7-model-renderer
feat: render dashboard from model
This commit is contained in:
commit
af09f705b1
18 changed files with 495 additions and 156 deletions
|
|
@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||||
|
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||||
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
|
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
|
||||||
import { loadDashboardRuntime } from "./dashboard";
|
import { loadDashboardRuntime } from "./dashboard";
|
||||||
|
|
||||||
|
|
@ -19,15 +20,43 @@ describe("dashboard runtime loader", () => {
|
||||||
test("seeds and loads the active dashboard from sqlite", async () => {
|
test("seeds and loads the active dashboard from sqlite", async () => {
|
||||||
const store = await createTestStore();
|
const store = await createTestStore();
|
||||||
|
|
||||||
const runtime = loadDashboardRuntime(store);
|
const runtime = loadDashboardRuntime(store, { seedIfEmpty: true });
|
||||||
|
|
||||||
expect(runtime.title).toBe(dimensionLabDashboardFixture.metadata.title);
|
expect(runtime.state).toBe("ready");
|
||||||
expect(runtime.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle);
|
if (runtime.state !== "ready") throw new Error("expected ready dashboard");
|
||||||
expect(runtime.status).toBe("building");
|
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.schemaVersion).toBe(dimensionLabDashboardFixture.schemaVersion);
|
||||||
expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId);
|
expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId);
|
||||||
expect(store.listRevisions()).toHaveLength(1);
|
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("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() {
|
async function createTestStore() {
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,115 @@
|
||||||
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
|
||||||
|
import type { DashboardDocument } from "$lib/model";
|
||||||
import {
|
import {
|
||||||
createDashboardStore,
|
createDashboardStore,
|
||||||
|
DashboardPersistenceValidationError,
|
||||||
type DashboardStore,
|
type DashboardStore,
|
||||||
} from "$lib/server/db/dashboard-store";
|
} from "$lib/server/db/dashboard-store";
|
||||||
|
import { UnsupportedDashboardModelVersionError } from "$lib/server/db/model-migrations";
|
||||||
|
|
||||||
export type PlaceholderStatus = "ready" | "building";
|
export type DashboardRuntimeState =
|
||||||
|
| DashboardRuntimeEmpty
|
||||||
|
| DashboardRuntimeInvalid
|
||||||
|
| DashboardRuntimeLoading
|
||||||
|
| DashboardRuntimeReady;
|
||||||
|
|
||||||
export interface PlaceholderDashboard {
|
export interface DashboardRuntimeReady {
|
||||||
|
state: "ready";
|
||||||
|
document: DashboardDocument;
|
||||||
|
schemaVersion: string;
|
||||||
|
currentRevisionId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRuntimeEmpty {
|
||||||
|
state: "empty";
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
status: PlaceholderStatus;
|
|
||||||
message: string;
|
message: string;
|
||||||
schemaVersion?: string;
|
|
||||||
currentRevisionId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadPlaceholderDashboard(): PlaceholderDashboard {
|
export interface DashboardRuntimeLoading {
|
||||||
return {
|
state: "loading";
|
||||||
title: "Dashboard Runtime",
|
title: string;
|
||||||
subtitle: "Runtime scaffold",
|
subtitle: string;
|
||||||
status: "building",
|
message: string;
|
||||||
message:
|
|
||||||
"SvelteKit is running. Later issues will replace this placeholder with the validated dashboard model.",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadDashboardRuntime(store?: DashboardStore): PlaceholderDashboard {
|
export interface DashboardRuntimeInvalid {
|
||||||
|
state: "invalid";
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
message: string;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardRuntimeOptions {
|
||||||
|
seedIfEmpty?: boolean;
|
||||||
|
seedDocument?: DashboardDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDashboardRuntime(
|
||||||
|
store?: DashboardStore,
|
||||||
|
options: DashboardRuntimeOptions = {},
|
||||||
|
): DashboardRuntimeState {
|
||||||
const dashboardStore = store || createDashboardStore();
|
const dashboardStore = store || createDashboardStore();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const seeded = dashboardStore.seedDashboardIfEmpty(dimensionLabDashboardFixture, {
|
|
||||||
actor: "initial-seed",
|
|
||||||
message: "load initial dashboard document",
|
|
||||||
});
|
|
||||||
const active = dashboardStore.getActiveDashboard();
|
const active = dashboardStore.getActiveDashboard();
|
||||||
const document = active?.document || seeded.document;
|
if (active) {
|
||||||
const currentRevisionId = active?.currentRevisionId || seeded.id;
|
return readyRuntimeState(active.document, active.currentRevisionId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if (!options.seedIfEmpty) {
|
||||||
title: document.metadata.title,
|
return {
|
||||||
subtitle: document.metadata.subtitle || "",
|
state: "empty",
|
||||||
status: "building",
|
title: "No Dashboard Model",
|
||||||
message:
|
subtitle: "No active document",
|
||||||
"Active dashboard document loaded from SQLite. Later issues will replace this placeholder with model-driven rendering.",
|
message: "No validated dashboard document is active yet.",
|
||||||
schemaVersion: document.schemaVersion,
|
};
|
||||||
currentRevisionId,
|
}
|
||||||
};
|
|
||||||
|
const seeded = dashboardStore.seedDashboardIfEmpty(
|
||||||
|
options.seedDocument || dimensionLabDashboardFixture,
|
||||||
|
{
|
||||||
|
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 {
|
} finally {
|
||||||
if (!store) dashboardStore.close();
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<StatusStrip items={dashboard.statusItems} />
|
<StatusStrip id={dashboard.statusStripId} items={dashboard.statusItems} />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
<a
|
<a
|
||||||
class="footer-cell"
|
class="footer-cell"
|
||||||
data-severity={item.severity || "neutral"}
|
data-severity={item.severity || "neutral"}
|
||||||
|
data-model-id={item.id}
|
||||||
href={item.link.href}
|
href={item.link.href}
|
||||||
target={target}
|
target={target}
|
||||||
rel={rel}
|
rel={rel}
|
||||||
|
|
@ -24,7 +25,7 @@
|
||||||
{@render cellContent()}
|
{@render cellContent()}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{: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()}
|
{@render cellContent()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
let { module }: { module: UiModuleBlock } = $props();
|
let { module }: { module: UiModuleBlock } = $props();
|
||||||
</script>
|
</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>
|
<div>
|
||||||
{#if module.title}
|
{#if module.title}
|
||||||
<h2>{module.title}</h2>
|
<h2>{module.title}</h2>
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@
|
||||||
let { group }: { group: UiServiceGroup } = $props();
|
let { group }: { group: UiServiceGroup } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Panel title={group.title}>
|
<div class="service-panel" data-model-id={group.id}>
|
||||||
|
<Panel title={group.title}>
|
||||||
{#if group.summary?.length}
|
{#if group.summary?.length}
|
||||||
<StatusStrip items={group.summary} compact />
|
<StatusStrip id={`${group.id}:summary`} items={group.summary} compact />
|
||||||
{/if}
|
{/if}
|
||||||
{#each group.services as service (service.id)}
|
{#each group.services as service (service.id)}
|
||||||
<ServiceRow {service} />
|
<ServiceRow {service} />
|
||||||
{/each}
|
{/each}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
<a
|
<a
|
||||||
class="service-row"
|
class="service-row"
|
||||||
data-severity={service.severity}
|
data-severity={service.severity}
|
||||||
|
data-model-id={service.id}
|
||||||
href={service.link.href}
|
href={service.link.href}
|
||||||
target={target}
|
target={target}
|
||||||
rel={rel}
|
rel={rel}
|
||||||
|
|
@ -32,7 +33,7 @@
|
||||||
{@render rowContent()}
|
{@render rowContent()}
|
||||||
</a>
|
</a>
|
||||||
{:else}
|
{:else}
|
||||||
<article class="service-row" data-severity={service.severity}>
|
<article class="service-row" data-severity={service.severity} data-model-id={service.id}>
|
||||||
{@render rowContent()}
|
{@render rowContent()}
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,17 @@
|
||||||
import FooterCell from "./FooterCell.svelte";
|
import FooterCell from "./FooterCell.svelte";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
id,
|
||||||
items,
|
items,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string;
|
||||||
items: UiStatusItem[];
|
items: UiStatusItem[];
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="status-strip" data-compact={compact}>
|
<section class="status-strip" data-compact={compact} data-model-id={id}>
|
||||||
{#each items as item (item.id)}
|
{#each items as item (item.id)}
|
||||||
<FooterCell {item} />
|
<FooterCell {item} />
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
let value = $derived(formatMetricValue(card.value));
|
let value = $derived(formatMetricValue(card.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="telemetry-card" data-severity={card.severity}>
|
<article class="telemetry-card" data-severity={card.severity} data-model-id={card.id}>
|
||||||
<header>
|
<header>
|
||||||
{#if card.icon}
|
{#if card.icon}
|
||||||
<IconGlyph name={card.icon} size="sm" />
|
<IconGlyph name={card.icon} size="sm" />
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,17 @@ describe("dashboard UI components", () => {
|
||||||
expect(footer.body).toContain("href=\"https://example.test/status\"");
|
expect(footer.body).toContain("href=\"https://example.test/status\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders stable model IDs on group and status containers", () => {
|
||||||
|
const { body } = render(DashboardFrame, {
|
||||||
|
props: {
|
||||||
|
dashboard: dashboardPreviewFixtures.primary,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toContain("data-model-id=\"queue-workers\"");
|
||||||
|
expect(body).toContain("data-model-id=\"dashboard-status\"");
|
||||||
|
});
|
||||||
|
|
||||||
test("does not render progress bars for non-percent metrics without explicit progress", () => {
|
test("does not render progress bars for non-percent metrics without explicit progress", () => {
|
||||||
const withoutProgress = render(TelemetryCard, {
|
const withoutProgress = render(TelemetryCard, {
|
||||||
props: {
|
props: {
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ export const dashboardPreviewFixtures: Record<string, UiDashboardPreview> = {
|
||||||
{ id: "uptime", label: "Uptime", value: "14d 08h", severity: "neutral" },
|
{ id: "uptime", label: "Uptime", value: "14d 08h", severity: "neutral" },
|
||||||
{ id: "refresh", label: "Refresh", value: "15s", severity: "neutral" },
|
{ id: "refresh", label: "Refresh", value: "15s", severity: "neutral" },
|
||||||
],
|
],
|
||||||
|
statusStripId: "dashboard-status",
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
eyebrow: "Support Surface",
|
eyebrow: "Support Surface",
|
||||||
|
|
@ -96,6 +97,7 @@ export const dashboardPreviewFixtures: Record<string, UiDashboardPreview> = {
|
||||||
{ id: "handoff", label: "Handoff", value: "Pending", severity: "warning" },
|
{ id: "handoff", label: "Handoff", value: "Pending", severity: "warning" },
|
||||||
{ id: "routing", label: "Routing", value: "Manual", severity: "neutral" },
|
{ id: "routing", label: "Routing", value: "Manual", severity: "neutral" },
|
||||||
],
|
],
|
||||||
|
statusStripId: "support-status",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export { default as TelemetryGrid } from "./components/TelemetryGrid.svelte";
|
||||||
export { default as TelemetryStrip } from "./components/TelemetryStrip.svelte";
|
export { default as TelemetryStrip } from "./components/TelemetryStrip.svelte";
|
||||||
export { default as WeatherModule } from "./components/WeatherModule.svelte";
|
export { default as WeatherModule } from "./components/WeatherModule.svelte";
|
||||||
export { dashboardPreviewFixtures } from "./fixtures";
|
export { dashboardPreviewFixtures } from "./fixtures";
|
||||||
|
export { dashboardDocumentToUiDashboard } from "./model-renderer";
|
||||||
export type {
|
export type {
|
||||||
UiDashboardPreview,
|
UiDashboardPreview,
|
||||||
UiLink,
|
UiLink,
|
||||||
|
|
|
||||||
91
src/lib/ui/model-renderer.test.ts
Normal file
91
src/lib/ui/model-renderer.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
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",
|
||||||
|
]);
|
||||||
|
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(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");
|
||||||
|
});
|
||||||
|
});
|
||||||
110
src/lib/ui/model-renderer.ts
Normal file
110
src/lib/ui/model-renderer.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
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),
|
||||||
|
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,
|
||||||
|
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] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -83,4 +83,5 @@ export interface UiDashboardPreview {
|
||||||
serviceGroups: UiServiceGroup[];
|
serviceGroups: UiServiceGroup[];
|
||||||
modules: UiModuleBlock[];
|
modules: UiModuleBlock[];
|
||||||
statusItems: UiStatusItem[];
|
statusItems: UiStatusItem[];
|
||||||
|
statusStripId?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@ import { loadDashboardRuntime } from "$lib/server/dashboard";
|
||||||
|
|
||||||
export function load() {
|
export function load() {
|
||||||
return {
|
return {
|
||||||
dashboard: loadDashboardRuntime(),
|
dashboard: loadDashboardRuntime(undefined, { seedIfEmpty: true }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,124 +1,97 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { DashboardFrame, dashboardPreviewFixtures } from "$lib/ui";
|
import {
|
||||||
|
DashboardFrame,
|
||||||
|
SystemState,
|
||||||
|
dashboardDocumentToUiDashboard,
|
||||||
|
} from "$lib/ui";
|
||||||
import type { PageData } from "./$types";
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
const fixtureKeys = ["runtime", "primary", "secondary"] as const;
|
|
||||||
let selectedFixture: (typeof fixtureKeys)[number] = $state("runtime");
|
|
||||||
let runtimeDashboard = $derived({
|
|
||||||
...dashboardPreviewFixtures.primary,
|
|
||||||
eyebrow: data.dashboard.schemaVersion || "Runtime",
|
|
||||||
title: data.dashboard.title,
|
|
||||||
subtitle: data.dashboard.subtitle,
|
|
||||||
modules: [
|
|
||||||
{
|
|
||||||
id: "runtime-state",
|
|
||||||
title: "Runtime",
|
|
||||||
value: data.dashboard.status,
|
|
||||||
detail: data.dashboard.message,
|
|
||||||
icon: "mdi:database-check-outline",
|
|
||||||
severity: "loading" as const,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
statusItems: [
|
|
||||||
{
|
|
||||||
id: "runtime-status",
|
|
||||||
label: "Runtime",
|
|
||||||
value: data.dashboard.status,
|
|
||||||
severity: "loading" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "schema-version",
|
|
||||||
label: "Schema",
|
|
||||||
value: data.dashboard.schemaVersion || "pending",
|
|
||||||
severity: "neutral" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "revision",
|
|
||||||
label: "Revision",
|
|
||||||
value: data.dashboard.currentRevisionId?.slice(0, 12) || "pending",
|
|
||||||
severity: "stale" as const,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
let dashboard = $derived(
|
let dashboard = $derived(
|
||||||
selectedFixture === "runtime"
|
data.dashboard.state === "ready"
|
||||||
? runtimeDashboard
|
? dashboardDocumentToUiDashboard(data.dashboard.document)
|
||||||
: dashboardPreviewFixtures[selectedFixture],
|
: null,
|
||||||
|
);
|
||||||
|
let stateTitle = $derived(
|
||||||
|
data.dashboard.state === "ready" ? "" : data.dashboard.title,
|
||||||
|
);
|
||||||
|
let stateSubtitle = $derived(
|
||||||
|
data.dashboard.state === "ready" ? "" : data.dashboard.subtitle,
|
||||||
|
);
|
||||||
|
let stateMessage = $derived(
|
||||||
|
data.dashboard.state === "ready" ? "" : data.dashboard.message,
|
||||||
|
);
|
||||||
|
let stateErrors = $derived(
|
||||||
|
data.dashboard.state === "invalid" ? data.dashboard.errors : [],
|
||||||
|
);
|
||||||
|
let pageTitle = $derived(dashboard?.title || stateTitle);
|
||||||
|
let pageDescription = $derived(
|
||||||
|
dashboard?.subtitle || stateSubtitle || stateMessage,
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{dashboard.title}</title>
|
<title>{pageTitle}</title>
|
||||||
<meta
|
<meta name="description" content={pageDescription} />
|
||||||
name="description"
|
|
||||||
content="Standalone SvelteKit runtime for a model-driven system overview dashboard."
|
|
||||||
/>
|
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="preview-shell">
|
{#if dashboard}
|
||||||
<div class="fixture-switcher" aria-label="Preview fixture">
|
|
||||||
{#each fixtureKeys as key}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class:active={selectedFixture === key}
|
|
||||||
aria-pressed={selectedFixture === key}
|
|
||||||
onclick={() => {
|
|
||||||
selectedFixture = key;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{key}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DashboardFrame {dashboard} />
|
<DashboardFrame {dashboard} />
|
||||||
</div>
|
{:else}
|
||||||
|
<main class="state-shell" data-dashboard-state={data.dashboard.state}>
|
||||||
|
<SystemState
|
||||||
|
title={stateTitle}
|
||||||
|
detail={`${stateSubtitle}: ${stateMessage}`}
|
||||||
|
severity={stateSeverity(data.dashboard.state)}
|
||||||
|
icon={stateIcon(data.dashboard.state)}
|
||||||
|
/>
|
||||||
|
{#if stateErrors.length}
|
||||||
|
<ul aria-label="Validation errors">
|
||||||
|
{#each stateErrors as error}
|
||||||
|
<li>{error}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</main>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.preview-shell {
|
.state-shell {
|
||||||
position: relative;
|
display: grid;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
align-content: center;
|
||||||
|
gap: var(--ui-space-3);
|
||||||
|
padding: var(--ui-space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixture-switcher {
|
ul {
|
||||||
position: fixed;
|
display: grid;
|
||||||
z-index: var(--ui-z-overlay);
|
max-width: 56rem;
|
||||||
top: var(--ui-space-3);
|
gap: var(--ui-space-2);
|
||||||
right: var(--ui-space-3);
|
margin: 0;
|
||||||
display: inline-grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
gap: 1px;
|
|
||||||
border: var(--ui-border);
|
border: var(--ui-border);
|
||||||
background: var(--ui-color-line);
|
background: rgba(6, 8, 7, 0.82);
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
min-width: 5.5rem;
|
|
||||||
min-height: 2rem;
|
|
||||||
border: 0;
|
|
||||||
background: rgba(2, 3, 2, 0.92);
|
|
||||||
color: var(--ui-color-muted);
|
color: var(--ui-color-muted);
|
||||||
cursor: pointer;
|
font-size: 0.76rem;
|
||||||
font-size: 0.72rem;
|
list-style-position: inside;
|
||||||
font-weight: 800;
|
padding: var(--ui-space-3);
|
||||||
letter-spacing: 0;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.active {
|
|
||||||
background: var(--ui-color-accent);
|
|
||||||
color: var(--ui-color-canvas);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 780px) {
|
|
||||||
.fixture-switcher {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
right: auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script lang="ts" module>
|
||||||
|
import type { DashboardRuntimeState } from "$lib/server/dashboard";
|
||||||
|
import type { UiSeverity } from "$lib/ui";
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,16 @@
|
||||||
import { render } from "svelte/server";
|
import { render } from "svelte/server";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||||
import Page from "./+page.svelte";
|
import Page from "./+page.svelte";
|
||||||
|
|
||||||
describe("home page design-system preview", () => {
|
describe("home page model renderer", () => {
|
||||||
test("renders the runtime dashboard data from the server load", () => {
|
test("renders the active dashboard model from the server load", () => {
|
||||||
const { body } = render(Page, {
|
const { body } = render(Page, {
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
dashboard: {
|
dashboard: {
|
||||||
title: "Runtime Surface",
|
state: "ready",
|
||||||
subtitle: "Loaded from persistence",
|
document: genericDashboardFixture,
|
||||||
status: "building",
|
|
||||||
message: "Runtime model is available.",
|
|
||||||
schemaVersion: "dashboard.v1",
|
schemaVersion: "dashboard.v1",
|
||||||
currentRevisionId: "revision-1234567890",
|
currentRevisionId: "revision-1234567890",
|
||||||
},
|
},
|
||||||
|
|
@ -19,10 +18,63 @@ describe("home page design-system preview", () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(body).toContain("Runtime Surface");
|
expect(body).toContain("Operations Console");
|
||||||
expect(body).toContain("Loaded from persistence");
|
expect(body).toContain("Service Uptime");
|
||||||
expect(body).toContain("revision");
|
expect(body).toContain("Identity");
|
||||||
expect(body).toContain("primary");
|
expect(body).toContain("data-model-id=\"service-uptime\"");
|
||||||
expect(body).toContain("secondary");
|
expect(body).not.toContain("primary");
|
||||||
|
expect(body).not.toContain("secondary");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders invalid model state without crashing", () => {
|
||||||
|
const { body } = render(Page, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
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.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders empty and loading model states without crashing", () => {
|
||||||
|
const empty = render(Page, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
dashboard: {
|
||||||
|
state: "empty",
|
||||||
|
title: "No Dashboard Model",
|
||||||
|
subtitle: "No active document",
|
||||||
|
message: "No validated dashboard document is active yet.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const loading = render(Page, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
dashboard: {
|
||||||
|
state: "loading",
|
||||||
|
title: "Loading Dashboard",
|
||||||
|
subtitle: "Fetching active model",
|
||||||
|
message: "Waiting for the active dashboard document.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(empty.body).toContain("No Dashboard Model");
|
||||||
|
expect(empty.body).toContain("No active document");
|
||||||
|
expect(loading.body).toContain("Loading Dashboard");
|
||||||
|
expect(loading.body).toContain("Fetching active model");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue