完善运维错误详情兼容展示

This commit is contained in:
IanShaw
2026-08-19 23:13:47 -07:00
parent c374ff2951
commit e4f869e0c7
5 changed files with 173 additions and 11 deletions
@@ -21,6 +21,13 @@ describe('ops locale key completeness', () => {
'admin.ops.timeRange.custom',
'admin.ops.customTimeRange.startTime',
'admin.ops.customTimeRange.endTime',
'admin.ops.errorDetail.upstreamStatus',
'admin.ops.errorDetail.rootCause',
'admin.ops.errorDetail.diagnosticPayloads',
'admin.ops.errorDetail.payloads.client',
'admin.ops.errorDetail.payloads.upstream_message',
'admin.ops.errorDetail.payloads.upstream_detail',
'admin.ops.errorDetail.payloads.upstream_events',
]
for (const key of requiredKeys) {
@@ -29,6 +36,13 @@ describe('ops locale key completeness', () => {
expect(enKeys).toContain(key)
})
}
for (const key of requiredKeys) {
it(`zh locale has ${key}`, () => {
const zhKeys = flattenKeys(zh)
expect(zhKeys).toContain(key)
})
}
})
describe('groups locale key completeness', () => {
@@ -339,7 +339,16 @@ export default {
time: 'Time',
phase: 'Phase',
status: 'Status',
upstreamStatus: 'Upstream Status',
message: 'Message',
rootCause: 'Root Cause',
diagnosticPayloads: 'Diagnostic Payloads',
payloads: {
client: 'Client Response',
upstream_message: 'Upstream Message',
upstream_detail: 'Upstream Detail',
upstream_events: 'Upstream Events'
},
basicInfo: 'Basic Info',
platform: 'Platform',
model: 'Model',
@@ -339,7 +339,16 @@ export default {
time: '时间',
phase: '阶段',
status: '状态码',
upstreamStatus: '上游状态码',
message: '消息',
rootCause: '根因',
diagnosticPayloads: '诊断载荷',
payloads: {
client: '客户端响应',
upstream_message: '上游消息',
upstream_detail: '上游详情',
upstream_events: '上游事件'
},
basicInfo: '基本信息',
platform: '平台',
model: '模型',
@@ -93,6 +93,15 @@
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.upstreamStatus') }}</div>
<div class="mt-1">
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', upstreamStatusClass]">
{{ detail.upstream_status_code ?? '—' }}
</span>
</div>
</div>
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.requestType') }}</div>
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
@@ -102,8 +111,8 @@
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.message') }}</div>
<div class="mt-1 truncate text-sm font-medium text-gray-900 dark:text-white" :title="detail.message">
{{ detail.message || '—' }}
<div class="mt-1 break-words text-sm font-medium text-gray-900 dark:text-white" :title="rootCauseMessage">
{{ rootCauseMessage || '—' }}
</div>
</div>
@@ -116,10 +125,20 @@
</div>
<!-- Response content (client request -> error_body; upstream -> upstream_error_detail/message) -->
<div v-if="rootCauseMessage" class="rounded-xl bg-amber-50 p-6 dark:bg-amber-900/10">
<h3 class="text-sm font-black uppercase tracking-wider text-amber-900 dark:text-amber-200">{{ t('admin.ops.errorDetail.rootCause') }}</h3>
<div class="mt-3 break-words text-sm font-medium text-amber-900 dark:text-amber-100">{{ rootCauseMessage }}</div>
</div>
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.responseBody') }}</h3>
<pre class="mt-4 max-h-[520px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"><code>{{ prettyJSON(primaryResponseBody || '') }}</code></pre>
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.diagnosticPayloads') }}</h3>
<div v-if="!diagnosticPayloadSections.length" class="mt-4 text-sm text-gray-500 dark:text-gray-400">{{ t('common.noData') }}</div>
<div v-else class="mt-4 space-y-4">
<div v-for="section in diagnosticPayloadSections" :key="section.key">
<div class="mb-2 text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">{{ diagnosticPayloadLabel(section.key) }}</div>
<pre class="max-h-[520px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"><code>{{ prettyJSON(section.value) }}</code></pre>
</div>
</div>
</div>
<!-- Upstream errors list (only for request errors) -->
@@ -203,7 +222,7 @@ import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores'
import { opsAPI, type OpsErrorDetail } from '@/api/admin/ops'
import { formatDateTime } from '@/utils/format'
import { resolvePrimaryResponseBody, resolveUpstreamPayload } from '../utils/errorDetailResponse'
import { resolveUpstreamPayload } from '../utils/errorDetailResponse'
interface Props {
show: boolean
@@ -228,12 +247,41 @@ const showUpstreamList = computed(() => props.errorType === 'request')
const requestId = computed(() => detail.value?.request_id || detail.value?.client_request_id || '')
const primaryResponseBody = computed(() => {
return resolvePrimaryResponseBody(detail.value, props.errorType)
type DiagnosticPayloadKey = 'client' | 'upstream_message' | 'upstream_detail' | 'upstream_events'
const rootCauseMessage = computed(() => {
const current = detail.value
if (!current) return ''
for (const candidate of [current.upstream_error_message, current.upstream_error_detail, current.message, current.error_body]) {
const value = meaningfulPayload(candidate)
if (value) return value
}
return ''
})
const diagnosticPayloadSections = computed(() => {
const current = detail.value
if (!current) return []
const candidates: Array<{ key: DiagnosticPayloadKey; value: string }> = [
{ key: 'client', value: meaningfulPayload(current.error_body) },
{ key: 'upstream_message', value: meaningfulPayload(current.upstream_error_message) },
{ key: 'upstream_detail', value: meaningfulPayload(current.upstream_error_detail) },
{ key: 'upstream_events', value: meaningfulPayload(current.upstream_errors) }
]
return candidates.filter((section, index, all) => {
return section.value && all.findIndex(candidate => candidate.value === section.value) === index
})
})
function meaningfulPayload(candidate: unknown): string {
const value = String(candidate || '').trim()
if (!value || value === '[]' || value === '{}' || value.toLowerCase() === 'null') return ''
return value
}
function diagnosticPayloadLabel(key: DiagnosticPayloadKey): string {
return t(`admin.ops.errorDetail.payloads.${key}`)
}
const title = computed(() => {
if (!props.errorId) return t('admin.ops.errorDetail.title')
@@ -358,12 +406,15 @@ watch(
{ immediate: true }
)
const statusClass = computed(() => {
const code = detail.value?.status_code ?? 0
function statusBadgeClass(code: number): string {
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
})
}
const statusClass = computed(() => statusBadgeClass(detail.value?.status_code ?? 0))
const upstreamStatusClass = computed(() => statusBadgeClass(detail.value?.upstream_status_code ?? 0))
</script>
@@ -0,0 +1,79 @@
import { flushPromises, shallowMount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import OpsErrorDetailModal from '../OpsErrorDetailModal.vue'
const mocks = vi.hoisted(() => ({
getRequestErrorDetail: vi.fn(),
listRequestErrorUpstreamErrors: vi.fn()
}))
vi.mock('@/api/admin/ops', () => ({
opsAPI: {
getRequestErrorDetail: mocks.getRequestErrorDetail,
getUpstreamErrorDetail: vi.fn(),
listRequestErrorUpstreamErrors: mocks.listRequestErrorUpstreamErrors
}
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({ showError: vi.fn() })
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({ t: (key: string) => key })
}
})
describe('OpsErrorDetailModal', () => {
beforeEach(() => {
mocks.getRequestErrorDetail.mockReset()
mocks.listRequestErrorUpstreamErrors.mockReset()
mocks.listRequestErrorUpstreamErrors.mockResolvedValue({ items: [] })
})
it('prioritizes upstream root cause and deduplicates diagnostic payloads', async () => {
mocks.getRequestErrorDetail.mockResolvedValue({
id: 1,
created_at: '2026-08-19T00:00:00Z',
phase: 'request',
type: 'upstream_error',
error_owner: 'provider',
error_source: 'gateway',
severity: 'P1',
status_code: 502,
upstream_status_code: 429,
platform: 'openai',
model: 'gpt-5.6',
resolved: false,
request_id: 'rid-1',
message: 'All available accounts exhausted',
error_body: '{"error":"same"}',
upstream_error_message: 'provider rate limit exhausted',
upstream_error_detail: '{"error":"same"}',
upstream_errors: '[]',
account_name: 'account',
group_name: 'group',
is_business_limited: false
})
const wrapper = shallowMount(OpsErrorDetailModal, {
props: { show: true, errorId: 1, errorType: 'request' },
global: {
stubs: {
BaseDialog: { template: '<div><slot /></div>' },
Icon: true
}
}
})
await flushPromises()
expect(wrapper.text()).toContain('provider rate limit exhausted')
expect(wrapper.text()).toContain('admin.ops.errorDetail.upstreamStatus')
expect(wrapper.text()).toContain('429')
expect(wrapper.findAll('pre')).toHaveLength(2)
expect(wrapper.text()).not.toContain('admin.ops.errorDetail.payloads.upstream_detail')
})
})