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; 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(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(); 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"); }