summaryrefslogtreecommitdiff
path: root/internal/tui/flamegraph
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/flamegraph')
-rw-r--r--internal/tui/flamegraph/animation.go145
-rw-r--r--internal/tui/flamegraph/animation_test.go50
-rw-r--r--internal/tui/flamegraph/bench_test.go401
-rw-r--r--internal/tui/flamegraph/controls.go173
-rw-r--r--internal/tui/flamegraph/doc.go2
-rw-r--r--internal/tui/flamegraph/model.go1027
-rw-r--r--internal/tui/flamegraph/model_test.go987
-rw-r--r--internal/tui/flamegraph/renderer.go708
-rw-r--r--internal/tui/flamegraph/renderer_test.go368
-rw-r--r--internal/tui/flamegraph/search.go141
-rw-r--r--internal/tui/flamegraph/stress_race_disabled_test.go7
-rw-r--r--internal/tui/flamegraph/stress_race_enabled_test.go7
-rw-r--r--internal/tui/flamegraph/stress_test.go236
-rw-r--r--internal/tui/flamegraph/testdata_fixture_test.go39
-rw-r--r--internal/tui/flamegraph/testdata_test.go185
-rw-r--r--internal/tui/flamegraph/zoom.go39
16 files changed, 4515 insertions, 0 deletions
diff --git a/internal/tui/flamegraph/animation.go b/internal/tui/flamegraph/animation.go
new file mode 100644
index 0000000..103d43b
--- /dev/null
+++ b/internal/tui/flamegraph/animation.go
@@ -0,0 +1,145 @@
+package flamegraph
+
+import (
+ "math"
+
+ "github.com/charmbracelet/harmonica"
+)
+
+const springEpsilon = 0.01
+
+type frameSpring struct {
+ path string
+ base tuiFrame
+ widthSpring harmonica.Spring
+ colSpring harmonica.Spring
+
+ currentW float64
+ currentCol float64
+ velocityW float64
+ velocityCol float64
+
+ targetW float64
+ targetCol float64
+}
+
+// AnimationState stores per-frame spring interpolation state.
+type AnimationState struct {
+ springs []frameSpring
+ frames []tuiFrame
+ settled bool
+
+ fps int
+ angularVelocity float64
+ damping float64
+}
+
+// NewAnimationState builds a spring animation state with the provided parameters.
+func NewAnimationState(fps int, angularVelocity, damping float64) AnimationState {
+ if fps <= 0 {
+ fps = 30
+ }
+ return AnimationState{
+ fps: fps,
+ angularVelocity: angularVelocity,
+ damping: damping,
+ settled: true,
+ }
+}
+
+// SetTargets sets new frame targets, preserving spring motion for matching paths.
+func (a *AnimationState) SetTargets(targets []tuiFrame) {
+ existing := make(map[string]frameSpring, len(a.springs))
+ for _, spring := range a.springs {
+ existing[spring.path] = spring
+ }
+
+ next := make([]frameSpring, 0, len(targets))
+ for _, target := range targets {
+ spring, ok := existing[target.Path]
+ if !ok {
+ spring = frameSpring{
+ path: target.Path,
+ currentW: float64(target.Width),
+ currentCol: float64(target.Col),
+ }
+ }
+ spring.base = target
+ spring.targetW = float64(target.Width)
+ spring.targetCol = float64(target.Col)
+ spring.widthSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
+ spring.colSpring = harmonica.NewSpring(harmonica.FPS(a.fps), a.angularVelocity, a.damping)
+ next = append(next, spring)
+ }
+ a.springs = next
+ if cap(a.frames) < len(a.springs) {
+ a.frames = make([]tuiFrame, len(a.springs))
+ } else {
+ a.frames = a.frames[:len(a.springs)]
+ }
+ a.settled = len(a.springs) == 0
+ for _, spring := range a.springs {
+ if !isSpringSettled(spring) {
+ a.settled = false
+ break
+ }
+ }
+}
+
+// Tick advances springs by delta seconds and returns true while animation is active.
+func (a *AnimationState) Tick(delta float64) bool {
+ if len(a.springs) == 0 {
+ a.settled = true
+ return false
+ }
+ baseDelta := harmonica.FPS(a.fps)
+ if delta <= 0 {
+ delta = baseDelta
+ }
+
+ active := false
+ for idx := range a.springs {
+ spring := &a.springs[idx]
+ if delta != baseDelta {
+ spring.widthSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
+ spring.colSpring = harmonica.NewSpring(delta, a.angularVelocity, a.damping)
+ }
+ spring.currentW, spring.velocityW = spring.widthSpring.Update(spring.currentW, spring.velocityW, spring.targetW)
+ spring.currentCol, spring.velocityCol = spring.colSpring.Update(spring.currentCol, spring.velocityCol, spring.targetCol)
+ if !isSpringSettled(*spring) {
+ active = true
+ }
+ }
+ a.settled = !active
+ return active
+}
+
+// CurrentFrames returns interpolated frames for the current animation step.
+func (a *AnimationState) CurrentFrames() []tuiFrame {
+ for idx, spring := range a.springs {
+ frame := spring.base
+ frame.Col = maxInt(0, int(math.Round(spring.currentCol)))
+ frame.Width = maxInt(1, int(math.Round(spring.currentW)))
+ a.frames[idx] = frame
+ }
+ return a.frames
+}
+
+// Settled reports whether all active springs are at rest.
+func (a AnimationState) Settled() bool {
+ return a.settled
+}
+
+func isSpringSettled(s frameSpring) bool {
+ return math.Abs(s.currentW-s.targetW) < springEpsilon &&
+ math.Abs(s.currentCol-s.targetCol) < springEpsilon &&
+ math.Abs(s.velocityW) < springEpsilon &&
+ math.Abs(s.velocityCol) < springEpsilon
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
diff --git a/internal/tui/flamegraph/animation_test.go b/internal/tui/flamegraph/animation_test.go
new file mode 100644
index 0000000..94272e2
--- /dev/null
+++ b/internal/tui/flamegraph/animation_test.go
@@ -0,0 +1,50 @@
+package flamegraph
+
+import "testing"
+
+func TestAnimationStateConvergesToTarget(t *testing.T) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ state.SetTargets([]tuiFrame{{Path: "root", Col: 0, Width: 10}})
+ state.SetTargets([]tuiFrame{{Path: "root", Col: 100, Width: 50}})
+
+ active := true
+ for i := 0; i < 180 && active; i++ {
+ active = state.Tick(0)
+ }
+ if active {
+ t.Fatalf("expected springs to settle within 180 ticks")
+ }
+
+ frames := state.CurrentFrames()
+ if len(frames) != 1 {
+ t.Fatalf("expected one interpolated frame, got %d", len(frames))
+ }
+ if frames[0].Col != 100 || frames[0].Width != 50 {
+ t.Fatalf("expected settled frame at col=100 width=50, got col=%d width=%d", frames[0].Col, frames[0].Width)
+ }
+ if state.Tick(0) {
+ t.Fatalf("expected settled animation to remain inactive")
+ }
+}
+
+func TestAnimationStateHandlesAddedAndRemovedFrames(t *testing.T) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ state.SetTargets([]tuiFrame{
+ {Path: "root", Col: 0, Width: 20},
+ {Path: "root\x1fchild", Col: 20, Width: 20},
+ })
+ if got := len(state.CurrentFrames()); got != 2 {
+ t.Fatalf("expected 2 frames after initial targets, got %d", got)
+ }
+
+ state.SetTargets([]tuiFrame{
+ {Path: "root\x1fchild", Col: 40, Width: 30},
+ })
+ frames := state.CurrentFrames()
+ if len(frames) != 1 {
+ t.Fatalf("expected removed frame to be dropped, got %d frames", len(frames))
+ }
+ if frames[0].Path != "root\x1fchild" {
+ t.Fatalf("expected remaining frame path root\\x1fchild, got %q", frames[0].Path)
+ }
+}
diff --git a/internal/tui/flamegraph/bench_test.go b/internal/tui/flamegraph/bench_test.go
new file mode 100644
index 0000000..33d77d1
--- /dev/null
+++ b/internal/tui/flamegraph/bench_test.go
@@ -0,0 +1,401 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ coreflamegraph "ior/internal/flamegraph"
+ "ior/internal/types"
+
+ "github.com/charmbracelet/harmonica"
+)
+
+var (
+ benchFramesSink []tuiFrame
+ benchStringSink string
+ benchIntSink int
+ benchFloatSink float64
+)
+
+func BenchmarkBuildTerminalLayout(b *testing.B) {
+ // Performance target: medium_120col should remain below 500us/op.
+ fixtures := []struct {
+ label string
+ depth int
+ breadth int
+ }{
+ {label: "small", depth: fixtureSmallDepth, breadth: fixtureSmallBreadth},
+ {label: "medium", depth: fixtureMediumDepth, breadth: fixtureMediumBreadth},
+ {label: "large", depth: fixtureLargeDepth, breadth: fixtureLargeBreadth},
+ {label: "deep", depth: fixtureDeepDepth, breadth: fixtureDeepBreadth},
+ {label: "wide", depth: fixtureWideDepth, breadth: fixtureWideBreadth},
+ }
+ widths := []int{80, 120, 200, 300}
+ const height = 40
+
+ snapshots := make(map[string]*snapshotNode, len(fixtures))
+ for _, fixture := range fixtures {
+ snapshots[fixture.label] = generateTestSnapshot(fixture.depth, fixture.breadth)
+ }
+
+ for _, fixture := range fixtures {
+ snapshot := snapshots[fixture.label]
+ for _, width := range widths {
+ name := fmt.Sprintf("%s_%dcol", fixture.label, width)
+ b.Run(name, func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchFramesSink = BuildTerminalLayout(snapshot, width, height)
+ }
+ if len(benchFramesSink) == 0 {
+ b.Fatal("layout returned no frames")
+ }
+ })
+ }
+ }
+}
+
+func BenchmarkRenderFrame(b *testing.B) {
+ // Performance target: medium_120x40 should remain below 2ms/op.
+ // Allocation target: run with -benchmem and keep render path below 5 allocs/op.
+ fixtures := []struct {
+ label string
+ snapshot *snapshotNode
+ }{
+ {label: "medium", snapshot: generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)},
+ {label: "large", snapshot: generateTestSnapshot(fixtureLargeDepth, fixtureLargeBreadth)},
+ }
+ viewports := []struct {
+ width int
+ height int
+ }{
+ {width: 80, height: 24},
+ {width: 120, height: 40},
+ {width: 200, height: 60},
+ }
+
+ for _, fixture := range fixtures {
+ for _, viewport := range viewports {
+ name := fmt.Sprintf("%s_%dx%d", fixture.label, viewport.width, viewport.height)
+ b.Run(name, func(b *testing.B) {
+ model := NewModel(nil)
+ model.width = viewport.width
+ model.height = viewport.height
+ model.snapshot = fixture.snapshot
+ model.rebuildFrames(false)
+ if len(model.frames) == 0 {
+ b.Fatal("render benchmark requires non-empty frame layout")
+ }
+
+ for idx := range model.frames {
+ switch idx % 12 {
+ case 0:
+ model.frames[idx].Name = "sys_enter_read"
+ case 1:
+ model.frames[idx].Name = "sys_enter_write"
+ }
+ }
+ model.selectedIdx = midDepthFrameIndex(model.frames)
+ model.subtreeSet = computeSubtreeSetInto(model.frames, model.selectedIdx, model.subtreeSet)
+ model.applySearchQuery("sys_")
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchStringSink = model.View().Content
+ }
+ })
+ }
+ }
+}
+
+func BenchmarkComputeSubtreeSet(b *testing.B) {
+ // Performance target: 1000-frame subtree membership should remain below 100us/op.
+ // Allocation target: zero allocs/op by reusing map storage.
+ cases := []struct {
+ label string
+ frameCount int
+ }{
+ {label: "100frames", frameCount: 100},
+ {label: "1000frames", frameCount: 1000},
+ {label: "5000frames", frameCount: 5000},
+ }
+
+ for _, tc := range cases {
+ frames := benchmarkFramesForCount(tc.frameCount)
+ if len(frames) == 0 {
+ b.Fatalf("%s produced no frames", tc.label)
+ }
+ selectedIdx := midDepthFrameIndex(frames)
+ reuse := make(map[int]bool, len(frames))
+
+ b.Run(tc.label, func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ subtree := computeSubtreeSetInto(frames, selectedIdx, reuse)
+ benchIntSink = len(subtree)
+ }
+ })
+ }
+}
+
+func BenchmarkSearchHighlight(b *testing.B) {
+ // Performance target: 1000-frame search should remain below 200us/op.
+ cases := []struct {
+ label string
+ frameCount int
+ }{
+ {label: "100frames", frameCount: 100},
+ {label: "1000frames", frameCount: 1000},
+ {label: "5000frames", frameCount: 5000},
+ }
+ queries := []string{"read", "sys_", "/srv/app"}
+
+ for _, tc := range cases {
+ frames := benchmarkFramesForCount(tc.frameCount)
+ if len(frames) == 0 {
+ b.Fatalf("%s produced no frames", tc.label)
+ }
+ decorateFramesForSearch(frames)
+
+ model := NewModel(nil)
+ model.frames = frames
+ model.selectedIdx = midDepthFrameIndex(frames)
+ model.subtreeSet = computeSubtreeSetInto(model.frames, model.selectedIdx, model.subtreeSet)
+
+ b.Run(tc.label, func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ model.applySearchQuery(queries[i%len(queries)])
+ benchIntSink = len(model.matchIndices)
+ }
+ })
+ }
+}
+
+func BenchmarkSpringUpdate(b *testing.B) {
+ // Performance target: 500 active springs should update in < 1ms per tick.
+ counts := []int{100, 500, 2000}
+ const (
+ angularVelocity = 6.0
+ damping = 1.0
+ )
+
+ for _, count := range counts {
+ b.Run(fmt.Sprintf("%d_springs", count), func(b *testing.B) {
+ springs := make([]harmonica.Spring, count)
+ current := make([]float64, count)
+ velocity := make([]float64, count)
+ target := make([]float64, count)
+
+ for idx := range springs {
+ springs[idx] = harmonica.NewSpring(harmonica.FPS(30), angularVelocity, damping)
+ current[idx] = float64(idx)
+ target[idx] = float64(idx + 8)
+ }
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ for idx := range springs {
+ current[idx], velocity[idx] = springs[idx].Update(current[idx], velocity[idx], target[idx])
+ }
+ benchFloatSink = current[count-1]
+ }
+ })
+ }
+}
+
+func BenchmarkAnimationTick(b *testing.B) {
+ // Performance target: 500 animated frames should complete in < 1ms per tick.
+ // Allocation target: zero allocs/op in the tick + CurrentFrames path.
+ counts := []int{100, 500, 2000}
+
+ for _, count := range counts {
+ b.Run(fmt.Sprintf("%d_frames", count), func(b *testing.B) {
+ state := NewAnimationState(30, 6.0, 1.0)
+ base := linearFrames(count, 0, 10)
+ target := linearFrames(count, 5, 20)
+ state.SetTargets(base)
+ state.SetTargets(target)
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ if !state.Tick(0) {
+ for idx := range state.springs {
+ state.springs[idx].targetCol += 3
+ state.springs[idx].targetW += 2
+ }
+ state.settled = false
+ }
+ frames := state.CurrentFrames()
+ benchIntSink = frames[len(frames)-1].Width
+ }
+ })
+ }
+}
+
+func BenchmarkZoomTransition(b *testing.B) {
+ // Performance target: zoom-in transition should stay below 1ms/op.
+ snapshot := generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)
+ model := NewModel(nil)
+ model.width = 120
+ model.height = 40
+ model.snapshot = snapshot
+ model.rebuildFrames(false)
+ if len(model.frames) == 0 {
+ b.Fatal("zoom benchmark requires non-empty initial layout")
+ }
+ zoomPath := model.frames[midDepthFrameIndex(model.frames)].Path
+
+ b.Run("zoom_in", func(b *testing.B) {
+ benchModel := model
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchModel.zoomReset()
+ benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath)
+ benchModel.zoomIn()
+ benchIntSink = len(benchModel.targetFrames)
+ }
+ })
+
+ b.Run("undo_zoom", func(b *testing.B) {
+ benchModel := model
+ benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath)
+ benchModel.zoomIn()
+ if len(benchModel.zoomStack) == 0 {
+ b.Fatal("undo benchmark requires an active zoom stack")
+ }
+
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ benchModel.zoomUndo()
+ benchIntSink = len(benchModel.frames)
+
+ benchModel.selectedIdx = frameIndexByPath(benchModel.frames, zoomPath)
+ benchModel.zoomIn()
+ }
+ })
+}
+
+func BenchmarkLiveTrieIngestAndSnapshot(b *testing.B) {
+ // Performance target: ingest+snapshot pipeline should remain below 200us/op for small/medium cycles.
+ counts := []int{100, 1000, 10000}
+ for _, count := range counts {
+ b.Run(fmt.Sprintf("%d_events", count), func(b *testing.B) {
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count")
+ for eventIdx := 0; eventIdx < count; eventIdx++ {
+ traceID := types.SYS_ENTER_READ
+ if eventIdx%2 == 0 {
+ traceID = types.SYS_ENTER_WRITE
+ }
+ pair := newBenchmarkPair(
+ fmt.Sprintf("worker-%d", eventIdx%4),
+ traceID,
+ uint32(1000+(eventIdx%64)),
+ uint32(200000+eventIdx),
+ buildBenchmarkPath(8, 6, eventIdx),
+ )
+ liveTrie.Ingest(pair)
+ pair.Recycle()
+ }
+
+ payload, _ := liveTrie.SnapshotJSON()
+ var snapshot snapshotNode
+ if err := json.Unmarshal(payload, &snapshot); err != nil {
+ b.Fatalf("snapshot decode failed: %v", err)
+ }
+ benchFramesSink = BuildTerminalLayout(&snapshot, 120, 40)
+ }
+ })
+ }
+}
+
+func BenchmarkResizeRelayout(b *testing.B) {
+ // Performance target: resize relayout cost should match BuildTerminalLayout (< 500us medium@120col).
+ snapshot := generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth)
+ b.ReportAllocs()
+ for i := 0; i < b.N; i++ {
+ frames120 := BuildTerminalLayout(snapshot, 120, 40)
+ frames80 := BuildTerminalLayout(snapshot, 80, 24)
+ benchFramesSink = BuildTerminalLayout(snapshot, 120, 40)
+ benchIntSink = len(frames120) + len(frames80) + len(benchFramesSink)
+ }
+}
+
+func benchmarkFramesForCount(frameCount int) []tuiFrame {
+ var snapshot *snapshotNode
+ switch frameCount {
+ case 100:
+ snapshot = generateTestSnapshot(fixtureDeepDepth, fixtureDeepBreadth)
+ case 1000:
+ snapshot = generateTestSnapshot(20, 5)
+ case 5000:
+ snapshot = generateTestSnapshot(fixtureWideDepth, fixtureWideBreadth)
+ default:
+ snapshot = generateTestSnapshot(10, 5)
+ }
+ return BuildTerminalLayout(snapshot, 200, 80)
+}
+
+func decorateFramesForSearch(frames []tuiFrame) {
+ for idx := range frames {
+ switch idx % 6 {
+ case 0:
+ frames[idx].Name = "sys_enter_read"
+ case 1:
+ frames[idx].Name = "sys_enter_write"
+ case 2:
+ frames[idx].Name = "read_cache_buffer"
+ case 3:
+ frames[idx].Name = "path:/srv/app/api"
+ case 4:
+ frames[idx].Name = "worker_loop"
+ default:
+ frames[idx].Name = "io_wait"
+ }
+ }
+}
+
+func midDepthFrameIndex(frames []tuiFrame) int {
+ if len(frames) == 0 {
+ return 0
+ }
+ maxDepth := 0
+ for _, frame := range frames {
+ if frame.Depth > maxDepth {
+ maxDepth = frame.Depth
+ }
+ }
+ targetDepth := maxDepth / 2
+ indices := framesAtDepth(frames, targetDepth)
+ if len(indices) == 0 {
+ return len(frames) / 2
+ }
+ return indices[len(indices)/2]
+}
+
+func frameIndexByPath(frames []tuiFrame, path string) int {
+ for idx, frame := range frames {
+ if frame.Path == path {
+ return idx
+ }
+ }
+ return 0
+}
+
+func linearFrames(count, colOffset, width int) []tuiFrame {
+ frames := make([]tuiFrame, count)
+ for idx := 0; idx < count; idx++ {
+ path := fmt.Sprintf("root%snode-%d", pathSeparator, idx)
+ frames[idx] = tuiFrame{
+ Name: fmt.Sprintf("node-%d", idx),
+ Path: path,
+ Col: colOffset + idx,
+ Row: idx % 8,
+ Width: width,
+ }
+ }
+ return frames
+}
diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go
new file mode 100644
index 0000000..06e6d0d
--- /dev/null
+++ b/internal/tui/flamegraph/controls.go
@@ -0,0 +1,173 @@
+package flamegraph
+
+import (
+ "fmt"
+ "strings"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/lipgloss/v2"
+)
+
+func (m *Model) togglePause() {
+ m.paused = !m.paused
+}
+
+func (m *Model) clearSnapshotState(clearSearch bool) {
+ m.zoomRoot = nil
+ m.zoomPath = ""
+ m.zoomStack = nil
+ m.selectedIdx = 0
+ m.snapshot = nil
+ m.globalTotal = 0
+ m.frames = nil
+ m.targetFrames = nil
+ m.matchIndices = make(map[int]bool)
+ m.filterVisible = make(map[int]bool)
+ m.subtreeSet = make(map[int]bool)
+ m.hasNavigableSnapshot = false
+ if clearSearch {
+ m.searchQuery = ""
+ }
+}
+
+func (m *Model) resetBaseline() {
+ if m.liveTrie != nil {
+ m.liveTrie.Reset()
+ }
+ m.clearSnapshotState(true)
+ m.statusMessage = "Baseline reset"
+}
+
+func (m *Model) cycleFieldOrder() {
+ if len(m.fieldPresets) == 0 {
+ return
+ }
+ m.fieldIndex = (m.fieldIndex + 1) % len(m.fieldPresets)
+ nextPreset := m.fieldPresets[m.fieldIndex]
+ if m.liveTrie != nil {
+ if err := m.liveTrie.Reconfigure(nextPreset); err != nil {
+ m.statusMessage = "Field order error: " + err.Error()
+ return
+ }
+ }
+ m.clearSnapshotState(false)
+ m.statusMessage = "Order: " + strings.Join(nextPreset, "/")
+}
+
+func (m *Model) toggleCountField() {
+ next := "bytes"
+ if m.countField == "bytes" {
+ next = "count"
+ }
+ if m.liveTrie != nil {
+ if err := m.liveTrie.SetCountField(next); err != nil {
+ m.statusMessage = "Metric toggle error: " + err.Error()
+ return
+ }
+ }
+ m.countField = next
+ m.clearSnapshotState(false)
+ m.statusMessage = "Metric: " + m.countFieldLabel() + " (new baseline)"
+}
+
+func (m *Model) toggleHelp() {
+ m.showHelp = !m.showHelp
+}
+
+func (m Model) toolbarLine() string {
+ state := lipgloss.NewStyle().Foreground(common.ColorPrimary).Render("[LIVE]")
+ if m.paused {
+ state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]")
+ }
+ order := m.currentFieldPresetLabel()
+ line := fmt.Sprintf("%s | view:%s | o:order(%s) | b:metric(%s) | /:search | enter:zoom | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order, m.countFieldLabel())
+ if m.searchQuery != "" {
+ line += " | filter:" + m.searchQuery
+ }
+ if m.statusMessage != "" {
+ line += " | " + m.statusMessage
+ }
+ if m.lastKeyDebug != "" {
+ line += " | " + m.lastKeyDebug
+ }
+ width := m.width
+ if width <= 0 {
+ width = 80
+ }
+ return padOrTrim(line, width)
+}
+
+func (m Model) helpOverlay() string {
+ width := m.width
+ if width <= 0 {
+ width = 80
+ }
+ help := "Flame help: j/k depth h/l sibling pgup top pgdn root enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order b metric ? help"
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width))
+}
+
+func (m Model) selectionStatusLine() string {
+ width := m.width
+ if width <= 0 {
+ width = 80
+ }
+ mode := "LIVE"
+ if m.paused {
+ mode = "PAUSED"
+ }
+ if len(m.frames) == 0 {
+ line := fmt.Sprintf("[%s] sel:none | arrows/hjkl navigate | enter zoom | / filter", mode)
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
+ }
+ selIdx := m.selectedIdx
+ if selIdx < 0 || selIdx >= len(m.frames) {
+ selIdx = 0
+ }
+ frame := m.frames[selIdx]
+ systemShare := frame.Percent
+ if m.globalTotal > 0 {
+ systemShare = percentOfTotal(frame.Total, m.globalTotal)
+ }
+ metric := m.countFieldLabel()
+ shareLabel := fmt.Sprintf("%.2f%% of total %s", systemShare, metric)
+ if strings.TrimSpace(m.searchQuery) != "" && len(m.matchIndices) > 0 {
+ filterTotal, _ := filterCoverageTotals(m.frames, m.matchIndices, m.globalTotal)
+ if filterTotal > 0 {
+ selectedFilterTotal := filterCoverageTotalForPath(m.frames, m.matchIndices, frame.Path)
+ filterShare := percentOfTotal(selectedFilterTotal, filterTotal)
+ shareLabel = fmt.Sprintf("%.2f%% of filtered %s", filterShare, metric)
+ }
+ }
+ line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total(%s):%d | %s",
+ mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, m.countFieldLabel(), frame.Total, shareLabel)
+ if m.searchQuery != "" {
+ line += " | filter:" + m.searchQuery
+ }
+ return common.HelpBarStyle.Width(width).Render(padOrTrim(line, width))
+}
+
+func (m Model) currentFieldPresetLabel() string {
+ if len(m.fieldPresets) == 0 {
+ return "n/a"
+ }
+ idx := m.fieldIndex
+ if idx < 0 {
+ idx = 0
+ }
+ if idx >= len(m.fieldPresets) {
+ idx = len(m.fieldPresets) - 1
+ }
+ return strings.Join(m.fieldPresets[idx], "/")
+}
+
+func (m Model) countFieldLabel() string {
+ switch m.countField {
+ case "count":
+ return "events"
+ case "bytes":
+ return "bytes"
+ default:
+ return m.countField
+ }
+}
diff --git a/internal/tui/flamegraph/doc.go b/internal/tui/flamegraph/doc.go
new file mode 100644
index 0000000..7982ae9
--- /dev/null
+++ b/internal/tui/flamegraph/doc.go
@@ -0,0 +1,2 @@
+// Package flamegraph renders the interactive terminal flamegraph dashboard tab.
+package flamegraph
diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go
new file mode 100644
index 0000000..cc208ae
--- /dev/null
+++ b/internal/tui/flamegraph/model.go
@@ -0,0 +1,1027 @@
+package flamegraph
+
+import (
+ "encoding/json"
+ "fmt"
+ "image/color"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/bubbles/v2/key"
+ "charm.land/bubbles/v2/textinput"
+ tea "charm.land/bubbletea/v2"
+)
+
+type snapshotNode struct {
+ Name string `json:"n"`
+ Value uint64 `json:"v"`
+ Total uint64 `json:"t"`
+ Children []*snapshotNode `json:"c,omitempty"`
+}
+
+type animTickMsg struct{}
+
+const animFrameDuration = 33 * time.Millisecond
+
+// LiveTrieSource is the minimal trie contract needed by the flamegraph TUI model.
+type LiveTrieSource interface {
+ Fields() []string
+ CountField() string
+ Reconfigure([]string) error
+ SetCountField(string) error
+ Reset()
+ Version() uint64
+ SnapshotJSON() ([]byte, uint64)
+}
+
+type zoomState struct {
+ path string
+ previousSelectedIdx int
+}
+
+type flameKeyMap struct {
+ MoveShallower key.Binding
+ MoveDeeper key.Binding
+ PrevSibling key.Binding
+ NextSibling key.Binding
+ JumpTop key.Binding
+ JumpRoot key.Binding
+ ZoomIn key.Binding
+ ZoomUndo key.Binding
+ ZoomReset key.Binding
+}
+
+func defaultFlameKeyMap() flameKeyMap {
+ return flameKeyMap{
+ MoveShallower: key.NewBinding(key.WithKeys("j", "down")),
+ MoveDeeper: key.NewBinding(key.WithKeys("k", "up")),
+ PrevSibling: key.NewBinding(key.WithKeys("h", "left")),
+ NextSibling: key.NewBinding(key.WithKeys("l", "right")),
+ JumpTop: key.NewBinding(key.WithKeys("pgup", "pageup")),
+ JumpRoot: key.NewBinding(key.WithKeys("pgdown", "pgdn", "pagedown")),
+ ZoomIn: key.NewBinding(key.WithKeys("enter")),
+ ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u", "esc")),
+ ZoomReset: key.NewBinding(),
+ }
+}
+
+// Model is the Bubble Tea model for the TUI flamegraph tab.
+type Model struct {
+ liveTrie LiveTrieSource
+ lastVersion uint64
+ snapshot *snapshotNode
+ globalTotal uint64
+
+ frames []tuiFrame
+ targetFrames []tuiFrame
+ width int
+ height int
+
+ selectedIdx int
+ zoomStack []zoomState
+ zoomRoot *snapshotNode
+ zoomPath string
+
+ searchActive bool
+ searchInput textinput.Model
+ searchQuery string
+ matchIndices map[int]bool
+ filterVisible map[int]bool
+ subtreeSet map[int]bool
+ showHelp bool
+ statusMessage string
+ lastKeyDebug string
+
+ fieldPresets [][]string
+ fieldIndex int
+ countField string
+
+ animation AnimationState
+ animating bool
+ paused bool
+ // hasNavigableSnapshot flips once we have at least one selectable non-root frame.
+ hasNavigableSnapshot bool
+ isDark bool
+ keys flameKeyMap
+}
+
+// tuiFrame stores one terminal flamegraph frame cell.
+type tuiFrame struct {
+ Name string
+ Col int
+ Row int
+ Width int
+ Total uint64
+ Percent float64
+ Fill color.Color
+ Depth int
+ Path string
+}
+
+// NewModel constructs a flamegraph tab model with default state.
+func NewModel(liveTrie LiveTrieSource) Model {
+ searchInput := textinput.New()
+ searchInput.Prompt = "/"
+ searchInput.CharLimit = 0
+ searchInput.SetWidth(32)
+ searchInput.SetStyles(textinput.DefaultStyles(true))
+
+ m := Model{
+ liveTrie: liveTrie,
+ matchIndices: make(map[int]bool),
+ filterVisible: make(map[int]bool),
+ subtreeSet: make(map[int]bool),
+ searchInput: searchInput,
+ fieldPresets: [][]string{
+ {"comm", "tracepoint", "path"},
+ {"path", "tracepoint", "comm"},
+ {"tracepoint", "comm", "path"},
+ {"pid", "tracepoint", "path"},
+ {"comm", "path", "tracepoint"},
+ },
+ isDark: true,
+ keys: defaultFlameKeyMap(),
+ animation: NewAnimationState(30, 6.0, 1.0),
+ countField: "count",
+ }
+ m.syncFieldPresetToTrie()
+ m.syncCountFieldToTrie()
+ return m
+}
+
+// Init starts the flamegraph model.
+func (m Model) Init() tea.Cmd {
+ return nil
+}
+
+// Update handles incoming messages.
+func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case animTickMsg:
+ if !m.animating {
+ return m, nil
+ }
+ m.animating = m.animation.Tick(0)
+ m.frames = m.animation.CurrentFrames()
+ m.clampSelection()
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ if m.animating {
+ return m, animTickCmd()
+ }
+ return m, nil
+ case tea.WindowSizeMsg:
+ m.width = msg.Width
+ m.height = msg.Height
+ m.rebuildFrames(true)
+ if m.animating {
+ return m, animTickCmd()
+ }
+ return m, nil
+ case tea.KeyPressMsg:
+ if m.searchActive {
+ handled := false
+ switch msg.String() {
+ case "esc":
+ handled = true
+ m.clearSearch()
+ m.recordKeyDebug(msg, handled, false)
+ return m, nil
+ case "enter":
+ handled = true
+ m.applySearchQuery(m.searchInput.Value())
+ m.searchActive = false
+ m.searchInput.Blur()
+ m.recordKeyDebug(msg, handled, false)
+ return m, nil
+ }
+ var cmd tea.Cmd
+ m.searchInput, cmd = m.searchInput.Update(msg)
+ _ = cmd
+ m.recordKeyDebug(msg, true, false)
+ return m, nil
+ }
+
+ prev := m.selectedIdx
+ handled := false
+ switch {
+ case isSearchOpenKey(msg):
+ handled = true
+ m.openSearch()
+ case isNextMatchKey(msg):
+ handled = true
+ m.jumpMatch(1)
+ case isPrevMatchKey(msg):
+ handled = true
+ m.jumpMatch(-1)
+ case isPauseKey(msg):
+ handled = true
+ m.togglePause()
+ case isResetBaselineKey(msg):
+ handled = true
+ m.resetBaseline()
+ case isCycleOrderKey(msg):
+ handled = true
+ m.cycleFieldOrder()
+ case isCycleMetricKey(msg):
+ handled = true
+ m.toggleCountField()
+ case isHelpToggleKey(msg):
+ handled = true
+ m.toggleHelp()
+ case isZoomInKey(msg, m.keys):
+ handled = true
+ m.zoomIn()
+ case isZoomUndoKey(msg, m.keys):
+ handled = true
+ m.zoomUndo()
+ case isZoomResetKey(msg, m.keys):
+ handled = true
+ m.zoomReset()
+ case isMoveShallowerKey(msg, m.keys):
+ handled = true
+ m.moveVerticalWithFallback(-1, 1, -1)
+ case isMoveDeeperKey(msg, m.keys):
+ handled = true
+ m.moveVerticalWithFallback(1, -1, 1)
+ case isPrevSiblingKey(msg, m.keys):
+ handled = true
+ m.moveSibling(-1)
+ case isNextSiblingKey(msg, m.keys):
+ handled = true
+ m.moveSibling(1)
+ case isJumpTopKey(msg, m.keys):
+ handled = true
+ m.jumpToTop()
+ case isJumpRootKey(msg, m.keys):
+ handled = true
+ m.jumpToRoot()
+ }
+ if m.selectedIdx != prev {
+ m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet)
+ }
+ m.recordKeyDebug(msg, handled, m.selectedIdx != prev)
+ }