diff --git a/static/app.js b/static/app.js index 8e75e02..ee36520 100644 --- a/static/app.js +++ b/static/app.js @@ -8,6 +8,10 @@ import { _populateVizPicker, setViz, } from './js/viz.js'; +import { + exportDiagnostics, + previewDiagnostics, +} from './js/diagnostics-export.js'; // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; @@ -4107,275 +4111,6 @@ async function importSettings(file) { setTimeout(() => location.reload(), 800); } -// ── Diagnostics export (feedBack#166) ─────────────────────────────────── -// -// Companion to Settings export but for troubleshooting bug reports. -// Bundle layout + schemas: docs/diagnostics-bundle-spec.md. -// -// Frontend's job is to: -// 1. Snapshot the browser-only state (console ring buffer, hardware -// probe, localStorage, ua) via window.feedBack.diagnostics. -// 2. POST it to /api/diagnostics/export with the user's include / -// redact toggles. -// 3. Stream the returned zip to disk. - -function _diagIncludeFromUI() { - const v = (id) => document.getElementById(id)?.checked !== false; - return { - system: v('diag-incl-system'), - hardware: v('diag-incl-hardware'), - logs: v('diag-incl-logs'), - console: v('diag-incl-console'), - plugins: v('diag-incl-plugins'), - }; -} - -function _diagRedactFromUI() { - const el = document.getElementById('diag-redact'); - return el ? !!el.checked : true; -} - -// Map raw file paths inside the bundle to plain-English labels + -// descriptions for the preview UI. Only paths that show up in -// previews need entries — unknown paths fall back to the path itself. -const _DIAG_FILE_LABELS = { - 'system/version.json': { label: 'App version', desc: 'FeedBack version, Python, OS' }, - 'system/env.json': { label: 'Environment', desc: 'Allowlisted env vars (LOG_LEVEL, etc.). No secrets.' }, - 'system/hardware.json': { label: 'Hardware (server-side)', desc: 'CPU, RAM, GPU. In Docker this reflects the container, not the host.' }, - 'system/plugins.json': { label: 'Plugins', desc: 'Loaded plugins + git commit + orphan detection.' }, - 'logs/server.log': { label: 'Server log', desc: 'Tail of LOG_FILE (last ~5 MB).' }, - 'logs/server.log.meta.json': { label: 'Log metadata', desc: 'Log file path, size, rotation info.' }, - 'client/console.json': { label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' }, - 'client/hardware.json': { label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' }, - 'client/local_storage.json': { label: 'Browser storage', desc: 'localStorage contents (preferences).' }, - 'client/ua.json': { label: 'User agent', desc: 'Browser, screen, page URL.' }, -}; - -function _formatBytes(n) { - if (!n || n < 1024) return (n || 0) + ' B'; - if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; - return (n / (1024 * 1024)).toFixed(1) + ' MB'; -} - -function _escapeHtml(s) { - return String(s || '').replace(/[&<>"']/g, c => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', - }[c])); -} - -function _renderDiagPreview(data) { - const m = data.manifest || {}; - const files = m.files || []; - const groups = { system: [], logs: [], client: [], plugins: [], other: [] }; - for (const f of files) { - const top = (f.path || '').split('/')[0]; - (groups[top] || groups.other).push(f); - } - const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0); - const include = _diagIncludeFromUI(); - const redact = _diagRedactFromUI(); - - const sections = []; - // Per-file `summary` (server-derived) → human one-liner. - function _summaryLine(path, summary) { - if (!summary || typeof summary !== 'object') return ''; - if (path === 'system/plugins.json') { - const loaded = summary.loaded_count || 0; - const orphans = summary.orphan_count || 0; - const orphPart = orphans ? ` · ${orphans} orphan${orphans === 1 ? '' : 's'}` : ''; - return `${loaded} plugin${loaded === 1 ? '' : 's'} loaded${orphPart}`; - } - if (path === 'client/console.json') { - const total = summary.entry_count || 0; - const lvl = summary.by_level || {}; - const parts = []; - for (const k of ['error','warn','info','log','debug']) { - if (lvl[k]) parts.push(`${lvl[k]} ${k}`); - } - return `${total} entries${parts.length ? ' (' + parts.join(', ') + ')' : ''}`; - } - if (path === 'system/hardware.json') { - const bits = []; - if (summary.cpu_brand) bits.push(summary.cpu_brand); - if (summary.cores_logical) bits.push(`${summary.cores_logical} cores`); - if (summary.gpu_count) bits.push(`${summary.gpu_count} GPU`); - if (summary.runtime) bits.push(`runtime: ${summary.runtime}`); - return bits.join(' · '); - } - if (path === 'client/hardware.json') { - const bits = []; - if (summary.runtime) bits.push(summary.runtime); - if (summary.webgl_renderer) bits.push(summary.webgl_renderer); - return bits.join(' · '); - } - if (path === 'client/local_storage.json') { - return `${summary.key_count || 0} keys`; - } - if (path === 'system/version.json') { - const bits = []; - if (summary.feedBack) bits.push(`feedBack ${summary.feedBack}`); - if (summary.python) bits.push(`python ${summary.python}`); - if (summary.os) bits.push(summary.os); - return bits.join(' · '); - } - return ''; - } - - function pushSection(title, list, emptyHint) { - if (!list.length) { - if (emptyHint) { - sections.push(`
${_escapeHtml(title)}
${_escapeHtml(emptyHint)}
`); - } - return; - } - const rows = list.map(f => { - const meta = _DIAG_FILE_LABELS[f.path] || { label: f.path, desc: '' }; - const summary = _summaryLine(f.path, f.summary); - const summaryHtml = summary - ? `
${summary}
` - : ''; - return `
-
-
${_escapeHtml(meta.label)}
-
${_escapeHtml(meta.desc)}
- ${summaryHtml} -
-
${_escapeHtml(_formatBytes(f.size))}
-
`; - }).join(''); - sections.push(`
${_escapeHtml(title)}
${rows}
`); - } - - pushSection('System', groups.system, include.system ? '' : 'Skipped (toggle off)'); - pushSection('Server logs', groups.logs, include.logs - ? 'No log file configured — set LOG_FILE env var to include server logs.' - : 'Skipped (toggle off)'); - pushSection('Plugin diagnostics', groups.plugins, include.plugins - ? 'No plugins have opted in to diagnostics.' - : 'Skipped (toggle off)'); - - // Client section preview is a server-side estimate only — actual - // client/* payloads are added at Export time after the browser - // snapshots. Show what WILL be added, not file sizes. - const clientLines = []; - if (include.console) clientLines.push({ label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' }); - if (include.hardware) clientLines.push({ label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' }); - clientLines.push({ label: 'Browser storage', desc: 'localStorage contents (preferences).' }); - clientLines.push({ label: 'User agent', desc: 'Browser, screen, page URL.' }); - const clientHtml = clientLines.map(c => `
-
${_escapeHtml(c.label)}
${_escapeHtml(c.desc)}
-
added on export
-
`).join(''); - sections.push(`
Browser data
${clientHtml}
`); - - const notesHtml = (m.notes || []).length - ? `
-
Notes
- ${(m.notes).map(n => `
• ${_escapeHtml(n)}
`).join('')} -
` - : ''; - - const privacyHtml = redact - ? `
🔒 Redaction enabled — paths, song names, IPs, and secrets will be replaced with stable hash tokens.
` - : `
⚠ Redaction OFF — bundle will contain raw paths, song names, and IPs. Only share with people you trust.
`; - - return ` -
-
-
${_escapeHtml(data.filename)}
-
${_escapeHtml(_formatBytes(totalBytes))} server-side
-
-
runtime: ${_escapeHtml(m.runtime || 'unknown')} · exported_at: ${_escapeHtml(m.exported_at || '')}
- ${notesHtml} - ${sections.join('')} - ${privacyHtml} -
`; -} - -async function previewDiagnostics() { - const status = document.getElementById('diag-status'); - const preview = document.getElementById('diag-preview'); - if (!status || !preview) return; - status.textContent = 'Building preview…'; - preview.classList.add('hidden'); - const include = _diagIncludeFromUI(); - const params = new URLSearchParams({ - redact: String(_diagRedactFromUI()), - system: String(include.system), - hardware: String(include.hardware), - logs: String(include.logs), - console: String(include.console), - plugins: String(include.plugins), - }); - try { - const resp = await fetch(`/api/diagnostics/preview?${params.toString()}`); - if (!resp.ok) { - status.textContent = `Preview failed (HTTP ${resp.status})`; - return; - } - const data = await resp.json(); - preview.innerHTML = _renderDiagPreview(data); - preview.classList.remove('hidden'); - status.textContent = 'Preview ready.'; - } catch (e) { - status.textContent = `Preview failed: ${e.message}`; - } -} - -async function exportDiagnostics() { - const status = document.getElementById('diag-status'); - if (!status) return; - status.textContent = 'Building bundle…'; - const include = _diagIncludeFromUI(); - const redact = _diagRedactFromUI(); - - const diag = window.feedBack && window.feedBack.diagnostics; - const body = { - redact, - include, - client_console: include.console && diag ? diag.snapshotConsole() : null, - client_hardware: include.hardware && diag ? await diag.snapshotHardware() : null, - client_ua: diag ? diag.snapshotUa() : null, - local_storage: diag ? diag.snapshotLocalStorage() : null, - client_contributions: diag ? diag.snapshotContributions() : null, - }; - - let resp; - try { - resp = await fetch('/api/diagnostics/export', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - } catch (e) { - status.textContent = `Export failed: ${e.message}`; - return; - } - if (!resp.ok) { - status.textContent = `Export failed (HTTP ${resp.status})`; - return; - } - let filename = 'feedBack-diag.zip'; - const disp = resp.headers.get('Content-Disposition'); - if (disp) { - const m = /filename="([^"]+)"/.exec(disp); - if (m) filename = m[1]; - } - try { - const blob = await resp.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - status.textContent = `Exported ${filename}`; - } catch (e) { - status.textContent = `Export failed during download: ${e.message}`; - } -} async function uploadSongs(fileList) { if (!fileList || fileList.length === 0) return; diff --git a/static/js/diagnostics-export.js b/static/js/diagnostics-export.js new file mode 100644 index 0000000..869eb60 --- /dev/null +++ b/static/js/diagnostics-export.js @@ -0,0 +1,280 @@ +// The diagnostics-bundle export — the Settings "Export diagnostics" flow. +// +// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing. +// It snapshots the browser-only state (console ring buffer, hardware probe, +// localStorage, ua) via window.feedBack.diagnostics, POSTs it to +// /api/diagnostics/export with the user's include/redact toggles, and streams the +// returned zip to disk. Bundle layout + schemas: docs/diagnostics-bundle-spec.md. +// +// Everything except the two entry points is module-private — the preview +// renderer, the file-label table, and the byte/HTML formatters are used nowhere +// else in core. + +// +// Companion to Settings export but for troubleshooting bug reports. +// Bundle layout + schemas: docs/diagnostics-bundle-spec.md. +// +// Frontend's job is to: +// 1. Snapshot the browser-only state (console ring buffer, hardware +// probe, localStorage, ua) via window.feedBack.diagnostics. +// 2. POST it to /api/diagnostics/export with the user's include / +// redact toggles. +// 3. Stream the returned zip to disk. + +function _diagIncludeFromUI() { + const v = (id) => document.getElementById(id)?.checked !== false; + return { + system: v('diag-incl-system'), + hardware: v('diag-incl-hardware'), + logs: v('diag-incl-logs'), + console: v('diag-incl-console'), + plugins: v('diag-incl-plugins'), + }; +} + +function _diagRedactFromUI() { + const el = document.getElementById('diag-redact'); + return el ? !!el.checked : true; +} + +// Map raw file paths inside the bundle to plain-English labels + +// descriptions for the preview UI. Only paths that show up in +// previews need entries — unknown paths fall back to the path itself. +const _DIAG_FILE_LABELS = { + 'system/version.json': { label: 'App version', desc: 'FeedBack version, Python, OS' }, + 'system/env.json': { label: 'Environment', desc: 'Allowlisted env vars (LOG_LEVEL, etc.). No secrets.' }, + 'system/hardware.json': { label: 'Hardware (server-side)', desc: 'CPU, RAM, GPU. In Docker this reflects the container, not the host.' }, + 'system/plugins.json': { label: 'Plugins', desc: 'Loaded plugins + git commit + orphan detection.' }, + 'logs/server.log': { label: 'Server log', desc: 'Tail of LOG_FILE (last ~5 MB).' }, + 'logs/server.log.meta.json': { label: 'Log metadata', desc: 'Log file path, size, rotation info.' }, + 'client/console.json': { label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' }, + 'client/hardware.json': { label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' }, + 'client/local_storage.json': { label: 'Browser storage', desc: 'localStorage contents (preferences).' }, + 'client/ua.json': { label: 'User agent', desc: 'Browser, screen, page URL.' }, +}; + +function _formatBytes(n) { + if (!n || n < 1024) return (n || 0) + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / (1024 * 1024)).toFixed(1) + ' MB'; +} + +function _escapeHtml(s) { + return String(s || '').replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +function _renderDiagPreview(data) { + const m = data.manifest || {}; + const files = m.files || []; + const groups = { system: [], logs: [], client: [], plugins: [], other: [] }; + for (const f of files) { + const top = (f.path || '').split('/')[0]; + (groups[top] || groups.other).push(f); + } + const totalBytes = files.reduce((s, f) => s + (f.size || 0), 0); + const include = _diagIncludeFromUI(); + const redact = _diagRedactFromUI(); + + const sections = []; + // Per-file `summary` (server-derived) → human one-liner. + function _summaryLine(path, summary) { + if (!summary || typeof summary !== 'object') return ''; + if (path === 'system/plugins.json') { + const loaded = summary.loaded_count || 0; + const orphans = summary.orphan_count || 0; + const orphPart = orphans ? ` · ${orphans} orphan${orphans === 1 ? '' : 's'}` : ''; + return `${loaded} plugin${loaded === 1 ? '' : 's'} loaded${orphPart}`; + } + if (path === 'client/console.json') { + const total = summary.entry_count || 0; + const lvl = summary.by_level || {}; + const parts = []; + for (const k of ['error','warn','info','log','debug']) { + if (lvl[k]) parts.push(`${lvl[k]} ${k}`); + } + return `${total} entries${parts.length ? ' (' + parts.join(', ') + ')' : ''}`; + } + if (path === 'system/hardware.json') { + const bits = []; + if (summary.cpu_brand) bits.push(summary.cpu_brand); + if (summary.cores_logical) bits.push(`${summary.cores_logical} cores`); + if (summary.gpu_count) bits.push(`${summary.gpu_count} GPU`); + if (summary.runtime) bits.push(`runtime: ${summary.runtime}`); + return bits.join(' · '); + } + if (path === 'client/hardware.json') { + const bits = []; + if (summary.runtime) bits.push(summary.runtime); + if (summary.webgl_renderer) bits.push(summary.webgl_renderer); + return bits.join(' · '); + } + if (path === 'client/local_storage.json') { + return `${summary.key_count || 0} keys`; + } + if (path === 'system/version.json') { + const bits = []; + if (summary.feedBack) bits.push(`feedBack ${summary.feedBack}`); + if (summary.python) bits.push(`python ${summary.python}`); + if (summary.os) bits.push(summary.os); + return bits.join(' · '); + } + return ''; + } + + function pushSection(title, list, emptyHint) { + if (!list.length) { + if (emptyHint) { + sections.push(`
${_escapeHtml(title)}
${_escapeHtml(emptyHint)}
`); + } + return; + } + const rows = list.map(f => { + const meta = _DIAG_FILE_LABELS[f.path] || { label: f.path, desc: '' }; + const summary = _summaryLine(f.path, f.summary); + const summaryHtml = summary + ? `
${summary}
` + : ''; + return `
+
+
${_escapeHtml(meta.label)}
+
${_escapeHtml(meta.desc)}
+ ${summaryHtml} +
+
${_escapeHtml(_formatBytes(f.size))}
+
`; + }).join(''); + sections.push(`
${_escapeHtml(title)}
${rows}
`); + } + + pushSection('System', groups.system, include.system ? '' : 'Skipped (toggle off)'); + pushSection('Server logs', groups.logs, include.logs + ? 'No log file configured — set LOG_FILE env var to include server logs.' + : 'Skipped (toggle off)'); + pushSection('Plugin diagnostics', groups.plugins, include.plugins + ? 'No plugins have opted in to diagnostics.' + : 'Skipped (toggle off)'); + + // Client section preview is a server-side estimate only — actual + // client/* payloads are added at Export time after the browser + // snapshots. Show what WILL be added, not file sizes. + const clientLines = []; + if (include.console) clientLines.push({ label: 'Browser console', desc: 'console.log/warn/error transcript + window errors.' }); + if (include.hardware) clientLines.push({ label: 'Hardware (browser)', desc: 'WebGL/WebGPU adapter, host OS via userAgent.' }); + clientLines.push({ label: 'Browser storage', desc: 'localStorage contents (preferences).' }); + clientLines.push({ label: 'User agent', desc: 'Browser, screen, page URL.' }); + const clientHtml = clientLines.map(c => `
+
${_escapeHtml(c.label)}
${_escapeHtml(c.desc)}
+
added on export
+
`).join(''); + sections.push(`
Browser data
${clientHtml}
`); + + const notesHtml = (m.notes || []).length + ? `
+
Notes
+ ${(m.notes).map(n => `
• ${_escapeHtml(n)}
`).join('')} +
` + : ''; + + const privacyHtml = redact + ? `
🔒 Redaction enabled — paths, song names, IPs, and secrets will be replaced with stable hash tokens.
` + : `
⚠ Redaction OFF — bundle will contain raw paths, song names, and IPs. Only share with people you trust.
`; + + return ` +
+
+
${_escapeHtml(data.filename)}
+
${_escapeHtml(_formatBytes(totalBytes))} server-side
+
+
runtime: ${_escapeHtml(m.runtime || 'unknown')} · exported_at: ${_escapeHtml(m.exported_at || '')}
+ ${notesHtml} + ${sections.join('')} + ${privacyHtml} +
`; +} + +export async function previewDiagnostics() { + const status = document.getElementById('diag-status'); + const preview = document.getElementById('diag-preview'); + if (!status || !preview) return; + status.textContent = 'Building preview…'; + preview.classList.add('hidden'); + const include = _diagIncludeFromUI(); + const params = new URLSearchParams({ + redact: String(_diagRedactFromUI()), + system: String(include.system), + hardware: String(include.hardware), + logs: String(include.logs), + console: String(include.console), + plugins: String(include.plugins), + }); + try { + const resp = await fetch(`/api/diagnostics/preview?${params.toString()}`); + if (!resp.ok) { + status.textContent = `Preview failed (HTTP ${resp.status})`; + return; + } + const data = await resp.json(); + preview.innerHTML = _renderDiagPreview(data); + preview.classList.remove('hidden'); + status.textContent = 'Preview ready.'; + } catch (e) { + status.textContent = `Preview failed: ${e.message}`; + } +} + +export async function exportDiagnostics() { + const status = document.getElementById('diag-status'); + if (!status) return; + status.textContent = 'Building bundle…'; + const include = _diagIncludeFromUI(); + const redact = _diagRedactFromUI(); + + const diag = window.feedBack && window.feedBack.diagnostics; + const body = { + redact, + include, + client_console: include.console && diag ? diag.snapshotConsole() : null, + client_hardware: include.hardware && diag ? await diag.snapshotHardware() : null, + client_ua: diag ? diag.snapshotUa() : null, + local_storage: diag ? diag.snapshotLocalStorage() : null, + client_contributions: diag ? diag.snapshotContributions() : null, + }; + + let resp; + try { + resp = await fetch('/api/diagnostics/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (e) { + status.textContent = `Export failed: ${e.message}`; + return; + } + if (!resp.ok) { + status.textContent = `Export failed (HTTP ${resp.status})`; + return; + } + let filename = 'feedBack-diag.zip'; + const disp = resp.headers.get('Content-Disposition'); + if (disp) { + const m = /filename="([^"]+)"/.exec(disp); + if (m) filename = m[1]; + } + try { + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + status.textContent = `Exported ${filename}`; + } catch (e) { + status.textContent = `Export failed during download: ${e.message}`; + } +}