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) => ( ))}
room service agent status queue
{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) => ( ))}
task status schedule next last
{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;