diff --git a/src/lib/model/fixtures/dimensionlab.ts b/src/lib/model/fixtures/dimensionlab.ts index 316e9a8..572067f 100644 --- a/src/lib/model/fixtures/dimensionlab.ts +++ b/src/lib/model/fixtures/dimensionlab.ts @@ -44,7 +44,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = { metric("system-disk-peak", "System Disk Peak", "percent", 49, "linux /boot", "ok"), metric("peak-cpu-busy", "Peak CPU Busy", "percent", 3, "linux-infra", "ok"), metric("top-cpu-container", "Top CPU Container", "percent", 7.9, "container placeholder", "ok"), - metric("top-ram-container", "Top RAM Container", "bytes", "5.2 GB", "container placeholder", "danger"), + metric("top-ram-container", "Top RAM Container", "bytes", 5.2 * 1024 ** 3, "container placeholder", "danger"), metric("gpu-3060-load", "3060 GPU Load", "percent", 0, "RTX 3060", "ok"), metric("gpu-3060-vram", "3060 VRAM", "percent", 0, "RTX 3060", "ok"), metric("gpu-3060-temp", "3060 Temp", "temperature", 54, "RTX 3060", "ok"), @@ -111,14 +111,14 @@ export const dimensionLabDashboardFixture: DashboardDocument = { ], }; -type ValueKind = "percent" | "bytes" | "temperature" | "latency" | "number" | "text"; +type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number"; type Severity = "neutral" | "ok" | "warning" | "danger" | "stale" | "unavailable"; function metric( id: string, label: string, - kind: ValueKind, - value: number | string, + kind: NumericValueKind, + value: number, detail: string, severity: Severity, ) { diff --git a/src/lib/model/schema.test.ts b/src/lib/model/schema.test.ts index 40f9baa..0f3153b 100644 --- a/src/lib/model/schema.test.ts +++ b/src/lib/model/schema.test.ts @@ -54,6 +54,77 @@ describe("dashboard model validation", () => { } }); + 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 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 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("exports JSON Schema for external tool contracts", () => { expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1"); expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion"); diff --git a/src/lib/model/schema.ts b/src/lib/model/schema.ts index 388e921..d338468 100644 --- a/src/lib/model/schema.ts +++ b/src/lib/model/schema.ts @@ -64,7 +64,7 @@ const DatasourceReferenceSchema = Type.Union([ ExternalDatasourceSchema, ]); -const MetricValueSchema = Type.Object( +const NumericMetricValueSchema = Type.Object( { kind: Type.Union([ Type.Literal("percent"), @@ -72,21 +72,34 @@ const MetricValueSchema = Type.Object( Type.Literal("temperature"), Type.Literal("latency"), Type.Literal("number"), - Type.Literal("text"), ]), - value: Type.Union([Type.Number(), Type.String()]), + value: Type.Number(), 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 }, +); + +const MetricValueSchema = Type.Union([ + NumericMetricValueSchema, + TextMetricValueSchema, +]); + const ThresholdSchema = Type.Object( { warning: Type.Optional(Type.Number()), danger: Type.Optional(Type.Number()), }, - { additionalProperties: false }, + { additionalProperties: false, minProperties: 1 }, ); export const TelemetryCardSchema = Type.Object( diff --git a/src/lib/model/validation.ts b/src/lib/model/validation.ts index 2059f3c..3ed22ab 100644 --- a/src/lib/model/validation.ts +++ b/src/lib/model/validation.ts @@ -5,10 +5,20 @@ import { type DashboardDocument, } from "./schema"; +type DashboardValidationIssue = ErrorObject | SemanticValidationIssue; + +interface SemanticValidationIssue { + instancePath: string; + schemaPath: string; + keyword: "semantic"; + params: Record; + message: string; +} + export interface DashboardValidationFailure { valid: false; errors: string[]; - details: ErrorObject[]; + details: DashboardValidationIssue[]; } export interface DashboardValidationSuccess { @@ -24,27 +34,46 @@ const ajv = addFormats( new Ajv({ allErrors: true, strict: false, + strictNumbers: true, }), ); const validateDashboard = ajv.compile(DashboardDocumentSchema); -export function formatValidationErrors(errors: ErrorObject[] = []): string[] { +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}`; + return `${path} ${message}${suffix}`; }); } export function validateDashboardDocument( value: unknown, ): DashboardValidationResult { + const finiteNumberIssues: SemanticValidationIssue[] = []; + collectFiniteNumberIssues(value, "", finiteNumberIssues); + if (validateDashboard(value)) { - return { valid: true, data: value }; + const semanticIssues = [ + ...finiteNumberIssues, + ...validateSemanticRules(value), + ]; + if (semanticIssues.length === 0) { + return { valid: true, data: value }; + } + + return { + valid: false, + errors: formatValidationErrors(semanticIssues), + details: semanticIssues, + }; } - const details = [...(validateDashboard.errors || [])]; + const details = [...(validateDashboard.errors || []), ...finiteNumberIssues]; return { valid: false, errors: formatValidationErrors(details), @@ -64,3 +93,165 @@ export function assertDashboardDocument( 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, + ); + }); + + 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) => { + 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 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(); + 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, + 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, +): SemanticValidationIssue { + return { + instancePath, + schemaPath: "", + keyword: "semantic", + params, + message, + }; +} + +function escapeJsonPointer(value: string): string { + return value.replaceAll("~", "~0").replaceAll("/", "~1"); +}