Adds a disabled-by-default loopback web dashboard MVP with read-only control-plane views, prompt preview redaction, Vite React UI, and validation coverage.
445 lines
12 KiB
TypeScript
445 lines
12 KiB
TypeScript
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 (
|
|
<section className="card metric-card">
|
|
<span>{label}</span>
|
|
<strong>{value}</strong>
|
|
{hint ? <small>{hint}</small> : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function EmptyState({ children }: { children: ReactNode }) {
|
|
return <div className="empty-state">{children}</div>;
|
|
}
|
|
|
|
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 (
|
|
<section className="control-rail" aria-label="control plane summary">
|
|
<div>
|
|
<span className="eyebrow">agent heartbeat</span>
|
|
<strong>
|
|
{data.overview.rooms.active + data.overview.rooms.waiting}/
|
|
{data.overview.rooms.total}
|
|
</strong>
|
|
<small>processing + waiting rooms</small>
|
|
</div>
|
|
<div>
|
|
<span className="eyebrow">work queue</span>
|
|
<strong>{queue.pendingTasks}</strong>
|
|
<small>{queue.pendingMessageRooms} rooms with pending messages</small>
|
|
</div>
|
|
<div>
|
|
<span className="eyebrow">governance</span>
|
|
<strong>read-only</strong>
|
|
<small>no approvals, merges, or worker kills in MVP</small>
|
|
</div>
|
|
<div>
|
|
<span className="eyebrow">audit safety</span>
|
|
<strong>redacted</strong>
|
|
<small>task prompts are preview-only</small>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function ServicePanel({ overview }: { overview: DashboardOverview }) {
|
|
if (overview.services.length === 0) {
|
|
return (
|
|
<EmptyState>
|
|
최근 status snapshot이 없어. 서비스가 아직 heartbeat를 안 쓴 상태일 수
|
|
있어.
|
|
</EmptyState>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="service-grid">
|
|
{overview.services.map((service) => (
|
|
<section className="card service-card" key={service.serviceId}>
|
|
<div>
|
|
<span className="eyebrow">{service.agentType}</span>
|
|
<h3>{service.assistantName}</h3>
|
|
</div>
|
|
<div className="heartbeat-line">
|
|
<span />
|
|
<small>heartbeat {formatDate(service.updatedAt)}</small>
|
|
</div>
|
|
<dl>
|
|
<div>
|
|
<dt>service</dt>
|
|
<dd>{service.serviceId}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>rooms</dt>
|
|
<dd>
|
|
{service.activeRooms}/{service.totalRooms} active
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt>updated</dt>
|
|
<dd>{formatDate(service.updatedAt)}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) {
|
|
const entries = snapshots.flatMap((snapshot) =>
|
|
snapshot.entries.map((entry) => ({
|
|
...entry,
|
|
serviceId: snapshot.serviceId,
|
|
})),
|
|
);
|
|
|
|
if (entries.length === 0) {
|
|
return <EmptyState>표시할 룸 상태가 없어.</EmptyState>;
|
|
}
|
|
|
|
return (
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>room</th>
|
|
<th>service</th>
|
|
<th>agent</th>
|
|
<th>status</th>
|
|
<th>queue</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{entries.map((entry) => (
|
|
<tr key={`${entry.serviceId}:${entry.jid}`}>
|
|
<td>
|
|
<strong>{entry.name}</strong>
|
|
<span>
|
|
{entry.folder} · {entry.jid}
|
|
</span>
|
|
</td>
|
|
<td>{entry.serviceId}</td>
|
|
<td>{entry.agentType}</td>
|
|
<td>
|
|
<span className={`pill pill-${entry.status}`}>
|
|
{statusLabel(entry.status)}
|
|
</span>
|
|
<small>{formatDuration(entry.elapsedMs)}</small>
|
|
</td>
|
|
<td>
|
|
{entry.pendingTasks} task{entry.pendingMessages ? ' · msg' : ''}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UsagePanel({ overview }: { overview: DashboardOverview }) {
|
|
if (overview.usage.rows.length === 0) {
|
|
return (
|
|
<EmptyState>
|
|
사용량 snapshot이 없어. usage dashboard가 꺼져 있거나 아직 수집 전일 수
|
|
있어.
|
|
</EmptyState>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="usage-list">
|
|
{overview.usage.rows.map((row) => (
|
|
<section className="usage-row" key={row.name}>
|
|
<div className="usage-title">
|
|
<strong>{row.name}</strong>
|
|
<span>fetched {formatDate(overview.usage.fetchedAt)}</span>
|
|
</div>
|
|
<div className="bar-line">
|
|
<span>5h</span>
|
|
<progress max={100} value={row.h5pct} />
|
|
<strong>{formatPct(row.h5pct)}</strong>
|
|
<small>{row.h5reset}</small>
|
|
</div>
|
|
<div className="bar-line">
|
|
<span>7d</span>
|
|
<progress max={100} value={row.d7pct} />
|
|
<strong>{formatPct(row.d7pct)}</strong>
|
|
<small>{row.d7reset}</small>
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 <EmptyState>등록된 scheduled task가 없어.</EmptyState>;
|
|
}
|
|
|
|
return (
|
|
<div className="table-wrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>task</th>
|
|
<th>status</th>
|
|
<th>schedule</th>
|
|
<th>next</th>
|
|
<th>last</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedTasks.map((task) => (
|
|
<tr key={task.id}>
|
|
<td>
|
|
<strong>{task.isWatcher ? 'CI Watch' : task.id}</strong>
|
|
<span>{task.promptPreview || '(empty prompt preview)'}</span>
|
|
<small>
|
|
{task.groupFolder} · {task.promptLength} chars
|
|
</small>
|
|
</td>
|
|
<td>
|
|
<span className={`pill pill-${task.status}`}>
|
|
{statusLabel(task.status)}
|
|
</span>
|
|
{task.suspendedUntil ? (
|
|
<small>until {formatDate(task.suspendedUntil)}</small>
|
|
) : null}
|
|
</td>
|
|
<td>
|
|
{task.scheduleType} · {task.scheduleValue}
|
|
<small>{task.contextMode}</small>
|
|
</td>
|
|
<td>{formatDate(task.nextRun)}</td>
|
|
<td>
|
|
{formatDate(task.lastRun)}
|
|
{task.lastResult ? <small>{task.lastResult}</small> : null}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const [data, setData] = useState<DashboardState | null>(null);
|
|
const [error, setError] = useState<string | null>(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 (
|
|
<main className="shell loading">EJClaw Dashboard 불러오는 중...</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="shell">
|
|
<header className="hero">
|
|
<div>
|
|
<span className="eyebrow">EJClaw Control Plane · read-only MVP</span>
|
|
<h1>Agent factory control room</h1>
|
|
<p>
|
|
Discord는 pager와 승인 호출로 남기고, 여기서는 agent heartbeat, room
|
|
queue, scheduled work, quota, audit preview를 한 화면에서 본다.
|
|
</p>
|
|
</div>
|
|
<button disabled={refreshing} onClick={() => void refresh(true)}>
|
|
{refreshing ? '새로고침 중' : '새로고침'}
|
|
</button>
|
|
</header>
|
|
|
|
{error ? (
|
|
<section className="error-card">API 오류: {error}</section>
|
|
) : null}
|
|
|
|
{data ? (
|
|
<>
|
|
<ControlRail data={data} />
|
|
|
|
<section className="metrics-grid">
|
|
<Card
|
|
label="agents"
|
|
value={data.overview.services.length}
|
|
hint={formatDate(data.overview.generatedAt)}
|
|
/>
|
|
<Card
|
|
label="rooms"
|
|
value={data.overview.rooms.total}
|
|
hint={`${data.overview.rooms.active} processing · ${data.overview.rooms.waiting} waiting`}
|
|
/>
|
|
<Card
|
|
label="tasks"
|
|
value={data.overview.tasks.total}
|
|
hint={`${data.overview.tasks.active} active · ${data.overview.tasks.paused} paused`}
|
|
/>
|
|
<Card
|
|
label="CI watchers"
|
|
value={data.overview.tasks.watchers.active}
|
|
hint={`${data.overview.tasks.watchers.paused} paused · ${data.overview.tasks.watchers.completed} done`}
|
|
/>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel-title">
|
|
<h2>Agent Heartbeats</h2>
|
|
<span>최근 heartbeat 기준</span>
|
|
</div>
|
|
<ServicePanel overview={data.overview} />
|
|
</section>
|
|
|
|
<section className="panel split-panel">
|
|
<div>
|
|
<div className="panel-title">
|
|
<h2>Cost & Quota</h2>
|
|
<span>5h / 7d usage snapshot</span>
|
|
</div>
|
|
<UsagePanel overview={data.overview} />
|
|
</div>
|
|
<div>
|
|
<div className="panel-title">
|
|
<h2>Rooms & Queues</h2>
|
|
<span>processing / waiting / inactive</span>
|
|
</div>
|
|
<RoomPanel snapshots={data.snapshots} />
|
|
</div>
|
|
</section>
|
|
|
|
<section className="panel">
|
|
<div className="panel-title">
|
|
<h2>Scheduled Work</h2>
|
|
<span>watchers, heartbeats, redacted prompt previews</span>
|
|
</div>
|
|
<TaskPanel tasks={data.tasks} />
|
|
</section>
|
|
</>
|
|
) : null}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default App;
|