1292 lines
36 KiB
TypeScript
1292 lines
36 KiB
TypeScript
import Ajv, { type ErrorObject } from "ajv";
|
|
import addFormats from "ajv-formats";
|
|
import { Type, type Static } from "@sinclair/typebox";
|
|
import {
|
|
DashboardDocumentSchema,
|
|
DatasourceReferenceSchema,
|
|
ServiceEntrySchema,
|
|
TelemetryCardSchema,
|
|
ThresholdSchema,
|
|
validateDashboardDocument,
|
|
type DashboardDocument,
|
|
type DashboardModule,
|
|
type DashboardValidationFailure,
|
|
type DatasourceReference,
|
|
type ServiceEntry,
|
|
type ServiceGroup,
|
|
type StatusItem,
|
|
type TelemetryCard,
|
|
} from "$lib/model";
|
|
import {
|
|
createDashboardStore,
|
|
DashboardRevisionNotFoundError,
|
|
type DashboardRevision,
|
|
type DashboardStore,
|
|
type DashboardWriteMetadata,
|
|
} from "$lib/server/db/dashboard-store";
|
|
|
|
const IdentifierSchema = Type.String({
|
|
minLength: 1,
|
|
pattern: "^[a-z0-9][a-z0-9-_.:]*$",
|
|
});
|
|
|
|
const PositionSchema = Type.Object(
|
|
{
|
|
index: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
beforeId: Type.Optional(IdentifierSchema),
|
|
afterId: Type.Optional(IdentifierSchema),
|
|
},
|
|
{ additionalProperties: false, minProperties: 1 },
|
|
);
|
|
|
|
const AgentTargetSchema = Type.Union([
|
|
Type.Object(
|
|
{
|
|
kind: Type.Literal("telemetry"),
|
|
id: IdentifierSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
Type.Object(
|
|
{
|
|
kind: Type.Literal("service"),
|
|
groupId: Type.Optional(IdentifierSchema),
|
|
id: IdentifierSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
Type.Object(
|
|
{
|
|
kind: Type.Literal("serviceGroup"),
|
|
id: IdentifierSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
Type.Object(
|
|
{
|
|
kind: Type.Literal("statusItem"),
|
|
stripId: Type.Optional(IdentifierSchema),
|
|
id: IdentifierSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
Type.Object(
|
|
{
|
|
kind: Type.Literal("module"),
|
|
id: IdentifierSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
]);
|
|
|
|
const AddSectionOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("add_section"),
|
|
section: Type.Object(
|
|
{
|
|
id: IdentifierSchema,
|
|
title: Type.String({ minLength: 1 }),
|
|
layout: Type.Optional(Type.Union([Type.Literal("list"), Type.Literal("grid")])),
|
|
},
|
|
{ additionalProperties: false },
|
|
),
|
|
position: Type.Optional(PositionSchema),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const AddServiceOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("add_service"),
|
|
groupId: IdentifierSchema,
|
|
service: ServiceEntrySchema,
|
|
position: Type.Optional(PositionSchema),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const AddMetricCardOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("add_metric_card"),
|
|
card: TelemetryCardSchema,
|
|
position: Type.Optional(PositionSchema),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const ConnectDatasourceOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("connect_datasource"),
|
|
target: AgentTargetSchema,
|
|
datasource: DatasourceReferenceSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const SetStatusRuleOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("set_status_rule"),
|
|
target: AgentTargetSchema,
|
|
thresholds: Type.Optional(ThresholdSchema),
|
|
severity: Type.Optional(
|
|
Type.Union([
|
|
Type.Literal("neutral"),
|
|
Type.Literal("ok"),
|
|
Type.Literal("warning"),
|
|
Type.Literal("danger"),
|
|
Type.Literal("stale"),
|
|
Type.Literal("unavailable"),
|
|
]),
|
|
),
|
|
detail: Type.Optional(Type.String()),
|
|
value: Type.Optional(Type.String()),
|
|
},
|
|
{ additionalProperties: false, minProperties: 3 },
|
|
);
|
|
|
|
const ArrangeItemOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("arrange_item"),
|
|
area: Type.Union([
|
|
Type.Literal("telemetry"),
|
|
Type.Literal("serviceGroups"),
|
|
Type.Literal("statusStrips"),
|
|
Type.Literal("modules"),
|
|
]),
|
|
id: IdentifierSchema,
|
|
index: Type.Integer({ minimum: 0 }),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const RemoveItemOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("remove_item"),
|
|
target: AgentTargetSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const NestedDashboardDocumentSchema = Type.Unsafe<DashboardDocument>(
|
|
schemaWithoutRootId(DashboardDocumentSchema),
|
|
);
|
|
|
|
const CreateDashboardOperationSchema = Type.Object(
|
|
{
|
|
type: Type.Literal("create_dashboard"),
|
|
document: NestedDashboardDocumentSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
export const AgentDashboardOperationSchema = Type.Union([
|
|
CreateDashboardOperationSchema,
|
|
AddSectionOperationSchema,
|
|
AddServiceOperationSchema,
|
|
AddMetricCardOperationSchema,
|
|
ConnectDatasourceOperationSchema,
|
|
SetStatusRuleOperationSchema,
|
|
ArrangeItemOperationSchema,
|
|
RemoveItemOperationSchema,
|
|
]);
|
|
|
|
export const AgentDashboardOperationsSchema = Type.Array(AgentDashboardOperationSchema, {
|
|
minItems: 1,
|
|
});
|
|
|
|
const PreviewRequestSchema = Type.Object(
|
|
{
|
|
action: Type.Literal("preview_changes"),
|
|
operations: AgentDashboardOperationsSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const PublishRequestSchema = Type.Object(
|
|
{
|
|
action: Type.Literal("publish_changes"),
|
|
actor: Type.Optional(Type.String({ minLength: 1 })),
|
|
message: Type.Optional(Type.String({ minLength: 1 })),
|
|
operations: AgentDashboardOperationsSchema,
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
const RollbackRequestSchema = Type.Object(
|
|
{
|
|
action: Type.Literal("rollback_revision"),
|
|
actor: Type.Optional(Type.String({ minLength: 1 })),
|
|
message: Type.Optional(Type.String({ minLength: 1 })),
|
|
revisionId: Type.String({ minLength: 1 }),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
|
|
export const AgentDashboardRequestSchema = Type.Union([
|
|
PreviewRequestSchema,
|
|
PublishRequestSchema,
|
|
RollbackRequestSchema,
|
|
]);
|
|
|
|
export type AgentDashboardOperation = Static<typeof AgentDashboardOperationSchema>;
|
|
export type AgentDashboardRequest = Static<typeof AgentDashboardRequestSchema>;
|
|
|
|
export interface JsonPatchAddOperation {
|
|
op: "add";
|
|
path: string;
|
|
value: unknown;
|
|
}
|
|
|
|
export interface JsonPatchRemoveOperation {
|
|
op: "remove";
|
|
path: string;
|
|
}
|
|
|
|
export interface JsonPatchReplaceOperation {
|
|
op: "replace";
|
|
path: string;
|
|
value: unknown;
|
|
}
|
|
|
|
export type JsonPatchOperation =
|
|
| JsonPatchAddOperation
|
|
| JsonPatchRemoveOperation
|
|
| JsonPatchReplaceOperation;
|
|
|
|
export interface AgentConfigError {
|
|
code: string;
|
|
message: string;
|
|
operationIndex?: number;
|
|
path: string;
|
|
details?: unknown;
|
|
}
|
|
|
|
export type AgentPreviewResult =
|
|
| {
|
|
ok: true;
|
|
document: DashboardDocument;
|
|
patch: JsonPatchOperation[];
|
|
}
|
|
| {
|
|
ok: false;
|
|
errors: AgentConfigError[];
|
|
};
|
|
|
|
export type AgentPublishResult =
|
|
| {
|
|
ok: true;
|
|
document: DashboardDocument;
|
|
patch: JsonPatchOperation[];
|
|
previousRevisionId: string | null;
|
|
revision: DashboardRevision;
|
|
}
|
|
| {
|
|
ok: false;
|
|
errors: AgentConfigError[];
|
|
};
|
|
|
|
export interface AgentDashboardHandlerOptions {
|
|
store?: DashboardStore;
|
|
token?: string;
|
|
}
|
|
|
|
export class AgentConfigAuthorizationError extends Error {
|
|
constructor() {
|
|
super("Unauthorized dashboard configuration request");
|
|
this.name = "AgentConfigAuthorizationError";
|
|
}
|
|
}
|
|
|
|
const validateOperations = createAjv().compile<AgentDashboardOperation[]>(
|
|
AgentDashboardOperationsSchema,
|
|
);
|
|
const validateRequest = createAjv().compile<AgentDashboardRequest>(
|
|
AgentDashboardRequestSchema,
|
|
);
|
|
|
|
function schemaWithoutRootId<T>(schema: T): T {
|
|
const clone = JSON.parse(JSON.stringify(schema)) as T & { $id?: string };
|
|
delete clone.$id;
|
|
return clone;
|
|
}
|
|
|
|
function createAjv() {
|
|
return addFormats(
|
|
new Ajv({
|
|
allErrors: true,
|
|
strict: false,
|
|
strictNumbers: true,
|
|
}),
|
|
);
|
|
}
|
|
|
|
export function previewDashboardChanges(
|
|
document: DashboardDocument,
|
|
operations: AgentDashboardOperation[],
|
|
): AgentPreviewResult {
|
|
const operationValidation = validateAgentOperations(operations);
|
|
if (operationValidation.length) return { ok: false, errors: operationValidation };
|
|
|
|
const before = structuredClone(document);
|
|
let next = structuredClone(document);
|
|
const errors: AgentConfigError[] = [];
|
|
|
|
operations.forEach((operation, operationIndex) => {
|
|
if (errors.length) return;
|
|
const result = applyOperation(next, operation, operationIndex);
|
|
if (result.ok) {
|
|
next = result.document;
|
|
} else {
|
|
errors.push(...result.errors);
|
|
}
|
|
});
|
|
|
|
if (errors.length) return { ok: false, errors };
|
|
|
|
const validation = validateDashboardDocument(next);
|
|
if (!validation.valid) {
|
|
return {
|
|
ok: false,
|
|
errors: dashboardValidationErrors(validation),
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
document: validation.data,
|
|
patch: createJsonPatch(before, validation.data),
|
|
};
|
|
}
|
|
|
|
export function publishDashboardChanges(
|
|
store: DashboardStore,
|
|
operations: AgentDashboardOperation[],
|
|
metadata: DashboardWriteMetadata = {},
|
|
): AgentPublishResult {
|
|
const active = store.getActiveDashboard();
|
|
if (!active) {
|
|
const create = operations[0];
|
|
if (!create || create.type !== "create_dashboard") {
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "no_active_dashboard",
|
|
message: "No active dashboard exists; start with create_dashboard",
|
|
path: "/",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
}
|
|
|
|
const base = active?.document || emptyDashboardDocument();
|
|
const preview = previewDashboardChanges(base, operations);
|
|
if (!preview.ok) return preview;
|
|
|
|
const revision = store.commitDashboard(preview.document, metadata);
|
|
return {
|
|
ok: true,
|
|
document: revision.document,
|
|
patch: preview.patch,
|
|
previousRevisionId: active?.currentRevisionId || null,
|
|
revision,
|
|
};
|
|
}
|
|
|
|
export function rollbackDashboardRevision(
|
|
store: DashboardStore,
|
|
revisionId: string,
|
|
metadata: DashboardWriteMetadata = {},
|
|
) {
|
|
return store.rollbackToRevision(revisionId, metadata);
|
|
}
|
|
|
|
export function authorizeAgentConfigRequest(
|
|
request: Request,
|
|
token = process.env.AGENT_CONFIG_TOKEN,
|
|
): { ok: true } {
|
|
if (!token) throw new AgentConfigAuthorizationError();
|
|
|
|
const authorization = request.headers.get("authorization") || "";
|
|
const bearer = authorization.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
const headerToken = request.headers.get("x-agent-config-token");
|
|
if (bearer === token || headerToken === token) return { ok: true };
|
|
|
|
throw new AgentConfigAuthorizationError();
|
|
}
|
|
|
|
export async function handleAgentDashboardRequest(
|
|
request: Request,
|
|
options: AgentDashboardHandlerOptions = {},
|
|
): Promise<Response> {
|
|
const ownsStore = !options.store;
|
|
const store = options.store || createDashboardStore();
|
|
|
|
try {
|
|
authorizeAgentConfigRequest(request, options.token);
|
|
const body = await parseRequestBody(request);
|
|
const requestValidationErrors = validateAgentRequest(body);
|
|
if (requestValidationErrors.length) {
|
|
return Response.json(
|
|
{ ok: false, errors: requestValidationErrors },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
const agentRequest = body as AgentDashboardRequest;
|
|
if (agentRequest.action === "rollback_revision") {
|
|
const revision = rollbackDashboardRevision(store, agentRequest.revisionId, {
|
|
actor: agentRequest.actor || "agent",
|
|
message: agentRequest.message,
|
|
});
|
|
return Response.json({
|
|
ok: true,
|
|
action: agentRequest.action,
|
|
revision: serializeRevision(revision),
|
|
});
|
|
}
|
|
|
|
if (agentRequest.action === "preview_changes") {
|
|
const active = store.getActiveDashboard();
|
|
const base = active?.document || previewBaseDocument(agentRequest.operations);
|
|
if (!base) {
|
|
return Response.json(
|
|
{
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "no_active_dashboard",
|
|
message: "No active dashboard exists to preview against",
|
|
path: "/",
|
|
},
|
|
],
|
|
},
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
const preview = previewDashboardChanges(base, agentRequest.operations);
|
|
return Response.json(
|
|
{
|
|
...preview,
|
|
action: agentRequest.action,
|
|
},
|
|
{ status: preview.ok ? 200 : 422 },
|
|
);
|
|
}
|
|
|
|
const publish = publishDashboardChanges(store, agentRequest.operations, {
|
|
actor: agentRequest.actor || "agent",
|
|
message: agentRequest.message,
|
|
});
|
|
return Response.json(
|
|
{
|
|
...publish,
|
|
action: agentRequest.action,
|
|
revision: publish.ok ? serializeRevision(publish.revision) : undefined,
|
|
},
|
|
{ status: publish.ok ? 200 : 422 },
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof AgentConfigAuthorizationError) {
|
|
return Response.json(
|
|
{
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "unauthorized",
|
|
message: error.message,
|
|
path: "/",
|
|
},
|
|
],
|
|
},
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
if (error instanceof SyntaxError) {
|
|
return Response.json(
|
|
{
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "invalid_json",
|
|
message: error.message,
|
|
path: "/",
|
|
},
|
|
],
|
|
},
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
|
|
if (error instanceof DashboardRevisionNotFoundError) {
|
|
return Response.json(
|
|
{
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code: "revision_not_found",
|
|
message: error.message,
|
|
path: "/revisionId",
|
|
},
|
|
],
|
|
},
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
throw error;
|
|
} finally {
|
|
if (ownsStore) store.close();
|
|
}
|
|
}
|
|
|
|
function validateAgentOperations(operations: unknown): AgentConfigError[] {
|
|
if (!validateOperations(operations)) {
|
|
return schemaErrors(validateOperations.errors || [], "invalid_operation_schema");
|
|
}
|
|
|
|
const createDashboardIndices = operations.flatMap((operation, index) =>
|
|
operation.type === "create_dashboard" ? [index] : [],
|
|
);
|
|
if (createDashboardIndices.length > 1) {
|
|
const operationIndex = createDashboardIndices[1];
|
|
return [
|
|
{
|
|
code: "invalid_operation_sequence",
|
|
message: "create_dashboard can only appear once",
|
|
operationIndex,
|
|
path: `/${operationIndex}`,
|
|
},
|
|
];
|
|
}
|
|
if (createDashboardIndices[0] !== undefined && createDashboardIndices[0] !== 0) {
|
|
const operationIndex = createDashboardIndices[0];
|
|
return [
|
|
{
|
|
code: "invalid_operation_sequence",
|
|
message: "create_dashboard must be the first operation",
|
|
operationIndex,
|
|
path: `/${operationIndex}`,
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function validateAgentRequest(value: unknown): AgentConfigError[] {
|
|
if (validateRequest(value)) return [];
|
|
return schemaErrors(validateRequest.errors || [], "invalid_request_schema");
|
|
}
|
|
|
|
function schemaErrors(errors: ErrorObject[], code: string): AgentConfigError[] {
|
|
return errors.map((error) => ({
|
|
code,
|
|
message: error.message || "is invalid",
|
|
operationIndex: operationIndexFromPath(error.instancePath),
|
|
path: error.instancePath || "/",
|
|
details: error.params,
|
|
}));
|
|
}
|
|
|
|
function dashboardValidationErrors(
|
|
failure: DashboardValidationFailure,
|
|
): AgentConfigError[] {
|
|
return failure.errors.map((message, index) => ({
|
|
code: "dashboard_validation_failed",
|
|
message,
|
|
path: failure.details[index]?.instancePath || "/",
|
|
details: failure.details[index]?.params,
|
|
}));
|
|
}
|
|
|
|
function operationIndexFromPath(path: string): number | undefined {
|
|
const segments = path.split("/").filter(Boolean);
|
|
const operationsIndex = segments.indexOf("operations");
|
|
const candidate =
|
|
operationsIndex >= 0 ? segments[operationsIndex + 1] : segments[0];
|
|
const index = Number(candidate);
|
|
return Number.isInteger(index) ? index : undefined;
|
|
}
|
|
|
|
type ApplyResult =
|
|
| { ok: true; document: DashboardDocument }
|
|
| { ok: false; errors: AgentConfigError[] };
|
|
|
|
function applyOperation(
|
|
document: DashboardDocument,
|
|
operation: AgentDashboardOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
const next = structuredClone(document);
|
|
|
|
switch (operation.type) {
|
|
case "create_dashboard":
|
|
return { ok: true, document: structuredClone(operation.document) };
|
|
case "add_section":
|
|
return addSection(next, operation, operationIndex);
|
|
case "add_service":
|
|
return addService(next, operation, operationIndex);
|
|
case "add_metric_card":
|
|
return addMetricCard(next, operation, operationIndex);
|
|
case "connect_datasource":
|
|
return connectDatasource(next, operation, operationIndex);
|
|
case "set_status_rule":
|
|
return setStatusRule(next, operation, operationIndex);
|
|
case "arrange_item":
|
|
return arrangeItem(next, operation, operationIndex);
|
|
case "remove_item":
|
|
return removeItem(next, operation, operationIndex);
|
|
}
|
|
}
|
|
|
|
type AddSectionOperation = Extract<AgentDashboardOperation, { type: "add_section" }>;
|
|
type AddServiceOperation = Extract<AgentDashboardOperation, { type: "add_service" }>;
|
|
type AddMetricCardOperation = Extract<AgentDashboardOperation, { type: "add_metric_card" }>;
|
|
type ConnectDatasourceOperation = Extract<AgentDashboardOperation, { type: "connect_datasource" }>;
|
|
type SetStatusRuleOperation = Extract<AgentDashboardOperation, { type: "set_status_rule" }>;
|
|
type ArrangeItemOperation = Extract<AgentDashboardOperation, { type: "arrange_item" }>;
|
|
type RemoveItemOperation = Extract<AgentDashboardOperation, { type: "remove_item" }>;
|
|
|
|
function addSection(
|
|
document: DashboardDocument,
|
|
operation: AddSectionOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
if (document.serviceGroups.some((group) => group.id === operation.section.id)) {
|
|
return failure(
|
|
"duplicate_id",
|
|
`Service group already exists: ${operation.section.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
|
|
const group: ServiceGroup = {
|
|
...operation.section,
|
|
services: [],
|
|
};
|
|
document.serviceGroups.push(group);
|
|
document.layout.serviceGroups = insertId(
|
|
document.layout.serviceGroups,
|
|
group.id,
|
|
operation.position,
|
|
);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
function addService(
|
|
document: DashboardDocument,
|
|
operation: AddServiceOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
const group = document.serviceGroups.find((item) => item.id === operation.groupId);
|
|
if (!group) {
|
|
return failure(
|
|
"group_not_found",
|
|
`Service group not found: ${operation.groupId}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
|
|
if (
|
|
document.serviceGroups.some((item) =>
|
|
item.services.some((service) => service.id === operation.service.id),
|
|
)
|
|
) {
|
|
return failure(
|
|
"duplicate_id",
|
|
`Service already exists: ${operation.service.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
|
|
group.services = insertItem(group.services, operation.service, operation.position);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
function addMetricCard(
|
|
document: DashboardDocument,
|
|
operation: AddMetricCardOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
if (document.telemetry.some((card) => card.id === operation.card.id)) {
|
|
return failure(
|
|
"duplicate_id",
|
|
`Telemetry card already exists: ${operation.card.id}`,
|
|
operationIndex,
|
|
"/telemetry",
|
|
);
|
|
}
|
|
|
|
document.telemetry.push(operation.card);
|
|
document.layout.telemetry = insertId(
|
|
document.layout.telemetry,
|
|
operation.card.id,
|
|
operation.position,
|
|
);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
function connectDatasource(
|
|
document: DashboardDocument,
|
|
operation: ConnectDatasourceOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
if (!supportsDatasource(operation.target.kind)) {
|
|
return failure(
|
|
"unsupported_target",
|
|
`Target does not support datasources: ${operation.target.kind}`,
|
|
operationIndex,
|
|
targetPath(operation.target),
|
|
);
|
|
}
|
|
|
|
const targetResolution = resolveTarget(document, operation.target, operationIndex);
|
|
if (!targetResolution.ok) {
|
|
return { ok: false, errors: targetResolution.errors };
|
|
}
|
|
|
|
const target = targetResolution.target;
|
|
(target as DatasourceAgentTarget).datasource = operation.datasource;
|
|
return { ok: true, document };
|
|
}
|
|
|
|
function setStatusRule(
|
|
document: DashboardDocument,
|
|
operation: SetStatusRuleOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
if (operation.target.kind === "serviceGroup") {
|
|
return failure(
|
|
"unsupported_target",
|
|
`Target does not support status rules: ${operation.target.kind}`,
|
|
operationIndex,
|
|
targetPath(operation.target),
|
|
);
|
|
}
|
|
if (operation.thresholds && operation.target.kind !== "telemetry") {
|
|
return failure(
|
|
"unsupported_target",
|
|
"Thresholds can only be set on telemetry targets",
|
|
operationIndex,
|
|
targetPath(operation.target),
|
|
);
|
|
}
|
|
if (operation.detail !== undefined && !supportsDetail(operation.target.kind)) {
|
|
return failure(
|
|
"unsupported_target",
|
|
"Detail can only be set on telemetry, service, or module targets",
|
|
operationIndex,
|
|
targetPath(operation.target),
|
|
);
|
|
}
|
|
if (operation.value !== undefined && !supportsStringValue(operation.target.kind)) {
|
|
return failure(
|
|
"unsupported_target",
|
|
"String values can only be set on status item or module targets",
|
|
operationIndex,
|
|
targetPath(operation.target),
|
|
);
|
|
}
|
|
|
|
const targetResolution = resolveTarget(document, operation.target, operationIndex);
|
|
if (!targetResolution.ok) {
|
|
return { ok: false, errors: targetResolution.errors };
|
|
}
|
|
|
|
const target = targetResolution.target;
|
|
if (operation.thresholds && operation.target.kind === "telemetry") {
|
|
(target as TelemetryCard).thresholds = operation.thresholds;
|
|
}
|
|
if (operation.severity) {
|
|
target.severity = operation.severity;
|
|
}
|
|
if (operation.detail !== undefined && supportsDetail(operation.target.kind)) {
|
|
(target as DetailAgentTarget).detail = operation.detail;
|
|
}
|
|
if (operation.value !== undefined && supportsStringValue(operation.target.kind)) {
|
|
(target as StringValueAgentTarget).value = operation.value;
|
|
}
|
|
|
|
return { ok: true, document };
|
|
}
|
|
|
|
function arrangeItem(
|
|
document: DashboardDocument,
|
|
operation: ArrangeItemOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
const list = layoutList(document, operation.area);
|
|
if (!list.includes(operation.id)) {
|
|
return failure(
|
|
"item_not_found",
|
|
`Layout item not found: ${operation.id}`,
|
|
operationIndex,
|
|
`/layout/${operation.area}`,
|
|
);
|
|
}
|
|
|
|
const without = list.filter((id) => id !== operation.id);
|
|
const index = Math.min(operation.index, without.length);
|
|
without.splice(index, 0, operation.id);
|
|
setLayoutList(document, operation.area, without);
|
|
syncCollectionOrder(document, operation.area, without);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
function removeItem(
|
|
document: DashboardDocument,
|
|
operation: RemoveItemOperation,
|
|
operationIndex: number,
|
|
): ApplyResult {
|
|
if (operation.target.kind === "telemetry") {
|
|
if (!document.telemetry.some((card) => card.id === operation.target.id)) {
|
|
return failure(
|
|
"item_not_found",
|
|
`Telemetry card not found: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/telemetry",
|
|
);
|
|
}
|
|
document.telemetry = document.telemetry.filter((card) => card.id !== operation.target.id);
|
|
document.layout.telemetry = document.layout.telemetry.filter((id) => id !== operation.target.id);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
if (operation.target.kind === "serviceGroup") {
|
|
if (!document.serviceGroups.some((group) => group.id === operation.target.id)) {
|
|
return failure(
|
|
"item_not_found",
|
|
`Service group not found: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
document.serviceGroups = document.serviceGroups.filter((group) => group.id !== operation.target.id);
|
|
document.layout.serviceGroups = document.layout.serviceGroups.filter((id) => id !== operation.target.id);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
if (operation.target.kind === "service") {
|
|
const matches = findServices(document, operation.target.id, operation.target.groupId);
|
|
if (!matches.length) {
|
|
return failure(
|
|
"item_not_found",
|
|
`Service not found: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
if (!operation.target.groupId && matches.length > 1) {
|
|
return failure(
|
|
"ambiguous_target",
|
|
`Service exists in multiple groups: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
|
|
const match = matches[0];
|
|
match.group.services = match.group.services.filter((service) => service.id !== operation.target.id);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
if (operation.target.kind === "module") {
|
|
if (!(document.modules || []).some((module) => module.id === operation.target.id)) {
|
|
return failure(
|
|
"item_not_found",
|
|
`Module not found: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/modules",
|
|
);
|
|
}
|
|
document.modules = (document.modules || []).filter((module) => module.id !== operation.target.id);
|
|
document.layout.modules = (document.layout.modules || []).filter((id) => id !== operation.target.id);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
if (operation.target.kind === "statusItem") {
|
|
const matches = findStatusItems(document, operation.target.id, operation.target.stripId);
|
|
if (!matches.length) {
|
|
return failure(
|
|
"item_not_found",
|
|
`Status item not found: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/statusStrips",
|
|
);
|
|
}
|
|
if (!operation.target.stripId && matches.length > 1) {
|
|
return failure(
|
|
"ambiguous_target",
|
|
`Status item exists in multiple strips: ${operation.target.id}`,
|
|
operationIndex,
|
|
"/statusStrips",
|
|
);
|
|
}
|
|
|
|
const [match] = matches;
|
|
match.strip.items = match.strip.items.filter((item) => item.id !== operation.target.id);
|
|
return { ok: true, document };
|
|
}
|
|
|
|
return failure("unsupported_target", "Cannot remove target kind", operationIndex, "/");
|
|
}
|
|
|
|
function resolveTarget(
|
|
document: DashboardDocument,
|
|
target: ConnectDatasourceOperation["target"],
|
|
operationIndex: number,
|
|
): ResolveTargetResult {
|
|
if (target.kind === "telemetry") {
|
|
const card = document.telemetry.find((item) => item.id === target.id);
|
|
if (!card) {
|
|
return resolutionFailure(
|
|
"item_not_found",
|
|
`Telemetry card not found: ${target.id}`,
|
|
operationIndex,
|
|
"/telemetry",
|
|
);
|
|
}
|
|
return { ok: true, target: card };
|
|
}
|
|
if (target.kind === "service") {
|
|
const matches = findServices(document, target.id, target.groupId);
|
|
if (!matches.length) {
|
|
return resolutionFailure(
|
|
"item_not_found",
|
|
`Service not found: ${target.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
if (!target.groupId && matches.length > 1) {
|
|
return resolutionFailure(
|
|
"ambiguous_target",
|
|
`Service exists in multiple groups: ${target.id}`,
|
|
operationIndex,
|
|
"/serviceGroups",
|
|
);
|
|
}
|
|
return { ok: true, target: matches[0].service };
|
|
}
|
|
if (target.kind === "statusItem") {
|
|
const matches = findStatusItems(document, target.id, target.stripId);
|
|
if (!matches.length) {
|
|
return resolutionFailure(
|
|
"item_not_found",
|
|
`Status item not found: ${target.id}`,
|
|
operationIndex,
|
|
"/statusStrips",
|
|
);
|
|
}
|
|
if (!target.stripId && matches.length > 1) {
|
|
return resolutionFailure(
|
|
"ambiguous_target",
|
|
`Status item exists in multiple strips: ${target.id}`,
|
|
operationIndex,
|
|
"/statusStrips",
|
|
);
|
|
}
|
|
return { ok: true, target: matches[0].item };
|
|
}
|
|
if (target.kind === "module") {
|
|
const module = (document.modules || []).find((item) => item.id === target.id);
|
|
if (!module) {
|
|
return resolutionFailure(
|
|
"item_not_found",
|
|
`Module not found: ${target.id}`,
|
|
operationIndex,
|
|
"/modules",
|
|
);
|
|
}
|
|
return { ok: true, target: module };
|
|
}
|
|
return resolutionFailure(
|
|
"item_not_found",
|
|
`Target not found: ${target.id}`,
|
|
operationIndex,
|
|
targetPath(target),
|
|
);
|
|
}
|
|
|
|
type ResolveTargetResult =
|
|
| { ok: true; target: ResolvedAgentTarget }
|
|
| { ok: false; errors: AgentConfigError[] };
|
|
type ResolvedAgentTarget = TelemetryCard | ServiceEntry | StatusItem | DashboardModule;
|
|
type DatasourceAgentTarget = TelemetryCard | ServiceEntry | DashboardModule;
|
|
type DetailAgentTarget = TelemetryCard | ServiceEntry | DashboardModule;
|
|
type StringValueAgentTarget = StatusItem | DashboardModule;
|
|
|
|
type DatasourceTargetKind = Extract<
|
|
ConnectDatasourceOperation["target"]["kind"],
|
|
"telemetry" | "service" | "module"
|
|
>;
|
|
|
|
function supportsDatasource(
|
|
kind: ConnectDatasourceOperation["target"]["kind"],
|
|
): kind is DatasourceTargetKind {
|
|
return kind === "telemetry" || kind === "service" || kind === "module";
|
|
}
|
|
|
|
type DetailTargetKind = Extract<
|
|
SetStatusRuleOperation["target"]["kind"],
|
|
"telemetry" | "service" | "module"
|
|
>;
|
|
|
|
function supportsDetail(
|
|
kind: SetStatusRuleOperation["target"]["kind"],
|
|
): kind is DetailTargetKind {
|
|
return kind === "telemetry" || kind === "service" || kind === "module";
|
|
}
|
|
|
|
type StringValueTargetKind = Extract<
|
|
SetStatusRuleOperation["target"]["kind"],
|
|
"statusItem" | "module"
|
|
>;
|
|
|
|
function supportsStringValue(
|
|
kind: SetStatusRuleOperation["target"]["kind"],
|
|
): kind is StringValueTargetKind {
|
|
return kind === "statusItem" || kind === "module";
|
|
}
|
|
|
|
function findServices(document: DashboardDocument, id: string, groupId?: string) {
|
|
const groups = groupId
|
|
? document.serviceGroups.filter((group) => group.id === groupId)
|
|
: document.serviceGroups;
|
|
return groups.flatMap((group) =>
|
|
group.services.flatMap((service) =>
|
|
service.id === id ? [{ group, service }] : [],
|
|
),
|
|
);
|
|
}
|
|
|
|
function findStatusItems(document: DashboardDocument, id: string, stripId?: string) {
|
|
const strips = stripId
|
|
? document.statusStrips.filter((strip) => strip.id === stripId)
|
|
: document.statusStrips;
|
|
return strips.flatMap((strip) =>
|
|
strip.items.flatMap((item) => (item.id === id ? [{ strip, item }] : [])),
|
|
);
|
|
}
|
|
|
|
function resolutionFailure(
|
|
code: string,
|
|
message: string,
|
|
operationIndex: number,
|
|
path: string,
|
|
): ResolveTargetResult {
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code,
|
|
message,
|
|
operationIndex,
|
|
path,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function targetPath(target: ConnectDatasourceOperation["target"]): string {
|
|
if (target.kind === "telemetry") return "/telemetry";
|
|
if (target.kind === "service") return "/serviceGroups";
|
|
if (target.kind === "serviceGroup") return "/serviceGroups";
|
|
if (target.kind === "statusItem") return "/statusStrips";
|
|
return "/modules";
|
|
}
|
|
|
|
function layoutList(document: DashboardDocument, area: ArrangeItemOperation["area"]) {
|
|
if (area === "modules") return document.layout.modules || [];
|
|
return document.layout[area];
|
|
}
|
|
|
|
function setLayoutList(
|
|
document: DashboardDocument,
|
|
area: ArrangeItemOperation["area"],
|
|
value: string[],
|
|
) {
|
|
if (area === "modules") {
|
|
document.layout.modules = value;
|
|
return;
|
|
}
|
|
document.layout[area] = value;
|
|
}
|
|
|
|
function syncCollectionOrder(
|
|
document: DashboardDocument,
|
|
area: ArrangeItemOperation["area"],
|
|
orderedIds: string[],
|
|
) {
|
|
if (area === "telemetry") {
|
|
document.telemetry = orderByIds(document.telemetry, orderedIds);
|
|
return;
|
|
}
|
|
if (area === "serviceGroups") {
|
|
document.serviceGroups = orderByIds(document.serviceGroups, orderedIds);
|
|
return;
|
|
}
|
|
if (area === "statusStrips") {
|
|
document.statusStrips = orderByIds(document.statusStrips, orderedIds);
|
|
return;
|
|
}
|
|
document.modules = orderByIds(document.modules || [], orderedIds);
|
|
}
|
|
|
|
function orderByIds<T extends { id: string }>(items: T[], orderedIds: string[]): T[] {
|
|
const order = new Map(orderedIds.map((id, index) => [id, index]));
|
|
return [...items].sort(
|
|
(left, right) =>
|
|
(order.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
|
|
(order.get(right.id) ?? Number.MAX_SAFE_INTEGER),
|
|
);
|
|
}
|
|
|
|
function insertItem<T extends { id: string }>(
|
|
items: T[],
|
|
item: T,
|
|
position?: Static<typeof PositionSchema>,
|
|
): T[] {
|
|
const next = [...items];
|
|
next.splice(resolveInsertionIndex(next.map((entry) => entry.id), position), 0, item);
|
|
return next;
|
|
}
|
|
|
|
function insertId(
|
|
ids: string[],
|
|
id: string,
|
|
position?: Static<typeof PositionSchema>,
|
|
): string[] {
|
|
const next = [...ids];
|
|
next.splice(resolveInsertionIndex(next, position), 0, id);
|
|
return next;
|
|
}
|
|
|
|
function resolveInsertionIndex(ids: string[], position?: Static<typeof PositionSchema>) {
|
|
if (!position) return ids.length;
|
|
if (typeof position.index === "number") return Math.min(position.index, ids.length);
|
|
if (position.beforeId) {
|
|
const beforeIndex = ids.indexOf(position.beforeId);
|
|
return beforeIndex >= 0 ? beforeIndex : ids.length;
|
|
}
|
|
if (position.afterId) {
|
|
const afterIndex = ids.indexOf(position.afterId);
|
|
return afterIndex >= 0 ? afterIndex + 1 : ids.length;
|
|
}
|
|
return ids.length;
|
|
}
|
|
|
|
function failure(
|
|
code: string,
|
|
message: string,
|
|
operationIndex: number,
|
|
path: string,
|
|
): ApplyResult {
|
|
return {
|
|
ok: false,
|
|
errors: [
|
|
{
|
|
code,
|
|
message,
|
|
operationIndex,
|
|
path,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function createJsonPatch(
|
|
before: unknown,
|
|
after: unknown,
|
|
path = "",
|
|
): JsonPatchOperation[] {
|
|
if (before === undefined) return [{ op: "add", path: path || "/", value: after }];
|
|
if (after === undefined) return [{ op: "remove", path: path || "/" }];
|
|
if (Object.is(before, after)) return [];
|
|
|
|
if (!isObjectLike(before) || !isObjectLike(after)) {
|
|
return [{ op: "replace", path: path || "/", value: after }];
|
|
}
|
|
|
|
if (Array.isArray(before) || Array.isArray(after)) {
|
|
if (!Array.isArray(before) || !Array.isArray(after)) {
|
|
return [{ op: "replace", path: path || "/", value: after }];
|
|
}
|
|
const operations: JsonPatchOperation[] = [];
|
|
const sharedLength = Math.min(before.length, after.length);
|
|
for (let index = 0; index < sharedLength; index += 1) {
|
|
operations.push(
|
|
...createJsonPatch(before[index], after[index], `${path}/${index}`),
|
|
);
|
|
}
|
|
for (let index = before.length - 1; index >= after.length; index -= 1) {
|
|
operations.push({ op: "remove", path: `${path}/${index}` });
|
|
}
|
|
for (let index = before.length; index < after.length; index += 1) {
|
|
operations.push({ op: "add", path: `${path}/${index}`, value: after[index] });
|
|
}
|
|
return operations;
|
|
}
|
|
|
|
const beforeObject = before as Record<string, unknown>;
|
|
const afterObject = after as Record<string, unknown>;
|
|
const keys = new Set([...Object.keys(beforeObject), ...Object.keys(afterObject)]);
|
|
return [...keys].flatMap((key) =>
|
|
createJsonPatch(beforeObject[key], afterObject[key], `${path}/${escapeJsonPointer(key)}`),
|
|
);
|
|
}
|
|
|
|
function isObjectLike(value: unknown): value is Record<string, unknown> | unknown[] {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function escapeJsonPointer(value: string): string {
|
|
return value.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
}
|
|
|
|
async function parseRequestBody(request: Request): Promise<unknown> {
|
|
const text = await request.text();
|
|
return text ? JSON.parse(text) : {};
|
|
}
|
|
|
|
function previewBaseDocument(operations: AgentDashboardOperation[]): DashboardDocument | null {
|
|
return operations[0]?.type === "create_dashboard" ? emptyDashboardDocument() : null;
|
|
}
|
|
|
|
function serializeRevision(revision: DashboardRevision) {
|
|
return {
|
|
id: revision.id,
|
|
dashboardId: revision.dashboardId,
|
|
schemaVersion: revision.schemaVersion,
|
|
actor: revision.actor,
|
|
message: revision.message,
|
|
operation: revision.operation,
|
|
sourceRevisionId: revision.sourceRevisionId,
|
|
createdAt: revision.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
function emptyDashboardDocument(): DashboardDocument {
|
|
return {
|
|
schemaVersion: "dashboard.v1",
|
|
metadata: {
|
|
title: "Untitled Dashboard",
|
|
},
|
|
layout: {
|
|
telemetry: [],
|
|
serviceGroups: [],
|
|
statusStrips: [],
|
|
modules: [],
|
|
},
|
|
telemetry: [],
|
|
serviceGroups: [],
|
|
statusStrips: [],
|
|
modules: [],
|
|
};
|
|
}
|