diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index 3b664412..93acfc59 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -51,6 +51,7 @@ func (h *Handler) routes() { h.mux.HandleFunc("GET /api/alert-events", h.handleQualityIssues) h.mux.HandleFunc("GET /api/alert-events/notification-plan", h.handleQualityNotificationPlan) h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth) + h.mux.HandleFunc("GET /api/ops/source-readiness", h.handleSourceReadiness) } func (h *Handler) handleDashboardSummary(w http.ResponseWriter, r *http.Request) { @@ -205,6 +206,11 @@ func (h *Handler) handleOpsHealth(w http.ResponseWriter, r *http.Request) { h.write(w, r, data, err) } +func (h *Handler) handleSourceReadiness(w http.ResponseWriter, r *http.Request) { + data, err := h.service.SourceReadiness(r.Context()) + h.write(w, r, data, err) +} + func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err error) { if err != nil { if clientErr, ok := asClientError(err); ok { diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 0a98bebb..b948ae4c 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -939,6 +939,44 @@ func TestHandlerOpsHealthIncludesVehicleServiceRuntime(t *testing.T) { } } +func TestHandlerSourceReadiness(t *testing.T) { + handler := NewHandler(NewServiceWithRuntime(NewMockStore(), RuntimeInfo{PlatformRelease: "platform-source-test"})) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/ops/source-readiness", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data SourceReadinessPlan `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode source readiness: %v body=%s", err, rec.Body.String()) + } + if body.Data.PlatformRelease != "platform-source-test" { + t.Fatalf("source readiness should expose runtime release, got %+v", body.Data) + } + if len(body.Data.Sources) < len(canonicalVehicleProtocols) { + t.Fatalf("source readiness should expose canonical protocol slots, got %+v", body.Data.Sources) + } + byProtocol := map[string]SourceReadinessRow{} + for _, source := range body.Data.Sources { + byProtocol[source.Protocol] = source + } + for _, protocol := range canonicalVehicleProtocols { + row, ok := byProtocol[protocol] + if !ok { + t.Fatalf("source readiness missing protocol %s: %+v", protocol, body.Data.Sources) + } + if row.Role == "" || row.Evidence == "" || row.Action == "" || row.Acceptance == "" { + t.Fatalf("source readiness row should be operationally actionable: %+v", row) + } + if !strings.Contains(row.VehiclesHash, "missingProtocol="+protocol) || !strings.Contains(row.RealtimeHash, "protocol="+protocol) { + t.Fatalf("source readiness should include workflow hashes, got %+v", row) + } + } +} + func TestHandlerVehicleDataAPIsResolveVehicleKeyword(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) cases := []struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index d0f8e406..c7885ca6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -173,6 +173,34 @@ type MissingSourceStat struct { Count int `json:"count"` } +type SourceReadinessPlan struct { + TotalVehicles int `json:"totalVehicles"` + OnlineVehicles int `json:"onlineVehicles"` + KafkaLag *int `json:"kafkaLag"` + ActiveConnections *int `json:"activeConnections"` + RedisOnlineKeys *int `json:"redisOnlineKeys"` + PlatformRelease string `json:"platformRelease"` + Sources []SourceReadinessRow `json:"sources"` +} + +type SourceReadinessRow struct { + Protocol string `json:"protocol"` + Role string `json:"role"` + Online int `json:"online"` + Total int `json:"total"` + OnlineRate float64 `json:"onlineRate"` + MissingVehicles int `json:"missingVehicles"` + Severity string `json:"severity"` + Status string `json:"status"` + Evidence string `json:"evidence"` + Action string `json:"action"` + Acceptance string `json:"acceptance"` + VehiclesHash string `json:"vehiclesHash"` + RealtimeHash string `json:"realtimeHash"` + HistoryHash string `json:"historyHash"` + AlertHash string `json:"alertHash"` +} + type ArchiveMissingFieldStat struct { Field string `json:"field"` Title string `json:"title"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service.go b/vehicle-data-platform/apps/api/internal/platform/service.go index 3e1f3e05..4b40bf4b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service.go +++ b/vehicle-data-platform/apps/api/internal/platform/service.go @@ -180,6 +180,119 @@ func completeProtocolStats(stats []ProtocolStat) []ProtocolStat { return out } +func buildSourceReadinessPlan(summary VehicleServiceSummary, health OpsHealth, quality QualitySummary) SourceReadinessPlan { + missingByProtocol := make(map[string]int, len(summary.MissingSources)) + for _, item := range summary.MissingSources { + missingByProtocol[strings.TrimSpace(item.Protocol)] = item.Count + } + issueByType := make(map[string]int, len(quality.IssueTypes)) + for _, item := range quality.IssueTypes { + issueByType[strings.TrimSpace(item.Name)] = item.Count + } + protocolStats := completeProtocolStats(summary.Protocols) + rows := make([]SourceReadinessRow, 0, len(protocolStats)) + for _, stat := range protocolStats { + protocol := strings.TrimSpace(stat.Protocol) + if protocol == "" { + continue + } + missing := missingByProtocol[protocol] + row := SourceReadinessRow{ + Protocol: protocol, + Role: sourceReadinessRole(protocol), + Online: stat.Online, + Total: stat.Total, + OnlineRate: sourceOnlineRate(stat), + MissingVehicles: missing, + Severity: "ok", + Status: "生产可用", + Evidence: sourceReadinessEvidence(stat, missing, health), + Action: "保持实时、轨迹、RAW 和统计链路巡检。", + Acceptance: "该来源有在线车辆、无消息积压,车辆服务可回查实时、轨迹和 RAW 证据。", + VehiclesHash: buildHash("vehicles", "", protocol, map[string]string{"missingProtocol": protocol}), + RealtimeHash: buildHash("realtime", "", protocol, map[string]string{"online": "online"}), + HistoryHash: buildHash("history-query", "", protocol, map[string]string{"tab": "raw", "includeFields": "true"}), + AlertHash: buildHash("alert-events", "", protocol, nil), + } + if stat.Total <= 0 { + row.Severity = "error" + row.Status = "未形成来源证据" + row.Action = "确认平台账号、端口监听、订阅配置和协议接入服务是否启动。" + row.Acceptance = "该协议至少出现绑定车辆和实时 RAW 证据。" + } else if stat.Online <= 0 { + row.Severity = "error" + row.Status = "来源全离线" + row.Action = "优先检查上游平台转发、网关连接、登录/鉴权回复和 Redis 在线 TTL。" + row.Acceptance = "该协议恢复在线车辆,Redis 在线状态持续续期。" + } else if missing > summary.TotalVehicles/2 && summary.TotalVehicles > 0 { + row.Severity = "warning" + row.Status = "覆盖不足" + row.Action = "核对该协议车辆范围、绑定表和平台转发清单,确认缺失车辆是否应接入。" + row.Acceptance = "缺失来源车辆下降,单源车辆可解释。" + } + if health.KafkaLag != nil && *health.KafkaLag > 0 { + row.Severity = maxSourceSeverity(row.Severity, "warning") + row.Status = row.Status + " / Kafka积压" + row.Action = row.Action + " 同时检查 Kafka 消费 Lag,避免历史和统计滞后。" + } + if issueByType["NO_SOURCE"] > 0 && stat.Total <= 0 { + row.Severity = "error" + } + rows = append(rows, row) + } + return SourceReadinessPlan{ + TotalVehicles: summary.TotalVehicles, + OnlineVehicles: summary.OnlineVehicles, + KafkaLag: health.KafkaLag, + ActiveConnections: health.ActiveConnections, + RedisOnlineKeys: health.RedisOnlineKeys, + PlatformRelease: health.Runtime.PlatformRelease, + Sources: rows, + } +} + +func sourceReadinessRole(protocol string) string { + switch strings.ToUpper(strings.TrimSpace(protocol)) { + case "GB32960": + return "整车与氢能实时数据主来源" + case "JT808": + return "位置、总里程和设备在线主来源" + case "YUTONG_MQTT": + return "宇通派生工况与燃料电池补充来源" + default: + return "扩展接入来源" + } +} + +func sourceOnlineRate(stat ProtocolStat) float64 { + if stat.Total <= 0 { + return 0 + } + return float64(stat.Online) / float64(stat.Total) * 100 +} + +func sourceReadinessEvidence(stat ProtocolStat, missing int, health OpsHealth) string { + parts := []string{ + "在线 " + strconv.Itoa(stat.Online) + "/" + strconv.Itoa(stat.Total), + "缺失车辆 " + strconv.Itoa(missing), + } + if health.KafkaLag != nil { + parts = append(parts, "Kafka Lag "+strconv.Itoa(*health.KafkaLag)) + } + if health.RedisOnlineKeys != nil { + parts = append(parts, "Redis在线Key "+strconv.Itoa(*health.RedisOnlineKeys)) + } + return strings.Join(parts, ",") +} + +func maxSourceSeverity(left string, right string) string { + weight := map[string]int{"ok": 0, "warning": 1, "error": 2} + if weight[right] > weight[left] { + return right + } + return left +} + func missingCanonicalProtocols(protocols []string) []string { missing := make([]string, 0, len(canonicalVehicleProtocols)) for _, protocol := range canonicalVehicleProtocols { @@ -251,6 +364,23 @@ func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSumm return s.store.VehicleServiceSummary(ctx) } +func (s *Service) SourceReadiness(ctx context.Context) (SourceReadinessPlan, error) { + summary, err := s.store.VehicleServiceSummary(ctx) + if err != nil { + return SourceReadinessPlan{}, err + } + health, err := s.store.OpsHealth(ctx) + if err != nil { + return SourceReadinessPlan{}, err + } + quality, err := s.store.QualitySummary(ctx, url.Values{}) + if err != nil { + return SourceReadinessPlan{}, err + } + health.Runtime = s.runtime + return buildSourceReadinessPlan(summary, health, quality), nil +} + func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) { resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil { diff --git a/vehicle-data-platform/apps/web/src/api/client.test.ts b/vehicle-data-platform/apps/web/src/api/client.test.ts index aceb6bd2..4bd1d18f 100644 --- a/vehicle-data-platform/apps/web/src/api/client.test.ts +++ b/vehicle-data-platform/apps/web/src/api/client.test.ts @@ -287,6 +287,47 @@ test('onlineVehicleStatuses reads paged online vehicle status rows', async () => expect(result.items[0].offlineDurationMinutes).toBe(18); }); +test('sourceReadiness reads ops source readiness plan', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 0, + activeConnections: 178, + redisOnlineKeys: 258, + platformRelease: 'platform-source-test', + sources: [{ + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340', + action: '核对平台转发清单', + acceptance: '缺失来源车辆下降', + vehiclesHash: '#/vehicles?missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960', + historyHash: '#/history-query?protocol=GB32960&tab=raw', + alertHash: '#/alert-events?protocol=GB32960' + }] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response); + + const result = await api.sourceReadiness(); + + expect(fetchMock).toHaveBeenCalledWith('/api/ops/source-readiness', undefined); + expect(result.platformRelease).toBe('platform-source-test'); + expect(result.sources[0].protocol).toBe('GB32960'); +}); + test('api errors include backend message, detail, and trace id', async () => { vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false, diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 84385207..2d76c585 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -14,6 +14,7 @@ import type { QualityIssueRow, RawFrameRow, RealtimeLocationRow, + SourceReadinessPlan, VehicleRealtimeRow, VehicleCoverageRow, VehicleCoverageSummary, @@ -117,5 +118,6 @@ export const api = { alertEvents: (params = new URLSearchParams()) => request>(`/api/alert-events?${params.toString()}`), alertEventNotificationPlan: (params = new URLSearchParams()) => request(`/api/alert-events/notification-plan?${params.toString()}`), reverseGeocode: (params = new URLSearchParams()) => request(`/api/map/reverse-geocode?${params.toString()}`), - opsHealth: () => request('/api/ops/health') + opsHealth: () => request('/api/ops/health'), + sourceReadiness: () => request('/api/ops/source-readiness') }; diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index ea985306..fc9e2935 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -359,6 +359,34 @@ export interface OpsHealth { runtime: RuntimeInfo; } +export interface SourceReadinessPlan { + totalVehicles: number; + onlineVehicles: number; + kafkaLag: number | null; + activeConnections: number | null; + redisOnlineKeys: number | null; + platformRelease: string; + sources: SourceReadinessRow[]; +} + +export interface SourceReadinessRow { + protocol: string; + role: string; + online: number; + total: number; + onlineRate: number; + missingVehicles: number; + severity: 'ok' | 'warning' | 'error' | string; + status: string; + evidence: string; + action: string; + acceptance: string; + vehiclesHash: string; + realtimeHash: string; + historyHash: string; + alertHash: string; +} + export interface CapacityMetrics { activeConnections: number; kafkaLag: number; diff --git a/vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx b/vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx index 37bc2873..ee21dde8 100644 --- a/vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx +++ b/vehicle-data-platform/apps/web/src/pages/OpsQuality.tsx @@ -2,7 +2,7 @@ import { Button, Card, Space, Table, Tag, Toast, Typography } from '@douyinfe/se import { IconCopy } from '@douyinfe/semi-icons'; import { useEffect, useState } from 'react'; import { api } from '../api/client'; -import type { CapacityMetrics, LinkHealth, OpsHealth } from '../api/types'; +import type { CapacityMetrics, LinkHealth, OpsHealth, SourceReadinessPlan, SourceReadinessRow } from '../api/types'; import { PageHeader } from '../components/PageHeader'; const statusColor: Record = { @@ -15,6 +15,14 @@ function formatNumber(value?: number | null) { return value == null ? '-' : value.toLocaleString(); } +function formatRate(value?: number | null) { + return value == null || !Number.isFinite(Number(value)) ? '0%' : `${Number(value).toLocaleString(undefined, { maximumFractionDigits: 1 })}%`; +} + +function normalizeSourceReadiness(plan: SourceReadinessPlan | null): SourceReadinessPlan | null { + return plan && Array.isArray(plan.sources) ? plan : null; +} + function statusText(ok?: boolean) { return ok ? '正常' : '异常'; } @@ -384,6 +392,27 @@ function opsCapacityActionPlanText(health: OpsHealth | null) { ].join('\n\n'); } +function sourceReadinessReport(plan: SourceReadinessPlan | null) { + if (!plan) return '【数据源生产就绪度】\n暂无数据源就绪度数据'; + return [ + '【数据源生产就绪度】', + `运行版本:${plan.platformRelease || '-'}`, + `车辆规模:${formatNumber(plan.totalVehicles)} / 在线 ${formatNumber(plan.onlineVehicles)}`, + `接入状态:连接 ${formatNumber(plan.activeConnections)} / Redis在线Key ${formatNumber(plan.redisOnlineKeys)} / Kafka Lag ${formatNumber(plan.kafkaLag)}`, + '', + ...plan.sources.map((source, index) => [ + `${index + 1}. [${source.severity}] ${source.protocol} - ${source.status}`, + ` 角色:${source.role}`, + ` 证据:${source.evidence}`, + ` 在线率:${formatRate(source.onlineRate)},缺失车辆:${formatNumber(source.missingVehicles)}`, + ` 动作:${source.action}`, + ` 验收:${source.acceptance}`, + ` 实时监控:${window.location.origin}${window.location.pathname}${source.realtimeHash}`, + ` 历史证据:${window.location.origin}${window.location.pathname}${source.historyHash}` + ].join('\n')) + ].join('\n'); +} + async function copyText(value: string, label: string) { try { await navigator.clipboard.writeText(value); @@ -395,12 +424,22 @@ async function copyText(value: string, label: string) { export function OpsQuality() { const [health, setHealth] = useState(null); + const [sourceReadiness, setSourceReadiness] = useState(null); const [loading, setLoading] = useState(true); const load = () => { setLoading(true); - api.opsHealth() - .then(setHealth) + Promise.all([ + api.opsHealth(), + api.sourceReadiness().catch((error: Error) => { + Toast.error(error.message); + return null; + }) + ]) + .then(([nextHealth, nextSourceReadiness]) => { + setHealth(nextHealth); + setSourceReadiness(normalizeSourceReadiness(nextSourceReadiness)); + }) .catch((error: Error) => Toast.error(error.message)) .finally(() => setLoading(false)); }; @@ -477,6 +516,53 @@ export function OpsQuality() { + 数据源生产就绪度} + loading={loading} + style={{ marginTop: 16 }} + > + + pagination={false} + dataSource={sourceReadiness?.sources ?? []} + rowKey={(row?: SourceReadinessRow) => row?.protocol ?? ''} + columns={[ + { + title: '来源', + width: 150, + render: (_: unknown, row: SourceReadinessRow) => ( + + {row.protocol} + {row.role} + + ) + }, + { + title: '就绪状态', + width: 150, + render: (_: unknown, row: SourceReadinessRow) => {row.status} + }, + { title: '在线率', width: 100, render: (_: unknown, row: SourceReadinessRow) => formatRate(row.onlineRate) }, + { title: '在线/总数', width: 120, render: (_: unknown, row: SourceReadinessRow) => `${formatNumber(row.online)} / ${formatNumber(row.total)}` }, + { title: '缺失车辆', width: 110, render: (_: unknown, row: SourceReadinessRow) => formatNumber(row.missingVehicles) }, + { title: '证据', dataIndex: 'evidence' }, + { title: '建议动作', dataIndex: 'action' }, + { + title: '入口', + width: 220, + render: (_: unknown, row: SourceReadinessRow) => ( + + + + + + + ) + } + ]} + /> + + pagination={false} 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 5ba50c12..4fc79381 100644 --- a/vehicle-data-platform/apps/web/src/test/App.test.tsx +++ b/vehicle-data-platform/apps/web/src/test/App.test.tsx @@ -121,6 +121,59 @@ test('exposes AMap operations shortcuts when map key is configured', async () => }) } as Response; } + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 42, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-ops-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 42', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 174, + total: 424, + onlineRate: 41.04, + missingVehicles: 609, + severity: 'ok', + status: '生产可用', + evidence: '在线 174/424,缺失车辆 609,Kafka Lag 42', + action: '保持实时、轨迹、RAW 和统计链路巡检。', + acceptance: '车辆服务可回查实时、轨迹和 RAW 证据。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -191,6 +244,59 @@ test('shows global alert pressure from notification plan in topbar', async () => }) } as Response; } + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 42, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-ops-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 42', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 42', + action: '补齐手机号绑定和注册缺失车辆。', + acceptance: '未知 VIN 注册下降,位置与里程持续可查。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -3732,6 +3838,59 @@ test('renders ops quality as a standalone runtime health page', async () => { }) } as Response; } + if (path.includes('/api/ops/source-readiness')) { + return { + ok: true, + json: async () => ({ + data: { + totalVehicles: 1033, + onlineVehicles: 208, + kafkaLag: 42, + activeConnections: 1234, + redisOnlineKeys: 368, + platformRelease: 'platform-ops-test', + sources: [ + { + protocol: 'GB32960', + role: '整车与氢能实时数据主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 42', + action: '核对现代和交投平台转发清单。', + acceptance: '缺失来源车辆下降,单源车辆可解释。', + vehiclesHash: '#/vehicles?protocol=GB32960&missingProtocol=GB32960', + realtimeHash: '#/realtime?protocol=GB32960&online=online', + historyHash: '#/history-query?protocol=GB32960&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=GB32960' + }, + { + protocol: 'JT808', + role: '位置、总里程和设备在线主来源', + online: 73, + total: 340, + onlineRate: 21.47, + missingVehicles: 693, + severity: 'warning', + status: '覆盖不足', + evidence: '在线 73/340,缺失车辆 693,Kafka Lag 42', + action: '补齐手机号绑定和注册缺失车辆。', + acceptance: '未知 VIN 注册下降,位置与里程持续可查。', + vehiclesHash: '#/vehicles?protocol=JT808&missingProtocol=JT808', + realtimeHash: '#/realtime?protocol=JT808&online=online', + historyHash: '#/history-query?protocol=JT808&tab=raw&includeFields=true', + alertHash: '#/alert-events?protocol=JT808' + } + ] + }, + traceId: 'trace-test', + timestamp: 1783094400000 + }) + } as Response; + } return { ok: true, json: async () => ({ @@ -3768,7 +3927,14 @@ test('renders ops quality as a standalone runtime health page', async () => { expect(screen.getByText('NATS 桥接 ACK 未确认')).toBeInTheDocument(); expect(screen.getAllByText('NATS 桥接待消费').length).toBeGreaterThanOrEqual(1); expect(screen.getByText('桥接处置顺序')).toBeInTheDocument(); + expect(screen.getByText('数据源生产就绪度')).toBeInTheDocument(); + expect(await screen.findByText('GB32960')).toBeInTheDocument(); + expect(screen.getByText('JT808')).toBeInTheDocument(); + expect(screen.getByText('整车与氢能实时数据主来源')).toBeInTheDocument(); + expect(screen.getAllByText('覆盖不足').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('在线 73/340,缺失车辆 693,Kafka Lag 42').length).toBeGreaterThanOrEqual(1); expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/health'), undefined); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/source-readiness'), undefined); fireEvent.click(screen.getByRole('button', { name: '复制容量交接' })); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台运维容量交接】')); @@ -3782,6 +3948,13 @@ test('renders ops quality as a standalone runtime health page', async () => { expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台容量处置计划】')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('P0 稳定 NATS 到 Kafka 桥接')); expect(writeText).toHaveBeenCalledWith(expect.stringContaining('负责人:数据库 / 应用')); + + const sourceReadinessCopyButton = screen.getByText('复制就绪度报告').closest('button'); + expect(sourceReadinessCopyButton).not.toBeNull(); + fireEvent.click(sourceReadinessCopyButton as HTMLButtonElement); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【数据源生产就绪度】')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('GB32960 - 覆盖不足')); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('实时监控:http://localhost:3000/#/realtime?protocol=GB32960&online=online')); }); test('copies notification text from quality priority queue', async () => {