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] Minimale Player-UI bauen
|
||||
- [ ] 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
|
||||
- [ ] `valid_from`/`valid_until` im Prototyp pruefen
|
||||
- [x] Offline-Sync mit lokalem Cache pruefen
|
||||
|
|
@ -138,20 +138,20 @@
|
|||
|
||||
### Hohe Prioritaet
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens)
|
||||
- [x] Flash-Messages nach Aktionen in Manage-UI (Upload, Loeschen, Speichern) — Feedback fuer den Nutzer
|
||||
- [x] Screen-Online/Offline-Status in Admin-Tabelle anzeigen (aus /status-Endpoint befuellen)
|
||||
- [x] Playlist-Tabelle in overflow-x Wrapper einwickeln (Responsive auf kleinen Screens)
|
||||
|
||||
### Mittlere Prioritaet
|
||||
|
||||
- [ ] Loesch-Bestaetigung: Bulma-Modal statt browser-nativer confirm()-Dialog
|
||||
- [ ] Status-Page: Sprache von Englisch auf Deutsch vereinheitlichen
|
||||
- [ ] Status-Page: Relative Zeitstempel statt RFC3339 ("vor 2 Minuten")
|
||||
- [ ] Querlinks zwischen Admin-UI und Status-Page (Navigation)
|
||||
- [ ] Bulma und SortableJS als lokale Assets einbetten statt CDN
|
||||
- [ ] Player-UI: CSS-Transitions fuer sanfte Content-Wechsel (Fade statt abrupt)
|
||||
- [ ] Player-UI: Erweitertes Sysinfo-Overlay (aktueller Titel, Playlist-Laenge)
|
||||
- [ ] Aria-Labels fuer Loesch-Buttons und Drag-Handles (Accessibility)
|
||||
- [x] Loesch-Bestaetigung: Bulma-Modal statt browser-nativer confirm()-Dialog
|
||||
- [x] Status-Page: Sprache von Englisch auf Deutsch vereinheitlichen
|
||||
- [x] Status-Page: Relative Zeitstempel statt RFC3339 ("vor 2 Minuten")
|
||||
- [x] Querlinks zwischen Admin-UI und Status-Page (Navigation)
|
||||
- [x] Bulma und SortableJS als lokale Assets einbetten statt CDN
|
||||
- [x] Player-UI: CSS-Transitions fuer sanfte Content-Wechsel (Fade statt abrupt)
|
||||
- [x] Player-UI: Erweitertes Sysinfo-Overlay (aktueller Titel, Playlist-Laenge)
|
||||
- [x] Aria-Labels fuer Loesch-Buttons und Drag-Handles (Accessibility)
|
||||
|
||||
### Niedrige Prioritaet
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,8 @@ const playerHTML = `<!DOCTYPE html>
|
|||
position: fixed; inset: 0;
|
||||
width: 100%; height: 100%;
|
||||
border: none; display: none; z-index: 10;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
#img-view {
|
||||
object-fit: contain;
|
||||
|
|
@ -264,9 +266,30 @@ const playerHTML = `<!DOCTYPE html>
|
|||
window.addEventListener('resize', updateSplash);
|
||||
|
||||
// ── 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 = '';
|
||||
(items || []).forEach(function(item) {
|
||||
all.forEach(function(item) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'info-item';
|
||||
el.innerHTML =
|
||||
|
|
@ -286,6 +309,11 @@ const playerHTML = `<!DOCTYPE html>
|
|||
var currentIdx = 0;
|
||||
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.
|
||||
function playlistKey(pl) {
|
||||
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.
|
||||
// Blendet zunächst auf opacity:0 aus und entfernt display erst nach der
|
||||
// Transition (500ms), damit der Fade-Out sichtbar ist.
|
||||
function hideAllContent() {
|
||||
frame.style.display = 'none';
|
||||
imgView.style.display = 'none';
|
||||
videoView.style.display = 'none';
|
||||
frameError.style.display = 'none';
|
||||
// Laufendes Video stoppen damit kein Audio weiterläuft.
|
||||
// Laufendes Video sofort stoppen damit kein Audio weiterläuft.
|
||||
videoView.pause();
|
||||
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).
|
||||
|
|
@ -325,23 +361,38 @@ const playerHTML = `<!DOCTYPE html>
|
|||
}, ms);
|
||||
}
|
||||
|
||||
// TRANSITION_MS muss mit der CSS-Transition-Dauer übereinstimmen.
|
||||
var TRANSITION_MS = 500;
|
||||
|
||||
function showItem(item) {
|
||||
if (!item) { showSplash(); return; }
|
||||
|
||||
// Erst Fade-Out des aktuellen Inhalts abwarten, dann neuen anzeigen.
|
||||
hideAllContent();
|
||||
hideSplash();
|
||||
overlay.style.display = 'none';
|
||||
|
||||
setTimeout(function() { displayItem(item); }, TRANSITION_MS);
|
||||
}
|
||||
|
||||
function displayItem(item) {
|
||||
var type = item.type || 'web';
|
||||
|
||||
if (type === 'image') {
|
||||
// display setzen, dann per doppeltem rAF opacity auf 1 für Fade-In.
|
||||
imgView.src = item.src;
|
||||
imgView.style.display = '';
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() { imgView.style.opacity = '1'; });
|
||||
});
|
||||
scheduleNext(item.duration_seconds);
|
||||
|
||||
} else if (type === 'video') {
|
||||
videoView.src = item.src;
|
||||
videoView.style.display = '';
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() { videoView.style.opacity = '1'; });
|
||||
});
|
||||
videoView.load();
|
||||
videoView.play().catch(function() {});
|
||||
// Nach Ablauf der konfigurierten Dauer oder am Ende des Videos rotieren.
|
||||
|
|
@ -361,8 +412,11 @@ const playerHTML = `<!DOCTYPE html>
|
|||
// type === 'web' oder unbekannt → iframe
|
||||
frame.src = item.src;
|
||||
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.onload = function() {
|
||||
// Prüfen ob der iframe-Inhalt zugänglich ist. Bei cross-origin-Blockierung
|
||||
|
|
@ -410,11 +464,16 @@ const playerHTML = `<!DOCTYPE html>
|
|||
var lastPlaylistKey = '';
|
||||
|
||||
function applyNowPlaying(data) {
|
||||
// Connectivity-Punkt und dynamische Overlay-Variable aktualisieren.
|
||||
dot.className = data.connectivity || '';
|
||||
dynConnectivity = data.connectivity || '';
|
||||
|
||||
// Legacy single-URL fallback.
|
||||
if (data.url && (!data.playlist || data.playlist.length === 0)) {
|
||||
var key = data.url + ':legacy';
|
||||
dynPlaylistLength = 1;
|
||||
dynCurrentTitle = data.url;
|
||||
renderSysInfo();
|
||||
if (lastPlaylistKey !== key) {
|
||||
lastPlaylistKey = key;
|
||||
items = [{ src: data.url, type: 'web', duration_seconds: 30 }];
|
||||
|
|
@ -425,13 +484,23 @@ const playerHTML = `<!DOCTYPE html>
|
|||
}
|
||||
|
||||
var playlist = data.playlist || [];
|
||||
dynPlaylistLength = playlist.length;
|
||||
if (playlist.length === 0) {
|
||||
dynCurrentTitle = '';
|
||||
renderSysInfo();
|
||||
showSplash();
|
||||
lastPlaylistKey = '';
|
||||
items = [];
|
||||
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);
|
||||
if (key === lastPlaylistKey) {
|
||||
return; // unchanged — let current rotation continue
|
||||
|
|
@ -441,6 +510,8 @@ const playerHTML = `<!DOCTYPE html>
|
|||
lastPlaylistKey = key;
|
||||
items = playlist;
|
||||
currentIdx = 0;
|
||||
dynCurrentTitle = items[0].title || items[0].src || '';
|
||||
renderSysInfo();
|
||||
showItem(items[0]);
|
||||
}
|
||||
|
||||
|
|
@ -448,6 +519,8 @@ const playerHTML = `<!DOCTYPE html>
|
|||
function pollSysInfo() {
|
||||
fetch('/api/sysinfo')
|
||||
.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); })
|
||||
.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 name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
body { background: #f5f5f5; }
|
||||
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>
|
||||
function copy(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
navigator.clipboard.writeText(el.innerText).then(function() {
|
||||
var btn = el.nextElementSibling;
|
||||
if (!btn) return;
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = '✓ Kopiert!';
|
||||
setTimeout(function() { btn.textContent = orig; }, 1500);
|
||||
|
|
@ -134,7 +136,7 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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>
|
||||
body { background: #f5f5f5; }
|
||||
.navbar { margin-bottom: 1.5rem; }
|
||||
|
|
@ -152,9 +154,32 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
<div id="adminNavbar" class="navbar-menu">
|
||||
<div class="navbar-start">
|
||||
<a class="navbar-item" href="/status">Diagnose</a>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
(function() {
|
||||
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>
|
||||
(function() {
|
||||
|
|
@ -222,10 +259,10 @@ const adminTmpl = `<!DOCTYPE html>
|
|||
<td>
|
||||
<a class="button is-small is-link" href="/manage/{{.Slug}}">Playlist verwalten</a>
|
||||
|
||||
<form method="POST" action="/admin/screens/{{.ID}}/delete" style="display:inline"
|
||||
onsubmit="return confirm('Bildschirm löschen?\n\nAlle Playlist-Einträge werden ebenfalls gelöscht.')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit">Löschen</button>
|
||||
</form>
|
||||
<button class="button is-small is-danger is-outlined"
|
||||
type="button"
|
||||
aria-label="Bildschirm {{.Name}} löschen"
|
||||
onclick="openDeleteModal('/admin/screens/{{.ID}}/delete', '{{.Name}}')">Löschen</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
@ -374,8 +411,8 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playlist – {{.Screen.Name}}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.3/Sortable.min.js"></script>
|
||||
<link rel="stylesheet" href="/static/bulma.min.css">
|
||||
<script src="/static/Sortable.min.js"></script>
|
||||
<style>
|
||||
body { background: #f5f5f5; }
|
||||
.drag-handle { cursor: grab; color: #aaa; font-size: 1.2em; user-select: none; }
|
||||
|
|
@ -410,6 +447,26 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</div>
|
||||
</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>
|
||||
(function() {
|
||||
var msg = new URLSearchParams(window.location.search).get('msg');
|
||||
|
|
@ -437,6 +494,19 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
url.searchParams.delete('msg');
|
||||
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>
|
||||
<section class="section pt-4">
|
||||
<div class="container">
|
||||
|
|
@ -460,7 +530,7 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
<tbody id="sortable-items">
|
||||
{{range .Items}}
|
||||
<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>
|
||||
<span class="tag is-light tag-type">{{typeIcon .Type}} {{.Type}}</span>
|
||||
</td>
|
||||
|
|
@ -478,11 +548,11 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</td>
|
||||
<td>
|
||||
<button class="button is-small is-info is-outlined" onclick="toggleEdit('{{.ID}}')">Bearbeiten</button>
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Eintrag wirklich aus der Playlist entfernen?')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit" title="Entfernen">✕</button>
|
||||
</form>
|
||||
<button class="button is-small is-danger is-outlined"
|
||||
type="button"
|
||||
aria-label="{{if .Title}}{{.Title}}{{else}}Eintrag{{end}} aus Playlist entfernen"
|
||||
title="Entfernen"
|
||||
onclick="openManageDeleteModal('/manage/{{$.Screen.Slug}}/items/{{.ID}}/delete', 'Eintrag entfernen?', 'Eintrag wirklich aus der Playlist entfernen?')">✕</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="edit-{{.ID}}" class="edit-row" style="display:none">
|
||||
|
|
@ -575,11 +645,11 @@ const manageTmpl = `<!DOCTYPE html>
|
|||
</form>
|
||||
|
||||
{{end}}
|
||||
<form method="POST" action="/manage/{{$.Screen.Slug}}/media/{{.ID}}/delete"
|
||||
style="display:inline"
|
||||
onsubmit="return confirm('Medium wirklich aus der Bibliothek löschen?\n(Playlist-Einträge bleiben bestehen, zeigen dann aber nichts an.)')">
|
||||
<button class="button is-small is-danger is-outlined" type="submit" title="Aus Bibliothek löschen">🗑</button>
|
||||
</form>
|
||||
<button class="button is-small is-danger is-outlined"
|
||||
type="button"
|
||||
aria-label="{{.Title}} aus Bibliothek löschen"
|
||||
title="Aus Bibliothek löschen"
|
||||
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>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
@ -699,6 +769,7 @@ function switchTab(tab) {
|
|||
panels.forEach(function(p) {
|
||||
var panel = document.getElementById('panel-' + p);
|
||||
var tabEl = document.getElementById('tab-' + p);
|
||||
if (!panel || !tabEl) return;
|
||||
if (p === tab) {
|
||||
panel.classList.add('is-active');
|
||||
tabEl.classList.add('is-active');
|
||||
|
|
|
|||
|
|
@ -76,6 +76,10 @@ func registerManageRoutes(mux *http.ServeMux, d RouterDeps) {
|
|||
// Serve uploaded files.
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
mux.HandleFunc("GET /admin", manage.HandleAdminUI(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",
|
||||
"2026-03-22T16:09:30Z",
|
||||
"/api/v1/screens/info01-dev/status",
|
||||
"← All screens",
|
||||
"Timing",
|
||||
"Endpoints",
|
||||
"← Alle Bildschirme",
|
||||
"Zeitstempel",
|
||||
"Verbindungen",
|
||||
"<meta http-equiv=\"refresh\" content=\"15\">",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
|
|
@ -264,7 +264,7 @@ func TestRouterScreenDetailPageNotFound(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -321,13 +321,13 @@ func TestRouterStatusPageRoute(t *testing.T) {
|
|||
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{
|
||||
"Screen Status",
|
||||
"2 screens",
|
||||
"Bildschirmstatus",
|
||||
"2 Bildschirme",
|
||||
"<meta http-equiv=\"refresh\" content=\"15\">",
|
||||
"Connectivity offline",
|
||||
"Connectivity degraded",
|
||||
"Stale reports",
|
||||
"Fresh reports",
|
||||
"Konnektivität: Offline",
|
||||
"Konnektivität: Eingeschränkt",
|
||||
"Veraltete Meldungen",
|
||||
"Aktuelle Meldungen",
|
||||
"updated_since=2026-03-22T15%3A55%3A00Z",
|
||||
"screen-offline",
|
||||
"offline",
|
||||
|
|
|
|||
|
|
@ -432,15 +432,16 @@ var statusTemplateFuncs = template.FuncMap{
|
|||
"screenDetailHTMLPath": screenDetailHTMLPath,
|
||||
"statusClass": statusClass,
|
||||
"timestampLabel": timestampLabel,
|
||||
"stateLabel": stateLabel,
|
||||
}
|
||||
|
||||
var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
|
||||
<title>Screen Status</title>
|
||||
<title>Bildschirmstatus</title>
|
||||
` + statusPageCSSBlock + `
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -448,19 +449,20 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<section class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>Screen Status</h1>
|
||||
<p class="lead">A compact browser view of the latest screen reports from the current in-memory status overview. Offline and degraded screens stay at the top for quick diagnostics.</p>
|
||||
<h1>Bildschirmstatus</h1>
|
||||
<p class="lead">Kompakte Übersicht der zuletzt gemeldeten Bildschirmzustände. Offline- und eingeschränkte Bildschirme erscheinen oben für schnelle Diagnose.</p>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>{{.Overview.Summary.Total}} screens</div>
|
||||
<div>Updated {{.GeneratedAt}}</div>
|
||||
<div>{{.Overview.Summary.Total}} Bildschirme</div>
|
||||
<div>Aktualisiert <time id="generated-at" datetime="{{.GeneratedAt}}">{{.GeneratedAt}}</time></div>
|
||||
<div style="margin-top: 8px;"><a class="meta-chip" href="/admin">← Admin</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card">
|
||||
<strong>{{.Overview.Summary.Total}}</strong>
|
||||
<span>Total known screens</span>
|
||||
<span>Bildschirme gesamt</span>
|
||||
</article>
|
||||
<article class="summary-card offline">
|
||||
<strong>{{.Overview.Summary.Offline}}</strong>
|
||||
|
|
@ -468,7 +470,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</article>
|
||||
<article class="summary-card degraded">
|
||||
<strong>{{.Overview.Summary.Degraded}}</strong>
|
||||
<span>Degraded</span>
|
||||
<span>Eingeschränkt</span>
|
||||
</article>
|
||||
<article class="summary-card online">
|
||||
<strong>{{.Overview.Summary.Online}}</strong>
|
||||
|
|
@ -476,7 +478,7 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</article>
|
||||
<article class="summary-card">
|
||||
<strong>{{.Overview.Summary.Stale}}</strong>
|
||||
<span>Stale reports</span>
|
||||
<span>Veraltete Meldungen</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -484,15 +486,15 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Filters and refresh</h2>
|
||||
<p class="panel-copy">This page refreshes every {{.RefreshSeconds}} seconds. Use the shortcut links or the form to narrow the existing connectivity and freshness filters without leaving the lightweight server-rendered flow.</p>
|
||||
<h2>Filter und Aktualisierung</h2>
|
||||
<p class="panel-copy">Diese Seite aktualisiert sich alle {{.RefreshSeconds}} Sekunden. Verwende die Schnellfilter oder das Formular, um die Ansicht einzugrenzen.</p>
|
||||
</div>
|
||||
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON overview</a>
|
||||
<a class="meta-chip" href="{{.StatusAPIPath}}">JSON-Übersicht</a>
|
||||
</div>
|
||||
|
||||
<div class="controls-grid">
|
||||
<div>
|
||||
<h2>Quick views</h2>
|
||||
<h2>Schnellansichten</h2>
|
||||
<div class="quick-filters">
|
||||
{{range .QuickFilters}}
|
||||
<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}}">
|
||||
<div class="field full">
|
||||
<label for="q">Screen ID contains</label>
|
||||
<input id="q" name="q" type="text" placeholder="e.g. info01" value="{{.Filters.ScreenIDFilter}}">
|
||||
<label for="q">Screen-ID enthält</label>
|
||||
<input id="q" name="q" type="text" placeholder="z.B. info01" value="{{.Filters.ScreenIDFilter}}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="server_connectivity">Server connectivity</label>
|
||||
<label for="server_connectivity">Serverkonnektivität</label>
|
||||
<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="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="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unknown</option>
|
||||
<option value="unknown" {{if eq .Filters.ServerConnectivity "unknown"}}selected{{end}}>Unbekannt</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="stale">Freshness</label>
|
||||
<label for="stale">Aktualität</label>
|
||||
<select id="stale" name="stale">
|
||||
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Any</option>
|
||||
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Stale only</option>
|
||||
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Fresh only</option>
|
||||
<option value="" {{if eq .Filters.Stale ""}}selected{{end}}>Alle</option>
|
||||
<option value="true" {{if eq .Filters.Stale "true"}}selected{{end}}>Nur veraltet</option>
|
||||
<option value="false" {{if eq .Filters.Stale "false"}}selected{{end}}>Nur aktuell</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="derived_state">Derived state</label>
|
||||
<label for="derived_state">Abgeleiteter Status</label>
|
||||
<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="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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<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}}">
|
||||
</div>
|
||||
|
||||
|
|
@ -547,8 +549,8 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit">Apply filters</button>
|
||||
<a class="text-link" href="{{.StatusPagePath}}">Clear</a>
|
||||
<button type="submit">Filter anwenden</button>
|
||||
<a class="text-link" href="{{.StatusPagePath}}">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -557,23 +559,23 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Latest reports</h2>
|
||||
<p class="panel-copy">Each row links to the HTML detail view and the raw JSON endpoint for a quick drill-down.</p>
|
||||
<h2>Aktuelle Meldungen</h2>
|
||||
<p class="panel-copy">Jede Zeile verlinkt auf die HTML-Detailansicht und den JSON-Endpunkt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-actions">
|
||||
<a class="text-link" href="{{.StatusAPIPath}}">Open filtered JSON overview</a>
|
||||
<a class="text-link" href="{{.StatusAPIPath}}">Gefilterte JSON-Übersicht öffnen</a>
|
||||
</div>
|
||||
{{if .Overview.Screens}}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Screen</th>
|
||||
<th>Derived state</th>
|
||||
<th>Player status</th>
|
||||
<th>Server link</th>
|
||||
<th>Received</th>
|
||||
<th>Bildschirm</th>
|
||||
<th>Status</th>
|
||||
<th>Player-Status</th>
|
||||
<th>Server</th>
|
||||
<th>Empfangen</th>
|
||||
<th>Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -582,28 +584,28 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
<tr>
|
||||
<td>
|
||||
<div class="screen">{{.ScreenID}}</div>
|
||||
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>No endpoint details</small>{{end}}
|
||||
{{if .MQTTBroker}}<small>{{.MQTTBroker}}</small>{{else if .ServerURL}}<small>{{.ServerURL}}</small>{{else}}<small>Keine Verbindungsdetails</small>{{end}}
|
||||
<div class="screen-links">
|
||||
<a class="filter-link" href="{{screenDetailHTMLPath .ScreenID}}">Details</a>
|
||||
<a class="json-link" href="{{screenDetailPath .ScreenID}}">JSON</a>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="pill {{statusClass .DerivedState}}">{{.DerivedState}}</span></td>
|
||||
<td><span class="pill {{statusClass .DerivedState}}">{{stateLabel .DerivedState}}</span></td>
|
||||
<td>
|
||||
<div>{{.Status}}</div>
|
||||
{{if .Stale}}<small>Marked stale by server freshness check</small>{{else}}<small>Fresh within expected heartbeat window</small>{{end}}
|
||||
{{if .Stale}}<small>Vom Server als veraltet markiert</small>{{else}}<small>Aktuell im erwarteten Heartbeat-Fenster</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<span class="state {{statusClass .ServerConnectivity}}">{{connectivityLabel .ServerConnectivity}}</span>
|
||||
{{if .ServerURL}}<small>{{.ServerURL}}</small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
<div>{{timestampLabel .ReceivedAt}}</div>
|
||||
{{if .LastHeartbeatAt}}<small>Heartbeat {{timestampLabel .LastHeartbeatAt}}</small>{{end}}
|
||||
<div><time class="reltime" datetime="{{.ReceivedAt}}">{{timestampLabel .ReceivedAt}}</time></div>
|
||||
{{if .LastHeartbeatAt}}<small>Heartbeat <time class="reltime" datetime="{{.LastHeartbeatAt}}">{{timestampLabel .LastHeartbeatAt}}</time></small>{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if gt .HeartbeatEverySeconds 0}}{{.HeartbeatEverySeconds}}s{{else}}-{{end}}
|
||||
{{if .StartedAt}}<small>Started {{timestampLabel .StartedAt}}</small>{{end}}
|
||||
{{if .StartedAt}}<small>Gestartet <time class="reltime" datetime="{{.StartedAt}}">{{timestampLabel .StartedAt}}</time></small>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
|
@ -611,21 +613,48 @@ var statusPageTemplate = template.Must(template.New("status-page").Funcs(statusT
|
|||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty">No screen has reported status yet. Once a player posts to the existing status API, it will appear here automatically.</p>
|
||||
<p class="empty">Noch kein Bildschirm hat einen Status gemeldet. Sobald ein Player den Status-API-Endpunkt aufruft, erscheint er hier automatisch.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(function() {
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr || dateStr === '-') return dateStr;
|
||||
var d = new Date(dateStr);
|
||||
if (isNaN(d)) return dateStr;
|
||||
var diff = Math.round((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'gerade eben';
|
||||
if (diff < 60) return 'vor ' + diff + ' Sekunden';
|
||||
var mins = Math.round(diff / 60);
|
||||
if (mins < 60) return 'vor ' + mins + (mins === 1 ? ' Minute' : ' Minuten');
|
||||
var hours = Math.round(diff / 3600);
|
||||
if (hours < 24) return 'vor ' + hours + (hours === 1 ? ' Stunde' : ' Stunden');
|
||||
var days = Math.round(diff / 86400);
|
||||
return 'vor ' + days + (days === 1 ? ' Tag' : ' Tagen');
|
||||
}
|
||||
function updateRelTimes() {
|
||||
document.querySelectorAll('time.reltime').forEach(function(el) {
|
||||
el.textContent = relativeTime(el.getAttribute('datetime'));
|
||||
});
|
||||
var genAt = document.getElementById('generated-at');
|
||||
if (genAt) genAt.textContent = relativeTime(genAt.getAttribute('datetime'));
|
||||
}
|
||||
updateRelTimes();
|
||||
setInterval(updateRelTimes, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="{{.RefreshSeconds}}">
|
||||
<title>{{.Record.ScreenID}} – Screen Status</title>
|
||||
<title>{{.Record.ScreenID}} – Bildschirmstatus</title>
|
||||
` + statusPageCSSBlock + `
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -634,12 +663,13 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
<div class="hero-top">
|
||||
<div>
|
||||
<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 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;">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -647,20 +677,20 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card {{statusClass .Record.DerivedState}}">
|
||||
<strong><span class="state {{statusClass .Record.DerivedState}}">{{.Record.DerivedState}}</span></strong>
|
||||
<span>Derived state</span>
|
||||
<strong><span class="state {{statusClass .Record.DerivedState}}">{{stateLabel .Record.DerivedState}}</span></strong>
|
||||
<span>Abgeleiteter Status</span>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<strong>{{.Record.Status}}</strong>
|
||||
<span>Player status</span>
|
||||
<span>Player-Status</span>
|
||||
</article>
|
||||
<article class="summary-card {{statusClass .Record.ServerConnectivity}}">
|
||||
<strong>{{connectivityLabel .Record.ServerConnectivity}}</strong>
|
||||
<span>Server connectivity</span>
|
||||
<span>Serverkonnektivität</span>
|
||||
</article>
|
||||
<article class="summary-card">
|
||||
<strong>{{if .Record.Stale}}stale{{else}}fresh{{end}}</strong>
|
||||
<span>Freshness</span>
|
||||
<strong>{{if .Record.Stale}}Veraltet{{else}}Aktuell{{end}}</strong>
|
||||
<span>Aktualität</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -668,30 +698,30 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Timing</h2>
|
||||
<p class="panel-copy">Timestamps reported by the player and annotated by the server at receive time.</p>
|
||||
<h2>Zeitstempel</h2>
|
||||
<p class="panel-copy">Vom Player gemeldete und vom Server beim Empfang ergänzte Zeitstempel.</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Received at (server)</th>
|
||||
<td>{{timestampLabel .Record.ReceivedAt}}</td>
|
||||
<th>Empfangen (Server)</th>
|
||||
<td><time class="reltime" datetime="{{.Record.ReceivedAt}}">{{timestampLabel .Record.ReceivedAt}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Player timestamp</th>
|
||||
<td>{{timestampLabel .Record.Timestamp}}</td>
|
||||
<th>Player-Zeitstempel</th>
|
||||
<td><time class="reltime" datetime="{{.Record.Timestamp}}">{{timestampLabel .Record.Timestamp}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Started at</th>
|
||||
<td>{{timestampLabel .Record.StartedAt}}</td>
|
||||
<th>Gestartet</th>
|
||||
<td><time class="reltime" datetime="{{.Record.StartedAt}}">{{timestampLabel .Record.StartedAt}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last heartbeat at</th>
|
||||
<td>{{timestampLabel .Record.LastHeartbeatAt}}</td>
|
||||
<th>Letzter Heartbeat</th>
|
||||
<td><time class="reltime" datetime="{{.Record.LastHeartbeatAt}}">{{timestampLabel .Record.LastHeartbeatAt}}</time></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Heartbeat interval</th>
|
||||
<th>Heartbeat-Intervall</th>
|
||||
<td>{{if gt .Record.HeartbeatEverySeconds 0}}{{.Record.HeartbeatEverySeconds}}s{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -701,34 +731,61 @@ var screenDetailTemplate = template.Must(template.New("screen-detail").Funcs(sta
|
|||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<h2>Endpoints</h2>
|
||||
<p class="panel-copy">Connection details reported by the player in the last accepted status.</p>
|
||||
<h2>Verbindungen</h2>
|
||||
<p class="panel-copy">Verbindungsdetails aus dem zuletzt akzeptierten Status-Report.</p>
|
||||
</div>
|
||||
</div>
|
||||
<table class="detail-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Server URL</th>
|
||||
<th>Server-URL</th>
|
||||
<td>{{if .Record.ServerURL}}{{.Record.ServerURL}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>MQTT broker</th>
|
||||
<th>MQTT-Broker</th>
|
||||
<td>{{if .Record.MQTTBroker}}{{.Record.MQTTBroker}}{{else}}-{{end}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
(function() {
|
||||
function relativeTime(dateStr) {
|
||||
if (!dateStr || dateStr === '-') return dateStr;
|
||||
var d = new Date(dateStr);
|
||||
if (isNaN(d)) return dateStr;
|
||||
var diff = Math.round((Date.now() - d.getTime()) / 1000);
|
||||
if (diff < 5) return 'gerade eben';
|
||||
if (diff < 60) return 'vor ' + diff + ' Sekunden';
|
||||
var mins = Math.round(diff / 60);
|
||||
if (mins < 60) return 'vor ' + mins + (mins === 1 ? ' Minute' : ' Minuten');
|
||||
var hours = Math.round(diff / 3600);
|
||||
if (hours < 24) return 'vor ' + hours + (hours === 1 ? ' Stunde' : ' Stunden');
|
||||
var days = Math.round(diff / 86400);
|
||||
return 'vor ' + days + (days === 1 ? ' Tag' : ' Tagen');
|
||||
}
|
||||
function updateRelTimes() {
|
||||
document.querySelectorAll('time.reltime').forEach(function(el) {
|
||||
el.textContent = relativeTime(el.getAttribute('datetime'));
|
||||
});
|
||||
var genAt = document.getElementById('generated-at');
|
||||
if (genAt) genAt.textContent = relativeTime(genAt.getAttribute('datetime'));
|
||||
}
|
||||
updateRelTimes();
|
||||
setInterval(updateRelTimes, 30000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
|
||||
var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(statusTemplateFuncs).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Invalid filter – Screen Status</title>
|
||||
<title>Ungültiger Filter – Bildschirmstatus</title>
|
||||
` + statusPageCSSBlock + `
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -736,11 +793,11 @@ var statusPageErrorTemplate = template.Must(template.New("status-error").Funcs(s
|
|||
<section class="hero">
|
||||
<div class="hero-top">
|
||||
<div>
|
||||
<h1>Invalid filter</h1>
|
||||
<h1>Ungültiger Filter</h1>
|
||||
<p class="lead">{{.Message}}</p>
|
||||
</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>
|
||||
</main>
|
||||
</body>
|
||||
|
|
@ -773,7 +830,7 @@ func handleScreenDetailPage(store playerStatusStore) http.HandlerFunc {
|
|||
record, ok := store.Get(screenID)
|
||||
if !ok {
|
||||
data := statusPageErrorData{
|
||||
Message: "Fuer diesen Screen liegt noch kein Status vor.",
|
||||
Message: "Für diesen Screen liegt noch kein Status vor.",
|
||||
StatusPagePath: "/status",
|
||||
}
|
||||
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}
|
||||
return []statusFilterLink{
|
||||
{
|
||||
Label: "All screens",
|
||||
Label: "Alle Bildschirme",
|
||||
Href: buildStatusPageHref(base),
|
||||
Class: "",
|
||||
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}),
|
||||
Class: "offline",
|
||||
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}),
|
||||
Class: "degraded",
|
||||
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}),
|
||||
Class: "",
|
||||
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}),
|
||||
Class: "online",
|
||||
Active: filters.ServerConnectivity == "" && filters.Stale == "false",
|
||||
|
|
@ -935,3 +992,22 @@ func timestampLabel(value string) string {
|
|||
}
|
||||
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