278 lines
10 KiB
TypeScript
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;
|
|
}
|