feat: define dashboard model schema #15

Merged
vince merged 4 commits from codex/issue-3-dashboard-model into main 2026-06-18 17:58:06 +02:00
3 changed files with 72 additions and 3 deletions
Showing only changes of commit b7c673bbd7 - Show all commits

View file

@ -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<typeof service>[]) {

View file

@ -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");

View file

@ -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,