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 { ScreenIDFilter string DerivedState string ServerConnectivity string Stale string UpdatedSince string Limit string } type statusFilterLink struct { Label string Href string Class string Active bool } type screenDetailPageData struct { GeneratedAt string RefreshSeconds int 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 ` var statusTemplateFuncs = template.FuncMap{ "connectivityLabel": connectivityLabel, "screenDetailPath": screenDetailPath, "screenDetailHTMLPath": screenDetailHTMLPath, "statusClass": statusClass, "timestampLabel": timestampLabel, "stateLabel": stateLabel, } var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(` Bildschirmstatus ` + statusPageCSSBlock + `

Bildschirmstatus

Kompakte Übersicht der zuletzt gemeldeten Bildschirmzustände. Offline- und eingeschränkte Bildschirme erscheinen oben für schnelle Diagnose.

{{.Overview.Summary.Total}} Bildschirme
Aktualisiert
← Admin
{{.Overview.Summary.Total}} Bildschirme gesamt
{{.Overview.Summary.Offline}} Offline
{{.Overview.Summary.Degraded}} Eingeschränkt
{{.Overview.Summary.Online}} Online
{{.Overview.Summary.Stale}} Veraltete Meldungen

Filter und Aktualisierung

Diese Seite aktualisiert sich alle {{.RefreshSeconds}} Sekunden. Verwende die Schnellfilter oder das Formular, um die Ansicht einzugrenzen.

JSON-Übersicht

Schnellansichten

{{range .QuickFilters}} {{.Label}} {{end}}
Zurücksetzen

Aktuelle Meldungen

Jede Zeile verlinkt auf die HTML-Detailansicht und den JSON-Endpunkt.

{{if .Overview.Screens}}
{{range .Overview.Screens}} {{end}}
Bildschirm Status Player-Status Server Empfangen Heartbeat
{{.ScreenID}}
{{if .MQTTBroker}}{{.MQTTBroker}}{{else if .ServerURL}}{{.ServerURL}}{{else}}Keine Verbindungsdetails{{end}}
{{stateLabel .DerivedState}}
{{.Status}}
{{if .Stale}}Vom Server als veraltet markiert{{else}}Aktuell im erwarteten Heartbeat-Fenster{{end}}
{{connectivityLabel .ServerConnectivity}} {{if .ServerURL}}{{.ServerURL}}{{end}}
{{if .LastHeartbeatAt}}Heartbeat {{end}}
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}} {{if .StartedAt}}Gestartet {{end}}
{{else}}

Noch kein Bildschirm hat einen Status gemeldet. Sobald ein Player den Status-API-Endpunkt aufruft, erscheint er hier automatisch.

{{end}}
`)) var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(` {{.Record.ScreenID}} – Bildschirmstatus ` + statusPageCSSBlock + `

{{.Record.ScreenID}}

Detailansicht auf Basis des zuletzt akzeptierten Status-Reports.

Aktualisiert
← Alle Bildschirme ← Admin JSON
{{stateLabel .Record.DerivedState}} Abgeleiteter Status
{{.Record.Status}} Player-Status
{{connectivityLabel .Record.ServerConnectivity}} Serverkonnektivität
{{if .Record.Stale}}Veraltet{{else}}Aktuell{{end}} Aktualität

Zeitstempel

Vom Player gemeldete und vom Server beim Empfang ergänzte Zeitstempel.

Empfangen (Server)
Player-Zeitstempel
Gestartet
Letzter Heartbeat
Heartbeat-Intervall {{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}

Verbindungen

Verbindungsdetails aus dem zuletzt akzeptierten Status-Report.

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(` Ungültiger Filter – Bildschirmstatus ` + statusPageCSSBlock + `

Ungültiger Filter

{{.Message}}

← Zurück zum Bildschirmstatus
`)) 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: "Für 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), RefreshSeconds: 15, 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{ ScreenIDFilter: strings.TrimSpace(query.Get("q")), DerivedState: strings.TrimSpace(query.Get("derived_state")), 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 { base := statusPageFilters{ScreenIDFilter: filters.ScreenIDFilter, Limit: filters.Limit, UpdatedSince: filters.UpdatedSince} return []statusFilterLink{ { Label: "Alle Bildschirme", Href: buildStatusPageHref(base), Class: "", Active: filters.ServerConnectivity == "" && filters.Stale == "", }, { Label: "Konnektivität: Offline", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "offline", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Class: "offline", Active: filters.ServerConnectivity == "offline" && filters.Stale == "", }, { Label: "Konnektivität: Eingeschränkt", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "degraded", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Class: "degraded", Active: filters.ServerConnectivity == "degraded" && filters.Stale == "", }, { Label: "Veraltete Meldungen", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "true", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Class: "", Active: filters.ServerConnectivity == "" && filters.Stale == "true", }, { Label: "Aktuelle Meldungen", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "false", Limit: base.Limit, UpdatedSince: base.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.ScreenIDFilter != "" { query.Set("q", filters.ScreenIDFilter) } if filters.DerivedState != "" { query.Set("derived_state", filters.DerivedState) } 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) } } 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 } func stateLabel(value string) string { switch strings.TrimSpace(value) { case "online": return "Online" case "offline": return "Offline" case "degraded": return "Eingeschränkt" case "unknown": return "Unbekannt" default: trimmed := strings.TrimSpace(value) if trimmed == "" { return "Unbekannt" } return trimmed } }