feat: define dashboard model schema #15
4 changed files with 288 additions and 13 deletions
|
|
@ -44,7 +44,7 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
|
|||
metric("system-disk-peak", "System Disk Peak", "percent", 49, "linux /boot", "ok"),
|
||||
metric("peak-cpu-busy", "Peak CPU Busy", "percent", 3, "linux-infra", "ok"),
|
||||
metric("top-cpu-container", "Top CPU Container", "percent", 7.9, "container placeholder", "ok"),
|
||||
metric("top-ram-container", "Top RAM Container", "bytes", "5.2 GB", "container placeholder", "danger"),
|
||||
metric("top-ram-container", "Top RAM Container", "bytes", 5.2 * 1024 ** 3, "container placeholder", "danger"),
|
||||
metric("gpu-3060-load", "3060 GPU Load", "percent", 0, "RTX 3060", "ok"),
|
||||
metric("gpu-3060-vram", "3060 VRAM", "percent", 0, "RTX 3060", "ok"),
|
||||
metric("gpu-3060-temp", "3060 Temp", "temperature", 54, "RTX 3060", "ok"),
|
||||
|
|
@ -111,14 +111,14 @@ export const dimensionLabDashboardFixture: DashboardDocument = {
|
|||
],
|
||||
};
|
||||
|
||||
type ValueKind = "percent" | "bytes" | "temperature" | "latency" | "number" | "text";
|
||||
type NumericValueKind = "percent" | "bytes" | "temperature" | "latency" | "number";
|
||||
type Severity = "neutral" | "ok" | "warning" | "danger" | "stale" | "unavailable";
|
||||
|
||||
function metric(
|
||||
id: string,
|
||||
label: string,
|
||||
kind: ValueKind,
|
||||
value: number | string,
|
||||
kind: NumericValueKind,
|
||||
value: number,
|
||||
detail: string,
|
||||
severity: Severity,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,77 @@ describe("dashboard model validation", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("includes offending additional property names in errors", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.metadata.unexpected = true;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("unexpected");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-finite numbers that cannot roundtrip through JSON", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[0].value.value = Number.NaN;
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("finite JSON number");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects dangling layout references", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.layout.telemetry.push("missing-card");
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("missing-card");
|
||||
expect(result.errors.join(" ")).toContain("must reference an existing item");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects duplicate IDs within collections", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
invalid.telemetry[1].id = invalid.telemetry[0].id;
|
||||
|
||||
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.telemetry[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";
|
||||
|
||||
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 };
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("warning threshold");
|
||||
}
|
||||
});
|
||||
|
||||
it("exports JSON Schema for external tool contracts", () => {
|
||||
expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion");
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const DatasourceReferenceSchema = Type.Union([
|
|||
ExternalDatasourceSchema,
|
||||
]);
|
||||
|
||||
const MetricValueSchema = Type.Object(
|
||||
const NumericMetricValueSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Union([
|
||||
Type.Literal("percent"),
|
||||
|
|
@ -72,21 +72,34 @@ const MetricValueSchema = Type.Object(
|
|||
Type.Literal("temperature"),
|
||||
Type.Literal("latency"),
|
||||
Type.Literal("number"),
|
||||
Type.Literal("text"),
|
||||
]),
|
||||
value: Type.Union([Type.Number(), Type.String()]),
|
||||
value: Type.Number(),
|
||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const TextMetricValueSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Literal("text"),
|
||||
value: Type.String(),
|
||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const MetricValueSchema = Type.Union([
|
||||
NumericMetricValueSchema,
|
||||
TextMetricValueSchema,
|
||||
]);
|
||||
|
||||
const ThresholdSchema = Type.Object(
|
||||
{
|
||||
warning: Type.Optional(Type.Number()),
|
||||
danger: Type.Optional(Type.Number()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
{ additionalProperties: false, minProperties: 1 },
|
||||
);
|
||||
|
||||
export const TelemetryCardSchema = Type.Object(
|
||||
|
|
|
|||
|
|
@ -5,10 +5,20 @@ import {
|
|||
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: ErrorObject[];
|
||||
details: DashboardValidationIssue[];
|
||||
}
|
||||
|
||||
export interface DashboardValidationSuccess {
|
||||
|
|
@ -24,27 +34,46 @@ const ajv = addFormats(
|
|||
new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
strictNumbers: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] = []): string[] {
|
||||
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}`;
|
||||
return `${path} ${message}${suffix}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDashboardDocument(
|
||||
value: unknown,
|
||||
): DashboardValidationResult {
|
||||
const finiteNumberIssues: SemanticValidationIssue[] = [];
|
||||
collectFiniteNumberIssues(value, "", finiteNumberIssues);
|
||||
|
||||
if (validateDashboard(value)) {
|
||||
return { valid: true, data: value };
|
||||
const semanticIssues = [
|
||||
...finiteNumberIssues,
|
||||
...validateSemanticRules(value),
|
||||
];
|
||||
if (semanticIssues.length === 0) {
|
||||
return { valid: true, data: value };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(semanticIssues),
|
||||
details: semanticIssues,
|
||||
};
|
||||
}
|
||||
|
||||
const details = [...(validateDashboard.errors || [])];
|
||||
const details = [...(validateDashboard.errors || []), ...finiteNumberIssues];
|
||||
return {
|
||||
valid: false,
|
||||
errors: formatValidationErrors(details),
|
||||
|
|
@ -64,3 +93,165 @@ export function assertDashboardDocument(
|
|||
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,
|
||||
);
|
||||
});
|
||||
|
||||
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) => {
|
||||
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 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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue