summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/tui-flamegraph-plan.md452
-rw-r--r--internal/tui/common/keys.go14
-rw-r--r--internal/tui/common/keys_test.go20
-rw-r--r--internal/tui/dashboard/model.go44
-rw-r--r--internal/tui/dashboard/model_test.go87
-rw-r--r--internal/tui/dashboard/tabs.go2
-rw-r--r--internal/tui/flamegraph/model.go28
-rw-r--r--internal/tui/flamegraph/renderer.go26
-rw-r--r--internal/tui/flamegraph/renderer_test.go24
-rw-r--r--internal/tui/tui.go8
-rw-r--r--internal/tui/tui_test.go58
11 files changed, 704 insertions, 59 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
+```
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index 6b0ae27..ab9865d 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -38,13 +38,13 @@ func DefaultKeyMap() KeyMap {
return KeyMap{
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")),
ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")),
- One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "overview")),
- Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "syscalls")),
- Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "files")),
- Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "processes")),
- Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "lat+gaps")),
- Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "stream")),
- Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "flame")),
+ One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")),
+ Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")),
+ Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")),
+ Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")),
+ Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")),
+ Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")),
+ Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")),
DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")),
SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")),
SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")),
diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go
index e043f9e..4284faf 100644
--- a/internal/tui/common/keys_test.go
+++ b/internal/tui/common/keys_test.go
@@ -24,8 +24,8 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) {
t.Fatalf("unexpected select tid binding help: key=%q desc=%q", selectTIDHelp.Key, selectTIDHelp.Desc)
}
- flameHelp := keys.Seven.Help()
- if flameHelp.Key != "7" || flameHelp.Desc != "flame" {
+ flameHelp := keys.One.Help()
+ if flameHelp.Key != "1" || flameHelp.Desc != "flame" {
t.Fatalf("unexpected flame binding help: key=%q desc=%q", flameHelp.Key, flameHelp.Desc)
}
}
@@ -38,7 +38,7 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
}
found := false
- foundSeven := false
+ foundOne := false
for _, binding := range groups[1] {
help := binding.Help()
if help.Key == "d" && help.Desc == "dir group" {
@@ -52,12 +52,12 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
for _, binding := range groups[0] {
help := binding.Help()
- if help.Key == "7" && help.Desc == "flame" {
- foundSeven = true
+ if help.Key == "1" && help.Desc == "flame" {
+ foundOne = true
break
}
}
- if !foundSeven {
+ if !foundOne {
t.Fatalf("expected flame tab binding in dashboard full help tabs")
}
@@ -103,7 +103,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
short := keys.DashboardStatusHelp()
found := false
foundSelectTID := false
- foundSeven := false
+ foundOne := false
for _, binding := range short {
help := binding.Help()
if help.Key == "o" && help.Desc == "probes" {
@@ -112,8 +112,8 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
if help.Key == "t" && help.Desc == "select tid" {
foundSelectTID = true
}
- if help.Key == "7" && help.Desc == "flame" {
- foundSeven = true
+ if help.Key == "1" && help.Desc == "flame" {
+ foundOne = true
}
}
if !found {
@@ -122,7 +122,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
if !foundSelectTID {
t.Fatalf("expected select tid binding in dashboard short help")
}
- if !foundSeven {
+ if !foundOne {
t.Fatalf("expected flame tab binding in dashboard short help")
}
}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 7807b31..24b6c8e 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -68,7 +68,7 @@ func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuf
refreshMs = defaultRefreshMs
}
m := Model{
- activeTab: TabOverview,
+ activeTab: TabFlame,
engine: engine,
refreshEvery: time.Duration(refreshMs) * time.Millisecond,
keys: keys,
@@ -157,6 +157,11 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
m.showHelp = !m.showHelp
return m, nil
}
+ if m.activeTab == TabFlame && m.flamegraphModel.ConsumesKey(msg) {
+ next, flameCmd := m.flamegraphModel.Update(msg)
+ m.flamegraphModel = next.(flamegraphtui.Model)
+ return m, flameCmd
+ }
handled, scrollCmd := m.handleScrollKey(msg)
if scrollCmd != nil {
cmd = scrollCmd
@@ -167,32 +172,32 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
if !handled {
switch {
+ case key.Matches(msg, m.keys.One):
+ m.activeTab = TabFlame
+ handled = true
case key.Matches(msg, m.keys.Tab):
m.activeTab = nextTab(m.activeTab)
handled = true
case key.Matches(msg, m.keys.ShiftTab):
m.activeTab = prevTab(m.activeTab)
handled = true
- case key.Matches(msg, m.keys.One):
- m.activeTab = TabOverview
- handled = true
case key.Matches(msg, m.keys.Two):
- m.activeTab = TabSyscalls
+ m.activeTab = TabOverview
handled = true
case key.Matches(msg, m.keys.Three):
- m.activeTab = TabFiles
+ m.activeTab = TabSyscalls
handled = true
case key.Matches(msg, m.keys.Four):
- m.activeTab = TabProcesses
+ m.activeTab = TabFiles
handled = true
case key.Matches(msg, m.keys.Five):
- m.activeTab = TabLatency
+ m.activeTab = TabProcesses
handled = true
case key.Matches(msg, m.keys.Six):
- m.activeTab = TabStream
+ m.activeTab = TabLatency
handled = true
case key.Matches(msg, m.keys.Seven):
- m.activeTab = TabFlame
+ m.activeTab = TabStream
handled = true
case key.Matches(msg, m.keys.Refresh):
snap := m.snapshot()
@@ -206,6 +211,11 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
}
if !handled {
+ if m.activeTab == TabFlame {
+ next, flameCmd := m.flamegraphModel.Update(msg)
+ m.flamegraphModel = next.(flamegraphtui.Model)
+ return m, flameCmd
+ }
return m, nil
}
batch := make([]tea.Cmd, 0, 3)
@@ -317,10 +327,16 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot {
return m.latest
}
-// BlocksGlobalShortcuts reports whether modal UI in the active tab should
-// suppress top-level shortcuts (for example global export key handling).
-func (m Model) BlocksGlobalShortcuts() bool {
- return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible())
+// BlocksGlobalShortcuts reports whether the active tab should suppress a
+// top-level shortcut for the given key press.
+func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool {
+ if m.activeTab == TabStream {
+ return m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible() || m.streamModel.SearchModalVisible()
+ }
+ if m.activeTab == TabFlame {
+ return m.flamegraphModel.ConsumesKey(msg)
+ }
+ return false
}
// SetStreamSource updates the live stream source used by the stream tab.
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 46f4944..6d35d5a 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -31,37 +31,38 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) {
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
model := next.(Model)
- if model.activeTab != TabSyscalls {
- t.Fatalf("expected syscalls tab, got %v", model.activeTab)
+ if model.activeTab != TabOverview {
+ t.Fatalf("expected overview tab on key 2, got %v", model.activeTab)
}
next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab})
model = next.(Model)
- if model.activeTab != TabFiles {
- t.Fatalf("expected next tab to be files, got %v", model.activeTab)
+ if model.activeTab != TabSyscalls {
+ t.Fatalf("expected next tab to be syscalls, got %v", model.activeTab)
}
next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift})
model = next.(Model)
- if model.activeTab != TabSyscalls {
- t.Fatalf("expected previous tab to be syscalls, got %v", model.activeTab)
+ if model.activeTab != TabOverview {
+ t.Fatalf("expected previous tab to be overview, got %v", model.activeTab)
}
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})})
model = next.(Model)
- if model.activeTab != TabFlame {
- t.Fatalf("expected flame tab on key 7, got %v", model.activeTab)
+ if model.activeTab != TabStream {
+ t.Fatalf("expected stream tab on key 7, got %v", model.activeTab)
}
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})})
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'1'}[0], Text: string([]rune{'1'})})
model = next.(Model)
- if model.activeTab != TabStream {
- t.Fatalf("expected stream tab on key 6, got %v", model.activeTab)
+ if model.activeTab != TabFlame {
+ t.Fatalf("expected flame tab on key 1, got %v", model.activeTab)
}
}
func TestArrowAndViKeysDoNotCycleTabs(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabOverview
next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
model := next.(Model)
@@ -303,6 +304,7 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) {
snap := &statsengine.Snapshot{TotalSyscalls: 13}
engine := &fakeSnapshotSource{snap: snap}
m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabOverview
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})})
_ = next
if cmd == nil {
@@ -318,6 +320,63 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) {
}
}
+func TestFlameTabReceivesSlashKey(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFlame
+ m.width = 120
+ m.height = 30
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})})
+ model := next.(Model)
+ if cmd != nil {
+ t.Fatalf("did not expect global command for flame search key")
+ }
+ if !strings.Contains(model.View().Content, "0/0 matches") {
+ t.Fatalf("expected flame search footer after pressing /")
+ }
+}
+
+func TestFlameTabReceivesResetAndPauseKeys(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFlame
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ model := next.(Model)
+ if !strings.Contains(model.View().Content, "[PAUSED]") {
+ t.Fatalf("expected flame pause key to toggle paused state")
+ }
+
+ next, cmd := model.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})})
+ model = next.(Model)
+ if cmd != nil {
+ t.Fatalf("expected flame reset key to be handled by flame tab without global refresh command")
+ }
+ if model.activeTab != TabFlame {
+ t.Fatalf("expected flame tab to stay active after reset key")
+ }
+}
+
+func TestFlameSearchConsumesNumericTabKeys(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFlame
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'/'}[0], Text: string([]rune{'/'})})
+ model := next.(Model)
+ if model.activeTab != TabFlame {
+ t.Fatalf("expected flame tab to stay active after opening search")
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
+ model = next.(Model)
+ if model.activeTab != TabFlame {
+ t.Fatalf("expected numeric key while searching to stay in flame tab")
+ }
+}
+
func TestRefreshTickEmitsStatsTickMsg(t *testing.T) {
snap := &statsengine.Snapshot{TotalSyscalls: 9}
engine := &fakeSnapshotSource{snap: snap}
@@ -386,8 +445,8 @@ func TestStatsTickClampsGroupedFilesOffset(t *testing.T) {
func TestViewRendersTabBarAndHelp(t *testing.T) {
m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap())
out := m.View().Content
- if !strings.Contains(out, "Overview") {
- t.Fatalf("expected overview label in view")
+ if !strings.Contains(out, "Flame") {
+ t.Fatalf("expected flame tab label in view")
}
if !strings.Contains(out, "press H for help") {
t.Fatalf("expected help hint text in view")
@@ -437,7 +496,7 @@ func TestStreamTabViewKeepsTabAndHelpChromeVisible(t *testing.T) {
m.streamModel.Refresh()
out := m.View().Content
- if !strings.Contains(out, "1:Overview") {
+ if !strings.Contains(out, "1:Flame") {
t.Fatalf("expected tab bar to remain visible in stream view")
}
if !strings.Contains(out, "press H for help") {
diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go
index 731e21f..85ce319 100644
--- a/internal/tui/dashboard/tabs.go
+++ b/internal/tui/dashboard/tabs.go
@@ -30,13 +30,13 @@ const (
)
var allTabs = []Tab{
+ TabFlame,
TabOverview,
TabSyscalls,
TabFiles,
TabProcesses,
TabLatency,
TabStream,
- TabFlame,
}
func (t Tab) String() string {
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
index 5f5a83c..5d101c2 100644
--- a/internal/tui/flamegraph/model.go
+++ b/internal/tui/flamegraph/model.go
@@ -208,6 +208,34 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
+// ConsumesKey reports whether the flamegraph should handle a key press before
+// dashboard- or app-level shortcuts.
+func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool {
+ if m.searchActive {
+ return true
+ }
+ switch {
+ case msg.String() == "/",
+ msg.String() == "n",
+ msg.String() == "N",
+ msg.String() == "p",
+ msg.String() == "r",
+ msg.String() == "o",
+ msg.String() == "?":
+ return true
+ case key.Matches(msg, m.keys.ZoomIn),
+ key.Matches(msg, m.keys.ZoomUndo),
+ key.Matches(msg, m.keys.ZoomReset),
+ key.Matches(msg, m.keys.MoveShallower),
+ key.Matches(msg, m.keys.MoveDeeper),
+ key.Matches(msg, m.keys.PrevSibling),
+ key.Matches(msg, m.keys.NextSibling):
+ return true
+ default:
+ return false
+ }
+}
+
// View renders the flamegraph viewport.
func (m Model) View() tea.View {
content := RenderTerminalView(m.frames, m.width, m.height, m.selectedIdx, m.subtreeSet, m.matchIndices, m.isDark, m.searchActive, m.searchQuery)
diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go
index ad74173..9f31023 100644
--- a/internal/tui/flamegraph/renderer.go
+++ b/internal/tui/flamegraph/renderer.go
@@ -102,6 +102,10 @@ func frameName(name string, depth int) string {
}
func terminalFrameColor(name string) color.Color {
+ if semantic, ok := semanticFrameColor(name); ok {
+ return semantic
+ }
+
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(name))
h := hasher.Sum32()
@@ -113,6 +117,28 @@ func terminalFrameColor(name string) color.Color {
}
}
+func semanticFrameColor(name string) (color.Color, bool) {
+ label := strings.ToLower(strings.TrimSpace(name))
+ switch {
+ case label == "":
+ return nil, false
+ case strings.Contains(label, "read"), strings.Contains(label, "pread"):
+ return color.RGBA{R: 78, G: 132, B: 201, A: 255}, true // read I/O: blue
+ case strings.Contains(label, "write"), strings.Contains(label, "pwrite"):
+ return color.RGBA{R: 222, G: 122, B: 58, A: 255}, true // write I/O: orange
+ case strings.Contains(label, "open"), strings.Contains(label, "close"), strings.Contains(label, "stat"), strings.Contains(label, "rename"), strings.Contains(label, "link"):
+ return color.RGBA{R: 196, G: 168, B: 72, A: 255}, true // metadata I/O: amber
+ case strings.HasPrefix(label, "/"), strings.Contains(label, "path:"), strings.Contains(label, "/"):
+ return color.RGBA{R: 88, G: 156, B: 84, A: 255}, true // file paths: green
+ case strings.Contains(label, "pid"), strings.Contains(label, "tid"):
+ return color.RGBA{R: 67, G: 151, B: 149, A: 255}, true // process/thread dimensions: teal
+ case strings.HasPrefix(label, "sys_"):
+ return color.RGBA{R: 191, G: 99, B: 74, A: 255}, true // other syscall buckets: rust
+ default:
+ return nil, false
+ }
+}
+
// RenderTerminalView renders a terminal flamegraph viewport from laid out frames.
func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtreeSet, matchSet map[int]bool, isDark, searchActive bool, searchQuery string) string {
if width < minFlameWidth {
diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go
index ca837fe..eb111b8 100644
--- a/internal/tui/flamegraph/renderer_test.go
+++ b/internal/tui/flamegraph/renderer_test.go
@@ -1,6 +1,7 @@
package flamegraph
import (
+ "image/color"
"strings"
"testing"
)
@@ -108,6 +109,29 @@ func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) {
}
}
+func TestTerminalFrameColorSemanticPalette(t *testing.T) {
+ tests := []struct {
+ name string
+ label string
+ want color.RGBA
+ }{
+ {name: "read", label: "sys_enter_read", want: color.RGBA{R: 78, G: 132, B: 201, A: 255}},
+ {name: "write", label: "sys_enter_write", want: color.RGBA{R: 222, G: 122, B: 58, A: 255}},
+ {name: "metadata", label: "sys_enter_openat", want: color.RGBA{R: 196, G: 168, B: 72, A: 255}},
+ {name: "path", label: "/var/log/app.log", want: color.RGBA{R: 88, G: 156, B: 84, A: 255}},
+ {name: "pid", label: "pid=1234", want: color.RGBA{R: 67, G: 151, B: 149, A: 255}},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := terminalFrameColor(tc.label)
+ if got != tc.want {
+ t.Fatalf("unexpected semantic color for %q: got=%v want=%v", tc.label, got, tc.want)
+ }
+ })
+ }
+}
+
func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) {
out := RenderTerminalView(nil, 50, 10, 0, nil, nil, true, false, "")
if !strings.Contains(out, "terminal too narrow") {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index ab719fb..328202e 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -300,18 +300,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stopTrace()
return m, tea.Quit
}
- if m.exportEnabled && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() {
+ if m.exportEnabled && m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) {
m.exporter = m.exporter.Open()
return m, nil
}
- if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Probes) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() {
+ if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Probes) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) {
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open()
return m, nil
}
- if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectPID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() {
+ if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectPID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) {
return m.reselectPID()
}
- if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectTID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts() {
+ if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.SelectTID) && !m.exporter.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) {
return m.reselectTID()
}
case tuiexport.RequestMsg:
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index c801b24..876fe8f 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -270,7 +270,7 @@ func TestTracingStartedRebindsEventStreamSource(t *testing.T) {
next, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
m = next.(Model)
- next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})})
+ next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})})
m = next.(Model)
next, _ = m.Update(messages.StatsTickMsg{})
m = next.(Model)
@@ -295,6 +295,37 @@ func TestExportKeyOpensModalOnDashboard(t *testing.T) {
}
}
+func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
+ updated := next.(Model)
+ if updated.screen != ScreenDashboard {
+ t.Fatalf("expected flame pause key to keep dashboard screen, got %v", updated.screen)
+ }
+ if !strings.Contains(updated.View().Content, "[PAUSED]") {
+ t.Fatalf("expected flame pause key to toggle flame paused state")
+ }
+}
+
+func TestFlameOrderKeyDoesNotOpenProbeModal(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.width = 120
+ m.height = 30
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'o'}[0], Text: string([]rune{'o'})})
+ updated := next.(Model)
+ if updated.probeModal.Visible() {
+ t.Fatalf("expected flame order key to stay in flame tab, not open probes modal")
+ }
+}
+
func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard
@@ -304,6 +335,9 @@ func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) {
stopped := false
m.traceStop = func() { stopped = true }
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
+ m = next.(Model)
+
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: string([]rune{'p'})})
updated := next.(Model)
@@ -336,6 +370,9 @@ func TestSelectTIDKeyReturnsToPickerWhenPIDFilterIsAll(t *testing.T) {
stopped := false
m.traceStop = func() { stopped = true }
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
+ m = next.(Model)
+
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'t'}[0], Text: string([]rune{'t'})})
updated := next.(Model)
if !stopped {
@@ -361,6 +398,9 @@ func TestSelectTIDKeyReturnsToPickerWhenSinglePIDSelected(t *testing.T) {
stopped := false
m.traceStop = func() { stopped = true }
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
+ m = next.(Model)
+
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'t'}[0], Text: string([]rune{'t'})})
updated := next.(Model)
if !stopped {
@@ -444,7 +484,7 @@ func TestStreamFilterModalConsumesEKeyInsteadOfOpeningExport(t *testing.T) {
m.width = 120
m.height = 30
- next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'6'}[0], Text: string([]rune{'6'})})
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: string([]rune{'7'})})
m = next.(Model)
next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
m = next.(Model)
@@ -586,22 +626,22 @@ func TestDashboardTabKeysChangeActiveView(t *testing.T) {
m.height = 30
out := m.View().Content
- if !strings.Contains(out, "Overview: waiting for stats") {
- t.Fatalf("expected overview waiting view by default")
+ if !strings.Contains(out, "Flame: waiting for data") {
+ t.Fatalf("expected flame waiting view by default")
}
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
updated := next.(Model)
out = updated.View().Content
- if !strings.Contains(out, "Syscalls: waiting for stats") {
- t.Fatalf("expected syscalls waiting view after pressing 2")
+ if !strings.Contains(out, "Overview: waiting for stats") {
+ t.Fatalf("expected overview waiting view after pressing 2")
}
next, _ = updated.Update(tea.KeyPressMsg{Code: tea.KeyTab})
updated = next.(Model)
out = updated.View().Content
- if !strings.Contains(out, "Files: waiting for stats") {
- t.Fatalf("expected files waiting view after tab")
+ if !strings.Contains(out, "Syscalls: waiting for stats") {
+ t.Fatalf("expected syscalls waiting view after tab")
}
}
@@ -619,7 +659,7 @@ func TestProbeModalViewDoesNotStackDashboardContent(t *testing.T) {
if !strings.Contains(out, "Probes (") {
t.Fatalf("expected probe modal content, got %q", out)
}
- if strings.Contains(out, "Overview: waiting for stats") {
+ if strings.Contains(out, "Flame: waiting for data") || strings.Contains(out, "Overview: waiting for stats") {
t.Fatalf("expected probe modal to render as standalone view, got stacked dashboard content")
}
}