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:
parent
883a8146c5
commit
62c1b8cd5c
9 changed files with 388 additions and 129 deletions
24
TODO.md
24
TODO.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
server/backend/internal/httpapi/manage/static.go
Normal file
30
server/backend/internal/httpapi/manage/static.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
2
server/backend/internal/httpapi/manage/static/Sortable.min.js
vendored
Normal file
2
server/backend/internal/httpapi/manage/static/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
server/backend/internal/httpapi/manage/static/bulma.min.css
vendored
Normal file
3
server/backend/internal/httpapi/manage/static/bulma.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
||||||
<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}} {{.Type}}</span>
|
<span class="tag is-light tag-type">{{typeIcon .Type}} {{.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>
|
||||||
|
|
||||||
{{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');
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue