完善运维错误详情兼容展示
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user