diff --git a/.env.example b/.env.example index 2727de8..23e11f6 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,22 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # STATUS_SHOW_ROOMS=true # Show room status in dashboard # STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info +# --- Codex warm-up (optional, disabled by default) --- +# Sends a tiny real Codex prompt only when both 5h and 7d usage are 0%, so +# reset countdown starts once after a fresh quota window. Keep disabled if +# OpenAI changes policy or starts blocking automated warm-up prompts. +# Normally only set this one line; the other knobs have conservative defaults. +# CODEX_WARMUP_ENABLED=false +# CODEX_WARMUP_PROMPT=Reply exactly OK. Do not run tools. +# CODEX_WARMUP_MODEL= # Defaults to CODEX_MODEL +# CODEX_WARMUP_INTERVAL_MS=300000 # Check every 5min +# CODEX_WARMUP_MIN_INTERVAL_MS=18300000 # Per-account minimum gap: 5h5m +# CODEX_WARMUP_STAGGER_MS=1800000 # Global gap between accounts: 30min +# CODEX_WARMUP_MAX_USAGE_PCT=0 # Only warm accounts at/below this 5h usage +# CODEX_WARMUP_MAX_D7_USAGE_PCT=0 # Only warm accounts at/below this 7d usage +# CODEX_WARMUP_MAX_CONSECUTIVE_FAILURES=2 +# CODEX_WARMUP_FAILURE_COOLDOWN_MS=21600000 # Back off 6h after repeated failure + # --- Fallback provider (when Claude/Codex both unavailable) --- # FALLBACK_ENABLED=true # Enable fallback provider # FALLBACK_PROVIDER_NAME=kimi # Provider name for logging diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html new file mode 100644 index 0000000..3fdc00b --- /dev/null +++ b/apps/dashboard/index.html @@ -0,0 +1,22 @@ + + + + + + + + + + + + + EJClaw Ops + + +
+ + + diff --git a/apps/dashboard/public/icons/icon-192.png b/apps/dashboard/public/icons/icon-192.png new file mode 100644 index 0000000..90ed779 Binary files /dev/null and b/apps/dashboard/public/icons/icon-192.png differ diff --git a/apps/dashboard/public/icons/icon-192.svg b/apps/dashboard/public/icons/icon-192.svg new file mode 100644 index 0000000..7ac3f43 --- /dev/null +++ b/apps/dashboard/public/icons/icon-192.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/dashboard/public/icons/icon-512.png b/apps/dashboard/public/icons/icon-512.png new file mode 100644 index 0000000..9c4c78a Binary files /dev/null and b/apps/dashboard/public/icons/icon-512.png differ diff --git a/apps/dashboard/public/icons/icon-512.svg b/apps/dashboard/public/icons/icon-512.svg new file mode 100644 index 0000000..7594125 --- /dev/null +++ b/apps/dashboard/public/icons/icon-512.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/dashboard/public/manifest.webmanifest b/apps/dashboard/public/manifest.webmanifest new file mode 100644 index 0000000..3c78ff5 --- /dev/null +++ b/apps/dashboard/public/manifest.webmanifest @@ -0,0 +1,27 @@ +{ + "name": "EJClaw Ops", + "short_name": "EJClaw", + "description": "EJClaw operations console", + "start_url": "/", + "scope": "/", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone", "browser"], + "background_color": "#f3efe4", + "theme_color": "#2b3726", + "orientation": "portrait-primary", + "categories": ["productivity", "utilities"], + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/apps/dashboard/public/offline.html b/apps/dashboard/public/offline.html new file mode 100644 index 0000000..f71f86a --- /dev/null +++ b/apps/dashboard/public/offline.html @@ -0,0 +1,87 @@ + + + + + + + EJClaw Offline + + + +
+ EJClaw +

Offline

+

네트워크가 복구되면 대시보드를 다시 불러옵니다.

+ +
+ + diff --git a/apps/dashboard/public/sw.js b/apps/dashboard/public/sw.js new file mode 100644 index 0000000..ec1b737 --- /dev/null +++ b/apps/dashboard/public/sw.js @@ -0,0 +1,78 @@ +const CACHE_NAME = 'ejclaw-dashboard-v1'; +const PRECACHE_URLS = [ + '/', + '/index.html', + '/offline.html', + '/manifest.webmanifest', + '/icons/icon-192.png', + '/icons/icon-512.png', + '/icons/icon-192.svg', + '/icons/icon-512.svg', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches + .open(CACHE_NAME) + .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(() => self.skipWaiting()), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys + .filter((key) => key !== CACHE_NAME) + .map((key) => caches.delete(key)), + ), + ) + .then(() => self.clients.claim()), + ); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + + if (request.method !== 'GET' || url.origin !== self.location.origin) { + return; + } + + if (url.pathname.startsWith('/api/')) { + event.respondWith(fetch(request)); + return; + } + + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put('/', copy)); + return response; + }) + .catch(() => + caches + .match('/') + .then((cached) => cached || caches.match('/offline.html')), + ), + ); + return; + } + + event.respondWith( + caches.match(request).then((cached) => { + if (cached) return cached; + return fetch(request).then((response) => { + if (!response.ok) return response; + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }); + }), + ); +}); diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx new file mode 100644 index 0000000..ba961af --- /dev/null +++ b/apps/dashboard/src/App.tsx @@ -0,0 +1,4439 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { + Activity, + Clock, + Download, + Gauge, + Inbox as InboxIcon, + MessageSquare, + RefreshCw, + Send, + Settings, +} from 'lucide-react'; + +import { + type ClaudeAccountSummary, + type CodexAccountSummary, + type CreateScheduledTaskInput, + type DashboardInboxAction, + type DashboardRoomActivity, + type DashboardTaskContextMode, + type DashboardTaskScheduleType, + type DashboardTaskAction, + type DashboardOverview, + type DashboardTask, + type ModelConfigSnapshot, + type ModelRoleConfig, + type UpdateScheduledTaskInput, + type StatusSnapshot, + addClaudeAccount, + createScheduledTask, + deleteAccount, + fetchAccounts, + fetchDashboardData, + fetchModelConfig, + fetchRoomsTimelineBatch, + runInboxAction, + runServiceAction, + runScheduledTaskAction, + sendRoomMessage, + updateModels, + updateScheduledTask, +} from './api'; +import { + LOCALES, + isLocale, + languageNames, + localeTags, + matchLocale, + messages, + type Locale, + type Messages, +} from './i18n'; +import './styles.css'; + +interface DashboardState { + overview: DashboardOverview; + snapshots: StatusSnapshot[]; + tasks: DashboardTask[]; +} + +type RoomActivityMap = Record; + +type UsageRow = DashboardOverview['usage']['rows'][number]; +type InboxItem = DashboardOverview['inbox'][number]; +type RiskLevel = 'ok' | 'warn' | 'critical'; +type UsageGroup = 'primary' | 'codex'; +type UsageLimitWindow = 'h5' | 'd7'; +type DashboardView = + | 'usage' + | 'inbox' + | 'health' + | 'rooms' + | 'scheduled' + | 'settings'; +type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; +type TaskResultTone = 'ok' | 'fail' | 'none'; +type TaskActionKey = + | 'create' + | `${string}:edit` + | `${string}:${DashboardTaskAction}`; +type InboxActionKey = `${string}:${DashboardInboxAction}`; +type ServiceActionKey = 'stack:restart'; +type InboxFilter = 'all' | InboxItem['kind']; +type HealthLevel = 'ok' | 'stale' | 'down'; +type FreshnessLevel = 'fresh' | 'stale' | 'offline'; + +type BeforeInstallPromptEvent = Event & { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; +}; + +interface RoomOption { + jid: string; + name: string; + folder: 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; +} + +type ParsedSegment = + | { kind: 'text'; value: string } + | { kind: 'code'; lang: string; value: string } + | { kind: 'inline-code'; value: string } + | { kind: 'bold'; value: string } + | { kind: 'italic'; value: string } + | { kind: 'strike'; value: string } + | { kind: 'url'; href: string; label: string } + | { kind: 'mention'; value: string } + | { kind: 'marker'; tone: 'done' | 'fail' | 'info'; value: string } + | { kind: 'path'; value: string }; + +const URL_RE = /\bhttps?:\/\/[^\s)<>]+/g; +const MENTION_RE = /(^|\s)(@[A-Za-z0-9_-]+)/g; +const MARKER_RE = + /\b(TASK_DONE|TASK_FAILED|TASK_FAIL|TASK_OK|DONE|FAILED|FAIL|OK|ERROR|WARNING|RETRY|STATUS|PASS|PASSED)\b/g; +const PATH_RE = /(?:^|\s)((?:\/|\.\/|\.\.\/)[^\s)<>]+)/g; + +function tokenizeInline(input: string): ParsedSegment[] { + if (!input) return []; + const segments: ParsedSegment[] = []; + const matches: Array<{ + start: number; + end: number; + seg: ParsedSegment; + priority: number; + }> = []; + for (const m of input.matchAll(/`([^`\n]+)`/g)) { + matches.push({ + start: m.index ?? 0, + end: (m.index ?? 0) + m[0].length, + seg: { kind: 'inline-code', value: m[1] }, + priority: 0, + }); + } + for (const m of input.matchAll(/\*\*([^*\n]+)\*\*/g)) { + matches.push({ + start: m.index ?? 0, + end: (m.index ?? 0) + m[0].length, + seg: { kind: 'bold', value: m[1] }, + priority: 0, + }); + } + for (const m of input.matchAll(/(? + a.start === b.start ? a.priority - b.priority : a.start - b.start, + ); + let cursor = 0; + const used: Array<[number, number]> = []; + for (const match of matches) { + if (match.start < cursor) continue; + const overlaps = used.some( + ([s, e]) => !(match.end <= s || match.start >= e), + ); + if (overlaps) continue; + if (match.start > cursor) { + segments.push({ kind: 'text', value: input.slice(cursor, match.start) }); + } + segments.push(match.seg); + used.push([match.start, match.end]); + cursor = match.end; + } + if (cursor < input.length) { + segments.push({ kind: 'text', value: input.slice(cursor) }); + } + return segments; +} + +type ParsedBlock = + | { kind: 'paragraph'; segments: ParsedSegment[] } + | { kind: 'code'; lang: string; value: string } + | { kind: 'heading'; level: 1 | 2 | 3 | 4; segments: ParsedSegment[] } + | { kind: 'list'; items: ParsedSegment[][] } + | { kind: 'olist'; items: ParsedSegment[][]; start: number } + | { kind: 'quote'; segments: ParsedSegment[] }; + +function parseTextBlocks(raw: string): ParsedBlock[] { + const blocks: ParsedBlock[] = []; + const lines = raw.split(/\n/); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) { + i++; + continue; + } + const heading = trimmed.match(/^(#{1,4})\s+(.*)$/); + if (heading) { + const level = Math.min(heading[1].length, 4) as 1 | 2 | 3 | 4; + blocks.push({ + kind: 'heading', + level, + segments: tokenizeInline(heading[2]), + }); + i++; + continue; + } + if (/^>\s+/.test(trimmed)) { + const quoteLines: string[] = []; + while (i < lines.length && /^\s*>\s?/.test(lines[i])) { + quoteLines.push(lines[i].replace(/^\s*>\s?/, '')); + i++; + } + blocks.push({ + kind: 'quote', + segments: tokenizeInline(quoteLines.join('\n')), + }); + continue; + } + if (/^[-*]\s+/.test(trimmed)) { + const items: ParsedSegment[][] = []; + while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) { + const itemText = lines[i].replace(/^\s*[-*]\s+/, ''); + items.push(tokenizeInline(itemText)); + i++; + } + blocks.push({ kind: 'list', items }); + continue; + } + const olistMatch = trimmed.match(/^(\d+)\.\s+/); + if (olistMatch) { + const items: ParsedSegment[][] = []; + const start = Number(olistMatch[1]); + while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) { + const itemText = lines[i].replace(/^\s*\d+\.\s+/, ''); + items.push(tokenizeInline(itemText)); + i++; + } + blocks.push({ kind: 'olist', items, start }); + continue; + } + // Plain paragraph: gather consecutive non-empty lines + const paraLines: string[] = []; + while ( + i < lines.length && + lines[i].trim() && + !/^(#{1,4})\s+/.test(lines[i].trim()) && + !/^[-*]\s+/.test(lines[i].trim()) && + !/^\d+\.\s+/.test(lines[i].trim()) && + !/^>\s+/.test(lines[i].trim()) + ) { + paraLines.push(lines[i]); + i++; + } + if (paraLines.length > 0) { + blocks.push({ + kind: 'paragraph', + segments: tokenizeInline(paraLines.join('\n')), + }); + } + } + return blocks; +} + +function parseMessageBody(raw: string | null | undefined): ParsedBlock[] { + if (!raw) return []; + const cleaned = raw + .replace(/<\/?internal[^>]*>/gi, '') + .replace(/<\/?intern\.{3}/gi, '') + .trim(); + if (!cleaned) return []; + const blocks: ParsedBlock[] = []; + const fenceRe = /```([A-Za-z0-9_+-]*)\n?([\s\S]*?)```/g; + let cursor = 0; + let m: RegExpExecArray | null; + while ((m = fenceRe.exec(cleaned)) !== null) { + if (m.index > cursor) { + const pre = cleaned.slice(cursor, m.index).trim(); + if (pre) blocks.push(...parseTextBlocks(pre)); + } + blocks.push({ kind: 'code', lang: m[1] || '', value: m[2] }); + cursor = m.index + m[0].length; + } + if (cursor < cleaned.length) { + const tail = cleaned.slice(cursor).trim(); + if (tail) blocks.push(...parseTextBlocks(tail)); + } + return blocks; +} + +function renderInlineSegments( + segments: ParsedSegment[], + truncate: number | undefined, + usedRef: { used: number }, +): ReactNode[] { + const out: ReactNode[] = []; + segments.forEach((s, j) => { + if (truncate && usedRef.used >= truncate) return; + const remaining = truncate ? truncate - usedRef.used : Infinity; + if (s.kind === 'text') { + const v = + s.value.length > remaining + ? `${s.value.slice(0, remaining)}…` + : s.value; + usedRef.used += v.length; + out.push({v}); + return; + } + if (s.kind === 'inline-code') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + if (s.kind === 'bold') { + usedRef.used += s.value.length; + out.push({s.value}); + return; + } + if (s.kind === 'italic') { + usedRef.used += s.value.length; + out.push({s.value}); + return; + } + if (s.kind === 'strike') { + usedRef.used += s.value.length; + out.push({s.value}); + return; + } + if (s.kind === 'url') { + usedRef.used += s.label.length; + out.push( + + {s.label} + , + ); + return; + } + if (s.kind === 'mention') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + if (s.kind === 'path') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + if (s.kind === 'marker') { + usedRef.used += s.value.length; + out.push( + + {s.value} + , + ); + return; + } + }); + return out; +} + +function ParsedBody({ + text, + truncate, +}: { + text: string | null | undefined; + truncate?: number; +}): ReactNode { + const cleaned = useMemo(() => { + if (!text) return ''; + let out = text + .replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`) + .replace(SECRET_VALUE_RE, '***'); + if (truncate && out.length > truncate) { + out = out.slice(0, truncate) + '…'; + } + return out; + }, [text, truncate]); + + if (!cleaned.trim()) { + return ; + } + + return ( +
+ {cleaned} +
+ ); +} + +function isInternalProtocolPayload( + content: string | null | undefined, +): boolean { + if (!content) return false; + if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true; + if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) { + return true; + } + return false; +} + +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 sanitizeInboxText(value: string | null | undefined): string { + if (!value) return ''; + return value + .replace(/<\/?internal[^>]*>/gi, '') + .replace(/<\/?intern\.{3}/gi, '') + .replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '') + .replace(/\s{2,}/g, ' ') + .trim(); +} + +function formatDate(value: string | null | undefined, locale: Locale): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const now = Date.now(); + const ageMs = now - date.getTime(); + if (ageMs >= 0 && ageMs < 60_000) { + return locale === 'ko' + ? '방금' + : locale === 'ja' + ? 'たった今' + : locale === 'zh' + ? '刚刚' + : 'just now'; + } + if (ageMs >= 0 && ageMs < 3_600_000) { + const mins = Math.floor(ageMs / 60_000); + return locale === 'ko' + ? `${mins}분 전` + : locale === 'ja' + ? `${mins}分前` + : locale === 'zh' + ? `${mins} 分钟前` + : `${mins}m ago`; + } + const sameDay = new Date().toDateString() === date.toDateString(); + if (sameDay) { + return new Intl.DateTimeFormat(localeTags[locale], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + } + const time = new Intl.DateTimeFormat(localeTags[locale], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + if (locale === 'ko') + return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; + if (locale === 'ja') + return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; + if (locale === 'zh') + return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; + return new Intl.DateTimeFormat(localeTags[locale], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +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 formatTaskDate( + value: string | null | undefined, + locale: Locale, +): string { + if (!value) return '-'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const time = new Intl.DateTimeFormat(localeTags[locale], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); + if (locale === 'ko') + return `${date.getMonth() + 1}월 ${date.getDate()}일 ${time}`; + if (locale === 'ja' || locale === 'zh') + return `${date.getMonth() + 1}月${date.getDate()}日 ${time}`; + return new Intl.DateTimeFormat(localeTags[locale], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(date); +} + +function formatRelativeDate( + value: string | null | undefined, + locale: Locale, + t: Messages, +): string { + if (!value) return t.tasks.noTime; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + const diffMs = date.getTime() - Date.now(); + const absMs = Math.abs(diffMs); + if (absMs < 45_000) return t.tasks.now; + + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ['day', 86_400_000], + ['hour', 3_600_000], + ['minute', 60_000], + ]; + const [unit, unitMs] = + units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!; + return new Intl.RelativeTimeFormat(localeTags[locale], { + numeric: 'auto', + style: 'short', + }).format(Math.round(diffMs / unitMs), unit); +} + +function formatPct(value: number): string { + if (value < 0) return '-'; + return `${Math.round(value)}%`; +} + +function usagePeak(row: UsageRow): number { + return Math.max(row.h5pct, row.d7pct); +} + +function usageLimitWindow(row: UsageRow): UsageLimitWindow { + return row.d7pct >= row.h5pct ? 'd7' : 'h5'; +} + +function usageWindowRemaining( + row: UsageRow, + window: UsageLimitWindow, +): number | null { + const pct = window === 'h5' ? row.h5pct : row.d7pct; + if (pct < 0) return null; + return Math.max(0, 100 - pct); +} + +function usageRiskLevel(row: UsageRow): RiskLevel { + const peak = usagePeak(row); + if (peak >= 85) return 'critical'; + if (peak >= 65) return 'warn'; + return 'ok'; +} + +function usageActive(row: UsageRow): boolean { + return row.name.includes('*'); +} + +function usageLimited(row: UsageRow): boolean { + return row.name.includes('!'); +} + +function usageNameParts(row: UsageRow): { + account: string; + plan: string | null; +} { + const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim(); + const parts = cleaned.split(' '); + const plan = parts.at(-1) ?? null; + if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) { + return { account: parts.slice(0, -1).join(' ') || cleaned, plan }; + } + return { account: cleaned, plan: null }; +} + +function usageWindowReset(row: UsageRow, window: UsageLimitWindow): string { + return (window === 'd7' ? row.d7reset : row.h5reset).trim(); +} + +function usageBurnRate(row: UsageRow): number | null { + if (row.h5pct < 0) return null; + return row.h5pct / 5; +} + +function usageSpeedLevel(rate: number | null): RiskLevel { + if (rate === null) return 'ok'; + if (rate >= 12) return 'critical'; + if (rate >= 7) return 'warn'; + return 'ok'; +} + +function formatUsageRate(rate: number | null): string { + if (rate === null) return '-'; + if (rate > 0 && rate < 1) return '<1%/h'; + return `${Math.round(rate)}%/h`; +} + +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}${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}`; +} + +function queueLabel( + pendingTasks: number, + pendingMessages: boolean, + t: Messages, +) { + const parts = [`${pendingTasks} ${t.units.task}`]; + if (pendingMessages) parts.push(t.units.messageShort); + return parts.join(' · '); +} + +const SECRET_ASSIGNMENT_RE = + /\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi; +const SECRET_VALUE_RE = + /\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g; + +function safePreview( + value: string | null | undefined, + fallback: string, +): string { + const cleaned = (value ?? '') + .replace(SECRET_ASSIGNMENT_RE, '$1=') + .replace(SECRET_VALUE_RE, '') + .replace(/<\/?internal[^>]*>/gi, '') + .replace(/\s+/g, ' ') + .trim(); + if (!cleaned) return fallback; + return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned; +} + +function taskGroupKey(task: DashboardTask): TaskGroupKey { + if (task.status === 'completed') return 'completed'; + if (task.status === 'paused') return 'paused'; + if (task.isWatcher) return 'watchers'; + return 'scheduled'; +} + +function taskResultTone(task: DashboardTask): TaskResultTone { + if (!task.lastResult) return 'none'; + const normalized = task.lastResult.toLowerCase(); + if ( + normalized.includes('fail') || + normalized.includes('error') || + normalized.includes('timeout') || + normalized.includes('cancel') || + normalized.includes('reject') + ) { + return 'fail'; + } + return 'ok'; +} + +function taskDisplayName(task: DashboardTask, t: Messages): string { + if (task.isWatcher) return t.tasks.ciWatch; + if (task.scheduleType) return task.scheduleType; + return task.id; +} + +function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { + if (task.status === 'active') return ['pause', 'cancel']; + if (task.status === 'paused') return ['resume', 'cancel']; + return []; +} + +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 isTaskScheduleType( + value: FormDataEntryValue | null, +): value is DashboardTaskScheduleType { + return value === 'cron' || value === 'interval' || value === 'once'; +} + +function isTaskContextMode( + value: FormDataEntryValue | null, +): value is DashboardTaskContextMode { + return value === 'group' || value === 'isolated'; +} + +function readRequiredText(form: FormData, name: string): string | null { + const value = form.get(name); + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function readTaskForm( + form: FormData, + includeRoom: true, +): CreateScheduledTaskInput | null; +function readTaskForm( + form: FormData, + includeRoom: false, +): UpdateScheduledTaskInput | null; +function readTaskForm( + form: FormData, + includeRoom: boolean, +): CreateScheduledTaskInput | UpdateScheduledTaskInput | null { + const prompt = readRequiredText(form, 'prompt'); + const scheduleValue = readRequiredText(form, 'scheduleValue'); + const scheduleTypeValue = form.get('scheduleType'); + if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) { + return null; + } + const scheduleType = scheduleTypeValue; + + if (!includeRoom) { + return prompt + ? { prompt, scheduleType, scheduleValue } + : { scheduleType, scheduleValue }; + } + + if (!prompt) { + return null; + } + + const roomJid = readRequiredText(form, 'roomJid'); + const contextMode = form.get('contextMode'); + if (!roomJid || !isTaskContextMode(contextMode)) return null; + return { + contextMode, + prompt, + roomJid, + scheduleType, + scheduleValue, + }; +} + +function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { + if ( + item.source === 'paired-task' && + (item.kind === 'reviewer-request' || + item.kind === 'approval' || + item.kind === 'arbiter-request') + ) { + return ['run', 'decline', 'dismiss']; + } + return ['dismiss']; +} + +function inboxActionLabel( + item: InboxItem, + action: DashboardInboxAction, + t: Messages, +): string { + if (action === 'dismiss') return t.inbox.actions.dismiss; + if (action === 'decline') return t.inbox.actions.decline; + if (item.kind === 'reviewer-request') return t.inbox.actions.runReview; + if (item.kind === 'approval') return t.inbox.actions.finalize; + if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter; + return t.inbox.actions.run; +} + +const INBOX_FILTERS: InboxFilter[] = [ + 'all', + 'ci-failure', + 'approval', + 'reviewer-request', + 'arbiter-request', + 'pending-room', + 'mention', +]; + +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 inboxTargetHref(item: InboxItem): string | null { + if (item.taskId) return '#/scheduled'; + if (item.roomJid || item.groupFolder) return '#/rooms'; + return null; +} + +const NAV_ICONS: Record = { + usage: , + inbox: , + health: , + rooms: , + scheduled: , + settings: , +}; + +function navItems(t: Messages) { + return [ + { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, + { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, + { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, + { href: '#/health', label: t.nav.health, view: 'health' as const }, + { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, + { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, + ]; +} + +function EmptyState({ children }: { children: ReactNode }) { + return
{children}
; +} + +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 AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { + const [data, setData] = useState<{ + claude: ClaudeAccountSummary[]; + codex: CodexAccountSummary[]; + } | null>(null); + const [busy, setBusy] = useState(false); + 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); + } + } + + return ( +
+

계정

+ {error ?

{error}

: null} + +
+

Claude

+ {!data ? ( +

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

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

계정 없음

+ ) : ( +
    + {data.claude.map((acc) => ( +
  • + #{acc.index} + + {acc.subscriptionType ?? 'unknown'} + {acc.expiresAt + ? ` · 만료 ${new Date(acc.expiresAt).toLocaleDateString()}` + : ''} + + {acc.index > 0 ? ( + + ) : ( + 기본 + )} +
  • + ))} +
+ )} +
+