mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-12 14:10:19 +08:00
feat(usage): expose request timing details
This commit is contained in:
@@ -234,6 +234,12 @@ export interface RequestDetail {
|
||||
has_provider_request_body?: boolean
|
||||
has_response_body?: boolean
|
||||
has_client_response_body?: boolean
|
||||
body_load_errors?: {
|
||||
request_body?: boolean
|
||||
provider_request_body?: boolean
|
||||
response_body?: boolean
|
||||
client_response_body?: boolean
|
||||
} | null
|
||||
metadata?: Record<string, unknown>
|
||||
routing?: Record<string, unknown>
|
||||
body_capture?: Record<string, unknown>
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface UsageRecordDetail {
|
||||
rate_multiplier?: number // 成本倍率(仅管理员可见)
|
||||
response_time_ms?: number | null
|
||||
first_byte_time_ms?: number | null
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
is_stream: boolean
|
||||
upstream_is_stream?: boolean
|
||||
client_requested_stream?: boolean
|
||||
@@ -352,6 +354,8 @@ export const meApi = {
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
status_code?: number | null
|
||||
error_message?: string | null
|
||||
api_format?: string | null
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface UsageRecord {
|
||||
cost?: number
|
||||
response_time?: number
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
has_fallback?: boolean // 🆕 是否发生了 fallback
|
||||
client_family?: string | null
|
||||
client_ip?: string | null
|
||||
@@ -472,7 +474,10 @@ export const usageApi = {
|
||||
provider?: string
|
||||
api_format?: string // API 格式筛选(如 openai:chat, claude:messages)
|
||||
status?: string // 'stream' | 'standard' | 'error'
|
||||
client_family?: string
|
||||
hide_unknown?: boolean
|
||||
include_total?: boolean
|
||||
total_only?: boolean
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{
|
||||
@@ -480,6 +485,7 @@ export const usageApi = {
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
total_is_estimated?: boolean
|
||||
}> {
|
||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
||||
return dedupedRequest(key, async () => {
|
||||
@@ -488,6 +494,38 @@ export const usageApi = {
|
||||
})
|
||||
},
|
||||
|
||||
async getAllUsageRecordTotal(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
search?: string
|
||||
user_id?: string
|
||||
username?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
api_format?: string
|
||||
status?: string
|
||||
client_family?: string
|
||||
hide_unknown?: boolean
|
||||
}): Promise<number> {
|
||||
const requestParams = compactParams({
|
||||
...params,
|
||||
include_total: true,
|
||||
total_only: true,
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
})
|
||||
const key = buildCacheKey('usage:records:total', requestParams)
|
||||
return dedupedRequest(key, async () => {
|
||||
const response = await apiClient.get<UsageListResponse>('/api/admin/usage/records', {
|
||||
params: requestParams,
|
||||
})
|
||||
return assertNumber(response.data.total, 'total')
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
|
||||
* @param ids 可选,逗号分隔的请求 ID 列表
|
||||
@@ -511,6 +549,8 @@ export const usageApi = {
|
||||
rate_multiplier?: number | null
|
||||
response_time_ms: number | null
|
||||
first_byte_time_ms: number | null
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
status_code?: number | null
|
||||
error_message?: string | null
|
||||
provider?: string | null
|
||||
|
||||
@@ -7,11 +7,13 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
createdAt?: string | null
|
||||
responseTimeUpdatedAt?: string | null
|
||||
status?: string | null
|
||||
responseTimeMs?: number | null
|
||||
precision?: number
|
||||
}>(), {
|
||||
createdAt: null,
|
||||
responseTimeUpdatedAt: null,
|
||||
status: null,
|
||||
responseTimeMs: null,
|
||||
precision: 2,
|
||||
@@ -30,6 +32,10 @@ function parseCreatedAtMs(value: string | null | undefined): number {
|
||||
return new Date(normalized).getTime()
|
||||
}
|
||||
|
||||
function finiteNonNegativeMs(value: number | null | undefined): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
||||
}
|
||||
|
||||
function stopRaf() {
|
||||
if (rafId == null) return
|
||||
cancelAnimationFrame(rafId)
|
||||
@@ -61,8 +67,16 @@ onUnmounted(() => {
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!isActive.value) {
|
||||
if (props.responseTimeMs == null) return '-'
|
||||
return `${(props.responseTimeMs / 1000).toFixed(precision.value)}s`
|
||||
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
|
||||
if (responseTimeMs == null) return '-'
|
||||
return `${(responseTimeMs / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
const responseTimeMs = finiteNonNegativeMs(props.responseTimeMs)
|
||||
const updatedAtMs = parseCreatedAtMs(props.responseTimeUpdatedAt)
|
||||
if (responseTimeMs != null && !Number.isNaN(updatedAtMs)) {
|
||||
const elapsedSinceUpdateMs = Math.max(0, now.value - updatedAtMs)
|
||||
return `${((responseTimeMs + elapsedSinceUpdateMs) / 1000).toFixed(precision.value)}s`
|
||||
}
|
||||
|
||||
if (!props.createdAt) return '-'
|
||||
|
||||
@@ -1727,7 +1727,7 @@ const loadTrace = async (silent = false) => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId, { attemptedOnly: true })
|
||||
} catch (err: unknown) {
|
||||
if (isAxiosError(err) && err.response?.status === 404) {
|
||||
internalTrace.value = null
|
||||
|
||||
@@ -734,6 +734,24 @@
|
||||
>
|
||||
<Skeleton class="h-32 w-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="requestBodyLoadErrorVisible"
|
||||
class="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{{ bodyLoadErrorMessage }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="retryBodyContentLoad"
|
||||
>
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationView
|
||||
v-else-if="contentViewMode === 'conversation'"
|
||||
:render-result="requestRenderResult"
|
||||
@@ -783,6 +801,24 @@
|
||||
>
|
||||
<Skeleton class="h-32 w-full" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="responseBodyLoadErrorVisible"
|
||||
class="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{{ bodyLoadErrorMessage }}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-3 h-8 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="retryBodyContentLoad"
|
||||
>
|
||||
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
<ConversationView
|
||||
v-else-if="contentViewMode === 'conversation'"
|
||||
:render-result="responseRenderResult"
|
||||
@@ -843,7 +879,12 @@ import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageS
|
||||
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
|
||||
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { formatCompactNumber, formatShortRequestId, formatTokens } from '@/utils/format'
|
||||
import {
|
||||
formatByteSize,
|
||||
formatCompactNumber,
|
||||
formatShortRequestId,
|
||||
formatTokens,
|
||||
} from '@/utils/format'
|
||||
import { log } from '@/utils/logger'
|
||||
import { getEffectiveInputTokens } from '../token-normalization'
|
||||
import {
|
||||
@@ -942,6 +983,8 @@ type PricingTierLike = {
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
const METADATA_BYTE_FIELD_PATTERN = /(^bytes$|_bytes$|bytes$)/i
|
||||
|
||||
type NormalizedErrorDomain = {
|
||||
source?: string | null
|
||||
status_code?: number | null
|
||||
@@ -956,6 +999,29 @@ function asRecord(value: unknown): JsonRecord | null {
|
||||
return value as JsonRecord
|
||||
}
|
||||
|
||||
function shouldFormatMetadataByteField(key: string, value: unknown): value is number {
|
||||
return typeof value === 'number'
|
||||
&& Number.isFinite(value)
|
||||
&& METADATA_BYTE_FIELD_PATTERN.test(key)
|
||||
}
|
||||
|
||||
function formatMetadataDisplayValue(value: unknown, key = ''): unknown {
|
||||
if (shouldFormatMetadataByteField(key, value)) {
|
||||
return formatByteSize(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => formatMetadataDisplayValue(item))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.reduce<Record<string, unknown>>((formatted, [childKey, childValue]) => {
|
||||
formatted[childKey] = formatMetadataDisplayValue(childValue, childKey)
|
||||
return formatted
|
||||
}, {})
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): NormalizedErrorDomain | null {
|
||||
if (!domain || typeof domain !== 'object') return null
|
||||
const message = typeof domain.message === 'string' ? domain.message.trim() : ''
|
||||
@@ -1130,6 +1196,7 @@ const curlCopying = ref(false)
|
||||
const curlCopied = ref(false)
|
||||
const replayDialogOpen = ref(false)
|
||||
const bodyLoading = ref(false)
|
||||
const bodyLoadError = ref<string | null>(null)
|
||||
const bodiesLoadedForRequestId = ref<string | null>(null)
|
||||
const showTimeline = ref(false)
|
||||
const AUTO_REFRESH_INTERVAL_MS = 1000
|
||||
@@ -1236,7 +1303,9 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
|
||||
merged.settlement = detail.value.settlement
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? merged : null
|
||||
return Object.keys(merged).length > 0
|
||||
? formatMetadataDisplayValue(merged) as Record<string, unknown>
|
||||
: null
|
||||
})
|
||||
|
||||
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
|
||||
@@ -1288,6 +1357,36 @@ const isResponseBodyLoading = computed(() => {
|
||||
return bodyLoading.value && activeTab.value === 'response-body' && !currentResponseBody.value
|
||||
})
|
||||
|
||||
const requestBodyLoadFailed = computed(() => {
|
||||
const errors = detail.value?.body_load_errors
|
||||
return Boolean(errors?.request_body || errors?.provider_request_body)
|
||||
})
|
||||
|
||||
const responseBodyLoadFailed = computed(() => {
|
||||
const errors = detail.value?.body_load_errors
|
||||
return Boolean(errors?.response_body || errors?.client_response_body)
|
||||
})
|
||||
|
||||
const bodyLoadErrorMessage = computed(() => {
|
||||
return bodyLoadError.value || '正文内容加载失败,请重试'
|
||||
})
|
||||
|
||||
const requestBodyLoadErrorVisible = computed(() => {
|
||||
return Boolean(
|
||||
(bodyLoadError.value || requestBodyLoadFailed.value) &&
|
||||
activeTab.value === 'request-body' &&
|
||||
!currentRequestBody.value
|
||||
)
|
||||
})
|
||||
|
||||
const responseBodyLoadErrorVisible = computed(() => {
|
||||
return Boolean(
|
||||
(bodyLoadError.value || responseBodyLoadFailed.value) &&
|
||||
activeTab.value === 'response-body' &&
|
||||
!currentResponseBody.value
|
||||
)
|
||||
})
|
||||
|
||||
function clearTimelineMountTimer() {
|
||||
if (timelineMountTimer) {
|
||||
clearTimeout(timelineMountTimer)
|
||||
@@ -1918,6 +2017,15 @@ function hasContent(data: unknown): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
function hasBodyLoadErrors(errors: RequestDetail['body_load_errors'] | null | undefined): boolean {
|
||||
return Boolean(
|
||||
errors?.request_body ||
|
||||
errors?.provider_request_body ||
|
||||
errors?.response_body ||
|
||||
errors?.client_response_body
|
||||
)
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
@@ -2118,6 +2226,7 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||
showTimeline.value = false
|
||||
clearTimelineMountTimer()
|
||||
bodyLoading.value = false
|
||||
bodyLoadError.value = null
|
||||
bodiesLoadedForRequestId.value = null
|
||||
}
|
||||
})
|
||||
@@ -2131,6 +2240,7 @@ async function ensureBodyContentLoaded() {
|
||||
|
||||
const requestId = ++bodyLoadRequestId
|
||||
bodyLoading.value = true
|
||||
bodyLoadError.value = null
|
||||
try {
|
||||
const response = await dashboardApi.getRequestDetail(props.requestId, { includeBodies: true })
|
||||
if (requestId !== bodyLoadRequestId || !detail.value) return
|
||||
@@ -2144,6 +2254,7 @@ async function ensureBodyContentLoaded() {
|
||||
has_provider_request_body: response.has_provider_request_body,
|
||||
has_response_body: response.has_response_body,
|
||||
has_client_response_body: response.has_client_response_body,
|
||||
body_load_errors: response.body_load_errors,
|
||||
request_error: response.request_error,
|
||||
upstream_error: response.upstream_error,
|
||||
client_error: response.client_error,
|
||||
@@ -2152,10 +2263,13 @@ async function ensureBodyContentLoaded() {
|
||||
error_flow: response.error_flow,
|
||||
scheduling_failure: response.scheduling_failure,
|
||||
}
|
||||
bodiesLoadedForRequestId.value = cacheKey
|
||||
if (!hasBodyLoadErrors(response.body_load_errors)) {
|
||||
bodiesLoadedForRequestId.value = cacheKey
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestId !== bodyLoadRequestId) return
|
||||
log.error('Failed to load request bodies:', err)
|
||||
bodyLoadError.value = '正文内容加载失败,请重试'
|
||||
} finally {
|
||||
if (requestId === bodyLoadRequestId) {
|
||||
bodyLoading.value = false
|
||||
@@ -2163,6 +2277,11 @@ async function ensureBodyContentLoaded() {
|
||||
}
|
||||
}
|
||||
|
||||
function retryBodyContentLoad() {
|
||||
bodyLoadError.value = null
|
||||
void ensureBodyContentLoaded()
|
||||
}
|
||||
|
||||
async function loadDetail(id: string, silent = false) {
|
||||
if (silent && loadDetailInFlight) {
|
||||
return
|
||||
@@ -2178,6 +2297,7 @@ async function loadDetail(id: string, silent = false) {
|
||||
clearTimelineMountTimer()
|
||||
++bodyLoadRequestId
|
||||
bodyLoading.value = false
|
||||
bodyLoadError.value = null
|
||||
}
|
||||
error.value = null
|
||||
try {
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
<ElapsedTimeText
|
||||
class="text-primary"
|
||||
:created-at="record.created_at"
|
||||
:response-time-updated-at="record.response_time_updated_at ?? null"
|
||||
:status="getDisplayStatus(record)"
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
/>
|
||||
@@ -972,6 +973,7 @@
|
||||
<ElapsedTimeText
|
||||
class="text-primary"
|
||||
:created-at="record.created_at"
|
||||
:response-time-updated-at="record.response_time_updated_at ?? null"
|
||||
:status="getDisplayStatus(record)"
|
||||
:response-time-ms="record.response_time_ms ?? null"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, h, nextTick, type App } from 'vue'
|
||||
import ElapsedTimeText from '../ElapsedTimeText.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App, root: HTMLElement }> = []
|
||||
|
||||
function mountElapsedTimeText(props: Record<string, unknown>) {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp({
|
||||
render: () => h(ElapsedTimeText, props),
|
||||
})
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
})
|
||||
|
||||
describe('ElapsedTimeText', () => {
|
||||
it('uses active response timing from response_time_updated_at instead of stale created_at', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:10.000Z'))
|
||||
|
||||
const root = mountElapsedTimeText({
|
||||
status: 'streaming',
|
||||
createdAt: '2026-06-08T11:59:00Z',
|
||||
responseTimeUpdatedAt: '2026-06-08T12:00:06Z',
|
||||
responseTimeMs: 1500,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toBe('5.50s')
|
||||
})
|
||||
|
||||
it('falls back to created_at when active timing has not reached the backend yet', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-08T12:00:10.000Z'))
|
||||
|
||||
const root = mountElapsedTimeText({
|
||||
status: 'pending',
|
||||
createdAt: '2026-06-08T12:00:06Z',
|
||||
responseTimeUpdatedAt: null,
|
||||
responseTimeMs: null,
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(root.textContent).toBe('4.00s')
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { ref } from 'vue'
|
||||
|
||||
const {
|
||||
getAllUsageRecordsMock,
|
||||
getAllUsageRecordTotalMock,
|
||||
getUsageStatsMock,
|
||||
getUsageByModelMock,
|
||||
getUsageByProviderMock,
|
||||
@@ -10,6 +11,7 @@ const {
|
||||
meGetUsageMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getAllUsageRecordsMock: vi.fn(),
|
||||
getAllUsageRecordTotalMock: vi.fn(),
|
||||
getUsageStatsMock: vi.fn(),
|
||||
getUsageByModelMock: vi.fn(),
|
||||
getUsageByProviderMock: vi.fn(),
|
||||
@@ -20,6 +22,7 @@ const {
|
||||
vi.mock('@/api/usage', () => ({
|
||||
usageApi: {
|
||||
getAllUsageRecords: getAllUsageRecordsMock,
|
||||
getAllUsageRecordTotal: getAllUsageRecordTotalMock,
|
||||
getUsageStats: getUsageStatsMock,
|
||||
getUsageByModel: getUsageByModelMock,
|
||||
getUsageByProvider: getUsageByProviderMock,
|
||||
@@ -72,6 +75,7 @@ describe('useUsageData', () => {
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
getAllUsageRecordTotalMock.mockResolvedValue(1)
|
||||
getUsageStatsMock.mockRejectedValue({
|
||||
response: { status: 500 },
|
||||
message: 'stats failed',
|
||||
@@ -143,6 +147,35 @@ describe('useUsageData', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes exact admin record totals after an estimated first page', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const { loadRecords, totalRecords } = useUsageData({ isAdminPage })
|
||||
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
|
||||
|
||||
getAllUsageRecordsMock.mockResolvedValueOnce({
|
||||
records: [buildUsageRecord()],
|
||||
total: 21,
|
||||
total_is_estimated: true,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
})
|
||||
getAllUsageRecordTotalMock.mockResolvedValueOnce(122101)
|
||||
|
||||
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
|
||||
|
||||
expect(getAllUsageRecordsMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
include_total: false,
|
||||
}))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(getAllUsageRecordTotalMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
preset: 'last7days',
|
||||
tz_offset_minutes: 0,
|
||||
}))
|
||||
expect(totalRecords.value).toBe(122101)
|
||||
})
|
||||
|
||||
it('continues loading admin breakdowns when the summary request fails', async () => {
|
||||
const isAdminPage = ref(true)
|
||||
const {
|
||||
|
||||
@@ -366,13 +366,19 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
params.hide_unknown = true
|
||||
}
|
||||
|
||||
const response = await usageApi.getAllUsageRecords(params)
|
||||
const response = await usageApi.getAllUsageRecords({
|
||||
...params,
|
||||
include_total: false,
|
||||
})
|
||||
if (requestId !== loadRecordsRequestId) {
|
||||
return
|
||||
}
|
||||
const nextRecords = (response.records || []) as UsageRecord[]
|
||||
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
|
||||
totalRecords.value = response.total || 0
|
||||
if (response.total_is_estimated === true) {
|
||||
void refreshAdminRecordTotal(params, requestId)
|
||||
}
|
||||
} else {
|
||||
// 用户页面:使用用户 API
|
||||
const userData = await meApi.getUsage(params)
|
||||
@@ -397,6 +403,20 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminRecordTotal(
|
||||
params: Record<string, unknown>,
|
||||
requestId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const total = await usageApi.getAllUsageRecordTotal(params)
|
||||
if (requestId === loadRecordsRequestId) {
|
||||
totalRecords.value = total
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('加载使用记录总数失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function mergePositiveDurationMs(
|
||||
existingValue: number | null | undefined,
|
||||
nextValue: number | null | undefined
|
||||
@@ -513,6 +533,8 @@ export function useUsageData(options: UseUsageDataOptions) {
|
||||
actual_cost: existing.actual_cost ?? record.actual_cost,
|
||||
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms),
|
||||
first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
|
||||
updated_at: existing.updated_at ?? record.updated_at,
|
||||
response_time_updated_at: existing.response_time_updated_at ?? record.response_time_updated_at,
|
||||
status_code: existing.status_code ?? record.status_code,
|
||||
error_message: existing.error_message ?? record.error_message,
|
||||
image_progress: existing.image_progress ?? record.image_progress,
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface UsageRecord {
|
||||
error_message?: string
|
||||
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
|
||||
created_at: string
|
||||
updated_at?: string | null
|
||||
response_time_updated_at?: string | null
|
||||
has_fallback?: boolean
|
||||
has_retry?: boolean
|
||||
image_progress?: ImageProgress | null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatCompactNumber, formatTokens, formatUsageCount } from '../format'
|
||||
import { formatByteSize, formatCompactNumber, formatTokens, formatUsageCount } from '../format'
|
||||
|
||||
describe('format utils', () => {
|
||||
it('formats compact numbers beyond millions', () => {
|
||||
@@ -16,4 +16,11 @@ describe('format utils', () => {
|
||||
expect(formatTokens(1_500_000_000_000)).toBe('1.5T')
|
||||
expect(formatUsageCount(1_000_000_000)).toBe('1B')
|
||||
})
|
||||
|
||||
it('formats byte sizes with automatic units', () => {
|
||||
expect(formatByteSize(512)).toBe('0.5 KB')
|
||||
expect(formatByteSize(1024)).toBe('1 KB')
|
||||
expect(formatByteSize(12.5 * 1024 * 1024)).toBe('12.5 MB')
|
||||
expect(formatByteSize(2 * 1024 * 1024 * 1024)).toBe('2 GB')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,25 @@ export function formatCompactNumber(
|
||||
return `${sign}${formatCompactScaledValue(absValue, unitIndex, options.fractionDigits)}`
|
||||
}
|
||||
|
||||
export function formatByteSize(bytes: number | undefined | null): string {
|
||||
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const absBytes = Math.max(0, Math.abs(bytes))
|
||||
const units = [
|
||||
{ value: 1024 ** 3, suffix: 'GB' },
|
||||
{ value: 1024 ** 2, suffix: 'MB' },
|
||||
{ value: 1024, suffix: 'KB' },
|
||||
] as const
|
||||
const unit = units.find(candidate => absBytes >= candidate.value) ?? units[2]
|
||||
const scaled = absBytes / unit.value
|
||||
const fractionDigits = scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2
|
||||
const formatted = trimTrailingDecimalZeros(scaled.toFixed(fractionDigits))
|
||||
|
||||
return `${bytes < 0 ? '-' : ''}${formatted} ${unit.suffix}`
|
||||
}
|
||||
|
||||
// Token formatting - intelligent display based on value size
|
||||
export function formatTokens(num: number | undefined | null): string {
|
||||
return formatCompactNumber(num)
|
||||
|
||||
@@ -522,6 +522,15 @@ async function pollActiveRequests() {
|
||||
record.rate_multiplier = update.rate_multiplier ?? undefined
|
||||
record.response_time_ms = update.response_time_ms ?? undefined
|
||||
record.first_byte_time_ms = update.first_byte_time_ms ?? undefined
|
||||
if ('updated_at' in update) {
|
||||
record.updated_at = typeof update.updated_at === 'string' ? update.updated_at : null
|
||||
}
|
||||
if ('response_time_updated_at' in update) {
|
||||
record.response_time_updated_at =
|
||||
typeof update.response_time_updated_at === 'string'
|
||||
? update.response_time_updated_at
|
||||
: null
|
||||
}
|
||||
record.status_code = update.status_code ?? undefined
|
||||
record.error_message = update.error_message ?? undefined
|
||||
if (typeof update.upstream_is_stream === 'boolean') {
|
||||
|
||||
Reference in New Issue
Block a user