diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx
index 11e92ca..864702d 100644
--- a/apps/dashboard/src/App.tsx
+++ b/apps/dashboard/src/App.tsx
@@ -36,7 +36,7 @@ type HealthLevel = 'ok' | 'stale' | 'down';
const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
-const DEFAULT_VIEW: DashboardView = 'usage';
+const DEFAULT_VIEW: DashboardView = 'inbox';
const HEALTH_STALE_MS = 5 * 60_000;
const HEALTH_DOWN_MS = 15 * 60_000;
@@ -139,6 +139,12 @@ function usagePeak(row: UsageRow): number {
return Math.max(row.h5pct, row.d7pct);
}
+function usageRemaining(row: UsageRow): number | null {
+ const peak = usagePeak(row);
+ if (peak < 0) return null;
+ return Math.max(0, 100 - peak);
+}
+
function usageRiskLevel(row: UsageRow): RiskLevel {
const peak = usagePeak(row);
if (peak >= 85) return 'critical';
@@ -146,6 +152,49 @@ function usageRiskLevel(row: UsageRow): RiskLevel {
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 usageLimitReset(row: UsageRow): string {
+ return (row.d7pct >= row.h5pct ? 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';
}
@@ -322,7 +371,6 @@ function LoadingSkeleton({ t }: { t: Messages }) {
function SideRail({
activeView,
- lastRefreshed,
locale,
onNavigate,
onLocaleChange,
@@ -331,7 +379,6 @@ function SideRail({
t,
}: {
activeView: DashboardView;
- lastRefreshed: string | null;
locale: Locale;
onNavigate: (view: DashboardView) => void;
onLocaleChange: (locale: Locale) => void;
@@ -368,10 +415,6 @@ function SideRail({
>
{refreshing ? t.actions.refreshing : t.actions.refresh}
-
- {t.nav.updated}
- {formatDate(lastRefreshed, locale)}
-
);
}
@@ -379,7 +422,6 @@ function SideRail({
function SectionNav({
activeView,
drawerOpen,
- lastRefreshed,
locale,
onCloseDrawer,
onLocaleChange,
@@ -391,7 +433,6 @@ function SectionNav({
}: {
activeView: DashboardView;
drawerOpen: boolean;
- lastRefreshed: string | null;
locale: Locale;
onCloseDrawer: () => void;
onLocaleChange: (locale: Locale) => void;
@@ -430,9 +471,6 @@ function SectionNav({
>
{refreshing ? '...' : t.actions.refresh}
-
- {t.nav.updated} {formatDate(lastRefreshed, locale)}
-
{drawerOpen ? (
@@ -484,10 +522,6 @@ function SectionNav({
onLocaleChange={onLocaleChange}
t={t}
/>
-
- {t.nav.updated}
- {formatDate(lastRefreshed, locale)}
-
>
) : null}
@@ -631,9 +665,14 @@ function InboxPanel({
{t.inbox.kinds[item.kind]}
{item.title}
-
- {t.inbox.severity[item.severity]}
-
+
+
+ {t.inbox.severity[item.severity]}
+
+ {item.occurrences > 1 ? (
+ x{item.occurrences}
+ ) : null}
+
{item.summary || t.inbox.noSummary}
@@ -696,9 +735,11 @@ function HealthPanel({
},
{ pendingTasks: 0, pendingMessageRooms: 0 },
);
- const ciFailures = data.overview.inbox.filter(
- (item) => item.kind === 'ci-failure',
- ).length;
+ const ciFailures = data.overview.inbox.reduce(
+ (count, item) =>
+ item.kind === 'ci-failure' ? count + item.occurrences : count,
+ 0,
+ );
const healthLevel: HealthLevel =
down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok';
const affectedServices = serviceLevels.filter((item) => item.level !== 'ok');
@@ -735,9 +776,6 @@ function HealthPanel({
{t.health.ciFailures}
{ciFailures}
-
- {data.overview.tasks.watchers.paused} {t.status.paused}
-
@@ -803,10 +841,9 @@ function RoomPanel({
| {t.rooms.room} |
- {t.rooms.service} |
- {t.rooms.agent} |
{t.rooms.status} |
{t.rooms.queue} |
+ {t.rooms.elapsed} |
@@ -814,21 +851,23 @@ function RoomPanel({
{entry.name}
-
- {entry.folder} · {entry.jid}
-
+
+ {t.rooms.details}
+
+ {entry.folder} · {entry.jid} · {entry.serviceId} ·{' '}
+ {entry.agentType}
+
+
|
- {entry.serviceId} |
- {entry.agentType} |
{statusLabel(entry.status, t)}
- {formatDuration(entry.elapsedMs, t)}
|
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
|
+ {formatDuration(entry.elapsedMs, t)} |
))}
@@ -857,20 +896,18 @@ function RoomPanel({
{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}
+
+ {t.rooms.details}
+
+ {entry.folder} · {entry.jid} · {entry.serviceId} ·{' '}
+ {entry.agentType}
+
+
))}
@@ -878,54 +915,80 @@ function RoomPanel({
);
}
-function UsageMeter({
- label,
- pct,
- reset,
+function UsageRemainingMeter({
+ row,
rowName,
t,
}: {
- label: string;
- pct: number;
- reset: string;
+ row: UsageRow;
rowName: string;
t: Messages;
}) {
+ const remaining = usageRemaining(row);
+ const reset = usageLimitReset(row);
+
return (
-
+
- {label}
- {formatPct(pct)}
+ {t.usage.remaining}
+ {remaining === null ? '-' : formatPct(remaining)}
-
- {t.usage.reset} {reset || '-'}
-
+
{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}
+
+ );
+}
+
+function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
+ const rate = usageBurnRate(row);
+ const level = usageSpeedLevel(rate);
+
+ return (
+
+ {t.usage.speed}
+ {formatUsageRate(rate)}
+ {t.usage.speedLabel[level]}
);
}
function UsagePanel({
overview,
- locale,
t,
}: {
overview: DashboardOverview;
- locale: Locale;
t: Messages;
}) {
- const rows = useMemo(() => [...overview.usage.rows], [overview.usage.rows]);
+ const rows = useMemo(
+ () =>
+ [...overview.usage.rows].sort((a, b) => {
+ if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
+ return usagePeak(b) - usagePeak(a);
+ }),
+ [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 activeRows = rows.filter(usageActive);
+ const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1);
+ const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest;
+ const focusValue = focusRows
+ .map((row) => {
+ const { account } = usageNameParts(row);
+ const remaining = usageRemaining(row);
+ return `${account} ${remaining === null ? '-' : formatPct(remaining)}`;
+ })
+ .join(' · ');
const groups = [
{
key: 'primary' as const,
@@ -943,26 +1006,20 @@ function UsagePanel({
- {t.usage.highest}
-
- {highest.name} · {formatPct(usagePeak(highest))}
-
+ {focusLabel}
+ {focusValue}
{t.usage.watch}
{watched}
-
- {t.usage.updated}
- {formatDate(overview.usage.fetchedAt, locale)}
-
{t.usage.usage}
- {t.usage.window5h}
- {t.usage.window7d}
+ {t.usage.remaining}
+ {t.usage.speed}
{groups.map((group) => (
@@ -971,28 +1028,25 @@ function UsagePanel({
{group.rows.map((row) => {
const risk = usageRiskLevel(row);
+ const { account, plan } = usageNameParts(row);
return (
-
{row.name}
-
- {t.usage.risk[risk]}
-
+
{account}
+
+ {usageActive(row) ? (
+ {t.usage.inUse}
+ ) : null}
+ {plan ? {plan} : null}
+ {usageLimited(row) || risk !== 'ok' ? (
+
+ {t.usage.risk[risk]}
+
+ ) : null}
+
-
-
+
+
);
})}
@@ -1192,7 +1246,6 @@ 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 [activeView, setActiveView] = useState(readViewFromHash);
const [locale, setLocale] = useState(readInitialLocale);
@@ -1215,7 +1268,6 @@ function App() {
try {
const nextData = await fetchDashboardData();
setData(nextData);
- setLastRefreshed(new Date().toISOString());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -1266,7 +1318,6 @@ function App() {
setDrawerOpen(false)}
onLocaleChange={setDashboardLocale}
@@ -1309,7 +1359,7 @@ function App() {
{t.panels.usage}
{t.panels.usageWindow}
-
+
>
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts
index 67c8646..546406b 100644
--- a/apps/dashboard/src/api.ts
+++ b/apps/dashboard/src/api.ts
@@ -37,6 +37,7 @@ export interface DashboardOverview {
};
inbox: Array<{
id: string;
+ groupKey: string;
kind:
| 'pending-room'
| 'reviewer-request'
@@ -48,7 +49,9 @@ export interface DashboardOverview {
title: string;
summary: string;
occurredAt: string;
+ lastOccurredAt: string;
createdAt: string;
+ occurrences: number;
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
roomJid?: string;
roomName?: string;
diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts
index b51769a..0f96e07 100644
--- a/apps/dashboard/src/i18n.ts
+++ b/apps/dashboard/src/i18n.ts
@@ -114,17 +114,19 @@ export interface Messages {
status: string;
queue: string;
elapsed: string;
+ details: string;
};
usage: {
empty: string;
- highest: string;
+ current: string;
+ tightest: string;
watch: string;
- updated: string;
- peak: string;
+ remaining: string;
+ speed: string;
+ inUse: string;
reset: string;
+ noReset: string;
usage: string;
- window5h: string;
- window7d: string;
groupPrimary: string;
groupCodex: string;
risk: {
@@ -132,6 +134,11 @@ export interface Messages {
warn: string;
critical: string;
};
+ speedLabel: {
+ ok: string;
+ warn: string;
+ critical: string;
+ };
};
tasks: {
empty: string;
@@ -244,7 +251,7 @@ export const messages = {
inbox: '인입',
inboxQueue: '대기 항목',
usage: '사용량',
- usageWindow: '5시간 / 7일',
+ usageWindow: '남음 / 속도',
rooms: '룸',
queue: '큐',
scheduled: '예약',
@@ -308,17 +315,19 @@ export const messages = {
status: '상태',
queue: '큐',
elapsed: '경과',
+ details: '세부',
},
usage: {
empty: '사용량 스냅샷 없음. 수집기 확인.',
- highest: '최고',
+ current: '사용중',
+ tightest: '가장 적음',
watch: '주의',
- updated: '갱신',
- peak: '피크',
+ remaining: '남음',
+ speed: '속도',
+ inUse: '사용중',
reset: '리셋',
+ noReset: '리셋 없음',
usage: '사용량',
- window5h: '5시간',
- window7d: '7일',
groupPrimary: 'Claude / Kimi',
groupCodex: 'Codex',
risk: {
@@ -326,6 +335,11 @@ export const messages = {
warn: '주의',
critical: '위험',
},
+ speedLabel: {
+ ok: '보통',
+ warn: '빠름',
+ critical: '너무 빠름',
+ },
},
tasks: {
empty: '예약 작업 없음.',
@@ -422,7 +436,7 @@ export const messages = {
inbox: 'Inbox',
inboxQueue: 'Work intake',
usage: 'Usage',
- usageWindow: '5h / 7d',
+ usageWindow: 'Remaining / speed',
rooms: 'Rooms',
queue: 'Queue',
scheduled: 'Scheduled',
@@ -486,17 +500,19 @@ export const messages = {
status: 'status',
queue: 'queue',
elapsed: 'elapsed',
+ details: 'details',
},
usage: {
empty: 'No usage snapshot. Check collector.',
- highest: 'Highest',
+ current: 'In use',
+ tightest: 'Lowest',
watch: 'Watch',
- updated: 'Updated',
- peak: 'Peak',
+ remaining: 'Left',
+ speed: 'Speed',
+ inUse: 'in use',
reset: 'reset',
+ noReset: 'no reset',
usage: 'usage',
- window5h: '5h',
- window7d: '7d',
groupPrimary: 'Claude / Kimi',
groupCodex: 'Codex',
risk: {
@@ -504,6 +520,11 @@ export const messages = {
warn: 'Watch',
critical: 'Limit risk',
},
+ speedLabel: {
+ ok: 'Normal',
+ warn: 'Fast',
+ critical: 'Too fast',
+ },
},
tasks: {
empty: 'No scheduled work.',
@@ -600,7 +621,7 @@ export const messages = {
inbox: '收件',
inboxQueue: '待处理',
usage: '用量',
- usageWindow: '5小时 / 7天',
+ usageWindow: '剩余 / 速度',
rooms: '房间',
queue: '队列',
scheduled: '计划',
@@ -617,7 +638,7 @@ export const messages = {
system: '系统',
signals: '健康信号',
services: '服务',
- fresh: '心跳正常',
+ fresh: '正常',
stale: '延迟',
queue: '队列',
ciFailures: 'CI 失败',
@@ -664,17 +685,19 @@ export const messages = {
status: '状态',
queue: '队列',
elapsed: '耗时',
+ details: '详情',
},
usage: {
empty: '暂无用量快照。检查采集器。',
- highest: '最高',
+ current: '使用中',
+ tightest: '剩余最少',
watch: '关注',
- updated: '更新',
- peak: '峰值',
+ remaining: '剩余',
+ speed: '速度',
+ inUse: '使用中',
reset: '重置',
+ noReset: '无重置',
usage: '用量',
- window5h: '5小时',
- window7d: '7天',
groupPrimary: 'Claude / Kimi',
groupCodex: 'Codex',
risk: {
@@ -682,6 +705,11 @@ export const messages = {
warn: '关注',
critical: '接近上限',
},
+ speedLabel: {
+ ok: '正常',
+ warn: '较快',
+ critical: '过快',
+ },
},
tasks: {
empty: '暂无计划任务。',
@@ -778,7 +806,7 @@ export const messages = {
inbox: '受信',
inboxQueue: '対応待ち',
usage: '使用量',
- usageWindow: '5時間 / 7日',
+ usageWindow: '残量 / 速度',
rooms: 'ルーム',
queue: 'キュー',
scheduled: '予定',
@@ -842,17 +870,19 @@ export const messages = {
status: '状態',
queue: 'キュー',
elapsed: '経過',
+ details: '詳細',
},
usage: {
empty: '使用量スナップショットなし。収集器を確認。',
- highest: '最高',
+ current: '使用中',
+ tightest: '残量最少',
watch: '注意',
- updated: '更新',
- peak: 'ピーク',
+ remaining: '残量',
+ speed: '速度',
+ inUse: '使用中',
reset: 'リセット',
+ noReset: 'リセットなし',
usage: '使用量',
- window5h: '5時間',
- window7d: '7日',
groupPrimary: 'Claude / Kimi',
groupCodex: 'Codex',
risk: {
@@ -860,6 +890,11 @@ export const messages = {
warn: '注意',
critical: '上限注意',
},
+ speedLabel: {
+ ok: '通常',
+ warn: '速い',
+ critical: '速すぎ',
+ },
},
tasks: {
empty: '予定作業なし。',
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index 03685ba..dc939c8 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -206,17 +206,6 @@ button:disabled {
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;
@@ -383,19 +372,6 @@ button:disabled {
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 {
@@ -627,7 +603,7 @@ td small {
.usage-summary {
display: grid;
- grid-template-columns: 1.1fr 0.46fr 0.9fr;
+ grid-template-columns: minmax(0, 1fr) minmax(90px, 0.28fr);
gap: 8px;
}
@@ -646,7 +622,7 @@ td small {
}
.usage-summary span,
-.usage-window small {
+.usage-remaining small {
color: var(--muted);
}
@@ -666,7 +642,10 @@ td small {
.usage-matrix-head,
.usage-row {
display: grid;
- grid-template-columns: minmax(120px, 0.72fr) minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-columns: minmax(132px, 0.78fr) minmax(0, 1fr) minmax(
+ 96px,
+ 0.46fr
+ );
gap: 8px;
align-items: center;
}
@@ -714,7 +693,7 @@ td small {
}
.usage-account,
-.usage-window div {
+.usage-remaining div {
display: flex;
min-width: 0;
gap: 8px;
@@ -723,7 +702,16 @@ td small {
}
.usage-account {
- justify-content: flex-start;
+ display: grid;
+ gap: 5px;
+ justify-content: stretch;
+}
+
+.usage-account div {
+ display: flex;
+ min-width: 0;
+ flex-wrap: wrap;
+ gap: 5px;
}
.usage-account strong {
@@ -735,7 +723,8 @@ td small {
white-space: nowrap;
}
-.usage-window span {
+.usage-remaining span,
+.usage-speed span {
color: var(--muted);
font-size: 11px;
font-weight: 850;
@@ -743,12 +732,38 @@ td small {
text-transform: uppercase;
}
-.usage-window {
+.usage-remaining,
+.usage-speed {
display: grid;
min-width: 0;
gap: 5px;
}
+.usage-speed {
+ min-height: 54px;
+ align-content: center;
+ padding: 8px;
+ border-radius: 14px;
+ background: rgba(43, 55, 38, 0.045);
+}
+
+.usage-speed strong {
+ font-size: 18px;
+ line-height: 1;
+}
+
+.usage-speed small {
+ color: var(--muted);
+}
+
+.usage-speed-warn {
+ background: rgba(181, 138, 34, 0.16);
+}
+
+.usage-speed-critical {
+ background: rgba(183, 71, 52, 0.16);
+}
+
progress {
width: 100%;
height: 8px;
@@ -904,6 +919,13 @@ progress::-moz-progress-bar {
align-items: start;
}
+.inbox-card-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ justify-content: flex-end;
+}
+
.inbox-card-head strong,
.inbox-card p,
.inbox-meta strong,
@@ -1028,6 +1050,23 @@ progress::-moz-progress-bar {
font-style: normal;
}
+.record-details {
+ margin-top: 4px;
+}
+
+.record-details summary {
+ display: inline-flex;
+ cursor: pointer;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 850;
+ list-style: none;
+}
+
+.record-details summary::-webkit-details-marker {
+ display: none;
+}
+
.task-board {
display: grid;
gap: 12px;
@@ -1462,10 +1501,6 @@ progress::-moz-progress-bar {
font-size: 12px;
}
- .section-nav span {
- display: none;
- }
-
.section-nav .menu-button span {
display: block;
}
@@ -1520,11 +1555,7 @@ progress::-moz-progress-bar {
}
.usage-summary {
- grid-template-columns: minmax(0, 1fr) minmax(78px, 0.55fr);
- }
-
- .usage-summary div:last-child {
- grid-column: 1 / -1;
+ grid-template-columns: minmax(0, 1fr) minmax(78px, 0.4fr);
}
.usage-matrix-head {
@@ -1532,7 +1563,7 @@ progress::-moz-progress-bar {
}
.usage-row {
- grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr) minmax(90px, 0.5fr);
gap: 6px;
padding: 9px;
}
@@ -1542,15 +1573,16 @@ progress::-moz-progress-bar {
gap: 5px;
}
- .usage-window {
+ .usage-remaining,
+ .usage-speed {
gap: 4px;
}
- .usage-window div {
+ .usage-remaining div {
gap: 6px;
}
- .usage-window small {
+ .usage-remaining small {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
@@ -1650,7 +1682,6 @@ progress::-moz-progress-bar {
.usage-account {
grid-column: 1 / -1;
- grid-template-columns: minmax(0, 1fr) auto;
}
.skeleton-grid,
diff --git a/src/web-dashboard-data.test.ts b/src/web-dashboard-data.test.ts
index 41fc815..1831e68 100644
--- a/src/web-dashboard-data.test.ts
+++ b/src/web-dashboard-data.test.ts
@@ -291,6 +291,14 @@ describe('web dashboard data', () => {
last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
status: 'paused',
}),
+ makeTask({
+ id: 'ci-2',
+ prompt:
+ '[BACKGROUND CI WATCH]\nWatch target:\nPR #22\n\nCheck instructions:\nwatch',
+ last_run: '2026-04-26T05:07:00.000Z',
+ last_result: 'Error: BOT_TOKEN=plain-secret-value failed',
+ status: 'active',
+ }),
makeTask({
id: 'cron-1',
prompt: 'regular cron',
@@ -329,10 +337,12 @@ describe('web dashboard data', () => {
);
const ciFailure = overview.inbox.find((item) => item.kind === 'ci-failure');
expect(ciFailure).toMatchObject({
- id: 'ci:ci-1',
+ id: 'ci:ci-2',
severity: 'error',
+ occurrences: 2,
source: 'scheduled-task',
- taskId: 'ci-1',
+ taskId: 'ci-2',
+ lastOccurredAt: '2026-04-26T05:07:00.000Z',
});
expect(ciFailure?.summary).toContain('BOT_TOKEN=');
expect(ciFailure?.summary).not.toContain('plain-secret-value');
diff --git a/src/web-dashboard-data.ts b/src/web-dashboard-data.ts
index ad2a64e..239c5c7 100644
--- a/src/web-dashboard-data.ts
+++ b/src/web-dashboard-data.ts
@@ -69,12 +69,15 @@ export type InboxItemSeverity = 'info' | 'warn' | 'error';
export interface InboxItem {
id: string;
+ groupKey: string;
kind: InboxItemKind;
severity: InboxItemSeverity;
title: string;
summary: string;
occurredAt: string;
+ lastOccurredAt: string;
createdAt: string;
+ occurrences: number;
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
roomJid?: string;
roomName?: string;
@@ -108,6 +111,14 @@ function buildInboxPreview(value: string): string {
return truncateText(redactSensitiveText(value).replace(/\s+/g, ' ').trim());
}
+function stableHash(value: string): string {
+ let hash = 0;
+ for (let index = 0; index < value.length; index += 1) {
+ hash = (hash * 31 + value.charCodeAt(index)) | 0;
+ }
+ return Math.abs(hash).toString(36);
+}
+
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
const rows: UsageRowSnapshot[] = [];
const seen = new Set();
@@ -174,12 +185,15 @@ function collectInboxItems(args: {
items.push({
id: `room:${snapshot.serviceId}:${entry.jid}`,
+ groupKey: `room:${snapshot.serviceId}:${entry.jid}`,
kind: 'pending-room',
severity: entry.pendingTasks > 0 ? 'warn' : 'info',
title: entry.name || entry.folder || entry.jid,
summary: parts.join(' · '),
occurredAt: snapshot.updatedAt,
+ lastOccurredAt: snapshot.updatedAt,
createdAt: args.createdAt,
+ occurrences: 1,
source: 'status-snapshot',
roomJid: entry.jid,
roomName: entry.name,
@@ -195,9 +209,9 @@ function collectInboxItems(args: {
items.push({
id: `paired:${task.id}:${task.status}`,
+ groupKey: `paired:${task.id}:${task.status}`,
kind,
- severity:
- kind === 'approval' || kind === 'arbiter-request' ? 'error' : 'warn',
+ severity: kind === 'arbiter-request' ? 'error' : 'warn',
title: task.title || task.group_folder,
summary: task.status,
occurredAt:
@@ -206,7 +220,14 @@ function collectInboxItems(args: {
: kind === 'reviewer-request'
? (task.review_requested_at ?? task.updated_at)
: task.updated_at,
+ lastOccurredAt:
+ kind === 'arbiter-request'
+ ? (task.arbiter_requested_at ?? task.updated_at)
+ : kind === 'reviewer-request'
+ ? (task.review_requested_at ?? task.updated_at)
+ : task.updated_at,
createdAt: args.createdAt,
+ occurrences: 1,
source: 'paired-task',
roomJid: task.chat_jid,
groupFolder: task.group_folder,
@@ -226,12 +247,15 @@ function collectInboxItems(args: {
items.push({
id: `ci:${task.id}`,
+ groupKey: `ci:${stableHash(result.toLowerCase())}`,
kind: 'ci-failure',
severity: task.status === 'paused' ? 'error' : 'warn',
title: 'CI watcher failed',
summary: result,
occurredAt: task.last_run ?? task.created_at,
+ lastOccurredAt: task.last_run ?? task.created_at,
createdAt: args.createdAt,
+ occurrences: 1,
source: 'scheduled-task',
roomJid: task.chat_jid,
groupFolder: task.group_folder,
@@ -247,11 +271,40 @@ function collectInboxItems(args: {
info: 2,
};
- return items
+ const groupedItems = new Map();
+ for (const item of items) {
+ const existing = groupedItems.get(item.groupKey);
+ if (!existing) {
+ groupedItems.set(item.groupKey, item);
+ continue;
+ }
+
+ const occurrences = existing.occurrences + item.occurrences;
+ const severity =
+ severityRank[item.severity] < severityRank[existing.severity]
+ ? item.severity
+ : existing.severity;
+ const latest =
+ item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0
+ ? item
+ : existing;
+
+ groupedItems.set(item.groupKey, {
+ ...latest,
+ severity,
+ occurrences,
+ lastOccurredAt:
+ item.lastOccurredAt.localeCompare(existing.lastOccurredAt) > 0
+ ? item.lastOccurredAt
+ : existing.lastOccurredAt,
+ });
+ }
+
+ return [...groupedItems.values()]
.sort((a, b) => {
const severityDelta = severityRank[a.severity] - severityRank[b.severity];
if (severityDelta !== 0) return severityDelta;
- return b.occurredAt.localeCompare(a.occurredAt);
+ return b.lastOccurredAt.localeCompare(a.lastOccurredAt);
})
.slice(0, 50);
}