summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-05 23:47:19 +0200
committerPaul Buetow <paul@buetow.org>2026-03-05 23:47:19 +0200
commit77310af6f292004fbdd11eaa0bcfeaff812a365d (patch)
tree02c0c242759efa8a9fa2dfc970515bcc6b77bc1a /docs
parentb48fb545191be25e9795e79336c45c439466986c (diff)
Make flame tab default and fix flame hotkey routing
Diffstat (limited to 'docs')
-rw-r--r--docs/tui-flamegraph-plan.md452
1 files changed, 452 insertions, 0 deletions
diff --git a/docs/tui-flamegraph-plan.md b/docs/tui-flamegraph-plan.md
new file mode 100644
index 0000000..67e8653
--- /dev/null
+++ b/docs/tui-flamegraph-plan.md
@@ -0,0 +1,452 @@
+# TUI Flamegraph Tab - Full Design Plan
+
+## Overview
+
+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.
+
+## Architecture
+
+```
+BPF events -> eventLoop.printCb
+ |
+ +-> statsengine.Ingest() (existing tabs 1-5)
+ +-> eventstream.Push() (existing tab 6)
+ +-> LiveTrie.Ingest() (NEW: tab 7)
+```
+
+The `LiveTrie` is instantiated in the TUI startup path and published via
+`runtimeBindings`, similar to how `SnapshotSource` and `RingBuffer` are already
+wired.
+
+## New Files and Packages
+
+| File/Package | Purpose |
+|---|---|
+| `internal/tui/flamegraph/model.go` | Bubble Tea sub-model: state, Update, View |
+| `internal/tui/flamegraph/renderer.go` | Converts LiveTrie snapshot -> terminal frame layout, renders with lipgloss |
+| `internal/tui/flamegraph/animation.go` | Harmonica spring state for frame width interpolation and zoom transitions |
+| `internal/tui/flamegraph/search.go` | Search/highlight: text input bubble, match filtering, highlight styling |
+| `internal/tui/flamegraph/zoom.go` | Zoom stack management (zoom into subtree, undo zoom, reset zoom) |
+| `internal/tui/flamegraph/controls.go` | Toolbar rendering (status line, field order, keybindings help) |
+
+## Detailed Design
+
+### 1. Data Wiring
+
+**Changes to existing files:**
+
+- `internal/tui/tui.go` -- Add `SetLiveTrie(*flamegraph.LiveTrie)` to
+ `TraceRuntimeBindings` interface and `runtimeBindings` struct. The trace starter
+ publishes the `LiveTrie` the same way it publishes the stats engine.
+- `internal/ior.go` / trace setup -- When running in TUI mode, create a `LiveTrie`
+ alongside the stats engine. In the `eventLoop.printCb`, call
+ `liveTrie.Ingest(ep)` in addition to existing stats/stream ingestion. Publish via
+ `bindings.SetLiveTrie(lt)`.
+- `internal/tui/dashboard/model.go` -- Add `liveTrie *flamegraph.LiveTrie` field,
+ `flamegraphModel flamegraphtui.Model` child model. Wire refresh tick to poll
+ `LiveTrie.Version()`.
+
+### 2. Tab Integration
+
+**Changes to `internal/tui/dashboard/tabs.go`:**
+
+```go
+const (
+ // ... existing tabs ...
+ TabFlame // new 7th tab
+)
+
+var allTabs = []Tab{..., TabFlame}
+```
+
+Tab label: `"Flame"` (short: `"Flm"`). Key binding: `7`.
+
+**Changes to `internal/tui/dashboard/model.go`:**
+
+- Add `flamegraphModel` field of type `flamegraphtui.Model`
+- In `Update()`, on `refreshTickMsg` or a dedicated `flameTickMsg` (200ms like
+ stream), poll `LiveTrie.Version()` and push snapshot data into the flamegraph
+ model
+- In `handleKey()`, add `key.Matches(msg, m.keys.Seven)` -> `TabFlame`
+- In `renderActiveTab()`, delegate to `flamegraphModel.View(width, height)`
+- On `WindowSizeMsg`, propagate dimensions to `flamegraphModel.SetViewport(w, h)`
+ -- this triggers re-layout of all frames to fit new terminal size
+
+### 3. Flamegraph TUI Model (`internal/tui/flamegraph/model.go`)
+
+```go
+type Model struct {
+ // Data
+ liveTrie *flamegraph.LiveTrie
+ lastVersion uint64
+ snapshot *flamegraph.trieSnapshot // latest parsed snapshot
+
+ // Layout
+ frames []tuiFrame // current rendered frames
+ targetFrames []tuiFrame // target frames (for animation lerp)
+ width, height int
+
+ // Interaction
+ selectedIdx int // cursor/selected frame index
+ zoomStack []zoomState // zoom history for undo
+ zoomRoot *flamegraph.trieSnapshot // current zoom root (nil = full view)
+
+ // Search
+ searchActive bool
+ searchInput textinput.Model // from bubbles/textinput
+ searchQuery string
+ matchIndices map[int]bool // frame indices matching search
+
+ // Field ordering
+ fieldPresets [][]string
+ fieldIndex int
+
+ // Animation
+ springs []frameSpring // per-frame Harmonica spring state
+ animTicker bool // whether animation tick is running
+
+ // Flags
+ paused bool
+ isDark bool
+}
+
+type tuiFrame struct {
+ Name string
+ Col int // column position (0-based, in terminal cells)
+ Row int // row from bottom
+ Width int // width in terminal cells
+ Total uint64
+ Percent float64
+ Fill lipgloss.Color
+ Depth int
+ Path string // full path for zoom identification
+}
+```
+
+### 4. Rendering Strategy (`internal/tui/flamegraph/renderer.go`)
+
+Terminal flamegraphs use a **cell-based layout** rather than pixel coordinates:
+
+1. **BuildTerminalLayout(snapshot, width, height, zoomRoot)** converts trie snapshot
+ to `[]tuiFrame`:
+ - Width is terminal columns (not 1200px). Each frame width =
+ `floor(termWidth * (node.total / rootTotal))`.
+ - Height is terminal rows. Each frame is exactly **1 row tall** (not 16px).
+ - Rows grow bottom-to-top: root at the bottom, leaves at the top (classic
+ flamegraph orientation). If tree depth exceeds available rows, only show the
+ deepest `height-2` levels (toolbar + status take 2 rows).
+ - Frames narrower than 1 cell are culled (terminal equivalent of `minWidthPx`).
+
+2. **Frame rendering**: Each frame is a **colored block** of text:
+ - 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`)
+
+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
+ assembled by joining frame cells horizontally with background fill for gaps.
+
+4. **Auto-resize**: On `WindowSizeMsg`, re-run `BuildTerminalLayout` with new
+ dimensions. All frame widths and row counts recalculate. Harmonica springs
+ animate from old positions/widths to new ones.
+
+### 5. Subtree Highlighting
+
+When the user selects (navigates to) a frame, the **entire subtree rooted at that
+frame** is visually highlighted so the user can see exactly what would be zoomed
+into on `enter`.
+
+**Visual states for any frame:**
+
+| State | Visual Treatment |
+|---|---|
+| **Selected frame** | Bold text + bright border/underline + slightly lightened background |
+| **Selected subtree** (ancestors + descendants) | Full saturation, normal brightness -- "active" look |
+| **Not in subtree** | **Dimmed**: reduced saturation / lower contrast background, muted text |
+| **Search match** | Red/magenta background overlay (overrides dim but not selection) |
+
+Dimming the *non-subtree* frames makes the selected subtree "pop" naturally.
+
+**Ancestor vs Descendant Distinction:**
+
+| Relationship | Visual |
+|---|---|
+| **Selected frame** | Bold, inverted/bright border |
+| **Descendants** | Full color, normal weight |
+| **Ancestors** | Full color with subtle left-border indicator (breadcrumb trail) |
+| **Unrelated** | Dimmed (lower contrast background, gray text) |
+
+**Subtree membership** computed via `Path` field (the `\x1f`-delimited ancestor
+chain). A frame is in the subtree if:
+- Its path is a **prefix** of the selected frame's path (ancestor), OR
+- The selected frame's path is a **prefix** of its path (descendant), OR
+- It **is** the selected frame
+
+O(n) scan over frames, recomputed each time selection moves.
+
+**Interaction with search**: Search matches outside subtree shown dimmed in match
+color; inside subtree shown bright. Selected frame matching search uses selection
+style.
+
+### 6. Animation with Harmonica (`internal/tui/flamegraph/animation.go`)
+
+```go
+type frameSpring struct {
+ widthSpring harmonica.Spring
+ colSpring harmonica.Spring
+ currentW float64
+ currentCol float64
+ velocityW float64
+ velocityCol float64
+}
+```
+
+**Two animation scenarios:**
+
+1. **Data refresh**: When `LiveTrie` version changes and new frame widths differ
+ from current, set new target widths. On each animation tick (~30fps =
+ `tea.Tick(33ms)`), call `spring.Update(current, velocity, target)` for each
+ frame's width and column. Render at interpolated values. Stop animation tick
+ when all frames reach target within epsilon.
+
+2. **Zoom transition**: When user zooms into a subtree, the target layout changes
+ (zoomed subtree expands to fill full width). Springs animate column positions
+ and widths from pre-zoom to post-zoom. Undo-zoom reverses this.
+
+**Spring configuration**: `harmonica.NewSpring(harmonica.FPS(30), 6.0, 1.0)` --
+critically damped for snappy transitions without oscillation.
+
+### 7. Keybindings
+
+| Key(s) | Action |
+|---|---|
+| `j` / `down` / `arrow-down` | Move selection to frame below (shallower depth) |
+| `k` / `up` / `arrow-up` | Move selection to frame above (deeper / toward leaves) |
+| `h` / `left` / `arrow-left` | Move selection to previous sibling at same depth |
+| `l` / `right` / `arrow-right` | Move selection to next sibling at same depth |
+| `enter` | Zoom into selected frame's subtree |
+| `backspace` / `u` | Undo zoom (pop zoom stack) |
+| `escape` (when zoomed) | Reset zoom to root |
+| `/` | Open search input |
+| `escape` (when searching) | Close search, clear highlights |
+| `n` | Jump to next search match |
+| `N` (shift+n) | Jump to previous search match |
+| `p` | Toggle pause |
+| `r` | Reset baseline |
+| `o` | Cycle field order preset |
+| `?` | Toggle flame-specific help overlay |
+
+Both vim-style (j/k/h/l) and regular cursor keys (arrow keys) are bound to the
+same actions via `key.NewBinding(key.WithKeys("j", "down"))`.
+
+### 8. Search (`internal/tui/flamegraph/search.go`)
+
+- Uses `bubbles/textinput` for inline search input at the bottom of flame view
+- On submit, iterate frames and mark matching indices (case-insensitive substring
+ match on frame name)
+- Matching frames rendered with highlight color; non-matching frames dimmed
+- `n`/`N` moves selection to next/previous match
+- Show match count in status line (e.g. `3/12 matches`)
+
+### 8.1 Color Coding (Implemented)
+
+Flame frames now use semantic colors first, with hash-based fallback for unknown labels:
+
+| Category | Match rule | Color (RGBA / hex) |
+|---|---|---|
+| Read I/O | name contains `read`/`pread` | `78,132,201` (`#4E84C9`) |
+| Write I/O | name contains `write`/`pwrite` | `222,122,58` (`#DE7A3A`) |
+| Metadata I/O | name contains `open`, `close`, `stat`, `rename`, `link` | `196,168,72` (`#C4A848`) |
+| Path-oriented nodes | starts with `/`, contains `/`, or `path:` | `88,156,84` (`#589C54`) |
+| Process/thread labels | contains `pid` or `tid` | `67,151,149` (`#439795`) |
+| Other syscall buckets | starts with `sys_` | `191,99,74` (`#BF634A`) |
+| Fallback | anything else | deterministic hash palette |
+
+This keeps common I/O classes visually stable across refreshes while preserving
+distinct colors for uncategorized frames.
+
+### 9. Zoom (`internal/tui/flamegraph/zoom.go`)
+
+- `zoomStack []zoomState` where `zoomState` holds the `path` string of the zoomed
+ node and the previous `selectedIdx`
+- On zoom-in: push current state, find subtree node matching selected frame's path,
+ set as `zoomRoot`, rebuild layout with subtree as root
+- On undo: pop stack, restore previous root
+- On reset: clear stack, set `zoomRoot = nil`
+
+### 10. Field Order Cycling
+
+Same preset cycle as the web version:
+```go
+fieldPresets = [][]string{
+ {"comm", "path", "tracepoint"},
+ {"path", "tracepoint", "comm"},
+ {"tracepoint", "comm", "path"},
+ {"pid", "path", "tracepoint"},
+}
+```
+
+Pressing `o` calls `LiveTrie.Reconfigure(nextPreset)` which resets the trie and
+starts fresh accumulation.
+
+### 11. Toolbar / Status Line (`internal/tui/flamegraph/controls.go`)
+
+Rendered as a single line above the flamegraph area:
+
+```
+[LIVE] | o:order(comm>path>tp) | /:search | enter:zoom | u:undo | r:reset | p:pause
+```
+
+When paused: `[PAUSED]` in red. When searching: shows search input and match count.
+
+Selected frame info line at the bottom:
+```
+sys_read (1,234 calls, 45.2%) - /usr/bin/myapp > /dev/sda > sys_enter_read
+```
+
+### 12. Dependencies to Add
+
+- `github.com/charmbracelet/harmonica` -- spring animation
+- `charm.land/bubbles/v2/textinput` -- search input (already transitive via bubbles v2)
+
+### 13. Changes to Existing Files (Summary)
+
+| File | Change |
+|---|---|
+| `internal/tui/dashboard/tabs.go` | Add `TabFlame`, update `allTabs`, `String()`, `tabLabel()` |
+| `internal/tui/dashboard/model.go` | Add `flamegraphModel` field, wire refresh, handle key `7`, render in `renderActiveTab()` |
+| `internal/tui/tui.go` | Add `SetLiveTrie()` to bindings interface, propagate to dashboard |
+| `internal/tui/common/keys.go` | Add `Seven` key binding for tab 7 |
+| `internal/ior.go` | Create `LiveTrie` in TUI mode, wire into eventLoop callback, publish via bindings |
+| `internal/flags/flags.go` | Add `-fields` default propagation to TUI mode |
+| `go.mod` | Add `github.com/charmbracelet/harmonica` dependency |
+
+### 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.
+
+2. **Terminal width too narrow**: Flamegraphs with many shallow frames may not
+ render meaningfully in 80-column terminals. Mitigation: cull frames below 1 cell,
+ show "terminal too narrow" message below ~60 columns.
+
+3. **Animation frame budget**: 30fps animation ticks in a terminal could cause
+ flicker on slow terminals. Mitigation: only run animation tick when springs are
+ active, stop when settled.
+
+4. **Color support**: Not all terminals support 24-bit color. Mitigation: lipgloss
+ v2 auto-downgrades. The warm flamegraph palette degrades gracefully to 256-color.
+
+---
+
+## Benchmarking & Profiling Plan
+
+### Goals
+
+1. Quantify render performance at various terminal sizes and trie depths
+2. Measure animation overhead of Harmonica spring ticks at 30fps with N springs
+3. Detect regressions via baseline benchmarks running in CI alongside `mage bench`
+4. Profile hot paths to identify allocations and CPU bottlenecks
+
+### Benchmark Suite
+
+New file: `internal/tui/flamegraph/bench_test.go`
+
+| Benchmark | What it measures |
+|---|---|
+| `BenchmarkBuildTerminalLayout` | trieSnapshot -> []tuiFrame at widths 80/120/200/300 and depths 10/50/100 |
+| `BenchmarkRenderFrame` | Full View() render at 80x24, 120x40, 200x60 |
+| `BenchmarkComputeSubtreeSet` | Subtree membership with 100/1000/5000 frames |
+| `BenchmarkSearchHighlight` | Search match computation across N frames |
+| `BenchmarkSpringUpdate` | harmonica spring.Update() across 100/500/2000 springs |
+| `BenchmarkAnimationTick` | Full tick: update springs + rebuild render output |
+| `BenchmarkZoomTransition` | Layout rebuild on zoom-in |
+| `BenchmarkLiveTrieIngestAndSnapshot` | End-to-end: ingest N events + snapshot + layout |
+| `BenchmarkResizeRelayout` | Layout rebuild at new terminal dimensions |
+
+### Benchmark Fixtures
+
+Synthetic trie generators in `internal/tui/flamegraph/testdata_test.go`:
+
+| Label | Depth | Breadth | Approximate frame count |
+|---|---|---|---|
+| `small` | 5 | 3 | ~120 |
+| `medium` | 10 | 5 | ~2,500 |
+| `large` | 15 | 8 | ~10,000+ |
+| `deep` | 50 | 2 | ~100 (narrow but deep) |
+| `wide` | 3 | 50 | ~5,000 (shallow but very wide) |
+
+### Performance Targets
+
+| Operation | Target | Rationale |
+|---|---|---|
+| `BuildTerminalLayout` (medium, 120-col) | < 500us | Well within one tick interval |
+| `View()` full render (medium, 120x40) | < 2ms | 30fps = 33ms budget |
+| `ComputeSubtreeSet` (1000 frames) | < 100us | Runs on every selection move |
+| Single animation tick (500 springs) | < 1ms | 16ms frame budget headroom |
+| `LiveTrie.Ingest` + `SnapshotJSON` | < 200us | Hot path performance |
+
+### Profiling Integration
+
+#### Built-in profiling flag
+
+When `-pprof` is set in TUI mode:
+- Write `ior-tui-cpu.prof` during session
+- Write `ior-tui-mem.prof` on quit
+- Write `ior-tui-trace.out` for first 10 seconds
+
+#### Mage Targets
+
+| Target | Command |
+|---|---|
+| `mage benchFlame` | `go test ./internal/tui/flamegraph/ -bench=. -benchmem -count=5` |
+| `mage benchFlameProf` | Same + `-cpuprofile` + `-memprofile` |
+| `mage benchFlameCmp` | Compare against saved baseline via `benchstat` |
+
+### Allocation Targets
+
+| Hot path | Strategy |
+|---|---|
+| `BuildTerminalLayout` | Pre-allocate []tuiFrame, reuse across refreshes |
+| `View()` render | strings.Builder with pre-estimated capacity, cache styles |
+| `computeSubtreeSet` | Reuse map[int]bool (clear + repopulate) |
+| Spring updates | Fixed-size []frameSpring, no per-tick allocs |
+
+Target: **zero allocs** in animation tick, **< 5 allocs/op** in full render.
+
+### Stress Tests
+
+New file: `internal/tui/flamegraph/stress_test.go`
+
+- **TestStressHighEventRate**: 100k events from 10 goroutines + concurrent render
+- **TestStressRapidResize**: 100 WindowSizeMsg with random dimensions
+- **TestStressZoomDuringRefresh**: Interleaved zoom/undo with data refresh ticks
+
+All run with `-race`.
+
+### Profiling Workflow (Manual)
+
+```bash
+# Run TUI with profiling
+sudo ior -pprof -pid 1234
+
+# Analyze CPU profile
+go tool pprof -http=:8080 ior-tui-cpu.prof
+
+# Analyze allocations
+go tool pprof -http=:8080 -alloc_space ior-tui-mem.prof
+
+# Analyze execution trace
+go tool trace ior-tui-trace.out
+
+# Benchmark-specific profiling
+mage benchFlameProf
+go tool pprof -http=:8080 flame-cpu.prof
+```