Fuege HTML-Detailseite und HTML-Fehlerseite fuer den Status-UI-Pfad hinzu

Drei eng zusammenhaengende Aenderungen in einem Commit, da sie dieselbe
Template-Infrastruktur teilen und gemeinsam die HTML-Diagnoseansicht
abrunden:

1. CSS-Extraktion (Verbesserung, kein Verhalten geaendert)
   Das bisherige Inline-CSS wurde in die Konstante statusPageCSS
   ausgelagert. statusPageCSSBlock injiziert es per String-Konkatenation
   in alle Templates, sodass kein Template-Inheritance-Mechanismus
   benoetigt wird und jede Seite eigenstaendig ausfuehrbar bleibt.

2. GET /status/{screenId} -- neue HTML-Detailseite
   Zeigt den letzten bekannten Datensatz eines einzelnen Screens:
   Derived State, Player-Status, Connectivity und Frische als
   Summary-Cards; Timing- und Endpoints-Details in aufgeraeuemten
   Key-Value-Tabellen. Verlinkung zurueck auf /status und auf den
   bestehenden JSON-Endpunkt. Bei unbekanntem Screen: 404 mit HTML-
   Fehlerseite und Rueck-Link.
   Route: GET /status/{screenId}

3. HTML-Fehlerseite fuer /status bei ungueltigen Query-Parametern
   Bisher lieferte handleStatusPage einen rohen JSON-Fehler, wenn
   z.B. ?stale=banana uebergeben wurde -- inkongruent fuer einen
   HTML-Endpunkt. writeStatusPageQueryError rendert jetzt dasselbe
   statusPageErrorTemplate wie der Not-Found-Fall der Detailseite
   und gibt text/html mit 400 zurueck.

Neue Tests (router_test.go):
- ScreenDetailPageRoute: prueft Inhalt, Links, Content-Type
- ScreenDetailPageNotFound: prueft 404 + HTML + Rueck-Link
- StatusPageRejectsInvalidQueryParams: prueft jetzt auch text/html
  fuer alle Fehlerfaelle

Docs (PLAYER-STATUS-HTTP.md):
- Query-Parameter-Validierung mit erlaubten Werten und Fehlercodes
- Neue /status/{screenId}-Seite und HTML-Fehlerseiten dokumentiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-22 20:03:24 +01:00
parent a7889231c0
commit cfab277dc4
4 changed files with 343 additions and 21 deletions

View file

@ -92,14 +92,20 @@ Zusaetzlich enthaelt die Antwort eine `summary` mit kompakten Counts fuer `total
Aktuell unterstuetzte Query-Parameter fuer die Uebersicht:
- `server_connectivity=<value>` zum Filtern nach Reachability-Zustand
- `stale=true|false` zum Filtern nach serverseitiger Veraltet-Einschaetzung
- `updated_since=<RFC3339>` zum Filtern nach `received_at`
- `limit=<positive integer>` zum Begrenzen der Anzahl zurueckgelieferter Screens
- `server_connectivity=<value>` 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=<RFC3339>` zum Filtern nach `received_at`; ungueltige Zeitstempel liefern 400 (`invalid_updated_since`)
- `limit=<positive integer>` 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.

View file

@ -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{

View file

@ -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)
}
})
}
}

View file

@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>Screen Status</title>
<style>
type screenDetailPageData struct {
GeneratedAt string
Record playerStatusRecord
StatusPagePath string
}
type statusPageErrorData struct {
Message string
StatusPagePath string
}
// statusPageCSS is the shared stylesheet for all status page templates.
// It is injected via string concatenation at parse time so that each template
// is self-contained and can be executed independently without template inheritance.
const statusPageCSS = `
:root {
color-scheme: light;
--bg: #f4efe7;
@ -366,6 +368,23 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
line-height: 1.5;
}
/* Key-value detail tables used on the screen detail page. */
.detail-table {
min-width: 0;
}
.detail-table th {
font-weight: normal;
white-space: nowrap;
padding-right: 24px;
width: 40%;
}
.detail-table tbody tr:last-child th,
.detail-table tbody tr:last-child td {
border-bottom: 0;
}
@media (max-width: 720px) {
.page {
padding: 20px 14px 32px;
@ -392,8 +411,34 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
.filter-form {
grid-template-columns: 1fr;
}
.detail-table th {
width: auto;
}
</style>
}
`
// statusPageCSSBlock wraps statusPageCSS in a <style> element.
// Each template embeds it via string concatenation so that no template
// inheritance is needed and every page is independently executable.
const statusPageCSSBlock = ` <style>` + statusPageCSS + ` </style>`
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>Screen Status</title>
` + statusPageCSSBlock + `
</head>
<body>
<main class="page">
@ -495,7 +540,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
<div class="panel-head">
<div>
<h2>Latest reports</h2>
<p class="panel-copy">Each row links directly to the existing per-screen JSON detail endpoint for a quick drill-down into the raw status payload.</p>
<p class="panel-copy">Each row links to the HTML detail view and the raw JSON endpoint for a quick drill-down.</p>
</div>
</div>
<div class="table-actions">
@ -521,7 +566,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
<div class="screen">{{.ScreenID}}</div>
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>No endpoint details</small>{{end}}
<div class="screen-links">
<a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON detail</a>
<a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a>
<a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a>
</div>
</td>
<td><span class="pill {{statusClass .DerivedState}}">{{.DerivedState}}</span></td>
@ -555,11 +601,138 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
</html>
`))
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Record.ScreenID}} Screen Status</title>
` + statusPageCSSBlock + `
</head>
<body>
<main class="page">
<section class="hero">
<div class="hero-top">
<div>
<h1>{{.Record.ScreenID}}</h1>
<p class="lead">Single screen diagnostic view based on the last accepted status report.</p>
</div>
<div class="meta">
<div>Updated {{.GeneratedAt}}</div>
<div style="margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
<a class="meta-chip" href="{{.StatusPagePath}}"> All screens</a>
<a class="meta-chip" href="{{screenDetailPath .Record.ScreenID}}">JSON</a>
</div>
</div>
</div>
<div class="summary-grid">
<article class="summary-card {{statusClass .Record.DerivedState}}">
<strong><span class="state {{statusClass .Record.DerivedState}}">{{.Record.DerivedState}}</span></strong>
<span>Derived state</span>
</article>
<article class="summary-card">
<strong>{{.Record.Status}}</strong>
<span>Player status</span>
</article>
<article class="summary-card {{statusClass .Record.ServerConnectivity}}">
<strong>{{connectivityLabel .Record.ServerConnectivity}}</strong>
<span>Server connectivity</span>
</article>
<article class="summary-card">
<strong>{{if .Record.Stale}}stale{{else}}fresh{{end}}</strong>
<span>Freshness</span>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Timing</h2>
<p class="panel-copy">Timestamps reported by the player and annotated by the server at receive time.</p>
</div>
</div>
<table class="detail-table">
<tbody>
<tr>
<th>Received at (server)</th>
<td>{{timestampLabel .Record.ReceivedAt}}</td>
</tr>
<tr>
<th>Player timestamp</th>
<td>{{timestampLabel .Record.Timestamp}}</td>
</tr>
<tr>
<th>Started at</th>
<td>{{timestampLabel .Record.StartedAt}}</td>
</tr>
<tr>
<th>Last heartbeat at</th>
<td>{{timestampLabel .Record.LastHeartbeatAt}}</td>
</tr>
<tr>
<th>Heartbeat interval</th>
<td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td>
</tr>
</tbody>
</table>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Endpoints</h2>
<p class="panel-copy">Connection details reported by the player in the last accepted status.</p>
</div>
</div>
<table class="detail-table">
<tbody>
<tr>
<th>Server URL</th>
<td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td>
</tr>
<tr>
<th>MQTT broker</th>
<td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td>
</tr>
</tbody>
</table>
</section>
</main>
</body>
</html>
`))
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid filter Screen Status</title>
` + statusPageCSSBlock + `
</head>
<body>
<main class="page" style="max-width: 640px;">
<section class="hero">
<div class="hero-top">
<div>
<h1>Invalid filter</h1>
<p class="lead">{{.Message}}</p>
</div>
</div>
<a class="filter-link" href="{{.StatusPagePath}}"> Back to Screen Status</a>
</section>
</main>
</body>
</html>
`))
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 == "" {