Expose local scheduling failures in usage UI

This commit is contained in:
MMEXA
2026-05-18 16:28:37 +00:00
parent d51b44d642
commit 4317ff78b1
15 changed files with 620 additions and 24 deletions

View File

@@ -151,6 +151,19 @@ export interface RequestSchedulingFailure {
reason_summary?: string | null
status_code?: number | null
no_upstream_attempt?: boolean | null
requested_model?: string | null
candidate_count?: number | null
persisted_candidate_count?: number | null
skipped_candidate_count?: number | null
skip_reasons?: Record<string, number> | null
provider_hint?: {
id?: string | null
name?: string | null
} | null
endpoint_hint?: {
id?: string | null
api_format?: string | null
} | null
}
export interface RequestDetail {

View File

@@ -547,6 +547,38 @@
</Card>
</div>
<!-- Local Scheduling Failure State -->
<Card
v-else-if="schedulingFailureNotice"
class="border-red-200 dark:border-red-800"
>
<div class="p-4 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<Badge variant="destructive">
调度失败
</Badge>
<h4 class="text-sm font-semibold text-red-950 dark:text-red-100">
{{ schedulingFailureNotice.title }}
</h4>
</div>
<p class="text-sm leading-6 text-red-900 dark:text-red-100">
{{ schedulingFailureNotice.message }}
</p>
<div
v-if="schedulingFailureNotice.meta.length > 0"
class="flex flex-wrap gap-1.5"
>
<span
v-for="item in schedulingFailureNotice.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>
</Card>
<!-- Empty State -->
<Card
v-else
@@ -575,6 +607,7 @@ import { parseApiError } from '@/utils/errorParser'
import { formatApiFormat } from '@/api/endpoints/types/api-format'
import { useDarkMode } from '@/composables/useDarkMode'
import { resolveTimelineFinalStatus } from '../utils/status'
import type { RequestSchedulingFailure } from '@/api/dashboard'
import {
buildPoolGroupVisibleAttempts,
buildPoolParticipatedCandidates,
@@ -637,6 +670,8 @@ const props = defineProps<{
usageData?: UsageData | null
/** 请求元数据(用于号池调度组装) */
requestMetadata?: Record<string, unknown> | null
/** 本地调度失败摘要;用于没有 trace candidate 时替代空态 */
schedulingFailure?: RequestSchedulingFailure | null
/** 已获取的追踪数据;传入时不再内部拉取 */
traceData?: RequestTrace | null
}>()
@@ -812,6 +847,62 @@ const computedFinalStatus = computed(() => {
})
})
const nonEmptyNoticeString = (value: string | null | undefined): string | null => {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
const uniqueNoticeMeta = (values: Array<string | null | undefined>): string[] => {
return Array.from(new Set(values.map(value => value?.trim()).filter((value): value is string => Boolean(value))))
}
const isUnknownProviderHint = (value: string | null): boolean => {
const normalized = value?.trim().toLowerCase()
return !normalized || ['unknown', 'unknow', 'pending'].includes(normalized)
}
const schedulingFailureProviderHint = (failure: RequestSchedulingFailure): string | null => {
const name = nonEmptyNoticeString(failure.provider_hint?.name)
if (name && !isUnknownProviderHint(name)) return name
const id = nonEmptyNoticeString(failure.provider_hint?.id)
if (id && !isUnknownProviderHint(id)) return id
return null
}
const schedulingFailureEndpointHint = (failure: RequestSchedulingFailure): string | null => {
return nonEmptyNoticeString(failure.endpoint_hint?.api_format)
?? nonEmptyNoticeString(failure.endpoint_hint?.id)
}
const schedulingFailureNotice = computed(() => {
const failure = props.schedulingFailure
if (!failure) return null
const title = nonEmptyNoticeString(failure.title) ?? '本地调度失败'
const message = nonEmptyNoticeString(failure.message)
?? nonEmptyNoticeString(failure.reason_summary)
?? nonEmptyNoticeString(failure.reason_label)
?? nonEmptyNoticeString(failure.reason)
?? '本地调度阶段没有选出可用上游提供商'
return {
title,
message,
meta: uniqueNoticeMeta([
schedulingFailureProviderHint(failure),
schedulingFailureEndpointHint(failure),
nonEmptyNoticeString(failure.requested_model),
nonEmptyNoticeString(failure.reason_summary),
nonEmptyNoticeString(failure.reason_label),
nonEmptyNoticeString(failure.reason),
typeof failure.status_code === 'number' ? `HTTP ${failure.status_code}` : null,
failure.no_upstream_attempt ? '未进入上游执行' : null,
]),
}
})
const compareBySchedulingOrder = (a: CandidateRecord, b: CandidateRecord): number => {
if (a.candidate_index !== b.candidate_index) {
return a.candidate_index - b.candidate_index

View File

@@ -496,6 +496,7 @@
:request-status="detail.status"
:request-api-format="detail.api_format || null"
:request-metadata="traceRequestMetadata"
:scheduling-failure="detail.scheduling_failure"
@trace-state="handleTraceState"
/>
</div>

View File

@@ -536,15 +536,18 @@
>
<div class="flex min-w-0 items-center gap-1">
<div class="flex min-w-0 flex-col text-xs gap-0.5">
<span class="truncate">{{ record.provider }}</span>
<span
v-if="record.provider_key_name"
class="truncate"
:title="getRecordProviderTitle(record)"
>{{ getRecordProviderDisplay(record) }}</span>
<span
v-if="getRecordProviderSecondaryText(record)"
class="text-muted-foreground truncate"
:title="record.provider_key_name"
:title="getRecordProviderSecondaryTitle(record)"
>
{{ record.provider_key_name }}
{{ getRecordProviderSecondaryText(record) }}
<span
v-if="record.rate_multiplier && record.rate_multiplier !== 1.0"
v-if="record.provider_key_name && record.rate_multiplier && record.rate_multiplier !== 1.0"
class="text-foreground/60"
>({{ record.rate_multiplier }}x)</span>
</span>
@@ -938,6 +941,57 @@ function getDisplayStatus(record: UsageRecord) {
return resolveDisplayRequestStatus(record)
}
function nonEmptyDisplayString(value: string | null | undefined): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function isUnknownProvider(value: string | null | undefined): boolean {
const normalized = value?.trim().toLowerCase()
return !normalized || ['unknown', 'unknow', 'pending'].includes(normalized)
}
function getRecordSchedulingProviderHint(record: UsageRecord): string | null {
const name = nonEmptyDisplayString(record.scheduling_failure?.provider_hint?.name)
if (name && !isUnknownProvider(name)) return name
const id = nonEmptyDisplayString(record.scheduling_failure?.provider_hint?.id)
if (id && !isUnknownProvider(id)) return id
return null
}
function getRecordProviderDisplay(record: UsageRecord): string {
const provider = nonEmptyDisplayString(record.provider)
if (provider && !isUnknownProvider(provider)) return provider
const providerHint = getRecordSchedulingProviderHint(record)
if (providerHint) return providerHint
if (record.scheduling_failure) return '未选定提供商'
return provider ?? 'unknown'
}
function getRecordProviderTitle(record: UsageRecord): string {
const schedulingMessage = nonEmptyDisplayString(record.scheduling_failure?.message)
const providerDisplay = getRecordProviderDisplay(record)
return schedulingMessage ? `${providerDisplay}\n${schedulingMessage}` : providerDisplay
}
function getRecordProviderSecondaryText(record: UsageRecord): string | null {
return nonEmptyDisplayString(record.provider_key_name)
?? nonEmptyDisplayString(record.scheduling_failure?.reason_label)
?? nonEmptyDisplayString(record.scheduling_failure?.reason_summary)
?? nonEmptyDisplayString(record.scheduling_failure?.reason)
}
function getRecordProviderSecondaryTitle(record: UsageRecord): string | undefined {
return nonEmptyDisplayString(record.provider_key_name)
?? nonEmptyDisplayString(record.scheduling_failure?.message)
?? getRecordProviderSecondaryText(record)
?? undefined
}
function getStreamModeLabel(record: UsageRecord): string {
return formatUsageStreamLabel(record)
}

View File

@@ -350,6 +350,40 @@ describe('HorizontalRequestTimeline', () => {
expect(nodeDot?.classList.contains('status-success')).toBe(false)
})
it('shows scheduling failure context instead of an empty trace state when no candidates exist', async () => {
const trace = buildTrace([])
trace.final_status = 'failed'
const root = mountTimeline(trace, {
requestStatus: 'failed',
overrideStatusCode: 503,
schedulingFailure: {
source: 'local_execution_runtime_miss',
reason: 'all_candidates_skipped',
reason_label: '所有候选均被跳过',
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
status_code: 503,
no_upstream_attempt: true,
provider_hint: {
id: 'provider-google-api',
name: 'Google API',
},
endpoint_hint: {
id: 'endpoint-gemini',
api_format: 'gemini:generate_content',
},
},
})
await nextTick()
expect(root.textContent).toContain('本地调度失败:所有候选均被跳过')
expect(root.textContent).toContain('没有可用提供商支持模型 gemma-4-31b-it 的同步请求')
expect(root.textContent).toContain('Google API')
expect(root.textContent).toContain('gemini:generate_content')
expect(root.textContent).not.toContain('暂无追踪数据')
})
it('keeps emitted trace state active while the request lifecycle is still streaming', async () => {
const onTraceState = vi.fn()
const trace = buildTrace([

View File

@@ -247,6 +247,31 @@ describe('UsageRecordsTable', () => {
expect(root.textContent).not.toContain('等待中')
})
it('uses scheduling failure provider hints instead of rendering unknown provider', () => {
const root = mountUsageRecordsTable([buildRecord({
provider: 'unknown',
status: 'failed',
status_code: 503,
scheduling_failure: {
source: 'local_execution_runtime_miss',
reason: 'all_candidates_skipped',
reason_label: '所有候选均被跳过',
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
status_code: 503,
no_upstream_attempt: true,
provider_hint: {
id: 'provider-google-api',
name: 'Google API',
},
},
} as Partial<UsageRecord>)])
expect(root.textContent).toContain('Google API')
expect(root.textContent).toContain('所有候选均被跳过')
expect(root.textContent).not.toContain('unknown')
})
it('renders output TPS in the non-admin usage table', () => {
const root = mountUsageRecordsTable([buildRecord()], { isAdmin: false })

View File

@@ -511,7 +511,8 @@ export function useUsageData(options: UseUsageDataOptions) {
api_key_name: existing.api_key_name || record.api_key_name,
provider_key_name: existing.provider_key_name || record.provider_key_name,
rate_multiplier: existing.rate_multiplier ?? record.rate_multiplier,
target_model: existing.target_model || record.target_model
target_model: existing.target_model || record.target_model,
scheduling_failure: existing.scheduling_failure ?? record.scheduling_failure
}
}
@@ -519,7 +520,8 @@ export function useUsageData(options: UseUsageDataOptions) {
if (protectProvider) {
return {
...record,
provider: existing.provider
provider: existing.provider,
scheduling_failure: existing.scheduling_failure ?? record.scheduling_failure
}
}

View File

@@ -1,4 +1,5 @@
import type { ImageProgress } from '@/api/requestTrace'
import type { RequestSchedulingFailure } from '@/api/dashboard'
// 统计数据状态
export interface UsageStatsState {
@@ -115,6 +116,7 @@ export interface UsageRecord {
status_code?: number
error_message?: string
status?: RequestStatus // 请求状态: pending, streaming, completed, failed
scheduling_failure?: RequestSchedulingFailure | null
created_at: string
has_fallback?: boolean
has_retry?: boolean

View File

@@ -73,6 +73,43 @@ describe('request failure notice', () => {
})
})
it('includes scheduling failure provider and endpoint hints in metadata', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
failure_summary: {
status_code: 503,
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
},
scheduling_failure: {
source: 'local_execution_runtime_miss',
reason: 'all_candidates_skipped',
reason_label: '所有候选均被跳过',
title: '本地调度失败:所有候选均被跳过',
message: '没有可用提供商支持模型 gemma-4-31b-it 的同步请求',
status_code: 503,
no_upstream_attempt: true,
requested_model: 'gemma-4-31b-it',
provider_hint: {
id: 'provider-google-api',
name: 'Google API',
},
endpoint_hint: {
id: 'endpoint-gemini',
api_format: 'gemini:generate_content',
},
} as NonNullable<RequestDetail['scheduling_failure']>,
}))
expect(notice?.meta).toEqual([
'Google API',
'gemini:generate_content',
'gemma-4-31b-it',
'所有候选均被跳过',
'all_candidates_skipped',
'HTTP 503',
'未进入上游执行',
])
})
it('falls back to the failure summary for upstream failures', () => {
const notice = resolveRequestFailureNotice(buildRequestDetail({
failure_summary: {

View File

@@ -25,6 +25,11 @@ 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 isUnknownProviderHint(value: string | null): boolean {
const normalized = value?.trim().toLowerCase()
return !normalized || ['unknown', 'unknow', 'pending'].includes(normalized)
}
function schedulingFailureMessage(
failure: RequestSchedulingFailure,
fallbackDomain: RequestErrorDomain | null,
@@ -37,6 +42,21 @@ function schedulingFailureMessage(
?? nonEmptyString(failure.reason)
}
function schedulingFailureProviderHint(failure: RequestSchedulingFailure): string | null {
const name = nonEmptyString(failure.provider_hint?.name)
if (name && !isUnknownProviderHint(name)) return name
const id = nonEmptyString(failure.provider_hint?.id)
if (id && !isUnknownProviderHint(id)) return id
return null
}
function schedulingFailureEndpointHint(failure: RequestSchedulingFailure): string | null {
return nonEmptyString(failure.endpoint_hint?.api_format)
?? nonEmptyString(failure.endpoint_hint?.id)
}
export function resolveRequestFailureNotice(detail: RequestDetail | null | undefined): RequestFailureNotice | null {
if (!detail) return null
@@ -55,6 +75,9 @@ export function resolveRequestFailureNotice(detail: RequestDetail | null | undef
message,
isSchedulingFailure: true,
meta: uniqueMeta([
schedulingFailureProviderHint(schedulingFailure),
schedulingFailureEndpointHint(schedulingFailure),
nonEmptyString(schedulingFailure.requested_model),
nonEmptyString(schedulingFailure.reason_summary),
nonEmptyString(schedulingFailure.reason_label),
nonEmptyString(schedulingFailure.reason),