feat(platform): summarize quality issues
This commit is contained in:
@@ -36,6 +36,7 @@ func (h *Handler) routes() {
|
|||||||
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
h.mux.HandleFunc("POST /api/history/raw-frames/query", h.handleRawFramesPost)
|
||||||
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
h.mux.HandleFunc("GET /api/mileage/summary", h.handleMileageSummary)
|
||||||
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
h.mux.HandleFunc("GET /api/mileage/daily", h.handleDailyMileage)
|
||||||
|
h.mux.HandleFunc("GET /api/quality/summary", h.handleQualitySummary)
|
||||||
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
h.mux.HandleFunc("GET /api/quality/issues", h.handleQualityIssues)
|
||||||
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
|
h.mux.HandleFunc("GET /api/ops/health", h.handleOpsHealth)
|
||||||
}
|
}
|
||||||
@@ -122,6 +123,11 @@ func (h *Handler) handleQualityIssues(w http.ResponseWriter, r *http.Request) {
|
|||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleQualitySummary(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, err := h.service.QualitySummary(r.Context(), r.URL.Query())
|
||||||
|
h.write(w, r, data, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) handleOpsHealth(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleOpsHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := h.service.OpsHealth(r.Context())
|
data, err := h.service.OpsHealth(r.Context())
|
||||||
h.write(w, r, data, err)
|
h.write(w, r, data, err)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
for _, want := range []string{"identity", "realtimeSummary", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus", "VIN_MISSING"} {
|
for _, want := range []string{"identity", "realtimeSummary", "realtime", "history", "raw", "mileage", "quality", "sources", "sourceStatus"} {
|
||||||
if !strings.Contains(rec.Body.String(), want) {
|
if !strings.Contains(rec.Body.String(), want) {
|
||||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||||
}
|
}
|
||||||
@@ -113,6 +113,7 @@ func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
|||||||
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
|
{"/api/history/raw-frames?protocol=GB32960&vin=LB9A32A24R0LS1426&limit=1&includeFields=true", "plate"},
|
||||||
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
{"/api/mileage/summary?limit=10", "totalMileageKm"},
|
||||||
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
{"/api/mileage/daily?limit=10", "dailyMileageKm"},
|
||||||
|
{"/api/quality/summary?limit=10", "issueVehicleCount"},
|
||||||
{"/api/quality/issues?limit=10", "issueType"},
|
{"/api/quality/issues?limit=10", "issueType"},
|
||||||
{"/api/ops/health", "linkHealth"},
|
{"/api/ops/health", "linkHealth"},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,10 +275,7 @@ func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
rows := []QualityIssueRow{
|
rows := mockQualityRows()
|
||||||
{VIN: "LB9A32A24R0LS1426", Plate: "粤AG18312", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 曾未命中 binding 表,已通过手机号关联 VIN"},
|
|
||||||
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"},
|
|
||||||
}
|
|
||||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||||
rows = keep(rows, func(row QualityIssueRow) bool { return row.VIN == vin })
|
rows = keep(rows, func(row QualityIssueRow) bool { return row.VIN == vin })
|
||||||
}
|
}
|
||||||
@@ -288,6 +285,49 @@ func (m *MockStore) QualityIssues(_ context.Context, query url.Values) (Page[Qua
|
|||||||
return page(rows, query), nil
|
return page(rows, query), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockStore) QualitySummary(_ context.Context, query url.Values) (QualitySummary, error) {
|
||||||
|
page, _ := m.QualityIssues(context.Background(), query)
|
||||||
|
vehicles := map[string]struct{}{}
|
||||||
|
protocols := map[string]int{}
|
||||||
|
issueTypes := map[string]int{}
|
||||||
|
summary := QualitySummary{IssueRecordCount: page.Total}
|
||||||
|
for _, row := range page.Items {
|
||||||
|
vehicles[firstNonEmpty(row.VIN, row.Plate, row.Detail)] = struct{}{}
|
||||||
|
protocols[row.Protocol]++
|
||||||
|
issueTypes[row.IssueType]++
|
||||||
|
if row.Severity == "error" {
|
||||||
|
summary.ErrorCount++
|
||||||
|
} else {
|
||||||
|
summary.WarningCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.IssueVehicleCount = len(vehicles)
|
||||||
|
summary.Protocols = bucketStats(protocols)
|
||||||
|
summary.IssueTypes = bucketStats(issueTypes)
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mockQualityRows() []QualityIssueRow {
|
||||||
|
return []QualityIssueRow{
|
||||||
|
{VIN: "unknown", Plate: "", Protocol: "JT808", IssueType: "VIN_MISSING", Severity: "warning", LastSeen: "2026-07-03 19:58:00", Detail: "phone 13300000001 未命中 binding 表"},
|
||||||
|
{VIN: "LNXNEGRR7SR318212", Plate: "川AHTWO1", Protocol: "GB32960", IssueType: "LINK_GAP", Severity: "error", LastSeen: "2026-07-03 18:20:00", Detail: "Hyundai 平台近 60 分钟无转发"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bucketStats(values map[string]int) []QualityBucketStat {
|
||||||
|
out := make([]QualityBucketStat, 0, len(values))
|
||||||
|
for name, count := range values {
|
||||||
|
out = append(out, QualityBucketStat{Name: name, Count: count})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if out[i].Count == out[j].Count {
|
||||||
|
return out[i].Name < out[j].Name
|
||||||
|
}
|
||||||
|
return out[i].Count > out[j].Count
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) {
|
func (m *MockStore) OpsHealth(context.Context) (OpsHealth, error) {
|
||||||
summary, _ := m.DashboardSummary(context.Background())
|
summary, _ := m.DashboardSummary(context.Background())
|
||||||
redisOnlineKeys := 92
|
redisOnlineKeys := 92
|
||||||
|
|||||||
@@ -160,6 +160,20 @@ type QualityIssueRow struct {
|
|||||||
Detail string `json:"detail"`
|
Detail string `json:"detail"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QualitySummary struct {
|
||||||
|
IssueVehicleCount int `json:"issueVehicleCount"`
|
||||||
|
IssueRecordCount int `json:"issueRecordCount"`
|
||||||
|
ErrorCount int `json:"errorCount"`
|
||||||
|
WarningCount int `json:"warningCount"`
|
||||||
|
Protocols []QualityBucketStat `json:"protocols"`
|
||||||
|
IssueTypes []QualityBucketStat `json:"issueTypes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QualityBucketStat struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
type OpsHealth struct {
|
type OpsHealth struct {
|
||||||
LinkHealth []LinkHealth `json:"linkHealth"`
|
LinkHealth []LinkHealth `json:"linkHealth"`
|
||||||
KafkaLag *int `json:"kafkaLag"`
|
KafkaLag *int `json:"kafkaLag"`
|
||||||
|
|||||||
@@ -460,8 +460,7 @@ func (s *ProductionStore) DailyMileage(ctx context.Context, query url.Values) (P
|
|||||||
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
return Page[DailyMileageRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
func buildQualityIssueWhere(query url.Values) (string, []any) {
|
||||||
limit, offset := buildLimitOffset(query)
|
|
||||||
where := []string{"(r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL)"}
|
where := []string{"(r.vin = '' OR r.vin = 'unknown' OR r.vin IS NULL)"}
|
||||||
args := []any{}
|
args := []any{}
|
||||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||||
@@ -477,9 +476,26 @@ func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (
|
|||||||
like := "%" + keyword + "%"
|
like := "%" + keyword + "%"
|
||||||
args = append(args, like, like, like)
|
args = append(args, like, like, like)
|
||||||
}
|
}
|
||||||
|
return `FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone WHERE ` + strings.Join(where, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProductionStore) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
||||||
|
fromSQL, args := buildQualityIssueWhere(query)
|
||||||
|
var summary QualitySummary
|
||||||
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT COALESCE(NULLIF(b.vin, ''), r.phone)) AS issue_vehicle_count, COUNT(*) AS issue_record_count `+fromSQL, args...).Scan(&summary.IssueVehicleCount, &summary.IssueRecordCount); err != nil {
|
||||||
|
return QualitySummary{}, err
|
||||||
|
}
|
||||||
|
summary.WarningCount = summary.IssueRecordCount
|
||||||
|
summary.Protocols = []QualityBucketStat{{Name: "JT808", Count: summary.IssueRecordCount}}
|
||||||
|
summary.IssueTypes = []QualityBucketStat{{Name: "VIN_MISSING", Count: summary.IssueRecordCount}}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ProductionStore) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
|
limit, offset := buildLimitOffset(query)
|
||||||
|
fromSQL, args := buildQualityIssueWhere(query)
|
||||||
countArgs := append([]any(nil), args...)
|
countArgs := append([]any(nil), args...)
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
fromSQL := `FROM jt808_registration r LEFT JOIN vehicle_identity_binding b ON b.phone = r.phone WHERE ` + strings.Join(where, " AND ")
|
|
||||||
total := 0
|
total := 0
|
||||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) `+fromSQL, countArgs...).Scan(&total); err != nil {
|
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) `+fromSQL, countArgs...).Scan(&total); err != nil {
|
||||||
return Page[QualityIssueRow]{}, err
|
return Page[QualityIssueRow]{}, err
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type Store interface {
|
|||||||
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
||||||
MileageSummary(context.Context, url.Values) (MileageSummary, error)
|
MileageSummary(context.Context, url.Values) (MileageSummary, error)
|
||||||
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
|
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
|
||||||
|
QualitySummary(context.Context, url.Values) (QualitySummary, error)
|
||||||
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
|
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
|
||||||
OpsHealth(context.Context) (OpsHealth, error)
|
OpsHealth(context.Context) (OpsHealth, error)
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
|||||||
return s.store.DailyMileage(ctx, query)
|
return s.store.DailyMileage(ctx, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
|
||||||
|
return s.store.QualitySummary(ctx, query)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
|
||||||
return s.store.QualityIssues(ctx, query)
|
return s.store.QualityIssues(ctx, query)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
MileageSummary,
|
MileageSummary,
|
||||||
OpsHealth,
|
OpsHealth,
|
||||||
Page,
|
Page,
|
||||||
|
QualitySummary,
|
||||||
QualityIssueRow,
|
QualityIssueRow,
|
||||||
RawFrameRow,
|
RawFrameRow,
|
||||||
RealtimeLocationRow,
|
RealtimeLocationRow,
|
||||||
@@ -35,6 +36,7 @@ export const api = {
|
|||||||
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
rawFrames: (params = new URLSearchParams()) => request<Page<RawFrameRow>>(`/api/history/raw-frames?${params.toString()}`),
|
||||||
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
mileageSummary: (params = new URLSearchParams()) => request<MileageSummary>(`/api/mileage/summary?${params.toString()}`),
|
||||||
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
dailyMileage: (params = new URLSearchParams()) => request<Page<DailyMileageRow>>(`/api/mileage/daily?${params.toString()}`),
|
||||||
|
qualitySummary: (params = new URLSearchParams()) => request<QualitySummary>(`/api/quality/summary?${params.toString()}`),
|
||||||
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
qualityIssues: (params = new URLSearchParams()) => request<Page<QualityIssueRow>>(`/api/quality/issues?${params.toString()}`),
|
||||||
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
opsHealth: () => request<OpsHealth>('/api/ops/health')
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,6 +150,20 @@ export interface QualityIssueRow {
|
|||||||
detail: string;
|
detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QualitySummary {
|
||||||
|
issueVehicleCount: number;
|
||||||
|
issueRecordCount: number;
|
||||||
|
errorCount: number;
|
||||||
|
warningCount: number;
|
||||||
|
protocols: QualityBucketStat[];
|
||||||
|
issueTypes: QualityBucketStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QualityBucketStat {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpsHealth {
|
export interface OpsHealth {
|
||||||
linkHealth: LinkHealth[];
|
linkHealth: LinkHealth[];
|
||||||
kafkaLag: number | null;
|
kafkaLag: number | null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
import { Button, Card, Col, Form, Row, Select, Space, Table, Tag, Toast } from '@douyinfe/semi-ui';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { OpsHealth, QualityIssueRow } from '../api/types';
|
import type { OpsHealth, QualitySummary, QualityIssueRow } 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'> = {
|
||||||
@@ -18,19 +18,37 @@ function storageReadStatus(health: OpsHealth | null) {
|
|||||||
return health?.tdengineWritable && health.mysqlWritable ? 'ok' : 'error';
|
return health?.tdengineWritable && health.mysqlWritable ? 'ok' : 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptySummary: QualitySummary = {
|
||||||
|
issueVehicleCount: 0,
|
||||||
|
issueRecordCount: 0,
|
||||||
|
errorCount: 0,
|
||||||
|
warningCount: 0,
|
||||||
|
protocols: [],
|
||||||
|
issueTypes: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function qualityParams(values: Record<string, string>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (values?.keyword) params.set('keyword', values.keyword);
|
||||||
|
if (values?.protocol) params.set('protocol', values.protocol);
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealth) => void }) {
|
export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealth) => void }) {
|
||||||
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
||||||
|
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
|
||||||
const [health, setHealth] = useState<OpsHealth | null>(null);
|
const [health, setHealth] = useState<OpsHealth | null>(null);
|
||||||
const [loadingIssues, setLoadingIssues] = useState(true);
|
const [loadingIssues, setLoadingIssues] = useState(true);
|
||||||
|
const [loadingSummary, setLoadingSummary] = useState(true);
|
||||||
const [loadingHealth, setLoadingHealth] = useState(true);
|
const [loadingHealth, setLoadingHealth] = useState(true);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
|
|
||||||
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
const loadIssues = (values: Record<string, string> = filters, page = pagination.currentPage, pageSize = pagination.pageSize) => {
|
||||||
setLoadingIssues(true);
|
setLoadingIssues(true);
|
||||||
const params = new URLSearchParams({ limit: String(pageSize), offset: String((page - 1) * pageSize) });
|
const params = qualityParams(values);
|
||||||
if (values?.keyword) params.set('keyword', values.keyword);
|
params.set('limit', String(pageSize));
|
||||||
if (values?.protocol) params.set('protocol', values.protocol);
|
params.set('offset', String((page - 1) * pageSize));
|
||||||
api.qualityIssues(params)
|
api.qualityIssues(params)
|
||||||
.then((nextPage) => {
|
.then((nextPage) => {
|
||||||
setIssues(nextPage.items);
|
setIssues(nextPage.items);
|
||||||
@@ -40,6 +58,14 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt
|
|||||||
.finally(() => setLoadingIssues(false));
|
.finally(() => setLoadingIssues(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadSummary = (values: Record<string, string> = filters) => {
|
||||||
|
setLoadingSummary(true);
|
||||||
|
api.qualitySummary(qualityParams(values))
|
||||||
|
.then(setSummary)
|
||||||
|
.catch((error: Error) => Toast.error(error.message))
|
||||||
|
.finally(() => setLoadingSummary(false));
|
||||||
|
};
|
||||||
|
|
||||||
const loadHealth = () => {
|
const loadHealth = () => {
|
||||||
setLoadingHealth(true);
|
setLoadingHealth(true);
|
||||||
api.opsHealth()
|
api.opsHealth()
|
||||||
@@ -52,6 +78,7 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
loadSummary({});
|
||||||
loadIssues({}, 1, pagination.pageSize);
|
loadIssues({}, 1, pagination.pageSize);
|
||||||
loadHealth();
|
loadHealth();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -59,6 +86,22 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt
|
|||||||
return (
|
return (
|
||||||
<div className="vp-page">
|
<div className="vp-page">
|
||||||
<PageHeader title="数据质量" description="断链、VIN 缺失、字段缺失和链路健康的排查入口" />
|
<PageHeader title="数据质量" description="断链、VIN 缺失、字段缺失和链路健康的排查入口" />
|
||||||
|
<div className="vp-kpi-grid">
|
||||||
|
{[
|
||||||
|
{ label: '问题车辆', value: summary.issueVehicleCount.toLocaleString() },
|
||||||
|
{ label: '问题记录', value: summary.issueRecordCount.toLocaleString() },
|
||||||
|
{ label: '错误 / 警告', value: `${summary.errorCount}/${summary.warningCount}` },
|
||||||
|
{
|
||||||
|
label: '主要问题',
|
||||||
|
value: summary.issueTypes.length > 0 ? `${summary.issueTypes[0].name} ${summary.issueTypes[0].count}` : '-'
|
||||||
|
}
|
||||||
|
].map((item) => (
|
||||||
|
<Card key={item.label} bordered loading={loadingSummary}>
|
||||||
|
<div className="vp-kpi-value">{item.value}</div>
|
||||||
|
<div className="vp-kpi-label">{item.label}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={8}><Card bordered title="Kafka Lag">{formatLag(health?.kafkaLag)}</Card></Col>
|
<Col span={8}><Card bordered title="Kafka Lag">{formatLag(health?.kafkaLag)}</Card></Col>
|
||||||
<Col span={8}><Card bordered title="Redis 在线 Key">{formatLag(health?.redisOnlineKeys)}</Card></Col>
|
<Col span={8}><Card bordered title="Redis 在线 Key">{formatLag(health?.redisOnlineKeys)}</Card></Col>
|
||||||
@@ -72,6 +115,7 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt
|
|||||||
<Form layout="horizontal" onSubmit={(values) => {
|
<Form layout="horizontal" onSubmit={(values) => {
|
||||||
const nextFilters = values as Record<string, string>;
|
const nextFilters = values as Record<string, string>;
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
|
loadSummary(nextFilters);
|
||||||
loadIssues(nextFilters, 1, pagination.pageSize);
|
loadIssues(nextFilters, 1, pagination.pageSize);
|
||||||
}} style={{ marginBottom: 12 }}>
|
}} style={{ marginBottom: 12 }}>
|
||||||
<Form.Input field="keyword" label="关键词" placeholder="手机号 / 来源地址 / 车牌" style={{ width: 240 }} />
|
<Form.Input field="keyword" label="关键词" placeholder="手机号 / 来源地址 / 车牌" style={{ width: 240 }} />
|
||||||
@@ -82,6 +126,7 @@ export function Quality({ onHealthLoaded }: { onHealthLoaded?: (health: OpsHealt
|
|||||||
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
<Button htmlType="submit" theme="solid" type="primary">筛选</Button>
|
||||||
<Button onClick={() => {
|
<Button onClick={() => {
|
||||||
setFilters({});
|
setFilters({});
|
||||||
|
loadSummary({});
|
||||||
loadIssues({}, 1, pagination.pageSize);
|
loadIssues({}, 1, pagination.pageSize);
|
||||||
}}>重置</Button>
|
}}>重置</Button>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
Reference in New Issue
Block a user