diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index a5ec5f65..45880d41 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -35,6 +35,18 @@ func TestHandlerVehicles(t *testing.T) { if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") { t.Fatalf("response missing vehicle: %s", rec.Body.String()) } + var body struct { + Data Page[VehicleRow] `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String()) + } + if len(body.Data.Items) == 0 || body.Data.Items[0].ServiceStatus == nil { + t.Fatalf("vehicle row should include canonical vehicle service status: %s", rec.Body.String()) + } + if body.Data.Items[0].ServiceStatus.Status != "degraded" { + t.Fatalf("vehicle row should expose degraded status, got %+v", body.Data.Items[0].ServiceStatus) + } } func TestHandlerVehicleCoverage(t *testing.T) { diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 2f015dd5..dd387a5a 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -61,9 +61,43 @@ func (m *MockStore) DashboardSummary(context.Context) (DashboardSummary, error) func (m *MockStore) Vehicles(_ context.Context, query url.Values) (Page[VehicleRow], error) { items := filterVehicles(m.vehicles, query) + for index := range items { + items[index].ServiceStatus = m.vehicleRowServiceStatus(items[index]) + } return page(items, query), nil } +func (m *MockStore) vehicleRowServiceStatus(row VehicleRow) *VehicleServiceStatus { + status := VehicleCoverageRow{ + VIN: row.VIN, + Plate: row.Plate, + Phone: row.Phone, + OEM: row.OEM, + LastSeen: row.LastSeen, + BindingStatus: "bound", + } + if strings.TrimSpace(row.VIN) == "" { + status.BindingStatus = "unbound" + } + for _, vehicle := range m.vehicles { + if vehicle.VIN != row.VIN { + continue + } + if !containsString(status.Protocols, vehicle.Protocol) { + status.Protocols = append(status.Protocols, vehicle.Protocol) + status.SourceCount = len(status.Protocols) + } + if vehicle.Online { + status.Online = true + status.OnlineSourceCount++ + } + if vehicle.LastSeen > status.LastSeen { + status.LastSeen = vehicle.LastSeen + } + } + return buildVehicleCoverageServiceStatus(status) +} + func (m *MockStore) VehicleCoverage(_ context.Context, query url.Values) (Page[VehicleCoverageRow], error) { vehicles := filterVehicles(m.vehicles, query) byVIN := map[string]*VehicleCoverageRow{} diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index c3cb42e7..e222be78 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -37,15 +37,16 @@ type LinkHealth struct { } type VehicleRow struct { - VIN string `json:"vin"` - Plate string `json:"plate"` - Phone string `json:"phone"` - OEM string `json:"oem"` - Protocol string `json:"protocol"` - Online bool `json:"online"` - LastSeen string `json:"lastSeen"` - LocationText string `json:"locationText"` - BindingScore int `json:"bindingScore"` + VIN string `json:"vin"` + Plate string `json:"plate"` + Phone string `json:"phone"` + OEM string `json:"oem"` + Protocol string `json:"protocol"` + Online bool `json:"online"` + LastSeen string `json:"lastSeen"` + LocationText string `json:"locationText"` + BindingScore int `json:"bindingScore"` + ServiceStatus *VehicleServiceStatus `json:"serviceStatus,omitempty"` } type VehicleCoverageRow struct { diff --git a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go index 667e6e83..d1d2ec16 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go +++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go @@ -32,13 +32,20 @@ func buildVehicleListSQL(query url.Values) SQLQuery { fromSQL := `FROM vehicle_realtime_snapshot s ` + `LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` + `LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol ` + + `LEFT JOIN (` + + `SELECT vin, COUNT(DISTINCT protocol) AS source_count, ` + + `COUNT(DISTINCT CASE WHEN updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN protocol END) AS online_source_count ` + + `FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> '' GROUP BY vin` + + `) vs ON vs.vin = s.vin ` + `WHERE ` + strings.Join(where, " AND ") return SQLQuery{ Text: `SELECT s.vin, COALESCE(NULLIF(s.plate, ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem, s.protocol, ` + `CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN 1 ELSE 0 END AS online, ` + `COALESCE(DATE_FORMAT(s.updated_at, '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` + `COALESCE(CONCAT(l.longitude, ',', l.latitude), '') AS location_text, ` + - `CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 100 ELSE 60 END AS binding_score ` + + `CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 100 ELSE 60 END AS binding_score, ` + + `COALESCE(vs.source_count, 0) AS source_count, COALESCE(vs.online_source_count, 0) AS online_source_count, ` + + `CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 'bound' ELSE 'unbound' END AS binding_status ` + fromSQL + ` ORDER BY s.updated_at DESC, s.vin ASC, s.protocol ASC LIMIT ? OFFSET ?`, Args: args, CountText: `SELECT COUNT(*) ` + fromSQL, diff --git a/vehicle-data-platform/apps/api/internal/platform/production_store.go b/vehicle-data-platform/apps/api/internal/platform/production_store.go index 1d3e983a..953cfcf4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/production_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/production_store.go @@ -124,10 +124,25 @@ func (s *ProductionStore) Vehicles(ctx context.Context, query url.Values) (Page[ for rows.Next() { var row VehicleRow var online int - if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.Protocol, &online, &row.LastSeen, &row.LocationText, &row.BindingScore); err != nil { + var sourceCount int + var onlineSourceCount int + var bindingStatus string + if err := rows.Scan(&row.VIN, &row.Plate, &row.Phone, &row.OEM, &row.Protocol, &online, &row.LastSeen, &row.LocationText, &row.BindingScore, &sourceCount, &onlineSourceCount, &bindingStatus); err != nil { return Page[VehicleRow]{}, err } row.Online = online == 1 + row.ServiceStatus = buildVehicleCoverageServiceStatus(VehicleCoverageRow{ + VIN: row.VIN, + Plate: row.Plate, + Phone: row.Phone, + OEM: row.OEM, + Protocols: []string{row.Protocol}, + SourceCount: sourceCount, + OnlineSourceCount: onlineSourceCount, + Online: onlineSourceCount > 0, + LastSeen: row.LastSeen, + BindingStatus: bindingStatus, + }) items = append(items, row) } if err := rows.Err(); err != nil { diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index fabb7bb4..14d245ab 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -43,6 +43,7 @@ export interface VehicleRow { lastSeen: string; locationText: string; bindingScore: number; + serviceStatus?: VehicleServiceStatus; } export interface VehicleCoverageRow {