From 047f95f40c29a02d86845203bbc699d6bd5db8c7 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 7 Aug 2026 19:26:03 -0500 Subject: [PATCH] Freeze and copy the live debug log, and unstick the control row Three small things off the device page. The control row had margin-top but no margin-bottom, so the buttons sat flush on top of the STATUS card and the destructive ones read as part of the status panel. Freeze is the one with a decision in it: it holds the VIEW still and keeps buffering underneath rather than pausing the stream. The moment you freeze a log to read something is the exact moment the lines that explain it are still arriving, so dropping them would throw away the part you were about to want. Resume replays them in order. The held buffer is capped at the same 500 as the panel, and the status text says how many are waiting -- otherwise a frozen panel is indistinguishable from a device that went quiet, and silence reads as a symptom. Overflow says so too. Copy takes what is on screen (not the held lines -- the paste must agree with the panel) and stamps it with the device and an ISO timestamp, because a pasted log with no device in it is a log nobody can act on. It falls back to execCommand when navigator.clipboard is absent, which is every self-hosted dashboard on plain http: that is not a secure context, and the other copy buttons in this app quietly do nothing there. Clear earns its place next to Copy: without it you always copy 500 lines of history instead of the capture you just made. The hint promised the stream "turns off on its own when the device reconnects", which was never true and is not what happens now -- it turns off when you leave the screen, and on the device after 30 minutes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS --- frontend/js/i18n/en.js | 11 +- frontend/js/views/device-detail.js | 134 +++++++++++++-- .../test/dashboard-debug-log-controls.test.js | 161 ++++++++++++++++++ 3 files changed, 290 insertions(+), 16 deletions(-) create mode 100644 server/test/dashboard-debug-log-controls.test.js 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('