import { useEffect, useState } from 'react'; import { type ClaudeAccountSummary, type CodexAccountSummary, type CreateScheduledTaskInput, type DashboardInboxAction, type DashboardRoomActivity, type DashboardTaskAction, type DashboardOverview, type DashboardTask, type FastModeSnapshot, type ModelConfigSnapshot, type ModelRoleConfig, type UpdateScheduledTaskInput, type StatusSnapshot, addClaudeAccount, createScheduledTask, deleteAccount, fetchAccounts, fetchDashboardData, fetchFastMode, fetchModelConfig, refreshAllCodexAccounts as refreshAllCodexAccountsApi, refreshCodexAccount as refreshCodexAccountApi, runInboxAction, runServiceAction, runScheduledTaskAction, sendRoomMessage, setCurrentCodexAccount as setCurrentCodexAccountApi, updateFastMode, updateModels, updateScheduledTask, } from './api'; import { LOCALES, isLocale, languageNames, localeTags, matchLocale, messages, type Locale, type Messages, } from './i18n'; import { useSelectedRoomActivity } from './useRoomActivity'; import { SectionNav, SideRail, type DashboardFreshness, type DashboardView, } from './DashboardNav'; import { formatDate, statusLabel } from './dashboardHelpers'; import { InboxPanel, type InboxActionKey, type InboxItem } from './InboxPanel'; import { RoomBoardV2 } from './RoomBoardV2'; import { TaskPanel, type RoomOption, type TaskActionKey } from './TaskPanel'; import { UsagePanel } from './UsagePanel'; import { EmptyState } from './EmptyState'; import { ParsedBody } from './ParsedBody'; import './styles.css'; interface DashboardState { overview: DashboardOverview; snapshots: StatusSnapshot[]; tasks: DashboardTask[]; } type ServiceActionKey = 'stack:restart'; type HealthLevel = 'ok' | 'stale' | 'down'; type FreshnessLevel = DashboardFreshness; type BeforeInstallPromptEvent = Event & { prompt: () => Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; }; const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; const DEFAULT_VIEW: DashboardView = 'rooms'; const HEALTH_STALE_MS = 5 * 60_000; const HEALTH_DOWN_MS = 15 * 60_000; const DASHBOARD_STALE_MS = 75_000; function makeClientRequestId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } function isDashboardView( value: string | null | undefined, ): value is DashboardView { return ( value === 'usage' || value === 'inbox' || value === 'health' || value === 'rooms' || value === 'scheduled' || value === 'settings' ); } function readViewFromHash(): DashboardView { if (typeof window === 'undefined') return DEFAULT_VIEW; const raw = window.location.hash.replace(/^#\/?/, ''); return isDashboardView(raw) ? raw : DEFAULT_VIEW; } 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 'en'; } function humanizeError(raw: string, t: Messages): string { const lower = raw.toLowerCase(); if (/abort|timeout|timed out/.test(lower)) return t.error.timeout; if (/network|fetch failed|failed to fetch|networkerror|offline/.test(lower)) return t.error.network; const statusMatch = lower.match(/\b(\d{3})\b/); if (statusMatch) { const code = Number(statusMatch[1]); if (code === 401 || code === 403) return t.error.auth; if (code === 404) return t.error.notFound; if (code >= 500) return t.error.server; if (code >= 400) return t.error.unknown; } return raw || t.error.unknown; } function senderRoleClass(value: string | null | undefined): string { const v = (value ?? '').toLowerCase(); if (v.includes('오너') || v.includes('owner')) return 'role-owner'; if (v.includes('리뷰어') || v.includes('reviewer')) return 'role-reviewer'; if (v.includes('중재자') || v.includes('arbiter')) return 'role-arbiter'; if (v.includes('cron') || v.includes('sentry') || v.includes('webhook')) return 'role-cron'; return 'role-human'; } function dashboardAgeMs(value: string | null | undefined): number | null { if (!value) return null; const date = new Date(value); if (Number.isNaN(date.getTime())) return null; return Math.max(0, Date.now() - date.getTime()); } function dashboardFreshness( online: boolean, generatedAt: string | null | undefined, ): FreshnessLevel { if (!online) return 'offline'; const age = dashboardAgeMs(generatedAt); if (age !== null && age > DASHBOARD_STALE_MS) return 'stale'; return 'fresh'; } function freshnessLabel(level: FreshnessLevel, t: Messages): string { if (level === 'offline') return t.pwa.offline; if (level === 'stale') return t.pwa.stale; return t.pwa.fresh; } function isStandaloneDisplay(): boolean { if (typeof window === 'undefined') return false; const standaloneNavigator = navigator as Navigator & { standalone?: boolean }; return ( standaloneNavigator.standalone === true || (typeof window.matchMedia === 'function' && window.matchMedia('(display-mode: standalone)').matches) ); } function canUsePwaCore(): boolean { return ( typeof window !== 'undefined' && window.isSecureContext && 'serviceWorker' in navigator ); } function formatDuration(value: number | null, t: Messages): string { if (value === null) return '-'; const seconds = Math.floor(value / 1000); if (seconds < 60) return `${seconds}${t.units.second}`; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}${t.units.minute}`; const hours = Math.floor(minutes / 60); return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`; } function formatLiveElapsed(value: number, t: Messages): string { const seconds = Math.max(0, Math.floor(value / 1000)); if (seconds < 60) return `${seconds}${t.units.second}`; const minutes = Math.floor(seconds / 60); const remSec = seconds % 60; if (minutes < 60) return `${minutes}${t.units.minute} ${remSec}${t.units.second}`; const hours = Math.floor(minutes / 60); const remMin = minutes % 60; return `${hours}${t.units.hour} ${remMin}${t.units.minute} ${remSec}${t.units.second}`; } const ROOM_BOARD_FORMATTERS = { formatDate, formatDuration, formatLiveElapsed, senderRoleClass, statusLabel, }; 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 buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { const rooms = new Map(); for (const snapshot of snapshots) { for (const entry of snapshot.entries) { if (!rooms.has(entry.jid)) { rooms.set(entry.jid, { jid: entry.jid, name: entry.name || entry.folder || entry.jid, folder: entry.folder, }); } } } return [...rooms.values()].sort((a, b) => `${a.name} ${a.folder}`.localeCompare(`${b.name} ${b.folder}`), ); } function serviceAgeMs( service: DashboardOverview['services'][number], generatedAt: string, ): number | null { const updated = new Date(service.updatedAt).getTime(); const now = new Date(generatedAt).getTime(); if (Number.isNaN(updated) || Number.isNaN(now)) return null; return Math.max(0, now - updated); } function serviceHealthLevel( service: DashboardOverview['services'][number], generatedAt: string, ): HealthLevel { const age = serviceAgeMs(service, generatedAt); if (age === null) return 'stale'; if (age >= HEALTH_DOWN_MS) return 'down'; if (age >= HEALTH_STALE_MS) return 'stale'; return 'ok'; } function LanguageSelector({ locale, onLocaleChange, t, }: { locale: Locale; onLocaleChange: (locale: Locale) => void; t: Messages; }) { return ( ); } function SettingsPanel({ locale, nickname, onLocaleChange, onNicknameChange, onRestartStack, t, }: { locale: Locale; nickname: string; onLocaleChange: (locale: Locale) => void; onNicknameChange: (next: string) => void; onRestartStack: () => void; t: Messages; }) { return (

일반

); } function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { const [config, setConfig] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [savedAt, setSavedAt] = useState(null); useEffect(() => { let cancelled = false; setBusy(true); fetchModelConfig() .then((c) => { if (cancelled) return; setConfig(c); setDraft(c); setError(null); }) .catch((err) => { if (cancelled) return; setError(err instanceof Error ? err.message : String(err)); }) .finally(() => { if (!cancelled) setBusy(false); }); return () => { cancelled = true; }; }, []); async function save() { if (!draft || !config) return; setBusy(true); setError(null); try { const next = await updateModels(draft); setConfig(next); setDraft(next); setSavedAt(Date.now()); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } function setRole( role: keyof ModelConfigSnapshot, patch: Partial, ) { setDraft((prev) => prev ? { ...prev, [role]: { ...prev[role], ...patch }, } : prev, ); } const dirty = draft !== null && config !== null && JSON.stringify(draft) !== JSON.stringify(config); return (

모델

{error ?

{error}

: null} {!draft ? (

{busy ? '불러오는 중…' : '모델 정보 없음'}

) : ( <> {(['owner', 'reviewer', 'arbiter'] as const).map((role) => (
{role} setRole(role, { model: e.target.value })} placeholder="claude / codex / claude-opus-4-7 …" type="text" value={draft[role].model} /> setRole(role, { effort: e.target.value })} placeholder="effort" type="text" value={draft[role].effort} />
))}
{savedAt && !dirty ? ( 저장됨. 적용하려면 스택 재시작 필요. ) : null}
)}
); } function FastModeSettings() { const [state, setState] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; fetchFastMode() .then((s) => { if (cancelled) return; setState(s); setError(null); }) .catch((err) => { if (cancelled) return; setError(err instanceof Error ? err.message : String(err)); }); return () => { cancelled = true; }; }, []); async function toggle(provider: keyof FastModeSnapshot) { if (!state) return; const next = !state[provider]; const optimistic = { ...state, [provider]: next }; setState(optimistic); setBusy(true); setError(null); try { const fresh = await updateFastMode({ [provider]: next }); setState(fresh); } catch (err) { setState(state); setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } return (

패스트 모드

{error ?

{error}

: null} {!state ? (

불러오는 중…

) : ( <> )}
); } function formatExpiry( iso: string | null, ): { label: string; cls: string } | null { if (!iso) return null; const dt = new Date(iso); if (Number.isNaN(dt.getTime())) return null; const days = (dt.getTime() - Date.now()) / 86400000; const dateStr = dt.toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit', }); if (days < 0) { const ago = Math.ceil(-days); return { label: `결제 만료 ${dateStr} (${ago}일 전)`, cls: 'is-expired', }; } if (days < 7) { return { label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`, cls: 'is-soon', }; } return { label: `결제 ${dateStr}까지 (${Math.floor(days)}일)`, cls: 'is-active', }; } function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { const [data, setData] = useState<{ claude: ClaudeAccountSummary[]; codex: CodexAccountSummary[]; codexCurrentIndex?: number; } | null>(null); const [busy, setBusy] = useState(false); const [perRowBusy, setPerRowBusy] = useState(null); const [error, setError] = useState(null); const [tokenInput, setTokenInput] = useState(''); async function refresh() { setBusy(true); setError(null); try { const fresh = await fetchAccounts(); setData(fresh); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } useEffect(() => { void refresh(); }, []); async function handleDelete(provider: 'claude' | 'codex', index: number) { if ( !window.confirm( `${provider} 계정 #${index} 디렉터리를 삭제합니다. 되돌릴 수 없습니다. 계속할까요?`, ) ) { return; } setBusy(true); setError(null); try { await deleteAccount(provider, index); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function handleAdd() { const token = tokenInput.trim(); if (!token) return; setBusy(true); setError(null); try { await addClaudeAccount(token); setTokenInput(''); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function handleCodexRefresh(index: number) { setPerRowBusy(`refresh:${index}`); setError(null); try { await refreshCodexAccountApi(index); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setPerRowBusy(null); } } async function handleRefreshAllCodex() { setBusy(true); setError(null); try { const result = await refreshAllCodexAccountsApi(); await refresh(); if (result.failed.length > 0) { setError( `일부 갱신 실패: ${result.failed.map((f) => `#${f.index}`).join(', ')}`, ); } } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function handleSwitchCodex(index: number) { setPerRowBusy(`switch:${index}`); setError(null); try { await setCurrentCodexAccountApi(index); await refresh(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setPerRowBusy(null); } } return (

계정

{error ?

{error}

: null}

Claude

{!data ? (

{busy ? '불러오는 중…' : '없음'}

) : data.claude.length === 0 ? (

계정 없음

) : (
    {data.claude.map((acc) => (
  • #{acc.index} {acc.subscriptionType ?? 'unknown'} {acc.rateLimitTier ? ` · ${acc.rateLimitTier}` : ''} claude 토큰 자동갱신
    {acc.index > 0 ? ( ) : ( 기본 )}
  • ))}
)}