diff --git a/src/lib/model/schema.test.ts b/src/lib/model/schema.test.ts index ab9e27a..7eeed74 100644 --- a/src/lib/model/schema.test.ts +++ b/src/lib/model/schema.test.ts @@ -104,6 +104,21 @@ describe("dashboard model validation", () => { } }); + 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"; @@ -113,6 +128,24 @@ describe("dashboard model validation", () => { 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 }; @@ -125,6 +158,18 @@ describe("dashboard model validation", () => { } }); + 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" }; diff --git a/src/lib/model/schema.ts b/src/lib/model/schema.ts index d338468..e0215a4 100644 --- a/src/lib/model/schema.ts +++ b/src/lib/model/schema.ts @@ -64,16 +64,25 @@ const DatasourceReferenceSchema = Type.Union([ ExternalDatasourceSchema, ]); -const NumericMetricValueSchema = Type.Object( +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("percent"), Type.Literal("bytes"), Type.Literal("temperature"), Type.Literal("latency"), Type.Literal("number"), ]), - value: Type.Number(), + value: Type.Number({ minimum: 0 }), unit: Type.Optional(Type.String({ minLength: 1 })), precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })), }, @@ -90,14 +99,15 @@ const TextMetricValueSchema = Type.Object( ); const MetricValueSchema = Type.Union([ - NumericMetricValueSchema, + PercentMetricValueSchema, + NonNegativeMetricValueSchema, TextMetricValueSchema, ]); const ThresholdSchema = Type.Object( { - warning: Type.Optional(Type.Number()), - danger: Type.Optional(Type.Number()), + warning: Type.Optional(Type.Number({ minimum: 0 })), + danger: Type.Optional(Type.Number({ minimum: 0 })), }, { additionalProperties: false, minProperties: 1 }, ); diff --git a/src/lib/model/validation.ts b/src/lib/model/validation.ts index 7a79b54..f70b0cd 100644 --- a/src/lib/model/validation.ts +++ b/src/lib/model/validation.ts @@ -135,6 +135,14 @@ function validateSemanticRules(document: DashboardDocument): SemanticValidationI ); }); + document.statusStrips.forEach((strip, stripIndex) => { + collectDuplicateIdIssues( + `statusStrips/${stripIndex}/items`, + strip.items, + issues, + ); + }); + collectMissingReferenceIssues( "layout/telemetry", document.layout.telemetry, @@ -171,6 +179,20 @@ function validateSemanticRules(document: DashboardDocument): SemanticValidationI ); } + 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) {