mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 09:57:47 +08:00
feat(usage): show output speed in usage records
Display completed request output speed in the usage records table while keeping active requests focused on first-byte latency and live total duration.
The visible table keeps the compact tps label, while the tooltip expands the same value as tokens/s alongside first-byte, total, and generation durations.
Constraint: Synced against aether-rust-pioneer at 77c04749 with no upstream diff
Confidence: high
Scope-risk: narrow
Tested: npm run test:run -- src/features/usage/components/__tests__/UsageRecordsTable.spec.ts src/features/usage/__tests__/performance.spec.ts
Tested: npm run type-check
Tested: npm run build
Not-tested: Full backend test suite
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user