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:
Eyejoker
2026-04-26 17:05:50 +09:00
committed by GitHub
parent d52556fd28
commit 5ba607644f
20 changed files with 1857 additions and 10 deletions

182
src/web-dashboard-server.ts Normal file
View 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),
};
}