fix(frontend): synchronize pool account state

This commit is contained in:
elky
2026-07-23 16:20:18 +08:00
parent b94ef91d07
commit a0767d957c
12 changed files with 557 additions and 94 deletions
@@ -39,6 +39,7 @@ export interface QuotaWindowSnapshot {
reset_at?: number | null reset_at?: number | null
reset_seconds?: number | null reset_seconds?: number | null
window_minutes?: number | null window_minutes?: number | null
usage_reset_at?: number | null
is_exhausted?: boolean | null is_exhausted?: boolean | null
usage?: QuotaWindowUsageSnapshot | null usage?: QuotaWindowUsageSnapshot | null
} }
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
describe('resetCodexCycleUsageWindows', () => {
it('mirrors the backend reset scope and creates zero usage for resettable windows', () => {
const windows: QuotaWindowSnapshot[] = [
{
code: '5h',
scope: 'account',
window_minutes: 300,
usage: null,
},
{
code: 'spark_weekly',
scope: 'account',
window_minutes: 10_080,
usage: { request_count: 3, total_tokens: 30, total_cost_usd: '0.03' },
},
{
code: 'model_daily',
scope: 'model',
window_minutes: 1_440,
usage: { request_count: 4, total_tokens: 40, total_cost_usd: '0.04' },
},
{
code: 'lifetime',
scope: 'account',
window_minutes: 0,
usage: { request_count: 5, total_tokens: 50, total_cost_usd: '0.05' },
},
]
const result = resetCodexCycleUsageWindows(windows, 123)
expect(result[0]).toMatchObject({
usage_reset_at: 123,
usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' },
})
expect(result[1]).toBe(windows[1])
expect(result[2]).toBe(windows[2])
expect(result[3]).toBe(windows[3])
})
it('treats a missing scope as account and ignores empty codes', () => {
const windows: QuotaWindowSnapshot[] = [
{ code: 'weekly', usage: { request_count: 7 } },
{ code: ' ', scope: 'account', usage: { request_count: 8 } },
]
const result = resetCodexCycleUsageWindows(windows, 456)
expect(result[0].usage?.request_count).toBe(0)
expect(result[0].usage_reset_at).toBe(456)
expect(result[1]).toBe(windows[1])
})
})
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest'
import type { PoolKeyDetail } from '@/api/endpoints/pool'
import type { RefreshQuotaResult } from '@/api/endpoints/keys'
import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh'
function createKey(keyId: string): PoolKeyDetail {
return {
key_id: keyId,
key_name: keyId,
is_active: true,
auth_type: 'oauth',
api_formats: ['openai:responses'],
internal_priority: 0,
account_quota: null,
cooldown_reason: null,
cooldown_ttl_seconds: null,
cost_window_usage: 0,
cost_limit: null,
request_count: 0,
total_tokens: 0,
total_cost_usd: '0',
sticky_sessions: 0,
lru_score: null,
created_at: null,
last_used_at: null,
}
}
describe('mergePoolKeyQuotaSnapshots', () => {
it('merges snapshots from non-success quota results', () => {
const keys = [createKey('exhausted'), createKey('invalid'), createKey('unchanged')]
const results: RefreshQuotaResult['results'] = [
{
key_id: 'exhausted',
key_name: 'exhausted',
status: 'quota_exhausted',
quota_snapshot: {
code: 'exhausted',
exhausted: true,
updated_at: 123,
},
},
{
key_id: 'invalid',
key_name: 'invalid',
status: 'auth_invalid',
quota_snapshot: {
code: 'unknown',
exhausted: false,
observed_at: 456,
},
},
{
key_id: 'unchanged',
key_name: 'unchanged',
status: 'error',
},
]
const merged = mergePoolKeyQuotaSnapshots(keys, results)
expect(merged[0].status_snapshot?.quota.code).toBe('exhausted')
expect(merged[0].quota_updated_at).toBe(123)
expect(merged[1].status_snapshot?.quota.code).toBe('unknown')
expect(merged[1].quota_updated_at).toBe(456)
expect(merged[2]).toBe(keys[2])
})
})
@@ -0,0 +1,27 @@
import type { QuotaWindowSnapshot } from '@/api/endpoints/types'
export function resetCodexCycleUsageWindows(
windows: QuotaWindowSnapshot[],
resetAt: number,
): QuotaWindowSnapshot[] {
return windows.map((window) => {
const code = String(window.code || '').trim()
const scope = String(window.scope || 'account').trim().toLowerCase()
const shouldReset = Boolean(code)
&& scope === 'account'
&& !code.toLowerCase().startsWith('spark_')
&& window.window_minutes !== 0
if (!shouldReset) return window
return {
...window,
usage_reset_at: Number.isFinite(resetAt) ? resetAt : window.usage_reset_at,
usage: {
request_count: 0,
total_tokens: 0,
total_cost_usd: '0.00000000',
},
}
})
}
@@ -0,0 +1,28 @@
import type { PoolKeyDetail } from '@/api/endpoints/pool'
import type { RefreshQuotaResult } from '@/api/endpoints/keys'
export function mergePoolKeyQuotaSnapshots(
keys: PoolKeyDetail[],
results: RefreshQuotaResult['results'],
): PoolKeyDetail[] {
const quotaByKeyId = new Map<string, NonNullable<RefreshQuotaResult['results'][number]['quota_snapshot']>>()
for (const result of results) {
if (result.quota_snapshot) {
quotaByKeyId.set(result.key_id, result.quota_snapshot)
}
}
if (quotaByKeyId.size === 0) return keys
return keys.map((key) => {
const quotaSnapshot = quotaByKeyId.get(key.key_id)
if (!quotaSnapshot) return key
return {
...key,
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
status_snapshot: {
...(key.status_snapshot ?? {}),
quota: quotaSnapshot,
},
}
})
}
@@ -1091,6 +1091,14 @@ let mappingPreviewLoadRequestId = 0
const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3 const DEFAULT_PROVIDER_KEYS_PAGE_SIZE = 3
const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4 const CUSTOM_PROVIDER_KEYS_PAGE_SIZE = 4
function applyProviderSnapshot(updated: ProviderWithEndpointsSummary): void {
if (provider.value?.id === updated.id) {
Object.assign(provider.value, updated)
return
}
provider.value = updated
}
function getProviderKeysPageSize(providerType?: string | null): number { function getProviderKeysPageSize(providerType?: string | null): number {
return (providerType || '').trim().toLowerCase() === 'custom' return (providerType || '').trim().toLowerCase() === 'custom'
? CUSTOM_PROVIDER_KEYS_PAGE_SIZE ? CUSTOM_PROVIDER_KEYS_PAGE_SIZE
@@ -1386,7 +1394,7 @@ async function toggleFormatConversion() {
const newValue = !provider.value.enable_format_conversion const newValue = !provider.value.enable_format_conversion
try { try {
const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue }) const updated = await updateProvider(provider.value.id, { enable_format_conversion: newValue })
provider.value = updated applyProviderSnapshot(updated)
showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换')) showSuccess(legacyT(newValue ? '已启用格式转换' : '已禁用格式转换'))
emit('refresh') emit('refresh')
} catch { } catch {
@@ -1408,7 +1416,7 @@ async function setProviderProxy(nodeId: string) {
const updated = await updateProvider(provider.value.id, { const updated = await updateProvider(provider.value.id, {
proxy: { node_id: nodeId, enabled: true }, proxy: { node_id: nodeId, enabled: true },
}) })
provider.value = updated applyProviderSnapshot(updated)
providerProxyPopoverOpen.value = false providerProxyPopoverOpen.value = false
showSuccess(legacyT('代理节点已设置')) showSuccess(legacyT('代理节点已设置'))
emit('refresh') emit('refresh')
@@ -1424,7 +1432,7 @@ async function clearProviderProxy() {
savingProviderProxy.value = true savingProviderProxy.value = true
try { try {
const updated = await updateProvider(provider.value.id, { proxy: null }) const updated = await updateProvider(provider.value.id, { proxy: null })
provider.value = updated applyProviderSnapshot(updated)
providerProxyPopoverOpen.value = false providerProxyPopoverOpen.value = false
showSuccess(legacyT('已清除提供商代理')) showSuccess(legacyT('已清除提供商代理'))
emit('refresh') emit('refresh')
@@ -1447,7 +1455,7 @@ function handleEditEndpoint(_endpoint: ProviderEndpoint) {
} }
async function handleEndpointChanged() { async function handleEndpointChanged() {
await Promise.all([loadProvider(), loadEndpoints()]) await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
emit('refresh') emit('refresh')
} }
@@ -1562,7 +1570,7 @@ async function confirmDeleteKey() {
await deleteEndpointKey(keyId) await deleteEndpointKey(keyId)
showSuccess(legacyT('密钥已删除')) showSuccess(legacyT('密钥已删除'))
// 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联) // 刷新端点列表及模型数据(删除 Key 触发自动解除模型关联)
await loadEndpoints() await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
emit('refresh') emit('refresh')
} catch (err: unknown) { } catch (err: unknown) {
showError(localizedApiError(err, '删除密钥失败'), legacyT('错误')) showError(localizedApiError(err, '删除密钥失败'), legacyT('错误'))
@@ -1573,7 +1581,7 @@ async function handleRecoverKey(key: EndpointAPIKey) {
try { try {
const result = await recoverKeyHealth(key.id) const result = await recoverKeyHealth(key.id)
showSuccess(legacyT(result.message || 'Key已完全恢复')) showSuccess(legacyT(result.message || 'Key已完全恢复'))
await loadEndpoints() await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh') emit('refresh')
} catch (err: unknown) { } catch (err: unknown) {
showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误')) showError(localizedApiError(err, 'Key恢复失败'), legacyT('错误'))
@@ -1625,11 +1633,15 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
} else { } else {
showSuccess(legacyT(feedback.message)) showSuccess(legacyT(feedback.message))
} }
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取 emit('refresh')
// (不 emit('refresh'),避免触发全局 provider 余额刷新) // Token 刷新可能激活账号并更新配额,完成后再同步一次父列表。
void autoRefreshQuotaInBackground() void autoRefreshQuotaInBackground().then((changed) => {
if (changed) emit('refresh')
})
} catch (err: unknown) { } catch (err: unknown) {
showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误')) showError(localizedApiError(err, 'Token 刷新失败'), legacyT('错误'))
await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh')
} finally { } finally {
refreshingOAuthKeyId.value = null refreshingOAuthKeyId.value = null
} }
@@ -1692,9 +1704,12 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
} }
} }
} }
await loadEndpoints() await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh')
} catch (err: unknown) { } catch (err: unknown) {
showError(localizedApiError(err, '清除失败'), legacyT('错误')) showError(localizedApiError(err, '清除失败'), legacyT('错误'))
await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh')
} finally { } finally {
clearingOAuthInvalidKeyId.value = null clearingOAuthInvalidKeyId.value = null
} }
@@ -1755,8 +1770,11 @@ async function handleConsumeCodexResetCredit(key: EndpointAPIKey) {
if (result.refresh_status === 'failed') { if (result.refresh_status === 'failed') {
showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败')) showWarning(legacyT(result.refresh_error || '重置请求已处理,但最新配额刷新失败'))
} }
emit('refresh')
} catch (err: unknown) { } catch (err: unknown) {
showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误')) showError(localizedApiError(err, 'Codex 重置机会使用失败'), legacyT('错误'))
await Promise.all([loadProvider(), loadEndpoints()])
emit('refresh')
} finally { } finally {
consumingCodexResetCreditKeyId.value = null consumingCodexResetCreditKeyId.value = null
} }
@@ -2707,13 +2725,13 @@ function applyQuotaResults(
} }
// 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web) // 通用的自动刷新配额函数(支持 Codex、Gemini CLI、Antigravity、Kiro、Windsurf 和 ChatGPT Web)
async function autoRefreshQuotaInBackground() { async function autoRefreshQuotaInBackground(): Promise<boolean> {
const providerId = props.providerId const providerId = props.providerId
if (!providerId) return if (!providerId) return false
if (refreshingQuota.value) return if (refreshingQuota.value) return false
const providerType = provider.value?.provider_type const providerType = provider.value?.provider_type
if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return if (providerType !== 'codex' && providerType !== 'gemini_cli' && providerType !== 'antigravity' && providerType !== 'kiro' && providerType !== 'windsurf' && providerType !== 'chatgpt_web' && providerType !== 'grok') return false
// 检查是否需要刷新 // 检查是否需要刷新
let shouldRefresh = false let shouldRefresh = false
@@ -2732,7 +2750,7 @@ async function autoRefreshQuotaInBackground() {
} else if (providerType === 'chatgpt_web') { } else if (providerType === 'chatgpt_web') {
shouldRefresh = shouldAutoRefreshChatGPTWebQuota() shouldRefresh = shouldAutoRefreshChatGPTWebQuota()
} }
if (!shouldRefresh) return if (!shouldRefresh) return false
let hadCachedQuota = false let hadCachedQuota = false
if (providerType === 'codex') { if (providerType === 'codex') {
@@ -2758,10 +2776,12 @@ async function autoRefreshQuotaInBackground() {
if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') { if (result.success <= 0 && applied === 0 && !hadCachedQuota && providerType === 'antigravity') {
showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示')) showError(legacyT('没有获取到配额信息(请检查账号是否已授权、project_id 是否存在)'), legacyT('提示'))
} }
return applied > 0
} catch (err: unknown) { } catch (err: unknown) {
if (!hadCachedQuota && providerType === 'antigravity') { if (!hadCachedQuota && providerType === 'antigravity') {
showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误')) showError(localizedApiError(err, '后台刷新配额失败'), legacyT('错误'))
} }
return false
} finally { } finally {
refreshingQuota.value = false refreshingQuota.value = false
} }
@@ -2792,10 +2812,12 @@ async function openAntigravityQuotaDialog(key: EndpointAPIKey) {
} }
async function handleKeyChanged() { async function handleKeyChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()]) await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
emit('refresh') emit('refresh')
// 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空) // 添加/修改 key 后自动获取已支持 provider 的配额(新 key 的 upstream_metadata 为空)
void autoRefreshQuotaInBackground() void autoRefreshQuotaInBackground().then((changed) => {
if (changed) emit('refresh')
})
} }
// 切换密钥启用状态 // 切换密钥启用状态
@@ -2805,8 +2827,10 @@ async function toggleKeyActive(key: EndpointAPIKey) {
togglingKeyId.value = key.id togglingKeyId.value = key.id
try { try {
const newStatus = !key.is_active const newStatus = !key.is_active
await updateProviderKey(key.id, { is_active: newStatus }) const updated = await updateProviderKey(key.id, { is_active: newStatus })
Object.assign(key, updated)
key.is_active = newStatus key.is_active = newStatus
await Promise.all([loadProvider(), loadEndpoints()])
showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用')) showSuccess(legacyT(newStatus ? '密钥已启用' : '密钥已停用'))
emit('refresh') emit('refresh')
} catch (err: unknown) { } catch (err: unknown) {
@@ -2885,20 +2909,20 @@ function handleBatchAssignDialogOpenUpdate(value: boolean) {
// 处理批量关联完成 // 处理批量关联完成
async function handleBatchAssignChanged() { async function handleBatchAssignChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()]) await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
emit('refresh') emit('refresh')
} }
// 处理模型映射变更 // 处理模型映射变更
async function handleModelMappingChanged() { async function handleModelMappingChanged() {
await Promise.all([loadEndpoints(), loadMappingPreview()]) await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
emit('refresh') emit('refresh')
} }
// 处理模型保存完成 // 处理模型保存完成
async function handleModelSaved() { async function handleModelSaved() {
editingModel.value = null editingModel.value = null
await Promise.all([loadEndpoints(), loadMappingPreview()]) await Promise.all([loadProvider(), loadEndpoints(), loadMappingPreview()])
emit('refresh') emit('refresh')
} }
@@ -3738,7 +3762,7 @@ async function loadProvider() {
void loadSystemFormatConversionConfig() void loadSystemFormatConversionConfig()
const providerData = await getProvider(props.providerId) const providerData = await getProvider(props.providerId)
if (requestId !== providerLoadRequestId) return if (requestId !== providerLoadRequestId) return
provider.value = providerData applyProviderSnapshot(providerData)
keyPageSize.value = getProviderKeysPageSize(providerData.provider_type) keyPageSize.value = getProviderKeysPageSize(providerData.provider_type)
if (!provider.value) { if (!provider.value) {
@@ -91,6 +91,8 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
:title="legacyT(provider.is_active ? '点击停用' : '点击启用')" :title="legacyT(provider.is_active ? '点击停用' : '点击启用')"
:aria-label="legacyT(provider.is_active ? '点击停用' : '点击启用')"
data-testid="provider-toggle-active"
@click="$emit('toggleStatus', provider)" @click="$emit('toggleStatus', provider)"
> >
<Power class="w-4 h-4" /> <Power class="w-4 h-4" />
@@ -126,6 +126,8 @@
class="h-7 w-7" class="h-7 w-7"
:disabled="toggling" :disabled="toggling"
:title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')" :title="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
:aria-label="legacyT(apiKey.is_active ? '点击停用' : '点击启用')"
data-testid="provider-key-toggle-active"
@click="$emit('toggleActive')" @click="$emit('toggleActive')"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
@@ -128,6 +128,7 @@ describe('ProviderKeyActionCluster', () => {
expect(root.querySelector('button[title="Recover key"]')).toBeTruthy() expect(root.querySelector('button[title="Recover key"]')).toBeTruthy()
expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy() expect(root.querySelector('button[title="代理: Tokyo"]')).toBeTruthy()
expect(root.querySelector('button[title="配额详情"]')).toBeTruthy() expect(root.querySelector('button[title="配额详情"]')).toBeTruthy()
expect(root.querySelector('[data-testid="provider-key-toggle-active"]')?.getAttribute('aria-label')).toBe('点击停用')
unmount() unmount()
}) })
@@ -179,4 +180,22 @@ describe('ProviderKeyActionCluster', () => {
unmount() unmount()
}) })
it('offers an enable action for an inactive account', () => {
const onToggleActive = vi.fn()
const { root, unmount } = mount({
apiKey: createProviderKey({ is_active: false }),
onToggleActive,
})
const enableButton = root.querySelector('[data-testid="provider-key-toggle-active"]') as HTMLButtonElement | null
expect(enableButton).toBeTruthy()
expect(enableButton?.getAttribute('title')).toBe('点击启用')
expect(enableButton?.getAttribute('aria-label')).toBe('点击启用')
enableButton?.click()
expect(onToggleActive).toHaveBeenCalledTimes(1)
unmount()
})
}) })
+164 -68
View File
@@ -516,6 +516,8 @@
class="h-7 w-7 text-foreground hover:text-foreground" class="h-7 w-7 text-foreground hover:text-foreground"
:disabled="togglingKeyId === key.key_id" :disabled="togglingKeyId === key.key_id"
:title="key.is_active ? '禁用' : '启用'" :title="key.is_active ? '禁用' : '启用'"
:aria-label="key.is_active ? '禁用账号' : '启用账号'"
:data-testid="`pool-toggle-active-desktop-${key.key_id}`"
@click="toggleKeyActive(key)" @click="toggleKeyActive(key)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
@@ -847,6 +849,8 @@
class="h-7 w-7 shrink-0 text-foreground hover:text-foreground" class="h-7 w-7 shrink-0 text-foreground hover:text-foreground"
:disabled="togglingKeyId === key.key_id" :disabled="togglingKeyId === key.key_id"
:title="key.is_active ? '禁用' : '启用'" :title="key.is_active ? '禁用' : '启用'"
:aria-label="key.is_active ? '禁用账号' : '启用账号'"
:data-testid="`pool-toggle-active-mobile-${key.key_id}`"
@click="toggleKeyActive(key)" @click="toggleKeyActive(key)"
> >
<Power class="w-3.5 h-3.5" /> <Power class="w-3.5 h-3.5" />
@@ -1124,6 +1128,8 @@ import {
type PoolStatsDisplay, type PoolStatsDisplay,
type PoolStatsMetric, type PoolStatsMetric,
} from '@/features/pool/utils/poolStatsDisplay' } from '@/features/pool/utils/poolStatsDisplay'
import { resetCodexCycleUsageWindows } from '@/features/pool/utils/poolCycleStats'
import { mergePoolKeyQuotaSnapshots } from '@/features/pool/utils/poolQuotaRefresh'
import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow' import { getCodexQuotaWindowPresentation } from '@/utils/codexQuotaWindow'
import { getOAuthOrgBadge } from '@/utils/oauthIdentity' import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType' import { formatOAuthPlanType, getOAuthPlanTypeClass } from '@/utils/oauthPlanType'
@@ -1312,9 +1318,11 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
if (requestId !== overviewRequestId) return if (requestId !== overviewRequestId) return
if (!options.silent) { if (!options.silent) {
showError(parseApiError(err)) showError(parseApiError(err))
} else {
showWarning(parseApiError(err, '同步 Provider 概览失败'))
} }
} finally { } finally {
if (requestId === overviewRequestId && !options.silent) { if (requestId === overviewRequestId) {
overviewLoading.value = false overviewLoading.value = false
} }
} }
@@ -1323,11 +1331,15 @@ async function loadOverview(options: { cacheTtlMs?: number, silent?: boolean } =
async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) { async function handleSchedulingSaved(updatedProvider: ProviderWithEndpointsSummary) {
// 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。 // 优先回写保存接口返回值,避免弹窗立即重开时读到旧配置。
if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) { if (selectedProviderId.value && updatedProvider.id === selectedProviderId.value) {
selectedProviderData.value = updatedProvider if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updatedProvider)
} else {
selectedProviderData.value = updatedProvider
}
} }
showSchedulingDialog.value = false showSchedulingDialog.value = false
showAdvancedDialog.value = false showAdvancedDialog.value = false
await loadOverview() await loadOverview({ silent: true })
} }
// --- Provider Selection --- // --- Provider Selection ---
@@ -1699,15 +1711,23 @@ async function selectProvider(
if (requestId !== selectProviderRequestId) return if (requestId !== selectProviderRequestId) return
} }
async function loadProviderData(id: string) { async function loadProviderData(id: string, options: { preserveOnError?: boolean } = {}) {
const requestId = ++providerDataRequestId const requestId = ++providerDataRequestId
try { try {
const providerData = await getProvider(id) const providerData = await getProvider(id)
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
selectedProviderData.value = providerData if (selectedProviderData.value?.id === providerData.id) {
Object.assign(selectedProviderData.value, providerData)
} else {
selectedProviderData.value = providerData
}
} catch { } catch {
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
selectedProviderData.value = null if (options.preserveOnError) {
showWarning('同步 Provider 详情失败,已保留当前数据')
} else {
selectedProviderData.value = null
}
} }
} }
@@ -2069,30 +2089,76 @@ function resetKeyPage(page = currentPage.value, pageSizeValue = pageSize.value):
} }
function refreshOverviewInBackground(): void { function refreshOverviewInBackground(): void {
void loadOverview() void loadOverview({ silent: true })
}
function clampActiveKeyCount(current: unknown, total: unknown, delta: number): number {
const currentValue = Number(current)
const nextValue = Math.max(0, (Number.isFinite(currentValue) ? currentValue : 0) + delta)
const totalValue = Number(total)
if (!Number.isFinite(totalValue)) return nextValue
return Math.min(Math.max(0, totalValue), nextValue)
}
function isManualInactiveReason(reason: { code?: string; source?: string }): boolean {
const code = String(reason.code || '').trim().toLowerCase()
return code === 'inactive' || code === 'manual_disabled'
}
function applyPoolKeyActiveState(key: PoolKeyDetail, nextStatus: boolean): void {
const previousStatus = key.is_active
key.is_active = nextStatus
if (nextStatus) {
const remainingReasons = (key.scheduling_reasons ?? []).filter(
reason => !isManualInactiveReason(reason),
)
key.scheduling_reasons = remainingReasons
const remainingBlockingReason = remainingReasons.find(reason => reason.blocking)
if (remainingBlockingReason) {
key.scheduling_reason = remainingBlockingReason.code
key.scheduling_label = remainingBlockingReason.label
key.scheduling_status = remainingBlockingReason.code === 'cooldown' ? 'degraded' : 'blocked'
} else if (key.cooldown_reason) {
key.scheduling_reason = 'cooldown'
key.scheduling_label = '冷却中'
key.scheduling_status = 'degraded'
} else {
key.scheduling_reason = 'available'
key.scheduling_label = '可用'
key.scheduling_status = 'available'
}
} else {
key.scheduling_label = '已禁用'
key.scheduling_status = 'blocked'
key.scheduling_reason = 'inactive'
key.scheduling_reasons = [{
code: 'inactive',
label: '已禁用',
blocking: true,
source: 'manual',
ttl_seconds: null,
detail: null,
}]
}
if (previousStatus === nextStatus) return
const delta = nextStatus ? 1 : -1
const overview = poolProviders.value.find(item => item.provider_id === selectedProviderId.value)
if (overview) {
overview.active_keys = clampActiveKeyCount(overview.active_keys, overview.total_keys, delta)
}
if (selectedProviderData.value) {
selectedProviderData.value.active_keys = clampActiveKeyCount(
selectedProviderData.value.active_keys,
selectedProviderData.value.total_keys,
delta,
)
}
} }
function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void { function applyQuotaRefreshResultToCurrentPage(result: Awaited<ReturnType<typeof refreshProviderQuota>>): void {
const quotaByKeyId = new Map<string, NonNullable<NonNullable<typeof result.results>[number]['quota_snapshot']>>() keyPage.value.keys = mergePoolKeyQuotaSnapshots(keyPage.value.keys, result.results)
for (const item of result.results) {
if (item.status === 'success' && item.quota_snapshot) {
quotaByKeyId.set(item.key_id, item.quota_snapshot)
}
}
if (quotaByKeyId.size === 0) return
keyPage.value.keys = keyPage.value.keys.map((key) => {
const quotaSnapshot = quotaByKeyId.get(key.key_id)
if (!quotaSnapshot) return key
return {
...key,
quota_updated_at: quotaSnapshot.updated_at ?? quotaSnapshot.observed_at ?? key.quota_updated_at ?? null,
status_snapshot: {
...(key.status_snapshot ?? {}),
quota: quotaSnapshot,
},
}
})
} }
function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null { function normalizeQuotaUpdatedAt(raw: number | null | undefined): number | null {
@@ -2139,7 +2205,7 @@ const currentPageQuotaRefreshStats = computed(() => {
}) })
async function refreshCurrentPageQuotaInBackground( async function refreshCurrentPageQuotaInBackground(
options: { silent?: boolean; reloadAfter?: boolean } = {}, options: { silent?: boolean; reloadAfter?: boolean | 'silent' } = {},
): Promise<boolean> { ): Promise<boolean> {
if (!selectedProviderId.value || !quotaRefreshSupported.value) return false if (!selectedProviderId.value || !quotaRefreshSupported.value) return false
@@ -2169,7 +2235,7 @@ async function refreshCurrentPageQuotaInBackground(
// 刷新当前页数据,展示最新额度与状态 // 刷新当前页数据,展示最新额度与状态
if (selectedProviderId.value === providerId && options.reloadAfter !== false) { if (selectedProviderId.value === providerId && options.reloadAfter !== false) {
await loadKeys() await loadKeys({ silent: options.reloadAfter === 'silent' })
} }
if (!options.silent) { if (!options.silent) {
@@ -2216,7 +2282,7 @@ async function refreshCurrentPage() {
} }
} }
async function loadKeys(options: { cacheTtlMs?: number } = {}) { async function loadKeys(options: { cacheTtlMs?: number, silent?: boolean } = {}) {
if (!selectedProviderId.value) return if (!selectedProviderId.value) return
const requestId = ++keysRequestId const requestId = ++keysRequestId
const providerId = selectedProviderId.value const providerId = selectedProviderId.value
@@ -2225,7 +2291,9 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
const search = searchQuery.value || undefined const search = searchQuery.value || undefined
const status = statusFilter.value const status = statusFilter.value
const sortByValue = sortBy.value || undefined const sortByValue = sortBy.value || undefined
keysLoading.value = true if (!options.silent) {
keysLoading.value = true
}
try { try {
const nextPage = await listPoolKeys(providerId, { const nextPage = await listPoolKeys(providerId, {
page, page,
@@ -2251,9 +2319,13 @@ async function loadKeys(options: { cacheTtlMs?: number } = {}) {
keysLoadedOnce.value = true keysLoadedOnce.value = true
} catch (err) { } catch (err) {
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
resetKeyPage(page, pageSizeValue) if (!options.silent) {
keysLoadedOnce.value = true resetKeyPage(page, pageSizeValue)
showError(parseApiError(err)) keysLoadedOnce.value = true
showError(parseApiError(err))
} else {
showWarning(parseApiError(err, '同步账号列表失败'))
}
} finally { } finally {
if (requestId === keysRequestId) { if (requestId === keysRequestId) {
keysLoading.value = false keysLoading.value = false
@@ -2465,12 +2537,12 @@ function closeKeyBatchEditDialog(): void {
async function handleKeyBatchEditSaved(): Promise<void> { async function handleKeyBatchEditSaved(): Promise<void> {
resetPoolKeySelection(true) resetPoolKeySelection(true)
await Promise.all([loadKeys(), loadOverview()]) await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
} }
async function handleDialogSaved() { async function handleDialogSaved() {
editingKeyDetail.value = null editingKeyDetail.value = null
await loadKeys() await loadKeys({ silent: true })
} }
function closeKeyFormDialog() { function closeKeyFormDialog() {
@@ -2647,7 +2719,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
if (target) { if (target) {
target.oauth_expires_at = refreshedExpiresAt target.oauth_expires_at = refreshedExpiresAt
} }
await loadKeys() await loadKeys({ silent: true })
if (refreshedExpiresAt != null) { if (refreshedExpiresAt != null) {
const reloadedTarget = keyPage.value.keys.find(k => k.key_id === key.key_id) const reloadedTarget = keyPage.value.keys.find(k => k.key_id === key.key_id)
if ( if (
@@ -2671,7 +2743,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
} }
} catch (err) { } catch (err) {
showError(parseApiError(err, 'Token 刷新失败')) showError(parseApiError(err, 'Token 刷新失败'))
await loadKeys() await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
} finally { } finally {
refreshingOAuthKeyId.value = null refreshingOAuthKeyId.value = null
} }
@@ -2683,8 +2755,20 @@ async function clearCooldown(keyId: string) {
try { try {
const res = await clearPoolCooldown(selectedProviderId.value, keyId) const res = await clearPoolCooldown(selectedProviderId.value, keyId)
success(res.message) success(res.message)
await loadKeys() const key = keyPage.value.keys.find(item => item.key_id === keyId)
refreshOverviewInBackground() if (key) {
key.cooldown_reason = null
key.cooldown_ttl_seconds = null
if (key.scheduling_reason === 'cooldown') {
key.scheduling_reason = key.is_active ? 'available' : 'inactive'
key.scheduling_status = key.is_active ? 'available' : 'blocked'
key.scheduling_label = key.is_active ? '可用' : '已禁用'
}
key.scheduling_reasons = key.scheduling_reasons?.filter(
item => item.code !== 'cooldown',
)
}
await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
} catch (err) { } catch (err) {
showError(parseApiError(err)) showError(parseApiError(err))
} }
@@ -2704,7 +2788,17 @@ async function handleResetCycleStats(key: PoolKeyDetail) {
try { try {
const result = await resetProviderKeyCycleStats(key.key_id) const result = await resetProviderKeyCycleStats(key.key_id)
success(result.message || '周期统计已重置') success(result.message || '周期统计已重置')
await loadKeys() if (key.status_snapshot?.quota?.windows) {
const resetAt = Number(result.reset_at)
key.status_snapshot = {
...key.status_snapshot,
quota: {
...key.status_snapshot.quota,
windows: resetCodexCycleUsageWindows(key.status_snapshot.quota.windows, resetAt),
},
}
}
await loadKeys({ silent: true })
} catch (err) { } catch (err) {
showError(parseApiError(err, '重置周期统计失败')) showError(parseApiError(err, '重置周期统计失败'))
} finally { } finally {
@@ -2718,21 +2812,9 @@ async function toggleKeyActive(key: PoolKeyDetail) {
try { try {
const nextStatus = !key.is_active const nextStatus = !key.is_active
await updateProviderKey(key.key_id, { is_active: nextStatus }) await updateProviderKey(key.key_id, { is_active: nextStatus })
key.is_active = nextStatus applyPoolKeyActiveState(key, nextStatus)
if (nextStatus) { await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
delete key.scheduling_label
delete key.scheduling_status
if (key.scheduling_reason === 'manual_disabled') {
delete key.scheduling_reason
}
} else {
key.scheduling_label = '禁用'
key.scheduling_status = 'blocked'
key.scheduling_reason = 'manual_disabled'
}
success(nextStatus ? '账号已启用' : '账号已停用') success(nextStatus ? '账号已启用' : '账号已停用')
await loadKeys()
refreshOverviewInBackground()
} catch (err) { } catch (err) {
showError(parseApiError(err)) showError(parseApiError(err))
} finally { } finally {
@@ -2775,9 +2857,9 @@ async function handleProviderDrawerRefresh(): Promise<void> {
if (!providerId) return if (!providerId) return
await Promise.all([ await Promise.all([
loadKeys(), loadKeys({ silent: true }),
loadOverview({ silent: true }), loadOverview({ silent: true }),
loadProviderData(providerId), loadProviderData(providerId, { preserveOnError: true }),
]) ])
resetPoolKeySelection(true) resetPoolKeySelection(true)
} }
@@ -2789,8 +2871,13 @@ async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary):
try { try {
const latest = await getProvider(providerId) const latest = await getProvider(providerId)
if (selectedProviderId.value !== providerId) return if (selectedProviderId.value !== providerId) return
selectedProviderData.value = latest if (selectedProviderData.value?.id === latest.id) {
providerToEdit.value = latest Object.assign(selectedProviderData.value, latest)
providerToEdit.value = selectedProviderData.value
} else {
selectedProviderData.value = latest
providerToEdit.value = latest
}
} catch (err) { } catch (err) {
if (selectedProviderId.value !== providerId) return if (selectedProviderId.value !== providerId) return
const fallbackProvider = provider ?? selectedProviderData.value const fallbackProvider = provider ?? selectedProviderData.value
@@ -2806,11 +2893,16 @@ async function openProviderEditDialog(provider?: ProviderWithEndpointsSummary):
async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> { async function handleProviderEditSaved(updatedProvider: ProviderWithEndpointsSummary): Promise<void> {
if (selectedProviderId.value === updatedProvider.id) { if (selectedProviderId.value === updatedProvider.id) {
selectedProviderData.value = updatedProvider if (selectedProviderData.value) {
providerToEdit.value = updatedProvider Object.assign(selectedProviderData.value, updatedProvider)
providerToEdit.value = selectedProviderData.value
} else {
selectedProviderData.value = updatedProvider
providerToEdit.value = updatedProvider
}
} }
providerEditDialogOpen.value = false providerEditDialogOpen.value = false
await loadOverview() await loadOverview({ silent: true })
} }
async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> { async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSummary): Promise<void> {
@@ -2834,11 +2926,15 @@ async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSumm
try { try {
const updated = await updateProvider(providerId, { is_active: nextStatus }) const updated = await updateProvider(providerId, { is_active: nextStatus })
Object.assign(current, updated) Object.assign(current, updated)
if (selectedProviderId.value === providerId) { if (selectedProviderId.value === providerId && selectedProviderData.value !== current) {
selectedProviderData.value = updated if (selectedProviderData.value) {
Object.assign(selectedProviderData.value, updated)
} else {
selectedProviderData.value = updated
}
} }
success(nextStatus ? '提供商已启用' : '提供商已禁用') success(nextStatus ? '提供商已启用' : '提供商已禁用')
await loadOverview() await loadOverview({ silent: true })
} catch (err) { } catch (err) {
showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败')) showError(parseApiError(err, nextStatus ? '启用提供商失败' : '禁用提供商失败'))
} finally { } finally {
@@ -2848,14 +2944,14 @@ async function toggleSelectedProviderStatus(provider?: ProviderWithEndpointsSumm
async function handleAccountBatchChanged(): Promise<void> { async function handleAccountBatchChanged(): Promise<void> {
resetPoolKeySelection(true) resetPoolKeySelection(true)
await Promise.all([loadKeys(), loadOverview()]) await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
} }
async function handleAccountDialogSaved() { async function handleAccountDialogSaved() {
showImportDialog.value = false showImportDialog.value = false
await Promise.all([loadKeys(), loadOverview()]) await Promise.all([loadKeys({ silent: true }), loadOverview({ silent: true })])
// 导入账号后补一次静默额度刷新,避免新账号在列表里暂无额度信息 // 导入账号后补一次静默额度刷新,避免新账号在列表里暂无额度信息
await refreshCurrentPageQuotaInBackground({ silent: true }) await refreshCurrentPageQuotaInBackground({ silent: true, reloadAfter: 'silent' })
} }
// --- Formatting --- // --- Formatting ---
@@ -568,7 +568,13 @@ async function loadProviders(options: { cacheTtlMs?: number } = {}) {
cacheTtlMs: options.cacheTtlMs ?? 0, cacheTtlMs: options.cacheTtlMs ?? 0,
}) })
if (requestId !== providersRequestId) return if (requestId !== providersRequestId) return
providers.value = response.items const existingProviders = new Map(providers.value.map(provider => [provider.id, provider]))
providers.value = response.items.map((item) => {
const existing = existingProviders.get(item.id)
if (!existing) return item
Object.assign(existing, item)
return existing
})
total.value = response.total total.value = response.total
// 异步加载配置了 ops 的 provider 的余额数据 // 异步加载配置了 ops 的 provider 的余额数据
loadBalances(providers.value) loadBalances(providers.value)
@@ -639,8 +645,8 @@ function openProviderDrawer(providerId: string) {
function mergeUpdatedProvider(updated: ProviderWithEndpointsSummary) { function mergeUpdatedProvider(updated: ProviderWithEndpointsSummary) {
const index = providers.value.findIndex(p => p.id === updated.id) const index = providers.value.findIndex(p => p.id === updated.id)
if (index !== -1) { if (index !== -1) {
providers.value[index] = updated Object.assign(providers.value[index], updated)
loadBalances([updated], false) loadBalances([providers.value[index]], false)
} }
} }
@@ -873,8 +873,45 @@ describe('PoolManagement Codex cycle stats mode', () => {
it('resets Codex cycle stats from the action column', async () => { it('resets Codex cycle stats from the action column', async () => {
const codexKey = createPoolKey('codex') const codexKey = createPoolKey('codex')
const resetKey = createPoolKey('codex', {
status_snapshot: {
...codexKey.status_snapshot,
quota: {
...codexKey.status_snapshot?.quota,
windows: [
{
code: '5h',
scope: 'account',
window_minutes: 300,
usage_reset_at: 123,
usage: { request_count: 0, total_tokens: 0, total_cost_usd: '0.00000000' },
},
{
code: 'spark_preview',
scope: 'account',
window_minutes: 300,
usage: { request_count: 12, total_tokens: 500, total_cost_usd: '0.01' },
},
{
code: 'model_window',
scope: 'model',
window_minutes: 300,
usage: { request_count: 9, total_tokens: 400, total_cost_usd: '0.02' },
},
{
code: 'lifetime',
scope: 'account',
window_minutes: 0,
usage: { request_count: 8, total_tokens: 300, total_cost_usd: '0.03' },
},
],
},
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] }) endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys.mockResolvedValue(createKeyPage(codexKey)) endpointMocks.listPoolKeys
.mockResolvedValueOnce(createKeyPage(codexKey))
.mockResolvedValue(createKeyPage(resetKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex')) endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
const root = mountPoolManagement() const root = mountPoolManagement()
@@ -888,6 +925,100 @@ describe('PoolManagement Codex cycle stats mode', () => {
expect(endpointMocks.resetProviderKeyCycleStats).toHaveBeenCalledWith(codexKey.key_id) expect(endpointMocks.resetProviderKeyCycleStats).toHaveBeenCalledWith(codexKey.key_id)
expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2) expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2)
expect(root.querySelector('[data-testid="pool-stats-cycle-request_count"]')?.textContent?.trim()).toBe('-/0')
})
it('toggles a pool account and silently revalidates the current key page', async () => {
const inactiveKey = createPoolKey('codex', {
is_active: false,
cooldown_reason: 'rate_limited_429',
cooldown_ttl_seconds: 60,
scheduling_status: 'blocked',
scheduling_reason: 'inactive',
scheduling_label: '已禁用',
scheduling_reasons: [{
code: 'inactive',
label: '已禁用',
blocking: true,
source: 'manual',
}],
})
const enabledKey = createPoolKey('codex', {
is_active: true,
cooldown_reason: 'rate_limited_429',
cooldown_ttl_seconds: 60,
scheduling_status: 'degraded',
scheduling_reason: 'cooldown',
scheduling_label: '冷却中',
scheduling_reasons: [{
code: 'cooldown',
label: '冷却中',
blocking: true,
source: 'pool',
ttl_seconds: 60,
detail: 'rate_limited_429',
}],
})
endpointMocks.getPoolOverview.mockResolvedValue({
items: [{ ...createOverview('codex'), active_keys: 0 }],
})
endpointMocks.listPoolKeys
.mockResolvedValueOnce(createKeyPage(inactiveKey))
.mockResolvedValue(createKeyPage(enabledKey))
endpointMocks.getProvider.mockResolvedValue(createProvider('codex', {
total_keys: 1,
active_keys: 0,
}))
endpointMocks.updateProviderKey.mockResolvedValue({ ...inactiveKey, is_active: true })
const root = mountPoolManagement()
await settle()
const toggleButton = root.querySelector<HTMLButtonElement>(
`[data-testid="pool-toggle-active-desktop-${inactiveKey.key_id}"]`,
)
expect(toggleButton).not.toBeNull()
expect(toggleButton?.getAttribute('aria-label')).toBe('启用账号')
const listCallsBeforeToggle = endpointMocks.listPoolKeys.mock.calls.length
toggleButton?.click()
await settle()
expect(endpointMocks.updateProviderKey).toHaveBeenCalledWith(inactiveKey.key_id, { is_active: true })
expect(toggleButton?.getAttribute('aria-label')).toBe('禁用账号')
expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(listCallsBeforeToggle + 1)
expect(root.textContent).toContain('冷却中')
})
it('revalidates the pool page when OAuth refresh fails after server-side invalidation', async () => {
const oauthKey = createPoolKey('codex', {
auth_type: 'oauth',
oauth_managed: true,
can_refresh_oauth: true,
status_snapshot: {
...createPoolKey('codex').status_snapshot,
oauth: { code: 'expired', expires_at: 1 },
},
})
endpointMocks.getPoolOverview.mockResolvedValue({ items: [createOverview('codex')] })
endpointMocks.listPoolKeys
.mockResolvedValueOnce(createKeyPage(oauthKey))
.mockResolvedValue({ total: 0, page: 1, page_size: 50, keys: [] })
endpointMocks.getProvider.mockResolvedValue(createProvider('codex'))
endpointMocks.refreshProviderOAuth.mockRejectedValue(new Error('OAuth invalid'))
const root = mountPoolManagement()
await settle()
const refreshButton = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
.find(button => button.title === '重新授权')
expect(refreshButton).not.toBeUndefined()
refreshButton?.click()
await settle()
expect(endpointMocks.refreshProviderOAuth).toHaveBeenCalledWith(oauthKey.key_id)
expect(endpointMocks.listPoolKeys).toHaveBeenCalledTimes(2)
expect(root.textContent).not.toContain(oauthKey.key_name)
}) })
it('hides the stats mode switch for non-Codex providers and keeps account totals', async () => { it('hides the stats mode switch for non-Codex providers and keeps account totals', async () => {