refactor(model): extract dashboard model package
This commit is contained in:
parent
22f50d64f9
commit
7a6ad8ab0e
35 changed files with 164 additions and 57 deletions
34
packages/dashboard-model/package.json
Normal file
34
packages/dashboard-model/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@dimensionlab/dashboard-model",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./fixtures": {
|
||||
"types": "./dist/fixtures/index.d.ts",
|
||||
"default": "./dist/fixtures/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"check": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.49",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/node": "^25.9.3",
|
||||
"bun-types": "^1.3.14",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
91
packages/dashboard-model/src/fixtures/generic.ts
Normal file
91
packages/dashboard-model/src/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" },
|
||||
},
|
||||
],
|
||||
};
|
||||
1
packages/dashboard-model/src/fixtures/index.ts
Normal file
1
packages/dashboard-model/src/fixtures/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { genericDashboardFixture } from "./generic";
|
||||
29
packages/dashboard-model/src/index.ts
Normal file
29
packages/dashboard-model/src/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";
|
||||
197
packages/dashboard-model/src/schema.test.ts
Normal file
197
packages/dashboard-model/src/schema.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
dashboardDocumentJsonSchema,
|
||||
validateDashboardDocument,
|
||||
} from ".";
|
||||
import {
|
||||
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("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
packages/dashboard-model/src/schema.ts
Normal file
252
packages/dashboard-model/src/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
packages/dashboard-model/src/validation.ts
Normal file
322
packages/dashboard-model/src/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");
|
||||
}
|
||||
12
packages/dashboard-model/tsconfig.build.json
Normal file
12
packages/dashboard-model/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": false,
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "src/**/*.test.ts"]
|
||||
}
|
||||
9
packages/dashboard-model/tsconfig.json
Normal file
9
packages/dashboard-model/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"types": ["node", "bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue