diff --git a/docs/PLAYER-STATUS-HTTP.md b/docs/PLAYER-STATUS-HTTP.md index d0e5917..7d16f25 100644 --- a/docs/PLAYER-STATUS-HTTP.md +++ b/docs/PLAYER-STATUS-HTTP.md @@ -92,14 +92,20 @@ Zusaetzlich enthaelt die Antwort eine `summary` mit kompakten Counts fuer `total Aktuell unterstuetzte Query-Parameter fuer die Uebersicht: -- `server_connectivity=` zum Filtern nach Reachability-Zustand -- `stale=true|false` zum Filtern nach serverseitiger Veraltet-Einschaetzung -- `updated_since=` zum Filtern nach `received_at` -- `limit=` zum Begrenzen der Anzahl zurueckgelieferter Screens +- `server_connectivity=` zum Filtern nach Reachability-Zustand; erlaubte Werte: `online`, `offline`, `degraded`, `unknown`; ungueltige Werte liefern 400 (`invalid_server_connectivity`) +- `stale=true|false` zum Filtern nach serverseitiger Veraltet-Einschaetzung; ungueltige Werte liefern 400 (`invalid_stale`) +- `updated_since=` zum Filtern nach `received_at`; ungueltige Zeitstempel liefern 400 (`invalid_updated_since`) +- `limit=` zum Begrenzen der Anzahl zurueckgelieferter Screens; nicht-positive Werte liefern 400 (`invalid_limit`) Die Query-Parameter beeinflussen die Liste in `screens`; die `summary` beschreibt weiterhin den gesamten aktuell bekannten Statusbestand. Dieselben Parameter koennen aktuell sowohl an `GET /api/v1/screens/status` als auch an `GET /status` verwendet werden, damit Browser-Ansicht und JSON-Uebersicht dieselbe Diagnose-Sicht teilen. +`GET /status/{screenId}` liefert eine HTML-Detailseite fuer einen einzelnen Screen. +Sie zeigt denselben Datensatz wie der JSON-Endpunkt – Derived State, Player-Status, Connectivity, Frische, Timestamps und Endpoints – in derselben visuellen Sprache wie die Uebersichtsseite. +Bei unbekanntem Screen liefert sie 404 mit einer erklaerenden HTML-Fehlermeldung und einem Rueck-Link auf `/status`. + +Fehlerfall bei ungueltigem Query-Parameter auf `/status` (z.B. `?stale=banana`): statt rohem JSON liefert der Endpunkt jetzt eine HTML-Fehlerseite mit erklaerenden Hinweisen und einem Rueck-Link. + `GET /api/v1/screens/{screenId}/status` liefert den zuletzt akzeptierten Status fuer einen einzelnen Screen zurueck. Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit dem gemeinsamen Fehlerumschlag. diff --git a/server/backend/internal/httpapi/router.go b/server/backend/internal/httpapi/router.go index aac3508..2fde419 100644 --- a/server/backend/internal/httpapi/router.go +++ b/server/backend/internal/httpapi/router.go @@ -15,6 +15,7 @@ func NewRouter(store playerStatusStore) http.Handler { }) mux.HandleFunc("GET /status", handleStatusPage(store)) + mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(store)) mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ diff --git a/server/backend/internal/httpapi/router_test.go b/server/backend/internal/httpapi/router_test.go index 847ae9d..adaa783 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -176,6 +176,74 @@ func TestRouterScreenStatusListRoute(t *testing.T) { } } +func TestRouterScreenDetailPageRoute(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: "info01-dev", + Timestamp: "2026-03-22T16:09:30Z", + Status: "running", + ServerConnectivity: "online", + ReceivedAt: "2026-03-22T16:09:30Z", + HeartbeatEverySeconds: 30, + ServerURL: "http://127.0.0.1:8080", + MQTTBroker: "tcp://127.0.0.1:1883", + }) + + req := httptest.NewRequest(http.MethodGet, "/status/info01-dev", 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{ + "info01-dev", + "online", + "running", + "fresh", + "http://127.0.0.1:8080", + "tcp://127.0.0.1:1883", + "2026-03-22T16:09:30Z", + "/api/v1/screens/info01-dev/status", + "← All screens", + "Timing", + "Endpoints", + } { + if !strings.Contains(body, want) { + t.Fatalf("body missing %q", want) + } + } +} + +func TestRouterScreenDetailPageNotFound(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/status/missing-screen", nil) + w := httptest.NewRecorder() + + NewRouter(newInMemoryPlayerStatusStore()).ServeHTTP(w, req) + + if got, want := w.Code, http.StatusNotFound; 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) + } + + if !strings.Contains(w.Body.String(), "← Back to Screen Status") { + t.Fatal("body missing back link") + } +} + func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) { cases := []struct { name string @@ -198,6 +266,9 @@ func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) { if got, want := w.Code, http.StatusBadRequest; 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 (error page must be HTML, not JSON)", got) + } }) } } diff --git a/server/backend/internal/httpapi/statuspage.go b/server/backend/internal/httpapi/statuspage.go index 4dd47a8..39719d0 100644 --- a/server/backend/internal/httpapi/statuspage.go +++ b/server/backend/internal/httpapi/statuspage.go @@ -32,19 +32,21 @@ type statusFilterLink struct { Active bool } -var statusPageTemplate = template.Must(template.New("status-page").Funcs(template.FuncMap{ - "connectivityLabel": connectivityLabel, - "screenDetailPath": screenDetailPath, - "statusClass": statusClass, - "timestampLabel": timestampLabel, -}).Parse(` - - - - - - Screen Status - +` + +// statusPageCSSBlock wraps statusPageCSS in a ` + +var statusTemplateFuncs = template.FuncMap{ + "connectivityLabel": connectivityLabel, + "screenDetailPath": screenDetailPath, + "screenDetailHTMLPath": screenDetailHTMLPath, + "statusClass": statusClass, + "timestampLabel": timestampLabel, +} + +var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(` + + + + + + Screen Status +` + statusPageCSSBlock + `
@@ -495,7 +540,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat

Latest reports

-

Each row links directly to the existing per-screen JSON detail endpoint for a quick drill-down into the raw status payload.

+

Each row links to the HTML detail view and the raw JSON endpoint for a quick drill-down.

@@ -521,7 +566,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
{{.ScreenID}}
{{if .MQTTBroker}}{{.MQTTBroker}}{{else if .ServerURL}}{{.ServerURL}}{{else}}No endpoint details{{end}} {{.DerivedState}} @@ -555,11 +601,138 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat `)) +var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(` + + + + + {{.Record.ScreenID}} – Screen Status +` + statusPageCSSBlock + ` + + +
+
+
+
+

{{.Record.ScreenID}}

+

Single screen diagnostic view based on the last accepted status report.

+
+
+
Updated {{.GeneratedAt}}
+ +
+
+ +
+
+ {{.Record.DerivedState}} + Derived state +
+
+ {{.Record.Status}} + Player status +
+
+ {{connectivityLabel .Record.ServerConnectivity}} + Server connectivity +
+
+ {{if .Record.Stale}}stale{{else}}fresh{{end}} + Freshness +
+
+
+ +
+
+
+

Timing

+

Timestamps reported by the player and annotated by the server at receive time.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
Received at (server){{timestampLabel .Record.ReceivedAt}}
Player timestamp{{timestampLabel .Record.Timestamp}}
Started at{{timestampLabel .Record.StartedAt}}
Last heartbeat at{{timestampLabel .Record.LastHeartbeatAt}}
Heartbeat interval{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}
+
+ +
+
+
+

Endpoints

+

Connection details reported by the player in the last accepted status.

+
+
+ + + + + + + + + + + +
Server URL{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}
MQTT broker{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}
+
+
+ + +`)) + +var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(` + + + + + Invalid filter – Screen Status +` + statusPageCSSBlock + ` + + +
+
+
+
+

Invalid filter

+

{{.Message}}

+
+
+ ← Back to Screen Status +
+
+ + +`)) + 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) + writeStatusPageQueryError(w, err) return } @@ -574,6 +747,40 @@ func handleStatusPage(store playerStatusStore) http.HandlerFunc { } } +func handleScreenDetailPage(store playerStatusStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + screenID := strings.TrimSpace(r.PathValue("screenId")) + + record, ok := store.Get(screenID) + if !ok { + data := statusPageErrorData{ + Message: "Fuer diesen Screen liegt noch kein Status vor.", + StatusPagePath: "/status", + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _ = statusPageErrorTemplate.Execute(w, data) + return + } + + record.Stale = isStale(record, store.Now()) + record.DerivedState = deriveState(record) + + data := screenDetailPageData{ + GeneratedAt: store.Now().Format(time.RFC3339), + Record: record, + StatusPagePath: "/status", + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + if err := screenDetailTemplate.Execute(w, data); err != nil { + http.Error(w, "failed to render detail page", http.StatusInternalServerError) + } + } +} + func buildStatusPageData(store playerStatusStore, query url.Values, overview screenStatusOverview) statusPageData { filters := statusPageFilters{ ServerConnectivity: strings.TrimSpace(query.Get("server_connectivity")), @@ -653,6 +860,39 @@ func buildOverviewPath(basePath string, filters statusPageFilters) string { return basePath + "?" + encoded } +// writeStatusPageQueryError renders an HTML 400 error page for invalid query +// parameters on /status. This keeps the error in the same visual context as +// the rest of the status UI instead of returning a raw JSON blob in a browser. +func writeStatusPageQueryError(w http.ResponseWriter, queryErr error) { + data := statusPageErrorData{ + Message: overviewQueryErrorMessage(queryErr), + StatusPagePath: "/status", + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + if err := statusPageErrorTemplate.Execute(w, data); err != nil { + http.Error(w, data.Message, http.StatusBadRequest) + } +} + +// overviewQueryErrorMessage returns a human-readable message for the given +// overview query validation error. It is shared between the HTML and JSON +// error paths to keep messages consistent. +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 statusClass(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { @@ -673,6 +913,10 @@ func screenDetailPath(screenID string) string { return "/api/v1/screens/" + url.PathEscape(strings.TrimSpace(screenID)) + "/status" } +func screenDetailHTMLPath(screenID string) string { + return "/status/" + url.PathEscape(strings.TrimSpace(screenID)) +} + func timestampLabel(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" {