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:
parent
a7889231c0
commit
cfab277dc4
4 changed files with 343 additions and 21 deletions
|
|
@ -92,14 +92,20 @@ Zusaetzlich enthaelt die Antwort eine `summary` mit kompakten Counts fuer `total
|
||||||
|
|
||||||
Aktuell unterstuetzte Query-Parameter fuer die Uebersicht:
|
Aktuell unterstuetzte Query-Parameter fuer die Uebersicht:
|
||||||
|
|
||||||
- `server_connectivity=<value>` zum Filtern nach Reachability-Zustand
|
- `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
|
- `stale=true|false` zum Filtern nach serverseitiger Veraltet-Einschaetzung; ungueltige Werte liefern 400 (`invalid_stale`)
|
||||||
- `updated_since=<RFC3339>` zum Filtern nach `received_at`
|
- `updated_since=<RFC3339>` zum Filtern nach `received_at`; ungueltige Zeitstempel liefern 400 (`invalid_updated_since`)
|
||||||
- `limit=<positive integer>` zum Begrenzen der Anzahl zurueckgelieferter Screens
|
- `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.
|
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.
|
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.
|
`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.
|
Wenn fuer den Screen noch kein Status vorliegt, liefert das Backend `404` mit dem gemeinsamen Fehlerumschlag.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ func NewRouter(store playerStatusStore) http.Handler {
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("GET /status", handleStatusPage(store))
|
mux.HandleFunc("GET /status", handleStatusPage(store))
|
||||||
|
mux.HandleFunc("GET /status/{screenId}", handleScreenDetailPage(store))
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /api/v1", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -198,6 +266,9 @@ func TestRouterStatusPageRejectsInvalidQueryParams(t *testing.T) {
|
||||||
if got, want := w.Code, http.StatusBadRequest; got != want {
|
if got, want := w.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status = %d, want %d", 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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,19 +32,21 @@ type statusFilterLink struct {
|
||||||
Active bool
|
Active bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusPageTemplate = template.Must(template.New("status-page").Funcs(template.FuncMap{
|
type screenDetailPageData struct {
|
||||||
"connectivityLabel": connectivityLabel,
|
GeneratedAt string
|
||||||
"screenDetailPath": screenDetailPath,
|
Record playerStatusRecord
|
||||||
"statusClass": statusClass,
|
StatusPagePath string
|
||||||
"timestampLabel": timestampLabel,
|
}
|
||||||
}).Parse(`<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
type statusPageErrorData struct {
|
||||||
<head>
|
Message string
|
||||||
<meta charset="utf-8">
|
StatusPagePath string
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
}
|
||||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
|
|
||||||
<title>Screen Status</title>
|
// statusPageCSS is the shared stylesheet for all status page templates.
|
||||||
<style>
|
// 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 {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f4efe7;
|
--bg: #f4efe7;
|
||||||
|
|
@ -366,6 +368,23 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
|
||||||
line-height: 1.5;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.page {
|
.page {
|
||||||
padding: 20px 14px 32px;
|
padding: 20px 14px 32px;
|
||||||
|
|
@ -392,8 +411,34 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
|
||||||
.filter-form {
|
.filter-form {
|
||||||
grid-template-columns: 1fr;
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
|
@ -495,7 +540,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Latest reports</h2>
|
<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>
|
</div>
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
|
|
@ -521,7 +566,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(templat
|
||||||
<div class="screen">{{.ScreenID}}</div>
|
<div class="screen">{{.ScreenID}}</div>
|
||||||
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>No endpoint details</small>{{end}}
|
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>No endpoint details</small>{{end}}
|
||||||
<div class="screen-links">
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="pill {{statusClass .DerivedState}}">{{.DerivedState}}</span></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>
|
</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 {
|
func handleStatusPage(store playerStatusStore) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
overview, err := buildScreenStatusOverview(store, r.URL.Query())
|
overview, err := buildScreenStatusOverview(store, r.URL.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeOverviewQueryError(w, err)
|
writeStatusPageQueryError(w, err)
|
||||||
return
|
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 {
|
func buildStatusPageData(store playerStatusStore, query url.Values, overview screenStatusOverview) statusPageData {
|
||||||
filters := statusPageFilters{
|
filters := statusPageFilters{
|
||||||
ServerConnectivity: strings.TrimSpace(query.Get("server_connectivity")),
|
ServerConnectivity: strings.TrimSpace(query.Get("server_connectivity")),
|
||||||
|
|
@ -653,6 +860,39 @@ func buildOverviewPath(basePath string, filters statusPageFilters) string {
|
||||||
return basePath + "?" + encoded
|
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 {
|
func statusClass(value string) string {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|
@ -673,6 +913,10 @@ func screenDetailPath(screenID string) string {
|
||||||
return "/api/v1/screens/" + url.PathEscape(strings.TrimSpace(screenID)) + "/status"
|
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 {
|
func timestampLabel(value string) string {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue