maestro/src/bridge/dashboard-api.ts
2026-06-03 05:08:00 +00:00

278 lines
10 KiB
TypeScript

/**
* dashboard-api.ts — REST router for Side Info Panel.
* Mounted at /api/local/dashboard.
*
* Routes:
* GET /widgets — list current user's widgets
* POST /widgets — create
* PATCH /widgets/:id — update title/content
* DELETE /widgets/:id — delete
* PUT /widgets/reorder — reorder by id list
* GET /workers — worker idle/running (no job details)
*
* Auth: all routes require req.user (or fall back to 'local' when authActive=false).
* Owner: every operation scopes to req.user.id; cross-user access returns 404.
*/
import { Router, type Request, type Response } from 'express';
import { createHash } from 'crypto';
import { isDashboardWidgetKind, type DashboardWidgetKind, type Repository } from '../db/repository.js';
import type { WorkerDef } from '../config.js';
import { collectWorkerStatuses } from './dashboard-workers.js';
import type { BackendStatusRegistry } from '../engine/backend-status-registry.js';
import { logger } from '../logger.js';
const SLUG_PATTERN = /^[a-z0-9-]+$/;
const MAX_SLUG_LEN = 32;
const MAX_TITLE_LEN = 64;
const MAX_CONTENT_BYTES = 64 * 1024;
interface AuthedUser { id: string; role: string; }
function getUser(req: Request): AuthedUser | null {
return (req.user as AuthedUser | undefined) ?? null;
}
export interface DashboardApiDeps {
repo: Repository;
getWorkers: () => WorkerDef[];
authActive?: boolean;
/**
* Optional BackendStatusRegistry. When supplied, the API exposes
* GET /node-status; when omitted (e.g. in unit tests that don't care
* about node status), the route 503s.
*/
backendStatusRegistry?: BackendStatusRegistry | null;
}
export function createDashboardApi(deps: DashboardApiDeps): Router {
const { repo, getWorkers } = deps;
const authActive = deps.authActive ?? true;
const r = Router();
r.use((req: Request, res: Response, next) => {
if (!authActive && !getUser(req)) {
(req as any).user = { id: 'local', role: 'user' };
}
if (!getUser(req)) {
res.status(401).json({ error: 'Unauthenticated' });
return;
}
next();
});
r.get('/widgets', async (req, res) => {
const u = getUser(req)!;
try {
const widgets = await repo.listDashboardWidgets(u.id);
res.json({ widgets });
} catch (err) {
logger.error(`[dashboard-api] GET /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to list widgets' });
}
});
r.post('/widgets', async (req, res) => {
const u = getUser(req)!;
const { slug, title, content, kind } = (req.body ?? {}) as {
slug?: string;
title?: string;
content?: string;
kind?: string;
};
if (!slug || !SLUG_PATTERN.test(slug) || slug.length > MAX_SLUG_LEN) {
res.status(400).json({ error: 'invalid slug (lowercase a-z, 0-9, hyphen; max 32 chars)' });
return;
}
if (!title || title.length > MAX_TITLE_LEN) {
res.status(400).json({ error: `title required and <= ${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
// kind is optional; defaults to 'markdown' for backward compat.
let resolvedKind: DashboardWidgetKind = 'markdown';
if (kind !== undefined) {
if (!isDashboardWidgetKind(kind)) {
res.status(400).json({ error: 'invalid kind (allowed: markdown, node-status)' });
return;
}
resolvedKind = kind;
}
try {
const widget = await repo.createDashboardWidget({
userId: u.id,
slug,
title,
content: content ?? '',
kind: resolvedKind,
});
res.status(201).json({ widget });
} catch (err: any) {
if (String(err?.message ?? err).includes('UNIQUE')) {
res.status(409).json({ error: 'slug already exists' });
return;
}
logger.error(`[dashboard-api] POST /widgets failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to create widget' });
}
});
r.patch('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
const { title, content } = (req.body ?? {}) as { title?: string; content?: string };
// Non-markdown widget kinds (currently just 'node-status') render
// data live from a backing source instead of stored markdown — any
// content the caller sends would be dead state at best and a
// confusing surprise on the next render at worst. Title remains
// editable so the user can rename the panel.
if (existing.kind !== 'markdown' && content !== undefined) {
res.status(400).json({
error: `cannot edit content of ${existing.kind} widget (title-only updates allowed)`,
});
return;
}
if (title !== undefined && (title.length === 0 || title.length > MAX_TITLE_LEN)) {
res.status(400).json({ error: `title must be 1..${MAX_TITLE_LEN} chars` });
return;
}
if (content !== undefined && Buffer.byteLength(content, 'utf8') > MAX_CONTENT_BYTES) {
res.status(400).json({ error: `content exceeds ${MAX_CONTENT_BYTES} bytes` });
return;
}
try {
const widget = await repo.updateDashboardWidget(id, u.id, { title, content });
res.json({ widget });
} catch (err) {
logger.error(`[dashboard-api] PATCH /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to update widget' });
}
});
r.delete('/widgets/:id', async (req, res) => {
const u = getUser(req)!;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'invalid id' });
return;
}
const existing = await repo.getDashboardWidget(id, u.id);
if (!existing) {
res.status(404).json({ error: 'not found' });
return;
}
try {
await repo.deleteDashboardWidget(id, u.id);
res.status(204).end();
} catch (err) {
logger.error(`[dashboard-api] DELETE /widgets/${id} failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to delete widget' });
}
});
r.put('/widgets/reorder', async (req, res) => {
const u = getUser(req)!;
const { ids } = (req.body ?? {}) as { ids?: number[] };
if (!Array.isArray(ids) || !ids.every(n => Number.isFinite(n))) {
res.status(400).json({ error: 'ids must be array of numbers' });
return;
}
try {
await repo.reorderDashboardWidgets(u.id, ids);
res.json({ ok: true });
} catch (err) {
logger.error(`[dashboard-api] reorder failed user=${u.id} err=${err}`);
res.status(500).json({ error: 'Failed to reorder' });
}
});
r.get('/workers', async (_req, res) => {
try {
const workers = await collectWorkerStatuses(repo, getWorkers(), deps.backendStatusRegistry ?? null);
res.json({ workers });
} catch (err) {
logger.error(`[dashboard-api] GET /workers failed err=${err}`);
res.status(500).json({ error: 'Failed to list worker status' });
}
});
// GET /node-status
//
// Returns the latest BackendStatusRegistry snapshot. The registry is
// already polling in the background at a fixed cadence, so this
// handler is a cheap cache read.
//
// Caching headers (Phase C):
// - `Cache-Control: no-store` — multiple AAO instances might sit
// behind a shared proxy/CDN with body-level caching defaults; the
// snapshot is per-process state and must never be cached
// intermediately.
// - Weak ETag of the JSON payload + 304 short-circuit — when 5s polls
// land on an unchanged registry (idle pool, no probes flipped) the
// response avoids re-serialising the body and the browser refetch
// skips the JSON parse, halving the per-tick CPU under N-tab loads.
//
// The registry tick also notifies the registry that a subscriber is
// active so the polling cadence can fall back to the idle interval
// when no UI is open (see BackendStatusRegistry.noteSubscriberActivity).
r.get('/node-status', async (req, res) => {
const reg = deps.backendStatusRegistry ?? null;
if (!reg) {
// The registry is started by server.ts; when running under tests
// that don't bother to construct one, we'd rather signal "feature
// disabled" than crash.
res.status(503).json({ nodes: [], error: 'node-status registry not configured' });
return;
}
try {
const nodes = reg.getAll();
// Signal to the registry that a UI is actively watching so the
// polling cadence stays in the active (5s) band; without any
// recent GET the registry falls back to the idle (30s) cadence.
if (typeof reg.noteSubscriberActivity === 'function') {
try { reg.noteSubscriberActivity(); } catch { /* never fail the GET on metrics */ }
}
const body = JSON.stringify({ nodes });
// Weak ETag: payload identity is the only thing that matters for
// 304 short-circuiting; we don't care about byte-for-byte
// equivalence (no Content-Encoding negotiation here).
const etag = `W/"${createHash('sha1').update(body).digest('hex').slice(0, 16)}"`;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('ETag', etag);
const inm = req.headers['if-none-match'];
// RFC 9110 §13.1.2: If-None-Match may carry a comma-separated
// list of entity tags (browsers' BFCache restore and HTTP
// intermediaries can both produce multi-tag headers). Strict
// equality on the whole header would silently miss matches and
// re-send the body unnecessarily — splitting + per-tag compare
// is the spec-compliant behaviour.
if (typeof inm === 'string') {
const tags = inm.split(',').map(s => s.trim());
if (tags.includes(etag)) {
res.status(304).end();
return;
}
}
res.type('application/json').send(body);
} catch (err) {
logger.error(`[dashboard-api] GET /node-status failed err=${err}`);
res.status(500).json({ error: 'Failed to read node status' });
}
});
return r;
}