Add read-only web dashboard MVP
Adds a disabled-by-default loopback web dashboard MVP with read-only control-plane views, prompt preview redaction, Vite React UI, and validation coverage.
This commit is contained in:
182
src/web-dashboard-server.ts
Normal file
182
src/web-dashboard-server.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { WEB_DASHBOARD } from './config.js';
|
||||
import { getAllTasks } from './db.js';
|
||||
import { logger } from './logger.js';
|
||||
import {
|
||||
readStatusSnapshots,
|
||||
type StatusSnapshot,
|
||||
} from './status-dashboard.js';
|
||||
import type { ScheduledTask } from './types.js';
|
||||
import {
|
||||
buildWebDashboardOverview,
|
||||
sanitizeScheduledTask,
|
||||
} from './web-dashboard-data.js';
|
||||
|
||||
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
|
||||
|
||||
export interface WebDashboardHandlerOptions {
|
||||
staticDir?: string;
|
||||
statusMaxAgeMs?: number;
|
||||
readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[];
|
||||
getTasks?: () => ScheduledTask[];
|
||||
now?: () => string;
|
||||
}
|
||||
|
||||
export interface StartedWebDashboardServer {
|
||||
url: string;
|
||||
stop: () => void;
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(value, null, 2), {
|
||||
...init,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getContentType(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case '.html':
|
||||
return 'text/html; charset=utf-8';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'text/javascript; charset=utf-8';
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.json':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
default:
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
|
||||
function resolveStaticFile(staticDir: string, pathname: string): string | null {
|
||||
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
const candidate = path.resolve(staticDir, normalizedPath || 'index.html');
|
||||
const root = path.resolve(staticDir);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const indexPath = path.join(root, 'index.html');
|
||||
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
|
||||
return indexPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function serveStaticFile(staticDir: string, pathname: string): Response {
|
||||
const filePath = resolveStaticFile(staticDir, pathname);
|
||||
if (!filePath) {
|
||||
return new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(fs.readFileSync(filePath), {
|
||||
headers: {
|
||||
'content-type': getContentType(filePath),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createWebDashboardHandler(
|
||||
opts: WebDashboardHandlerOptions = {},
|
||||
): (request: Request) => Response | Promise<Response> {
|
||||
const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots;
|
||||
const loadTasks = opts.getTasks ?? getAllTasks;
|
||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||
|
||||
return (request: Request): Response => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/health') {
|
||||
return jsonResponse({ ok: true });
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/overview') {
|
||||
const snapshots = readSnapshots(statusMaxAgeMs);
|
||||
const tasks = loadTasks();
|
||||
return jsonResponse(
|
||||
buildWebDashboardOverview({
|
||||
now: opts.now?.(),
|
||||
snapshots,
|
||||
tasks,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/status-snapshots') {
|
||||
return jsonResponse(readSnapshots(statusMaxAgeMs));
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/tasks') {
|
||||
return jsonResponse(loadTasks().map(sanitizeScheduledTask));
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
return jsonResponse({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!opts.staticDir) {
|
||||
return new Response('Dashboard static directory is not configured', {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return serveStaticFile(opts.staticDir, url.pathname);
|
||||
};
|
||||
}
|
||||
|
||||
export function startWebDashboardServer(
|
||||
opts: {
|
||||
enabled?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
staticDir?: string;
|
||||
} = {},
|
||||
): StartedWebDashboardServer | null {
|
||||
const enabled = opts.enabled ?? WEB_DASHBOARD.enabled;
|
||||
if (!enabled) return null;
|
||||
|
||||
const host = opts.host ?? WEB_DASHBOARD.host;
|
||||
const port = opts.port ?? WEB_DASHBOARD.port;
|
||||
const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir;
|
||||
const server = Bun.serve({
|
||||
hostname: host,
|
||||
port,
|
||||
fetch: createWebDashboardHandler({ staticDir }),
|
||||
});
|
||||
const url = `http://${host}:${server.port}`;
|
||||
|
||||
logger.info({ url, staticDir }, 'Web dashboard started');
|
||||
|
||||
return {
|
||||
url,
|
||||
stop: () => server.stop(true),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user