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;