feat(quota): refine quota actions and usage metrics layout

This commit is contained in:
lzt404 committed 2026-10-04 19:57:22 +08:00
1 parent 3c5114ebbb
commit d4e49a240c
14 files changed
+230 -37

No files matched your search

+34 -3
View File
@@ -97,15 +97,46 @@
min-width: 0;
padding: 2px 5px;
overflow: hidden;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
padding: 2px 7px;
border: 0;
border-radius: 0;
background: transparent;
font-size: 10px;
font-weight: 550;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Dense quota cells follow the compact overview treatment: quiet labels and a
single visual weight for the remaining amount. */
.credential-quota-dense .credential-quota-label span {
border-radius: 5px;
background: color-mix(in srgb, var(--text-tertiary) 8%, var(--bg-card));
color: var(--text-secondary);
font-weight: 600;
}
.credential-quota-dense .credential-quota-track {
height: 6px;
border-radius: 999px;
background: color-mix(in srgb, var(--text-tertiary) 10%, var(--bg-card));
}
.credential-quota-dense .credential-quota-track i {
border-radius: inherit;
background: var(--quota-color, var(--success-color));
}
.credential-quota-dense .credential-quota-row.high { --quota-color: #20bf62; }
.credential-quota-dense .credential-quota-row.medium { --quota-color: #e6a523; }
.credential-quota-dense .credential-quota-row.low { --quota-color: #e25555; }
.credential-quota-dense .credential-quota-row small {
color: var(--text-tertiary);
font-variant-numeric: tabular-nums;
}
.credential-quota-dense .credential-quota-empty {
margin: 0;
color: var(--text-tertiary);
font-size: 11px;
}
.credential-quota-dense .credential-quota-label strong {
font-size: 12px;
line-height: 1;
+10 -7
View File
@@ -9,7 +9,8 @@
.auth-file-usage-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
gap: 8px 12px;
align-items: center;
}
.auth-file-usage-metric {
@@ -22,8 +23,8 @@
.auth-file-usage-metric svg {
box-sizing: content-box;
flex-shrink: 0;
padding: 2px;
border-radius: 4px;
padding: 3px;
border-radius: 5px;
color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
}
@@ -34,6 +35,11 @@
background: color-mix(in srgb, var(--success-color) 10%, transparent);
}
.auth-file-usage-metric.rate svg {
color: #8b5cf6;
background: color-mix(in srgb, #8b5cf6 10%, transparent);
}
.auth-file-usage-metric.failure svg {
color: var(--failure-badge-text);
background: color-mix(in srgb, var(--failure-badge-text) 10%, transparent);
@@ -48,10 +54,7 @@
}
.auth-file-usage-scope {
display: block;
margin-top: 5px;
color: var(--text-tertiary);
font-size: var(--font-size-meta);
display: none;
}
@container auth-file-usage (max-width: 130px) {
+2 -5
View File
@@ -1,4 +1,4 @@
import { Activity, Check, Send, X } from 'lucide-react';
import { Check, Percent, Send, X } from 'lucide-react';
import { useI18n } from '../i18n';
import { authFileRequestStats } from '../services/authFileRequests';
import { formatUsageNumber } from '../services/usageNumber';
@@ -16,7 +16,7 @@ export function AuthFileUsageSummary({ file }: { file: Record<string, unknown> }
const rateText = rate === null ? '—' : formatNumber(rate, { style: 'percent', maximumFractionDigits: 1 });
const metrics = [
{ key: 'total', Icon: Send, value: countText(total), label: `${t('usage.stat.requests')}: ${exactCount(total)}` },
{ key: 'rate', Icon: Activity, value: rateText, label: t('authFiles.requests.rate', { rate: rateText }) },
{ key: 'rate', Icon: Percent, value: rateText, label: t('authFiles.requests.rate', { rate: rateText }) },
{ key: 'success', Icon: Check, value: countText(success), label: t('authFiles.requests.success', { count: exactCount(success) }) },
{ key: 'failure', Icon: X, value: countText(failure), label: t('authFiles.requests.failure', { count: exactCount(failure) }) },
];
@@ -31,9 +31,6 @@ export function AuthFileUsageSummary({ file }: { file: Record<string, unknown> }
</span>
))}
</div>
<span className="auth-file-usage-scope" title={t('authFiles.requests.totalsHint')}>
{t('authFiles.usage.runtimeTotals')}
</span>
</div>
);
}
+6
View File
@@ -22,6 +22,8 @@ export const authFileListZhCN = {
'authFiles.list.next': '下一页',
'authFiles.usage.runtimeTotals': '运行累计',
'authFiles.usage.title': '累计用量',
'authFiles.toolbar.refreshList': '刷新列表',
'authFiles.toolbar.refreshQuota': '刷新额度',
} as const;
export const authFileListEn: Record<keyof typeof authFileListZhCN, string> = {
@@ -48,6 +50,8 @@ export const authFileListEn: Record<keyof typeof authFileListZhCN, string> = {
'authFiles.list.next': 'Next',
'authFiles.usage.runtimeTotals': 'Core session totals',
'authFiles.usage.title': 'Cumulative usage',
'authFiles.toolbar.refreshList': 'Refresh list',
'authFiles.toolbar.refreshQuota': 'Refresh quota',
};
export const authFileListJa: Record<keyof typeof authFileListZhCN, string> = {
@@ -74,4 +78,6 @@ export const authFileListJa: Record<keyof typeof authFileListZhCN, string> = {
'authFiles.list.next': '次へ',
'authFiles.usage.runtimeTotals': 'コア稼働中の累計',
'authFiles.usage.title': '累計使用量',
'authFiles.toolbar.refreshList': '一覧を更新',
'authFiles.toolbar.refreshQuota': 'クォータを更新',
};
+2 -2
View File
@@ -449,8 +449,8 @@ export const jaOverrides = {
'quota.queryableCredentials.other': '照会可能な認証情報 {count} 件',
'quota.credentials.one': '認証情報 {count} 件',
'quota.credentials.other': '認証情報 {count} 件',
'quota.readList': '一覧を読み込む',
'quota.refreshAll': 'すべて更新',
'quota.readList': '一覧を更新',
'quota.refreshAll': 'クォータを更新',
'quota.loadingFiles': '認証ファイルを読み込んでいます',
'quota.empty.title': '照会可能なクォータがありません',
'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。',
+2 -2
View File
@@ -436,8 +436,8 @@ export const en: Record<MessageKey, string> = {
'quota.queryableCredentials.other': '{count} queryable credentials',
'quota.credentials.one': '{count} credential',
'quota.credentials.other': '{count} credentials',
'quota.readList': 'Load List',
'quota.refreshAll': 'Refresh All',
'quota.readList': 'Refresh List',
'quota.refreshAll': 'Refresh Quota',
'quota.loadingFiles': 'Loading authentication files',
'quota.empty.title': 'No quota data available',
'quota.empty.description': 'Add a credential that supports quota queries under Auth Files first.',
+2 -2
View File
@@ -435,8 +435,8 @@ export const zhCN = {
'quota.queryableCredentials.other': '{count} 个可查询凭据',
'quota.credentials.one': '{count} 个凭据',
'quota.credentials.other': '{count} 个凭据',
'quota.readList': '读取列表',
'quota.refreshAll': '刷新全部',
'quota.readList': '刷新列表',
'quota.refreshAll': '刷新额度',
'quota.loadingFiles': '读取凭证文件中',
'quota.empty.title': '暂无可查询配额',
'quota.empty.description': '先在凭证文件中添加支持配额查询的凭据。',
+29 -10
View File
@@ -379,6 +379,9 @@ let agentViewStateCache: Record<'full' | 'embedded', Partial<Record<AgentClientI
};
const AGENT_MODEL_SELECTIONS_KEY = 'cpa-gui.agent-model-selections.v1';
// Keep the last successful data across page visits; revalidate it in the background.
let agentStatusesCache: AgentConfigStatus[] | null = null;
const agentModelsCache: Partial<Record<AgentClientId, ModelOption[]>> = {};
const AGENT_SELECTED_CLIENT_KEY = 'cpa-gui.agent-selected-client.v1';
const AGENT_LAUNCH_DIRECTORY_HISTORY_KEY = 'cpa-gui.agent-launch-directory-history.v1';
@@ -834,8 +837,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const setConfigurationNotice = (configurationNotice: string) => updateViewState({ configurationNotice });
const setClearNotice = (clearNotice: string) => updateViewState({ clearNotice });
const setLaunchError = (launchError: string) => updateViewState({ launchError });
const [statuses, setStatuses] = useState<AgentConfigStatus[]>([]);
const [models, setModels] = useState<ModelOption[]>([]);
const [statuses, setStatuses] = useState<AgentConfigStatus[]>(() => agentStatusesCache ?? []);
const [modelData, setModelData] = useState(() => ({
client: selected, models: agentModelsCache[selected] ?? [],
}));
const models = modelData.client === selected ? modelData.models : agentModelsCache[selected] ?? [];
const [modelByClient, setModelByClient] = useState<Partial<Record<AgentClientId, string>>>(
readAgentModelSelections,
);
@@ -851,7 +857,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const [claudeCustomMappingByClient, setClaudeCustomMappingByClientState] = useState(
() => ({ ...claudeCustomMappingCache }),
);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(() => agentStatusesCache === null);
const [modelLoading, setModelLoading] = useState(false);
const [busyAction, setBusyAction] = useState<
'backup' | 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'native-oauth' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null
@@ -890,6 +896,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
);
const [piProviderUpdateStatus, setPiProviderUpdateStatus] = useState<PiProviderUpdateStatus | null>(null);
const modelRequestRef = useRef(0);
const statusRequestRef = useRef(0);
const piUpdateRequestRef = useRef(0);
const claudeModelMappingsDirtyRef = useRef(claudeModelMappingsDirtyCache);
const launchDirectoryDialogRef = useDialogFocusTrap<HTMLElement>({
@@ -947,28 +954,40 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}, []);
const loadStatuses = useCallback(async (forceRefresh = false) => {
const requestId = ++statusRequestRef.current;
const command = forceRefresh
? 'refresh_agent_config_statuses'
: 'get_agent_config_statuses';
const nextStatuses = await invoke<AgentConfigStatus[]>(command);
if (requestId !== statusRequestRef.current) return;
setStatuses(nextStatuses);
}, []);
useEffect(() => {
if (statuses.length) agentStatusesCache = statuses;
}, [statuses]);
useEffect(() => () => {
// Responses from a previous visit must not overwrite the shared cache.
modelRequestRef.current += 1;
statusRequestRef.current += 1;
}, []);
const loadDeepSeekHarnessProcessStatus = useCallback(async () => {
const status = await invoke<DeepSeekHarnessProcessStatus>('get_deepseek_harness_process_status');
setDeepSeekHarnessProcessStatus(status);
}, []);
const loadModels = useCallback(async (client: AgentClientId, preferredModel = '') => {
const loadModels = useCallback(async (client: AgentClientId, preferredModel = '', background = false) => {
const requestId = modelRequestRef.current + 1;
modelRequestRef.current = requestId;
setModelLoading(true);
setModelLoading(!background || agentModelsCache[client] === undefined);
setModelError('');
setModels([]);
try {
const nextModels = await invoke<ModelOption[]>('get_agent_models', { client });
if (modelRequestRef.current !== requestId) return;
setModels(nextModels);
agentModelsCache[client] = nextModels;
setModelData({ client, models: nextModels });
setModelSelectionError('');
setModelByClient((current) => {
const next = {
@@ -998,7 +1017,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
}, [loadStatuses]);
useEffect(() => {
setLoading(true);
setLoading(agentStatusesCache === null);
setDetectionError('');
void loadStatuses()
.catch((requestError) => setDetectionError(String(requestError)))
@@ -1009,7 +1028,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
if (loading) return;
const status = statuses.find((status) => status.id === selected);
const preferredModel = status?.currentModel ?? '';
void loadModels(selected, preferredModel);
void loadModels(selected, preferredModel, true);
}, [loadModels, loading, selected]);
useEffect(() => {
@@ -1021,7 +1040,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
void loadStatuses().catch((requestError) => {
if (!disposed) setDetectionError(String(requestError));
});
void loadModels(selected);
void loadModels(selected, '', true);
}).then((unlisten) => {
if (disposed) unlisten();
else stop = unlisten;
+46 -1
View File
@@ -72,8 +72,10 @@
.auth-list-recent .auth-file-requests-timeline { justify-content: center; gap: 5px; font-size: 10px; }
.auth-list-recent .auth-file-requests-unavailable { font-size: 10px; line-height: 1.4; text-align: center; color: var(--auth-muted); }
.auth-list-usage .auth-file-usage { color: var(--auth-text); }
.auth-list-usage .auth-file-usage-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px 8px; }
.auth-list-usage .auth-file-usage-metrics { grid-template-columns: max-content max-content; grid-template-rows: repeat(2, 20px); gap: 0 18px; justify-content: center; align-content: center; }
.auth-list-usage .auth-file-usage-metric { gap: 5px; }
.auth-list-usage .auth-file-usage-metric { min-height: 0; height: 20px; line-height: 1; }
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; }
.auth-list-usage .auth-file-usage-metric svg { width: 12px; height: 12px; padding: 2px; border-radius: 4px; }
.auth-list-usage .auth-file-usage-metric.total svg { color: var(--auth-accent); background: color-mix(in srgb, var(--auth-accent) 9%, var(--bg-card)); }
.auth-list-usage .auth-file-usage-metric strong { font-size: 12.75px; line-height: 1.2; font-weight: 750; }
@@ -195,3 +197,46 @@
.auth-files-heading-actions { margin-left: auto; }
}
@media (prefers-reduced-motion: reduce) { .auth-list-switch > span { transition: none; } }
/* Keep the quota column airy without the heavy framed inset used previously. */
.auth-files-page .auth-list-quota {
min-width: 0;
padding: 0 10px;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.auth-files-page .auth-list-quota .credential-quota,
.auth-files-page .auth-list-quota .credential-quota.credential-quota-compact,
.auth-files-page .auth-list-quota .credential-quota.credential-quota-dense {
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
padding: 0 !important;
}
.auth-files-page .credential-quota-dense {
width: 100%;
min-width: 0;
gap: 7px;
}
.auth-files-page .credential-quota-dense .credential-quota-rows {
width: 100%;
gap: 10px 18px;
}
.auth-files-page .credential-quota-dense .credential-quota-row {
min-width: 0;
}
.auth-files-page .credential-quota-dense .credential-quota-row small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-files-page .auth-file-card {
border-color: #edf0f4;
box-shadow: 0 1px 2px rgb(35 48 65 / 3%);
}
.auth-files-page .auth-file-card:hover {
border-color: color-mix(in srgb, var(--auth-accent) 26%, #edf0f4);
box-shadow: 0 4px 14px rgb(35 83 160 / 7%);
}
+20 -1
View File
@@ -138,6 +138,7 @@ export function AuthFileManagementPage() {
const [statusFilter, setStatusFilter] = useState<'all' | 'enabled' | 'disabled' | 'runtime'>('all');
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [quotaRefreshing, setQuotaRefreshing] = useState(false);
const [cooldownResetting, setCooldownResetting] = useState<Set<string>>(() => new Set());
const [error, setError] = useState('');
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
@@ -246,6 +247,21 @@ export function AuthFileManagementPage() {
});
};
const refreshAllQuotas = async () => {
if (quotaRefreshing || busy || loading) return;
const queryable = files.filter((file) => !readBoolean(file, 'disabled') && quotaProviderForFile(file));
if (!queryable.length) return;
setQuotaRefreshing(true);
setError('');
try {
for (let index = 0; index < queryable.length; index += 4) {
await Promise.all(queryable.slice(index, index + 4).map((file) => refreshQuota(file)));
}
} finally {
if (mountedRef.current) setQuotaRefreshing(false);
}
};
const closeOauthModels = () => {
if (oauthModelSaveRef.current) return;
oauthModelRequestRef.current += 1;
@@ -461,7 +477,10 @@ export function AuthFileManagementPage() {
}}><Settings2 size={15} />{t('authFiles.models.toolbarButton')}</button>
<button type="button" className="secondary-button compact-button auth-toolbar-quiet" disabled={busy} onClick={() => void openAuthFilesDirectory()}><FolderOpen size={15} />{t('authFiles.openDirectory')}</button>
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || busy}>
<RefreshCw size={16} className={loading ? 'spin' : ''} />{t('common.refresh')}
<RefreshCw size={16} className={loading ? 'spin' : ''} />{t('authFiles.toolbar.refreshList')}
</button>
<button type="button" className="secondary-button compact-button auth-toolbar-quiet" onClick={() => void refreshAllQuotas()} disabled={loading || busy || quotaRefreshing || !files.some((file) => !readBoolean(file, 'disabled') && quotaProviderForFile(file))}>
<RefreshCw size={16} className={quotaRefreshing ? 'spin' : ''} />{t('authFiles.toolbar.refreshQuota')}
</button>
<button type="button" className="primary-button compact-button" onClick={() => fileInputRef.current?.click()} disabled={busy}>
<Import size={16} />{t('authFiles.import')}
+11 -3
View File
@@ -66,26 +66,34 @@
.quota-page .quota-status-badge.ready { background: #e8f8ee; color: #149447; }
.quota-page .quota-status-badge.error { background: #fff0ee; color: #d94b42; }
.quota-page .quota-row-list { grid-column: 4; gap: 9px; min-width: 0; }
.quota-page .quota-row-list { grid-row: 1 / span 2; align-self: center; }
.quota-page .real-quota-row { gap: 5px; }
.quota-page .real-quota-row > div:first-child { font-size: var(--font-size-meta); }
.quota-page .real-quota-track { height: 7px; background: #edf0f4; }
.quota-page .real-quota-track span { background: #20bf62; }
.quota-page .real-quota-row small { font-size: 11px; }
.quota-page .quota-card-actions { grid-column: 5; grid-row: 1; justify-content: flex-end; align-self: center; flex-wrap: wrap; }
.quota-page .quota-reset-footer { grid-column: 5; grid-row: 2; justify-self: end; align-self: end; margin: 0; min-height: 30px; padding: 5px 10px; border-radius: 7px; color: #347df4; border-color: #d8e5fb; background: #f3f7ff; }
.quota-page .quota-reset-footer:hover:not(:disabled) { border-color: #9dbcf4; background: #eaf2ff; }
.quota-page .quota-card-actions .icon-button { width: 30px; height: 30px; border-radius: 7px; background: #eef4ff; color: #347df4; }
.quota-page .quota-card-message, .quota-page .quota-card-error, .quota-page .quota-action-feedback, .quota-page .quota-reset-credit-summary { grid-column: 1 / -1; }
.quota-page .quota-card-message, .quota-page .quota-card-error, .quota-page .quota-action-feedback { grid-column: 1 / -1; }
.quota-page .quota-reset-credit-summary { grid-column: 1 / 4; grid-row: 2; align-self: end; }
.quota-page .quota-card-message { min-height: 36px; }
.quota-page .quota-card-error { min-height: 36px; }
@media (max-width: 980px) {
.quota-page .quota-table-head { display: none; }
.quota-page .real-quota-card { grid-template-columns: minmax(180px, 1fr) minmax(100px, .7fr) minmax(100px, .7fr) 38px; }
.quota-page .quota-row-list { grid-column: 1 / -1; }
.quota-page .quota-row-list { grid-column: 1 / -1; grid-row: auto; }
.quota-page .quota-reset-credit-summary { grid-column: 1 / -1; grid-row: auto; }
.quota-page .quota-card-actions { grid-column: 4; }
.quota-page .quota-reset-footer { grid-column: 4; grid-row: 2; }
}
@media (max-width: 640px) {
.quota-page .real-quota-card { grid-template-columns: 1fr auto; padding: 13px; }
.quota-page .quota-plan, .quota-page .quota-status { grid-column: 1 / -1; }
.quota-page .quota-card-actions { grid-column: 2; grid-row: 1; }
.quota-page .quota-row-list { grid-column: 1 / -1; }
.quota-page .quota-reset-footer { grid-column: 2; grid-row: 2; }
.quota-page .quota-row-list { grid-column: 1 / -1; grid-row: auto; }
.quota-page .quota-reset-credit-summary { grid-column: 1 / -1; grid-row: auto; }
}
+1 -1
View File
@@ -201,7 +201,6 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
<div className="quota-plan"><strong>{quota.plan || '—'}</strong><span>{quota.subscriptionActiveUntil ? formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) : ''}</span></div>
<div className="quota-status"><span className={quota.status === 'success' ? 'quota-status-badge ready' : quota.status === 'error' ? 'quota-status-badge error' : 'quota-status-badge'}>{quota.status === 'success' ? t('common.enabled') : quota.status === 'loading' ? t('quota.querying') : quota.status === 'error' ? t('common.unavailable') : t('quota.notFetched')}</span><small>{t('authFiles.settings.priority')} 0</small></div>
<div className="quota-card-actions">
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')} aria-label={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} aria-hidden="true" /></button>
</div>
</div>
@@ -228,6 +227,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
<small>{[row.detail, reset].filter(Boolean).join(' · ')}</small>
</div>;
})}</div> : null}
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button quota-reset-footer" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
</article>
);
}