From e195bf88247a4b2ca3061e4c0d3579fb98defb29 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Sun, 26 Apr 2026 23:40:07 +0900 Subject: [PATCH] feat(dashboard): add inbox and health views --- apps/dashboard/src/App.tsx | 315 +++++++++++++++++++++++++++++---- apps/dashboard/src/api.ts | 22 +++ apps/dashboard/src/i18n.ts | 230 ++++++++++++++++++++++++ apps/dashboard/src/styles.css | 322 ++++++++++++++++++++++++++++------ 4 files changed, 795 insertions(+), 94 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 3fb5c71..5f14ee2 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -25,21 +25,27 @@ interface DashboardState { } type UsageRow = DashboardOverview['usage']['rows'][number]; +type InboxItem = DashboardOverview['inbox'][number]; type RiskLevel = 'ok' | 'warn' | 'critical'; type UsageGroup = 'primary' | 'codex'; -type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled'; +type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskResultTone = 'ok' | 'fail' | 'none'; +type InboxFilter = 'all' | InboxItem['kind']; +type HealthLevel = 'ok' | 'stale' | 'down'; const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale'; const DEFAULT_VIEW: DashboardView = 'usage'; +const HEALTH_STALE_MS = 5 * 60_000; +const HEALTH_DOWN_MS = 15 * 60_000; function isDashboardView( value: string | null | undefined, ): value is DashboardView { return ( value === 'usage' || + value === 'inbox' || value === 'health' || value === 'rooms' || value === 'scheduled' @@ -215,9 +221,47 @@ function taskDisplayName(task: DashboardTask, t: Messages): string { return task.id; } +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; +} + function navItems(t: Messages) { return [ { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, + { href: '#/inbox', label: t.nav.inbox, view: 'inbox' as const }, { href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, @@ -496,7 +540,7 @@ function ControlRail({ data, t }: { data: DashboardState; t: Messages }) { ); } -function ServicePanel({ +function InboxPanel({ overview, locale, t, @@ -505,42 +549,229 @@ function ServicePanel({ locale: Locale; t: Messages; }) { - if (overview.services.length === 0) { - return {t.service.empty}; + const [filter, setFilter] = useState('all'); + const items = overview.inbox ?? []; + const counts = useMemo(() => { + const next: Record = { + all: items.length, + 'pending-room': 0, + 'reviewer-request': 0, + approval: 0, + 'arbiter-request': 0, + 'ci-failure': 0, + mention: 0, + }; + for (const item of items) next[item.kind] += 1; + return next; + }, [items]); + const filteredItems = + filter === 'all' ? items : items.filter((item) => item.kind === filter); + const severityCounts = items.reduce( + (acc, item) => { + acc[item.severity] += 1; + return acc; + }, + { error: 0, warn: 0, info: 0 }, + ); + + if (items.length === 0) { + return {t.inbox.empty}; } return ( -
- {overview.services.map((service) => ( -
-
- {service.agentType} -

{service.assistantName}

-
-
- - - {t.service.heartbeat} {formatDate(service.updatedAt, locale)} - -
-
-
-
{t.service.service}
-
{service.serviceId}
-
-
-
{t.service.rooms}
-
- {service.activeRooms}/{service.totalRooms} {t.status.active} -
-
-
-
{t.service.updated}
-
{formatDate(service.updatedAt, locale)}
-
-
-
- ))} +
+
+
+ {t.inbox.total} + {items.length} +
+
+ {t.inbox.severity.error} + {severityCounts.error} +
+
+ {t.inbox.severity.warn} + {severityCounts.warn} +
+
+ {t.inbox.severity.info} + {severityCounts.info} +
+
+ +
+ {INBOX_FILTERS.map((item) => { + if (item !== 'all' && counts[item] === 0) return null; + const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item]; + return ( + + ); + })} +
+ +
+ {filteredItems.map((item) => { + const href = inboxTargetHref(item); + return ( +
+
+
+ {t.inbox.kinds[item.kind]} + {item.title} +
+ + {t.inbox.severity[item.severity]} + +
+

{item.summary || t.inbox.noSummary}

+
+ + {t.inbox.occurred} + {formatDate(item.occurredAt, locale)} + + + {t.inbox.source} + {item.source} + + + {t.inbox.target} + + {item.taskId ?? + item.roomName ?? + item.groupFolder ?? + item.roomJid ?? + '-'} + + +
+ {href ? ( + + {item.taskId ? t.inbox.openTask : t.inbox.openRoom} + + ) : null} +
+ ); + })} +
+
+ ); +} + +function HealthPanel({ + data, + locale, + t, +}: { + data: DashboardState; + locale: Locale; + t: Messages; +}) { + const services = data.overview.services; + const serviceLevels = services.map((service) => ({ + service, + level: serviceHealthLevel(service, data.overview.generatedAt), + age: serviceAgeMs(service, data.overview.generatedAt), + })); + const down = serviceLevels.filter((item) => item.level === 'down').length; + const stale = serviceLevels.filter((item) => item.level === 'stale').length; + const queue = data.snapshots.reduce( + (acc, snapshot) => { + for (const entry of snapshot.entries) { + acc.pendingTasks += entry.pendingTasks; + if (entry.pendingMessages) acc.pendingMessageRooms += 1; + } + return acc; + }, + { pendingTasks: 0, pendingMessageRooms: 0 }, + ); + const ciFailures = data.overview.inbox.filter( + (item) => item.kind === 'ci-failure', + ).length; + const healthLevel: HealthLevel = + down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok'; + + return ( +
+
+
+ {t.health.system} + {t.health.levels[healthLevel]} +
+

{t.health.summary}

+
+ +
+
+ {t.health.services} + + {services.length - stale - down}/{services.length} + + {t.health.fresh} +
+
+ {t.health.stale} + {stale + down} + + {down} {t.health.levels.down} + +
+
+ {t.health.queue} + {queue.pendingTasks} + + {queue.pendingMessageRooms} {t.control.pendingRooms} + +
+
+ {t.health.ciFailures} + {ciFailures} + + {data.overview.tasks.watchers.paused} {t.status.paused} + +
+
+ + {services.length === 0 ? ( + {t.service.empty} + ) : ( +
+ {serviceLevels.map(({ service, level, age }) => ( +
+
+ {service.agentType} + {service.assistantName} + {service.serviceId} +
+ + {t.health.levels[level]} + +
+ {t.service.updated} + {formatDate(service.updatedAt, locale)} + {formatDuration(age, t)} +
+
+ {t.service.rooms} + + {service.activeRooms}/{service.totalRooms} + +
+
+ ))} +
+ )}
); } @@ -1082,13 +1313,23 @@ function App() { ) : null} + {activeView === 'inbox' ? ( +
+
+

{t.panels.inbox}

+ {t.panels.inboxQueue} +
+ +
+ ) : null} + {activeView === 'health' ? (

{t.panels.health}

- {t.panels.heartbeat} + {t.panels.healthSignals}
- +
) : null} diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 031eec8..67c8646 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -35,6 +35,28 @@ export interface DashboardOverview { }>; fetchedAt: string | null; }; + inbox: Array<{ + id: string; + kind: + | 'pending-room' + | 'reviewer-request' + | 'approval' + | 'arbiter-request' + | 'ci-failure' + | 'mention'; + severity: 'info' | 'warn' | 'error'; + title: string; + summary: string; + occurredAt: string; + createdAt: string; + source: 'status-snapshot' | 'paired-task' | 'scheduled-task'; + roomJid?: string; + roomName?: string; + groupFolder?: string; + serviceId?: string; + taskId?: string; + taskStatus?: string; + }>; } export interface StatusSnapshot { diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index ba0820c..6ef0e50 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -21,6 +21,7 @@ export interface Messages { operations: string; updated: string; health: string; + inbox: string; usage: string; rooms: string; scheduled: string; @@ -45,6 +46,9 @@ export interface Messages { panels: { health: string; heartbeat: string; + healthSignals: string; + inbox: string; + inboxQueue: string; usage: string; usageWindow: string; rooms: string; @@ -59,6 +63,48 @@ export interface Messages { rooms: string; updated: string; }; + health: { + system: string; + summary: string; + signals: string; + services: string; + fresh: string; + stale: string; + queue: string; + ciFailures: string; + levels: { + ok: string; + stale: string; + down: string; + }; + }; + inbox: { + empty: string; + cardsAria: string; + summary: string; + total: string; + filters: string; + all: string; + noSummary: string; + occurred: string; + source: string; + target: string; + openTask: string; + openRoom: string; + kinds: { + 'pending-room': string; + 'reviewer-request': string; + approval: string; + 'arbiter-request': string; + 'ci-failure': string; + mention: string; + }; + severity: { + info: string; + warn: string; + error: string; + }; + }; rooms: { empty: string; cardsAria: string; @@ -169,6 +215,7 @@ export const messages = { operations: '운영', updated: '갱신', health: '상태', + inbox: '인입', usage: '사용량', rooms: '룸', scheduled: '예약', @@ -193,6 +240,9 @@ export const messages = { panels: { health: '상태', heartbeat: '하트비트', + healthSignals: '운영 신호', + inbox: '인입', + inboxQueue: '대기 항목', usage: '사용량', usageWindow: '5시간 / 7일', rooms: '룸', @@ -207,6 +257,48 @@ export const messages = { rooms: '룸', updated: '갱신', }, + health: { + system: '시스템', + summary: '헬스는 heartbeat, 큐, CI 실패를 합산합니다.', + signals: '헬스 신호', + services: '서비스', + fresh: '정상 heartbeat', + stale: '지연', + queue: '큐', + ciFailures: 'CI 실패', + levels: { + ok: '정상', + stale: '주의', + down: '중단', + }, + }, + inbox: { + empty: '인입 없음.', + cardsAria: '인입 항목', + summary: '인입 요약', + total: '전체', + filters: '인입 필터', + all: '전체', + noSummary: '요약 없음', + occurred: '발생', + source: '소스', + target: '대상', + openTask: '예약 보기', + openRoom: '룸 보기', + kinds: { + 'pending-room': '대기 룸', + 'reviewer-request': '리뷰 요청', + approval: '승인', + 'arbiter-request': '중재 요청', + 'ci-failure': 'CI 실패', + mention: '멘션', + }, + severity: { + info: '정보', + warn: '주의', + error: '위험', + }, + }, rooms: { empty: '룸 없음.', cardsAria: '룸 상태 카드', @@ -301,6 +393,7 @@ export const messages = { operations: 'Ops', updated: 'Updated', health: 'Health', + inbox: 'Inbox', usage: 'Usage', rooms: 'Rooms', scheduled: 'Scheduled', @@ -325,6 +418,9 @@ export const messages = { panels: { health: 'Health', heartbeat: 'Heartbeat', + healthSignals: 'Signals', + inbox: 'Inbox', + inboxQueue: 'Work intake', usage: 'Usage', usageWindow: '5h / 7d', rooms: 'Rooms', @@ -339,6 +435,48 @@ export const messages = { rooms: 'rooms', updated: 'updated', }, + health: { + system: 'System', + summary: 'Health combines heartbeat, queue, and CI failures.', + signals: 'Health signals', + services: 'Services', + fresh: 'fresh heartbeat', + stale: 'stale', + queue: 'Queue', + ciFailures: 'CI failures', + levels: { + ok: 'OK', + stale: 'Watch', + down: 'Down', + }, + }, + inbox: { + empty: 'No inbound work.', + cardsAria: 'Inbox items', + summary: 'Inbox summary', + total: 'Total', + filters: 'Inbox filters', + all: 'All', + noSummary: 'No summary', + occurred: 'Occurred', + source: 'Source', + target: 'Target', + openTask: 'Open scheduled', + openRoom: 'Open room', + kinds: { + 'pending-room': 'Pending room', + 'reviewer-request': 'Review request', + approval: 'Approval', + 'arbiter-request': 'Arbiter request', + 'ci-failure': 'CI failure', + mention: 'Mention', + }, + severity: { + info: 'Info', + warn: 'Warn', + error: 'Risk', + }, + }, rooms: { empty: 'No rooms yet.', cardsAria: 'Room status cards', @@ -433,6 +571,7 @@ export const messages = { operations: '运营', updated: '更新', health: '健康', + inbox: '收件', usage: '用量', rooms: '房间', scheduled: '计划', @@ -457,6 +596,9 @@ export const messages = { panels: { health: '健康', heartbeat: '心跳', + healthSignals: '运行信号', + inbox: '收件', + inboxQueue: '待处理', usage: '用量', usageWindow: '5小时 / 7天', rooms: '房间', @@ -471,6 +613,48 @@ export const messages = { rooms: '房间', updated: '更新', }, + health: { + system: '系统', + summary: '健康状态汇总心跳、队列和 CI 失败。', + signals: '健康信号', + services: '服务', + fresh: '心跳正常', + stale: '延迟', + queue: '队列', + ciFailures: 'CI 失败', + levels: { + ok: '正常', + stale: '关注', + down: '中断', + }, + }, + inbox: { + empty: '暂无收件。', + cardsAria: '收件项', + summary: '收件摘要', + total: '全部', + filters: '收件筛选', + all: '全部', + noSummary: '无摘要', + occurred: '发生', + source: '来源', + target: '目标', + openTask: '查看计划', + openRoom: '查看房间', + kinds: { + 'pending-room': '待处理房间', + 'reviewer-request': '评审请求', + approval: '审批', + 'arbiter-request': '仲裁请求', + 'ci-failure': 'CI 失败', + mention: '提及', + }, + severity: { + info: '信息', + warn: '关注', + error: '风险', + }, + }, rooms: { empty: '暂无房间。', cardsAria: '房间状态卡片', @@ -565,6 +749,7 @@ export const messages = { operations: '運用', updated: '更新', health: '状態', + inbox: '受信', usage: '使用量', rooms: 'ルーム', scheduled: '予定', @@ -589,6 +774,9 @@ export const messages = { panels: { health: '状態', heartbeat: 'ハートビート', + healthSignals: '運用シグナル', + inbox: '受信', + inboxQueue: '対応待ち', usage: '使用量', usageWindow: '5時間 / 7日', rooms: 'ルーム', @@ -603,6 +791,48 @@ export const messages = { rooms: 'ルーム', updated: '更新', }, + health: { + system: 'システム', + summary: '状態はハートビート、キュー、CI失敗を集計します。', + signals: '状態シグナル', + services: 'サービス', + fresh: '正常ハートビート', + stale: '遅延', + queue: 'キュー', + ciFailures: 'CI失敗', + levels: { + ok: '正常', + stale: '注意', + down: '停止', + }, + }, + inbox: { + empty: '受信なし。', + cardsAria: '受信項目', + summary: '受信サマリー', + total: '全体', + filters: '受信フィルター', + all: '全体', + noSummary: '概要なし', + occurred: '発生', + source: 'ソース', + target: '対象', + openTask: '予定を見る', + openRoom: 'ルームを見る', + kinds: { + 'pending-room': '待機ルーム', + 'reviewer-request': 'レビュー依頼', + approval: '承認', + 'arbiter-request': '仲裁依頼', + 'ci-failure': 'CI失敗', + mention: 'メンション', + }, + severity: { + info: '情報', + warn: '注意', + error: '危険', + }, + }, rooms: { empty: 'ルームなし。', cardsAria: 'ルーム状態カード', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 7c157b4..b18e4db 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -523,63 +523,6 @@ dd, letter-spacing: -0.05em; } -.service-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 14px; -} - -.service-card { - display: grid; - gap: 18px; - padding: 20px; - border-radius: 24px; - background: rgba(255, 250, 240, 0.72); -} - -.service-card h3 { - margin: 6px 0 0; - font-size: 26px; - letter-spacing: -0.04em; -} - -.heartbeat-line { - display: flex; - gap: 10px; - align-items: center; - color: var(--muted); -} - -.heartbeat-line span { - width: 10px; - height: 10px; - border-radius: 999px; - background: var(--green); - box-shadow: 0 0 0 7px rgba(63, 127, 81, 0.13); -} - -dl { - display: grid; - gap: 10px; - margin: 0; -} - -dl div { - display: flex; - gap: 12px; - justify-content: space-between; -} - -dt { - color: var(--muted); - font-weight: 750; -} - -dd { - margin: 0; - text-align: right; -} - .table-wrap { overflow: auto; border: 1px solid rgba(43, 55, 38, 0.1); @@ -837,6 +780,235 @@ progress::-moz-progress-bar { background: rgba(255, 250, 240, 0.45); } +.pill-info { + color: #625d52; + background: rgba(109, 115, 95, 0.16); +} + +.pill-error, +.pill-down { + color: #7c2518; + background: rgba(183, 71, 52, 0.18); +} + +.pill-stale { + color: #6e4a05; + background: rgba(181, 138, 34, 0.2); +} + +.inbox-board, +.health-board { + display: grid; + gap: 12px; +} + +.inbox-summary, +.health-signals { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; +} + +.inbox-summary div, +.health-signals div, +.health-overview, +.inbox-card, +.health-service { + border: 1px solid rgba(43, 55, 38, 0.1); + border-radius: 18px; + background: rgba(255, 250, 240, 0.64); +} + +.inbox-summary div, +.health-signals div { + display: grid; + gap: 4px; + min-width: 0; + padding: 11px 12px; +} + +.inbox-summary span, +.health-signals span, +.inbox-meta small, +.health-service small, +.health-service em { + color: var(--muted); +} + +.inbox-summary strong, +.health-signals strong { + font-size: clamp(18px, 2vw, 28px); + line-height: 1; +} + +.inbox-filters { + display: flex; + gap: 7px; + overflow-x: auto; + padding-bottom: 2px; +} + +.inbox-filters button { + display: inline-flex; + min-height: 38px; + flex: none; + gap: 7px; + align-items: center; + padding: 0 12px; + color: var(--bg-ink); + background: rgba(43, 55, 38, 0.08); + box-shadow: none; + font-size: 13px; + font-weight: 900; +} + +.inbox-filters button.is-active, +.inbox-filters button[aria-pressed='true'] { + color: #fffaf0; + background: linear-gradient(135deg, #2b3726, #1c211c); +} + +.inbox-filters span { + color: inherit; + opacity: 0.72; +} + +.inbox-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(290px, 1fr)); + gap: 10px; +} + +.inbox-card { + display: grid; + gap: 10px; + min-width: 0; + padding: 13px; +} + +.inbox-error { + border-color: rgba(183, 71, 52, 0.26); + background: rgba(255, 244, 238, 0.72); +} + +.inbox-warn { + border-color: rgba(181, 138, 34, 0.24); +} + +.inbox-card-head, +.health-service { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} + +.inbox-card-head strong, +.inbox-card p, +.inbox-meta strong, +.health-service strong, +.health-service small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inbox-card p { + margin: 0; + color: var(--bg-ink); + overflow-wrap: anywhere; + white-space: normal; +} + +.inbox-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; +} + +.inbox-meta span { + min-width: 0; + padding: 8px; + border-radius: 12px; + background: rgba(43, 55, 38, 0.045); +} + +.inbox-meta small, +.inbox-meta strong { + display: block; +} + +.inbox-target { + display: inline-flex; + width: fit-content; + min-height: 34px; + align-items: center; + padding: 0 12px; + border-radius: 999px; + color: var(--accent-strong); + background: rgba(191, 95, 44, 0.11); + font-size: 13px; + font-weight: 900; + text-decoration: none; +} + +.health-overview { + display: grid; + grid-template-columns: minmax(0, 0.42fr) minmax(0, 1fr); + gap: 14px; + align-items: center; + padding: 16px; +} + +.health-overview strong { + display: block; + margin-top: 4px; + font-size: clamp(24px, 3vw, 40px); + letter-spacing: -0.05em; +} + +.health-overview p { + margin: 0; + color: var(--muted); +} + +.health-down { + border-color: rgba(183, 71, 52, 0.32); +} + +.health-stale { + border-color: rgba(181, 138, 34, 0.28); +} + +.health-service-list { + display: grid; + gap: 8px; +} + +.health-service { + grid-template-columns: minmax(0, 1.2fr) auto minmax(0, 0.9fr) minmax( + 64px, + 0.35fr + ); + align-items: center; + padding: 12px; +} + +.health-service > div { + min-width: 0; +} + +.health-service strong, +.health-service small, +.health-service em { + display: block; +} + +.health-service em { + font-style: normal; +} + .task-board { display: grid; gap: 12px; @@ -1370,6 +1542,37 @@ progress::-moz-progress-bar { grid-template-columns: 1fr 1fr; } + .inbox-summary, + .health-signals { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .inbox-list { + grid-template-columns: 1fr; + } + + .inbox-card { + padding: 10px; + } + + .inbox-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .health-overview { + grid-template-columns: 1fr; + gap: 8px; + } + + .health-service { + grid-template-columns: minmax(0, 1fr) auto; + } + + .health-service > div:nth-of-type(2), + .health-service > div:nth-of-type(3) { + grid-column: 1 / -1; + } + .task-group { padding: 10px; } @@ -1440,6 +1643,11 @@ progress::-moz-progress-bar { grid-template-columns: 1fr; } + .inbox-meta, + .health-signals { + grid-template-columns: 1fr; + } + .task-time-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }