morz-infoboard/server/backend/internal/httpapi/statuspage.go
Jesko Anschütz cfab277dc4 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>
2026-03-22 20:03:24 +01:00

926 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package httpapi
import (
"html/template"
"net/http"
"net/url"
"strings"
"time"
)
type statusPageData struct {
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
}
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;
--surface: rgba(255, 252, 247, 0.94);
--surface-strong: #fffdf9;
--text: #1d2935;
--muted: #5d6b78;
--border: rgba(29, 41, 53, 0.12);
--online: #2f7d4a;
--degraded: #9a6a18;
--offline: #a43b32;
--shadow: 0 18px 40px rgba(63, 46, 26, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
background:
radial-gradient(circle at top left, rgba(191, 148, 77, 0.18), transparent 30%),
linear-gradient(180deg, #f8f4ed 0%, var(--bg) 100%);
color: var(--text);
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.hero,
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 22px;
box-shadow: var(--shadow);
backdrop-filter: blur(8px);
}
.hero {
padding: 28px;
margin-bottom: 20px;
}
h1,
h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.02em;
}
h1 {
font-size: clamp(2rem, 4vw, 3.2rem);
margin-bottom: 10px;
}
h2 {
font-size: 1.2rem;
margin-bottom: 18px;
}
.lead,
.meta,
.empty,
td small {
color: var(--muted);
}
.lead {
margin: 0;
max-width: 46rem;
line-height: 1.5;
}
.hero-top {
display: flex;
gap: 16px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
margin-bottom: 24px;
}
.hero-top .meta {
text-align: right;
min-width: 12rem;
font-size: 0.95rem;
line-height: 1.5;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.summary-card {
padding: 16px;
border-radius: 18px;
background: var(--surface-strong);
border: 1px solid var(--border);
}
.summary-card strong {
display: block;
font-size: 1.8rem;
margin-bottom: 4px;
}
.summary-card span {
color: var(--muted);
font-size: 0.95rem;
}
.summary-card.offline strong,
.state.offline,
.pill.offline { color: var(--offline); }
.summary-card.degraded strong,
.state.degraded,
.pill.degraded { color: var(--degraded); }
.summary-card.online strong,
.state.online,
.pill.online { color: var(--online); }
.panel {
padding: 24px;
}
.panel + .panel {
margin-top: 18px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.panel-head h2 {
margin-bottom: 6px;
}
.panel-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.meta-chip,
.filter-link,
.json-link,
.text-link {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid var(--border);
padding: 6px 12px;
color: inherit;
text-decoration: none;
background: rgba(255, 255, 255, 0.65);
}
.meta-chip {
color: var(--muted);
font-size: 0.9rem;
}
.controls-grid {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
gap: 18px;
}
.quick-filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-start;
}
.filter-link.active {
border-color: rgba(29, 41, 53, 0.28);
background: var(--surface-strong);
color: var(--text);
box-shadow: inset 0 0 0 1px rgba(29, 41, 53, 0.05);
}
.filter-link.offline.active { color: var(--offline); }
.filter-link.degraded.active { color: var(--degraded); }
.filter-link.online.active { color: var(--online); }
.filter-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.field input,
.field select {
width: 100%;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface-strong);
color: var(--text);
padding: 10px 12px;
font: inherit;
}
.field.full {
grid-column: 1 / -1;
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
grid-column: 1 / -1;
padding-top: 4px;
}
button {
border: 0;
border-radius: 999px;
padding: 10px 16px;
background: var(--text);
color: #fffdf9;
font: inherit;
cursor: pointer;
}
.json-link,
.text-link {
color: var(--muted);
font-size: 0.9rem;
}
.table-actions {
margin-bottom: 16px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
}
th,
td {
padding: 14px 0;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
tbody tr:last-child td {
border-bottom: 0;
}
.screen {
font-weight: 600;
margin-bottom: 4px;
}
.screen-links {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid currentColor;
padding: 4px 10px;
font-size: 0.84rem;
text-transform: capitalize;
background: rgba(255, 255, 255, 0.5);
}
.empty {
padding: 10px 0 4px;
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;
}
.hero,
.panel {
border-radius: 18px;
}
.hero {
padding: 22px;
}
.panel {
padding: 18px;
}
.hero-top .meta {
text-align: left;
}
.controls-grid,
.filter-form {
grid-template-columns: 1fr;
}
.detail-table th {
width: auto;
}
}
`
// 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">
<section class="hero">
<div class="hero-top">
<div>
<h1>Screen Status</h1>
<p class="lead">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.</p>
</div>
<div class="meta">
<div>{{.Overview.Summary.Total}} screens</div>
<div>Updated {{.GeneratedAt}}</div>
</div>
</div>
<div class="summary-grid">
<article class="summary-card">
<strong>{{.Overview.Summary.Total}}</strong>
<span>Total known screens</span>
</article>
<article class="summary-card offline">
<strong>{{.Overview.Summary.Offline}}</strong>
<span>Offline</span>
</article>
<article class="summary-card degraded">
<strong>{{.Overview.Summary.Degraded}}</strong>
<span>Degraded</span>
</article>
<article class="summary-card online">
<strong>{{.Overview.Summary.Online}}</strong>
<span>Online</span>
</article>
<article class="summary-card">
<strong>{{.Overview.Summary.Stale}}</strong>
<span>Stale reports</span>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Filters and refresh</h2>
<p class="panel-copy">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.</p>
</div>
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON overview</a>
</div>
<div class="controls-grid">
<div>
<h2>Quick views</h2>
<div class="quick-filters">
{{range .QuickFilters}}
<a class="filter-link {{.Class}} {{if .Active}}active{{end}}" href="{{.Href}}">{{.Label}}</a>
{{end}}
</div>
</div>
<form class="filter-form" method="get" action="{{.StatusPagePath}}">
<div class="field">
<label for="server_connectivity">Server connectivity</label>
<select id="server_connectivity" name="server_connectivity">
<option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Any</option>
<option value="online" {{if eq .Filters.ServerConnectivity "online"}}selected{{end}}>Online</option>
<option value="degraded" {{if eq .Filters.ServerConnectivity "degraded"}}selected{{end}}>Degraded</option>
<option value="offline" {{if eq .Filters.ServerConnectivity "offline"}}selected{{end}}>Offline</option>
<option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unknown</option>
</select>
</div>
<div class="field">
<label for="stale">Freshness</label>
<select id="stale" name="stale">
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Any</option>
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Stale only</option>
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Fresh only</option>
</select>
</div>
<div class="field full">
<label for="updated_since">Updated since (RFC3339)</label>
<input id="updated_since" name="updated_since" type="text" placeholder="2026-03-22T16:05:00Z" value="{{.Filters.UpdatedSince}}">
</div>
<div class="field">
<label for="limit">Limit</label>
<input id="limit" name="limit" type="number" min="1" inputmode="numeric" value="{{.Filters.Limit}}">
</div>
<div class="form-actions">
<button type="submit">Apply filters</button>
<a class="text-link" href="{{.StatusPagePath}}">Clear</a>
</div>
</form>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Latest reports</h2>
<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">
<a class="text-link" href="{{.StatusAPIPath}}">Open filtered JSON overview</a>
</div>
{{if .Overview.Screens}}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Screen</th>
<th>Derived state</th>
<th>Player status</th>
<th>Server link</th>
<th>Received</th>
<th>Heartbeat</th>
</tr>
</thead>
<tbody>
{{range .Overview.Screens}}
<tr>
<td>
<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="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>
<td>
<div>{{.Status}}</div>
{{if .Stale}}<small>Marked stale by server freshness check</small>{{else}}<small>Fresh within expected heartbeat window</small>{{end}}
</td>
<td>
<span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span>
{{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}}
</td>
<td>
<div>{{timestampLabel .ReceivedAt}}</div>
{{if .LastHeartbeatAt}}<small>Heartbeat {{timestampLabel .LastHeartbeatAt}}</small>{{end}}
</td>
<td>
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}}
{{if .StartedAt}}<small>Started {{timestampLabel .StartedAt}}</small>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">No screen has reported status yet. Once a player posts to the existing status API, it will appear here automatically.</p>
{{end}}
</section>
</main>
</body>
</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 {
writeStatusPageQueryError(w, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
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)
}
}
}
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")),
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
}
// 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 == "" {
return "degraded"
}
return trimmed
}
func connectivityLabel(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "unknown"
}
return trimmed
}
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 == "" {
return "-"
}
return trimmed
}