Merge branch 'pr-388' into aether-rust-pioneer

This commit is contained in:
fawney19
2026-05-07 01:34:22 +08:00
2 changed files with 295 additions and 54 deletions

View File

@@ -272,16 +272,28 @@
<!-- 耗时 --> <!-- 耗时 -->
<span <span
v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'" v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
class="text-primary tabular-nums" class="tabular-nums whitespace-nowrap"
><ElapsedTimeText >
:created-at="record.created_at" <span>{{ formatRecordDurationSeconds(record.first_byte_time_ms) }}</span>
:status="getDisplayStatus(record)" <span class="text-muted-foreground"> / </span>
:response-time-ms="record.response_time_ms ?? null" <ElapsedTimeText
/></span> class="text-primary"
:created-at="record.created_at"
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/>
</span>
<span <span
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null" v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="tabular-nums" class="flex flex-col items-end tabular-nums leading-3 shrink-0"
>{{ record.first_byte_time_ms != null ? (record.first_byte_time_ms / 1000).toFixed(1) + '/' : '' }}{{ record.response_time_ms != null ? (record.response_time_ms / 1000).toFixed(1) : '-' }}{{ record.response_time_ms != null ? 's' : '' }}</span> :title="getRecordPerformanceTitle(record)"
>
<span class="whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span
v-if="getRecordDisplayOutputRate(record) != null"
class="text-muted-foreground tabular-nums whitespace-nowrap"
>{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
</span>
<span <span
v-else v-else
class="tabular-nums" class="tabular-nums"
@@ -310,12 +322,12 @@
<colgroup v-else> <colgroup v-else>
<col class="w-[9%]"> <col class="w-[9%]">
<col class="w-[17%]"> <col class="w-[17%]">
<col class="w-[26%]"> <col class="w-[24%]">
<col class="w-[15%]"> <col class="w-[15%]">
<col class="w-[7%]"> <col class="w-[7%]">
<col class="w-[11%]"> <col class="w-[11%]">
<col class="w-[7%]"> <col class="w-[7%]">
<col class="w-[8%]"> <col class="w-[10%]">
</colgroup> </colgroup>
<TableHeader> <TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent"> <TableRow class="border-b border-border/60 hover:bg-transparent">
@@ -429,8 +441,8 @@
</TableHead> </TableHead>
<TableHead class="h-12 font-semibold w-[9%] text-right"> <TableHead class="h-12 font-semibold w-[9%] text-right">
<div class="flex flex-col items-end text-xs gap-0.5"> <div class="flex flex-col items-end text-xs gap-0.5">
<span>首字</span> <span class="whitespace-nowrap">首字/总耗时</span>
<span class="text-muted-foreground font-normal">总耗时</span> <span class="text-muted-foreground font-normal">输出速度</span>
</div> </div>
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -716,58 +728,33 @@
</div> </div>
</TableCell> </TableCell>
<TableCell class="text-right py-4 w-[9%]"> <TableCell class="text-right py-4 w-[9%]">
<!-- pending 状态只显示增长的总时间 --> <!-- pending/streaming 状态首字与动态总耗时保留在同一行 -->
<div <div
v-if="getDisplayStatus(record) === 'pending'" v-if="getDisplayStatus(record) === 'pending' || getDisplayStatus(record) === 'streaming'"
class="flex flex-col items-end text-xs gap-0.5" class="flex flex-col items-end text-xs gap-0.5"
> >
<span class="text-muted-foreground">-</span> <span class="tabular-nums whitespace-nowrap">
<span class="text-primary tabular-nums"><ElapsedTimeText <span>{{ formatRecordDurationSeconds(record.first_byte_time_ms) }}</span>
:created-at="record.created_at" <span class="text-muted-foreground"> / </span>
:status="getDisplayStatus(record)" <ElapsedTimeText
:response-time-ms="record.response_time_ms ?? null" class="text-primary"
/></span> :created-at="record.created_at"
</div> :status="getDisplayStatus(record)"
<!-- streaming 状态首字固定 + 总时间增长 --> :response-time-ms="record.response_time_ms ?? null"
<div />
v-else-if="getDisplayStatus(record) === 'streaming'" </span>
class="flex flex-col items-end text-xs gap-0.5"
>
<span
v-if="record.first_byte_time_ms != null"
class="tabular-nums"
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
<span class="text-primary tabular-nums"><ElapsedTimeText
:created-at="record.created_at"
:status="getDisplayStatus(record)"
:response-time-ms="record.response_time_ms ?? null"
/></span>
</div> </div>
<!-- 已完成状态首字 + 总耗时 --> <!-- 已完成状态首字 + 总耗时 -->
<div <div
v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null" v-else-if="record.response_time_ms != null || record.first_byte_time_ms != null"
class="flex flex-col items-end text-xs gap-0.5" class="flex flex-col items-end text-xs gap-0.5"
:title="getRecordPerformanceTitle(record)"
> >
<span class="tabular-nums whitespace-nowrap">{{ formatRecordLatencyPair(record) }}</span>
<span <span
v-if="record.first_byte_time_ms != null" v-if="getRecordDisplayOutputRate(record) != null"
class="tabular-nums" class="text-muted-foreground tabular-nums whitespace-nowrap"
>{{ (record.first_byte_time_ms / 1000).toFixed(2) }}s</span> >{{ formatOutputRate(getRecordDisplayOutputRate(record)) }}</span>
<span
v-else
class="text-muted-foreground"
>-</span>
<span
v-if="record.response_time_ms != null"
class="text-muted-foreground tabular-nums"
>{{ (record.response_time_ms / 1000).toFixed(2) }}s</span>
<span
v-else
class="text-muted-foreground"
>-</span>
</div> </div>
<span <span
v-else v-else
@@ -820,6 +807,12 @@ import {
import { RefreshCcw, Search } from 'lucide-vue-next' import { RefreshCcw, Search } from 'lucide-vue-next'
import { formatTokens, formatCurrency } from '@/utils/format' import { formatTokens, formatCurrency } from '@/utils/format'
import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization' import { getCacheCreationTokens, getCacheReadTokens, getEffectiveInputTokens } from '../token-normalization'
import {
formatOutputRate,
formatOutputRateValue,
getDisplayOutputRate,
getGenerationTimeMs,
} from '../performance'
import { import {
formatUsageStreamLabel, formatUsageStreamLabel,
isUsageRecordFailed, isUsageRecordFailed,
@@ -1040,6 +1033,43 @@ function formatOptionalTokens(value: number | null | undefined): string {
return hasPositiveTokens(value) ? formatTokens(value) : '-' return hasPositiveTokens(value) ? formatTokens(value) : '-'
} }
function formatRecordLatencyPair(record: UsageRecord): string {
const firstByte = formatRecordDurationSeconds(record.first_byte_time_ms)
const total = formatRecordDurationSeconds(record.response_time_ms)
return `${firstByte} / ${total}`
}
function formatRecordDurationSeconds(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms)) return '-'
return `${(ms / 1000).toFixed(2)}s`
}
function getRecordDisplayOutputRate(record: UsageRecord): number | null {
return getDisplayOutputRate({
output_tokens: record.output_tokens,
response_time_ms: record.response_time_ms,
first_byte_time_ms: record.first_byte_time_ms,
is_stream: record.is_stream,
upstream_is_stream: record.upstream_is_stream,
})
}
function getRecordPerformanceTitle(record: UsageRecord): string {
const outputRate = getRecordDisplayOutputRate(record)
return [
`首字: ${formatRecordDurationSeconds(record.first_byte_time_ms)}`,
`总耗时: ${formatRecordDurationSeconds(record.response_time_ms)}`,
`生成耗时: ${formatRecordDurationSeconds(getGenerationTimeMs(record))}`,
`输出速度: ${formatOutputRateTokensPerSecond(outputRate)}`,
].join('\n')
}
function formatOutputRateTokensPerSecond(outputRate: number | null | undefined): string {
const value = formatOutputRateValue(outputRate)
if (value === '-') return value
return `${value} tokens/s`
}
// useDebounceFn 自动处理清理,无需 onUnmounted // useDebounceFn 自动处理清理,无需 onUnmounted
// 判断是否应该显示格式转换信息 // 判断是否应该显示格式转换信息

View File

@@ -0,0 +1,211 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, type App } from 'vue'
import UsageRecordsTable from '../UsageRecordsTable.vue'
import type { UsageRecord } from '../../types'
vi.mock('@/components/ui', async () => {
const { defineComponent, h } = await import('vue')
const passthrough = (name: string, tag = 'div') => defineComponent({
name,
setup(_, { slots }) {
return () => h(tag, [
slots.default?.(),
slots.actions?.(),
slots.pagination?.(),
slots.filter?.({ close: () => undefined }),
])
},
})
return {
TableCard: passthrough('TableCardStub', 'section'),
Badge: passthrough('BadgeStub', 'span'),
Button: passthrough('ButtonStub', 'button'),
Input: defineComponent({
name: 'InputStub',
props: { modelValue: String },
emits: ['update:modelValue'],
setup(props, { attrs, emit }) {
return () => h('input', {
...attrs,
value: props.modelValue ?? '',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
})
},
}),
Select: passthrough('SelectStub'),
SelectTrigger: passthrough('SelectTriggerStub'),
SelectValue: passthrough('SelectValueStub', 'span'),
SelectContent: passthrough('SelectContentStub'),
SelectItem: passthrough('SelectItemStub'),
Table: passthrough('TableStub', 'table'),
TableHeader: passthrough('TableHeaderStub', 'thead'),
TableBody: passthrough('TableBodyStub', 'tbody'),
TableRow: passthrough('TableRowStub', 'tr'),
TableHead: passthrough('TableHeadStub', 'th'),
TableCell: passthrough('TableCellStub', 'td'),
Pagination: passthrough('PaginationStub'),
SortableTableHead: passthrough('SortableTableHeadStub', 'th'),
TableFilterMenu: passthrough('TableFilterMenuStub'),
}
})
vi.mock('@/components/common', async () => {
const { defineComponent, h } = await import('vue')
return {
TimeRangePicker: defineComponent({
name: 'TimeRangePickerStub',
setup() {
return () => h('div')
},
}),
}
})
vi.mock('lucide-vue-next', async () => {
const { defineComponent, h } = await import('vue')
const Icon = defineComponent({
name: 'IconStub',
setup() {
return () => h('span')
},
})
return {
RefreshCcw: Icon,
Search: Icon,
}
})
vi.mock('../ElapsedTimeText.vue', () => ({
default: defineComponent({
name: 'ElapsedTimeTextStub',
setup() {
return () => h('span', 'elapsed')
},
}),
}))
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
function buildRecord(overrides: Partial<UsageRecord> = {}): UsageRecord {
return {
id: 'usage-1',
model: 'gpt-5',
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
cost: 0.01,
response_time_ms: 1000,
first_byte_time_ms: 500,
is_stream: true,
upstream_is_stream: true,
status: 'completed',
created_at: '2026-05-06T12:00:00Z',
...overrides,
}
}
function mountUsageRecordsTable(records: UsageRecord[], overrides: Record<string, unknown> = {}) {
const root = document.createElement('div')
document.body.appendChild(root)
const app = createApp(UsageRecordsTable, {
records,
isAdmin: true,
showActualCost: false,
loading: false,
timeRange: { preset: 'today', tz_offset_minutes: 0 },
filterSearch: '',
filterUser: '__all__',
filterModel: '__all__',
filterProvider: '__all__',
filterApiFormat: '__all__',
filterStatus: '__all__',
availableUsers: [],
availableModels: [],
availableProviders: [],
currentPage: 1,
pageSize: 20,
totalRecords: records.length,
pageSizeOptions: [20, 50],
autoRefresh: false,
...overrides,
})
app.mount(root)
mountedApps.push({ app, root })
return root
}
afterEach(() => {
for (const { app, root } of mountedApps.splice(0)) {
app.unmount()
root.remove()
}
})
describe('UsageRecordsTable', () => {
it('shows output TPS after the request completes', () => {
const root = mountUsageRecordsTable([buildRecord()])
expect(root.textContent).toContain('输出速度')
expect(root.textContent).toContain('0.50s / 1.00s')
expect(root.textContent).not.toContain('500ms')
expect(root.textContent).toContain('100 tps')
expect([...root.querySelectorAll<HTMLElement>('.text-muted-foreground')]
.some((element) => element.textContent?.includes('100 tps'))).toBe(true)
const tpsElements = [...root.querySelectorAll<HTMLElement>('.text-muted-foreground')]
.filter((element) => element.textContent?.trim() === '100 tps')
expect(tpsElements.some((element) => element.classList.contains('text-[11px]'))).toBe(false)
const titles = [...root.querySelectorAll<HTMLElement>('[title]')].map((element) => element.title)
expect(titles).toContain([
'首字: 0.50s',
'总耗时: 1.00s',
'生成耗时: 0.50s',
'输出速度: 100 tokens/s',
].join('\n'))
expect(titles.join('\n')).not.toContain('500ms')
expect(titles.join('\n')).not.toContain('首字后生成耗时')
})
it('keeps active request latency in one first-byte / live-total line without TPS', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'streaming',
response_time_ms: null,
first_byte_time_ms: 500,
})])
expect(root.textContent).toContain('0.50s')
expect(root.textContent).toContain('elapsed')
expect(root.textContent).toContain('0.50s / elapsed')
expect(root.textContent).not.toContain('100 tps')
expect(root.textContent).not.toContain('生成中')
expect(root.textContent).not.toContain('等待首字')
expect(root.querySelector('[data-active-latency-state="streaming"]')).toBeNull()
})
it('uses a first-byte placeholder and live total before the first byte arrives', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
response_time_ms: null,
first_byte_time_ms: null,
})])
expect(root.textContent).toContain('- / elapsed')
expect(root.textContent).toContain('elapsed')
expect(root.textContent).not.toContain('等待首字')
expect(root.querySelector('[data-active-latency-state="waiting-first-byte"]')).toBeNull()
})
it('renders output TPS in the non-admin usage table', () => {
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })
expect(root.textContent).toContain('100 tps')
expect(root.textContent).toContain('0.50s / 1.00s')
expect(root.textContent).toContain('gpt-5')
})
})