diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index 4eb8471..5944720 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -176,6 +176,14 @@ func buildScreenStatusOverview(store playerStatusStore, query url.Values) (scree return screenStatusOverview{}, errInvalidStale } + wantDerivedState := strings.TrimSpace(query.Get("derived_state")) + switch wantDerivedState { + case "", "online", "degraded", "offline": + // valid + default: + return screenStatusOverview{}, errInvalidDerivedState + } + updatedSince, err := parseOptionalRFC3339(query.Get("updated_since")) if err != nil { return screenStatusOverview{}, errInvalidUpdatedSince @@ -221,6 +229,9 @@ func buildScreenStatusOverview(store playerStatusStore, query url.Values) (scree continue } } + if wantDerivedState != "" && records[i].DerivedState != wantDerivedState { + continue + } overview.Screens = append(overview.Screens, records[i]) } @@ -246,6 +257,7 @@ var ( errInvalidLimit = errors.New("invalid limit") errInvalidServerConnectivity = errors.New("invalid server_connectivity") errInvalidStale = errors.New("invalid stale") + errInvalidDerivedState = errors.New("invalid derived_state") ) // overviewQueryErrorCode returns the machine-readable error code for query @@ -260,6 +272,8 @@ func overviewQueryErrorCode(err error) string { return "invalid_server_connectivity" case errInvalidStale: return "invalid_stale" + case errInvalidDerivedState: + return "invalid_derived_state" default: return "invalid_query" } @@ -278,6 +292,8 @@ func overviewQueryErrorMessage(err error) string { return "server_connectivity muss online, offline, degraded oder unknown sein" case errInvalidStale: return "stale muss true oder false sein" + case errInvalidDerivedState: + return "derived_state muss online, degraded oder offline sein" default: return "ungueltige Query-Parameter" } diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go index eb90963..8ab7dbb 100644 --- a/server/backend/internal/httpapi/playerstatus_test.go +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -628,6 +628,47 @@ func TestHandleListLatestPlayerStatusesScreenIDFilterIsCaseInsensitive(t *testin } } +func TestHandleListLatestPlayerStatusesFiltersByDerivedState(t *testing.T) { + store := newInMemoryPlayerStatusStore() + store.now = func() time.Time { + return time.Date(2026, 3, 22, 16, 10, 0, 0, time.UTC) + } + store.Save(playerStatusRecord{ScreenID: "screen-online", Timestamp: "2026-03-22T16:09:30Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:09:30Z", HeartbeatEverySeconds: 30}) + store.Save(playerStatusRecord{ScreenID: "screen-offline", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "offline", ReceivedAt: "2026-03-22T16:00:00Z", HeartbeatEverySeconds: 30}) + store.Save(playerStatusRecord{ScreenID: "screen-degraded", Timestamp: "2026-03-22T16:09:00Z", Status: "running", ServerConnectivity: "degraded", ReceivedAt: "2026-03-22T16:09:00Z", HeartbeatEverySeconds: 30}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status?derived_state=online", nil) + w := httptest.NewRecorder() + + handleListLatestPlayerStatuses(store)(w, req) + + var response struct { + Screens []playerStatusRecord `json:"screens"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := len(response.Screens), 1; got != want { + t.Fatalf("len(response.Screens) = %d, want %d", got, want) + } + + if got, want := response.Screens[0].ScreenID, "screen-online"; got != want { + t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want) + } +} + +func TestHandleListLatestPlayerStatusesRejectsInvalidDerivedState(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status?derived_state=unknown", nil) + w := httptest.NewRecorder() + + handleListLatestPlayerStatuses(newInMemoryPlayerStatusStore())(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + func TestHandleListLatestPlayerStatusesRejectsInvalidServerConnectivity(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status?server_connectivity=garbage", nil) w := httptest.NewRecorder() diff --git a/server/backend/internal/httpapi/statuspage.go b/server/backend/internal/httpapi/statuspage.go index a88bcd9..d523819 100644 --- a/server/backend/internal/httpapi/statuspage.go +++ b/server/backend/internal/httpapi/statuspage.go @@ -20,6 +20,7 @@ type statusPageData struct { type statusPageFilters struct { ScreenIDFilter string + DerivedState string ServerConnectivity string Stale string UpdatedSince string @@ -525,6 +526,16 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT +
+ + +
+
@@ -793,6 +804,7 @@ func handleScreenDetailPage(store playerStatusStore) http.HandlerFunc { func buildStatusPageData(store playerStatusStore, query url.Values, overview screenStatusOverview) statusPageData { filters := statusPageFilters{ ScreenIDFilter: strings.TrimSpace(query.Get("q")), + DerivedState: strings.TrimSpace(query.Get("derived_state")), ServerConnectivity: strings.TrimSpace(query.Get("server_connectivity")), Stale: strings.TrimSpace(query.Get("stale")), UpdatedSince: strings.TrimSpace(query.Get("updated_since")), @@ -855,6 +867,9 @@ func buildOverviewPath(basePath string, filters statusPageFilters) string { if filters.ScreenIDFilter != "" { query.Set("q", filters.ScreenIDFilter) } + if filters.DerivedState != "" { + query.Set("derived_state", filters.DerivedState) + } if filters.ServerConnectivity != "" { query.Set("server_connectivity", filters.ServerConnectivity) }