Merge remote-tracking branch 'origin/pr-476'

This commit is contained in:
fawney19
2026-05-18 16:13:45 +08:00
5 changed files with 361 additions and 9 deletions

View File

@@ -142,6 +142,17 @@ export interface RequestErrorFlow {
summary_source?: string | null
}
export interface RequestSchedulingFailure {
source?: string | null
reason?: string | null
reason_label?: string | null
title?: string | null
message?: string | null
reason_summary?: string | null
status_code?: number | null
no_upstream_attempt?: boolean | null
}
export interface RequestDetail {
id: string // UUID
request_id: string
@@ -206,6 +217,7 @@ export interface RequestDetail {
failure_summary?: RequestErrorDomain | null
errors?: RequestErrorDomains | null
error_flow?: RequestErrorFlow | null
scheduling_failure?: RequestSchedulingFailure | null
response_time_ms: number
first_byte_time_ms?: number | null
created_at: string
@@ -366,7 +378,7 @@ export interface TimeRangeParams {
export const dashboardApi = {
// 获取仪表盘统计数据
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const cacheKey = buildCacheKey('dashboard:stats', params)
const cacheKey = buildCacheKey('dashboard:stats', params as Record<string, unknown> | undefined)
return cachedRequest(
cacheKey,
async () => {
@@ -427,7 +439,7 @@ export const dashboardApi = {
// 获取每日统计数据
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
const cacheKey = buildCacheKey('dashboard:daily-stats', params as Record<string, unknown> | undefined)
return cachedRequest(
cacheKey,
async () => {

View File

@@ -159,6 +159,47 @@
v-else-if="detail"
class="space-y-4"
>
<!-- 执行失败原因优先展示本地调度/运行时失败摘要 -->
<Card
v-if="failureNotice"
class="border-red-200 bg-red-50/80 shadow-sm dark:border-red-900/60 dark:bg-red-950/30"
>
<div class="p-3 sm:p-4 flex gap-3">
<div class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-300">
<AlertTriangle class="h-4 w-4" />
</div>
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
{{ failureNotice.title }}
</h4>
<Badge
v-if="failureNotice.isSchedulingFailure"
variant="outline"
class="border-red-300 bg-white/60 text-[10px] text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
>
调度阶段
</Badge>
</div>
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
{{ failureNotice.message }}
</p>
<div
v-if="failureNotice.meta.length > 0"
class="flex flex-wrap gap-1.5"
>
<span
v-for="item in failureNotice.meta"
:key="item"
class="rounded-full border border-red-200 bg-white/70 px-2 py-0.5 text-[11px] font-mono text-red-700 dark:border-red-900 dark:bg-red-950/50 dark:text-red-200"
>
{{ item }}
</span>
</div>
</div>
</div>
</Card>
<!-- 费用与性能概览 -->
<Card>
<div class="p-3 sm:p-4">
@@ -701,7 +742,7 @@ import Separator from '@/components/ui/separator.vue'
import Skeleton from '@/components/ui/skeleton.vue'
import Tabs from '@/components/ui/tabs.vue'
import TabsContent from '@/components/ui/tabs-content.vue'
import { Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { AlertTriangle, Check, Columns2, RefreshCw, X, Monitor, Server, MessageSquareText, Code2, Terminal, Play } from 'lucide-vue-next'
import { dashboardApi, type RequestDetail, type RequestErrorDomain } from '@/api/dashboard'
import type { ImageProgress, RequestTrace } from '@/api/requestTrace'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
@@ -721,6 +762,7 @@ import {
resolveDisplayRequestStatus,
resolveUsageStreamLabelSegments,
} from '../utils/status'
import { resolveRequestFailureNotice } from '../utils/errorNotice'
// 子组件
import RequestHeadersContent from './RequestDetailDrawer/RequestHeadersContent.vue'
@@ -1023,6 +1065,8 @@ const metadataPanelData = computed<Record<string, unknown> | null>(() => {
return Object.keys(merged).length > 0 ? merged : null
})
const failureNotice = computed(() => resolveRequestFailureNotice(detail.value))
const settlementInfo = computed<JsonRecord | null>(() =>
asRecord(detail.value?.settlement ?? null),
)
@@ -1826,6 +1870,7 @@ async function ensureBodyContentLoaded() {
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
bodiesLoadedForRequestId.value = cacheKey
} catch (err) {
@@ -1872,12 +1917,13 @@ async function loadDetail(id: string, silent = false) {
provider_request_body: sameRequest ? previousDetail?.provider_request_body : undefined,
response_body: sameRequest ? previousDetail?.response_body : undefined,
client_response_body: sameRequest ? previousDetail?.client_response_body : undefined,
request_error: sameRequest ? (previousDetail?.request_error ?? response.request_error) : response.request_error,
upstream_error: sameRequest ? (previousDetail?.upstream_error ?? response.upstream_error) : response.upstream_error,
client_error: sameRequest ? (previousDetail?.client_error ?? response.client_error) : response.client_error,
failure_summary: sameRequest ? (previousDetail?.failure_summary ?? response.failure_summary) : response.failure_summary,
errors: sameRequest ? (previousDetail?.errors ?? response.errors) : response.errors,
error_flow: sameRequest ? (previousDetail?.error_flow ?? response.error_flow) : response.error_flow,
request_error: response.request_error,
upstream_error: response.upstream_error,
client_error: response.client_error,
failure_summary: response.failure_summary,
errors: response.errors,
error_flow: response.error_flow,
scheduling_failure: response.scheduling_failure,
}
bodiesLoadedForRequestId.value = sameRequest ? bodiesLoadedForRequestId.value : null

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from 'vitest'
import type { RequestDetail } from '@/api/dashboard'
import { resolveRequestFailureNotice } from '../errorNotice'
function buildRequestDetail(overrides: Partial<RequestDetail> = {}): RequestDetail {
return {
id: 'usage-1',
request_id: 'req-1',
user: {
id: 'user-1',
username: 'alice',
email: 'alice@example.com',
},
api_key: {
id: 'key-1',
name: 'primary',
display: 'primary',
},
provider: 'OpenAI',
model: 'gpt-5',
tokens: {
input: 0,
output: 0,
total: 0,
},
cost: {
input: 0,
output: 0,
total: 0,
},
request_type: 'chat',
is_stream: true,
status_code: 503,
status: 'failed',
response_time_ms: 0,
created_at: '2026-05-14T10:33:21Z',
...overrides,
}
}
describe('request failure notice', () => {
it('prioritizes local scheduling failure details over generic 503 status', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
error_message: 'generic 503',
failure_summary: {
status_code: 503,
message: '没有可用提供商支持模型 gpt-5 的流式请求',
},
scheduling_failure: {
source: 'local_execution_runtime_miss',
reason: 'all_candidates_skipped',
reason_label: '所有候选均被跳过',
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gpt-5 的流式请求',
reason_summary: 'pool_account_exhausted 2 次',
status_code: 503,
no_upstream_attempt: true,
},
}))
expect(notice).toEqual({
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gpt-5 的流式请求',
isSchedulingFailure: true,
meta: [
'pool_account_exhausted 2 次',
'所有候选均被跳过',
'all_candidates_skipped',
'HTTP 503',
'未进入上游执行',
],
})
})
it('falls back to the failure summary for upstream failures', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
failure_summary: {
source: 'upstream_response',
status_code: 429,
type: 'insufficient_quota',
message: 'quota exceeded',
},
}))
expect(notice).toEqual({
title: '执行失败原因',
message: 'quota exceeded',
isSchedulingFailure: false,
meta: ['HTTP 429', 'insufficient_quota', 'upstream_response'],
})
})
it('does not show a stale notice when the refreshed detail has no error fields', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
status_code: 200,
status: 'completed',
error_message: undefined,
scheduling_failure: null,
failure_summary: null,
client_error: null,
upstream_error: null,
request_error: null,
}))
expect(notice).toBeNull()
})
})

View File

@@ -0,0 +1,82 @@
import type { RequestDetail, RequestErrorDomain, RequestSchedulingFailure } from '@/api/dashboard'
export interface RequestFailureNotice {
title: string
message: string
meta: string[]
isSchedulingFailure: boolean
}
function nonEmptyString(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function normalizeErrorDomain(domain: RequestErrorDomain | null | undefined): RequestErrorDomain | null {
if (!nonEmptyString(domain?.message)) return null
return domain ?? null
}
function formatHttpStatus(statusCode: number | null | undefined): string | null {
return typeof statusCode === 'number' ? `HTTP ${statusCode}` : null
}
function uniqueMeta(values: Array<string | null | undefined>): string[] {
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
}
function schedulingFailureMessage(
failure: RequestSchedulingFailure,
fallbackDomain: RequestErrorDomain | null,
fallbackErrorMessage: string | null,
): string | null {
return nonEmptyString(failure.message)
?? nonEmptyString(fallbackDomain?.message)
?? fallbackErrorMessage
?? nonEmptyString(failure.reason_label)
?? nonEmptyString(failure.reason)
}
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
if (!detail) return null
const fallbackDomain = normalizeErrorDomain(detail.failure_summary)
?? normalizeErrorDomain(detail.client_error)
?? normalizeErrorDomain(detail.upstream_error)
?? normalizeErrorDomain(detail.request_error)
const fallbackErrorMessage = nonEmptyString(detail.error_message ?? null)
const schedulingFailure = detail.scheduling_failure ?? null
if (schedulingFailure) {
const message = schedulingFailureMessage(schedulingFailure, fallbackDomain, fallbackErrorMessage)
if (message) {
return {
title: nonEmptyString(schedulingFailure.title) ?? '本地调度失败',
message,
isSchedulingFailure: true,
meta: uniqueMeta([
nonEmptyString(schedulingFailure.reason_summary),
nonEmptyString(schedulingFailure.reason_label),
nonEmptyString(schedulingFailure.reason),
formatHttpStatus(schedulingFailure.status_code ?? detail.status_code),
schedulingFailure.no_upstream_attempt ? '未进入上游执行' : null,
]),
}
}
}
const domain = fallbackDomain
const message = nonEmptyString(domain?.message) ?? fallbackErrorMessage
if (!message) return null
return {
title: '执行失败原因',
message,
isSchedulingFailure: false,
meta: uniqueMeta([
formatHttpStatus(domain?.status_code ?? detail.status_code),
nonEmptyString(domain?.type),
nonEmptyString(domain?.source),
]),
}
}