diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 531fa98..a58b26d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -198,6 +198,7 @@ Ergaenzt seit dem ersten Geruest: - letzter bekannter Player-Status wird im Backend pro Screen in-memory vorgehalten und lesbar gemacht - Backend ergaenzt den Read-Pfad um `received_at` und eine einfache `stale`-Ableitung - Backend bietet zusaetzlich eine kleine Uebersicht aller zuletzt meldenden Screens +- Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab - dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides - strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown - erster periodischer HTTP-Status-Reporter im Agent diff --git a/docs/PLAYER-STATUS-HTTP.md b/docs/PLAYER-STATUS-HTTP.md index 317bd34..f113f60 100644 --- a/docs/PLAYER-STATUS-HTTP.md +++ b/docs/PLAYER-STATUS-HTTP.md @@ -24,6 +24,12 @@ Mindestens enthalten: - `ts` - `status` +Fuer die aktuelle Entwicklungsstufe sind zulaessig: + +- `status`: `starting`, `running`, `stopped` +- `server_connectivity`: `unknown`, `online`, `degraded`, `offline` +- `heartbeat_every_seconds`: positive Ganzzahl + Aktuell zusaetzlich enthalten: - `server_connectivity` @@ -81,6 +87,7 @@ Zusaetzlich fuegt das Backend im Read-Pfad derzeit hinzu: - `stale` als einfache serverseitige Einordnung, ob der letzte Report bereits veraltet wirkt `stale` ist aktuell bewusst nur eine kleine Diagnosehilfe fuer die Entwicklungsstufe und noch kein vollstaendiges Online-/Offline-Modell fuer spaetere Admin-Oberflaechen. +Die Schwelle wird derzeit einfach aus dem gemeldeten `heartbeat_every_seconds` abgeleitet: mehr als zwei Intervalle ohne neuen Report gelten als veraltet. ## Abgrenzung diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index bd78116..a7d53db 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -6,7 +6,19 @@ import ( "time" ) -const staleThreshold = 2 * time.Minute +var allowedStatuses = map[string]struct{}{ + "starting": {}, + "running": {}, + "stopped": {}, +} + +var allowedServerConnectivity = map[string]struct{}{ + "": {}, + "unknown": {}, + "online": {}, + "degraded": {}, + "offline": {}, +} type playerStatusRequest struct { ScreenID string `json:"screen_id"` @@ -43,6 +55,22 @@ func handlePlayerStatus(store playerStatusStore) http.HandlerFunc { writeError(w, http.StatusBadRequest, "status_required", "status ist erforderlich", nil) return } + request.Status = strings.TrimSpace(request.Status) + if _, ok := allowedStatuses[request.Status]; !ok { + writeError(w, http.StatusBadRequest, "invalid_status", "status ist ungueltig", nil) + return + } + + request.ServerConnectivity = strings.TrimSpace(request.ServerConnectivity) + if _, ok := allowedServerConnectivity[request.ServerConnectivity]; !ok { + writeError(w, http.StatusBadRequest, "invalid_server_connectivity", "server_connectivity ist ungueltig", nil) + return + } + + if request.HeartbeatEverySeconds <= 0 { + writeError(w, http.StatusBadRequest, "invalid_heartbeat_interval", "heartbeat_every_seconds muss positiv sein", nil) + return + } if err := validateOptionalRFC3339(request.Timestamp); err != nil { writeError(w, http.StatusBadRequest, "invalid_timestamp", "ts ist kein gueltiger RFC3339-Zeitstempel", nil) @@ -129,5 +157,14 @@ func isStale(record playerStatusRecord, now time.Time) bool { return false } - return now.Sub(receivedAt) > staleThreshold + threshold := staleThresholdFor(record) + return now.Sub(receivedAt) > threshold +} + +func staleThresholdFor(record playerStatusRecord) time.Duration { + if record.HeartbeatEverySeconds > 0 { + return time.Duration(record.HeartbeatEverySeconds*2) * time.Second + } + + return 2 * time.Minute } diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go index 3215521..63d9d93 100644 --- a/server/backend/internal/httpapi/playerstatus_test.go +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -95,7 +95,8 @@ func TestHandlePlayerStatusStoresNormalizedScreenID(t *testing.T) { body := []byte(`{ "screen_id": " info01-dev ", "ts": "2026-03-22T16:00:00Z", - "status": "running" + "status": "running", + "heartbeat_every_seconds": 30 }`) req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) @@ -144,6 +145,59 @@ func TestHandlePlayerStatusRejectsMissingStatus(t *testing.T) { } } +func TestHandlePlayerStatusRejectsUnknownStatus(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z", + "status": "broken" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsUnknownServerConnectivity(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z", + "status": "running", + "server_connectivity": "maybe" + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + +func TestHandlePlayerStatusRejectsNonPositiveHeartbeatInterval(t *testing.T) { + body := []byte(`{ + "screen_id": "info01-dev", + "ts": "2026-03-22T16:00:00Z", + "status": "running", + "heartbeat_every_seconds": 0 + }`) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewReader(body)) + w := httptest.NewRecorder() + + handlePlayerStatus(newInMemoryPlayerStatusStore())(w, req) + + if got, want := w.Code, http.StatusBadRequest; got != want { + t.Fatalf("status = %d, want %d", got, want) + } +} + func TestHandlePlayerStatusRejectsMalformedTimestamps(t *testing.T) { body := []byte(`{ "screen_id": "info01-dev", @@ -277,6 +331,36 @@ func TestHandleGetLatestPlayerStatusMarksStaleRecords(t *testing.T) { } } +func TestHandleGetLatestPlayerStatusUsesHeartbeatIntervalForFreshness(t *testing.T) { + store := newInMemoryPlayerStatusStore() + store.now = func() time.Time { + return time.Date(2026, 3, 22, 16, 3, 30, 0, time.UTC) + } + store.Save(playerStatusRecord{ + ScreenID: "slow-screen", + Timestamp: "2026-03-22T16:00:00Z", + Status: "running", + ServerConnectivity: "online", + ReceivedAt: "2026-03-22T16:00:00Z", + HeartbeatEverySeconds: 120, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/slow-screen/status", nil) + req.SetPathValue("screenId", "slow-screen") + w := httptest.NewRecorder() + + handleGetLatestPlayerStatus(store)(w, req) + + var response playerStatusRecord + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := response.Stale, false; got != want { + t.Fatalf("response.Stale = %v, want %v", got, want) + } +} + func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/missing/status", nil) req.SetPathValue("screenId", "missing") diff --git a/server/backend/internal/httpapi/router_test.go b/server/backend/internal/httpapi/router_test.go index 423d2de..2735b6a 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -139,7 +139,7 @@ func TestRouterMeta(t *testing.T) { } func TestRouterPlayerStatusRoute(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/player/status", bytes.NewBufferString(`{"screen_id":"demo","ts":"2026-03-22T16:00:00Z","status":"running","heartbeat_every_seconds":30}`)) w := httptest.NewRecorder() NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req)