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:
1 parent
d29a1af69b
commit
cc24f8419f
35 files changed
+1504
-116
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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': '累計使用量',
|
||||
};
|
||||
@@ -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': '認証情報を更新中',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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,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'];
|
||||
@@ -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; } }
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>,
|
||||
);
|
||||
Vendored
+2
@@ -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>
|
||||
Vendored
+89
@@ -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>,
|
||||
);
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user