feat: define dashboard model schema
This commit is contained in:
parent
59baec9831
commit
7cfd5b4069
9 changed files with 714 additions and 2 deletions
162
src/lib/model/fixtures/dimensionlab.ts
Normal file
162
src/lib/model/fixtures/dimensionlab.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
type DashboardDocument,
|
||||
} from "../schema";
|
||||
|
||||
export const dimensionLabDashboardFixture: DashboardDocument = {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
metadata: {
|
||||
title: "System Overview",
|
||||
subtitle: "Capacity, noise & top consumers",
|
||||
description: "Dimension Lab operational dashboard seed data.",
|
||||
timezone: "Europe/Amsterdam",
|
||||
refreshIntervalSeconds: 15,
|
||||
},
|
||||
layout: {
|
||||
density: "dense",
|
||||
telemetry: [
|
||||
"infra-ram",
|
||||
"gpu-host-ram",
|
||||
"network-ram",
|
||||
"user-disk-peak",
|
||||
"system-disk-peak",
|
||||
"peak-cpu-busy",
|
||||
"top-cpu-container",
|
||||
"top-ram-container",
|
||||
"gpu-3060-load",
|
||||
"gpu-3060-vram",
|
||||
"gpu-3060-temp",
|
||||
"gpu-3060-fan",
|
||||
"gpu-3090-load",
|
||||
"gpu-3090-vram",
|
||||
"gpu-3090-temp",
|
||||
"gpu-3090-fan",
|
||||
],
|
||||
serviceGroups: ["essentials", "monitoring", "ai-automation", "systems", "runtime-health"],
|
||||
statusStrips: ["footer-status"],
|
||||
modules: ["weather"],
|
||||
},
|
||||
telemetry: [
|
||||
metric("infra-ram", "Infra RAM", "percent", 19, "linux-infra", "ok"),
|
||||
metric("gpu-host-ram", "GPU Host RAM", "percent", 17, "linux", "ok"),
|
||||
metric("network-ram", "Network RAM", "percent", 5, "network-core", "ok"),
|
||||
metric("user-disk-peak", "User Disk Peak", "percent", 29, "linux /home", "ok"),
|
||||
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("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"),
|
||||
metric("gpu-3060-fan", "3060 Fan Spin", "percent", 0, "RTX 3060", "ok"),
|
||||
metric("gpu-3090-load", "3090 GPU Load", "percent", 0, "RTX 3090", "ok"),
|
||||
metric("gpu-3090-vram", "3090 VRAM", "percent", 74, "RTX 3090", "warning"),
|
||||
metric("gpu-3090-temp", "3090 Temp", "temperature", 50, "RTX 3090", "ok"),
|
||||
metric("gpu-3090-fan", "3090 Fan Spin", "percent", 0, "RTX 3090", "ok"),
|
||||
],
|
||||
serviceGroups: [
|
||||
group("essentials", "Essentials", [
|
||||
service("vaultwarden", "Vaultwarden", "Password manager", "simple-icons:vaultwarden", "https://vault.dimensionlab.net"),
|
||||
service("forgejo", "Forgejo", "Git repositories", "simple-icons:forgejo", "https://git.dimensionlab.net"),
|
||||
service("wiki", "Wiki", "Internal documentation", "simple-icons:wikidotjs", "https://wiki.dimensionlab.net"),
|
||||
service("aws-start", "AWS Start", "AWS access portal", "simple-icons:amazonaws", "https://dimensionlab.awsapps.com/start"),
|
||||
]),
|
||||
group("monitoring", "Monitoring", [
|
||||
service("grafana", "Grafana", "Capacity and noise dashboard", "simple-icons:grafana", "https://grafana.dimensionlab.net"),
|
||||
service("uptime-kuma", "Uptime Kuma", "Service uptime checks", "simple-icons:uptimekuma", "https://uptime.dimensionlab.net"),
|
||||
service("prometheus", "Prometheus", "Metrics database", "simple-icons:prometheus", "https://prometheus.dimensionlab.net"),
|
||||
service("backrest", "Backrest", "Restic backup manager", "mdi:backup-restore", "https://backups.dimensionlab.net"),
|
||||
]),
|
||||
group("ai-automation", "AI & Automation", [
|
||||
service("n8n", "n8n", "Workflow automation", "simple-icons:n8n", "https://workflows.dimensionlab.net"),
|
||||
service("open-webui", "Open WebUI", "Chat and model interface", "simple-icons:openwebui", "https://webui.dimensionlab.net"),
|
||||
service("comfyui", "ComfyUI", "Image generation workflows", "mdi:image-edit-outline", "https://comfy.dimensionlab.net"),
|
||||
service("models", "Models", "Local model management", "mdi:brain", "https://models.dimensionlab.net"),
|
||||
]),
|
||||
group("systems", "Systems", [
|
||||
service("adminer", "Adminer", "PostgreSQL database browser", "simple-icons:adminer", "https://db.dimensionlab.net"),
|
||||
service("assistant", "Assistant", "Personal AI agent gateway", "mdi:robot-outline", "https://assistant.dimensionlab.net"),
|
||||
service("suna", "Suna", "AI command center", "mdi:account-hard-hat-outline", "https://suna.dimensionlab.net"),
|
||||
service("cockpit-infra", "Cockpit Infra", "linux-infra server console", "simple-icons:cockpit", "https://infra-cockpit.dimensionlab.net"),
|
||||
]),
|
||||
group("runtime-health", "Runtime Health", [
|
||||
service("forgejo-ssh-relay", "Forgejo SSH Relay", "Public Git SSH relay", "simple-icons:forgejo", "https://uptime.dimensionlab.net/status/dimensionlab"),
|
||||
service("postgresql", "PostgreSQL", "Shared application database", "simple-icons:postgresql"),
|
||||
service("ollama-api", "Ollama API", "Local model API", "simple-icons:ollama"),
|
||||
service("node-exporter", "Node Exporter", "Host metrics exporter", "simple-icons:prometheus"),
|
||||
]),
|
||||
],
|
||||
statusStrips: [
|
||||
{
|
||||
id: "footer-status",
|
||||
items: [
|
||||
{ id: "system-status", label: "System Status", value: "All systems operational", severity: "ok" },
|
||||
{ id: "last-sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" },
|
||||
{ id: "uptime", label: "Uptime", value: "28d 14h 32m", severity: "ok" },
|
||||
{ id: "load-avg", label: "Load Avg", value: "0.47 0.53 0.59", severity: "neutral" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "weather",
|
||||
kind: "weather",
|
||||
title: "Amsterdam",
|
||||
value: "28.3 C",
|
||||
detail: "Clear",
|
||||
icon: "mdi:weather-sunny",
|
||||
severity: "ok",
|
||||
datasource: { type: "placeholder", reason: "weather adapter pending" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
type ValueKind = "percent" | "bytes" | "temperature" | "latency" | "number" | "text";
|
||||
type Severity = "neutral" | "ok" | "warning" | "danger" | "stale" | "unavailable";
|
||||
|
||||
function metric(
|
||||
id: string,
|
||||
label: string,
|
||||
kind: ValueKind,
|
||||
value: number | string,
|
||||
detail: string,
|
||||
severity: Severity,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
value: { kind, value },
|
||||
detail,
|
||||
severity,
|
||||
datasource: { type: "placeholder" as const, reason: "live datasource pending" },
|
||||
sparkline: [10, 18, 15, 28, 24, 32],
|
||||
};
|
||||
}
|
||||
|
||||
function service(
|
||||
id: string,
|
||||
label: string,
|
||||
description: string,
|
||||
icon: string,
|
||||
href?: string,
|
||||
) {
|
||||
return {
|
||||
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" },
|
||||
};
|
||||
}
|
||||
|
||||
function group(id: string, title: string, services: ReturnType<typeof service>[]) {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
layout: "list" as const,
|
||||
services,
|
||||
};
|
||||
}
|
||||
91
src/lib/model/fixtures/generic.ts
Normal file
91
src/lib/model/fixtures/generic.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
type DashboardDocument,
|
||||
} from "../schema";
|
||||
|
||||
export const genericDashboardFixture: DashboardDocument = {
|
||||
schemaVersion: DASHBOARD_SCHEMA_VERSION,
|
||||
metadata: {
|
||||
title: "Operations Console",
|
||||
subtitle: "Generic environment",
|
||||
description: "Portable fixture for component and renderer tests.",
|
||||
timezone: "UTC",
|
||||
refreshIntervalSeconds: 30,
|
||||
},
|
||||
layout: {
|
||||
density: "dense",
|
||||
telemetry: ["service-uptime", "queue-depth"],
|
||||
serviceGroups: ["core-services"],
|
||||
statusStrips: ["runtime"],
|
||||
modules: ["ambient"],
|
||||
},
|
||||
telemetry: [
|
||||
{
|
||||
id: "service-uptime",
|
||||
label: "Service Uptime",
|
||||
value: { kind: "percent", value: 99.9, precision: 1 },
|
||||
detail: "last 30 days",
|
||||
severity: "ok",
|
||||
datasource: { type: "static", label: "fixture" },
|
||||
sparkline: [99.7, 99.8, 99.9, 99.9],
|
||||
},
|
||||
{
|
||||
id: "queue-depth",
|
||||
label: "Queue Depth",
|
||||
value: { kind: "number", value: 18 },
|
||||
detail: "pending jobs",
|
||||
severity: "warning",
|
||||
thresholds: { warning: 15, danger: 50 },
|
||||
datasource: { type: "placeholder", reason: "adapter pending" },
|
||||
sparkline: [6, 11, 13, 18],
|
||||
},
|
||||
],
|
||||
serviceGroups: [
|
||||
{
|
||||
id: "core-services",
|
||||
title: "Core Services",
|
||||
layout: "list",
|
||||
services: [
|
||||
{
|
||||
id: "identity",
|
||||
label: "Identity",
|
||||
description: "Authentication and profile service",
|
||||
icon: "mdi:account-key-outline",
|
||||
severity: "ok",
|
||||
detail: "ready",
|
||||
datasource: { type: "static", label: "fixture" },
|
||||
},
|
||||
{
|
||||
id: "scheduler",
|
||||
label: "Scheduler",
|
||||
description: "Background task coordinator",
|
||||
icon: "mdi:calendar-clock",
|
||||
severity: "warning",
|
||||
detail: "delayed",
|
||||
datasource: { type: "placeholder", reason: "health adapter pending" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
statusStrips: [
|
||||
{
|
||||
id: "runtime",
|
||||
items: [
|
||||
{ id: "status", label: "System Status", value: "Degraded", severity: "warning" },
|
||||
{ id: "sync", label: "Last Sync", value: "2 minutes ago", severity: "stale" },
|
||||
],
|
||||
},
|
||||
],
|
||||
modules: [
|
||||
{
|
||||
id: "ambient",
|
||||
kind: "summary",
|
||||
title: "Environment",
|
||||
value: "Nominal",
|
||||
detail: "static fixture",
|
||||
icon: "mdi:radar",
|
||||
severity: "ok",
|
||||
datasource: { type: "static", label: "fixture" },
|
||||
},
|
||||
],
|
||||
};
|
||||
2
src/lib/model/fixtures/index.ts
Normal file
2
src/lib/model/fixtures/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { dimensionLabDashboardFixture } from "./dimensionlab";
|
||||
export { genericDashboardFixture } from "./generic";
|
||||
24
src/lib/model/index.ts
Normal file
24
src/lib/model/index.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
DashboardDocumentSchema,
|
||||
dashboardDocumentJsonSchema,
|
||||
type DashboardDocument,
|
||||
type DashboardModule,
|
||||
type DatasourceReference,
|
||||
type MetricValue,
|
||||
type ServiceEntry,
|
||||
type ServiceGroup,
|
||||
type Severity,
|
||||
type StatusItem,
|
||||
type StatusStrip,
|
||||
type TelemetryCard,
|
||||
} from "./schema";
|
||||
export {
|
||||
assertDashboardDocument,
|
||||
formatValidationErrors,
|
||||
isDashboardDocument,
|
||||
validateDashboardDocument,
|
||||
type DashboardValidationFailure,
|
||||
type DashboardValidationResult,
|
||||
type DashboardValidationSuccess,
|
||||
} from "./validation";
|
||||
63
src/lib/model/schema.test.ts
Normal file
63
src/lib/model/schema.test.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
dashboardDocumentJsonSchema,
|
||||
validateDashboardDocument,
|
||||
} from ".";
|
||||
import {
|
||||
dimensionLabDashboardFixture,
|
||||
genericDashboardFixture,
|
||||
} from "./fixtures";
|
||||
|
||||
describe("dashboard model validation", () => {
|
||||
it("accepts the generic dashboard fixture", () => {
|
||||
const result = validateDashboardDocument(genericDashboardFixture);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
if (result.valid) {
|
||||
expect(result.data.schemaVersion).toBe(DASHBOARD_SCHEMA_VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the Dimension Lab dashboard fixture", () => {
|
||||
const result = validateDashboardDocument(dimensionLabDashboardFixture);
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects documents with an unsupported schema version", () => {
|
||||
const invalid = {
|
||||
...genericDashboardFixture,
|
||||
schemaVersion: "dashboard.v0",
|
||||
};
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.join(" ")).toContain("must be equal to constant");
|
||||
expect(result.details.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns actionable field paths for invalid documents", () => {
|
||||
const invalid = JSON.parse(JSON.stringify(genericDashboardFixture));
|
||||
delete invalid.metadata.title;
|
||||
invalid.telemetry[0].severity = "fine";
|
||||
|
||||
const result = validateDashboardDocument(invalid);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) {
|
||||
expect(result.errors.some((error) => error.includes("/metadata"))).toBe(true);
|
||||
expect(result.errors.some((error) => error.includes("/telemetry/0/severity"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("exports JSON Schema for external tool contracts", () => {
|
||||
expect(dashboardDocumentJsonSchema.$id).toContain("dashboard-document.v1");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("schemaVersion");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("telemetry");
|
||||
expect(dashboardDocumentJsonSchema.properties).toHaveProperty("serviceGroups");
|
||||
});
|
||||
});
|
||||
229
src/lib/model/schema.ts
Normal file
229
src/lib/model/schema.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const DASHBOARD_SCHEMA_VERSION = "dashboard.v1" as const;
|
||||
|
||||
const IdentifierSchema = Type.String({
|
||||
minLength: 1,
|
||||
pattern: "^[a-z0-9][a-z0-9-_.:]*$",
|
||||
});
|
||||
|
||||
const SeveritySchema = Type.Union([
|
||||
Type.Literal("neutral"),
|
||||
Type.Literal("ok"),
|
||||
Type.Literal("warning"),
|
||||
Type.Literal("danger"),
|
||||
Type.Literal("stale"),
|
||||
Type.Literal("unavailable"),
|
||||
]);
|
||||
|
||||
const IconReferenceSchema = Type.String({ minLength: 1 });
|
||||
|
||||
const LinkSchema = Type.Object(
|
||||
{
|
||||
href: Type.String({ format: "uri" }),
|
||||
label: Type.Optional(Type.String({ minLength: 1 })),
|
||||
external: Type.Optional(Type.Boolean()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const StaticDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("static"),
|
||||
label: Type.Optional(Type.String({ minLength: 1 })),
|
||||
updatedAt: Type.Optional(Type.String({ format: "date-time" })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const PlaceholderDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("placeholder"),
|
||||
reason: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const ExternalDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("external"),
|
||||
adapter: Type.Union([
|
||||
Type.Literal("prometheus"),
|
||||
Type.Literal("http-status"),
|
||||
Type.Literal("weather"),
|
||||
Type.Literal("custom"),
|
||||
]),
|
||||
reference: Type.String({ minLength: 1 }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const DatasourceReferenceSchema = Type.Union([
|
||||
StaticDatasourceSchema,
|
||||
PlaceholderDatasourceSchema,
|
||||
ExternalDatasourceSchema,
|
||||
]);
|
||||
|
||||
const MetricValueSchema = Type.Object(
|
||||
{
|
||||
kind: Type.Union([
|
||||
Type.Literal("percent"),
|
||||
Type.Literal("bytes"),
|
||||
Type.Literal("temperature"),
|
||||
Type.Literal("latency"),
|
||||
Type.Literal("number"),
|
||||
Type.Literal("text"),
|
||||
]),
|
||||
value: Type.Union([Type.Number(), Type.String()]),
|
||||
unit: Type.Optional(Type.String({ minLength: 1 })),
|
||||
precision: Type.Optional(Type.Integer({ minimum: 0, maximum: 4 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const ThresholdSchema = Type.Object(
|
||||
{
|
||||
warning: Type.Optional(Type.Number()),
|
||||
danger: Type.Optional(Type.Number()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TelemetryCardSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
label: Type.String({ minLength: 1 }),
|
||||
description: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(IconReferenceSchema),
|
||||
value: MetricValueSchema,
|
||||
detail: Type.Optional(Type.String()),
|
||||
severity: SeveritySchema,
|
||||
thresholds: Type.Optional(ThresholdSchema),
|
||||
datasource: Type.Optional(DatasourceReferenceSchema),
|
||||
sparkline: Type.Optional(Type.Array(Type.Number())),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ServiceEntrySchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
label: Type.String({ minLength: 1 }),
|
||||
description: Type.String(),
|
||||
icon: Type.Optional(IconReferenceSchema),
|
||||
link: Type.Optional(LinkSchema),
|
||||
severity: SeveritySchema,
|
||||
detail: Type.Optional(Type.String()),
|
||||
datasource: Type.Optional(DatasourceReferenceSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ServiceGroupSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
title: Type.String({ minLength: 1 }),
|
||||
layout: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("grid")])),
|
||||
services: Type.Array(ServiceEntrySchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const StatusItemSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
label: Type.String({ minLength: 1 }),
|
||||
value: Type.String(),
|
||||
severity: Type.Optional(SeveritySchema),
|
||||
link: Type.Optional(LinkSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const StatusStripSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
title: Type.Optional(Type.String({ minLength: 1 })),
|
||||
items: Type.Array(StatusItemSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DashboardModuleSchema = Type.Object(
|
||||
{
|
||||
id: IdentifierSchema,
|
||||
kind: Type.Union([
|
||||
Type.Literal("summary"),
|
||||
Type.Literal("weather"),
|
||||
Type.Literal("custom"),
|
||||
]),
|
||||
title: Type.Optional(Type.String({ minLength: 1 })),
|
||||
label: Type.Optional(Type.String()),
|
||||
value: Type.Optional(Type.String()),
|
||||
detail: Type.Optional(Type.String()),
|
||||
icon: Type.Optional(IconReferenceSchema),
|
||||
severity: Type.Optional(SeveritySchema),
|
||||
datasource: Type.Optional(DatasourceReferenceSchema),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const LayoutSchema = Type.Object(
|
||||
{
|
||||
density: Type.Optional(Type.Union([Type.Literal("compact"), Type.Literal("dense")])),
|
||||
telemetry: Type.Array(IdentifierSchema),
|
||||
serviceGroups: Type.Array(IdentifierSchema),
|
||||
statusStrips: Type.Array(IdentifierSchema),
|
||||
modules: Type.Optional(Type.Array(IdentifierSchema)),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const MetadataSchema = Type.Object(
|
||||
{
|
||||
title: Type.String({ minLength: 1 }),
|
||||
subtitle: Type.Optional(Type.String()),
|
||||
description: Type.Optional(Type.String()),
|
||||
timezone: Type.Optional(Type.String({ minLength: 1 })),
|
||||
refreshIntervalSeconds: Type.Optional(Type.Integer({ minimum: 5 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const DashboardDocumentSchema = Type.Object(
|
||||
{
|
||||
schemaVersion: Type.Literal(DASHBOARD_SCHEMA_VERSION),
|
||||
metadata: MetadataSchema,
|
||||
layout: LayoutSchema,
|
||||
telemetry: Type.Array(TelemetryCardSchema),
|
||||
serviceGroups: Type.Array(ServiceGroupSchema),
|
||||
statusStrips: Type.Array(StatusStripSchema),
|
||||
modules: Type.Optional(Type.Array(DashboardModuleSchema)),
|
||||
migration: Type.Optional(
|
||||
Type.Object(
|
||||
{
|
||||
previousVersion: Type.Optional(Type.String({ minLength: 1 })),
|
||||
notes: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
$id: "https://dimensionlab.net/schemas/dashboard-document.v1.json",
|
||||
additionalProperties: false,
|
||||
},
|
||||
);
|
||||
|
||||
export type Severity = Static<typeof SeveritySchema>;
|
||||
export type DatasourceReference = Static<typeof DatasourceReferenceSchema>;
|
||||
export type MetricValue = Static<typeof MetricValueSchema>;
|
||||
export type TelemetryCard = Static<typeof TelemetryCardSchema>;
|
||||
export type ServiceEntry = Static<typeof ServiceEntrySchema>;
|
||||
export type ServiceGroup = Static<typeof ServiceGroupSchema>;
|
||||
export type StatusItem = Static<typeof StatusItemSchema>;
|
||||
export type StatusStrip = Static<typeof StatusStripSchema>;
|
||||
export type DashboardModule = Static<typeof DashboardModuleSchema>;
|
||||
export type DashboardDocument = Static<typeof DashboardDocumentSchema>;
|
||||
|
||||
export const dashboardDocumentJsonSchema = DashboardDocumentSchema;
|
||||
66
src/lib/model/validation.ts
Normal file
66
src/lib/model/validation.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import Ajv, { type ErrorObject } from "ajv";
|
||||
import addFormats from "ajv-formats";
|
||||
import {
|
||||
DashboardDocumentSchema,
|
||||
type DashboardDocument,
|
||||
} from "./schema";
|
||||
|
||||
export interface DashboardValidationFailure {
|
||||
valid: false;
|
||||
errors: string[];
|
||||
details: ErrorObject[];
|
||||
}
|
||||
|
||||
export interface DashboardValidationSuccess {
|
||||
valid: true;
|
||||
data: DashboardDocument;
|
||||
}
|
||||
|
||||
export type DashboardValidationResult =
|
||||
| DashboardValidationFailure
|
||||
| DashboardValidationSuccess;
|
||||
|
||||
const ajv = addFormats(
|
||||
new Ajv({
|
||||
allErrors: true,
|
||||
strict: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const validateDashboard = ajv.compile<DashboardDocument>(DashboardDocumentSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] = []): string[] {
|
||||
return errors.map((error) => {
|
||||
const path = error.instancePath || "/";
|
||||
const message = error.message || "is invalid";
|
||||
return `${path} ${message}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateDashboardDocument(
|
||||
value: unknown,
|
||||
): DashboardValidationResult {
|
||||
if (validateDashboard(value)) {
|
||||
return { valid: true, data: value };
|
||||
}
|
||||
|
||||
const details = [...(validateDashboard.errors || [])];
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue