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

View File

@@ -42,6 +42,10 @@ describe('config/env loading', () => {
delete process.env.CODEX_WARMUP_COMMAND_TIMEOUT_MS;
delete process.env.CODEX_WARMUP_FAILURE_COOLDOWN_MS;
delete process.env.CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES;
delete process.env.WEB_DASHBOARD_ENABLED;
delete process.env.WEB_DASHBOARD_HOST;
delete process.env.WEB_DASHBOARD_PORT;
delete process.env.WEB_DASHBOARD_STATIC_DIR;
delete process.env.SESSION_COMMAND_USER_IDS;
vi.resetModules();
});
@@ -163,8 +167,32 @@ describe('config/env loading', () => {
);
});
it('keeps the web dashboard disabled by default and loads explicit bind/static settings', async () => {
let config = await import('./config.js');
expect(config.WEB_DASHBOARD.enabled).toBe(false);
expect(config.WEB_DASHBOARD.host).toBe('127.0.0.1');
expect(config.WEB_DASHBOARD.port).toBe(8734);
expect(config.WEB_DASHBOARD.staticDir).toBe(
path.resolve(tempRoot, 'apps', 'dashboard', 'dist'),
);
vi.resetModules();
process.env.WEB_DASHBOARD_ENABLED = 'true';
process.env.WEB_DASHBOARD_HOST = '0.0.0.0';
process.env.WEB_DASHBOARD_PORT = '9001';
process.env.WEB_DASHBOARD_STATIC_DIR = './custom-dashboard-dist';
config = await import('./config.js');
expect(config.WEB_DASHBOARD).toEqual({
enabled: true,
host: '0.0.0.0',
port: 9001,
staticDir: path.resolve(tempRoot, 'custom-dashboard-dist'),
});
});
it('fails fast when a legacy Discord token alias is configured', async () => {
process.env.DISCORD_BOT_TOKEN = 'legacy-owner-token';
process.env.DISCORD_BOT_TOKEN = 'legacy...oken';
const { loadConfig } = await import('./config/load-config.js');

View File

@@ -141,6 +141,7 @@ export const STATUS_SHOW_ROOMS = CONFIG.status.showRooms;
export const STATUS_SHOW_ROOM_DETAILS = CONFIG.status.showRoomDetails;
export const USAGE_DASHBOARD_ENABLED = CONFIG.status.usageDashboardEnabled;
export const CODEX_WARMUP_CONFIG = CONFIG.codexWarmup;
export const WEB_DASHBOARD = CONFIG.webDashboard;
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default

View File

@@ -260,6 +260,15 @@ export function loadConfig(): AppConfig {
timezone:
readText('TZ') ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
},
webDashboard: {
enabled: readBoolean('WEB_DASHBOARD_ENABLED', false),
host: readNonEmptyText('WEB_DASHBOARD_HOST') ?? '127.0.0.1',
port: readIntegerAtLeast('WEB_DASHBOARD_PORT', 8734, 1),
staticDir: path.resolve(
readNonEmptyText('WEB_DASHBOARD_STATIC_DIR') ??
path.join(projectRoot, 'apps', 'dashboard', 'dist'),
),
},
codexWarmup: {
enabled: readBoolean('CODEX_WARMUP_ENABLED', false),
prompt:

View File

@@ -78,6 +78,12 @@ export interface AppConfig {
usageDashboardEnabled: boolean;
timezone: string;
};
webDashboard: {
enabled: boolean;
host: string;
port: number;
staticDir: string;
};
codexWarmup: {
enabled: boolean;
prompt: string;

View File

@@ -10,6 +10,7 @@ import {
TIMEZONE,
TRIGGER_PATTERN,
USAGE_UPDATE_INTERVAL,
WEB_DASHBOARD,
} from './config.js';
import './channels/index.js';
import {
@@ -54,6 +55,7 @@ import {
import { createMessageRuntime } from './message-runtime.js';
import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
import { startUnifiedDashboard } from './unified-dashboard.js';
import { startWebDashboardServer } from './web-dashboard-server.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
import { initCodexTokenRotation } from './codex-token-rotation.js';
@@ -248,6 +250,7 @@ async function main(): Promise<void> {
// Graceful shutdown handlers
let leaseRecoveryTimer: ReturnType<typeof setInterval> | null = null;
let webDashboardServer: ReturnType<typeof startWebDashboardServer> = null;
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
stopTokenRefreshLoop();
@@ -255,6 +258,8 @@ async function main(): Promise<void> {
clearInterval(leaseRecoveryTimer);
leaseRecoveryTimer = null;
}
webDashboardServer?.stop();
webDashboardServer = null;
const roomBindings = runtimeState.getRoomBindings();
const interruptedGroups = queue
.getStatuses(Object.keys(roomBindings))
@@ -516,6 +521,7 @@ async function main(): Promise<void> {
},
purgeOnStart: true,
});
webDashboardServer = startWebDashboardServer(WEB_DASHBOARD);
leaseRecoveryTimer = setInterval(() => {
const failover = getGlobalFailoverInfo();

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import type { StatusSnapshot } from './status-dashboard.js';
import type { ScheduledTask } from './types.js';
import {
buildWebDashboardOverview,
sanitizeScheduledTask,
} from './web-dashboard-data.js';
function makeTask(overrides: Partial<ScheduledTask>): ScheduledTask {
return {
id: 'task-1',
group_folder: 'general',
chat_jid: 'dc:general',
agent_type: null,
status_message_id: null,
status_started_at: null,
prompt: 'secret long prompt that should not be exposed in full',
schedule_type: 'cron',
schedule_value: '* * * * *',
context_mode: 'group',
next_run: '2026-04-26T05:00:00.000Z',
last_run: null,
last_result: null,
status: 'active',
created_at: '2026-04-26T04:00:00.000Z',
...overrides,
};
}
describe('web dashboard data', () => {
it('builds overview counts from status snapshots and scheduled tasks', () => {
const snapshots: StatusSnapshot[] = [
{
serviceId: 'codex-main',
agentType: 'codex',
assistantName: 'Codex',
updatedAt: '2026-04-26T04:59:00.000Z',
entries: [
{
jid: 'dc:1',
name: '#general',
folder: 'general',
agentType: 'codex',
status: 'processing',
elapsedMs: 1200,
pendingMessages: true,
pendingTasks: 2,
},
{
jid: 'dc:2',
name: '#brain',
folder: 'brain',
agentType: 'claude-code',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
},
],
usageRows: [
{
name: 'codex-a',
h5pct: 10,
h5reset: '1h',
d7pct: 20,
d7reset: '2d',
},
],
},
];
const overview = buildWebDashboardOverview({
now: '2026-04-26T05:00:00.000Z',
snapshots,
tasks: [
makeTask({
id: 'watch-1',
prompt: '[BACKGROUND CI WATCH] owner/repo#1',
status: 'active',
}),
makeTask({ id: 'cron-1', prompt: 'regular cleanup', status: 'paused' }),
],
});
expect(overview.rooms.total).toBe(2);
expect(overview.rooms.active).toBe(1);
expect(overview.rooms.waiting).toBe(0);
expect(overview.rooms.inactive).toBe(1);
expect(overview.tasks.total).toBe(2);
expect(overview.tasks.active).toBe(1);
expect(overview.tasks.paused).toBe(1);
expect(overview.tasks.watchers.active).toBe(1);
expect(overview.usage.rows).toHaveLength(1);
});
it('does not expose full scheduled task prompts through API payloads', () => {
const sanitized = sanitizeScheduledTask(
makeTask({
prompt: 'x'.repeat(220),
last_result: 'ok',
}),
);
expect(sanitized).not.toHaveProperty('prompt');
expect(sanitized.promptPreview.length).toBeLessThanOrEqual(123);
expect(sanitized.promptLength).toBe(220);
});
it('redacts common secret values from scheduled task prompt previews', () => {
const sanitized = sanitizeScheduledTask(
makeTask({
prompt:
'deploy with OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyz123456 and BOT_TOKEN=plain-secret-value',
}),
);
expect(sanitized.promptPreview).toContain('OPENAI_API_KEY=<redacted>');
expect(sanitized.promptPreview).toContain('BOT_TOKEN=<redacted>');
expect(sanitized.promptPreview).not.toContain(
'sk-abcdefghijklmnopqrstuvwxyz123456',
);
expect(sanitized.promptPreview).not.toContain('plain-secret-value');
});
});

185
src/web-dashboard-data.ts Normal file
View File

@@ -0,0 +1,185 @@
import { isWatchCiTask } from './task-watch-status.js';
import type { StatusSnapshot, UsageRowSnapshot } from './status-dashboard.js';
import type { ScheduledTask } from './types.js';
export interface SanitizedScheduledTask {
id: string;
groupFolder: string;
chatJid: string;
agentType: ScheduledTask['agent_type'];
ciProvider: ScheduledTask['ci_provider'];
ciMetadata: ScheduledTask['ci_metadata'];
scheduleType: ScheduledTask['schedule_type'];
scheduleValue: string;
contextMode: ScheduledTask['context_mode'];
nextRun: string | null;
lastRun: string | null;
lastResult: string | null;
status: ScheduledTask['status'];
suspendedUntil: string | null;
createdAt: string;
promptPreview: string;
promptLength: number;
isWatcher: boolean;
}
export interface WebDashboardOverview {
generatedAt: string;
services: Array<{
serviceId: string;
assistantName: string;
agentType: StatusSnapshot['agentType'];
updatedAt: string;
totalRooms: number;
activeRooms: number;
}>;
rooms: {
total: number;
active: number;
waiting: number;
inactive: number;
};
tasks: {
total: number;
active: number;
paused: number;
completed: number;
watchers: {
active: number;
paused: number;
completed: number;
};
};
usage: {
rows: UsageRowSnapshot[];
fetchedAt: string | null;
};
}
const SECRET_ASSIGNMENT_RE =
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
const SECRET_VALUE_RE =
/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g;
function redactSensitiveText(value: string): string {
return value
.replace(SECRET_ASSIGNMENT_RE, '$1=<redacted>')
.replace(SECRET_VALUE_RE, '<redacted-token>');
}
function truncateText(value: string, maxLength = 120): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength)}...`;
}
function buildPromptPreview(prompt: string): string {
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
}
export function sanitizeScheduledTask(
task: ScheduledTask,
): SanitizedScheduledTask {
return {
id: task.id,
groupFolder: task.group_folder,
chatJid: task.chat_jid,
agentType: task.agent_type,
ciProvider: task.ci_provider ?? null,
ciMetadata: task.ci_metadata ?? null,
scheduleType: task.schedule_type,
scheduleValue: task.schedule_value,
contextMode: task.context_mode,
nextRun: task.next_run,
lastRun: task.last_run,
lastResult: task.last_result,
status: task.status,
suspendedUntil: task.suspended_until ?? null,
createdAt: task.created_at,
promptPreview: buildPromptPreview(task.prompt),
promptLength: task.prompt.length,
isWatcher: isWatchCiTask(task),
};
}
export function buildWebDashboardOverview(args: {
now?: string;
snapshots: StatusSnapshot[];
tasks: ScheduledTask[];
}): WebDashboardOverview {
const rooms = {
total: 0,
active: 0,
waiting: 0,
inactive: 0,
};
const services = args.snapshots.map((snapshot) => {
let activeRooms = 0;
for (const entry of snapshot.entries) {
rooms.total += 1;
if (entry.status === 'processing') {
rooms.active += 1;
activeRooms += 1;
} else if (entry.status === 'waiting') {
rooms.waiting += 1;
activeRooms += 1;
} else {
rooms.inactive += 1;
}
}
return {
serviceId: snapshot.serviceId,
assistantName: snapshot.assistantName,
agentType: snapshot.agentType,
updatedAt: snapshot.updatedAt,
totalRooms: snapshot.entries.length,
activeRooms,
};
});
const tasks = {
total: args.tasks.length,
active: 0,
paused: 0,
completed: 0,
watchers: {
active: 0,
paused: 0,
completed: 0,
},
};
for (const task of args.tasks) {
if (task.status === 'active') tasks.active += 1;
if (task.status === 'paused') tasks.paused += 1;
if (task.status === 'completed') tasks.completed += 1;
if (isWatchCiTask(task)) {
if (task.status === 'active') tasks.watchers.active += 1;
if (task.status === 'paused') tasks.watchers.paused += 1;
if (task.status === 'completed') tasks.watchers.completed += 1;
}
}
const usageRows = args.snapshots.flatMap(
(snapshot) => snapshot.usageRows ?? [],
);
const usageFetchedAt =
args.snapshots
.map((snapshot) => snapshot.usageRowsFetchedAt)
.filter((value): value is string => !!value)
.sort()
.at(-1) ?? null;
return {
generatedAt: args.now ?? new Date().toISOString(),
services,
rooms,
tasks,
usage: {
rows: usageRows,
fetchedAt: usageFetchedAt,
},
};
}

View File

@@ -0,0 +1,73 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { createWebDashboardHandler } from './web-dashboard-server.js';
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('web dashboard server handler', () => {
it('serves health and overview JSON without requiring Discord', async () => {
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
});
const health = await handler(new Request('http://localhost/api/health'));
expect(health.status).toBe(200);
await expect(health.json()).resolves.toEqual({ ok: true });
const overview = await handler(
new Request('http://localhost/api/overview'),
);
expect(overview.status).toBe(200);
const body = (await overview.json()) as {
rooms: { total: number };
tasks: { total: number };
};
expect(body.rooms.total).toBe(0);
expect(body.tasks.total).toBe(0);
});
it('serves Vite static assets and falls back to index for SPA routes', async () => {
const staticDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-dashboard-'),
);
tempDirs.push(staticDir);
fs.writeFileSync(
path.join(staticDir, 'index.html'),
'<div id="root"></div>',
);
fs.mkdirSync(path.join(staticDir, 'assets'));
fs.writeFileSync(
path.join(staticDir, 'assets', 'app.js'),
'console.log("ok")',
);
const handler = createWebDashboardHandler({
staticDir,
readStatusSnapshots: () => [],
getTasks: () => [],
});
const asset = await handler(new Request('http://localhost/assets/app.js'));
expect(asset.status).toBe(200);
expect(asset.headers.get('content-type')).toContain('text/javascript');
await expect(asset.text()).resolves.toContain('console.log');
const fallback = await handler(
new Request('http://localhost/tasks/swarm_123'),
);
expect(fallback.status).toBe(200);
expect(fallback.headers.get('content-type')).toContain('text/html');
await expect(fallback.text()).resolves.toContain('root');
});
});

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),
};
}