feat(platform): summarize service status on dashboard
This commit is contained in:
@@ -19,6 +19,9 @@ func TestHandlerDashboardSummary(t *testing.T) {
|
|||||||
if !strings.Contains(rec.Body.String(), "onlineVehicles") {
|
if !strings.Contains(rec.Body.String(), "onlineVehicles") {
|
||||||
t.Fatalf("response missing onlineVehicles: %s", rec.Body.String())
|
t.Fatalf("response missing onlineVehicles: %s", rec.Body.String())
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "serviceStatuses") || !strings.Contains(rec.Body.String(), "degraded") {
|
||||||
|
t.Fatalf("response missing vehicle service status distribution: %s", rec.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandlerVehicles(t *testing.T) {
|
func TestHandlerVehicles(t *testing.T) {
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ func (m *MockStore) DashboardSummary(context.Context) (DashboardSummary, error)
|
|||||||
{Protocol: "JT808", Online: 73, Total: 318},
|
{Protocol: "JT808", Online: 73, Total: 318},
|
||||||
{Protocol: "YUTONG_MQTT", Online: 1, Total: 8},
|
{Protocol: "YUTONG_MQTT", Online: 1, Total: 8},
|
||||||
},
|
},
|
||||||
|
ServiceStatuses: []ServiceStatusStat{
|
||||||
|
{Status: "healthy", Title: "服务正常", Count: 2},
|
||||||
|
{Status: "degraded", Title: "部分来源离线", Count: 1},
|
||||||
|
{Status: "offline", Title: "车辆离线", Count: 1},
|
||||||
|
{Status: "identity_required", Title: "身份未绑定", Count: 0},
|
||||||
|
},
|
||||||
LinkHealth: []LinkHealth{
|
LinkHealth: []LinkHealth{
|
||||||
{Name: "Redis realtime", Status: "ok", Detail: "在线状态 1 分钟 TTL 正常"},
|
{Name: "Redis realtime", Status: "ok", Detail: "在线状态 1 分钟 TTL 正常"},
|
||||||
{Name: "TDengine raw_frames", Status: "ok", Detail: "最近 5 分钟有写入"},
|
{Name: "TDengine raw_frames", Status: "ok", Detail: "最近 5 分钟有写入"},
|
||||||
|
|||||||
@@ -8,13 +8,14 @@ type Page[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DashboardSummary struct {
|
type DashboardSummary struct {
|
||||||
OnlineVehicles int `json:"onlineVehicles"`
|
OnlineVehicles int `json:"onlineVehicles"`
|
||||||
ActiveToday int `json:"activeToday"`
|
ActiveToday int `json:"activeToday"`
|
||||||
FrameToday int `json:"frameToday"`
|
FrameToday int `json:"frameToday"`
|
||||||
IssueVehicles int `json:"issueVehicles"`
|
IssueVehicles int `json:"issueVehicles"`
|
||||||
KafkaLag *int `json:"kafkaLag"`
|
KafkaLag *int `json:"kafkaLag"`
|
||||||
Protocols []ProtocolStat `json:"protocols"`
|
Protocols []ProtocolStat `json:"protocols"`
|
||||||
LinkHealth []LinkHealth `json:"linkHealth"`
|
ServiceStatuses []ServiceStatusStat `json:"serviceStatuses"`
|
||||||
|
LinkHealth []LinkHealth `json:"linkHealth"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProtocolStat struct {
|
type ProtocolStat struct {
|
||||||
@@ -23,6 +24,12 @@ type ProtocolStat struct {
|
|||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceStatusStat struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
type LinkHealth struct {
|
type LinkHealth struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ func (s *ProductionStore) DashboardSummary(ctx context.Context) (DashboardSummar
|
|||||||
return DashboardSummary{}, err
|
return DashboardSummary{}, err
|
||||||
}
|
}
|
||||||
summary.Protocols = protocols
|
summary.Protocols = protocols
|
||||||
|
serviceStatuses, err := s.serviceStatusStats(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return DashboardSummary{}, err
|
||||||
|
}
|
||||||
|
summary.ServiceStatuses = serviceStatuses
|
||||||
health, err := s.OpsHealth(ctx)
|
health, err := s.OpsHealth(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DashboardSummary{}, err
|
return DashboardSummary{}, err
|
||||||
@@ -69,6 +74,27 @@ func (s *ProductionStore) DashboardSummary(ctx context.Context) (DashboardSummar
|
|||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ProductionStore) serviceStatusStats(ctx context.Context) ([]ServiceStatusStat, error) {
|
||||||
|
definitions := []ServiceStatusStat{
|
||||||
|
{Status: "healthy", Title: "服务正常"},
|
||||||
|
{Status: "degraded", Title: "部分来源离线"},
|
||||||
|
{Status: "offline", Title: "车辆离线"},
|
||||||
|
{Status: "identity_required", Title: "身份未绑定"},
|
||||||
|
}
|
||||||
|
out := make([]ServiceStatusStat, 0, len(definitions))
|
||||||
|
for _, definition := range definitions {
|
||||||
|
query := url.Values{"serviceStatus": {definition.Status}, "limit": {"1"}}
|
||||||
|
built := buildVehicleCoverageSQL(query)
|
||||||
|
count := 0
|
||||||
|
if err := s.db.QueryRowContext(ctx, built.CountText, built.CountArgs...).Scan(&count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
definition.Count = count
|
||||||
|
out = append(out, definition)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ProductionStore) frameToday(ctx context.Context, now time.Time) (int, error) {
|
func (s *ProductionStore) frameToday(ctx context.Context, now time.Time) (int, error) {
|
||||||
if s.tdengine == nil {
|
if s.tdengine == nil {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
|
|||||||
@@ -23,9 +23,16 @@ export interface DashboardSummary {
|
|||||||
issueVehicles: number;
|
issueVehicles: number;
|
||||||
kafkaLag: number | null;
|
kafkaLag: number | null;
|
||||||
protocols: ProtocolStat[];
|
protocols: ProtocolStat[];
|
||||||
|
serviceStatuses: ServiceStatusStat[];
|
||||||
linkHealth: LinkHealth[];
|
linkHealth: LinkHealth[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceStatusStat {
|
||||||
|
status: string;
|
||||||
|
title: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface VehicleRow {
|
export interface VehicleRow {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: string;
|
plate: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, Typography } from '@douyinfe/semi-ui';
|
import { Button, Card, Col, Form, Row, Select, Space, Spin, Table, Tag, Toast, Typography } 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 { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, VehicleCoverageRow, VehicleRealtimeRow } from '../api/types';
|
import type { DashboardSummary, LinkHealth, ProtocolStat, QualityIssueRow, ServiceStatusStat, VehicleCoverageRow, VehicleRealtimeRow } from '../api/types';
|
||||||
import { PageHeader } from '../components/PageHeader';
|
import { PageHeader } from '../components/PageHeader';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
import { qualityIssueVehicleLookup } from '../domain/vehicleLookup';
|
||||||
@@ -12,6 +12,13 @@ const statusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
|||||||
error: 'red'
|
error: 'red'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const serviceStatusColor: Record<string, 'green' | 'orange' | 'red' | 'grey'> = {
|
||||||
|
healthy: 'green',
|
||||||
|
degraded: 'orange',
|
||||||
|
offline: 'red',
|
||||||
|
identity_required: 'orange'
|
||||||
|
};
|
||||||
|
|
||||||
function formatLag(value?: number | null) {
|
function formatLag(value?: number | null) {
|
||||||
return value == null ? '未接入' : value.toLocaleString();
|
return value == null ? '未接入' : value.toLocaleString();
|
||||||
}
|
}
|
||||||
@@ -74,7 +81,22 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={14}>
|
<Col span={8}>
|
||||||
|
<Card title="车辆服务状态" bordered>
|
||||||
|
<Table
|
||||||
|
pagination={false}
|
||||||
|
dataSource={summary?.serviceStatuses ?? []}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
render: (_: unknown, row: ServiceStatusStat) => <Tag color={serviceStatusColor[row.status] ?? 'grey'}>{row.title}</Tag>
|
||||||
|
},
|
||||||
|
{ title: '车辆数', dataIndex: 'count' }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
<Card title="数据来源在线分布" bordered>
|
<Card title="数据来源在线分布" bordered>
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
@@ -91,7 +113,7 @@ export function Dashboard({ onOpenVehicle, onOpenQuality }: { onOpenVehicle: (vi
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={10}>
|
<Col span={8}>
|
||||||
<Card title="链路健康" bordered>
|
<Card title="链路健康" bordered>
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
|||||||
@@ -14,6 +14,49 @@ test('renders vehicle platform shell', () => {
|
|||||||
expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1);
|
expect(screen.getAllByText('总览工作台').length).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('shows vehicle service status distribution on dashboard', async () => {
|
||||||
|
window.history.replaceState(null, '', '/#/dashboard');
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||||
|
const path = String(input);
|
||||||
|
if (path.includes('/api/dashboard/summary')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
onlineVehicles: 3,
|
||||||
|
activeToday: 4,
|
||||||
|
frameToday: 1286320,
|
||||||
|
issueVehicles: 7,
|
||||||
|
kafkaLag: 0,
|
||||||
|
protocols: [],
|
||||||
|
serviceStatuses: [
|
||||||
|
{ status: 'healthy', title: '服务正常', count: 12 },
|
||||||
|
{ status: 'degraded', title: '部分来源离线', count: 39 }
|
||||||
|
],
|
||||||
|
linkHealth: []
|
||||||
|
},
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
data: { items: [], total: 0, limit: 10, offset: 0 },
|
||||||
|
traceId: 'trace-test',
|
||||||
|
timestamp: 1783094400000
|
||||||
|
})
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('车辆服务状态')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('部分来源离线')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('39')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test('opens vehicle detail from shareable hash', async () => {
|
test('opens vehicle detail from shareable hash', async () => {
|
||||||
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ If a keyword cannot be resolved to a VIN, data APIs must not fabricate a VIN. Th
|
|||||||
|
|
||||||
## Core Query APIs
|
## Core Query APIs
|
||||||
|
|
||||||
|
### Dashboard Summary
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/dashboard/summary
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns vehicle service KPIs, source distribution, vehicle service status distribution, link health, and realtime backlog.
|
||||||
|
|
||||||
### Vehicle Service
|
### Vehicle Service
|
||||||
|
|
||||||
```http
|
```http
|
||||||
|
|||||||
Reference in New Issue
Block a user