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}`;
+ }
+}