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 ( +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
roomserviceagentstatusqueue
+ {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 ( +
+ + + + + + + + + + + + {sortedTasks.map((task) => ( + + + + + + + + ))} + +
taskstatusschedulenextlast
+ {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 ( +
+
+
+ EJClaw Control Plane · read-only MVP +

Agent factory control room

+

+ Discord는 pager와 승인 호출로 남기고, 여기서는 agent heartbeat, room + queue, scheduled work, quota, audit preview를 한 화면에서 본다. +

+
+ +
+ + {error ? ( +
API 오류: {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), + }; +}