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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
125
src/web-dashboard-data.test.ts
Normal file
125
src/web-dashboard-data.test.ts
Normal 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
185
src/web-dashboard-data.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
73
src/web-dashboard-server.test.ts
Normal file
73
src/web-dashboard-server.test.ts
Normal 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
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