morz-infoboard/server/backend/internal/httpapi/statuspage.go
Jesko Anschütz b73da77835 feat(screens): Screen-Übersicht mit On-Demand-Screenshots für Multi-Screen-User
- GET /manage: neue Übersichtsseite mit Bulma-Karten für screen_user mit ≥2 Screens
- handleScreenUserRedirect leitet bei ≥2 Screens auf /manage statt auf ersten Screen
- On-Demand-Screenshot-Flow via MQTT:
  - Backend publiziert signage/screen/{slug}/screenshot-request beim Seitenaufruf
  - Player-Agent empfängt Topic, ruft TakeAndSendOnce() auf
  - Player POST /api/v1/player/screenshot → Backend speichert in ScreenshotStore (RAM)
  - GET /api/v1/screens/{screenId}/screenshot liefert gespeichertes Bild (authOnly)
- ScreenshotStore: In-Memory, thread-safe, kein Persistenz-Overhead
- JS-Retry nach 4s in Templates (Screenshot braucht 1-3s für MQTT-Roundtrip)
- manageTmpl zeigt Screenshot-Thumbnail beim Einzelscreen-Aufruf
- Doku: neue Endpoints, MQTT-Topics, Screenshot-Flow in SERVER-KONZEPT.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 14:27:10 +01:00

1045 lines
31 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
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 <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,
"stateLabel": stateLabel,
}
var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>Bildschirmstatus morz infoboard</title>
` + statusPageCSSBlock + `
</head>
<body>
<main class="page">
<section class="hero">
<div class="hero-top">
<div>
<h1>Bildschirmstatus</h1>
<p class="lead">Kompakte Übersicht der zuletzt gemeldeten Bildschirmzustände. Offline- und eingeschränkte Bildschirme erscheinen oben für schnelle Diagnose.</p>
</div>
<div class="meta">
<div>{{.Overview.Summary.Total}} Bildschirme</div>
<div>Aktualisiert <time id="generated-at" datetime="{{.GeneratedAt}}">{{.GeneratedAt}}</time></div>
<div style="margin-top: 8px;"><a class="meta-chip" href="/admin">← Admin</a></div>
</div>
</div>
<div class="summary-grid">
<article class="summary-card">
<strong>{{.Overview.Summary.Total}}</strong>
<span>Bildschirme gesamt</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>Eingeschränkt</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>Veraltete Meldungen</span>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Filter und Aktualisierung</h2>
<p class="panel-copy">Diese Seite aktualisiert sich alle {{.RefreshSeconds}} Sekunden. Verwende die Schnellfilter oder das Formular, um die Ansicht einzugrenzen.</p>
</div>
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON-Übersicht</a>
</div>
<div class="controls-grid">
<div>
<h2>Schnellansichten</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 enthält</label>
<input id="q" name="q" type="text" placeholder="z.B. info01" value="{{.Filters.ScreenIDFilter}}">
</div>
<div class="field">
<label for="server_connectivity">Verbindung zum Server</label>
<select id="server_connectivity" name="server_connectivity">
<option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Alle</option>
<option value="online" {{if eq .Filters.ServerConnectivity "online"}}selected{{end}}>Online</option>
<option value="degraded" {{if eq .Filters.ServerConnectivity "degraded"}}selected{{end}}>Eingeschränkt</option>
<option value="offline" {{if eq .Filters.ServerConnectivity "offline"}}selected{{end}}>Offline</option>
<option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unbekannt</option>
</select>
</div>
<div class="field">
<label for="stale">Meldungsalter</label>
<select id="stale" name="stale">
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Alle</option>
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Nur veraltet</option>
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Nur aktuell</option>
</select>
</div>
<div class="field">
<label for="derived_state">Gesamtstatus</label>
<select id="derived_state" name="derived_state">
<option value="" {{if eq .Filters.DerivedState ""}}selected{{end}}>Alle</option>
<option value="online" {{if eq .Filters.DerivedState "online"}}selected{{end}}>Online</option>
<option value="degraded" {{if eq .Filters.DerivedState "degraded"}}selected{{end}}>Eingeschränkt</option>
<option value="offline" {{if eq .Filters.DerivedState "offline"}}selected{{end}}>Offline</option>
</select>
</div>
<div class="field full">
<label for="updated_since">Aktualisiert seit</label>
<input id="updated_since" name="updated_since" type="datetime-local" 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">Filter anwenden</button>
<a class="text-link" href="{{.StatusPagePath}}">Zurücksetzen</a>
</div>
</form>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Aktuelle Meldungen</h2>
<p class="panel-copy">Jede Zeile verlinkt auf die HTML-Detailansicht und den JSON-Endpunkt.</p>
</div>
</div>
<div class="table-actions">
<a class="text-link" href="{{.StatusAPIPath}}">Gefilterte JSON-Übersicht öffnen</a>
</div>
{{if .Overview.Screens}}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Bildschirm</th>
<th>Status</th>
<th>Player-Status</th>
<th>Server</th>
<th>Empfangen</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>Keine Verbindungsdetails</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}}">{{stateLabel .DerivedState}}</span></td>
<td>
<div>{{.Status}}</div>
{{if .Stale}}<small>Vom Server als veraltet markiert</small>{{else}}<small>Aktuell im erwarteten Heartbeat-Fenster</small>{{end}}
</td>
<td>
<span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span>
{{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}}
</td>
<td>
<div><time class="reltime" datetime="{{.ReceivedAt}}">{{timestampLabel .ReceivedAt}}</time></div>
{{if .LastHeartbeatAt}}<small>Heartbeat <time class="reltime" datetime="{{.LastHeartbeatAt}}">{{timestampLabel .LastHeartbeatAt}}</time></small>{{end}}
</td>
<td>
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}}
{{if .StartedAt}}<small>Gestartet <time class="reltime" datetime="{{.StartedAt}}">{{timestampLabel .StartedAt}}</time></small>{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="empty">Noch kein Bildschirm hat einen Status gemeldet. Sobald ein Player den Status-API-Endpunkt aufruft, erscheint er hier automatisch.</p>
{{end}}
</section>
</main>
<script>
(function() {
function relativeTime(dateStr) {
if (!dateStr || dateStr === '-') return dateStr;
var d = new Date(dateStr);
if (isNaN(d)) return dateStr;
var diff = Math.round((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'gerade eben';
if (diff < 60) return 'vor ' + diff + ' Sekunden';
var mins = Math.round(diff / 60);
if (mins < 60) return 'vor ' + mins + (mins === 1 ? ' Minute' : ' Minuten');
var hours = Math.round(diff / 3600);
if (hours < 24) return 'vor ' + hours + (hours === 1 ? ' Stunde' : ' Stunden');
var days = Math.round(diff / 86400);
return 'vor ' + days + (days === 1 ? ' Tag' : ' Tagen');
}
function updateRelTimes() {
document.querySelectorAll('time.reltime').forEach(function(el) {
el.textContent = relativeTime(el.getAttribute('datetime'));
});
var genAt = document.getElementById('generated-at');
if (genAt) genAt.textContent = relativeTime(genAt.getAttribute('datetime'));
}
updateRelTimes();
setInterval(updateRelTimes, 30000);
})();
// Beim Laden: RFC3339-Wert in datetime-local-Format konvertieren (YYYY-MM-DDTHH:MM)
(function() {
var input = document.getElementById('updated_since');
if (input && input.value) {
var d = new Date(input.value);
if (!isNaN(d)) {
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
input.value = d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) +
'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
}
})();
// Beim Submit: datetime-local Wert zu RFC3339 konvertieren
(function() {
var form = document.querySelector('form.filter-form');
if (form) {
form.addEventListener('submit', function(e) {
var input = document.getElementById('updated_since');
if (input && input.value) {
input.value = new Date(input.value).toISOString();
}
});
}
})();
</script>
</body>
</html>
`))
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>{{.Record.ScreenID}} Bildschirmstatus</title>
` + statusPageCSSBlock + `
</head>
<body>
<main class="page">
<section class="hero">
<div class="hero-top">
<div>
<!-- N5: ScreenID (Slug) als Titel. Displayname könnte hier ergänzt werden,
wenn handleScreenDetailPage zusätzlich *store.ScreenStore erhält
und GetBySlug(ctx, screenID) aufruft. -->
<h1>{{.Record.ScreenID}}</h1>
<p class="lead">Detailansicht auf Basis des zuletzt akzeptierten Status-Reports.</p>
</div>
<div class="meta">
<div>Aktualisiert <time id="generated-at" datetime="{{.GeneratedAt}}">{{.GeneratedAt}}</time></div>
<div style="margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
<a class="meta-chip" href="{{.StatusPagePath}}">← Alle Bildschirme</a>
<a class="meta-chip" href="/admin">← Admin</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}}">{{stateLabel .Record.DerivedState}}</span></strong>
<span>Abgeleiteter Status</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>Serverkonnektivität</span>
</article>
<article class="summary-card">
<strong>{{if .Record.Stale}}Veraltet{{else}}Aktuell{{end}}</strong>
<span>Aktualität</span>
</article>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Zeitstempel</h2>
<p class="panel-copy">Vom Player gemeldete und vom Server beim Empfang ergänzte Zeitstempel.</p>
</div>
</div>
<table class="detail-table">
<tbody>
<tr>
<th>Empfangen (Server)</th>
<td><time class="reltime" datetime="{{.Record.ReceivedAt}}">{{timestampLabel .Record.ReceivedAt}}</time></td>
</tr>
<tr>
<th>Player-Zeitstempel</th>
<td><time class="reltime" datetime="{{.Record.Timestamp}}">{{timestampLabel .Record.Timestamp}}</time></td>
</tr>
<tr>
<th>Gestartet</th>
<td><time class="reltime" datetime="{{.Record.StartedAt}}">{{timestampLabel .Record.StartedAt}}</time></td>
</tr>
<tr>
<th>Letzter Heartbeat</th>
<td><time class="reltime" datetime="{{.Record.LastHeartbeatAt}}">{{timestampLabel .Record.LastHeartbeatAt}}</time></td>
</tr>
<tr>
<th>Heartbeat-Intervall</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>Verbindungen</h2>
<p class="panel-copy">Verbindungsdetails aus dem zuletzt akzeptierten Status-Report.</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>
<script>
(function() {
function relativeTime(dateStr) {
if (!dateStr || dateStr === '-') return dateStr;
var d = new Date(dateStr);
if (isNaN(d)) return dateStr;
var diff = Math.round((Date.now() - d.getTime()) / 1000);
if (diff < 5) return 'gerade eben';
if (diff < 60) return 'vor ' + diff + ' Sekunden';
var mins = Math.round(diff / 60);
if (mins < 60) return 'vor ' + mins + (mins === 1 ? ' Minute' : ' Minuten');
var hours = Math.round(diff / 3600);
if (hours < 24) return 'vor ' + hours + (hours === 1 ? ' Stunde' : ' Stunden');
var days = Math.round(diff / 86400);
return 'vor ' + days + (days === 1 ? ' Tag' : ' Tagen');
}
function updateRelTimes() {
document.querySelectorAll('time.reltime').forEach(function(el) {
el.textContent = relativeTime(el.getAttribute('datetime'));
});
var genAt = document.getElementById('generated-at');
if (genAt) genAt.textContent = relativeTime(genAt.getAttribute('datetime'));
}
updateRelTimes();
setInterval(updateRelTimes, 30000);
})();
</script>
</body>
</html>
`))
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="de" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Ungültiger Filter Bildschirmstatus</title>
` + statusPageCSSBlock + `
</head>
<body>
<main class="page" style="max-width: 640px;">
<section class="hero">
<div class="hero-top">
<div>
<h1>Ungültiger Filter</h1>
<p class="lead">{{.Message}}</p>
</div>
</div>
<a class="filter-link" href="{{.StatusPagePath}}">← Zurück zum Bildschirmstatus</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: "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
}
}