fix(usage): preserve failed status for active request refreshes

This commit is contained in:
zhefox
2026-05-24 18:48:50 +08:00
parent bbd4338e8e
commit 2207b60834
6 changed files with 153 additions and 15 deletions
@@ -828,6 +828,16 @@ import {
type RenderBlock, type RenderBlock,
} from '../conversation' } from '../conversation'
type RequestStateStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
const REQUEST_STATE_STATUSES = new Set<RequestStateStatus>([
'pending',
'streaming',
'completed',
'failed',
'cancelled',
])
const props = defineProps<{ const props = defineProps<{
isOpen: boolean isOpen: boolean
requestId: string | null requestId: string | null
@@ -838,7 +848,7 @@ const emit = defineEmits<{
requestState: [state: { requestState: [state: {
id: string id: string
requestId?: string | null requestId?: string | null
status?: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' status?: RequestStateStatus
statusCode?: number | null statusCode?: number | null
responseTimeMs?: number | null responseTimeMs?: number | null
imageProgress?: ImageProgress | null imageProgress?: ImageProgress | null
@@ -922,7 +932,7 @@ function formatErrorDomainMeta(domain: NormalizedErrorDomain): string {
function mapTraceFinalStatusToRequestStatus( function mapTraceFinalStatusToRequestStatus(
status?: RequestTrace['final_status'] | null status?: RequestTrace['final_status'] | null
): 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' | undefined { ): RequestStateStatus | undefined {
switch (status) { switch (status) {
case 'success': case 'success':
return 'completed' return 'completed'
@@ -939,6 +949,49 @@ function mapTraceFinalStatusToRequestStatus(
} }
} }
function normalizeRequestStateStatus(status: unknown): RequestStateStatus | undefined {
const normalized = typeof status === 'string' ? status.trim().toLowerCase() : ''
return REQUEST_STATE_STATUSES.has(normalized as RequestStateStatus)
? normalized as RequestStateStatus
: undefined
}
function hasRequestFailureSignal(statusCode?: number | null, errorMessage?: string | null): boolean {
return (typeof statusCode === 'number' && statusCode >= 400) ||
(typeof errorMessage === 'string' && errorMessage.trim().length > 0)
}
function resolveRequestStateStatus(
status: unknown,
statusCode?: number | null,
errorMessage?: string | null
): RequestStateStatus | undefined {
const normalized = normalizeRequestStateStatus(status)
if ((normalized == null || normalized === 'pending' || normalized === 'streaming') &&
hasRequestFailureSignal(statusCode, errorMessage)) {
return 'failed'
}
return normalized
}
function resolveRequestStateStatusFromDetail(nextDetail: Pick<RequestDetail, 'status' | 'status_code' | 'error_message'>): RequestStateStatus | undefined {
return resolveRequestStateStatus(nextDetail.status, nextDetail.status_code, nextDetail.error_message)
}
function emitDetailRequestState(nextDetail: RequestDetail) {
const id = props.requestId
if (!id) return
emit('requestState', {
id,
requestId: nextDetail.request_id || nextDetail.id || null,
status: resolveRequestStateStatusFromDetail(nextDetail),
statusCode: nextDetail.status_code ?? undefined,
responseTimeMs: nextDetail.response_time_ms ?? undefined,
errorMessage: nextDetail.error_message ?? undefined,
})
}
function handleTraceState(state: { function handleTraceState(state: {
loaded: boolean loaded: boolean
hasTrace: boolean hasTrace: boolean
@@ -953,7 +1006,11 @@ function handleTraceState(state: {
const id = props.requestId const id = props.requestId
if (!id) return if (!id) return
const status = mapTraceFinalStatusToRequestStatus(state.finalStatus) const status = resolveRequestStateStatus(
mapTraceFinalStatusToRequestStatus(state.finalStatus),
state.statusCode,
state.errorMessage
)
const imageFailed = state.imageProgress?.phase === 'failed' const imageFailed = state.imageProgress?.phase === 'failed'
if (!status && !state.imageProgress && state.statusCode == null && state.latencyMs == null) return if (!status && !state.imageProgress && state.statusCode == null && state.latencyMs == null) return
@@ -2094,8 +2151,9 @@ async function loadDetail(id: string, silent = false) {
const prevKey = previousDetail?.request_id || previousDetail?.id const prevKey = previousDetail?.request_id || previousDetail?.id
const currKey = response.request_id || response.id const currKey = response.request_id || response.id
const sameRequest = !!prevKey && prevKey === currKey const sameRequest = !!prevKey && prevKey === currKey
detail.value = { const nextDetail: RequestDetail = {
...response, ...response,
status: resolveRequestStateStatusFromDetail(response) ?? response.status,
request_body: sameRequest ? previousDetail?.request_body : undefined, request_body: sameRequest ? previousDetail?.request_body : undefined,
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined, provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined, response_body: sameRequest ? previousDetail?.response_body : undefined,
@@ -2108,7 +2166,9 @@ async function loadDetail(id: string, silent = false) {
error_flow: response.error_flow, error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure, scheduling_failure: response.scheduling_failure,
} }
detail.value = nextDetail
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null
emitDetailRequestState(nextDetail)
// 首次加载时优先停留在轻量 tab,避免默认触发大 body 加载 // 首次加载时优先停留在轻量 tab,避免默认触发大 body 加载
if (!silent) { if (!silent) {
@@ -654,7 +654,14 @@
<TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]"> <TableCell v-if="isColumnVisible('status')" class="text-center py-4 w-[10%]">
<!-- 优先显示请求状态 --> <!-- 优先显示请求状态 -->
<Badge <Badge
v-if="getDisplayStatus(record) === 'pending'" v-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge
v-else-if="getDisplayStatus(record) === 'pending'"
variant="outline" variant="outline"
class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground" class="whitespace-nowrap animate-pulse border-muted-foreground/30 text-muted-foreground"
> >
@@ -667,13 +674,6 @@
> >
传输中 传输中
</Badge> </Badge>
<Badge
v-else-if="isUsageRecordFailed(record)"
variant="destructive"
class="whitespace-nowrap"
>
失败
</Badge>
<Badge <Badge
v-else-if="record.status === 'cancelled'" v-else-if="record.status === 'cancelled'"
variant="outline" variant="outline"
@@ -257,6 +257,19 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).not.toContain('等待中') expect(root.textContent).not.toContain('等待中')
}) })
it('shows failed instead of waiting when an active row has an HTTP error code', () => {
const root = mountUsageRecordsTable([buildRecord({
status: 'pending',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: null,
first_byte_time_ms: null,
})])
expect(root.textContent).toContain('失败')
expect(root.textContent).not.toContain('等待中')
})
it('renders output TPS in the non-admin usage table', () => { it('renders output TPS in the non-admin usage table', () => {
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false }) const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })
@@ -102,6 +102,47 @@ describe('useUsageData', () => {
expect(totalRecords.value).toBe(1) expect(totalRecords.value).toBe(1)
}) })
it('keeps locally resolved failure fields when a stale active record refreshes', async () => {
const isAdminPage = ref(true)
const { loadRecords, currentRecords } = useUsageData({ isAdminPage })
const dateRange = { preset: 'today', tz_offset_minutes: 0 }
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
getAllUsageRecordsMock.mockResolvedValueOnce({
records: [buildUsageRecord({
status: 'pending',
status_code: undefined,
error_message: undefined,
response_time_ms: null,
})],
total: 1,
limit: 20,
offset: 0,
})
await loadRecords({ page: 1, pageSize: 20 }, undefined, dateRange)
expect(currentRecords.value[0]).toMatchObject({
status: 'failed',
status_code: 524,
error_message: 'error code: 524',
response_time_ms: 125_000,
})
})
it('continues loading admin breakdowns when the summary request fails', async () => { it('continues loading admin breakdowns when the summary request fails', async () => {
const isAdminPage = ref(true) const isAdminPage = ref(true)
const { const {
@@ -509,6 +509,9 @@ export function useUsageData(options: UseUsageDataOptions) {
actual_cost: existing.actual_cost ?? record.actual_cost, actual_cost: existing.actual_cost ?? record.actual_cost,
response_time_ms: mergePositiveDurationMs(existing.response_time_ms, record.response_time_ms), 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), first_byte_time_ms: mergePositiveDurationMs(existing.first_byte_time_ms, record.first_byte_time_ms),
status_code: existing.status_code ?? record.status_code,
error_message: existing.error_message ?? record.error_message,
image_progress: existing.image_progress ?? record.image_progress,
is_stream: upstreamIsStream, is_stream: upstreamIsStream,
upstream_is_stream: upstreamIsStream, upstream_is_stream: upstreamIsStream,
client_requested_stream: clientRequestedStream, client_requested_stream: clientRequestedStream,
+24 -3
View File
@@ -156,6 +156,7 @@ import {
hasUsageFallback, hasUsageFallback,
isUsageRecordFailed, isUsageRecordFailed,
isUsageUpstreamStream, isUsageUpstreamStream,
normalizeRequestStatus,
resolveDisplayRequestStatus, resolveDisplayRequestStatus,
} from '@/features/usage/utils/status' } from '@/features/usage/utils/status'
import type { DateRangeParams, FilterStatusValue, RequestStatus } from '@/features/usage/types' import type { DateRangeParams, FilterStatusValue, RequestStatus } from '@/features/usage/types'
@@ -974,6 +975,8 @@ function handleDetailRequestState(update: {
const record = currentRecords.value.find(record => record.id === update.id) const record = currentRecords.value.find(record => record.id === update.id)
if (!record) return if (!record) return
const nextStatus = resolveDetailUpdateStatus(update)
const statusPriority: Record<RequestStatus, number> = { const statusPriority: Record<RequestStatus, number> = {
pending: 0, pending: 0,
streaming: 1, streaming: 1,
@@ -981,11 +984,11 @@ function handleDetailRequestState(update: {
failed: 2, failed: 2,
cancelled: 2, cancelled: 2,
} }
if (update.status) { if (nextStatus) {
const currentRank = record.status ? statusPriority[record.status] : 0 const currentRank = record.status ? statusPriority[record.status] : 0
const nextRank = statusPriority[update.status] const nextRank = statusPriority[nextStatus]
if (nextRank >= currentRank) { if (nextRank >= currentRank) {
record.status = update.status record.status = nextStatus
} }
} }
if ('statusCode' in update) { if ('statusCode' in update) {
@@ -1005,6 +1008,24 @@ function handleDetailRequestState(update: {
} }
} }
function resolveDetailUpdateStatus(update: {
status?: RequestStatus
statusCode?: number | null
imageProgress?: ImageProgress | null
errorMessage?: string | null
}): RequestStatus | undefined {
const status = normalizeRequestStatus(update.status)
const hasFailureSignal =
(typeof update.statusCode === 'number' && update.statusCode >= 400) ||
(typeof update.errorMessage === 'string' && update.errorMessage.trim().length > 0) ||
update.imageProgress?.phase === 'failed'
if ((status == null || status === 'pending' || status === 'streaming') && hasFailureSignal) {
return 'failed'
}
return status
}
function prefetchRequestDetail(id: string) { function prefetchRequestDetail(id: string) {
if (!isAdminPage.value) return if (!isAdminPage.value) return
void dashboardApi.prefetchRequestDetail(id).catch(error => { void dashboardApi.prefetchRequestDetail(id).catch(error => {