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 ? ( + <> + + + + +
+ {t.nav.updated} + {formatDate(lastRefreshed, locale)} +
+ + + ) : 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 ( -
- - - - - - - - - - - - {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' : ''} -
+ + + + + + + - ))} - -
{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 ( -
- - - - - - - - - - - - {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} -
+ + + + + + + - ))} - -
{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 ( -
-
-
- EJClaw Control Plane · read-only MVP -

Agent factory control room

-

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

-
- -
+
+ void refresh(true)} + refreshing={refreshing} + t={t} + /> +
+ setDrawerOpen(false)} + onLocaleChange={setDashboardLocale} + onOpenDrawer={() => setDrawerOpen(true)} + onRefresh={() => void refresh(true)} + refreshing={refreshing} + t={t} + /> - {error ? ( -
API 오류: {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-'),