From cc06b5a7281a82d79628bd13b383dea248f1cf0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesko=20Ansch=C3=BCtz?= Date: Sun, 22 Mar 2026 18:30:49 +0100 Subject: [PATCH] Leite Frische des letzten Player-Status ab Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- DEVELOPMENT.md | 1 + docs/PLAYER-STATUS-HTTP.md | 7 +++ .../backend/internal/httpapi/playerstatus.go | 17 +++++++ .../internal/httpapi/playerstatus_store.go | 21 ++++++++- .../internal/httpapi/playerstatus_test.go | 47 +++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 525c386..9d60a21 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -196,6 +196,7 @@ Ergaenzt seit dem ersten Geruest: - Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt - erster `POST /api/v1/player/status`-Endpunkt im Backend - 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 - 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 e8b8932..b5969ac 100644 --- a/docs/PLAYER-STATUS-HTTP.md +++ b/docs/PLAYER-STATUS-HTTP.md @@ -72,6 +72,13 @@ Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit de Der aktuell zurueckgelieferte Datensatz enthaelt damit sowohl den Lifecycle-Status (`status`) als auch den vom Agenten lokal abgeleiteten Reachability-Zustand (`server_connectivity`). +Zusaetzlich fuegt das Backend im Read-Pfad derzeit hinzu: + +- `received_at` als serverseitigen Annahmezeitpunkt des letzten gueltigen Reports +- `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. + ## Abgrenzung Noch nicht Teil dieser Stufe: diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index af98b46..bb5be4f 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -6,6 +6,8 @@ import ( "time" ) +const staleThreshold = 2 * time.Minute + type playerStatusRequest struct { ScreenID string `json:"screen_id"` Timestamp string `json:"ts"` @@ -88,6 +90,8 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc { return } + record.Stale = isStale(record, store.Now()) + writeJSON(w, http.StatusOK, record) } } @@ -100,3 +104,16 @@ func validateOptionalRFC3339(value string) error { _, err := time.Parse(time.RFC3339, value) return err } + +func isStale(record playerStatusRecord, now time.Time) bool { + if strings.TrimSpace(record.ReceivedAt) == "" { + return false + } + + receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt) + if err != nil { + return false + } + + return now.Sub(receivedAt) > staleThreshold +} diff --git a/server/backend/internal/httpapi/playerstatus_store.go b/server/backend/internal/httpapi/playerstatus_store.go index 1291347..9648772 100644 --- a/server/backend/internal/httpapi/playerstatus_store.go +++ b/server/backend/internal/httpapi/playerstatus_store.go @@ -1,12 +1,17 @@ package httpapi -import "sync" +import ( + "sync" + "time" +) type playerStatusRecord struct { ScreenID string `json:"screen_id"` Timestamp string `json:"ts"` Status string `json:"status"` ServerConnectivity string `json:"server_connectivity,omitempty"` + ReceivedAt string `json:"received_at,omitempty"` + Stale bool `json:"stale,omitempty"` ServerURL string `json:"server_url,omitempty"` MQTTBroker string `json:"mqtt_broker,omitempty"` HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"` @@ -17,15 +22,17 @@ type playerStatusRecord struct { type playerStatusStore interface { Save(record playerStatusRecord) Get(screenID string) (playerStatusRecord, bool) + Now() time.Time } type inMemoryPlayerStatusStore struct { mu sync.RWMutex records map[string]playerStatusRecord + now func() time.Time } func newInMemoryPlayerStatusStore() *inMemoryPlayerStatusStore { - return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord)} + return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord), now: time.Now} } func NewPlayerStatusStore() playerStatusStore { @@ -35,6 +42,9 @@ func NewPlayerStatusStore() playerStatusStore { func (s *inMemoryPlayerStatusStore) Save(record playerStatusRecord) { s.mu.Lock() defer s.mu.Unlock() + if s.now != nil && record.ReceivedAt == "" { + record.ReceivedAt = s.now().Format(time.RFC3339) + } s.records[record.ScreenID] = record } @@ -44,3 +54,10 @@ func (s *inMemoryPlayerStatusStore) Get(screenID string) (playerStatusRecord, bo record, ok := s.records[screenID] return record, ok } + +func (s *inMemoryPlayerStatusStore) Now() time.Time { + if s.now == nil { + return time.Now() + } + return s.now() +} diff --git a/server/backend/internal/httpapi/playerstatus_test.go b/server/backend/internal/httpapi/playerstatus_test.go index 9a7e920..1f7b7f7 100644 --- a/server/backend/internal/httpapi/playerstatus_test.go +++ b/server/backend/internal/httpapi/playerstatus_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" ) func TestHandlePlayerStatusAccepted(t *testing.T) { @@ -55,6 +56,10 @@ func TestHandlePlayerStatusAccepted(t *testing.T) { if got, want := stored.ServerConnectivity, "online"; got != want { t.Fatalf("stored.ServerConnectivity = %q, want %q", got, want) } + + if stored.ReceivedAt == "" { + t.Fatal("stored.ReceivedAt = empty, want server receive timestamp") + } } func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) { @@ -172,11 +177,15 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) { func TestHandleGetLatestPlayerStatus(t *testing.T) { store := newInMemoryPlayerStatusStore() + store.now = func() time.Time { + return time.Date(2026, 3, 22, 16, 1, 0, 0, time.UTC) + } store.Save(playerStatusRecord{ ScreenID: "info01-dev", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "degraded", + ReceivedAt: "2026-03-22T16:00:05Z", ServerURL: "http://127.0.0.1:8080", MQTTBroker: "tcp://127.0.0.1:1883", HeartbeatEverySeconds: 30, @@ -206,6 +215,44 @@ func TestHandleGetLatestPlayerStatus(t *testing.T) { if got, want := response.ServerConnectivity, "degraded"; got != want { t.Fatalf("response.ServerConnectivity = %q, want %q", got, want) } + + if got, want := response.Stale, false; got != want { + t.Fatalf("response.Stale = %v, want %v", got, want) + } +} + +func TestHandleGetLatestPlayerStatusMarksStaleRecords(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: "stale-screen", + Timestamp: "2026-03-22T16:00:00Z", + Status: "running", + ServerConnectivity: "offline", + ReceivedAt: "2026-03-22T16:00:00Z", + HeartbeatEverySeconds: 30, + }) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/screens/stale-screen/status", nil) + req.SetPathValue("screenId", "stale-screen") + w := httptest.NewRecorder() + + handleGetLatestPlayerStatus(store)(w, req) + + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + + var response playerStatusRecord + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if got, want := response.Stale, true; got != want { + t.Fatalf("response.Stale = %v, want %v", got, want) + } } func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) {