diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 26195de..11559ae 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -200,6 +200,7 @@ Ergaenzt seit dem ersten Geruest: - Backend bietet zusaetzlich eine kleine Uebersicht aller zuletzt meldenden Screens - Backend validiert den Statuspfad jetzt enger auf erlaubte Lifecycle-/Connectivity-Werte und leitet `stale` aus dem gemeldeten Intervall ab - Backend leitet im Read-Pfad zusaetzlich ein kompaktes `derived_state` fuer Diagnosekonsumenten ab +- Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten - 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 bd04909..c9cf59b 100644 --- a/docs/PLAYER-STATUS-HTTP.md +++ b/docs/PLAYER-STATUS-HTTP.md @@ -71,9 +71,13 @@ Bei ungueltigen Requests wird wie bei den anderen API-Endpunkten der gemeinsame Zusätzlich zur Write-Route gibt es in dieser Stufe: +- `GET /status` - `GET /api/v1/screens/status` - `GET /api/v1/screens/{screenId}/status` +`GET /status` liefert eine kleine serverseitig gerenderte HTML-Statusseite fuer den Browser. +Sie nutzt dieselbe in-memory Statusuebersicht wie die JSON-Endpunkte und ist als erste sichtbare Diagnoseoberflaeche gedacht. + `GET /api/v1/screens/status` liefert eine kleine Uebersicht aller bisher berichtenden Screens mit ihrem jeweils letzten bekannten Datensatz. Die Rueckgabe wird aktuell fuer Diagnosezwecke priorisiert sortiert: zuerst `offline`, dann `degraded`, dann `online`, innerhalb derselben Gruppe nach `screen_id`. Zusaetzlich enthaelt die Antwort eine `summary` mit kompakten Counts fuer `total`, `online`, `degraded`, `offline` und `stale`. diff --git a/server/backend/internal/httpapi/playerstatus.go b/server/backend/internal/httpapi/playerstatus.go index 88e54ca..53b4838 100644 --- a/server/backend/internal/httpapi/playerstatus.go +++ b/server/backend/internal/httpapi/playerstatus.go @@ -1,13 +1,28 @@ 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": {}, @@ -130,70 +145,95 @@ func handleGetLatestPlayerStatus(store playerStatusStore) http.HandlerFunc { 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")) - updatedSince, err := parseOptionalRFC3339(r.URL.Query().Get("updated_since")) + overview, err := buildScreenStatusOverview(store, r.URL.Query()) if err != nil { - writeError(w, http.StatusBadRequest, "invalid_updated_since", "updated_since ist kein gueltiger RFC3339-Zeitstempel", nil) + writeOverviewQueryError(w, err) return } - limit, err := parseOptionalPositiveInt(r.URL.Query().Get("limit")) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid_limit", "limit muss eine positive Ganzzahl sein", nil) - 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")) + wantStale := strings.TrimSpace(query.Get("stale")) + 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++ } - filtered := make([]playerStatusRecord, 0, len(records)) - summary := map[string]int{ - "total": 0, - "online": 0, - "degraded": 0, - "offline": 0, - "stale": 0, + if records[i].Stale { + overview.Summary.Stale++ } - for i := range records { - records[i].Stale = isStale(records[i], store.Now()) - records[i].DerivedState = deriveState(records[i]) - summary["total"]++ - summary[records[i].DerivedState]++ - if records[i].Stale { - summary["stale"]++ - } - if updatedSince != nil && !isUpdatedSince(records[i], *updatedSince) { + 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 wantConnectivity != "" && records[i].ServerConnectivity != wantConnectivity { + if wantStale == "false" && records[i].Stale { continue } - if wantStale != "" { - if wantStale == "true" && !records[i].Stale { - continue - } - if wantStale == "false" && records[i].Stale { - continue - } - } - filtered = append(filtered, records[i]) + } + 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 } - 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 overview.Screens[i].ScreenID < overview.Screens[j].ScreenID + }) - return filtered[i].ScreenID < filtered[j].ScreenID - }) + if limit > 0 && len(overview.Screens) > limit { + overview.Screens = overview.Screens[:limit] + } - if limit > 0 && len(filtered) > limit { - filtered = filtered[:limit] - } + return overview, nil +} - writeJSON(w, http.StatusOK, map[string]any{ - "summary": summary, - "screens": filtered, - }) +var ( + errInvalidUpdatedSince = errors.New("invalid updated_since") + errInvalidLimit = errors.New("invalid limit") +) + +func writeOverviewQueryError(w http.ResponseWriter, err error) { + switch err { + case errInvalidUpdatedSince: + writeError(w, http.StatusBadRequest, "invalid_updated_since", "updated_since ist kein gueltiger RFC3339-Zeitstempel", nil) + case errInvalidLimit: + writeError(w, http.StatusBadRequest, "invalid_limit", "limit muss eine positive Ganzzahl sein", nil) + default: + writeError(w, http.StatusBadRequest, "invalid_query", "ungueltige Query-Parameter", nil) } } diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index a50c72f..aac3508 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -14,6 +14,8 @@ func NewRouter(store playerStatusStore) http.Handler { }) }) + mux.HandleFunc("GET /status", handleStatusPage(store)) + mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "name": "morz-infoboard-backend", diff --git a/server/backend/internal/httpapi/router_test.go b/server/backend/internal/httpapi/router_test.go index 2735b6a..c83b2dc 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + "time" ) func TestRouterHealthz(t *testing.T) { @@ -173,3 +175,32 @@ func TestRouterScreenStatusListRoute(t *testing.T) { t.Fatalf("status = %d, want %d", got, want) } } + +func TestRouterStatusPageRoute(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: "screen-online", Timestamp: "2026-03-22T16:09:30Z", Status: "running", ServerConnectivity: "online", ReceivedAt: "2026-03-22T16:09:30Z", HeartbeatEverySeconds: 30}) + store.Save(playerStatusRecord{ScreenID: "screen-offline", Timestamp: "2026-03-22T16:00:00Z", Status: "running", ServerConnectivity: "offline", ReceivedAt: "2026-03-22T16:00:00Z", HeartbeatEverySeconds: 30}) + + req := httptest.NewRequest(http.MethodGet, "/status", nil) + w := httptest.NewRecorder() + + NewRouter(store).ServeHTTP(w, req) + + if got, want := w.Code, http.StatusOK; got != want { + t.Fatalf("status = %d, want %d", got, want) + } + + if got := w.Header().Get("Content-Type"); !strings.Contains(got, "text/html") { + t.Fatalf("Content-Type = %q, want text/html", got) + } + + body := w.Body.String() + for _, want := range []string{"Screen Status", "2 screens", "screen-offline", "offline", "screen-online", "online"} { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q", want) + } + } +} diff --git a/server/backend/internal/httpapi/statuspage.go b/server/backend/internal/httpapi/statuspage.go new file mode 100644 index 0000000..7f7982e --- /dev/null +++ b/server/backend/internal/httpapi/statuspage.go @@ -0,0 +1,365 @@ +package httpapi + +import ( + "html/template" + "net/http" + "strings" + "time" +) + +type statusPageData struct { + GeneratedAt string + Overview screenStatusOverview +} + +var statusPageTemplate = template.Must(template.New("status-page").Funcs(template.FuncMap{ + "connectivityLabel": connectivityLabel, + "statusClass": statusClass, + "timestampLabel": timestampLabel, +}).Parse(` + + + + + Screen Status + + + +
+
+
+
+

Screen Status

+

A compact browser view of the latest screen reports from the current in-memory status overview. Offline and degraded screens stay at the top for quick diagnostics.

+
+
+
{{.Overview.Summary.Total}} screens
+
Updated {{.GeneratedAt}}
+
+
+ +
+
+ {{.Overview.Summary.Total}} + Total known screens +
+
+ {{.Overview.Summary.Offline}} + Offline +
+
+ {{.Overview.Summary.Degraded}} + Degraded +
+
+ {{.Overview.Summary.Online}} + Online +
+
+ {{.Overview.Summary.Stale}} + Stale reports +
+
+
+ +
+

Latest reports

+ {{if .Overview.Screens}} +
+ + + + + + + + + + + + + {{range .Overview.Screens}} + + + + + + + + + {{end}} + +
ScreenDerived statePlayer statusServer linkReceivedHeartbeat
+
{{.ScreenID}}
+ {{if .MQTTBroker}}{{.MQTTBroker}}{{else if .ServerURL}}{{.ServerURL}}{{else}}No endpoint details{{end}} +
{{.DerivedState}} +
{{.Status}}
+ {{if .Stale}}Marked stale by server freshness check{{else}}Fresh within expected heartbeat window{{end}} +
+ {{connectivityLabel .ServerConnectivity}} + {{if .ServerURL}}{{.ServerURL}}{{end}} + +
{{timestampLabel .ReceivedAt}}
+ {{if .LastHeartbeatAt}}Heartbeat {{timestampLabel .LastHeartbeatAt}}{{end}} +
+ {{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}} + {{if .StartedAt}}Started {{timestampLabel .StartedAt}}{{end}} +
+
+ {{else}} +

No screen has reported status yet. Once a player posts to the existing status API, it will appear here automatically.

+ {{end}} +
+
+ + +`)) + +func handleStatusPage(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 + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + data := statusPageData{ + GeneratedAt: store.Now().Format(time.RFC3339), + Overview: overview, + } + + if err := statusPageTemplate.Execute(w, data); err != nil { + http.Error(w, "failed to render status page", http.StatusInternalServerError) + } + } +} + +func statusClass(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "degraded" + } + return trimmed +} + +func connectivityLabel(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "unknown" + } + return trimmed +} + +func timestampLabel(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "-" + } + return trimmed +}