fix: improve dashboard react doctor score (#215)

This commit is contained in:
Eyejoker
2026-06-03 08:30:27 +08:00
committed by GitHub
parent 2dab27f443
commit 32468e0214
10 changed files with 309 additions and 141 deletions

View File

@@ -204,7 +204,7 @@ function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] {
}
}
}
return [...rooms.values()].sort((a, b) =>
return Array.from(rooms.values()).sort((a, b) =>
`${a.name} ${a.folder}`.localeCompare(`${b.name} ${b.folder}`),
);
}
@@ -247,7 +247,7 @@ function DashboardErrorCard({
<strong>{t.error.api}</strong>
<small>{humanizeError(error, t)}</small>
</span>
<button disabled={refreshing} onClick={onRetry}>
<button disabled={refreshing} onClick={onRetry} type="button">
{t.actions.retry}
</button>
</section>

View File

@@ -300,7 +300,7 @@ export function RoomBoardV2({
const filtered = allEntries.filter(
(entry) => filter === 'all' || entry.status === filter,
);
const sorted = [...filtered].sort((a, b) => {
const sorted = Array.from(filtered).sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'queue') {
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
@@ -321,10 +321,11 @@ export function RoomBoardV2({
const nextJid = selectedEntry?.jid ?? null;
if (nextJid !== selectedJid) {
onSelectedJidChange(nextJid);
setMobileDetailOpen(false);
}
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
const detailOpen = mobileDetailOpen && selectedEntry?.jid === selectedJid;
if (allEntries.length === 0) {
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
@@ -359,9 +360,7 @@ export function RoomBoardV2({
{sorted.length === 0 ? (
<EmptyState>{t.rooms.empty}</EmptyState>
) : (
<div
className={`rooms-twopane${mobileDetailOpen ? ' is-detail-open' : ''}`}
>
<div className={`rooms-twopane${detailOpen ? ' is-detail-open' : ''}`}>
<RoomsList
entries={sorted}
inbox={inbox}

View File

@@ -91,6 +91,56 @@ interface TaskEditFormProps {
t: Messages;
}
const TASK_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
en: new Intl.DateTimeFormat(localeTags.en, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
ja: new Intl.DateTimeFormat(localeTags.ja, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
ko: new Intl.DateTimeFormat(localeTags.ko, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
zh: new Intl.DateTimeFormat(localeTags.zh, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
const EN_TASK_DATE_TIME_FORMATTER = new Intl.DateTimeFormat(localeTags.en, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const RELATIVE_TIME_FORMATTERS: Record<Locale, Intl.RelativeTimeFormat> = {
en: new Intl.RelativeTimeFormat(localeTags.en, {
numeric: 'auto',
style: 'short',
}),
ja: new Intl.RelativeTimeFormat(localeTags.ja, {
numeric: 'auto',
style: 'short',
}),
ko: new Intl.RelativeTimeFormat(localeTags.ko, {
numeric: 'auto',
style: 'short',
}),
zh: new Intl.RelativeTimeFormat(localeTags.zh, {
numeric: 'auto',
style: 'short',
}),
};
function formatTaskDate(
value: string | null | undefined,
locale: Locale,
@@ -98,22 +148,12 @@ function formatTaskDate(
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const time = new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
const time = TASK_TIME_FORMATTERS[locale].format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja' || locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return new Intl.DateTimeFormat(localeTags[locale], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date);
return EN_TASK_DATE_TIME_FORMATTER.format(date);
}
function formatRelativeDate(
@@ -135,10 +175,10 @@ function formatRelativeDate(
];
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);
return RELATIVE_TIME_FORMATTERS[locale].format(
Math.round(diffMs / unitMs),
unit,
);
}
function safePreview(

View File

@@ -141,7 +141,7 @@ function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
export function UsagePanel({ overview, t }: UsagePanelProps) {
const rows = useMemo(
() =>
[...overview.usage.rows].sort((a, b) => {
Array.from(overview.usage.rows).sort((a, b) => {
if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
return usagePeak(b) - usagePeak(a);
}),
@@ -194,24 +194,28 @@ export function UsagePanel({ overview, t }: UsagePanelProps) {
</div>
</div>
<div className="usage-matrix" role="table" aria-label={t.panels.usage}>
<div className="usage-matrix-head" role="row">
<span>{t.usage.usage}</span>
<span>{t.usage.quota.h5}</span>
<span>{t.usage.quota.d7}</span>
<span>{t.usage.speed}</span>
</div>
<table className="usage-matrix" aria-label={t.panels.usage}>
<thead>
<tr className="usage-matrix-head">
<th scope="col">{t.usage.usage}</th>
<th scope="col">{t.usage.quota.h5}</th>
<th scope="col">{t.usage.quota.d7}</th>
<th scope="col">{t.usage.speed}</th>
</tr>
</thead>
{groups.map((group) => (
<div className="usage-group" key={group.key} role="rowgroup">
<div className="usage-group-label" role="row">
<span>{group.label}</span>
</div>
<tbody className="usage-group" key={group.key}>
<tr className="usage-group-label">
<th colSpan={4} scope="colgroup">
{group.label}
</th>
</tr>
{group.rows.map((row) => {
const risk = usageRiskLevel(row);
const { account, plan } = usageNameParts(row);
return (
<section className={`usage-row usage-${risk}`} key={row.name}>
<div className="usage-account">
<tr className={`usage-row usage-${risk}`} key={row.name}>
<th className="usage-account" scope="row">
<strong>{account}</strong>
<div>
{usageActive(row) ? (
@@ -224,26 +228,32 @@ export function UsagePanel({ overview, t }: UsagePanelProps) {
</span>
) : null}
</div>
</div>
<UsageQuotaMeter
row={row}
rowName={account}
window="h5"
t={t}
/>
<UsageQuotaMeter
row={row}
rowName={account}
window="d7"
t={t}
/>
<UsageSpeed row={row} t={t} />
</section>
</th>
<td>
<UsageQuotaMeter
row={row}
rowName={account}
window="h5"
t={t}
/>
</td>
<td>
<UsageQuotaMeter
row={row}
rowName={account}
window="d7"
t={t}
/>
</td>
<td>
<UsageSpeed row={row} t={t} />
</td>
</tr>
);
})}
</div>
</tbody>
))}
</div>
</table>
</div>
);
}

View File

@@ -1,6 +1,34 @@
import type { DashboardTask, DashboardTaskAction } from './api';
import { localeTags, type Locale, type Messages } from './i18n';
const SHORT_TIME_FORMAT_OPTIONS = {
hour: '2-digit',
hour12: false,
minute: '2-digit',
} as const;
const MONTH_DAY_TIME_FORMAT_OPTIONS = {
day: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: 'short',
} as const;
const SHORT_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
en: new Intl.DateTimeFormat(localeTags.en, SHORT_TIME_FORMAT_OPTIONS),
ja: new Intl.DateTimeFormat(localeTags.ja, SHORT_TIME_FORMAT_OPTIONS),
ko: new Intl.DateTimeFormat(localeTags.ko, SHORT_TIME_FORMAT_OPTIONS),
zh: new Intl.DateTimeFormat(localeTags.zh, SHORT_TIME_FORMAT_OPTIONS),
};
const MONTH_DAY_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
en: new Intl.DateTimeFormat(localeTags.en, MONTH_DAY_TIME_FORMAT_OPTIONS),
ja: new Intl.DateTimeFormat(localeTags.ja, MONTH_DAY_TIME_FORMAT_OPTIONS),
ko: new Intl.DateTimeFormat(localeTags.ko, MONTH_DAY_TIME_FORMAT_OPTIONS),
zh: new Intl.DateTimeFormat(localeTags.zh, MONTH_DAY_TIME_FORMAT_OPTIONS),
};
export function formatDate(
value: string | null | undefined,
locale: Locale,
@@ -31,30 +59,16 @@ export function formatDate(
}
const sameDay = new Date().toDateString() === date.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
hour12: false,
minute: '2-digit',
}).format(date);
return SHORT_TIME_FORMATTERS[locale].format(date);
}
const time = new Intl.DateTimeFormat(localeTags[locale], {
hour: '2-digit',
hour12: false,
minute: '2-digit',
}).format(date);
const time = SHORT_TIME_FORMATTERS[locale].format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return new Intl.DateTimeFormat(localeTags[locale], {
day: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: 'short',
}).format(date);
return MONTH_DAY_TIME_FORMATTERS[locale].format(date);
}
export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {

View File

@@ -169,6 +169,41 @@ function mergeAdjacentBotChunks(entries: RoomThreadEntry[]): RoomThreadEntry[] {
return merged;
}
function collectOutputEntries(outputs: RoomOutput[]): RoomThreadEntry[] {
const entries: RoomThreadEntry[] = [];
for (const output of outputs) {
const entry = toOutputEntry(output);
if (entry) entries.push(entry);
}
return entries;
}
function collectChatEntries(
messages: RoomMessage[],
outputEntries: RoomThreadEntry[],
): RoomThreadEntry[] {
const entries: RoomThreadEntry[] = [];
for (const message of messages) {
if (!isThreadChatMessage(message)) continue;
const entry = toMessageEntry(message);
if (!isDuplicateOutputMessage(entry, outputEntries)) entries.push(entry);
}
return entries;
}
function collectOptimisticPending(
pendingMessages: RoomMessage[],
confirmedSet: Set<string>,
): RoomThreadEntry[] {
const entries: RoomThreadEntry[] = [];
for (const message of pendingMessages) {
if (confirmedSet.has(messageKey(message))) continue;
if (isInternalProtocolPayload(message.content)) continue;
entries.push(toMessageEntry(message));
}
return entries;
}
export function buildRoomThreadEntries({
messages,
outputs,
@@ -179,20 +214,14 @@ export function buildRoomThreadEntries({
pendingMessages?: RoomMessage[];
}): RoomThreadEntry[] {
const confirmedSet = new Set(messages.map(messageKey));
const outputEntries = outputs
.map(toOutputEntry)
.filter((entry): entry is RoomThreadEntry => Boolean(entry));
const chatEntries = messages
.filter(isThreadChatMessage)
.map(toMessageEntry)
.filter((entry) => !isDuplicateOutputMessage(entry, outputEntries));
const optimisticPending = pendingMessages
.filter((message) => !confirmedSet.has(messageKey(message)))
.filter((message) => !isInternalProtocolPayload(message.content))
.map(toMessageEntry);
const entries = [...chatEntries, ...optimisticPending, ...outputEntries].sort(
(a, b) => a.timestamp.localeCompare(b.timestamp),
const outputEntries = collectOutputEntries(outputs);
const chatEntries = collectChatEntries(messages, outputEntries);
const optimisticPending = collectOptimisticPending(
pendingMessages,
confirmedSet,
);
const entries = chatEntries
.concat(optimisticPending, outputEntries)
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
return mergeAdjacentBotChunks(entries);
}

View File

@@ -2227,6 +2227,19 @@ dd,
.usage-matrix {
display: grid;
overflow: hidden;
border-spacing: 0;
}
.usage-matrix thead,
.usage-matrix tbody {
display: grid;
}
.usage-matrix th,
.usage-matrix td {
padding: 0;
border: 0;
text-align: inherit;
}
.usage-matrix-head,
@@ -2258,7 +2271,6 @@ dd,
}
.usage-group-label {
padding: 7px 10px;
color: var(--accent-strong);
font-size: 11px;
font-weight: 800;
@@ -2267,6 +2279,14 @@ dd,
background: rgba(191, 95, 44, 0.07);
}
.usage-group-label th {
padding: 7px 10px;
color: inherit;
font-weight: inherit;
letter-spacing: inherit;
text-transform: inherit;
}
.usage-row {
--meter: var(--green);
padding: 8px 10px;

View File

@@ -33,7 +33,6 @@ export function useSelectedRoomActivity({
useEffect(() => {
if (!active || !selectedRoomJid) {
setRoomActivityLoading(false);
return undefined;
}
@@ -64,5 +63,10 @@ export function useSelectedRoomActivity({
};
}, [active, pollMs, refreshRoom, selectedRoomJid]);
return { refreshRoom, roomActivity, roomActivityLoading };
return {
refreshRoom,
roomActivity,
roomActivityLoading:
active && selectedRoomJid !== null ? roomActivityLoading : false,
};
}