morz-infoboard/server/backend/internal/httpapi/playerstatus.go
Jesko Anschütz cbe0c40f45 Priorisiere Statusuebersicht fuer Diagnosefaelle
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-22 18:52:18 +01:00

221 lines
6.3 KiB
Go

package httpapi
import (
"net/http"
"sort"
"strings"
"time"
)
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"`
Timestamp string `json:"ts"`
Status string `json:"status"`
ServerConnectivity string `json:"server_connectivity"`
ServerURL string `json:"server_url"`
MQTTBroker string `json:"mqtt_broker"`
HeartbeatEverySeconds int `json:"heartbeat_every_seconds"`
StartedAt string `json:"started_at"`
LastHeartbeatAt string `json:"last_heartbeat_at"`
}
func handlePlayerStatus(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var request playerStatusRequest
if err := decodeJSON(r, &request); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", "ungueltiger JSON-Body", nil)
return
}
if strings.TrimSpace(request.ScreenID) == "" {
writeError(w, http.StatusBadRequest, "screen_id_required", "screen_id ist erforderlich", nil)
return
}
request.ScreenID = strings.TrimSpace(request.ScreenID)
if strings.TrimSpace(request.Timestamp) == "" {
writeError(w, http.StatusBadRequest, "timestamp_required", "ts ist erforderlich", nil)
return
}
if strings.TrimSpace(request.Status) == "" {
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)
return
}
if err := validateOptionalRFC3339(request.StartedAt); err != nil {
writeError(w, http.StatusBadRequest, "invalid_started_at", "started_at ist kein gueltiger RFC3339-Zeitstempel", nil)
return
}
if err := validateOptionalRFC3339(request.LastHeartbeatAt); err != nil {
writeError(w, http.StatusBadRequest, "invalid_last_heartbeat_at", "last_heartbeat_at ist kein gueltiger RFC3339-Zeitstempel", nil)
return
}
store.Save(playerStatusRecord{
ScreenID: request.ScreenID,
Timestamp: request.Timestamp,
Status: request.Status,
ServerConnectivity: request.ServerConnectivity,
ServerURL: request.ServerURL,
MQTTBroker: request.MQTTBroker,
HeartbeatEverySeconds: request.HeartbeatEverySeconds,
StartedAt: request.StartedAt,
LastHeartbeatAt: request.LastHeartbeatAt,
})
writeJSON(w, http.StatusOK, map[string]string{
"status": "accepted",
})
}
}
func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
screenID := strings.TrimSpace(r.PathValue("screenId"))
if screenID == "" {
writeError(w, http.StatusBadRequest, "screen_id_required", "screenId ist erforderlich", nil)
return
}
record, ok := store.Get(screenID)
if !ok {
writeError(w, http.StatusNotFound, "screen_status_not_found", "Fuer diesen Screen liegt noch kein Status vor", nil)
return
}
record.Stale = isStale(record, store.Now())
record.DerivedState = deriveState(record)
writeJSON(w, http.StatusOK, record)
}
}
func handleListLatestPlayerStatuses(store playerStatusStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
records := store.List()
wantConnectivity := strings.TrimSpace(r.URL.Query().Get("server_connectivity"))
wantStale := strings.TrimSpace(r.URL.Query().Get("stale"))
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 wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity {
continue
}
if wantStale != "" {
if wantStale == "true" && !records[i].Stale {
continue
}
if wantStale == "false" && records[i].Stale {
continue
}
}
filtered = append(filtered, records[i])
}
sort.Slice(filtered, func(i, j int) bool {
leftPriority := derivedStatePriority(filtered[i].DerivedState)
rightPriority := derivedStatePriority(filtered[j].DerivedState)
if leftPriority != rightPriority {
return leftPriority < rightPriority
}
return filtered[i].ScreenID < filtered[j].ScreenID
})
writeJSON(w, http.StatusOK, map[string]any{
"screens": filtered,
})
}
}
func validateOptionalRFC3339(value string) error {
if strings.TrimSpace(value) == "" {
return nil
}
_, 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
}
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
}
func deriveState(record playerStatusRecord) string {
if record.Stale || record.ServerConnectivity == "offline" {
return "offline"
}
if record.ServerConnectivity == "degraded" || record.ServerConnectivity == "unknown" || record.Status != "running" {
return "degraded"
}
return "online"
}
func derivedStatePriority(state string) int {
switch state {
case "offline":
return 0
case "degraded":
return 1
default:
return 2
}
}