refactor dashboard room activity fetching

Refactor selected-room dashboard activity fetching, split Rooms i18n, and tighten mobile usage layout.
This commit is contained in:
Eyejoker
2026-04-27 20:36:18 +09:00
committed by GitHub
parent ea323cc787
commit eeba0334f9
5 changed files with 390 additions and 329 deletions

View File

@@ -34,7 +34,6 @@ import {
fetchAccounts,
fetchDashboardData,
fetchModelConfig,
fetchRoomsTimelineBatch,
runInboxAction,
runServiceAction,
runScheduledTaskAction,
@@ -52,6 +51,10 @@ import {
type Locale,
type Messages,
} from './i18n';
import {
useSelectedRoomActivity,
type RoomActivityMap,
} from './useRoomActivity';
import './styles.css';
interface DashboardState {
@@ -60,8 +63,6 @@ interface DashboardState {
tasks: DashboardTask[];
}
type RoomActivityMap = Record<string, DashboardRoomActivity>;
type UsageRow = DashboardOverview['usage']['rows'][number];
type InboxItem = DashboardOverview['inbox'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
@@ -2568,7 +2569,9 @@ function RoomBoardV2({
roomActivity,
roomActivityLoading,
roomMessageKey,
selectedJid,
locale,
onSelectedJidChange,
snapshots,
t,
}: {
@@ -2585,13 +2588,14 @@ function RoomBoardV2({
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
roomMessageKey: string | null;
selectedJid: string | null;
locale: Locale;
onSelectedJidChange: (jid: string | null) => void;
snapshots: StatusSnapshot[];
t: Messages;
}) {
const [filter, setFilter] = useState<RoomFilter>('all');
const [sort, setSort] = useState<RoomSort>('recent');
const [selectedJid, setSelectedJid] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
@@ -2601,10 +2605,6 @@ function RoomBoardV2({
})),
);
if (allEntries.length === 0) {
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
const counts = {
all: allEntries.length,
processing: allEntries.filter((e) => e.status === 'processing').length,
@@ -2632,6 +2632,15 @@ function RoomBoardV2({
const selectedEntry =
sorted.find((e) => e.jid === selectedJid) ?? sorted[0] ?? null;
useEffect(() => {
const nextJid = selectedEntry?.jid ?? null;
if (nextJid !== selectedJid) onSelectedJidChange(nextJid);
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
if (allEntries.length === 0) {
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
function setDraft(jid: string, value: string) {
setDrafts((previous) => ({ ...previous, [jid]: value }));
}
@@ -2705,7 +2714,7 @@ function RoomBoardV2({
aria-current={active ? 'page' : undefined}
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
key={`${entry.serviceId}:${entry.jid}`}
onClick={() => setSelectedJid(entry.jid)}
onClick={() => onSelectedJidChange(entry.jid)}
type="button"
>
<span className={`room-pulse pulse-${entry.status}`}>
@@ -3788,8 +3797,15 @@ function App() {
const [serviceActionKey, setServiceActionKey] =
useState<ServiceActionKey | null>(null);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
const [roomActivity, setRoomActivity] = useState<RoomActivityMap>({});
const [roomActivityLoading, setRoomActivityLoading] = useState(false);
const [selectedRoomJid, setSelectedRoomJid] = useState<string | null>(null);
const {
refreshRoom: refreshRoomActivity,
roomActivity,
roomActivityLoading,
} = useSelectedRoomActivity({
active: activeView === 'rooms',
selectedRoomJid: selectedRoomJid,
});
const [pendingMessages, setPendingMessages] = useState<
Record<string, Array<DashboardRoomActivity['messages'][number]>>
>({});
@@ -3956,8 +3972,7 @@ function App() {
try {
await sendRoomMessage(roomJid, text, requestId, nickname || null);
try {
const fresh = await fetchRoomsTimelineBatch();
setRoomActivity(fresh);
await refreshRoomActivity(roomJid);
} catch {
/* refresh will retry on next poll */
}
@@ -4125,102 +4140,6 @@ function App() {
return () => window.clearInterval(id);
}, []);
const roomsJidsKey = useMemo(() => {
if (!data) return '';
return [
...new Set(
data.snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => entry.jid),
),
),
]
.sort()
.join('|');
}, [data]);
const roomsLastUpdatedKey = useMemo(() => {
if (!data) return '';
return data.snapshots
.map((s) => s.updatedAt)
.sort()
.join('|');
}, [data]);
useEffect(() => {
if (activeView !== 'rooms' || !data) return;
if (!roomsJidsKey) {
setRoomActivity({});
return;
}
let cancelled = false;
let inFlight = false;
let pollIntervalId: number | null = null;
let es: EventSource | null = null;
const applyMap = (map: RoomActivityMap) => {
if (!cancelled) setRoomActivity(map);
};
const fetchOnce = async (initial: boolean) => {
if (cancelled || inFlight) return;
inFlight = true;
if (initial) setRoomActivityLoading(true);
try {
applyMap(await fetchRoomsTimelineBatch());
} catch {
/* keep last good state */
} finally {
inFlight = false;
if (!cancelled && initial) setRoomActivityLoading(false);
}
};
const startPollingFallback = () => {
if (pollIntervalId !== null) return;
pollIntervalId = window.setInterval(() => void fetchOnce(false), 2000);
};
const stopPollingFallback = () => {
if (pollIntervalId === null) return;
window.clearInterval(pollIntervalId);
pollIntervalId = null;
};
if (typeof window !== 'undefined' && 'EventSource' in window) {
void fetchOnce(true);
try {
es = new EventSource('/api/stream');
es.addEventListener('rooms-timeline', (event) => {
try {
const parsed = JSON.parse(
(event as MessageEvent).data,
) as RoomActivityMap;
applyMap(parsed);
stopPollingFallback();
} catch {
/* malformed event, ignore */
}
});
es.onerror = () => {
// Browser auto-reconnects; meanwhile keep data fresh via polling.
startPollingFallback();
};
} catch {
startPollingFallback();
}
} else {
void fetchOnce(true);
startPollingFallback();
}
return () => {
cancelled = true;
stopPollingFallback();
es?.close();
};
}, [activeView, roomsJidsKey]);
useEffect(() => {
setPendingMessages((prev) => {
const next: typeof prev = {};
@@ -4385,6 +4304,8 @@ function App() {
roomActivityLoading={roomActivityLoading}
roomMessageKey={roomMessageKey}
locale={locale}
onSelectedJidChange={setSelectedRoomJid}
selectedJid={selectedRoomJid}
snapshots={data.snapshots}
t={t}
/>

View File

@@ -1,3 +1,5 @@
import { roomMessages, type RoomMessages } from './i18n/rooms';
export const LOCALES = ['ko', 'en', 'zh', 'ja'] as const;
export type Locale = (typeof LOCALES)[number];
@@ -152,46 +154,7 @@ export interface Messages {
confirmDecline: string;
};
};
rooms: {
empty: string;
cardsAria: string;
room: string;
service: string;
agent: string;
status: string;
queue: string;
queueWaitingMessages: string;
tasks: string;
elapsed: string;
activity: string;
loadingActivity: string;
noActivity: string;
task: string;
noTask: string;
currentTurn: string;
noTurn: string;
round: string;
attempt: string;
updated: string;
output: string;
latestOutput: string;
outputHistory: string;
noOutput: string;
recentMessages: string;
messageHistory: string;
noMessages: string;
details: string;
message: string;
messagePlaceholder: string;
send: string;
sending: string;
error: string;
filterAll: string;
sortRecent: string;
sortName: string;
sortQueue: string;
sortLabel: string;
};
rooms: RoomMessages;
usage: {
empty: string;
current: string;
@@ -465,46 +428,7 @@ export const messages = {
confirmDecline: 'Decline this item?',
},
},
rooms: {
empty: '룸 없음.',
cardsAria: '룸 상태 카드',
room: '룸',
service: '서비스',
agent: '에이전트',
status: '상태',
queue: '큐',
queueWaitingMessages: '대기 메시지',
tasks: '태스크',
elapsed: '경과',
activity: '진행',
loadingActivity: '진행 로딩 중',
noActivity: '진행 없음',
task: '태스크',
noTask: '태스크 없음',
currentTurn: '현재 턴',
noTurn: '턴 없음',
round: '라운드',
attempt: '시도',
updated: '갱신',
output: '출력',
latestOutput: '마지막 출력',
outputHistory: '출력 기록',
noOutput: '출력 없음',
recentMessages: '메시지 기록',
messageHistory: '메시지 기록',
noMessages: '메시지 없음',
details: '세부',
message: '메시지',
messagePlaceholder: '요청 입력...',
send: '전송',
sending: '전송 중...',
error: '오류',
filterAll: '전체',
sortRecent: '최근 활동',
sortName: '이름순',
sortQueue: '큐 많은 순',
sortLabel: '정렬',
},
rooms: roomMessages.ko,
usage: {
empty: '사용량 스냅샷 없음. 수집기 확인.',
current: '사용중',
@@ -762,46 +686,7 @@ export const messages = {
confirmDecline: 'Decline this item?',
},
},
rooms: {
empty: 'No rooms yet.',
cardsAria: 'Room status cards',
room: 'room',
service: 'service',
agent: 'agent',
status: 'status',
queue: 'queue',
queueWaitingMessages: 'pending messages',
tasks: 'tasks',
elapsed: 'elapsed',
activity: 'activity',
loadingActivity: 'Loading activity',
noActivity: 'No activity',
task: 'task',
noTask: 'No task',
currentTurn: 'Current turn',
noTurn: 'No turn',
round: 'round',
attempt: 'attempt',
updated: 'updated',
output: 'output',
latestOutput: 'latest output',
outputHistory: 'output log',
noOutput: 'No output',
recentMessages: 'Message log',
messageHistory: 'Message log',
noMessages: 'No messages',
details: 'details',
message: 'message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
error: 'Error',
filterAll: 'All',
sortRecent: 'Recent activity',
sortName: 'Name',
sortQueue: 'Queue size',
sortLabel: 'Sort',
},
rooms: roomMessages.en,
usage: {
empty: 'No usage snapshot. Check collector.',
current: 'Active',
@@ -1059,46 +944,7 @@ export const messages = {
confirmDecline: '确认拒绝此项?',
},
},
rooms: {
empty: '暂无房间。',
cardsAria: '房间状态卡片',
room: '房间',
service: '服务',
agent: '代理',
status: '状态',
queue: '队列',
queueWaitingMessages: '待处理消息',
tasks: '任务',
elapsed: '耗时',
activity: '进展',
loadingActivity: '正在加载进展',
noActivity: '暂无进展',
task: '任务',
noTask: '无任务',
currentTurn: '当前回合',
noTurn: '无回合',
round: '轮次',
attempt: '尝试',
updated: '更新',
output: '输出',
latestOutput: '最新输出',
outputHistory: '输出记录',
noOutput: '暂无输出',
recentMessages: '消息记录',
messageHistory: '消息记录',
noMessages: '暂无消息',
details: '详情',
message: 'Message',
messagePlaceholder: '输入请求...',
send: '发送',
sending: '发送中...',
error: '错误',
filterAll: '全部',
sortRecent: '最近活动',
sortName: '名称',
sortQueue: '队列大小',
sortLabel: '排序',
},
rooms: roomMessages.zh,
usage: {
empty: '暂无用量快照。检查采集器。',
current: '使用中',
@@ -1358,46 +1204,7 @@ export const messages = {
confirmDecline: 'この項目を拒否しますか?',
},
},
rooms: {
empty: 'ルームなし。',
cardsAria: 'ルーム状態カード',
room: 'ルーム',
service: 'サービス',
agent: 'エージェント',
status: '状態',
queue: 'キュー',
queueWaitingMessages: '待機メッセージ',
tasks: 'タスク',
elapsed: '経過',
activity: '進行',
loadingActivity: '進行を読み込み中',
noActivity: '進行なし',
task: 'タスク',
noTask: 'タスクなし',
currentTurn: '現在ターン',
noTurn: 'ターンなし',
round: 'ラウンド',
attempt: '試行',
updated: '更新',
output: '出力',
latestOutput: '最新出力',
outputHistory: '出力履歴',
noOutput: '出力なし',
recentMessages: 'メッセージ履歴',
messageHistory: 'メッセージ履歴',
noMessages: 'メッセージなし',
details: '詳細',
message: 'メッセージ',
messagePlaceholder: '依頼を入力...',
send: '送信',
sending: '送信中...',
error: 'エラー',
filterAll: '全て',
sortRecent: '最近の活動',
sortName: '名前順',
sortQueue: 'キュー順',
sortLabel: '並び替え',
},
rooms: roomMessages.ja,
usage: {
empty: '使用量スナップショットなし。収集器を確認。',
current: '使用中',

View File

@@ -0,0 +1,205 @@
import type { Locale } from '../i18n';
export interface RoomMessages {
empty: string;
cardsAria: string;
room: string;
service: string;
agent: string;
status: string;
queue: string;
queueWaitingMessages: string;
tasks: string;
elapsed: string;
activity: string;
loadingActivity: string;
noActivity: string;
task: string;
noTask: string;
currentTurn: string;
noTurn: string;
round: string;
attempt: string;
updated: string;
output: string;
latestOutput: string;
outputHistory: string;
noOutput: string;
recentMessages: string;
messageHistory: string;
noMessages: string;
details: string;
message: string;
messagePlaceholder: string;
send: string;
sending: string;
error: string;
filterAll: string;
sortRecent: string;
sortName: string;
sortQueue: string;
sortLabel: string;
}
export const roomMessages = {
ko: {
empty: '룸 없음.',
cardsAria: '룸 상태 카드',
room: '룸',
service: '서비스',
agent: '에이전트',
status: '상태',
queue: '큐',
queueWaitingMessages: '대기 메시지',
tasks: '태스크',
elapsed: '경과',
activity: '진행',
loadingActivity: '진행 로딩 중',
noActivity: '진행 없음',
task: '태스크',
noTask: '태스크 없음',
currentTurn: '현재 턴',
noTurn: '턴 없음',
round: '라운드',
attempt: '시도',
updated: '갱신',
output: '출력',
latestOutput: '마지막 출력',
outputHistory: '출력 기록',
noOutput: '출력 없음',
recentMessages: '메시지 기록',
messageHistory: '메시지 기록',
noMessages: '메시지 없음',
details: '세부',
message: '메시지',
messagePlaceholder: '요청 입력...',
send: '전송',
sending: '전송 중...',
error: '오류',
filterAll: '전체',
sortRecent: '최근 활동',
sortName: '이름순',
sortQueue: '큐 많은 순',
sortLabel: '정렬',
},
en: {
empty: 'No rooms yet.',
cardsAria: 'Room status cards',
room: 'room',
service: 'service',
agent: 'agent',
status: 'status',
queue: 'queue',
queueWaitingMessages: 'pending messages',
tasks: 'tasks',
elapsed: 'elapsed',
activity: 'activity',
loadingActivity: 'Loading activity',
noActivity: 'No activity',
task: 'task',
noTask: 'No task',
currentTurn: 'Current turn',
noTurn: 'No turn',
round: 'round',
attempt: 'attempt',
updated: 'updated',
output: 'output',
latestOutput: 'latest output',
outputHistory: 'output log',
noOutput: 'No output',
recentMessages: 'Message log',
messageHistory: 'Message log',
noMessages: 'No messages',
details: 'details',
message: 'message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
error: 'Error',
filterAll: 'All',
sortRecent: 'Recent activity',
sortName: 'Name',
sortQueue: 'Queue size',
sortLabel: 'Sort',
},
zh: {
empty: '暂无房间。',
cardsAria: '房间状态卡片',
room: '房间',
service: '服务',
agent: '代理',
status: '状态',
queue: '队列',
queueWaitingMessages: '待处理消息',
tasks: '任务',
elapsed: '耗时',
activity: '进展',
loadingActivity: '正在加载进展',
noActivity: '暂无进展',
task: '任务',
noTask: '无任务',
currentTurn: '当前回合',
noTurn: '无回合',
round: '轮次',
attempt: '尝试',
updated: '更新',
output: '输出',
latestOutput: '最新输出',
outputHistory: '输出记录',
noOutput: '暂无输出',
recentMessages: '消息记录',
messageHistory: '消息记录',
noMessages: '暂无消息',
details: '详情',
message: 'Message',
messagePlaceholder: '输入请求...',
send: '发送',
sending: '发送中...',
error: '错误',
filterAll: '全部',
sortRecent: '最近活动',
sortName: '名称',
sortQueue: '队列大小',
sortLabel: '排序',
},
ja: {
empty: 'ルームなし。',
cardsAria: 'ルーム状態カード',
room: 'ルーム',
service: 'サービス',
agent: 'エージェント',
status: '状態',
queue: 'キュー',
queueWaitingMessages: '待機メッセージ',
tasks: 'タスク',
elapsed: '経過',
activity: '進行',
loadingActivity: '進行を読み込み中',
noActivity: '進行なし',
task: 'タスク',
noTask: 'タスクなし',
currentTurn: '現在ターン',
noTurn: 'ターンなし',
round: 'ラウンド',
attempt: '試行',
updated: '更新',
output: '出力',
latestOutput: '最新出力',
outputHistory: '出力履歴',
noOutput: '出力なし',
recentMessages: 'メッセージ履歴',
messageHistory: 'メッセージ履歴',
noMessages: 'メッセージなし',
details: '詳細',
message: 'メッセージ',
messagePlaceholder: '依頼を入力...',
send: '送信',
sending: '送信中...',
error: 'エラー',
filterAll: '全て',
sortRecent: '最近の活動',
sortName: '名前順',
sortQueue: 'キュー順',
sortLabel: '並び替え',
},
} satisfies Record<Locale, RoomMessages>;

View File

@@ -3004,35 +3004,66 @@ progress::-moz-progress-bar {
.usage-row {
grid-template-columns:
minmax(86px, 0.86fr) minmax(62px, 0.54fr) minmax(62px, 0.54fr)
minmax(58px, 0.4fr);
gap: 5px;
padding: 9px;
minmax(72px, 0.82fr) minmax(52px, 0.48fr) minmax(52px, 0.48fr)
minmax(48px, 0.36fr);
gap: 4px;
padding: 7px 6px;
}
.usage-account {
display: grid;
gap: 5px;
gap: 3px;
}
.usage-account div {
gap: 3px;
}
.usage-account strong {
font-size: 13px;
}
.usage-account .pill,
.usage-account .mono-chip {
padding: 3px 6px;
font-size: 10px;
}
.usage-quota,
.usage-speed {
gap: 4px;
gap: 3px;
}
.usage-quota {
padding: 5px;
padding: 4px;
}
.usage-quota div {
gap: 6px;
gap: 4px;
}
.usage-quota span,
.usage-speed span {
font-size: 9px;
letter-spacing: 0.04em;
}
.usage-quota strong,
.usage-speed strong {
font-size: 14px;
}
.usage-speed {
min-height: 0;
padding: 4px;
}
.usage-quota small {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
display: none;
}
.usage-speed small {
display: none;
}
.record-card-grid {
@@ -3168,7 +3199,7 @@ progress::-moz-progress-bar {
}
.usage-summary {
grid-template-columns: 1fr;
grid-template-columns: minmax(0, 1fr) auto;
}
.usage-summary div:last-child {
@@ -3176,19 +3207,19 @@ progress::-moz-progress-bar {
}
.usage-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns:
minmax(68px, 0.8fr) minmax(48px, 0.48fr) minmax(48px, 0.48fr)
minmax(44px, 0.34fr);
}
.usage-account {
grid-column: 1 / -1;
grid-column: auto;
}
.usage-speed {
grid-column: 1 / -1;
display: flex;
min-height: auto;
align-items: center;
justify-content: space-between;
grid-column: auto;
display: grid;
min-height: 0;
}
.skeleton-grid,
@@ -4582,6 +4613,35 @@ progress::-moz-progress-bar {
color: var(--muted);
}
@media (max-width: 640px) {
.usage-matrix {
overflow-x: hidden;
}
.usage-row {
grid-template-columns: minmax(0, 1fr) 62px 62px !important;
align-items: center;
}
.usage-speed {
display: none;
}
.usage-account strong {
font-size: 13px;
}
.usage-account .pill,
.usage-account .mono-chip {
padding: 3px 6px;
font-size: 10px;
}
.usage-quota {
min-width: 0;
}
}
@media (max-width: 760px) {
.rooms-grid {
grid-template-columns: 1fr;

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchRoomTimeline, type DashboardRoomActivity } from './api';
export type RoomActivityMap = Record<string, DashboardRoomActivity>;
interface UseSelectedRoomActivityOptions {
active: boolean;
pollMs?: number;
selectedRoomJid: string | null;
}
export function useSelectedRoomActivity({
active,
pollMs = 2000,
selectedRoomJid,
}: UseSelectedRoomActivityOptions): {
refreshRoom: (roomJid: string) => Promise<DashboardRoomActivity>;
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
} {
const [roomActivity, setRoomActivity] = useState<RoomActivityMap>({});
const [roomActivityLoading, setRoomActivityLoading] = useState(false);
const refreshRoom = useCallback(async (roomJid: string) => {
const activity = await fetchRoomTimeline(roomJid);
setRoomActivity((previous) => ({
...previous,
[roomJid]: activity,
}));
return activity;
}, []);
useEffect(() => {
if (!active || !selectedRoomJid) {
setRoomActivityLoading(false);
return undefined;
}
let cancelled = false;
let inFlight = false;
let pollIntervalId: number | null = null;
const fetchOnce = async (initial: boolean) => {
if (cancelled || inFlight) return;
inFlight = true;
if (initial) setRoomActivityLoading(true);
try {
await refreshRoom(selectedRoomJid);
} catch {
/* Keep the last good timeline snapshot. */
} finally {
inFlight = false;
if (!cancelled && initial) setRoomActivityLoading(false);
}
};
void fetchOnce(true);
pollIntervalId = window.setInterval(() => void fetchOnce(false), pollMs);
return () => {
cancelled = true;
if (pollIntervalId !== null) window.clearInterval(pollIntervalId);
};
}, [active, pollMs, refreshRoom, selectedRoomJid]);
return { refreshRoom, roomActivity, roomActivityLoading };
}