feat(agent-config): add typed dashboard mutation API
This commit is contained in:
parent
8af0fce6f1
commit
9b7f20c486
5 changed files with 1668 additions and 6 deletions
|
|
@ -1,6 +1,11 @@
|
|||
export {
|
||||
DASHBOARD_SCHEMA_VERSION,
|
||||
DashboardDocumentSchema,
|
||||
DatasourceReferenceSchema,
|
||||
ServiceEntrySchema,
|
||||
ServiceGroupSchema,
|
||||
TelemetryCardSchema,
|
||||
ThresholdSchema,
|
||||
dashboardDocumentJsonSchema,
|
||||
type DashboardDocument,
|
||||
type DashboardModule,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const LinkSchema = Type.Object(
|
|||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const StaticDatasourceSchema = Type.Object(
|
||||
export const StaticDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("static"),
|
||||
label: Type.Optional(Type.String({ minLength: 1 })),
|
||||
|
|
@ -36,7 +36,7 @@ const StaticDatasourceSchema = Type.Object(
|
|||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const PlaceholderDatasourceSchema = Type.Object(
|
||||
export const PlaceholderDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("placeholder"),
|
||||
reason: Type.String({ minLength: 1 }),
|
||||
|
|
@ -44,7 +44,7 @@ const PlaceholderDatasourceSchema = Type.Object(
|
|||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const ExternalDatasourceSchema = Type.Object(
|
||||
export const ExternalDatasourceSchema = Type.Object(
|
||||
{
|
||||
type: Type.Literal("external"),
|
||||
adapter: Type.Union([
|
||||
|
|
@ -58,7 +58,7 @@ const ExternalDatasourceSchema = Type.Object(
|
|||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const DatasourceReferenceSchema = Type.Union([
|
||||
export const DatasourceReferenceSchema = Type.Union([
|
||||
StaticDatasourceSchema,
|
||||
PlaceholderDatasourceSchema,
|
||||
ExternalDatasourceSchema,
|
||||
|
|
@ -98,13 +98,13 @@ const TextMetricValueSchema = Type.Object(
|
|||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const MetricValueSchema = Type.Union([
|
||||
export const MetricValueSchema = Type.Union([
|
||||
PercentMetricValueSchema,
|
||||
NonNegativeMetricValueSchema,
|
||||
TextMetricValueSchema,
|
||||
]);
|
||||
|
||||
const ThresholdSchema = Type.Object(
|
||||
export const ThresholdSchema = Type.Object(
|
||||
{
|
||||
warning: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
danger: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
|
|
|
|||
446
src/lib/server/agent-config/agent-config.test.ts
Normal file
446
src/lib/server/agent-config/agent-config.test.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
import { existsSync, rmSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
|
||||
import { createDashboardStore, type DashboardStore } from "$lib/server/db/dashboard-store";
|
||||
import {
|
||||
AgentConfigAuthorizationError,
|
||||
authorizeAgentConfigRequest,
|
||||
handleAgentDashboardRequest,
|
||||
previewDashboardChanges,
|
||||
publishDashboardChanges,
|
||||
rollbackDashboardRevision,
|
||||
type AgentDashboardOperation,
|
||||
} from ".";
|
||||
|
||||
const stores: DashboardStore[] = [];
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
stores.splice(0).forEach((store) => store.close());
|
||||
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
|
||||
});
|
||||
|
||||
describe("agent dashboard configuration API", () => {
|
||||
test("previews typed dashboard operations with an RFC 6902-compatible patch", () => {
|
||||
const operations = exampleOperations();
|
||||
|
||||
const result = previewDashboardChanges(genericDashboardFixture, operations);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected preview success");
|
||||
expect(result.document.serviceGroups[0]?.id).toBe("edge");
|
||||
expect(result.document.serviceGroups[0]?.services[0]).toMatchObject({
|
||||
id: "edge-router",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://edge.example.test/api/status",
|
||||
},
|
||||
});
|
||||
expect(result.document.layout.telemetry).toEqual([
|
||||
"service-uptime",
|
||||
"edge-latency",
|
||||
"queue-depth",
|
||||
]);
|
||||
expect(genericDashboardFixture.serviceGroups.map((group) => group.id)).toEqual([
|
||||
"core-services",
|
||||
]);
|
||||
expect(result.patch.length).toBeGreaterThan(0);
|
||||
expect(result.patch.every((operation) => operation.path.startsWith("/"))).toBe(true);
|
||||
expect(result.patch.map((operation) => operation.op)).toContain("add");
|
||||
});
|
||||
|
||||
test("returns structured repairable errors for invalid operations", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "add_service",
|
||||
groupId: "missing-group",
|
||||
service: exampleService(),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected preview failure");
|
||||
expect(result.errors).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "group_not_found",
|
||||
operationIndex: 0,
|
||||
path: "/serviceGroups",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects unsupported target-specific mutations", () => {
|
||||
const datasourceResult = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "connect_datasource",
|
||||
target: { kind: "statusItem", stripId: "runtime", id: "status" },
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://status.example.test/api",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(datasourceResult.ok).toBe(false);
|
||||
if (datasourceResult.ok) throw new Error("expected datasource failure");
|
||||
expect(datasourceResult.errors[0]).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
operationIndex: 0,
|
||||
path: "/statusStrips",
|
||||
});
|
||||
|
||||
const thresholdResult = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "set_status_rule",
|
||||
target: { kind: "service", id: "identity" },
|
||||
thresholds: { warning: 1 },
|
||||
},
|
||||
]);
|
||||
expect(thresholdResult.ok).toBe(false);
|
||||
if (thresholdResult.ok) throw new Error("expected threshold failure");
|
||||
expect(thresholdResult.errors[0]).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
operationIndex: 0,
|
||||
path: "/serviceGroups",
|
||||
});
|
||||
});
|
||||
|
||||
test("removes status strip items through the shared remove operation", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "statusItem", stripId: "runtime", id: "sync" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected remove success");
|
||||
expect(result.document.statusStrips[0]?.items.map((item) => item.id)).toEqual([
|
||||
"status",
|
||||
]);
|
||||
expect(result.patch.map((operation) => operation.op)).toContain("remove");
|
||||
});
|
||||
|
||||
test("rejects create_dashboard when it is not the first operation", () => {
|
||||
const result = previewDashboardChanges(genericDashboardFixture, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "telemetry", id: "queue-depth" },
|
||||
},
|
||||
{
|
||||
type: "create_dashboard",
|
||||
document: genericDashboardFixture,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected sequence failure");
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
code: "invalid_operation_sequence",
|
||||
operationIndex: 1,
|
||||
path: "/1",
|
||||
});
|
||||
});
|
||||
|
||||
test("publishes valid operations as a persisted dashboard revision", async () => {
|
||||
const { dbPath, store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
message: "initial dashboard",
|
||||
});
|
||||
|
||||
const result = publishDashboardChanges(store, exampleOperations(), {
|
||||
actor: "agent",
|
||||
message: "add edge router",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("expected publish success");
|
||||
expect(existsSync(dbPath)).toBe(true);
|
||||
expect(result.revision.operation).toBe("commit");
|
||||
expect(result.revision.actor).toBe("agent");
|
||||
expect(result.revision.message).toBe("add edge router");
|
||||
expect(result.previousRevisionId).toBe(seed.id);
|
||||
expect(result.patch.length).toBeGreaterThan(0);
|
||||
expect(store.getActiveDashboard()?.document.serviceGroups[0]?.id).toBe("edge");
|
||||
expect(store.listRevisions()).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("does not publish invalid operations", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
|
||||
const result = publishDashboardChanges(store, [
|
||||
{
|
||||
type: "remove_item",
|
||||
target: { kind: "telemetry", id: "missing-metric" },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected publish failure");
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
code: "item_not_found",
|
||||
operationIndex: 0,
|
||||
});
|
||||
expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id);
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rolls back through the agent-safe revision path", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
const publish = publishDashboardChanges(store, exampleOperations(), {
|
||||
actor: "agent",
|
||||
});
|
||||
expect(publish.ok).toBe(true);
|
||||
|
||||
const rollback = rollbackDashboardRevision(store, seed.id, {
|
||||
actor: "agent",
|
||||
message: "restore previous dashboard",
|
||||
});
|
||||
|
||||
expect(rollback.operation).toBe("rollback");
|
||||
expect(rollback.actor).toBe("agent");
|
||||
expect(rollback.sourceRevisionId).toBe(seed.id);
|
||||
expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console");
|
||||
expect(store.listRevisions().map((revision) => revision.operation)).toEqual([
|
||||
"rollback",
|
||||
"commit",
|
||||
"seed",
|
||||
]);
|
||||
});
|
||||
|
||||
test("authenticates agent configuration requests with a shared token", () => {
|
||||
const authorized = new Request("https://dimensionlab.test/api/agent/dashboard", {
|
||||
headers: { authorization: "Bearer shared-secret" },
|
||||
});
|
||||
const rejected = new Request("https://dimensionlab.test/api/agent/dashboard");
|
||||
|
||||
expect(authorizeAgentConfigRequest(authorized, "shared-secret")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(() => authorizeAgentConfigRequest(rejected, "shared-secret")).toThrow(
|
||||
AgentConfigAuthorizationError,
|
||||
);
|
||||
expect(() => authorizeAgentConfigRequest(authorized, "")).toThrow(
|
||||
AgentConfigAuthorizationError,
|
||||
);
|
||||
});
|
||||
|
||||
test("handles preview, publish, and rollback requests through the HTTP adapter", async () => {
|
||||
const { store } = await createTestStore();
|
||||
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
|
||||
const preview = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "preview_changes",
|
||||
operations: exampleOperations(),
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
expect(preview.status).toBe(200);
|
||||
expect(await preview.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "preview_changes",
|
||||
patch: expect.any(Array),
|
||||
});
|
||||
expect(store.listRevisions()).toHaveLength(1);
|
||||
|
||||
const publish = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "publish_changes",
|
||||
actor: "agent",
|
||||
message: "publish edge router",
|
||||
operations: exampleOperations(),
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
expect(publish.status).toBe(200);
|
||||
expect(await publish.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "publish_changes",
|
||||
revision: { operation: "commit", actor: "agent" },
|
||||
});
|
||||
expect(store.listRevisions()).toHaveLength(2);
|
||||
|
||||
const rollback = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "rollback_revision",
|
||||
actor: "agent",
|
||||
revisionId: seed.id,
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
expect(rollback.status).toBe(200);
|
||||
expect(await rollback.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "rollback_revision",
|
||||
revision: { operation: "rollback", sourceRevisionId: seed.id },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns a structured error for unknown rollback revisions", async () => {
|
||||
const { store } = await createTestStore();
|
||||
store.seedDashboardIfEmpty(genericDashboardFixture, {
|
||||
actor: "seed",
|
||||
});
|
||||
|
||||
const rollback = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "rollback_revision",
|
||||
actor: "agent",
|
||||
revisionId: "missing-revision",
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
|
||||
expect(rollback.status).toBe(404);
|
||||
expect(await rollback.json()).toMatchObject({
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
code: "revision_not_found",
|
||||
path: "/revisionId",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("previews create_dashboard requests against an empty store", async () => {
|
||||
const { store } = await createTestStore();
|
||||
|
||||
const preview = await handleAgentDashboardRequest(
|
||||
jsonRequest({
|
||||
action: "preview_changes",
|
||||
operations: [
|
||||
{
|
||||
type: "create_dashboard",
|
||||
document: genericDashboardFixture,
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ store, token: "shared-secret" },
|
||||
);
|
||||
|
||||
expect(preview.status).toBe(200);
|
||||
expect(await preview.json()).toMatchObject({
|
||||
ok: true,
|
||||
action: "preview_changes",
|
||||
document: {
|
||||
metadata: {
|
||||
title: "Operations Console",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(store.listRevisions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function createTestStore() {
|
||||
const root = await mkdtemp(join(tmpdir(), "dimensionlab-agent-config-"));
|
||||
tempRoots.push(root);
|
||||
const dbPath = join(root, "dashboard.sqlite");
|
||||
const store = createDashboardStore({
|
||||
databaseUrl: `file:${dbPath}`,
|
||||
});
|
||||
stores.push(store);
|
||||
|
||||
return { dbPath, store };
|
||||
}
|
||||
|
||||
function exampleOperations(): AgentDashboardOperation[] {
|
||||
return [
|
||||
{
|
||||
type: "add_section",
|
||||
section: {
|
||||
id: "edge",
|
||||
title: "Edge",
|
||||
layout: "list",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "add_service",
|
||||
groupId: "edge",
|
||||
service: exampleService(),
|
||||
},
|
||||
{
|
||||
type: "add_metric_card",
|
||||
card: {
|
||||
id: "edge-latency",
|
||||
label: "Edge Latency",
|
||||
value: { kind: "latency", value: 12, precision: 0 },
|
||||
severity: "ok",
|
||||
detail: "p95",
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "prometheus",
|
||||
reference: "histogram_quantile(0.95, edge_request_duration_seconds_bucket)",
|
||||
},
|
||||
},
|
||||
position: { afterId: "service-uptime" },
|
||||
},
|
||||
{
|
||||
type: "connect_datasource",
|
||||
target: {
|
||||
kind: "service",
|
||||
groupId: "edge",
|
||||
id: "edge-router",
|
||||
},
|
||||
datasource: {
|
||||
type: "external",
|
||||
adapter: "http-status",
|
||||
reference: "GET https://edge.example.test/api/status",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "set_status_rule",
|
||||
target: { kind: "telemetry", id: "edge-latency" },
|
||||
thresholds: { warning: 50, danger: 100 },
|
||||
},
|
||||
{
|
||||
type: "arrange_item",
|
||||
area: "serviceGroups",
|
||||
id: "edge",
|
||||
index: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function exampleService() {
|
||||
return {
|
||||
id: "edge-router",
|
||||
label: "Edge Router",
|
||||
description: "Ingress and routing",
|
||||
icon: "mdi:router-network",
|
||||
severity: "ok" as const,
|
||||
detail: "pending datasource",
|
||||
datasource: { type: "placeholder" as const, reason: "health adapter pending" },
|
||||
link: {
|
||||
href: "https://edge.example.test",
|
||||
label: "Open Edge Router",
|
||||
external: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function jsonRequest(body: unknown) {
|
||||
return new Request("https://dimensionlab.test/api/agent/dashboard", {
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
authorization: "Bearer shared-secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
1207
src/lib/server/agent-config/index.ts
Normal file
1207
src/lib/server/agent-config/index.ts
Normal file
File diff suppressed because it is too large
Load diff
4
src/routes/api/agent/dashboard/+server.ts
Normal file
4
src/routes/api/agent/dashboard/+server.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { handleAgentDashboardRequest } from "$lib/server/agent-config";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = ({ request }) => handleAgentDashboardRequest(request);
|
||||
Loading…
Add table
Add a link
Reference in a new issue