feat(platform): add source readiness operations view
This commit is contained in:
@@ -51,6 +51,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("GET /api/alert-events", h.handleQualityIssues)
|
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/alert-events/notification-plan", h.handleQualityNotificationPlan)
|
||||||
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
|
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) {
|
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)
|
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) {
|
func (h *Handler) write(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if clientErr, ok := asClientError(err); ok {
|
if clientErr, ok := asClientError(err); ok {
|
||||||
|
|||||||
@@ -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) {
|
func TestHandlerVehicleDataAPIsResolveVehicleKeyword(t *testing.T) {
|
||||||
handler := NewHandler(NewService(NewMockStore()))
|
handler := NewHandler(NewService(NewMockStore()))
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -173,6 +173,34 @@ type MissingSourceStat struct {
|
|||||||
Count int `json:"count"`
|
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 {
|
type ArchiveMissingFieldStat struct {
|
||||||
Field string `json:"field"`
|
Field string `json:"field"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|||||||
@@ -180,6 +180,119 @@ func completeProtocolStats(stats []ProtocolStat) []ProtocolStat {
|
|||||||
return out
|
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 {
|
func missingCanonicalProtocols(protocols []string) []string {
|
||||||
missing := make([]string, 0, len(canonicalVehicleProtocols))
|
missing := make([]string, 0, len(canonicalVehicleProtocols))
|
||||||
for _, protocol := range canonicalVehicleProtocols {
|
for _, protocol := range canonicalVehicleProtocols {
|
||||||
@@ -251,6 +364,23 @@ func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSumm
|
|||||||
return s.store.VehicleServiceSummary(ctx)
|
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) {
|
func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -287,6 +287,47 @@ test('onlineVehicleStatuses reads paged online vehicle status rows', async () =>
|
|||||||
expect(result.items[0].offlineDurationMinutes).toBe(18);
|
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 () => {
|
test('api errors include backend message, detail, and trace id', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
QualityIssueRow,
|
QualityIssueRow,
|
||||||
RawFrameRow,
|
RawFrameRow,
|
||||||
RealtimeLocationRow,
|
RealtimeLocationRow,
|
||||||
|
SourceReadinessPlan,
|
||||||
VehicleRealtimeRow,
|
VehicleRealtimeRow,
|
||||||
VehicleCoverageRow,
|
VehicleCoverageRow,
|
||||||
VehicleCoverageSummary,
|
VehicleCoverageSummary,
|
||||||
@@ -117,5 +118,6 @@ export const api = {
|
|||||||
alertEvents: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/alert-events?${params.toString()}`),
|
alertEvents: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/alert-events?${params.toString()}`),
|
||||||
alertEventNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/alert-events/notification-plan?${params.toString()}`),
|
alertEventNotificationPlan: (params = new URLSearchParams()) => request<QualityNotificationPlan>(`/api/alert-events/notification-plan?${params.toString()}`),
|
||||||
reverseGeocode: (params = new URLSearchParams()) => request<MapReverseGeocode>(`/api/map/reverse-geocode?${params.toString()}`),
|
reverseGeocode: (params = new URLSearchParams()) => request<MapReverseGeocode>(`/api/map/reverse-geocode?${params.toString()}`),
|
||||||
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
opsHealth: () => request<OpsHealth>('/api/ops/health'),
|
||||||
|
sourceReadiness: () => request<SourceReadinessPlan>('/api/ops/source-readiness')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -359,6 +359,34 @@ export interface OpsHealth {
|
|||||||
runtime: RuntimeInfo;
|
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 {
|
export interface CapacityMetrics {
|
||||||
activeConnections: number;
|
activeConnections: number;
|
||||||
kafkaLag: number;
|
kafkaLag: number;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Button, Card, Space, Table, Tag, Toast, Typography } from '@douyinfe/se
|
|||||||
import { IconCopy } from '@douyinfe/semi-icons';
|
import { IconCopy } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
|
|
||||||
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||||
@@ -15,6 +15,14 @@ function formatNumber(value?: number | null) {
|
|||||||
return value == null ? '-' : value.toLocaleString();
|
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) {
|
function statusText(ok?: boolean) {
|
||||||
return ok ? '正常' : '异常';
|
return ok ? '正常' : '异常';
|
||||||
}
|
}
|
||||||
@@ -384,6 +392,27 @@ function opsCapacityActionPlanText(health: OpsHealth | null) {
|
|||||||
].join('\n\n');
|
].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) {
|
async function copyText(value: string, label: string) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(value);
|
await navigator.clipboard.writeText(value);
|
||||||
@@ -395,12 +424,22 @@ async function copyText(value: string, label: string) {
|
|||||||
|
|
||||||
export function OpsQuality() {
|
export function OpsQuality() {
|
||||||
const [health, setHealth] = useState<OpsHealth | null>(null);
|
const [health, setHealth] = useState<OpsHealth | null>(null);
|
||||||
|
const [sourceReadiness, setSourceReadiness] = useState<SourceReadinessPlan | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.opsHealth()
|
Promise.all([
|
||||||
.then(setHealth)
|
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))
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
};
|
};
|
||||||
@@ -477,6 +516,53 @@ export function OpsQuality() {
|
|||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
bordered
|
||||||
|
title={<Space><span>数据源生产就绪度</span><Button size="small" icon={<IconCopy />} disabled={!sourceReadiness} onClick={() => copyText(sourceReadinessReport(sourceReadiness), '数据源生产就绪度')}>复制就绪度报告</Button></Space>}
|
||||||
|
loading={loading}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
|
<Table<SourceReadinessRow>
|
||||||
|
pagination={false}
|
||||||
|
dataSource={sourceReadiness?.sources ?? []}
|
||||||
|
rowKey={(row?: SourceReadinessRow) => row?.protocol ?? ''}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
width: 150,
|
||||||
|
render: (_: unknown, row: SourceReadinessRow) => (
|
||||||
|
<Space vertical align="start" spacing={4}>
|
||||||
|
<Tag color={row.severity === 'error' ? 'red' : row.severity === 'warning' ? 'orange' : 'green'}>{row.protocol}</Tag>
|
||||||
|
<Typography.Text type="secondary">{row.role}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '就绪状态',
|
||||||
|
width: 150,
|
||||||
|
render: (_: unknown, row: SourceReadinessRow) => <Tag color={row.severity === 'error' ? 'red' : row.severity === 'warning' ? 'orange' : 'green'}>{row.status}</Tag>
|
||||||
|
},
|
||||||
|
{ 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) => (
|
||||||
|
<Space wrap>
|
||||||
|
<Button size="small" onClick={() => { window.location.hash = row.realtimeHash; }}>实时</Button>
|
||||||
|
<Button size="small" onClick={() => { window.location.hash = row.vehiclesHash; }}>车辆</Button>
|
||||||
|
<Button size="small" onClick={() => { window.location.hash = row.historyHash; }}>RAW</Button>
|
||||||
|
<Button size="small" onClick={() => { window.location.hash = row.alertHash; }}>告警</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card bordered title="消息链路积压" loading={loading} style={{ marginTop: 16 }}>
|
<Card bordered title="消息链路积压" loading={loading} style={{ marginTop: 16 }}>
|
||||||
<Table<CapacityMetricRow>
|
<Table<CapacityMetricRow>
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|||||||
@@ -121,6 +121,59 @@ test('exposes AMap operations shortcuts when map key is configured', async () =>
|
|||||||
})
|
})
|
||||||
} as Response;
|
} 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -191,6 +244,59 @@ test('shows global alert pressure from notification plan in topbar', async () =>
|
|||||||
})
|
})
|
||||||
} as Response;
|
} 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -3732,6 +3838,59 @@ test('renders ops quality as a standalone runtime health page', async () => {
|
|||||||
})
|
})
|
||||||
} as Response;
|
} 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
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.getByText('NATS 桥接 ACK 未确认')).toBeInTheDocument();
|
||||||
expect(screen.getAllByText('NATS 桥接待消费').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('NATS 桥接待消费').length).toBeGreaterThanOrEqual(1);
|
||||||
expect(screen.getByText('桥接处置顺序')).toBeInTheDocument();
|
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/health'), undefined);
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/ops/source-readiness'), undefined);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '复制容量交接' }));
|
fireEvent.click(screen.getByRole('button', { name: '复制容量交接' }));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('【车辆数据中台运维容量交接】'));
|
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('【车辆数据中台容量处置计划】'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('P0 稳定 NATS 到 Kafka 桥接'));
|
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('P0 稳定 NATS 到 Kafka 桥接'));
|
||||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('负责人:数据库 / 应用'));
|
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 () => {
|
test('copies notification text from quality priority queue', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user