UX Block 2: Lösch-Modals, Status-Page Deutsch, Transitions, lokale Assets, Accessibility

- Lösch-Bestätigung: Bulma-Modal statt browser-nativer confirm()
- Status-Page komplett auf Deutsch, relative Zeitstempel ("vor 2 Min")
- Querlinks Admin ↔ Status-Page
- Bulma CSS + SortableJS als lokale go:embed Assets statt CDN
- Player-UI: sanfte Fade-Transitions (500ms) bei Content-Wechsel
- Player-UI: erweitertes Sysinfo-Overlay (Titel, Playlist-Länge, Netzwerk)
- Aria-Labels für Lösch-Buttons und Drag-Handles
- Larry-Fixes: Null-Checks in copy()/switchTab(), Umlaut-Korrektur

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jesko Anschütz 2026-03-23 11:03:04 +01:00
parent 883a8146c5
commit 62c1b8cd5c
9 changed files with 388 additions and 129 deletions

24
TODO.md
View file

@ -74,7 +74,7 @@
- [x] Minimalen Player-Agent-Prototyp bauen - [x] Minimalen Player-Agent-Prototyp bauen
- [x] Minimale Player-UI bauen - [x] Minimale Player-UI bauen
- [ ] Lokale Test-Playlist mit Bild, Video, PDF und Webseite anlegen - [ ] Lokale Test-Playlist mit Bild, Video, PDF und Webseite anlegen
- [ ] **BUG**: Datei `120papag.mpg` ist als `type: image` gespeichert, muss `type: video` sein Player-UI versucht `<img>`-Laden, was fehlschlägt - [x] **BUG**: Datei `120papag.mpg` ist als `type: image` gespeichert, muss `type: video` sein Player-UI versucht `<img>`-Laden, was fehlschlägt
- [x] Fallback-Verzeichnisbetrieb demonstrieren - [x] Fallback-Verzeichnisbetrieb demonstrieren
- [ ] `valid_from`/`valid_until` im Prototyp pruefen - [ ] `valid_from`/`valid_until` im Prototyp pruefen
- [x] Offline-Sync mit lokalem Cache pruefen - [x] Offline-Sync mit lokalem Cache pruefen
@ -138,20 +138,20 @@
### Hohe Prioritaet ### Hohe Prioritaet
- [ ] Flash-Messages nach Aktionen in Manage-UI (Upload, Loeschen, Speichern) — Feedback fuer den Nutzer - [x] Flash-Messages nach Aktionen in Manage-UI (Upload, Loeschen, Speichern) — Feedback fuer den Nutzer
- [ ] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen) - [x] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen)
- [ ] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens) - [x] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens)
### Mittlere Prioritaet ### Mittlere Prioritaet
- [ ] Loesch-Bestaetigung: Bulma-Modal statt browser-nativer confirm()-Dialog - [x] Loesch-Bestaetigung: Bulma-Modal statt browser-nativer confirm()-Dialog
- [ ] Status-Page: Sprache von Englisch auf Deutsch vereinheitlichen - [x] Status-Page: Sprache von Englisch auf Deutsch vereinheitlichen
- [ ] Status-Page: Relative Zeitstempel statt RFC3339 ("vor 2 Minuten") - [x] Status-Page: Relative Zeitstempel statt RFC3339 ("vor 2 Minuten")
- [ ] Querlinks zwischen Admin-UI und Status-Page (Navigation) - [x] Querlinks zwischen Admin-UI und Status-Page (Navigation)
- [ ] Bulma und SortableJS als lokale Assets einbetten statt CDN - [x] Bulma und SortableJS als lokale Assets einbetten statt CDN
- [ ] Player-UI: CSS-Transitions fuer sanfte Content-Wechsel (Fade statt abrupt) - [x] Player-UI: CSS-Transitions fuer sanfte Content-Wechsel (Fade statt abrupt)
- [ ] Player-UI: Erweitertes Sysinfo-Overlay (aktueller Titel, Playlist-Laenge) - [x] Player-UI: Erweitertes Sysinfo-Overlay (aktueller Titel, Playlist-Laenge)
- [ ] Aria-Labels fuer Loesch-Buttons und Drag-Handles (Accessibility) - [x] Aria-Labels fuer Loesch-Buttons und Drag-Handles (Accessibility)
### Niedrige Prioritaet ### Niedrige Prioritaet

View file

@ -192,6 +192,8 @@ const playerHTML = `<!DOCTYPE html>
position: fixed; inset: 0; position: fixed; inset: 0;
width: 100%; height: 100%; width: 100%; height: 100%;
border: none; display: none; z-index: 10; border: none; display: none; z-index: 10;
opacity: 0;
transition: opacity 0.5s ease;
} }
#img-view { #img-view {
object-fit: contain; object-fit: contain;
@ -264,9 +266,30 @@ const playerHTML = `<!DOCTYPE html>
window.addEventListener('resize', updateSplash); window.addEventListener('resize', updateSplash);
// ── Sysinfo-Overlay ─────────────────────────────────────────────── // ── Sysinfo-Overlay ───────────────────────────────────────────────
function renderSysInfo(items) { // staticSysItems wird beim pollSysInfo-Callback gesetzt und enthält
// Hostname und Uptime vom Server.
var staticSysItems = [];
function renderSysInfo(staticItems) {
if (staticItems) { staticSysItems = staticItems; }
var all = staticSysItems.slice();
// Dynamische Einträge aus Playlist-Daten anhängen.
if (dynCurrentTitle) {
all.push({ label: 'Jetzt', value: dynCurrentTitle });
}
if (dynPlaylistLength > 0) {
all.push({ label: 'Playlist', value: dynPlaylistLength + ' Eintr\u00e4ge' });
}
if (dynConnectivity) {
var connLabel = dynConnectivity === 'online' ? 'Online'
: dynConnectivity === 'degraded' ? 'Eingeschränkt'
: 'Offline';
all.push({ label: 'Netzwerk', value: connLabel });
}
overlay.innerHTML = ''; overlay.innerHTML = '';
(items || []).forEach(function(item) { all.forEach(function(item) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'info-item'; el.className = 'info-item';
el.innerHTML = el.innerHTML =
@ -286,6 +309,11 @@ const playerHTML = `<!DOCTYPE html>
var currentIdx = 0; var currentIdx = 0;
var rotateTimer = null; var rotateTimer = null;
// ── Sysinfo-Erweiterung: dynamische Overlay-Daten ─────────────────
var dynCurrentTitle = ''; // Titel des aktuell spielenden Items
var dynPlaylistLength = 0; // Anzahl Einträge in der Playlist
var dynConnectivity = ''; // online / degraded / offline
// Returns a fingerprint string for change detection. // Returns a fingerprint string for change detection.
function playlistKey(pl) { function playlistKey(pl) {
return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|'); return (pl || []).map(function(i) { return i.src + ':' + i.duration_seconds; }).join('|');
@ -296,14 +324,22 @@ const playerHTML = `<!DOCTYPE html>
} }
// Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs. // Versteckt alle Content-Elemente vor dem Anzeigen des richtigen Typs.
// Blendet zunächst auf opacity:0 aus und entfernt display erst nach der
// Transition (500ms), damit der Fade-Out sichtbar ist.
function hideAllContent() { function hideAllContent() {
frame.style.display = 'none'; // Laufendes Video sofort stoppen damit kein Audio weiterläuft.
imgView.style.display = 'none';
videoView.style.display = 'none';
frameError.style.display = 'none';
// Laufendes Video stoppen damit kein Audio weiterläuft.
videoView.pause(); videoView.pause();
videoView.src = ''; videoView.src = '';
[frame, imgView, videoView].forEach(function(el) {
if (el.style.display !== 'none') {
el.style.opacity = '0';
(function(e) {
setTimeout(function() { e.style.display = 'none'; }, 500);
})(el);
}
});
frameError.style.display = 'none';
} }
// Blendet den Splash-Screen aus (wird aufgerufen wenn echter Content angezeigt wird). // Blendet den Splash-Screen aus (wird aufgerufen wenn echter Content angezeigt wird).
@ -325,23 +361,38 @@ const playerHTML = `<!DOCTYPE html>
}, ms); }, ms);
} }
// TRANSITION_MS muss mit der CSS-Transition-Dauer übereinstimmen.
var TRANSITION_MS = 500;
function showItem(item) { function showItem(item) {
if (!item) { showSplash(); return; } if (!item) { showSplash(); return; }
// Erst Fade-Out des aktuellen Inhalts abwarten, dann neuen anzeigen.
hideAllContent(); hideAllContent();
hideSplash(); hideSplash();
overlay.style.display = 'none'; overlay.style.display = 'none';
setTimeout(function() { displayItem(item); }, TRANSITION_MS);
}
function displayItem(item) {
var type = item.type || 'web'; var type = item.type || 'web';
if (type === 'image') { if (type === 'image') {
// display setzen, dann per doppeltem rAF opacity auf 1 für Fade-In.
imgView.src = item.src; imgView.src = item.src;
imgView.style.display = ''; imgView.style.display = '';
requestAnimationFrame(function() {
requestAnimationFrame(function() { imgView.style.opacity = '1'; });
});
scheduleNext(item.duration_seconds); scheduleNext(item.duration_seconds);
} else if (type === 'video') { } else if (type === 'video') {
videoView.src = item.src; videoView.src = item.src;
videoView.style.display = ''; videoView.style.display = '';
requestAnimationFrame(function() {
requestAnimationFrame(function() { videoView.style.opacity = '1'; });
});
videoView.load(); videoView.load();
videoView.play().catch(function() {}); videoView.play().catch(function() {});
// Nach Ablauf der konfigurierten Dauer oder am Ende des Videos rotieren. // Nach Ablauf der konfigurierten Dauer oder am Ende des Videos rotieren.
@ -361,8 +412,11 @@ const playerHTML = `<!DOCTYPE html>
// type === 'web' oder unbekannt → iframe // type === 'web' oder unbekannt → iframe
frame.src = item.src; frame.src = item.src;
frame.style.display = ''; frame.style.display = '';
requestAnimationFrame(function() {
requestAnimationFrame(function() { frame.style.opacity = '1'; });
});
// Bug 3: Fehler-Fallback wenn iframe-Laden fehlschlägt (z.B. X-Frame-Options). // Fehler-Fallback wenn iframe-Laden fehlschlägt (z.B. X-Frame-Options).
frame.onerror = null; frame.onerror = null;
frame.onload = function() { frame.onload = function() {
// Prüfen ob der iframe-Inhalt zugänglich ist. Bei cross-origin-Blockierung // Prüfen ob der iframe-Inhalt zugänglich ist. Bei cross-origin-Blockierung
@ -410,11 +464,16 @@ const playerHTML = `<!DOCTYPE html>
var lastPlaylistKey = ''; var lastPlaylistKey = '';
function applyNowPlaying(data) { function applyNowPlaying(data) {
// Connectivity-Punkt und dynamische Overlay-Variable aktualisieren.
dot.className = data.connectivity || ''; dot.className = data.connectivity || '';
dynConnectivity = data.connectivity || '';
// Legacy single-URL fallback. // Legacy single-URL fallback.
if (data.url && (!data.playlist || data.playlist.length === 0)) { if (data.url && (!data.playlist || data.playlist.length === 0)) {
var key = data.url + ':legacy'; var key = data.url + ':legacy';
dynPlaylistLength = 1;
dynCurrentTitle = data.url;
renderSysInfo();
if (lastPlaylistKey !== key) { if (lastPlaylistKey !== key) {
lastPlaylistKey = key; lastPlaylistKey = key;
items = [{ src: data.url, type: 'web', duration_seconds: 30 }]; items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
@ -425,13 +484,23 @@ const playerHTML = `<!DOCTYPE html>
} }
var playlist = data.playlist || []; var playlist = data.playlist || [];
dynPlaylistLength = playlist.length;
if (playlist.length === 0) { if (playlist.length === 0) {
dynCurrentTitle = '';
renderSysInfo();
showSplash(); showSplash();
lastPlaylistKey = ''; lastPlaylistKey = '';
items = []; items = [];
return; return;
} }
// Titel des aktuell laufenden Items ermitteln (currentIdx kann nach
// einem Playlist-Wechsel ggf. noch auf dem alten Index stehen wir
// nehmen das Item des zuletzt gesetzten currentIdx, falls vorhanden).
var cur = playlist[currentIdx] || playlist[0];
dynCurrentTitle = cur.title || cur.src || '';
renderSysInfo();
var key = playlistKey(playlist); var key = playlistKey(playlist);
if (key === lastPlaylistKey) { if (key === lastPlaylistKey) {
return; // unchanged — let current rotation continue return; // unchanged — let current rotation continue
@ -441,6 +510,8 @@ const playerHTML = `<!DOCTYPE html>
lastPlaylistKey = key; lastPlaylistKey = key;
items = playlist; items = playlist;
currentIdx = 0; currentIdx = 0;
dynCurrentTitle = items[0].title || items[0].src || '';
renderSysInfo();
showItem(items[0]); showItem(items[0]);
} }
@ -448,6 +519,8 @@ const playerHTML = `<!DOCTYPE html>
function pollSysInfo() { function pollSysInfo() {
fetch('/api/sysinfo') fetch('/api/sysinfo')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
// Statische Items (Hostname, Uptime) übergeben; renderSysInfo hängt
// die dynamischen Daten (Titel, Playlist-Länge, Konnektivität) selbst an.
.then(function(d) { renderSysInfo(d.items); }) .then(function(d) { renderSysInfo(d.items); })
.catch(function() {}); .catch(function() {});
} }

View file

@ -0,0 +1,30 @@
package manage
import (
_ "embed"
"net/http"
)
//go:embed static/bulma.min.css
var bulmaCSS []byte
//go:embed static/Sortable.min.js
var sortableJS []byte
// HandleStaticBulmaCSS serves the embedded Bulma CSS file.
func HandleStaticBulmaCSS() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(bulmaCSS) //nolint:errcheck
}
}
// HandleStaticSortableJS serves the embedded SortableJS file.
func HandleStaticSortableJS() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(sortableJS) //nolint:errcheck
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,7 +6,7 @@ const provisionTmpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Einrichten {{.Screen.Name}}</title> <title>Einrichten {{.Screen.Name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"> <link rel="stylesheet" href="/static/bulma.min.css">
<style> <style>
body { background: #f5f5f5; } body { background: #f5f5f5; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px; pre { background: #1e1e1e; color: #d4d4d4; padding: 1rem; border-radius: 4px;
@ -117,8 +117,10 @@ ansible-playbook -i ansible/inventory.yml ansible/site.yml --limit {{.Screen.Slu
<script> <script>
function copy(id) { function copy(id) {
var el = document.getElementById(id); var el = document.getElementById(id);
if (!el) return;
navigator.clipboard.writeText(el.innerText).then(function() { navigator.clipboard.writeText(el.innerText).then(function() {
var btn = el.nextElementSibling; var btn = el.nextElementSibling;
if (!btn) return;
var orig = btn.textContent; var orig = btn.textContent;
btn.textContent = ' Kopiert!'; btn.textContent = ' Kopiert!';
setTimeout(function() { btn.textContent = orig; }, 1500); setTimeout(function() { btn.textContent = orig; }, 1500);
@ -134,7 +136,7 @@ const adminTmpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MORZ Infoboard Admin</title> <title>MORZ Infoboard Admin</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"> <link rel="stylesheet" href="/static/bulma.min.css">
<style> <style>
body { background: #f5f5f5; } body { background: #f5f5f5; }
.navbar { margin-bottom: 1.5rem; } .navbar { margin-bottom: 1.5rem; }
@ -152,9 +154,32 @@ const adminTmpl = `<!DOCTYPE html>
</div> </div>
<div id="adminNavbar" class="navbar-menu"> <div id="adminNavbar" class="navbar-menu">
<div class="navbar-start"> <div class="navbar-start">
<a class="navbar-item" href="/status">Diagnose</a>
</div> </div>
</div> </div>
</nav> </nav>
<!-- Lösch-Bestätigungs-Modal -->
<div id="delete-modal" class="modal">
<div class="modal-background" onclick="closeDeleteModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Bildschirm löschen?</p>
<button class="delete" aria-label="Schließen" onclick="closeDeleteModal()"></button>
</header>
<section class="modal-card-body">
<p>Soll <strong id="delete-modal-name"></strong> wirklich gelöscht werden?</p>
<p class="has-text-grey is-size-7 mt-2">Alle Playlist-Einträge werden ebenfalls gelöscht.</p>
</section>
<footer class="modal-card-foot">
<form id="delete-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeDeleteModal()">Abbrechen</button>
</footer>
</div>
</div>
<script> <script>
(function() { (function() {
var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]'); var burger = document.querySelector('.navbar-burger[data-target="adminNavbar"]');
@ -166,6 +191,18 @@ const adminTmpl = `<!DOCTYPE html>
}); });
} }
})(); })();
function openDeleteModal(action, name) {
document.getElementById('delete-modal-form').action = action;
document.getElementById('delete-modal-name').textContent = name;
document.getElementById('delete-modal').classList.add('is-active');
}
function closeDeleteModal() {
document.getElementById('delete-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeDeleteModal();
});
</script> </script>
<script> <script>
(function() { (function() {
@ -222,10 +259,10 @@ const adminTmpl = `<!DOCTYPE html>
<td> <td>
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a> <a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
&nbsp; &nbsp;
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline" <button class="button is-small is-danger is-outlined"
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')"> type="button"
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button> aria-label="Bildschirm {{.Name}} löschen"
</form> onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
</td> </td>
</tr> </tr>
{{end}} {{end}}
@ -374,8 +411,8 @@ const manageTmpl = `<!DOCTYPE html>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Playlist {{.Screen.Name}}</title> <title>Playlist {{.Screen.Name}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"> <link rel="stylesheet" href="/static/bulma.min.css">
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script> <script src="/static/Sortable.min.js"></script>
<style> <style>
body { background: #f5f5f5; } body { background: #f5f5f5; }
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; } .drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
@ -410,6 +447,26 @@ const manageTmpl = `<!DOCTYPE html>
</div> </div>
</nav> </nav>
<!-- Lösch-Bestätigungs-Modal -->
<div id="manage-delete-modal" class="modal">
<div class="modal-background" onclick="closeManageDeleteModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title" id="manage-delete-modal-title">Eintrag entfernen?</p>
<button class="delete" aria-label="Schließen" onclick="closeManageDeleteModal()"></button>
</header>
<section class="modal-card-body">
<p id="manage-delete-modal-body">Soll der Eintrag wirklich entfernt werden?</p>
</section>
<footer class="modal-card-foot">
<form id="manage-delete-modal-form" method="POST">
<button class="button is-danger" type="submit">Wirklich löschen</button>
</form>
<button class="button" onclick="closeManageDeleteModal()">Abbrechen</button>
</footer>
</div>
</div>
<script> <script>
(function() { (function() {
var msg = new URLSearchParams(window.location.search).get('msg'); var msg = new URLSearchParams(window.location.search).get('msg');
@ -437,6 +494,19 @@ const manageTmpl = `<!DOCTYPE html>
url.searchParams.delete('msg'); url.searchParams.delete('msg');
history.replaceState(null, '', url.toString()); history.replaceState(null, '', url.toString());
})(); })();
function openManageDeleteModal(action, title, body) {
document.getElementById('manage-delete-modal-form').action = action;
document.getElementById('manage-delete-modal-title').textContent = title;
document.getElementById('manage-delete-modal-body').textContent = body;
document.getElementById('manage-delete-modal').classList.add('is-active');
}
function closeManageDeleteModal() {
document.getElementById('manage-delete-modal').classList.remove('is-active');
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeManageDeleteModal();
});
</script> </script>
<section class="section pt-4"> <section class="section pt-4">
<div class="container"> <div class="container">
@ -460,7 +530,7 @@ const manageTmpl = `<!DOCTYPE html>
<tbody id="sortable-items"> <tbody id="sortable-items">
{{range .Items}} {{range .Items}}
<tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}"> <tr id="item-{{.ID}}" class="{{if not .Enabled}}item-disabled{{end}}">
<td class="drag-handle" title="Ziehen zum Sortieren"></td> <td class="drag-handle" role="button" aria-label="Reihenfolge ändern" tabindex="0" title="Ziehen zum Sortieren"></td>
<td> <td>
<span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span> <span class="tag is-light tag-type">{{typeIcon .Type}}&nbsp;{{.Type}}</span>
</td> </td>
@ -478,11 +548,11 @@ const manageTmpl = `<!DOCTYPE html>
</td> </td>
<td> <td>
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button> <button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete" <button class="button is-small is-danger is-outlined"
style="display:inline" type="button"
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')"> aria-label="{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} aus Playlist entfernen"
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen"></button> title="Entfernen"
</form> onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete', 'Eintrag entfernen?', 'Eintrag wirklich aus der Playlist entfernen?')"></button>
</td> </td>
</tr> </tr>
<tr id="edit-{{.ID}}" class="edit-row" style="display:none"> <tr id="edit-{{.ID}}" class="edit-row" style="display:none">
@ -575,11 +645,11 @@ const manageTmpl = `<!DOCTYPE html>
</form> </form>
&nbsp; &nbsp;
{{end}} {{end}}
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete" <button class="button is-small is-danger is-outlined"
style="display:inline" type="button"
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')"> aria-label="{{.Title}} aus Bibliothek löschen"
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button> title="Aus Bibliothek löschen"
</form> onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete', 'Medium löschen?', 'Medium wirklich aus der Bibliothek löschen? Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.')">🗑</button>
</td> </td>
</tr> </tr>
{{end}} {{end}}
@ -699,6 +769,7 @@ function switchTab(tab) {
panels.forEach(function(p) { panels.forEach(function(p) {
var panel = document.getElementById('panel-' + p); var panel = document.getElementById('panel-' + p);
var tabEl = document.getElementById('tab-' + p); var tabEl = document.getElementById('tab-' + p);
if (!panel || !tabEl) return;
if (p === tab) { if (p === tab) {
panel.classList.add('is-active'); panel.classList.add('is-active');
tabEl.classList.add('is-active'); tabEl.classList.add('is-active');

View file

@ -76,6 +76,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
// Serve uploaded files. // Serve uploaded files.
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir)))) mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadDir))))
// Serve embedded static assets (Bulma CSS, SortableJS) — no external CDN needed.
mux.HandleFunc("GET /static/bulma.min.css", manage.HandleStaticBulmaCSS())
mux.HandleFunc("GET /static/Sortable.min.js", manage.HandleStaticSortableJS())
// ── Admin UI ────────────────────────────────────────────────────────── // ── Admin UI ──────────────────────────────────────────────────────────
mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore)) mux.HandleFunc("GET /admin", manage.HandleAdminUI(d.TenantStore, d.ScreenStore))
mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore)) mux.HandleFunc("POST /admin/screens/provision", manage.HandleProvisionUI(d.TenantStore, d.ScreenStore))

View file

@ -239,9 +239,9 @@ func TestRouterScreenDetailPageRoute(t *testing.T) {
"tcp://127.0.0.1:1883", "tcp://127.0.0.1:1883",
"2026-03-22T16:09:30Z", "2026-03-22T16:09:30Z",
"/api/v1/screens/info01-dev/status", "/api/v1/screens/info01-dev/status",
"← All screens", "← Alle Bildschirme",
"Timing", "Zeitstempel",
"Endpoints", "Verbindungen",
"<meta http-equiv=\"refresh\" content=\"15\">", "<meta http-equiv=\"refresh\" content=\"15\">",
} { } {
if !strings.Contains(body, want) { if !strings.Contains(body, want) {
@ -264,7 +264,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
t.Fatalf("Content-Type = %q, want text/html", got) t.Fatalf("Content-Type = %q, want text/html", got)
} }
if !strings.Contains(w.Body.String(), "← Back to Screen Status") { if !strings.Contains(w.Body.String(), "← Zurück zum Bildschirmstatus") {
t.Fatal("body missing back link") t.Fatal("body missing back link")
} }
} }
@ -321,13 +321,13 @@ func TestRouterStatusPageRoute(t *testing.T) {
body := w.Body.String() body := w.Body.String()
for _, want := range []string{ for _, want := range []string{
"Screen Status", "Bildschirmstatus",
"2 screens", "2 Bildschirme",
"<meta http-equiv=\"refresh\" content=\"15\">", "<meta http-equiv=\"refresh\" content=\"15\">",
"Connectivity offline", "Konnektivität: Offline",
"Connectivity degraded", "Konnektivität: Eingeschränkt",
"Stale reports", "Veraltete Meldungen",
"Fresh reports", "Aktuelle Meldungen",
"updated_since=2026-03-22T15%3A55%3A00Z", "updated_since=2026-03-22T15%3A55%3A00Z",
"screen-offline", "screen-offline",
"offline", "offline",

View file

@ -432,15 +432,16 @@ var statusTemplateFuncs = template.FuncMap{
"screenDetailHTMLPath": screenDetailHTMLPath, "screenDetailHTMLPath": screenDetailHTMLPath,
"statusClass": statusClass, "statusClass": statusClass,
"timestampLabel": timestampLabel, "timestampLabel": timestampLabel,
"stateLabel": stateLabel,
} }
var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html> var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}"> <meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>Screen Status</title> <title>Bildschirmstatus</title>
` + statusPageCSSBlock + ` ` + statusPageCSSBlock + `
</head> </head>
<body> <body>
@ -448,19 +449,20 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<section class="hero"> <section class="hero">
<div class="hero-top"> <div class="hero-top">
<div> <div>
<h1>Screen Status</h1> <h1>Bildschirmstatus</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> <p class="lead">Kompakte Übersicht der zuletzt gemeldeten Bildschirmzustände. Offline- und eingeschränkte Bildschirme erscheinen oben für schnelle Diagnose.</p>
</div> </div>
<div class="meta"> <div class="meta">
<div>{{.Overview.Summary.Total}} screens</div> <div>{{.Overview.Summary.Total}} Bildschirme</div>
<div>Updated {{.GeneratedAt}}</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> </div>
<div class="summary-grid"> <div class="summary-grid">
<article class="summary-card"> <article class="summary-card">
<strong>{{.Overview.Summary.Total}}</strong> <strong>{{.Overview.Summary.Total}}</strong>
<span>Total known screens</span> <span>Bildschirme gesamt</span>
</article> </article>
<article class="summary-card offline"> <article class="summary-card offline">
<strong>{{.Overview.Summary.Offline}}</strong> <strong>{{.Overview.Summary.Offline}}</strong>
@ -468,7 +470,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</article> </article>
<article class="summary-card degraded"> <article class="summary-card degraded">
<strong>{{.Overview.Summary.Degraded}}</strong> <strong>{{.Overview.Summary.Degraded}}</strong>
<span>Degraded</span> <span>Eingeschränkt</span>
</article> </article>
<article class="summary-card online"> <article class="summary-card online">
<strong>{{.Overview.Summary.Online}}</strong> <strong>{{.Overview.Summary.Online}}</strong>
@ -476,7 +478,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</article> </article>
<article class="summary-card"> <article class="summary-card">
<strong>{{.Overview.Summary.Stale}}</strong> <strong>{{.Overview.Summary.Stale}}</strong>
<span>Stale reports</span> <span>Veraltete Meldungen</span>
</article> </article>
</div> </div>
</section> </section>
@ -484,15 +486,15 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Filters and refresh</h2> <h2>Filter und Aktualisierung</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> <p class="panel-copy">Diese Seite aktualisiert sich alle {{.RefreshSeconds}} Sekunden. Verwende die Schnellfilter oder das Formular, um die Ansicht einzugrenzen.</p>
</div> </div>
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON overview</a> <a class="meta-chip" href="{{.StatusAPIPath}}">JSON-Übersicht</a>
</div> </div>
<div class="controls-grid"> <div class="controls-grid">
<div> <div>
<h2>Quick views</h2> <h2>Schnellansichten</h2>
<div class="quick-filters"> <div class="quick-filters">
{{range .QuickFilters}} {{range .QuickFilters}}
<a class="filter-link {{.Class}} {{if .Active}}active{{end}}" href="{{.Href}}">{{.Label}}</a> <a class="filter-link {{.Class}} {{if .Active}}active{{end}}" href="{{.Href}}">{{.Label}}</a>
@ -502,42 +504,42 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<form class="filter-form" method="get" action="{{.StatusPagePath}}"> <form class="filter-form" method="get" action="{{.StatusPagePath}}">
<div class="field full"> <div class="field full">
<label for="q">Screen ID contains</label> <label for="q">Screen-ID enthält</label>
<input id="q" name="q" type="text" placeholder="e.g. info01" value="{{.Filters.ScreenIDFilter}}"> <input id="q" name="q" type="text" placeholder="z.B. info01" value="{{.Filters.ScreenIDFilter}}">
</div> </div>
<div class="field"> <div class="field">
<label for="server_connectivity">Server connectivity</label> <label for="server_connectivity">Serverkonnektivität</label>
<select id="server_connectivity" name="server_connectivity"> <select id="server_connectivity" name="server_connectivity">
<option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Any</option> <option value="" {{if eq .Filters.ServerConnectivity ""}}selected{{end}}>Alle</option>
<option value="online" {{if eq .Filters.ServerConnectivity "online"}}selected{{end}}>Online</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="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="offline" {{if eq .Filters.ServerConnectivity "offline"}}selected{{end}}>Offline</option>
<option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unknown</option> <option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unbekannt</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="stale">Freshness</label> <label for="stale">Aktualität</label>
<select id="stale" name="stale"> <select id="stale" name="stale">
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Any</option> <option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Alle</option>
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Stale only</option> <option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Nur veraltet</option>
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Fresh only</option> <option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Nur aktuell</option>
</select> </select>
</div> </div>
<div class="field"> <div class="field">
<label for="derived_state">Derived state</label> <label for="derived_state">Abgeleiteter Status</label>
<select id="derived_state" name="derived_state"> <select id="derived_state" name="derived_state">
<option value="" {{if eq .Filters.DerivedState ""}}selected{{end}}>Any</option> <option value="" {{if eq .Filters.DerivedState ""}}selected{{end}}>Alle</option>
<option value="online" {{if eq .Filters.DerivedState "online"}}selected{{end}}>Online</option> <option value="online" {{if eq .Filters.DerivedState "online"}}selected{{end}}>Online</option>
<option value="degraded" {{if eq .Filters.DerivedState "degraded"}}selected{{end}}>Degraded</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> <option value="offline" {{if eq .Filters.DerivedState "offline"}}selected{{end}}>Offline</option>
</select> </select>
</div> </div>
<div class="field full"> <div class="field full">
<label for="updated_since">Updated since (RFC3339)</label> <label for="updated_since">Aktualisiert seit (RFC3339)</label>
<input id="updated_since" name="updated_since" type="text" placeholder="2026-03-22T16:05:00Z" value="{{.Filters.UpdatedSince}}"> <input id="updated_since" name="updated_since" type="text" placeholder="2026-03-22T16:05:00Z" value="{{.Filters.UpdatedSince}}">
</div> </div>
@ -547,8 +549,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit">Apply filters</button> <button type="submit">Filter anwenden</button>
<a class="text-link" href="{{.StatusPagePath}}">Clear</a> <a class="text-link" href="{{.StatusPagePath}}">Zurücksetzen</a>
</div> </div>
</form> </form>
</div> </div>
@ -557,23 +559,23 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Latest reports</h2> <h2>Aktuelle Meldungen</h2>
<p class="panel-copy">Each row links to the HTML detail view and the raw JSON endpoint for a quick drill-down.</p> <p class="panel-copy">Jede Zeile verlinkt auf die HTML-Detailansicht und den JSON-Endpunkt.</p>
</div> </div>
</div> </div>
<div class="table-actions"> <div class="table-actions">
<a class="text-link" href="{{.StatusAPIPath}}">Open filtered JSON overview</a> <a class="text-link" href="{{.StatusAPIPath}}">Gefilterte JSON-Übersicht öffnen</a>
</div> </div>
{{if .Overview.Screens}} {{if .Overview.Screens}}
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Screen</th> <th>Bildschirm</th>
<th>Derived state</th> <th>Status</th>
<th>Player status</th> <th>Player-Status</th>
<th>Server link</th> <th>Server</th>
<th>Received</th> <th>Empfangen</th>
<th>Heartbeat</th> <th>Heartbeat</th>
</tr> </tr>
</thead> </thead>
@ -582,28 +584,28 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
<tr> <tr>
<td> <td>
<div class="screen">{{.ScreenID}}</div> <div class="screen">{{.ScreenID}}</div>
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>No endpoint details</small>{{end}} {{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>Keine Verbindungsdetails</small>{{end}}
<div class="screen-links"> <div class="screen-links">
<a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a> <a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a>
<a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a> <a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a>
</div> </div>
</td> </td>
<td><span class="pill {{statusClass .DerivedState}}">{{.DerivedState}}</span></td> <td><span class="pill {{statusClass .DerivedState}}">{{stateLabel .DerivedState}}</span></td>
<td> <td>
<div>{{.Status}}</div> <div>{{.Status}}</div>
{{if .Stale}}<small>Marked stale by server freshness check</small>{{else}}<small>Fresh within expected heartbeat window</small>{{end}} {{if .Stale}}<small>Vom Server als veraltet markiert</small>{{else}}<small>Aktuell im erwarteten Heartbeat-Fenster</small>{{end}}
</td> </td>
<td> <td>
<span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span> <span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span>
{{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}} {{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}}
</td> </td>
<td> <td>
<div>{{timestampLabel .ReceivedAt}}</div> <div><time class="reltime" datetime="{{.ReceivedAt}}">{{timestampLabel .ReceivedAt}}</time></div>
{{if .LastHeartbeatAt}}<small>Heartbeat {{timestampLabel .LastHeartbeatAt}}</small>{{end}} {{if .LastHeartbeatAt}}<small>Heartbeat <time class="reltime" datetime="{{.LastHeartbeatAt}}">{{timestampLabel .LastHeartbeatAt}}</time></small>{{end}}
</td> </td>
<td> <td>
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}} {{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}}
{{if .StartedAt}}<small>Started {{timestampLabel .StartedAt}}</small>{{end}} {{if .StartedAt}}<small>Gestartet <time class="reltime" datetime="{{.StartedAt}}">{{timestampLabel .StartedAt}}</time></small>{{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}
@ -611,21 +613,48 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
</table> </table>
</div> </div>
{{else}} {{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> <p class="empty">Noch kein Bildschirm hat einen Status gemeldet. Sobald ein Player den Status-API-Endpunkt aufruft, erscheint er hier automatisch.</p>
{{end}} {{end}}
</section> </section>
</main> </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> </body>
</html> </html>
`)) `))
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html> var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="{{.RefreshSeconds}}"> <meta http-equiv="refresh" content="{{.RefreshSeconds}}">
<title>{{.Record.ScreenID}} Screen Status</title> <title>{{.Record.ScreenID}} Bildschirmstatus</title>
` + statusPageCSSBlock + ` ` + statusPageCSSBlock + `
</head> </head>
<body> <body>
@ -634,12 +663,13 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<div class="hero-top"> <div class="hero-top">
<div> <div>
<h1>{{.Record.ScreenID}}</h1> <h1>{{.Record.ScreenID}}</h1>
<p class="lead">Single screen diagnostic view based on the last accepted status report.</p> <p class="lead">Detailansicht auf Basis des zuletzt akzeptierten Status-Reports.</p>
</div> </div>
<div class="meta"> <div class="meta">
<div>Updated {{.GeneratedAt}}</div> <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;"> <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="{{.StatusPagePath}}"> Alle Bildschirme</a>
<a class="meta-chip" href="/admin"> Admin</a>
<a class="meta-chip" href="{{screenDetailPath .Record.ScreenID}}">JSON</a> <a class="meta-chip" href="{{screenDetailPath .Record.ScreenID}}">JSON</a>
</div> </div>
</div> </div>
@ -647,20 +677,20 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<div class="summary-grid"> <div class="summary-grid">
<article class="summary-card {{statusClass .Record.DerivedState}}"> <article class="summary-card {{statusClass .Record.DerivedState}}">
<strong><span class="state {{statusClass .Record.DerivedState}}">{{.Record.DerivedState}}</span></strong> <strong><span class="state {{statusClass .Record.DerivedState}}">{{stateLabel .Record.DerivedState}}</span></strong>
<span>Derived state</span> <span>Abgeleiteter Status</span>
</article> </article>
<article class="summary-card"> <article class="summary-card">
<strong>{{.Record.Status}}</strong> <strong>{{.Record.Status}}</strong>
<span>Player status</span> <span>Player-Status</span>
</article> </article>
<article class="summary-card {{statusClass .Record.ServerConnectivity}}"> <article class="summary-card {{statusClass .Record.ServerConnectivity}}">
<strong>{{connectivityLabel .Record.ServerConnectivity}}</strong> <strong>{{connectivityLabel .Record.ServerConnectivity}}</strong>
<span>Server connectivity</span> <span>Serverkonnektivität</span>
</article> </article>
<article class="summary-card"> <article class="summary-card">
<strong>{{if .Record.Stale}}stale{{else}}fresh{{end}}</strong> <strong>{{if .Record.Stale}}Veraltet{{else}}Aktuell{{end}}</strong>
<span>Freshness</span> <span>Aktualität</span>
</article> </article>
</div> </div>
</section> </section>
@ -668,30 +698,30 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Timing</h2> <h2>Zeitstempel</h2>
<p class="panel-copy">Timestamps reported by the player and annotated by the server at receive time.</p> <p class="panel-copy">Vom Player gemeldete und vom Server beim Empfang ergänzte Zeitstempel.</p>
</div> </div>
</div> </div>
<table class="detail-table"> <table class="detail-table">
<tbody> <tbody>
<tr> <tr>
<th>Received at (server)</th> <th>Empfangen (Server)</th>
<td>{{timestampLabel .Record.ReceivedAt}}</td> <td><time class="reltime" datetime="{{.Record.ReceivedAt}}">{{timestampLabel .Record.ReceivedAt}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Player timestamp</th> <th>Player-Zeitstempel</th>
<td>{{timestampLabel .Record.Timestamp}}</td> <td><time class="reltime" datetime="{{.Record.Timestamp}}">{{timestampLabel .Record.Timestamp}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Started at</th> <th>Gestartet</th>
<td>{{timestampLabel .Record.StartedAt}}</td> <td><time class="reltime" datetime="{{.Record.StartedAt}}">{{timestampLabel .Record.StartedAt}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Last heartbeat at</th> <th>Letzter Heartbeat</th>
<td>{{timestampLabel .Record.LastHeartbeatAt}}</td> <td><time class="reltime" datetime="{{.Record.LastHeartbeatAt}}">{{timestampLabel .Record.LastHeartbeatAt}}</time></td>
</tr> </tr>
<tr> <tr>
<th>Heartbeat interval</th> <th>Heartbeat-Intervall</th>
<td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td> <td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td>
</tr> </tr>
</tbody> </tbody>
@ -701,34 +731,61 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
<h2>Endpoints</h2> <h2>Verbindungen</h2>
<p class="panel-copy">Connection details reported by the player in the last accepted status.</p> <p class="panel-copy">Verbindungsdetails aus dem zuletzt akzeptierten Status-Report.</p>
</div> </div>
</div> </div>
<table class="detail-table"> <table class="detail-table">
<tbody> <tbody>
<tr> <tr>
<th>Server URL</th> <th>Server-URL</th>
<td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td> <td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td>
</tr> </tr>
<tr> <tr>
<th>MQTT broker</th> <th>MQTT-Broker</th>
<td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td> <td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</section> </section>
</main> </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> </body>
</html> </html>
`)) `))
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html> var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Invalid filter Screen Status</title> <title>Ungültiger Filter Bildschirmstatus</title>
` + statusPageCSSBlock + ` ` + statusPageCSSBlock + `
</head> </head>
<body> <body>
@ -736,11 +793,11 @@ var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(s
<section class="hero"> <section class="hero">
<div class="hero-top"> <div class="hero-top">
<div> <div>
<h1>Invalid filter</h1> <h1>Ungültiger Filter</h1>
<p class="lead">{{.Message}}</p> <p class="lead">{{.Message}}</p>
</div> </div>
</div> </div>
<a class="filter-link" href="{{.StatusPagePath}}"> Back to Screen Status</a> <a class="filter-link" href="{{.StatusPagePath}}"> Zurück zum Bildschirmstatus</a>
</section> </section>
</main> </main>
</body> </body>
@ -773,7 +830,7 @@ func handleScreenDetailPage(store playerStatusStore) http.HandlerFunc {
record, ok := store.Get(screenID) record, ok := store.Get(screenID)
if !ok { if !ok {
data := statusPageErrorData{ data := statusPageErrorData{
Message: "Fuer diesen Screen liegt noch kein Status vor.", Message: "Für diesen Screen liegt noch kein Status vor.",
StatusPagePath: "/status", StatusPagePath: "/status",
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -826,31 +883,31 @@ func buildStatusQuickFilters(filters statusPageFilters) []statusFilterLink {
base := statusPageFilters{ScreenIDFilter: filters.ScreenIDFilter, Limit: filters.Limit, UpdatedSince: filters.UpdatedSince} base := statusPageFilters{ScreenIDFilter: filters.ScreenIDFilter, Limit: filters.Limit, UpdatedSince: filters.UpdatedSince}
return []statusFilterLink{ return []statusFilterLink{
{ {
Label: "All screens", Label: "Alle Bildschirme",
Href: buildStatusPageHref(base), Href: buildStatusPageHref(base),
Class: "", Class: "",
Active: filters.ServerConnectivity == "" && filters.Stale == "", Active: filters.ServerConnectivity == "" && filters.Stale == "",
}, },
{ {
Label: "Connectivity offline", Label: "Konnektivität: Offline",
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "offline", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "offline", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "offline", Class: "offline",
Active: filters.ServerConnectivity == "offline" && filters.Stale == "", Active: filters.ServerConnectivity == "offline" && filters.Stale == "",
}, },
{ {
Label: "Connectivity degraded", Label: "Konnektivität: Eingeschränkt",
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "degraded", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, ServerConnectivity: "degraded", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "degraded", Class: "degraded",
Active: filters.ServerConnectivity == "degraded" && filters.Stale == "", Active: filters.ServerConnectivity == "degraded" && filters.Stale == "",
}, },
{ {
Label: "Stale reports", Label: "Veraltete Meldungen",
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "true", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "true", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "", Class: "",
Active: filters.ServerConnectivity == "" && filters.Stale == "true", Active: filters.ServerConnectivity == "" && filters.Stale == "true",
}, },
{ {
Label: "Fresh reports", Label: "Aktuelle Meldungen",
Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "false", Limit: base.Limit, UpdatedSince: base.UpdatedSince}), Href: buildStatusPageHref(statusPageFilters{ScreenIDFilter: base.ScreenIDFilter, Stale: "false", Limit: base.Limit, UpdatedSince: base.UpdatedSince}),
Class: "online", Class: "online",
Active: filters.ServerConnectivity == "" && filters.Stale == "false", Active: filters.ServerConnectivity == "" && filters.Stale == "false",
@ -935,3 +992,22 @@ func timestampLabel(value string) string {
} }
return trimmed 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
}
}