refactor(model): extract dashboard model package

This commit is contained in:
vince 2026-06-20 06:26:19 +02:00
parent 22f50d64f9
commit 7a6ad8ab0e
35 changed files with 164 additions and 57 deletions

View file

@ -7,7 +7,7 @@ import type {
DatasourceReference,
ServiceEntry,
TelemetryCard,
} from "../schema";
} from "@dimensionlab/dashboard-model";
describe("Dimension Lab dashboard seed", () => {
test("defines the primary first-screen sections as model data", () => {

View file

@ -7,7 +7,7 @@ import {
type ServiceGroup,
type Severity,
type TelemetryCard,
} from "../schema";
} from "@dimensionlab/dashboard-model";
type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number";

View file

@ -1,91 +0,0 @@
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" },
},
],
};

View file

@ -1,2 +0,0 @@
export { dimensionLabDashboardFixture } from "./dimensionlab";
export { genericDashboardFixture } from "./generic";

View file

@ -1,29 +0,0 @@
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";

View file

@ -1,204 +0,0 @@
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");
});
});

View file

@ -1,252 +0,0 @@
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;

View file

@ -1,322 +0,0 @@
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");
}

View file

@ -74,5 +74,7 @@ function findFiles(path: string, extension: string): string[] {
}
function withoutInternalPackageScope(source: string): string {
return source.replaceAll("@dimensionlab/ui", "@internal/ui");
return source
.replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model")
.replaceAll("@dimensionlab/ui", "@internal/ui");
}

View file

@ -3,8 +3,8 @@ 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 type { DashboardDocument } from "@dimensionlab/dashboard-model";
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store";
import {
AgentConfigAuthorizationError,

View file

@ -16,7 +16,7 @@ import {
type ServiceGroup,
type StatusItem,
type TelemetryCard,
} from "$lib/model";
} from "@dimensionlab/dashboard-model";
import {
createDashboardStore,
DashboardRevisionNotFoundError,

View file

@ -3,8 +3,8 @@ 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 { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
import { loadDashboardRuntime } from "./dashboard";

View file

@ -1,5 +1,5 @@
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
import type { DashboardDocument } from "$lib/model";
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
import {
createDashboardStore,
DashboardPersistenceValidationError,

View file

@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest";
import {
DASHBOARD_SCHEMA_VERSION,
type DashboardDocument,
} from "$lib/model";
} from "@dimensionlab/dashboard-model";
import { resolveDashboardDatasources } from ".";
describe("dashboard datasource resolution", () => {

View file

@ -8,7 +8,7 @@ import type {
StatusItem,
StatusStrip,
TelemetryCard,
} from "$lib/model";
} from "@dimensionlab/dashboard-model";
export interface DatasourceResolutionOptions {
fetch?: DatasourceFetch;

View file

@ -3,8 +3,8 @@ 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 { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
import {
DashboardPersistenceValidationError,
createDashboardStore,

View file

@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm";
import {
type DashboardDocument,
type DashboardValidationFailure,
} from "$lib/model";
} from "@dimensionlab/dashboard-model";
import {
type DashboardDatabaseConnection,
openDashboardDatabase,

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { DASHBOARD_SCHEMA_VERSION } from "$lib/model";
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
import { DASHBOARD_SCHEMA_VERSION } from "@dimensionlab/dashboard-model";
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import {
UnsupportedDashboardModelVersionError,
migrateDashboardDocumentForPersistence,

View file

@ -3,7 +3,7 @@ import {
validateDashboardDocument,
type DashboardDocument,
type DashboardValidationFailure,
} from "$lib/model";
} from "@dimensionlab/dashboard-model";
export interface DashboardModelMigrationSuccess {
valid: true;

View file

@ -1,4 +1,4 @@
import type { DashboardDocument } from "$lib/model";
import type { DashboardDocument } from "@dimensionlab/dashboard-model";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const dashboardDocuments = sqliteTable("dashboard_documents", {

View file

@ -1,9 +1,9 @@
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 { type DashboardDocument } from "@dimensionlab/dashboard-model";
import { dimensionLabDashboardFixture } from "$lib/dashboard-seed/dimensionlab";
import { genericDashboardFixture } from "@dimensionlab/dashboard-model/fixtures";
import { dashboardDocumentToUiDashboard } from "./model-renderer";
const appRoot = process.cwd().endsWith(`${join("apps", "web")}`)
@ -93,6 +93,7 @@ describe("dashboard model renderer", () => {
"utf8",
)
.toLowerCase()
.replaceAll("@dimensionlab/dashboard-model", "@internal/dashboard-model")
.replaceAll("@dimensionlab/ui", "@internal/ui");
expect(source).not.toContain("dimension");

View file

@ -6,7 +6,7 @@ import type {
StatusItem,
StatusStrip,
TelemetryCard,
} from "$lib/model";
} from "@dimensionlab/dashboard-model";
import type {
UiDashboardPreview,
UiModuleBlock,

View file

@ -88,4 +88,29 @@ describe("workspace boundaries", () => {
expect(uiPackage.exports).toHaveProperty(".");
expect(uiPackage.exports).toHaveProperty("./styles.css");
});
test("keeps the dashboard model in a reusable internal package", () => {
const webPackage = JSON.parse(
readFileSync(join(root, "apps/web/package.json"), "utf8"),
) as { dependencies?: Record<string, string> };
const modelPackage = JSON.parse(
readFileSync(join(root, "packages/dashboard-model/package.json"), "utf8"),
) as { exports?: Record<string, unknown>; name?: string };
const tsconfig = JSON.parse(
readFileSync(join(root, "tsconfig.json"), "utf8"),
) as { references?: Array<{ path: string }> };
expect(webPackage.dependencies?.["@dimensionlab/dashboard-model"]).toBe(
"workspace:*",
);
expect(modelPackage.name).toBe("@dimensionlab/dashboard-model");
expect(modelPackage.exports).toHaveProperty(".");
expect(modelPackage.exports).toHaveProperty("./fixtures");
expect(tsconfig.references).toEqual(
expect.arrayContaining([{ path: "./packages/dashboard-model" }]),
);
expect(existsSync(join(root, "apps/web/src/lib/model/index.ts"))).toBe(
false,
);
});
});