diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index 27d48e5..7fd51b0 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -6,6 +6,16 @@ import {
type StatusSnapshot,
fetchDashboardData,
} from './api';
+import {
+ LOCALES,
+ isLocale,
+ languageNames,
+ localeTags,
+ matchLocale,
+ messages,
+ type Locale,
+ type Messages,
+} from './i18n';
import './styles.css';
interface DashboardState {
@@ -14,13 +24,37 @@ interface DashboardState {
tasks: DashboardTask[];
}
-const REFRESH_INTERVAL_MS = 15_000;
+type UsageRow = DashboardOverview['usage']['rows'][number];
+type RiskLevel = 'ok' | 'warn' | 'critical';
+type UsageGroup = 'primary' | 'codex';
-function formatDate(value: string | null | undefined): string {
+const REFRESH_INTERVAL_MS = 15_000;
+const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
+
+function readInitialLocale(): Locale {
+ const stored =
+ typeof window === 'undefined'
+ ? null
+ : window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ if (isLocale(stored)) return stored;
+
+ const languages =
+ typeof navigator === 'undefined'
+ ? []
+ : [...(navigator.languages || []), navigator.language];
+ for (const language of languages) {
+ const matched = matchLocale(language);
+ if (matched) return matched;
+ }
+
+ return 'ko';
+}
+
+function formatDate(value: string | null | undefined, locale: Locale): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
- return new Intl.DateTimeFormat('ko-KR', {
+ return new Intl.DateTimeFormat(localeTags[locale], {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
@@ -30,61 +64,261 @@ function formatDate(value: string | null | undefined): string {
}
function formatPct(value: number): string {
+ if (value < 0) return '-';
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 usagePeak(row: UsageRow): number {
+ return Math.max(row.h5pct, row.d7pct);
}
-function formatDuration(value: number | null): string {
+function usageRiskLevel(row: UsageRow): RiskLevel {
+ const peak = usagePeak(row);
+ if (peak >= 85) return 'critical';
+ if (peak >= 65) return 'warn';
+ return 'ok';
+}
+
+function usageGroup(row: UsageRow): UsageGroup {
+ return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
+}
+
+function statusLabel(status: string, t: Messages): string {
+ if (status in t.status) return t.status[status as keyof Messages['status']];
+ return status;
+}
+
+function formatDuration(value: number | null, t: Messages): string {
if (value === null) return '-';
const seconds = Math.floor(value / 1000);
- if (seconds < 60) return `${seconds}s`;
+ if (seconds < 60) return `${seconds}${t.units.second}`;
const minutes = Math.floor(seconds / 60);
- if (minutes < 60) return `${minutes}m`;
+ if (minutes < 60) return `${minutes}${t.units.minute}`;
const hours = Math.floor(minutes / 60);
- return `${hours}h ${minutes % 60}m`;
+ return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`;
}
-function Card({
- label,
- value,
- hint,
-}: {
- label: string;
- value: string | number;
- hint?: string;
-}) {
- return (
-
- {label}
- {value}
- {hint ? {hint} : null}
-
- );
+function queueLabel(
+ pendingTasks: number,
+ pendingMessages: boolean,
+ t: Messages,
+) {
+ const parts = [`${pendingTasks} ${t.units.task}`];
+ if (pendingMessages) parts.push(t.units.messageShort);
+ return parts.join(' · ');
+}
+
+function navItems(t: Messages) {
+ return [
+ { href: '#overview', label: t.nav.health },
+ { href: '#usage', label: t.nav.usage },
+ { href: '#rooms', label: t.nav.rooms },
+ { href: '#work', label: t.nav.scheduled },
+ ];
}
function EmptyState({ children }: { children: ReactNode }) {
return
{children}
;
}
-function ControlRail({ data }: { data: DashboardState }) {
+function LanguageSelector({
+ locale,
+ onLocaleChange,
+ t,
+}: {
+ locale: Locale;
+ onLocaleChange: (locale: Locale) => void;
+ t: Messages;
+}) {
+ return (
+
+ );
+}
+
+function LoadingSkeleton({ t }: { t: Messages }) {
+ return (
+
+
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function SideRail({
+ lastRefreshed,
+ locale,
+ onLocaleChange,
+ onRefresh,
+ refreshing,
+ t,
+}: {
+ lastRefreshed: string | null;
+ locale: Locale;
+ onLocaleChange: (locale: Locale) => void;
+ onRefresh: () => void;
+ refreshing: boolean;
+ t: Messages;
+}) {
+ return (
+
+ );
+}
+
+function SectionNav({
+ drawerOpen,
+ lastRefreshed,
+ locale,
+ onCloseDrawer,
+ onLocaleChange,
+ onOpenDrawer,
+ refreshing,
+ onRefresh,
+ t,
+}: {
+ drawerOpen: boolean;
+ lastRefreshed: string | null;
+ locale: Locale;
+ onCloseDrawer: () => void;
+ onLocaleChange: (locale: Locale) => void;
+ onOpenDrawer: () => void;
+ refreshing: boolean;
+ onRefresh: () => void;
+ t: Messages;
+}) {
+ return (
+ <>
+
+
+ {drawerOpen ? (
+ <>
+
+
+ >
+ ) : null}
+ >
+ );
+}
+
+function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
const queue = data.snapshots.reduce(
(acc, snapshot) => {
for (const entry of snapshot.entries) {
@@ -97,42 +331,49 @@ function ControlRail({ data }: { data: DashboardState }) {
);
return (
-
+
- agent heartbeat
+ {t.metrics.rooms}
{data.overview.rooms.active + data.overview.rooms.waiting}/
{data.overview.rooms.total}
- processing + waiting rooms
+ {t.control.activeRooms}
- work queue
+ {t.control.queue}
{queue.pendingTasks}
- {queue.pendingMessageRooms} rooms with pending messages
+
+ {queue.pendingMessageRooms} {t.control.pendingRooms}
+
- governance
- read-only
- no approvals, merges, or worker kills in MVP
+ {t.metrics.agents}
+ {data.overview.services.length}
+ {t.panels.heartbeat}
- audit safety
- redacted
- task prompts are preview-only
+ {t.metrics.ciWatchers}
+ {data.overview.tasks.watchers.active}
+
+ {data.overview.tasks.watchers.paused} {t.status.paused}
+
);
}
-function ServicePanel({ overview }: { overview: DashboardOverview }) {
+function ServicePanel({
+ overview,
+ locale,
+ t,
+}: {
+ overview: DashboardOverview;
+ locale: Locale;
+ t: Messages;
+}) {
if (overview.services.length === 0) {
- return (
-
- 최근 status snapshot이 없어. 서비스가 아직 heartbeat를 안 쓴 상태일 수
- 있어.
-
- );
+ return {t.service.empty};
}
return (
@@ -145,22 +386,24 @@ function ServicePanel({ overview }: { overview: DashboardOverview }) {
- heartbeat {formatDate(service.updatedAt)}
+
+ {t.service.heartbeat} {formatDate(service.updatedAt, locale)}
+
-
- service
+ - {t.service.service}
- {service.serviceId}
-
- rooms
+ - {t.service.rooms}
-
- {service.activeRooms}/{service.totalRooms} active
+ {service.activeRooms}/{service.totalRooms} {t.status.active}
-
- updated
- - {formatDate(service.updatedAt)}
+ - {t.service.updated}
+ - {formatDate(service.updatedAt, locale)}
@@ -169,7 +412,13 @@ function ServicePanel({ overview }: { overview: DashboardOverview }) {
);
}
-function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) {
+function RoomPanel({
+ snapshots,
+ t,
+}: {
+ snapshots: StatusSnapshot[];
+ t: Messages;
+}) {
const entries = snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => ({
...entry,
@@ -178,86 +427,225 @@ function RoomPanel({ snapshots }: { snapshots: StatusSnapshot[] }) {
);
if (entries.length === 0) {
- return 표시할 룸 상태가 없어.;
+ return {t.rooms.empty};
}
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' : ''}
- |
+ <>
+
+
+
+
+ | {t.rooms.room} |
+ {t.rooms.service} |
+ {t.rooms.agent} |
+ {t.rooms.status} |
+ {t.rooms.queue} |
- ))}
-
-
+
+
+ {entries.map((entry) => (
+
+ |
+ {entry.name}
+
+ {entry.folder} · {entry.jid}
+
+ |
+ {entry.serviceId} |
+ {entry.agentType} |
+
+
+ {statusLabel(entry.status, t)}
+
+ {formatDuration(entry.elapsedMs, t)}
+ |
+
+ {queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
+ |
+
+ ))}
+
+
+
+
+
+ {entries.map((entry) => (
+
+
+
+ {entry.name}
+ {entry.folder}
+
+
+ {statusLabel(entry.status, t)}
+
+
+
+
+ {t.rooms.queue}
+
+ {queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
+
+
+
+ {t.rooms.agent}
+ {entry.agentType}
+
+
+ {t.rooms.service}
+ {entry.serviceId}
+
+
+ {t.rooms.elapsed}
+ {formatDuration(entry.elapsedMs, t)}
+
+
+ {entry.jid}
+
+ ))}
+
+ >
+ );
+}
+
+function UsageMeter({
+ label,
+ pct,
+ reset,
+ rowName,
+ t,
+}: {
+ label: string;
+ pct: number;
+ reset: string;
+ rowName: string;
+ t: Messages;
+}) {
+ return (
+
+
+ {label}
+ {formatPct(pct)}
+
+
+
+ {t.usage.reset} {reset || '-'}
+
);
}
-function UsagePanel({ overview }: { overview: DashboardOverview }) {
- if (overview.usage.rows.length === 0) {
- return (
-
- 사용량 snapshot이 없어. usage dashboard가 꺼져 있거나 아직 수집 전일 수
- 있어.
-
- );
+function UsagePanel({
+ overview,
+ locale,
+ t,
+}: {
+ overview: DashboardOverview;
+ locale: Locale;
+ t: Messages;
+}) {
+ const rows = useMemo(() => [...overview.usage.rows], [overview.usage.rows]);
+ const watched = rows.filter((row) => usagePeak(row) >= 65).length;
+
+ if (rows.length === 0) {
+ return {t.usage.empty};
}
+ const highest = rows[0];
+ const groups = [
+ {
+ key: 'primary' as const,
+ label: t.usage.groupPrimary,
+ rows: rows.filter((row) => usageGroup(row) === 'primary'),
+ },
+ {
+ key: 'codex' as const,
+ label: t.usage.groupCodex,
+ rows: rows.filter((row) => usageGroup(row) === 'codex'),
+ },
+ ].filter((group) => group.rows.length > 0);
+
return (
-
- {overview.usage.rows.map((row) => (
-
-
-
{row.name}
-
fetched {formatDate(overview.usage.fetchedAt)}
+
+
+
+ {t.usage.highest}
+
+ {highest.name} · {formatPct(usagePeak(highest))}
+
+
+
+ {t.usage.watch}
+ {watched}
+
+
+ {t.usage.updated}
+ {formatDate(overview.usage.fetchedAt, locale)}
+
+
+
+
+
+ {t.usage.usage}
+ {t.usage.window5h}
+ {t.usage.window7d}
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+ {group.rows.map((row) => {
+ const risk = usageRiskLevel(row);
+ return (
+
+
+ {row.name}
+
+ {t.usage.risk[risk]}
+
+
+
+
+
+ );
+ })}
-
-
5h
-
-
{formatPct(row.h5pct)}
-
{row.h5reset}
-
-
-
7d
-
-
{formatPct(row.d7pct)}
-
{row.d7reset}
-
-
- ))}
+ ))}
+
);
}
-function TaskPanel({ tasks }: { tasks: DashboardTask[] }) {
+function TaskPanel({
+ tasks,
+ locale,
+ t,
+}: {
+ tasks: DashboardTask[];
+ locale: Locale;
+ t: Messages;
+}) {
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) => {
@@ -272,53 +660,97 @@ function TaskPanel({ tasks }: { tasks: DashboardTask[] }) {
);
if (sortedTasks.length === 0) {
- return
등록된 scheduled task가 없어.;
+ return
{t.tasks.empty};
}
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}
- |
+ <>
+
+
+
+
+ | {t.tasks.task} |
+ {t.tasks.status} |
+ {t.tasks.schedule} |
+ {t.tasks.next} |
+ {t.tasks.last} |
- ))}
-
-
-
+
+
+ {sortedTasks.map((task) => (
+
+ |
+ {task.isWatcher ? t.tasks.ciWatch : task.id}
+ {task.promptPreview || t.tasks.emptyPrompt}
+
+ {task.groupFolder} · {task.promptLength} {t.units.chars}
+
+ |
+
+
+ {statusLabel(task.status, t)}
+
+ {task.suspendedUntil ? (
+
+ {t.tasks.until} {formatDate(task.suspendedUntil, locale)}
+
+ ) : null}
+ |
+
+ {task.scheduleType} · {task.scheduleValue}
+ {task.contextMode}
+ |
+ {formatDate(task.nextRun, locale)} |
+
+ {formatDate(task.lastRun, locale)}
+ {task.lastResult ? {task.lastResult} : null}
+ |
+
+ ))}
+
+
+
+
+
+ {sortedTasks.map((task) => (
+
+
+
+ {task.isWatcher ? t.tasks.ciWatch : task.id}
+ {task.groupFolder}
+
+
+ {statusLabel(task.status, t)}
+
+
+ {task.promptPreview || t.tasks.emptyPrompt}
+
+
+ {t.tasks.schedule}
+ {task.scheduleType}
+
+
+ {t.tasks.next}
+ {formatDate(task.nextRun, locale)}
+
+
+ {t.tasks.last}
+ {formatDate(task.lastRun, locale)}
+
+
+ {t.tasks.context}
+ {task.contextMode}
+
+
+ {task.lastResult ? (
+
+ {t.tasks.lastResult}: {task.lastResult}
+
+ ) : null}
+
+ ))}
+
+ >
);
}
@@ -327,12 +759,22 @@ function App() {
const [error, setError] = useState
(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
+ const [lastRefreshed, setLastRefreshed] = useState(null);
+ const [drawerOpen, setDrawerOpen] = useState(false);
+ const [locale, setLocale] = useState(readInitialLocale);
+ const t = messages[locale];
+
+ function setDashboardLocale(nextLocale: Locale) {
+ setLocale(nextLocale);
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
+ }
async function refresh(showSpinner = false) {
if (showSpinner) setRefreshing(true);
try {
const nextData = await fetchDashboardData();
setData(nextData);
+ setLastRefreshed(new Date().toISOString());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -342,6 +784,10 @@ function App() {
}
}
+ useEffect(() => {
+ document.documentElement.lang = localeTags[locale];
+ }, [locale]);
+
useEffect(() => {
void refresh();
const id = window.setInterval(() => {
@@ -350,94 +796,94 @@ function App() {
return () => window.clearInterval(id);
}, []);
+ useEffect(() => {
+ if (!drawerOpen) return;
+
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === 'Escape') setDrawerOpen(false);
+ }
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [drawerOpen]);
+
if (loading && !data) {
- return (
- EJClaw Dashboard 불러오는 중...
- );
+ return ;
}
return (
-
-
+
+
void refresh(true)}
+ refreshing={refreshing}
+ t={t}
+ />
+
+ setDrawerOpen(false)}
+ onLocaleChange={setDashboardLocale}
+ onOpenDrawer={() => setDrawerOpen(true)}
+ onRefresh={() => void refresh(true)}
+ refreshing={refreshing}
+ t={t}
+ />
- {error ? (
-
- ) : null}
-
- {data ? (
- <>
-
-
-
-
-
-
-
+ {error ? (
+
+
+ {t.error.api}: {error}
+
+
+ ) : null}
-
-
-
Agent Heartbeats
- 최근 heartbeat 기준
-
-
-
-
-
-
+ {data ? (
+ <>
+
-
Cost & Quota
- 5h / 7d usage snapshot
+ {t.panels.usage}
+ {t.panels.usageWindow}
-
-
-
-
-
Rooms & Queues
- processing / waiting / inactive
-
-
-
-
+
+
-
-
-
Scheduled Work
- watchers, heartbeats, redacted prompt previews
-
-
-
- >
- ) : null}
-
+
+
+
+
+
{t.panels.health}
+ {t.panels.heartbeat}
+
+
+
+
+
+
+
{t.panels.rooms}
+ {t.panels.queue}
+
+
+
+
+
+
+
{t.panels.scheduled}
+ {t.panels.promptPreviews}
+
+
+
+ >
+ ) : null}
+
+
);
}
diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts
new file mode 100644
index 0000000..f249a02
--- /dev/null
+++ b/apps/dashboard/src/i18n.ts
@@ -0,0 +1,615 @@
+export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
+
+export type Locale = (typeof LOCALES)[number];
+
+export interface Messages {
+ app: {
+ loading: string;
+ };
+ actions: {
+ close: string;
+ refresh: string;
+ refreshing: string;
+ retry: string;
+ };
+ nav: {
+ aria: string;
+ drawerAria: string;
+ drawerNavAria: string;
+ menuOpen: string;
+ menuClose: string;
+ operations: string;
+ updated: string;
+ health: string;
+ usage: string;
+ rooms: string;
+ scheduled: string;
+ };
+ language: {
+ label: string;
+ };
+ error: {
+ api: string;
+ };
+ control: {
+ aria: string;
+ queue: string;
+ activeRooms: string;
+ pendingRooms: string;
+ };
+ metrics: {
+ agents: string;
+ rooms: string;
+ ciWatchers: string;
+ };
+ panels: {
+ health: string;
+ heartbeat: string;
+ usage: string;
+ usageWindow: string;
+ rooms: string;
+ queue: string;
+ scheduled: string;
+ promptPreviews: string;
+ };
+ service: {
+ empty: string;
+ heartbeat: string;
+ service: string;
+ rooms: string;
+ updated: string;
+ };
+ rooms: {
+ empty: string;
+ cardsAria: string;
+ room: string;
+ service: string;
+ agent: string;
+ status: string;
+ queue: string;
+ elapsed: string;
+ };
+ usage: {
+ empty: string;
+ highest: string;
+ watch: string;
+ updated: string;
+ peak: string;
+ reset: string;
+ usage: string;
+ window5h: string;
+ window7d: string;
+ groupPrimary: string;
+ groupCodex: string;
+ risk: {
+ ok: string;
+ warn: string;
+ critical: string;
+ };
+ };
+ tasks: {
+ empty: string;
+ cardsAria: string;
+ task: string;
+ status: string;
+ schedule: string;
+ next: string;
+ last: string;
+ context: string;
+ ciWatch: string;
+ emptyPrompt: string;
+ until: string;
+ lastResult: string;
+ };
+ status: {
+ processing: string;
+ waiting: string;
+ inactive: string;
+ active: string;
+ paused: string;
+ completed: string;
+ };
+ units: {
+ second: string;
+ minute: string;
+ hour: string;
+ task: string;
+ messageShort: string;
+ chars: string;
+ };
+}
+
+export const languageNames: Record = {
+ ko: '한국어',
+ en: 'English',
+ zh: '简体中文',
+ ja: '日本語',
+};
+
+export const localeTags: Record = {
+ ko: 'ko-KR',
+ en: 'en-US',
+ zh: 'zh-CN',
+ ja: 'ja-JP',
+};
+
+export const messages = {
+ ko: {
+ app: {
+ loading: '대시보드 로딩 중',
+ },
+ actions: {
+ close: '닫기',
+ refresh: '새로고침',
+ refreshing: '새로고침 중',
+ retry: '다시 시도',
+ },
+ nav: {
+ aria: '대시보드 섹션',
+ drawerAria: '대시보드 메뉴',
+ drawerNavAria: '대시보드 메뉴 섹션',
+ menuOpen: '메뉴 열기',
+ menuClose: '메뉴 닫기',
+ operations: '운영',
+ updated: '갱신',
+ health: '상태',
+ usage: '사용량',
+ rooms: '룸',
+ scheduled: '예약',
+ },
+ language: {
+ label: '언어',
+ },
+ error: {
+ api: 'API 오류',
+ },
+ control: {
+ aria: '컨트롤 플레인 요약',
+ queue: '작업 큐',
+ activeRooms: '처리 + 대기 룸',
+ pendingRooms: '메시지 대기 룸',
+ },
+ metrics: {
+ agents: '에이전트',
+ rooms: '룸',
+ ciWatchers: 'CI 감시',
+ },
+ panels: {
+ health: '상태',
+ heartbeat: '하트비트',
+ usage: '사용량',
+ usageWindow: '5시간 / 7일',
+ rooms: '룸',
+ queue: '큐',
+ scheduled: '예약',
+ promptPreviews: '프롬프트 미리보기',
+ },
+ service: {
+ empty: '하트비트 없음. 서비스 로그 확인.',
+ heartbeat: '하트비트',
+ service: '서비스',
+ rooms: '룸',
+ updated: '갱신',
+ },
+ rooms: {
+ empty: '룸 없음.',
+ cardsAria: '룸 상태 카드',
+ room: '룸',
+ service: '서비스',
+ agent: '에이전트',
+ status: '상태',
+ queue: '큐',
+ elapsed: '경과',
+ },
+ usage: {
+ empty: '사용량 스냅샷 없음. 수집기 확인.',
+ highest: '최고',
+ watch: '주의',
+ updated: '갱신',
+ peak: '피크',
+ reset: '리셋',
+ usage: '사용량',
+ window5h: '5시간',
+ window7d: '7일',
+ groupPrimary: 'Claude / Kimi',
+ groupCodex: 'Codex',
+ risk: {
+ ok: '여유',
+ warn: '주의',
+ critical: '위험',
+ },
+ },
+ tasks: {
+ empty: '예약 작업 없음.',
+ cardsAria: '예약 작업 카드',
+ task: '작업',
+ status: '상태',
+ schedule: '스케줄',
+ next: '다음',
+ last: '최근',
+ context: '컨텍스트',
+ ciWatch: 'CI 감시',
+ emptyPrompt: '(빈 미리보기)',
+ until: '까지',
+ lastResult: '최근 결과',
+ },
+ status: {
+ processing: '처리중',
+ waiting: '대기',
+ inactive: '휴면',
+ active: '활성',
+ paused: '일시정지',
+ completed: '완료',
+ },
+ units: {
+ second: '초',
+ minute: '분',
+ hour: '시간',
+ task: '작업',
+ messageShort: '메시지',
+ chars: '자',
+ },
+ },
+ en: {
+ app: {
+ loading: 'Loading dashboard',
+ },
+ actions: {
+ close: 'Close',
+ refresh: 'Refresh',
+ refreshing: 'Refreshing',
+ retry: 'Retry',
+ },
+ nav: {
+ aria: 'Dashboard sections',
+ drawerAria: 'Dashboard menu',
+ drawerNavAria: 'Dashboard menu sections',
+ menuOpen: 'Open menu',
+ menuClose: 'Close menu',
+ operations: 'Ops',
+ updated: 'Updated',
+ health: 'Health',
+ usage: 'Usage',
+ rooms: 'Rooms',
+ scheduled: 'Scheduled',
+ },
+ language: {
+ label: 'Language',
+ },
+ error: {
+ api: 'API error',
+ },
+ control: {
+ aria: 'Control plane summary',
+ queue: 'Work queue',
+ activeRooms: 'processing + waiting rooms',
+ pendingRooms: 'rooms with pending messages',
+ },
+ metrics: {
+ agents: 'agents',
+ rooms: 'rooms',
+ ciWatchers: 'CI watchers',
+ },
+ panels: {
+ health: 'Health',
+ heartbeat: 'Heartbeat',
+ usage: 'Usage',
+ usageWindow: '5h / 7d',
+ rooms: 'Rooms',
+ queue: 'Queue',
+ scheduled: 'Scheduled',
+ promptPreviews: 'Prompt previews',
+ },
+ service: {
+ empty: 'No heartbeat yet. Check service logs.',
+ heartbeat: 'heartbeat',
+ service: 'service',
+ rooms: 'rooms',
+ updated: 'updated',
+ },
+ rooms: {
+ empty: 'No rooms yet.',
+ cardsAria: 'Room status cards',
+ room: 'room',
+ service: 'service',
+ agent: 'agent',
+ status: 'status',
+ queue: 'queue',
+ elapsed: 'elapsed',
+ },
+ usage: {
+ empty: 'No usage snapshot. Check collector.',
+ highest: 'Highest',
+ watch: 'Watch',
+ updated: 'Updated',
+ peak: 'Peak',
+ reset: 'reset',
+ usage: 'usage',
+ window5h: '5h',
+ window7d: '7d',
+ groupPrimary: 'Claude / Kimi',
+ groupCodex: 'Codex',
+ risk: {
+ ok: 'Clear',
+ warn: 'Watch',
+ critical: 'Limit risk',
+ },
+ },
+ tasks: {
+ empty: 'No scheduled work.',
+ cardsAria: 'Scheduled task cards',
+ task: 'task',
+ status: 'status',
+ schedule: 'schedule',
+ next: 'next',
+ last: 'last',
+ context: 'context',
+ ciWatch: 'CI Watch',
+ emptyPrompt: '(empty preview)',
+ until: 'until',
+ lastResult: 'last result',
+ },
+ status: {
+ processing: 'processing',
+ waiting: 'waiting',
+ inactive: 'inactive',
+ active: 'active',
+ paused: 'paused',
+ completed: 'completed',
+ },
+ units: {
+ second: 's',
+ minute: 'm',
+ hour: 'h',
+ task: 'task',
+ messageShort: 'msg',
+ chars: 'chars',
+ },
+ },
+ zh: {
+ app: {
+ loading: '正在加载仪表盘',
+ },
+ actions: {
+ close: '关闭',
+ refresh: '刷新',
+ refreshing: '刷新中',
+ retry: '重试',
+ },
+ nav: {
+ aria: '仪表盘分区',
+ drawerAria: '仪表盘菜单',
+ drawerNavAria: '仪表盘菜单分区',
+ menuOpen: '打开菜单',
+ menuClose: '关闭菜单',
+ operations: '运营',
+ updated: '更新',
+ health: '健康',
+ usage: '用量',
+ rooms: '房间',
+ scheduled: '计划',
+ },
+ language: {
+ label: '语言',
+ },
+ error: {
+ api: 'API 错误',
+ },
+ control: {
+ aria: '控制平面摘要',
+ queue: '任务队列',
+ activeRooms: '处理中 + 等待房间',
+ pendingRooms: '有待处理消息的房间',
+ },
+ metrics: {
+ agents: '代理',
+ rooms: '房间',
+ ciWatchers: 'CI 监控',
+ },
+ panels: {
+ health: '健康',
+ heartbeat: '心跳',
+ usage: '用量',
+ usageWindow: '5小时 / 7天',
+ rooms: '房间',
+ queue: '队列',
+ scheduled: '计划',
+ promptPreviews: '提示预览',
+ },
+ service: {
+ empty: '暂无心跳。检查服务日志。',
+ heartbeat: '心跳',
+ service: '服务',
+ rooms: '房间',
+ updated: '更新',
+ },
+ rooms: {
+ empty: '暂无房间。',
+ cardsAria: '房间状态卡片',
+ room: '房间',
+ service: '服务',
+ agent: '代理',
+ status: '状态',
+ queue: '队列',
+ elapsed: '耗时',
+ },
+ usage: {
+ empty: '暂无用量快照。检查采集器。',
+ highest: '最高',
+ watch: '关注',
+ updated: '更新',
+ peak: '峰值',
+ reset: '重置',
+ usage: '用量',
+ window5h: '5小时',
+ window7d: '7天',
+ groupPrimary: 'Claude / Kimi',
+ groupCodex: 'Codex',
+ risk: {
+ ok: '充足',
+ warn: '关注',
+ critical: '接近上限',
+ },
+ },
+ tasks: {
+ empty: '暂无计划任务。',
+ cardsAria: '计划任务卡片',
+ task: '任务',
+ status: '状态',
+ schedule: '计划',
+ next: '下次',
+ last: '最近',
+ context: '上下文',
+ ciWatch: 'CI 监控',
+ emptyPrompt: '(空预览)',
+ until: '直到',
+ lastResult: '最近结果',
+ },
+ status: {
+ processing: '处理中',
+ waiting: '等待',
+ inactive: '空闲',
+ active: '活跃',
+ paused: '暂停',
+ completed: '完成',
+ },
+ units: {
+ second: '秒',
+ minute: '分',
+ hour: '小时',
+ task: '任务',
+ messageShort: '消息',
+ chars: '字',
+ },
+ },
+ ja: {
+ app: {
+ loading: 'ダッシュボードを読み込み中',
+ },
+ actions: {
+ close: '閉じる',
+ refresh: '更新',
+ refreshing: '更新中',
+ retry: '再試行',
+ },
+ nav: {
+ aria: 'ダッシュボードセクション',
+ drawerAria: 'ダッシュボードメニュー',
+ drawerNavAria: 'ダッシュボードメニューセクション',
+ menuOpen: 'メニューを開く',
+ menuClose: 'メニューを閉じる',
+ operations: '運用',
+ updated: '更新',
+ health: '状態',
+ usage: '使用量',
+ rooms: 'ルーム',
+ scheduled: '予定',
+ },
+ language: {
+ label: '言語',
+ },
+ error: {
+ api: 'API エラー',
+ },
+ control: {
+ aria: 'コントロールプレーン概要',
+ queue: '作業キュー',
+ activeRooms: '処理中 + 待機ルーム',
+ pendingRooms: 'メッセージ待ちルーム',
+ },
+ metrics: {
+ agents: 'エージェント',
+ rooms: 'ルーム',
+ ciWatchers: 'CI監視',
+ },
+ panels: {
+ health: '状態',
+ heartbeat: 'ハートビート',
+ usage: '使用量',
+ usageWindow: '5時間 / 7日',
+ rooms: 'ルーム',
+ queue: 'キュー',
+ scheduled: '予定',
+ promptPreviews: 'プロンプトプレビュー',
+ },
+ service: {
+ empty: 'ハートビートなし。サービスログを確認。',
+ heartbeat: 'ハートビート',
+ service: 'サービス',
+ rooms: 'ルーム',
+ updated: '更新',
+ },
+ rooms: {
+ empty: 'ルームなし。',
+ cardsAria: 'ルーム状態カード',
+ room: 'ルーム',
+ service: 'サービス',
+ agent: 'エージェント',
+ status: '状態',
+ queue: 'キュー',
+ elapsed: '経過',
+ },
+ usage: {
+ empty: '使用量スナップショットなし。収集器を確認。',
+ highest: '最高',
+ watch: '注意',
+ updated: '更新',
+ peak: 'ピーク',
+ reset: 'リセット',
+ usage: '使用量',
+ window5h: '5時間',
+ window7d: '7日',
+ groupPrimary: 'Claude / Kimi',
+ groupCodex: 'Codex',
+ risk: {
+ ok: '余裕',
+ warn: '注意',
+ critical: '上限注意',
+ },
+ },
+ tasks: {
+ empty: '予定作業なし。',
+ cardsAria: '予定作業カード',
+ task: '作業',
+ status: '状態',
+ schedule: '予定',
+ next: '次回',
+ last: '直近',
+ context: 'コンテキスト',
+ ciWatch: 'CI監視',
+ emptyPrompt: '(空のプレビュー)',
+ until: 'まで',
+ lastResult: '直近結果',
+ },
+ status: {
+ processing: '処理中',
+ waiting: '待機',
+ inactive: '休止',
+ active: '有効',
+ paused: '一時停止',
+ completed: '完了',
+ },
+ units: {
+ second: '秒',
+ minute: '分',
+ hour: '時間',
+ task: '作業',
+ messageShort: 'メッセージ',
+ chars: '字',
+ },
+ },
+} satisfies Record;
+
+export function isLocale(value: string | null | undefined): value is Locale {
+ return LOCALES.includes(value as Locale);
+}
+
+export function matchLocale(value: string | null | undefined): Locale | null {
+ if (!value) return null;
+ const normalized = value.toLowerCase();
+ if (normalized.startsWith('ko')) return 'ko';
+ if (normalized.startsWith('en')) return 'en';
+ if (normalized.startsWith('zh')) return 'zh';
+ if (normalized.startsWith('ja')) return 'ja';
+ return null;
+}
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index 176b1a7..96895e2 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -11,6 +11,7 @@
--green: #3f7f51;
--yellow: #b58a22;
--red: #b74734;
+ --drawer: rgba(26, 31, 26, 0.72);
--shadow: 0 24px 70px rgba(44, 39, 25, 0.18);
font-family:
'Avenir Next', 'Segoe UI Variable', 'Noto Sans KR', ui-sans-serif,
@@ -19,6 +20,11 @@
color: var(--bg-ink);
}
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 92px;
+}
+
* {
box-sizing: border-box;
}
@@ -27,6 +33,7 @@ body {
min-width: 320px;
min-height: 100vh;
margin: 0;
+ overflow-x: hidden;
background:
radial-gradient(
circle at 18% 12%,
@@ -48,9 +55,23 @@ table {
}
.shell {
+ display: grid;
+ grid-template-columns: 184px minmax(0, 1fr);
+ gap: 18px;
+ align-items: start;
width: min(1480px, calc(100% - 40px));
margin: 0 auto;
- padding: 34px 0 56px;
+ padding: 18px 0 calc(36px + env(safe-area-inset-bottom));
+}
+
+.shell-loading {
+ display: block;
+}
+
+.dashboard-content {
+ display: grid;
+ gap: 12px;
+ min-width: 0;
}
.shell::before {
@@ -75,42 +96,6 @@ table {
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;
@@ -130,11 +115,266 @@ button {
cursor: pointer;
}
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+
+button:focus-visible,
+a:focus-visible {
+ outline: 3px solid rgba(191, 95, 44, 0.45);
+ outline-offset: 3px;
+}
+
button:disabled {
cursor: wait;
opacity: 0.62;
}
+.section-nav {
+ position: sticky;
+ top: 10px;
+ z-index: 20;
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: 8px;
+ align-items: center;
+ margin: 0;
+ padding: 10px;
+ border: 1px solid rgba(43, 55, 38, 0.14);
+ border-radius: 22px;
+ background: rgba(255, 250, 240, 0.82);
+ box-shadow: 0 18px 48px rgba(44, 39, 25, 0.12);
+ backdrop-filter: blur(18px);
+}
+
+.section-nav button {
+ display: inline-flex;
+ min-height: 44px;
+ align-items: center;
+ justify-content: center;
+ padding: 0 16px;
+ border-radius: 999px;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.section-nav button {
+ min-height: 44px;
+}
+
+.section-nav .menu-button {
+ width: 48px;
+ min-width: 48px;
+ padding: 0;
+ gap: 4px;
+ flex-direction: column;
+ background: #20281f;
+ box-shadow: none;
+}
+
+.menu-button span {
+ display: block;
+ width: 18px;
+ height: 2px;
+ border-radius: 999px;
+ background: #fffaf0;
+}
+
+.section-nav .refresh-button {
+ min-width: 96px;
+}
+
+.topbar-label {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 14px;
+ letter-spacing: -0.02em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.section-nav span {
+ min-width: 0;
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 13px;
+ font-weight: 750;
+ text-align: right;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.side-rail {
+ position: sticky;
+ top: 18px;
+ display: grid;
+ min-height: calc(100vh - 36px);
+ align-content: start;
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid rgba(43, 55, 38, 0.14);
+ border-radius: 28px;
+ background: rgba(255, 250, 240, 0.78);
+ box-shadow: 0 18px 48px rgba(44, 39, 25, 0.1);
+ backdrop-filter: blur(18px);
+}
+
+.side-rail-brand strong {
+ display: block;
+ margin-top: 6px;
+ font-size: 24px;
+ letter-spacing: -0.05em;
+}
+
+.side-rail nav {
+ display: grid;
+ gap: 7px;
+}
+
+.side-rail nav a {
+ display: flex;
+ min-height: 44px;
+ align-items: center;
+ padding: 0 12px;
+ border-radius: 15px;
+ color: var(--bg-ink);
+ background: rgba(43, 55, 38, 0.055);
+ font-weight: 900;
+ text-decoration: none;
+}
+
+.side-rail nav a:hover {
+ background: rgba(191, 95, 44, 0.12);
+}
+
+.side-refresh {
+ width: 100%;
+ min-height: 44px;
+ color: var(--bg-ink);
+ background: rgba(43, 55, 38, 0.08);
+ box-shadow: none;
+ font-weight: 900;
+}
+
+.side-refresh:hover {
+ background: rgba(191, 95, 44, 0.12);
+}
+
+.language-select {
+ display: grid;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 850;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.language-select select {
+ min-height: 44px;
+ width: 100%;
+ padding: 0 12px;
+ border: 1px solid rgba(43, 55, 38, 0.14);
+ border-radius: 15px;
+ color: var(--bg-ink);
+ background: rgba(255, 250, 240, 0.78);
+ font: inherit;
+ font-size: 14px;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 35;
+ min-height: 0;
+ padding: 0;
+ border-radius: 0;
+ background: var(--drawer);
+ box-shadow: none;
+ cursor: pointer;
+}
+
+.nav-drawer {
+ position: fixed;
+ z-index: 40;
+ top: max(14px, env(safe-area-inset-top));
+ bottom: max(14px, env(safe-area-inset-bottom));
+ left: 14px;
+ display: grid;
+ width: min(360px, calc(100vw - 28px));
+ grid-template-rows: auto 1fr auto;
+ gap: 18px;
+ padding: 18px;
+ border: 1px solid rgba(255, 250, 240, 0.3);
+ border-radius: 28px;
+ background: rgba(255, 250, 240, 0.94);
+ box-shadow: 0 28px 90px rgba(20, 18, 10, 0.36);
+ backdrop-filter: blur(20px);
+}
+
+.drawer-head {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.drawer-head strong {
+ display: block;
+ margin-top: 4px;
+ font-size: 24px;
+ letter-spacing: -0.04em;
+}
+
+.drawer-head button {
+ min-height: 44px;
+ padding: 0 14px;
+ color: var(--bg-ink);
+ background: rgba(43, 55, 38, 0.08);
+ box-shadow: none;
+}
+
+.nav-drawer nav {
+ display: grid;
+ align-content: start;
+ gap: 8px;
+}
+
+.nav-drawer nav a {
+ display: flex;
+ min-height: 48px;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 16px;
+ border: 1px solid rgba(43, 55, 38, 0.08);
+ border-radius: 18px;
+ color: var(--bg-ink);
+ background: rgba(43, 55, 38, 0.045);
+ font-weight: 900;
+ text-decoration: none;
+}
+
+.nav-drawer nav a::after {
+ color: var(--muted);
+ content: '↘';
+}
+
+.drawer-meta {
+ display: grid;
+ gap: 4px;
+ padding: 14px;
+ border-radius: 18px;
+ color: var(--muted);
+ background: rgba(43, 55, 38, 0.06);
+}
+
+.drawer-meta strong {
+ color: var(--bg-ink);
+}
+
.error-card,
.card,
.panel {
@@ -145,6 +385,10 @@ button:disabled {
}
.error-card {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+ justify-content: space-between;
margin-top: 18px;
padding: 18px 20px;
border-color: rgba(183, 71, 52, 0.35);
@@ -154,18 +398,23 @@ button:disabled {
font-weight: 800;
}
-.metrics-grid {
+.error-card button {
+ min-width: 112px;
+ flex: none;
+}
+
+.skeleton-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
- margin: 22px 0;
+ margin: 0;
}
-.control-rail {
+.ops-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
- margin: 18px 0 22px;
+ margin: 0;
overflow: hidden;
border: 1px solid var(--panel-border);
border-radius: 28px;
@@ -173,11 +422,11 @@ button:disabled {
box-shadow: 0 18px 50px rgba(44, 39, 25, 0.08);
}
-.control-rail div {
+.ops-strip div {
display: grid;
- gap: 8px;
- min-height: 116px;
- padding: 20px;
+ gap: 5px;
+ min-height: 74px;
+ padding: 12px 14px;
background:
linear-gradient(
145deg,
@@ -191,27 +440,34 @@ button:disabled {
);
}
-.control-rail strong {
- font-size: clamp(22px, 3vw, 38px);
+.ops-strip span {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.ops-strip strong {
+ font-size: clamp(18px, 2vw, 28px);
line-height: 1;
letter-spacing: -0.05em;
}
-.control-rail small {
+.ops-strip small {
color: var(--muted);
+ font-size: 12px;
line-height: 1.35;
}
-.metric-card {
+.skeleton-card {
display: grid;
gap: 8px;
- min-height: 142px;
+ min-height: 112px;
padding: 22px;
border-radius: 26px;
}
-.metric-card span,
-.metric-card small,
td span,
td small,
dd,
@@ -221,28 +477,14 @@ dd,
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;
+ scroll-margin-top: 92px;
}
-.split-panel {
- display: grid;
- grid-template-columns: minmax(340px, 0.82fr) minmax(0, 1.18fr);
- gap: 22px;
+.usage-first {
+ padding: 18px;
}
.panel-title {
@@ -324,6 +566,10 @@ dd {
background: rgba(255, 250, 240, 0.56);
}
+.mobile-record-list {
+ display: none;
+}
+
table {
width: 100%;
border-collapse: collapse;
@@ -374,6 +620,7 @@ td small {
border-radius: 999px;
font-size: 12px;
font-weight: 850;
+ white-space: nowrap;
}
.pill-processing,
@@ -394,36 +641,153 @@ td small {
background: rgba(109, 115, 95, 0.16);
}
-.usage-list {
- display: grid;
- gap: 14px;
+.pill-ok {
+ color: #173e23;
+ background: rgba(63, 127, 81, 0.18);
}
-.usage-row {
- padding: 18px;
+.pill-warn {
+ color: #6e4a05;
+ background: rgba(181, 138, 34, 0.2);
+}
+
+.pill-critical {
+ color: #7c2518;
+ background: rgba(183, 71, 52, 0.18);
+}
+
+.usage-dashboard {
+ display: grid;
+ gap: 10px;
+}
+
+.usage-summary {
+ display: grid;
+ grid-template-columns: 1.1fr 0.46fr 0.9fr;
+ gap: 8px;
+}
+
+.usage-summary div,
+.usage-matrix {
border: 1px solid rgba(43, 55, 38, 0.1);
- border-radius: 22px;
+ border-radius: 18px;
background: rgba(255, 250, 240, 0.62);
}
-.usage-title {
- display: flex;
- gap: 10px;
- justify-content: space-between;
- margin-bottom: 14px;
+.usage-summary div {
+ display: grid;
+ gap: 4px;
+ min-width: 0;
+ padding: 10px 12px;
}
-.bar-line {
+.usage-summary span,
+.usage-window small {
+ color: var(--muted);
+}
+
+.usage-summary strong {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 14px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.usage-matrix {
display: grid;
- grid-template-columns: 34px 1fr 54px minmax(60px, auto);
- gap: 10px;
+ overflow: hidden;
+}
+
+.usage-matrix-head,
+.usage-row {
+ display: grid;
+ grid-template-columns: minmax(120px, 0.72fr) minmax(0, 1fr) minmax(0, 1fr);
+ gap: 8px;
align-items: center;
- margin-top: 9px;
+}
+
+.usage-matrix-head {
+ padding: 9px 12px;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ background: rgba(43, 55, 38, 0.055);
+}
+
+.usage-group {
+ display: grid;
+}
+
+.usage-group + .usage-group {
+ border-top: 1px solid rgba(43, 55, 38, 0.12);
+}
+
+.usage-group-label {
+ padding: 7px 10px;
+ color: var(--accent-strong);
+ font-size: 11px;
+ font-weight: 950;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ background: rgba(191, 95, 44, 0.07);
+}
+
+.usage-row {
+ --meter: var(--green);
+ padding: 8px 10px;
+ border-top: 1px solid rgba(43, 55, 38, 0.08);
+}
+
+.usage-warn {
+ --meter: var(--yellow);
+}
+
+.usage-critical {
+ --meter: var(--red);
+}
+
+.usage-account,
+.usage-window div {
+ display: flex;
+ min-width: 0;
+ gap: 8px;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.usage-account {
+ justify-content: flex-start;
+}
+
+.usage-account strong {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 14px;
+ letter-spacing: -0.02em;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.usage-window span {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 850;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.usage-window {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
}
progress {
width: 100%;
- height: 12px;
+ height: 8px;
overflow: hidden;
border: 0;
border-radius: 999px;
@@ -436,12 +800,12 @@ progress::-webkit-progress-bar {
progress::-webkit-progress-value {
border-radius: 999px;
- background: linear-gradient(90deg, var(--green), var(--accent));
+ background: linear-gradient(90deg, var(--meter, var(--green)), var(--accent));
}
progress::-moz-progress-bar {
border-radius: 999px;
- background: linear-gradient(90deg, var(--green), var(--accent));
+ background: linear-gradient(90deg, var(--meter, var(--green)), var(--accent));
}
.empty-state {
@@ -452,55 +816,350 @@ progress::-moz-progress-bar {
background: rgba(255, 250, 240, 0.45);
}
+.record-card {
+ display: grid;
+ gap: 14px;
+ padding: 16px;
+ border: 1px solid rgba(43, 55, 38, 0.12);
+ border-radius: 22px;
+ background: rgba(255, 250, 240, 0.68);
+}
+
+.record-card-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: start;
+}
+
+.record-card-head strong,
+.task-card > p {
+ overflow-wrap: anywhere;
+}
+
+.record-card-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.record-card-grid > span {
+ min-width: 0;
+ padding: 11px;
+ border: 1px solid rgba(43, 55, 38, 0.08);
+ border-radius: 16px;
+ background: rgba(43, 55, 38, 0.045);
+}
+
+.record-card-grid small,
+.record-id {
+ color: var(--muted);
+}
+
+.record-card-grid small,
+.record-card-grid strong {
+ display: block;
+}
+
+.record-card-grid strong {
+ min-width: 0;
+ overflow: hidden;
+ margin-top: 3px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.mono-chip,
+.record-id {
+ font-family:
+ 'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace;
+ font-size: 12px;
+}
+
+.mono-chip {
+ display: inline-flex;
+ max-width: 100%;
+ min-height: 28px;
+ align-items: center;
+ margin-top: 8px;
+ padding: 0 9px;
+ overflow: hidden;
+ border-radius: 999px;
+ color: var(--muted);
+ background: rgba(43, 55, 38, 0.08);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.record-id {
+ min-width: 0;
+ margin: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.skeleton-line,
+.skeleton-button {
+ display: block;
+ overflow: hidden;
+ border-radius: 999px;
+ background: linear-gradient(
+ 90deg,
+ rgba(43, 55, 38, 0.08),
+ rgba(255, 250, 240, 0.85),
+ rgba(43, 55, 38, 0.08)
+ );
+ background-size: 220% 100%;
+ animation: shimmer 1.45s ease-in-out infinite;
+}
+
+.skeleton-short {
+ width: 180px;
+ height: 14px;
+}
+
+.skeleton-title {
+ width: min(640px, 84vw);
+ height: clamp(48px, 10vw, 94px);
+ margin-top: 16px;
+}
+
+.skeleton-copy {
+ width: min(520px, 72vw);
+ height: 18px;
+ margin-top: 14px;
+}
+
+.skeleton-number {
+ width: 90px;
+ height: 56px;
+}
+
+.skeleton-button {
+ width: 126px;
+ height: 48px;
+}
+
+@keyframes shimmer {
+ from {
+ background-position: 120% 0;
+ }
+
+ to {
+ background-position: -120% 0;
+ }
+}
+
+@media (min-width: 981px) {
+ .section-nav {
+ display: none;
+ }
+}
+
@media (max-width: 980px) {
- .hero,
- .split-panel {
- grid-template-columns: 1fr;
+ .shell {
+ display: block;
}
- .hero {
- align-items: start;
+ .side-rail {
+ display: none;
}
- .metrics-grid {
+ .skeleton-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
- .control-rail {
+ .ops-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
+@media (max-width: 760px) {
+ .desktop-table {
+ display: none;
+ }
+
+ .mobile-record-list {
+ display: grid;
+ gap: 12px;
+ }
+}
+
@media (max-width: 640px) {
- .shell {
- width: min(100% - 24px, 1480px);
- padding: 18px 0 34px;
+ html {
+ scroll-padding-top: 18px;
+ scroll-padding-bottom: 104px;
+ }
+
+ .shell {
+ width: min(100% - 20px, 1480px);
+ padding: 10px 0 calc(24px + env(safe-area-inset-bottom));
}
- .hero,
.panel {
border-radius: 22px;
- padding: 20px;
+ padding: 18px;
}
- .metrics-grid {
- grid-template-columns: 1fr;
+ .section-nav {
+ position: sticky;
+ top: max(8px, env(safe-area-inset-top));
+ right: 10px;
+ left: 10px;
+ grid-template-columns: 52px minmax(0, 1fr) 54px;
+ gap: 6px;
+ margin: 0;
+ padding: 8px;
+ border-radius: 24px;
}
- .control-rail {
- grid-template-columns: 1fr;
+ .section-nav button {
+ min-height: 48px;
+ padding: 0 8px;
+ font-size: 12px;
}
- .panel-title,
- .usage-title {
+ .section-nav span {
+ display: none;
+ }
+
+ .section-nav button {
+ min-width: 0;
+ color: transparent;
+ font-size: 0;
+ }
+
+ .section-nav .menu-button {
+ padding: 0;
+ }
+
+ .section-nav .refresh-button {
+ min-width: 0;
+ }
+
+ .section-nav .refresh-button::before {
+ color: #fffaf0;
+ content: '↻';
+ font-size: 20px;
+ font-weight: 900;
+ }
+
+ .section-nav .refresh-button:disabled::before {
+ content: '…';
+ }
+
+ .skeleton-grid,
+ .ops-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+ }
+
+ .ops-strip div,
+ .skeleton-card {
+ min-height: 84px;
+ padding: 14px;
+ }
+
+ .ops-strip strong {
+ font-size: clamp(22px, 8vw, 34px);
+ }
+
+ .ops-strip small {
+ font-size: 12px;
+ }
+
+ .panel-title {
display: grid;
}
- .bar-line {
- grid-template-columns: 28px 1fr 44px;
+ .usage-summary {
+ grid-template-columns: minmax(0, 1fr) minmax(78px, 0.55fr);
}
- .bar-line small {
- grid-column: 2 / -1;
+ .usage-summary div:last-child {
+ grid-column: 1 / -1;
+ }
+
+ .usage-matrix-head {
+ display: none;
+ }
+
+ .usage-row {
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1fr);
+ gap: 6px;
+ padding: 9px;
+ }
+
+ .usage-account {
+ display: grid;
+ gap: 5px;
+ }
+
+ .usage-window {
+ gap: 4px;
+ }
+
+ .usage-window div {
+ gap: 6px;
+ }
+
+ .usage-window small {
+ overflow: hidden;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .record-card-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 380px) {
+ .shell {
+ width: min(100% - 16px, 1480px);
+ }
+
+ .section-nav {
+ right: 8px;
+ left: 8px;
+ }
+
+ .usage-summary {
+ grid-template-columns: 1fr;
+ }
+
+ .usage-summary div:last-child {
+ grid-column: auto;
+ }
+
+ .usage-row {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ }
+
+ .usage-account {
+ grid-column: 1 / -1;
+ grid-template-columns: minmax(0, 1fr) auto;
+ }
+
+ .skeleton-grid,
+ .ops-strip {
+ gap: 8px;
+ }
+
+ .record-card-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
}
}
diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts
index 3a6cd5d..6e77084 100644
--- a/src/unified-dashboard.test.ts
+++ b/src/unified-dashboard.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { UsageRow } from './dashboard-usage-rows.js';
import {
+ buildWebUsageRowsForSnapshot,
formatStatusHeader,
renderUsageTable,
summarizeWatcherTasks,
@@ -107,3 +108,94 @@ describe('renderUsageTable', () => {
expect(lines).toEqual(['_조회 불가_']);
});
});
+
+describe('buildWebUsageRowsForSnapshot', () => {
+ it('keeps real Claude and Kimi rows ahead of Codex rows for web snapshots', () => {
+ const rows = buildWebUsageRowsForSnapshot({
+ serviceAgentType: 'claude-code',
+ claudeAccounts: [
+ {
+ index: 0,
+ masked: 'claude-1',
+ isActive: true,
+ isRateLimited: false,
+ usage: {
+ five_hour: {
+ utilization: 0.2,
+ resets_at: '2026-04-26T12:00:00.000Z',
+ },
+ seven_day: {
+ utilization: 0.4,
+ resets_at: '2026-04-27T12:00:00.000Z',
+ },
+ },
+ },
+ {
+ index: 1,
+ masked: 'claude-2',
+ isActive: false,
+ isRateLimited: false,
+ usage: {
+ five_hour: {
+ utilization: 0.3,
+ resets_at: '2026-04-26T12:00:00.000Z',
+ },
+ seven_day: {
+ utilization: 0.5,
+ resets_at: '2026-04-27T12:00:00.000Z',
+ },
+ },
+ },
+ ],
+ kimiUsage: {
+ fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' },
+ weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' },
+ },
+ codexRows: [
+ {
+ name: 'Codex1',
+ h5pct: 25,
+ h5reset: '1h',
+ d7pct: 35,
+ d7reset: '2d',
+ },
+ ],
+ });
+
+ const names = rows.map((row) => row.name);
+ expect(names[0]).toMatch(/^Claude1/);
+ expect(names[1]).toMatch(/^Claude2/);
+ expect(names[2]).toMatch(/^Kimi/);
+ expect(names[3]).toBe('Codex1');
+ });
+
+ it('does not invent Claude or Kimi rows for codex-only services', () => {
+ const rows = buildWebUsageRowsForSnapshot({
+ serviceAgentType: 'codex',
+ claudeAccounts: [
+ {
+ index: 0,
+ masked: 'claude-1',
+ isActive: true,
+ isRateLimited: false,
+ usage: null,
+ },
+ ],
+ kimiUsage: {
+ fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' },
+ weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' },
+ },
+ codexRows: [
+ {
+ name: 'Codex1',
+ h5pct: 25,
+ h5reset: '1h',
+ d7pct: 35,
+ d7reset: '2d',
+ },
+ ],
+ });
+
+ expect(rows.map((row) => row.name)).toEqual(['Codex1']);
+ });
+});
diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts
index 44048fd..86113ba 100644
--- a/src/unified-dashboard.ts
+++ b/src/unified-dashboard.ts
@@ -17,7 +17,11 @@ import {
CODEX_WARMUP_CONFIG,
getMoaConfig,
} from './config.js';
-import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js';
+import {
+ fetchKimiUsage,
+ buildKimiUsageRows,
+ type KimiUsageData,
+} from './kimi-usage.js';
import { getGlobalFailoverInfo } from './service-routing.js';
import {
fetchAllClaudeUsage,
@@ -93,7 +97,7 @@ const RENDERER_USAGE_REFRESH_MS = 30_000;
let statusMessageId: string | null = null;
let cachedUsageContent = '';
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
-let cachedKimiUsage: import('./kimi-usage.js').KimiUsageData | null = null;
+let cachedKimiUsage: KimiUsageData | null = null;
let usageUpdateInProgress = false;
let channelMetaCache = new Map();
let channelMetaLastRefresh = 0;
@@ -102,6 +106,8 @@ let dashboardUpdateLogged = false;
let cachedCodexUsageRows: UsageRow[] = [];
/** Codex service only: ISO timestamp of last successful usage fetch. */
let codexUsageFetchedAt: string | null = null;
+/** Renderer service only: ISO timestamp of last successful Claude/Kimi usage render. */
+let rendererUsageFetchedAt: string | null = null;
export interface WatcherTaskSummary {
active: number;
@@ -231,9 +237,47 @@ function formatRoomName(
return base;
}
+export function buildWebUsageRowsForSnapshot(args: {
+ serviceAgentType: AgentType;
+ claudeAccounts: ClaudeAccountUsage[];
+ kimiUsage: KimiUsageData | null;
+ codexRows: UsageRow[];
+}): UsageRow[] {
+ const rows: UsageRow[] = [];
+
+ if (args.serviceAgentType === 'claude-code') {
+ rows.push(...buildClaudeUsageRows(args.claudeAccounts));
+ rows.push(...buildKimiUsageRows(args.kimiUsage));
+ }
+
+ rows.push(...args.codexRows);
+ return rows;
+}
+
+function buildUsageSnapshotRows(opts: UnifiedDashboardOptions): {
+ rows: UsageRow[];
+ fetchedAt: string | null;
+} {
+ const rows = buildWebUsageRowsForSnapshot({
+ serviceAgentType: opts.serviceAgentType,
+ claudeAccounts: cachedClaudeAccounts,
+ kimiUsage: cachedKimiUsage,
+ codexRows: cachedCodexUsageRows,
+ });
+
+ const fetchedAt =
+ [rendererUsageFetchedAt, codexUsageFetchedAt]
+ .filter((value): value is string => !!value)
+ .sort()
+ .at(-1) ?? null;
+
+ return { rows, fetchedAt };
+}
+
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
const groups = opts.roomBindings();
const statuses = opts.queue.getStatuses(Object.keys(groups));
+ const usageSnapshot = buildUsageSnapshotRows(opts);
writeStatusSnapshot({
serviceId: opts.serviceId,
@@ -267,8 +311,10 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
pendingMessages: boolean;
pendingTasks: number;
}>,
- ...(cachedCodexUsageRows.length > 0 && { usageRows: cachedCodexUsageRows }),
- ...(codexUsageFetchedAt && { usageRowsFetchedAt: codexUsageFetchedAt }),
+ ...(usageSnapshot.rows.length > 0 && { usageRows: usageSnapshot.rows }),
+ ...(usageSnapshot.fetchedAt && {
+ usageRowsFetchedAt: usageSnapshot.fetchedAt,
+ }),
});
}
@@ -647,6 +693,7 @@ async function refreshUsageCache(): Promise {
usageUpdateInProgress = true;
try {
cachedUsageContent = await buildUsageContent();
+ rendererUsageFetchedAt = new Date().toISOString();
} catch (err) {
logger.warn({ err }, 'Failed to build usage content');
} finally {
diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts
index 99bc4a3..8d3c098 100644
--- a/src/web-dashboard-data.test.ts
+++ b/src/web-dashboard-data.test.ts
@@ -94,6 +94,83 @@ describe('web dashboard data', () => {
expect(overview.usage.rows).toHaveLength(1);
});
+ it('deduplicates full usage rows from renderer and codex snapshots', () => {
+ const snapshots: StatusSnapshot[] = [
+ {
+ serviceId: 'codex-main',
+ agentType: 'codex',
+ assistantName: 'Codex',
+ updatedAt: '2026-04-26T04:59:00.000Z',
+ entries: [],
+ usageRowsFetchedAt: '2026-04-26T04:59:00.000Z',
+ usageRows: [
+ {
+ name: 'Codex1',
+ h5pct: 20,
+ h5reset: '1h',
+ d7pct: 30,
+ d7reset: '2d',
+ },
+ {
+ name: 'Codex2',
+ h5pct: 15,
+ h5reset: '1h',
+ d7pct: 18,
+ d7reset: '2d',
+ },
+ ],
+ },
+ {
+ serviceId: 'claude-main',
+ agentType: 'claude-code',
+ assistantName: 'Claude',
+ updatedAt: '2026-04-26T05:00:00.000Z',
+ entries: [],
+ usageRowsFetchedAt: '2026-04-26T05:00:00.000Z',
+ usageRows: [
+ {
+ name: 'Claude1 Max',
+ h5pct: 66,
+ h5reset: '2h',
+ d7pct: 40,
+ d7reset: '4d',
+ },
+ {
+ name: 'Kimi',
+ h5pct: 10,
+ h5reset: '3h',
+ d7pct: 12,
+ d7reset: '5d',
+ },
+ {
+ name: 'Codex1',
+ h5pct: 25,
+ h5reset: '55m',
+ d7pct: 35,
+ d7reset: '2d',
+ },
+ ],
+ },
+ ];
+
+ const overview = buildWebDashboardOverview({
+ now: '2026-04-26T05:01:00.000Z',
+ snapshots,
+ tasks: [],
+ });
+
+ expect(overview.usage.rows.map((row) => row.name)).toEqual([
+ 'Claude1 Max',
+ 'Kimi',
+ 'Codex1',
+ 'Codex2',
+ ]);
+ expect(
+ overview.usage.rows.filter((row) => row.name === 'Codex1'),
+ ).toHaveLength(1);
+ expect(overview.usage.fetchedAt).toBe('2026-04-26T05:00:00.000Z');
+ });
+
it('does not expose full scheduled task prompts through API payloads', () => {
const sanitized = sanitizeScheduledTask(
makeTask({
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
index 87c14ec..3d45814 100644
--- a/src/web-dashboard-data.ts
+++ b/src/web-dashboard-data.ts
@@ -76,6 +76,26 @@ function buildPromptPreview(prompt: string): string {
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
}
+function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
+ const rows: UsageRowSnapshot[] = [];
+ const seen = new Set();
+ const sortedSnapshots = [...snapshots].sort((a, b) =>
+ (b.usageRowsFetchedAt ?? b.updatedAt).localeCompare(
+ a.usageRowsFetchedAt ?? a.updatedAt,
+ ),
+ );
+
+ for (const snapshot of sortedSnapshots) {
+ for (const row of snapshot.usageRows ?? []) {
+ if (seen.has(row.name)) continue;
+ seen.add(row.name);
+ rows.push(row);
+ }
+ }
+
+ return rows;
+}
+
export function sanitizeScheduledTask(
task: ScheduledTask,
): SanitizedScheduledTask {
@@ -162,9 +182,7 @@ export function buildWebDashboardOverview(args: {
}
}
- const usageRows = args.snapshots.flatMap(
- (snapshot) => snapshot.usageRows ?? [],
- );
+ const usageRows = collectUsageRows(args.snapshots);
const usageFetchedAt =
args.snapshots
.map((snapshot) => snapshot.usageRowsFetchedAt)
diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts
index 9d065a6..5223b13 100644
--- a/src/web-dashboard-server.test.ts
+++ b/src/web-dashboard-server.test.ts
@@ -37,6 +37,59 @@ describe('web dashboard server handler', () => {
expect(body.tasks.total).toBe(0);
});
+ it('serves full Claude, Kimi, and Codex usage rows through overview JSON', async () => {
+ const handler = createWebDashboardHandler({
+ readStatusSnapshots: () => [
+ {
+ serviceId: 'renderer',
+ agentType: 'claude-code',
+ assistantName: 'Claude',
+ updatedAt: '2026-04-26T11:59:00.000Z',
+ entries: [],
+ usageRowsFetchedAt: '2026-04-26T11:59:00.000Z',
+ usageRows: [
+ {
+ name: 'Claude1 Max',
+ h5pct: 66,
+ h5reset: '2h',
+ d7pct: 40,
+ d7reset: '4d',
+ },
+ {
+ name: 'Kimi',
+ h5pct: 10,
+ h5reset: '3h',
+ d7pct: 12,
+ d7reset: '5d',
+ },
+ {
+ name: 'Codex1',
+ h5pct: 25,
+ h5reset: '55m',
+ d7pct: 35,
+ d7reset: '2d',
+ },
+ ],
+ },
+ ],
+ getTasks: () => [],
+ });
+
+ const overview = await handler(
+ new Request('http://localhost/api/overview'),
+ );
+ expect(overview.status).toBe(200);
+ const body = (await overview.json()) as {
+ usage: { rows: Array<{ name: string }> };
+ };
+
+ expect(body.usage.rows.map((row) => row.name)).toEqual([
+ 'Claude1 Max',
+ 'Kimi',
+ 'Codex1',
+ ]);
+ });
+
it('serves Vite static assets and falls back to index for SPA routes', async () => {
const staticDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-dashboard-'),