feat(html): added interactive html profiles

This commit is contained in:
2026-03-25 07:31:14 -04:00
parent aef19e99fa
commit e856554f97
6 changed files with 311 additions and 3 deletions

View File

@@ -1,8 +1,10 @@
[package] [package]
name = "strata" name = "strata"
version = "0.1.0" version = "0.1.1"
edition = "2024" edition = "2024"
publish = ["gitea"] publish = ["gitea"]
[dependencies] [dependencies]
clap = { version = "4.6.0", features = ["derive"] } clap = { version = "4.6.0", features = ["derive"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"

View File

@@ -24,12 +24,13 @@ strata <input_file> --mode <MODE> [OPTIONS]
- `flat-symbol`: Aggregates the observed weights by the resolved symbol name, irrespective of the call path context, yielding a flat percentage summary. - `flat-symbol`: Aggregates the observed weights by the resolved symbol name, irrespective of the call path context, yielding a flat percentage summary.
- `namespace`: Aggregates observed weights by namespace exclusively. - `namespace`: Aggregates observed weights by namespace exclusively.
### Filtering Options ### Filtering and Output Options
Strata supports the refinement of the call graph through three primary filtering mechanisms. When nodes are filtered from the reporting view, their weights are correctly collapsed into the nearest visible ancestor to ensure strict weight conservation. Strata supports the refinement of the call graph through three primary filtering mechanisms, as well as an interactive HTML output capability.
- `--whitelist <namespaces>`: Only the specified namespaces remain visible in the output hierarchy. - `--whitelist <namespaces>`: Only the specified namespaces remain visible in the output hierarchy.
- `--blacklist <namespaces>`: The specified namespaces are removed from explicit representation, with their weights folded upward. - `--blacklist <namespaces>`: The specified namespaces are removed from explicit representation, with their weights folded upward.
- `--fold <substrings>`: Halts traversal at the first node whose literal frame matches the provided substring. The internal operations of that subtree are hidden, and all inclusive weights descending from that node are strictly rolled up into its exclusive weight. - `--fold <substrings>`: Halts traversal at the first node whose literal frame matches the provided substring. The internal operations of that subtree are hidden, and all inclusive weights descending from that node are strictly rolled up into its exclusive weight.
- `--graph <FILE_PATH.html>`: Generates a self-contained, interactive `D3.js` HTML report. If the mode is set to `tree`, it produces a panning, zooming Node-Link Collapsible Tree visualizer. For `flat-symbol` or `namespace` modes, it generates an interactive bar chart.
## Development Context and Methodology ## Development Context and Methodology
This software was constructed through iterative interactions with a generative artificial intelligence coding assistant. It serves primarily as an experimental test case for the author to evaluate the feasibility of AI-driven software engineering in systems tooling. This software was constructed through iterative interactions with a generative artificial intelligence coding assistant. It serves primarily as an experimental test case for the author to evaluate the feasibility of AI-driven software engineering in systems tooling.

View File

@@ -22,6 +22,10 @@ pub struct Cli {
/// Comma-separated list of substrings to match for folding subtrees /// Comma-separated list of substrings to match for folding subtrees
#[arg(short = 'f', long)] #[arg(short = 'f', long)]
pub fold: Option<String>, pub fold: Option<String>,
/// Path to output an interactive HTML graph
#[arg(short = 'g', long)]
pub graph: Option<String>,
} }
#[derive(ValueEnum, Clone, Debug)] #[derive(ValueEnum, Clone, Debug)]

293
src/html.rs Normal file
View File

@@ -0,0 +1,293 @@
use crate::filter::ReportGraph;
use crate::cli::ReportMode;
use serde::Serialize;
use std::fs::File;
use std::io::Write;
#[derive(Serialize)]
struct TreeNode {
name: String,
inclusive: u64,
exclusive: u64,
children: Vec<TreeNode>,
}
fn build_tree_node(graph: &ReportGraph, id: usize) -> TreeNode {
let node = &graph.nodes[id];
let mut children = Vec::new();
for child_id in node.children.values() {
children.push(build_tree_node(graph, *child_id));
}
children.sort_by(|a, b| b.inclusive.cmp(&a.inclusive));
TreeNode {
name: node.display_label.clone(),
inclusive: node.inclusive_weight,
exclusive: node.exclusive_weight,
children,
}
}
pub fn generate_html_report(graph: &ReportGraph, mode: &ReportMode, out_path: &str) -> std::io::Result<()> {
let mut f = File::create(out_path)?;
match mode {
ReportMode::Tree => {
let root_node = build_tree_node(graph, graph.root);
let json_data = serde_json::to_string(&root_node).unwrap();
let html = format!(r#"<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>Strata Call Graph</title>
<script src='https://d3js.org/d3.v7.min.js'></script>
<style>
body {{ font-family: sans-serif; margin: 0; padding: 0; background: #fafafa; }}
svg {{ width: 100vw; height: 100vh; display: block; }}
.node circle {{ fill: #fff; stroke: steelblue; stroke-width: 3px; cursor: pointer; }}
.node text {{ font: 12px monospace; }}
.link {{ fill: none; stroke: #ccc; stroke-width: 2px; }}
#tooltip {{
position: absolute; text-align: left; padding: 10px;
font: 13px sans-serif; background: #fff; border: 1px solid #ccc;
border-radius: 8px; pointer-events: none; opacity: 0; box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
}}
</style>
</head>
<body>
<div id='tooltip'></div>
<svg id='chart'></svg>
<script>
const treeData = {0};
const svg = d3.select('#chart'),
width = document.body.clientWidth,
height = document.body.clientHeight;
const g = svg.append('g');
svg.call(d3.zoom().on('zoom', (event) => {{
g.attr('transform', event.transform);
}}));
const treeLayout = d3.tree().nodeSize([30, 300]);
let root = d3.hierarchy(treeData, d => d.children);
root.x0 = height / 2;
root.y0 = 0;
root.descendants().forEach((d, i) => {{
if (d.depth > 1 && d.children) {{
d._children = d.children;
d.children = null;
}}
}});
let i = 0;
const duration = 500;
const tooltip = d3.select('#tooltip');
function update(source) {{
const treeData = treeLayout(root);
const nodes = treeData.descendants(),
links = treeData.descendants().slice(1);
nodes.forEach(d => {{ d.y = d.depth * 300; }});
const node = g.selectAll('g.node')
.data(nodes, d => d.id || (d.id = ++i));
const nodeEnter = node.enter().append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${{source.y0}},${{source.x0}})`)
.on('click', (event, d) => {{
if (d.children) {{ d._children = d.children; d.children = null; }}
else {{ d.children = d._children; d._children = null; }}
update(d);
}})
.on('mouseover', (event, d) => {{
tooltip.transition().duration(200).style('opacity', .9);
tooltip.html(`<b>${{d.data.name}}</b><br/>Inclusive: ${{d.data.inclusive}}<br/>Exclusive: ${{d.data.exclusive}}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px');
}})
.on('mouseout', d => {{
tooltip.transition().duration(500).style('opacity', 0);
}});
nodeEnter.append('circle')
.attr('r', 1e-6)
.style('fill', d => d._children ? 'lightsteelblue' : '#fff');
nodeEnter.append('text')
.attr('dy', '.35em')
.attr('x', d => d.children || d._children ? -13 : 13)
.attr('text-anchor', d => d.children || d._children ? 'end' : 'start')
.text(d => d.data.name.substring(0, 50) + (d.data.name.length > 50 ? '...' : ''));
const nodeUpdate = nodeEnter.merge(node);
nodeUpdate.transition()
.duration(duration)
.attr('transform', d => `translate(${{d.y}},${{d.x}})`);
nodeUpdate.select('circle')
.attr('r', 6)
.style('fill', d => d._children ? 'lightsteelblue' : '#fff');
const nodeExit = node.exit().transition()
.duration(duration)
.attr('transform', d => `translate(${{source.y}},${{source.x}})`)
.remove();
nodeExit.select('circle').attr('r', 1e-6);
nodeExit.select('text').style('fill-opacity', 1e-6);
const link = g.selectAll('path.link')
.data(links, d => d.id);
const linkEnter = link.enter().insert('path', 'g')
.attr('class', 'link')
.attr('d', d => {{
const o = {{x: source.x0, y: source.y0}};
return diagonal(o, o);
}});
const linkUpdate = linkEnter.merge(link);
linkUpdate.transition()
.duration(duration)
.attr('d', d => diagonal(d.parent, d));
link.exit().transition()
.duration(duration)
.attr('d', d => {{
const o = {{x: source.x, y: source.y}};
return diagonal(o, o);
}})
.remove();
nodes.forEach(d => {{
d.x0 = d.x;
d.y0 = d.y;
}});
function diagonal(s, d) {{
return `M ${{s.y}} ${{s.x}}
C ${{ (s.y + d.y) / 2 }} ${{s.x}},
${{ (s.y + d.y) / 2 }} ${{d.x}},
${{d.y}} ${{d.x}}`;
}}
}}
g.attr('transform', `translate(100, ${{height/2}})`);
update(root);
</script>
</body>
</html>"#, json_data);
f.write_all(html.as_bytes())?;
}
ReportMode::FlatSymbol | ReportMode::Namespace => {
let data = match mode {
ReportMode::FlatSymbol => crate::report::report_flat_symbol(graph),
ReportMode::Namespace => crate::report::report_namespace(graph),
_ => unreachable!(),
};
let top_data: Vec<_> = data.into_iter().take(150).collect();
let json_data = serde_json::to_string(&top_data).unwrap();
let title = match mode {
ReportMode::FlatSymbol => "Flat Symbol Report",
ReportMode::Namespace => "Namespace Report",
_ => "",
};
let html = format!(r#"<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='UTF-8'>
<title>{1}</title>
<script src='https://d3js.org/d3.v7.min.js'></script>
<style>
body {{ font-family: sans-serif; margin: 20px; }}
.bar {{ fill: steelblue; }}
.bar:hover {{ fill: orange; }}
.axis-label {{ font: 12px sans-serif; }}
#tooltip {{
position: absolute; text-align: left; padding: 10px;
font: 13px sans-serif; background: #fff; border: 1px solid #ccc;
border-radius: 8px; pointer-events: none; opacity: 0; box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
}}
</style>
</head>
<body>
<h2>{1}</h2>
<div id='tooltip'></div>
<div id='chart'></div>
<script>
const data = {0};
const margin = {{top: 20, right: 30, bottom: 40, left: 350}},
width = 1200 - margin.left - margin.right,
height = Math.max(800, data.length * 20) - margin.top - margin.bottom;
const svg = d3.select('#chart')
.append('svg')
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', `translate(${{margin.left}},${{margin.top}})`);
const x = d3.scaleLinear()
.domain([0, d3.max(data, d => d[1])])
.range([0, width]);
svg.append('g')
.attr('transform', `translate(0,${{height}})`)
.call(d3.axisBottom(x))
.selectAll('text')
.attr('transform', 'translate(-10,0)rotate(-45)')
.style('text-anchor', 'end');
const y = d3.scaleBand()
.range([0, height])
.domain(data.map(d => d[0]))
.padding(.1);
svg.append('g')
.call(d3.axisLeft(y))
.selectAll('text')
.style('font-family', 'monospace')
.text(d => d.length > 45 ? d.substring(0, 42) + '...' : d);
const tooltip = d3.select('#tooltip');
svg.selectAll('myRect')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', x(0) )
.attr('y', d => y(d[0]))
.attr('width', d => x(d[1]))
.attr('height', y.bandwidth())
.on('mouseover', (event, d) => {{
tooltip.transition().duration(200).style('opacity', .9);
tooltip.html(`<b>${{d[0]}}</b><br/>Weight: ${{d[1]}}`)
.style('left', (event.pageX + 10) + 'px')
.style('top', (event.pageY - 28) + 'px');
}})
.on('mouseout', d => {{
tooltip.transition().duration(500).style('opacity', 0);
}});
</script>
</body>
</html>"#, json_data, title);
f.write_all(html.as_bytes())?;
}
}
Ok(())
}

View File

@@ -4,3 +4,4 @@ pub mod model;
pub mod parser; pub mod parser;
pub mod cli; pub mod cli;
pub mod report; pub mod report;
pub mod html;

View File

@@ -4,6 +4,7 @@ use strata::builder::SourceGraphBuilder;
use strata::cli::{Cli, ReportMode}; use strata::cli::{Cli, ReportMode};
use strata::filter::{collapse_graph, FilterMode, FilterOptions}; use strata::filter::{collapse_graph, FilterMode, FilterOptions};
use strata::report::{report_flat_symbol, report_namespace, report_tree}; use strata::report::{report_flat_symbol, report_namespace, report_tree};
use strata::html::generate_html_report;
fn main() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Cli::parse(); let args = Cli::parse();
@@ -54,5 +55,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
// 5. Build HTML Graph if requested
if let Some(graph_file) = &args.graph {
generate_html_report(&report_graph, &args.mode, graph_file)?;
println!("Interactive graph written to {}", graph_file);
}
Ok(()) Ok(())
} }