| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>VN Debug Dashboard</title> |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.6/dist/chart.umd.min.js"></script> |
| <style> |
| :root { |
| --bg: #0f1117; |
| --surface: #1a1d27; |
| --border: #252836; |
| --text: #dde1f0; |
| --muted: #6e7891; |
| --blue: #4c9be8; |
| --red: #e05c5c; |
| --green: #56b87a; |
| --yellow: #e0a840; |
| --orange: #e09440; |
| --purple: #b07de8; |
| --gray: #4e5568; |
| } |
| * { box-sizing: border-box; margin: 0; padding: 0; } |
| body { |
| background: var(--bg); |
| color: var(--text); |
| font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; |
| font-size: 13px; |
| padding: 16px; |
| min-height: 100vh; |
| } |
| |
| |
| header { |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| margin-bottom: 16px; |
| } |
| header h1 { |
| font-size: 17px; |
| font-weight: 700; |
| letter-spacing: -.02em; |
| } |
| header h1 span { color: var(--blue); } |
| .status { |
| display: flex; |
| align-items: center; |
| gap: 7px; |
| font-size: 12px; |
| color: var(--muted); |
| } |
| .dot { |
| width: 8px; height: 8px; |
| border-radius: 50%; |
| background: var(--gray); |
| flex-shrink: 0; |
| transition: background 0.3s; |
| } |
| .dot.live { background: var(--green); box-shadow: 0 0 6px var(--green); } |
| .dot.error { background: var(--red); } |
| |
| |
| .kpi-grid { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 10px; |
| margin-bottom: 12px; |
| } |
| @media (max-width: 800px) { .kpi-grid { grid-template-columns: repeat(2, 1fr); } } |
| .kpi { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 12px 14px; |
| } |
| .kpi-label { |
| font-size: 10px; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: .07em; |
| color: var(--muted); |
| margin-bottom: 5px; |
| } |
| .kpi-value { |
| font-size: 28px; |
| font-weight: 800; |
| line-height: 1; |
| font-variant-numeric: tabular-nums; |
| color: var(--text); |
| } |
| .kpi-sub { |
| font-size: 11px; |
| color: var(--muted); |
| margin-top: 5px; |
| } |
| .c-blue { color: var(--blue) !important; } |
| .c-green { color: var(--green) !important; } |
| .c-yellow { color: var(--yellow) !important; } |
| .c-red { color: var(--red) !important; } |
| |
| |
| .charts-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 10px; |
| margin-bottom: 12px; |
| } |
| @media (max-width: 900px) { .charts-grid { grid-template-columns: 1fr; } } |
| .card { |
| background: var(--surface); |
| border: 1px solid var(--border); |
| border-radius: 8px; |
| padding: 14px; |
| } |
| .card h2 { |
| font-size: 10px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: .07em; |
| color: var(--muted); |
| margin-bottom: 10px; |
| } |
| |
| |
| table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 12px; |
| } |
| th { |
| text-align: right; |
| font-size: 10px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: .05em; |
| color: var(--muted); |
| padding: 0 8px 8px; |
| border-bottom: 1px solid var(--border); |
| } |
| th:first-child { text-align: left; } |
| td { |
| padding: 6px 8px; |
| border-bottom: 1px solid var(--border); |
| text-align: right; |
| font-variant-numeric: tabular-nums; |
| color: var(--text); |
| } |
| td:first-child { text-align: left; color: var(--muted); } |
| tr:last-child td { border-bottom: none; } |
| tr:hover td { background: rgba(255,255,255,.025); } |
| .total-row td { font-weight: 700; color: var(--text) !important; } |
| |
| |
| .actions { |
| display: flex; |
| align-items: center; |
| gap: 10px; |
| margin-top: 12px; |
| } |
| button { |
| background: var(--blue); |
| color: #fff; |
| border: none; |
| border-radius: 6px; |
| padding: 7px 14px; |
| font-size: 12px; |
| font-weight: 700; |
| cursor: pointer; |
| opacity: 1; |
| transition: opacity .15s; |
| } |
| button:hover:not(:disabled) { opacity: .82; } |
| button:disabled { opacity: .35; cursor: default; } |
| #report-msg { font-size: 12px; color: var(--green); } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <header> |
| <h1>VN <span>Debug</span> Dashboard</h1> |
| <div class="status"> |
| <div class="dot" id="dot"></div> |
| <span id="status-text">Connectingβ¦</span> |
| </div> |
| </header> |
|
|
| |
| <div class="kpi-grid"> |
| <div class="kpi"> |
| <div class="kpi-label">Turns</div> |
| <div class="kpi-value c-blue" id="kpi-turns">β</div> |
| <div class="kpi-sub">completed</div> |
| </div> |
| <div class="kpi"> |
| <div class="kpi-label">LLM avg</div> |
| <div class="kpi-value" id="kpi-llm">β</div> |
| <div class="kpi-sub" id="kpi-llm-sub">p95 β</div> |
| </div> |
| <div class="kpi"> |
| <div class="kpi-label">Image cache</div> |
| <div class="kpi-value" id="kpi-cache">β</div> |
| <div class="kpi-sub" id="kpi-cache-sub">hits / total</div> |
| </div> |
| <div class="kpi"> |
| <div class="kpi-label">Session</div> |
| <div class="kpi-value c-blue" id="kpi-elapsed">β</div> |
| <div class="kpi-sub" id="kpi-gpu">β</div> |
| </div> |
| </div> |
|
|
| |
| <div class="charts-grid"> |
| <div class="card"> |
| <h2>System Resources β last 2 min</h2> |
| <canvas id="res-chart" height="170"></canvas> |
| </div> |
| <div class="card"> |
| <h2>Latency per Turn (ms, stacked)</h2> |
| <canvas id="lat-chart" height="170"></canvas> |
| </div> |
| </div> |
|
|
| |
| <div class="card"> |
| <h2>Operations</h2> |
| <table> |
| <thead> |
| <tr> |
| <th>Operation</th> |
| <th>n</th> |
| <th>avg ms</th> |
| <th>p95 ms</th> |
| <th>max ms</th> |
| </tr> |
| </thead> |
| <tbody id="ops-body"></tbody> |
| </table> |
| </div> |
|
|
| |
| <div class="actions"> |
| <button id="save-btn" onclick="saveReport()">Save report now</button> |
| <span id="report-msg"></span> |
| </div> |
|
|
| <script> |
| |
| Chart.defaults.color = '#6e7891'; |
| Chart.defaults.borderColor = '#252836'; |
| Chart.defaults.font.size = 10; |
| |
| const GRID_COLOR = 'rgba(255,255,255,.04)'; |
| const BASE_SCALES = { |
| x: { ticks: { color: '#555', maxTicksLimit: 8 }, grid: { color: GRID_COLOR } }, |
| y: { ticks: { color: '#555' }, grid: { color: GRID_COLOR } }, |
| }; |
| |
| |
| const resCtx = document.getElementById('res-chart').getContext('2d'); |
| const resChart = new Chart(resCtx, { |
| type: 'line', |
| data: { |
| labels: [], |
| datasets: [ |
| { |
| label: 'RAM %', yAxisID: 'yPct', data: [], |
| borderColor: '#4c9be8', backgroundColor: 'rgba(76,155,232,.06)', |
| borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3, |
| }, |
| { |
| label: 'CPU %', yAxisID: 'yPct', data: [], |
| borderColor: '#e05c5c', backgroundColor: 'rgba(224,92,92,.05)', |
| borderWidth: 1.5, pointRadius: 0, fill: true, tension: 0.3, |
| }, |
| { |
| label: 'VRAM', yAxisID: 'yVram', data: [], |
| borderColor: '#e09440', backgroundColor: 'rgba(224,148,64,.05)', |
| borderWidth: 1.5, pointRadius: 0, fill: false, tension: 0.3, |
| }, |
| ] |
| }, |
| options: { |
| animation: false, |
| responsive: true, |
| interaction: { mode: 'index', intersect: false }, |
| plugins: { |
| legend: { labels: { color: '#888', boxWidth: 10, font: { size: 10 } } }, |
| }, |
| scales: { |
| x: BASE_SCALES.x, |
| yPct: { |
| type: 'linear', position: 'left', min: 0, suggestedMax: 100, |
| ticks: { color: '#555', callback: v => v + '%' }, |
| grid: { color: GRID_COLOR }, |
| }, |
| yVram: { |
| type: 'linear', position: 'right', min: 0, |
| ticks: { color: '#7a6040' }, |
| grid: { drawOnChartArea: false }, |
| display: false, |
| }, |
| }, |
| }, |
| }); |
| |
| |
| const latCtx = document.getElementById('lat-chart').getContext('2d'); |
| const latChart = new Chart(latCtx, { |
| type: 'bar', |
| data: { |
| labels: [], |
| datasets: [ |
| { label: 'LLM', data: [], backgroundColor: '#4c9be8', stack: 's' }, |
| { label: 'Painter', data: [], backgroundColor: '#56b87a', stack: 's' }, |
| { label: 'STT', data: [], backgroundColor: '#b07de8', stack: 's' }, |
| { label: 'Compact', data: [], backgroundColor: '#e09440', stack: 's' }, |
| { label: 'Other', data: [], backgroundColor: '#4e5568', stack: 's' }, |
| ] |
| }, |
| options: { |
| animation: false, |
| responsive: true, |
| plugins: { |
| legend: { labels: { color: '#888', boxWidth: 10, font: { size: 10 } } }, |
| tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${Math.round(ctx.raw)} ms` } }, |
| }, |
| scales: { |
| x: { ...BASE_SCALES.x, stacked: true }, |
| y: { |
| ...BASE_SCALES.y, stacked: true, |
| title: { display: true, text: 'ms', color: '#555', font: { size: 10 } }, |
| }, |
| }, |
| }, |
| }); |
| |
| |
| function fmtMs(ms) { |
| if (ms == null) return 'β'; |
| return ms >= 1000 ? (ms / 1000).toFixed(1) + 's' : Math.round(ms) + 'ms'; |
| } |
| function fmtElapsed(s) { |
| if (s == null) return 'β'; |
| const m = Math.floor(s / 60), sec = Math.floor(s % 60); |
| return m > 0 ? `${m}m ${sec}s` : `${sec}s`; |
| } |
| function fmtTime(ts) { |
| const d = new Date(ts * 1000); |
| return d.getMinutes().toString().padStart(2, '0') + ':' + d.getSeconds().toString().padStart(2, '0'); |
| } |
| function latencyClass(ms) { |
| if (ms == null) return ''; |
| if (ms < 2000) return 'c-green'; |
| if (ms < 6000) return 'c-yellow'; |
| return 'c-red'; |
| } |
| function cacheClass(rate) { |
| if (rate == null) return ''; |
| if (rate >= 0.7) return 'c-green'; |
| if (rate >= 0.4) return 'c-yellow'; |
| return 'c-red'; |
| } |
| |
| const GPU_LABELS = { nvml: 'NVIDIA GPU', rocm: 'AMD ROCm', mps: 'Apple MPS (unified)' }; |
| |
| const OP_ORDER = [ |
| 'total_turn', 'llm_direct', 'compact_memory', 'context_assembly', |
| 'apply_directives', 'save_memory', 'painter_backdrop', 'painter_sprite', 'stt', |
| ]; |
| const OP_NAMES = { |
| total_turn: 'Turn total', |
| llm_direct: 'LLM direct_turn', |
| compact_memory: 'Compact memory (LLM)', |
| context_assembly: 'Context assembly', |
| apply_directives: 'apply_directives', |
| save_memory: 'save_memory', |
| painter_backdrop: 'Painter β backdrop', |
| painter_sprite: 'Painter β sprite', |
| stt: 'STT transcribe', |
| }; |
| |
| |
| function update(data) { |
| |
| document.getElementById('kpi-turns').textContent = data.turns_completed ?? 'β'; |
| |
| |
| const llm = data.ops?.llm_direct; |
| const kpiLlm = document.getElementById('kpi-llm'); |
| kpiLlm.textContent = fmtMs(llm?.avg_ms); |
| kpiLlm.className = 'kpi-value ' + latencyClass(llm?.avg_ms); |
| document.getElementById('kpi-llm-sub').textContent = |
| llm ? `p95 ${fmtMs(llm.p95_ms)} Β· max ${fmtMs(llm.max_ms)}` : 'no data yet'; |
| |
| |
| const rate = data.cache_hit_rate; |
| const kpiCache = document.getElementById('kpi-cache'); |
| kpiCache.textContent = rate != null ? Math.round(rate * 100) + '%' : 'β'; |
| kpiCache.className = 'kpi-value ' + cacheClass(rate); |
| const tot = (data.cache_hits ?? 0) + (data.cache_misses ?? 0); |
| document.getElementById('kpi-cache-sub').textContent = |
| tot > 0 ? `${data.cache_hits} hits / ${tot} total` : 'no renders yet'; |
| |
| |
| document.getElementById('kpi-elapsed').textContent = fmtElapsed(data.session_elapsed_s); |
| document.getElementById('kpi-gpu').textContent = |
| data.gpu_backend ? (GPU_LABELS[data.gpu_backend] ?? data.gpu_backend) : 'No GPU detected'; |
| |
| |
| const samples = data.samples ?? []; |
| resChart.data.labels = samples.map(s => fmtTime(s.ts)); |
| resChart.data.datasets[0].data = samples.map(s => s.ram_pct ?? null); |
| resChart.data.datasets[1].data = samples.map(s => s.cpu_pct ?? null); |
| |
| const hasVram = samples.some(s => s.vram_mb != null); |
| if (hasVram) { |
| |
| |
| resChart.data.datasets[2].data = samples.map(s => { |
| if (s.vram_mb == null) return null; |
| if (s.vram_pct != null) return s.vram_pct; |
| |
| if (s.ram_pct > 0) return (s.vram_mb / (s.ram_mb / (s.ram_pct / 100))) * 100; |
| return null; |
| }); |
| const vramLabel = data.gpu_backend === 'mps' ? 'RSS % (MPS)' : 'VRAM %'; |
| resChart.data.datasets[2].label = vramLabel; |
| resChart.options.scales.yVram.display = false; |
| } else { |
| resChart.data.datasets[2].data = samples.map(() => null); |
| } |
| resChart.update('none'); |
| |
| |
| const turns = data.turn_breakdown ?? []; |
| latChart.data.labels = turns.map(t => `T${t.turn}`); |
| latChart.data.datasets[0].data = turns.map(t => t.llm_direct ?? 0); |
| latChart.data.datasets[1].data = turns.map(t => (t.painter_backdrop ?? 0) + (t.painter_sprite ?? 0)); |
| latChart.data.datasets[2].data = turns.map(t => t.stt ?? 0); |
| latChart.data.datasets[3].data = turns.map(t => t.compact_memory ?? 0); |
| latChart.data.datasets[4].data = turns.map(t => { |
| const acc = (t.llm_direct ?? 0) + (t.painter_backdrop ?? 0) + (t.painter_sprite ?? 0) |
| + (t.stt ?? 0) + (t.compact_memory ?? 0) |
| + (t.apply_directives ?? 0) + (t.save_memory ?? 0); |
| return Math.max(0, (t.total_turn ?? 0) - acc); |
| }); |
| latChart.update('none'); |
| |
| |
| const ops = data.ops ?? {}; |
| const keys = [...OP_ORDER.filter(k => k in ops), ...Object.keys(ops).filter(k => !OP_ORDER.includes(k))]; |
| const tbody = document.getElementById('ops-body'); |
| tbody.innerHTML = keys.map(k => { |
| const o = ops[k]; |
| if (!o) return ''; |
| const isTot = k === 'total_turn'; |
| return `<tr class="${isTot ? 'total-row' : ''}"> |
| <td>${OP_NAMES[k] ?? k}</td> |
| <td>${o.count}</td> |
| <td class="${latencyClass(o.avg_ms)}">${Math.round(o.avg_ms)}</td> |
| <td>${Math.round(o.p95_ms)}</td> |
| <td>${Math.round(o.max_ms)}</td> |
| </tr>`; |
| }).join(''); |
| } |
| |
| |
| const dot = document.getElementById('dot'); |
| const statusTxt = document.getElementById('status-text'); |
| let src = null; |
| |
| function connect() { |
| if (src) { src.close(); src = null; } |
| dot.className = 'dot'; |
| statusTxt.textContent = 'Connectingβ¦'; |
| |
| src = new EventSource('/debug/stream'); |
| src.onopen = () => { |
| dot.className = 'dot live'; |
| statusTxt.textContent = 'Live'; |
| }; |
| src.onmessage = e => { |
| try { update(JSON.parse(e.data)); } catch { } |
| }; |
| src.onerror = () => { |
| dot.className = 'dot error'; |
| statusTxt.textContent = 'Reconnecting in 3sβ¦'; |
| src.close(); src = null; |
| setTimeout(connect, 3000); |
| }; |
| } |
| |
| |
| async function saveReport() { |
| const btn = document.getElementById('save-btn'); |
| const msg = document.getElementById('report-msg'); |
| btn.disabled = true; |
| msg.style.color = 'var(--muted)'; |
| msg.textContent = 'Savingβ¦'; |
| try { |
| const r = await fetch('/debug/report'); |
| const d = await r.json(); |
| msg.style.color = 'var(--green)'; |
| msg.textContent = d.status === 'ok' ? 'β Saved to runs/debug_*/' : 'β Save failed'; |
| } catch { |
| msg.style.color = 'var(--red)'; |
| msg.textContent = 'β Request failed'; |
| } |
| setTimeout(() => { msg.textContent = ''; btn.disabled = false; }, 4000); |
| } |
| |
| connect(); |
| </script> |
| </body> |
| </html> |
|
|