diff --git a/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts b/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts index d44a47dbc..4f5cdd4e3 100644 --- a/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts +++ b/frontend/src/i18n/__tests__/opsLocaleKeys.spec.ts @@ -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', () => { diff --git a/frontend/src/i18n/locales/en/admin/ops.ts b/frontend/src/i18n/locales/en/admin/ops.ts index 8210b789a..fe6a7a28b 100644 --- a/frontend/src/i18n/locales/en/admin/ops.ts +++ b/frontend/src/i18n/locales/en/admin/ops.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh/admin/ops.ts b/frontend/src/i18n/locales/zh/admin/ops.ts index 3c8e9b044..1e521c891 100644 --- a/frontend/src/i18n/locales/zh/admin/ops.ts +++ b/frontend/src/i18n/locales/zh/admin/ops.ts @@ -339,7 +339,16 @@ export default { time: '时间', phase: '阶段', status: '状态码', + upstreamStatus: '上游状态码', message: '消息', + rootCause: '根因', + diagnosticPayloads: '诊断载荷', + payloads: { + client: '客户端响应', + upstream_message: '上游消息', + upstream_detail: '上游详情', + upstream_events: '上游事件' + }, basicInfo: '基本信息', platform: '平台', model: '模型', diff --git a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue index 348eba297..407c671ca 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue @@ -93,6 +93,15 @@ +
+
{{ t('admin.ops.errorDetail.upstreamStatus') }}
+
+ + {{ detail.upstream_status_code ?? '—' }} + +
+
+
{{ t('admin.ops.errorDetail.requestType') }}
@@ -102,8 +111,8 @@
{{ t('admin.ops.errorDetail.message') }}
-
- {{ detail.message || '—' }} +
+ {{ rootCauseMessage || '—' }}
@@ -116,10 +125,20 @@
- +
+

{{ t('admin.ops.errorDetail.rootCause') }}

+
{{ rootCauseMessage }}
+
+
-

{{ t('admin.ops.errorDetail.responseBody') }}

-
{{ prettyJSON(primaryResponseBody || '') }}
+

{{ t('admin.ops.errorDetail.diagnosticPayloads') }}

+
{{ t('common.noData') }}
+
+
+
{{ diagnosticPayloadLabel(section.key) }}
+
{{ prettyJSON(section.value) }}
+
+
@@ -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)) diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts new file mode 100644 index 000000000..188c150e0 --- /dev/null +++ b/frontend/src/views/admin/ops/components/__tests__/OpsErrorDetailModal.spec.ts @@ -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() + 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: '
' }, + 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') + }) +})