Files
EasyCLIProxyAPI/src/components/AuthFileHealthStatus.tsx
T
lzt404 fe83731b94 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
2026-09-20 18:09:32 +08:00

144 lines
7.5 KiB
TypeScript

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>
);
}