diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html
new file mode 100644
index 0000000..434ca72
--- /dev/null
+++ b/apps/dashboard/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ EJClaw Dashboard
+
+
+
+
+
+
diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
new file mode 100644
index 0000000..27d48e5
--- /dev/null
+++ b/apps/dashboard/src/App.tsx
@@ -0,0 +1,444 @@
+import { useEffect, useMemo, useState, type ReactNode } from 'react';
+
+import {
+ type DashboardOverview,
+ type DashboardTask,
+ type StatusSnapshot,
+ fetchDashboardData,
+} from './api';
+import './styles.css';
+
+interface DashboardState {
+ overview: DashboardOverview;
+ snapshots: StatusSnapshot[];
+ tasks: DashboardTask[];
+}
+
+const REFRESH_INTERVAL_MS = 15_000;
+
+function formatDate(value: string | null | undefined): string {
+ if (!value) return '-';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return value;
+ return new Intl.DateTimeFormat('ko-KR', {
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ }).format(date);
+}
+
+function formatPct(value: number): string {
+ return `${Math.round(value)}%`;
+}
+
+function statusLabel(status: string): string {
+ switch (status) {
+ case 'processing':
+ return '처리중';
+ case 'waiting':
+ return '대기';
+ case 'inactive':
+ return '휴면';
+ case 'active':
+ return '활성';
+ case 'paused':
+ return '일시정지';
+ case 'completed':
+ return '완료';
+ default:
+ return status;
+ }
+}
+
+function formatDuration(value: number | null): string {
+ if (value === null) return '-';
+ const seconds = Math.floor(value / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ return `${hours}h ${minutes % 60}m`;
+}
+
+function Card({
+ label,
+ value,
+ hint,
+}: {
+ label: string;
+ value: string | number;
+ hint?: string;
+}) {
+ return (
+
+ {label}
+ {value}
+ {hint ? {hint} : null}
+
+ );
+}
+
+function EmptyState({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function ControlRail({ data }: { data: DashboardState }) {
+ const queue = data.snapshots.reduce(
+ (acc, snapshot) => {
+ for (const entry of snapshot.entries) {
+ acc.pendingTasks += entry.pendingTasks;
+ if (entry.pendingMessages) acc.pendingMessageRooms += 1;
+ }
+ return acc;
+ },
+ { pendingTasks: 0, pendingMessageRooms: 0 },
+ );
+
+ return (
+
+
+ agent heartbeat
+
+ {data.overview.rooms.active + data.overview.rooms.waiting}/
+ {data.overview.rooms.total}
+
+ processing + waiting rooms
+
+
+ work queue
+ {queue.pendingTasks}
+ {queue.pendingMessageRooms} rooms with pending messages
+
+
+ governance
+ read-only
+ no approvals, merges, or worker kills in MVP
+
+
+ audit safety
+ redacted
+ task prompts are preview-only
+
+
+ );
+}
+
+function ServicePanel({ overview }: { overview: DashboardOverview }) {
+ if (overview.services.length === 0) {
+ return (
+
+ 최근 status snapshot이 없어. 서비스가 아직 heartbeat를 안 쓴 상태일 수
+ 있어.
+
+ );
+ }
+
+ return (
+
+ {overview.services.map((service) => (
+
+
+ {service.agentType}
+
{service.assistantName}
+
+
+
+ heartbeat {formatDate(service.updatedAt)}
+
+
+
+
- service
+ - {service.serviceId}
+
+
+
- rooms
+ -
+ {service.activeRooms}/{service.totalRooms} active
+
+
+
+
- updated
+ - {formatDate(service.updatedAt)}
+
+
+
+ ))}
+
+ );
+}
+
+function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) {
+ const entries = snapshots.flatMap((snapshot) =>
+ snapshot.entries.map((entry) => ({
+ ...entry,
+ serviceId: snapshot.serviceId,
+ })),
+ );
+
+ if (entries.length === 0) {
+ return 표시할 룸 상태가 없어.;
+ }
+
+ return (
+
+
+
+
+ | room |
+ service |
+ agent |
+ status |
+ queue |
+
+
+
+ {entries.map((entry) => (
+
+ |
+ {entry.name}
+
+ {entry.folder} · {entry.jid}
+
+ |
+ {entry.serviceId} |
+ {entry.agentType} |
+
+
+ {statusLabel(entry.status)}
+
+ {formatDuration(entry.elapsedMs)}
+ |
+
+ {entry.pendingTasks} task{entry.pendingMessages ? ' · msg' : ''}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+function UsagePanel({ overview }: { overview: DashboardOverview }) {
+ if (overview.usage.rows.length === 0) {
+ return (
+
+ 사용량 snapshot이 없어. usage dashboard가 꺼져 있거나 아직 수집 전일 수
+ 있어.
+
+ );
+ }
+
+ return (
+
+ {overview.usage.rows.map((row) => (
+
+
+ {row.name}
+ fetched {formatDate(overview.usage.fetchedAt)}
+
+
+
5h
+
+
{formatPct(row.h5pct)}
+
{row.h5reset}
+
+
+
7d
+
+
{formatPct(row.d7pct)}
+
{row.d7reset}
+
+
+ ))}
+
+ );
+}
+
+function TaskPanel({ tasks }: { tasks: DashboardTask[] }) {
+ const sortedTasks = useMemo(
+ () =>
+ [...tasks].sort((a, b) => {
+ const statusRank = { active: 0, paused: 1, completed: 2 } as const;
+ const rankDelta = statusRank[a.status] - statusRank[b.status];
+ if (rankDelta !== 0) return rankDelta;
+ return (a.nextRun ?? a.createdAt).localeCompare(
+ b.nextRun ?? b.createdAt,
+ );
+ }),
+ [tasks],
+ );
+
+ if (sortedTasks.length === 0) {
+ return 등록된 scheduled task가 없어.;
+ }
+
+ return (
+
+
+
+
+ | task |
+ status |
+ schedule |
+ next |
+ last |
+
+
+
+ {sortedTasks.map((task) => (
+
+ |
+ {task.isWatcher ? 'CI Watch' : task.id}
+ {task.promptPreview || '(empty prompt preview)'}
+
+ {task.groupFolder} · {task.promptLength} chars
+
+ |
+
+
+ {statusLabel(task.status)}
+
+ {task.suspendedUntil ? (
+ until {formatDate(task.suspendedUntil)}
+ ) : null}
+ |
+
+ {task.scheduleType} · {task.scheduleValue}
+ {task.contextMode}
+ |
+ {formatDate(task.nextRun)} |
+
+ {formatDate(task.lastRun)}
+ {task.lastResult ? {task.lastResult} : null}
+ |
+
+ ))}
+
+
+
+ );
+}
+
+function App() {
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ async function refresh(showSpinner = false) {
+ if (showSpinner) setRefreshing(true);
+ try {
+ const nextData = await fetchDashboardData();
+ setData(nextData);
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }
+
+ useEffect(() => {
+ void refresh();
+ const id = window.setInterval(() => {
+ void refresh();
+ }, REFRESH_INTERVAL_MS);
+ return () => window.clearInterval(id);
+ }, []);
+
+ if (loading && !data) {
+ return (
+ EJClaw Dashboard 불러오는 중...
+ );
+ }
+
+ return (
+
+
+
+ {error ? (
+
+ ) : null}
+
+ {data ? (
+ <>
+
+
+
+
+
+
+
Agent Heartbeats
+ 최근 heartbeat 기준
+
+
+
+
+
+
+
+
Cost & Quota
+ 5h / 7d usage snapshot
+
+
+
+
+
+
Rooms & Queues
+ processing / waiting / inactive
+
+
+
+
+
+
+
+
Scheduled Work
+ watchers, heartbeats, redacted prompt previews
+
+
+
+ >
+ ) : null}
+
+ );
+}
+
+export default App;
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts
new file mode 100644
index 0000000..031eec8
--- /dev/null
+++ b/apps/dashboard/src/api.ts
@@ -0,0 +1,100 @@
+export interface DashboardOverview {
+ generatedAt: string;
+ services: Array<{
+ serviceId: string;
+ assistantName: string;
+ agentType: string;
+ 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: Array<{
+ name: string;
+ h5pct: number;
+ h5reset: string;
+ d7pct: number;
+ d7reset: string;
+ }>;
+ fetchedAt: string | null;
+ };
+}
+
+export interface StatusSnapshot {
+ serviceId: string;
+ agentType: string;
+ assistantName: string;
+ updatedAt: string;
+ entries: Array<{
+ jid: string;
+ name: string;
+ folder: string;
+ agentType: string;
+ status: 'processing' | 'waiting' | 'inactive';
+ elapsedMs: number | null;
+ pendingMessages: boolean;
+ pendingTasks: number;
+ }>;
+}
+
+export interface DashboardTask {
+ id: string;
+ groupFolder: string;
+ chatJid: string;
+ agentType: string | null;
+ ciProvider: string | null;
+ ciMetadata: string | null;
+ scheduleType: string;
+ scheduleValue: string;
+ contextMode: string;
+ nextRun: string | null;
+ lastRun: string | null;
+ lastResult: string | null;
+ status: 'active' | 'paused' | 'completed';
+ suspendedUntil: string | null;
+ createdAt: string;
+ promptPreview: string;
+ promptLength: number;
+ isWatcher: boolean;
+}
+
+async function fetchJson(path: string): Promise {
+ const response = await fetch(path, {
+ headers: { accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error(`${path} failed: ${response.status}`);
+ }
+ return (await response.json()) as T;
+}
+
+export async function fetchDashboardData(): Promise<{
+ overview: DashboardOverview;
+ snapshots: StatusSnapshot[];
+ tasks: DashboardTask[];
+}> {
+ const [overview, snapshots, tasks] = await Promise.all([
+ fetchJson('/api/overview'),
+ fetchJson('/api/status-snapshots'),
+ fetchJson('/api/tasks'),
+ ]);
+
+ return { overview, snapshots, tasks };
+}
diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx
new file mode 100644
index 0000000..f0becbb
--- /dev/null
+++ b/apps/dashboard/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+
+import App from './App';
+
+const root = document.getElementById('root');
+
+if (!root) {
+ throw new Error('Dashboard root element was not found');
+}
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
new file mode 100644
index 0000000..176b1a7
--- /dev/null
+++ b/apps/dashboard/src/styles.css
@@ -0,0 +1,506 @@
+:root {
+ color-scheme: light;
+ --bg: #f3efe4;
+ --bg-ink: #1c211c;
+ --panel: rgba(255, 252, 242, 0.9);
+ --panel-solid: #fffaf0;
+ --panel-border: rgba(43, 55, 38, 0.16);
+ --muted: #6d735f;
+ --accent: #bf5f2c;
+ --accent-strong: #8f351b;
+ --green: #3f7f51;
+ --yellow: #b58a22;
+ --red: #b74734;
+ --shadow: 0 24px 70px rgba(44, 39, 25, 0.18);
+ font-family:
+ 'Avenir Next', 'Segoe UI Variable', 'Noto Sans KR', ui-sans-serif,
+ sans-serif;
+ background: var(--bg);
+ color: var(--bg-ink);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ background:
+ radial-gradient(
+ circle at 18% 12%,
+ rgba(214, 127, 58, 0.22),
+ transparent 28rem
+ ),
+ radial-gradient(
+ circle at 88% 8%,
+ rgba(74, 123, 87, 0.18),
+ transparent 24rem
+ ),
+ linear-gradient(135deg, #f4ead5 0%, #edf1df 46%, #f5f0e5 100%);
+}
+
+button,
+input,
+table {
+ font: inherit;
+}
+
+.shell {
+ width: min(1480px, calc(100% - 40px));
+ margin: 0 auto;
+ padding: 34px 0 56px;
+}
+
+.shell::before {
+ position: fixed;
+ inset: 0;
+ z-index: -1;
+ pointer-events: none;
+ content: '';
+ background-image:
+ linear-gradient(rgba(28, 33, 28, 0.055) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(28, 33, 28, 0.045) 1px, transparent 1px);
+ background-size: 44px 44px;
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), transparent 82%);
+}
+
+.loading {
+ display: grid;
+ min-height: 100vh;
+ place-items: center;
+ font-size: clamp(24px, 5vw, 54px);
+ font-weight: 900;
+ letter-spacing: -0.05em;
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 28px;
+ align-items: end;
+ padding: clamp(28px, 4vw, 54px);
+ overflow: hidden;
+ border: 1px solid var(--panel-border);
+ border-radius: 34px;
+ background:
+ linear-gradient(
+ 128deg,
+ rgba(255, 250, 240, 0.96),
+ rgba(255, 250, 240, 0.76)
+ ),
+ linear-gradient(45deg, rgba(191, 95, 44, 0.12), rgba(63, 127, 81, 0.1));
+ box-shadow: var(--shadow);
+}
+
+.hero h1 {
+ max-width: 880px;
+ margin: 10px 0 12px;
+ font-family: 'Georgia', 'Noto Serif KR', serif;
+ font-size: clamp(42px, 8vw, 108px);
+ line-height: 0.92;
+ letter-spacing: -0.08em;
+}
+
+.hero p {
+ max-width: 720px;
+ margin: 0;
+ color: var(--muted);
+ font-size: clamp(16px, 2vw, 22px);
+ line-height: 1.55;
+}
+
+.eyebrow {
+ color: var(--accent-strong);
+ font-size: 12px;
+ font-weight: 850;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+button {
+ min-height: 48px;
+ padding: 0 22px;
+ border: 0;
+ border-radius: 999px;
+ color: #fffaf0;
+ background: linear-gradient(135deg, var(--accent), var(--accent-strong));
+ box-shadow: 0 14px 30px rgba(143, 53, 27, 0.24);
+ cursor: pointer;
+}
+
+button:disabled {
+ cursor: wait;
+ opacity: 0.62;
+}
+
+.error-card,
+.card,
+.panel {
+ border: 1px solid var(--panel-border);
+ background: var(--panel);
+ box-shadow: 0 12px 44px rgba(44, 39, 25, 0.09);
+ backdrop-filter: blur(14px);
+}
+
+.error-card {
+ margin-top: 18px;
+ padding: 18px 20px;
+ border-color: rgba(183, 71, 52, 0.35);
+ border-radius: 22px;
+ color: #7c2518;
+ background: rgba(255, 235, 225, 0.82);
+ font-weight: 800;
+}
+
+.metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+ margin: 22px 0;
+}
+
+.control-rail {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1px;
+ margin: 18px 0 22px;
+ overflow: hidden;
+ border: 1px solid var(--panel-border);
+ border-radius: 28px;
+ background: rgba(43, 55, 38, 0.12);
+ box-shadow: 0 18px 50px rgba(44, 39, 25, 0.08);
+}
+
+.control-rail div {
+ display: grid;
+ gap: 8px;
+ min-height: 116px;
+ padding: 20px;
+ background:
+ linear-gradient(
+ 145deg,
+ rgba(255, 250, 240, 0.9),
+ rgba(255, 250, 240, 0.68)
+ ),
+ radial-gradient(
+ circle at 18% 18%,
+ rgba(191, 95, 44, 0.1),
+ transparent 16rem
+ );
+}
+
+.control-rail strong {
+ font-size: clamp(22px, 3vw, 38px);
+ line-height: 1;
+ letter-spacing: -0.05em;
+}
+
+.control-rail small {
+ color: var(--muted);
+ line-height: 1.35;
+}
+
+.metric-card {
+ display: grid;
+ gap: 8px;
+ min-height: 142px;
+ padding: 22px;
+ border-radius: 26px;
+}
+
+.metric-card span,
+.metric-card small,
+td span,
+td small,
+dd,
+.panel-title span,
+.usage-title span,
+.bar-line small {
+ color: var(--muted);
+}
+
+.metric-card span {
+ font-weight: 850;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.metric-card strong {
+ font-size: clamp(36px, 5vw, 66px);
+ line-height: 0.9;
+ letter-spacing: -0.07em;
+}
+
+.panel {
+ margin-top: 18px;
+ padding: 24px;
+ border-radius: 30px;
+}
+
+.split-panel {
+ display: grid;
+ grid-template-columns: minmax(340px, 0.82fr) minmax(0, 1.18fr);
+ gap: 22px;
+}
+
+.panel-title {
+ display: flex;
+ gap: 16px;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 18px;
+}
+
+.panel-title h2 {
+ margin: 0;
+ font-family: 'Georgia', 'Noto Serif KR', serif;
+ font-size: clamp(26px, 3vw, 40px);
+ letter-spacing: -0.05em;
+}
+
+.service-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
+ gap: 14px;
+}
+
+.service-card {
+ display: grid;
+ gap: 18px;
+ padding: 20px;
+ border-radius: 24px;
+ background: rgba(255, 250, 240, 0.72);
+}
+
+.service-card h3 {
+ margin: 6px 0 0;
+ font-size: 26px;
+ letter-spacing: -0.04em;
+}
+
+.heartbeat-line {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ color: var(--muted);
+}
+
+.heartbeat-line span {
+ width: 10px;
+ height: 10px;
+ border-radius: 999px;
+ background: var(--green);
+ box-shadow: 0 0 0 7px rgba(63, 127, 81, 0.13);
+}
+
+dl {
+ display: grid;
+ gap: 10px;
+ margin: 0;
+}
+
+dl div {
+ display: flex;
+ gap: 12px;
+ justify-content: space-between;
+}
+
+dt {
+ color: var(--muted);
+ font-weight: 750;
+}
+
+dd {
+ margin: 0;
+ text-align: right;
+}
+
+.table-wrap {
+ overflow: auto;
+ border: 1px solid rgba(43, 55, 38, 0.1);
+ border-radius: 22px;
+ background: rgba(255, 250, 240, 0.56);
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ min-width: 720px;
+}
+
+th,
+td {
+ padding: 16px;
+ border-bottom: 1px solid rgba(43, 55, 38, 0.1);
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ color: var(--muted);
+ font-size: 12px;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ background: rgba(255, 250, 240, 0.7);
+}
+
+tr:last-child td {
+ border-bottom: 0;
+}
+
+td strong,
+td span,
+td small {
+ display: block;
+}
+
+td strong {
+ margin-bottom: 4px;
+}
+
+td small {
+ margin-top: 4px;
+ max-width: 480px;
+ overflow-wrap: anywhere;
+}
+
+.pill {
+ display: inline-flex;
+ width: fit-content;
+ align-items: center;
+ padding: 5px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 850;
+}
+
+.pill-processing,
+.pill-active {
+ color: #173e23;
+ background: rgba(63, 127, 81, 0.18);
+}
+
+.pill-waiting,
+.pill-paused {
+ color: #6e4a05;
+ background: rgba(181, 138, 34, 0.2);
+}
+
+.pill-inactive,
+.pill-completed {
+ color: #625d52;
+ background: rgba(109, 115, 95, 0.16);
+}
+
+.usage-list {
+ display: grid;
+ gap: 14px;
+}
+
+.usage-row {
+ padding: 18px;
+ border: 1px solid rgba(43, 55, 38, 0.1);
+ border-radius: 22px;
+ background: rgba(255, 250, 240, 0.62);
+}
+
+.usage-title {
+ display: flex;
+ gap: 10px;
+ justify-content: space-between;
+ margin-bottom: 14px;
+}
+
+.bar-line {
+ display: grid;
+ grid-template-columns: 34px 1fr 54px minmax(60px, auto);
+ gap: 10px;
+ align-items: center;
+ margin-top: 9px;
+}
+
+progress {
+ width: 100%;
+ height: 12px;
+ overflow: hidden;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(43, 55, 38, 0.1);
+}
+
+progress::-webkit-progress-bar {
+ background: rgba(43, 55, 38, 0.1);
+}
+
+progress::-webkit-progress-value {
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--green), var(--accent));
+}
+
+progress::-moz-progress-bar {
+ border-radius: 999px;
+ background: linear-gradient(90deg, var(--green), var(--accent));
+}
+
+.empty-state {
+ padding: 24px;
+ border: 1px dashed rgba(43, 55, 38, 0.2);
+ border-radius: 22px;
+ color: var(--muted);
+ background: rgba(255, 250, 240, 0.45);
+}
+
+@media (max-width: 980px) {
+ .hero,
+ .split-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .hero {
+ align-items: start;
+ }
+
+ .metrics-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .control-rail {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 640px) {
+ .shell {
+ width: min(100% - 24px, 1480px);
+ padding: 18px 0 34px;
+ }
+
+ .hero,
+ .panel {
+ border-radius: 22px;
+ padding: 20px;
+ }
+
+ .metrics-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .control-rail {
+ grid-template-columns: 1fr;
+ }
+
+ .panel-title,
+ .usage-title {
+ display: grid;
+ }
+
+ .bar-line {
+ grid-template-columns: 28px 1fr 44px;
+ }
+
+ .bar-line small {
+ grid-column: 2 / -1;
+ }
+}
diff --git a/apps/dashboard/src/vite-env.d.ts b/apps/dashboard/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/apps/dashboard/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json
new file mode 100644
index 0000000..596336d
--- /dev/null
+++ b/apps/dashboard/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"],
+ "references": []
+}
diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts
new file mode 100644
index 0000000..2f02257
--- /dev/null
+++ b/apps/dashboard/vite.config.ts
@@ -0,0 +1,18 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ root: __dirname,
+ plugins: [react()],
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+ server: {
+ host: '127.0.0.1',
+ port: 5174,
+ proxy: {
+ '/api': 'http://127.0.0.1:8734',
+ },
+ },
+});
diff --git a/bun.lock b/bun.lock
index c559ad9..d9060f2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -11,6 +11,8 @@
"ejclaw-runners-shared": "file:./runners/shared",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
"yaml": "^2.8.2",
"zod": "^4.3.6",
},
@@ -18,11 +20,15 @@
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.11",
"@types/node": "^22.10.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"better-sqlite3": "^12.8.0",
"husky": "^9.1.7",
"prettier": "^3.8.1",
"typescript": "^5.7.0",
+ "vite": "^8.0.10",
"vitest": "^4.0.18",
},
},
@@ -50,6 +56,12 @@
"@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
+ "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
+
+ "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+
+ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
+
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -112,8 +124,44 @@
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
+ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
+
+ "@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
+
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.17", "", { "os": "android", "cpu": "arm64" }, "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ=="],
+
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw=="],
+
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.17", "", { "os": "darwin", "cpu": "x64" }, "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw=="],
+
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.17", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw=="],
+
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm" }, "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ=="],
+
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q=="],
+
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "arm64" }, "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg=="],
+
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA=="],
+
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "s390x" }, "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA=="],
+
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA=="],
+
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.17", "", { "os": "linux", "cpu": "x64" }, "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw=="],
+
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.17", "", { "os": "none", "cpu": "arm64" }, "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA=="],
+
+ "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.17", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA=="],
+
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "arm64" }, "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA=="],
+
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.17", "", { "os": "win32", "cpu": "x64" }, "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg=="],
+
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
+
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="],
@@ -172,6 +220,8 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
"@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
@@ -184,8 +234,14 @@
"@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
+ "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
+
+ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
+
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="],
"@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="],
@@ -256,6 +312,8 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -392,6 +450,30 @@
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+ "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
+
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
+
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
+
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
+
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
+
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
+
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
+
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
+
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
+
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
@@ -454,7 +536,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="],
@@ -466,7 +548,7 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
- "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
+ "postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
@@ -488,6 +570,10 @@
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
+ "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
+
+ "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
+
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
@@ -496,6 +582,8 @@
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+ "rolldown": ["rolldown@1.0.0-rc.17", "", { "dependencies": { "@oxc-project/types": "=0.127.0", "@rolldown/pluginutils": "1.0.0-rc.17" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", "@rolldown/binding-darwin-x64": "1.0.0-rc.17", "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA=="],
+
"rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -506,6 +594,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
+
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -562,7 +652,7 @@
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
@@ -590,7 +680,7 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
- "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+ "vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
@@ -615,5 +705,15 @@
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
+
+ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
+
+ "vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+
+ "vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+
+ "vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+
+ "vitest/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
}
}
diff --git a/package.json b/package.json
index 1458599..a647dfe 100644
--- a/package.json
+++ b/package.json
@@ -7,9 +7,12 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
+ "dashboard:dev": "vite --config apps/dashboard/vite.config.ts",
+ "dashboard:build": "vite build --config apps/dashboard/vite.config.ts",
+ "dashboard:preview": "vite preview --config apps/dashboard/vite.config.ts",
"install:runners": "bun install --frozen-lockfile --cwd runners/shared && bun install --frozen-lockfile --cwd runners/agent-runner && bun install --frozen-lockfile --cwd runners/codex-runner",
"build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build",
- "build:all": "bun run build && bun run build:runners",
+ "build:all": "bun run build && bun run dashboard:build && bun run build:runners",
"build:runtime": "bun run build:all",
"verify:dist": "bash scripts/check-dist-fresh.sh",
"deploy": "git pull --ff-only && bun run build:all && bun run verify:dist && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw",
@@ -17,9 +20,9 @@
"dev": "bun --watch src/index.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
- "typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json",
- "format:fix": "prettier --write \"{src,setup,shared,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
- "format:check": "prettier --check \"{src,setup,shared,test,scripts}/**/*.{ts,js}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
+ "typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p apps/dashboard/tsconfig.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json",
+ "format:fix": "prettier --write \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
+ "format:check": "prettier --check \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
"check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all",
"prepare": "husky",
"setup": "bun setup/index.ts"
@@ -31,6 +34,8 @@
"ejclaw-runners-shared": "file:./runners/shared",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
+ "react": "^19.2.5",
+ "react-dom": "^19.2.5",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
@@ -38,11 +43,15 @@
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.11",
"@types/node": "^22.10.0",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"better-sqlite3": "^12.8.0",
"husky": "^9.1.7",
"prettier": "^3.8.1",
"typescript": "^5.7.0",
+ "vite": "^8.0.10",
"vitest": "^4.0.18"
},
"engines": {
diff --git a/setup/room-registration-state.test.ts b/setup/room-registration-state.test.ts
index 4bb5cba..54daedd 100644
--- a/setup/room-registration-state.test.ts
+++ b/setup/room-registration-state.test.ts
@@ -243,7 +243,12 @@ describe('room registration state', () => {
JSON.stringify({ last_timestamp: '1234' }),
);
- expect(detectRoomRegistrationState({ projectRoot: tempRoot })).toEqual({
+ expect(
+ detectRoomRegistrationState({
+ projectRoot: tempRoot,
+ dbPath: path.join(tempRoot, 'messages.db'),
+ }),
+ ).toEqual({
assignedRooms: 0,
roomsByOwnerAgent: {},
legacyRegisteredGroupRows: 0,
diff --git a/src/config.test.ts b/src/config.test.ts
index d9280a0..6d54501 100644
--- a/src/config.test.ts
+++ b/src/config.test.ts
@@ -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');
diff --git a/src/config.ts b/src/config.ts
index a739df3..8a113e0 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -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
diff --git a/src/config/load-config.ts b/src/config/load-config.ts
index 76f910c..cee46da 100644
--- a/src/config/load-config.ts
+++ b/src/config/load-config.ts
@@ -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:
diff --git a/src/config/schema.ts b/src/config/schema.ts
index d5450f9..15afdf1 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -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;
diff --git a/src/index.ts b/src/index.ts
index 16831a9..a9dde28 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -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 {
// Graceful shutdown handlers
let leaseRecoveryTimer: ReturnType | null = null;
+ let webDashboardServer: ReturnType = null;
const shutdown = async (signal: string) => {
logger.info({ signal }, 'Shutdown signal received');
stopTokenRefreshLoop();
@@ -255,6 +258,8 @@ async function main(): Promise {
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 {
},
purgeOnStart: true,
});
+ webDashboardServer = startWebDashboardServer(WEB_DASHBOARD);
leaseRecoveryTimer = setInterval(() => {
const failover = getGlobalFailoverInfo();
diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts
new file mode 100644
index 0000000..99bc4a3
--- /dev/null
+++ b/src/web-dashboard-data.test.ts
@@ -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 {
+ 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=');
+ expect(sanitized.promptPreview).toContain('BOT_TOKEN=');
+ expect(sanitized.promptPreview).not.toContain(
+ 'sk-abcdefghijklmnopqrstuvwxyz123456',
+ );
+ expect(sanitized.promptPreview).not.toContain('plain-secret-value');
+ });
+});
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
new file mode 100644
index 0000000..87c14ec
--- /dev/null
+++ b/src/web-dashboard-data.ts
@@ -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=')
+ .replace(SECRET_VALUE_RE, '');
+}
+
+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,
+ },
+ };
+}
diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts
new file mode 100644
index 0000000..9d065a6
--- /dev/null
+++ b/src/web-dashboard-server.test.ts
@@ -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'),
+ '',
+ );
+ 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');
+ });
+});
diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts
new file mode 100644
index 0000000..ac2242c
--- /dev/null
+++ b/src/web-dashboard-server.ts
@@ -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 {
+ 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),
+ };
+}