diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 2464929..20d2763 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -549,7 +549,16 @@ export default { 'device.form.notes_label': 'Notes', 'device.form.notes_placeholder': 'Location, setup details, etc.', 'device.debug.toggle': 'Debug logging (live)', - 'device.debug.hint': 'Streams player/zone logs from this device in real time. Turns off on its own when the device reconnects.', + 'device.debug.hint': 'Streams this display\'s log in real time, and replays what it buffered before you opened it. Turns off when you leave this screen, and on the device itself after 30 minutes.', + 'device.debug.freeze': 'Freeze', + 'device.debug.resume': 'Resume', + 'device.debug.copy': 'Copy', + 'device.debug.clear': 'Clear', + 'device.debug.held': 'frozen — {n} new line(s) waiting', + 'device.debug.held_max': 'frozen — {n} waiting (oldest now being dropped)', + 'device.debug.copied': 'Copied {n} line(s) to the clipboard', + 'device.debug.copy_empty': 'Nothing to copy yet', + 'device.debug.copy_failed': 'Could not reach the clipboard — select the log and copy manually', 'device.ota.toggle': 'Self-update (OTA)', 'device.ota.beta': 'Accept pre-release builds', 'device.ota.beta_hint': 'Puts this display on the pre-release channel: it receives the beta build if the server has one published, and keeps a test build instead of being updated back to the current release. Untick to move it back to the release build. Does nothing if no beta is published.', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 7fd37ac..d8371da 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -38,6 +38,76 @@ let remoteActive = false; // emitting, the dashboard kept relaying, and nobody was listening. The player carries its own // auto-off as the backstop for the case this can't cover -- a tab that is killed, not closed. let debugStreamOn = false; +let debugFrozen = false; +let debugHeld = []; // lines that arrived while frozen, replayed on resume +const DEBUG_PANEL_MAX = 500; // panel rows AND the held-while-frozen cap + +// Every player sends a level and the panel used to render all four identically, so the one line +// that explains the fault sat in a wall of grey. Errors and warnings are why the operator opened it. +const DEBUG_LEVEL_COLOR = { e: '#f87171', w: '#fbbf24', d: '#64748b' }; + +function debugLineText(d) { + return `${new Date(d.ts || Date.now()).toLocaleTimeString()} [${d.tag || ''}] ${d.message || ''}`; +} + +function appendDebugLine(d) { + const panel = document.getElementById('debugLogPanel'); + if (!panel) return; + const line = document.createElement('div'); + line.textContent = debugLineText(d); // textContent — no HTML injection + const tone = DEBUG_LEVEL_COLOR[(d.level || '').toLowerCase()]; + if (tone) line.style.color = tone; + panel.appendChild(line); + while (panel.childElementCount > DEBUG_PANEL_MAX) panel.removeChild(panel.firstChild); + panel.scrollTop = panel.scrollHeight; +} + +function updateDebugTools() { + const btn = document.getElementById('debugFreezeBtn'); + const status = document.getElementById('debugLogStatus'); + if (btn) btn.textContent = debugFrozen ? t('device.debug.resume') : t('device.debug.freeze'); + if (status) { + // Say how many are waiting, so freezing never feels like the device went quiet. + status.textContent = debugFrozen + ? (debugHeld.length >= DEBUG_PANEL_MAX + ? t('device.debug.held_max', { n: debugHeld.length }) + : t('device.debug.held', { n: debugHeld.length })) + : ''; + } +} + +function setDebugFrozen(frozen) { + debugFrozen = frozen; + if (!frozen) { + const held = debugHeld; + debugHeld = []; + for (const d of held) appendDebugLine(d); // resume shows what you missed, in order + } + updateDebugTools(); +} + +/* + * Clipboard with a fallback, because a self-hosted dashboard on plain http is NOT a secure context + * and `navigator.clipboard` is simply absent there — the copy buttons elsewhere in this app quietly + * do nothing in that case. A debug log is precisely what a self-hoster wants to paste into an issue. + */ +async function copyToClipboard(text) { + try { + if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); return true; } + } catch (e) { /* fall through to the legacy path */ } + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.setAttribute('readonly', ''); + ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0'; + document.body.appendChild(ta); + ta.select(); + ta.setSelectionRange(0, ta.value.length); + const ok = document.execCommand('copy'); + document.body.removeChild(ta); + return ok; + } catch (e) { return false; } +} // Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session // is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive). @@ -189,20 +259,16 @@ export function render(container, deviceId) { // checkbox is on). Appended via textContent — no HTML injection. logHandler = (data) => { if (data.device_id !== deviceId) return; - const panel = document.getElementById('debugLogPanel'); - if (!panel) return; - const line = document.createElement('div'); - const time = new Date(data.ts || Date.now()).toLocaleTimeString(); - line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`; - // Every player sends a level and the panel used to render all four identically, so the one - // line that explains the fault sat in a wall of grey. Errors and warnings are why the - // operator opened this. - const LEVEL_COLOR = { e: '#f87171', w: '#fbbf24', d: '#64748b' }; - const tone = LEVEL_COLOR[(data.level || '').toLowerCase()]; - if (tone) line.style.color = tone; - panel.appendChild(line); - while (panel.childElementCount > 500) panel.removeChild(panel.firstChild); - panel.scrollTop = panel.scrollHeight; + // Frozen: HOLD the line rather than drop it. A log you froze to read something is the exact + // moment the lines that explain it are still arriving — pausing the stream would throw away + // the part you were about to want. + if (debugFrozen) { + debugHeld.push(data); + if (debugHeld.length > DEBUG_PANEL_MAX) debugHeld.shift(); + updateDebugTools(); + return; + } + appendDebugLine(data); }; on('device-status', statusHandler); @@ -367,7 +433,7 @@ async function loadDevice(deviceId, activeTab = null) { past everything to reach the one button you came for. Kept as a single wrapping row so a narrow screen reflows rather than clipping, and each button still renders only where the display can honour it. --> -