diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 17:32:24 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 17:32:24 +0200 |
| commit | 1561987330cb898f5ff64383a9c78e7e6559f118 (patch) | |
| tree | 69a823e8f98dce572566c97e6879c11c9d591bda /internal/tui/flamegraph | |
| parent | 96225fb6159212a8851043a08d781aba721b4e78 (diff) | |
| parent | 110a193e04b81abb8d8e159abd73f9f6ed1acd7e (diff) | |
Merge branch 'feat/bubbletea-v2-migration'
Diffstat (limited to 'internal/tui/flamegraph')
| -rw-r--r-- | internal/tui/flamegraph/animation.go | 145 | ||||
| -rw-r--r-- | internal/tui/flamegraph/animation_test.go | 50 | ||||
| -rw-r--r-- | internal/tui/flamegraph/bench_test.go | 401 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 173 | ||||
| -rw-r--r-- | internal/tui/flamegraph/doc.go | 2 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 1027 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 987 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 708 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 368 | ||||
| -rw-r--r-- | internal/tui/flamegraph/search.go | 141 | ||||
| -rw-r--r-- | internal/tui/flamegraph/stress_race_disabled_test.go | 7 | ||||
| -rw-r--r-- | internal/tui/flamegraph/stress_race_enabled_test.go | 7 | ||||
| -rw-r--r-- | internal/tui/flamegraph/stress_test.go | 236 | ||||
| -rw-r--r-- | internal/tui/flamegraph/testdata_fixture_test.go | 39 | ||||
| -rw-r--r-- | internal/tui/flamegraph/testdata_test.go | 185 | ||||
| -rw-r--r-- | internal/tui/flamegraph/zoom.go | 39 |
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( |
