From 5a109f95cbab3ed511d873b3d20cd51b91b56ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Sun, 22 Mar 2026 19:08:55 +0100 Subject: [PATCH] Erweitere Statusuebersicht um Zeit- und Mengengrenzen Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- docs/PLAYER-STATUS-HTTP.md | 7 +++ .../backend/internal/httpapi/playerstatus.go | 57 ++++++++++++++++++ .../internal/httpapi/playerstatus_test.go | 60 +++++++++++++++++++ 3 files changed, 124 insertions(+) diff --git a/docs/PLAYER-STATUS-HTTP.md b/docs/PLAYER-STATUS-HTTP.md index d2fad87..aaa853d 100644 --- a/docs/PLAYER-STATUS-HTTP.md +++ b/docs/PLAYER-STATUS-HTTP.md @@ -77,6 +77,13 @@ Zusätzlich zur Write-Route gibt es in dieser Stufe: `GET /api/v1/screens/status` liefert eine kleine Uebersicht aller bisher berichtenden Screens mit ihrem jeweils letzten bekannten Datensatz. Die Rueckgabe wird aktuell fuer Diagnosezwecke priorisiert sortiert: zuerst `offline`, dann `degraded`, dann `online`, innerhalb derselben Gruppe nach `screen_id`. +Aktuell unterstuetzte Query-Parameter fuer die Uebersicht: + +- `server_connectivity=` zum Filtern nach Reachability-Zustand +- `stale=true|false` zum Filtern nach serverseitiger Veraltet-Einschaetzung +- `updated_since=` zum Filtern nach `received_at` +- `limit=` zum Begrenzen der Anzahl zurueckgelieferter Screens + `GET /api/v1/screens/{screenId}/status` liefert den zuletzt akzeptierten Status fuer einen einzelnen Screen zurueck. Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit dem gemeinsamen Fehlerumschlag. diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index 671140e..3f36790 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -3,6 +3,7 @@ package httpapi import ( "net/http" "sort" + "strconv" "strings" "time" ) @@ -132,10 +133,23 @@ func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc { records := store.List() wantConnectivity := strings.TrimSpace(r.URL.Query().Get("server_connectivity")) wantStale := strings.TrimSpace(r.URL.Query().Get("stale")) + updatedSince, err := parseOptionalRFC3339(r.URL.Query().Get("updated_since")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_updated_since", "updated_since ist kein gueltiger RFC3339-Zeitstempel", nil) + return + } + limit, err := parseOptionalPositiveInt(r.URL.Query().Get("limit")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_limit", "limit muss eine positive Ganzzahl sein", nil) + return + } filtered := make([]playerStatusRecord, 0, len(records)) for i := range records { records[i].Stale = isStale(records[i], store.Now()) records[i].DerivedState = deriveState(records[i]) + if updatedSince != nil && !isUpdatedSince(records[i], *updatedSince) { + continue + } if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity { continue } @@ -160,6 +174,10 @@ func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc { return filtered[i].ScreenID < filtered[j].ScreenID }) + if limit > 0 && len(filtered) > limit { + filtered = filtered[:limit] + } + writeJSON(w, http.StatusOK, map[string]any{ "screens": filtered, }) @@ -175,6 +193,32 @@ func validateOptionalRFC3339(value string) error { return err } +func parseOptionalRFC3339(value string) (*time.Time, error) { + if strings.TrimSpace(value) == "" { + return nil, nil + } + + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) + if err != nil { + return nil, err + } + + return &parsed, nil +} + +func parseOptionalPositiveInt(value string) (int, error) { + if strings.TrimSpace(value) == "" { + return 0, nil + } + + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || parsed <= 0 { + return 0, strconv.ErrSyntax + } + + return parsed, nil +} + func isStale(record playerStatusRecord, now time.Time) bool { if strings.TrimSpace(record.ReceivedAt) == "" { return false @@ -209,6 +253,19 @@ func deriveState(record playerStatusRecord) string { return "online" } +func isUpdatedSince(record playerStatusRecord, threshold time.Time) bool { + if strings.TrimSpace(record.ReceivedAt) == "" { + return false + } + + receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt) + if err != nil { + return false + } + + return !receivedAt.Before(threshold) +} + func derivedStatePriority(state string) int { switch state { case "offline": diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go index 9e4efc6..c5ea87d 100644 --- a/server/backend/internal/httpapi/playerstatus_test.go +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -511,3 +511,63 @@ func TestHandleListLatestPlayerStatusesFiltersByConnectivityAndStale(t *testing. t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want) } } + +func TestHandleListLatestPlayerStatusesAppliesLimit(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?limit=2", 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), 2; got != want { + t.Fatalf("len(response.Screens) = %d, want %d", got, want) + } + + if got, want := response.Screens[0].ScreenID, "screen-offline"; got != want { + t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want) + } + + if got, want := response.Screens[1].ScreenID, "screen-degraded"; got != want { + t.Fatalf("response.Screens[1].ScreenID = %q, want %q", got, want) + } +} + +func TestHandleListLatestPlayerStatusesFiltersByUpdatedSince(t *testing.T) { + store := newInMemoryPlayerStatusStore() + store.Save(playerStatusRecord{ScreenID: "screen-old", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:00:00Z", HeartbeatEverySeconds: 30}) + store.Save(playerStatusRecord{ScreenID: "screen-new", Timestamp: "2026-03-22T16:06:00Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:06:00Z", HeartbeatEverySeconds: 30}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/status?updated_since=2026-03-22T16:05:00Z", 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-new"; got != want { + t.Fatalf("response.Screens[0].ScreenID = %q, want %q", got, want) + } +}