Auto-Refresh auf GET /status/{screenId}:
screenDetailPageData bekommt RefreshSeconds (wie statusPageData). Das
Detail-Template rendert das Meta-Tag analog zur Uebersichtsseite mit
denselben 15 Sekunden, damit ein Screen seinen Zustand (z.B. fresh ->
stale, connectivity-Wechsel) auch ohne manuellen Reload sichtbar macht.
Test: meta-refresh-Tag jetzt in TestRouterScreenDetailPageRoute geprueft.
DRY-Refactor: Fehlermeldungen vereinheitlicht:
overviewQueryErrorMessage und overviewQueryErrorCode sind jetzt in
playerstatus.go definiert -- dort wo auch die Validierungslogik lebt.
writeOverviewQueryError delegiert vollstaendig an beide Helper statt
die Meldungen selbst zu duplizieren. Die vorherige Kopie in statuspage.go
mit abweichenden Satzendezeichen und Grossschreibung wurde entfernt.
Beide Fehlerpfade (JSON und HTML) nutzen jetzt exakt dieselben
Meldungstexte.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
911 lines
25 KiB
Go
911 lines
25 KiB
Go
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
|
||
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">
|
||
<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{
|
||
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)
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|