diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 6d96882..3fb5c71 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -28,6 +28,8 @@ type UsageRow = DashboardOverview['usage']['rows'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled'; +type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; +type TaskResultTone = 'ok' | 'fail' | 'none'; const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale'; @@ -82,6 +84,46 @@ function formatDate(value: string | null | undefined, locale: Locale): string { }).format(date); } +function formatTaskDate( + 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(localeTags[locale], { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).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)}%`; @@ -127,6 +169,52 @@ function queueLabel( 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(/\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 navItems(t: Messages) { return [ { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, @@ -691,111 +779,178 @@ function TaskPanel({ locale: Locale; t: Messages; }) { - const sortedTasks = useMemo( - () => - [...tasks].sort((a, b) => { - const statusRank = { active: 0, paused: 1, completed: 2 } as const; - const rankDelta = statusRank[a.status] - statusRank[b.status]; - if (rankDelta !== 0) return rankDelta; - return (a.nextRun ?? a.createdAt).localeCompare( - b.nextRun ?? b.createdAt, - ); - }), - [tasks], - ); + const taskGroups = useMemo(() => { + const groups: Record = { + watchers: [], + scheduled: [], + paused: [], + completed: [], + }; - if (sortedTasks.length === 0) { + for (const task of tasks) { + groups[taskGroupKey(task)].push(task); + } + + for (const groupTasks of Object.values(groups)) { + groupTasks.sort((a, b) => + (a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare( + b.nextRun ?? b.lastRun ?? b.createdAt, + ), + ); + } + + return [ + { key: 'watchers' as const, tasks: groups.watchers }, + { key: 'scheduled' as const, tasks: groups.scheduled }, + { key: 'paused' as const, tasks: groups.paused }, + { key: 'completed' as const, tasks: groups.completed }, + ]; + }, [tasks]); + + if (tasks.length === 0) { return {t.tasks.empty}; } return ( - <> -
- - - - - - - - - - - - {sortedTasks.map((task) => ( - - - - - - - - ))} - -
{t.tasks.task}{t.tasks.status}{t.tasks.schedule}{t.tasks.next}{t.tasks.last}
- {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} -
-
+
+ {taskGroups.map((group) => { + const label = t.tasks.groups[group.key]; + const groupHead = ( +
+
+ {label} + + {group.tasks.length} {t.tasks.count} + +
+ + {group.tasks.length} + +
+ ); + const groupBody = + group.tasks.length === 0 ? ( +
{t.tasks.groupEmpty}
+ ) : ( +
+ {group.tasks.map((task) => { + const resultTone = taskResultTone(task); + const lastResult = safePreview( + task.lastResult, + t.tasks.noResult, + ); + return ( +
+
+
+ {taskDisplayName(task, t)} + {task.groupFolder} +
+
+ + {statusLabel(task.status, t)} + + {task.ciProvider ? ( + + {task.ciProvider} + + ) : null} +
+
-
- {sortedTasks.map((task) => ( -
-
-
- {task.isWatcher ? t.tasks.ciWatch : task.id} - {task.groupFolder} -
- - {statusLabel(task.status, t)} - +
+ + {t.tasks.next} + {formatTaskDate(task.nextRun, locale)} + {formatRelativeDate(task.nextRun, locale, t)} + + + {t.tasks.last} + {formatTaskDate(task.lastRun, locale)} + {formatRelativeDate(task.lastRun, locale, t)} + + + {t.tasks.schedule} + {task.scheduleType} + {task.scheduleValue} + +
+ + {task.suspendedUntil ? ( +
+ {t.tasks.suspendedUntil} + + {formatTaskDate(task.suspendedUntil, locale)} + + + {formatRelativeDate(task.suspendedUntil, locale, t)} + +
+ ) : null} + +
+ + {resultTone === 'fail' + ? t.tasks.resultFail + : resultTone === 'ok' + ? t.tasks.resultOk + : t.tasks.result} + + {lastResult} +
+ +
+ {t.tasks.prompt} +

+ {safePreview(task.promptPreview, t.tasks.emptyPrompt)} +

+ + {task.id} · {task.contextMode} · {task.promptLength}{' '} + {t.units.chars} + +
+
+ ); + })}
-

{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} -
- ))} -
- + ); + + if (group.key === 'completed') { + return ( +
+ +
+ {label} + + {group.tasks.length} {t.tasks.count} + +
+ + {group.tasks.length} + +
+ {groupBody} +
+ ); + } + + return ( +
+ {groupHead} + {groupBody} +
+ ); + })} +
); } diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index f249a02..ba0820c 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -90,6 +90,14 @@ export interface Messages { tasks: { empty: string; cardsAria: string; + groups: { + watchers: string; + scheduled: string; + paused: string; + completed: string; + }; + count: string; + groupEmpty: string; task: string; status: string; schedule: string; @@ -98,8 +106,16 @@ export interface Messages { context: string; ciWatch: string; emptyPrompt: string; + prompt: string; until: string; + suspendedUntil: string; lastResult: string; + result: string; + resultOk: string; + resultFail: string; + noResult: string; + noTime: string; + now: string; }; status: { processing: string; @@ -222,6 +238,14 @@ export const messages = { tasks: { empty: '예약 작업 없음.', cardsAria: '예약 작업 카드', + groups: { + watchers: 'CI 감시', + scheduled: '예약', + paused: '일시정지', + completed: '완료', + }, + count: '개', + groupEmpty: '해당 없음', task: '작업', status: '상태', schedule: '스케줄', @@ -230,8 +254,16 @@ export const messages = { context: '컨텍스트', ciWatch: 'CI 감시', emptyPrompt: '(빈 미리보기)', + prompt: '프롬프트', until: '까지', + suspendedUntil: '정지 해제', lastResult: '최근 결과', + result: '결과 없음', + resultOk: '정상', + resultFail: '실패', + noResult: '결과 없음', + noTime: '-', + now: '지금', }, status: { processing: '처리중', @@ -338,6 +370,14 @@ export const messages = { tasks: { empty: 'No scheduled work.', cardsAria: 'Scheduled task cards', + groups: { + watchers: 'CI watch', + scheduled: 'Scheduled', + paused: 'Paused', + completed: 'Completed', + }, + count: 'items', + groupEmpty: 'None', task: 'task', status: 'status', schedule: 'schedule', @@ -346,8 +386,16 @@ export const messages = { context: 'context', ciWatch: 'CI Watch', emptyPrompt: '(empty preview)', + prompt: 'prompt', until: 'until', + suspendedUntil: 'resume', lastResult: 'last result', + result: 'no result', + resultOk: 'ok', + resultFail: 'failed', + noResult: 'no result', + noTime: '-', + now: 'now', }, status: { processing: 'processing', @@ -454,6 +502,14 @@ export const messages = { tasks: { empty: '暂无计划任务。', cardsAria: '计划任务卡片', + groups: { + watchers: 'CI 监控', + scheduled: '计划', + paused: '暂停', + completed: '完成', + }, + count: '项', + groupEmpty: '无', task: '任务', status: '状态', schedule: '计划', @@ -462,8 +518,16 @@ export const messages = { context: '上下文', ciWatch: 'CI 监控', emptyPrompt: '(空预览)', + prompt: '提示', until: '直到', + suspendedUntil: '恢复', lastResult: '最近结果', + result: '无结果', + resultOk: '正常', + resultFail: '失败', + noResult: '无结果', + noTime: '-', + now: '现在', }, status: { processing: '处理中', @@ -570,6 +634,14 @@ export const messages = { tasks: { empty: '予定作業なし。', cardsAria: '予定作業カード', + groups: { + watchers: 'CI監視', + scheduled: '予定', + paused: '一時停止', + completed: '完了', + }, + count: '件', + groupEmpty: 'なし', task: '作業', status: '状態', schedule: '予定', @@ -578,8 +650,16 @@ export const messages = { context: 'コンテキスト', ciWatch: 'CI監視', emptyPrompt: '(空のプレビュー)', + prompt: 'プロンプト', until: 'まで', + suspendedUntil: '再開', lastResult: '直近結果', + result: '結果なし', + resultOk: '正常', + resultFail: '失敗', + noResult: '結果なし', + noTime: '-', + now: '今', }, status: { processing: '処理中', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 26a161b..7c157b4 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -837,6 +837,236 @@ progress::-moz-progress-bar { background: rgba(255, 250, 240, 0.45); } +.task-board { + display: grid; + gap: 12px; +} + +.task-group { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(43, 55, 38, 0.11); + border-radius: 22px; + background: rgba(255, 250, 240, 0.54); +} + +.task-group-completed { + display: block; +} + +.task-group-completed[open] { + display: grid; +} + +.task-group-completed summary { + cursor: pointer; + list-style: none; +} + +.task-group-completed summary::-webkit-details-marker { + display: none; +} + +.task-group-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.task-group-head strong { + display: block; + margin-top: 2px; + font-size: 18px; + letter-spacing: -0.03em; +} + +.task-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 360px)); + gap: 10px; +} + +.task-card { + display: grid; + gap: 9px; + min-width: 0; + padding: 12px; + border: 1px solid rgba(43, 55, 38, 0.1); + border-radius: 18px; + background: rgba(255, 250, 240, 0.72); +} + +.task-card-main, +.task-time-grid, +.task-suspended, +.task-result { + min-width: 0; +} + +.task-card-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} + +.task-title { + min-width: 0; +} + +.task-title strong { + display: block; + overflow: hidden; + font-size: 15px; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-status-line { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.task-provider { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 9px; + border-radius: 999px; + color: var(--muted); + font-size: 12px; + font-weight: 800; + background: rgba(43, 55, 38, 0.08); +} + +.task-time-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} + +.task-time-grid > span, +.task-suspended, +.task-result { + padding: 9px; + border: 1px solid rgba(43, 55, 38, 0.08); + border-radius: 14px; + background: rgba(43, 55, 38, 0.04); +} + +.task-time-grid small, +.task-suspended span, +.task-result span, +.task-time-grid em, +.task-suspended em { + color: var(--muted); + font-size: 11px; + font-style: normal; + font-weight: 780; +} + +.task-time-grid small, +.task-time-grid strong, +.task-time-grid em, +.task-suspended span, +.task-suspended strong, +.task-suspended em, +.task-result span, +.task-result strong { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-time-grid strong, +.task-suspended strong, +.task-result strong { + margin-top: 3px; +} + +.task-suspended { + border-color: rgba(181, 138, 34, 0.22); + background: rgba(181, 138, 34, 0.08); +} + +.task-result { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; + border-color: rgba(63, 127, 81, 0.15); +} + +.result-fail { + border-color: rgba(183, 71, 52, 0.28); + background: rgba(183, 71, 52, 0.08); +} + +.result-none { + border-color: rgba(109, 115, 95, 0.14); +} + +.task-prompt { + color: var(--muted); +} + +.task-prompt summary { + min-height: 32px; + color: var(--accent-strong); + font-size: 12px; + font-weight: 900; + cursor: pointer; +} + +.task-prompt p { + margin: 4px 0; + overflow-wrap: anywhere; +} + +.task-prompt small { + display: block; + overflow: hidden; + font-family: + 'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-group-empty { + padding: 12px; + border-radius: 14px; + color: var(--muted); + background: rgba(43, 55, 38, 0.04); +} + +.pill-watchers { + color: #173e23; + background: rgba(63, 127, 81, 0.18); +} + +.pill-scheduled { + color: #6e4a05; + background: rgba(181, 138, 34, 0.16); +} + +.pill-paused { + color: #6e4a05; + background: rgba(181, 138, 34, 0.2); +} + +.pill-completed { + color: #625d52; + background: rgba(109, 115, 95, 0.16); +} + .record-card { display: grid; gap: 14px; @@ -1139,6 +1369,39 @@ progress::-moz-progress-bar { .record-card-grid { grid-template-columns: 1fr 1fr; } + + .task-group { + padding: 10px; + } + + .task-list { + grid-template-columns: 1fr; + gap: 8px; + } + + .task-card { + gap: 7px; + padding: 9px; + } + + .task-card-main { + gap: 7px; + } + + .task-time-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + } + + .task-time-grid > span, + .task-suspended, + .task-result { + padding: 7px; + } + + .task-prompt summary { + min-height: 28px; + } } @media (max-width: 380px) { @@ -1176,6 +1439,14 @@ progress::-moz-progress-bar { .record-card-grid { grid-template-columns: 1fr; } + + .task-time-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .task-time-grid > span:last-child { + grid-column: auto; + } } @media (prefers-reduced-motion: reduce) {