diff --git a/src/lib/model/fixtures/dimensionlab.ts b/src/lib/model/fixtures/dimensionlab.ts index 572067f..be23b02 100644 --- a/src/lib/model/fixtures/dimensionlab.ts +++ b/src/lib/model/fixtures/dimensionlab.ts @@ -140,16 +140,17 @@ function service( icon: string, href?: string, ) { - return { + const entry = { id, label, description, icon, - link: href ? { href, external: true } : undefined, severity: "ok" as const, detail: "ready", datasource: { type: "placeholder" as const, reason: "service health adapter pending" }, }; + + return href ? { ...entry, link: { href, external: true } } : entry; } function group(id: string, title: string, services: ReturnType[]) { diff --git a/src/lib/model/schema.test.ts b/src/lib/model/schema.test.ts index 0f3153b..ab9e27a 100644 --- a/src/lib/model/schema.test.ts +++ b/src/lib/model/schema.test.ts @@ -125,6 +125,31 @@ describe("dashboard model validation", () => { } }); + 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"); diff --git a/src/lib/model/validation.ts b/src/lib/model/validation.ts index 3ed22ab..7a79b54 100644 --- a/src/lib/model/validation.ts +++ b/src/lib/model/validation.ts @@ -55,11 +55,14 @@ 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) { @@ -73,7 +76,11 @@ export function validateDashboardDocument( }; } - const details = [...(validateDashboard.errors || []), ...finiteNumberIssues]; + const details = [ + ...(validateDashboard.errors || []), + ...finiteNumberIssues, + ...undefinedIssues, + ]; return { valid: false, errors: formatValidationErrors(details), @@ -154,6 +161,16 @@ function validateSemanticRules(document: DashboardDocument): SemanticValidationI ); 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 }, + ), + ); + } + const warning = card.thresholds?.warning; const danger = card.thresholds?.danger; if (warning !== undefined && danger !== undefined && warning > danger) { @@ -170,6 +187,32 @@ function validateSemanticRules(document: DashboardDocument): SemanticValidationI 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,