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
|
- Basisendpunkte und `message_wall`-Validierung im Backend testseitig breiter abgedeckt
|
||||||
- erster `POST /api/v1/player/status`-Endpunkt im Backend
|
- erster `POST /api/v1/player/status`-Endpunkt im Backend
|
||||||
- letzter bekannter Player-Status wird im Backend pro Screen in-memory vorgehalten und lesbar gemacht
|
- 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
|
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
|
||||||
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
|
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
|
||||||
- erster periodischer HTTP-Status-Reporter im Agent
|
- 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`).
|
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
|
## Abgrenzung
|
||||||
|
|
||||||
Noch nicht Teil dieser Stufe:
|
Noch nicht Teil dieser Stufe:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const staleThreshold = 2 * time.Minute
|
||||||
|
|
||||||
type playerStatusRequest struct {
|
type playerStatusRequest struct {
|
||||||
ScreenID string `json:"screen_id"`
|
ScreenID string `json:"screen_id"`
|
||||||
Timestamp string `json:"ts"`
|
Timestamp string `json:"ts"`
|
||||||
|
|
@ -88,6 +90,8 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record.Stale = isStale(record, store.Now())
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, record)
|
writeJSON(w, http.StatusOK, record)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -100,3 +104,16 @@ func validateOptionalRFC3339(value string) error {
|
||||||
_, err := time.Parse(time.RFC3339, value)
|
_, err := time.Parse(time.RFC3339, value)
|
||||||
return err
|
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
|
package httpapi
|
||||||
|
|
||||||
import "sync"
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type playerStatusRecord struct {
|
type playerStatusRecord struct {
|
||||||
ScreenID string `json:"screen_id"`
|
ScreenID string `json:"screen_id"`
|
||||||
Timestamp string `json:"ts"`
|
Timestamp string `json:"ts"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
ServerConnectivity string `json:"server_connectivity,omitempty"`
|
ServerConnectivity string `json:"server_connectivity,omitempty"`
|
||||||
|
ReceivedAt string `json:"received_at,omitempty"`
|
||||||
|
Stale bool `json:"stale,omitempty"`
|
||||||
ServerURL string `json:"server_url,omitempty"`
|
ServerURL string `json:"server_url,omitempty"`
|
||||||
MQTTBroker string `json:"mqtt_broker,omitempty"`
|
MQTTBroker string `json:"mqtt_broker,omitempty"`
|
||||||
HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"`
|
HeartbeatEverySeconds int `json:"heartbeat_every_seconds,omitempty"`
|
||||||
|
|
@ -17,15 +22,17 @@ type playerStatusRecord struct {
|
||||||
type playerStatusStore interface {
|
type playerStatusStore interface {
|
||||||
Save(record playerStatusRecord)
|
Save(record playerStatusRecord)
|
||||||
Get(screenID string) (playerStatusRecord, bool)
|
Get(screenID string) (playerStatusRecord, bool)
|
||||||
|
Now() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
type inMemoryPlayerStatusStore struct {
|
type inMemoryPlayerStatusStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
records map[string]playerStatusRecord
|
records map[string]playerStatusRecord
|
||||||
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newInMemoryPlayerStatusStore() *inMemoryPlayerStatusStore {
|
func newInMemoryPlayerStatusStore() *inMemoryPlayerStatusStore {
|
||||||
return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord)}
|
return &inMemoryPlayerStatusStore{records: make(map[string]playerStatusRecord), now: time.Now}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPlayerStatusStore() playerStatusStore {
|
func NewPlayerStatusStore() playerStatusStore {
|
||||||
|
|
@ -35,6 +42,9 @@ func NewPlayerStatusStore() playerStatusStore {
|
||||||
func (s *inMemoryPlayerStatusStore) Save(record playerStatusRecord) {
|
func (s *inMemoryPlayerStatusStore) Save(record playerStatusRecord) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
if s.now != nil && record.ReceivedAt == "" {
|
||||||
|
record.ReceivedAt = s.now().Format(time.RFC3339)
|
||||||
|
}
|
||||||
s.records[record.ScreenID] = record
|
s.records[record.ScreenID] = record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,3 +54,10 @@ func (s *inMemoryPlayerStatusStore) Get(screenID string) (playerStatusRecord, bo
|
||||||
record, ok := s.records[screenID]
|
record, ok := s.records[screenID]
|
||||||
return record, ok
|
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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHandlePlayerStatusAccepted(t *testing.T) {
|
func TestHandlePlayerStatusAccepted(t *testing.T) {
|
||||||
|
|
@ -55,6 +56,10 @@ func TestHandlePlayerStatusAccepted(t *testing.T) {
|
||||||
if got, want := stored.ServerConnectivity, "online"; got != want {
|
if got, want := stored.ServerConnectivity, "online"; got != want {
|
||||||
t.Fatalf("stored.ServerConnectivity = %q, want %q", 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) {
|
func TestHandlePlayerStatusRejectsInvalidJSON(t *testing.T) {
|
||||||
|
|
@ -172,11 +177,15 @@ func TestHandlePlayerStatusRejectsMalformedLastHeartbeatAt(t *testing.T) {
|
||||||
|
|
||||||
func TestHandleGetLatestPlayerStatus(t *testing.T) {
|
func TestHandleGetLatestPlayerStatus(t *testing.T) {
|
||||||
store := newInMemoryPlayerStatusStore()
|
store := newInMemoryPlayerStatusStore()
|
||||||
|
store.now = func() time.Time {
|
||||||
|
return time.Date(2026, 3, 22, 16, 1, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
store.Save(playerStatusRecord{
|
store.Save(playerStatusRecord{
|
||||||
ScreenID: "info01-dev",
|
ScreenID: "info01-dev",
|
||||||
Timestamp: "2026-03-22T16:00:00Z",
|
Timestamp: "2026-03-22T16:00:00Z",
|
||||||
Status: "running",
|
Status: "running",
|
||||||
ServerConnectivity: "degraded",
|
ServerConnectivity: "degraded",
|
||||||
|
ReceivedAt: "2026-03-22T16:00:05Z",
|
||||||
ServerURL: "http://127.0.0.1:8080",
|
ServerURL: "http://127.0.0.1:8080",
|
||||||
MQTTBroker: "tcp://127.0.0.1:1883",
|
MQTTBroker: "tcp://127.0.0.1:1883",
|
||||||
HeartbeatEverySeconds: 30,
|
HeartbeatEverySeconds: 30,
|
||||||
|
|
@ -206,6 +215,44 @@ func TestHandleGetLatestPlayerStatus(t *testing.T) {
|
||||||
if got, want := response.ServerConnectivity, "degraded"; got != want {
|
if got, want := response.ServerConnectivity, "degraded"; got != want {
|
||||||
t.Fatalf("response.ServerConnectivity = %q, want %q", 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) {
|
func TestHandleGetLatestPlayerStatusNotFound(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue