/** * Bjorn Debug — Real-time process profiler. * Shows CPU, RSS, FD, threads over time + per-thread / per-file tables. * v2: rich thread info, line-level tracemalloc, open files, graph tooltip. */ import { ResourceTracker } from '../core/resource-tracker.js'; import { api, Poller } from '../core/api.js'; import { el, $, setText, empty } from '../core/dom.js'; let tracker = null; let snapshotPoller = null; // Ring buffers for graph const MAX_PTS = 200; const history = { ts: [], cpu: [], rss: [], fd: [], threads: [], swap: [] }; // Canvas refs let graphCanvas = null; let graphCtx = null; let graphRAF = null; // Tooltip state let hoverIndex = -1; let tooltipEl = null; // State let latestSnapshot = null; let isPaused = false; /* ============================================================ * mount / unmount * ============================================================ */ export async function mount(container) { tracker = new ResourceTracker('bjorn-debug'); container.innerHTML = ''; container.appendChild(buildLayout()); graphCanvas = document.getElementById('debugGraph'); tooltipEl = document.getElementById('dbgTooltip'); if (graphCanvas) { graphCtx = graphCanvas.getContext('2d'); resizeCanvas(); tracker.trackEventListener(window, 'resize', resizeCanvas); tracker.trackEventListener(graphCanvas, 'mousemove', onGraphMouseMove); tracker.trackEventListener(graphCanvas, 'mouseleave', onGraphMouseLeave); } // Seed with server history try { const h = await api.get('/api/debug/history'); if (h && h.history) { for (const pt of h.history) { pushPoint(pt.ts, pt.proc_cpu_pct, pt.rss_kb, pt.fd_open, pt.py_thread_count, pt.vm_swap_kb || 0); } } } catch (e) { /* first load */ } snapshotPoller = new Poller(fetchSnapshot, 2000); snapshotPoller.start(); drawLoop(); } export function unmount() { if (snapshotPoller) { snapshotPoller.stop(); snapshotPoller = null; } if (graphRAF) { cancelAnimationFrame(graphRAF); graphRAF = null; } if (tracker) { tracker.cleanupAll(); tracker = null; } graphCanvas = null; graphCtx = null; tooltipEl = null; latestSnapshot = null; hoverIndex = -1; for (const k of Object.keys(history)) history[k].length = 0; } /* ============================================================ * Data fetching * ============================================================ */ async function fetchSnapshot() { if (isPaused) return; try { const data = await api.get('/api/debug/snapshot', { timeout: 5000, retries: 0 }); latestSnapshot = data; pushPoint(data.ts, data.proc_cpu_pct, data.rss_kb, data.fd_open, data.py_thread_count, data.vm_swap_kb || 0); updateCards(data); updateThreadTable(data); updatePyThreadTable(data); updateTracemallocByLine(data); updateTracemallocByFile(data); updateOpenFilesTable(data); } catch (e) { /* skip */ } } function pushPoint(ts, cpu, rss, fd, threads, swap) { history.ts.push(ts); history.cpu.push(cpu); history.rss.push(rss); history.fd.push(fd); history.threads.push(threads); history.swap.push(swap); if (history.ts.length > MAX_PTS) { for (const k of Object.keys(history)) history[k].shift(); } } /* ============================================================ * Layout * ============================================================ */ function buildLayout() { const page = el('div', { class: 'dbg-page' }); // -- Header -- const header = el('div', { class: 'dbg-header' }); header.appendChild(el('h2', { class: 'dbg-title' }, ['Bjorn Debug'])); const controls = el('div', { class: 'dbg-controls' }); const pauseBtn = el('button', { class: 'btn dbg-btn', id: 'dbgPause' }, ['Pause']); pauseBtn.addEventListener('click', () => { isPaused = !isPaused; pauseBtn.textContent = isPaused ? 'Resume' : 'Pause'; pauseBtn.classList.toggle('active', isPaused); }); const gcBtn = el('button', { class: 'btn dbg-btn', id: 'dbgGC' }, ['Force GC']); gcBtn.addEventListener('click', async () => { try { const r = await api.post('/api/debug/gc/collect', {}); if (window.toast) window.toast(`GC collected ${r.collected} objects`); } catch (e) { if (window.toast) window.toast('GC failed'); } }); const tmBtn = el('button', { class: 'btn dbg-btn', id: 'dbgTracemalloc' }, ['tracemalloc: ?']); tmBtn.addEventListener('click', async () => { const tracing = latestSnapshot?.tracemalloc_active; try { const r = await api.post('/api/debug/tracemalloc', { action: tracing ? 'stop' : 'start' }); tmBtn.textContent = `tracemalloc: ${r.tracing ? 'ON' : 'OFF'}`; tmBtn.classList.toggle('active', r.tracing); } catch (e) { if (window.toast) window.toast('tracemalloc toggle failed'); } }); controls.append(pauseBtn, gcBtn, tmBtn); header.appendChild(controls); page.appendChild(header); // -- KPI cards -- const cards = el('div', { class: 'dbg-cards', id: 'dbgCards' }); for (const cd of [ { id: 'cardCPU', label: 'CPU %', value: '--' }, { id: 'cardRSS', label: 'RSS (MB)', value: '--' }, { id: 'cardSwap', label: 'Swap (MB)', value: '--' }, { id: 'cardFD', label: 'Open FDs', value: '--' }, { id: 'cardThreads', label: 'Threads', value: '--' }, { id: 'cardPeak', label: 'RSS Peak (MB)', value: '--' }, ]) { const c = el('div', { class: 'dbg-card', id: cd.id }); c.appendChild(el('div', { class: 'dbg-card-value' }, [cd.value])); c.appendChild(el('div', { class: 'dbg-card-label' }, [cd.label])); cards.appendChild(c); } page.appendChild(cards); // -- Graph with tooltip -- const graphWrap = el('div', { class: 'dbg-graph-wrap' }); const legend = el('div', { class: 'dbg-legend' }); for (const li of [ { color: '#00d4ff', label: 'CPU %' }, { color: '#00ff6a', label: 'RSS (MB)' }, { color: '#ff4169', label: 'FDs' }, { color: '#ffaa00', label: 'Threads' }, { color: '#b44dff', label: 'Swap (MB)' }, ]) { const item = el('span', { class: 'dbg-legend-item' }); item.appendChild(el('span', { class: 'dbg-legend-dot', style: `background:${li.color}` })); item.appendChild(document.createTextNode(li.label)); legend.appendChild(item); } graphWrap.appendChild(legend); const canvasContainer = el('div', { class: 'dbg-canvas-container' }); canvasContainer.appendChild(el('canvas', { id: 'debugGraph', class: 'dbg-canvas' })); canvasContainer.appendChild(el('div', { id: 'dbgTooltip', class: 'dbg-tooltip' })); graphWrap.appendChild(canvasContainer); page.appendChild(graphWrap); // -- Tables -- const tables = el('div', { class: 'dbg-tables' }); // 1. Kernel threads (with Python mapping) tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Kernel Threads (CPU %) — mapped to Python'])); tables.appendChild(makeTable('threadTable', 'threadBody', ['TID', 'Kernel', 'Python Name', 'Target / Current', 'State', 'CPU %', 'Bar'])); // 2. Python threads (rich) tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Python Threads — Stack Trace'])); tables.appendChild(makeTable('pyThreadTable', 'pyThreadBody', ['Name', 'Target Function', 'Source File', 'Current Frame', 'Daemon', 'Alive'])); // 3. tracemalloc by LINE (the leak finder) tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['tracemalloc — Top Allocations by Line'])); const tmInfo = el('div', { class: 'dbg-tm-info', id: 'tmInfo' }, ['tracemalloc not active — click the button to start']); tables.appendChild(tmInfo); tables.appendChild(makeTable('tmLineTable', 'tmLineBody', ['File', 'Line', 'Size (KB)', 'Count', 'Bar'])); // 4. tracemalloc by FILE (overview) tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['tracemalloc — Aggregated by File'])); tables.appendChild(makeTable('tmFileTable', 'tmFileBody', ['File', 'Size (KB)', 'Count', 'Bar'])); // 5. Open file descriptors tables.appendChild(el('h3', { class: 'dbg-section-title' }, ['Open File Descriptors'])); tables.appendChild(makeTable('fdTable', 'fdBody', ['Target', 'Type', 'Count', 'FDs', 'Bar'])); page.appendChild(tables); // CSS const style = document.createElement('style'); style.textContent = SCOPED_CSS; page.appendChild(style); return page; } function makeTable(tableId, bodyId, headers) { const wrap = el('div', { class: 'dbg-table-wrap' }); const table = el('table', { class: 'dbg-table', id: tableId }); table.appendChild(el('thead', {}, [ el('tr', {}, headers.map(h => el('th', {}, [h]))) ])); table.appendChild(el('tbody', { id: bodyId })); wrap.appendChild(table); return wrap; } /* ============================================================ * Card updates * ============================================================ */ function updateCards(d) { setCardVal('cardCPU', d.proc_cpu_pct.toFixed(1), d.proc_cpu_pct > 80 ? 'hot' : d.proc_cpu_pct > 40 ? 'warm' : ''); setCardVal('cardRSS', (d.rss_kb / 1024).toFixed(1), d.rss_kb > 400000 ? 'hot' : d.rss_kb > 200000 ? 'warm' : ''); setCardVal('cardSwap', ((d.vm_swap_kb || 0) / 1024).toFixed(1), d.vm_swap_kb > 50000 ? 'hot' : d.vm_swap_kb > 10000 ? 'warm' : ''); setCardVal('cardFD', d.fd_open, d.fd_open > 500 ? 'hot' : d.fd_open > 200 ? 'warm' : ''); setCardVal('cardThreads', `${d.py_thread_count} / ${d.kernel_threads}`, d.py_thread_count > 50 ? 'hot' : d.py_thread_count > 20 ? 'warm' : ''); setCardVal('cardPeak', ((d.vm_peak_kb || 0) / 1024).toFixed(1), ''); const tmBtn = document.getElementById('dbgTracemalloc'); if (tmBtn) { tmBtn.textContent = `tracemalloc: ${d.tracemalloc_active ? 'ON' : 'OFF'}`; tmBtn.classList.toggle('active', d.tracemalloc_active); } } function setCardVal(id, val, level) { const card = document.getElementById(id); if (!card) return; const valEl = card.querySelector('.dbg-card-value'); if (valEl) valEl.textContent = val; card.classList.remove('hot', 'warm'); if (level) card.classList.add(level); } /* ============================================================ * Tables * ============================================================ */ function updateThreadTable(d) { const body = document.getElementById('threadBody'); if (!body || !d.threads) return; body.innerHTML = ''; const maxCpu = Math.max(1, ...d.threads.map(t => t.cpu_pct)); for (const t of d.threads.slice(0, 40)) { const pct = t.cpu_pct; const barW = Math.max(1, (pct / maxCpu) * 100); const barColor = pct > 50 ? '#ff4169' : pct > 15 ? '#ffaa00' : '#00d4ff'; // Build target/current cell let targetText = ''; if (t.py_target) { targetText = t.py_target; if (t.py_module) targetText = `${t.py_module}.${targetText}`; } if (t.py_current) { targetText += targetText ? ` | ${t.py_current}` : t.py_current; } const row = el('tr', { class: pct > 30 ? 'dbg-row-hot' : '' }, [ el('td', { class: 'dbg-num' }, [String(t.tid)]), el('td', { class: 'dbg-mono' }, [t.name]), el('td', { class: 'dbg-mono' }, [t.py_name || '--']), el('td', { class: 'dbg-mono dbg-target', title: targetText }, [targetText || '--']), el('td', {}, [t.state]), el('td', { class: 'dbg-num' }, [pct.toFixed(1)]), el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${barColor}` })]), ]); body.appendChild(row); } } function updatePyThreadTable(d) { const body = document.getElementById('pyThreadBody'); if (!body || !d.py_threads) return; body.innerHTML = ''; for (const t of d.py_threads) { // Format current frame as "file:line func()" let currentFrame = '--'; if (t.stack_top && t.stack_top.length > 0) { const f = t.stack_top[0]; currentFrame = `${f.file}:${f.line} ${f.func}()`; } // Build full stack tooltip let stackTooltip = ''; if (t.stack_top) { stackTooltip = t.stack_top.map(f => `${f.file}:${f.line} ${f.func}()`).join('\n'); } const targetFile = t.target_file || t.target_module || ''; const shortFile = targetFile.split('/').slice(-2).join('/'); const row = el('tr', {}, [ el('td', { class: 'dbg-mono dbg-name' }, [t.name]), el('td', { class: 'dbg-mono' }, [t.target_func || '--']), el('td', { class: 'dbg-mono dbg-file', title: targetFile }, [shortFile || '--']), el('td', { class: 'dbg-mono dbg-target', title: stackTooltip }, [currentFrame]), el('td', {}, [t.daemon ? 'Yes' : 'No']), el('td', {}, [t.alive ? 'Yes' : 'No']), ]); body.appendChild(row); } } function updateTracemallocByLine(d) { const info = document.getElementById('tmInfo'); const body = document.getElementById('tmLineBody'); if (!body) return; if (!d.tracemalloc_active) { if (info) info.textContent = 'tracemalloc not active — click the button to start tracing'; body.innerHTML = ''; return; } if (info) info.textContent = `Traced: ${d.tracemalloc_current_kb.toFixed(0)} KB — Peak: ${d.tracemalloc_peak_kb.toFixed(0)} KB`; body.innerHTML = ''; const items = d.tracemalloc_by_line || []; if (!items.length) return; const maxSize = Math.max(1, ...items.map(t => t.size_kb)); for (const t of items) { const barW = Math.max(1, (t.size_kb / maxSize) * 100); const sizeColor = t.size_kb > 100 ? '#ff4169' : t.size_kb > 30 ? '#ffaa00' : '#b44dff'; const row = el('tr', { class: t.size_kb > 100 ? 'dbg-row-hot' : '' }, [ el('td', { class: 'dbg-mono dbg-file', title: t.full_path }, [t.file]), el('td', { class: 'dbg-num' }, [String(t.line)]), el('td', { class: 'dbg-num' }, [t.size_kb.toFixed(1)]), el('td', { class: 'dbg-num' }, [String(t.count)]), el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${sizeColor}` })]), ]); body.appendChild(row); } } function updateTracemallocByFile(d) { const body = document.getElementById('tmFileBody'); if (!body) return; body.innerHTML = ''; const items = d.tracemalloc_by_file || []; if (!items.length || !d.tracemalloc_active) return; const maxSize = Math.max(1, ...items.map(t => t.size_kb)); for (const t of items) { const barW = Math.max(1, (t.size_kb / maxSize) * 100); const row = el('tr', {}, [ el('td', { class: 'dbg-mono dbg-file', title: t.full_path }, [t.file]), el('td', { class: 'dbg-num' }, [t.size_kb.toFixed(1)]), el('td', { class: 'dbg-num' }, [String(t.count)]), el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:#b44dff` })]), ]); body.appendChild(row); } } function updateOpenFilesTable(d) { const body = document.getElementById('fdBody'); if (!body) return; body.innerHTML = ''; const items = d.open_files || []; if (!items.length) return; const maxCount = Math.max(1, ...items.map(f => f.count)); for (const f of items) { const barW = Math.max(1, (f.count / maxCount) * 100); const typeColors = { file: '#00d4ff', socket: '#ff4169', pipe: '#ffaa00', device: '#888', proc: '#666', temp: '#b44dff', anon: '#555', other: '#444' }; const barColor = typeColors[f.type] || '#444'; const fdStr = f.fds.join(', ') + (f.count > f.fds.length ? '...' : ''); const row = el('tr', { class: f.count > 5 ? 'dbg-row-warn' : '' }, [ el('td', { class: 'dbg-mono dbg-target', title: f.target }, [f.target]), el('td', {}, [el('span', { class: `dbg-type-badge dbg-type-${f.type}` }, [f.type])]), el('td', { class: 'dbg-num' }, [String(f.count)]), el('td', { class: 'dbg-mono dbg-fds' }, [fdStr]), el('td', {}, [el('div', { class: 'dbg-bar', style: `width:${barW}%;background:${barColor}` })]), ]); body.appendChild(row); } } /* ============================================================ * Graph + tooltip * ============================================================ */ function getGraphLayout() { if (!graphCanvas) return null; const W = graphCanvas.width; const H = graphCanvas.height; const dpr = window.devicePixelRatio || 1; const pad = { l: 50 * dpr, r: 60 * dpr, t: 10 * dpr, b: 25 * dpr }; return { W, H, dpr, pad, gW: W - pad.l - pad.r, gH: H - pad.t - pad.b }; } function onGraphMouseMove(e) { if (!graphCanvas || history.ts.length < 2) return; const rect = graphCanvas.getBoundingClientRect(); const L = getGraphLayout(); if (!L) return; const mouseX = (e.clientX - rect.left) * L.dpr; const frac = (mouseX - L.pad.l) / L.gW; const idx = Math.round(frac * (history.ts.length - 1)); if (idx < 0 || idx >= history.ts.length) { hoverIndex = -1; if (tooltipEl) tooltipEl.style.display = 'none'; return; } hoverIndex = idx; // Position & populate tooltip if (tooltipEl) { const ago = history.ts[history.ts.length - 1] - history.ts[idx]; const ts = new Date(history.ts[idx] * 1000); const timeStr = ts.toLocaleTimeString(); tooltipEl.innerHTML = `