fix(usage): refine record details and error filters

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-02 14:43:44 +08:00
co-authored by Codex
parent 08d518324a
commit 22c697bbff
15 changed files with 278 additions and 424 deletions
@@ -135,13 +135,11 @@ pub struct OpsErrorFilter {
pub provider_kind: Option<String>,
pub provider_account_ref: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
@@ -107,14 +107,12 @@ pub struct OpsQuery {
pub provider: Option<String>,
pub request_id: Option<String>,
pub account_id: Option<String>,
pub route: Option<String>,
pub model: Option<String>,
pub status_code: Option<i64>,
pub client_status_code: Option<i64>,
pub upstream_status_code: Option<i64>,
pub transport: Option<String>,
pub attempt_index: Option<i64>,
pub failure_class: Option<String>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub search: Option<String>,
@@ -352,13 +350,11 @@ pub(crate) fn ops_command(query: &OpsQuery) -> Result<domain::OpsErrorQuery, Wir
provider_kind: non_empty(query.provider.clone()),
provider_account_ref: non_empty(query.account_id.clone()),
operation: non_empty(query.kind.clone()),
endpoint: non_empty(query.route.clone()),
model: non_empty(query.model.clone()),
transport: non_empty(query.transport.clone()),
attempt_index: parse_attempt_index(query.attempt_index)?,
response_id: non_empty(query.response_id.clone()),
upstream_request_id: non_empty(query.upstream_request_id.clone()),
failure_kind: non_empty(query.failure_class.clone()),
status_code,
search: non_empty(query.search.clone()),
},
@@ -49,6 +49,12 @@ fn ops_query_should_reject_page_size_above_terminal_limit() {
assert_eq!(query.validate_pagination().unwrap_err().field(), "pageSize");
}
#[test]
fn ops_query_should_reject_removed_filters() {
assert!(serde_json::from_value::<OpsQuery>(json!({"route": "/v1/responses"})).is_err());
assert!(serde_json::from_value::<OpsQuery>(json!({"failureClass": "rate_limited"})).is_err());
}
#[test]
fn diagnostics_query_should_keep_wire_dimension_name() {
let query: DiagnosticsQuery =
@@ -51,13 +51,11 @@ pub(crate) fn store_ops_error_filter(
provider_account_ref: filter.provider_account_ref,
provider_kind: filter.provider_kind,
operation: filter.operation,
endpoint: filter.endpoint,
model: filter.model,
transport: filter.transport,
attempt_index: filter.attempt_index,
response_id: filter.response_id,
upstream_request_id: filter.upstream_request_id,
failure_kind: filter.failure_kind,
status_code: filter.status_code,
search: filter.search,
}
@@ -126,13 +126,11 @@ pub struct OpsErrorFilter {
pub provider_account_ref: Option<String>,
pub provider_kind: Option<String>,
pub operation: Option<String>,
pub endpoint: Option<String>,
pub model: Option<String>,
pub transport: Option<String>,
pub attempt_index: Option<u32>,
pub response_id: Option<String>,
pub upstream_request_id: Option<String>,
pub failure_kind: Option<String>,
pub status_code: Option<u16>,
pub search: Option<String>,
}
@@ -148,14 +146,12 @@ impl OpsErrorFilter {
),
(self.provider_kind.as_deref(), "provider filter"),
(self.operation.as_deref(), "operation filter"),
(self.endpoint.as_deref(), "endpoint filter"),
(self.model.as_deref(), "model filter"),
(self.transport.as_deref(), "transport filter"),
(
self.upstream_request_id.as_deref(),
"upstream request ID filter",
),
(self.failure_kind.as_deref(), "failure kind filter"),
] {
validate_optional_text(value, MAX_FILTER_BYTES, field)?;
}
@@ -120,16 +120,11 @@ fn push_request_error_predicates(
("mr.provider_account_ref", &filter.provider_account_ref),
("mr.provider_kind", &filter.provider_kind),
("mr.operation", &filter.operation),
("mr.endpoint", &filter.endpoint),
("mr.upstream_transport", &filter.transport),
("mr.upstream_request_id", &filter.upstream_request_id),
] {
push_text_equality(statement, column, value);
}
if let Some(value) = &filter.failure_kind {
statement.push(" and coalesce(mr.error_kind, 'failed') = ");
statement.push_bind(value.clone());
}
push_response_id_filter(statement, "mr.client_response_id", filter);
push_text_equality(statement, "mr.upstream_model_id", &filter.model);
if let Some(index) = filter.attempt_index {
@@ -167,9 +162,7 @@ fn push_ops_event_predicates(
("oe.provider_account_ref", &filter.provider_account_ref),
("oe.provider_kind", &filter.provider_kind),
("oe.operation", &filter.operation),
("mr.endpoint", &filter.endpoint),
("oe.upstream_request_id", &filter.upstream_request_id),
("oe.failure_kind", &filter.failure_kind),
] {
push_text_equality(statement, column, value);
}
@@ -762,9 +762,7 @@ async fn admin_observability_adapter_preserves_utc_queries_metrics_costs_and_det
provider_kind: Some("openai".to_owned()),
provider_account_ref: Some("acct_observe".to_owned()),
operation: Some("responses".to_owned()),
endpoint: Some("/v1/responses".to_owned()),
model: Some("upstream-model".to_owned()),
failure_kind: Some("rate_limited".to_owned()),
status_code: Some(429),
search: Some("req_observe_failed".to_owned()),
..admin_observability::OpsErrorFilter::default()
+1 -4
View File
@@ -420,10 +420,7 @@ interface PageQuery {
type UsagePageQuery = UsageRangeQuery & PageQuery
type OpsErrorPageQuery = UsagePageQuery & {
failureClass?: string
route?: string
}
type OpsErrorPageQuery = UsagePageQuery
interface UsageDetailQuery {
id: string
@@ -2,12 +2,18 @@
import type { OpsError } from '@/api'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { displayValue } from '../utils/detail'
import { presentOpsError } from '../utils/opsErrorPresentation'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
import UsageDetailFieldGrid from './UsageDetailFieldGrid.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
interface DetailField {
label: string
value: unknown
mono?: boolean
wrap?: boolean
fullWidth?: boolean
}
const props = defineProps<{
record: OpsError | null
@@ -15,8 +21,6 @@ const props = defineProps<{
const open = defineModel<boolean>({ default: false })
const presentation = computed(() => props.record ? presentOpsError(props.record) : null)
const panelClass = 'min-w-0 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5'
const panelTitleClass = 'm-0 text-cp-sm leading-none font-heavy text-cp-text-secondary'
@@ -25,72 +29,56 @@ const accountLabel = computed(() => props.record?.accountEmail
|| props.record?.metadata.accountLabel
|| props.record?.accountId)
const requestFields = computed(() => [
{ label: '客户端 IP', value: props.record?.clientIp, mono: true },
{ label: 'User-Agent', value: props.record?.userAgent, mono: true, wrap: true },
{ label: '协议', value: props.record?.protocol, mono: true },
{ label: '客户端传输', value: props.record?.clientTransport, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true },
{ label: '响应 ID', value: props.record?.responseId, mono: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true },
])
const errorFields = computed(() => visibleFields([
{ label: 'Provider 错误码', value: props.record?.providerErrorCode, mono: true },
{ label: '失败分类', value: props.record?.failureClass, mono: true },
{ label: '发送状态', value: props.record?.upstreamSendState, mono: true },
{ label: '客户端状态', value: props.record?.clientStatusCode, mono: true },
{ label: '上游状态', value: props.record?.upstreamStatusCode, mono: true },
{ label: '耗时(ms)', value: props.record?.latencyMs, mono: true },
]))
const routeFields = computed(() => [
const requestFields = computed(() => visibleFields([
{ label: '账号', value: accountLabel.value, mono: true },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
{ label: '客户端 IP', value: props.record?.clientIp, mono: true },
{ label: '协议', value: props.record?.protocol, mono: true },
{ label: 'User-Agent', value: props.record?.userAgent, mono: true, wrap: true, fullWidth: true },
]))
const routeFields = computed(() => visibleFields([
{ label: '端点', value: props.record?.route, mono: true },
{ label: 'Provider', value: props.record?.provider, mono: true },
{ label: '认证类型', value: props.record?.authenticationKind, mono: true },
{ label: '账号 ID', value: props.record?.accountId, mono: true },
{ label: '请求模型', value: props.record?.requestedModel, mono: true },
{ label: '上游模型', value: props.record?.upstreamModel, mono: true },
{ label: '记录模型', value: props.record?.model, mono: true },
{ label: '推理强度', value: props.record?.reasoningEffort, mono: true },
{ label: '推理预设', value: props.record?.reasoningPreset, mono: true },
{ label: '客户端传输', value: props.record?.clientTransport, mono: true },
{ label: '上游传输', value: props.record?.transport, mono: true },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '请求类型', value: props.record?.requestKind, mono: true },
{ label: '子代理类型', value: props.record?.subagentKind, mono: true },
{ label: '压缩请求', value: booleanDisplay(props.record?.compact) },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '上游传输', value: props.record?.transport, mono: true },
{ label: '压缩请求', value: props.record?.compact, mono: true },
]))
const eventFields = computed(() => visibleFields([
{ label: '记录来源', value: props.record?.metadata.source, mono: true },
{ label: '组件', value: props.record?.metadata.component || props.record?.kind, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '操作', value: props.record?.operation, mono: true },
{ label: '尝试序号', value: props.record?.attemptIndex },
])
const diagnosticFields = computed(() => [
{ label: '账号', value: accountLabel.value, mono: true },
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '记录来源', value: presentation.value?.sourceLabel },
{ label: '组件', value: presentation.value?.componentLabel },
{ label: '操作/触发', value: props.record?.operation, mono: true },
{ label: '失败分类', value: presentation.value?.failureClassLabel },
{ label: '发送状态', value: props.record?.upstreamSendState, mono: true },
{ label: '平台/类型', value: providerKindLabel(props.record) },
{ label: '账号 ID', value: props.record?.accountId, mono: true },
{ label: 'Provider 错误码', value: props.record?.providerErrorCode, mono: true },
{ label: '聚合次数', value: props.record?.occurrenceCount },
{ label: '耗时', value: latencyDisplay(props.record?.latencyMs), mono: true },
])
{ label: '响应 ID', value: props.record?.responseId, mono: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true },
]))
const metadataText = computed(() => {
const metadata = props.record?.metadata
if (!metadata || (typeof metadata === 'object' && Object.keys(metadata).length === 0))
return ''
return JSON.stringify(metadata, null, 2)
})
function latencyDisplay(value: unknown) {
return typeof value === 'number' ? `${value} ms` : '—'
}
function booleanDisplay(value: boolean | null | undefined) {
if (value === true)
return '是'
if (value === false)
return '否'
return '—'
}
function providerKindLabel(record: OpsError | null) {
if (!record?.provider)
return '—'
return record.authenticationKind
? `${record.provider} · ${record.authenticationKind}`
: record.provider
function visibleFields(items: DetailField[]) {
return items.filter(({ value }) => value !== null && value !== undefined && value !== '')
}
</script>
@@ -98,68 +86,57 @@ function providerKindLabel(record: OpsError | null) {
<BaseModal
v-model="open"
title="错误明细"
description="请求来源、链路上下文与上游错误原文"
description="上游错误与请求上下文"
tone="danger"
size="xl"
>
<template v-if="record">
<section class="rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5">
<div class="flex flex-wrap items-center gap-3">
<UsageStatusCodeBadge :status-code="record.clientStatusCode" />
<span class="text-cp-sm font-bold text-cp-text-secondary">
客户端 {{ displayValue(record.clientStatusCode) }}
</span>
<span class="text-cp-sm font-bold text-cp-text-secondary">
上游 {{ displayValue(record.upstreamStatusCode) }}
</span>
<section :class="panelClass">
<h3 :class="panelTitleClass">
错误
</h3>
<UsageDetailFieldGrid :items="errorFields" />
<div v-if="record.message" class="mt-3">
<UsageDetailCodePanel title="错误信息" max-height="220px" :content="record.message" />
</div>
<p class="mt-3 mb-0 text-cp leading-relaxed font-bold text-cp-text">
{{ displayValue(presentation?.summary) }}
</p>
</section>
<section v-if="record.rawUpstreamError" class="mt-3" :class="panelClass">
<UsageDetailCodePanel
title="上游返回原文"
max-height="360px"
:content="record.rawUpstreamError"
/>
</section>
<div class="mt-3 grid min-w-0 gap-3 lg:grid-cols-2">
<section :class="panelClass">
<section v-if="requestFields.length" :class="panelClass">
<h3 :class="panelTitleClass">
请求追踪
请求与客户端
</h3>
<UsageDetailFieldGrid :items="requestFields" />
</section>
<section :class="panelClass">
<section v-if="routeFields.length" :class="panelClass">
<h3 :class="panelTitleClass">
模型与链路
路由与模型
</h3>
<UsageDetailFieldGrid :items="routeFields" />
</section>
</div>
<section class="mt-3" :class="panelClass">
<section v-if="eventFields.length" class="mt-3" :class="panelClass">
<h3 :class="panelTitleClass">
错误事件
事件记录
</h3>
<UsageDetailFieldGrid :items="diagnosticFields" />
<UsageDetailFieldGrid :items="eventFields" />
</section>
</template>
<section class="mt-3" :class="panelClass">
<UsageDetailCodePanel title="诊断摘要" max-height="260px" :content="record.message" />
</section>
<section
v-if="record.rawUpstreamError"
class="mt-3"
:class="panelClass"
>
<UsageDetailCodePanel title="上游返回原文" max-height="360px" :content="record.rawUpstreamError" />
</section>
<section
v-if="metadataText"
class="mt-3"
:class="panelClass"
>
<UsageDetailCodePanel title="Metadata" max-height="260px" :content="metadataText" />
</section>
<template #footer>
<BaseButton variant="primary" @click="open = false">
关闭
</BaseButton>
</template>
</BaseModal>
</template>
@@ -11,10 +11,7 @@ import BaseTable from '@/components/base/BaseTable/index.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useOpsErrorsTable } from '../composables/useOpsErrorsTable'
import { opsErrorColumns } from '../constants'
import { presentOpsError } from '../utils/opsErrorPresentation'
import OpsErrorDetailModal from './OpsErrorDetailModal.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
import UsageTransportBadge from './UsageTransportBadge.vue'
const props = defineProps<{
timeRangeParams: UsageTimeRangeParams
@@ -25,8 +22,6 @@ const {
refreshing,
records,
searchQuery,
failureClass,
route,
pagination,
handlePageChange,
handlePageSizeChange,
@@ -36,29 +31,27 @@ const {
const selectedRecord = shallowRef<OpsError | null>(null)
const detailOpen = shallowRef(false)
const upstreamSendStateLabels: Record<string, string> = {
sent: '已发送',
not_sent: '未发送',
ambiguous: '状态不明',
}
function showDetail(record: OpsError) {
selectedRecord.value = record
detailOpen.value = true
}
function failureClassText(record: OpsError) {
return presentOpsError(record).failureClassLabel
}
function errorSummary(record: OpsError) {
return presentOpsError(record).summary
}
function accountText(record: OpsError) {
return record.accountEmail
|| record.accountName
|| record.metadata.accountLabel
|| record.accountId
|| '—'
|| '未记录'
}
function modelText(record: OpsError) {
return record.requestedModel || record.model || record.upstreamModel || '—'
return record.requestedModel || record.model || record.upstreamModel || '未记录'
}
function modelTitle(record: OpsError) {
@@ -69,8 +62,10 @@ function modelTitle(record: OpsError) {
: modelText(record)
}
function reasoningText(record: OpsError) {
return record.reasoningPreset || record.reasoningEffort || '—'
function upstreamSendStateText(value: string | null | undefined) {
if (!value)
return '未记录'
return upstreamSendStateLabels[value] ?? value
}
</script>
@@ -79,22 +74,18 @@ function reasoningText(record: OpsError) {
<div
class="flex w-full flex-col gap-3 lg:flex-row lg:flex-wrap lg:items-center"
role="group"
aria-label="错误明细筛选与操作"
aria-label="错误筛选与操作"
>
<div class="min-w-0 flex-1">
<div class="grid min-w-0 grid-cols-1 gap-2 sm:grid-cols-2 lg:flex lg:items-center lg:gap-3">
<BaseInput
v-model="searchQuery"
placeholder="事件、请求、Key 或账号 ID 前缀"
class="min-w-0 sm:col-span-2 lg:min-w-64 lg:flex-1 lg:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
<BaseInput v-model="failureClass" placeholder="失败分类(精确)" class="min-w-0" />
<BaseInput v-model="route" placeholder="端点(精确)" class="min-w-0" />
</div>
<BaseInput
v-model="searchQuery"
placeholder="请求 ID、Key 或账号"
class="min-w-0 w-full lg:max-w-96"
>
<template #prefix>
<Search class="size-4.5 text-cp-text-tertiary" />
</template>
</BaseInput>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 lg:ml-auto">
@@ -120,23 +111,8 @@ function reasoningText(record: OpsError) {
:columns="opsErrorColumns"
:rows="records"
:loading="loading"
empty-text="暂无错误明细"
empty-text="当前时段没有错误"
>
<template #upstreamStatusCode="{ row }">
<UsageStatusCodeBadge
:status-code="typeof row.upstreamStatusCode === 'number' ? row.upstreamStatusCode : null"
/>
</template>
<template #clientStatusCode="{ row }">
<UsageStatusCodeBadge
:status-code="typeof row.clientStatusCode === 'number' ? row.clientStatusCode : null"
/>
</template>
<template #failureClass="{ row }">
<span class="font-mono text-cp-sm font-bold text-cp-error-text">
{{ failureClassText(row) }}
</span>
</template>
<template #provider="{ row }">
<ProviderIconGroup
:provider="String(row.provider || '')"
@@ -144,8 +120,24 @@ function reasoningText(record: OpsError) {
/>
</template>
<template #message="{ row }">
<span class="block max-w-full truncate text-cp-sm font-emphasis text-cp-text" :title="errorSummary(row)">
{{ errorSummary(row) }}
<div class="min-w-0 py-0.5" :title="row.message || row.providerErrorCode || row.failureClass">
<code class="block max-w-full truncate font-mono text-cp-sm font-bold text-cp-error-text">
{{ row.providerErrorCode || row.failureClass }}
</code>
<p
v-if="row.message"
class="mt-1 mb-0 line-clamp-1 text-cp-xs leading-[1.45] font-emphasis text-cp-text-secondary"
>
{{ row.message }}
</p>
</div>
</template>
<template #upstreamSendState="{ row }">
<span
class="inline-flex h-6 max-w-full items-center rounded-full bg-cp-fill-quaternary px-2.5 font-mono text-cp-sm leading-none font-bold text-cp-text-secondary"
:title="row.upstreamSendState || '未记录'"
>
<span class="min-w-0 truncate">{{ upstreamSendStateText(row.upstreamSendState) }}</span>
</span>
</template>
<template #accountId="{ row }">
@@ -164,38 +156,14 @@ function reasoningText(record: OpsError) {
{{ modelText(row) }}
</span>
</template>
<template #reasoningEffort="{ row }">
<span class="text-cp-sm font-bold text-cp-text-secondary">
{{ reasoningText(row) }}
</span>
</template>
<template #transport="{ row }">
<UsageTransportBadge :transport="row.transport" />
</template>
<template #clientTransport="{ row }">
<UsageTransportBadge :transport="row.clientTransport" />
</template>
<template #clientIp="{ row }">
<span
class="inline-flex h-6 max-w-full items-center rounded-full bg-cp-blue-bg px-2.5 font-mono text-cp-sm leading-none font-bold text-cp-blue-text-on-bg"
:title="row.clientIp || '—'"
>
<span class="min-w-0 truncate">{{ row.clientIp || '—' }}</span>
</span>
</template>
<template #userAgent="{ row }">
<span class="block max-w-full wrap-break-word whitespace-normal font-mono text-cp-sm leading-[1.4] font-emphasis text-cp-text-secondary">
{{ row.userAgent || '—' }}
</span>
</template>
<template #actions="{ row }">
<BaseIconButton
variant="ghost"
size="sm"
size="md"
label="查看错误详情"
@click="showDetail(row)"
>
<Eye class="size-3.5" />
<Eye class="size-4.5" />
</BaseIconButton>
</template>
</BaseTable>
@@ -3,7 +3,7 @@ import { displayValue, fieldLabelClass, fieldValueClass } from '../utils/detail'
// 详情弹窗的两列字段网格:label + 值(可选等宽字体),空值以“—”占位。
defineProps<{
items: Array<{ label: string, value: unknown, mono?: boolean, wrap?: boolean }>
items: Array<{ label: string, value: unknown, mono?: boolean, wrap?: boolean, fullWidth?: boolean }>
}>()
function valueClass(item: { mono?: boolean, wrap?: boolean }) {
@@ -19,7 +19,12 @@ function valueClass(item: { mono?: boolean, wrap?: boolean }) {
<template>
<dl class="mt-3 grid min-w-0 grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
<div v-for="item in items" :key="item.label" class="min-w-0">
<div
v-for="item in items"
:key="item.label"
class="min-w-0"
:class="item.fullWidth ? 'sm:col-span-2' : undefined"
>
<dt :class="fieldLabelClass">
{{ item.label }}
</dt>
@@ -57,58 +57,51 @@ const finalAttemptIndex = computed(() => {
return last?.attemptIndex ?? props.record?.attemptCount
})
const overviewItems = computed(() => [
{ label: '时间', value: props.record?.createdAtDisplay, mono: true },
{ label: '客户端传输', value: usageTransportType(props.record?.clientTransport) },
{ label: '上游传输', value: usageTransportType(props.record?.upstreamTransport) },
{ label: '耗时', value: props.record?.latencyMsDisplay, mono: true },
{ label: '端点', value: props.record?.route, mono: true },
{ label: '客户端传输', value: usageTransportType(props.record?.clientTransport), mono: true },
{ label: '上游传输', value: usageTransportType(props.record?.upstreamTransport), mono: true },
{ label: '总耗时', value: props.record?.latencyMsDisplay, mono: true },
{
label: latencyDetails.value?.firstOutputLabel ?? '首字',
value: latencyDetails.value?.firstOutputDisplay ?? '—',
mono: true,
},
{ label: '总 Token', value: tokenDetails.value?.totalTokensDisplay, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true, wide: true },
{ label: '消息', value: props.record?.message, wide: true },
])
const detailGroups = computed(() => [
const modelRouteItems = computed(() => [
{ label: '端点', value: props.record?.route, mono: true },
{ label: '推理强度', value: props.record ? usageReasoningEffort(props.record) : '—' },
{ label: '请求模型', value: modelDisplay.value.primary, mono: true },
{
title: '模型与链路',
items: [
{ label: '端点', value: props.record?.route, mono: true },
{ label: '请求模型', value: modelDisplay.value.primary, mono: true },
{
label: '上游模型',
value: modelDisplay.value.secondary || props.record?.upstreamModel,
mono: true,
},
{ label: '存储模型', value: props.record?.model, mono: true },
{ label: '推理强度', value: props.record ? usageReasoningEffort(props.record) : '—' },
{ label: '账号 ID', value: props.record?.accountId, mono: true },
{ label: '请求 ID', value: props.record?.requestId, mono: true },
{ label: '响应 ID', value: props.record?.responseId, mono: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true },
],
label: '上游模型',
value: modelDisplay.value.secondary || props.record?.upstreamModel,
mono: true,
},
{ label: '存储模型', value: props.record?.model, mono: true },
])
const clientUpstreamItems = computed(() => [
{ label: '客户端 IP', value: props.record ? usageClientIp(props.record) : '—', mono: true },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: finalAttemptIndex.value },
{
title: '客户端与上游',
items: [
{ label: '客户端 IP', value: props.record ? usageClientIp(props.record) : '—', mono: true },
{
label: 'User-Agent',
value: props.record ? usageUserAgent(props.record) : '',
mono: true,
wrap: true,
},
{ label: '事件类型', value: props.record?.kind, mono: true },
{ label: '尝试序号', value: finalAttemptIndex.value },
{ label: '服务档位', value: props.record?.serviceTier, mono: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true },
],
label: 'User-Agent',
value: props.record ? usageUserAgent(props.record) : '',
mono: true,
wrap: true,
fullWidth: true,
},
])
const modelRouteGroup = computed(() => detailGroups.value[0])
const clientUpstreamGroup = computed(() => detailGroups.value[1])
const identifierItems = computed(() => [
{ label: '请求 ID', value: props.record?.requestId, mono: true, wrap: true, fullWidth: true },
{ label: '响应 ID', value: props.record?.responseId, mono: true, wrap: true },
{ label: '上游请求 ID', value: props.record?.upstreamRequestId, mono: true, wrap: true },
{ label: '账号 ID', value: props.record?.accountId, mono: true, wrap: true },
{ label: '客户端 Key ID', value: props.record?.clientApiKeyId, mono: true, wrap: true },
])
interface AttemptRow {
id: string
@@ -286,34 +279,53 @@ const tokenDonutOption = computed<EChartsOption>(() => {
>
<div v-if="record" class="grid min-w-0 gap-3">
<section :class="panelClass">
<div class="min-w-0">
<span :class="fieldLabelClass">账号</span>
<p
class="mt-1.5 mb-0 truncate font-mono text-cp leading-none font-heavy text-cp-text"
:title="displayValue(accountDisplay)"
>
{{ displayValue(accountDisplay) }}
</p>
</div>
<dl
class="mt-4 grid grid-cols-2 gap-x-5 gap-y-3 lg:grid-cols-[120px_120px_repeat(5,minmax(0,1fr))]"
class="grid min-w-0 grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4 lg:grid-cols-[minmax(0,2fr)_100px_minmax(0,1fr)_180px]"
>
<div class="col-span-2 min-w-0 lg:col-span-1">
<dt :class="fieldLabelClass">
账号
</dt>
<dd
class="mt-1.5 mb-0 min-w-0 break-all font-mono text-cp-sm leading-snug font-heavy text-cp-text"
:title="displayValue(accountDisplay)"
>
{{ displayValue(accountDisplay) }}
</dd>
</div>
<div class="min-w-0">
<dt :class="fieldLabelClass">
状态码
</dt>
<dd class="mt-1.5 mb-0">
<UsageStatusCodeBadge :status-code="record.statusCode" />
<dd :class="fieldValueClass(true)" :title="displayValue(record.statusCode)">
{{ displayValue(record.statusCode) }}
</dd>
</div>
<div
v-for="item in overviewItems"
:key="item.label"
class="min-w-0"
:class="item.wide ? 'col-span-2 lg:col-span-2' : undefined"
>
<div class="min-w-0">
<dt :class="fieldLabelClass">
消息
</dt>
<dd :class="fieldValueClass()" :title="displayValue(record.message)">
{{ displayValue(record.message) }}
</dd>
</div>
<div class="col-span-2 min-w-0 lg:col-span-1">
<dt :class="fieldLabelClass">
时间
</dt>
<dd :class="fieldValueClass(true)" :title="displayValue(record.createdAtDisplay)">
{{ displayValue(record.createdAtDisplay) }}
</dd>
</div>
</dl>
<dl
class="mt-4 grid min-w-0 grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-[minmax(160px,1.4fr)_repeat(5,minmax(0,1fr))]"
>
<div v-for="item in overviewItems" :key="item.label" class="min-w-0">
<dt :class="fieldLabelClass">
{{ item.label }}
</dt>
@@ -324,74 +336,79 @@ const tokenDonutOption = computed<EChartsOption>(() => {
</dl>
</section>
<section class="grid min-w-0 gap-3 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div class="flex min-h-0 min-w-0 flex-col gap-3">
<section :class="panelClass">
<h3 :class="panelTitleClass">
{{ modelRouteGroup.title }}
</h3>
<UsageDetailFieldGrid :items="modelRouteGroup.items" />
</section>
<section class="grid min-w-0 gap-3 lg:grid-cols-2">
<section :class="panelClass">
<h3 :class="panelTitleClass">
模型与路由
</h3>
<UsageDetailFieldGrid :items="modelRouteItems" />
</section>
<section class="flex min-h-0 flex-1 flex-col" :class="[panelClass]">
<h3 :class="panelTitleClass">
Token
</h3>
<div
class="mt-3 grid min-h-38 min-w-0 flex-1 grid-cols-1 content-center items-center gap-3 sm:grid-cols-[150px_minmax(0,1fr)]"
>
<div class="relative mx-auto w-38 sm:mx-0">
<BaseChart :option="tokenDonutOption" :height="152" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="grid text-center">
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
总计
</span>
<strong
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-cp-text"
>
{{ tokenDetails?.totalTokensDisplay ?? '—' }}
</strong>
</div>
<section :class="panelClass">
<h3 :class="panelTitleClass">
客户端与上游
</h3>
<UsageDetailFieldGrid :items="clientUpstreamItems" />
</section>
</section>
<section :class="panelClass">
<h3 :class="panelTitleClass">
请求标识
</h3>
<UsageDetailFieldGrid :items="identifierItems" />
</section>
<section class="grid min-w-0 gap-3 lg:grid-cols-2">
<section class="flex min-h-0 flex-col" :class="panelClass">
<h3 :class="panelTitleClass">
Token
</h3>
<div
class="mt-3 grid min-h-38 min-w-0 flex-1 grid-cols-1 content-center items-center gap-3 sm:grid-cols-[150px_minmax(0,1fr)]"
>
<div class="relative mx-auto w-38 sm:mx-0">
<BaseChart :option="tokenDonutOption" :height="152" />
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="grid text-center">
<span class="text-cp-xs leading-none font-bold text-cp-text-quaternary">
总计
</span>
<strong
class="mt-1 font-mono text-[16px] leading-none font-extrabold tabular-nums text-cp-text"
>
{{ tokenDetails?.totalTokensDisplay ?? '—' }}
</strong>
</div>
</div>
<dl class="grid min-w-0 grid-cols-2 gap-x-4 gap-y-3">
<div v-for="item in tokenChartItems" :key="item.label" class="min-w-0">
<dt class="flex min-w-0 items-center gap-1.5" :class="fieldLabelClass">
<i
class="size-1.75 shrink-0 rounded-full"
:style="{ backgroundColor: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</dt>
<dd
class="font-mono tabular-nums" :class="[fieldValueBaseClass]"
:title="displayValue(item.display)"
>
{{ displayValue(item.display) }}
</dd>
</div>
</dl>
</div>
</section>
</div>
<div class="flex min-h-0 min-w-0 flex-col gap-3">
<section class="flex-1" :class="[panelClass]">
<h3 :class="panelTitleClass">
{{ clientUpstreamGroup.title }}
</h3>
<UsageDetailFieldGrid :items="clientUpstreamGroup.items" />
</section>
<dl class="grid min-w-0 grid-cols-2 gap-x-4 gap-y-3">
<div v-for="item in tokenChartItems" :key="item.label" class="min-w-0">
<dt class="flex min-w-0 items-center gap-1.5" :class="fieldLabelClass">
<i
class="size-1.75 shrink-0 rounded-full"
:style="{ backgroundColor: item.color }"
/>
<span class="truncate">{{ item.label }}</span>
</dt>
<dd
class="font-mono tabular-nums" :class="[fieldValueBaseClass]"
:title="displayValue(item.display)"
>
{{ displayValue(item.display) }}
</dd>
</div>
</dl>
</div>
</section>
<section :class="panelClass">
<h3 :class="panelTitleClass">
费用
</h3>
<UsageDetailFieldGrid :items="billingItems" />
</section>
</div>
<section :class="panelClass">
<h3 :class="panelTitleClass">
费用
</h3>
<UsageDetailFieldGrid :items="billingItems" />
</section>
</section>
<section v-if="attemptRows.length" :class="panelClass">
@@ -11,16 +11,12 @@ import { errorMessage, withMinimumDuration } from '@/utils/async'
export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangeParams>>) {
const refreshing = shallowRef(false)
const searchQuery = shallowRef('')
const failureClass = shallowRef('')
const route = shallowRef('')
const query = useStablePagedQuery({
initialPageSize: 10,
load: ({ currentPage, pageSize }) => getOpsErrors({
currentPage,
pageSize,
search: searchQuery.value.trim() || undefined,
failureClass: failureClass.value.trim() || undefined,
route: route.value.trim() || undefined,
...timeRangeParams.value,
}),
onError: error => toast.error(errorMessage(error, '加载错误明细失败')),
@@ -56,7 +52,7 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
}
watchDebounced(
[searchQuery, failureClass, route],
searchQuery,
() => {
void query.reloadFromStart()
},
@@ -74,8 +70,6 @@ export function useOpsErrorsTable(timeRangeParams: Readonly<Ref<UsageTimeRangePa
refreshing,
records: query.items,
searchQuery,
failureClass,
route,
pagination,
handlePageChange,
handlePageSizeChange,
+8 -14
View File
@@ -34,22 +34,16 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
])
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'accountId', label: '账号', kind: 'mono', size: '3xl' },
{ key: 'accountId', label: '账号', kind: 'identity', size: '3xl', emptyText: '未知账号' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'message', label: '错误摘要', kind: 'custom', size: '3xl' },
{ key: 'failureClass', label: '失败分类', kind: 'mono' },
{ key: 'upstreamStatusCode', label: '上游状态', kind: 'status', size: 'sm' },
{ key: 'clientStatusCode', label: '客户端状态', kind: 'status', size: 'md' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'custom' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'transport', label: '上游', kind: 'custom', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'custom', size: 'md' },
{ key: 'message', label: '错误', kind: 'custom', size: '4xl' },
{ key: 'upstreamSendState', label: '发送状态', kind: 'custom', size: 'xl' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl', emptyText: '未记录模型' },
{ key: 'route', label: '端点', kind: 'mono', size: 'xl', emptyText: '未记录' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl' },
{ key: 'kind', label: '事件', kind: 'mono' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl', emptyText: '未记录' },
{ key: 'clientIp', label: 'IP', kind: 'mono', size: '3xl', emptyText: '未记录' },
{ key: 'userAgent', label: 'User-Agent', kind: 'mono', size: '4xl', emptyText: '未记录' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
@@ -1,83 +0,0 @@
import type { OpsError } from '@/api/modules/usage'
const FAILURE_CLASS_LABELS: Record<string, string> = {
invalid_request: '请求不合法',
unsupported: '请求能力不受支持',
unauthorized: '认证失败',
auth_failed: '认证失败',
permission_denied: '上游权限不足',
policy_denied: '策略拒绝',
model_not_found: '模型不存在',
no_available_provider: '没有可用 Provider',
no_eligible_account: '没有符合条件的账号',
account_capacity_unavailable: '账号容量不足',
provider_infrastructure_unavailable: 'Provider 基础设施不可用',
rate_limited: '上游限流',
quota_exhausted: '账号额度已耗尽',
upstream_unavailable: '上游服务不可用',
upstream_error: '上游响应错误',
transport: '上游传输失败',
protocol: '上游协议错误',
unavailable: 'Provider 暂不可用',
timeout: '请求超时',
cancelled: '请求已取消',
process_terminated: '进程终止',
internal_error: '内部错误',
failed: '请求失败',
}
const COMPONENT_LABELS: Record<string, string> = {
model_request: '模型请求',
routing: '路由调度',
account_probe: '账号连接测试',
}
const SOURCE_LABELS: Record<string, string> = {
model_request: '模型请求记录',
ops_event: '运维事件',
}
export interface OpsErrorPresentation {
summary: string
failureClassLabel: string
componentLabel: string
sourceLabel: string
}
/** 将运维错误的稳定机器字段转换为中文摘要;原始诊断仍由调用方单独展示。 */
export function presentOpsError(record: OpsError): OpsErrorPresentation {
const failureClassLabel = mappedOrOriginal(FAILURE_CLASS_LABELS, record.failureClass, '未分类错误')
const componentLabel = mappedOrOriginal(
COMPONENT_LABELS,
record.metadata.component || record.kind,
'未知组件',
)
const sourceLabel = mappedOrOriginal(SOURCE_LABELS, record.metadata.source, '未知来源')
const statuses = [
statusText('客户端', record.clientStatusCode),
statusText('上游', record.upstreamStatusCode),
].filter(Boolean)
const statusSuffix = statuses.length > 0 ? `(${statuses.join(',')})` : ''
const providerCode = record.providerErrorCode?.trim()
const codeSuffix = providerCode ? ` [${providerCode}]` : ''
return {
summary: `${componentLabel}:${failureClassLabel}${codeSuffix}${statusSuffix}`,
failureClassLabel,
componentLabel,
sourceLabel,
}
}
function mappedOrOriginal(
labels: Record<string, string>,
value: string | null | undefined,
fallback: string,
) {
const normalized = value?.trim()
return normalized ? labels[normalized] || normalized : fallback
}
function statusText(owner: string, status: number | null) {
return typeof status === 'number' ? `${owner} HTTP ${status}` : ''
}