diff --git a/Cargo.lock b/Cargo.lock index 334162e..0bf7628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1412,7 +1412,7 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "stylus-trace-core" -version = "0.1.11" +version = "0.1.12" dependencies = [ "addr2line", "anyhow", @@ -1433,7 +1433,7 @@ dependencies = [ [[package]] name = "stylus-trace-studio" -version = "0.1.11" +version = "0.1.12" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index df7d4d8..97c511d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.1.11" +version = "0.1.12" edition = "2021" authors = ["CreativesOnchain"] license = "MIT" @@ -17,7 +17,7 @@ keywords = ["arbitrum", "stylus", "profiling", "gas", "flamegraph"] categories = ["development-tools::profiling", "wasm"] [workspace.dependencies] -stylus-trace-core = { version = "0.1.11", path = "crates/stylus-trace-core" } +stylus-trace-core = { version = "0.1.12", path = "crates/stylus-trace-core" } clap = { version = "4.5", features = ["derive", "env"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/README.md b/README.md index 4b768a9..0635d36 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ Stylus Trace turns opaque Stylus transaction traces into **interactive flamegrap ## 🚀 Key Features -- **Interactive Flamegraphs**: Visualize execution paths with interactive SVG snapshots. +- **Interactive Web Viewer**: Explore transactions in a high-intensity "Cyber Diagnostics" terminal with real-time symbol search and magnitude-sorted deltas. - **Optimization Insights**: Get qualitative feedback on loop redundancies, high-cost storage access, and potential caching opportunities. - **Gas & Ink Analysis**: Seamlessly toggle between standard Gas and high-precision Stylus Ink (10,000x) units. -- **Transaction Dashboards**: Get a "hot path" summary directly in your terminal. +- **Side-by-Side Diffing**: Compare two profiles visually to hunt down regressions or verify optimizations. - **Automated Artifacts**: Built-in organization for profiles and graphs in a dedicated `artifacts/` folder. - **Arbitrum Native**: Designed specifically for the Arbitrum Nitro/Stylus execution environment. @@ -81,23 +81,20 @@ stylus-trace capture \ --flamegraph \ --summary -OR - -# This will generate output as profile.json but no flamegraph in artifacts directory -stylus-trace capture --tx --summary +# Profile and immediately launch the Cyber Viewer +stylus-trace capture --tx --view OR -# this will generate output as profile.json and flamegraph.svg in artifacts directory -stylus-trace capture \ ---tx \ ---flamegraph \ ---summary + +# Generate output as profile.json and interactive flamegraph +stylus-trace capture --tx --flamegraph --summary ``` **What happens?** - `artifacts/profile.json`: A detailed data structure of your transaction. -- `artifacts/flamegraph.svg`: An interactive SVG you can open in any browser. +- `artifacts/viewer.html`: A self-contained, high-performance web viewer. +- `artifacts/flamegraph.svg`: A classic interactive SVG snapshot. - **Terminal Output**: A high-level summary of the hottest paths. --- @@ -139,6 +136,14 @@ This feature will be enabled automatically once upstream tracer support is avail | `--summary` | Print human-readable summary to terminal | `true` | | `--output` | Path to write the diff report JSON | `artifacts/diff/diff_report.json` | | `--flamegraph` | Path to write visual diff flamegraph SVG | `artifacts/diff/diff.svg` | +| `--view` | Open the interactive comparison viewer | `false` | + +### `view` + +| Flag | Description | Default | +|------------|---------------------------------------------|---------| +| `--target` | **(Required)** Path to profile JSON to view | - | +| `--baseline` | Optional baseline profile JSON for comparison| - | ### `ci init` | Flag | Description | Default | diff --git a/bin/stylus-trace-studio/src/main.rs b/bin/stylus-trace-studio/src/main.rs index 79cfcba..a00881f 100644 --- a/bin/stylus-trace-studio/src/main.rs +++ b/bin/stylus-trace-studio/src/main.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result}; use clap::{Args, Parser, Subcommand}; use env_logger::Env; +use log::info; use std::path::PathBuf; use stylus_trace_core::commands::{ @@ -13,6 +14,8 @@ use stylus_trace_core::commands::{ CaptureArgs, }; use stylus_trace_core::flamegraph::FlamegraphConfig; +use stylus_trace_core::output::json::read_profile; +use stylus_trace_core::output::viewer::{generate_viewer, open_browser}; /// Stylus Trace Studio - Performance profiling for Arbitrum Stylus #[derive(Parser, Debug)] @@ -88,11 +91,26 @@ pub enum Commands { /// Specific HostIO calls increase threshold percentage #[arg(long = "hostio-threshold")] hostio_threshold: Option, + + /// Open interactive web viewer + #[arg(long)] + view: bool, }, /// Compare two transaction profiles and detect regressions Diff(DiffSubArgs), + /// Open a previously captured profile in the web viewer + View { + /// Transaction hash or path to profile JSON + #[arg(short, long)] + tx: String, + + /// RPC endpoint URL (optional, used if fetching new trace) + #[arg(short, long, default_value = "http://localhost:8547")] + rpc: String, + }, + /// Validate a profile JSON file Validate { /// Path to profile JSON file @@ -152,6 +170,10 @@ pub struct DiffSubArgs { /// Path to write the visual diff flamegraph SVG #[arg(short = 'f', long, default_missing_value = "diff.svg", num_args = 0..=1)] pub flamegraph: Option, + + /// Open interactive side-by-side web viewer + #[arg(long)] + pub view: bool, } fn main() -> Result<()> { @@ -161,6 +183,7 @@ fn main() -> Result<()> { match cli.command { Commands::Capture { .. } => handle_capture(cli.command)?, Commands::Diff(ref args) => handle_diff(args)?, + Commands::View { ref tx, ref rpc } => handle_view(tx, rpc)?, Commands::Validate { file } => { validate_profile_file(file).context("Failed to validate profile")? } @@ -251,6 +274,7 @@ fn handle_capture(command: Commands) -> Result<()> { threshold_percent, gas_threshold, hostio_threshold, + view, } = command { // Enforce artifacts/ directory for relative paths @@ -287,6 +311,7 @@ fn handle_capture(command: Commands) -> Result<()> { gas_threshold, hostio_threshold, wasm: None, + view, }; validate_args(&args).context("Invalid capture arguments")?; @@ -314,6 +339,7 @@ fn handle_diff(args: &DiffSubArgs) -> Result<()> { .map(|p| resolve_artifact_path(p.clone(), "diff")), gas_threshold: args.gas_threshold, hostio_threshold: args.hostio_threshold, + view: args.view, }; stylus_trace_core::commands::diff::execute_diff(studio_args) @@ -321,6 +347,35 @@ fn handle_diff(args: &DiffSubArgs) -> Result<()> { Ok(()) } +/// Handle the view command logic +fn handle_view(tx_or_path: &str, rpc: &str) -> Result<()> { + let path = PathBuf::from(tx_or_path); + + // Check if it's an existing JSON file + if path.exists() && path.extension().is_some_and(|ext| ext == "json") { + 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)?; + 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); + let output = resolve_artifact_path(PathBuf::from("profile.json"), "capture"); + let args = CaptureArgs { + rpc_url: rpc.to_string(), + transaction_hash: tx_or_path.to_string(), + output_json: output, + view: true, + ..Default::default() + }; + execute_capture(args).context("Capture and view failed")?; + } else { + anyhow::bail!("Invalid input: provide a path to a .json profile or a 0x transaction hash"); + } + + Ok(()) +} + /// Resolves a path to the artifacts/ directory if it's a simple filename fn resolve_artifact_path(path: PathBuf, category: &str) -> PathBuf { if path diff --git a/crates/stylus-trace-core/src/commands/capture.rs b/crates/stylus-trace-core/src/commands/capture.rs index 5352198..51d097b 100644 --- a/crates/stylus-trace-core/src/commands/capture.rs +++ b/crates/stylus-trace-core/src/commands/capture.rs @@ -142,7 +142,7 @@ pub fn execute_capture(args: CaptureArgs) -> Result<()> { &args, &parsed_trace, &stacks, - hot_paths, + hot_paths.clone(), mapper.as_ref(), svg_content, )?; @@ -182,6 +182,20 @@ pub fn execute_capture(args: CaptureArgs) -> Result<()> { print_transaction_summary(&args, &parsed_trace, &stacks, mapper.as_ref()); } + if args.view { + info!("Generating interactive web viewer..."); + let viewer_path = args.output_json.with_extension("html"); + let profile = to_profile( + &parsed_trace, + hot_paths, + Some(stacks.to_vec()), + mapper.as_ref(), + ); + crate::output::viewer::generate_viewer(&profile, &viewer_path)?; + info!("✓ Viewer generated at: {}", viewer_path.display()); + crate::output::viewer::open_browser(&viewer_path)?; + } + info!( "Capture completed in {:.2}s", start_time.elapsed().as_secs_f64() diff --git a/crates/stylus-trace-core/src/commands/diff.rs b/crates/stylus-trace-core/src/commands/diff.rs index 309750d..f18f3d9 100644 --- a/crates/stylus-trace-core/src/commands/diff.rs +++ b/crates/stylus-trace-core/src/commands/diff.rs @@ -10,6 +10,7 @@ use crate::output::json::read_profile; use crate::parser::schema::Profile; use anyhow::{Context, Result}; use colored::*; +use log::info; use std::fs; /// Execute the diff command @@ -123,6 +124,25 @@ pub fn execute_diff(args: DiffArgs) -> Result<()> { println!("{}", render_terminal_diff(&report)); } + if args.view { + info!("Generating interactive side-by-side diff viewer..."); + let viewer_path = args + .output + .clone() + .unwrap_or_else(|| args.target.with_extension("diff.html")) + .with_extension("html"); + + let report_json = serde_json::to_value(&report)?; + crate::output::viewer::generate_diff_viewer( + &baseline, + &target, + &report_json, + &viewer_path, + )?; + info!("✓ Diff viewer generated at: {}", viewer_path.display()); + crate::output::viewer::open_browser(&viewer_path)?; + } + // Step 7: Final Status Exit Code Handling (implicit) if report.summary.status == "FAILED" { return Err(anyhow::anyhow!("Regression detected against thresholds")); diff --git a/crates/stylus-trace-core/src/commands/models.rs b/crates/stylus-trace-core/src/commands/models.rs index 99224a4..f7fc3a2 100644 --- a/crates/stylus-trace-core/src/commands/models.rs +++ b/crates/stylus-trace-core/src/commands/models.rs @@ -48,6 +48,9 @@ pub struct CaptureArgs { /// Path to WASM binary (optional) pub wasm: Option, + + /// Open interactive web viewer + pub view: bool, } impl Default for CaptureArgs { @@ -67,6 +70,7 @@ impl Default for CaptureArgs { threshold_percent: None, gas_threshold: None, hostio_threshold: None, + view: false, } } } @@ -125,6 +129,9 @@ pub struct DiffArgs { /// Path to write the visual diff flamegraph SVG pub output_svg: Option, + + /// Open interactive web viewer + pub view: bool, } impl Default for DiffArgs { @@ -139,6 +146,7 @@ impl Default for DiffArgs { summary: true, output: None, output_svg: None, + view: false, } } } diff --git a/crates/stylus-trace-core/src/output/mod.rs b/crates/stylus-trace-core/src/output/mod.rs index f307095..11073f6 100644 --- a/crates/stylus-trace-core/src/output/mod.rs +++ b/crates/stylus-trace-core/src/output/mod.rs @@ -7,10 +7,12 @@ pub mod json; pub mod svg; +pub mod viewer; // Re-export main functions pub use json::{read_profile, write_profile}; pub use svg::write_svg; +pub use viewer::{generate_diff_viewer, generate_viewer, open_browser}; use crate::utils::error::OutputError; use std::path::Path; diff --git a/crates/stylus-trace-core/src/output/viewer.rs b/crates/stylus-trace-core/src/output/viewer.rs new file mode 100644 index 0000000..0a7de8e --- /dev/null +++ b/crates/stylus-trace-core/src/output/viewer.rs @@ -0,0 +1,102 @@ +//! Viewer generation and browser orchestration. + +use crate::parser::schema::Profile; +use anyhow::{Context, Result}; +use std::fs; +use std::path::Path; + +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)?; + + // 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. + let mut html = HTML_TEMPLATE.to_string(); + + // Inject data + html = html.replace("/* PROFILE_DATA_JSON */", &profile_json); + + // Inline CSS and JS for "self-contained" requirement + html = html.replace( + "", + &format!("", CSS_TEMPLATE), + ); + html = html.replace( + "", + &format!("", JS_TEMPLATE), + ); + + fs::write(output_path, html).context("Failed to write viewer HTML")?; + + Ok(()) +} + +/// Generate a self-contained HTML viewer for a diff +pub fn generate_diff_viewer( + profile_a: &Profile, + profile_b: &Profile, + diff_report: &serde_json::Value, + 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); + + // Inline CSS and JS + html = html.replace( + "", + &format!("", CSS_TEMPLATE), + ); + html = html.replace( + "", + &format!("", JS_TEMPLATE), + ); + + fs::write(output_path, html).context("Failed to write diff viewer HTML")?; + + Ok(()) +} + +/// Open a path in the system default browser +pub fn open_browser(path: &Path) -> Result<()> { + let url = format!("file://{}", path.canonicalize()?.display()); + + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg(url) + .status() + .context("Failed to open browser on macOS")?; + } + + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(url) + .status() + .context("Failed to open browser on Linux")?; + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("cmd") + .arg("/C") + .arg("start") + .arg(url) + .status() + .context("Failed to open browser on Windows")?; + } + + Ok(()) +} diff --git a/crates/stylus-trace-core/src/output/viewer/index.html b/crates/stylus-trace-core/src/output/viewer/index.html new file mode 100644 index 0000000..ba13565 --- /dev/null +++ b/crates/stylus-trace-core/src/output/viewer/index.html @@ -0,0 +1,95 @@ + + + + + + Stylus Trace Studio - Cyber Diagnostics + + + + + + +
+
+
+ + +
+ + + +
+
+ +
+ + +
+
+ +
> BASELINE_DATA
+
+ +
+
+
+ +
+
> 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 new file mode 100644 index 0000000..65d8137 --- /dev/null +++ b/crates/stylus-trace-core/src/output/viewer/viewer.css @@ -0,0 +1,206 @@ +:root { + --bg-color: #020202; + --green-main: #00ff41; + --green-dark: #008f11; + --green-faint: rgba(0, 255, 65, 0.1); + --border-style: 2px solid var(--green-dark); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background-color: var(--bg-color); + color: var(--green-main); + font-family: 'VT323', monospace; + font-size: 20px; + overflow: hidden; + height: 100vh; + text-shadow: 0 0 4px var(--green-main); +} + +.crt-overlay { + position: fixed; + top: 0; left: 0; width: 100vw; height: 100vh; + background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.06)); + background-size: 100% 4px, 6px 100%; + pointer-events: none; + z-index: 9999; + opacity: 0.6; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; + z-index: 1; +} + +.terminal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 24px; + border-bottom: var(--border-style); + background: #050505; +} + +.logo h1, .glitch { + font-size: 28px; + font-weight: normal; + letter-spacing: 2px; +} + +.search-bar { display: flex; flex: 1; justify-content: center; } +.search-wrapper { position: relative; width: 320px; display: flex; align-items: center; } +.search-wrapper input { + background: transparent; + border: var(--border-style); + color: var(--green-main); + font-family: 'VT323', monospace; + font-size: 20px; + padding: 6px 12px; + width: 100%; + outline: none; + text-shadow: 0 0 4px var(--green-main); + position: relative; + z-index: 2; +} +#search-ghost { + position: absolute; + top: 50%; + left: 12px; + transform: translateY(-50%); + color: var(--green-main); + font-size: 20px; + pointer-events: none; + z-index: 1; + white-space: pre; + opacity: 0.3; +} + +.controls { display: flex; gap: 10px; } +.controls button { + background: transparent; + border: none; + color: var(--green-main); + font-family: 'VT323', monospace; + font-size: 20px; + cursor: pointer; + transition: all 0.2s; + text-shadow: 0 0 4px var(--green-main); +} +.controls button:hover { + color: #fff; + text-shadow: 0 0 8px #fff; +} + +main { display: flex; flex: 1; overflow: hidden; } + +#sidebar { + width: 350px; + border-right: var(--border-style); + background: #050505; + padding: 20px; + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; +} + +.panel { + border: 1px dotted var(--green-dark); + padding: 15px; +} + +.panel h2 { + font-size: 22px; + margin-bottom: 15px; + color: #fff; +} + +.tx-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; +} +.tx-item { + font-size: 16px; + background: var(--green-faint); + border-left: 3px solid var(--green-dark); + padding: 8px; + word-break: break-all; +} +.tx-item label { color: #fff; opacity: 0.6; margin-right: 5px; } +.tx-item.target { border-left-color: var(--green-main); } + +.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; } +.stat-row .value { font-size: 20px; color: var(--green-main); } +.stat-row .delta { + margin-left: 10px; + font-weight: bold; + font-size: 22px; + letter-spacing: 1px; +} +.stat-row .delta.pos { color: #ff4d4d; } +.stat-row .delta.neg { color: #00ff41; } +.stat-row .delta.neutral { color: #888; } + +#hot-paths-list { list-style: none; display: flex; flex-direction: column; gap: 8px; } +.hot-path-item { + border: 1px solid var(--green-dark); + padding: 8px; + cursor: pointer; + transition: background 0.2s; +} +.hot-path-item:hover { background: var(--green-faint); } +.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; } + +#viewer-container { + flex: 1; + display: flex; + position: relative; + background: repeating-linear-gradient( + 45deg, + #020202, + #020202 10px, + #050505 10px, + #050505 20px + ); +} +.chart-canvas { + flex: 1; position: relative; border-right: var(--border-style); + display: flex; flex-direction: column; +} +.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); + pointer-events: none; +} + +#tooltip { + position: absolute; display: none; pointer-events: none; + background: #000; border: 2px solid var(--green-main); + 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; +} + +.hidden { display: none !important; } + +/* Responsive adjustments */ +@media (max-width: 900px) { + main { flex-direction: column; } + #sidebar { width: 100%; height: 300px; border-right: none; border-bottom: var(--border-style); } +} diff --git a/crates/stylus-trace-core/src/output/viewer/viewer.js b/crates/stylus-trace-core/src/output/viewer/viewer.js new file mode 100644 index 0000000..867c132 --- /dev/null +++ b/crates/stylus-trace-core/src/output/viewer/viewer.js @@ -0,0 +1,537 @@ +/** + * Stylus-Trace Studio - Pie Chart Viewer Logic (Retro Tech Theme) + */ + +const CONFIG = { + colors: { + StorageExpensive: 'rgb(220, 20, 60)', + StorageNormal: 'rgb(255, 140, 0)', + Crypto: 'rgb(138, 43, 226)', + Memory: 'rgb(34, 139, 34)', + Call: 'rgb(70, 130, 180)', + System: 'rgb(100, 149, 237)', + Root: 'rgb(75, 0, 130)', + UserCode: 'rgb(169, 169, 169)', + Other: '#002209' + } +}; + +class PieChart { + constructor(canvasId, data, isDiff = false) { + this.canvas = document.getElementById(canvasId); + this.ctx = this.canvas.getContext('2d'); + this.data = data; + this.isDiff = isDiff; + this.zoom = 1.0; + this.offsetX = 0; + this.offsetY = 0; + this.hoveredSlice = null; + this.searchQuery = ''; + + this.init(); + } + + init() { + this.processData(); + this.setupListeners(); + setTimeout(() => { + this.resize(); + window.addEventListener('resize', () => this.resize()); + }, 100); + } + + processData() { + if (!this.data || !this.data.hot_paths) return; + + let total = this.data.total_gas; + let tracked = 0; + this.slices = []; + + this.data.hot_paths.slice(0, 15).forEach(path => { + let name = path.stack.split(';').pop(); + const category = this.getCategory(name); + this.slices.push({ + name: name, + fullStack: path.stack, + value: path.gas, + percentage: path.percentage, + color: CONFIG.colors[category] || CONFIG.colors.UserCode + }); + tracked += path.gas; + }); + + if (total > tracked) { + this.slices.push({ + name: 'Other', + fullStack: 'Other Operations', + value: total - tracked, + percentage: ((total - tracked) / total) * 100, + color: CONFIG.colors.Other + }); + } + + let startAngle = 0; + this.slices.forEach(slice => { + let sliceAngle = (slice.value / total) * 2 * Math.PI; + slice.startAngle = startAngle; + slice.endAngle = startAngle + sliceAngle; + startAngle += sliceAngle; + }); + } + + resize() { + const dpr = window.devicePixelRatio || 1; + const rect = this.canvas.parentElement.getBoundingClientRect(); + this.canvas.width = rect.width * dpr; + this.canvas.height = rect.height * dpr; + this.ctx.scale(dpr, dpr); + this.render(); + } + + setupListeners() { + this.canvas.addEventListener('mousemove', (e) => { + const rect = this.canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + this.handleMouseMove(mouseX, mouseY, e.clientX, e.clientY); + }); + + this.canvas.addEventListener('mousedown', (e) => { + this.isDragging = true; + this.lastX = e.clientX; + this.lastY = e.clientY; + }); + + window.addEventListener('mouseup', () => this.isDragging = false); + + window.addEventListener('mousemove', (e) => { + if (this.isDragging) { + const dx = (e.clientX - this.lastX); + const dy = (e.clientY - this.lastY); + this.offsetX += dx / this.zoom; + this.offsetY += dy / this.zoom; + this.lastX = e.clientX; + this.lastY = e.clientY; + this.render(); + + if (window.app.syncZoom) { + const other = this === window.app.chartA ? window.app.chartB : window.app.chartA; + if (other) { + other.offsetX = this.offsetX; + other.offsetY = this.offsetY; + other.render(); + } + } + } + }); + + this.canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + this.zoom *= e.deltaY > 0 ? 0.9 : 1.1; + this.zoom = Math.max(0.1, Math.min(this.zoom, 10)); + this.render(); + if (window.app.syncZoom) { + const other = this === window.app.chartA ? window.app.chartB : window.app.chartA; + if (other) { + other.zoom = this.zoom; + other.render(); + } + } + }, { passive: false }); + } + + handleMouseMove(x, y, screenX, screenY) { + if (!this.slices) return; + const width = this.canvas.width / (window.devicePixelRatio || 1); + const height = this.canvas.height / (window.devicePixelRatio || 1); + const centerX = width / 2; + const centerY = height / 2; + const adjustedX = (x - centerX) / this.zoom - this.offsetX; + const adjustedY = (y - centerY) / this.zoom - this.offsetY; + const distance = Math.sqrt(adjustedX * adjustedX + adjustedY * adjustedY); + const radius = Math.min(width, height) / 2.5; + + let hit = null; + if (distance <= radius) { + let angle = Math.atan2(adjustedY, adjustedX); + if (angle < 0) angle += 2 * Math.PI; + hit = this.slices.find(slice => angle >= slice.startAngle && angle <= slice.endAngle); + } + + 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') { + const el = document.getElementById(`path-${hit.name}`); + if (el) el.classList.add('highlight'); + } + } + } + + 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.innerHTML = ` +
>${this.hoveredSlice.name}
+
+
GAS_USED: ${this.hoveredSlice.value.toLocaleString()}
+
SHARE: ${this.hoveredSlice.percentage.toFixed(2)}%
+
+ `; + } else { + tooltip.style.display = 'none'; + } + } + + render() { + const width = this.canvas.width / (window.devicePixelRatio || 1); + const height = this.canvas.height / (window.devicePixelRatio || 1); + this.ctx.clearRect(0, 0, width, height); + + if (!this.slices) return; + + this.ctx.save(); + this.ctx.translate(width / 2, height / 2); + this.ctx.scale(this.zoom, this.zoom); + this.ctx.translate(this.offsetX, this.offsetY); + + const radius = Math.min(width, height) / 2.5; + + this.slices.forEach(slice => { + this.ctx.beginPath(); + this.ctx.moveTo(0, 0); + this.ctx.arc(0, 0, radius, slice.startAngle, slice.endAngle); + this.ctx.closePath(); + + this.ctx.fillStyle = slice.color; + let isHighlighted = (this.hoveredSlice === slice); + if (!isHighlighted && this.searchQuery && slice.name !== 'Other') { + const query = this.searchQuery.toLowerCase(); + const sliceName = slice.name.toLowerCase(); + // Highlight if exact match OR if it's a prefix of at least 3 chars + isHighlighted = (sliceName === query) || (query.length >= 3 && sliceName.startsWith(query)); + } + + if (isHighlighted) this.ctx.fillStyle = '#ffffff'; + + this.ctx.fill(); + this.ctx.strokeStyle = '#000000'; + this.ctx.lineWidth = 2 / this.zoom; + this.ctx.stroke(); + + let midAngle = slice.startAngle + (slice.endAngle - slice.startAngle) / 2; + 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.font = `${Math.max(12, 16 / this.zoom)}px 'VT323'`; + this.ctx.textAlign = 'center'; + this.ctx.textBaseline = 'middle'; + this.ctx.fillText(slice.name, textX, textY); + } + }); + + this.ctx.restore(); + + this.ctx.beginPath(); + this.ctx.arc(width / 2, height / 2, 5, 0, Math.PI * 2); + this.ctx.fillStyle = '#00ff41'; + 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.lineWidth = 1; + this.ctx.stroke(); + } + + getCategory(name) { + const n = name.toLowerCase(); + if (n === 'root') return 'Root'; + if (n.includes('storage_store') || n.includes('storage_flush')) return 'StorageExpensive'; + if (n.includes('storage_load') || n.includes('storage_cache')) return 'StorageNormal'; + if (n.includes('keccak')) return 'Crypto'; + if (n.includes('memory') || n.includes('args') || n.includes('result')) return 'Memory'; + if (n.includes('call') || n.includes('create')) return 'Call'; + if (n.includes('host') || n.includes('msg') || n.includes('block')) return 'System'; + return 'UserCode'; + } +} + +window.app = { + profileA: null, + profileB: null, + diff: null, + chartA: null, + chartB: null, + syncZoom: true, + searchQuery: '', + availableSymbols: [] +}; + +document.addEventListener('DOMContentLoaded', () => { + loadData(); + setupControls(); + 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); + } +}); + +function loadData() { + try { + 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; + }; + window.app.profileA = getJson('profile-data'); + window.app.profileB = getJson('profile-b-data'); + window.app.diff = getJson('diff-data'); + } catch (e) { + console.error('Data loading error', e); + } +} + +function setupControls() { + const zoomIn = () => { + if (window.app.chartA) { + window.app.chartA.zoom *= 1.2; + window.app.chartA.render(); + } + if (window.app.chartB) { + window.app.chartB.zoom = window.app.chartA.zoom; + window.app.chartB.render(); + } + }; + const zoomOut = () => { + if (window.app.chartA) { + window.app.chartA.zoom *= 0.8; + window.app.chartA.render(); + } + if (window.app.chartB) { + window.app.chartB.zoom = window.app.chartA.zoom; + window.app.chartB.render(); + } + }; + const reset = () => { + if (window.app.chartA) { + window.app.chartA.zoom = 1.0; + window.app.chartA.offsetX = 0; + window.app.chartA.offsetY = 0; + window.app.chartA.render(); + } + if (window.app.chartB) { + window.app.chartB.zoom = 1.0; + window.app.chartB.offsetX = 0; + window.app.chartB.offsetY = 0; + window.app.chartB.render(); + } + } + document.getElementById('zoom-in').onclick = zoomIn; + document.getElementById('zoom-out').onclick = zoomOut; + document.getElementById('reset-view').onclick = reset; + + const searchInput = document.getElementById('search-input'); + + const searchGhost = document.getElementById('search-ghost'); + searchGhost.textContent = ">_ SEARCH SYMBOLS..."; + + searchInput.onfocus = () => { if (searchInput.value === '') searchGhost.textContent = ''; }; + searchInput.onblur = () => { if (searchInput.value === '') searchGhost.textContent = '>_ SEARCH SYMBOLS...'; }; + + searchInput.oninput = (e) => { + const val = e.target.value.toLowerCase(); + + if (val.length === 0) { + searchGhost.textContent = ""; + updateSearch(''); + return; + } + + // Find matches for ghosting/autocomplete + const suggestion = window.app.availableSymbols.find(s => s.toLowerCase().startsWith(val)); + + if (suggestion) { + // Display suggestion in ghost - ensure casing matches what's typed for the prefix + const typedPrefix = e.target.value; + const remaining = suggestion.slice(typedPrefix.length); + searchGhost.textContent = typedPrefix + remaining; + } else { + searchGhost.textContent = ""; + } + + updateSearch(val); + }; + + searchInput.onkeydown = (e) => { + if (e.key === 'Enter' || e.key === 'Tab') { + const val = searchInput.value.toLowerCase(); + const suggestion = window.app.availableSymbols.find(s => s.toLowerCase().startsWith(val)); + if (suggestion) { + searchInput.value = suggestion; + searchGhost.textContent = suggestion; + updateSearch(suggestion); + if (e.key === 'Tab') e.preventDefault(); + } + } + }; + + function updateSearch(query) { + window.app.searchQuery = query; + if (window.app.chartA) { + window.app.chartA.searchQuery = query; + window.app.chartA.render(); + } + if (window.app.chartB) { + window.app.chartB.searchQuery = query; + window.app.chartB.render(); + } + } + window.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + document.getElementById('search-input').focus(); + } + }); +} + +function updateUI() { + const profA = window.app.profileA; + const profB = window.app.profileB; + + // Hashes + document.getElementById('hash-a').textContent = profA.transaction_hash; + if (profB) { + document.getElementById('hash-b').textContent = profB.transaction_hash; + document.getElementById('gas-label').textContent = 'GAS_DELTA:'; + document.getElementById('hostio-label').textContent = 'HOST_IO_DELTA:'; + } else { + const targetTx = document.querySelector('.tx-item.target'); + if (targetTx) targetTx.classList.add('hidden'); + document.getElementById('gas-label').textContent = 'TOTAL_GAS:'; + document.getElementById('hostio-label').textContent = 'HOST_IO_CALLS:'; + } + + // Delta Stats Helper + const formatDelta = (v1, v2) => { + const diff = v2 - v1; + const pct = v1 === 0 ? (v2 > 0 ? 100 : 0) : (diff / v1) * 100; + const sign = diff > 0 ? '+' : ''; + const cls = diff > 0 ? 'pos' : (diff < 0 ? 'neg' : 'neutral'); + return `${v1.toLocaleString()} -> ${v2.toLocaleString()} (${sign}${pct.toFixed(2)}%)`; + }; + + // Gas Delta + const gasA = profA.total_gas; + const gasB = profB ? profB.total_gas : gasA; + if (profB) { + document.getElementById('gas-delta').innerHTML = formatDelta(gasA, gasB); + } else { + document.getElementById('gas-delta').textContent = gasA.toLocaleString(); + } + + // HostIO Delta + const ioA = profA.hostio_summary?.total_calls || 0; + const ioB = profB ? (profB.hostio_summary?.total_calls || 0) : ioA; + if (profB) { + document.getElementById('hostio-delta').innerHTML = `📈 ${formatDelta(ioA, ioB)}`; + } else { + document.getElementById('hostio-delta').textContent = ioA.toLocaleString(); + } + + const profileName = profB ? + `${profA.transaction_hash.slice(0, 8)}... vs ${profB.transaction_hash.slice(0, 8)}...` : + profA.transaction_hash.slice(0, 10) + '...'; + document.getElementById('profile-name').textContent = profileName; + + // Collect symbols for autocomplete with safety guards + const symbols = new Set(); + if (profA && profA.hot_paths) { + profA.hot_paths.forEach(p => symbols.add(p.stack.split(';').pop())); + } + if (profB && profB.hot_paths) { + profB.hot_paths.forEach(p => symbols.add(p.stack.split(';').pop())); + } + window.app.availableSymbols = Array.from(symbols).sort(); + + // Hot Paths + const hotPathsList = document.getElementById('hot-paths-list'); + hotPathsList.innerHTML = ''; + + let pathsToShow = profB ? [...profB.hot_paths] : [...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 (pathsToShow) { + pathsToShow.slice(0, 10).forEach(path => { + const li = document.createElement('li'); + li.className = 'hot-path-item'; + const name = path.stack.split(';').pop(); + li.id = `path-${name}`; + + let deltaDisplay = ''; + let rightSide = ''; + if (profB) { + 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 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 + } else { + deltaDisplay = `[${path.percentage.toFixed(1)}%]`; + rightSide = `${(path.gas / 1000).toFixed(0)}k gas`; + } + + li.innerHTML = ` +
+ ${deltaDisplay} + ${rightSide} +
+ > ${name} + `; + + li.onmouseenter = () => { + if (window.app.chartA) { + window.app.chartA.hoveredSlice = window.app.chartA.slices.find(s => s.name === name); + window.app.chartA.render(); + } + if (window.app.chartB) { + window.app.chartB.hoveredSlice = window.app.chartB.slices.find(s => s.name === name); + window.app.chartB.render(); + } + }; + li.onmouseleave = () => { + if (window.app.chartA) window.app.chartA.hoveredSlice = null; + if (window.app.chartB) window.app.chartB.hoveredSlice = null; + if (window.app.chartA) window.app.chartA.render(); + if (window.app.chartB) window.app.chartB.render(); + }; + hotPathsList.appendChild(li); + }); + } +} diff --git a/tests/fixtures/baseline.html b/tests/fixtures/baseline.html new file mode 100644 index 0000000..40e6f5c --- /dev/null +++ b/tests/fixtures/baseline.html @@ -0,0 +1,838 @@ + + + + + + Stylus Trace Studio - Cyber Diagnostics + + + + + + +
+
+
+ + +
+ + + +
+
+ +
+ + +
+
+ +
> BASELINE_DATA
+
+ +
+
+
+ +
+
> SYSTEM: ONLINE | PROFILE: none
+
+
+ + + + + + + + +