Leite Frische des letzten Player-Status ab
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
8f0f06ae25
commit
cc06b5a728
5 changed files with 91 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue