feat(auth-files): redesign credential management as a row-based list

- Display credentials, plans, status, request statistics, quotas, and actions
- Add pagination, enable/disable switches, expandable details, and compact quota views
- Preserve model cooldown details, countdowns, and reset controls
- Support responsive layouts, light/dark themes, and localization
- Add component and UI regression tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent d29a1af69b
commit cc24f8419f
35 files changed
+1504 -116

No files matched your search

+8
View File
@@ -7,6 +7,7 @@ import {
Check,
ChevronUp,
ExternalLink,
Gauge,
History,
House,
Languages,
@@ -28,6 +29,7 @@ import { ApiAccessPage } from './pages/ApiAccessPage';
import { KernelPage } from './pages/Kernel';
import { VersionManagementPage } from './pages/VersionManagementPage';
import { OAuthManagementPage } from './pages/ManagementPages';
import { QuotaPage } from './pages/QuotaPage';
import { AgentsPage } from './pages/AgentsPage';
import { EasyModePage } from './pages/EasyModePage';
import { UsageRecordsPage } from './pages/UsageRecordsPage';
@@ -67,6 +69,12 @@ const pages = [
icon: LogIn,
component: OAuthManagementPage,
},
{
id: 'quota',
labelKey: 'app.nav.quota',
icon: Gauge,
component: QuotaPage,
},
{
id: 'agents',
labelKey: 'app.nav.agents',
+11
View File
@@ -83,6 +83,17 @@
.auth-health-record-heading strong { min-width: 0; color: var(--text-primary); overflow-wrap: anywhere; }
.auth-health-record-meta { margin: 3px 0; }
.auth-health-hint { display: block; margin: 3px 0 0; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; }
.auth-health-actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px 12px;
padding: 8px 12px;
border-top: 1px solid var(--border-color);
}
.auth-health-actions .auth-health-hint { flex: 1; min-width: 140px; margin: 0; }
.auth-health-reset { flex-shrink: 0; margin-left: auto; }
@media (max-width: 760px) {
.auth-health-summary-content { flex-basis: calc(100% - 30px); }
+22 -11
View File
@@ -1,5 +1,5 @@
import { useMemo, useSyncExternalStore } from 'react';
import { ChevronDown, CircleHelp, Clock3, Info, PauseCircle, ShieldAlert } from 'lucide-react';
import { ChevronDown, CircleHelp, Clock3, Info, LoaderCircle, PauseCircle, RotateCcw, ShieldAlert } from 'lucide-react';
import { useI18n } from '../i18n';
import {
authFileHealth,
@@ -35,10 +35,12 @@ const subscribeClock = (listener: () => void) => {
type HealthProps = {
file: Record<string, unknown>;
snapshot?: AuthFileCooldownSnapshot;
resetting?: boolean;
resetDisabled?: boolean;
onReset?: () => void;
};
export function AuthFileHealthStatus({ file, receivedAtMs, observedAt }: {
file: Record<string, unknown>;
export function AuthFileHealthStatus({ file, receivedAtMs, observedAt, ...resetProps }: Omit<HealthProps, 'snapshot'> & {
receivedAtMs: number;
observedAt?: string;
}) {
@@ -47,8 +49,8 @@ export function AuthFileHealthStatus({ file, receivedAtMs, observedAt }: {
[file, receivedAtMs, observedAt],
);
return snapshot?.records?.length
? <TimedHealthStatus file={file} snapshot={snapshot} />
: <HealthStatus file={file} snapshot={snapshot} nowMs={receivedAtMs} />;
? <TimedHealthStatus file={file} snapshot={snapshot} {...resetProps} />
: <HealthStatus file={file} snapshot={snapshot} nowMs={receivedAtMs} {...resetProps} />;
}
function TimedHealthStatus(props: HealthProps) {
@@ -56,7 +58,7 @@ function TimedHealthStatus(props: HealthProps) {
return <HealthStatus {...props} nowMs={nowMs} />;
}
function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }) {
function HealthStatus({ file, snapshot, nowMs, resetting = false, resetDisabled = false, onReset }: HealthProps & { nowMs: number }) {
const { t, formatNumber, formatDate } = useI18n();
const health = authFileHealth(file);
const cooldown = summarizeAuthFileCooldowns(snapshot, nowMs);
@@ -67,10 +69,6 @@ function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }
const reasonSet = new Set(cooldown.active.map(({ record }) => cooldownReasonKey(record.reason)));
if (reasonSet.has('authFiles.health.reason.credentialQuota')) reasonSet.delete('authFiles.health.reason.quota');
const reasons = Array.from(reasonSet);
const title = health.disabled ? t(health.label)
: cooldown.elapsed ? t('authFiles.health.elapsed')
: cooldown.active.length ? reasons.map((key) => t(key)).join(' · ')
: healthy ? t('authFiles.health.unknownCooldown') : t(health.label);
const tone = health.disabled || (healthy && !hasTimers) ? 'neutral' : hasTimers ? 'warning' : health.tone;
const separateState = hasTimers && !health.disabled && (
health.status === 'pending' || health.status === 'refreshing'
@@ -89,13 +87,17 @@ function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }
? cooldown.modelCount ? t('authFiles.health.credentialModels', { count: cooldown.modelCount })
: t('authFiles.health.credential')
: cooldown.modelCount ? t('authFiles.health.models', { count: cooldown.modelCount }) : '';
const title = health.disabled ? t(health.label)
: cooldown.elapsed ? t('authFiles.health.elapsed')
: scope || (healthy ? t('authFiles.health.unknownCooldown') : t(health.label));
const subtitle = health.disabled ? scope : reasons.map((key) => t(key)).join(' · ');
const formatTimestamp = (value: string) => formatDate(value, {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
const summary = <>
<Icon size={16} className="auth-health-icon" aria-hidden="true" />
<span className="auth-health-summary-content">
<span className="auth-health-heading"><strong>{title}</strong>{scope ? <span>{scope}</span> : null}</span>
<span className="auth-health-heading"><strong>{title}</strong>{subtitle ? <span>{subtitle}</span> : null}</span>
{separateState ? <span className="auth-health-hint">{t('authFiles.health.coreStatus', { status: t(health.label) })}</span> : null}
{health.message && !hasTimers && !health.label.startsWith('authFiles.health.reason.')
? <span className="auth-health-message-preview">{health.message}</span> : null}
@@ -138,6 +140,15 @@ function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }
{hasTimers ? <p className="auth-health-hint">{t('authFiles.health.note')}</p> : null}
</div>
</details> : <div className="auth-health-summary">{summary}</div>}
{hasTimers && onReset ? <div className="auth-health-actions">
<span className="auth-health-hint">{t('authFiles.cooldown.resetHint')}</span>
<button type="button" className="secondary-button compact-button auth-health-reset"
onClick={onReset} disabled={resetDisabled || resetting} aria-busy={resetting}
title={t('authFiles.cooldown.resetHint')}>
{resetting ? <LoaderCircle size={14} className="spin" aria-hidden="true" /> : <RotateCcw size={14} aria-hidden="true" />}
{t(resetting ? 'authFiles.cooldown.resetting' : 'authFiles.cooldown.resetButton')}
</button>
</div> : null}
</section>
);
}
+67
View File
@@ -0,0 +1,67 @@
.credential-quota.credential-quota-compact {
container: credential-quota / inline-size;
min-width: 0;
gap: 6px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.credential-quota-compact .credential-quota-rows {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 14px;
}
.credential-quota-compact .credential-quota-row {
align-content: start;
gap: 5px;
}
.credential-quota-compact .credential-quota-label {
gap: 6px;
}
.credential-quota-compact .credential-quota-label span {
font-size: var(--font-size-caption);
}
.credential-quota-compact .credential-quota-label strong {
font-size: var(--font-size-caption);
}
.credential-quota-compact .credential-quota-track {
height: 5px;
}
.credential-quota-compact .credential-quota-row small {
line-height: 1.4;
}
.credential-quota-compact .credential-quota-heading .credential-quota-reset {
min-height: 22px;
padding: 2px 6px;
}
.credential-quota-compact .credential-quota-footnotes {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
}
.credential-quota-more > summary {
width: fit-content;
color: var(--primary-color);
font-size: var(--font-size-caption);
cursor: pointer;
}
.credential-quota-more[open] > summary {
margin-bottom: 8px;
}
@container credential-quota (max-width: 280px) {
.credential-quota-compact .credential-quota-rows {
grid-template-columns: minmax(0, 1fr);
}
}
+33 -23
View File
@@ -4,48 +4,58 @@ import { MessageNotice } from '../appNotice';
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
import { canResetCodexQuota } from '../services/quotaActions';
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
import './AuthFileQuotaPanel.css';
export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset }: {
export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset, compact = false }: {
quota: QuotaState;
file: AuthFile;
disabled: boolean;
onRefresh: () => void;
onReset?: () => void;
compact?: boolean;
}) {
const { locale, t } = useI18n();
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
const loading = quota.status === 'loading';
const name = fileName(file);
const showReset = Boolean(onReset && (quota.resetCredits ?? 0) > 0);
const renderRow = (row: QuotaState['rows'][number], index: number) => {
const percent = row.remainingPercent !== null && Number.isFinite(row.remainingPercent) ? Math.max(0, Math.min(100, row.remainingPercent)) : null;
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
const tone = percent === null ? 'unknown' : percent <= 10 ? 'low' : percent <= 30 ? 'medium' : 'high';
return <div className={`credential-quota-row ${tone}`} key={`${row.label}-${index}`}>
<div className="credential-quota-label"><span title={row.label}>{row.label}</span><strong>{percent === null ? '—' : `${Math.round(percent)}%`}</strong></div>
<div className="credential-quota-track" role={percent === null ? undefined : 'progressbar'} aria-label={`${row.label} · ${t('authFiles.settings.quotaRemaining')}`}
aria-valuemin={percent === null ? undefined : 0} aria-valuemax={percent === null ? undefined : 100} aria-valuenow={percent ?? undefined}>
{percent === null ? <span className="sr-only">{t('authFiles.settings.quotaUnknown')}</span> : <i style={{ width: `${percent}%` }} />}
</div>
{reset ? <small>{reset}</small> : null}
{row.detail ? <small>{row.detail}</small> : null}
</div>;
};
return (
<section className="credential-quota" aria-label={t('authFiles.quota.aria')} aria-busy={loading}>
<div className="credential-quota-heading">
<strong>{t('authFiles.settings.quotaRemaining')}</strong>
{quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
<button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
<section className={`credential-quota${compact ? ' credential-quota-compact' : ''}`} aria-label={t('authFiles.quota.aria')} aria-busy={loading}>
{!compact || showReset ? <div className="credential-quota-heading">
{!compact ? <strong>{t('authFiles.settings.quotaRemaining')}</strong> : null}
{!compact && quota.plan ? <span className="credential-quota-plan">{quota.plan}</span> : null}
{showReset ? <button type="button" className="secondary-button compact-button credential-quota-reset" onClick={onReset} disabled={disabled || !canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
{!compact ? <button type="button" className="credential-quota-refresh" disabled={disabled || loading} onClick={onRefresh}
title={disabled ? t('quota.fileDisabled') : t('authFiles.quota.refresh')}>
{loading ? <LoaderCircle size={13} className="spin" /> : <RefreshCw size={13} />}
{t(loading ? 'authFiles.quota.querying' : quota.status === 'idle' ? 'authFiles.quota.fetch' : 'common.refresh')}
</button>
</div>
</button> : null}
</div> : null}
{loading ? <div className="credential-quota-loading" role="status"><span>{t(quota.pendingAction === 'reset' ? 'quota.resetting' : 'authFiles.quota.loading')}</span><div className="credential-quota-track indeterminate"><i /></div></div> : null}
{quota.status === 'idle' ? <p className="credential-quota-empty">{t(disabled ? 'quota.fileDisabled' : 'authFiles.settings.quotaIdle')}</p> : null}
{quota.status === 'error' ? <div className="credential-quota-error" role="status"><span>{t('authFiles.quota.failed')}</span>{quota.error ? <small>{quota.error}</small> : null}</div> : null}
{quota.status === 'success' ? <>
{quota.rows.length ? <div className="credential-quota-rows">{quota.rows.map((row, index) => {
const percent = row.remainingPercent !== null && Number.isFinite(row.remainingPercent) ? Math.max(0, Math.min(100, row.remainingPercent)) : null;
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
const tone = percent === null ? 'unknown' : percent <= 10 ? 'low' : percent <= 30 ? 'medium' : 'high';
return <div className={`credential-quota-row ${tone}`} key={`${row.label}-${index}`}>
<div className="credential-quota-label"><span title={row.label}>{row.label}</span><strong>{percent === null ? '—' : `${Math.round(percent)}%`}</strong></div>
<div className="credential-quota-track" role={percent === null ? undefined : 'progressbar'} aria-label={`${row.label} · ${t('authFiles.settings.quotaRemaining')}`}
aria-valuemin={percent === null ? undefined : 0} aria-valuemax={percent === null ? undefined : 100} aria-valuenow={percent ?? undefined}>
{percent === null ? <span className="sr-only">{t('authFiles.settings.quotaUnknown')}</span> : <i style={{ width: `${percent}%` }} />}
</div>
{reset ? <small>{reset}</small> : null}
{row.detail ? <small>{row.detail}</small> : null}
</div>;
})}</div> : <p className="credential-quota-empty">{t('authFiles.quota.empty')}</p>}
{quota.rows.length ? <>
<div className="credential-quota-rows">{(compact ? quota.rows.slice(0, 2) : quota.rows).map(renderRow)}</div>
{compact && quota.rows.length > 2 ? <details className="credential-quota-more">
<summary>{t('authFiles.health.details')} (+{quota.rows.length - 2})</summary>
<div className="credential-quota-rows">{quota.rows.slice(2).map((row, index) => renderRow(row, index + 2))}</div>
</details> : null}
</> : <p className="credential-quota-empty">{t('authFiles.quota.empty')}</p>}
<div className="credential-quota-footnotes">
{quota.resetCredits !== undefined ? <small>{t('authFiles.quota.resets', { count: quota.resetCredits })}</small> : null}
{quota.resetCreditsApplicable !== undefined ? <small>{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</small> : null}
+30
View File
@@ -106,3 +106,33 @@
left: auto;
transform: none;
}
.auth-file-requests.auth-file-requests-compact {
grid-column: auto;
gap: 6px;
align-content: center;
}
.auth-file-requests-compact .auth-file-requests-latest {
color: var(--text-secondary);
font-size: var(--font-size-caption);
font-weight: var(--font-weight-medium);
overflow-wrap: anywhere;
}
.auth-file-requests-compact .auth-file-requests-chart {
flex-basis: 100%;
max-width: none;
}
.auth-file-requests-compact .auth-file-requests-blocks {
gap: 2px;
}
.auth-file-requests-compact .auth-file-request-block {
height: 16px;
}
.auth-file-requests-compact .auth-file-requests-tooltip {
max-width: min(280px, 80vw);
}
+7 -6
View File
@@ -3,7 +3,7 @@ import { useI18n } from '../i18n';
import { authFileRequestStats, requestRateColor } from '../services/authFileRequests';
import './AuthFileRequestStatus.css';
export function AuthFileRequestStatus({ file }: { file: Record<string, unknown> }) {
export function AuthFileRequestStatus({ file, compact = false }: { file: Record<string, unknown>; compact?: boolean }) {
const { t, formatNumber } = useI18n();
const stats = useMemo(() => authFileRequestStats(file), [file]);
const [active, setActive] = useState<number | null>(null);
@@ -15,6 +15,7 @@ export function AuthFileRequestStatus({ file }: { file: Record<string, unknown>
const rateClass = stats.recentRate === null ? ''
: stats.recentRate >= 0.9 ? 'success' : stats.recentRate >= 0.5 ? 'mixed' : 'failure';
const detail = active === null ? null : stats.buckets[active];
const latestRequest = [...stats.buckets].reverse().find((bucket) => bucket.rate !== null);
const bucketLabel = (index: number) => {
const bucket = stats.buckets[index];
const time = bucket.time || t('authFiles.requests.interval', { index: index + 1 });
@@ -24,15 +25,15 @@ export function AuthFileRequestStatus({ file }: { file: Record<string, unknown>
};
return (
<div className="auth-file-requests" role="group" aria-label={t('authFiles.requests.title')}>
<div className="auth-file-requests-heading">
<div className={`auth-file-requests${compact ? ' auth-file-requests-compact' : ''}`} role="group" aria-label={t('authFiles.requests.title')}>
{compact ? <div className="auth-file-requests-latest" title={t('authFiles.requests.window')}>{latestRequest?.time || '—'}</div> : <div className="auth-file-requests-heading">
<span>{t('authFiles.requests.title')}</span>
<span className="auth-file-requests-counts" title={t('authFiles.requests.totalsHint')}>
<span className={stats.success ? 'success' : ''}>{t('authFiles.requests.success', { count: countText(stats.success) })}</span>
<span className={stats.failure ? 'failure' : ''}>{t('authFiles.requests.failure', { count: countText(stats.failure) })}</span>
</span>
{stats.recentAvailable ? <span className="auth-file-requests-window">{t('authFiles.requests.window')}</span> : null}
</div>
</div>}
{stats.recentAvailable ? (
<div className="auth-file-requests-timeline">
<div className="auth-file-requests-chart">
@@ -74,9 +75,9 @@ export function AuthFileRequestStatus({ file }: { file: Record<string, unknown>
</div>
) : null}
</div>
<span className={`auth-file-requests-rate ${rateClass}`} title={t('authFiles.requests.recentRate')}>
{!compact ? <span className={`auth-file-requests-rate ${rateClass}`} title={t('authFiles.requests.recentRate')}>
{rateText(stats.recentRate)}
</span>
</span> : null}
{stats.recentRate === null ? <span>{t('authFiles.requests.empty')}</span> : null}
</div>
) : <span className="auth-file-requests-unavailable">{t('authFiles.requests.unavailable')}</span>}
+59
View File
@@ -0,0 +1,59 @@
.auth-file-usage {
container: auth-file-usage / inline-size;
min-width: 0;
color: var(--text-primary);
font-size: var(--font-size-label);
font-variant-numeric: tabular-nums;
}
.auth-file-usage-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
}
.auth-file-usage-metric {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
}
.auth-file-usage-metric svg {
box-sizing: content-box;
flex-shrink: 0;
padding: 2px;
border-radius: 4px;
color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
}
.auth-file-usage-metric.rate svg,
.auth-file-usage-metric.success svg {
color: var(--success-color);
background: color-mix(in srgb, var(--success-color) 10%, transparent);
}
.auth-file-usage-metric.failure svg {
color: var(--failure-badge-text);
background: color-mix(in srgb, var(--failure-badge-text) 10%, transparent);
}
.auth-file-usage-metric strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: var(--font-weight-semibold);
}
.auth-file-usage-scope {
display: block;
margin-top: 5px;
color: var(--text-tertiary);
font-size: var(--font-size-meta);
}
@container auth-file-usage (max-width: 130px) {
.auth-file-usage-metrics { grid-template-columns: minmax(0, 1fr); gap: 5px; }
}
+39
View File
@@ -0,0 +1,39 @@
import { Activity, Check, Send, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { authFileRequestStats } from '../services/authFileRequests';
import { formatUsageNumber } from '../services/usageNumber';
import './AuthFileUsageSummary.css';
/** Credential counters are cumulative for the current core session, not the recent request window. */
export function AuthFileUsageSummary({ file }: { file: Record<string, unknown> }) {
const { t, locale, formatNumber } = useI18n();
const { success, failure } = authFileRequestStats(file);
const sum = success === null || failure === null ? null : success + failure;
const total = sum !== null && Number.isSafeInteger(sum) ? sum : null;
const rate = total !== null && total > 0 && success !== null ? success / total : null;
const countText = (count: number | null) => count === null ? '—' : formatUsageNumber(count, locale);
const exactCount = (count: number | null) => count === null ? '—' : formatNumber(count);
const rateText = rate === null ? '—' : formatNumber(rate, { style: 'percent', maximumFractionDigits: 1 });
const metrics = [
{ key: 'total', Icon: Send, value: countText(total), label: `${t('usage.stat.requests')}: ${exactCount(total)}` },
{ key: 'rate', Icon: Activity, value: rateText, label: t('authFiles.requests.rate', { rate: rateText }) },
{ key: 'success', Icon: Check, value: countText(success), label: t('authFiles.requests.success', { count: exactCount(success) }) },
{ key: 'failure', Icon: X, value: countText(failure), label: t('authFiles.requests.failure', { count: exactCount(failure) }) },
];
return (
<div className="auth-file-usage" role="group" aria-label={t('authFiles.usage.title')}>
<div className="auth-file-usage-metrics">
{metrics.map(({ key, Icon, value, label }) => (
<span key={key} className={`auth-file-usage-metric ${key}`} role="img" title={label} aria-label={label}>
<Icon size={13} aria-hidden="true" />
<strong aria-hidden="true">{value}</strong>
</span>
))}
</div>
<span className="auth-file-usage-scope" title={t('authFiles.requests.totalsHint')}>
{t('authFiles.usage.runtimeTotals')}
</span>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
export const authFileListZhCN = {
'authFiles.list.credential': '凭证',
'authFiles.list.plan': '套餐',
'authFiles.list.status': '可用状态',
'authFiles.list.recent': '最近请求',
'authFiles.list.quota': '额度',
'authFiles.list.actions': '操作',
'authFiles.list.cooling': '冷却限制',
'authFiles.list.noQuota': '暂不支持额度查询',
'authFiles.list.expiry': '到期 {time}',
'authFiles.list.daysRemaining': '{count} 天',
'authFiles.list.expired': '已到期',
'authFiles.list.details': '详情',
'authFiles.list.collapse': '收起',
'authFiles.list.updated': '更新于',
'authFiles.list.size': '文件大小',
'authFiles.list.pageSummary': '第 {page} / {pages} 页 · 显示 {start}–{end} / {total}',
'authFiles.list.pageSize': '每页',
'authFiles.list.perPage': '{count} 条/页',
'authFiles.list.previous': '上一页',
'authFiles.list.next': '下一页',
'authFiles.usage.runtimeTotals': '运行累计',
'authFiles.usage.title': '累计用量',
} as const;
export const authFileListEn: Record<keyof typeof authFileListZhCN, string> = {
'authFiles.list.credential': 'Credential',
'authFiles.list.plan': 'Plan',
'authFiles.list.status': 'Availability',
'authFiles.list.recent': 'Recent requests',
'authFiles.list.quota': 'Quota',
'authFiles.list.actions': 'Actions',
'authFiles.list.cooling': 'Cooldown restriction',
'authFiles.list.noQuota': 'Quota lookup unavailable',
'authFiles.list.expiry': 'Expires {time}',
'authFiles.list.daysRemaining': '{count} days left',
'authFiles.list.expired': 'Expired',
'authFiles.list.details': 'Details',
'authFiles.list.collapse': 'Collapse',
'authFiles.list.updated': 'Updated',
'authFiles.list.size': 'File size',
'authFiles.list.pageSummary': 'Page {page} / {pages} · {start}–{end} of {total}',
'authFiles.list.pageSize': 'Per page',
'authFiles.list.perPage': '{count} / page',
'authFiles.list.previous': 'Previous',
'authFiles.list.next': 'Next',
'authFiles.usage.runtimeTotals': 'Core session totals',
'authFiles.usage.title': 'Cumulative usage',
};
export const authFileListJa: Record<keyof typeof authFileListZhCN, string> = {
'authFiles.list.credential': '認証情報',
'authFiles.list.plan': 'プラン',
'authFiles.list.status': '利用状態',
'authFiles.list.recent': '最近のリクエスト',
'authFiles.list.quota': 'クォータ',
'authFiles.list.actions': '操作',
'authFiles.list.cooling': 'クールダウン制限',
'authFiles.list.noQuota': 'クォータ取得未対応',
'authFiles.list.expiry': '有効期限 {time}',
'authFiles.list.daysRemaining': '残り {count} 日',
'authFiles.list.expired': '期限切れ',
'authFiles.list.details': '詳細',
'authFiles.list.collapse': '閉じる',
'authFiles.list.updated': '更新日時',
'authFiles.list.size': 'ファイルサイズ',
'authFiles.list.pageSummary': '{page} / {pages} ページ · {start}–{end} / {total} 件',
'authFiles.list.pageSize': '表示件数',
'authFiles.list.perPage': '{count} 件/ページ',
'authFiles.list.previous': '前へ',
'authFiles.list.next': '次へ',
'authFiles.usage.runtimeTotals': 'コア稼働中の累計',
'authFiles.usage.title': '累計使用量',
};
+11
View File
@@ -1,10 +1,12 @@
import type { MessageKey } from './locales/zh-CN';
import { easyModeJa } from './easyMode';
import { homeJa } from './home';
import { authFileListJa } from './authFileList';
export const jaOverrides = {
'app.nav.plugins': 'プラグイン',
...homeJa,
...authFileListJa,
'apiAccess.entries.addKey': 'API キーを追加',
'apiAccess.entries.addProvider': 'サービスを追加',
'apiAccess.entries.editKey': 'API キーを編集',
@@ -98,6 +100,15 @@ export const jaOverrides = {
'authFiles.settings.quotaUnknown': "残量不明",
'authFiles.settings.quotaIdle': "クォータ未取得",
'authFiles.health.title': '認証情報の状態',
'authFiles.cooldown.resetButton': 'クールダウンを解除',
'authFiles.cooldown.resetHint': 'この認証情報のローカルルーティングのクールダウンを解除',
'authFiles.cooldown.resetTitle': '認証情報のクールダウンを解除しますか?',
'authFiles.cooldown.resetConfirm': '「{name}」のローカルルーティングのクールダウンを解除しますか?解除後、この認証情報はすぐにリクエストに使用される可能性があります。上流サービスのクォータは回復しません。',
'authFiles.cooldown.resetSuccess': '{name} のクールダウンを解除しました。',
'authFiles.cooldown.resetFailed': '{name} のクールダウンを解除できませんでした:{message}',
'authFiles.cooldown.resetting': '解除中…',
'authFiles.cooldown.missingIndex': '認証情報に有効な認証インデックスがありません。一覧を更新してからクールダウンの解除を再試行してください。',
'authFiles.cooldown.invalidResponse': 'コアから有効な解除結果が返されませんでした。一覧を更新してクールダウン状態を確認してください。',
'authFiles.health.active': '利用可能',
'authFiles.health.error': 'リクエストエラー',
'authFiles.health.refreshing': '認証情報を更新中',
+11
View File
@@ -1,10 +1,12 @@
import type { MessageKey } from './zh-CN';
import { easyModeEn } from '../easyMode';
import { homeEn } from '../home';
import { authFileListEn } from '../authFileList';
export const en: Record<MessageKey, string> = {
'app.nav.plugins': 'Plugins',
...homeEn,
...authFileListEn,
'apiAccess.entries.addKey': 'Add API Key',
'apiAccess.entries.addProvider': 'Add Service',
'apiAccess.entries.editKey': 'Edit API Key',
@@ -98,6 +100,15 @@ export const en: Record<MessageKey, string> = {
'authFiles.settings.quotaUnknown': "Remaining quota unknown",
'authFiles.settings.quotaIdle': "Quota not fetched",
'authFiles.health.title': 'Credential status',
'authFiles.cooldown.resetButton': 'Clear cooldown',
'authFiles.cooldown.resetHint': 'Clear this credential’s local routing cooldown',
'authFiles.cooldown.resetTitle': 'Clear credential cooldown?',
'authFiles.cooldown.resetConfirm': 'Clear the local routing cooldown for “{name}”? This may make the credential eligible for another request immediately. It does not restore upstream quota.',
'authFiles.cooldown.resetSuccess': 'Cooldown cleared for {name}.',
'authFiles.cooldown.resetFailed': 'Could not clear cooldown for {name}: {message}',
'authFiles.cooldown.resetting': 'Clearing…',
'authFiles.cooldown.missingIndex': 'This credential has no valid auth index. Refresh the list before clearing its cooldown.',
'authFiles.cooldown.invalidResponse': 'The core did not return a valid reset result. Refresh the list to confirm the cooldown state.',
'authFiles.health.active': 'Available',
'authFiles.health.error': 'Request error',
'authFiles.health.refreshing': 'Refreshing credentials',
+11
View File
@@ -1,9 +1,11 @@
import { easyModeZhCN } from '../easyMode';
import { homeZhCN } from '../home';
import { authFileListZhCN } from '../authFileList';
export const zhCN = {
'app.nav.plugins': '插件',
...homeZhCN,
...authFileListZhCN,
'apiAccess.entries.addKey': '添加 API Key',
'apiAccess.entries.addProvider': '添加服务',
'apiAccess.entries.editKey': '编辑 API Key',
@@ -97,6 +99,15 @@ export const zhCN = {
'authFiles.settings.quotaUnknown': "剩余额度未知",
'authFiles.settings.quotaIdle': "尚未获取额度",
'authFiles.health.title': '凭证状态',
'authFiles.cooldown.resetButton': '清除冷却',
'authFiles.cooldown.resetHint': '清除此凭证的本地路由冷却状态',
'authFiles.cooldown.resetTitle': '清除凭证冷却?',
'authFiles.cooldown.resetConfirm': '确定清除“{name}”的本地路由冷却状态吗?清除后此凭证可能立即再次参与请求,但不会恢复上游额度。',
'authFiles.cooldown.resetSuccess': '已清除 {name} 的冷却状态。',
'authFiles.cooldown.resetFailed': '无法清除 {name} 的冷却状态:{message}',
'authFiles.cooldown.resetting': '正在清除…',
'authFiles.cooldown.missingIndex': '凭证缺少有效的认证索引,无法清除冷却。请刷新列表后重试。',
'authFiles.cooldown.invalidResponse': '内核未返回有效的清除结果,请刷新列表确认冷却状态。',
'authFiles.health.active': '正常可用',
'authFiles.health.error': '请求异常',
'authFiles.health.refreshing': '凭证刷新中',
+9
View File
@@ -9,6 +9,15 @@ export type MessageVariables = Record<string, string | number>;
export const zhTW: Record<MessageKey, string> = {
...createTraditionalMessages(zhCN),
'authFiles.cooldown.resetButton': '清除冷卻',
'authFiles.cooldown.resetHint': '清除此憑證的本機路由冷卻狀態',
'authFiles.cooldown.resetTitle': '清除憑證冷卻?',
'authFiles.cooldown.resetConfirm': '確定清除「{name}」的本機路由冷卻狀態嗎?清除後此憑證可能立即再次參與請求,但不會恢復上游額度。',
'authFiles.cooldown.resetSuccess': '已清除 {name} 的冷卻狀態。',
'authFiles.cooldown.resetFailed': '無法清除 {name} 的冷卻狀態:{message}',
'authFiles.cooldown.resetting': '正在清除…',
'authFiles.cooldown.missingIndex': '憑證缺少有效的驗證索引,無法清除冷卻。請重新整理列表後重試。',
'authFiles.cooldown.invalidResponse': '核心未傳回有效的清除結果,請重新整理列表確認冷卻狀態。',
'app.contact.title': '加入 Discord 伺服器',
'app.contact.label': '加入 Discord 伺服器',
'config.diagnostics.description': '啟用後將記錄呼叫出錯的請求,成功的請求不會記錄,請及時關閉「**寫入日誌檔案**」,避免占用過多儲存空間。',
-1
View File
@@ -1,7 +1,6 @@
export const oauthSubpages = [
{ id: 'login', labelKey: 'oauth.title' },
{ id: 'authFiles', labelKey: 'authFiles.title' },
{ id: 'quota', labelKey: 'quota.title' },
] as const;
export type OAuthSubpage = (typeof oauthSubpages)[number]['id'];
+71 -21
View File
@@ -1,31 +1,61 @@
.auth-file-card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 14px; padding: 16px; }
.auth-file-card { display: flex; flex-direction: column; min-width: 0; gap: 14px; padding: 16px; border: 1px solid var(--border-color); border-radius: 14px; background: var(--bg-card); transition: border-color .15s; }
.auth-files-panel { --auth-list-columns: minmax(180px, 1.2fr) 78px 100px 124px 148px minmax(230px, 1.8fr) 170px; }
.auth-file-table-scroll { overflow-x: auto; padding: 0 14px 14px; }
.auth-file-list-head, .auth-credential-row { display: grid; grid-template-columns: var(--auth-list-columns); align-items: center; gap: 16px; }
.auth-file-list-head { min-width: 1264px; padding: 14px 17px; color: var(--text-tertiary); font-size: var(--font-size-meta); font-weight: var(--font-weight-semibold); }
.auth-file-card-grid { display: grid; grid-template-columns: minmax(0, 1fr); min-width: 1264px; gap: 8px; }
.auth-file-card { display: flex; flex-direction: column; min-width: 0; padding: 0 16px; border: 1px solid var(--border-color); border-radius: 9px; background: var(--bg-card); transition: border-color .15s; }
.auth-file-card:hover { border-color: color-mix(in srgb, var(--primary-color) 35%, var(--border-color)); }
.auth-file-card.is-disabled { border-style: dashed; }
.auth-file-card.is-disabled .auth-card-header .provider-logo { opacity: .65; }
.auth-credential-row { min-height: 104px; padding: 14px 0; }
.auth-list-cell { min-width: 0; }
.auth-card-header { display: flex; gap: 10px; align-items: center; min-width: 0; }
.auth-card-header .provider-logo { width: 32px; height: 32px; object-fit: contain; flex-shrink: 0; }
.auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 4px; }
.auth-card-identity strong { font-size: var(--font-size-body); font-weight: var(--font-weight-semibold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-header.filename-only strong { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; white-space: normal; overflow-wrap: anywhere; }
.auth-card-identity { min-width: 0; flex: 1; display: grid; gap: 6px; }
.auth-card-identity strong { font-size: var(--font-size-label); font-weight: var(--font-weight-semibold); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.auth-card-provider { font-size: var(--font-size-meta); color: var(--text-secondary); font-weight: var(--font-weight-medium); }
.auth-card-filename { margin: -3px 0 0; color: var(--text-tertiary); overflow-wrap: anywhere; font-size: var(--font-size-meta); line-height: var(--line-height-body); }
.auth-card-filename { color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--font-size-meta); }
.auth-list-plan, .auth-list-status { display: flex; flex-direction: column; align-items: flex-start; gap: 9px; font-size: var(--font-size-label); overflow-wrap: anywhere; }
.auth-list-plan > span { text-transform: capitalize; }
.auth-list-plan small, .auth-list-status small { color: var(--text-tertiary); font-size: var(--font-size-caption); }
.auth-status-badge { display: inline-flex; padding: 3px 7px; border-radius: 5px; background: var(--bg-secondary); color: var(--text-secondary); font-size: var(--font-size-meta); line-height: 1.5; font-weight: var(--font-weight-semibold); }
.auth-status-badge.success { color: var(--success-color); background: color-mix(in srgb, var(--success-color) 10%, var(--bg-card)); }
.auth-status-badge.warning { color: var(--amber-text); background: color-mix(in srgb, var(--amber-text) 10%, var(--bg-card)); }
.auth-status-badge.error { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 8%, var(--bg-card)); }
.auth-status-badge.info { color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 10%, var(--bg-card)); }
.auth-list-muted { color: var(--text-tertiary); font-size: var(--font-size-meta); }
.auth-card-note { display: flex; align-items: baseline; gap: 8px; min-width: 0; padding: 8px 10px; border-radius: 7px; background: color-mix(in srgb, var(--text-tertiary) 5%, var(--bg-card)); font-size: var(--font-size-meta); line-height: var(--line-height-body); }
.auth-card-note > span { flex-shrink: 0; color: var(--text-tertiary); font-size: var(--font-size-caption); }
.auth-card-note p { min-width: 0; margin: 0; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.auth-card-meta { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 5px 12px; margin-top: auto; color: var(--text-tertiary); font-size: var(--font-size-caption); }
.auth-card-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding-top: 12px; border-top: 1px solid var(--border-color); }
.auth-card-actions .compact-button { min-height: 32px; margin-top: 0; padding: 5px 9px; font-size: var(--font-size-meta); }
.auth-card-actions .icon-button { width: 30px; height: 30px; }
.auth-card-actions .auth-card-toggle { margin-left: auto; }
.auth-card-note p { min-width: 0; margin: 0; color: var(--text-secondary); white-space: pre-wrap; overflow-wrap: anywhere; }
.auth-card-meta { display: flex; flex-wrap: wrap; gap: 8px 20px; color: var(--text-tertiary); font-size: var(--font-size-meta); overflow-wrap: anywhere; }
.auth-card-meta strong { color: var(--text-secondary); font-weight: var(--font-weight-medium); }
.auth-card-actions { display: flex; align-items: center; justify-content: flex-end; gap: 12px; }
.auth-list-icon-actions { display: grid; grid-template-columns: repeat(3, 28px); gap: 5px; }
.auth-card-actions .auth-list-action { width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; color: var(--primary-color); background: color-mix(in srgb, var(--primary-color) 9%, var(--bg-card)); }
.auth-card-actions .auth-list-action.muted { color: var(--text-secondary); background: var(--bg-secondary); }
.auth-card-actions .auth-list-action.danger { color: var(--failure-badge-text); background: color-mix(in srgb, var(--failure-badge-text) 8%, var(--bg-card)); }
.auth-card-actions .auth-list-action:disabled { opacity: .4; cursor: default; }
.auth-card-actions .auth-list-action:not(:disabled):hover { filter: brightness(.93); }
.auth-list-main-actions { display: grid; justify-items: center; gap: 8px; }
.auth-list-switch { display: flex; align-items: center; width: 36px; height: 21px; padding: 2px; border: 1px solid var(--border-color); border-radius: 20px; background: var(--text-disabled); cursor: pointer; }
.auth-list-switch > span { display: block; width: 15px; height: 15px; flex-shrink: 0; border-radius: 50%; background: var(--bg-card); box-shadow: 0 1px 3px #0002; transition: transform .15s; }
.auth-list-switch[aria-checked="true"] { background: var(--primary-color); border-color: var(--primary-color); }
.auth-list-switch[aria-checked="true"] > span { transform: translateX(15px); background: #fff; }
.auth-list-switch:disabled { opacity: .5; cursor: default; }
.auth-list-details-button { min-height: 28px; padding: 4px 7px; border: 0; border-radius: 5px; background: color-mix(in srgb, var(--primary-color) 7%, var(--bg-card)); color: var(--primary-color); font: inherit; font-size: var(--font-size-caption); cursor: pointer; }
.auth-list-switch:focus-visible, .auth-list-details-button:focus-visible { outline: 2px solid var(--primary-color); outline-offset: 3px; }
.auth-list-details { border-top: 1px solid var(--border-color); padding: 12px 0; }
.auth-list-details .auth-card-note { margin-top: 10px; }
.auth-file-card > .auth-file-health { margin-bottom: 12px; }
.auth-file-card > .auth-file-health.detailed { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; }
.auth-file-card .auth-health-actions { padding: 6px 8px; border-top: 0; }
.auth-file-card .auth-health-actions .auth-health-hint { display: none; }
.auth-file-card .auth-file-health, .auth-file-card .auth-file-requests { grid-column: auto; }
.auth-file-card .auth-file-requests { padding-top: 12px; border-top: 1px solid var(--border-color); }
.auth-file-card .auth-file-requests-chart { max-width: none; }
.auth-file-card .auth-health-summary { gap: 6px; }
.auth-file-card .auth-health-heading { gap: 2px 8px; }
.auth-file-card .auth-health-countdown { margin-left: 25px; }
.auth-file-card .auth-health-summary-content { flex-basis: calc(100% - 28px); }
.auth-file-card .auth-health-disclosure { margin-left: auto; }
.auth-file-card .auth-health-body, .auth-file-card .auth-health-summary { overflow-wrap: anywhere; }
.auth-list-pagination { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--border-color); color: var(--text-tertiary); font-size: var(--font-size-meta); }
.auth-list-pagination > span { margin-right: auto; }
.auth-list-pagination label { display: flex; align-items: center; gap: 8px; }
.auth-list-pagination select { min-height: 32px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--bg-card); color: var(--text-secondary); padding: 4px 8px; font: inherit; }
.auth-list-pagination .compact-button { min-height: 32px; padding: 5px 9px; margin: 0; font-size: var(--font-size-meta); }
.credential-quota { display: grid; gap: 12px; padding: 12px; border: 1px solid var(--border-color); border-radius: 10px; background: color-mix(in srgb, var(--text-tertiary) 3%, var(--bg-card)); font-size: var(--font-size-meta); }
.credential-quota-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-width: 0; }
.credential-quota-heading > strong { color: var(--text-secondary); font-size: var(--font-size-meta); font-weight: var(--font-weight-medium); }
@@ -54,4 +84,24 @@
.credential-quota-track.indeterminate i { width: 35%; animation: credential-quota-pending 1.3s ease-in-out infinite alternate; }
@keyframes credential-quota-pending { from { transform: translateX(-100%); } to { transform: translateX(300%); } }
@media (prefers-reduced-motion: reduce) { .credential-quota-track.indeterminate i { animation: none; width: 100%; opacity: .3; } }
@media (max-width: 600px) { .auth-file-card-grid { padding: 10px; gap: 10px; } .auth-file-card { padding: 14px; } }
@media (max-width: 760px) {
.auth-file-table-scroll { overflow: visible; padding: 10px; }
.auth-file-list-head { display: none; }
.auth-file-card-grid { min-width: 0; gap: 10px; }
.auth-file-card { padding: 0 12px; }
.auth-credential-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; padding: 14px 0; }
.auth-file-card > .auth-file-health.detailed { grid-template-columns: minmax(0, 1fr); }
.auth-card-header { grid-column: 1 / -1; }
.auth-list-cell[data-label]::before { content: attr(data-label); display: block; margin-bottom: 7px; color: var(--text-tertiary); font-size: var(--font-size-caption); font-weight: var(--font-weight-medium); }
.auth-list-plan, .auth-list-status { gap: 5px; }
.auth-list-plan::before, .auth-list-status::before { margin-bottom: 2px; }
.auth-list-quota { grid-column: 1 / -1; }
.auth-card-actions { grid-column: 1 / -1; padding-top: 10px; border-top: 1px solid var(--border-color); }
.auth-card-actions[data-label]::before { margin: 0 auto 0 0; }
.auth-list-icon-actions { display: flex; flex-wrap: wrap; }
.auth-list-main-actions { display: flex; }
.auth-list-pagination { gap: 8px; padding: 12px; }
.auth-list-pagination > span { flex-basis: 100%; }
.auth-list-pagination label { margin-right: auto; }
}
@media (prefers-reduced-motion: reduce) { .auth-list-switch > span { transition: none; } }
+158 -26
View File
@@ -10,15 +10,19 @@ import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
import { AuthFileRequestStatus } from '../components/AuthFileRequestStatus';
import { AuthFileHealthStatus } from '../components/AuthFileHealthStatus';
import { AuthFileUsageSummary } from '../components/AuthFileUsageSummary';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import {
Check,
ChevronLeft,
ChevronRight,
Copy,
FileDown,
Files,
FolderOpen,
Import,
LoaderCircle,
Network,
RefreshCw,
Search,
Settings2,
@@ -58,14 +62,19 @@ import {
} from '../services/quotaCache';
import {
authFileName,
authFileCooldownResetIndex,
dedupeAuthFiles,
isOAuthCredentialFile,
isRuntimeOnlyAuthFile,
oauthModelProvidersFromAuthFiles,
normalizeOAuthProvider,
parseAuthFilePriority,
resetAuthFileCooldown,
setOAuthCredentialFileDisabled,
} from '../services/authFiles';
import { authFileHealth, normalizeAuthFileCooldowns } from '../services/authFileHealth';
import { codexMetadataFor } from '../services/quotaMetadata';
import { quotaResetInstant, useQuotaClock } from '../services/quotaTime';
import {
modelMatchesRule,
normalizeOAuthExcludedRules,
@@ -115,6 +124,7 @@ const isRuntimeOnly = isRuntimeOnlyAuthFile;
export function AuthFileManagementPage() {
const { t } = useI18n();
const now = useQuotaClock();
const { askConfirmation, confirmationDialog } = useConfirmation();
const [fileSnapshot, setFileSnapshot] = useState<{
files: AuthFile[];
@@ -123,10 +133,14 @@ export function AuthFileManagementPage() {
}>({ files: [], receivedAtMs: 0 });
const { files, receivedAtMs, observedAt } = fileSnapshot;
const [filter, setFilter] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [providerFilter, setProviderFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled' | 'runtime'>('all');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [cooldownResetting, setCooldownResetting] = useState<Set<string>>(() => new Set());
const [error, setError] = useState('');
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
const feedback = useAppNotice();
@@ -143,6 +157,9 @@ export function AuthFileManagementPage() {
const [oauthModelError, setOauthModelError] = useState('');
const quotas = useQuotaCache();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const mountedRef = useRef(true);
const fileRequestRef = useRef(0);
const cooldownResetPendingRef = useRef(new Set<string>());
const oauthModelRequestRef = useRef(0);
const oauthModelSaveRef = useRef(false);
const oauthModels = oauthModelSettings?.models ?? [];
@@ -153,10 +170,12 @@ export function AuthFileManagementPage() {
.sort((a, b) => a.label.localeCompare(b.label)), [files]);
const loadFiles = useCallback(async (showLoading = true) => {
const requestId = ++fileRequestRef.current;
if (showLoading) setLoading(true);
setError('');
try {
const payload = await managementApi.get('/auth-files');
if (!mountedRef.current || requestId !== fileRequestRef.current) return;
const nextFiles = dedupeAuthFiles(responseList(payload, 'files'));
setFileSnapshot({ files: nextFiles, receivedAtMs: Date.now(), observedAt: readString(payload, 'observed_at') });
const validQuotaKeys = new Set(nextFiles.map(quotaKey));
@@ -170,12 +189,53 @@ export function AuthFileManagementPage() {
return next;
});
} catch (requestError) {
setError(String(requestError));
if (mountedRef.current && requestId === fileRequestRef.current) setError(String(requestError));
} finally {
if (showLoading) setLoading(false);
if (mountedRef.current && requestId === fileRequestRef.current) setLoading(false);
}
}, []);
const resetCooldown = async (file: AuthFile) => {
const authIndex = authFileCooldownResetIndex(file);
if (!authIndex || busy || loading || cooldownResetPendingRef.current.has(authIndex)
|| !normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt)?.records?.length) return;
const name = fileName(file);
cooldownResetPendingRef.current.add(authIndex);
try {
const confirmed = await askConfirmation({
title: t('authFiles.cooldown.resetTitle'),
message: t('authFiles.cooldown.resetConfirm', { name }),
confirmText: t('authFiles.cooldown.resetButton'),
});
if (!confirmed || !mountedRef.current) return;
setCooldownResetting((current) => new Set(current).add(authIndex));
feedback.clearNotice();
setError('');
await resetAuthFileCooldown(authIndex);
if (!mountedRef.current) return;
// Discard list requests started before the reset. Keep other cards' timer anchors intact.
fileRequestRef.current += 1;
setFileSnapshot((current) => ({
...current,
files: current.files.map((item) => authFileCooldownResetIndex(item) === authIndex
? { ...item, cooldowns: [] } : item),
}));
showNotice({ key: 'authFiles.cooldown.resetSuccess', variables: { name } });
await loadFiles(false);
} catch (requestError) {
if (mountedRef.current) setError(t('authFiles.cooldown.resetFailed', {
name, message: requestError instanceof Error ? requestError.message : String(requestError),
}));
} finally {
cooldownResetPendingRef.current.delete(authIndex);
if (mountedRef.current) setCooldownResetting((current) => {
const next = new Set(current);
next.delete(authIndex);
return next;
});
}
};
const refreshQuota = async (file: AuthFile) => {
if (readBoolean(file, 'disabled')) return;
const key = quotaKey(file);
@@ -260,7 +320,12 @@ export function AuthFileManagementPage() {
};
useEffect(() => {
mountedRef.current = true;
void loadFiles();
return () => {
mountedRef.current = false;
fileRequestRef.current += 1;
};
}, [loadFiles]);
const providers = useMemo(
@@ -288,6 +353,18 @@ export function AuthFileManagementPage() {
});
}, [files, filter, providerFilter, statusFilter]);
const pageCount = Math.max(1, Math.ceil(visibleFiles.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pageFiles = visibleFiles.slice((currentPage - 1) * pageSize, currentPage * pageSize);
useEffect(() => setPage(1), [filter, providerFilter, statusFilter, pageSize]);
useEffect(() => setPage((current) => Math.min(current, pageCount)), [pageCount]);
const toggleDetails = (key: string) => setExpandedFiles((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(event.currentTarget.files ?? []);
event.currentTarget.value = '';
@@ -402,12 +479,12 @@ export function AuthFileManagementPage() {
<section className="panel auth-files-panel real-auth-files-panel">
<div className="management-toolbar auth-files-toolbar">
<Search size={16} />
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('authFiles.searchPlaceholder')} />
<select value={providerFilter} onChange={(event) => setProviderFilter(event.currentTarget.value)}>
<input value={filter} onChange={(event) => setFilter(event.currentTarget.value)} placeholder={t('authFiles.searchPlaceholder')} aria-label={t('authFiles.searchPlaceholder')} />
<select value={providerFilter} onChange={(event) => setProviderFilter(event.currentTarget.value)} aria-label={t('authFiles.filter.allProviders')}>
<option value="all">{t('authFiles.filter.allProviders')}</option>
{providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</select>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)}>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.currentTarget.value as typeof statusFilter)} aria-label={t('authFiles.filter.allStatuses')}>
<option value="all">{t('authFiles.filter.allStatuses')}</option>
<option value="enabled">{t('authFiles.filter.enabled')}</option>
<option value="disabled">{t('authFiles.filter.disabled')}</option>
@@ -420,44 +497,99 @@ export function AuthFileManagementPage() {
) : visibleFiles.length === 0 ? (
<div className="management-empty"><FileDown size={24} /><strong>{files.length ? t('authFiles.empty.filtered') : t('authFiles.empty.none')}</strong><span>{files.length ? t('authFiles.empty.tryFilter') : t('authFiles.empty.upload')}</span></div>
) : (
<div className="auth-file-card-grid">
{visibleFiles.map((file) => {
<div className="auth-file-table-scroll">
<div className="auth-file-list-head" aria-hidden="true">
<span>{t('authFiles.list.credential')}</span><span>{t('authFiles.list.plan')}</span>
<span>{t('authFiles.list.status')}</span><span>{t('authFiles.list.recent')}</span>
<span>{t('authFiles.usage.title')}</span><span>{t('authFiles.list.quota')}</span>
<span>{t('authFiles.list.actions')}</span>
</div>
<div className="auth-file-card-grid" role="list" aria-label={t('authFiles.title')}>
{pageFiles.map((file) => {
const name = fileName(file);
const key = quotaKey(file);
const icon = providerIcons[providerKey(file)] ?? geminiIcon;
const disabled = readBoolean(file, 'disabled');
const priority = parseAuthFilePriority(file.priority) ?? 0;
const identity = readString(file, 'email', 'project_id', 'label');
const note = readString(file, 'note');
const quota = quotas[quotaKey(file)] ?? idleQuota();
const quotaProvider = quotaProviderForFile(file);
const metadata = providerKey(file) === 'codex' ? codexMetadataFor(file) : undefined;
const plan = quota.plan || metadata?.plan || readString(file, 'plan_type', 'plan');
const expiry = quota.subscriptionActiveUntil || metadata?.subscriptionActiveUntil;
const expiryMs = quotaResetInstant(expiry);
const health = authFileHealth(file);
const hasCooldown = Boolean(normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt)?.records?.length);
const statusLabel = hasCooldown && !health.disabled ? t('authFiles.list.cooling') : t(health.label);
const expanded = expandedFiles.has(key);
const detailsId = `auth-file-details-${encodeURIComponent(key)}`;
const cooldownResetIndex = authFileCooldownResetIndex(file);
const resettingCooldown = Boolean(cooldownResetIndex && cooldownResetting.has(cooldownResetIndex));
return (
<article className={`auth-file-card ${disabled ? 'is-disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
<header className={`auth-card-header ${identity ? '' : 'filename-only'}`}>
<article className={`auth-file-card ${disabled ? 'is-disabled' : ''}`} key={key} role="listitem" aria-label={identity || name}>
<div className="auth-credential-row">
<header className={`auth-card-header auth-list-cell ${identity ? '' : 'filename-only'}`}>
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
<div className="auth-card-identity"><span className="auth-card-provider">{providerName(file)}</span><strong title={identity || name}>{identity || name}</strong></div>
{isRuntimeOnly(file) ? <span className="state-pill">{t('authFiles.runtime')}</span> : null}
<div className="auth-card-identity">
<strong title={identity || name}>{identity || name}</strong>
<span className="auth-card-filename" title={name}>{identity ? name : providerName(file)}</span>
{isRuntimeOnly(file) ? <span className="auth-card-provider">{t('authFiles.runtime')}</span> : null}
</div>
</header>
{identity ? <p className="auth-card-filename">{name}</p> : null}
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} observedAt={observedAt} />
<AuthFileRequestStatus file={file} />
{quotaProviderForFile(file) ? <AuthFileQuotaPanel quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProviderForFile(file) === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : null}
<QuotaActionFeedback quota={quota} name={name} />
<div className="auth-card-meta">
<span>{t('authFiles.priority.button', { priority })} · {readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
<span>{formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
<div className="auth-list-cell auth-list-plan" data-label={t('authFiles.list.plan')}>
<span title={plan || undefined}>{plan || '—'}</span>
{expiryMs !== undefined ? <small title={t('authFiles.list.expiry', { time: formatDate(new Date(expiryMs).toISOString()) })}>{expiryMs <= now ? t('authFiles.list.expired') : t('authFiles.list.daysRemaining', { count: Math.ceil((expiryMs - now) / 86_400_000) })}</small> : null}
</div>
{note ? <div className="auth-card-note"><span>{t('authFiles.settings.note')}</span><p title={note}>{note}</p></div> : null}
<footer className="auth-card-actions">
<button type="button" className="secondary-button compact-button" onClick={() => setSettingsName(name)} disabled={busy || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')}><Settings2 size={14} />{t('authFiles.settings.button')}</button>
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')} aria-label={t('authFiles.copyName')}>{copied === name ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}</button>
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy || !isOAuthCredentialFile(file)} title={isOAuthCredentialFile(file) ? undefined : t('authFiles.fileOnly')}>{disabled ? t('common.enable') : t('common.disable')}</button>
<button type="button" className="icon-button danger" onClick={() => void deleteFile(file)} disabled={busy || isRuntimeOnly(file)} title={t('common.delete')} aria-label={t('common.delete')}><Trash2 size={15} aria-hidden="true" /></button>
<div className="auth-list-cell auth-list-status" data-label={t('authFiles.list.status')}>
<span className={`auth-status-badge ${hasCooldown && !health.disabled ? 'warning' : health.tone}`} title={statusLabel}>{statusLabel}</span>
<small>{t('authFiles.priority.button', { priority })}</small>
</div>
<div className="auth-list-cell auth-list-recent" data-label={t('authFiles.list.recent')}><AuthFileRequestStatus file={file} compact /></div>
<div className="auth-list-cell auth-list-usage" data-label={t('authFiles.usage.title')}><AuthFileUsageSummary file={file} /></div>
<div className="auth-list-cell auth-list-quota" data-label={t('authFiles.list.quota')}>
{quotaProvider ? <AuthFileQuotaPanel compact quota={quota} file={file} disabled={busy || disabled} onRefresh={() => void refreshQuota(file)} onReset={quotaProvider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} /> : <span className="auth-list-muted">{t('authFiles.list.noQuota')}</span>}
<QuotaActionFeedback quota={quota} name={name} />
</div>
<footer className="auth-card-actions auth-list-cell" data-label={t('authFiles.list.actions')}>
<div className="auth-list-icon-actions">
<button type="button" className="icon-button auth-list-action" onClick={() => void refreshQuota(file)} disabled={busy || disabled || !quotaProvider || quota.status === 'loading'} title={t('authFiles.quota.refresh')} aria-label={t('authFiles.quota.refresh')}><RefreshCw size={15} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
<button type="button" className="icon-button auth-list-action" onClick={() => setSettingsName(name)} disabled={busy || resettingCooldown || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? 'authFiles.settings.title' : 'authFiles.fileOnly')} aria-label={t('authFiles.settings.title')}><Settings2 size={15} aria-hidden="true" /></button>
<button type="button" className="icon-button auth-list-action" onClick={() => setModelViewName(name)} disabled={busy || !providerKey(file)} title={t('authFiles.models.viewTitle')} aria-label={t('authFiles.models.viewTitle')}><Network size={15} aria-hidden="true" /></button>
<button type="button" className="icon-button auth-list-action muted" onClick={() => void copyName(name)} disabled={busy} title={t('authFiles.copyName')} aria-label={t('authFiles.copyName')}>{copied === name ? <Check size={15} aria-hidden="true" /> : <Copy size={15} aria-hidden="true" />}</button>
<button type="button" className="icon-button auth-list-action danger" onClick={() => void deleteFile(file)} disabled={busy || resettingCooldown || isRuntimeOnly(file)} title={t('common.delete')} aria-label={t('common.delete')}><Trash2 size={15} aria-hidden="true" /></button>
</div>
<div className="auth-list-main-actions">
<button type="button" className="auth-list-switch" role="switch" aria-checked={!disabled} aria-label={`${t(disabled ? 'common.enable' : 'common.disable')} ${identity || name}`} onClick={() => void toggleStatus(file)} disabled={busy || resettingCooldown || !isOAuthCredentialFile(file)} title={t(isOAuthCredentialFile(file) ? disabled ? 'common.enable' : 'common.disable' : 'authFiles.fileOnly')}><span /></button>
<button type="button" className="auth-list-details-button" onClick={() => toggleDetails(key)} aria-expanded={expanded} aria-controls={detailsId}>{t(expanded ? 'authFiles.list.collapse' : 'authFiles.list.details')}</button>
</div>
</footer>
</div>
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} observedAt={observedAt}
resetting={resettingCooldown} resetDisabled={busy || loading}
onReset={cooldownResetIndex ? () => void resetCooldown(file) : undefined} />
<div className="auth-list-details" id={detailsId} hidden={!expanded}>
<div className="auth-card-meta">
<strong>{name}</strong><span>{providerName(file)}</span>
<span>{t('authFiles.list.size')} · {readNumber(file, 'size') === null ? t('authFiles.unknownSize') : `${Math.ceil((readNumber(file, 'size') ?? 0) / 1024)} KB`}</span>
<span>{t('authFiles.list.updated')} · {formatDate(file.modtime ?? file.updated_at ?? file.last_refresh)}</span>
</div>
{note ? <div className="auth-card-note"><span>{t('authFiles.settings.note')}</span><p>{note}</p></div> : null}
</div>
</article>
);
})}
</div>
</div>
)}
{!loading && visibleFiles.length > 0 ? <nav className="auth-list-pagination" aria-label={t('authFiles.title')}>
<span>{t('authFiles.list.pageSummary', { page: currentPage, pages: pageCount, start: (currentPage - 1) * pageSize + 1, end: Math.min(currentPage * pageSize, visibleFiles.length), total: visibleFiles.length })}</span>
<label>{t('authFiles.list.pageSize')}<select value={pageSize} onChange={(event) => setPageSize(Number(event.currentTarget.value))}>
{[10, 20, 50].map((size) => <option key={size} value={size}>{t('authFiles.list.perPage', { count: size })}</option>)}
</select></label>
<button type="button" className="secondary-button compact-button" disabled={currentPage === 1} onClick={() => setPage(currentPage - 1)}><ChevronLeft size={14} aria-hidden="true" />{t('authFiles.list.previous')}</button>
<button type="button" className="secondary-button compact-button" disabled={currentPage === pageCount} onClick={() => setPage(currentPage + 1)}>{t('authFiles.list.next')}<ChevronRight size={14} aria-hidden="true" /></button>
</nav> : null}
</section>
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
+44 -16
View File
@@ -2,7 +2,7 @@
display: flex;
flex-direction: column;
align-items: stretch;
gap: 18px;
gap: 22px;
min-width: 0;
}
@@ -16,18 +16,16 @@
.home-dashboard .home-top-grid > .panel {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
min-width: 0;
margin: 0;
padding: 16px 20px;
padding: 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-card);
box-shadow: none;
}
.home-dashboard .home-top-grid > .home-access-panel { gap: 14px; }
.home-dashboard .home-top-grid .panel-heading {
flex-direction: row;
align-items: center;
@@ -63,8 +61,8 @@
justify-content: space-between;
gap: 16px;
min-width: 0;
min-height: 28px;
padding: 3px 0;
min-height: 30px;
padding: 5px 0;
border: 0;
border-bottom: 1px solid var(--border-secondary);
}
@@ -114,7 +112,6 @@
min-height: 34px;
padding: 6px 8px;
font-size: 12px;
line-height: 18px;
}
.home-runtime-state { display: inline-flex; align-items: center; justify-content: flex-end; gap: 6px; color: var(--text-tertiary); }
@@ -168,7 +165,7 @@
flex: 1;
min-width: 0;
margin: 0;
padding: 7px 4px 9px;
padding: 9px 4px 11px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
@@ -202,7 +199,7 @@
justify-content: space-between;
flex-wrap: wrap;
gap: 4px 12px;
margin-bottom: 6px;
margin-bottom: 8px;
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
@@ -213,7 +210,7 @@
align-items: center;
gap: 4px;
min-width: 0;
min-height: 40px;
min-height: 42px;
padding: 4px 6px 4px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
@@ -243,7 +240,7 @@
font-size: 11px;
}
.home-overview-heading { flex-wrap: wrap; margin-bottom: 10px; }
.home-overview-heading { flex-wrap: wrap; margin-bottom: 14px; }
.home-overview-heading > div { display: flex; align-items: center; gap: 8px; color: var(--text-tertiary); font-size: 12px; }
.home-overview-heading .home-overview-label { gap: 12px; }
.home-overview-label > span { padding-left: 12px; border-left: 1px solid var(--border-color); font-size: 11px; color: var(--text-quaternary); }
@@ -257,9 +254,9 @@
--stat-color: var(--text-tertiary);
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
min-width: 0;
padding: 14px 16px;
padding: 18px 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-card);
@@ -274,6 +271,37 @@
.home-stat-track > span { display: block; height: 100%; border-radius: inherit; background: var(--stat-color); }
.home-stat-card p { margin: 0; color: var(--text-tertiary); font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; }
/* Use the desktop workspace height; smaller windows retain natural scrolling. */
@media (min-width: 1160px) {
.content:has(> .home-dashboard) {
display: flex;
min-height: 100dvh;
padding-block: 32px;
}
.content > .home-page.home-dashboard { flex: 1; }
.home-dashboard .home-top-grid { flex: 1 0 auto; }
.home-dashboard .control-panel .panel-detail-grid { flex: 1; }
.home-dashboard .control-panel .panel-detail-row { flex: 1; }
.home-dashboard .home-access-panel { justify-content: space-between; }
.home-dashboard .home-overview { display: flex; flex-direction: column; flex: 1 0 auto; }
.home-dashboard .home-overview-heading { flex: none; }
.home-dashboard .home-stat-grid { flex: 1; }
}
/* A second row of metrics occupies the lower workspace without oversized panels. */
@media (min-width: 1160px) and (min-height: 800px) {
.home-dashboard .home-top-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.home-dashboard .home-stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(160px, 1fr));
}
.home-dashboard .home-stat-card { justify-content: center; padding: 20px 24px; }
.home-dashboard .home-stat-card h3 { font-size: 13px; }
.home-dashboard .home-stat-value { font-size: 34px; }
.home-dashboard .home-stat-card p { font-size: 12px; }
}
@container workspace (max-width: 800px) {
.home-top-grid { grid-template-columns: minmax(0, 1fr); gap: 16px; }
.home-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -281,9 +309,9 @@
@media (max-width: 620px) {
.home-top-grid { grid-template-columns: minmax(0, 1fr); }
.home-dashboard .home-top-grid > .panel { padding: 16px; }
.home-dashboard .home-top-grid > .panel { padding: 18px; }
.home-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.home-stat-card { padding: 14px; }
.home-stat-card { padding: 16px; }
.home-stat-value { font-size: 26px; }
.home-overview-heading { align-items: flex-start; gap: 10px; }
.home-overview-heading .home-overview-actions { width: 100%; flex-wrap: nowrap; }
-2
View File
@@ -29,7 +29,6 @@ import {
shouldShowOAuthLoginStatus,
} from '../services/oauthLoginState';
import { AuthFileManagementPage } from './AuthFileManagementPage';
import { QuotaPage } from './QuotaPage';
import { validateDevinCallback } from '../services/devinOAuth';
import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation';
@@ -169,7 +168,6 @@ export function OAuthManagementPage() {
>
{activeSubpage === 'login' ? <OAuthLoginPage /> : null}
{activeSubpage === 'authFiles' ? <AuthFileManagementPage /> : null}
{activeSubpage === 'quota' ? <QuotaPage /> : null}
</div>
</section>
);
+3
View File
@@ -145,6 +145,9 @@ export function QuotaPage() {
return (
<section className="page management-page quota-page">
{confirmationDialog}
<header className="management-header">
<div><h1>{t('quota.title')}</h1></div>
</header>
{error ? <MessageNotice message={error} /> : null}
<OAuthPageToolbar
icon={<Gauge size={18} />}
+10 -3
View File
@@ -66,7 +66,7 @@ function normalizeCooldown(value: unknown): AuthFileCooldown | null {
...(scope === 'model' ? { model } : {}),
retryAt,
remainingSeconds,
reason: readString(value, 'reason') || 'unknown',
reason: typeof value.reason === 'string' && value.reason.trim() ? value.reason.trim() : 'unknown',
...(typeof httpStatus === 'number' && Number.isInteger(httpStatus)
&& httpStatus >= 400 && httpStatus <= 599 ? { httpStatus } : {}),
...(typeof backoffLevel === 'number' && Number.isSafeInteger(backoffLevel)
@@ -87,11 +87,18 @@ export function normalizeAuthFileCooldowns(
return { ...snapshot, records: records as AuthFileCooldown[] };
}
export function cooldownRemainingSeconds(record: AuthFileCooldown, receivedAtMs: number, nowMs: number): number {
// Server-provided remaining time avoids client/server wall-clock skew. Keep
// expired records visible until a fresh snapshot confirms the restriction lifted.
const elapsedSeconds = Math.max(0, nowMs - receivedAtMs) / 1000;
return Math.max(0, Math.ceil(record.remainingSeconds - elapsedSeconds));
}
export function summarizeAuthFileCooldowns(snapshot: AuthFileCooldownSnapshot | undefined, nowMs: number) {
const elapsedSeconds = snapshot ? Math.max(0, nowMs - snapshot.receivedAtMs) / 1000 : 0;
const receivedAtMs = snapshot?.receivedAtMs ?? nowMs;
const rows = (snapshot?.records ?? []).map((record) => ({
record,
remainingSeconds: Math.max(0, Math.ceil(record.remainingSeconds - elapsedSeconds)),
remainingSeconds: cooldownRemainingSeconds(record, receivedAtMs, nowMs),
}));
const active = rows.filter((row) => row.remainingSeconds > 0);
return {
+31 -1
View File
@@ -1,9 +1,39 @@
import { managementApi, readBoolean, readString } from './managementApi';
import { isRecord, managementApi, readBoolean, readString } from './managementApi';
import { getCurrentLocale, translate } from '../i18n';
export type AuthFileRecord = Record<string, unknown>;
export type AuthFileSnapshot = Map<string, string>;
export type AuthFileCooldownResetResponse = {
status: 'ok';
auth_index: string;
models: string[];
};
export const authFileCooldownResetIndex = (file: AuthFileRecord): string | undefined => {
const value = file.auth_index ?? file.authIndex;
if (typeof value === 'string') return value.trim() || undefined;
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return String(value);
return undefined;
};
export const resetAuthFileCooldown = async (
authIndex: string,
api: { post: (path: string, body: Record<string, unknown>) => Promise<unknown> } = managementApi,
): Promise<AuthFileCooldownResetResponse> => {
const index = authFileCooldownResetIndex({ auth_index: authIndex });
if (!index) throw new Error(translate(getCurrentLocale(), 'authFiles.cooldown.missingIndex'));
const response = await api.post('/routing/cooldown/reset', { auth_index: index });
// Only an acknowledgement for this index confirms the operation. The caller
// reloads the list to obtain authoritative health and disabled state afterward.
if (!isRecord(response) || response.status !== 'ok' || response.auth_index !== index
|| (response.models != null && (!Array.isArray(response.models)
|| response.models.some((model) => typeof model !== 'string')))) {
throw new Error(translate(getCurrentLocale(), 'authFiles.cooldown.invalidResponse'));
}
return { status: 'ok', auth_index: index, models: (response.models ?? []) as string[] };
};
export const authFileName = (file: AuthFileRecord) =>
readString(file, 'name') || translate(getCurrentLocale(), 'authFiles.unnamed');
+152
View File
@@ -0,0 +1,152 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs/promises');
(async () => {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
const server = await createServer({ configFile: false, root: path.resolve(__dirname, '..'), plugins: [react()],
optimizeDeps: { entries: ['tests/fixtures/auth-file-cooldown.html'] }, logLevel: 'error',
server: { host: '127.0.0.1', port: 0, watch: null } });
let browser;
const screenshotDir = path.resolve('bin-work/auth-file-cooldown-qa');
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
await page.clock.install({ time: new Date('2026-10-03T04:00:00Z') });
const card = name => page.locator('.auth-file-card').filter({ has: page.locator('.auth-card-identity strong', { hasText: name }) });
const primary = () => card('01-primary.json');
const other = () => card('02-other.json');
const clear = target => target.getByRole('button', { name: '清除冷却', exact: true });
const confirmation = () => page.getByRole('alertdialog', { name: '清除凭证冷却?', exact: true });
const requests = () => page.evaluate(() => window.cooldownFixture.resetRequests);
const open = async query => {
await page.goto(`${base}/tests/fixtures/auth-file-cooldown.html?${query || ''}`);
await primary().waitFor();
};
const releaseReset = () => page.evaluate(() => window.cooldownFixture.releaseReset());
const confirmReset = async target => {
await clear(target).click();
await confirmation().waitFor();
await confirmation().getByRole('button', { name: '清除冷却', exact: true }).click();
await page.waitForFunction(() => Boolean(window.cooldownFixture.releaseReset));
};
await fs.mkdir(screenshotDir, { recursive: true });
await open();
assert.equal(await primary().locator('details').getAttribute('open'), null);
assert.equal(await clear(primary()).isVisible(), true, 'Reset is discoverable while details are collapsed');
for (const name of ['05-empty.json', '06-null.json', '07-absent.json', '08-no-index.json']) {
assert.equal(await clear(card(name)).count(), 0, `${name} must not offer an unsafe or inapplicable reset`);
}
await primary().locator('.auth-health-summary').click();
const details = primary().locator('.auth-health-body');
for (const text of ['整个凭证', 'gpt-fictional', 'claude-fictional', '额度或速率限制', '上游服务暂时异常', 'HTTP 429', 'HTTP 503', '退避等级 0']) {
assert.ok((await details.innerText()).includes(text), `Cooldown detail contains ${text}`);
}
assert.equal(await details.locator('time').count(), 4, 'Every cooldown and its observation timestamp are available');
const beforeTick = await primary().locator('.auth-health-countdown').innerText();
await page.clock.runFor(3100);
assert.notEqual(await primary().locator('.auth-health-countdown').innerText(), beforeTick, 'Countdown advances without a list reload');
assert.equal(await page.evaluate(() => window.cooldownFixture.reads), 1);
assert.ok((await card('04-expired-soon.json').innerText()).includes('冷却计时已到期'));
assert.equal(await clear(card('04-expired-soon.json')).isEnabled(), true, 'Elapsed records remain explicitly resettable');
assert.ok((await card('03-disabled-runtime.json').innerText()).includes('运行时'));
assert.ok((await card('03-disabled-runtime.json').locator('.auth-file-health').innerText()).includes('已停用'));
assert.equal(await clear(card('03-disabled-runtime.json')).isEnabled(), true, 'Runtime and disabled credentials can clear existing cooldown records');
await page.screenshot({ path: path.join(screenshotDir, 'details-light-desktop.png'), fullPage: true });
await clear(primary()).click();
await confirmation().waitFor();
assert.ok((await confirmation().innerText()).includes('01-primary.json'));
assert.deepEqual(await requests(), [], 'Opening confirmation does not submit a reset');
await confirmation().getByRole('button', { name: '取消', exact: true }).click();
assert.deepEqual(await requests(), [], 'Cancel leaves the backend untouched');
assert.equal(await primary().locator('.auth-health-records li').count(), 3);
const otherDeadline = await other().locator('.auth-health-records time').getAttribute('datetime');
const otherBefore = await other().locator('.auth-health-countdown').innerText();
await confirmReset(primary());
const pending = primary().locator('.auth-file-health button');
assert.equal(await pending.isDisabled(), true, 'Pending reset prevents duplicate submission');
await pending.evaluate(button => button.click());
const sent = await requests();
assert.equal(sent.length, 1);
assert.equal(sent[0].method, 'POST');
assert.equal(sent[0].path, '/routing/cooldown/reset');
assert.deepEqual(sent[0].body, { auth_index: 'auth-primary-123' }, 'Only the selected auth_index is sent; no all/model scope');
assert.equal(await primary().locator('.auth-health-records li').count(), 3, 'Pending request retains existing cooldown details');
await page.clock.runFor(3000);
await releaseReset();
await page.waitForFunction(() => window.cooldownFixture.reads === 2);
await primary().locator('.auth-file-health').waitFor({ state: 'detached' });
assert.equal(await other().locator('.auth-health-records li').count(), 1);
assert.equal(await other().locator('.auth-health-records time').getAttribute('datetime'), otherDeadline);
assert.notEqual(await other().locator('.auth-health-countdown').innerText(), otherBefore, 'A sibling timer continues instead of restarting or clearing');
assert.ok((await page.locator('.app-notice-stack').innerText()).includes('01-primary.json'), 'Success identifies the cleared credential');
await open('resetError=1');
await primary().locator('.auth-health-summary').click();
await confirmReset(primary());
await releaseReset();
await page.getByText(/Fixture cooldown reset failed/).waitFor();
assert.equal(await primary().locator('.auth-health-records li').count(), 3, 'A rejected reset retains the displayed records');
assert.equal(await clear(primary()).isEnabled(), true, 'Failure allows a deliberate retry');
assert.equal(await page.evaluate(() => window.cooldownFixture.reads), 1, 'Failure does not pretend a refreshed successful state');
await page.screenshot({ path: path.join(screenshotDir, 'reset-failure.png'), fullPage: true });
await open();
await confirmReset(card('03-disabled-runtime.json'));
await releaseReset();
await page.waitForFunction(() => window.cooldownFixture.reads === 2);
await clear(card('03-disabled-runtime.json')).waitFor({ state: 'detached' });
assert.ok((await card('03-disabled-runtime.json').innerText()).includes('运行时'));
assert.ok((await card('03-disabled-runtime.json').locator('.auth-file-health').innerText()).includes('已停用'), 'Clearing cooldown preserves disabled state');
await open();
await confirmReset(primary());
await page.evaluate(() => { window.cooldownFixture.holdNextRead = true; });
await page.getByRole('button', { name: '刷新', exact: true }).click();
await page.waitForFunction(() => Boolean(window.cooldownFixture.releaseRead));
await releaseReset();
await page.waitForFunction(() => window.cooldownFixture.reads === 3 && window.cooldownFixture.completedReads === 2);
await primary().waitFor();
assert.equal(await primary().locator('.auth-file-health').count(), 0);
await page.evaluate(() => window.cooldownFixture.releaseRead());
await page.waitForFunction(() => window.cooldownFixture.completedReads === 3);
await page.clock.runFor(100);
assert.equal(await primary().locator('.auth-file-health').count(), 0, 'An older list response cannot resurrect cleared cooldowns');
for (const theme of ['light', 'dark']) {
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 900 });
await open(`theme=${theme}`);
await primary().locator('.auth-health-summary').click();
await other().locator('.auth-health-summary').click();
const sizes = await page.locator('.auth-file-card, .auth-file-health').evaluateAll(nodes => nodes.map(node => ({
className: node.className, width: node.clientWidth, scroll: node.scrollWidth,
})));
assert.ok(sizes.every(size => size.scroll <= size.width + 1), `${theme} ${width}: ${JSON.stringify(sizes)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `No page overflow at ${theme} ${width}`);
await page.screenshot({ path: path.join(screenshotDir, `details-${theme}-${width}.png`), fullPage: true });
await primary().screenshot({ path: path.join(screenshotDir, `card-${theme}-${width}.png`) });
await clear(primary()).click();
await confirmation().waitFor();
assert.equal(await confirmation().evaluate(node => node.scrollWidth <= node.clientWidth + 1), true, 'Confirmation fits viewport');
await page.screenshot({ path: path.join(screenshotDir, `confirmation-${theme}-${width}.png`), fullPage: true });
await confirmation().screenshot({ path: path.join(screenshotDir, `dialog-${theme}-${width}.png`) });
await confirmation().getByRole('button', { name: '取消', exact: true }).click();
}
}
assert.deepEqual(errors, []);
console.log('PASS: cooldown models/reasons/countdowns, confirmation and cancel, single auth_index POST, pending duplicate protection, isolated success refresh, failure preservation, stale response protection, unknown/empty/missing-index guards, elapsed records, runtime/disabled state, light/dark desktop/mobile layout.');
console.log(`Screenshots: ${screenshotDir}`);
} finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+146
View File
@@ -0,0 +1,146 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs/promises');
(async () => {
const { createServer } = await import('vite');
const react = (await import('@vitejs/plugin-react')).default;
const server = await createServer({ configFile: false, root: path.resolve(__dirname, '..'), plugins: [react()],
optimizeDeps: { entries: ['tests/fixtures/auth-file-list.html'] }, logLevel: 'error',
server: { host: '127.0.0.1', port: 0, watch: null } });
let browser;
const screenshotDir = path.resolve('bin-work/auth-file-list-qa');
try {
await server.listen();
const base = `http://127.0.0.1:${server.httpServer.address().port}`;
browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
const page = await browser.newPage({ viewport: { width: 1800, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [], externalRequests = [];
page.on('pageerror', error => errors.push(String(error)));
await page.route('**/*', route => {
if (route.request().url().startsWith(`${base}/`)) return route.continue();
externalRequests.push(route.request().url());
return route.abort();
});
await page.clock.install({ time: new Date('2026-10-03T04:00:00Z') });
const cards = () => page.locator('.auth-file-card');
const card = number => cards().filter({ has: page.locator(`.auth-card-filename[title="${String(number).padStart(2, '0')}-account.json"]`) });
const pagination = () => page.locator('.auth-list-pagination');
const next = () => pagination().getByRole('button', { name: '下一页', exact: true });
const previous = () => pagination().getByRole('button', { name: '上一页', exact: true });
const open = async (query = '') => {
await page.goto(`${base}/tests/fixtures/auth-file-list.html?${query}`);
await card(1).waitFor();
await page.waitForFunction(() => window.authFileListFixture.reads === 1);
};
const assertFits = async label => {
const dimensions = await cards().evaluateAll(nodes => nodes.map(node => ({ width: node.clientWidth, scroll: node.scrollWidth })));
assert.ok(dimensions.every(size => size.scroll <= size.width + 1), `${label}: cards overflow ${JSON.stringify(dimensions)}`);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, `${label}: page overflow`);
};
await fs.mkdir(screenshotDir, { recursive: true });
await open();
assert.equal(await cards().count(), 10, 'Default page contains ten of twelve credentials');
assert.equal(await previous().isDisabled(), true);
assert.ok((await pagination().innerText()).includes('1 / 2'));
assert.equal(await page.locator('.auth-file-list-head > span').count(), 7);
const cells = await card(1).locator('.auth-credential-row > .auth-list-cell').evaluateAll(nodes => nodes.map(node => {
const bounds = node.getBoundingClientRect(); return { left: bounds.left, right: bounds.right };
}));
assert.equal(cells.length, 7, 'Desktop row includes all seven columns');
assert.ok(cells.slice(1).every((cell, index) => cell.left >= cells[index].right - 1), 'Desktop columns stay in one horizontal row');
await next().click();
await card(11).waitFor();
assert.equal(await cards().count(), 2);
assert.equal(await next().isDisabled(), true);
assert.ok((await pagination().innerText()).includes('2 / 2'));
await page.locator('.auth-files-toolbar input').fill('account');
await card(1).waitFor();
assert.equal(await cards().count(), 10, 'Search resets pagination even when all credentials match');
assert.equal(await previous().isDisabled(), true);
await next().click();
await page.locator('.auth-files-toolbar select').first().selectOption({ label: 'Codex' });
await card(1).waitFor();
assert.equal(await previous().isDisabled(), true, 'Provider filter resets to the first page');
await page.locator('.auth-files-toolbar select').first().selectOption('all');
await next().click();
await page.locator('.auth-files-toolbar select').last().selectOption('disabled');
await card(3).waitFor();
assert.equal(await cards().count(), 2, 'Disabled filter retains matching rows');
assert.equal(await previous().isDisabled(), true);
await page.locator('.auth-files-toolbar select').last().selectOption('all');
await pagination().locator('select').selectOption('20');
await card(12).waitFor();
assert.equal(await cards().count(), 12, 'Page size twenty shows all twelve credentials');
assert.equal(await next().isDisabled(), true);
const detailsButton = card(1).locator('.auth-list-details-button');
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false);
await detailsButton.click();
assert.equal(await detailsButton.getAttribute('aria-expanded'), 'true');
assert.equal(await card(1).locator('.auth-list-details').isVisible(), true);
assert.ok((await card(1).locator('.auth-list-details').innerText()).includes('Fictional account note'));
await detailsButton.click();
assert.equal(await card(1).locator('.auth-list-details').isVisible(), false);
const extraQuota = card(4).locator('.credential-quota-more');
assert.equal(await extraQuota.getAttribute('open'), null);
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), false);
await extraQuota.locator('summary').click();
assert.equal(await extraQuota.getByText('Additional model B', { exact: true }).isVisible(), true, 'All additional quotas remain accessible');
assert.ok((await extraQuota.innerText()).includes('No quota limit supplied'));
assert.equal(await card(1).locator('.auth-file-request-block').count(), 20);
await card(1).locator('.auth-file-request-block').nth(18).focus();
assert.equal(await card(1).getByRole('tooltip').isVisible(), true, 'Recent request details are keyboard accessible');
await page.keyboard.press('Escape');
await card(1).locator('.auth-file-request-block').nth(18).blur();
assert.equal(await card(2).getByRole('button', { name: '清除冷却', exact: true }).isVisible(), true);
await card(2).locator('.auth-health-summary').click();
assert.ok((await card(2).locator('.auth-health-body').innerText()).includes('gpt-fictional-layout'), 'Model cooldown details remain available');
await card(1).getByRole('switch').click();
await page.waitForFunction(() => window.authFileListFixture.reads === 2);
assert.equal(await card(1).getByRole('switch').getAttribute('aria-checked'), 'false');
let updates = await page.evaluate(() => window.authFileListFixture.requests.filter(request => request.method === 'PATCH'));
assert.equal(updates.length, 1);
assert.equal(updates[0].path, '/credentials/status');
assert.deepEqual(updates[0].body, { name: '01-account.json', disabled: true });
await card(1).getByRole('switch').click();
await page.waitForFunction(() => window.authFileListFixture.reads === 3);
updates = await page.evaluate(() => window.authFileListFixture.requests.filter(request => request.method === 'PATCH'));
assert.deepEqual(updates[1].body, { name: '01-account.json', disabled: false });
assert.equal(await card(1).getByRole('switch').getAttribute('aria-checked'), 'true');
assert.equal(await card(3).getByRole('switch').getAttribute('aria-checked'), 'false', 'Sibling status stays unchanged');
await card(12).getByRole('button', { name: '删除', exact: true }).click();
const deleteDialog = page.getByRole('alertdialog', { name: '删除', exact: true });
await deleteDialog.waitFor();
await deleteDialog.getByRole('button', { name: '删除', exact: true }).click();
await card(12).waitFor({ state: 'detached' });
assert.equal(await cards().count(), 11);
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.requests.find(request => request.method === 'DELETE')?.query), { name: '12-account.json' });
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
const cases = ['light', 'dark'].flatMap(theme => [1800, 1280, 390].map(width => ({ theme, width, locale: 'zh-CN' })));
cases.push({ theme: 'light', width: 1800, locale: 'en' }, { theme: 'light', width: 390, locale: 'en' });
for (const { theme, width, locale } of cases) {
await page.setViewportSize({ width, height: 1000 });
await open(`theme=${theme}&locale=${locale}`);
await assertFits(`${locale} ${theme} ${width}`);
await page.screenshot({ path: path.join(screenshotDir, `list-${locale}-${theme}-${width}.png`), fullPage: true });
await card(1).locator('.auth-list-details-button').click();
await card(2).locator('.auth-health-summary').click();
await card(4).locator('.credential-quota-more > summary').click();
await assertFits(`${locale} ${theme} ${width} expanded`);
await card(4).screenshot({ path: path.join(screenshotDir, `expanded-quota-${locale}-${theme}-${width}.png`) });
assert.deepEqual(await page.evaluate(() => window.authFileListFixture.unhandled), []);
}
assert.deepEqual(errors, []);
assert.deepEqual(externalRequests, []);
console.log('PASS: offline credential list, seven desktop columns, ten/two pagination, search/provider/status reset, page size twenty, details, all quota rows, request keyboard tooltip, cooldown controls, exact status PATCH, deletion, light/dark 1800/1280/390 and English desktop/mobile without page or card overflow.');
console.log(`Screenshots: ${screenshotDir}`);
} finally { await browser?.close(); await server.close(); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../src/i18n';
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
import { AuthFileRequestStatus } from '../src/components/AuthFileRequestStatus';
import type { QuotaState } from '../src/services/quotaService';
const renderQuota = (quota: QuotaState, compact = true) => renderToStaticMarkup(
<I18nProvider><AuthFileQuotaPanel file={{ name: 'test.json', provider: 'codex' }} quota={quota}
disabled={false} onRefresh={() => {}} onReset={() => {}} compact={compact} /></I18nProvider>,
);
const renderRequests = (file: Record<string, unknown>, compact = true) => renderToStaticMarkup(
<I18nProvider><AuthFileRequestStatus file={file} compact={compact} /></I18nProvider>,
);
describe('compact credential panels', () => {
it('keeps extra quota rows accessible through native details and preserves reset information', () => {
const quota: QuotaState = {
status: 'success', plan: 'Plan only shown in the plan column', resetCredits: 2, resetCreditsApplicable: 0,
rows: [
{ label: 'Five hour', remainingPercent: 24 },
{ label: 'Weekly', remainingPercent: 68 },
{ label: 'Extra model', remainingPercent: 0, detail: 'Additional quota detail' },
],
};
const compact = renderQuota(quota);
const beforeDetails = compact.split('<details')[0];
expect(beforeDetails).toContain('Five hour');
expect(beforeDetails).toContain('Weekly');
expect(beforeDetails).not.toContain('Extra model');
expect(compact).toMatch(/<details[^>]*><summary>Details \(\+1\)<\/summary>.*Extra model.*Additional quota detail.*<\/details>/);
expect(compact).toContain('title="Reset Quota"');
expect(compact).toContain('2 manual resets');
expect(compact).toContain('Currently applicable: 0');
expect(compact).not.toContain('credential-quota-refresh');
expect(compact).not.toContain(quota.plan!);
const full = renderQuota(quota, false);
expect(full).not.toContain('<details');
expect(full).toContain(quota.plan!);
expect(full).toContain('credential-quota-refresh');
});
it('preserves quota loading and error feedback in compact mode', () => {
expect(renderQuota({ status: 'loading', rows: [], pendingAction: 'reset' })).toContain('aria-busy="true"');
expect(renderQuota({ status: 'error', rows: [], error: 'Quota server unavailable' })).toContain('Quota server unavailable');
});
it('uses the latest nonempty request bucket time while retaining all twenty interactive buckets', () => {
const file = {
success: 91, failed: 9, modified: 'Unrelated modification time',
recent_requests: [
{ time: '09:00–09:10', success: 4, failed: 0 },
{ time: '09:10–09:20', success: 0, failed: 1 },
{ time: '09:20–09:30', success: 0, failed: 0 },
],
};
const compact = renderRequests(file);
expect(compact).toMatch(/auth-file-requests-latest[^>]*>09:10–09:20<\/div>/);
expect(compact.match(/class="auth-file-request-block"/g)).toHaveLength(20);
expect(compact).toContain('aria-label="09:10–09:20');
expect(compact).not.toContain('Unrelated modification time');
expect(compact).not.toContain('auth-file-requests-rate');
expect(compact).not.toContain('auth-file-requests-counts');
expect(renderRequests(file, false)).toContain('auth-file-requests-counts');
});
it('does not invent a recent request time for empty, unavailable or untimed statistics', () => {
const empty = renderRequests({ recent_requests: [{ time: '10:00–10:10', success: 0, failed: 0 }] });
const unavailable = renderRequests({});
const untimed = renderRequests({ recent_requests: [{ success: 1, failed: 0 }] });
for (const html of [empty, unavailable, untimed]) {
expect(html).toMatch(/auth-file-requests-latest[^>]*>—<\/div>/);
}
expect(empty).toContain('No requests');
expect(unavailable).toContain('The core did not provide recent request statistics');
});
});
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { clearMocks, mockIPC } from '@tauri-apps/api/mocks';
import { authFileCooldownResetIndex, resetAuthFileCooldown } from '../src/services/authFiles';
import {
cooldownReasonKey,
cooldownRemainingSeconds,
normalizeAuthFileCooldowns,
summarizeAuthFileCooldowns,
} from '../src/services/authFileHealth';
describe('credential cooldown reset', () => {
let originalWindow: PropertyDescriptor | undefined;
beforeEach(() => {
originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { value: {}, writable: true, configurable: true });
});
afterEach(() => {
clearMocks();
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
});
it('uses only an auth index and accepts runtime and disabled credentials', () => {
expect(authFileCooldownResetIndex({ auth_index: ' auth-one ', disabled: true })).toBe('auth-one');
expect(authFileCooldownResetIndex({ authIndex: 0, runtime_only: true })).toBe('0');
expect(authFileCooldownResetIndex({ auth_index: 'runtime-key', account_type: 'api_key' })).toBe('runtime-key');
expect(authFileCooldownResetIndex({ auth_index: null, authIndex: 'alias' })).toBe('alias');
expect(authFileCooldownResetIndex({ name: 'file.json', id: 'auth-id' })).toBeUndefined();
for (const auth_index of [undefined, null, '', ' ', {}, [], true, NaN, Infinity, -1, 1.5]) {
expect(authFileCooldownResetIndex({ auth_index })).toBeUndefined();
}
});
it('posts a raw auth_index operation through the v8 management bridge', async () => {
const requests: unknown[] = [];
mockIPC((command, args) => {
expect(command).toBe('management_request');
requests.push(args?.request);
return { status: 'ok', auth_index: 'auth-one', models: ['model-a', 'model-b'] };
});
expect(await resetAuthFileCooldown(' auth-one ')).toEqual({
status: 'ok', auth_index: 'auth-one', models: ['model-a', 'model-b'],
});
expect(requests).toEqual([{
method: 'POST', path: '/routing/cooldown/reset', body: { auth_index: 'auth-one' },
query: undefined, timeoutMs: undefined,
}]);
});
it('rejects a missing index before making a request', async () => {
let called = false;
const api = { post: async () => { called = true; return {}; } };
await expect(resetAuthFileCooldown(' ', api)).rejects.toThrow();
expect(called).toBe(false);
});
it('requires a successful acknowledgement for the selected credential', async () => {
for (const response of [undefined, null, [], {},
{ status: 'error', auth_index: 'auth-one', error: 'operation failed' },
{ status: 'ok', auth_index: 'another-index' },
{ status: 'ok' },
{ status: 'ok', auth_index: 'auth-one', models: [null] },
{ status: 'ok', auth_index: 'auth-one', models: 'model-a' },
]) {
await expect(resetAuthFileCooldown('auth-one', { post: async () => response })).rejects.toThrow();
}
for (const models of [[], null, undefined]) {
expect(await resetAuthFileCooldown('auth-one', {
post: async () => ({ status: 'ok', auth_index: 'auth-one', models }),
})).toEqual({ status: 'ok', auth_index: 'auth-one', models: [] });
}
});
it('preserves backend and transport failures without reporting successful reset', async () => {
for (const message of [
'Management API error (404): auth not found',
'Management API error (404): 404 page not found',
'Management API error (401): unauthorized',
'Management API error (500): failed to reset quota',
'Management API request failed: connection refused',
]) {
const failure = new Error(message);
mockIPC(() => { throw failure; });
await expect(resetAuthFileCooldown('auth-one')).rejects.toBe(failure);
}
});
});
describe('cooldown snapshot and timer invariants', () => {
const receivedAtMs = 10_000;
const model = {
scope: 'model', model_key: 'model-a', reason: 'quota',
retry_at: '2040-01-01T00:00:32Z', remaining_seconds: 32,
};
it('counts each model once and does not infer timers from diagnostic backoff', () => {
const snapshot = normalizeAuthFileCooldowns([
model, { ...model, remaining_seconds: 10, backoff_level: 100 },
], receivedAtMs)!;
const record = snapshot.records![1];
expect(cooldownRemainingSeconds(record, receivedAtMs, receivedAtMs - 5000)).toBe(10);
expect(cooldownRemainingSeconds(record, receivedAtMs, receivedAtMs + 1001)).toBe(9);
expect(cooldownRemainingSeconds(record, receivedAtMs, receivedAtMs + 10_000)).toBe(0);
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs)).toMatchObject({ modelCount: 1, earliestSeconds: 10 });
const elapsed = summarizeAuthFileCooldowns(snapshot, receivedAtMs + 40_000);
expect(elapsed.rows).toHaveLength(2);
expect(elapsed).toMatchObject({ elapsed: true, modelCount: 0, earliestSeconds: 0 });
});
it('treats absent, unknown, fresh empty and expired snapshots as separate states', () => {
expect(normalizeAuthFileCooldowns(undefined, receivedAtMs)).toBeUndefined();
expect(normalizeAuthFileCooldowns(null, receivedAtMs)?.records).toBeNull();
expect(normalizeAuthFileCooldowns([], receivedAtMs)?.records).toEqual([]);
const expired = normalizeAuthFileCooldowns([model], receivedAtMs)!;
expect(summarizeAuthFileCooldowns(expired, receivedAtMs + 33_000).elapsed).toBe(true);
const refreshed = normalizeAuthFileCooldowns([], receivedAtMs + 33_000)!;
expect(summarizeAuthFileCooldowns(refreshed, receivedAtMs + 33_000).elapsed).toBe(false);
expect(refreshed.records).toEqual([]);
});
it('normalizes diagnostics without displaying arbitrary reason values or invalid dates', () => {
for (const reason of [null, {}, 429, true, '']) {
const snapshot = normalizeAuthFileCooldowns([{ ...model, reason }], receivedAtMs, 'invalid');
expect(snapshot?.observedAt).toBeUndefined();
expect(snapshot?.records?.[0].reason).toBe('unknown');
}
for (const reason of ['future_reason', '__proto__', 'constructor', '<script>']) {
const record = normalizeAuthFileCooldowns([{ ...model, reason }], receivedAtMs)!.records![0];
expect(cooldownReasonKey(record.reason)).toBe('authFiles.health.reason.unknown');
}
expect(normalizeAuthFileCooldowns([
{ ...model, remaining_seconds: Number.MAX_SAFE_INTEGER },
], receivedAtMs)?.records).toBeNull();
});
});
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileHealthStatus } from '../src/components/AuthFileHealthStatus';
import { I18nProvider } from '../src/i18n';
const receivedAtMs = Date.now();
const cooldown = {
scope: 'model', model_key: 'model-a', reason: 'quota',
retry_at: '2026-01-01T10:00:32Z', remaining_seconds: 32, http_status: 429,
};
const render = (file: Record<string, unknown>, options: {
receivedAtMs?: number; resetting?: boolean; resetDisabled?: boolean; onReset?: () => void;
} = {}) => renderToStaticMarkup(<I18nProvider>
<AuthFileHealthStatus file={file} receivedAtMs={receivedAtMs} onReset={() => {}} {...options} />
</I18nProvider>);
describe('credential cooldown actions', () => {
it('makes model cooldowns and the clear action visible without opening details', () => {
const html = render({ status: 'error', cooldowns: [cooldown] });
const summary = html.slice(0, html.indexOf('</summary>'));
expect(summary).toContain('<strong>1 models cooling down</strong>');
expect(summary).toContain('Quota or rate limit');
expect(html.slice(html.indexOf('</details>'))).toContain('Clear cooldown</button>');
expect(html).toContain('HTTP 429');
});
it.each([undefined, null, [], {}, [null], [{ ...cooldown, remaining_seconds: 0 }]].map((cooldowns) => ({ cooldowns })))(
'does not expose clear for absent, empty or unknown cooldowns: %j', ({ cooldowns }) => {
expect(render({ status: 'error', cooldowns })).not.toContain('Clear cooldown</button>');
},
);
it('keeps elapsed cooldowns resettable without reporting restored availability', () => {
const html = render({ status: 'active', cooldowns: [cooldown] }, { receivedAtMs: receivedAtMs - 60_000 });
expect(html).toContain('Cooldown timer elapsed');
expect(html).toContain('Elapsed · confirmation needed');
expect(html).toContain('Clear cooldown</button>');
expect(html).not.toContain('Available');
});
it('honors unavailable and pending reset actions', () => {
const file = { status: 'error', cooldowns: [cooldown] };
expect(render(file, { onReset: undefined })).not.toContain('<button');
expect(render(file, { resetDisabled: true })).toMatch(/<button[^>]*disabled=""/);
const pending = render(file, { resetting: true });
expect(pending).toMatch(/<button[^>]*disabled=""[^>]*aria-busy="true"/);
expect(pending).toContain('Clearing…</button>');
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { AuthFileUsageSummary } from '../src/components/AuthFileUsageSummary';
import { I18nProvider } from '../src/i18n';
const renderSummary = (file: Record<string, unknown>) => renderToStaticMarkup(
<I18nProvider><AuthFileUsageSummary file={file} /></I18nProvider>,
);
describe('credential cumulative usage', () => {
it('uses cumulative counters even when recent requests have a different success rate', () => {
const html = renderSummary({ success: 1900, failed: 100, recent_requests: [{ success: 0, failed: 20 }] });
expect(html).toContain('aria-label="Total Requests: 2,000"');
expect(html).toContain('aria-label="Success rate 95%"');
expect(html).toContain('aria-label="Success 1,900"');
expect(html).toContain('aria-label="Failed 100"');
expect(html).toContain('Totals since the core started; reset on restart.');
expect(html).toContain('Core session totals');
expect(html).not.toContain('tokens');
});
it('preserves unavailable counters instead of treating a recent-only window or partial count as totals', () => {
const recentOnly = renderSummary({ recent_requests: [{ success: 12, failed: 0 }] });
expect(recentOnly).toContain('aria-label="Total Requests: —"');
expect(recentOnly).toContain('aria-label="Success rate —"');
const partial = renderSummary({ success: 12 });
expect(partial).toContain('aria-label="Success 12"');
expect(partial).toContain('aria-label="Failed —"');
expect(partial).toContain('aria-label="Total Requests: —"');
expect(partial).toContain('aria-label="Success rate —"');
});
it('shows zero requests without inventing a success percentage', () => {
const html = renderSummary({ success: 0, failed: 0 });
expect(html).toContain('aria-label="Total Requests: 0"');
expect(html).toContain('aria-label="Success rate —"');
});
});
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/auth-file-cooldown.tsx"></script></body></html>
+93
View File
@@ -0,0 +1,93 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
import '../../src/styles/index.css';
// Fictional metadata only. All management IPC is handled locally by this fixture.
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const startedAt = Date.now();
type MockFile = Record<string, unknown> & { name: string; cooldowns?: Record<string, unknown>[] | null };
type Request = { path: string; method: string; body?: Record<string, unknown> };
const timer = (seconds: number, model?: string, reason = 'quota', status = 429) => ({
scope: model ? 'model' : 'credential', reason, model_key: model,
retry_at: new Date(startedAt + seconds * 1000).toISOString(), remaining_seconds: seconds,
http_status: status, backoff_level: 0,
});
const files: MockFile[] = [
{ name: '01-primary.json', auth_index: 'auth-primary-123', status: 'error', unavailable: true,
status_message: 'quota exhausted', cooldowns: [timer(55), timer(55, 'gpt-fictional'), timer(180, 'claude-fictional', 'transient_error', 503)] },
{ name: '02-other.json', auth_index: 'auth-other-456', status: 'error',
cooldowns: [timer(45, 'model-with-a-deliberately-long-name-for-responsive-wrapping-verification', 'model_not_supported', 404)] },
{ name: '03-disabled-runtime.json', auth_index: 'auth-runtime-789', status: 'disabled', disabled: true,
runtime_only: true, cooldowns: [timer(120, 'runtime-model')] },
{ name: '04-expired-soon.json', auth_index: 'auth-expired-321', status: 'error', cooldowns: [timer(2, 'expired-model')] },
{ name: '05-empty.json', auth_index: 'auth-empty', status: 'active', cooldowns: [] },
{ name: '06-null.json', auth_index: 'auth-null', status: 'error', cooldowns: null },
{ name: '07-absent.json', auth_index: 'auth-absent', status: 'error' },
{ name: '08-no-index.json', status: 'error', cooldowns: [timer(120, 'missing-index-model')] },
];
const state = {
files,
reads: 0,
resetRequests: [] as Request[],
failReset: params.has('resetError'),
releaseReset: undefined as (() => void) | undefined,
holdNextRead: false,
releaseRead: undefined as (() => void) | undefined,
completedReads: 0,
};
(window as typeof window & { cooldownFixture: typeof state }).cooldownFixture = state;
mockIPC(async (cmd, args) => {
if (cmd === 'set_app_locale') return null;
if (cmd !== 'management_request') throw new Error(`Unhandled fixture command: ${cmd}`);
const request = args?.request as Request;
if (request.path === '/credentials' && request.method === 'GET') {
state.reads += 1;
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
const snapshot = {
observed_at: new Date().toISOString(),
files: state.files.map(file => ({
provider: 'codex', source: 'file', size: 1024, updated_at: '2026-10-01T00:00:00Z',
success: 20, failed: 2, ...file,
...(Array.isArray(file.cooldowns) ? { cooldowns: file.cooldowns.map(record => ({
...record, remaining_seconds: Math.max(1, Number(record.remaining_seconds) - elapsed),
})) } : {}),
})),
};
if (state.holdNextRead) {
state.holdNextRead = false;
await new Promise<void>(resolve => { state.releaseRead = resolve; });
state.releaseRead = undefined;
}
state.completedReads += 1;
return snapshot;
}
if (request.path === '/routing/cooldown/reset' && request.method === 'POST') {
state.resetRequests.push(structuredClone(request));
await new Promise<void>(resolve => { state.releaseReset = resolve; });
state.releaseReset = undefined;
if (state.failReset) throw new Error('Fixture cooldown reset failed');
const target = state.files.find(file => file.auth_index === request.body?.auth_index);
if (!target) throw new Error('Fixture reset requires one known auth_index');
target.cooldowns = [];
target.unavailable = false;
target.status_message = '';
if (!target.disabled) target.status = 'active';
return { status: 'ok', auth_index: target.auth_index, models: ['gpt-fictional', 'claude-fictional'] };
}
throw new Error(`Unhandled fixture request: ${request.method} ${request.path}`);
});
createRoot(document.getElementById('root')!).render(
<I18nProvider>
<div className="app-shell">
<aside className="sidebar" aria-hidden="true" />
<div className="workspace"><main className="content"><AuthFileManagementPage /></main></div>
</div>
</I18nProvider>,
);
+2
View File
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta name="viewport" content="width=device-width, initial-scale=1.0" /></head><body><div id="root"></div><script type="module" src="/tests/fixtures/auth-file-list.tsx"></script></body></html>
+89
View File
@@ -0,0 +1,89 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { mockIPC } from '@tauri-apps/api/mocks';
import { I18nProvider } from '../../src/i18n';
import { AuthFileManagementPage } from '../../src/pages/AuthFileManagementPage';
import { updateQuotaCache } from '../../src/services/quotaCache';
import { quotaKey, type QuotaState } from '../../src/services/quotaService';
import '../../src/styles/index.css';
// Fictional credentials and local IPC only; this fixture never connects to an upstream service.
const params = new URLSearchParams(location.search);
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-CN');
document.documentElement.dataset.theme = params.get('theme') || 'light';
const startedAt = Date.now();
type Request = { path: string; method: string; body?: Record<string, unknown>; query?: Record<string, string> };
const files: Record<string, unknown>[] = Array.from({ length: 12 }, (_, index) => ({
name: `${String(index + 1).padStart(2, '0')}-account.json`,
email: index === 0 ? 'long-fictional-account-for-layout@example.test' : `account-${index + 1}@example.test`,
auth_index: `fixture-account-${index + 1}`, provider: index === 3 ? 'claude' : index === 4 ? 'xai' : 'codex',
source: 'file', size: 1800 + index * 350, updated_at: '2026-10-01T00:00:00Z',
disabled: index === 2 || index === 6, status: index === 2 || index === 6 ? 'disabled' : 'active',
priority: index % 3, plan_type: index % 3 === 0 ? 'Pro 20x' : 'Team',
note: index === 0 ? 'Fictional account note, visible after opening credential details.' : '',
success: 1200 + index * 2450, failed: 3 + index * 97,
recent_requests: Array.from({ length: 20 }, (_, bucket) => ({
time: `${String(8 + Math.floor(bucket / 6)).padStart(2, '0')}:${String(bucket % 6 * 10).padStart(2, '0')}`,
success: bucket === 19 ? 0 : (bucket + index) % 7 + 1,
failed: bucket === 19 || (bucket + index) % 5 ? 0 : 2,
})),
cooldowns: index === 1 ? [{ scope: 'model', model_key: 'gpt-fictional-layout', reason: 'quota',
retry_at: new Date(startedAt + 180000).toISOString(), remaining_seconds: 180, http_status: 429, backoff_level: 0 }] : [],
}));
updateQuotaCache(Object.fromEntries(files.map((file, index) => {
const quota: QuotaState = {
status: 'success', plan: String(file.plan_type),
subscriptionActiveUntil: index === 0 ? '2026-11-01T00:00:00Z' : undefined,
resetCredits: index === 0 ? 2 : undefined,
resetCreditsApplicable: index === 0 ? 0 : undefined,
rows: index === 4 ? [{ label: 'Paid API account', remainingPercent: null, detail: 'Upstream does not report remaining quota.' }]
: [
{ label: '5h', remainingPercent: index === 1 ? 0 : index === 2 ? 8 : 72 - index * 3, resetAtMs: startedAt + 3 * 3600000 },
{ label: 'Weekly', remainingPercent: 83 - index * 5, resetAtMs: startedAt + 5 * 86400000 },
...(index === 3 ? [
{ label: 'Additional model A', remainingPercent: 34, resetAtMs: startedAt + 3600000 },
{ label: 'Additional model B', remainingPercent: null, detail: 'No quota limit supplied for this model.' },
] : []),
],
};
return [quotaKey(file), quota];
})));
const state = { files, requests: [] as Request[], unhandled: [] as string[], reads: 0 };
(window as typeof window & { authFileListFixture: typeof state }).authFileListFixture = state;
mockIPC(async (cmd, args) => {
if (cmd === 'set_app_locale') return null;
if (cmd !== 'management_request') {
state.unhandled.push(cmd);
throw new Error(`Unhandled fixture command: ${cmd}`);
}
const request = args?.request as Request;
state.requests.push(structuredClone(request));
if (request.path === '/credentials' && request.method === 'GET') {
state.reads += 1;
return { observed_at: new Date().toISOString(), files: structuredClone(state.files) };
}
if (request.path === '/credentials/status' && request.method === 'PATCH') {
const target = state.files.find(file => file.name === request.body?.name);
if (!target || typeof request.body?.disabled !== 'boolean') throw new Error('Fixture status update needs a known name and boolean');
target.disabled = request.body.disabled;
target.status = target.disabled ? 'disabled' : 'active';
return { status: 'ok' };
}
if (request.path === '/credentials' && request.method === 'DELETE') {
const index = state.files.findIndex(file => file.name === request.query?.name);
if (index < 0) throw new Error('Fixture deletion requires a known filename');
state.files.splice(index, 1);
return { status: 'ok' };
}
state.unhandled.push(`${request.method} ${request.path}`);
throw new Error(`Unhandled fixture request: ${request.method} ${request.path}`);
});
createRoot(document.getElementById('root')!).render(
<I18nProvider><div className="app-shell">
<aside className="sidebar" aria-hidden="true" />
<div className="workspace"><main className="content"><AuthFileManagementPage /></main></div>
</div></I18nProvider>,
);
+3 -3
View File
@@ -23,16 +23,16 @@ describe('简易模式、首页、配置与版本管理导航', () => {
test('内核运行后解锁其他功能页', () => {
expect(canOpenAppPage('config', true)).toBe(true);
expect(canOpenAppPage('agents', true)).toBe(true);
expect(canOpenAppPage('quota', true)).toBe(true);
});
});
describe('OAuth 子页面导航', () => {
test('认证文件和额度查询收纳在 OAuth 页面内', () => {
expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles', 'quota']);
test('OAuth 页面保留登录和认证文件,额度查询独立导航', () => {
expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles']);
expect(oauthSubpages.map((page) => page.labelKey)).toEqual([
'oauth.title',
'authFiles.title',
'quota.title',
]);
});
});
+1 -1
View File
@@ -58,7 +58,7 @@ describe('控件自身反馈', () => {
expect(attributes?.find((attribute) => attribute.name.getText(source) === 'className')?.initializer?.getText(source))
.toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`}");
expect(attributes?.find((attribute) => attribute.name.getText(source) === 'disabled')?.initializer?.getText(source))
.toBe('{busy || !isOAuthCredentialFile(file)}');
.toBe('{busy || resettingCooldown || !isOAuthCredentialFile(file)}');
const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8');
expect(styles).toMatch(/\.real-auth-file-row\.disabled\s*>\s*:not\(\.auth-file-actions\)\s*\{\s*opacity:\s*0\.68;/);
expect(styles).not.toMatch(/\.real-auth-file-row\.disabled\s*\{[^}]*opacity:/);
+6 -2
View File
@@ -189,12 +189,16 @@ const viewports = [
await assertReachable(page.locator('.provider-row-actions button').first(), `${label} provider action`);
await open('OAuth', '.oauth-subpage-tabs');
assert.equal(await page.locator('#oauth-subpage-tab-quota').count(), 0, `${label}: quota has a separate sidebar entry`);
await tab('oauth-subpage-tab-login', '.oauth-card');
await assertNoPageOverflow(`${label} OAuth login`);
await tab('oauth-subpage-tab-authFiles', '.auth-file-card');
await assertNoPageOverflow(`${label} OAuth credentials`);
await tab('oauth-subpage-tab-quota', '.real-quota-card');
await assertNoPageOverflow(`${label} OAuth quota`);
await open('额度查询', '.quota-page .real-quota-card');
assert.equal(await page.locator('.nav-section button.active').innerText(), '额度查询', `${label}: quota activates its sidebar entry`);
assert.equal(await page.locator('.quota-page h1').innerText(), '额度查询', `${label}: standalone quota page has its own heading`);
assert.equal(await page.locator('.oauth-subpage-tabs').count(), 0, `${label}: standalone quota page does not contain OAuth navigation`);
await assertNoPageOverflow(`${label} quota`);
await open('智能体配置', '.agent-config-panel');
await page.waitForFunction(() => !document.querySelector('.agent-client-list-heading button')?.disabled);