morz-infoboard/server/backend/internal/httpapi/statuspage.go
Jesko Anschütz 8243eb10c9 Ergaenze Screen-ID-Filter (q=) fuer Uebersicht und Status-API
GET /api/v1/screens/status und GET /status akzeptieren jetzt q=<substring>
zum Filtern der Ergebnisliste nach ScreenID. Der Vergleich ist case-
insensitiv. Leerer Wert bedeutet kein Filter; jeder andere String ist gueltig
(keine Validierung noetig). Die Summary-Counts bleiben unveraendert und
beschreiben weiterhin den gesamten Store-Bestand.

Die Quick-Filter auf /status behalten den aktuellen q-Wert beim Klick, damit
der Textfilter nicht verloren geht wenn man z.B. von "All screens" auf
"Stale reports" wechselt.

Tests: FiltersByScreenIDSubstring, ScreenIDFilterIsCaseInsensitive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:28:01 +01:00

922 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 {
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 <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 full">
<label for="q">Screen ID contains</label>
<input id="q" name="q" type="text" placeholder="e.g. info01" value="{{.Filters.ScreenIDFilter}}">
</div>
<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">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<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),
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
}