diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 11559ae..c701a37 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -200,7 +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 +- Backend liefert unter `/status` eine erste sichtbare HTML-Diagnoseseite auf Basis derselben Statusdaten, inklusive Auto-Refresh, leichten Filtern und JSON-Drill-down - 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 c9cf59b..d0e5917 100644 --- a/docs/PLAYER-STATUS-HTTP.md +++ b/docs/PLAYER-STATUS-HTTP.md @@ -78,6 +78,14 @@ Zusätzlich zur Write-Route gibt es in dieser Stufe: `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. +Die Seite bietet aktuell bewusst nur leichte Diagnosehilfen auf Basis des bestehenden Read-Pfads: + +- automatisches Refresh alle 15 Sekunden +- Shortcut-Links fuer Connectivity- und Freshness-Filter wie `server_connectivity=offline|degraded` sowie `stale=true|false` +- ein kleines Filterformular fuer dieselben Uebersichtsparameter wie im JSON-Read-Pfad +- direkte JSON-Detail-Links pro Screen auf `GET /api/v1/screens/{screenId}/status` +- einen Link zur aktuell gefilterten JSON-Uebersicht auf `GET /api/v1/screens/status` + `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`. @@ -90,6 +98,7 @@ Aktuell unterstuetzte Query-Parameter fuer die Uebersicht: - `limit=` zum Begrenzen der Anzahl zurueckgelieferter Screens 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 /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_test.go b/server/backend/internal/httpapi/router_test.go index c83b2dc..05ea613 100644 --- a/server/backend/internal/httpapi/router_test.go +++ b/server/backend/internal/httpapi/router_test.go @@ -184,7 +184,7 @@ func TestRouterStatusPageRoute(t *testing.T) { 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) + req := httptest.NewRequest(http.MethodGet, "/status?server_connectivity=offline&stale=true&updated_since=2026-03-22T15:55:00Z&limit=10", nil) w := httptest.NewRecorder() NewRouter(store).ServeHTTP(w, req) @@ -198,7 +198,25 @@ func TestRouterStatusPageRoute(t *testing.T) { } body := w.Body.String() - for _, want := range []string{"Screen Status", "2 screens", "screen-offline", "offline", "screen-online", "online"} { + for _, want := range []string{ + "Screen Status", + "2 screens", + "", + "Connectivity offline", + "Connectivity degraded", + "Stale reports", + "Fresh reports", + "updated_since=2026-03-22T15%3A55%3A00Z", + "screen-offline", + "offline", + "/api/v1/screens/screen-offline/status", + "name=\"server_connectivity\"", + "name=\"stale\"", + "name=\"limit\"", + "server_connectivity=offline", + "stale=true", + "value=\"10\"", + } { 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 index 7f7982e..4dd47a8 100644 --- a/server/backend/internal/httpapi/statuspage.go +++ b/server/backend/internal/httpapi/statuspage.go @@ -3,17 +3,38 @@ package httpapi import ( "html/template" "net/http" + "net/url" "strings" "time" ) type statusPageData struct { - GeneratedAt string - Overview screenStatusOverview + GeneratedAt string + RefreshSeconds int + Filters statusPageFilters + QuickFilters []statusFilterLink + Overview screenStatusOverview + StatusAPIPath string + StatusPagePath string +} + +type statusPageFilters struct { + ServerConnectivity string + Stale string + UpdatedSince string + Limit string +} + +type statusFilterLink struct { + Label string + Href string + Class string + Active bool } var statusPageTemplate = template.Must(template.New("status-page").Funcs(template.FuncMap{ "connectivityLabel": connectivityLabel, + "screenDetailPath": screenDetailPath, "statusClass": statusClass, "timestampLabel": timestampLabel, }).Parse(` @@ -21,6 +42,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat + Screen Status @@ -266,7 +434,73 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
-

Latest reports

+
+
+

Filters and refresh

+

This page refreshes every {{.RefreshSeconds}} seconds. Use the shortcut links or the form to narrow the existing connectivity and freshness filters without leaving the lightweight server-rendered flow.

+
+ JSON overview +
+ +
+
+

Quick views

+
+ {{range .QuickFilters}} + {{.Label}} + {{end}} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Clear +
+
+
+
+ +
+
+
+

Latest reports

+

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

+
+
+ {{if .Overview.Screens}}
@@ -286,6 +520,9 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
{{.ScreenID}}
{{if .MQTTBroker}}{{.MQTTBroker}}{{else if .ServerURL}}{{.ServerURL}}{{else}}No endpoint details{{end}} +
{{.DerivedState}} @@ -329,10 +566,7 @@ func handleStatusPage(store playerStatusStore) http.HandlerFunc { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) - data := statusPageData{ - GeneratedAt: store.Now().Format(time.RFC3339), - Overview: overview, - } + data := buildStatusPageData(store, r.URL.Query(), overview) if err := statusPageTemplate.Execute(w, data); err != nil { http.Error(w, "failed to render status page", http.StatusInternalServerError) @@ -340,6 +574,85 @@ func handleStatusPage(store playerStatusStore) http.HandlerFunc { } } +func buildStatusPageData(store playerStatusStore, query url.Values, overview screenStatusOverview) statusPageData { + filters := statusPageFilters{ + ServerConnectivity: strings.TrimSpace(query.Get("server_connectivity")), + Stale: strings.TrimSpace(query.Get("stale")), + UpdatedSince: strings.TrimSpace(query.Get("updated_since")), + Limit: strings.TrimSpace(query.Get("limit")), + } + + return statusPageData{ + GeneratedAt: store.Now().Format(time.RFC3339), + RefreshSeconds: 15, + Filters: filters, + QuickFilters: buildStatusQuickFilters(filters), + Overview: overview, + StatusAPIPath: buildOverviewPath("/api/v1/screens/status", filters), + StatusPagePath: "/status", + } +} + +func buildStatusQuickFilters(filters statusPageFilters) []statusFilterLink { + return []statusFilterLink{ + { + Label: "All screens", + Href: buildStatusPageHref(statusPageFilters{Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}), + Class: "", + Active: filters.ServerConnectivity == "" && filters.Stale == "", + }, + { + Label: "Connectivity offline", + Href: buildStatusPageHref(statusPageFilters{ServerConnectivity: "offline", Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}), + Class: "offline", + Active: filters.ServerConnectivity == "offline" && filters.Stale == "", + }, + { + Label: "Connectivity degraded", + Href: buildStatusPageHref(statusPageFilters{ServerConnectivity: "degraded", Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}), + Class: "degraded", + Active: filters.ServerConnectivity == "degraded" && filters.Stale == "", + }, + { + Label: "Stale reports", + Href: buildStatusPageHref(statusPageFilters{Stale: "true", Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}), + Class: "", + Active: filters.ServerConnectivity == "" && filters.Stale == "true", + }, + { + Label: "Fresh reports", + Href: buildStatusPageHref(statusPageFilters{Stale: "false", Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}), + Class: "online", + Active: filters.ServerConnectivity == "" && filters.Stale == "false", + }, + } +} + +func buildStatusPageHref(filters statusPageFilters) string { + return buildOverviewPath("/status", filters) +} + +func buildOverviewPath(basePath string, filters statusPageFilters) string { + query := url.Values{} + if filters.ServerConnectivity != "" { + query.Set("server_connectivity", filters.ServerConnectivity) + } + if filters.Stale != "" { + query.Set("stale", filters.Stale) + } + if filters.UpdatedSince != "" { + query.Set("updated_since", filters.UpdatedSince) + } + if filters.Limit != "" { + query.Set("limit", filters.Limit) + } + encoded := query.Encode() + if encoded == "" { + return basePath + } + return basePath + "?" + encoded +} + func statusClass(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" { @@ -356,6 +669,10 @@ func connectivityLabel(value string) string { return trimmed } +func screenDetailPath(screenID string) string { + return "/api/v1/screens/" + url.PathEscape(strings.TrimSpace(screenID)) + "/status" +} + func timestampLabel(value string) string { trimmed := strings.TrimSpace(value) if trimmed == "" {