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 {
|
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", "realtime", "history", "raw", "mileage", "sources"} {
|
for _, want := range []string{"identity", "realtime", "history", "raw", "mileage", "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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,13 +42,24 @@ type VehicleRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type VehicleDetail struct {
|
type VehicleDetail struct {
|
||||||
VIN string `json:"vin"`
|
VIN string `json:"vin"`
|
||||||
Identity *VehicleRow `json:"identity,omitempty"`
|
Identity *VehicleRow `json:"identity,omitempty"`
|
||||||
Sources []string `json:"sources"`
|
Sources []string `json:"sources"`
|
||||||
Realtime []RealtimeLocationRow `json:"realtime"`
|
SourceStatus []VehicleSourceStatus `json:"sourceStatus"`
|
||||||
History Page[HistoryLocationRow] `json:"history"`
|
Realtime []RealtimeLocationRow `json:"realtime"`
|
||||||
Raw Page[RawFrameRow] `json:"raw"`
|
History Page[HistoryLocationRow] `json:"history"`
|
||||||
Mileage Page[DailyMileageRow] `json:"mileage"`
|
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 {
|
type RealtimeLocationRow struct {
|
||||||
|
|||||||
@@ -85,17 +85,44 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return VehicleDetail{}, err
|
return VehicleDetail{}, err
|
||||||
}
|
}
|
||||||
|
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
|
||||||
|
sourceStatus = s.enrichVehicleSourceStatus(ctx, resolvedVIN, sourceStatus)
|
||||||
return VehicleDetail{
|
return VehicleDetail{
|
||||||
VIN: resolvedVIN,
|
VIN: resolvedVIN,
|
||||||
Identity: identity,
|
Identity: identity,
|
||||||
Sources: vehicleSources(vehicles.Items, realtime.Items, raw.Items, mileage.Items),
|
Sources: sourceNames(sourceStatus),
|
||||||
Realtime: realtime.Items,
|
SourceStatus: sourceStatus,
|
||||||
History: history,
|
Realtime: realtime.Items,
|
||||||
Raw: raw,
|
History: history,
|
||||||
Mileage: mileage,
|
Raw: raw,
|
||||||
|
Mileage: mileage,
|
||||||
}, nil
|
}, 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 {
|
func resolveVehicleIdentity(keyword string, vehicles []VehicleRow) *VehicleRow {
|
||||||
keyword = strings.TrimSpace(keyword)
|
keyword = strings.TrimSpace(keyword)
|
||||||
if keyword == "" {
|
if keyword == "" {
|
||||||
@@ -144,31 +171,83 @@ func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
|
|||||||
return s.store.OpsHealth(ctx)
|
return s.store.OpsHealth(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func vehicleSources(vehicles []VehicleRow, realtime []RealtimeLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []string {
|
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
|
||||||
seen := map[string]struct{}{}
|
statusByProtocol := map[string]*VehicleSourceStatus{}
|
||||||
add := func(value string) {
|
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)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[value] = struct{}{}
|
if current.LastSeen == "" || value > current.LastSeen {
|
||||||
|
current.LastSeen = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, row := range vehicles {
|
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 {
|
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 {
|
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 {
|
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))
|
out := make([]VehicleSourceStatus, 0, len(statusByProtocol))
|
||||||
for source := range seen {
|
for _, current := range statusByProtocol {
|
||||||
out = append(out, source)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,23 @@ export interface VehicleDetail {
|
|||||||
vin: string;
|
vin: string;
|
||||||
identity?: VehicleRow;
|
identity?: VehicleRow;
|
||||||
sources: string[];
|
sources: string[];
|
||||||
|
sourceStatus: VehicleSourceStatus[];
|
||||||
realtime: RealtimeLocationRow[];
|
realtime: RealtimeLocationRow[];
|
||||||
history: Page<HistoryLocationRow>;
|
history: Page<HistoryLocationRow>;
|
||||||
raw: Page<RawFrameRow>;
|
raw: Page<RawFrameRow>;
|
||||||
mileage: Page<DailyMileageRow>;
|
mileage: Page<DailyMileageRow>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VehicleSourceStatus {
|
||||||
|
protocol: string;
|
||||||
|
online: boolean;
|
||||||
|
lastSeen: string;
|
||||||
|
hasRealtime: boolean;
|
||||||
|
hasHistory: boolean;
|
||||||
|
hasRaw: boolean;
|
||||||
|
hasMileage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RealtimeLocationRow {
|
export interface RealtimeLocationRow {
|
||||||
vin: string;
|
vin: string;
|
||||||
plate: 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 { IconRefresh, IconSearch } from '@douyinfe/semi-icons';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { api } from '../api/client';
|
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 { PageHeader } from '../components/PageHeader';
|
||||||
import { StatusTag } from '../components/StatusTag';
|
import { StatusTag } from '../components/StatusTag';
|
||||||
|
|
||||||
@@ -84,6 +84,24 @@ export function VehicleDetail({ vin }: { vin: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 }}>
|
<Card bordered style={{ marginTop: 16 }}>
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tabs.TabPane tab="实时状态" itemKey="realtime">
|
<Tabs.TabPane tab="实时状态" itemKey="realtime">
|
||||||
|
|||||||
Reference in New Issue
Block a user