summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 15:35:24 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 15:35:24 +0200
commit99b02bf8c389a793df5d5986db05eed7e459f7b1 (patch)
treebc4e36cfcd3c9ef9b067beed2eb5b68a75a45aa2
parent4ff17c30120d657b966f8a55188ba167dc875e64 (diff)
refactor: remove web flamegrapher and keep TUI-only
-rw-r--r--AGENTS.md2
-rw-r--r--README.md57
-rw-r--r--docs/tui-flamegraph-plan.md12
-rw-r--r--internal/eventloop.go65
-rw-r--r--internal/eventloop_filter_test.go4
-rw-r--r--internal/flags/flags.go37
-rw-r--r--internal/flags/flags_test.go49
-rw-r--r--internal/flamegraph/doc.go2
-rw-r--r--internal/flamegraph/iordatacollector.go65
-rw-r--r--internal/flamegraph/layout.go78
-rw-r--r--internal/flamegraph/layout_test.go77
-rw-r--r--internal/flamegraph/livehtml.go842
-rw-r--r--internal/flamegraph/livehtml_browser_test.go314
-rw-r--r--internal/flamegraph/livehtml_interaction_test.go615
-rw-r--r--internal/flamegraph/liveserver.go314
-rw-r--r--internal/flamegraph/liveserver_open_test.go179
-rw-r--r--internal/flamegraph/liveserver_test.go380
-rw-r--r--internal/flamegraph/nativejson.go86
-rw-r--r--internal/flamegraph/nativejson_test.go75
-rw-r--r--internal/flamegraph/nativesvg.go97
-rw-r--r--internal/flamegraph/nativesvg_test.go60
-rw-r--r--internal/flamegraph/svgwriter.go151
-rw-r--r--internal/flamegraph/svgwriter_js.go212
-rw-r--r--internal/flamegraph/svgwriter_jscode.go214
-rw-r--r--internal/flamegraph/svgwriter_test.go112
-rw-r--r--internal/flamegraph/webserver.go199
-rw-r--r--internal/flamegraph/webserver_autoreload_test.go37
-rw-r--r--internal/flamegraph/webserver_timeout_test.go43
-rw-r--r--internal/flamegraph/worker.go34
-rw-r--r--internal/ior.go132
-rw-r--r--internal/ior_mode_test.go88
31 files changed, 46 insertions, 4586 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 95f1228..6eb7dc4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -46,7 +46,7 @@ Generator source code:
- **Entry point**: `cmd/ior/main.go` - Linux-only BPF-based I/O syscall tracer
- **Core packages**: `/internal/event/` (BPF event handling), `/internal/flamegraph/` (FlameGraph generation), `/internal/c/` (BPF programs)
-- **Output**: Compressed `.ior.zst` trace data and native SVG flamegraphs (served via embedded web server in `-ior` mode)
+- **Output**: TUI dashboard and TUI flamegraphs (no embedded web flamegraph server mode)
- **TUI package**: `/internal/tui/` contains top-level Bubble Tea orchestration (`tui.go`), shared key map (`keys.go`), and styles (`styles.go`).
- **Dashboard tabs**: `/internal/tui/dashboard/` contains tab renderers (overview/syscalls/files/processes/latency/gaps) and tab framework model.
- **Export modal**: `/internal/tui/export/model.go` implements the centered modal used for CSV export flow in TUI mode.
diff --git a/README.md b/README.md
index 5826ec1..8e402f4 100644
--- a/README.md
+++ b/README.md
@@ -61,60 +61,11 @@ make
sudo cp -v ./libelf/libelf.a /usr/lib64/
```
-## Native Flamegraph Generation
+## TUI Flamegraphs
-Flamegraphs are generated natively by `ior` from `.ior.zst` data files; no external flamegraph tool is required.
-When `-fields` is omitted, the default stack order is `comm,path,tracepoint` (bottom to top).
-To change grouping order, pass `-fields` explicitly in the desired order.
-
-```sh
-./ior -ior=trace.ior.zst -fields=comm,path,tracepoint -count=count
-```
-
-This generates an SVG and starts an embedded web server. The terminal prints a URL like:
-
-```text
-Flamegraph available at http://HOSTNAME:PORT/abs/path/to.svg
-```
-
-For experimental WebAssembly frontends, you can also emit a flamegraph JSON tree:
-
-```sh
-./ior -ior=trace.ior.zst -flamegraphJson
-```
-
-This writes `<trace>.<fields>-by-<count>.json` next to the SVG.
-
-To keep the served flamegraph changing as the `.ior.zst` file is updated, enable watch mode:
-
-```sh
-./ior -ior=trace.ior.zst -iorWatchInterval=2s
-```
-
-This polls the input file for modifications, regenerates SVG/JSON outputs, and serves an auto-reloading viewer at `/`.
-
-## Live Flamegraph Mode
-
-Run live mode (requires root privileges):
-
-```sh
-sudo ./ior -live -pid <PID> -live-interval 200ms -duration 300
-```
-
-The terminal prints a URL like:
-
-```text
-Live flamegraph available at http://HOSTNAME:PORT/
-```
-
-Live controls:
-
-- `Space`: pause/resume incoming updates.
-- `/`: search frame labels.
-- `Escape`: reset zoom and search highlighting.
-- `r`: reset baseline (clears all live aggregated stats on the server and restarts from zero).
-- `Reset Baseline` button: same behavior as `r`.
-- `Order: ...` toggle button: cycles stack order presets on the fly and re-baselines live aggregation for the new order.
+Flamegraphs are available only inside the TUI dashboard.
+Use `-fields` to change the stack order and `-count` to choose the metric.
+The default stack order is `comm,path,tracepoint` (bottom to top).
## TUI Hotkeys
diff --git a/docs/tui-flamegraph-plan.md b/docs/tui-flamegraph-plan.md
index 67e8653..261f0fb 100644
--- a/docs/tui-flamegraph-plan.md
+++ b/docs/tui-flamegraph-plan.md
@@ -5,8 +5,8 @@
Add a **7th dashboard tab** (`7:Flame`) that renders a live, interactive flamegraph
directly in the terminal using lipgloss for layout/styling and **Charm Harmonica**
for smooth spring-based animations on both zoom transitions and live data refresh.
-The tab consumes data from an embedded `LiveTrie` (same as the web live mode) and
-provides full feature parity with the browser version.
+The tab consumes data from an embedded `LiveTrie` and
+provides interactive flamegraph navigation directly in-terminal.
## Architecture
@@ -145,8 +145,7 @@ Terminal flamegraphs use a **cell-based layout** rather than pixel coordinates:
- Use lipgloss background color fill with the existing `frameColor()` warm palette
- Frame text = truncated function/path name that fits within the frame width
- Selected frame gets a distinct border/highlight style (e.g., bold + inverted)
- - Search-matched frames get a different highlight color (e.g., red background like
- the web version's `matchColor`)
+ - Search-matched frames get a different highlight color (e.g., red background)
3. **Compositing**: Use `lipgloss.Place()` or the new lipgloss v2 compositor/canvas
to layer frames at their (col, row) positions. Each row of the flamegraph is
@@ -282,7 +281,7 @@ distinct colors for uncategorized frames.
### 10. Field Order Cycling
-Same preset cycle as the web version:
+Preset cycle:
```go
fieldPresets = [][]string{
{"comm", "path", "tracepoint"},
@@ -330,8 +329,7 @@ sys_read (1,234 calls, 45.2%) - /usr/bin/myapp > /dev/sda > sys_enter_read
### 14. Risks and Mitigations
1. **Performance at high event rates**: The `LiveTrie.Ingest()` call adds overhead
- to the hot path. Mitigation: already designed for production rates (used in
- `-live` mode). TUI render is decoupled via version polling.
+ to the hot path. Mitigation: TUI render is decoupled via version polling.
2. **Terminal width too narrow**: Flamegraphs with many shallow frames may not
render meaningfully in 80-column terminals. Mitigation: cull frames below 1 cell,
diff --git a/internal/eventloop.go b/internal/eventloop.go
index 7d33f87..6f14325 100644
--- a/internal/eventloop.go
+++ b/internal/eventloop.go
@@ -13,7 +13,6 @@ import (
"ior/internal/event"
"ior/internal/file"
- "ior/internal/flamegraph"
"ior/internal/types"
. "ior/internal/types"
)
@@ -21,18 +20,13 @@ import (
const sysEnterNameToHandleAtName = "name_to_handle_at"
type eventLoopConfig struct {
- pidFilter int
- commFilter string
- pathFilter string
- liveFlamegraph bool
- liveInterval time.Duration
- liveOpenCommand string
- collapsedFields []string
- countField string
- flamegraphName string
- flamegraphEnable bool
- pprofEnable bool
- plainMode bool
+ pidFilter int
+ commFilter string
+ pathFilter string
+ collapsedFields []string
+ countField string
+ pprofEnable bool
+ plainMode bool
}
type fdTracker struct {
@@ -174,8 +168,6 @@ type eventLoop struct {
commResolver *commResolver
prevPairTimes map[uint32]uint64 // Previous event's time (to calculate time differences between two events)
rawHandlers map[EventType]rawEventHandler
- flamegraph flamegraph.IorDataCollector // Storing all paths in a map structure for analysis
- liveTrie *flamegraph.LiveTrie
printCb func(ep *event.Pair) // Callback to print the event
warningCb func(message string) // Optional callback for non-fatal event processing warnings
cfg eventLoopConfig
@@ -205,14 +197,10 @@ func newEventLoop(cfg eventLoopConfig) *eventLoop {
prevPairTimes: make(map[uint32]uint64),
rawHandlers: make(map[EventType]rawEventHandler),
printCb: func(ep *event.Pair) { fmt.Println(ep); ep.Recycle() },
- flamegraph: flamegraph.New(cfg.flamegraphName),
cfg: cfg,
done: make(chan struct{}),
}
el.initRawHandlers()
- if cfg.liveFlamegraph {
- el.liveTrie = flamegraph.NewLiveTrie(cfg.collapsedFields, cfg.countField)
- }
el.configureOutputCallback()
el.seedTrackedPidComm()
return el
@@ -244,14 +232,6 @@ func (e *eventLoop) commState() *commResolver {
func (e *eventLoop) configureOutputCallback() {
switch {
- case e.cfg.flamegraphEnable:
- e.printCb = func(ep *event.Pair) {
- e.flamegraph.Ch <- ep
- }
- case e.liveTrie != nil:
- e.printCb = func(ep *event.Pair) {
- e.liveTrie.Ingest(ep)
- }
case e.cfg.pprofEnable:
e.printCb = func(ep *event.Pair) {
ep.Recycle()
@@ -282,29 +262,10 @@ func (e *eventLoop) stats() string {
func (e *eventLoop) run(ctx context.Context, rawCh <-chan []byte) {
defer close(e.done)
- if e.liveTrie != nil {
- fmt.Println("Starting live flamegraph server")
- go func() {
- liveOptions := flamegraph.LiveServerOptions{
- OpenCommand: e.cfg.liveOpenCommand,
- }
- if e.warningCb != nil {
- liveOptions.WarningCb = e.notifyWarning
- }
- if err := flamegraph.ServeLiveWithOptions(ctx, e.liveTrie, e.cfg.liveInterval, liveOptions); err != nil && ctx.Err() == nil {
- fmt.Println("Live flamegraph server error:", err)
- }
- }()
- }
-
- if e.cfg.flamegraphEnable {
- fmt.Println("Collecting flame graph stats, press Ctrl+C to stop")
- e.flamegraph.Start(ctx)
- }
if e.cfg.pprofEnable {
fmt.Println("Profiling, press Ctrl+C to stop")
}
- if e.cfg.plainMode && !e.cfg.flamegraphEnable && !e.cfg.pprofEnable {
+ if e.cfg.plainMode && !e.cfg.pprofEnable {
fmt.Println(event.EventStreamHeader)
}
@@ -316,16 +277,6 @@ func (e *eventLoop) run(ctx context.Context, rawCh <-chan []byte) {
e.printCb(ep)
e.numSyscallsAfterFilter++
}
-
- if e.cfg.flamegraphEnable {
- fmt.Println("Waiting for flamegraph")
- if err := <-e.flamegraph.Done; err != nil {
- e.notifyWarning(fmt.Sprintf("Flamegraph generation failed: %v", err))
- if e.warningCb == nil {
- fmt.Println("Flamegraph generation failed:", err)
- }
- }
- }
}
func (e *eventLoop) events(ctx context.Context, rawCh <-chan []byte) <-chan *event.Pair {
diff --git a/internal/eventloop_filter_test.go b/internal/eventloop_filter_test.go
index e6e3b45..ddd6451 100644
--- a/internal/eventloop_filter_test.go
+++ b/internal/eventloop_filter_test.go
@@ -8,7 +8,6 @@ import (
"ior/internal/event"
"ior/internal/file"
- "ior/internal/flamegraph"
"ior/internal/types"
)
@@ -443,7 +442,6 @@ func TestCommFilterToggle(t *testing.T) {
comms: make(map[uint32]string),
prevPairTimes: make(map[uint32]uint64),
printCb: func(ep *event.Pair) { outCh <- ep },
- flamegraph: flamegraph.New(),
done: make(chan struct{}),
}
go el.run(ctx, inCh)
@@ -483,7 +481,6 @@ func TestCommFilterToggle(t *testing.T) {
comms: make(map[uint32]string),
prevPairTimes: make(map[uint32]uint64),
printCb: func(ep *event.Pair) { outCh <- ep },
- flamegraph: flamegraph.New(),
done: make(chan struct{}),
}
go el.run(ctx, inCh)
@@ -518,7 +515,6 @@ func newEventLoopWithFilter(commFilter, pathFilter string) *eventLoop {
comms: make(map[uint32]string),
prevPairTimes: make(map[uint32]uint64),
printCb: func(ep *event.Pair) { fmt.Println(ep); ep.Recycle() },
- flamegraph: flamegraph.New(),
done: make(chan struct{}),
}
return el
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 503aefb..cc6e70a 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -54,23 +54,14 @@ type Flags struct {
TracepointsToAttach []*regexp.Regexp
TracepointsToExclude []*regexp.Regexp
- // Flamegraph flags
- PlainMode bool
- FlamegraphEnable bool
- LiveFlamegraph bool
- TestFlames bool
- TestLiveFlames bool
- LiveInterval time.Duration
- OpenCommand string
- FlamegraphName string
- FlamegraphJSON bool
- TUIExportEnable bool
-
- // To convert ior data into native SVG format
- IorDataFile string
- IorWatchInterval time.Duration
- CollapsedFields []string
- CountField string
+ // Output/runtime flags
+ PlainMode bool
+ TestFlames bool
+ TestLiveFlames bool
+ LiveInterval time.Duration
+ TUIExportEnable bool
+ CollapsedFields []string
+ CountField string
}
// NewFlags returns a configuration instance initialized with project defaults.
@@ -81,7 +72,6 @@ func NewFlags() Flags {
EventMapSize: 4096 * 16,
Duration: 900,
LiveInterval: 200 * time.Millisecond,
- FlamegraphName: "default",
TUIExportEnable: true,
CollapsedFields: []string{"comm", "tracepoint", "path"},
CountField: "count",
@@ -184,19 +174,10 @@ func parse() error {
tracepointsToExclude := flag.String("tpsExclude", "", "Comma separated list regexes for tracepoints to exclude")
flag.BoolVar(&cfg.PlainMode, "plain", false, "Enable plain CSV output mode (disable TUI)")
- flag.BoolVar(&cfg.FlamegraphEnable, "flamegraph", false, "Enable flamegraph builder")
- flag.BoolVar(&cfg.LiveFlamegraph, "live", false, "Enable live flamegraph mode")
flag.BoolVar(&cfg.TestFlames, "testflames", false, "Run TUI with static synthetic flamegraph data for keyboard-navigation testing")
flag.BoolVar(&cfg.TestLiveFlames, "testliveflames", false, "Run TUI with continuously-updating synthetic flamegraph data for live keyboard-navigation testing")
- flag.DurationVar(&cfg.LiveInterval, "live-interval", cfg.LiveInterval, "Live flamegraph refresh interval")
- flag.StringVar(&cfg.OpenCommand, "open", "", "Command to open live flamegraph URL (used with -live); use {url} placeholder or URL is appended")
- flag.StringVar(&cfg.FlamegraphName, "name", cfg.FlamegraphName, "Name of the flamegraph, used to generate the SVG file")
- flag.BoolVar(&cfg.FlamegraphJSON, "flamegraphJson", false, "Also export flamegraph tree as JSON in -ior mode (experimental WASM-ready output)")
+ flag.DurationVar(&cfg.LiveInterval, "live-interval", cfg.LiveInterval, "Synthetic live flamegraph refresh interval for --testliveflames")
flag.BoolVar(&cfg.TUIExportEnable, "tuiExport", cfg.TUIExportEnable, "Enable writing TUI snapshot export files")
-
- flag.StringVar(&cfg.IorDataFile, "ior", "", "IOR data file to convert into native SVG flamegraph")
- flag.DurationVar(&cfg.IorWatchInterval, "iorWatchInterval", 0,
- "In -ior mode, poll input file for changes and regenerate outputs; also enables auto-reloading viewer")
fields := flag.String("fields", "",
fmt.Sprintf("Comma separated list of fields to collapse, valid are: %v", validCollapsedFields))
flag.StringVar(&cfg.CountField, "count", cfg.CountField,
diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go
index 7323438..08dd6a2 100644
--- a/internal/flags/flags_test.go
+++ b/internal/flags/flags_test.go
@@ -38,15 +38,12 @@ func parseForTest(t *testing.T, args ...string) (Flags, error) {
return cfg, err
}
-func TestParseLiveFlagsAndInterval(t *testing.T) {
- cfg, err := parseForTest(t, "-live", "-live-interval", "200ms", "-pid", "1234")
+func TestParseLiveIntervalAndPID(t *testing.T) {
+ cfg, err := parseForTest(t, "-live-interval", "200ms", "-pid", "1234")
if err != nil {
t.Fatalf("parse returned error: %v", err)
}
- if !cfg.LiveFlamegraph {
- t.Fatalf("expected -live to enable live mode")
- }
if cfg.LiveInterval != 200*time.Millisecond {
t.Fatalf("live interval = %v, want %v", cfg.LiveInterval, 200*time.Millisecond)
}
@@ -56,9 +53,6 @@ func TestParseLiveFlagsAndInterval(t *testing.T) {
if got := Get().GetPidFilter(); got != 1234 {
t.Fatalf("Get().GetPidFilter() = %d, want 1234", got)
}
- if cfg.OpenCommand != "" {
- t.Fatalf("expected empty open command by default")
- }
}
func TestNewFlagsDefaultsAndGetters(t *testing.T) {
@@ -83,48 +77,9 @@ func TestParseLiveDefaults(t *testing.T) {
t.Fatalf("parse returned error: %v", err)
}
- if cfg.LiveFlamegraph {
- t.Fatalf("expected live mode disabled by default")
- }
if cfg.LiveInterval != 200*time.Millisecond {
t.Fatalf("default live interval = %v, want %v", cfg.LiveInterval, 200*time.Millisecond)
}
- if cfg.OpenCommand != "" {
- t.Fatalf("expected empty open command by default")
- }
-}
-
-func TestParseOpenFlags(t *testing.T) {
- cfg, err := parseForTest(t, "-live", "-open", "chromium --new-window")
- if err != nil {
- t.Fatalf("parse returned error: %v", err)
- }
- if !cfg.LiveFlamegraph {
- t.Fatalf("expected live mode enabled")
- }
- if cfg.OpenCommand != "chromium --new-window" {
- t.Fatalf("open command = %q, want %q", cfg.OpenCommand, "chromium --new-window")
- }
-}
-
-func TestParseFlamegraphJSONFlag(t *testing.T) {
- cfg, err := parseForTest(t, "-flamegraphJson")
- if err != nil {
- t.Fatalf("parse returned error: %v", err)
- }
- if !cfg.FlamegraphJSON {
- t.Fatalf("expected -flamegraphJson to enable JSON export")
- }
-}
-
-func TestParseIorWatchIntervalFlag(t *testing.T) {
- cfg, err := parseForTest(t, "-iorWatchInterval", "2s")
- if err != nil {
- t.Fatalf("parse returned error: %v", err)
- }
- if cfg.IorWatchInterval != 2*time.Second {
- t.Fatalf("ior watch interval = %v, want %v", cfg.IorWatchInterval, 2*time.Second)
- }
}
func TestParseTestFlamesFlag(t *testing.T) {
diff --git a/internal/flamegraph/doc.go b/internal/flamegraph/doc.go
index 8ff27d2..02429d3 100644
--- a/internal/flamegraph/doc.go
+++ b/internal/flamegraph/doc.go
@@ -1,2 +1,2 @@
-// Package flamegraph builds aggregated call trees and rendering inputs for I/O flamegraphs.
+// Package flamegraph provides TUI flamegraph aggregation primitives.
package flamegraph
diff --git a/internal/flamegraph/iordatacollector.go b/internal/flamegraph/iordatacollector.go
deleted file mode 100644
index a2ae731..0000000
--- a/internal/flamegraph/iordatacollector.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package flamegraph
-
-import (
- "context"
- "fmt"
- "runtime"
- "sync"
-
- "ior/internal/event"
-)
-
-type IorDataCollector struct {
- flamegraphName string
- Ch chan *event.Pair
- Done chan error
- workers []worker
-}
-
-func New(flamegraphName ...string) IorDataCollector {
- name := "default"
- if len(flamegraphName) > 0 && flamegraphName[0] != "" {
- name = flamegraphName[0]
- }
-
- f := IorDataCollector{
- flamegraphName: name,
- Ch: make(chan *event.Pair, 4096),
- Done: make(chan error, 1),
- }
- numWorkers := runtime.NumCPU() / 4
- if numWorkers == 0 {
- numWorkers = 1
- }
- for range numWorkers {
- f.workers = append(f.workers, newWorker())
- }
- return f
-}
-
-func (f IorDataCollector) Start(ctx context.Context) {
- go func() {
- defer close(f.Done)
- var wg sync.WaitGroup
- wg.Add(len(f.workers))
-
- for i, worker := range f.workers {
- fmt.Println("Starting flamegraph worker", i)
- go worker.run(ctx, &wg, f.Ch)
- }
- wg.Wait()
-
- iod := f.workers[0].iod
- if len(f.workers) > 1 {
- for i, w := range f.workers[1:] {
- iod = iod.merge(w.iod)
- fmt.Println("Worker", i+1, "merged")
- }
- }
- if err := iod.serializeToFile(f.flamegraphName); err != nil {
- f.Done <- err
- return
- }
- f.Done <- nil
- }()
-}
diff --git a/internal/flamegraph/layout.go b/internal/flamegraph/layout.go
deleted file mode 100644
index c319800..0000000
--- a/internal/flamegraph/layout.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package flamegraph
-
-import "fmt"
-
-// FrameLayout captures renderer-agnostic flamegraph geometry for a single frame.
-//
-// The layout is reusable by non-SVG renderers (for example SDL or WASM UIs) so
-// they can render the same hierarchy without depending on SVG internals.
-type FrameLayout struct {
- Name string
- Title string
- Fill string
- X float64
- Y float64
- Width float64
- Height float64
- Depth int
- Total uint64
- Percent float64
-}
-
-func sanitizeSVGConfig(cfg SVGConfig) SVGConfig {
- if cfg.Width <= 0 || cfg.FrameHeight <= 0 || cfg.FontSize <= 0 || cfg.MinWidthPx <= 0 {
- return defaultSVGConfig()
- }
- if cfg.Title == "" {
- cfg.Title = defaultSVGConfig().Title
- }
- return cfg
-}
-
-func canvasHeightFor(cfg SVGConfig, t *trie) int {
- return cfg.FrameHeight*(t.maxDepth+1) + 80
-}
-
-// BuildFrameLayout builds renderer-agnostic frame coordinates from a flamegraph trie.
-func BuildFrameLayout(t *trie, cfg SVGConfig) []FrameLayout {
- if t == nil || t.root == nil || t.root.total == 0 {
- return nil
- }
- cfg = sanitizeSVGConfig(cfg)
- canvasHeight := canvasHeightFor(cfg, t)
- out := make([]FrameLayout, 0, len(t.root.children))
- collectFrameLayout(&out, t.root, t.root.total, cfg, 0, 0, canvasHeight, true)
- return out
-}
-
-func collectFrameLayout(out *[]FrameLayout, node *trieNode, rootTotal uint64,
- cfg SVGConfig, x float64, depth int, canvasHeight int, isRoot bool) {
-
- if !isRoot {
- w := float64(cfg.Width) * (float64(node.total) / float64(rootTotal))
- if w < cfg.MinWidthPx {
- return
- }
- y := float64(canvasHeight - (depth+1)*cfg.FrameHeight)
- pct := 100 * float64(node.total) / float64(rootTotal)
- *out = append(*out, FrameLayout{
- Name: node.name,
- Title: fmt.Sprintf("%s (%d, %.2f%%)", node.name, node.total, pct),
- Fill: frameColor(node.name),
- X: x,
- Y: y,
- Width: w,
- Height: float64(cfg.FrameHeight - 1),
- Depth: depth,
- Total: node.total,
- Percent: pct,
- })
- }
-
- cursor := x
- for _, child := range node.children {
- cw := float64(cfg.Width) * (float64(child.total) / float64(rootTotal))
- collectFrameLayout(out, child, rootTotal, cfg, cursor, depth+1, canvasHeight, false)
- cursor += cw
- }
-}
diff --git a/internal/flamegraph/layout_test.go b/internal/flamegraph/layout_test.go
deleted file mode 100644
index 8fa7398..0000000
--- a/internal/flamegraph/layout_test.go
+++ /dev/null
@@ -1,77 +0,0 @@
-package flamegraph
-
-import (
- "math"
- "testing"
-)
-
-func almostEqual(a, b float64) bool {
- return math.Abs(a-b) < 1e-6
-}
-
-func TestBuildFrameLayoutBasicGeometry(t *testing.T) {
- tr := newTrie()
- tr.add([]string{"A"}, 4)
- tr.add([]string{"B"}, 1)
- tr.computeTotals()
-
- cfg := defaultSVGConfig()
- cfg.Width = 100
- cfg.FrameHeight = 10
- cfg.FontSize = 10
- cfg.MinWidthPx = 1
-
- frames := BuildFrameLayout(tr, cfg)
- if len(frames) != 2 {
- t.Fatalf("frames len = %d, want 2", len(frames))
- }
-
- a := frames[0]
- if a.Name != "A" {
- t.Fatalf("first frame name = %q, want %q", a.Name, "A")
- }
- if !almostEqual(a.X, 0) {
- t.Fatalf("A x = %f, want 0", a.X)
- }
- if !almostEqual(a.Width, 80) {
- t.Fatalf("A width = %f, want 80", a.Width)
- }
- if !almostEqual(a.Percent, 80) {
- t.Fatalf("A percent = %f, want 80", a.Percent)
- }
- if a.Depth != 1 {
- t.Fatalf("A depth = %d, want 1", a.Depth)
- }
-
- b := frames[1]
- if b.Name != "B" {
- t.Fatalf("second frame name = %q, want %q", b.Name, "B")
- }
- if !almostEqual(b.X, 80) {
- t.Fatalf("B x = %f, want 80", b.X)
- }
- if !almostEqual(b.Width, 20) {
- t.Fatalf("B width = %f, want 20", b.Width)
- }
-}
-
-func TestBuildFrameLayoutSkipsFramesBelowMinWidth(t *testing.T) {
- tr := newTrie()
- tr.add([]string{"A"}, 999)
- tr.add([]string{"B"}, 1)
- tr.computeTotals()
-
- cfg := defaultSVGConfig()
- cfg.Width = 100
- cfg.FrameHeight = 10
- cfg.FontSize = 10
- cfg.MinWidthPx = 1
-
- frames := BuildFrameLayout(tr, cfg)
- if len(frames) != 1 {
- t.Fatalf("frames len = %d, want 1", len(frames))
- }
- if frames[0].Name != "A" {
- t.Fatalf("remaining frame name = %q, want %q", frames[0].Name, "A")
- }
-}
diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go
deleted file mode 100644
index 71b955e..0000000
--- a/internal/flamegraph/livehtml.go
+++ /dev/null
@@ -1,842 +0,0 @@
-package flamegraph
-
-const liveHTML = `<!doctype html>
-<html lang="en">
-<head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>I/O Flame Graph (Live)</title>
- <style>
- :root {
- --fg-bg: #f6f1ea;
- --fg-panel: #fbf7f1;
- --fg-border: #d8cdc0;
- --fg-text: #232323;
- --fg-muted: #5f5f5f;
- --fg-accent: #7b2d1f;
- --fg-btn: #efe2d2;
- --fg-btn-hover: #e6d5c1;
- --fg-paused: #b02222;
- }
-
- * { box-sizing: border-box; }
-
- body {
- margin: 0;
- min-height: 100vh;
- background: linear-gradient(180deg, #f8f2ea 0%, #f2e9dc 100%);
- color: var(--fg-text);
- font-family: monospace;
- }
-
- #controls {
- position: sticky;
- top: 0;
- z-index: 1;
- display: flex;
- gap: 8px;
- align-items: center;
- flex-wrap: wrap;
- padding: 10px 12px;
- background: var(--fg-panel);
- border-bottom: 1px solid var(--fg-border);
- }
-
- #controls button {
- border: 1px solid var(--fg-border);
- background: var(--fg-btn);
- color: var(--fg-text);
- font: inherit;
- font-size: 12px;
- line-height: 1.2;
- padding: 6px 10px;
- cursor: pointer;
- }
-
- #controls button:hover {
- background: var(--fg-btn-hover);
- }
-
- #controls .order-toggle {
- min-width: 220px;
- text-align: left;
- }
-
- #status {
- margin-left: 8px;
- font-size: 12px;
- color: var(--fg-muted);
- }
-
- .paused #status {
- color: var(--fg-paused);
- font-weight: 700;
- letter-spacing: 0.03em;
- text-transform: uppercase;
- }
-
- #flamegraph {
- display: block;
- width: 100%;
- height: calc(100vh - 56px);
- min-height: calc(100vh - 56px);
- background: transparent;
- }
-
- .title {
- font-size: 14px;
- font-family: monospace;
- }
-
- .controls text {
- font-size: 12px;
- font-family: monospace;
- cursor: pointer;
- fill: #444;
- }
-
- .frame text {
- font-size: 11px;
- font-family: monospace;
- pointer-events: none;
- fill: #111;
- }
-
- .frame rect {
- stroke: rgba(0, 0, 0, 0.18);
- stroke-width: 0.5;
- }
- </style>
-</head>
-<body>
- <div id="controls">
- <button id="btn-pause" type="button">Pause</button>
- <button id="btn-search" type="button">Search</button>
- <button id="btn-reset-search" type="button">Reset Search</button>
- <button id="btn-undo-zoom" type="button">Undo Zoom</button>
- <button id="btn-reset-zoom" type="button">Reset Zoom</button>
- <button id="btn-reset-baseline" type="button">Reset Baseline</button>
- <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > tracepoint > path</button>
- <span id="status">LIVE</span>
- </div>
-
- <svg id="flamegraph" xmlns="http://www.w3.org/2000/svg"></svg>
-
- <script>
- (function () {
- var fg = {
- paused: false,
- resetting: false,
- lastTreeData: null,
- pendingData: null,
- searchQuery: '',
- zoomStack: [],
- zoomRange: null,
- frames: [],
- rootWidth: 0,
- matchColor: 'rgb(220,30,70)',
- eventSource: null,
- svg: document.getElementById('flamegraph'),
- status: document.getElementById('status'),
- pauseBtn: document.getElementById('btn-pause'),
- searchBtn: document.getElementById('btn-search'),
- resetSearchBtn: document.getElementById('btn-reset-search'),
- undoZoomBtn: document.getElementById('btn-undo-zoom'),
- resetZoomBtn: document.getElementById('btn-reset-zoom'),
-