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}}
+ 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}}
+
+
+
+
+ | Screen |
+ Derived state |
+ Player status |
+ Server link |
+ Received |
+ Heartbeat |
+
+
+
+ {{range .Overview.Screens}}
+
+ |
+ {{.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}}
+ |
+
+ {{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
+}