feat(platform): summarize vehicle source status
This commit is contained in:
@@ -41,7 +41,7 @@ func TestHandlerVehicleDetail(t *testing.T) {
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "sources"} {
|
||||
for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "sources", "sourceStatus"} {
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Fatalf("response missing %q: %s", want, rec.Body.String())
|
||||
}
|
||||
|
||||
@@ -42,13 +42,24 @@ type VehicleRow struct {
|
||||
}
|
||||
|
||||
type VehicleDetail struct {
|
||||
VIN string `json:"vin"`
|
||||
Identity *VehicleRow `json:"identity,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||
History Page[HistoryLocationRow] `json:"history"`
|
||||
Raw Page[RawFrameRow] `json:"raw"`
|
||||
Mileage Page[DailyMileageRow] `json:"mileage"`
|
||||
VIN string `json:"vin"`
|
||||
Identity *VehicleRow `json:"identity,omitempty"`
|
||||
Sources []string `json:"sources"`
|
||||
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||
History Page[HistoryLocationRow] `json:"history"`
|
||||
Raw Page[RawFrameRow] `json:"raw"`
|
||||
Mileage Page[DailyMileageRow] `json:"mileage"`
|
||||
}
|
||||
|
||||
type VehicleSourceStatus struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Online bool `json:"online"`
|
||||
LastSeen string `json:"lastSeen"`
|
||||
HasRealtime bool `json:"hasRealtime"`
|
||||
HasHistory bool `json:"hasHistory"`
|
||||
HasRaw bool `json:"hasRaw"`
|
||||
HasMileage bool `json:"hasMileage"`
|
||||
}
|
||||
|
||||
type RealtimeLocationRow struct {
|
||||
|
||||
@@ -85,17 +85,44 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
||||
sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus)
|
||||
return VehicleDetail{
|
||||
VIN: resolvedVIN,
|
||||
Identity: identity,
|
||||
Sources: vehicleSources(vehicles.Items, realtime.Items, raw.Items, mileage.Items),
|
||||
Realtime: realtime.Items,
|
||||
History: history,
|
||||
Raw: raw,
|
||||
Mileage: mileage,
|
||||
VIN: resolvedVIN,
|
||||
Identity: identity,
|
||||
Sources: sourceNames(sourceStatus),
|
||||
SourceStatus: sourceStatus,
|
||||
Realtime: realtime.Items,
|
||||
History: history,
|
||||
Raw: raw,
|
||||
Mileage: mileage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
||||
for index := range statuses {
|
||||
protocol := statuses[index].Protocol
|
||||
if protocol == "" {
|
||||
continue
|
||||
}
|
||||
if !statuses[index].HasHistory {
|
||||
query := url.Values{"vin": {vin}, "protocol": {protocol}, "limit": {"1"}}
|
||||
if page, err := s.store.HistoryLocationsFromTDengine(ctx, query); err == nil && len(page.Items) > 0 {
|
||||
statuses[index].HasHistory = true
|
||||
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||
}
|
||||
}
|
||||
if !statuses[index].HasRaw {
|
||||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, Limit: 1})
|
||||
if err == nil && len(page.Items) > 0 {
|
||||
statuses[index].HasRaw = true
|
||||
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses
|
||||
}
|
||||
|
||||
func resolveVehicleIdentity(keyword string, vehicles []VehicleRow) *VehicleRow {
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
@@ -144,31 +171,83 @@ func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
||||
return s.store.OpsHealth(ctx)
|
||||
}
|
||||
|
||||
func vehicleSources(vehicles []VehicleRow, realtime []RealtimeLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []string {
|
||||
seen := map[string]struct{}{}
|
||||
add := func(value string) {
|
||||
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
||||
statusByProtocol := map[string]*VehicleSourceStatus{}
|
||||
ensure := func(protocol string) *VehicleSourceStatus {
|
||||
protocol = strings.TrimSpace(protocol)
|
||||
if protocol == "" {
|
||||
return nil
|
||||
}
|
||||
if current := statusByProtocol[protocol]; current != nil {
|
||||
return current
|
||||
}
|
||||
current := &VehicleSourceStatus{Protocol: protocol}
|
||||
statusByProtocol[protocol] = current
|
||||
return current
|
||||
}
|
||||
setLastSeen := func(current *VehicleSourceStatus, value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
if current.LastSeen == "" || value > current.LastSeen {
|
||||
current.LastSeen = value
|
||||
}
|
||||
}
|
||||
for _, row := range vehicles {
|
||||
add(row.Protocol)
|
||||
if current := ensure(row.Protocol); current != nil {
|
||||
current.Online = current.Online || row.Online
|
||||
setLastSeen(current, row.LastSeen)
|
||||
}
|
||||
}
|
||||
for _, row := range realtime {
|
||||
add(row.Protocol)
|
||||
if current := ensure(row.Protocol); current != nil {
|
||||
current.HasRealtime = true
|
||||
setLastSeen(current, row.LastSeen)
|
||||
}
|
||||
}
|
||||
for _, row := range history {
|
||||
if current := ensure(row.Protocol); current != nil {
|
||||
current.HasHistory = true
|
||||
setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime))
|
||||
}
|
||||
}
|
||||
for _, row := range raw {
|
||||
add(row.Protocol)
|
||||
if current := ensure(row.Protocol); current != nil {
|
||||
current.HasRaw = true
|
||||
setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime))
|
||||
}
|
||||
}
|
||||
for _, row := range mileage {
|
||||
add(row.Source)
|
||||
if current := ensure(row.Source); current != nil {
|
||||
current.HasMileage = true
|
||||
setLastSeen(current, row.Date)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for source := range seen {
|
||||
out = append(out, source)
|
||||
out := make([]VehicleSourceStatus, 0, len(statusByProtocol))
|
||||
for _, current := range statusByProtocol {
|
||||
out = append(out, *current)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Protocol < out[j].Protocol })
|
||||
return out
|
||||
}
|
||||
|
||||
func latestString(left string, right string) string {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
if left == "" {
|
||||
return right
|
||||
}
|
||||
if right == "" || left >= right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func sourceNames(statuses []VehicleSourceStatus) []string {
|
||||
out := make([]string, 0, len(statuses))
|
||||
for _, status := range statuses {
|
||||
out = append(out, status.Protocol)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -42,12 +42,23 @@ export interface VehicleDetail {
|
||||
vin: string;
|
||||
identity?: VehicleRow;
|
||||
sources: string[];
|
||||
sourceStatus: VehicleSourceStatus[];
|
||||
realtime: RealtimeLocationRow[];
|
||||
history: Page<HistoryLocationRow>;
|
||||
raw: Page<RawFrameRow>;
|
||||
mileage: Page<DailyMileageRow>;
|
||||
}
|
||||
|
||||
export interface VehicleSourceStatus {
|
||||
protocol: string;
|
||||
online: boolean;
|
||||
lastSeen: string;
|
||||
hasRealtime: boolean;
|
||||
hasHistory: boolean;
|
||||
hasRaw: boolean;
|
||||
hasMileage: boolean;
|
||||
}
|
||||
|
||||
export interface RealtimeLocationRow {
|
||||
vin: string;
|
||||
plate: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button, Card, Descriptions, Form, Select, Space, Table, Tabs, Tag, Toas
|
||||
import { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { VehicleDetail as VehicleDetailData } from '../api/types';
|
||||
import type { VehicleDetail as VehicleDetailData, VehicleSourceStatus } from '../api/types';
|
||||
import { PageHeader } from '../components/PageHeader';
|
||||
import { StatusTag } from '../components/StatusTag';
|
||||
|
||||
@@ -84,6 +84,24 @@ export function VehicleDetail({ vin }: { vin: string }) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card bordered title="来源状态" style={{ marginTop: 16 }}>
|
||||
<Table
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="protocol"
|
||||
dataSource={detail?.sourceStatus ?? []}
|
||||
columns={[
|
||||
{ title: '来源', dataIndex: 'protocol', width: 130 },
|
||||
{ title: '在线', width: 100, render: (_: unknown, row: VehicleSourceStatus) => <StatusTag status={row.online ? 'ok' : 'offline'} /> },
|
||||
{ title: '最后时间', dataIndex: 'lastSeen', width: 190 },
|
||||
{ title: '实时', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasRealtime ? 'green' : 'grey'}>{row.hasRealtime ? '有' : '无'}</Tag> },
|
||||
{ title: '历史', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasHistory ? 'green' : 'grey'}>{row.hasHistory ? '有' : '无'}</Tag> },
|
||||
{ title: 'RAW', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasRaw ? 'green' : 'grey'}>{row.hasRaw ? '有' : '无'}</Tag> },
|
||||
{ title: '里程', width: 90, render: (_: unknown, row: VehicleSourceStatus) => <Tag color={row.hasMileage ? 'green' : 'grey'}>{row.hasMileage ? '有' : '无'}</Tag> }
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card bordered style={{ marginTop: 16 }}>
|
||||
<Tabs>
|
||||
<Tabs.TabPane tab="实时状态" itemKey="realtime">
|
||||
|
||||
Reference in New Issue
Block a user