morz-infoboard/server/backend/internal/httpapi/playerstatus.go
Jesko Anschütz 57e0cdb43c Ergaenze Auto-Refresh auf Detailseite und bereinige Fehlermeldungs-Duplikat
Auto-Refresh auf GET /status/{screenId}:
  screenDetailPageData bekommt RefreshSeconds (wie statusPageData). Das
  Detail-Template rendert das Meta-Tag analog zur Uebersichtsseite mit
  denselben 15 Sekunden, damit ein Screen seinen Zustand (z.B. fresh ->
  stale, connectivity-Wechsel) auch ohne manuellen Reload sichtbar macht.
  Test: meta-refresh-Tag jetzt in TestRouterScreenDetailPageRoute geprueft.

DRY-Refactor: Fehlermeldungen vereinheitlicht:
  overviewQueryErrorMessage und overviewQueryErrorCode sind jetzt in
  playerstatus.go definiert -- dort wo auch die Validierungslogik lebt.
  writeOverviewQueryError delegiert vollstaendig an beide Helper statt
  die Meldungen selbst zu duplizieren. Die vorherige Kopie in statuspage.go
  mit abweichenden Satzendezeichen und Grossschreibung wurde entfernt.
  Beide Fehlerpfade (JSON und HTML) nutzen jetzt exakt dieselben
  Meldungstexte.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:16:22 +01:00

376 lines
10 KiB
Go

package httpapi
import (
"errors"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
type screenStatusSummary struct {
Total int `json:"total"`
Online int `json:"online"`
Degraded int `json:"degraded"`
Offline int `json:"offline"`
Stale int `json:"stale"`
}
type screenStatusOverview struct {
Summary screenStatusSummary `json:"summary"`
Screens []playerStatusRecord `json:"screens"`
}
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) {
overview, err := buildScreenStatusOverview(store, r.URL.Query())
if err != nil {
writeOverviewQueryError(w, err)
return
}
writeJSON(w, http.StatusOK, overview)
}
}
func buildScreenStatusOverview(store playerStatusStore, query url.Values) (screenStatusOverview, error) {
records := store.List()
wantConnectivity := strings.TrimSpace(query.Get("server_connectivity"))
switch wantConnectivity {
case "", "online", "degraded", "offline", "unknown":
// valid
default:
return screenStatusOverview{}, errInvalidServerConnectivity
}
wantStale := strings.TrimSpace(query.Get("stale"))
switch wantStale {
case "", "true", "false":
// valid
default:
return screenStatusOverview{}, errInvalidStale
}
updatedSince, err := parseOptionalRFC3339(query.Get("updated_since"))
if err != nil {
return screenStatusOverview{}, errInvalidUpdatedSince
}
limit, err := parseOptionalPositiveInt(query.Get("limit"))
if err != nil {
return screenStatusOverview{}, errInvalidLimit
}
overview := screenStatusOverview{
Screens: make([]playerStatusRecord, 0, len(records)),
}
for i := range records {
records[i].Stale = isStale(records[i], store.Now())
records[i].DerivedState = deriveState(records[i])
overview.Summary.Total++
switch records[i].DerivedState {
case "offline":
overview.Summary.Offline++
case "degraded":
overview.Summary.Degraded++
default:
overview.Summary.Online++
}
if records[i].Stale {
overview.Summary.Stale++
}
if updatedSince != nil && !isUpdatedSince(records[i], *updatedSince) {
continue
}
if wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity {
continue
}
if wantStale != "" {
if wantStale == "true" && !records[i].Stale {
continue
}
if wantStale == "false" && records[i].Stale {
continue
}
}
overview.Screens = append(overview.Screens, records[i])
}
sort.Slice(overview.Screens, func(i, j int) bool {
leftPriority := derivedStatePriority(overview.Screens[i].DerivedState)
rightPriority := derivedStatePriority(overview.Screens[j].DerivedState)
if leftPriority != rightPriority {
return leftPriority < rightPriority
}
return overview.Screens[i].ScreenID < overview.Screens[j].ScreenID
})
if limit > 0 && len(overview.Screens) > limit {
overview.Screens = overview.Screens[:limit]
}
return overview, nil
}
var (
errInvalidUpdatedSince = errors.New("invalid updated_since")
errInvalidLimit = errors.New("invalid limit")
errInvalidServerConnectivity = errors.New("invalid server_connectivity")
errInvalidStale = errors.New("invalid stale")
)
// overviewQueryErrorCode returns the machine-readable error code for query
// validation errors. It is used by both the JSON and HTML error paths.
func overviewQueryErrorCode(err error) string {
switch err {
case errInvalidUpdatedSince:
return "invalid_updated_since"
case errInvalidLimit:
return "invalid_limit"
case errInvalidServerConnectivity:
return "invalid_server_connectivity"
case errInvalidStale:
return "invalid_stale"
default:
return "invalid_query"
}
}
// overviewQueryErrorMessage returns the human-readable message for query
// validation errors. It is used by both the JSON and HTML error paths so
// that the wording stays consistent regardless of response format.
func overviewQueryErrorMessage(err error) string {
switch err {
case errInvalidUpdatedSince:
return "updated_since ist kein gueltiger RFC3339-Zeitstempel"
case errInvalidLimit:
return "limit muss eine positive Ganzzahl sein"
case errInvalidServerConnectivity:
return "server_connectivity muss online, offline, degraded oder unknown sein"
case errInvalidStale:
return "stale muss true oder false sein"
default:
return "ungueltige Query-Parameter"
}
}
func writeOverviewQueryError(w http.ResponseWriter, err error) {
writeError(w, http.StatusBadRequest, overviewQueryErrorCode(err), overviewQueryErrorMessage(err), nil)
}
func validateOptionalRFC3339(value string) error {
if strings.TrimSpace(value) == "" {
return nil
}
_, err := time.Parse(time.RFC3339, value)
return err
}
func parseOptionalRFC3339(value string) (*time.Time, error) {
if strings.TrimSpace(value) == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
if err != nil {
return nil, err
}
return &parsed, nil
}
func parseOptionalPositiveInt(value string) (int, error) {
if strings.TrimSpace(value) == "" {
return 0, nil
}
parsed, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || parsed <= 0 {
return 0, strconv.ErrSyntax
}
return parsed, nil
}
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 isUpdatedSince(record playerStatusRecord, threshold time.Time) bool {
if strings.TrimSpace(record.ReceivedAt) == "" {
return false
}
receivedAt, err := time.Parse(time.RFC3339, record.ReceivedAt)
if err != nil {
return false
}
return !receivedAt.Before(threshold)
}
func derivedStatePriority(state string) int {
switch state {
case "offline":
return 0
case "degraded":
return 1
default:
return 2
}
}