322 lines
7.9 KiB
TypeScript
322 lines
7.9 KiB
TypeScript
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");
|
|
}
|