diff --git a/frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue b/frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue index a52b5442..ad7ce074 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorDistributionChart.vue @@ -30,7 +30,11 @@ const colors = computed(() => ({ text: isDarkMode.value ? '#9ca3af' : '#6b7280' })) -const hasData = computed(() => (props.data?.total ?? 0) > 0) +const totalSlaErrors = computed(() => + (props.data?.items ?? []).reduce((total, item) => total + Number(item.sla || 0), 0) +) + +const hasData = computed(() => totalSlaErrors.value > 0) const state = computed(() => { if (hasData.value) return 'ready' @@ -54,7 +58,7 @@ const categories = computed(() => { for (const item of props.data.items || []) { const code = Number(item.status_code || 0) - const count = Number(item.total || 0) + const count = Number(item.sla || 0) if (!Number.isFinite(code) || !Number.isFinite(count)) continue if ([502, 503, 504].includes(code)) upstream += count diff --git a/frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue b/frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue index 088dc317..6e07926f 100644 --- a/frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue +++ b/frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue @@ -45,9 +45,7 @@ const colors = computed(() => ({ text: isDarkMode.value ? '#9ca3af' : '#6b7280' })) -const totalRequestErrors = computed(() => - sumNumbers(props.points.map((p) => (p.error_count_sla ?? 0) + (p.business_limited_count ?? 0))) -) +const totalRequestErrors = computed(() => sumNumbers(props.points.map((p) => p.error_count_sla ?? 0))) const totalUpstreamErrors = computed(() => sumNumbers( diff --git a/frontend/src/views/admin/ops/components/__tests__/OpsErrorScopeCharts.spec.ts b/frontend/src/views/admin/ops/components/__tests__/OpsErrorScopeCharts.spec.ts new file mode 100644 index 00000000..0c65bbcd --- /dev/null +++ b/frontend/src/views/admin/ops/components/__tests__/OpsErrorScopeCharts.spec.ts @@ -0,0 +1,147 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' +import { defineComponent } from 'vue' +import OpsErrorDistributionChart from '../OpsErrorDistributionChart.vue' +import OpsErrorTrendChart from '../OpsErrorTrendChart.vue' + +vi.mock('chart.js', () => ({ + Chart: { register: vi.fn() }, + ArcElement: {}, + CategoryScale: {}, + Filler: {}, + Legend: {}, + LineElement: {}, + LinearScale: {}, + PointElement: {}, + Title: {}, + Tooltip: {}, +})) + +vi.mock('vue-chartjs', async () => { + const { defineComponent } = await import('vue') + + return { + Doughnut: defineComponent({ + name: 'Doughnut', + props: { + data: { type: Object, required: true }, + options: { type: Object, default: () => ({}) }, + }, + template: '
', + }), + Line: defineComponent({ + name: 'Line', + props: { + data: { type: Object, required: true }, + options: { type: Object, default: () => ({}) }, + }, + template: '
', + }), + } +}) + +vi.mock('../../utils/opsFormatters', () => ({ + formatHistoryLabel: (date: string | undefined) => date ?? '', + sumNumbers: (values: Array) => + values.reduce((total, value) => total + (typeof value === 'number' && Number.isFinite(value) ? value : 0), 0), +})) + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + useI18n: () => ({ + t: (key: string) => key, + }), + } +}) + +const HelpTooltipStub = defineComponent({ + name: 'HelpTooltip', + props: { + content: { type: String, default: '' }, + }, + template: '', +}) + +const EmptyStateStub = defineComponent({ + name: 'EmptyState', + props: { + title: { type: String, default: '' }, + description: { type: String, default: '' }, + }, + template: '
', +}) + +const globalStubs = { + stubs: { + HelpTooltip: HelpTooltipStub, + EmptyState: EmptyStateStub, + }, +} + +describe('Ops SLA-scoped error charts', () => { + it('错误分布图按 SLA 错误数统计,不把业务限制错误算进请求错误分布', () => { + const wrapper = mount(OpsErrorDistributionChart, { + props: { + loading: false, + data: { + total: 10, + items: [ + { status_code: 400, total: 7, sla: 2, business_limited: 5 }, + { status_code: 503, total: 3, sla: 0, business_limited: 3 }, + ], + }, + }, + global: globalStubs, + }) + + const doughnut = wrapper.findComponent({ name: 'Doughnut' }) + expect(doughnut.exists()).toBe(true) + expect(doughnut.props('data')).toMatchObject({ + labels: ['admin.ops.client'], + datasets: [{ data: [2] }], + }) + }) + + it('错误分布图在只有业务限制错误时显示为空态', () => { + const wrapper = mount(OpsErrorDistributionChart, { + props: { + loading: false, + data: { + total: 4, + items: [{ status_code: 500, total: 4, sla: 0, business_limited: 4 }], + }, + }, + global: globalStubs, + }) + + expect(wrapper.findComponent({ name: 'Doughnut' }).exists()).toBe(false) + expect(wrapper.find('.empty-state-stub').exists()).toBe(true) + }) + + it('错误趋势图的请求错误详情按钮只按 SLA 错误启用', () => { + const wrapper = mount(OpsErrorTrendChart, { + props: { + loading: false, + timeRange: '1h', + points: [ + { + bucket_start: '2026-05-18T00:00:00Z', + error_count_total: 5, + business_limited_count: 5, + error_count_sla: 0, + upstream_error_count_excl_429_529: 0, + upstream_429_count: 0, + upstream_529_count: 0, + }, + ], + }, + global: globalStubs, + }) + + const requestErrorsButton = wrapper.findAll('button')[0] + expect(requestErrorsButton.attributes('disabled')).toBeDefined() + }) +})