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 @@
+
+
+
AWAITING // TRACE_DATA
+
DRAG & DROP REPORT.JSON
+
Drop 1 file for Profiling | Drop 2 files for Diffing
+
+
+
+
+
+
+
+
+
+
-
> BASELINE_DATA
+
> BASELINE_DATA
-
> TARGET_DATA
+
> TARGET_DATA
+
+
+
+
+
> NO_FLAMEGRAPH_DATA // Profile with --output-svg flag to generate one
+
-
-
-
-
+
+
+
+
+
+
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 ::