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. --> -
+
${can('system.reboot') ? ` + + + +
@@ -1161,10 +1237,36 @@ function setupActions(device) { const enabled = e.target.checked; const panel = document.getElementById('debugLogPanel'); if (panel) panel.style.display = enabled ? 'block' : 'none'; + const tools = document.getElementById('debugLogTools'); + if (tools) tools.style.display = enabled ? 'flex' : 'none'; debugStreamOn = enabled; + // Unticking and reticking should not resume into a frozen panel the operator forgot about. + if (!enabled) { debugFrozen = false; debugHeld = []; } + updateDebugTools(); sendCommand(device.id, 'set_debug', { enabled }); }); + document.getElementById('debugFreezeBtn')?.addEventListener('click', () => setDebugFrozen(!debugFrozen)); + + document.getElementById('debugClearBtn')?.addEventListener('click', () => { + const panel = document.getElementById('debugLogPanel'); + if (panel) panel.textContent = ''; + debugHeld = []; + updateDebugTools(); + }); + + document.getElementById('debugCopyBtn')?.addEventListener('click', async () => { + const panel = document.getElementById('debugLogPanel'); + // Copy what is ON SCREEN. Anything held while frozen is deliberately excluded — the operator + // is copying the capture they are looking at, and silently appending lines they have not seen + // would make the paste disagree with the panel. + const text = panel ? [...panel.children].map((el) => el.textContent).join('\n') : ''; + if (!text) { showToast(t('device.debug.copy_empty'), 'error'); return; } + const header = `${device.name || device.id} — ${device.platform || ''} ${device.hardware_model || ''} — ${new Date().toISOString()}`.trim(); + const ok = await copyToClipboard(`${header}\n${'-'.repeat(header.length)}\n${text}\n`); + showToast(ok ? t('device.debug.copied', { n: panel.childElementCount }) : t('device.debug.copy_failed'), ok ? 'success' : 'error'); + }); + document.getElementById('saveNotesBtn')?.addEventListener('click', async () => { try { await api.updateDevice(device.id, { @@ -2187,6 +2289,8 @@ export function cleanup() { // the display should stop talking. Must run BEFORE currentDevice is cleared. if (debugStreamOn && currentDevice) sendCommand(currentDevice.id, 'set_debug', { enabled: false }); debugStreamOn = false; + debugFrozen = false; + debugHeld = []; remoteActive = false; currentDevice = null; window._sendKey = null; diff --git a/server/test/dashboard-debug-log-controls.test.js b/server/test/dashboard-debug-log-controls.test.js new file mode 100644 index 0000000..2c32885 --- /dev/null +++ b/server/test/dashboard-debug-log-controls.test.js @@ -0,0 +1,161 @@ +'use strict'; + +/* + * Freeze / Copy / Clear on the live debug panel, and the gap between the control row and the info + * grid. + * + * Freeze is the one with a real design decision in it. A log you froze to read something is the + * exact moment the lines that EXPLAIN it are still arriving, so freezing holds the view still and + * keeps buffering underneath — it does not pause the stream. Dropping them would throw away the + * part the operator was about to want, and the panel would have lied about being a live log. + */ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.join(__dirname, '..', '..'); +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf8'); +const DETAIL = read('frontend/js/views/device-detail.js'); +const EN = read('frontend/js/i18n/en.js'); + +// ------------------------------------------------------------------ layout + +test('the control row is separated from the info grid', () => { + // They rendered flush against each other: the buttons sat directly on top of the STATUS card + // with no gap, so the destructive ones read as part of the status panel. + const row = DETAIL.slice(DETAIL.indexOf('