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 b631355..d8371da 100644
--- a/frontend/js/views/device-detail.js
+++ b/frontend/js/views/device-detail.js
@@ -33,6 +33,81 @@ let shellHandler = null;
let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open
let screenshotInterval = null;
let remoteActive = false;
+// Mirrors the Debug-logging checkbox so cleanup() can switch the device's stream back off.
+// Without this, leaving the screen left the panel streaming into nothing: the device kept
+// 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).
@@ -184,14 +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 || ''}`;
- 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);
@@ -356,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') ? `
@@ -1150,9 +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, {
@@ -2171,6 +2285,12 @@ export function cleanup() {
if (shellHandler) off('shell-result', shellHandler); // #161 owner-tools listener
if (screenshotInterval) clearInterval(screenshotInterval);
if (remoteActive && currentDevice) stopRemote(currentDevice.id);
+ // Same reasoning as stopRemote above: an operator who navigates away has stopped watching, so
+ // 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/player/index.html b/server/player/index.html
index edd56cc..829a9d8 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -24,6 +24,12 @@
try { return Date.now(); } catch (e) { return new Date().getTime(); }
}
+ // Live subscribers (the player's set_debug sink). Kept HERE rather than in the player
+ // script so entries recorded during boot -- before the player has even parsed -- reach a
+ // subscriber that registers later, via the backlog in window.__debugLog.
+ var subs = [];
+ var notifying = false;
+
function pushLog(entry) {
try {
entry.t = nowMs();
@@ -32,8 +38,22 @@
window.__debugLog.splice(0, window.__debugLog.length - MAX_LOG);
}
} catch (e) { /* we are the safety net; do not crash */ }
+ // Reentrancy guard, and it is not theoretical: this runs inside the console wrapper
+ // below, so a subscriber that logs anything at all -- directly, or through a library --
+ // would call back in here and recurse until the stack gave out. The player would die of
+ // its own diagnostics.
+ if (notifying) return;
+ notifying = true;
+ try {
+ for (var s = 0; s < subs.length; s++) {
+ try { subs[s](entry); } catch (e) { /* one bad subscriber must not eat the rest */ }
+ }
+ } finally { notifying = false; }
}
window.__debugLog_push = pushLog; // shared pusher for debug-overlay.js
+ window.__debugLog_subscribe = function (fn) {
+ try { if (typeof fn === 'function') subs.push(fn); } catch (e) {}
+ };
pushLog({
type: 'init',
@@ -515,6 +535,23 @@
// ==================== State ====================
let socket = null;
let config = getConfig();
+
+ /*
+ * Does a