diff --git a/Cargo.lock b/Cargo.lock index 0bf7628..55d486e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1416,6 +1416,7 @@ version = "0.1.12" dependencies = [ "addr2line", "anyhow", + "base64", "chrono", "clap", "colored", diff --git a/Cargo.toml b/Cargo.toml index 97c511d..4362fd4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ object = { version = "0.32", features = ["wasm"] } gimli = "0.28" tempfile = "3.10" pretty_assertions = "1.4" +base64 = "0.22" [profile.release] opt-level = 3 diff --git a/bin/stylus-trace-studio/src/main.rs b/bin/stylus-trace-studio/src/main.rs index a00881f..75dc948 100644 --- a/bin/stylus-trace-studio/src/main.rs +++ b/bin/stylus-trace-studio/src/main.rs @@ -356,7 +356,7 @@ fn handle_view(tx_or_path: &str, rpc: &str) -> Result<()> { info!("Opening existing profile: {}", path.display()); let profile = read_profile(&path).context("Failed to read profile JSON")?; let viewer_path = path.with_extension("html"); - generate_viewer(&profile, &viewer_path)?; + generate_viewer(&profile, None, &viewer_path)?; open_browser(&viewer_path)?; } else if tx_or_path.starts_with("0x") && tx_or_path.len() == 66 { info!("Capturing and viewing transaction: {}", tx_or_path); diff --git a/crates/stylus-trace-core/Cargo.toml b/crates/stylus-trace-core/Cargo.toml index f780a9d..b14b325 100644 --- a/crates/stylus-trace-core/Cargo.toml +++ b/crates/stylus-trace-core/Cargo.toml @@ -33,3 +33,4 @@ addr2line.workspace = true object.workspace = true gimli.workspace = true tempfile.workspace = true +base64.workspace = true diff --git a/crates/stylus-trace-core/src/aggregator/metrics.rs b/crates/stylus-trace-core/src/aggregator/metrics.rs index 218e3d2..4446e0f 100644 --- a/crates/stylus-trace-core/src/aggregator/metrics.rs +++ b/crates/stylus-trace-core/src/aggregator/metrics.rs @@ -4,7 +4,7 @@ //! These are the primary targets for optimization. use super::stack_builder::CollapsedStack; -use crate::parser::schema::HotPath; +use crate::parser::schema::{GasCategory, HotPath}; use log::debug; /// Calculate hot paths from collapsed stacks @@ -49,10 +49,16 @@ pub fn create_hot_path(stack: &CollapsedStack, denominator: u64) -> HotPath { 0.0 }; + // Determine the category from the leaf node of the stack. + // E.g., for "root;call;storage_flush_cache" the leaf is "storage_flush_cache". + let leaf = stack.stack.split(';').next_back().unwrap_or(&stack.stack); + let category = categorize_stack_leaf(leaf); + HotPath { stack: stack.stack.clone(), gas: stack.weight, percentage, + category, source_hint: stack.last_pc.map(|pc| crate::parser::schema::SourceHint { file: "unknown".to_string(), line: None, @@ -62,6 +68,45 @@ pub fn create_hot_path(stack: &CollapsedStack, denominator: u64) -> HotPath { } } +/// Categorize a hot-path leaf node into a [`GasCategory`]. +/// +/// This mirrors (and supersedes) the frontend `getCategory()` heuristic in +/// `viewer.js`, but is authoritative because it lives in the Rust backend +/// where `HostIoType` semantics are well-defined. +pub fn categorize_stack_leaf(leaf: &str) -> GasCategory { + let n = leaf.to_lowercase(); + + if n == "root" { + return GasCategory::Root; + } + // Expensive storage writes + if n.contains("storage_store") || n.contains("storage_flush") { + return GasCategory::StorageExpensive; + } + // Cheaper storage reads + if n.contains("storage_load") || n.contains("storage_cache") { + return GasCategory::StorageNormal; + } + // Cryptographic ops + if n.contains("keccak") { + return GasCategory::Crypto; + } + // Memory / ABI + if n.contains("memory") || n == "read_args" || n == "write_result" { + return GasCategory::Memory; + } + // External calls / creates + if n == "call" || n == "staticcall" || n == "delegatecall" || n.contains("create") { + return GasCategory::Call; + } + // System / context + if n.contains("msg_") || n.contains("block_") || n == "account_balance" { + return GasCategory::System; + } + // Catch-all: user code + GasCategory::UserCode +} + /// Calculate gas distribution statistics /// /// **Public** - provides summary statistics diff --git a/crates/stylus-trace-core/src/commands/capture.rs b/crates/stylus-trace-core/src/commands/capture.rs index 51d097b..a375e40 100644 --- a/crates/stylus-trace-core/src/commands/capture.rs +++ b/crates/stylus-trace-core/src/commands/capture.rs @@ -191,7 +191,11 @@ pub fn execute_capture(args: CaptureArgs) -> Result<()> { Some(stacks.to_vec()), mapper.as_ref(), ); - crate::output::viewer::generate_viewer(&profile, &viewer_path)?; + // 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(); + 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 f18f3d9..641bdad 100644 --- a/crates/stylus-trace-core/src/commands/diff.rs +++ b/crates/stylus-trace-core/src/commands/diff.rs @@ -133,10 +133,20 @@ pub fn execute_diff(args: DiffArgs) -> Result<()> { .with_extension("html"); let report_json = serde_json::to_value(&report)?; + + // 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() + .zip(target.all_stacks.as_ref()) + .and_then(|(b, t)| crate::flamegraph::generate_diff_flamegraph(b, t, None).ok()); + crate::output::viewer::generate_diff_viewer( &baseline, &target, &report_json, + diff_svg.as_deref(), &viewer_path, )?; info!("✓ Diff viewer generated at: {}", viewer_path.display()); diff --git a/crates/stylus-trace-core/src/diff/output.rs b/crates/stylus-trace-core/src/diff/output.rs index 79aa117..5ca40e4 100644 --- a/crates/stylus-trace-core/src/diff/output.rs +++ b/crates/stylus-trace-core/src/diff/output.rs @@ -145,12 +145,12 @@ fn render_hot_path_comparison_table(report: &DiffReport) -> String { format!("{:<38}", display_stack) }; - // Scale to Gas (ink / 10,000) - let baseline_gas = hp.baseline_gas / 10_000; - let target_gas = hp.target_gas / 10_000; + // Scale to Gas (ink / 10,000) with float precision + let baseline_gas = hp.baseline_gas as f64 / 10_000.0; + let target_gas = hp.target_gas as f64 / 10_000.0; out.push_str(&format!( - " ┃ {} ┃ {:>12} ┃ {:>12} ┃ {}{:>9.2}%{} ┃\n", + " ┃ {} ┃ {:>12.1} ┃ {:>12.1} ┃ {}{:>9.2}%{} ┃\n", display_stack_fixed, baseline_gas, target_gas, delta_color, hp.percent_change, reset )); } diff --git a/crates/stylus-trace-core/src/output/viewer.rs b/crates/stylus-trace-core/src/output/viewer.rs index 0a7de8e..bcf4bdc 100644 --- a/crates/stylus-trace-core/src/output/viewer.rs +++ b/crates/stylus-trace-core/src/output/viewer.rs @@ -2,6 +2,8 @@ use crate::parser::schema::Profile; use anyhow::{Context, Result}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; use std::fs; use std::path::Path; @@ -9,62 +11,81 @@ const HTML_TEMPLATE: &str = include_str!("viewer/index.html"); const CSS_TEMPLATE: &str = include_str!("viewer/viewer.css"); const JS_TEMPLATE: &str = include_str!("viewer/viewer.js"); -/// Generate a self-contained HTML viewer for a profile -pub fn generate_viewer(profile: &Profile, output_path: &Path) -> Result<()> { - let profile_json = serde_json::to_string(profile)?; +/// Encode a JSON string as Base64 for safe inline HTML injection. +/// This prevents any `` 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(); - - // 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); + let a_b64 = encode_json(&profile_a_json); + let b_b64 = encode_json(&profile_b_json); + let diff_b64 = encode_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
+ + +
- - - - + + + + + + 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 867c132..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, @@ -160,7 +167,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,17 +174,18 @@ 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}
-
+
>${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..d6957eb 100644 --- a/crates/stylus-trace-core/src/parser/schema.rs +++ b/crates/stylus-trace-core/src/parser/schema.rs @@ -7,6 +7,34 @@ 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, Default)] +#[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 + #[default] + UserCode, + /// Aggregated remainder + Other, +} + /// Top-level profile structure written to JSON #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Profile { @@ -58,6 +86,11 @@ 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. + #[serde(default)] + 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, 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 ::