Fix usage records filtering and pool trace display

This commit is contained in:
fawney19
2026-05-28 20:24:48 +08:00
parent df518ad668
commit ef2953038e
11 changed files with 309 additions and 15 deletions
@@ -22,6 +22,19 @@
class="h-8 w-full text-xs border-border/60 pl-8"
/>
</div>
<Button
variant="ghost"
size="icon"
data-usage-hide-unknown-toggle="mobile"
class="h-8 w-8 shrink-0 md:hidden"
:class="hideUnknownRecords ? 'text-primary' : ''"
:title="hideUnknownRecords ? '显示 unknown 请求' : '隐藏 unknown 请求'"
aria-label="隐藏 unknown 模型或提供商的请求"
:aria-pressed="hideUnknownRecords"
@click="$emit('update:hideUnknownRecords', !hideUnknownRecords)"
>
<EyeOff class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -178,6 +191,19 @@
<div class="hidden sm:block h-4 w-px bg-border" />
<!-- 自动刷新按钮 -->
<Button
variant="ghost"
size="icon"
data-usage-hide-unknown-toggle="desktop"
class="hidden h-8 w-8 shrink-0 md:inline-flex"
:class="hideUnknownRecords ? 'text-primary' : ''"
:title="hideUnknownRecords ? '显示 unknown 请求' : '隐藏 unknown 请求'"
aria-label="隐藏 unknown 模型或提供商的请求"
:aria-pressed="hideUnknownRecords"
@click="$emit('update:hideUnknownRecords', !hideUnknownRecords)"
>
<EyeOff class="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -1028,7 +1054,7 @@ import {
SortableTableHead,
TableFilterMenu,
} from '@/components/ui'
import { RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { EyeOff, RefreshCcw, Search, Shuffle } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
@@ -1114,6 +1140,7 @@ const props = defineProps<{
pageSizeOptions: number[]
// 自动刷新
autoRefresh: boolean
hideUnknownRecords: boolean
}>()
const emit = defineEmits<{
@@ -1128,6 +1155,7 @@ const emit = defineEmits<{
'update:currentPage': [value: number]
'update:pageSize': [value: number]
'update:autoRefresh': [value: boolean]
'update:hideUnknownRecords': [value: boolean]
'refresh': []
'showDetail': [id: string]
'prefetchDetail': [id: string]
@@ -257,6 +257,46 @@ describe('HorizontalRequestTimeline', () => {
expect(nodeDots[2].classList.contains('status-pending')).toBe(true)
})
it('keeps successful runtime pool key visible when only pool_key_index is recorded', async () => {
const trace = buildTrace([
buildCandidate({
id: 'pool-skipped',
provider_id: 'provider-pool',
provider_name: 'CodexFree2',
key_id: 'pool-group',
key_name: 'CodexFree2',
candidate_index: 0,
status: 'skipped',
started_at: undefined,
finished_at: undefined,
extra_data: { pool_group_id: 'provider-pool' },
}),
buildCandidate({
id: 'pool-success',
provider_id: 'provider-pool',
provider_name: 'CodexFree2',
key_id: 'key-success',
key_name: 'Success Key',
candidate_index: 1,
status: 'success',
extra_data: { pool_key_index: 0 },
}),
])
const root = mountTimeline(trace)
await nextTick()
const labels = [...root.querySelectorAll<HTMLElement>('.node-label')]
.map(label => label.textContent?.trim())
expect(labels).toEqual(['CodexFree2'])
expect(root.querySelector<HTMLElement>('.node-dot')?.classList.contains('status-success'))
.toBe(true)
expect([...root.querySelectorAll<HTMLButtonElement>('.sub-dot')]
.map(dot => dot.getAttribute('title'))).toEqual([
'#1 · Success Key · 成功',
])
})
it('uses candidate terminal status for node colors instead of overriding with HTTP code', async () => {
const trace = buildTrace([
buildCandidate({
@@ -81,6 +81,7 @@ vi.mock('lucide-vue-next', async () => {
return {
RefreshCcw: Icon,
EyeOff: Icon,
Search: Icon,
Shuffle: Icon,
ChevronDown: Icon,
@@ -152,6 +153,7 @@ function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string
totalRecords: records.length,
pageSizeOptions: [20, 50],
autoRefresh: false,
hideUnknownRecords: false,
...overrides,
})
@@ -301,6 +303,17 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).toContain('Doubao Embedding')
})
it('emits hide unknown toggle changes', () => {
const onUpdateHideUnknownRecords = vi.fn()
const root = mountUsageRecordsTable([buildRecord()], {
'onUpdate:hideUnknownRecords': onUpdateHideUnknownRecords,
})
root.querySelector<HTMLElement>('[data-usage-hide-unknown-toggle="desktop"]')?.click()
expect(onUpdateHideUnknownRecords).toHaveBeenCalledWith(true)
})
it('shows retry and fallback markers together when both flags are set', () => {
const root = mountUsageRecordsTable([buildRecord({
has_fallback: true,
@@ -37,6 +37,7 @@ export interface FilterParams {
api_format?: string
status?: string
client_family?: string
hideUnknownRecords?: boolean
}
export function useUsageData(options: UseUsageDataOptions) {
@@ -361,6 +362,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (filters?.client_family) {
params.client_family = filters.client_family
}
if (filters?.hideUnknownRecords) {
params.hide_unknown = true
}
const response = await usageApi.getAllUsageRecords(params)
if (requestId !== loadRecordsRequestId) {
@@ -175,6 +175,34 @@ describe('poolTrace', () => {
expect(attempts[1].extra_data?.pool_group_id).toBe('provider-1')
})
it('infers pool membership from runtime pool key metadata', () => {
const attempts = buildPoolParticipatedCandidates([
buildCandidate({
id: 'cand-pool-skipped',
candidate_index: 0,
provider_id: 'provider-1',
provider_name: 'CodexFree2',
key_id: 'pool-group',
key_name: 'CodexFree2',
status: 'skipped',
extra_data: { pool_group_id: 'provider-1' },
}),
buildCandidate({
id: 'cand-pool-success',
candidate_index: 1,
provider_id: 'provider-1',
provider_name: 'CodexFree2',
key_id: 'key-success',
key_name: 'Success Key',
status: 'success',
extra_data: { pool_key_index: 0 },
}),
], null, 'req-1')
expect(attempts).toHaveLength(2)
expect(attempts.map(item => item.key_id)).toEqual(['pool-group', 'key-success'])
})
it('treats only real execution statuses as attempted', () => {
expect(isAttemptedCandidate(buildCandidate({ status: 'success' }))).toBe(true)
expect(isAttemptedCandidate(buildCandidate({ status: 'failed' }))).toBe(true)
+31 -5
View File
@@ -74,14 +74,40 @@ export const parseTimelineStatus = (value: unknown): CandidateRecord['status'] |
}
export const extractPoolGroupId = (
candidate: Pick<CandidateRecord, 'extra_data'>,
candidate: Pick<CandidateRecord, 'extra_data' | 'provider_id'>,
): string | null => {
const extra = candidate.extra_data
if (!extra || typeof extra !== 'object' || Array.isArray(extra)) return null
const value = (extra as Record<string, unknown>).pool_group_id
if (typeof value !== 'string') return null
const text = value.trim()
return text || null
const raw = extra as Record<string, unknown>
for (const key of ['pool_group_id', 'candidate_group_id']) {
const value = raw[key]
if (typeof value === 'string') {
const text = value.trim()
if (text) return text
}
}
const routingTrace = raw.routing_trace
if (routingTrace && typeof routingTrace === 'object' && !Array.isArray(routingTrace)) {
const poolExpansion = (routingTrace as Record<string, unknown>).pool_expansion
if (Array.isArray(poolExpansion)) {
for (const item of poolExpansion) {
if (!item || typeof item !== 'object' || Array.isArray(item)) continue
const value = (item as Record<string, unknown>).pool_group_id
if (typeof value !== 'string') continue
const text = value.trim()
if (text) return text
}
}
}
if (raw.pool_key_index !== undefined && raw.pool_key_index !== null) {
const providerId = String(candidate.provider_id || '').trim()
if (providerId) return providerId
}
return null
}
export function buildPoolParticipatedCandidates(