mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(pool,scheduling): 号池调度维度、配额冷却机制与管理后台重构
- 新增 scheduling_dimensions 模块,为每个 Key 计算多维调度状态(手动/冷却/熔断/成本/健康) - 新增 quota_cooldown 模块,统一判定 Key 的有效冷却原因 - Pool 管理后台 API 扩展 Key 详情字段(调度状态/维度/配额/OAuth 信息) - 前端 Pool 管理页面重写,支持调度状态展示、批量清理封禁 Key - Handler 基类增加请求调度元数据采集,stream telemetry 增强 - 请求时间线组件增强,支持 attempted 候选展示 - Kiro OAuth 凭证导入解析改进 - 新增 usage 表 provider_key 索引迁移 - 补充调度维度、配额冷却、候选枚举等单元测试 Closes #197 Co-authored-by: AAEE86 <33052466+AAEE86@users.noreply.github.com>
This commit is contained in:
@@ -67,7 +67,7 @@
|
||||
<div
|
||||
class="node-dot"
|
||||
:class="[
|
||||
getStatusColorClass(group.primaryStatus),
|
||||
getStatusColorClass(getDisplayStatus(group.primary)),
|
||||
{ 'is-first-selected': isGroupSelected(group) && selectedAttemptIndex === 0 }
|
||||
]"
|
||||
@click.stop="selectFirstAttempt(group)"
|
||||
@@ -83,7 +83,7 @@
|
||||
:key="attempt.id"
|
||||
class="sub-dot"
|
||||
:class="[
|
||||
getStatusColorClass(attempt.status),
|
||||
getStatusColorClass(getDisplayStatus(attempt)),
|
||||
{ active: selectedAttemptIndex === idx + 1 }
|
||||
]"
|
||||
:title="attempt.key_name || `Key ${idx + 2}`"
|
||||
@@ -115,9 +115,9 @@
|
||||
<div class="panel-title">
|
||||
<span
|
||||
class="title-dot"
|
||||
:class="getStatusColorClass(currentAttempt.status)"
|
||||
:class="getStatusColorClass(getDisplayStatus(currentAttempt))"
|
||||
/>
|
||||
<span class="title-text">{{ selectedGroup.providerName }}</span>
|
||||
<span class="title-text">{{ currentGroupTitle }}</span>
|
||||
<a
|
||||
v-if="currentAttempt.provider_website"
|
||||
:href="currentAttempt.provider_website"
|
||||
@@ -130,7 +130,7 @@
|
||||
</a>
|
||||
<span
|
||||
class="status-tag"
|
||||
:class="getStatusColorClass(currentAttempt.status)"
|
||||
:class="getStatusColorClass(getDisplayStatus(currentAttempt))"
|
||||
>
|
||||
{{ currentAttempt.status_code || getStatusLabel(currentAttempt.status) }}
|
||||
</span>
|
||||
@@ -188,12 +188,12 @@
|
||||
<span class="info-value mono">{{ formatLatency(currentAttempt.extra_data.first_byte_time_ms) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttempt.extra_data?.provider_api_format"
|
||||
v-if="currentAttemptFormatDisplay"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">格式</span>
|
||||
<span class="info-value">
|
||||
<code class="format-code">{{ formatApiFormat(currentAttempt.extra_data.provider_api_format) }}</code>
|
||||
<code class="format-code">{{ currentAttemptFormatDisplay }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -433,6 +433,7 @@ interface NodeGroup {
|
||||
endIndex: number
|
||||
hasConversion: boolean // 组内是否有格式转换候选
|
||||
providerApiFormat: string | null // 提供商 API 格式(如 openai:cli)
|
||||
isPoolGroup?: boolean
|
||||
}
|
||||
|
||||
// 用量数据类型
|
||||
@@ -464,8 +465,12 @@ const props = defineProps<{
|
||||
requestId: string
|
||||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||||
overrideStatusCode?: number
|
||||
/** 请求侧 API 格式(客户端入口格式) */
|
||||
requestApiFormat?: string | null
|
||||
/** 用量和费用数据 */
|
||||
usageData?: UsageData | null
|
||||
/** 请求元数据(用于号池调度组装) */
|
||||
requestMetadata?: Record<string, unknown> | null
|
||||
}>()
|
||||
|
||||
// 用量数据(从 props 获取)
|
||||
@@ -594,21 +599,54 @@ const proxyTimingBreakdown = (proxy: Record<string, unknown>): string => {
|
||||
return parts.join(' / ')
|
||||
}
|
||||
|
||||
const TIMELINE_STATUS: CandidateRecord['status'][] = [
|
||||
'success',
|
||||
'failed',
|
||||
'skipped',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'streaming',
|
||||
'available',
|
||||
'unused',
|
||||
'stream_interrupted',
|
||||
]
|
||||
|
||||
const STATUS_PRIORITY: Record<string, number> = {
|
||||
available: 0,
|
||||
unused: 0,
|
||||
skipped: 1,
|
||||
failed: 2,
|
||||
cancelled: 2,
|
||||
stream_interrupted: 2,
|
||||
pending: 3,
|
||||
streaming: 3,
|
||||
success: 4,
|
||||
}
|
||||
|
||||
const toInt = (value: unknown, defaultValue = 0): number => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? Math.trunc(num) : defaultValue
|
||||
}
|
||||
|
||||
const makeAttemptKey = (candidateIndex: number, retryIndex: number): string => {
|
||||
return `${candidateIndex}:${retryIndex}`
|
||||
}
|
||||
|
||||
const normalizeTimelineStatus = (value: unknown): CandidateRecord['status'] => {
|
||||
if (typeof value !== 'string') return 'failed'
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if ((TIMELINE_STATUS as string[]).includes(normalized)) {
|
||||
return normalized as CandidateRecord['status']
|
||||
}
|
||||
// 兜底:内部调度轨迹里非标准状态统一按失败展示
|
||||
return 'failed'
|
||||
}
|
||||
|
||||
// 候选时间线(按实际执行顺序排序)
|
||||
const timeline = computed<CandidateRecord[]>(() => {
|
||||
const rawTimeline = computed<CandidateRecord[]>(() => {
|
||||
if (!trace.value) return []
|
||||
return [...trace.value.candidates]
|
||||
.filter(c => [
|
||||
'success',
|
||||
'failed',
|
||||
'skipped',
|
||||
'cancelled',
|
||||
'pending',
|
||||
'streaming',
|
||||
'available',
|
||||
'unused',
|
||||
'stream_interrupted'
|
||||
].includes(c.status))
|
||||
.filter(c => TIMELINE_STATUS.includes(c.status))
|
||||
.sort((a, b) => {
|
||||
const startedA = a.started_at ? new Date(a.started_at).getTime() : Infinity
|
||||
const startedB = b.started_at ? new Date(b.started_at).getTime() : Infinity
|
||||
@@ -620,18 +658,109 @@ const timeline = computed<CandidateRecord[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
|
||||
const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
if (!timeline.value || timeline.value.length === 0) return []
|
||||
const schedulingAudit = computed<Record<string, unknown> | null>(() => {
|
||||
const metadata = props.requestMetadata
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null
|
||||
const raw = metadata.scheduling_audit
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
||||
return raw as Record<string, unknown>
|
||||
})
|
||||
|
||||
const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
const audit = schedulingAudit.value
|
||||
if (!audit) return []
|
||||
const attempts = audit.attempts
|
||||
if (!Array.isArray(attempts) || attempts.length === 0) return []
|
||||
|
||||
const traceMap = new Map<string, CandidateRecord>()
|
||||
for (const candidate of rawTimeline.value) {
|
||||
traceMap.set(makeAttemptKey(candidate.candidate_index, candidate.retry_index), candidate)
|
||||
}
|
||||
|
||||
return attempts
|
||||
.map((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) return null
|
||||
const raw = item as Record<string, unknown>
|
||||
const candidateIndex = toInt(raw.candidate_index, index)
|
||||
const retryIndex = toInt(raw.retry_index, 0)
|
||||
const key = makeAttemptKey(candidateIndex, retryIndex)
|
||||
const fromTrace = traceMap.get(key)
|
||||
|
||||
const merged: CandidateRecord = fromTrace
|
||||
? { ...fromTrace }
|
||||
: {
|
||||
id: `pool-${props.requestId}-${candidateIndex}-${retryIndex}-${index}`,
|
||||
request_id: props.requestId,
|
||||
candidate_index: candidateIndex,
|
||||
retry_index: retryIndex,
|
||||
provider_id: undefined,
|
||||
provider_name: undefined,
|
||||
endpoint_id: undefined,
|
||||
key_id: undefined,
|
||||
key_name: undefined,
|
||||
status: 'failed',
|
||||
is_cached: false,
|
||||
created_at: new Date(0).toISOString(),
|
||||
}
|
||||
|
||||
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
|
||||
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
|
||||
return merged
|
||||
})
|
||||
.filter((item): item is CandidateRecord => item !== null)
|
||||
})
|
||||
|
||||
const poolAttemptKeySet = computed<Set<string>>(() => {
|
||||
return new Set(
|
||||
poolAttemptCandidates.value.map((item) => makeAttemptKey(item.candidate_index, item.retry_index)),
|
||||
)
|
||||
})
|
||||
|
||||
const timeline = computed<CandidateRecord[]>(() => {
|
||||
if (poolAttemptCandidates.value.length === 0) return rawTimeline.value
|
||||
return rawTimeline.value.filter(
|
||||
(candidate) => !poolAttemptKeySet.value.has(makeAttemptKey(candidate.candidate_index, candidate.retry_index)),
|
||||
)
|
||||
})
|
||||
|
||||
const AUTH_TYPE_PROVIDER_LABEL_MAP: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
kiro: 'Kiro',
|
||||
antigravity: 'Antigravity',
|
||||
claude_code: 'Claude Code',
|
||||
gemini_cli: 'Gemini CLI',
|
||||
}
|
||||
|
||||
const normalizeProviderName = (value: string): string => {
|
||||
const text = value.trim()
|
||||
if (!text) return '未知'
|
||||
// 管理后台中常见“xx反代”命名,展示时保留提供商品牌名即可
|
||||
return text.replace(/反代$/u, '').trim() || text
|
||||
}
|
||||
|
||||
const getProviderDisplayName = (attempt: CandidateRecord | null | undefined): string => {
|
||||
if (!attempt) return '未知'
|
||||
const authType = String(attempt.key_auth_type || '').trim().toLowerCase()
|
||||
if (authType && AUTH_TYPE_PROVIDER_LABEL_MAP[authType]) {
|
||||
return AUTH_TYPE_PROVIDER_LABEL_MAP[authType]
|
||||
}
|
||||
const providerName = String(attempt.provider_name || '').trim()
|
||||
return providerName ? normalizeProviderName(providerName) : '未知'
|
||||
}
|
||||
|
||||
const buildProviderGroups = (items: CandidateRecord[]): NodeGroup[] => {
|
||||
const groups: NodeGroup[] = []
|
||||
let currentGroup: NodeGroup | null = null
|
||||
|
||||
timeline.value.forEach((candidate, index) => {
|
||||
// 使用 provider_name 作为分组 key(同一个提供商的所有 Key 合并)
|
||||
items.forEach((candidate, index) => {
|
||||
const providerKey = candidate.provider_name || '未知'
|
||||
|
||||
// 如果属于同一个 Provider,合并到当前组
|
||||
if (currentGroup && currentGroup.id === providerKey) {
|
||||
currentGroup.allAttempts.push(candidate)
|
||||
currentGroup.retryCount++
|
||||
@@ -640,37 +769,67 @@ const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
if (candidate.extra_data?.needs_conversion) {
|
||||
currentGroup.hasConversion = true
|
||||
}
|
||||
// 按优先级提升组状态:success > streaming/pending > failed/cancelled/stream_interrupted > skipped > available/unused
|
||||
const statusPriority: Record<string, number> = {
|
||||
available: 0, unused: 0, skipped: 1,
|
||||
failed: 2, cancelled: 2, stream_interrupted: 2,
|
||||
pending: 3, streaming: 3, success: 4,
|
||||
}
|
||||
const currentPriority = statusPriority[currentGroup.primaryStatus] ?? 0
|
||||
const newPriority = statusPriority[candidate.status] ?? 0
|
||||
const currentPriority = STATUS_PRIORITY[currentGroup.primaryStatus] ?? 0
|
||||
const newPriority = STATUS_PRIORITY[candidate.status] ?? 0
|
||||
if (newPriority > currentPriority) {
|
||||
currentGroup.primaryStatus = candidate.status
|
||||
}
|
||||
} else {
|
||||
// 新建一个组
|
||||
currentGroup = {
|
||||
id: providerKey,
|
||||
providerName: candidate.provider_name || '未知',
|
||||
primary: candidate,
|
||||
primaryStatus: candidate.status,
|
||||
allAttempts: [candidate],
|
||||
retryCount: 0,
|
||||
totalLatency: candidate.latency_ms || 0,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
hasConversion: candidate.extra_data?.needs_conversion === true,
|
||||
providerApiFormat: candidate.extra_data?.provider_api_format || null,
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
return
|
||||
}
|
||||
|
||||
currentGroup = {
|
||||
id: providerKey,
|
||||
providerName: getProviderDisplayName(candidate),
|
||||
primary: candidate,
|
||||
primaryStatus: candidate.status,
|
||||
allAttempts: [candidate],
|
||||
retryCount: 0,
|
||||
totalLatency: candidate.latency_ms || 0,
|
||||
startIndex: index,
|
||||
endIndex: index,
|
||||
hasConversion: candidate.extra_data?.needs_conversion === true,
|
||||
providerApiFormat: candidate.extra_data?.provider_api_format || null,
|
||||
isPoolGroup: false,
|
||||
}
|
||||
groups.push(currentGroup)
|
||||
})
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// 将相同 Provider 的所有请求合并为组(同提供商的 Key 放在子节点)
|
||||
const groupedTimeline = computed<NodeGroup[]>(() => {
|
||||
const providerGroups = buildProviderGroups(timeline.value)
|
||||
const poolAttempts = poolAttemptCandidates.value
|
||||
if (poolAttempts.length === 0) {
|
||||
return providerGroups
|
||||
}
|
||||
|
||||
const poolPrimaryStatus = poolAttempts.reduce((best, current) => {
|
||||
const bestPriority = STATUS_PRIORITY[best] ?? 0
|
||||
const currentPriority = STATUS_PRIORITY[current.status] ?? 0
|
||||
return currentPriority > bestPriority ? current.status : best
|
||||
}, poolAttempts[0].status)
|
||||
|
||||
const successAttempt = poolAttempts.find((item) => item.status === 'success')
|
||||
const poolPrimary = successAttempt || poolAttempts[poolAttempts.length - 1] || poolAttempts[0]
|
||||
|
||||
const poolGroup: NodeGroup = {
|
||||
id: '__pool_group__',
|
||||
providerName: getProviderDisplayName(poolPrimary),
|
||||
primary: poolPrimary,
|
||||
primaryStatus: poolPrimaryStatus,
|
||||
allAttempts: poolAttempts,
|
||||
retryCount: Math.max(0, poolAttempts.length - 1),
|
||||
totalLatency: poolAttempts.reduce((sum, item) => sum + (item.latency_ms || 0), 0),
|
||||
startIndex: 0,
|
||||
endIndex: poolAttempts.length - 1,
|
||||
hasConversion: poolAttempts.some((item) => item.extra_data?.needs_conversion === true),
|
||||
providerApiFormat: null,
|
||||
isPoolGroup: true,
|
||||
}
|
||||
|
||||
return [poolGroup, ...providerGroups]
|
||||
})
|
||||
|
||||
// 格式转换分界点索引(首个 hasConversion=true 的 group index)
|
||||
@@ -687,16 +846,16 @@ const conversionBoundaryIndex = computed(() => {
|
||||
// 优先使用 latency_ms,因为它与 Usage.response_time_ms 使用相同的时间基准
|
||||
// 避免 finished_at - started_at 带来的额外延迟(数据库操作时间)
|
||||
const totalTraceLatency = computed(() => {
|
||||
if (!timeline.value || timeline.value.length === 0) return 0
|
||||
if (!rawTimeline.value || rawTimeline.value.length === 0) return 0
|
||||
|
||||
// 查找成功的候选,使用其 latency_ms
|
||||
const successCandidate = timeline.value.find(c => c.status === 'success')
|
||||
const successCandidate = rawTimeline.value.find(c => c.status === 'success')
|
||||
if (successCandidate?.latency_ms != null) {
|
||||
return successCandidate.latency_ms
|
||||
}
|
||||
|
||||
// 如果没有成功的候选,查找失败但有 latency_ms 的候选
|
||||
const failedWithLatency = timeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
|
||||
const failedWithLatency = rawTimeline.value.find(c => c.status === 'failed' && c.latency_ms != null)
|
||||
if (failedWithLatency?.latency_ms != null) {
|
||||
return failedWithLatency.latency_ms
|
||||
}
|
||||
@@ -705,7 +864,7 @@ const totalTraceLatency = computed(() => {
|
||||
let earliestStart: number | null = null
|
||||
let latestEnd: number | null = null
|
||||
|
||||
for (const candidate of timeline.value) {
|
||||
for (const candidate of rawTimeline.value) {
|
||||
if (candidate.started_at) {
|
||||
const startTime = new Date(candidate.started_at).getTime()
|
||||
if (earliestStart === null || startTime < earliestStart) {
|
||||
@@ -738,6 +897,49 @@ const currentAttempt = computed(() => {
|
||||
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
||||
})
|
||||
|
||||
const currentGroupTitle = computed(() => {
|
||||
if (!selectedGroup.value || !currentAttempt.value) return ''
|
||||
if (selectedGroup.value.isPoolGroup) {
|
||||
return getProviderDisplayName(currentAttempt.value)
|
||||
}
|
||||
return selectedGroup.value.providerName
|
||||
})
|
||||
|
||||
const normalizeFormatSignature = (value: string): string => {
|
||||
return value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
const currentAttemptFormatDisplay = computed(() => {
|
||||
const attempt = currentAttempt.value
|
||||
if (!attempt) return ''
|
||||
const extra = (
|
||||
attempt.extra_data && typeof attempt.extra_data === 'object' && !Array.isArray(attempt.extra_data)
|
||||
? attempt.extra_data
|
||||
: {}
|
||||
) as Record<string, unknown>
|
||||
|
||||
const providerRaw = typeof extra.provider_api_format === 'string' ? extra.provider_api_format : ''
|
||||
const clientRawFromExtra = typeof extra.client_api_format === 'string' ? extra.client_api_format : ''
|
||||
const requestRaw = clientRawFromExtra || (typeof props.requestApiFormat === 'string' ? props.requestApiFormat : '')
|
||||
|
||||
if (!providerRaw && !requestRaw) return ''
|
||||
|
||||
const providerText = providerRaw ? formatApiFormat(providerRaw) : ''
|
||||
const requestText = requestRaw ? formatApiFormat(requestRaw) : ''
|
||||
const convertedByFlag = extra.needs_conversion === true
|
||||
const convertedByDiff = Boolean(
|
||||
providerRaw &&
|
||||
requestRaw &&
|
||||
normalizeFormatSignature(providerRaw) !== normalizeFormatSignature(requestRaw),
|
||||
)
|
||||
|
||||
if ((convertedByFlag || convertedByDiff) && requestText && providerText) {
|
||||
return `${requestText} -> ${providerText}`
|
||||
}
|
||||
|
||||
return providerText || requestText
|
||||
})
|
||||
|
||||
// 计算当前尝试启用的能力标签(请求需要的能力)
|
||||
const activeCapabilities = computed(() => {
|
||||
if (!currentAttempt.value?.required_capabilities) return []
|
||||
@@ -1018,6 +1220,28 @@ const getStatusColorClass = (status: string) => {
|
||||
}
|
||||
return classes[status] || 'status-available'
|
||||
}
|
||||
|
||||
// 展示状态:进行中态优先(包括 started 但未 finished 的中间态),再按 HTTP 状态码兜底
|
||||
const getDisplayStatus = (attempt: CandidateRecord | null | undefined): string => {
|
||||
if (!attempt) return 'available'
|
||||
const hasFinished = Boolean(attempt.finished_at)
|
||||
const isExplicitPending = (attempt.status === 'pending' || attempt.status === 'streaming') && !hasFinished
|
||||
const isImplicitPending = Boolean(
|
||||
attempt.started_at &&
|
||||
!hasFinished &&
|
||||
!['failed', 'cancelled', 'skipped', 'stream_interrupted'].includes(attempt.status),
|
||||
)
|
||||
|
||||
if (isExplicitPending || isImplicitPending) {
|
||||
return 'pending'
|
||||
}
|
||||
const code = attempt.status_code
|
||||
if (typeof code === 'number') {
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 400) return 'failed'
|
||||
}
|
||||
return attempt.status
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -378,57 +378,14 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 号池调度摘要 -->
|
||||
<Card v-if="poolSummary">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="text-xs text-muted-foreground mb-2 font-medium">
|
||||
号池调度
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="font-mono">
|
||||
{{ poolSummary.total_keys }} 候选
|
||||
</span>
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono">
|
||||
{{ poolSummary.attempted }} 尝试
|
||||
</span>
|
||||
<template v-if="poolSummary.skipped_cooldown > 0">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono text-amber-600 dark:text-amber-400">
|
||||
{{ poolSummary.skipped_cooldown }} 冷却跳过
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="poolSummary.skipped_cost > 0">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono text-orange-600 dark:text-orange-400">
|
||||
{{ poolSummary.skipped_cost }} 成本跳过
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="poolSummary.sticky_session">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
粘性会话
|
||||
</Badge>
|
||||
</template>
|
||||
<template v-if="poolSummary.success_reason">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ poolSummary.success_reason }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 请求链路追踪卡片 -->
|
||||
<div v-if="detail.request_id || detail.id">
|
||||
<HorizontalRequestTimeline
|
||||
ref="timelineRef"
|
||||
:request-id="detail.request_id || detail.id"
|
||||
:override-status-code="detail.status_code"
|
||||
:request-api-format="detail.api_format || null"
|
||||
:request-metadata="traceRequestMetadata"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -781,11 +738,10 @@ const isDark = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// 号池调度摘要
|
||||
const poolSummary = computed(() => {
|
||||
const ps = detail.value?.metadata?.pool_summary as Record<string, unknown> | undefined
|
||||
if (!ps || !ps.enabled) return null
|
||||
return ps
|
||||
const traceRequestMetadata = computed<Record<string, unknown> | null>(() => {
|
||||
const meta = detail.value?.metadata
|
||||
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null
|
||||
return meta as Record<string, unknown>
|
||||
})
|
||||
|
||||
// 检测是否有提供商请求头
|
||||
|
||||
Reference in New Issue
Block a user