diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index b1c9656d..7f5d3c39 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -285,6 +285,7 @@ type OpsHealth struct { LinkHealth []LinkHealth `json:"linkHealth"` KafkaLag *int `json:"kafkaLag"` ActiveConnections *int `json:"activeConnections"` + CapacityFindings []string `json:"capacityFindings"` RedisOnlineKeys *int `json:"redisOnlineKeys"` TDengineWritable bool `json:"tdengineWritable"` MySQLWritable bool `json:"mysqlWritable"` diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 566edf58..12395b73 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -905,7 +905,7 @@ func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) { locationHealth := s.mysqlTableReadHealth(ctx, "vehicle_realtime_location", "读取实时位置表正常") tdengineHealth := s.tdengineRawFrameHealth(ctx) redisHealth, redisOnlineKeys := s.redisOnlineKeyHealth(ctx) - capacityHealth, kafkaLag, activeConnections := s.capacityCheckHealth(ctx) + capacityHealth, kafkaLag, activeConnections, capacityFindings := s.capacityCheckHealth(ctx) mysqlWritable := mysqlStatus == "ok" && snapshotHealth.Status == "ok" && locationHealth.Status == "ok" return OpsHealth{ LinkHealth: []LinkHealth{ @@ -918,22 +918,23 @@ func (s *ProductionStore) OpsHealth(ctx context.Context) (OpsHealth, error) { }, KafkaLag: kafkaLag, ActiveConnections: activeConnections, + CapacityFindings: capacityFindings, RedisOnlineKeys: redisOnlineKeys, TDengineWritable: tdengineHealth.Status == "ok", MySQLWritable: mysqlWritable, }, nil } -func (s *ProductionStore) capacityCheckHealth(ctx context.Context) (LinkHealth, *int, *int) { +func (s *ProductionStore) capacityCheckHealth(ctx context.Context) (LinkHealth, *int, *int, []string) { health := LinkHealth{Name: "Capacity check", Status: "warning", Detail: "平台暂未接入 capacity-check"} if s.capacityCheck == nil { - return health, nil, nil + return health, nil, nil, []string{} } result, err := s.capacityCheck.CheckCapacity(ctx) if err != nil { health.Status = "error" health.Detail = err.Error() - return health, nil, nil + return health, nil, nil, []string{} } kafkaLag := int(result.KafkaLag) activeConnections := int(result.ActiveConnections) @@ -947,7 +948,7 @@ func (s *ProductionStore) capacityCheckHealth(ctx context.Context) (LinkHealth, health.Status = "warning" } health.Detail = detail - return health, &kafkaLag, &activeConnections + return health, &kafkaLag, &activeConnections, append([]string{}, result.Findings...) } func (s *ProductionStore) redisOnlineKeyHealth(ctx context.Context) (LinkHealth, *int) { diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go index 5b45fec9..732c1c53 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store_test.go @@ -230,6 +230,9 @@ func TestOpsHealthUsesCapacityCheckProbeForKafkaLag(t *testing.T) { if health.ActiveConnections == nil || *health.ActiveConnections != 120000 { t.Fatalf("OpsHealth should expose capacity-check active connections, got %+v", health.ActiveConnections) } + if len(health.CapacityFindings) != 1 || health.CapacityFindings[0] != "kafka lag 42" { + t.Fatalf("OpsHealth should expose structured capacity findings, got %+v", health.CapacityFindings) + } var capacityHealth *LinkHealth for index := range health.LinkHealth { if health.LinkHealth[index].Name == "Capacity check" { @@ -242,6 +245,30 @@ func TestOpsHealthUsesCapacityCheckProbeForKafkaLag(t *testing.T) { } } +func TestOpsHealthReturnsEmptyCapacityFindingsArray(t *testing.T) { + db, err := sql.Open("ops_health_test", "") + if err != nil { + t.Fatalf("open ops health db: %v", err) + } + defer db.Close() + store := &ProductionStore{ + db: db, + capacityCheck: fakeCapacityCheckProbe{result: CapacityCheckResult{ + Status: "ok", + ActiveConnections: 254, + KafkaLag: 0, + }}, + } + + health, err := store.OpsHealth(context.Background()) + if err != nil { + t.Fatalf("OpsHealth returned error: %v", err) + } + if health.CapacityFindings == nil || len(health.CapacityFindings) != 0 { + t.Fatalf("OpsHealth should expose an empty capacity findings array, got %#v", health.CapacityFindings) + } +} + func TestVehicleServiceOverviewMatchesPartialKeyword(t *testing.T) { overview := VehicleServiceOverview{ VIN: "LB9A32A24R0LS1426", diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 9ed4036a..932b7497 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -275,6 +275,7 @@ export interface OpsHealth { linkHealth: LinkHealth[]; kafkaLag: number | null; activeConnections: number | null; + capacityFindings: string[]; redisOnlineKeys: number | null; tdengineWritable: boolean; mysqlWritable: boolean; diff --git a/vehicle-data-platform/apps/web/src/pages/Quality.tsx b/vehicle-data-platform/apps/web/src/pages/Quality.tsx index 813a295a..02253711 100644 --- a/vehicle-data-platform/apps/web/src/pages/Quality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/Quality.tsx @@ -198,6 +198,15 @@ export function Quality({ + {health?.capacityFindings?.length ? ( + + + {health.capacityFindings.map((item) => ( + {item} + ))} + + + ) : null} {filterSummary.length > 0 ? ( diff --git a/vehicle-data-platform/apps/web/src/test/App.test.tsx b/vehicle-data-platform/apps/web/src/test/App.test.tsx index 5f6a162c..1835c28e 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -4153,6 +4153,31 @@ test('quality health shows active connection capacity metric', async () => { expect(screen.getByText('120,000')).toBeInTheDocument(); }); +test('quality health shows structured capacity findings', async () => { + window.history.replaceState(null, '', '/#/quality'); + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const path = String(input); + return { + ok: true, + json: async () => ({ + data: path.includes('/api/ops/health') + ? { linkHealth: [], kafkaLag: 42, activeConnections: 120000, capacityFindings: ['kafka lag 42'], redisOnlineKeys: 368, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } } + : path.includes('/api/quality/summary') + ? { issueVehicleCount: 0, issueRecordCount: 0, errorCount: 0, warningCount: 0, protocols: [], issueTypes: [] } + : { items: [], total: 0, limit: 20, offset: 0 }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + }); + + render(); + + expect(await screen.findByRole('heading', { name: '质量治理' })).toBeInTheDocument(); + expect(await screen.findByText('容量检查发现')).toBeInTheDocument(); + expect(screen.getByText('kafka lag 42')).toBeInTheDocument(); +}); + test('shows vehicle service evidence chain before source details', async () => { window.history.replaceState(null, '', '/#/detail?keyword=VIN001'); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {