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 465ab42e..fbeebf79 100644
--- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go
+++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go
@@ -49,6 +49,22 @@ func TestHandlerVehicleCoverage(t *testing.T) {
}
}
+func TestHandlerVehicleCoverageFiltersServiceStatus(t *testing.T) {
+ handler := NewHandler(NewService(NewMockStore()))
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/api/vehicles/coverage?limit=10&serviceStatus=degraded", nil)
+ handler.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "LB9A32A24R0LS1426") {
+ t.Fatalf("degraded coverage should include partially online multi-source vehicle: %s", rec.Body.String())
+ }
+ if strings.Contains(rec.Body.String(), "LMRKH9AC2R1004087") {
+ t.Fatalf("degraded coverage should exclude healthy single-source vehicle: %s", rec.Body.String())
+ }
+}
+
func TestHandlerVehicleDetail(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
rec := httptest.NewRecorder()
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 f5faff4c..66c3fa3d 100644
--- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go
+++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go
@@ -427,9 +427,23 @@ func keepCoverageRow(row VehicleCoverageRow, query url.Values) bool {
}
switch strings.TrimSpace(query.Get("bindingStatus")) {
case "bound":
- return row.BindingStatus == "bound"
+ if row.BindingStatus != "bound" {
+ return false
+ }
case "unbound":
- return row.BindingStatus == "unbound"
+ if row.BindingStatus != "unbound" {
+ return false
+ }
+ }
+ switch strings.TrimSpace(query.Get("serviceStatus")) {
+ case "identity_required":
+ return row.BindingStatus != "bound"
+ case "offline":
+ return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount == 0
+ case "degraded":
+ return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount > 0 && row.OnlineSourceCount < row.SourceCount
+ case "healthy":
+ return row.BindingStatus == "bound" && row.SourceCount > 0 && row.OnlineSourceCount == row.SourceCount
default:
return true
}
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 77c58f7e..82764a70 100644
--- a/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go
+++ b/vehicle-data-platform/apps/api/internal/platform/mysql_queries.go
@@ -79,6 +79,23 @@ func buildVehicleCoverageSQL(query url.Values) SQLQuery {
case "unbound":
having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
}
+ switch strings.TrimSpace(query.Get("serviceStatus")) {
+ case "identity_required":
+ having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 0")
+ case "offline":
+ having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
+ having = append(having, "COUNT(DISTINCT s.protocol) > 0")
+ having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = 0")
+ case "degraded":
+ having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
+ having = append(having, "COUNT(DISTINCT s.protocol) > 0")
+ having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0")
+ having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol)")
+ case "healthy":
+ having = append(having, "MAX(CASE WHEN b.vin IS NOT NULL AND b.vin <> '' THEN 1 ELSE 0 END) = 1")
+ having = append(having, "COUNT(DISTINCT s.protocol) > 0")
+ having = append(having, "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) = COUNT(DISTINCT s.protocol)")
+ }
countArgs := append([]any(nil), args...)
args = append(args, limit, offset)
havingSQL := ""
diff --git a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go
index 0691a481..a48aa34e 100644
--- a/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go
+++ b/vehicle-data-platform/apps/api/internal/platform/query_builders_test.go
@@ -48,6 +48,24 @@ func TestBuildVehicleCoverageSQL(t *testing.T) {
}
}
+func TestBuildVehicleCoverageSQLFiltersServiceStatus(t *testing.T) {
+ query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
+ built := buildVehicleCoverageSQL(query)
+ for _, want := range []string{
+ "HAVING",
+ "COUNT(DISTINCT s.protocol) > 0",
+ "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) > 0",
+ "COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) < COUNT(DISTINCT s.protocol)",
+ } {
+ if !strings.Contains(built.Text, want) {
+ t.Fatalf("SQL missing degraded service status predicate %q: %s", want, built.Text)
+ }
+ }
+ if !strings.Contains(built.CountText, "vehicle_coverage_count") || !strings.Contains(built.CountText, "HAVING") {
+ t.Fatalf("count SQL should include service status HAVING: %s", built.CountText)
+ }
+}
+
func TestBuildRealtimeLocationSQL(t *testing.T) {
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "limit": {"10"}, "offset": {"20"}}
built := buildRealtimeLocationSQL(query)
diff --git a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
index 4659a463..229fd281 100644
--- a/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
+++ b/vehicle-data-platform/apps/web/src/pages/Vehicles.tsx
@@ -36,6 +36,7 @@ export function Vehicles({ onOpenVehicle }: { onOpenVehicle: (vin: string) => vo
if (values?.coverage) params.set('coverage', values.coverage);
if (values?.online) params.set('online', values.online);
if (values?.bindingStatus) params.set('bindingStatus', values.bindingStatus);
+ if (values?.serviceStatus) params.set('serviceStatus', values.serviceStatus);
api.vehicleCoverage(params)
.then((nextPage) => {
setRows(nextPage.items);
@@ -98,6 +99,12 @@ export function Vehicles({ onOpenVehicle }: { onOpenVehicle: (vin: string) => vo