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 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, } var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(` Screen Status ` + statusPageCSSBlock + `

Screen Status

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.

{{.Overview.Summary.Total}} screens
Updated {{.GeneratedAt}}
{{.Overview.Summary.Total}} Total known screens
{{.Overview.Summary.Offline}} Offline
{{.Overview.Summary.Degraded}} Degraded
{{.Overview.Summary.Online}} Online
{{.Overview.Summary.Stale}} Stale 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 to the HTML detail view and the raw JSON endpoint for a quick drill-down.

{{if .Overview.Screens}}
{{range .Overview.Screens}} {{end}}
Screen Derived state Player status Server link Received Heartbeat
{{.ScreenID}}
{{if .MQTTBroker}}{{.MQTTBroker}}{{else if .ServerURL}}{{.ServerURL}}{{else}}No endpoint details{{end}}
{{.DerivedState}}
{{.Status}}
{{if .Stale}}Marked stale by server freshness check{{else}}Fresh within expected heartbeat window{{end}}
{{connectivityLabel .ServerConnectivity}} {{if .ServerURL}}{{.ServerURL}}{{end}}
{{timestampLabel .ReceivedAt}}
{{if .LastHeartbeatAt}}Heartbeat {{timestampLabel .LastHeartbeatAt}}{{end}}
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}} {{if .StartedAt}}Started {{timestampLabel .StartedAt}}{{end}}
{{else}}

No screen has reported status yet. Once a player posts to the existing status API, it will appear here automatically.

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

{{.Record.ScreenID}}

Single screen diagnostic view based on the last accepted status report.

Updated {{.GeneratedAt}}
← All screens JSON
{{.Record.DerivedState}} Derived state
{{.Record.Status}} Player status
{{connectivityLabel .Record.ServerConnectivity}} Server connectivity
{{if .Record.Stale}}stale{{else}}fresh{{end}} Freshness

Timing

Timestamps reported by the player and annotated by the server at receive time.

Received at (server) {{timestampLabel .Record.ReceivedAt}}
Player timestamp {{timestampLabel .Record.Timestamp}}
Started at {{timestampLabel .Record.StartedAt}}
Last heartbeat at {{timestampLabel .Record.LastHeartbeatAt}}
Heartbeat interval {{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}

Endpoints

Connection details reported by the player in the last accepted status.

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(` Invalid filter – Screen Status ` + statusPageCSSBlock + `

Invalid filter

{{.Message}}

← Back to Screen Status
`)) 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), 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")), 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: "All screens", Href: buildStatusPageHref(base), Class: "", Active: filters.ServerConnectivity == "" && filters.Stale == "", }, { Label: "Connectivity offline", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "offline", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Class: "offline", Active: filters.ServerConnectivity == "offline" && filters.Stale == "", }, { Label: "Connectivity degraded", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "degraded", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Class: "degraded", Active: filters.ServerConnectivity == "degraded" && filters.Stale == "", }, { Label: "Stale reports", Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "true", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Class: "", Active: filters.ServerConnectivity == "" && filters.Stale == "true", }, { Label: "Fresh reports", 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.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 }