feat: enhance credential management with cards, settings, and quota controls
- Display credentials in responsive cards with quota progress bars - Add request statistics and detailed health and cooldown information - Consolidate nine credential settings into a dialog with partial updates - Reuse Codex quota reset logic and confirmation across pages - Move notes above card actions and hide the healthy status indicator - Add translations and regression coverage
This commit is contained in:
1 parent
bc8b34f9f1
commit
fe83731b94
24 files changed
+1973
-209
No files matched your search
@@ -0,0 +1,91 @@
|
||||
.auth-file-health {
|
||||
--health-accent: var(--text-tertiary);
|
||||
grid-column: 2 / -1;
|
||||
min-width: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-label);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.auth-file-health.success { --health-accent: var(--success-color); }
|
||||
.auth-file-health.warning { --health-accent: var(--amber-text); }
|
||||
.auth-file-health.error { --health-accent: var(--failure-badge-text); }
|
||||
.auth-file-health.info { --health-accent: var(--primary-color); }
|
||||
|
||||
.auth-file-health.detailed {
|
||||
border: 1px solid var(--border-color);
|
||||
border-left: 3px solid var(--health-accent);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--health-accent) 4%, var(--bg-card));
|
||||
}
|
||||
|
||||
.auth-health-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
min-width: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.auth-file-health.detailed .auth-health-summary {
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-health-summary::-webkit-details-marker { display: none; }
|
||||
.auth-health-summary:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.auth-health-icon { flex-shrink: 0; color: var(--health-accent); }
|
||||
.auth-health-summary-content { flex: 1; min-width: 0; }
|
||||
.auth-health-heading,
|
||||
.auth-health-record-heading,
|
||||
.auth-health-record-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px 12px;
|
||||
}
|
||||
.auth-health-heading strong { color: var(--health-accent); font-weight: 600; }
|
||||
.auth-health-heading > span { color: var(--text-secondary); }
|
||||
.auth-health-message-preview {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.auth-health-countdown,
|
||||
.auth-health-record-heading > span {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.auth-health-disclosure {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-meta);
|
||||
}
|
||||
.auth-file-health details[open] .auth-health-disclosure svg { transform: rotate(180deg); }
|
||||
.auth-health-body { padding: 0 12px 10px; }
|
||||
.auth-health-core-state { margin: 0 0 8px; }
|
||||
.auth-health-core-message > span { color: var(--text-tertiary); font-size: var(--font-size-meta); }
|
||||
.auth-health-core-message p { margin: 4px 0 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.auth-health-records { margin: 0; padding: 0; list-style: none; }
|
||||
.auth-health-records li { padding: 9px 0; border-top: 1px solid var(--border-color); }
|
||||
.auth-health-record-heading { justify-content: space-between; }
|
||||
.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; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.auth-health-summary-content { flex-basis: calc(100% - 30px); }
|
||||
.auth-health-countdown { margin-left: 26px; }
|
||||
.auth-health-disclosure { margin-left: auto; }
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
import { ChevronDown, CircleHelp, Clock3, Info, PauseCircle, ShieldAlert } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import {
|
||||
authFileHealth,
|
||||
cooldownReasonKey,
|
||||
normalizeAuthFileCooldowns,
|
||||
summarizeAuthFileCooldowns,
|
||||
type AuthFileCooldownSnapshot,
|
||||
} from '../services/authFileHealth';
|
||||
import './AuthFileHealthStatus.css';
|
||||
|
||||
let clockNow = Date.now();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
const getClockSnapshot = () => clockNow;
|
||||
const subscribeClock = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
if (!timer) {
|
||||
clockNow = Date.now();
|
||||
timer = setInterval(() => {
|
||||
clockNow = Date.now();
|
||||
listeners.forEach((notify) => notify());
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
type HealthProps = {
|
||||
file: Record<string, unknown>;
|
||||
snapshot?: AuthFileCooldownSnapshot;
|
||||
};
|
||||
|
||||
export function AuthFileHealthStatus({ file, receivedAtMs, observedAt }: {
|
||||
file: Record<string, unknown>;
|
||||
receivedAtMs: number;
|
||||
observedAt?: string;
|
||||
}) {
|
||||
const snapshot = useMemo(
|
||||
() => normalizeAuthFileCooldowns(file.cooldowns, receivedAtMs, observedAt),
|
||||
[file, receivedAtMs, observedAt],
|
||||
);
|
||||
return snapshot?.records?.length
|
||||
? <TimedHealthStatus file={file} snapshot={snapshot} />
|
||||
: <HealthStatus file={file} snapshot={snapshot} nowMs={receivedAtMs} />;
|
||||
}
|
||||
|
||||
function TimedHealthStatus(props: HealthProps) {
|
||||
const nowMs = useSyncExternalStore(subscribeClock, getClockSnapshot, getClockSnapshot);
|
||||
return <HealthStatus {...props} nowMs={nowMs} />;
|
||||
}
|
||||
|
||||
function HealthStatus({ file, snapshot, nowMs }: HealthProps & { nowMs: number }) {
|
||||
const { t, formatNumber, formatDate } = useI18n();
|
||||
const health = authFileHealth(file);
|
||||
const cooldown = summarizeAuthFileCooldowns(snapshot, nowMs);
|
||||
const hasTimers = cooldown.rows.length > 0;
|
||||
const healthy = health.label === 'authFiles.health.active';
|
||||
if (healthy && !hasTimers && snapshot?.records !== null) return null;
|
||||
const hasDetails = hasTimers || Boolean(health.message);
|
||||
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'
|
||||
|| health.label === 'authFiles.health.reason.invalidGrant'
|
||||
|| health.label === 'authFiles.health.reason.unauthorized'
|
||||
|| health.label === 'authFiles.health.reason.tokenExpired'
|
||||
);
|
||||
const Icon = health.disabled ? PauseCircle : hasTimers ? Clock3
|
||||
: tone === 'error' ? ShieldAlert
|
||||
: tone === 'warning' || tone === 'info' ? Info : CircleHelp;
|
||||
const duration = (seconds: number) => seconds < 60
|
||||
? t('authFiles.health.seconds', { count: formatNumber(seconds) })
|
||||
: seconds < 3600 ? t('authFiles.health.minutes', { count: formatNumber(Math.ceil(seconds / 60)) })
|
||||
: t('authFiles.health.hours', { count: formatNumber(Math.ceil(seconds / 3600)) });
|
||||
const scope = cooldown.credentialWide
|
||||
? cooldown.modelCount ? t('authFiles.health.credentialModels', { count: cooldown.modelCount })
|
||||
: t('authFiles.health.credential')
|
||||
: cooldown.modelCount ? t('authFiles.health.models', { count: cooldown.modelCount }) : '';
|
||||
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>
|
||||
{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}
|
||||
{cooldown.elapsed ? <span className="auth-health-hint">{t('authFiles.health.expiredHint')}</span> : null}
|
||||
{snapshot?.records === null && !healthy ? <span className="auth-health-hint">{t('authFiles.health.unknownCooldown')}</span> : null}
|
||||
{snapshot === undefined && health.tone === 'error' ? <span className="auth-health-hint">{t('authFiles.health.missingCooldown')}</span> : null}
|
||||
</span>
|
||||
{cooldown.active.length > 0 ? <span className="auth-health-countdown">
|
||||
{t('authFiles.health.earliest', { time: duration(cooldown.earliestSeconds) })}
|
||||
</span> : null}
|
||||
{hasDetails ? <span className="auth-health-disclosure"><span>{t('authFiles.health.details')}</span><ChevronDown size={14} aria-hidden="true" /></span> : null}
|
||||
</>;
|
||||
|
||||
return (
|
||||
<section className={`auth-file-health ${tone}${hasDetails ? ' detailed' : ''}`} aria-label={t('authFiles.health.title')}>
|
||||
{hasDetails ? <details>
|
||||
<summary className="auth-health-summary">{summary}</summary>
|
||||
<div className="auth-health-body">
|
||||
{hasTimers && !healthy ? <p className="auth-health-core-state">{t('authFiles.health.coreStatus', { status: t(health.label) })}</p> : null}
|
||||
{health.message ? <div className="auth-health-core-message">
|
||||
<span>{t('authFiles.health.coreMessage')}</span><p>{health.message}</p>
|
||||
</div> : null}
|
||||
<ul className="auth-health-records">
|
||||
{cooldown.rows.map(({ record, remainingSeconds }, index) => (
|
||||
<li key={`${record.scope}:${record.model ?? ''}:${index}`}>
|
||||
<div className="auth-health-record-heading">
|
||||
<strong>{record.scope === 'credential' ? t('authFiles.health.credentialScope') : record.model}</strong>
|
||||
<span>{remainingSeconds > 0 ? t('authFiles.health.remaining', { time: duration(remainingSeconds) }) : t('authFiles.health.waiting')}</span>
|
||||
</div>
|
||||
<div className="auth-health-record-meta">
|
||||
<span>{t(cooldownReasonKey(record.reason))}</span>
|
||||
{record.httpStatus !== undefined ? <span>HTTP {record.httpStatus}</span> : null}
|
||||
{record.backoffLevel !== undefined ? <span title={t('authFiles.health.backoffHint')}>{t('authFiles.health.backoff', { level: record.backoffLevel })}</span> : null}
|
||||
</div>
|
||||
<div className="auth-health-hint">{t('authFiles.health.deadline')} <time dateTime={record.retryAt}>{formatTimestamp(record.retryAt)}</time></div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{snapshot?.observedAt ? <p className="auth-health-hint">{t('authFiles.health.observed')} <time dateTime={snapshot.observedAt}>{formatTimestamp(snapshot.observedAt)}</time></p> : null}
|
||||
{hasTimers ? <p className="auth-health-hint">{t('authFiles.health.note')}</p> : null}
|
||||
</div>
|
||||
</details> : <div className="auth-health-summary">{summary}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { LoaderCircle, RefreshCw } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
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';
|
||||
|
||||
export function AuthFileQuotaPanel({ quota, file, disabled, onRefresh, onReset }: {
|
||||
quota: QuotaState;
|
||||
file: AuthFile;
|
||||
disabled: boolean;
|
||||
onRefresh: () => void;
|
||||
onReset?: () => void;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
|
||||
const loading = quota.status === 'loading';
|
||||
const name = fileName(file);
|
||||
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}
|
||||
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>
|
||||
{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>}
|
||||
<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}
|
||||
{quota.subscriptionActiveUntil ? <small>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</small> : null}
|
||||
{quota.resetCreditsEarliestExpiry ? <small>{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</small> : null}
|
||||
</div>
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
</> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
.auth-file-requests {
|
||||
grid-column: 2 / -1;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--font-size-meta);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.auth-file-requests-heading,
|
||||
.auth-file-requests-counts,
|
||||
.auth-file-requests-timeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 12px;
|
||||
}
|
||||
|
||||
.auth-file-requests-heading > span:first-child {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-file-requests-counts {
|
||||
gap: 5px 10px;
|
||||
}
|
||||
|
||||
.auth-file-requests-window {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.auth-file-requests-chart {
|
||||
position: relative;
|
||||
flex: 1 1 160px;
|
||||
max-width: 440px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-file-requests-blocks {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.auth-file-request-block {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: var(--border-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-file-request-block.active,
|
||||
.auth-file-request-block:focus-visible {
|
||||
outline: 1px solid var(--text-secondary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.auth-file-requests-rate {
|
||||
min-width: 42px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-file-requests .success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.auth-file-requests .failure {
|
||||
color: var(--failure-badge-text);
|
||||
}
|
||||
|
||||
.auth-file-requests .mixed {
|
||||
color: var(--amber-text);
|
||||
}
|
||||
|
||||
.auth-file-requests-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 5px);
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 4px 14px #0002;
|
||||
pointer-events: none;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.auth-file-requests-tooltip.start {
|
||||
left: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.auth-file-requests-tooltip.end {
|
||||
right: 0;
|
||||
left: auto;
|
||||
transform: none;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useId, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { authFileRequestStats, requestRateColor } from '../services/authFileRequests';
|
||||
import './AuthFileRequestStatus.css';
|
||||
|
||||
export function AuthFileRequestStatus({ file }: { file: Record<string, unknown> }) {
|
||||
const { t, formatNumber } = useI18n();
|
||||
const stats = useMemo(() => authFileRequestStats(file), [file]);
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
const tooltipId = useId();
|
||||
const rateText = (rate: number | null) => rate === null ? '—' : formatNumber(rate, {
|
||||
style: 'percent', maximumFractionDigits: 1,
|
||||
});
|
||||
const countText = (count: number | null) => count === null ? '—' : formatNumber(count);
|
||||
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 bucketLabel = (index: number) => {
|
||||
const bucket = stats.buckets[index];
|
||||
const time = bucket.time || t('authFiles.requests.interval', { index: index + 1 });
|
||||
return bucket.rate === null
|
||||
? `${time} · ${t('authFiles.requests.empty')}`
|
||||
: `${time} · ${t('authFiles.requests.success', { count: formatNumber(bucket.success) })} · ${t('authFiles.requests.failure', { count: formatNumber(bucket.failure) })} · ${t('authFiles.requests.rate', { rate: rateText(bucket.rate) })}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-file-requests" role="group" aria-label={t('authFiles.requests.title')}>
|
||||
<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>
|
||||
{stats.recentAvailable ? (
|
||||
<div className="auth-file-requests-timeline">
|
||||
<div className="auth-file-requests-chart">
|
||||
<div className="auth-file-requests-blocks" onMouseLeave={() => setActive(null)}>
|
||||
{stats.buckets.map((bucket, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={`auth-file-request-block${active === index ? ' active' : ''}`}
|
||||
style={bucket.rate === null ? undefined : { backgroundColor: requestRateColor(bucket.rate) }}
|
||||
aria-label={bucketLabel(index)}
|
||||
aria-describedby={active === index ? tooltipId : undefined}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onFocus={() => setActive(index)}
|
||||
onBlur={() => setActive(null)}
|
||||
onClick={() => setActive(index)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setActive(null);
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
const next = event.key === 'ArrowLeft'
|
||||
? event.currentTarget.previousElementSibling : event.currentTarget.nextElementSibling;
|
||||
if (next instanceof HTMLButtonElement) next.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{detail && active !== null ? (
|
||||
<div className={`auth-file-requests-tooltip ${active < 7 ? 'start' : active > 12 ? 'end' : ''}`} id={tooltipId} role="tooltip">
|
||||
<strong>{detail.time || t('authFiles.requests.interval', { index: active + 1 })}</strong>
|
||||
{detail.rate === null ? <span>{t('authFiles.requests.empty')}</span> : <>
|
||||
<span className="auth-file-requests-counts">
|
||||
<span className="success">{t('authFiles.requests.success', { count: formatNumber(detail.success) })}</span>
|
||||
<span className="failure">{t('authFiles.requests.failure', { count: formatNumber(detail.failure) })}</span>
|
||||
</span>
|
||||
<span>{t('authFiles.requests.rate', { rate: rateText(detail.rate) })}</span>
|
||||
</>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className={`auth-file-requests-rate ${rateClass}`} title={t('authFiles.requests.recentRate')}>
|
||||
{rateText(stats.recentRate)}
|
||||
</span>
|
||||
{stats.recentRate === null ? <span>{t('authFiles.requests.empty')}</span> : null}
|
||||
</div>
|
||||
) : <span className="auth-file-requests-unavailable">{t('authFiles.requests.unavailable')}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.credential-settings-dialog {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
max-height: min(900px, calc(100dvh - 40px));
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 16px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
box-shadow: 0 24px 80px #0004;
|
||||
}
|
||||
.credential-settings-dialog::backdrop { background: #0b122080; backdrop-filter: blur(3px); }
|
||||
.credential-settings-dialog form { display: flex; flex-direction: column; max-height: min(898px, calc(100dvh - 42px)); min-height: 0; }
|
||||
.credential-settings-heading, .credential-settings-footer { flex-shrink: 0; }
|
||||
.credential-settings-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding: 20px 24px; border-bottom: 1px solid var(--border-color); }
|
||||
.credential-settings-heading > div { min-width: 0; }
|
||||
.credential-settings-heading h2 { display: flex; align-items: center; gap: 9px; margin: 0; font-size: 18px; }
|
||||
.credential-settings-heading p { margin: 6px 0 0; color: var(--text-tertiary); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.credential-settings-body { overflow-y: auto; min-height: 0; padding: 22px 24px; overscroll-behavior: contain; }
|
||||
.credential-settings-body fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
|
||||
.credential-settings-section + .credential-settings-section { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--border-color); }
|
||||
.credential-settings-section h3 { margin: 0 0 15px; font-size: 14px; }
|
||||
.credential-settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px 20px; }
|
||||
.credential-settings-field { display: grid; align-content: start; gap: 7px; min-width: 0; font-size: 13px; }
|
||||
.credential-settings-field + .credential-settings-field { margin-top: 16px; }
|
||||
.credential-settings-grid .credential-settings-field { margin: 0; }
|
||||
.credential-settings-dialog code { font-family: ui-monospace, monospace; font-size: 11px; font-weight: 400; color: var(--text-tertiary); margin-left: 5px; }
|
||||
.credential-settings-dialog small { display: block; color: var(--text-tertiary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
|
||||
.credential-settings-dialog input:not([type=checkbox]), .credential-settings-dialog select, .credential-settings-dialog textarea {
|
||||
box-sizing: border-box; width: 100%; min-width: 0; padding: 9px 11px; border: 1px solid var(--border-color); border-radius: 7px; background: var(--bg-primary); color: var(--text-primary); font: inherit; line-height: 1.5;
|
||||
}
|
||||
.credential-settings-dialog textarea { resize: vertical; }
|
||||
.credential-settings-dialog textarea.credential-settings-json { font-family: ui-monospace, monospace; font-size: 12px; }
|
||||
.credential-settings-dialog :is(input, textarea, select, summary):focus-visible { outline: 2px solid var(--primary-color); outline-offset: 2px; }
|
||||
.credential-settings-catalog { margin-top: 12px; padding: 11px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 12px; }
|
||||
.credential-settings-catalog summary { cursor: pointer; color: var(--text-secondary); }
|
||||
.credential-settings-catalog[open] summary { margin-bottom: 12px; }
|
||||
.credential-settings-models { display: grid; gap: 8px; max-height: 210px; overflow-y: auto; margin-top: 12px; }
|
||||
.credential-settings-models label { display: flex; align-items: flex-start; gap: 8px; overflow-wrap: anywhere; }
|
||||
.credential-settings-models input { accent-color: var(--primary-color); margin-top: 3px; }
|
||||
.credential-settings-models span { min-width: 0; }
|
||||
.credential-settings-footer { padding: 16px 24px; border-top: 1px solid var(--border-color); }
|
||||
.credential-settings-actions { display: flex; gap: 10px; justify-content: flex-end; }
|
||||
.credential-settings-error { color: var(--failure-badge-text); margin: 0 0 12px; font-size: 12px; overflow-wrap: anywhere; max-height: 90px; overflow-y: auto; }
|
||||
.credential-settings-warning { color: var(--amber-text); }
|
||||
.credential-settings-discard { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; font-size: 12px; }
|
||||
.credential-settings-discard span { flex: 1 1 100%; }
|
||||
.credential-settings-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 150px; }
|
||||
@media (max-width: 560px) {
|
||||
.credential-settings-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.credential-settings-heading, .credential-settings-body, .credential-settings-footer { padding: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, LoaderCircle, Settings2, X } from 'lucide-react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { managementApi } from '../services/managementApi';
|
||||
import { loadAuthFileSettings, saveAuthFileSettings, type AuthFileSettingsDraft, type BooleanOverride } from '../services/authFileSettings';
|
||||
import { modelMatchesRule, normalizeOAuthExcludedRules, oauthModelCandidates, oauthModelsFromPayload, setOAuthModelsExcluded, type OAuthModelDefinition } from '../services/oauthModels';
|
||||
import './AuthFileSettingsDialog.css';
|
||||
|
||||
export function AuthFileSettingsDialog({ name, onClose, onSaved }: {
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const id = useId();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const savingRef = useRef(false);
|
||||
const [original, setOriginal] = useState<AuthFileSettingsDraft | null>(null);
|
||||
const [draft, setDraft] = useState<AuthFileSettingsDraft | null>(null);
|
||||
const [models, setModels] = useState<OAuthModelDefinition[]>([]);
|
||||
const [catalogError, setCatalogError] = useState('');
|
||||
const [catalogLoading, setCatalogLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [discard, setDiscard] = useState(false);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const dirty = draft !== null && JSON.stringify(draft) !== JSON.stringify(original);
|
||||
|
||||
useEffect(() => {
|
||||
const previousFocus = document.activeElement;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const dialog = dialogRef.current;
|
||||
dialog?.showModal();
|
||||
return () => {
|
||||
dialog?.close();
|
||||
document.body.style.overflow = previousOverflow;
|
||||
if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setCatalogLoading(true);
|
||||
setCatalogError('');
|
||||
void loadAuthFileSettings(name).then((value) => {
|
||||
if (active) { setOriginal(value); setDraft(value); }
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setError(reason instanceof Error ? reason.message : String(reason));
|
||||
}).finally(() => { if (active) setLoading(false); });
|
||||
void managementApi.get('/auth-files/models', { name }).then((payload) => {
|
||||
if (active) setModels(oauthModelsFromPayload(payload));
|
||||
}).catch((reason: unknown) => {
|
||||
if (active) setCatalogError(reason instanceof Error ? reason.message : String(reason));
|
||||
}).finally(() => { if (active) setCatalogLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [name, attempt]);
|
||||
|
||||
const close = () => {
|
||||
if (savingRef.current) return;
|
||||
if (dirty) setDiscard(true);
|
||||
else onClose();
|
||||
};
|
||||
const update = <K extends keyof AuthFileSettingsDraft>(key: K, value: AuthFileSettingsDraft[K]) => {
|
||||
setDraft((current) => current ? { ...current, [key]: value } : current);
|
||||
setError('');
|
||||
setDiscard(false);
|
||||
};
|
||||
const save = async () => {
|
||||
if (!draft || !original || savingRef.current) return;
|
||||
savingRef.current = true;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setDiscard(false);
|
||||
try {
|
||||
const changed = await saveAuthFileSettings(name, original, draft);
|
||||
if (changed) onSaved();
|
||||
else onClose();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
} finally {
|
||||
savingRef.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const rules = normalizeOAuthExcludedRules((draft?.excluded_models ?? '').split(/\r?\n/));
|
||||
const candidates = oauthModelCandidates(models, rules).filter((model) =>
|
||||
`${model.id} ${model.displayName ?? ''}`.toLowerCase().includes(search.trim().toLowerCase()));
|
||||
const textField = (key: 'prefix' | 'proxy_url' | 'priority' | 'weight') => (
|
||||
<label className="credential-settings-field">
|
||||
<span>{t(`authFiles.settings.${key}`)} <code>{key}</code></span>
|
||||
<input value={draft?.[key] ?? ''} onChange={(event) => update(key, event.currentTarget.value)}
|
||||
inputMode={key === 'priority' || key === 'weight' ? 'numeric' : undefined}
|
||||
placeholder={key === 'priority' ? '0' : key === 'weight' ? '1' : key === 'proxy_url' ? 'socks5://127.0.0.1:1080' : ''}
|
||||
autoComplete="off" spellCheck={false} />
|
||||
<small>{t(`authFiles.settings.${key}Hint`)}</small>
|
||||
</label>
|
||||
);
|
||||
const booleanField = (key: 'disable_cooling' | 'websockets') => (
|
||||
<label className="credential-settings-field">
|
||||
<span>{t(`authFiles.settings.${key}`)} <code>{key}</code></span>
|
||||
<select value={draft?.[key] ?? ''} onChange={(event) => update(key, event.currentTarget.value as BooleanOverride)}>
|
||||
<option value="">{t('authFiles.settings.inherit')}</option>
|
||||
<option value="true">{t('common.enable')}</option>
|
||||
<option value="false">{t('common.disable')}</option>
|
||||
</select>
|
||||
<small>{t(`authFiles.settings.${key}Hint`)}</small>
|
||||
</label>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<dialog ref={dialogRef} className="credential-settings-dialog" aria-labelledby={`${id}-title`}
|
||||
onCancel={(event) => { event.preventDefault(); close(); }} onKeyDown={(event) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
const controls = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('button, input, select, textarea, summary'))
|
||||
.filter((element) => !element.matches(':disabled') && element.getClientRects().length > 0);
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}}>
|
||||
<form onSubmit={(event) => { event.preventDefault(); void save(); }}>
|
||||
<header className="credential-settings-heading">
|
||||
<div><h2 id={`${id}-title`}><Settings2 size={20} />{t('authFiles.settings.title')}</h2><p>{name}</p></div>
|
||||
<button type="button" className="icon-button quiet" disabled={saving} onClick={close} aria-label={t('common.close')}><X size={18} /></button>
|
||||
</header>
|
||||
<div className="credential-settings-body" aria-busy={loading}>
|
||||
{loading ? <div className="credential-settings-loading"><LoaderCircle size={20} className="spin" />{t('common.loading')}</div> : draft ? (
|
||||
<fieldset disabled={saving}>
|
||||
<section className="credential-settings-section">
|
||||
<h3>{t('authFiles.settings.routing')}</h3>
|
||||
<div className="credential-settings-grid">{textField('prefix')}{textField('proxy_url')}{textField('priority')}{textField('weight')}{booleanField('disable_cooling')}{booleanField('websockets')}</div>
|
||||
</section>
|
||||
<section className="credential-settings-section">
|
||||
<h3>{t('authFiles.settings.excluded_models')} <code>excluded_models</code></h3>
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.rules')}</span>
|
||||
<textarea rows={3} value={draft.excluded_models} onChange={(event) => update('excluded_models', event.currentTarget.value)} spellCheck={false} placeholder={'gpt-example\nclaude-*'} />
|
||||
<small>{t('authFiles.settings.excludedHint')}</small>
|
||||
</label>
|
||||
<details className="credential-settings-catalog">
|
||||
<summary>{t('authFiles.settings.catalog', { count: models.length })}</summary>
|
||||
{catalogLoading ? <p role="status">{t('common.loading')}</p> : null}
|
||||
{catalogError ? <p className="credential-settings-warning">{t('authFiles.settings.catalogError')}<small>{catalogError}</small></p> : null}
|
||||
<input aria-label={t('authFiles.settings.searchModels')} placeholder={t('authFiles.settings.searchModels')} value={search} onChange={(event) => setSearch(event.currentTarget.value)} />
|
||||
<div className="credential-settings-models">
|
||||
{candidates.map((model) => {
|
||||
const wildcard = rules.some((rule) => rule.includes('*') && modelMatchesRule(model.id, rule));
|
||||
return <label key={model.id} title={wildcard ? t('authFiles.settings.wildcard') : model.displayName}>
|
||||
<input type="checkbox" checked={rules.some((rule) => modelMatchesRule(model.id, rule))} disabled={wildcard}
|
||||
onChange={(event) => update('excluded_models', setOAuthModelsExcluded(rules, [model], event.currentTarget.checked).join('\n'))} />
|
||||
<span>{model.id}{wildcard ? <small>{t('authFiles.settings.wildcard')}</small> : null}</span>
|
||||
</label>;
|
||||
})}
|
||||
{!catalogLoading && !candidates.length ? <p>{t('authFiles.settings.noModels')}</p> : null}
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
<section className="credential-settings-section">
|
||||
<h3>{t('authFiles.settings.additional')}</h3>
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.headers')} <code>headers</code></span>
|
||||
<textarea className="credential-settings-json" rows={5} value={draft.headers} onChange={(event) => update('headers', event.currentTarget.value)} spellCheck={false} autoComplete="off" />
|
||||
<small>{t('authFiles.settings.headersHint')}</small>
|
||||
</label>
|
||||
<label className="credential-settings-field">
|
||||
<span>{t('authFiles.settings.note')} <code>note</code></span>
|
||||
<textarea rows={2} value={draft.note} onChange={(event) => update('note', event.currentTarget.value)} />
|
||||
</label>
|
||||
</section>
|
||||
</fieldset>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="credential-settings-footer">
|
||||
{error ? <p className="credential-settings-error" role="alert">{error}</p> : null}
|
||||
{discard ? <div className="credential-settings-discard" role="alert"><span>{t('authFiles.settings.unsaved')}</span><button type="button" className="secondary-button compact-button" onClick={() => setDiscard(false)}>{t('authFiles.settings.keepEditing')}</button><button type="button" className="danger-button compact-button" onClick={onClose}>{t('authFiles.settings.discard')}</button></div> : null}
|
||||
<div className="credential-settings-actions">
|
||||
<button type="button" className="secondary-button" disabled={saving} onClick={close}>{t('common.cancel')}</button>
|
||||
{!loading && !draft ? <button type="button" className="primary-button" onClick={() => setAttempt((value) => value + 1)}>{t('common.refresh')}</button> : <button type="submit" className="primary-button" disabled={!draft || loading || saving || !dirty}>{saving ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{t(saving ? 'common.saving' : 'common.save')}</button>}
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
return typeof document === 'undefined' ? content : createPortal(content, document.body);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import { resetCodexQuotaWithConfirmation } from '../services/quotaActions';
|
||||
import { fileName, formatQuotaTimestamp, type AuthFile, type QuotaState } from '../services/quotaService';
|
||||
import type { ConfirmationOptions } from './ConfirmationDialog';
|
||||
|
||||
export function useCodexQuotaReset(
|
||||
askConfirmation: (options: ConfirmationOptions) => Promise<boolean>,
|
||||
setError: (message: string) => void,
|
||||
) {
|
||||
const { locale, t } = useI18n();
|
||||
return useCallback(async (file: AuthFile, quota: QuotaState) => {
|
||||
setError('');
|
||||
try {
|
||||
await resetCodexQuotaWithConfirmation(file, () => askConfirmation({
|
||||
title: t('quota.reset'),
|
||||
message: t('quota.confirm.title', { name: fileName(file) }),
|
||||
confirmText: t('quota.confirm.button'),
|
||||
details: [
|
||||
{ label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') },
|
||||
{ label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) },
|
||||
],
|
||||
warning: t('quota.confirm.warning'),
|
||||
}));
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
}
|
||||
}, [askConfirmation, locale, setError, t]);
|
||||
}
|
||||
@@ -3,6 +3,96 @@ import { easyModeJa } from './easyMode';
|
||||
|
||||
export const jaOverrides = {
|
||||
...easyModeJa,
|
||||
'authFiles.settings.noModels': '該当するモデルはありません。上の欄で除外ルールを入力できます。',
|
||||
'authFiles.settings.button': "設定",
|
||||
'authFiles.settings.title': "認証情報の設定",
|
||||
'authFiles.settings.updated': "{name} の設定を保存しました",
|
||||
'authFiles.settings.routing': "ルーティングと接続",
|
||||
'authFiles.settings.additional': "ヘッダーとメモ",
|
||||
'authFiles.settings.prefix': "プレフィックス",
|
||||
'authFiles.settings.prefixHint': "モデルのルーティング用。空欄で未設定。",
|
||||
'authFiles.settings.proxy_url': "プロキシ URL",
|
||||
'authFiles.settings.proxy_urlHint': "この認証情報専用のプロキシ。空欄で既定の設定を使用。",
|
||||
'authFiles.settings.priority': "優先度",
|
||||
'authFiles.settings.priorityHint': "整数。大きいほど優先。空欄は 0。",
|
||||
'authFiles.settings.weight': "振り分けの重み",
|
||||
'authFiles.settings.weightHint': "同じ優先度内の相対的な重み。最大 1000000。空欄は既定値 1、0 以下は 0 として加重振り分けから除外。",
|
||||
'authFiles.settings.disable_cooling': "指数バックオフの無効化",
|
||||
'authFiles.settings.disable_coolingHint': "有効にすると、この認証情報の指数バックオフを無効化。既定は全体設定に従います。",
|
||||
'authFiles.settings.websockets': "WebSockets",
|
||||
'authFiles.settings.websocketsHint': "この認証情報の WebSocket 対応(Codex / xAI)。接続にはバックエンドと上流の対応も必要です。",
|
||||
'authFiles.settings.inherit': "既定(上書きなし)",
|
||||
'authFiles.settings.excluded_models': "除外モデル",
|
||||
'authFiles.settings.rules': "除外ルール",
|
||||
'authFiles.settings.excludedHint': "1 行に 1 つのモデル ID。* ワイルドカード対応。空欄で個別の除外を解除。全体の除外設定は引き続き適用。",
|
||||
'authFiles.settings.catalog': "モデル一覧から除外を選択({count})",
|
||||
'authFiles.settings.catalogError': "モデル一覧を読み込めません。ルールは手動で編集できます。",
|
||||
'authFiles.settings.searchModels': "モデルを検索",
|
||||
'authFiles.settings.wildcard': "ワイルドカードで除外済み。上のルールを変更してください。",
|
||||
'authFiles.settings.headers': "カスタムヘッダー",
|
||||
'authFiles.settings.headersHint': "キーと値が文字列の JSON オブジェクト。キーの削除、または {} でヘッダーを解除。",
|
||||
'authFiles.settings.note': "メモ",
|
||||
'authFiles.settings.unsaved': "未保存の変更を破棄しますか?",
|
||||
'authFiles.settings.keepEditing': "編集を続ける",
|
||||
'authFiles.settings.discard': "変更を破棄",
|
||||
'authFiles.settings.invalid.metadata': "設定を読み込めません。有効な JSON オブジェクトではありません。",
|
||||
'authFiles.settings.invalid.headers': "ヘッダーは JSON オブジェクトで、有効かつ重複しない名前と改行のない文字列値が必要です。",
|
||||
'authFiles.settings.invalid.priority': "優先度は有効な整数で指定してください。",
|
||||
'authFiles.settings.invalid.weight': "重みは 1000000 以下の整数で指定してください。",
|
||||
'authFiles.settings.runtime': "実行時の認証情報は設定を保存できません。",
|
||||
'authFiles.settings.quotaRemaining': "残りのクォータ",
|
||||
'authFiles.settings.quotaUnknown': "残量不明",
|
||||
'authFiles.settings.quotaIdle': "クォータ未取得",
|
||||
'authFiles.health.title': '認証情報の状態',
|
||||
'authFiles.health.active': '利用可能',
|
||||
'authFiles.health.error': 'リクエストエラー',
|
||||
'authFiles.health.refreshing': '認証情報を更新中',
|
||||
'authFiles.health.pending': '認証待ち',
|
||||
'authFiles.health.warning': '状態に関する通知',
|
||||
'authFiles.health.unknown': '状態不明',
|
||||
'authFiles.health.elapsed': 'クールダウン時間が経過',
|
||||
'authFiles.health.unknownCooldown': '有効な詳細情報がコアから提供されていないため、クールダウン状態は不明です。',
|
||||
'authFiles.health.missingCooldown': 'コアからクールダウンの詳細が提供されていないため、復旧時刻は不明です。',
|
||||
'authFiles.health.credential': '認証情報全体のクールダウン',
|
||||
'authFiles.health.models': '{count} 個のモデルがクールダウン中',
|
||||
'authFiles.health.credentialModels': '認証情報および {count} 個のモデルがクールダウン中',
|
||||
'authFiles.health.earliest': '最短の残り時間:約 {time}',
|
||||
'authFiles.health.seconds': '{count} 秒',
|
||||
'authFiles.health.minutes': '{count} 分',
|
||||
'authFiles.health.hours': '{count} 時間',
|
||||
'authFiles.health.expiredHint': '一覧を更新して最新の状態を確認してください。時間の経過だけでは利用可能になったとは限りません。',
|
||||
'authFiles.health.details': '詳細',
|
||||
'authFiles.health.coreStatus': 'コアが報告した状態:{status}',
|
||||
'authFiles.health.coreMessage': 'コアの状態メッセージ',
|
||||
'authFiles.health.credentialScope': '認証情報全体',
|
||||
'authFiles.health.remaining': '残り {time}',
|
||||
'authFiles.health.waiting': '時間経過 · 要確認',
|
||||
'authFiles.health.backoff': 'バックオフレベル {level}',
|
||||
'authFiles.health.backoffHint': 'バックオフレベルは再試行の間隔を制御する値で、失敗回数ではありません。',
|
||||
'authFiles.health.deadline': 'クールダウン終了予定',
|
||||
'authFiles.health.observed': '状態の取得時刻',
|
||||
'authFiles.health.note': '残り時間はコアのスナップショットに基づく推定です。時間の経過はクォータや利用可否の回復を保証せず、テストリクエストも自動送信しません。',
|
||||
'authFiles.health.reason.quota': 'クォータまたはレート制限',
|
||||
'authFiles.health.reason.credentialQuota': '認証情報のクォータ制限',
|
||||
'authFiles.health.reason.cloudflare': 'Cloudflare 認証チャレンジ',
|
||||
'authFiles.health.reason.invalidGrant': '認可が無効',
|
||||
'authFiles.health.reason.unauthorized': '認証失敗',
|
||||
'authFiles.health.reason.accessDenied': 'アクセス制限(402/403)',
|
||||
'authFiles.health.reason.notFound': 'リソースが見つかりません',
|
||||
'authFiles.health.reason.modelUnsupported': 'モデル非対応',
|
||||
'authFiles.health.reason.upstream': '上流サービスの一時的なエラー',
|
||||
'authFiles.health.reason.tokenExpired': 'アクセストークンの有効期限切れ',
|
||||
'authFiles.health.reason.unknown': 'その他または不明な制限',
|
||||
'authFiles.requests.title': 'リクエスト履歴',
|
||||
'authFiles.requests.success': '成功 {count}',
|
||||
'authFiles.requests.failure': '失敗 {count}',
|
||||
'authFiles.requests.window': '直近200分 · 1ブロック10分',
|
||||
'authFiles.requests.rate': '成功率 {rate}',
|
||||
'authFiles.requests.recentRate': '直近200分のリクエスト成功率',
|
||||
'authFiles.requests.empty': 'リクエストなし',
|
||||
'authFiles.requests.unavailable': 'コアから最近のリクエスト統計が提供されていません',
|
||||
'authFiles.requests.interval': '時間帯 {index}',
|
||||
'authFiles.requests.totalsHint': 'コアの稼働中の累計回数です。再起動でリセットされます。一覧の更新で統計を更新できます。',
|
||||
'apiAccess.provider.codex': 'Codex API',
|
||||
'apiAccess.provider.deepseek': 'DeepSeek',
|
||||
'apiAccess.provider.claude': 'Claude',
|
||||
|
||||
@@ -3,6 +3,96 @@ import { easyModeEn } from '../easyMode';
|
||||
|
||||
export const en: Record<MessageKey, string> = {
|
||||
...easyModeEn,
|
||||
'authFiles.settings.noModels': 'No matching models. Enter exclusion rules above.',
|
||||
'authFiles.settings.button': "Settings",
|
||||
'authFiles.settings.title': "Credential settings",
|
||||
'authFiles.settings.updated': "Settings saved for {name}",
|
||||
'authFiles.settings.routing': "Routing & connection",
|
||||
'authFiles.settings.additional': "Headers & note",
|
||||
'authFiles.settings.prefix': "Prefix",
|
||||
'authFiles.settings.prefixHint': "Model routing prefix. Leave empty for no prefix.",
|
||||
'authFiles.settings.proxy_url': "Proxy URL",
|
||||
'authFiles.settings.proxy_urlHint': "Proxy for this credential. Leave empty to use the default configuration.",
|
||||
'authFiles.settings.priority': "Priority",
|
||||
'authFiles.settings.priorityHint': "Integer; higher values take precedence. Empty means 0.",
|
||||
'authFiles.settings.weight': "Scheduling weight",
|
||||
'authFiles.settings.weightHint': "Relative weight within a priority level, up to 1000000. Empty defaults to 1; nonpositive values become 0 and are excluded from weighted scheduling.",
|
||||
'authFiles.settings.disable_cooling': "Disable exponential cooling",
|
||||
'authFiles.settings.disable_coolingHint': "Enable to disable exponential cooling for this credential. Default follows global configuration.",
|
||||
'authFiles.settings.websockets': "WebSockets",
|
||||
'authFiles.settings.websocketsHint': "WebSocket support for this credential (Codex / xAI). Connections also depend on backend and upstream support.",
|
||||
'authFiles.settings.inherit': "Default (no override)",
|
||||
'authFiles.settings.excluded_models': "Excluded models",
|
||||
'authFiles.settings.rules': "Exclusion rules",
|
||||
'authFiles.settings.excludedHint': "One model ID per line; * wildcards are supported. Clearing removes credential exclusions; global exclusions still apply.",
|
||||
'authFiles.settings.catalog': "Choose exclusions from models ({count})",
|
||||
'authFiles.settings.catalogError': "Could not load models. Rules can still be edited manually.",
|
||||
'authFiles.settings.searchModels': "Search models",
|
||||
'authFiles.settings.wildcard': "Excluded by a wildcard; edit the rule above.",
|
||||
'authFiles.settings.headers': "Custom headers",
|
||||
'authFiles.settings.headersHint': "Enter a JSON object with string keys and values. Remove keys or use {} to clear headers.",
|
||||
'authFiles.settings.note': "Note",
|
||||
'authFiles.settings.unsaved': "Discard unsaved changes?",
|
||||
'authFiles.settings.keepEditing': "Keep editing",
|
||||
'authFiles.settings.discard': "Discard changes",
|
||||
'authFiles.settings.invalid.metadata': "Cannot read settings: the credential is not a valid JSON object.",
|
||||
'authFiles.settings.invalid.headers': "Headers must be a JSON object with valid, unique header names and string values without line breaks.",
|
||||
'authFiles.settings.invalid.priority': "Priority must be a valid integer.",
|
||||
'authFiles.settings.invalid.weight': "Scheduling weight must be an integer no greater than 1000000.",
|
||||
'authFiles.settings.runtime': "Runtime credentials cannot persist settings.",
|
||||
'authFiles.settings.quotaRemaining': "Remaining quota",
|
||||
'authFiles.settings.quotaUnknown': "Remaining quota unknown",
|
||||
'authFiles.settings.quotaIdle': "Quota not fetched",
|
||||
'authFiles.health.title': 'Credential status',
|
||||
'authFiles.health.active': 'Available',
|
||||
'authFiles.health.error': 'Request error',
|
||||
'authFiles.health.refreshing': 'Refreshing credentials',
|
||||
'authFiles.health.pending': 'Awaiting verification',
|
||||
'authFiles.health.warning': 'Status notice',
|
||||
'authFiles.health.unknown': 'Status unknown',
|
||||
'authFiles.health.elapsed': 'Cooldown timer elapsed',
|
||||
'authFiles.health.unknownCooldown': 'Cooldown state is unknown; the core did not provide valid details.',
|
||||
'authFiles.health.missingCooldown': 'This core did not provide cooldown details; recovery time is unknown.',
|
||||
'authFiles.health.credential': 'Credential-wide cooldown',
|
||||
'authFiles.health.models': '{count} models cooling down',
|
||||
'authFiles.health.credentialModels': 'Credential and {count} models cooling down',
|
||||
'authFiles.health.earliest': 'Earliest cooldown ends in ~{time}',
|
||||
'authFiles.health.seconds': '{count}s',
|
||||
'authFiles.health.minutes': '{count} min',
|
||||
'authFiles.health.hours': '{count} hr',
|
||||
'authFiles.health.expiredHint': 'Refresh the list to confirm the latest state; an elapsed timer does not confirm availability.',
|
||||
'authFiles.health.details': 'Details',
|
||||
'authFiles.health.coreStatus': 'Core-reported status: {status}',
|
||||
'authFiles.health.coreMessage': 'Core status message',
|
||||
'authFiles.health.credentialScope': 'Entire credential',
|
||||
'authFiles.health.remaining': '{time} remaining',
|
||||
'authFiles.health.waiting': 'Elapsed · confirmation needed',
|
||||
'authFiles.health.backoff': 'Backoff level {level}',
|
||||
'authFiles.health.backoffHint': 'Backoff level controls retry delays; it is not a failure count.',
|
||||
'authFiles.health.deadline': 'Cooldown deadline',
|
||||
'authFiles.health.observed': 'State observed at',
|
||||
'authFiles.health.note': 'Remaining time is estimated from the core snapshot. Expiry does not confirm restored quota or availability, and no test request is sent automatically.',
|
||||
'authFiles.health.reason.quota': 'Quota or rate limit',
|
||||
'authFiles.health.reason.credentialQuota': 'Credential quota limit',
|
||||
'authFiles.health.reason.cloudflare': 'Cloudflare challenge',
|
||||
'authFiles.health.reason.invalidGrant': 'Authorization grant invalid',
|
||||
'authFiles.health.reason.unauthorized': 'Authentication failed',
|
||||
'authFiles.health.reason.accessDenied': 'Access restricted (402/403)',
|
||||
'authFiles.health.reason.notFound': 'Resource not found',
|
||||
'authFiles.health.reason.modelUnsupported': 'Model not supported',
|
||||
'authFiles.health.reason.upstream': 'Temporary upstream error',
|
||||
'authFiles.health.reason.tokenExpired': 'Access token expired',
|
||||
'authFiles.health.reason.unknown': 'Other or unknown restriction',
|
||||
'authFiles.requests.title': 'Requests',
|
||||
'authFiles.requests.success': 'Success {count}',
|
||||
'authFiles.requests.failure': 'Failed {count}',
|
||||
'authFiles.requests.window': 'Last 200 min · 10 min per block',
|
||||
'authFiles.requests.rate': 'Success rate {rate}',
|
||||
'authFiles.requests.recentRate': 'Request success rate over the last 200 minutes',
|
||||
'authFiles.requests.empty': 'No requests',
|
||||
'authFiles.requests.unavailable': 'The core did not provide recent request statistics',
|
||||
'authFiles.requests.interval': 'Interval {index}',
|
||||
'authFiles.requests.totalsHint': 'Totals since the core started; reset on restart. Refresh the list to update statistics.',
|
||||
'apiAccess.provider.codex': 'Codex API',
|
||||
'apiAccess.provider.deepseek': 'DeepSeek',
|
||||
'apiAccess.provider.claude': 'Claude',
|
||||
|
||||
@@ -2,6 +2,96 @@ import { easyModeZhCN } from '../easyMode';
|
||||
|
||||
export const zhCN = {
|
||||
...easyModeZhCN,
|
||||
'authFiles.settings.noModels': '没有匹配的候选模型,可在上方手动填写排除规则。',
|
||||
'authFiles.settings.button': "设置",
|
||||
'authFiles.settings.title': "凭证设置",
|
||||
'authFiles.settings.updated': "已保存 {name} 的设置",
|
||||
'authFiles.settings.routing': "路由与连接",
|
||||
'authFiles.settings.additional': "请求头与备注",
|
||||
'authFiles.settings.prefix': "前缀",
|
||||
'authFiles.settings.prefixHint': "用于模型路由的前缀,留空不设置。",
|
||||
'authFiles.settings.proxy_url': "代理 URL",
|
||||
'authFiles.settings.proxy_urlHint': "此凭证专用的代理地址,留空使用默认配置。",
|
||||
'authFiles.settings.priority': "优先级",
|
||||
'authFiles.settings.priorityHint': "整数,数值越大越优先;留空为 0。",
|
||||
'authFiles.settings.weight': "调度权重",
|
||||
'authFiles.settings.weightHint': "同优先级下的相对权重,最大 1000000;留空默认 1,非正数按 0 处理,不参与加权调度。",
|
||||
'authFiles.settings.disable_cooling': "禁用指数冷却",
|
||||
'authFiles.settings.disable_coolingHint': "启用后关闭此凭证的指数冷却;默认跟随全局配置。",
|
||||
'authFiles.settings.websockets': "WebSockets",
|
||||
'authFiles.settings.websocketsHint': "控制此凭证的 WebSocket 支持,适用于 Codex / xAI,实际连接仍取决于内核及上游支持。",
|
||||
'authFiles.settings.inherit': "默认(不覆盖)",
|
||||
'authFiles.settings.excluded_models': "排除模型",
|
||||
'authFiles.settings.rules': "排除规则",
|
||||
'authFiles.settings.excludedHint': "每行一个模型 ID,支持 * 通配符。清空即移除此凭证的排除规则,全局排除规则仍然生效。",
|
||||
'authFiles.settings.catalog': "从模型列表选择排除项({count})",
|
||||
'authFiles.settings.catalogError': "模型列表读取失败,仍可手动编辑规则。",
|
||||
'authFiles.settings.searchModels': "搜索模型",
|
||||
'authFiles.settings.wildcard': "已被通配符规则排除,请修改上方规则。",
|
||||
'authFiles.settings.headers': "自定义请求头",
|
||||
'authFiles.settings.headersHint': "填写 JSON 对象,键和值均为字符串。删除键或填写 {} 可清空对应请求头。",
|
||||
'authFiles.settings.note': "备注",
|
||||
'authFiles.settings.unsaved': "有尚未保存的修改,是否放弃?",
|
||||
'authFiles.settings.keepEditing': "继续编辑",
|
||||
'authFiles.settings.discard': "放弃修改",
|
||||
'authFiles.settings.invalid.metadata': "无法读取凭证设置:文件内容不是有效的 JSON 对象。",
|
||||
'authFiles.settings.invalid.headers': "请求头必须是 JSON 对象,键和值为字符串,名称不能重复,值不能包含换行。",
|
||||
'authFiles.settings.invalid.priority': "优先级必须是有效整数。",
|
||||
'authFiles.settings.invalid.weight': "调度权重必须是整数且不能超过 1000000。",
|
||||
'authFiles.settings.runtime': "运行时凭证不支持保存设置。",
|
||||
'authFiles.settings.quotaRemaining': "剩余额度",
|
||||
'authFiles.settings.quotaUnknown': "剩余额度未知",
|
||||
'authFiles.settings.quotaIdle': "尚未获取额度",
|
||||
'authFiles.health.title': '凭证状态',
|
||||
'authFiles.health.active': '正常可用',
|
||||
'authFiles.health.error': '请求异常',
|
||||
'authFiles.health.refreshing': '凭证刷新中',
|
||||
'authFiles.health.pending': '等待验证',
|
||||
'authFiles.health.warning': '状态提示',
|
||||
'authFiles.health.unknown': '状态未知',
|
||||
'authFiles.health.elapsed': '冷却计时已到期',
|
||||
'authFiles.health.unknownCooldown': '冷却状态未知,内核当前未提供有效详情。',
|
||||
'authFiles.health.missingCooldown': '当前内核未提供冷却详情,无法确定恢复时间。',
|
||||
'authFiles.health.credential': '凭证整体冷却',
|
||||
'authFiles.health.models': '{count} 个模型冷却',
|
||||
'authFiles.health.credentialModels': '凭证及 {count} 个模型冷却',
|
||||
'authFiles.health.earliest': '最早结束约 {time}',
|
||||
'authFiles.health.seconds': '{count} 秒',
|
||||
'authFiles.health.minutes': '{count} 分钟',
|
||||
'authFiles.health.hours': '{count} 小时',
|
||||
'authFiles.health.expiredHint': '刷新列表确认最新状态;计时结束不代表已恢复可用。',
|
||||
'authFiles.health.details': '详情',
|
||||
'authFiles.health.coreStatus': '内核报告状态:{status}',
|
||||
'authFiles.health.coreMessage': '内核状态说明',
|
||||
'authFiles.health.credentialScope': '整个凭证',
|
||||
'authFiles.health.remaining': '剩余 {time}',
|
||||
'authFiles.health.waiting': '已到期 · 待确认',
|
||||
'authFiles.health.backoff': '退避等级 {level}',
|
||||
'authFiles.health.backoffHint': '退避等级用于延长重试间隔,不等于失败次数。',
|
||||
'authFiles.health.deadline': '冷却截止',
|
||||
'authFiles.health.observed': '状态采集于',
|
||||
'authFiles.health.note': '剩余时间基于内核快照估算。到期不代表额度恢复或凭证可用,也不会自动发起测试请求。',
|
||||
'authFiles.health.reason.quota': '额度或速率限制',
|
||||
'authFiles.health.reason.credentialQuota': '凭证额度限制',
|
||||
'authFiles.health.reason.cloudflare': 'Cloudflare 验证拦截',
|
||||
'authFiles.health.reason.invalidGrant': '授权已失效',
|
||||
'authFiles.health.reason.unauthorized': '身份认证失败',
|
||||
'authFiles.health.reason.accessDenied': '访问受限(402/403)',
|
||||
'authFiles.health.reason.notFound': '资源未找到',
|
||||
'authFiles.health.reason.modelUnsupported': '模型不受支持',
|
||||
'authFiles.health.reason.upstream': '上游服务暂时异常',
|
||||
'authFiles.health.reason.tokenExpired': '访问令牌已过期',
|
||||
'authFiles.health.reason.unknown': '其他或未知限制',
|
||||
'authFiles.requests.title': '请求记录',
|
||||
'authFiles.requests.success': '成功 {count}',
|
||||
'authFiles.requests.failure': '失败 {count}',
|
||||
'authFiles.requests.window': '最近 200 分钟 · 每格 10 分钟',
|
||||
'authFiles.requests.rate': '成功率 {rate}',
|
||||
'authFiles.requests.recentRate': '最近 200 分钟的请求成功率',
|
||||
'authFiles.requests.empty': '暂无请求',
|
||||
'authFiles.requests.unavailable': '内核未提供近期请求统计',
|
||||
'authFiles.requests.interval': '时段 {index}',
|
||||
'authFiles.requests.totalsHint': '内核运行期间的累计次数,重启后重置;刷新列表可更新统计。',
|
||||
'apiAccess.provider.codex': 'Codex API',
|
||||
'apiAccess.provider.deepseek': 'DeepSeek',
|
||||
'apiAccess.provider.claude': 'Claude',
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
.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-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-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: 14px; 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-provider { font-size: 11px; color: var(--text-tertiary); letter-spacing: .02em; }
|
||||
.auth-card-filename { margin: -3px 0 0; color: var(--text-secondary); overflow-wrap: anywhere; font-family: ui-monospace, monospace; font-size: 11px; line-height: 1.6; }
|
||||
.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: 12px; line-height: 1.6; }
|
||||
.auth-card-note > span { flex-shrink: 0; color: var(--text-tertiary); font-size: 11px; }
|
||||
.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: 11px; }
|
||||
.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: 30px; padding: 5px 9px; font-size: 12px; }
|
||||
.auth-card-actions .icon-button { width: 30px; height: 30px; }
|
||||
.auth-card-actions .auth-card-toggle { margin-left: auto; }
|
||||
.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; }
|
||||
.credential-quota { display: grid; gap: 12px; padding: 11px 12px; border: 1px solid var(--border-color); border-radius: 10px; background: color-mix(in srgb, var(--text-tertiary) 3%, var(--bg-card)); font-size: 12px; }
|
||||
.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: 11px; font-weight: 500; }
|
||||
.credential-quota-plan { max-width: 100%; overflow-wrap: anywhere; padding: 2px 6px; border-radius: 4px; font-size: 10px; background: var(--bg-secondary); color: var(--text-secondary); }
|
||||
.credential-quota-refresh { display: inline-flex; align-items: center; gap: 4px; border: 0; padding: 3px 0 3px 5px; background: transparent; color: var(--primary-color); font: inherit; font-size: 11px; margin-left: auto; cursor: pointer; }
|
||||
.credential-quota-refresh:disabled { color: var(--text-tertiary); cursor: default; }
|
||||
.credential-quota-heading .credential-quota-reset { min-height: 26px; padding: 3px 7px; margin-left: auto; font-size: 11px; }
|
||||
.credential-quota-reset + .credential-quota-refresh { margin-left: 0; }
|
||||
.credential-quota-rows { display: grid; gap: 14px; }
|
||||
.credential-quota-row { --quota-color: var(--success-color); display: grid; gap: 5px; min-width: 0; }
|
||||
.credential-quota-row.medium { --quota-color: var(--amber-text); }
|
||||
.credential-quota-row.low { --quota-color: var(--failure-badge-text); }
|
||||
.credential-quota-label { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.credential-quota-label span { min-width: 0; overflow-wrap: anywhere; color: var(--text-secondary); }
|
||||
.credential-quota-label strong { flex-shrink: 0; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--quota-color); }
|
||||
.credential-quota-row.unknown strong { color: var(--text-tertiary); }
|
||||
.credential-quota-track { height: 6px; border-radius: 3px; overflow: hidden; background: var(--border-color); }
|
||||
.credential-quota-track i { display: block; height: 100%; border-radius: inherit; background: var(--quota-color, var(--primary-color)); transition: width .25s ease; }
|
||||
.credential-quota-row.unknown .credential-quota-track { background: repeating-linear-gradient(115deg, var(--border-color) 0 4px, transparent 4px 8px); }
|
||||
.credential-quota small, .credential-quota-empty, .credential-quota-loading { font-size: 11px; line-height: 1.6; color: var(--text-tertiary); overflow-wrap: anywhere; }
|
||||
.credential-quota-empty { margin: 0; }
|
||||
.credential-quota-footnotes { display: grid; gap: 3px; }
|
||||
.credential-quota-footnotes:empty { display: none; }
|
||||
.credential-quota-error { display: grid; gap: 4px; color: var(--failure-badge-text); }
|
||||
.credential-quota-loading { display: grid; gap: 8px; }
|
||||
.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; } }
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel';
|
||||
import { AuthFileSettingsDialog } from '../components/AuthFileSettingsDialog';
|
||||
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
|
||||
import './AuthFileManagementPage.css';
|
||||
import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice';
|
||||
import { AuthFileModelsDialog } from '../components/AuthFileModelsDialog';
|
||||
import { AuthFileRequestStatus } from '../components/AuthFileRequestStatus';
|
||||
import { AuthFileHealthStatus } from '../components/AuthFileHealthStatus';
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
@@ -10,7 +16,6 @@ import {
|
||||
FolderOpen,
|
||||
Import,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
@@ -34,12 +39,10 @@ import {
|
||||
responseList,
|
||||
} from '../services/managementApi';
|
||||
import {
|
||||
formatQuotaTimestamp,
|
||||
idleQuota,
|
||||
loadQuota,
|
||||
providerForFile as quotaProviderForFile,
|
||||
quotaKey,
|
||||
type QuotaState,
|
||||
} from '../services/quotaService';
|
||||
import {
|
||||
captureQuotaCacheGeneration,
|
||||
@@ -53,7 +56,6 @@ import {
|
||||
authFileName,
|
||||
dedupeAuthFiles,
|
||||
isRuntimeOnlyAuthFile,
|
||||
normalizeAuthFilePriorityInput,
|
||||
parseAuthFilePriority,
|
||||
} from '../services/authFiles';
|
||||
import {
|
||||
@@ -70,17 +72,9 @@ import {
|
||||
type OAuthModelTarget,
|
||||
} from '../services/oauthModelSettings';
|
||||
import { getCurrentLocale, translate, useI18n } from '../i18n';
|
||||
import { formatQuotaReset, useQuotaClock } from '../services/quotaTime';
|
||||
|
||||
type AuthFile = Record<string, unknown>;
|
||||
|
||||
type PriorityEditor = {
|
||||
fileName: string;
|
||||
originalPriority: number;
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
const providerIcons: Record<string, string> = {
|
||||
antigravity: antigravityIcon,
|
||||
claude: claudeIcon,
|
||||
@@ -111,75 +105,26 @@ const fileName = authFileName;
|
||||
|
||||
const isRuntimeOnly = isRuntimeOnlyAuthFile;
|
||||
|
||||
const statusText = (file: AuthFile) => {
|
||||
if (readBoolean(file, 'disabled')) return translate(getCurrentLocale(), 'authFiles.status.disabled');
|
||||
if (readBoolean(file, 'unavailable')) return translate(getCurrentLocale(), 'authFiles.status.unavailable');
|
||||
return readString(file, 'status') || translate(getCurrentLocale(), 'authFiles.status.ready');
|
||||
};
|
||||
|
||||
function AuthFileQuotaSummary({ quota, name }: { quota: QuotaState; name: string }) {
|
||||
const { locale, t } = useI18n();
|
||||
const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0);
|
||||
if (quota.status === 'loading') {
|
||||
return (
|
||||
<div className="auth-file-quota loading">
|
||||
<LoaderCircle size={13} className="spin" />
|
||||
<span>{t('authFiles.quota.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (quota.status === 'error') {
|
||||
return (
|
||||
<div className="auth-file-quota error" title={quota.error}>
|
||||
<span>{t('authFiles.quota.failed')}</span>
|
||||
<MessageNotice message={quota.error ? name + ': ' + quota.error : null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (quota.status !== 'success') return null;
|
||||
|
||||
return (
|
||||
<div className="auth-file-quota" aria-label={t('authFiles.quota.aria')}>
|
||||
{quota.plan ? <span className="auth-file-quota-plan">{quota.plan}</span> : null}
|
||||
{quota.rows.length > 0 ? quota.rows.map((row, index) => {
|
||||
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
|
||||
const detail = [row.detail, reset].filter(Boolean).join(' · ');
|
||||
return (
|
||||
<span className="auth-file-quota-item" key={`${row.label}-${index}`} title={detail || undefined}>
|
||||
<span>{row.label}</span>
|
||||
<strong>{row.remainingPercent === null ? '—' : `${Math.round(row.remainingPercent)}%`}</strong>
|
||||
{reset ? <small>{reset}</small> : null}
|
||||
{row.remainingPercent === null && row.detail ? <small>{row.detail}</small> : null}
|
||||
</span>
|
||||
);
|
||||
}) : <span className="auth-file-quota-empty">{t('authFiles.quota.empty')}</span>}
|
||||
{quota.resetCredits !== undefined ? (
|
||||
<span className="auth-file-quota-credit">{t('authFiles.quota.resets', { count: quota.resetCredits })}</span>
|
||||
) : null}
|
||||
{quota.resetCreditsApplicable !== undefined ? <span className="auth-file-quota-credit">{t('quota.resetApplicable', { count: quota.resetCreditsApplicable })}</span> : null}
|
||||
{quota.subscriptionActiveUntil ? <span className="auth-file-quota-credit">{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span> : null}
|
||||
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
|
||||
{quota.resetCreditsEarliestExpiry ? (
|
||||
<span className="auth-file-quota-credit">{t('authFiles.quota.expiry', { time: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthFileManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const [fileSnapshot, setFileSnapshot] = useState<{
|
||||
files: AuthFile[];
|
||||
receivedAtMs: number;
|
||||
observedAt?: string;
|
||||
}>({ files: [], receivedAtMs: 0 });
|
||||
const { files, receivedAtMs, observedAt } = fileSnapshot;
|
||||
const [filter, setFilter] = useState('');
|
||||
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 [error, setError] = useState('');
|
||||
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
|
||||
const feedback = useAppNotice();
|
||||
const { showNotice } = feedback;
|
||||
const [copied, setCopied] = useState('');
|
||||
const [priorityEditor, setPriorityEditor] = useState<PriorityEditor | null>(null);
|
||||
const [settingsName, setSettingsName] = useState<string | null>(null);
|
||||
const [oauthModelTarget, setOauthModelTarget] = useState<OAuthModelTarget | null>(null);
|
||||
const [oauthModelSettings, setOauthModelSettings] = useState<OAuthModelSettings | null>(null);
|
||||
const [oauthExcludedRulesText, setOauthExcludedRulesText] = useState('');
|
||||
@@ -208,7 +153,7 @@ export function AuthFileManagementPage() {
|
||||
try {
|
||||
const payload = await managementApi.get('/auth-files');
|
||||
const nextFiles = dedupeAuthFiles(responseList(payload, 'files'));
|
||||
setFiles(nextFiles);
|
||||
setFileSnapshot({ files: nextFiles, receivedAtMs: Date.now(), observedAt: readString(payload, 'observed_at') });
|
||||
const validQuotaKeys = new Set(nextFiles.map(quotaKey));
|
||||
pruneQuotaCache(validQuotaKeys);
|
||||
updateQuotaCache((current) => {
|
||||
@@ -267,13 +212,6 @@ export function AuthFileManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openOauthModels = (file: AuthFile) => {
|
||||
const name = readString(file, 'name');
|
||||
const provider = providerKey(file);
|
||||
if (!name || !provider || isRuntimeOnly(file)) return;
|
||||
return openOauthModelSettings({ scope: 'credential', name, provider, label: providerName(file) });
|
||||
};
|
||||
|
||||
const saveOauthModels = async () => {
|
||||
if (!oauthModelSettings || oauthModelLoading || oauthModelSaveRef.current) return;
|
||||
const settings = oauthModelSettings;
|
||||
@@ -385,51 +323,6 @@ export function AuthFileManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const openPriorityEditor = (file: AuthFile) => {
|
||||
const priority = parseAuthFilePriority(file.priority);
|
||||
setPriorityEditor({
|
||||
fileName: fileName(file),
|
||||
originalPriority: priority ?? 0,
|
||||
value: priority === undefined || priority === 0 ? '' : String(priority),
|
||||
error: '',
|
||||
});
|
||||
};
|
||||
|
||||
const closePriorityEditor = () => {
|
||||
if (!busy) setPriorityEditor(null);
|
||||
};
|
||||
|
||||
const savePriority = async () => {
|
||||
if (!priorityEditor || busy) return;
|
||||
const priority = normalizeAuthFilePriorityInput(priorityEditor.value);
|
||||
if (priority === null) {
|
||||
setPriorityEditor((current) => current
|
||||
? { ...current, error: t('authFiles.priority.invalid') }
|
||||
: current);
|
||||
return;
|
||||
}
|
||||
if (priority === priorityEditor.originalPriority) {
|
||||
setPriorityEditor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const name = priorityEditor.fileName;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
await managementApi.patch('/auth-files/fields', { name, priority });
|
||||
setPriorityEditor(null);
|
||||
showNotice({ key: 'authFiles.priority.updated', variables: { name } });
|
||||
await loadFiles();
|
||||
} catch (requestError) {
|
||||
setPriorityEditor((current) => current
|
||||
? { ...current, error: String(requestError) }
|
||||
: current);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFile = async (file: AuthFile) => {
|
||||
const name = fileName(file);
|
||||
if (isRuntimeOnly(file)) {
|
||||
@@ -527,35 +420,39 @@ 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="real-auth-file-list">
|
||||
<div className="auth-file-card-grid">
|
||||
{visibleFiles.map((file) => {
|
||||
const name = fileName(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();
|
||||
return (
|
||||
<article className={`real-auth-file-row ${disabled ? 'disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
|
||||
<img src={icon} alt="" className={providerKey(file) === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} />
|
||||
<div className="auth-file-main">
|
||||
<div className="auth-file-title"><strong title={name}>{name}</strong><span className={`state-pill ${disabled ? 'error' : readBoolean(file, 'unavailable') ? 'error' : 'success'}`} title={readString(file, 'status_message', 'statusMessage') || undefined}>{statusText(file)}</span></div>
|
||||
<span>{providerName(file)}{readString(file, 'email', 'account', 'label') ? ` · ${readString(file, 'email', 'account', 'label')}` : ''}</span>
|
||||
</div>
|
||||
<div className="auth-file-meta">
|
||||
<span>{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>
|
||||
<article className={`auth-file-card ${disabled ? 'is-disabled' : ''}`} key={`${name}-${readString(file, 'auth_index', 'authIndex')}`}>
|
||||
<header className={`auth-card-header ${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}
|
||||
</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>
|
||||
<div className="auth-file-actions">
|
||||
{quotaProviderForFile(file) && !disabled ? <button type="button" className="secondary-button compact-button" onClick={() => void refreshQuota(file)} disabled={busy || quotas[quotaKey(file)]?.status === 'loading'}>{quotas[quotaKey(file)]?.status === 'loading' ? t('authFiles.quota.querying') : quotas[quotaKey(file)]?.status === 'success' ? t('authFiles.quota.refresh') : t('authFiles.quota.fetch')}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => setModelViewName(name)} disabled={busy || disabled} title={t('authFiles.models.viewTitle')}>{t('authFiles.models.button')}</button> : null}
|
||||
{providerKey(file) ? <button type="button" className="secondary-button compact-button" onClick={() => void openOauthModels(file)} disabled={busy || disabled || oauthModelSaving || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.models.runtimeUnsupported' : 'authFiles.models.settings')}>{t('authFiles.models.excludeButton')}</button> : null}
|
||||
<button type="button" className="secondary-button compact-button auth-file-priority-button" onClick={() => openPriorityEditor(file)} disabled={busy || disabled} title={t('authFiles.priority.hint')}><Pencil size={14} />{t('authFiles.priority.button', { priority })}</button>
|
||||
<button type="button" className="icon-button quiet" onClick={() => void copyName(name)} disabled={busy || disabled} title={t('authFiles.copyName')}>{copied === name ? <Check size={16} /> : <Copy size={16} />}</button>
|
||||
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button`} onClick={() => void toggleStatus(file)} disabled={busy}>{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')}><Trash2 size={16} /></button>
|
||||
</div>
|
||||
{quotaProviderForFile(file) && quotas[quotaKey(file)]?.status !== 'idle' ? <AuthFileQuotaSummary quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} /> : null}
|
||||
<QuotaActionFeedback quota={quotas[quotaKey(file)] ?? idleQuota()} name={name} />
|
||||
{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 || isRuntimeOnly(file) || !readString(file, 'name')} title={t(isRuntimeOnly(file) ? 'authFiles.settings.runtime' : 'authFiles.settings.title')}><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')}>{copied === name ? <Check size={15} /> : <Copy size={15} />}</button>
|
||||
<button type="button" className={`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`} onClick={() => void toggleStatus(file)} disabled={busy}>{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')}><Trash2 size={15} /></button>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
@@ -564,53 +461,11 @@ export function AuthFileManagementPage() {
|
||||
</section>
|
||||
{runtimeCount > 0 ? <p className="page-footnote">{t('authFiles.runtimeFootnote', { count: runtimeCount })}</p> : null}
|
||||
|
||||
{priorityEditor ? (
|
||||
<div className="config-dialog-backdrop" onMouseDown={(event) => event.currentTarget === event.target && closePriorityEditor()}>
|
||||
<form
|
||||
className="config-dialog auth-priority-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="auth-priority-title"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void savePriority();
|
||||
}}
|
||||
>
|
||||
<div className="config-dialog-heading">
|
||||
<div><Pencil size={19} /><h2 id="auth-priority-title">{t('authFiles.priority.title')}</h2></div>
|
||||
<button type="button" className="icon-button quiet" onClick={closePriorityEditor} disabled={busy} title={t('common.close')}><X size={18} /></button>
|
||||
</div>
|
||||
<div className="auth-priority-target" title={priorityEditor.fileName}>{priorityEditor.fileName}</div>
|
||||
<label className="auth-priority-field" htmlFor="auth-file-priority-input">
|
||||
<span>{t('authFiles.priority.label')}</span>
|
||||
<input
|
||||
id="auth-file-priority-input"
|
||||
autoFocus
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={priorityEditor.value}
|
||||
placeholder={t('authFiles.priority.placeholder')}
|
||||
disabled={busy}
|
||||
aria-invalid={Boolean(priorityEditor.error)}
|
||||
onChange={(event) => {
|
||||
const value = event.currentTarget.value;
|
||||
setPriorityEditor((current) => current
|
||||
? { ...current, value, error: '' }
|
||||
: current);
|
||||
}}
|
||||
/>
|
||||
<small>{t('authFiles.priority.hint')}</small>
|
||||
</label>
|
||||
<div className={`config-form-message ${priorityEditor.error ? 'error' : ''}`} role={priorityEditor.error ? 'alert' : undefined}>
|
||||
{priorityEditor.error || ' '}
|
||||
</div>
|
||||
<div className="config-dialog-actions two-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePriorityEditor} disabled={busy}>{t('common.cancel')}</button>
|
||||
<button type="submit" className="primary-button" disabled={busy}>{busy ? <LoaderCircle size={16} className="spin" /> : <Check size={16} />}{busy ? t('common.saving') : t('common.save')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
{settingsName ? <AuthFileSettingsDialog key={settingsName} name={settingsName} onClose={() => setSettingsName(null)} onSaved={() => {
|
||||
showNotice({ key: 'authFiles.settings.updated', variables: { name: settingsName } });
|
||||
setSettingsName(null);
|
||||
void loadFiles(false);
|
||||
}} /> : null}
|
||||
|
||||
{modelViewName ? <AuthFileModelsDialog name={modelViewName} onClose={() => setModelViewName(null)} /> : null}
|
||||
|
||||
|
||||
+4
-19
@@ -3,7 +3,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react';
|
||||
import { useConfirmation } from '../components/ConfirmationDialog';
|
||||
import { QuotaActionFeedback } from '../components/QuotaActionFeedback';
|
||||
import { canResetCodexQuota, resetCodexQuotaWithConfirmation } from '../services/quotaActions';
|
||||
import { canResetCodexQuota } from '../services/quotaActions';
|
||||
import { useCodexQuotaReset } from '../components/useCodexQuotaReset';
|
||||
import antigravityIcon from '../assets/icons/antigravity.svg';
|
||||
import claudeIcon from '../assets/icons/claude.svg';
|
||||
import codexIcon from '../assets/icons/codex.svg';
|
||||
@@ -47,7 +48,7 @@ const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai',
|
||||
const REFRESH_CONCURRENCY = 4;
|
||||
|
||||
export function QuotaPage() {
|
||||
const { locale, t } = useI18n();
|
||||
const { t } = useI18n();
|
||||
const { askConfirmation, confirmationDialog } = useConfirmation();
|
||||
const [files, setFiles] = useState<AuthFile[]>([]);
|
||||
const quotas = useQuotaCache();
|
||||
@@ -96,23 +97,7 @@ export function QuotaPage() {
|
||||
});
|
||||
}, [t]);
|
||||
|
||||
const resetCodexQuota = useCallback(async (file: AuthFile, quota: QuotaState) => {
|
||||
setError('');
|
||||
try {
|
||||
await resetCodexQuotaWithConfirmation(file, () => askConfirmation({
|
||||
title: t('quota.reset'),
|
||||
message: t('quota.confirm.title', { name: fileName(file) }),
|
||||
confirmText: t('quota.confirm.button'),
|
||||
details: [
|
||||
{ label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') },
|
||||
{ label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) },
|
||||
],
|
||||
warning: t('quota.confirm.warning'),
|
||||
}));
|
||||
} catch (requestError) {
|
||||
setError(requestError instanceof Error ? requestError.message : String(requestError));
|
||||
}
|
||||
}, [askConfirmation, locale, t]);
|
||||
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { MessageKey } from '../i18n/resources';
|
||||
import { isRecord, readBoolean, readString } from './managementApi';
|
||||
|
||||
export type AuthFileCooldown = {
|
||||
scope: 'credential' | 'model';
|
||||
model?: string;
|
||||
reason: string;
|
||||
retryAt: string;
|
||||
remainingSeconds: number;
|
||||
httpStatus?: number;
|
||||
backoffLevel?: number;
|
||||
};
|
||||
|
||||
export type AuthFileCooldownSnapshot = {
|
||||
receivedAtMs: number;
|
||||
observedAt?: string;
|
||||
records: AuthFileCooldown[] | null;
|
||||
};
|
||||
|
||||
export type AuthFileHealth = {
|
||||
label: MessageKey;
|
||||
tone: 'success' | 'warning' | 'error' | 'neutral' | 'info';
|
||||
message: string;
|
||||
status: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const reasonKeys: Record<string, MessageKey> = {
|
||||
quota: 'authFiles.health.reason.quota',
|
||||
credential_quota: 'authFiles.health.reason.credentialQuota',
|
||||
cloudflare_challenge: 'authFiles.health.reason.cloudflare',
|
||||
invalid_grant: 'authFiles.health.reason.invalidGrant',
|
||||
unauthorized: 'authFiles.health.reason.unauthorized',
|
||||
payment_required: 'authFiles.health.reason.accessDenied',
|
||||
not_found: 'authFiles.health.reason.notFound',
|
||||
model_not_supported: 'authFiles.health.reason.modelUnsupported',
|
||||
transient_error: 'authFiles.health.reason.upstream',
|
||||
token_expired: 'authFiles.health.reason.tokenExpired',
|
||||
};
|
||||
|
||||
export function cooldownReasonKey(reason: string): MessageKey {
|
||||
return Object.prototype.hasOwnProperty.call(reasonKeys, reason)
|
||||
? reasonKeys[reason] : 'authFiles.health.reason.unknown';
|
||||
}
|
||||
|
||||
export function cooldownTimestamp(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string' || !value.trim()) return undefined;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) && timestamp > 0 ? new Date(timestamp).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function normalizeCooldown(value: unknown): AuthFileCooldown | null {
|
||||
if (!isRecord(value)) return null;
|
||||
const scope = value.scope;
|
||||
const model = typeof value.model_key === 'string' ? value.model_key.trim() : '';
|
||||
const retryAt = cooldownTimestamp(value.retry_at);
|
||||
const remainingSeconds = value.remaining_seconds;
|
||||
if ((scope !== 'credential' && scope !== 'model') || (scope === 'model' && !model)
|
||||
|| !retryAt || typeof remainingSeconds !== 'number'
|
||||
|| !Number.isSafeInteger(remainingSeconds) || remainingSeconds <= 0
|
||||
|| remainingSeconds > Number.MAX_SAFE_INTEGER / 1000) return null;
|
||||
const httpStatus = value.http_status;
|
||||
const backoffLevel = value.backoff_level;
|
||||
return {
|
||||
scope,
|
||||
...(scope === 'model' ? { model } : {}),
|
||||
retryAt,
|
||||
remainingSeconds,
|
||||
reason: readString(value, 'reason') || 'unknown',
|
||||
...(typeof httpStatus === 'number' && Number.isInteger(httpStatus)
|
||||
&& httpStatus >= 400 && httpStatus <= 599 ? { httpStatus } : {}),
|
||||
...(typeof backoffLevel === 'number' && Number.isSafeInteger(backoffLevel)
|
||||
&& backoffLevel >= 0 ? { backoffLevel } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAuthFileCooldowns(
|
||||
value: unknown,
|
||||
receivedAtMs: number,
|
||||
observedAt?: string,
|
||||
): AuthFileCooldownSnapshot | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
const snapshot = { receivedAtMs, observedAt: cooldownTimestamp(observedAt) };
|
||||
if (!Array.isArray(value)) return { ...snapshot, records: null };
|
||||
const records = value.map(normalizeCooldown);
|
||||
if (records.some((record) => record === null)) return { ...snapshot, records: null };
|
||||
return { ...snapshot, records: records as AuthFileCooldown[] };
|
||||
}
|
||||
|
||||
export function summarizeAuthFileCooldowns(snapshot: AuthFileCooldownSnapshot | undefined, nowMs: number) {
|
||||
const elapsedSeconds = snapshot ? Math.max(0, nowMs - snapshot.receivedAtMs) / 1000 : 0;
|
||||
const rows = (snapshot?.records ?? []).map((record) => ({
|
||||
record,
|
||||
remainingSeconds: Math.max(0, Math.ceil(record.remainingSeconds - elapsedSeconds)),
|
||||
}));
|
||||
const active = rows.filter((row) => row.remainingSeconds > 0);
|
||||
return {
|
||||
rows,
|
||||
active,
|
||||
modelCount: new Set(active.filter(({ record }) => record.scope === 'model').map(({ record }) => record.model)).size,
|
||||
credentialWide: active.some(({ record }) => record.scope === 'credential'),
|
||||
earliestSeconds: active.length ? Math.min(...active.map((row) => row.remainingSeconds)) : 0,
|
||||
elapsed: rows.length > 0 && active.length === 0,
|
||||
};
|
||||
}
|
||||
|
||||
const healthyMessages = new Set(['ok', 'healthy', 'ready', 'success', 'available', 'active']);
|
||||
const messageReasons: Record<string, string> = {
|
||||
'quota exhausted': 'quota',
|
||||
'cloudflare challenge': 'cloudflare_challenge',
|
||||
'token expired': 'token_expired',
|
||||
'transient upstream error': 'transient_error',
|
||||
};
|
||||
|
||||
export function authFileHealth(file: Record<string, unknown>): AuthFileHealth {
|
||||
const status = readString(file, 'status').toLowerCase();
|
||||
const rawMessage = readString(file, 'status_message', 'statusMessage');
|
||||
const message = healthyMessages.has(rawMessage.toLowerCase()) ? '' : rawMessage;
|
||||
const disabled = readBoolean(file, 'disabled') || status === 'disabled';
|
||||
const base = { status, message, disabled };
|
||||
if (disabled) return { ...base, label: 'authFiles.status.disabled', tone: 'neutral' };
|
||||
if (readBoolean(file, 'unavailable') || status === 'error') {
|
||||
const marker = message.toLowerCase();
|
||||
const reason = Object.prototype.hasOwnProperty.call(messageReasons, marker) ? messageReasons[marker] : marker;
|
||||
const reasonKey = cooldownReasonKey(reason);
|
||||
return {
|
||||
...base,
|
||||
label: reasonKey !== 'authFiles.health.reason.unknown' ? reasonKey
|
||||
: readBoolean(file, 'unavailable') ? 'authFiles.status.unavailable' : 'authFiles.health.error',
|
||||
tone: 'error',
|
||||
};
|
||||
}
|
||||
if (status === 'refreshing') return { ...base, label: 'authFiles.health.refreshing', tone: 'info' };
|
||||
if (status === 'pending') return { ...base, label: 'authFiles.health.pending', tone: 'warning' };
|
||||
if (message) return { ...base, label: 'authFiles.health.warning', tone: 'warning' };
|
||||
if (status === 'active' || status === 'ready') return { ...base, label: 'authFiles.health.active', tone: 'success' };
|
||||
return { ...base, label: 'authFiles.health.unknown', tone: 'neutral' };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export const AUTH_REQUEST_BUCKET_COUNT = 20;
|
||||
|
||||
export type AuthRequestBucket = {
|
||||
time: string;
|
||||
success: number;
|
||||
failure: number;
|
||||
rate: number | null;
|
||||
};
|
||||
|
||||
export type AuthFileRequestStats = {
|
||||
success: number | null;
|
||||
failure: number | null;
|
||||
recentAvailable: boolean;
|
||||
buckets: AuthRequestBucket[];
|
||||
recentSuccess: number;
|
||||
recentFailure: number;
|
||||
recentRate: number | null;
|
||||
};
|
||||
|
||||
function requestCount(value: unknown): number | null {
|
||||
if (typeof value !== 'number' && typeof value !== 'string') return null;
|
||||
if (typeof value === 'string' && !value.trim()) return null;
|
||||
const count = Number(value);
|
||||
return Number.isSafeInteger(count) && count >= 0 ? count : null;
|
||||
}
|
||||
|
||||
export function authFileRequestStats(file: Record<string, unknown>): AuthFileRequestStats {
|
||||
const recent = file.recent_requests ?? file.recentRequests;
|
||||
const recentAvailable = Array.isArray(recent);
|
||||
const buckets: AuthRequestBucket[] = (recentAvailable ? recent : [])
|
||||
.slice(-AUTH_REQUEST_BUCKET_COUNT)
|
||||
.map((value: unknown) => {
|
||||
const bucket = value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown> : {};
|
||||
const success = requestCount(bucket.success) ?? 0;
|
||||
const failure = requestCount(bucket.failed) ?? 0;
|
||||
return {
|
||||
time: typeof bucket.time === 'string' ? bucket.time.trim() : '',
|
||||
success,
|
||||
failure,
|
||||
rate: success + failure > 0 ? success / (success + failure) : null,
|
||||
};
|
||||
});
|
||||
while (buckets.length < AUTH_REQUEST_BUCKET_COUNT) {
|
||||
buckets.unshift({ time: '', success: 0, failure: 0, rate: null });
|
||||
}
|
||||
const recentSuccess = buckets.reduce((total, bucket) => total + bucket.success, 0);
|
||||
const recentFailure = buckets.reduce((total, bucket) => total + bucket.failure, 0);
|
||||
return {
|
||||
success: requestCount(file.success ?? file.successCount),
|
||||
failure: requestCount(file.failed ?? file.failureCount),
|
||||
recentAvailable,
|
||||
buckets,
|
||||
recentSuccess,
|
||||
recentFailure,
|
||||
recentRate: recentSuccess + recentFailure > 0
|
||||
? recentSuccess / (recentSuccess + recentFailure) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function requestRateColor(rate: number): string {
|
||||
const stops = [[239, 68, 68], [250, 204, 21], [34, 197, 94]];
|
||||
const normalized = Math.max(0, Math.min(1, rate));
|
||||
const segment = normalized < 0.5 ? 0 : 1;
|
||||
const progress = segment === 0 ? normalized * 2 : (normalized - 0.5) * 2;
|
||||
const color = stops[segment].map((value, index) =>
|
||||
Math.round(value + (stops[segment + 1][index] - value) * progress),
|
||||
);
|
||||
return `rgb(${color.join(', ')})`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { getCurrentLocale, translate } from '../i18n';
|
||||
import { isRecord, managementApi } from './managementApi';
|
||||
import { normalizeAuthFilePriorityInput } from './authFiles';
|
||||
import { authFileExcludedRulesFromPayload } from './oauthModelSettings';
|
||||
import { normalizeOAuthExcludedRules } from './oauthModels';
|
||||
|
||||
export type BooleanOverride = '' | 'true' | 'false';
|
||||
export type AuthFileSettingsDraft = {
|
||||
prefix: string;
|
||||
proxy_url: string;
|
||||
priority: string;
|
||||
weight: string;
|
||||
disable_cooling: BooleanOverride;
|
||||
websockets: BooleanOverride;
|
||||
excluded_models: string;
|
||||
headers: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const fail = (key: 'metadata' | 'headers' | 'weight' | 'priority') => {
|
||||
throw new Error(translate(getCurrentLocale(), `authFiles.settings.invalid.${key}`));
|
||||
};
|
||||
|
||||
const override = (value: unknown, allowNumber = false): BooleanOverride => {
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
||||
if (allowNumber && typeof value === 'number' && Number.isFinite(value)) return value !== 0 ? 'true' : 'false';
|
||||
if (typeof value === 'string') {
|
||||
if (['1', 't', 'T', 'TRUE', 'true', 'True'].includes(value.trim())) return 'true';
|
||||
if (['0', 'f', 'F', 'FALSE', 'false', 'False'].includes(value.trim())) return 'false';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const headersFromText = (text: string): Record<string, string> => {
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(text.trim() || '{}'); } catch { return fail('headers'); }
|
||||
if (!isRecord(value)) return fail('headers');
|
||||
const names = new Set<string>();
|
||||
const result: Record<string, string> = Object.create(null);
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const name = key.trim();
|
||||
if (!/^[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(name) || typeof item !== 'string'
|
||||
|| /[\r\n\0]/.test(item) || names.has(name.toLowerCase())) return fail('headers');
|
||||
names.add(name.toLowerCase());
|
||||
if (item.trim()) result[name] = item.trim();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const authFileSettingsFromPayload = (payload: unknown): AuthFileSettingsDraft => {
|
||||
let metadata = payload;
|
||||
if (typeof metadata === 'string') {
|
||||
try { metadata = JSON.parse(metadata); } catch { return fail('metadata'); }
|
||||
}
|
||||
if (!isRecord(metadata)) return fail('metadata');
|
||||
const read = (key: string, legacy?: string) => Object.prototype.hasOwnProperty.call(metadata, key)
|
||||
? metadata[key] : legacy ? metadata[legacy] : undefined;
|
||||
const text = (value: unknown) => typeof value === 'string' || typeof value === 'number' ? String(value) : '';
|
||||
const headers = metadata.headers == null ? {} : metadata.headers;
|
||||
if (!isRecord(headers) || Object.values(headers).some((value) => typeof value !== 'string')) return fail('headers');
|
||||
return {
|
||||
prefix: text(metadata.prefix),
|
||||
proxy_url: text(read('proxy_url', 'proxy-url')),
|
||||
priority: text(metadata.priority),
|
||||
weight: text(metadata.weight),
|
||||
disable_cooling: override(read('disable_cooling', 'disable-cooling'), true),
|
||||
websockets: override(read('websockets', 'websocket')),
|
||||
excluded_models: authFileExcludedRulesFromPayload(metadata).join('\n'),
|
||||
headers: JSON.stringify(headers, null, 2),
|
||||
note: text(metadata.note),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildAuthFileSettingsPatch = (
|
||||
original: AuthFileSettingsDraft,
|
||||
draft: AuthFileSettingsDraft,
|
||||
): Record<string, unknown> => {
|
||||
const patch: Record<string, unknown> = {};
|
||||
for (const key of ['prefix', 'proxy_url', 'note'] as const) {
|
||||
if (draft[key].trim() !== original[key].trim()) patch[key] = draft[key].trim();
|
||||
}
|
||||
if (draft.priority !== original.priority) {
|
||||
const value = normalizeAuthFilePriorityInput(draft.priority);
|
||||
if (value === null) return fail('priority');
|
||||
if (value !== normalizeAuthFilePriorityInput(original.priority)) patch.priority = value;
|
||||
}
|
||||
if (draft.weight !== original.weight) {
|
||||
const text = draft.weight.trim();
|
||||
const value = text ? Number(text) : null;
|
||||
if (value !== null && (!/^-?\d+$/.test(text) || !Number.isSafeInteger(value) || value > 1_000_000)) return fail('weight');
|
||||
const normalized = value === null ? null : Math.max(0, value);
|
||||
const previous = original.weight.trim() ? Math.max(0, Number(original.weight)) : null;
|
||||
if (normalized !== previous) patch.weight = normalized;
|
||||
}
|
||||
for (const key of ['disable_cooling', 'websockets'] as const) {
|
||||
if (draft[key] !== original[key]) patch[key] = draft[key] === '' ? null : draft[key] === 'true';
|
||||
}
|
||||
const rules = (text: string) => normalizeOAuthExcludedRules(text.split(/\r?\n/));
|
||||
const nextRules = rules(draft.excluded_models);
|
||||
if (JSON.stringify([...nextRules].sort()) !== JSON.stringify(rules(original.excluded_models).sort())) {
|
||||
patch.excluded_models = nextRules;
|
||||
}
|
||||
if (draft.headers !== original.headers) {
|
||||
const previous = headersFromText(original.headers);
|
||||
const next = headersFromText(draft.headers);
|
||||
const headers: Record<string, string> = Object.create(null);
|
||||
for (const name of new Set([...Object.keys(previous), ...Object.keys(next)])) {
|
||||
if (previous[name] !== next[name]) headers[name] = next[name] ?? '';
|
||||
}
|
||||
if (Object.keys(headers).length) patch.headers = headers;
|
||||
}
|
||||
return patch;
|
||||
};
|
||||
|
||||
type SettingsApi = {
|
||||
get: (path: string, query: Record<string, string>) => Promise<unknown>;
|
||||
patch: (path: string, body: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export const loadAuthFileSettings = async (name: string, api: SettingsApi = managementApi) =>
|
||||
authFileSettingsFromPayload(await api.get('/auth-files/download', { name }));
|
||||
|
||||
export const saveAuthFileSettings = async (
|
||||
name: string, original: AuthFileSettingsDraft, draft: AuthFileSettingsDraft,
|
||||
api: SettingsApi = managementApi,
|
||||
) => {
|
||||
const patch = buildAuthFileSettingsPatch(original, draft);
|
||||
if (!Object.keys(patch).length) return false;
|
||||
await api.patch('/auth-files/fields', { name, ...patch });
|
||||
return true;
|
||||
};
|
||||
@@ -111,6 +111,7 @@ const mergeDuplicateAuthFiles = (entries: AuthFileRecord[]) => {
|
||||
const merged = { ...sorted[0] };
|
||||
sorted.slice(1).forEach((entry) => {
|
||||
Object.entries(entry).forEach(([key, value]) => {
|
||||
if (key === 'cooldowns' && Object.prototype.hasOwnProperty.call(merged, key)) return;
|
||||
if (!hasMeaningfulValue(merged[key]) && hasMeaningfulValue(value)) merged[key] = value;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
import { AuthFileHealthStatus } from '../src/components/AuthFileHealthStatus';
|
||||
import { dedupeAuthFiles } from '../src/services/authFiles';
|
||||
import {
|
||||
authFileHealth,
|
||||
cooldownReasonKey,
|
||||
normalizeAuthFileCooldowns,
|
||||
summarizeAuthFileCooldowns,
|
||||
} from '../src/services/authFileHealth';
|
||||
|
||||
const receivedAtMs = Date.now();
|
||||
const record = {
|
||||
scope: 'model', model_key: 'model-a', reason: 'quota',
|
||||
retry_at: '2026-01-01T10:00:32Z', remaining_seconds: 32, http_status: 429, backoff_level: 0,
|
||||
};
|
||||
const normalize = (value: unknown) => normalizeAuthFileCooldowns(value, receivedAtMs, '2026-01-01T10:00:00Z');
|
||||
const render = (file: Record<string, unknown>, received = receivedAtMs) => renderToStaticMarkup(
|
||||
createElement(I18nProvider, { children: createElement(AuthFileHealthStatus, { file, receivedAtMs: received }) }),
|
||||
);
|
||||
|
||||
describe('credential health and cooldowns', () => {
|
||||
it('distinguishes old cores, unknown snapshots and known-empty restrictions', () => {
|
||||
expect(normalize(undefined)).toBeUndefined();
|
||||
expect(normalize(null)?.records).toBeNull();
|
||||
expect(normalize([])?.records).toEqual([]);
|
||||
const snapshot = normalize([record])!;
|
||||
expect(snapshot.records?.[0]).toMatchObject({
|
||||
model: 'model-a', reason: 'quota', httpStatus: 429, backoffLevel: 0,
|
||||
retryAt: '2026-01-01T10:00:32.000Z', remainingSeconds: 32,
|
||||
});
|
||||
expect(snapshot.observedAt).toBe('2026-01-01T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('keeps malformed restrictions unknown instead of silently clearing them', () => {
|
||||
for (const value of [null, {}, 'bad', [null], [{ ...record, scope: 'future' }],
|
||||
[{ ...record, model_key: '' }], [{ ...record, model_key: {} }],
|
||||
[{ ...record, retry_at: 'bad-date' }], [{ ...record, remaining_seconds: 0 }],
|
||||
[{ ...record, remaining_seconds: -1 }], [{ ...record, remaining_seconds: 1.5 }],
|
||||
[{ ...record, remaining_seconds: Infinity }], [record, { scope: 'future' }]]) {
|
||||
expect(normalize(value)?.records).toBeNull();
|
||||
}
|
||||
const diagnostics = normalize([{ ...record, http_status: 200, backoff_level: -1 }])?.records?.[0];
|
||||
expect(diagnostics?.httpStatus).toBeUndefined();
|
||||
expect(diagnostics?.backoffLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves authoritative empty/unknown snapshots when deduplicating files', () => {
|
||||
for (const cooldowns of [[], null]) {
|
||||
const [file] = dedupeAuthFiles([
|
||||
{ name: 'a.json', source: 'file', path: '/synthetic/a.json', cooldowns },
|
||||
{ name: 'a.json', source: 'memory', cooldowns: [record] },
|
||||
]);
|
||||
expect(file.cooldowns).toEqual(cooldowns);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses server-relative time despite clock skew and keeps elapsed restrictions pending confirmation', () => {
|
||||
const snapshot = normalize([record])!;
|
||||
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs - 5000).earliestSeconds).toBe(32);
|
||||
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs + 1001).earliestSeconds).toBe(31);
|
||||
const elapsed = summarizeAuthFileCooldowns(snapshot, receivedAtMs + 33_000);
|
||||
expect(elapsed.earliestSeconds).toBe(0);
|
||||
expect(elapsed.elapsed).toBe(true);
|
||||
expect(elapsed.rows).toHaveLength(1);
|
||||
expect(elapsed.active).toHaveLength(0);
|
||||
expect(authFileHealth({ status: 'error', unavailable: true, cooldowns: [] }).tone).toBe('error');
|
||||
});
|
||||
|
||||
it('separates credential-wide cooldowns from model restrictions as timers expire', () => {
|
||||
const snapshot = normalize([
|
||||
record,
|
||||
{ ...record, scope: 'credential', reason: 'credential_quota', remaining_seconds: 10 },
|
||||
]);
|
||||
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs)).toMatchObject({
|
||||
modelCount: 1, credentialWide: true, earliestSeconds: 10,
|
||||
});
|
||||
expect(summarizeAuthFileCooldowns(snapshot, receivedAtMs + 11_000)).toMatchObject({
|
||||
modelCount: 1, credentialWide: false, earliestSeconds: 21,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses appropriate status tones and only exact known diagnostic markers', () => {
|
||||
expect(authFileHealth({ status: 'active', status_message: 'OK' })).toMatchObject({ tone: 'success', message: '' });
|
||||
expect(authFileHealth({ status: 'error', unavailable: false }).tone).toBe('error');
|
||||
expect(authFileHealth({ status: 'refreshing' }).label).toBe('authFiles.health.refreshing');
|
||||
expect(authFileHealth({ status: 'pending' }).label).toBe('authFiles.health.pending');
|
||||
expect(authFileHealth({ status: 'disabled', unavailable: true }).tone).toBe('neutral');
|
||||
expect(authFileHealth({}).label).toBe('authFiles.health.unknown');
|
||||
expect(authFileHealth({ status: 'active', status_message: 'Unexpected upstream response' }).tone).toBe('warning');
|
||||
expect(authFileHealth({ status: 'error', status_message: 'token expired' }).label)
|
||||
.toBe('authFiles.health.reason.tokenExpired');
|
||||
expect(authFileHealth({ status: 'error', status_message: 'HTTP 403: policy restriction' }).label)
|
||||
.toBe('authFiles.health.error');
|
||||
for (const reason of ['future', '__proto__', 'constructor']) {
|
||||
expect(cooldownReasonKey(reason)).toBe('authFiles.health.reason.unknown');
|
||||
}
|
||||
expect(cooldownReasonKey('payment_required')).toBe('authFiles.health.reason.accessDenied');
|
||||
});
|
||||
|
||||
it('renders expandable diagnostics with escaped error text and no fabricated HTTP status', () => {
|
||||
const html = render({ status: 'error', status_message: '<script>unsafe</script>', cooldowns: [record] });
|
||||
expect(html).toContain('<details>');
|
||||
expect(html).toContain('<summary');
|
||||
expect(html).toContain('model-a');
|
||||
expect(html).toContain('HTTP 429');
|
||||
expect(html).toContain('dateTime="2026-01-01T10:00:32.000Z"');
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
expect(render({ status: 'active', cooldowns: [{ ...record, http_status: undefined }] })).not.toContain('HTTP 429');
|
||||
});
|
||||
|
||||
it('does not render elapsed timers as healthy, or hide disabled state behind a cooldown', () => {
|
||||
expect(render({ status: 'active', cooldowns: [record] }, receivedAtMs - 60_000)).toContain('冷却计时已到期');
|
||||
expect(render({ disabled: true, status: 'error', cooldowns: [record] })).toContain('已停用');
|
||||
expect(render({ status: 'error', unavailable: true })).toContain('无法确定恢复时间');
|
||||
expect(render({ status: 'active', cooldowns: null })).toContain('冷却状态未知');
|
||||
const expiredToken = render({ status: 'error', status_message: 'token expired', cooldowns: [record] }, receivedAtMs - 60_000);
|
||||
expect(expiredToken.slice(0, expiredToken.indexOf('</summary>'))).toContain('访问令牌已过期');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AuthFileQuotaPanel } from '../src/components/AuthFileQuotaPanel';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
import type { QuotaState } from '../src/services/quotaService';
|
||||
import { QuotaCard } from '../src/pages/QuotaPage';
|
||||
|
||||
const render = (quota: QuotaState) => renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={{ name: 'test.json' }} quota={quota} disabled={false} onRefresh={() => {}} /></I18nProvider>);
|
||||
|
||||
it('renders real quota values with bounded bars and keeps unknown distinct from zero', () => {
|
||||
const html = render({ status: 'success', rows: [
|
||||
{ label: 'empty', remainingPercent: 0 }, { label: 'full', remainingPercent: 100 },
|
||||
{ label: 'too high', remainingPercent: 140 }, { label: 'too low', remainingPercent: -20 },
|
||||
{ label: 'unknown', remainingPercent: null }, { label: 'invalid', remainingPercent: NaN },
|
||||
] });
|
||||
expect(html.match(/role="progressbar"/g)).toHaveLength(4);
|
||||
expect(html.match(/aria-valuenow="0"/g)).toHaveLength(2);
|
||||
expect(html.match(/aria-valuenow="100"/g)).toHaveLength(2);
|
||||
expect(html.match(/credential-quota-row unknown/g)).toHaveLength(2);
|
||||
expect(html).not.toContain('NaN');
|
||||
expect(html).not.toContain('140%');
|
||||
});
|
||||
|
||||
it('does not fabricate progress percentages while loading or on failure', () => {
|
||||
const loading = render({ status: 'loading', rows: [] });
|
||||
expect(loading).toContain('indeterminate');
|
||||
expect(loading).not.toContain('aria-valuenow');
|
||||
const error = render({ status: 'error', rows: [], error: 'upstream failed' });
|
||||
expect(error).toContain('upstream failed');
|
||||
expect(error).not.toContain('role="progressbar"');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ credits: undefined, visible: false, disabled: false },
|
||||
{ credits: 0, visible: false, disabled: false },
|
||||
{ credits: 2, visible: true, disabled: false },
|
||||
{ credits: 2, applicable: 0, remaining: 100, visible: true, disabled: false },
|
||||
{ credits: 2, loading: true, visible: true, disabled: true },
|
||||
{ credits: 2, fileDisabled: true, visible: true, disabled: true },
|
||||
{ credits: 2, provider: 'claude', visible: false, disabled: false },
|
||||
{ credits: 2, result: 'error', visible: true, disabled: false },
|
||||
{ credits: 2, result: 'refresh-error', visible: true, disabled: false },
|
||||
] as const)('matches quota-page reset visibility and availability: %j', (scenario) => {
|
||||
const values = scenario as { credits?: number; applicable?: number; remaining?: number; loading?: boolean; fileDisabled?: boolean; provider?: string; result?: 'error' | 'refresh-error'; visible: boolean; disabled: boolean };
|
||||
const file = { name: 'test.json', provider: values.provider ?? 'codex', disabled: values.fileDisabled ?? false };
|
||||
const quota: QuotaState = {
|
||||
status: values.loading ? 'loading' : 'success', rows: [{ label: '5h', remainingPercent: values.remaining ?? 0 }],
|
||||
resetCredits: values.credits, resetCreditsApplicable: values.applicable,
|
||||
pendingAction: values.loading ? 'reset' : undefined,
|
||||
actionResult: values.result ? { action: 'reset', status: values.result, error: 'test error' } : undefined,
|
||||
};
|
||||
const onReset = file.provider === 'codex' ? () => {} : undefined;
|
||||
const credentialHtml = renderToStaticMarkup(<I18nProvider><AuthFileQuotaPanel file={file} quota={quota} disabled={file.disabled} onRefresh={() => {}} onReset={onReset} /></I18nProvider>);
|
||||
const quotaHtml = renderToStaticMarkup(<I18nProvider><QuotaCard file={file} quota={quota} onRefresh={() => {}} onReset={onReset} /></I18nProvider>);
|
||||
const buttonState = (html: string) => {
|
||||
const button = html.match(/<button[^>]*title="重置额度"[^>]*>重置额度<\/button>/)?.[0];
|
||||
return { visible: Boolean(button), disabled: Boolean(button?.includes('disabled=""')) };
|
||||
};
|
||||
expect(buttonState(credentialHtml)).toEqual({ visible: values.visible, disabled: values.disabled });
|
||||
expect(buttonState(credentialHtml)).toEqual(buttonState(quotaHtml));
|
||||
if (values.loading) expect(credentialHtml).toContain('正在提交重置并刷新额度');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { authFileRequestStats, requestRateColor } from '../src/services/authFileRequests';
|
||||
|
||||
describe('credential request statistics', () => {
|
||||
it('keeps cumulative counts separate from the recent success rate and preserves server times', () => {
|
||||
const stats = authFileRequestStats({
|
||||
success: 900,
|
||||
failed: 100,
|
||||
recent_requests: [
|
||||
{ time: '23:50-00:00', success: 3, failed: 1 },
|
||||
{ time: '00:00-00:10', success: 0, failed: 2 },
|
||||
],
|
||||
});
|
||||
expect(stats.success).toBe(900);
|
||||
expect(stats.failure).toBe(100);
|
||||
expect(stats.recentSuccess).toBe(3);
|
||||
expect(stats.recentFailure).toBe(3);
|
||||
expect(stats.recentRate).toBe(0.5);
|
||||
expect(stats.buckets).toHaveLength(20);
|
||||
expect(stats.buckets[18]).toEqual({ time: '23:50-00:00', success: 3, failure: 1, rate: 0.75 });
|
||||
expect(stats.buckets[19].rate).toBe(0);
|
||||
expect(stats.buckets[0].rate).toBeNull();
|
||||
});
|
||||
|
||||
it('distinguishes unsupported statistics from an empty interval, without inventing a success rate', () => {
|
||||
const missing = authFileRequestStats({});
|
||||
expect(missing.recentAvailable).toBe(false);
|
||||
expect(missing.success).toBeNull();
|
||||
expect(missing.failure).toBeNull();
|
||||
expect(missing.recentRate).toBeNull();
|
||||
const empty = authFileRequestStats({ success: 0, failed: 0, recent_requests: [] });
|
||||
expect(empty.recentAvailable).toBe(true);
|
||||
expect(empty.success).toBe(0);
|
||||
expect(empty.failure).toBe(0);
|
||||
expect(empty.recentRate).toBeNull();
|
||||
expect(empty.buckets.every((bucket) => bucket.rate === null)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts numeric strings and camelCase fields while rejecting malformed counts', () => {
|
||||
const stats = authFileRequestStats({
|
||||
successCount: ' 12 ', failureCount: '2',
|
||||
recentRequests: [
|
||||
null,
|
||||
{ success: -2, failed: true },
|
||||
{ success: Infinity, failed: 'NaN' },
|
||||
{ success: 1.2, failed: [] },
|
||||
{ time: '10:00-10:10', success: '3', failed: '1' },
|
||||
],
|
||||
});
|
||||
expect(stats.success).toBe(12);
|
||||
expect(stats.failure).toBe(2);
|
||||
expect(stats.recentRate).toBe(0.75);
|
||||
expect(stats.buckets.slice(0, 19).every((bucket) => bucket.rate === null)).toBe(true);
|
||||
for (const value of ['', ' ', -1, true, [], {}, NaN, Infinity, 1.5]) {
|
||||
expect(authFileRequestStats({ success: value }).success).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows only the latest twenty intervals in chronological order', () => {
|
||||
const recent = Array.from({ length: 25 }, (_, index) => ({
|
||||
time: `bucket-${index}`, success: index, failed: 0,
|
||||
}));
|
||||
const stats = authFileRequestStats({ recent_requests: recent });
|
||||
expect(stats.buckets).toHaveLength(20);
|
||||
expect(stats.buckets[0].time).toBe('bucket-5');
|
||||
expect(stats.buckets[19].time).toBe('bucket-24');
|
||||
expect(stats.recentRate).toBe(1);
|
||||
expect(recent).toHaveLength(25);
|
||||
});
|
||||
|
||||
it('maps failure, mixed and success rates onto the red-yellow-green scale', () => {
|
||||
expect(requestRateColor(0)).toBe('rgb(239, 68, 68)');
|
||||
expect(requestRateColor(0.5)).toBe('rgb(250, 204, 21)');
|
||||
expect(requestRateColor(1)).toBe('rgb(34, 197, 94)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { authFileSettingsFromPayload, buildAuthFileSettingsPatch, loadAuthFileSettings, saveAuthFileSettings } from '../src/services/authFileSettings';
|
||||
|
||||
describe('credential settings', () => {
|
||||
it('reads all fields and legacy aliases without retaining credential tokens', () => {
|
||||
const draft = authFileSettingsFromPayload(JSON.stringify({ prefix: 'team', 'proxy-url': 'socks5://localhost:1080', priority: -4, weight: 6,
|
||||
'disable-cooling': false, websocket: true, 'excluded-models': [' GPT-* ', 'gpt-*'], headers: { 'X-Team': 'test' }, note: 'note', access_token: 'secret' }));
|
||||
expect(draft).toEqual({ prefix: 'team', proxy_url: 'socks5://localhost:1080', priority: '-4', weight: '6', disable_cooling: 'false',
|
||||
websockets: 'true', excluded_models: 'gpt-*', headers: '{\n "X-Team": "test"\n}', note: 'note' });
|
||||
expect(JSON.stringify(draft)).not.toContain('secret');
|
||||
});
|
||||
|
||||
it('patches only changed settings while tokens or other fields refresh concurrently', async () => {
|
||||
const file = { access_token: 'before', priority: 5, note: 'old', auth_index: 'private-index' };
|
||||
const writes: unknown[] = [];
|
||||
const api = {
|
||||
async get(path: string, query: Record<string, string>) {
|
||||
expect([path, query]).toEqual(['/auth-files/download', { name: 'test.json' }]);
|
||||
return { ...file };
|
||||
},
|
||||
async patch(path: string, body: Record<string, unknown>) {
|
||||
writes.push({ path, body });
|
||||
const { name, ...fields } = body;
|
||||
Object.assign(file, fields);
|
||||
},
|
||||
};
|
||||
const original = await loadAuthFileSettings('test.json', api);
|
||||
file.access_token = 'refreshed';
|
||||
file.priority = 9;
|
||||
expect(await saveAuthFileSettings('test.json', original, { ...original, note: 'new' }, api)).toBe(true);
|
||||
expect(writes).toEqual([{ path: '/auth-files/fields', body: { name: 'test.json', note: 'new' } }]);
|
||||
expect(file).toEqual({ access_token: 'refreshed', priority: 9, note: 'new', auth_index: 'private-index' });
|
||||
expect(await saveAuthFileSettings('test.json', original, original, api)).toBe(false);
|
||||
expect(writes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('honors explicit canonical defaults and backend-supported boolean representations', () => {
|
||||
expect(authFileSettingsFromPayload({ disable_cooling: null, 'disable-cooling': true, excluded_models: [], 'excluded-models': ['old'] }))
|
||||
.toMatchObject({ disable_cooling: '', excluded_models: '' });
|
||||
expect(authFileSettingsFromPayload({ disable_cooling: 0, websockets: 'TRUE' }))
|
||||
.toMatchObject({ disable_cooling: 'false', websockets: 'true' });
|
||||
expect(authFileSettingsFromPayload({ disable_cooling: ' 1 ', websockets: 'False' }))
|
||||
.toMatchObject({ disable_cooling: 'true', websockets: 'false' });
|
||||
});
|
||||
|
||||
it('clears all nine settings with backend-compatible values', () => {
|
||||
const original = authFileSettingsFromPayload({ prefix: 'p', proxy_url: 'http://proxy', priority: 4, weight: 4, disable_cooling: true,
|
||||
websockets: true, excluded_models: ['model'], headers: { 'X-One': '1', 'X-Two': '2' }, note: 'note' });
|
||||
const empty = authFileSettingsFromPayload({});
|
||||
expect(buildAuthFileSettingsPatch(original, empty)).toEqual({ prefix: '', proxy_url: '', priority: 0, weight: null,
|
||||
disable_cooling: null, websockets: null, excluded_models: [], headers: { 'X-One': '', 'X-Two': '' }, note: '' });
|
||||
});
|
||||
|
||||
it('merges header edits and deletions without resending unchanged headers', () => {
|
||||
const original = authFileSettingsFromPayload({ headers: { 'X-Keep': 'a', 'X-Remove': 'b', 'X-Edit': 'old' } });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, headers: '{"X-Keep":"a","X-Edit":"new","X-Add":"c"}' }))
|
||||
.toEqual({ headers: { 'X-Remove': '', 'X-Edit': 'new', 'X-Add': 'c' } });
|
||||
});
|
||||
|
||||
it('preserves inheritance and recognizes semantically unchanged values', () => {
|
||||
const original = authFileSettingsFromPayload({ priority: 0, weight: 1, excluded_models: ['b', 'a'], headers: { 'X-Team': 'a' } });
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, priority: '', weight: '01', excluded_models: ' A \nb\na', headers: '{"X-Team":"a"}' })).toEqual({});
|
||||
expect(buildAuthFileSettingsPatch(original, { ...original, disable_cooling: 'false', websockets: 'true', weight: '-3' }))
|
||||
.toEqual({ disable_cooling: false, websockets: true, weight: 0 });
|
||||
});
|
||||
|
||||
it('rejects unsafe values before sending a request', async () => {
|
||||
const original = authFileSettingsFromPayload({});
|
||||
for (const change of [{ priority: '1.5' }, { priority: '9007199254740992' }, { weight: '1e3' }, { weight: '1000001' }, { weight: 'NaN' },
|
||||
{ headers: '[]' }, { headers: '{"X-Test":3}' }, { headers: '{"Invalid Header":"a"}' }, { headers: '{"X-Test":"a\\r\\nb"}' },
|
||||
{ headers: '{"X-Test":"a","x-test":"b"}' }]) {
|
||||
let called = false;
|
||||
const api = { get: async () => ({}), patch: async () => { called = true; } };
|
||||
await expect(saveAuthFileSettings('test.json', original, { ...original, ...change }, api)).rejects.toThrow();
|
||||
expect(called).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks editing when metadata is malformed and propagates save failures', async () => {
|
||||
for (const payload of ['not-json', [], null, { headers: [] }, { excluded_models: [3] }]) {
|
||||
expect(() => authFileSettingsFromPayload(payload)).toThrow();
|
||||
}
|
||||
const original = authFileSettingsFromPayload({});
|
||||
await expect(saveAuthFileSettings('test.json', original, { ...original, note: 'test' }, {
|
||||
get: async () => ({}), patch: async () => { throw new Error('save failed'); },
|
||||
})).rejects.toThrow('save failed');
|
||||
});
|
||||
});
|
||||
+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-requests.tsx"></script></body></html>
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
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 { QuotaPage } from '../../src/pages/QuotaPage';
|
||||
import { updateQuotaCache } from '../../src/services/quotaCache';
|
||||
import '../../src/styles.css';
|
||||
|
||||
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';
|
||||
|
||||
let refreshCount = 0;
|
||||
let resetCount = 0;
|
||||
const metadata: Record<string, Record<string, unknown>> = {};
|
||||
const settingsFor = (name: string) => metadata[name] ??= {
|
||||
prefix: 'team', proxy_url: '', priority: 3, weight: 2, disable_cooling: false,
|
||||
websockets: true, excluded_models: ['gpt-old-*'], headers: { 'X-Team': 'demo' }, note: '开发环境 · 主用凭证',
|
||||
};
|
||||
const cardFiles = [
|
||||
{ name: 'codex-demo.json', provider: 'codex', auth_index: 'demo', email: 'demo@example.com', status: 'active', cooldowns: [] },
|
||||
{ name: 'claude-idle.json', provider: 'claude', auth_index: 'idle', email: 'developer@example.com', status: 'error', unavailable: true, status_message: 'Rate limit exceeded', cooldowns: [{ scope: 'model', reason: 'quota', model_key: 'claude-example', remaining_seconds: 620, retry_at: new Date(Date.now() + 620_000).toISOString(), http_status: 429 }] },
|
||||
{ name: 'codex-disabled.json', provider: 'codex', auth_index: 'disabled', disabled: true, status: 'disabled', cooldowns: [] },
|
||||
{ name: 'runtime.json', provider: 'codex', auth_index: 'runtime', runtime_only: true, status: 'active', cooldowns: [] },
|
||||
{ name: 'a-very-long-credential-file-name-with-extra-identification-for-layout-testing.json', provider: 'codex', auth_index: 'long', status: 'pending', cooldowns: [] },
|
||||
];
|
||||
if (params.has('cards')) updateQuotaCache({
|
||||
'claude-idle.json::idle': { status: 'success', rows: [{ label: '5 小时', remainingPercent: 0, resetAtMs: Date.now() + 620_000 }, { label: '每周', remainingPercent: 24, resetAtMs: Date.now() + 3_600_000 }] },
|
||||
'codex-disabled.json::disabled': { status: 'success', rows: [{ label: '5 小时', remainingPercent: null, detail: '上游未返回百分比' }] },
|
||||
});
|
||||
const buckets = Array.from({ length: 20 }, (_, index) => ({
|
||||
time: `${String(10 + Math.floor(index / 6)).padStart(2, '0')}:${String(index % 6 * 10).padStart(2, '0')}-${String(10 + Math.floor((index + 1) / 6)).padStart(2, '0')}:${String((index + 1) % 6 * 10).padStart(2, '0')}`,
|
||||
success: index < 3 ? 0 : index === 15 ? 0 : 18,
|
||||
failed: index < 3 ? 0 : index === 15 ? 4 : index % 4 === 0 ? 6 : 0,
|
||||
}));
|
||||
|
||||
mockIPC(async (cmd, args) => {
|
||||
if (cmd === 'set_app_locale') return null;
|
||||
if (cmd === 'management_request') {
|
||||
const request = args?.request as { path: string; method: string; query?: Record<string, string>; body?: Record<string, unknown> };
|
||||
if (request.path === '/auth-files/download') {
|
||||
if (params.has('metadataError')) throw new Error('Fixture metadata read failed');
|
||||
return { ...settingsFor(request.query?.name ?? '') };
|
||||
}
|
||||
if (request.path === '/auth-files/models') {
|
||||
if (params.has('catalogError')) throw new Error('Fixture catalog read failed');
|
||||
return { models: [{ id: 'gpt-example' }, { id: 'gpt-old-example' }, { id: 'claude-example' }] };
|
||||
}
|
||||
if (request.path === '/auth-files/fields' && request.method === 'PATCH') {
|
||||
if (params.has('saveError')) throw new Error('Fixture settings save failed');
|
||||
const { name, headers, ...patch } = request.body ?? {};
|
||||
const target = settingsFor(String(name));
|
||||
Object.assign(target, patch);
|
||||
if (headers) {
|
||||
const merged = { ...(target.headers as Record<string, string>) };
|
||||
for (const [key, value] of Object.entries(headers as Record<string, string>)) {
|
||||
if (value) merged[key] = value;
|
||||
else delete merged[key];
|
||||
}
|
||||
target.headers = merged;
|
||||
}
|
||||
const output = document.getElementById('fixture-writes');
|
||||
if (output) output.textContent = JSON.stringify(request.body, null, 2);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
if (request.path === '/api-call') {
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
if (params.has('quotaError')) throw new Error('Fixture quota unavailable');
|
||||
if (String(request.body?.url).endsWith('/consume')) {
|
||||
resetCount += 1;
|
||||
const output = document.getElementById('fixture-reset-count');
|
||||
if (output) output.textContent = String(resetCount);
|
||||
return { status_code: 200, body: '{}' };
|
||||
}
|
||||
return { status_code: 200, body: JSON.stringify(String(request.body?.url).includes('reset-credits')
|
||||
? { available_count: Math.max(0, 2 - resetCount), applicable_available_count: 0, credits: [{ reset_type: 'codex_rate_limits', status: 'available', expires_at: '2030-01-01T00:00:00Z' }] }
|
||||
: { plan_type: 'Plus', rate_limit: { primary_window: { used_percent: resetCount ? 0 : 16, limit_window_seconds: 18000, reset_after_seconds: 3600 }, secondary_window: { used_percent: resetCount ? 0 : 72, limit_window_seconds: 604800, reset_after_seconds: 86400 } } }) };
|
||||
}
|
||||
if (request.path === '/auth-files' && request.method === 'GET') {
|
||||
refreshCount += 1;
|
||||
if (params.has('cards')) return { files: cardFiles.map((file) => ({ source: 'file', size: 2048, updated_at: '2026-09-20T01:20:00Z', success: 1200, failed: 28, recent_requests: buckets, ...file, ...settingsFor(file.name) })) };
|
||||
if (params.has('health')) {
|
||||
const now = Date.now();
|
||||
const timer = (scope: string, reason: string, seconds: number, model?: string, httpStatus?: number) => ({
|
||||
scope, reason, remaining_seconds: seconds,
|
||||
retry_at: new Date(now + seconds * 1000 + 3_600_000).toISOString(),
|
||||
model_key: model, http_status: httpStatus,
|
||||
});
|
||||
const recovered = params.has('fast') && refreshCount > 1;
|
||||
const files = [
|
||||
{ name: '01-healthy.json', status: 'active', cooldowns: [], status_message: 'ok' },
|
||||
{ name: '02-quota.json', status: recovered ? 'active' : 'error', unavailable: !recovered,
|
||||
status_message: recovered ? '' : 'quota exhausted',
|
||||
cooldowns: recovered ? [] : [
|
||||
timer('credential', 'credential_quota', params.has('fast') ? 3 : 125),
|
||||
timer('model', 'quota', params.has('fast') ? 3 : 125, 'gpt-example', 429),
|
||||
] },
|
||||
{ name: '03-model-errors.json', status: 'error', unavailable: false,
|
||||
cooldowns: [
|
||||
{ ...timer('model', 'transient_error', 45, 'claude-example', 503), backoff_level: 0 },
|
||||
timer('model', 'model_not_supported', 7200, 'model-with-a-long-name-for-wrapping-layout-verification', 404),
|
||||
] },
|
||||
{ name: '04-auth-expired.json', status: 'error', unavailable: true, status_message: 'token expired', cooldowns: [] },
|
||||
{ name: '05-pending.json', status: 'pending', cooldowns: [] },
|
||||
{ name: '06-refreshing.json', status: 'refreshing', cooldowns: [] },
|
||||
{ name: '07-disabled.json', status: 'disabled', disabled: true, cooldowns: [] },
|
||||
{ name: '08-unknown-cooldown.json', status: 'unknown', cooldowns: null },
|
||||
{ name: '09-legacy-error.json', status: 'error', status_message: '{"error":{"code":"upstream_error","message":"Example upstream failure with a long message. Refresh the list after the provider becomes available."}}' },
|
||||
];
|
||||
return {
|
||||
observed_at: new Date(now + 3_600_000).toISOString(),
|
||||
files: files.map((file) => ({ provider: 'codex', auth_index: file.name,
|
||||
source: 'file', size: 1024, success: 20, failed: 2, recent_requests: buckets, ...file })),
|
||||
};
|
||||
}
|
||||
return { files: [
|
||||
{ name: 'codex-demo.json', provider: 'codex', email: 'demo@example.com',
|
||||
auth_index: 'demo', source: 'file', size: 2048, status: 'ready',
|
||||
success: 1200 + refreshCount, failed: 28, recent_requests: buckets },
|
||||
{ name: 'claude-idle.json', provider: 'claude', auth_index: 'idle',
|
||||
source: 'file', size: 1024, status: 'active', success: 0, failed: 0,
|
||||
recent_requests: buckets.map((bucket) => ({ ...bucket, success: 0, failed: 0 })) },
|
||||
{ name: 'gemini-disabled.json', provider: 'gemini', auth_index: 'disabled',
|
||||
source: 'file', disabled: true, size: 1024, success: 0, failed: 40,
|
||||
recent_requests: buckets.map((bucket) => ({ ...bucket, success: 0, failed: 2 })) },
|
||||
{ name: 'legacy-no-stats.json', provider: 'codex', auth_index: 'legacy', size: 1024 },
|
||||
] };
|
||||
}
|
||||
}
|
||||
throw new Error(`Unhandled fixture command: ${cmd}`);
|
||||
});
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<I18nProvider>
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar" aria-hidden="true" />
|
||||
<div className="workspace">
|
||||
<main className="content">{params.has('quotaPage') ? <QuotaPage /> : <AuthFileManagementPage />}{params.has('cards') ? <><pre id="fixture-writes" aria-label="Fixture saved payload" /><output id="fixture-reset-count" aria-label="Fixture reset calls">0</output></> : null}</main>
|
||||
</div>
|
||||
</div>
|
||||
</I18nProvider>,
|
||||
);
|
||||
Reference in new issue
Block a user