[codex] Improve mobile dashboard UX (#19)
* feat(dashboard): improve mobile control plane UX * fix(dashboard): improve mobile nav accessibility * feat(dashboard): add mobile drawer and usage glance cards * feat(dashboard): add compact usage matrix and i18n * fix(dashboard): make usage-first console layout * fix(dashboard): remove chrome and group usage rows
This commit is contained in:
File diff suppressed because it is too large
Load Diff
615
apps/dashboard/src/i18n.ts
Normal file
615
apps/dashboard/src/i18n.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
|
||||
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
export interface Messages {
|
||||
app: {
|
||||
loading: string;
|
||||
};
|
||||
actions: {
|
||||
close: string;
|
||||
refresh: string;
|
||||
refreshing: string;
|
||||
retry: string;
|
||||
};
|
||||
nav: {
|
||||
aria: string;
|
||||
drawerAria: string;
|
||||
drawerNavAria: string;
|
||||
menuOpen: string;
|
||||
menuClose: string;
|
||||
operations: string;
|
||||
updated: string;
|
||||
health: string;
|
||||
usage: string;
|
||||
rooms: string;
|
||||
scheduled: string;
|
||||
};
|
||||
language: {
|
||||
label: string;
|
||||
};
|
||||
error: {
|
||||
api: string;
|
||||
};
|
||||
control: {
|
||||
aria: string;
|
||||
queue: string;
|
||||
activeRooms: string;
|
||||
pendingRooms: string;
|
||||
};
|
||||
metrics: {
|
||||
agents: string;
|
||||
rooms: string;
|
||||
ciWatchers: string;
|
||||
};
|
||||
panels: {
|
||||
health: string;
|
||||
heartbeat: string;
|
||||
usage: string;
|
||||
usageWindow: string;
|
||||
rooms: string;
|
||||
queue: string;
|
||||
scheduled: string;
|
||||
promptPreviews: string;
|
||||
};
|
||||
service: {
|
||||
empty: string;
|
||||
heartbeat: string;
|
||||
service: string;
|
||||
rooms: string;
|
||||
updated: string;
|
||||
};
|
||||
rooms: {
|
||||
empty: string;
|
||||
cardsAria: string;
|
||||
room: string;
|
||||
service: string;
|
||||
agent: string;
|
||||
status: string;
|
||||
queue: string;
|
||||
elapsed: string;
|
||||
};
|
||||
usage: {
|
||||
empty: string;
|
||||
highest: string;
|
||||
watch: string;
|
||||
updated: string;
|
||||
peak: string;
|
||||
reset: string;
|
||||
usage: string;
|
||||
window5h: string;
|
||||
window7d: string;
|
||||
groupPrimary: string;
|
||||
groupCodex: string;
|
||||
risk: {
|
||||
ok: string;
|
||||
warn: string;
|
||||
critical: string;
|
||||
};
|
||||
};
|
||||
tasks: {
|
||||
empty: string;
|
||||
cardsAria: string;
|
||||
task: string;
|
||||
status: string;
|
||||
schedule: string;
|
||||
next: string;
|
||||
last: string;
|
||||
context: string;
|
||||
ciWatch: string;
|
||||
emptyPrompt: string;
|
||||
until: string;
|
||||
lastResult: string;
|
||||
};
|
||||
status: {
|
||||
processing: string;
|
||||
waiting: string;
|
||||
inactive: string;
|
||||
active: string;
|
||||
paused: string;
|
||||
completed: string;
|
||||
};
|
||||
units: {
|
||||
second: string;
|
||||
minute: string;
|
||||
hour: string;
|
||||
task: string;
|
||||
messageShort: string;
|
||||
chars: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const languageNames: Record<Locale, string> = {
|
||||
ko: '한국어',
|
||||
en: 'English',
|
||||
zh: '简体中文',
|
||||
ja: '日本語',
|
||||
};
|
||||
|
||||
export const localeTags: Record<Locale, string> = {
|
||||
ko: 'ko-KR',
|
||||
en: 'en-US',
|
||||
zh: 'zh-CN',
|
||||
ja: 'ja-JP',
|
||||
};
|
||||
|
||||
export const messages = {
|
||||
ko: {
|
||||
app: {
|
||||
loading: '대시보드 로딩 중',
|
||||
},
|
||||
actions: {
|
||||
close: '닫기',
|
||||
refresh: '새로고침',
|
||||
refreshing: '새로고침 중',
|
||||
retry: '다시 시도',
|
||||
},
|
||||
nav: {
|
||||
aria: '대시보드 섹션',
|
||||
drawerAria: '대시보드 메뉴',
|
||||
drawerNavAria: '대시보드 메뉴 섹션',
|
||||
menuOpen: '메뉴 열기',
|
||||
menuClose: '메뉴 닫기',
|
||||
operations: '운영',
|
||||
updated: '갱신',
|
||||
health: '상태',
|
||||
usage: '사용량',
|
||||
rooms: '룸',
|
||||
scheduled: '예약',
|
||||
},
|
||||
language: {
|
||||
label: '언어',
|
||||
},
|
||||
error: {
|
||||
api: 'API 오류',
|
||||
},
|
||||
control: {
|
||||
aria: '컨트롤 플레인 요약',
|
||||
queue: '작업 큐',
|
||||
activeRooms: '처리 + 대기 룸',
|
||||
pendingRooms: '메시지 대기 룸',
|
||||
},
|
||||
metrics: {
|
||||
agents: '에이전트',
|
||||
rooms: '룸',
|
||||
ciWatchers: 'CI 감시',
|
||||
},
|
||||
panels: {
|
||||
health: '상태',
|
||||
heartbeat: '하트비트',
|
||||
usage: '사용량',
|
||||
usageWindow: '5시간 / 7일',
|
||||
rooms: '룸',
|
||||
queue: '큐',
|
||||
scheduled: '예약',
|
||||
promptPreviews: '프롬프트 미리보기',
|
||||
},
|
||||
service: {
|
||||
empty: '하트비트 없음. 서비스 로그 확인.',
|
||||
heartbeat: '하트비트',
|
||||
service: '서비스',
|
||||
rooms: '룸',
|
||||
updated: '갱신',
|
||||
},
|
||||
rooms: {
|
||||
empty: '룸 없음.',
|
||||
cardsAria: '룸 상태 카드',
|
||||
room: '룸',
|
||||
service: '서비스',
|
||||
agent: '에이전트',
|
||||
status: '상태',
|
||||
queue: '큐',
|
||||
elapsed: '경과',
|
||||
},
|
||||
usage: {
|
||||
empty: '사용량 스냅샷 없음. 수집기 확인.',
|
||||
highest: '최고',
|
||||
watch: '주의',
|
||||
updated: '갱신',
|
||||
peak: '피크',
|
||||
reset: '리셋',
|
||||
usage: '사용량',
|
||||
window5h: '5시간',
|
||||
window7d: '7일',
|
||||
groupPrimary: 'Claude / Kimi',
|
||||
groupCodex: 'Codex',
|
||||
risk: {
|
||||
ok: '여유',
|
||||
warn: '주의',
|
||||
critical: '위험',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
empty: '예약 작업 없음.',
|
||||
cardsAria: '예약 작업 카드',
|
||||
task: '작업',
|
||||
status: '상태',
|
||||
schedule: '스케줄',
|
||||
next: '다음',
|
||||
last: '최근',
|
||||
context: '컨텍스트',
|
||||
ciWatch: 'CI 감시',
|
||||
emptyPrompt: '(빈 미리보기)',
|
||||
until: '까지',
|
||||
lastResult: '최근 결과',
|
||||
},
|
||||
status: {
|
||||
processing: '처리중',
|
||||
waiting: '대기',
|
||||
inactive: '휴면',
|
||||
active: '활성',
|
||||
paused: '일시정지',
|
||||
completed: '완료',
|
||||
},
|
||||
units: {
|
||||
second: '초',
|
||||
minute: '분',
|
||||
hour: '시간',
|
||||
task: '작업',
|
||||
messageShort: '메시지',
|
||||
chars: '자',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
app: {
|
||||
loading: 'Loading dashboard',
|
||||
},
|
||||
actions: {
|
||||
close: 'Close',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing',
|
||||
retry: 'Retry',
|
||||
},
|
||||
nav: {
|
||||
aria: 'Dashboard sections',
|
||||
drawerAria: 'Dashboard menu',
|
||||
drawerNavAria: 'Dashboard menu sections',
|
||||
menuOpen: 'Open menu',
|
||||
menuClose: 'Close menu',
|
||||
operations: 'Ops',
|
||||
updated: 'Updated',
|
||||
health: 'Health',
|
||||
usage: 'Usage',
|
||||
rooms: 'Rooms',
|
||||
scheduled: 'Scheduled',
|
||||
},
|
||||
language: {
|
||||
label: 'Language',
|
||||
},
|
||||
error: {
|
||||
api: 'API error',
|
||||
},
|
||||
control: {
|
||||
aria: 'Control plane summary',
|
||||
queue: 'Work queue',
|
||||
activeRooms: 'processing + waiting rooms',
|
||||
pendingRooms: 'rooms with pending messages',
|
||||
},
|
||||
metrics: {
|
||||
agents: 'agents',
|
||||
rooms: 'rooms',
|
||||
ciWatchers: 'CI watchers',
|
||||
},
|
||||
panels: {
|
||||
health: 'Health',
|
||||
heartbeat: 'Heartbeat',
|
||||
usage: 'Usage',
|
||||
usageWindow: '5h / 7d',
|
||||
rooms: 'Rooms',
|
||||
queue: 'Queue',
|
||||
scheduled: 'Scheduled',
|
||||
promptPreviews: 'Prompt previews',
|
||||
},
|
||||
service: {
|
||||
empty: 'No heartbeat yet. Check service logs.',
|
||||
heartbeat: 'heartbeat',
|
||||
service: 'service',
|
||||
rooms: 'rooms',
|
||||
updated: 'updated',
|
||||
},
|
||||
rooms: {
|
||||
empty: 'No rooms yet.',
|
||||
cardsAria: 'Room status cards',
|
||||
room: 'room',
|
||||
service: 'service',
|
||||
agent: 'agent',
|
||||
status: 'status',
|
||||
queue: 'queue',
|
||||
elapsed: 'elapsed',
|
||||
},
|
||||
usage: {
|
||||
empty: 'No usage snapshot. Check collector.',
|
||||
highest: 'Highest',
|
||||
watch: 'Watch',
|
||||
updated: 'Updated',
|
||||
peak: 'Peak',
|
||||
reset: 'reset',
|
||||
usage: 'usage',
|
||||
window5h: '5h',
|
||||
window7d: '7d',
|
||||
groupPrimary: 'Claude / Kimi',
|
||||
groupCodex: 'Codex',
|
||||
risk: {
|
||||
ok: 'Clear',
|
||||
warn: 'Watch',
|
||||
critical: 'Limit risk',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
empty: 'No scheduled work.',
|
||||
cardsAria: 'Scheduled task cards',
|
||||
task: 'task',
|
||||
status: 'status',
|
||||
schedule: 'schedule',
|
||||
next: 'next',
|
||||
last: 'last',
|
||||
context: 'context',
|
||||
ciWatch: 'CI Watch',
|
||||
emptyPrompt: '(empty preview)',
|
||||
until: 'until',
|
||||
lastResult: 'last result',
|
||||
},
|
||||
status: {
|
||||
processing: 'processing',
|
||||
waiting: 'waiting',
|
||||
inactive: 'inactive',
|
||||
active: 'active',
|
||||
paused: 'paused',
|
||||
completed: 'completed',
|
||||
},
|
||||
units: {
|
||||
second: 's',
|
||||
minute: 'm',
|
||||
hour: 'h',
|
||||
task: 'task',
|
||||
messageShort: 'msg',
|
||||
chars: 'chars',
|
||||
},
|
||||
},
|
||||
zh: {
|
||||
app: {
|
||||
loading: '正在加载仪表盘',
|
||||
},
|
||||
actions: {
|
||||
close: '关闭',
|
||||
refresh: '刷新',
|
||||
refreshing: '刷新中',
|
||||
retry: '重试',
|
||||
},
|
||||
nav: {
|
||||
aria: '仪表盘分区',
|
||||
drawerAria: '仪表盘菜单',
|
||||
drawerNavAria: '仪表盘菜单分区',
|
||||
menuOpen: '打开菜单',
|
||||
menuClose: '关闭菜单',
|
||||
operations: '运营',
|
||||
updated: '更新',
|
||||
health: '健康',
|
||||
usage: '用量',
|
||||
rooms: '房间',
|
||||
scheduled: '计划',
|
||||
},
|
||||
language: {
|
||||
label: '语言',
|
||||
},
|
||||
error: {
|
||||
api: 'API 错误',
|
||||
},
|
||||
control: {
|
||||
aria: '控制平面摘要',
|
||||
queue: '任务队列',
|
||||
activeRooms: '处理中 + 等待房间',
|
||||
pendingRooms: '有待处理消息的房间',
|
||||
},
|
||||
metrics: {
|
||||
agents: '代理',
|
||||
rooms: '房间',
|
||||
ciWatchers: 'CI 监控',
|
||||
},
|
||||
panels: {
|
||||
health: '健康',
|
||||
heartbeat: '心跳',
|
||||
usage: '用量',
|
||||
usageWindow: '5小时 / 7天',
|
||||
rooms: '房间',
|
||||
queue: '队列',
|
||||
scheduled: '计划',
|
||||
promptPreviews: '提示预览',
|
||||
},
|
||||
service: {
|
||||
empty: '暂无心跳。检查服务日志。',
|
||||
heartbeat: '心跳',
|
||||
service: '服务',
|
||||
rooms: '房间',
|
||||
updated: '更新',
|
||||
},
|
||||
rooms: {
|
||||
empty: '暂无房间。',
|
||||
cardsAria: '房间状态卡片',
|
||||
room: '房间',
|
||||
service: '服务',
|
||||
agent: '代理',
|
||||
status: '状态',
|
||||
queue: '队列',
|
||||
elapsed: '耗时',
|
||||
},
|
||||
usage: {
|
||||
empty: '暂无用量快照。检查采集器。',
|
||||
highest: '最高',
|
||||
watch: '关注',
|
||||
updated: '更新',
|
||||
peak: '峰值',
|
||||
reset: '重置',
|
||||
usage: '用量',
|
||||
window5h: '5小时',
|
||||
window7d: '7天',
|
||||
groupPrimary: 'Claude / Kimi',
|
||||
groupCodex: 'Codex',
|
||||
risk: {
|
||||
ok: '充足',
|
||||
warn: '关注',
|
||||
critical: '接近上限',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
empty: '暂无计划任务。',
|
||||
cardsAria: '计划任务卡片',
|
||||
task: '任务',
|
||||
status: '状态',
|
||||
schedule: '计划',
|
||||
next: '下次',
|
||||
last: '最近',
|
||||
context: '上下文',
|
||||
ciWatch: 'CI 监控',
|
||||
emptyPrompt: '(空预览)',
|
||||
until: '直到',
|
||||
lastResult: '最近结果',
|
||||
},
|
||||
status: {
|
||||
processing: '处理中',
|
||||
waiting: '等待',
|
||||
inactive: '空闲',
|
||||
active: '活跃',
|
||||
paused: '暂停',
|
||||
completed: '完成',
|
||||
},
|
||||
units: {
|
||||
second: '秒',
|
||||
minute: '分',
|
||||
hour: '小时',
|
||||
task: '任务',
|
||||
messageShort: '消息',
|
||||
chars: '字',
|
||||
},
|
||||
},
|
||||
ja: {
|
||||
app: {
|
||||
loading: 'ダッシュボードを読み込み中',
|
||||
},
|
||||
actions: {
|
||||
close: '閉じる',
|
||||
refresh: '更新',
|
||||
refreshing: '更新中',
|
||||
retry: '再試行',
|
||||
},
|
||||
nav: {
|
||||
aria: 'ダッシュボードセクション',
|
||||
drawerAria: 'ダッシュボードメニュー',
|
||||
drawerNavAria: 'ダッシュボードメニューセクション',
|
||||
menuOpen: 'メニューを開く',
|
||||
menuClose: 'メニューを閉じる',
|
||||
operations: '運用',
|
||||
updated: '更新',
|
||||
health: '状態',
|
||||
usage: '使用量',
|
||||
rooms: 'ルーム',
|
||||
scheduled: '予定',
|
||||
},
|
||||
language: {
|
||||
label: '言語',
|
||||
},
|
||||
error: {
|
||||
api: 'API エラー',
|
||||
},
|
||||
control: {
|
||||
aria: 'コントロールプレーン概要',
|
||||
queue: '作業キュー',
|
||||
activeRooms: '処理中 + 待機ルーム',
|
||||
pendingRooms: 'メッセージ待ちルーム',
|
||||
},
|
||||
metrics: {
|
||||
agents: 'エージェント',
|
||||
rooms: 'ルーム',
|
||||
ciWatchers: 'CI監視',
|
||||
},
|
||||
panels: {
|
||||
health: '状態',
|
||||
heartbeat: 'ハートビート',
|
||||
usage: '使用量',
|
||||
usageWindow: '5時間 / 7日',
|
||||
rooms: 'ルーム',
|
||||
queue: 'キュー',
|
||||
scheduled: '予定',
|
||||
promptPreviews: 'プロンプトプレビュー',
|
||||
},
|
||||
service: {
|
||||
empty: 'ハートビートなし。サービスログを確認。',
|
||||
heartbeat: 'ハートビート',
|
||||
service: 'サービス',
|
||||
rooms: 'ルーム',
|
||||
updated: '更新',
|
||||
},
|
||||
rooms: {
|
||||
empty: 'ルームなし。',
|
||||
cardsAria: 'ルーム状態カード',
|
||||
room: 'ルーム',
|
||||
service: 'サービス',
|
||||
agent: 'エージェント',
|
||||
status: '状態',
|
||||
queue: 'キュー',
|
||||
elapsed: '経過',
|
||||
},
|
||||
usage: {
|
||||
empty: '使用量スナップショットなし。収集器を確認。',
|
||||
highest: '最高',
|
||||
watch: '注意',
|
||||
updated: '更新',
|
||||
peak: 'ピーク',
|
||||
reset: 'リセット',
|
||||
usage: '使用量',
|
||||
window5h: '5時間',
|
||||
window7d: '7日',
|
||||
groupPrimary: 'Claude / Kimi',
|
||||
groupCodex: 'Codex',
|
||||
risk: {
|
||||
ok: '余裕',
|
||||
warn: '注意',
|
||||
critical: '上限注意',
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
empty: '予定作業なし。',
|
||||
cardsAria: '予定作業カード',
|
||||
task: '作業',
|
||||
status: '状態',
|
||||
schedule: '予定',
|
||||
next: '次回',
|
||||
last: '直近',
|
||||
context: 'コンテキスト',
|
||||
ciWatch: 'CI監視',
|
||||
emptyPrompt: '(空のプレビュー)',
|
||||
until: 'まで',
|
||||
lastResult: '直近結果',
|
||||
},
|
||||
status: {
|
||||
processing: '処理中',
|
||||
waiting: '待機',
|
||||
inactive: '休止',
|
||||
active: '有効',
|
||||
paused: '一時停止',
|
||||
completed: '完了',
|
||||
},
|
||||
units: {
|
||||
second: '秒',
|
||||
minute: '分',
|
||||
hour: '時間',
|
||||
task: '作業',
|
||||
messageShort: 'メッセージ',
|
||||
chars: '字',
|
||||
},
|
||||
},
|
||||
} satisfies Record<Locale, Messages>;
|
||||
|
||||
export function isLocale(value: string | null | undefined): value is Locale {
|
||||
return LOCALES.includes(value as Locale);
|
||||
}
|
||||
|
||||
export function matchLocale(value: string | null | undefined): Locale | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized.startsWith('ko')) return 'ko';
|
||||
if (normalized.startsWith('en')) return 'en';
|
||||
if (normalized.startsWith('zh')) return 'zh';
|
||||
if (normalized.startsWith('ja')) return 'ja';
|
||||
return null;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { UsageRow } from './dashboard-usage-rows.js';
|
||||
import {
|
||||
buildWebUsageRowsForSnapshot,
|
||||
formatStatusHeader,
|
||||
renderUsageTable,
|
||||
summarizeWatcherTasks,
|
||||
@@ -107,3 +108,94 @@ describe('renderUsageTable', () => {
|
||||
expect(lines).toEqual(['_조회 불가_']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWebUsageRowsForSnapshot', () => {
|
||||
it('keeps real Claude and Kimi rows ahead of Codex rows for web snapshots', () => {
|
||||
const rows = buildWebUsageRowsForSnapshot({
|
||||
serviceAgentType: 'claude-code',
|
||||
claudeAccounts: [
|
||||
{
|
||||
index: 0,
|
||||
masked: 'claude-1',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
usage: {
|
||||
five_hour: {
|
||||
utilization: 0.2,
|
||||
resets_at: '2026-04-26T12:00:00.000Z',
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 0.4,
|
||||
resets_at: '2026-04-27T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
masked: 'claude-2',
|
||||
isActive: false,
|
||||
isRateLimited: false,
|
||||
usage: {
|
||||
five_hour: {
|
||||
utilization: 0.3,
|
||||
resets_at: '2026-04-26T12:00:00.000Z',
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 0.5,
|
||||
resets_at: '2026-04-27T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
kimiUsage: {
|
||||
fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' },
|
||||
weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' },
|
||||
},
|
||||
codexRows: [
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '1h',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const names = rows.map((row) => row.name);
|
||||
expect(names[0]).toMatch(/^Claude1/);
|
||||
expect(names[1]).toMatch(/^Claude2/);
|
||||
expect(names[2]).toMatch(/^Kimi/);
|
||||
expect(names[3]).toBe('Codex1');
|
||||
});
|
||||
|
||||
it('does not invent Claude or Kimi rows for codex-only services', () => {
|
||||
const rows = buildWebUsageRowsForSnapshot({
|
||||
serviceAgentType: 'codex',
|
||||
claudeAccounts: [
|
||||
{
|
||||
index: 0,
|
||||
masked: 'claude-1',
|
||||
isActive: true,
|
||||
isRateLimited: false,
|
||||
usage: null,
|
||||
},
|
||||
],
|
||||
kimiUsage: {
|
||||
fiveHour: { pct: 18, resetTime: '2026-04-26T12:00:00.000Z' },
|
||||
weekly: { pct: 29, resetTime: '2026-04-27T12:00:00.000Z' },
|
||||
},
|
||||
codexRows: [
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '1h',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual(['Codex1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
CODEX_WARMUP_CONFIG,
|
||||
getMoaConfig,
|
||||
} from './config.js';
|
||||
import { fetchKimiUsage, buildKimiUsageRows } from './kimi-usage.js';
|
||||
import {
|
||||
fetchKimiUsage,
|
||||
buildKimiUsageRows,
|
||||
type KimiUsageData,
|
||||
} from './kimi-usage.js';
|
||||
import { getGlobalFailoverInfo } from './service-routing.js';
|
||||
import {
|
||||
fetchAllClaudeUsage,
|
||||
@@ -93,7 +97,7 @@ const RENDERER_USAGE_REFRESH_MS = 30_000;
|
||||
let statusMessageId: string | null = null;
|
||||
let cachedUsageContent = '';
|
||||
let cachedClaudeAccounts: ClaudeAccountUsage[] = [];
|
||||
let cachedKimiUsage: import('./kimi-usage.js').KimiUsageData | null = null;
|
||||
let cachedKimiUsage: KimiUsageData | null = null;
|
||||
let usageUpdateInProgress = false;
|
||||
let channelMetaCache = new Map<string, ChannelMeta>();
|
||||
let channelMetaLastRefresh = 0;
|
||||
@@ -102,6 +106,8 @@ let dashboardUpdateLogged = false;
|
||||
let cachedCodexUsageRows: UsageRow[] = [];
|
||||
/** Codex service only: ISO timestamp of last successful usage fetch. */
|
||||
let codexUsageFetchedAt: string | null = null;
|
||||
/** Renderer service only: ISO timestamp of last successful Claude/Kimi usage render. */
|
||||
let rendererUsageFetchedAt: string | null = null;
|
||||
|
||||
export interface WatcherTaskSummary {
|
||||
active: number;
|
||||
@@ -231,9 +237,47 @@ function formatRoomName(
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildWebUsageRowsForSnapshot(args: {
|
||||
serviceAgentType: AgentType;
|
||||
claudeAccounts: ClaudeAccountUsage[];
|
||||
kimiUsage: KimiUsageData | null;
|
||||
codexRows: UsageRow[];
|
||||
}): UsageRow[] {
|
||||
const rows: UsageRow[] = [];
|
||||
|
||||
if (args.serviceAgentType === 'claude-code') {
|
||||
rows.push(...buildClaudeUsageRows(args.claudeAccounts));
|
||||
rows.push(...buildKimiUsageRows(args.kimiUsage));
|
||||
}
|
||||
|
||||
rows.push(...args.codexRows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildUsageSnapshotRows(opts: UnifiedDashboardOptions): {
|
||||
rows: UsageRow[];
|
||||
fetchedAt: string | null;
|
||||
} {
|
||||
const rows = buildWebUsageRowsForSnapshot({
|
||||
serviceAgentType: opts.serviceAgentType,
|
||||
claudeAccounts: cachedClaudeAccounts,
|
||||
kimiUsage: cachedKimiUsage,
|
||||
codexRows: cachedCodexUsageRows,
|
||||
});
|
||||
|
||||
const fetchedAt =
|
||||
[rendererUsageFetchedAt, codexUsageFetchedAt]
|
||||
.filter((value): value is string => !!value)
|
||||
.sort()
|
||||
.at(-1) ?? null;
|
||||
|
||||
return { rows, fetchedAt };
|
||||
}
|
||||
|
||||
function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
||||
const groups = opts.roomBindings();
|
||||
const statuses = opts.queue.getStatuses(Object.keys(groups));
|
||||
const usageSnapshot = buildUsageSnapshotRows(opts);
|
||||
|
||||
writeStatusSnapshot({
|
||||
serviceId: opts.serviceId,
|
||||
@@ -267,8 +311,10 @@ function writeLocalStatusSnapshot(opts: UnifiedDashboardOptions): void {
|
||||
pendingMessages: boolean;
|
||||
pendingTasks: number;
|
||||
}>,
|
||||
...(cachedCodexUsageRows.length > 0 && { usageRows: cachedCodexUsageRows }),
|
||||
...(codexUsageFetchedAt && { usageRowsFetchedAt: codexUsageFetchedAt }),
|
||||
...(usageSnapshot.rows.length > 0 && { usageRows: usageSnapshot.rows }),
|
||||
...(usageSnapshot.fetchedAt && {
|
||||
usageRowsFetchedAt: usageSnapshot.fetchedAt,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -647,6 +693,7 @@ async function refreshUsageCache(): Promise<void> {
|
||||
usageUpdateInProgress = true;
|
||||
try {
|
||||
cachedUsageContent = await buildUsageContent();
|
||||
rendererUsageFetchedAt = new Date().toISOString();
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Failed to build usage content');
|
||||
} finally {
|
||||
|
||||
@@ -94,6 +94,83 @@ describe('web dashboard data', () => {
|
||||
expect(overview.usage.rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deduplicates full usage rows from renderer and codex snapshots', () => {
|
||||
const snapshots: StatusSnapshot[] = [
|
||||
{
|
||||
serviceId: 'codex-main',
|
||||
agentType: 'codex',
|
||||
assistantName: 'Codex',
|
||||
updatedAt: '2026-04-26T04:59:00.000Z',
|
||||
entries: [],
|
||||
usageRowsFetchedAt: '2026-04-26T04:59:00.000Z',
|
||||
usageRows: [
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 20,
|
||||
h5reset: '1h',
|
||||
d7pct: 30,
|
||||
d7reset: '2d',
|
||||
},
|
||||
{
|
||||
name: 'Codex2',
|
||||
h5pct: 15,
|
||||
h5reset: '1h',
|
||||
d7pct: 18,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
serviceId: 'claude-main',
|
||||
agentType: 'claude-code',
|
||||
assistantName: 'Claude',
|
||||
updatedAt: '2026-04-26T05:00:00.000Z',
|
||||
entries: [],
|
||||
usageRowsFetchedAt: '2026-04-26T05:00:00.000Z',
|
||||
usageRows: [
|
||||
{
|
||||
name: 'Claude1 Max',
|
||||
h5pct: 66,
|
||||
h5reset: '2h',
|
||||
d7pct: 40,
|
||||
d7reset: '4d',
|
||||
},
|
||||
{
|
||||
name: 'Kimi',
|
||||
h5pct: 10,
|
||||
h5reset: '3h',
|
||||
d7pct: 12,
|
||||
d7reset: '5d',
|
||||
},
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '55m',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const overview = buildWebDashboardOverview({
|
||||
now: '2026-04-26T05:01:00.000Z',
|
||||
snapshots,
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
expect(overview.usage.rows.map((row) => row.name)).toEqual([
|
||||
'Claude1 Max',
|
||||
'Kimi',
|
||||
'Codex1',
|
||||
'Codex2',
|
||||
]);
|
||||
expect(
|
||||
overview.usage.rows.filter((row) => row.name === 'Codex1'),
|
||||
).toHaveLength(1);
|
||||
expect(overview.usage.fetchedAt).toBe('2026-04-26T05:00:00.000Z');
|
||||
});
|
||||
|
||||
it('does not expose full scheduled task prompts through API payloads', () => {
|
||||
const sanitized = sanitizeScheduledTask(
|
||||
makeTask({
|
||||
|
||||
@@ -76,6 +76,26 @@ function buildPromptPreview(prompt: string): string {
|
||||
return truncateText(redactSensitiveText(prompt).replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
function collectUsageRows(snapshots: StatusSnapshot[]): UsageRowSnapshot[] {
|
||||
const rows: UsageRowSnapshot[] = [];
|
||||
const seen = new Set<string>();
|
||||
const sortedSnapshots = [...snapshots].sort((a, b) =>
|
||||
(b.usageRowsFetchedAt ?? b.updatedAt).localeCompare(
|
||||
a.usageRowsFetchedAt ?? a.updatedAt,
|
||||
),
|
||||
);
|
||||
|
||||
for (const snapshot of sortedSnapshots) {
|
||||
for (const row of snapshot.usageRows ?? []) {
|
||||
if (seen.has(row.name)) continue;
|
||||
seen.add(row.name);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function sanitizeScheduledTask(
|
||||
task: ScheduledTask,
|
||||
): SanitizedScheduledTask {
|
||||
@@ -162,9 +182,7 @@ export function buildWebDashboardOverview(args: {
|
||||
}
|
||||
}
|
||||
|
||||
const usageRows = args.snapshots.flatMap(
|
||||
(snapshot) => snapshot.usageRows ?? [],
|
||||
);
|
||||
const usageRows = collectUsageRows(args.snapshots);
|
||||
const usageFetchedAt =
|
||||
args.snapshots
|
||||
.map((snapshot) => snapshot.usageRowsFetchedAt)
|
||||
|
||||
@@ -37,6 +37,59 @@ describe('web dashboard server handler', () => {
|
||||
expect(body.tasks.total).toBe(0);
|
||||
});
|
||||
|
||||
it('serves full Claude, Kimi, and Codex usage rows through overview JSON', async () => {
|
||||
const handler = createWebDashboardHandler({
|
||||
readStatusSnapshots: () => [
|
||||
{
|
||||
serviceId: 'renderer',
|
||||
agentType: 'claude-code',
|
||||
assistantName: 'Claude',
|
||||
updatedAt: '2026-04-26T11:59:00.000Z',
|
||||
entries: [],
|
||||
usageRowsFetchedAt: '2026-04-26T11:59:00.000Z',
|
||||
usageRows: [
|
||||
{
|
||||
name: 'Claude1 Max',
|
||||
h5pct: 66,
|
||||
h5reset: '2h',
|
||||
d7pct: 40,
|
||||
d7reset: '4d',
|
||||
},
|
||||
{
|
||||
name: 'Kimi',
|
||||
h5pct: 10,
|
||||
h5reset: '3h',
|
||||
d7pct: 12,
|
||||
d7reset: '5d',
|
||||
},
|
||||
{
|
||||
name: 'Codex1',
|
||||
h5pct: 25,
|
||||
h5reset: '55m',
|
||||
d7pct: 35,
|
||||
d7reset: '2d',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
getTasks: () => [],
|
||||
});
|
||||
|
||||
const overview = await handler(
|
||||
new Request('http://localhost/api/overview'),
|
||||
);
|
||||
expect(overview.status).toBe(200);
|
||||
const body = (await overview.json()) as {
|
||||
usage: { rows: Array<{ name: string }> };
|
||||
};
|
||||
|
||||
expect(body.usage.rows.map((row) => row.name)).toEqual([
|
||||
'Claude1 Max',
|
||||
'Kimi',
|
||||
'Codex1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('serves Vite static assets and falls back to index for SPA routes', async () => {
|
||||
const staticDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ejclaw-dashboard-'),
|
||||
|
||||
Reference in New Issue
Block a user