From f6871261894de3c1ab63c95c8479a3d28ca98c83 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Mon, 16 Mar 2026 18:29:33 +0100 Subject: [PATCH 1/4] fix(viewer): make pie chart tooltips follow cursor --- crates/stylus-trace-core/src/output/viewer/viewer.js | 6 +++--- tests/fixtures/baseline.html | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/stylus-trace-core/src/output/viewer/viewer.js b/crates/stylus-trace-core/src/output/viewer/viewer.js index 867c132..fe52349 100644 --- a/crates/stylus-trace-core/src/output/viewer/viewer.js +++ b/crates/stylus-trace-core/src/output/viewer/viewer.js @@ -160,7 +160,6 @@ class PieChart { if (hit !== this.hoveredSlice) { this.hoveredSlice = hit; - this.updateTooltip(screenX, screenY); this.render(); document.querySelectorAll('.hot-path-item').forEach(el => el.classList.remove('highlight')); if (hit && hit.name !== 'Other') { @@ -168,14 +167,15 @@ class PieChart { if (el) el.classList.add('highlight'); } } + this.updateTooltip(screenX, screenY); } updateTooltip(x, y) { const tooltip = document.getElementById('tooltip'); if (this.hoveredSlice) { tooltip.style.display = 'block'; - // tooltip.style.left = (x + 0) + 'px'; - // tooltip.style.top = (y + 0) + 'px'; + tooltip.style.left = (x + 15) + 'px'; + tooltip.style.top = (y + 15) + 'px'; tooltip.innerHTML = `
>${this.hoveredSlice.name}
diff --git a/tests/fixtures/baseline.html b/tests/fixtures/baseline.html index 40e6f5c..012cc5f 100644 --- a/tests/fixtures/baseline.html +++ b/tests/fixtures/baseline.html @@ -287,7 +287,7 @@

:: HOT_PATHS ::

` sequences in the JSON from breaking the page. +fn encode_json(json: &str) -> String { + BASE64.encode(json.as_bytes()) +} - // In a real implementation with more time, we'd use a proper template engine or - // at least a better replacement strategy. For this "best effort" we'll do simple replacement. +/// Build the final, self-contained HTML from the template. +/// +/// Inlines CSS and JS, and injects the base64 JSON blobs + optional SVG. +fn build_html( + profile_a_b64: &str, + profile_b_b64: Option<&str>, + diff_b64: Option<&str>, + flamegraph_svg: Option<&str>, +) -> String { let mut html = HTML_TEMPLATE.to_string(); - // Inject data - html = html.replace("/* PROFILE_DATA_JSON */", &profile_json); + // Inject data placeholders (base64-encoded JSON) + html = html.replace("/* PROFILE_DATA_B64 */", profile_a_b64); + html = html.replace( + "/* PROFILE_B_DATA_B64 */", + profile_b_b64.unwrap_or_default(), + ); + html = html.replace("/* DIFF_DATA_B64 */", diff_b64.unwrap_or_default()); + html = html.replace( + "/* FLAMEGRAPH_SVG */", + flamegraph_svg.unwrap_or_default(), + ); - // Inline CSS and JS for "self-contained" requirement + // Inline CSS html = html.replace( "", &format!("", CSS_TEMPLATE), ); + + // Inline JS html = html.replace( "", &format!("", JS_TEMPLATE), ); - fs::write(output_path, html).context("Failed to write viewer HTML")?; + html +} + +/// Generate a self-contained HTML viewer for a single profile. +pub fn generate_viewer( + profile: &Profile, + flamegraph_svg: Option<&str>, + output_path: &Path, +) -> Result<()> { + let profile_json = serde_json::to_string(profile)?; + let profile_b64 = encode_json(&profile_json); + let html = build_html(&profile_b64, None, None, flamegraph_svg); + + fs::write(output_path, html).context("Failed to write viewer HTML")?; Ok(()) } -/// Generate a self-contained HTML viewer for a diff +/// Generate a self-contained HTML viewer for a diff (baseline vs target). pub fn generate_diff_viewer( profile_a: &Profile, profile_b: &Profile, diff_report: &serde_json::Value, + flamegraph_svg: Option<&str>, output_path: &Path, ) -> Result<()> { let profile_a_json = serde_json::to_string(profile_a)?; let profile_b_json = serde_json::to_string(profile_b)?; let diff_json = serde_json::to_string(diff_report)?; - let mut html = HTML_TEMPLATE.to_string(); + let a_b64 = encode_json(&profile_a_json); + let b_b64 = encode_json(&profile_b_json); + let diff_b64 = encode_json(&diff_json); - // Inject data - html = html.replace("/* PROFILE_DATA_JSON */", &profile_a_json); - html = html.replace("/* PROFILE_B_DATA_JSON */", &profile_b_json); - html = html.replace("/* DIFF_DATA_JSON */", &diff_json); - - // Inline CSS and JS - html = html.replace( - "", - &format!("", CSS_TEMPLATE), - ); - html = html.replace( - "", - &format!("", JS_TEMPLATE), - ); + let html = build_html(&a_b64, Some(&b_b64), Some(&diff_b64), flamegraph_svg); fs::write(output_path, html).context("Failed to write diff viewer HTML")?; - Ok(()) } diff --git a/crates/stylus-trace-core/src/output/viewer/index.html b/crates/stylus-trace-core/src/output/viewer/index.html index ba13565..9ffb5bb 100644 --- a/crates/stylus-trace-core/src/output/viewer/index.html +++ b/crates/stylus-trace-core/src/output/viewer/index.html @@ -11,6 +11,14 @@
+
+ +
+ + +
+
+
-
> BASELINE_DATA
+
> BASELINE_DATA
+ + +
-
> SYSTEM: ONLINE | PROFILE: none
+
> SYSTEM: ONLINE | PROFILE: none
- - - - + + + + + + diff --git a/crates/stylus-trace-core/src/output/viewer/viewer.css b/crates/stylus-trace-core/src/output/viewer/viewer.css index 65d8137..233747a 100644 --- a/crates/stylus-trace-core/src/output/viewer/viewer.css +++ b/crates/stylus-trace-core/src/output/viewer/viewer.css @@ -1,11 +1,59 @@ :root { --bg-color: #020202; + --panel-bg: #050505; --green-main: #00ff41; --green-dark: #008f11; + --green-dim: #00cc33; --green-faint: rgba(0, 255, 65, 0.1); + --text-bright: #ffffff; + --text-dim: rgba(255, 255, 255, 0.6); --border-style: 2px solid var(--green-dark); + --bar-track: #111; } +/* Light theme overrides */ +body[data-theme="light"] { + --bg-color: #f5f5f0; + --panel-bg: #e8e8e0; + --green-main: #1a5c2a; + --green-dark: #2d7a3a; + --green-dim: #3d8a4a; + --green-faint: rgba(26, 92, 42, 0.1); + --text-bright: #0a2a12; + --text-dim: rgba(10, 42, 18, 0.7); + --border-style: 2px solid var(--green-dark); + --bar-track: #dcdccf; + + color: var(--green-main); + background-color: var(--bg-color); + text-shadow: none; +} + +body[data-theme="light"] .crt-overlay { display: none; } + +body[data-theme="light"] .glitch::before, +body[data-theme="light"] .glitch::after { display: none; } + +body[data-theme="light"] #viewer-container { + background: var(--bg-color); +} + +body[data-theme="light"] .panel { + background: var(--panel-bg); + border-color: var(--green-dark); +} + +body[data-theme="light"] .panel h2 { + text-shadow: none; +} + +body[data-theme="light"] #theme-toggle { color: #c06000; border-color: #c06000; } + +/* SVG Theme Overrides */ +body[data-theme="light"] svg text { fill: var(--text-bright) !important; } +body[data-theme="light"] svg rect { stroke: var(--panel-bg) !important; } +body[data-theme="light"] .flamegraph-empty { color: var(--green-dark); } + * { box-sizing: border-box; margin: 0; padding: 0; } body { @@ -42,7 +90,7 @@ body { justify-content: space-between; padding: 10px 24px; border-bottom: var(--border-style); - background: #050505; + background: var(--panel-bg); } .logo h1, .glitch { @@ -91,8 +139,8 @@ body { text-shadow: 0 0 4px var(--green-main); } .controls button:hover { - color: #fff; - text-shadow: 0 0 8px #fff; + color: var(--text-bright); + text-shadow: 0 0 8px var(--text-bright); } main { display: flex; flex: 1; overflow: hidden; } @@ -100,7 +148,7 @@ main { display: flex; flex: 1; overflow: hidden; } #sidebar { width: 350px; border-right: var(--border-style); - background: #050505; + background: var(--panel-bg); padding: 20px; display: flex; flex-direction: column; @@ -116,7 +164,7 @@ main { display: flex; flex: 1; overflow: hidden; } .panel h2 { font-size: 22px; margin-bottom: 15px; - color: #fff; + color: var(--text-bright); } .tx-list { @@ -129,18 +177,73 @@ main { display: flex; flex: 1; overflow: hidden; } font-size: 16px; background: var(--green-faint); border-left: 3px solid var(--green-dark); - padding: 8px; + padding: 10px; word-break: break-all; + font-family: 'Courier New', monospace; + font-size: 14px; + line-height: 1.4; +} +.tx-item label { + display: block; + color: var(--text-dim); + opacity: 1; + margin-bottom: 4px; + font-weight: bold; + text-transform: uppercase; + font-size: 12px; } -.tx-item label { color: #fff; opacity: 0.6; margin-right: 5px; } -.tx-item.target { border-left-color: var(--green-main); } +.tx-item span { color: var(--green-main); } +.tx-item.target { border-left-color: var(--green-main); margin-top: 10px; } .stats-column { display: flex; flex-direction: column; gap: 15px; } .stat-row { border-bottom: 1px dashed var(--green-dark); padding-bottom: 8px; } -.stat-row .label { font-size: 16px; color: #fff; opacity: 0.8; margin-bottom: 4px; } + +#drop-zone-container { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--bg-color); + z-index: 50; + padding: 20px; +} + +.drop-zone { + width: 600px; + height: 400px; + border: 3px dashed var(--green-dark); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + cursor: pointer; + background: repeating-linear-gradient(45deg, var(--bg-color), var(--bg-color) 10px, var(--panel-bg) 10px, var(--panel-bg) 20px); + transition: all 0.3s ease; + box-shadow: 0 0 20px rgba(0, 255, 65, 0.1); +} + +.drop-zone:hover, .drop-zone.dragover { + border-color: var(--green-main); + background: var(--green-faint); + box-shadow: 0 0 40px rgba(0, 255, 65, 0.3); +} + +.drop-zone p { + margin-top: 20px; + font-size: 24px; +} + +.drop-zone .subtext { + font-size: 18px; + opacity: 0.7; + margin-top: 10px; +} +.stat-row .label { font-size: 16px; color: var(--text-bright); opacity: 0.8; margin-bottom: 4px; } .stat-row .value { font-size: 20px; color: var(--green-main); } .stat-row .delta { margin-left: 10px; @@ -163,16 +266,63 @@ main { display: flex; flex: 1; overflow: hidden; } .hot-path-item.highlight { background: var(--green-main); color: #000; text-shadow: none; } .stack-name { display: block; word-break: break-all; font-size: 16px; margin-top: 4px; } +/* Category Stats Panel */ +#category-stats-list { list-style: none; display: flex; flex-direction: column; gap: 6px; } + +.cat-stat-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 15px; +} + +.cat-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + display: inline-block; +} + +.cat-name { + flex: 1; + color: var(--green-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cat-bar-track { + flex: 1.5; + height: 6px; + background: var(--bar-track); + border-radius: 3px; + overflow: hidden; +} + +.cat-bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.4s ease; +} + +.cat-pct { + color: var(--green-main); + min-width: 38px; + text-align: right; + font-size: 14px; +} + #viewer-container { flex: 1; display: flex; position: relative; background: repeating-linear-gradient( 45deg, - #020202, - #020202 10px, - #050505 10px, - #050505 20px + var(--bg-color), + var(--bg-color) 10px, + var(--panel-bg) 10px, + var(--panel-bg) 20px ); } .chart-canvas { @@ -182,23 +332,92 @@ main { display: flex; flex: 1; overflow: hidden; } .chart-canvas canvas { width: 100%; height: 100%; outline: none; } .chart-canvas .label { position: absolute; top: 15px; left: 15px; - background: #000; padding: 5px 10px; border: 1px solid var(--green-main); + background: var(--panel-bg); padding: 5px 10px; border: 1px solid var(--green-main); + color: var(--text-bright); pointer-events: none; } #tooltip { position: absolute; display: none; pointer-events: none; - background: #000; border: 2px solid var(--green-main); + background: var(--panel-bg); border: 2px solid var(--green-main); + color: var(--text-bright); padding: 15px; z-index: 1000; box-shadow: 5px 5px 0 var(--green-dark); } .terminal-footer { - padding: 5px 24px; border-top: var(--border-style); background: #050505; + padding: 5px 24px; border-top: var(--border-style); background: var(--panel-bg); } .hidden { display: none !important; } +/* View Tab Bar */ +#view-tabs { + display: flex; + gap: 5px; + padding: 6px 24px; + background: var(--panel-bg); + border-bottom: var(--border-style); +} + +.tab-btn { + background: transparent; + border: 1px solid var(--green-dark); + color: var(--green-dark); + font-family: 'VT323', monospace; + font-size: 18px; + padding: 3px 14px; + cursor: pointer; + transition: all 0.2s; + letter-spacing: 1px; +} + +.tab-btn:hover { + border-color: var(--green-main); + color: var(--green-main); + text-shadow: 0 0 6px var(--green-main); +} + +.tab-btn.active { + border-color: var(--green-main); + color: var(--green-main); + text-shadow: 0 0 8px var(--green-main); + box-shadow: inset 0 0 8px rgba(0, 255, 65, 0.15); +} + +/* Flamegraph View */ +#flamegraph-view { + flex: 1; + overflow: auto; + background: var(--bg-color); + display: flex; + flex-direction: column; + position: relative; +} + +#flamegraph-container { + flex: 1; + overflow: auto; + padding: 10px; +} + +#flamegraph-container svg { + width: 100%; + height: auto; + cursor: crosshair; +} + +.flamegraph-empty { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--green-dark); + font-size: 20px; + text-align: center; + pointer-events: none; +} + /* Responsive adjustments */ @media (max-width: 900px) { main { flex-direction: column; } diff --git a/crates/stylus-trace-core/src/output/viewer/viewer.js b/crates/stylus-trace-core/src/output/viewer/viewer.js index fe52349..f5998c5 100644 --- a/crates/stylus-trace-core/src/output/viewer/viewer.js +++ b/crates/stylus-trace-core/src/output/viewer/viewer.js @@ -16,6 +16,11 @@ const CONFIG = { } }; +/** Helper to get CSS variables for Canvas rendering */ +function getThemeColor(varName) { + return getComputedStyle(document.body).getPropertyValue(varName).trim(); +} + class PieChart { constructor(canvasId, data, isDiff = false) { this.canvas = document.getElementById(canvasId); @@ -49,7 +54,9 @@ class PieChart { this.data.hot_paths.slice(0, 15).forEach(path => { let name = path.stack.split(';').pop(); - const category = this.getCategory(name); + // Prefer server-provided category; fall back to legacy heuristic + // for profiles generated before this feature was added. + const category = path.category || this.getCategory(name); this.slices.push({ name: name, fullStack: path.stack, @@ -177,8 +184,8 @@ class PieChart { tooltip.style.left = (x + 15) + 'px'; tooltip.style.top = (y + 15) + 'px'; tooltip.innerHTML = ` -
>${this.hoveredSlice.name}
-
+
>${this.hoveredSlice.name}
+
GAS_USED: ${this.hoveredSlice.value.toLocaleString()}
SHARE: ${this.hoveredSlice.percentage.toFixed(2)}%
@@ -217,10 +224,10 @@ class PieChart { isHighlighted = (sliceName === query) || (query.length >= 3 && sliceName.startsWith(query)); } - if (isHighlighted) this.ctx.fillStyle = '#ffffff'; + if (isHighlighted) this.ctx.fillStyle = getThemeColor('--text-bright') || '#ffffff'; this.ctx.fill(); - this.ctx.strokeStyle = '#000000'; + this.ctx.strokeStyle = getThemeColor('--bg-color') || '#000000'; this.ctx.lineWidth = 2 / this.zoom; this.ctx.stroke(); @@ -228,7 +235,7 @@ class PieChart { if (slice.percentage > 3 && this.zoom > 0.5) { let textX = Math.cos(midAngle) * (radius * 0.7); let textY = Math.sin(midAngle) * (radius * 0.7); - this.ctx.fillStyle = '#000'; + this.ctx.fillStyle = '#000'; // Keep black for readability on colored slices this.ctx.font = `${Math.max(12, 16 / this.zoom)}px 'VT323'`; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; @@ -238,20 +245,23 @@ class PieChart { this.ctx.restore(); + const greenMain = getThemeColor('--green-main'); this.ctx.beginPath(); this.ctx.arc(width / 2, height / 2, 5, 0, Math.PI * 2); - this.ctx.fillStyle = '#00ff41'; + this.ctx.fillStyle = greenMain; this.ctx.fill(); this.ctx.beginPath(); this.ctx.moveTo(width / 2 - 20, height / 2); this.ctx.lineTo(width / 2 + 20, height / 2); this.ctx.moveTo(width / 2, height / 2 - 20); this.ctx.lineTo(width / 2, height / 2 + 20); - this.ctx.strokeStyle = '#00ff41'; + this.ctx.strokeStyle = greenMain; this.ctx.lineWidth = 1; this.ctx.stroke(); } + /** @deprecated Use `path.category` from the JSON profile instead. + * Kept as a fallback for old profiles that lack the `category` field. */ getCategory(name) { const n = name.toLowerCase(); if (n === 'root') return 'Root'; @@ -278,33 +288,222 @@ window.app = { document.addEventListener('DOMContentLoaded', () => { loadData(); + + // Check if we correctly loaded data statically + if (window.app.profileA) { + initViewer(); + } else { + // No static data, show the drag-and-drop zone + const appContainer = document.getElementById('app'); + const dropZoneContainer = document.getElementById('drop-zone-container'); + if (appContainer && dropZoneContainer) { + appContainer.classList.add('hidden'); + dropZoneContainer.classList.remove('hidden'); + setupDropZone(); + } + } +}); + +function initViewer() { + const dropZoneContainer = document.getElementById('drop-zone-container'); + const appContainer = document.getElementById('app'); + if (dropZoneContainer && appContainer) { + dropZoneContainer.classList.add('hidden'); + appContainer.classList.remove('hidden'); + } + setupControls(); + setupTabs(); + setupThemeToggle(); + updateUI(); if (window.app.profileA) { - updateUI(); window.app.chartA = new PieChart('canvas-a', window.app.profileA); } if (window.app.profileB) { document.getElementById('chart-b').classList.remove('hidden'); window.app.chartB = new PieChart('canvas-b', window.app.profileB, true); } -}); + renderFlamegraph(); +} + +function setupThemeToggle() { + const btn = document.getElementById('theme-toggle'); + if (!btn) return; + + // Restore persisted preference + const saved = localStorage.getItem('stylus-trace-theme'); + if (saved === 'light') { + document.body.setAttribute('data-theme', 'light'); + btn.textContent = '[ DARK ]'; + } + + btn.addEventListener('click', () => { + const isLight = document.body.getAttribute('data-theme') === 'light'; + if (isLight) { + document.body.removeAttribute('data-theme'); + btn.textContent = '[ LIGHT ]'; + localStorage.setItem('stylus-trace-theme', 'dark'); + } else { + document.body.setAttribute('data-theme', 'light'); + btn.textContent = '[ DARK ]'; + localStorage.setItem('stylus-trace-theme', 'light'); + } + // Re-render charts — canvas doesn't pick up CSS variable changes automatically + if (window.app.chartA) window.app.chartA.render(); + if (window.app.chartB) window.app.chartB.render(); + }); +} + +function setupDropZone() { + const dropZoneCtn = document.getElementById('drop-zone-container'); + const dropZone = dropZoneCtn.querySelector('.drop-zone'); + const fileInput = document.getElementById('file-input'); + + dropZone.addEventListener('click', () => fileInput.click()); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('dragover'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('dragover'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + handleFiles(e.dataTransfer.files); + }); + + fileInput.addEventListener('change', (e) => { + handleFiles(e.target.files); + }); +} + +function handleFiles(files) { + if (files.length === 0) return; + + const fileA = files[0]; + const fileB = files.length > 1 ? files[1] : null; + + const readJson = (file) => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + try { + resolve(JSON.parse(e.target.result)); + } catch (err) { + reject(err); + } + }; + reader.onerror = reject; + reader.readAsText(file); + }); + }; + + if (fileB) { + Promise.all([readJson(fileA), readJson(fileB)]).then(results => { + window.app.profileA = results[0]; + window.app.profileB = results[1]; + initViewer(); + }).catch(err => { + alert('Error parsing JSON files: ' + err.message); + }); + } else { + readJson(fileA).then(result => { + window.app.profileA = result; + window.app.profileB = null; + document.getElementById('chart-b').classList.add('hidden'); + if (window.app.chartA) window.app.chartA = null; + if (window.app.chartB) window.app.chartB = null; + initViewer(); + }).catch(err => { + alert('Error parsing JSON file: ' + err.message); + }); + } +} + function loadData() { try { + /** Decode a base64-encoded JSON blob injected by the Rust backend. + * Falls back silently for empty or placeholder values. */ const getJson = id => { const el = document.getElementById(id); if (!el) return null; const text = el.textContent.trim(); - return (text && !text.startsWith('/*')) ? JSON.parse(text) : null; + if (!text || text.startsWith('/*')) return null; + try { + return JSON.parse(atob(text)); + } catch (_) { + // Attempt raw JSON parse for drag-and-drop files loaded via handleFiles() + return JSON.parse(text); + } }; window.app.profileA = getJson('profile-data'); window.app.profileB = getJson('profile-b-data'); - window.app.diff = getJson('diff-data'); + window.app.diffData = getJson('diff-data'); + + // Load flamegraph SVG (not base64, injected as raw SVG text) + const svgEl = document.getElementById('flamegraph-svg-data'); + if (svgEl) { + const svgText = svgEl.textContent.trim(); + window.app.flamegraphSvg = (svgText && !svgText.startsWith('/*')) ? svgText : null; + } } catch (e) { console.error('Data loading error', e); } } +function setupTabs() { + const tabPie = document.getElementById('tab-pie'); + const tabFlame = document.getElementById('tab-flame'); + const viewerContainer = document.getElementById('viewer-container'); + const flamegraphView = document.getElementById('flamegraph-view'); + + if (!tabPie || !tabFlame) return; + + tabPie.addEventListener('click', () => { + tabPie.classList.add('active'); + tabFlame.classList.remove('active'); + viewerContainer.classList.remove('hidden'); + flamegraphView.classList.add('hidden'); + // Resize charts to ensure they repaint correctly after being shown + if (window.app.chartA) window.app.chartA.resize(); + if (window.app.chartB) window.app.chartB.resize(); + }); + + tabFlame.addEventListener('click', () => { + tabFlame.classList.add('active'); + tabPie.classList.remove('active'); + viewerContainer.classList.add('hidden'); + flamegraphView.classList.remove('hidden'); + }); +} + +function renderFlamegraph() { + const container = document.getElementById('flamegraph-container'); + const emptyMsg = document.getElementById('flamegraph-empty'); + if (!container) return; + + const svg = window.app.flamegraphSvg; + if (svg) { + container.innerHTML = svg; + if (emptyMsg) emptyMsg.classList.add('hidden'); + // Make SVG responsive + const svgEl = container.querySelector('svg'); + if (svgEl) { + svgEl.removeAttribute('width'); + svgEl.removeAttribute('height'); + svgEl.setAttribute('preserveAspectRatio', 'xMinYMin meet'); + } + } else { + container.innerHTML = ''; + if (emptyMsg) emptyMsg.classList.remove('hidden'); + } +} + function setupControls() { const zoomIn = () => { if (window.app.chartA) { @@ -471,17 +670,24 @@ function updateUI() { const hotPathsList = document.getElementById('hot-paths-list'); hotPathsList.innerHTML = ''; - let pathsToShow = profB ? [...profB.hot_paths] : [...profA.hot_paths]; + let pathsToShow = profA ? profA.hot_paths : []; - // sorting logic - if (profB) { - pathsToShow.sort((a, b) => { - const pathA_in_A = profA.hot_paths.find(p => p.stack === a.stack); - const pathB_in_A = profA.hot_paths.find(p => p.stack === b.stack); - const diffA = a.gas - (pathA_in_A ? pathA_in_A.gas : 0); - const diffB = b.gas - (pathB_in_A ? pathB_in_A.gas : 0); - return Math.abs(diffB) - Math.abs(diffA); // Sort by largest change magnitude - }); + if (profB && window.app.diffData && window.app.diffData.deltas && window.app.diffData.deltas.hot_paths) { + // In diff mode, we show common paths from the diff data + pathsToShow = window.app.diffData.deltas.hot_paths.common_paths; + // Sort by magnitude of percentage change for diff view + pathsToShow.sort((a, b) => Math.abs(b.percent_change) - Math.abs(a.percent_change)); + } else { + // Manual merging/fallback for older data or single profile + const allPathsMap = new Map(); + if (profA) profA.hot_paths.forEach(p => allPathsMap.set(p.stack, p)); + if (profB) { + profB.hot_paths.forEach(p => { + if (!allPathsMap.has(p.stack)) allPathsMap.set(p.stack, p); + }); + } + pathsToShow = Array.from(allPathsMap.values()); + pathsToShow.sort((a, b) => (b.gas || 0) - (a.gas || 0)); } if (pathsToShow) { @@ -493,18 +699,31 @@ function updateUI() { let deltaDisplay = ''; let rightSide = ''; - if (profB) { + + // If it's a HotPathComparison object from Rust (it has percent_change) + const isDiffComparison = profB && path.hasOwnProperty('percent_change'); + + if (isDiffComparison) { + const gasDiff = path.gas_change || 0; + const gasPct = path.percent_change || 0; + const sign = gasDiff > 0 ? '+' : ''; + const cls = gasDiff > 0 ? 'pos' : (gasDiff < 0 ? 'neg' : 'neutral'); + deltaDisplay = `${sign}${gasPct.toFixed(2)}%`; + rightSide = ''; + } else if (profB) { + // Manual fallback calculation const pathA = profA.hot_paths.find(p => p.stack === path.stack); - const gasA = pathA ? pathA.gas : 0; - const gasDiff = path.gas - gasA; - const gasPct = gasA === 0 ? (path.gas > 0 ? 100 : 0) : (gasDiff / gasA) * 100; + const gasA = pathA ? (pathA.gas || 0) : 0; + const gasB = path.gas || 0; + const gasDiff = gasB - gasA; + const gasPct = gasA === 0 ? (gasB > 0 ? 100 : 0) : (gasDiff / gasA) * 100; const sign = gasDiff > 0 ? '+' : ''; const cls = gasDiff > 0 ? 'pos' : (gasDiff < 0 ? 'neg' : 'neutral'); deltaDisplay = `${sign}${gasPct.toFixed(2)}%`; - rightSide = ''; // Don't show gas in hot path for diff + rightSide = ''; } else { - deltaDisplay = `[${path.percentage.toFixed(1)}%]`; - rightSide = `${(path.gas / 1000).toFixed(0)}k gas`; + deltaDisplay = `[${(path.percentage || 0).toFixed(1)}%]`; + rightSide = `${((path.gas || 0) / 1000).toFixed(0)}k gas`; } li.innerHTML = ` @@ -534,4 +753,45 @@ function updateUI() { hotPathsList.appendChild(li); }); } + + // Render category breakdown in sidebar + renderCategoryStats(profB || profA); +} + +/** + * Aggregate gas by category from a profile's hot_paths and render + * a compact color-coded bar list in the sidebar. + */ +function renderCategoryStats(profile) { + const list = document.getElementById('category-stats-list'); + if (!list || !profile || !profile.hot_paths) return; + + // Aggregate gas per category + const totals = {}; + let grandTotal = 0; + profile.hot_paths.forEach(path => { + const cat = path.category || 'Other'; + totals[cat] = (totals[cat] || 0) + path.gas; + grandTotal += path.gas; + }); + + if (grandTotal === 0) { list.innerHTML = ''; return; } + + // Sort descending by gas + const sorted = Object.entries(totals).sort((a, b) => b[1] - a[1]); + + list.innerHTML = sorted.map(([cat, gas]) => { + const pct = (gas / grandTotal * 100).toFixed(1); + const color = (CONFIG.colors[cat]) || CONFIG.colors.Other; + return [ + '
  • ', + ` `, + ` ${cat}`, + '
    ', + `
    `, + '
    ', + ` ${pct}%`, + '
  • ' + ].join(''); + }).join(''); } diff --git a/crates/stylus-trace-core/src/parser/schema.rs b/crates/stylus-trace-core/src/parser/schema.rs index 2e53419..75a8237 100644 --- a/crates/stylus-trace-core/src/parser/schema.rs +++ b/crates/stylus-trace-core/src/parser/schema.rs @@ -7,6 +7,33 @@ use crate::aggregator::stack_builder::CollapsedStack; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// A category describing what type of operation a hot path primarily performs. +/// +/// This is computed server-side from `HostIoType` knowledge so the frontend +/// does not need brittle substring-matching heuristics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum GasCategory { + /// Expensive storage writes (flush/store) + StorageExpensive, + /// Cheaper storage reads (load/cache) + StorageNormal, + /// Cryptographic operations (keccak) + Crypto, + /// Memory / ABI operations (read_args, write_result) + Memory, + /// External calls (call, delegatecall, staticcall, create) + Call, + /// System / context queries (msg_sender, msg_value, block_hash, etc.) + System, + /// Root / entry-point frame + Root, + /// User-defined contract code not matching any known host op + UserCode, + /// Aggregated remainder + Other, +} + /// Top-level profile structure written to JSON #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { @@ -58,6 +85,10 @@ pub struct HotPath { /// Percentage of total gas pub percentage: f64, + /// Gas category derived from the leaf node of the stack. + /// Computed server-side so the frontend doesn't need heuristics. + pub category: GasCategory, + /// Source hint (if debug symbols available) #[serde(skip_serializing_if = "Option::is_none")] pub source_hint: Option, diff --git a/crates/stylus-trace-core/tests/diff_tests.rs b/crates/stylus-trace-core/tests/diff_tests.rs index 52e082b..f0e354d 100644 --- a/crates/stylus-trace-core/tests/diff_tests.rs +++ b/crates/stylus-trace-core/tests/diff_tests.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use stylus_trace_core::diff::*; -use stylus_trace_core::parser::schema::{HostIoSummary, HotPath, Profile}; +use stylus_trace_core::parser::schema::{GasCategory, HostIoSummary, HotPath, Profile}; // ============================================================================ // SHARED TEST HELPERS @@ -289,12 +289,14 @@ fn test_hot_paths_comparison_logic() { stack: "A;B".to_string(), gas: 100, percentage: 50.0, + category: GasCategory::UserCode, source_hint: None, }]; let t_paths = vec![HotPath { stack: "A;B".to_string(), gas: 150, percentage: 75.0, + category: GasCategory::UserCode, source_hint: None, }]; diff --git a/crates/stylus-trace-core/tests/output_tests.rs b/crates/stylus-trace-core/tests/output_tests.rs index 4a25c24..4f31a4f 100644 --- a/crates/stylus-trace-core/tests/output_tests.rs +++ b/crates/stylus-trace-core/tests/output_tests.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::Path; use stylus_trace_core::output::validate_path; use stylus_trace_core::output::{read_profile, write_profile, write_svg}; -use stylus_trace_core::parser::schema::{HostIoSummary, HotPath, Profile}; +use stylus_trace_core::parser::schema::{GasCategory, HostIoSummary, HotPath, Profile}; use tempfile::NamedTempFile; fn create_test_profile() -> Profile { @@ -19,6 +19,7 @@ fn create_test_profile() -> Profile { stack: "main;execute".to_string(), gas: 50000, percentage: 50.0, + category: GasCategory::UserCode, source_hint: None, }], all_stacks: None, From e32f8fa201a1da9b3ae5e931cff7f6954839be04 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Tue, 17 Mar 2026 01:38:56 +0100 Subject: [PATCH 3/4] improved viewer --- crates/stylus-trace-core/src/aggregator/metrics.rs | 4 +--- crates/stylus-trace-core/src/commands/capture.rs | 3 ++- crates/stylus-trace-core/src/commands/diff.rs | 8 ++++---- crates/stylus-trace-core/src/output/viewer.rs | 5 +---- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/crates/stylus-trace-core/src/aggregator/metrics.rs b/crates/stylus-trace-core/src/aggregator/metrics.rs index c151f69..4446e0f 100644 --- a/crates/stylus-trace-core/src/aggregator/metrics.rs +++ b/crates/stylus-trace-core/src/aggregator/metrics.rs @@ -96,9 +96,7 @@ pub fn categorize_stack_leaf(leaf: &str) -> GasCategory { return GasCategory::Memory; } // External calls / creates - if n == "call" || n == "staticcall" || n == "delegatecall" - || n.contains("create") - { + if n == "call" || n == "staticcall" || n == "delegatecall" || n.contains("create") { return GasCategory::Call; } // System / context diff --git a/crates/stylus-trace-core/src/commands/capture.rs b/crates/stylus-trace-core/src/commands/capture.rs index 15b75c6..a375e40 100644 --- a/crates/stylus-trace-core/src/commands/capture.rs +++ b/crates/stylus-trace-core/src/commands/capture.rs @@ -193,7 +193,8 @@ pub fn execute_capture(args: CaptureArgs) -> Result<()> { ); // Generate SVG for the flamegraph tab in the viewer. // We attempt this even if --output-svg was not requested; failure is non-fatal. - let viewer_svg = generate_flamegraph(&stacks, args.flamegraph_config.as_ref(), mapper.as_ref()).ok(); + let viewer_svg = + generate_flamegraph(&stacks, args.flamegraph_config.as_ref(), mapper.as_ref()).ok(); crate::output::viewer::generate_viewer(&profile, viewer_svg.as_deref(), &viewer_path)?; info!("✓ Viewer generated at: {}", viewer_path.display()); crate::output::viewer::open_browser(&viewer_path)?; diff --git a/crates/stylus-trace-core/src/commands/diff.rs b/crates/stylus-trace-core/src/commands/diff.rs index 288548f..641bdad 100644 --- a/crates/stylus-trace-core/src/commands/diff.rs +++ b/crates/stylus-trace-core/src/commands/diff.rs @@ -136,11 +136,11 @@ pub fn execute_diff(args: DiffArgs) -> Result<()> { // Attempt to generate diff flamegraph SVG for the multi-view tab. // If full stacks are not available (older capture), viewer still works without it. - let diff_svg = baseline.all_stacks.as_ref() + let diff_svg = baseline + .all_stacks + .as_ref() .zip(target.all_stacks.as_ref()) - .and_then(|(b, t)| { - crate::flamegraph::generate_diff_flamegraph(b, t, None).ok() - }); + .and_then(|(b, t)| crate::flamegraph::generate_diff_flamegraph(b, t, None).ok()); crate::output::viewer::generate_diff_viewer( &baseline, diff --git a/crates/stylus-trace-core/src/output/viewer.rs b/crates/stylus-trace-core/src/output/viewer.rs index 5442e3e..bcf4bdc 100644 --- a/crates/stylus-trace-core/src/output/viewer.rs +++ b/crates/stylus-trace-core/src/output/viewer.rs @@ -35,10 +35,7 @@ fn build_html( profile_b_b64.unwrap_or_default(), ); html = html.replace("/* DIFF_DATA_B64 */", diff_b64.unwrap_or_default()); - html = html.replace( - "/* FLAMEGRAPH_SVG */", - flamegraph_svg.unwrap_or_default(), - ); + html = html.replace("/* FLAMEGRAPH_SVG */", flamegraph_svg.unwrap_or_default()); // Inline CSS html = html.replace( From ed5b8be1b1dfdec6ca2db789558a11e9d3f3f9a0 Mon Sep 17 00:00:00 2001 From: intelliDean Date: Tue, 17 Mar 2026 01:58:42 +0100 Subject: [PATCH 4/4] fix: make hot path category optional for backward compatibility - Implemented Default for GasCategory (UserCode). - Added #[serde(default)] to HotPath.category to support older JSON profiles. --- crates/stylus-trace-core/src/parser/schema.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/stylus-trace-core/src/parser/schema.rs b/crates/stylus-trace-core/src/parser/schema.rs index 75a8237..d6957eb 100644 --- a/crates/stylus-trace-core/src/parser/schema.rs +++ b/crates/stylus-trace-core/src/parser/schema.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; /// /// This is computed server-side from `HostIoType` knowledge so the frontend /// does not need brittle substring-matching heuristics. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "PascalCase")] pub enum GasCategory { /// Expensive storage writes (flush/store) @@ -29,6 +29,7 @@ pub enum GasCategory { /// Root / entry-point frame Root, /// User-defined contract code not matching any known host op + #[default] UserCode, /// Aggregated remainder Other, @@ -87,6 +88,7 @@ pub struct HotPath { /// Gas category derived from the leaf node of the stack. /// Computed server-side so the frontend doesn't need heuristics. + #[serde(default)] pub category: GasCategory, /// Source hint (if debug symbols available)