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() + 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) + } + 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 isSearchOpenKey(msg), + isNextMatchKey(msg), + isPrevMatchKey(msg), + isPauseKey(msg), + isResetBaselineKey(msg), + isCycleOrderKey(msg), + isCycleMetricKey(msg), + isHelpToggleKey(msg): + return true + case isZoomInKey(msg, m.keys), + isZoomUndoKey(msg, m.keys), + isZoomResetKey(msg, m.keys), + isMoveShallowerKey(msg, m.keys), + isMoveDeeperKey(msg, m.keys), + isPrevSiblingKey(msg, m.keys), + isNextSiblingKey(msg, m.keys), + isJumpTopKey(msg, m.keys), + isJumpRootKey(msg, m.keys): + return true + default: + return false + } +} + +// View renders the flamegraph viewport. +func (m Model) View() tea.View { + extraLines := 1 // selection status line + if m.showHelp { + extraLines++ + } + renderHeight := m.height - extraLines + if renderHeight < 3 { + renderHeight = 3 + } + + content := RenderTerminalView(m.frames, m.width, renderHeight, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.countFieldLabel(), m.isDark, m.searchActive, m.searchQuery) + content = replaceHeaderLine(content, m.toolbarLine()) + if m.searchActive { + content = replaceFooterLine(content, m.searchFooter()) + } + if m.snapshot != nil && len(m.frames) == 0 { + content = common.PanelStyle.Render(fmt.Sprintf("Flame: snapshot v%d has no visible frames", m.lastVersion)) + } + content += "\n" + m.selectionStatusLine() + if m.showHelp { + content += "\n" + m.helpOverlay() + } + return tea.NewView(content) +} + +// SetLiveTrie updates the data source used by the flamegraph model. +func (m *Model) SetLiveTrie(liveTrie LiveTrieSource) { + m.liveTrie = liveTrie + m.syncFieldPresetToTrie() + m.syncCountFieldToTrie() + m.lastVersion = 0 + m.snapshot = nil + m.globalTotal = 0 + m.selectedIdx = 0 + m.frames = nil + m.targetFrames = nil + m.zoomStack = nil + m.zoomRoot = nil + m.zoomPath = "" + m.subtreeSet = make(map[int]bool) + m.filterVisible = make(map[int]bool) + m.animation = NewAnimationState(30, 6.0, 1.0) + m.animating = false + m.hasNavigableSnapshot = false +} + +func (m *Model) syncFieldPresetToTrie() { + if m.liveTrie == nil { + m.fieldIndex = 0 + return + } + fields := m.liveTrie.Fields() + if len(fields) == 0 { + m.fieldIndex = 0 + return + } + for idx, preset := range m.fieldPresets { + if slices.Equal(preset, fields) { + m.fieldIndex = idx + return + } + } + custom := slices.Clone(fields) + m.fieldPresets = append([][]string{custom}, m.fieldPresets...) + m.fieldIndex = 0 +} + +func (m *Model) syncCountFieldToTrie() { + if m.liveTrie == nil { + m.countField = "count" + return + } + field := strings.TrimSpace(m.liveTrie.CountField()) + if field == "" { + field = "count" + } + m.countField = field +} + +// RefreshFromLiveTrie loads a new snapshot when the source version changes. +func (m *Model) RefreshFromLiveTrie() bool { + if m.liveTrie == nil { + return false + } + // Once a snapshot exists, paused mode must freeze it regardless of current + // navigability so selection and percentages remain stable. + if m.paused && m.snapshot != nil { + return false + } + version := m.liveTrie.Version() + if version == m.lastVersion && m.snapshot != nil { + return false + } + + payload, version := m.liveTrie.SnapshotJSON() + var snapshot snapshotNode + if err := json.Unmarshal(payload, &snapshot); err != nil { + return false + } + m.snapshot = &snapshot + m.globalTotal = snapshotTotal(m.snapshot) + if m.zoomPath != "" { + m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) + } else { + m.zoomRoot = nil + } + m.rebuildFrames(true) + m.lastVersion = version + return true +} + +// LastVersion returns the latest snapshot version loaded into the model. +func (m Model) LastVersion() uint64 { + return m.lastVersion +} + +// HasSnapshot reports whether the flamegraph model has loaded at least one snapshot. +func (m Model) HasSnapshot() bool { + return m.snapshot != nil +} + +// AnimationCmd returns a frame animation tick command when animation is active. +func (m Model) AnimationCmd() tea.Cmd { + if !m.animating { + return nil + } + return animTickCmd() +} + +// Paused reports whether live refresh is paused. +func (m Model) Paused() bool { + return m.paused +} + +// SetViewport updates model render dimensions. +func (m *Model) SetViewport(width, height int) { + m.width = width + m.height = height + m.rebuildFrames(true) +} + +// SetDarkMode sets the active color theme mode. +func (m *Model) SetDarkMode(isDark bool) { + m.isDark = isDark + m.searchInput.SetStyles(textinput.DefaultStyles(isDark)) +} + +func (m *Model) rebuildFrames(animate bool) { + prevPath := "" + if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) { + prevPath = m.frames[m.selectedIdx].Path + } + + var root *snapshotNode + rootPath := "" + if m.zoomRoot != nil { + root = m.zoomRoot + rootPath = m.zoomPath + } else { + root = m.snapshot + } + m.targetFrames = buildTerminalLayoutWithPath(root, m.width, m.height, rootPath) + m.animation.SetTargets(m.targetFrames) + if animate && len(m.frames) > 0 && !m.animation.Settled() { + m.animating = true + m.frames = m.animation.CurrentFrames() + } else { + m.animating = false + m.frames = append(m.frames[:0], m.targetFrames...) + } + if len(m.frames) > 1 { + m.hasNavigableSnapshot = true + } + m.restoreSelectionByPath(prevPath) + m.clampSelection() + m.recomputeFilterState() + m.ensureSelectionNavigable() + m.ensureSelectionVisible() + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) +} + +func (m *Model) restoreSelectionByPath(path string) { + if path == "" || len(m.frames) == 0 { + return + } + if idx := m.frameIndexByPath(path); idx >= 0 { + m.selectedIdx = idx + return + } + for idx, frame := range m.frames { + if hasPathBoundaryPrefix(path, frame.Path) || hasPathBoundaryPrefix(frame.Path, path) { + m.selectedIdx = idx + return + } + } +} + +func (m Model) frameIndexByPath(path string) int { + for idx, frame := range m.frames { + if frame.Path == path { + return idx + } + } + return -1 +} + +func (m *Model) zoomIn() { + if len(m.frames) == 0 || m.snapshot == nil { + m.statusMessage = "Zoom unavailable: no frame selected" + return + } + m.clampSelection() + selectedPath := m.frames[m.selectedIdx].Path + if selectedPath == m.currentRootPath() { + m.statusMessage = "Zoom unchanged: selected frame is current view root" + return + } + target := findNodeByPath(m.snapshot, selectedPath) + if target == nil { + m.statusMessage = "Zoom failed: selected node is unavailable" + return + } + m.zoomStack = append(m.zoomStack, zoomState{ + path: m.zoomPath, + previousSelectedIdx: m.selectedIdx, + }) + m.zoomRoot = target + m.zoomPath = selectedPath + m.selectedIdx = 0 + m.rebuildFrames(true) + m.statusMessage = "Zoom: " + compactFramePath(selectedPath) +} + +func (m *Model) zoomUndo() { + if len(m.zoomStack) == 0 || m.snapshot == nil { + m.statusMessage = "Zoom undo unavailable" + return + } + last := m.zoomStack[len(m.zoomStack)-1] + m.zoomStack = m.zoomStack[:len(m.zoomStack)-1] + m.zoomPath = last.path + if m.zoomPath == "" { + m.zoomRoot = nil + } else { + m.zoomRoot = findNodeByPath(m.snapshot, m.zoomPath) + } + m.selectedIdx = last.previousSelectedIdx + m.rebuildFrames(true) + if m.zoomPath == "" { + m.statusMessage = "Zoom: root" + return + } + m.statusMessage = "Zoom: " + compactFramePath(m.zoomPath) +} + +func (m *Model) zoomReset() { + if m.zoomRoot == nil && len(m.zoomStack) == 0 { + m.statusMessage = "Zoom already at root" + return + } + m.zoomRoot = nil + m.zoomPath = "" + m.zoomStack = nil + m.rebuildFrames(false) + m.statusMessage = "Zoom reset to root" +} + +func (m *Model) moveVertical(delta int) { + if len(m.frames) == 0 { + return + } + m.clampSelection() + m.ensureSelectionNavigable() + current := m.frames[m.selectedIdx] + targetDepth := current.Depth + delta + targets := m.framesAtDepth(targetDepth) + if len(targets) == 0 { + return + } + best := targets[0] + bestDist := abs(m.frames[best].Col - current.Col) + for _, idx := range targets[1:] { + dist := abs(m.frames[idx].Col - current.Col) + if dist < bestDist { + best = idx + bestDist = dist + } + } + m.selectedIdx = best +} + +func (m *Model) moveVerticalWithFallback(primaryDelta, fallbackDelta, traversalDelta int) { + before := m.selectedIdx + m.moveVertical(primaryDelta) + if m.selectedIdx == before && fallbackDelta != 0 { + m.moveVertical(fallbackDelta) + } + if m.selectedIdx == before && traversalDelta != 0 { + m.moveTraversal(traversalDelta) + } +} + +func (m *Model) moveSibling(delta int) { + if len(m.frames) == 0 { + return + } + before := m.selectedIdx + m.clampSelection() + m.ensureSelectionNavigable() + current := m.frames[m.selectedIdx] + siblings := m.framesAtDepth(current.Depth) + if len(siblings) <= 1 { + m.moveTraversal(delta) + return + } + pos := indexOf(siblings, m.selectedIdx) + if pos < 0 { + m.moveTraversal(delta) + return + } + next := pos + delta + if next < 0 { + next = 0 + } + if next >= len(siblings) { + next = len(siblings) - 1 + } + m.selectedIdx = siblings[next] + if m.selectedIdx == before { + m.moveTraversal(delta) + } +} + +func (m *Model) jumpToTop() { + if len(m.frames) == 0 { + return + } + m.clampSelection() + m.ensureSelectionNavigable() + + include := m.navigableFrameSet() + currentCol := m.frames[m.selectedIdx].Col + bestIdx := -1 + bestDepth := -1 + bestDist := int(^uint(0) >> 1) + + for idx, frame := range m.frames { + if include != nil && !include[idx] { + continue + } + dist := abs(frame.Col - currentCol) + if frame.Depth > bestDepth { + bestDepth = frame.Depth + bestIdx = idx + bestDist = dist + continue + } + if frame.Depth == bestDepth { + if dist < bestDist || (dist == bestDist && frame.Col < m.frames[bestIdx].Col) { + bestIdx = idx + bestDist = dist + } + } + } + if bestIdx >= 0 { + m.selectedIdx = bestIdx + } +} + +func (m *Model) jumpToRoot() { + if len(m.frames) == 0 { + return + } + m.clampSelection() + m.ensureSelectionNavigable() + + rootPath := m.currentRootPath() + if rootPath != "" { + if idx := m.frameIndexByPath(rootPath); idx >= 0 { + if !m.filterActive() || m.frameNavigable(idx) { + m.selectedIdx = idx + return + } + } + } + + include := m.navigableFrameSet() + currentCol := m.frames[m.selectedIdx].Col + bestIdx := -1 + bestDepth := int(^uint(0) >> 1) + bestDist := int(^uint(0) >> 1) + for idx, frame := range m.frames { + if include != nil && !include[idx] { + continue + } + dist := abs(frame.Col - currentCol) + if frame.Depth < bestDepth { + bestDepth = frame.Depth + bestDist = dist + bestIdx = idx + continue + } + if frame.Depth == bestDepth { + if dist < bestDist || (dist == bestDist && frame.Col < m.frames[bestIdx].Col) { + bestDist = dist + bestIdx = idx + } + } + } + if bestIdx >= 0 { + m.selectedIdx = bestIdx + } +} + +func framesAtDepth(frames []tuiFrame, depth int) []int { + return framesAtDepthFiltered(frames, depth, nil) +} + +func framesAtDepthFiltered(frames []tuiFrame, depth int, include map[int]bool) []int { + if depth < 0 { + return nil + } + indices := make([]int, 0) + for idx, frame := range frames { + if include != nil && !include[idx] { + continue + } + if frame.Depth == depth { + indices = append(indices, idx) + } + } + sort.Slice(indices, func(i, j int) bool { + return frames[indices[i]].Col < frames[indices[j]].Col + }) + return indices +} + +func indexOf(values []int, target int) int { + for idx, value := range values { + if value == target { + return idx + } + } + return -1 +} + +func (m *Model) clampSelection() { + if len(m.frames) == 0 { + m.selectedIdx = 0 + return + } + if m.selectedIdx < 0 { + m.selectedIdx = 0 + } + if m.selectedIdx >= len(m.frames) { + m.selectedIdx = len(m.frames) - 1 + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} + +func animTickCmd() tea.Cmd { + return tea.Tick(animFrameDuration, func(time.Time) tea.Msg { return animTickMsg{} }) +} + +func (m Model) currentRootPath() string { + if m.zoomPath != "" { + return m.zoomPath + } + if len(m.frames) == 0 { + return "" + } + return m.frames[0].Path +} + +func (m Model) filterActive() bool { + return strings.TrimSpace(m.searchQuery) != "" +} + +func (m Model) navigableFrameSet() map[int]bool { + if !m.filterActive() { + return nil + } + return m.filterVisible +} + +func (m Model) framesAtDepth(depth int) []int { + return framesAtDepthFiltered(m.frames, depth, m.navigableFrameSet()) +} + +func (m Model) frameNavigable(idx int) bool { + if idx < 0 || idx >= len(m.frames) { + return false + } + if !m.filterActive() { + return true + } + return m.filterVisible[idx] +} + +func (m *Model) ensureSelectionNavigable() { + if len(m.frames) == 0 { + m.selectedIdx = 0 + return + } + m.clampSelection() + if m.frameNavigable(m.selectedIdx) { + return + } + + if len(m.matchIndices) > 0 { + for _, idx := range orderedMatchIndices(m.matchIndices) { + if m.frameNavigable(idx) { + m.selectedIdx = idx + return + } + } + } + + for idx := range m.frames { + if m.frameNavigable(idx) { + m.selectedIdx = idx + return + } + } +} + +func (m *Model) recordKeyDebug(msg tea.KeyPressMsg, handled, moved bool) { + keyID := keyString(msg) + if keyID == "" { + keyID = fmt.Sprintf("code:%d", msg.Code) + } + sel := "-" + selIdx := m.selectedIdx + if len(m.frames) > 0 && m.selectedIdx >= 0 && m.selectedIdx < len(m.frames) { + sel = compactFramePath(m.frames[m.selectedIdx].Path) + } + m.lastKeyDebug = fmt.Sprintf("dbg frames=%d idx=%d key=%q code=%d handled=%t moved=%t sel=%s", len(m.frames), selIdx, keyID, msg.Code, handled, moved, sel) +} + +func (m *Model) moveTraversal(delta int) { + if len(m.frames) == 0 || delta == 0 { + return + } + order := m.visibleTraversalOrder() + if len(order) == 0 { + return + } + pos := indexOf(order, m.selectedIdx) + if pos < 0 { + pos = 0 + } + next := pos + delta + if next < 0 { + next = 0 + } + if next >= len(order) { + next = len(order) - 1 + } + m.selectedIdx = order[next] +} + +func (m Model) visibleTraversalOrder() []int { + indices := make([]int, 0, len(m.frames)) + include := m.navigableFrameSet() + for idx := range m.frames { + if include != nil && !include[idx] { + continue + } + indices = append(indices, idx) + } + sort.Slice(indices, func(i, j int) bool { + left := m.frames[indices[i]] + right := m.frames[indices[j]] + if left.Depth != right.Depth { + return left.Depth < right.Depth + } + if left.Col != right.Col { + return left.Col < right.Col + } + if left.Row != right.Row { + return left.Row < right.Row + } + return indices[i] < indices[j] + }) + return indices +} + +func keyString(msg tea.KeyPressMsg) string { + if s := msg.String(); s != "" { + return s + } + return msg.Text +} + +func isSearchOpenKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "/" } +func isNextMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "n" } +func isPrevMatchKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "N" } +func isPauseKey(msg tea.KeyPressMsg) bool { + k := keyString(msg) + return k == "p" || k == " " || k == "space" || msg.Code == tea.KeySpace +} +func isResetBaselineKey(msg tea.KeyPressMsg) bool { + return keyString(msg) == "r" +} +func isCycleOrderKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "o" } +func isCycleMetricKey(msg tea.KeyPressMsg) bool { + return keyString(msg) == "b" +} +func isHelpToggleKey(msg tea.KeyPressMsg) bool { return keyString(msg) == "?" } + +func isZoomInKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + return key.Matches(msg, keys.ZoomIn) || msg.Code == tea.KeyEnter || strings.EqualFold(keyString(msg), "enter") +} + +func isZoomUndoKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + return key.Matches(msg, keys.ZoomUndo) || msg.Code == tea.KeyBackspace || msg.Code == tea.KeyEsc +} + +func isZoomResetKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + return key.Matches(msg, keys.ZoomReset) +} + +func isMoveShallowerKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + k := keyString(msg) + return key.Matches(msg, keys.MoveShallower) || msg.Code == tea.KeyDown || keyMatchesDirection(k, "down", 'B') +} + +func isMoveDeeperKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + k := keyString(msg) + return key.Matches(msg, keys.MoveDeeper) || msg.Code == tea.KeyUp || keyMatchesDirection(k, "up", 'A') +} + +func isPrevSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + k := keyString(msg) + return key.Matches(msg, keys.PrevSibling) || msg.Code == tea.KeyLeft || keyMatchesDirection(k, "left", 'D') +} + +func isNextSiblingKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + k := keyString(msg) + return key.Matches(msg, keys.NextSibling) || msg.Code == tea.KeyRight || keyMatchesDirection(k, "right", 'C') +} + +func isJumpTopKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + k := strings.ToLower(keyString(msg)) + return key.Matches(msg, keys.JumpTop) || msg.Code == tea.KeyPgUp || k == "pgup" || k == "pageup" +} + +func isJumpRootKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { + k := strings.ToLower(keyString(msg)) + return key.Matches(msg, keys.JumpRoot) || msg.Code == tea.KeyPgDown || k == "pgdown" || k == "pgdn" || k == "pagedown" +} + +func keyMatchesDirection(keyName, plain string, ansiFinal byte) bool { + if keyName == plain || strings.HasSuffix(keyName, "+"+plain) { + return true + } + return isArrowEscapeSequence(keyName, ansiFinal) +} + +func isArrowEscapeSequence(value string, ansiFinal byte) bool { + if len(value) < 3 || value[0] != '\x1b' { + return false + } + last := value[len(value)-1] + if last != ansiFinal { + return false + } + return value[1] == '[' || value[1] == 'O' +} + +func (m Model) visibleRowOffset() int { + if len(m.frames) == 0 { + return 0 + } + availableRows := m.height - 2 // toolbar + status + if availableRows <= 0 { + return 0 + } + maxRow := maxFrameRowForSet(m.frames, m.navigableFrameSet()) + if maxRow+1 <= availableRows { + return 0 + } + return maxRow + 1 - availableRows +} + +func (m *Model) ensureSelectionVisible() { + if len(m.frames) == 0 { + return + } + m.clampSelection() + m.ensureSelectionNavigable() + if !m.frameNavigable(m.selectedIdx) { + return + } + rowOffset := m.visibleRowOffset() + selected := m.frames[m.selectedIdx] + if selected.Row >= rowOffset { + return + } + + bestIdx := -1 + bestScore := int(^uint(0) >> 1) + for idx, frame := range m.frames { + if !m.frameNavigable(idx) { + continue + } + if frame.Row < rowOffset { + continue + } + score := abs(frame.Row-rowOffset)*1000 + abs(frame.Col-selected.Col) + if score < bestScore { + bestIdx = idx + bestScore = score + } + } + if bestIdx >= 0 { + m.selectedIdx = bestIdx + } +} diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go new file mode 100644 index 0000000..74ce8d9 --- /dev/null +++ b/internal/tui/flamegraph/model_test.go @@ -0,0 +1,987 @@ +package flamegraph + +import ( + "reflect" + "strings" + "testing" + + coreflamegraph "ior/internal/flamegraph" + + tea "charm.land/bubbletea/v2" +) + +func TestNewModelDefaults(t *testing.T) { + m := NewModel(nil) + if m.liveTrie != nil { + t.Fatalf("expected nil liveTrie when constructor input is nil") + } + if m.matchIndices == nil { + t.Fatalf("expected matchIndices map to be initialized") + } + if len(m.fieldPresets) == 0 { + t.Fatalf("expected default field presets to be initialized") + } + if got, want := m.fieldPresets[0], []string{"comm", "tracepoint", "path"}; !reflect.DeepEqual(got, want) { + t.Fatalf("default field preset[0] = %v, want %v", got, want) + } + if !m.isDark { + t.Fatalf("expected dark mode enabled by default") + } +} + +func TestSetViewportAndDarkMode(t *testing.T) { + m := NewModel(nil) + m.SetViewport(120, 40) + m.SetDarkMode(false) + if m.width != 120 || m.height != 40 { + t.Fatalf("expected viewport 120x40, got %dx%d", m.width, m.height) + } + if m.isDark { + t.Fatalf("expected dark mode to be disabled") + } +} + +func TestRefreshFromLiveTrieTracksVersionAndSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected first refresh to load baseline snapshot") + } + if m.snapshot == nil { + t.Fatalf("expected snapshot to be populated after refresh") + } + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected no refresh when version is unchanged") + } +} + +func TestRefreshFromLiveTrieAllowsInitialLoadWhilePaused(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial paused refresh to load first snapshot") + } + if m.snapshot == nil { + t.Fatalf("expected snapshot to be available after initial paused refresh") + } + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected subsequent paused refresh to be skipped once snapshot exists") + } +} + +func TestRefreshFromLiveTriePausedBlocksAfterNavigableSnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + m.snapshot = &snapshotNode{Name: "root", Total: 1} + m.frames = []tuiFrame{ + {Name: "root", Path: "root"}, + {Name: "child", Path: "root" + pathSeparator + "child"}, + } + m.hasNavigableSnapshot = true + m.lastVersion = 1 + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected paused refresh to remain frozen once navigable snapshot exists") + } + if got, want := m.lastVersion, uint64(1); got != want { + t.Fatalf("expected version to remain unchanged while paused, got %d want %d", got, want) + } +} + +func TestRefreshFromLiveTriePausedBlocksAfterAnySnapshot(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModel(trie) + m.paused = true + m.snapshot = &snapshotNode{Name: "root", Total: 1} + m.frames = []tuiFrame{{Name: "root", Path: "root"}} + m.hasNavigableSnapshot = false + m.lastVersion = 1 + + if changed := m.RefreshFromLiveTrie(); changed { + t.Fatalf("expected paused refresh to freeze after first snapshot even when non-navigable") + } + if got, want := m.lastVersion, uint64(1); got != want { + t.Fatalf("expected paused refresh to keep existing snapshot version, got %d want %d", got, want) + } +} + +func TestKeyboardNavigationDeepNarrowTree(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Path: "root" + pathSeparator + "child"}, + {Name: "leaf", Depth: 2, Col: 0, Path: "root" + pathSeparator + "child" + pathSeparator + "leaf"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selectedIdx != 1 { + t.Fatalf("expected selection to move deeper to idx 1, got %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selectedIdx != 2 { + t.Fatalf("expected selection to move deeper to idx 2, got %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'j'}[0], Text: "j"}) + if m.selectedIdx != 1 { + t.Fatalf("expected selection to move shallower to idx 1, got %d", m.selectedIdx) + } +} + +func TestKeyboardNavigationShallowWideSiblings(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"}, + {Name: "C", Depth: 1, Col: 60, Path: "root" + pathSeparator + "C"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'k'}[0], Text: "k"}) + if m.selectedIdx != 1 { + t.Fatalf("expected first deeper frame to be A, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 2 { + t.Fatalf("expected next sibling B, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 3 { + t.Fatalf("expected next sibling C, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 3 { + t.Fatalf("expected selection to clamp at last sibling, got idx %d", m.selectedIdx) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"}) + if m.selectedIdx != 2 { + t.Fatalf("expected previous sibling B, got idx %d", m.selectedIdx) + } +} + +func TestHorizontalTraversalFallbackFromRoot(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 30, Path: "root" + pathSeparator + "B"}, + } + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected right arrow from root to move to first traversable frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'l'}[0], Text: "l"}) + if m.selectedIdx != 2 { + t.Fatalf("expected vi right key to move to next frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + if m.selectedIdx != 1 { + t.Fatalf("expected left arrow to move back to previous frame, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'h'}[0], Text: "h"}) + if m.selectedIdx != 0 { + t.Fatalf("expected vi left key to move back to root, got idx %d", m.selectedIdx) + } +} + +func TestPageUpJumpsSelectionToTopMostDepth(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + {Name: "B", Depth: 1, Col: 40, Path: "root" + pathSeparator + "B"}, + {Name: "A1", Depth: 2, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1"}, + {Name: "B1", Depth: 2, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1"}, + {Name: "A2", Depth: 3, Col: 0, Path: "root" + pathSeparator + "A" + pathSeparator + "A1" + pathSeparator + "A2"}, + {Name: "B2", Depth: 3, Col: 40, Path: "root" + pathSeparator + "B" + pathSeparator + "B1" + pathSeparator + "B2"}, + } + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"B"+pathSeparator+"B1") + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgUp}) + if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"B"+pathSeparator+"B1"+pathSeparator+"B2"; got != want { + t.Fatalf("expected pgup to jump to deepest top frame %q, got %q", want, got) + } +} + +func TestPageDownJumpsSelectionToCurrentViewRoot(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyPgDown}) + if got, want := m.frames[m.selectedIdx].Path, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected pgdn to jump to current zoom root %q, got %q", want, got) + } +} + +func TestPausedStateStillAllowsNavigation(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "A", Depth: 1, Col: 0, Path: "root" + pathSeparator + "A"}, + } + m.paused = true + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected navigation to work while paused, got idx %d", m.selectedIdx) + } +} + +func TestStaticFixtureArrowTraversalVisitsAllFrames(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestFlameData(trie) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected seeded fixture refresh to load frames") + } + if len(m.frames) < 2 { + t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames)) + } + + visited := map[int]bool{m.selectedIdx: true} + for i := 0; i < len(m.frames)*4; i++ { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + visited[m.selectedIdx] = true + } + for i := 0; i < len(m.frames)*4; i++ { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + visited[m.selectedIdx] = true + } + + if got, want := len(visited), len(m.frames); got != want { + t.Fatalf("expected arrow traversal to visit all frames: visited=%d frames=%d", got, want) + } + if !strings.Contains(m.View().Content, "sel:") { + t.Fatalf("expected view to expose selected-frame status line") + } +} + +func TestLiveFixtureArrowTraversalWhileStreamingVisitsAllFrames(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestLiveFlameData(trie, 0) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial refresh to load frames") + } + if len(m.frames) < 2 { + t.Fatalf("expected seeded fixture to contain navigable frames, got %d", len(m.frames)) + } + + selectedPath := func(model Model) string { + if len(model.frames) == 0 || model.selectedIdx < 0 || model.selectedIdx >= len(model.frames) { + return "" + } + return model.frames[model.selectedIdx].Path + } + + visitedPaths := map[string]bool{selectedPath(m): true} + moves := 0 + for i := 0; i < len(m.frames)*4; i++ { + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1)) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after synthetic live ingest at step %d", i) + } + before := selectedPath(m) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + after := selectedPath(m) + if after != before { + moves++ + } + visitedPaths[after] = true + } + for i := 0; i < len(m.frames)*4; i++ { + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, uint64(i+1+len(m.frames)*4)) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after synthetic live ingest (reverse) at step %d", i) + } + before := selectedPath(m) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyLeft}) + after := selectedPath(m) + if after != before { + moves++ + } + visitedPaths[after] = true + } + + if moves == 0 { + t.Fatalf("expected live-stream navigation to change selection at least once") + } + if len(visitedPaths) < 8 { + t.Fatalf("expected traversal across live updates to reach multiple frame paths, got %d", len(visitedPaths)) + } +} + +func TestSelectionRestoresByPathAcrossLiveRefresh(t *testing.T) { + trie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + coreflamegraph.SeedTestLiveFlameData(trie, 0) + + m := NewModel(trie) + m.SetViewport(180, 40) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected initial refresh") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + selected := m.frames[m.selectedIdx].Path + if selected == "" || selected == "root" { + t.Fatalf("expected selection to move off root, got %q", selected) + } + + trie.Reset() + coreflamegraph.SeedTestLiveFlameData(trie, 2) + if changed := m.RefreshFromLiveTrie(); !changed { + t.Fatalf("expected refresh after live update") + } + if got := m.frames[m.selectedIdx].Path; got != selected { + t.Fatalf("expected selection path to persist across refresh, got %q want %q", got, selected) + } +} + +func TestKeyboardNavigationSingleNodeClamped(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{{Name: "root", Depth: 0, Col: 0, Path: "root"}} + + keys := []tea.KeyPressMsg{ + {Code: []rune{'j'}[0], Text: "j"}, + {Code: []rune{'k'}[0], Text: "k"}, + {Code: []rune{'h'}[0], Text: "h"}, + {Code: []rune{'l'}[0], Text: "l"}, + {Code: tea.KeyDown}, + {Code: tea.KeyUp}, + {Code: tea.KeyLeft}, + {Code: tea.KeyRight}, + } + for _, keyMsg := range keys { + m = pressFlameKey(t, m, keyMsg) + if m.selectedIdx != 0 { + t.Fatalf("expected single-node selection to stay at idx 0, got %d", m.selectedIdx) + } + } +} + +func TestArrowDownFallsBackToVisibleDepthFromRoot(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 0 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.selectedIdx != 1 { + t.Fatalf("expected down arrow to move selection to child when root has no shallower row, got %d", m.selectedIdx) + } +} + +func TestArrowEscapeSequencesAreRecognized(t *testing.T) { + tests := []struct { + key string + dir string + ansiCode byte + }{ + {key: "\x1b[A", dir: "up", ansiCode: 'A'}, + {key: "\x1b[B", dir: "down", ansiCode: 'B'}, + {key: "\x1b[C", dir: "right", ansiCode: 'C'}, + {key: "\x1b[D", dir: "left", ansiCode: 'D'}, + {key: "\x1bOA", dir: "up", ansiCode: 'A'}, // application mode + {key: "\x1bOB", dir: "down", ansiCode: 'B'}, // application mode + {key: "\x1b[1;2A", dir: "up", ansiCode: 'A'}, + } + for _, tc := range tests { + if !keyMatchesDirection(tc.key, tc.dir, tc.ansiCode) { + t.Fatalf("expected key %q to match %s", tc.key, tc.dir) + } + } +} + +func TestFilteredNavigationSkipsHiddenBranches(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Path: "root"}, + {Name: "keep", Depth: 1, Col: 0, Row: 1, Path: "root" + pathSeparator + "keep"}, + {Name: "drop", Depth: 1, Col: 40, Row: 1, Path: "root" + pathSeparator + "drop"}, + } + m.searchQuery = "keep" + m.recomputeFilterState() + m.selectedIdx = 1 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyRight}) + if m.selectedIdx != 1 { + t.Fatalf("expected sibling navigation to stay on visible filtered branch, got idx %d", m.selectedIdx) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyDown}) + if m.selectedIdx != 0 { + t.Fatalf("expected down key to move to visible root ancestor, got idx %d", m.selectedIdx) + } +} + +func TestZoomInUndoSingleLevelAndNestedEsc(t *testing.T) { + m := newZoomModel() + + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected zoomPath %q, got %q", want, got) + } + if len(m.zoomStack) != 1 || m.zoomStack[0].path != "" { + t.Fatalf("expected one zoom stack entry from root, got %#v", m.zoomStack) + } + if m.zoomRoot == nil || m.zoomRoot.Name != "A" { + t.Fatalf("expected zoomRoot A, got %+v", m.zoomRoot) + } + + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"+pathSeparator+"A1"; got != want { + t.Fatalf("expected nested zoomPath %q, got %q", want, got) + } + if len(m.zoomStack) != 2 || m.zoomStack[1].path != "root"+pathSeparator+"A" { + t.Fatalf("expected nested zoom stack to preserve parent path, got %#v", m.zoomStack) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { + t.Fatalf("expected zoomPath after esc undo %q, got %q", want, got) + } + if len(m.zoomStack) != 1 { + t.Fatalf("expected one stack entry after esc undo, got %d", len(m.zoomStack)) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 { + t.Fatalf("expected second esc undo to return to root state, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack)) + } +} + +func TestZoomResetToRoot(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.zoomPath == "" || len(m.zoomStack) == 0 { + t.Fatalf("expected nested zoom before reset") + } + + m.zoomReset() + if m.zoomPath != "" || m.zoomRoot != nil || len(m.zoomStack) != 0 { + t.Fatalf("expected explicit zoom reset to clear zoom stack, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack)) + } +} + +func TestZoomInOnCurrentRootSetsStatusMessage(t *testing.T) { + m := newZoomModel() + m.selectedIdx = mustFrameIndex(t, m.frames, "root") + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.zoomPath != "" { + t.Fatalf("expected zoom path to remain root, got %q", m.zoomPath) + } + if m.statusMessage != "Zoom unchanged: selected frame is current view root" { + t.Fatalf("unexpected status message: %q", m.statusMessage) + } +} + +func TestZoomTransitionAnimatesToNewLayout(t *testing.T) { + m := newZoomModel() + pathA := "root" + pathSeparator + "A" + preWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width + + m.selectedIdx = mustFrameIndex(t, m.frames, pathA) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.animating { + t.Fatalf("expected zoom-in to start animation") + } + currentWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width + targetWidth := m.targetFrames[mustFrameIndex(t, m.targetFrames, pathA)].Width + if currentWidth == targetWidth { + t.Fatalf("expected intermediate zoom frame width to differ from target (current=%d target=%d, pre=%d)", currentWidth, targetWidth, preWidth) + } + + for i := 0; i < 180 && m.animating; i++ { + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected zoom animation to settle within 180 ticks") + } + finalWidth := m.frames[mustFrameIndex(t, m.frames, pathA)].Width + if finalWidth != targetWidth { + t.Fatalf("expected final zoom width %d, got %d", targetWidth, finalWidth) + } +} + +func TestSearchLifecycleAndMatchNavigation(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "alpha", Path: "root" + pathSeparator + "alpha"}, + {Name: "beta", Path: "root" + pathSeparator + "beta"}, + {Name: "alphabet", Path: "root" + pathSeparator + "alphabet"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + if !m.searchActive { + t.Fatalf("expected search mode to activate on '/'") + } + for _, r := range []rune{'a', 'l', 'p'} { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + if m.searchActive { + t.Fatalf("expected search mode to close on enter") + } + if got := len(m.matchIndices); got != 2 { + t.Fatalf("expected 2 matches for 'alp', got %d", got) + } + first := m.selectedIdx + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'n'}[0], Text: "n"}) + if m.selectedIdx == first { + t.Fatalf("expected 'n' to jump to next match") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'N'}[0], Text: "N"}) + if m.selectedIdx != first { + t.Fatalf("expected 'N' to jump back to previous match") + } +} + +func TestSearchEscapeClearsState(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{{Name: "alpha", Path: "root" + pathSeparator + "alpha"}} + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + + if m.searchActive { + t.Fatalf("expected search mode to close on escape") + } + if m.searchQuery != "" || len(m.matchIndices) != 0 { + t.Fatalf("expected search state to reset on escape, got query=%q matches=%d", m.searchQuery, len(m.matchIndices)) + } + if m.statusMessage != "Filter cleared" { + t.Fatalf("expected filter cleared status message, got %q", m.statusMessage) + } +} + +func TestSearchSubmitSetsFilterStatusMessage(t *testing.T) { + m := NewModel(nil) + m.frames = []tuiFrame{ + {Name: "alpha", Path: "root" + pathSeparator + "alpha"}, + {Name: "beta", Path: "root" + pathSeparator + "beta"}, + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'a'}[0], Text: "a"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.statusMessage != `Filter "a": 2 matches` { + t.Fatalf("unexpected status after applying filter: %q", m.statusMessage) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'/'}[0], Text: "/"}) + for _, r := range []rune{'z', 'z'} { + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if m.statusMessage != `Filter "zz": no matches` { + t.Fatalf("unexpected status for unmatched filter: %q", m.statusMessage) + } +} + +func TestControlPauseToggle(t *testing.T) { + m := NewModel(nil) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + if !m.paused { + t.Fatalf("expected pause to toggle on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if m.paused { + t.Fatalf("expected space key to toggle pause off") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + if !m.paused { + t.Fatalf("expected space key to toggle pause on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + if m.paused { + t.Fatalf("expected p key to toggle pause off") + } +} + +func TestControlResetBaseline(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + m.snapshot = &snapshotNode{Name: "root", Total: 10} + m.frames = []tuiFrame{{Name: "root", Path: "root"}} + m.zoomPath = "root" + m.zoomStack = []zoomState{{path: "", previousSelectedIdx: 0}} + m.selectedIdx = 3 + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'r'}[0], Text: "r"}) + if m.snapshot != nil || len(m.frames) != 0 || len(m.zoomStack) != 0 || m.zoomPath != "" { + t.Fatalf("expected baseline reset to clear snapshot/layout/zoom state") + } + if m.statusMessage != "Baseline reset" { + t.Fatalf("expected reset status message, got %q", m.statusMessage) + } +} + +func TestViewIncludesSelectionStatusBar(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 100, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 40, Percent: 40, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 100 + + view := m.View().Content + if !strings.Contains(view, "[LIVE] sel:2/2 child") { + t.Fatalf("expected selection status bar to include selected frame info, got %q", view) + } + if !strings.Contains(view, "40.00% of total events") { + t.Fatalf("expected selection status bar to include selected share, got %q", view) + } +} + +func TestViewSelectionStatusUsesBytesLabelInBytesMode(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.countField = "bytes" + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 120, Total: 200, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 60, Total: 80, Percent: 40, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 200 + + view := m.View().Content + if !strings.Contains(view, "40.00% of total bytes") { + t.Fatalf("expected bytes-based selection share label, got %q", view) + } +} + +func TestViewFitsViewportHeightAndKeepsSearchFooterVisible(t *testing.T) { + m := NewModel(nil) + m.width = 100 + m.height = 12 + m.frames = []tuiFrame{ + {Name: "root", Depth: 0, Col: 0, Row: 0, Width: 100, Total: 100, Percent: 100, Path: "root"}, + {Name: "child", Depth: 1, Col: 0, Row: 1, Width: 80, Total: 80, Percent: 80, Path: "root" + pathSeparator + "child"}, + } + m.selectedIdx = 1 + m.globalTotal = 100 + m.searchActive = true + m.searchInput.SetValue("child") + + view := m.View().Content + lines := strings.Split(view, "\n") + if got, max := len(lines), m.height; got > max { + t.Fatalf("expected flame view to fit viewport height <=%d, got %d lines", max, got) + } + if !strings.Contains(view, "matches") { + t.Fatalf("expected search footer to remain visible in viewport, got %q", view) + } + if !strings.Contains(view, "[LIVE] sel:2/2 child") { + t.Fatalf("expected selection status line to remain visible, got %q", view) + } +} + +func TestViewFilterSelectionStatusUsesFilteredTotalAndKeepsContextVisible(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "keep", + Total: 60, + Children: []*snapshotNode{ + {Name: "needle", Total: 60}, + }, + }, + { + Name: "drop", + Total: 40, + Children: []*snapshotNode{ + {Name: "noise", Total: 40}, + }, + }, + }, + } + m := NewModel(nil) + m.width = 220 + m.height = 12 + m.frames = BuildTerminalLayout(snapshot, m.width, m.height) + m.globalTotal = 100 + m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"keep"+pathSeparator+"needle") + m.searchQuery = "needle" + m.recomputeFilterState() + + view := m.View().Content + if !strings.Contains(view, "100.00% of filtered events") { + t.Fatalf("expected filtered selection share in status line, got %q", view) + } + if !strings.Contains(view, "drop") || !strings.Contains(view, "noise") { + t.Fatalf("expected non-matching branches to remain visible while filtering, got %q", view) + } +} + +func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + initial := append([]string(nil), m.fieldPresets[m.fieldIndex]...) + expectedNextIdx := (m.fieldIndex + 1) % len(m.fieldPresets) + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'o'}[0], Text: "o"}) + if m.fieldIndex != expectedNextIdx { + t.Fatalf("expected field index to advance to %d, got %d", expectedNextIdx, m.fieldIndex) + } + next := m.fieldPresets[m.fieldIndex] + if reflect.DeepEqual(initial, next) { + t.Fatalf("expected next field preset to differ from initial") + } + if got := liveTrie.Fields(); !reflect.DeepEqual(got, next) { + t.Fatalf("expected live trie fields %v, got %v", next, got) + } +} + +func TestControlMetricToggleReconfiguresLiveTrieCountField(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) + if got, want := m.countField, "bytes"; got != want { + t.Fatalf("expected model count field %q, got %q", want, got) + } + if got, want := liveTrie.CountField(), "bytes"; got != want { + t.Fatalf("expected live trie count field %q, got %q", want, got) + } + if got, want := m.statusMessage, "Metric: bytes (new baseline)"; got != want { + t.Fatalf("expected metric toggle status %q, got %q", want, got) + } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) + if got, want := m.countField, "count"; got != want { + t.Fatalf("expected model count field %q after second toggle, got %q", want, got) + } + if got, want := liveTrie.CountField(), "count"; got != want { + t.Fatalf("expected live trie count field %q after second toggle, got %q", want, got) + } +} + +func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + m := NewModel(liveTrie) + if got, want := m.fieldPresets[m.fieldIndex], []string{"comm", "path", "tracepoint"}; !reflect.DeepEqual(got, want) { + t.Fatalf("expected model field preset to align with trie fields, got %v want %v", got, want) + } +} + +func TestNewModelAlignsCountFieldToLiveTrie(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "bytes") + m := NewModel(liveTrie) + if got, want := m.countField, "bytes"; got != want { + t.Fatalf("expected model count field to align with trie field, got %q want %q", got, want) + } +} + +func TestControlHelpToggle(t *testing.T) { + m := NewModel(nil) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) + if !m.showHelp { + t.Fatalf("expected help overlay to toggle on") + } + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) + if m.showHelp { + t.Fatalf("expected help overlay to toggle off") + } +} + +func TestDataRefreshAnimationConvergesOverTicks(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 20 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + {Name: "A", Total: 60}, + {Name: "B", Total: 40}, + }, + } + m.rebuildFrames(false) + initial := append([]tuiFrame(nil), m.frames...) + + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + {Name: "A", Total: 20}, + {Name: "B", Total: 80}, + }, + } + m.rebuildFrames(true) + if !m.animating { + t.Fatalf("expected animation to start after animated rebuild") + } + + next, _ := m.Update(animTickMsg{}) + m = next.(Model) + if len(m.frames) != len(initial) { + t.Fatalf("expected frame count to remain stable during animation") + } + + for i := 0; i < 180 && m.animating; i++ { + next, _ = m.Update(animTickMsg{}) + m = next.(Model) + } + if m.animating { + t.Fatalf("expected animation to settle within 180 ticks") + } + if len(m.frames) != len(m.targetFrames) { + t.Fatalf("expected settled frame count to match targets") + } + for i := range m.frames { + if m.frames[i].Width != m.targetFrames[i].Width || m.frames[i].Col != m.targetFrames[i].Col { + t.Fatalf("frame %d did not converge to target: got col=%d width=%d want col=%d width=%d", + i, m.frames[i].Col, m.frames[i].Width, m.targetFrames[i].Col, m.targetFrames[i].Width) + } + } +} + +func TestRebuildKeepsSelectionOnVisibleRowsWhenTruncated(t *testing.T) { + m := NewModel(nil) + m.width = 80 + m.height = 4 // only 2 render rows remain after toolbar+status + m.snapshot = &snapshotNode{ + Name: "root", + Children: []*snapshotNode{ + { + Name: "a", + Children: []*snapshotNode{ + { + Name: "b", + Children: []*snapshotNode{ + {Name: "c", Total: 5}, + }, + }, + }, + }, + }, + } + + m.rebuildFrames(false) + if len(m.frames) == 0 { + t.Fatalf("expected rebuilt frames") + } + rowOffset := m.visibleRowOffset() + if m.frames[m.selectedIdx].Row < rowOffset { + t.Fatalf("expected selected frame row %d to be visible (offset=%d)", m.frames[m.selectedIdx].Row, rowOffset) + } +} + +func TestResizeRecalculatesLayoutAndCullsNarrowFrames(t *testing.T) { + m := NewModel(nil) + m.width = 120 + m.height = 40 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "big", + Total: 99, + Children: []*snapshotNode{ + {Name: "deep", Total: 99}, + }, + }, + {Name: "tiny", Total: 1}, + }, + } + m.rebuildFrames(false) + _ = mustFrameIndex(t, m.frames, "root"+pathSeparator+"tiny") + + next, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = next.(Model) + for i := 0; i < 180 && m.animating; i++ { + next, _ = m.Update(animTickMsg{}) + m = next.(Model) + } + + for _, frame := range m.frames { + if frame.Col+frame.Width > 80 { + t.Fatalf("frame exceeds resized width: %+v", frame) + } + if frame.Row >= 24 { + t.Fatalf("frame row exceeds resized height: %+v", frame) + } + } + for _, frame := range m.frames { + if frame.Path == "root"+pathSeparator+"tiny" { + t.Fatalf("expected tiny frame to be culled at width 80") + } + } +} + +func newZoomModel() Model { + m := NewModel(nil) + m.width = 120 + m.height = 30 + m.snapshot = &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "A", + Total: 60, + Children: []*snapshotNode{ + {Name: "A1", Total: 30}, + {Name: "A2", Total: 30}, + }, + }, + {Name: "B", Total: 40}, + }, + } + m.rebuildFrames(false) + return m +} + +func mustFrameIndex(t *testing.T, frames []tuiFrame, path string) int { + t.Helper() + for idx, frame := range frames { + if frame.Path == path { + return idx + } + } + t.Fatalf("frame path %q not found", path) + return -1 +} + +func pressFlameKey(t *testing.T, m Model, keyMsg tea.KeyPressMsg) Model { + t.Helper() + next, _ := m.Update(keyMsg) + return next.(Model) +} diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go new file mode 100644 index 0000000..e4c4043 --- /dev/null +++ b/internal/tui/flamegraph/renderer.go @@ -0,0 +1,708 @@ +package flamegraph + +import ( + "fmt" + "hash/fnv" + "image/color" + "math" + "sort" + "strings" + "unicode/utf8" + + common "ior/internal/tui/common" + + "charm.land/lipgloss/v2" +) + +const pathSeparator = "\x1f" +const pathSeparatorByte = '\x1f' +const minFlameWidth = 60 +const maxBarVisualHeight = 3 + +// BuildTerminalLayout converts a live trie snapshot into terminal frame cells. +func BuildTerminalLayout(snapshot *snapshotNode, width, height int) []tuiFrame { + return buildTerminalLayoutWithPath(snapshot, width, height, "") +} + +func buildTerminalLayoutWithPath(snapshot *snapshotNode, width, height int, rootPath string) []tuiFrame { + if snapshot == nil || width <= 0 || height <= 0 { + return nil + } + rootTotal := snapshotTotal(snapshot) + if rootTotal == 0 { + return nil + } + + rootName := frameName(snapshot.Name, 0) + if rootPath != "" { + rootName = rootPath + } + frames := make([]tuiFrame, 0, len(snapshot.Children)+1) + collectTerminalLayout(&frames, snapshot, rootTotal, height, 0, 0, rootName, width) + return frames +} + +func collectTerminalLayout(out *[]tuiFrame, node *snapshotNode, rootTotal uint64, height, depth, col int, path string, span int) { + if node == nil || depth >= height { + return + } + total := snapshotTotal(node) + if total == 0 || span < 1 { + return + } + + name := frameName(node.Name, depth) + *out = append(*out, tuiFrame{ + Name: name, + Col: col, + Row: depth, + Width: span, + Total: total, + Percent: 100 * float64(total) / float64(rootTotal), + Fill: terminalFrameColor(name), + Depth: depth, + Path: path, + }) + + if len(node.Children) == 0 { + return + } + + childWidths := allocateChildWidths(node.Children, total, span) + cursor := col + for idx, child := range node.Children { + childWidth := childWidths[idx] + if childWidth < 1 { + continue + } + childName := frameName(child.Name, depth+1) + childPath := strings.Join([]string{path, childName}, pathSeparator) + collectTerminalLayout(out, child, rootTotal, height, depth+1, cursor, childPath, childWidth) + cursor += childWidth + } +} + +func allocateChildWidths(children []*snapshotNode, parentTotal uint64, span int) []int { + widths := make([]int, len(children)) + if span <= 0 || parentTotal == 0 || len(children) == 0 { + return widths + } + + type childWidth struct { + idx int + total uint64 + raw float64 + } + items := make([]childWidth, 0, len(children)) + used := 0 + for idx, child := range children { + total := snapshotTotal(child) + if total == 0 { + continue + } + raw := float64(span) * (float64(total) / float64(parentTotal)) + width := int(math.Floor(raw)) + if width > 0 { + widths[idx] = width + used += width + } + items = append(items, childWidth{idx: idx, total: total, raw: raw}) + } + if len(items) == 0 { + return widths + } + + // If proportional rounding culled every child, surface top contributors so + // the user can still navigate beyond the root frame. + if used == 0 { + sort.Slice(items, func(i, j int) bool { + if items[i].total == items[j].total { + return items[i].idx < items[j].idx + } + return items[i].total > items[j].total + }) + visible := min(span, len(items)) + for i := 0; i < visible; i++ { + widths[items[i].idx] = 1 + } + } + return widths +} + +func snapshotTotal(node *snapshotNode) uint64 { + if node == nil { + return 0 + } + total := node.Value + for _, child := range node.Children { + total += snapshotTotal(child) + } + if node.Total > total { + return node.Total + } + return total +} + +func frameName(name string, depth int) string { + if name != "" { + return name + } + if depth == 0 { + return "root" + } + return "(unknown)" +} + +func terminalFrameColor(name string) color.Color { + if semantic, ok := semanticFrameColor(name); ok { + return semantic + } + + hasher := fnv.New32a() + _, _ = hasher.Write([]byte(name)) + h := hasher.Sum32() + return color.RGBA{ + R: uint8(200 + int(h%35)), + G: uint8(80 + int((h>>8)%120)), + B: uint8(40 + int((h>>16)%90)), + A: 255, + } +} + +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, filterSet map[int]bool, globalTotal uint64, metricLabel string, isDark, searchActive bool, searchQuery string) string { + if width < minFlameWidth { + return common.PanelStyle.Render("Flame: terminal too narrow (need >= 60 columns)") + } + if height < 3 { + return common.PanelStyle.Render("Flame: viewport too short") + } + if len(frames) == 0 { + return common.PanelStyle.Render("Flame: waiting for data...") + } + if strings.TrimSpace(metricLabel) == "" { + metricLabel = "events" + } + + filterActive := strings.TrimSpace(searchQuery) != "" + if filterActive { + if filterSet == nil { + filterSet = computeFilterVisibleSetInto(frames, matchSet, nil) + } + if len(filterSet) == 0 { + return common.PanelStyle.Render(fmt.Sprintf("Flame: no frames match filter %q", searchQuery)) + } + } else { + filterSet = nil + } + + selectedIdx = normalizeSelectedIndex(frames, selectedIdx, filterSet) + selected := frames[selectedIdx] + viewPath := compactFramePath(frames[0].Path) + if subtreeSet == nil { + subtreeSet = computeSubtreeSet(frames, selectedIdx) + } + + availableRows := height - 2 // toolbar + status + maxRow := maxFrameRowForSet(frames, nil) + totalDepthRows := maxRow + 1 + barHeight := computeBarHeight(availableRows, totalDepthRows, maxBarVisualHeight) + visibleDepthRows := availableRows / barHeight + if visibleDepthRows < 1 { + visibleDepthRows = 1 + } + rowOffset := 0 + truncated := false + if maxRow+1 > visibleDepthRows { + rowOffset = maxRow + 1 - visibleDepthRows + truncated = true + } + + visibleFrames := countVisibleFrames(frames, nil) + toolbar := fmt.Sprintf("Flame | view:%s | frames:%d", viewPath, visibleFrames) + toolbar += fmt.Sprintf(" | rows:%d", availableRows) + if truncated { + toolbar += " | showing deepest levels" + } + toolbar = padOrTrim(toolbar, width) + selectedSystemShare := selected.Percent + if globalTotal > 0 { + selectedSystemShare = percentOfTotal(selected.Total, globalTotal) + } + if filterActive { + filterCoveredTotal, filterBaseTotal := filterCoverageTotals(frames, matchSet, globalTotal) + filterSystemShare := percentOfTotal(filterCoveredTotal, filterBaseTotal) + selectedFilterShare := 0.0 + if filterCoveredTotal > 0 { + selectedMatchTotal := filterCoverageTotalForPath(frames, matchSet, selected.Path) + selectedFilterShare = percentOfTotal(selectedMatchTotal, filterCoveredTotal) + } + matches := orderedMatchIndices(matchSet) + pos := 0 + if len(matches) > 0 { + if idx := indexOf(matches, selectedIdx); idx >= 0 { + pos = idx + 1 + } + } + frameCoverage := 0.0 + if len(frames) > 0 { + frameCoverage = 100 * float64(visibleFrames) / float64(len(frames)) + } + status := fmt.Sprintf("Filter %q: %.1f%% %s (%d/%d matches, %.1f%% frames shown) | Selected: %s total(%s)=%d depth=%d %.2f%% filtered %s", + searchQuery, filterSystemShare, metricLabel, pos, len(matches), frameCoverage, + selected.Name, metricLabel, selected.Total, selected.Depth, selectedFilterShare, metricLabel) + return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) + } else { + status := fmt.Sprintf("Selected: %s [%s] total(%s)=%d depth=%d col=%d width=%d share=%.2f%% %s", + selected.Name, compactFramePath(selected.Path), metricLabel, selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare, metricLabel) + return renderViewRows(toolbar, status, rowsForRender(frames, width, rowOffset, maxRow, barHeight, availableRows, selected.Path, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive), width) + } +} + +func rowsForRender(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { + return buildRenderRows(frames, width, rowOffset, maxRow, barHeight, availableRows, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive) +} + +func renderViewRows(toolbar, status string, rows []string, width int) string { + status = padOrTrim(status, width) + var b strings.Builder + b.Grow((width + 1) * (len(rows) + 2)) + b.WriteString(toolbar) + for _, row := range rows { + b.WriteString("\n") + b.WriteString(row) + } + b.WriteString("\n") + b.WriteString(status) + return b.String() +} + +type indexedFrame struct { + idx int + frame tuiFrame +} + +func buildRenderRows(frames []tuiFrame, width, rowOffset, maxRow, barHeight, availableRows int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) []string { + rowsByDepth := make(map[int][]indexedFrame) + for idx, frame := range frames { + if frame.Row < rowOffset || frame.Row > maxRow { + continue + } + rowsByDepth[frame.Row] = append(rowsByDepth[frame.Row], indexedFrame{idx: idx, frame: frame}) + } + + if barHeight < 1 { + barHeight = 1 + } + + rows := make([]string, 0, (maxRow-rowOffset+1)*barHeight) + for row := maxRow; row >= rowOffset; row-- { + framesAtRow := rowsByDepth[row] + sort.Slice(framesAtRow, func(i, j int) bool { + return framesAtRow[i].frame.Col < framesAtRow[j].frame.Col + }) + for repeat := 0; repeat < barHeight; repeat++ { + showLabels := repeat == barHeight/2 + rows = append(rows, renderRow(framesAtRow, width, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive, showLabels)) + } + } + + if availableRows > 0 { + if len(rows) > availableRows { + rows = rows[:availableRows] + } + if len(rows) < availableRows { + blank := strings.Repeat(" ", width) + pad := make([]string, 0, availableRows) + for i := 0; i < availableRows-len(rows); i++ { + pad = append(pad, blank) + } + pad = append(pad, rows...) + rows = pad + } + } + return rows +} + +func renderRow(frames []indexedFrame, width int, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive, showLabels bool) string { + if len(frames) == 0 { + return strings.Repeat(" ", width) + } + var b strings.Builder + b.Grow(width + 8) + cursor := 0 + for _, item := range frames { + frame := item.frame + if frame.Col >= width { + continue + } + if frame.Col > cursor { + gap := frame.Col - cursor + b.WriteString(strings.Repeat(" ", gap)) + cursor += gap + } + + cellWidth := frame.Width + if frame.Col+cellWidth > width { + cellWidth = width - frame.Col + } + if cellWidth <= 0 { + continue + } + label := strings.Repeat(" ", cellWidth) + if showLabels { + label = frameLabel(frame.Name, cellWidth, item.idx == selectedIdx, matchSet != nil && matchSet[item.idx]) + } + style := styleForFrame(item.idx, frame, selectedPath, subtreeSet, matchSet, selectedIdx, isDark, searchActive, filterActive) + cell := style.Render(label) + b.WriteString(cell) + cursor = frame.Col + cellWidth + } + if cursor < width { + b.WriteString(strings.Repeat(" ", width-cursor)) + } + return b.String() +} + +func computeSubtreeSet(frames []tuiFrame, selectedIdx int) map[int]bool { + return computeSubtreeSetInto(frames, selectedIdx, nil) +} + +func computeSubtreeSetInto(frames []tuiFrame, selectedIdx int, subtree map[int]bool) map[int]bool { + if subtree == nil { + subtree = make(map[int]bool) + } else { + for idx := range subtree { + delete(subtree, idx) + } + } + if selectedIdx < 0 || selectedIdx >= len(frames) { + return subtree + } + + selectedPath := frames[selectedIdx].Path + for idx, frame := range frames { + path := frame.Path + if path == selectedPath || + hasPathBoundaryPrefix(path, selectedPath) || + hasPathBoundaryPrefix(selectedPath, path) { + subtree[idx] = true + } + } + return subtree +} + +func hasPathBoundaryPrefix(value, prefix string) bool { + if len(value) <= len(prefix) { + return false + } + if !strings.HasPrefix(value, prefix) { + return false + } + return value[len(prefix)] == pathSeparatorByte +} + +func computeFilterVisibleSetInto(frames []tuiFrame, matchSet, visible map[int]bool) map[int]bool { + if visible == nil { + visible = make(map[int]bool) + } else { + for idx := range visible { + delete(visible, idx) + } + } + if len(matchSet) == 0 { + return visible + } + + matchPaths := make([]string, 0, len(matchSet)) + for idx := range matchSet { + if idx >= 0 && idx < len(frames) { + matchPaths = append(matchPaths, frames[idx].Path) + } + } + for idx, frame := range frames { + for _, matchPath := range matchPaths { + // Show matching frames and their full ancestry to root. + if frame.Path == matchPath || hasPathBoundaryPrefix(matchPath, frame.Path) { + visible[idx] = true + break + } + } + } + return visible +} + +func styleForFrame(idx int, frame tuiFrame, selectedPath string, subtreeSet, matchSet map[int]bool, selectedIdx int, isDark, searchActive, filterActive bool) lipgloss.Style { + _ = searchActive + base := lipgloss.NewStyle(). + Foreground(common.ColorBackground). + Background(frame.Fill) + + isSelected := idx == selectedIdx + inSubtree := subtreeSet[idx] + isMatch := matchSet != nil && matchSet[idx] + + matchColor := lipgloss.Color("160") + if !isDark { + matchColor = lipgloss.Color("124") + } + + if isSelected { + selectedBg := lipgloss.Color("129") + selectedFg := lipgloss.Color("15") + if !isDark { + selectedBg = lipgloss.Color("129") + selectedFg = lipgloss.Color("15") + } + return base.Background(selectedBg).Foreground(selectedFg).Bold(true).Underline(true) + } + + if isMatch { + style := base.Background(matchColor).Foreground(lipgloss.Color("15")) + if inSubtree { + return style.Bold(true) + } + return style.Faint(true) + } + + if filterActive { + return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true) + } + + if inSubtree { + if frameRelation(frame.Path, selectedPath) == relationAncestor { + return base.BorderLeft(true).BorderForeground(common.ColorAccent) + } + return base + } + + return base.Background(common.ColorPanel).Foreground(common.ColorMuted).Faint(true) +} + +func frameLabel(name string, width int, isSelected, isMatch bool) string { + if width <= 0 { + return "" + } + if isSelected { + if width == 1 { + return ">" + } + return ">" + padOrTrim(name, width-2) + "<" + } + if isMatch { + if width == 1 { + return "*" + } + return "*" + padOrTrim(name, width-1) + } + return padOrTrim(name, width) +} + +func compactFramePath(path string) string { + if path == "" { + return "root" + } + parts := strings.Split(path, pathSeparator) + if len(parts) <= 3 { + return strings.Join(parts, "/") + } + return strings.Join([]string{parts[0], "...", parts[len(parts)-1]}, "/") +} + +type relation int + +const ( + relationNone relation = iota + relationAncestor + relationDescendant +) + +func frameRelation(path, selectedPath string) relation { + if path == selectedPath { + return relationDescendant + } + if strings.HasPrefix(selectedPath, path+pathSeparator) { + return relationAncestor + } + if strings.HasPrefix(path, selectedPath+pathSeparator) { + return relationDescendant + } + return relationNone +} + +func maxFrameRow(frames []tuiFrame) int { + return maxFrameRowForSet(frames, nil) +} + +func maxFrameRowForSet(frames []tuiFrame, include map[int]bool) int { + maxRow := 0 + for idx, frame := range frames { + if include != nil && !include[idx] { + continue + } + if frame.Row > maxRow { + maxRow = frame.Row + } + } + return maxRow +} + +func countVisibleFrames(frames []tuiFrame, include map[int]bool) int { + if include == nil { + return len(frames) + } + count := 0 + for idx := range frames { + if include[idx] { + count++ + } + } + return count +} + +func normalizeSelectedIndex(frames []tuiFrame, selectedIdx int, include map[int]bool) int { + if len(frames) == 0 { + return 0 + } + if selectedIdx >= 0 && selectedIdx < len(frames) && (include == nil || include[selectedIdx]) { + return selectedIdx + } + if include != nil { + for idx := range frames { + if include[idx] { + return idx + } + } + } + return 0 +} + +func filterSampleCoverage(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) float64 { + coveredTotal, rootTotal := filterCoverageTotals(frames, matchSet, totalBase) + return percentOfTotal(coveredTotal, rootTotal) +} + +func computeBarHeight(availableRows, depthRows, maxHeight int) int { + if availableRows <= 0 || depthRows <= 0 { + return 1 + } + height := availableRows / depthRows + if height < 1 { + height = 1 + } + if maxHeight > 0 && height > maxHeight { + height = maxHeight + } + return height +} + +func filterCoverageTotals(frames []tuiFrame, matchSet map[int]bool, totalBase uint64) (coveredTotal uint64, rootTotal uint64) { + if len(frames) == 0 || len(matchSet) == 0 { + return 0, 0 + } + rootTotal = totalBase + if rootTotal == 0 { + rootTotal = frames[0].Total + } + if rootTotal == 0 { + return 0, 0 + } + roots := compactMatchRoots(frames, matchSet) + for _, root := range roots { + coveredTotal += root.total + } + return coveredTotal, rootTotal +} + +func filterCoverageTotalForPath(frames []tuiFrame, matchSet map[int]bool, path string) uint64 { + if path == "" || len(frames) == 0 || len(matchSet) == 0 { + return 0 + } + roots := compactMatchRoots(frames, matchSet) + var coveredTotal uint64 + for _, root := range roots { + if root.path == path || hasPathBoundaryPrefix(root.path, path) { + coveredTotal += root.total + } + } + return coveredTotal +} + +type matchRoot struct { + path string + total uint64 +} + +func compactMatchRoots(frames []tuiFrame, matchSet map[int]bool) []matchRoot { + roots := make([]matchRoot, 0, len(matchSet)) + for idx := range matchSet { + if idx < 0 || idx >= len(frames) { + continue + } + roots = append(roots, matchRoot{ + path: frames[idx].Path, + total: frames[idx].Total, + }) + } + sort.Slice(roots, func(i, j int) bool { + return len(roots[i].path) < len(roots[j].path) + }) + merged := make([]matchRoot, 0, len(roots)) + for _, candidate := range roots { + covered := false + for _, root := range merged { + if candidate.path == root.path || hasPathBoundaryPrefix(candidate.path, root.path) { + covered = true + break + } + } + if covered { + continue + } + merged = append(merged, candidate) + } + return merged +} + +func percentOfTotal(value, total uint64) float64 { + if total == 0 { + return 0 + } + return 100 * float64(value) / float64(total) +} + +func padOrTrim(s string, width int) string { + if width <= 0 { + return "" + } + if utf8.RuneCountInString(s) <= width { + return s + strings.Repeat(" ", width-utf8.RuneCountInString(s)) + } + if width == 1 { + return "…" + } + r := []rune(s) + return string(r[:width-1]) + "…" +} diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go new file mode 100644 index 0000000..c546200 --- /dev/null +++ b/internal/tui/flamegraph/renderer_test.go @@ -0,0 +1,368 @@ +package flamegraph + +import ( + "image/color" + "strings" + "testing" +) + +func TestBuildTerminalLayoutWidthScaling(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "A", + Total: 60, + Children: []*snapshotNode{ + {Name: "A1", Total: 30}, + {Name: "A2", Total: 30}, + }, + }, + {Name: "B", Total: 40}, + }, + } + + tests := []struct { + width int + wantA int + wantB int + wantA1 int + wantA2 int + wantAll int + }{ + {width: 80, wantA: 48, wantB: 32, wantA1: 24, wantA2: 24, wantAll: 5}, + {width: 120, wantA: 72, wantB: 48, wantA1: 36, wantA2: 36, wantAll: 5}, + {width: 200, wantA: 120, wantB: 80, wantA1: 60, wantA2: 60, wantAll: 5}, + } + + for _, tc := range tests { + frames := BuildTerminalLayout(snapshot, tc.width, 10) + if len(frames) != tc.wantAll { + t.Fatalf("width %d: expected %d frames, got %d", tc.width, tc.wantAll, len(frames)) + } + root := mustFindFrame(t, frames, "root") + if root.Width != tc.width || root.Row != 0 || root.Col != 0 { + t.Fatalf("width %d: unexpected root frame %+v", tc.width, root) + } + a := mustFindFrame(t, frames, "root"+pathSeparator+"A") + b := mustFindFrame(t, frames, "root"+pathSeparator+"B") + a1 := mustFindFrame(t, frames, "root"+pathSeparator+"A"+pathSeparator+"A1") + a2 := mustFindFrame(t, frames, "root"+pathSeparator+"A"+pathSeparator+"A2") + + if a.Width != tc.wantA || b.Width != tc.wantB { + t.Fatalf("width %d: unexpected child widths A=%d B=%d", tc.width, a.Width, b.Width) + } + if a1.Width != tc.wantA1 || a2.Width != tc.wantA2 { + t.Fatalf("width %d: unexpected grandchild widths A1=%d A2=%d", tc.width, a1.Width, a2.Width) + } + if b.Col != a.Col+a.Width { + t.Fatalf("width %d: expected B col %d, got %d", tc.width, a.Col+a.Width, b.Col) + } + } +} + +func TestBuildTerminalLayoutCullsSubCellFramesAndRespectsHeight(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "big", + Total: 99, + Children: []*snapshotNode{ + {Name: "deep", Total: 99}, + }, + }, + {Name: "tiny", Total: 1}, + }, + } + + frames := BuildTerminalLayout(snapshot, 80, 2) + if hasFrame(frames, "root"+pathSeparator+"tiny") { + t.Fatalf("expected tiny frame to be culled (<1 terminal cell)") + } + if hasFrame(frames, "root"+pathSeparator+"big"+pathSeparator+"deep") { + t.Fatalf("expected deep frame to be omitted due height limit") + } + if !hasFrame(frames, "root"+pathSeparator+"big") { + t.Fatalf("expected big frame to be present") + } +} + +func TestBuildTerminalLayoutKeepsChildrenVisibleWhenRoundingWouldCullAll(t *testing.T) { + children := make([]*snapshotNode, 0, 200) + for i := 0; i < 200; i++ { + children = append(children, &snapshotNode{Name: "c", Total: 1}) + } + snapshot := &snapshotNode{Name: "root", Children: children} + + frames := BuildTerminalLayout(snapshot, 120, 6) + depthOne := 0 + for _, frame := range frames { + if frame.Depth == 1 { + depthOne++ + } + } + if depthOne == 0 { + t.Fatalf("expected at least one visible depth-1 frame, got none") + } +} + +func TestBuildTerminalLayoutUsesPathSeparatorAndColor(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + + frames := BuildTerminalLayout(snapshot, 80, 4) + child := mustFindFrame(t, frames, "root"+pathSeparator+"child") + if !strings.Contains(child.Path, pathSeparator) { + t.Fatalf("expected path %q to contain separator %q", child.Path, pathSeparator) + } + if child.Fill == nil { + t.Fatalf("expected frame color to be set") + } +} + +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, nil, 0, "events", true, false, "") + if !strings.Contains(out, "terminal too narrow") { + t.Fatalf("expected narrow terminal warning, got %q", out) + } +} + +func TestComputeBarHeightCappedAtThree(t *testing.T) { + if got := computeBarHeight(30, 4, 3); got != 3 { + t.Fatalf("expected bar height cap at 3, got %d", got) + } + if got := computeBarHeight(5, 10, 3); got != 1 { + t.Fatalf("expected bar height minimum 1 when depth exceeds rows, got %d", got) + } +} + +func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + frames := BuildTerminalLayout(snapshot, 80, 6) + + out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, "events", true, false, "") + if !strings.Contains(out, "Flame | view:root | frames:2") { + t.Fatalf("expected toolbar to include frame count, got %q", out) + } + if !strings.Contains(out, "Selected: child") { + t.Fatalf("expected status line to show selected frame, got %q", out) + } +} + +func TestRenderTerminalViewFillsAvailableHeightForShallowTree(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + frames := BuildTerminalLayout(snapshot, 100, 20) + + out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, "events", true, false, "") + lines := strings.Split(out, "\n") + if got, want := len(lines), 20; got != want { + t.Fatalf("expected render to fill viewport height (%d lines), got %d", want, got) + } +} + +func TestFrameLabelAddsSelectionAndMatchMarkers(t *testing.T) { + if got := frameLabel("child", 7, true, false); got != ">child<" { + t.Fatalf("expected selected marker label, got %q", got) + } + if got := frameLabel("child", 6, false, true); got != "*child" { + t.Fatalf("expected match marker label, got %q", got) + } +} + +func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 10, + Children: []*snapshotNode{ + {Name: "child", Total: 10}, + }, + } + frames := BuildTerminalLayout(snapshot, 80, 6) + matchSet := map[int]bool{1: true} + + out := RenderTerminalView(frames, 140, 6, 1, nil, matchSet, nil, 0, "events", true, false, "child") + if !strings.Contains(out, `Filter "child"`) { + t.Fatalf("expected filter context in status line, got %q", out) + } +} + +func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 100, + Children: []*snapshotNode{ + { + Name: "keep", + Total: 60, + Children: []*snapshotNode{ + {Name: "needle", Total: 60}, + }, + }, + { + Name: "drop", + Total: 40, + Children: []*snapshotNode{ + {Name: "noise", Total: 40}, + }, + }, + }, + } + frames := BuildTerminalLayout(snapshot, 80, 8) + needleIdx := frameIndexByPathRenderer(frames, "root"+pathSeparator+"keep"+pathSeparator+"needle") + if needleIdx < 0 { + t.Fatalf("expected needle frame in layout") + } + matchSet := map[int]bool{needleIdx: true} + + out := RenderTerminalView(frames, 180, 8, needleIdx, nil, matchSet, nil, 100, "bytes", true, false, "needle") + if !strings.Contains(out, `Filter "needle": 60.0% bytes`) { + t.Fatalf("expected filter status to report 60.0%% bytes share, got %q", out) + } + if !strings.Contains(out, "keep") || !strings.Contains(out, "needle") { + t.Fatalf("expected matching branch to remain visible, got %q", out) + } + if !strings.Contains(out, "drop") || !strings.Contains(out, "noise") { + t.Fatalf("expected non-matching branch to remain visible (greyed), got %q", out) + } + if !strings.Contains(out, "100.00% filtered bytes") { + t.Fatalf("expected selected match share to be computed against filtered total, got %q", out) + } +} + +func TestFilterSampleCoverageAvoidsDoubleCountingNestedMatches(t *testing.T) { + frames := []tuiFrame{ + {Path: "root", Total: 100}, + {Path: "root" + pathSeparator + "A", Total: 60}, + {Path: "root" + pathSeparator + "A" + pathSeparator + "A1", Total: 30}, + {Path: "root" + pathSeparator + "B", Total: 40}, + } + matchSet := map[int]bool{ + 1: true, // A + 2: true, // A1 (nested under A) + } + if got := filterSampleCoverage(frames, matchSet, 100); got != 60 { + t.Fatalf("expected nested matches to count once at 60%%, got %.1f%%", got) + } +} + +func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { + snapshot := &snapshotNode{ + Name: "root", + Total: 4, + Children: []*snapshotNode{ + { + Name: "a", + Total: 4, + Children: []*snapshotNode{ + { + Name: "b", + Total: 4, + Children: []*snapshotNode{ + { + Name: "c", + Total: 4, + Children: []*snapshotNode{ + {Name: "d", Total: 4}, + }, + }, + }, + }, + }, + }, + }, + } + frames := BuildTerminalLayout(snapshot, 80, 10) + out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, "events", true, false, "") + if !strings.Contains(out, "showing deepest levels") { + t.Fatalf("expected truncation hint in toolbar, got %q", out) + } +} + +func TestComputeSubtreeSetIncludesAncestorsAndDescendants(t *testing.T) { + frames := []tuiFrame{ + {Path: "root"}, + {Path: "root" + pathSeparator + "A"}, + {Path: "root" + pathSeparator + "A" + pathSeparator + "A1"}, + {Path: "root" + pathSeparator + "B"}, + } + + set := computeSubtreeSet(frames, 1) + if !set[0] || !set[1] || !set[2] { + t.Fatalf("expected root/A/A1 to be in selected subtree: %#v", set) + } + if set[3] { + t.Fatalf("did not expect sibling branch B in subtree: %#v", set) + } +} + +func mustFindFrame(t *testing.T, frames []tuiFrame, path string) tuiFrame { + t.Helper() + for _, frame := range frames { + if frame.Path == path { + return frame + } + } + t.Fatalf("frame with path %q not found", path) + return tuiFrame{} +} + +func hasFrame(frames []tuiFrame, path string) bool { + for _, frame := range frames { + if frame.Path == path { + return true + } + } + return false +} + +func frameIndexByPathRenderer(frames []tuiFrame, path string) int { + for idx, frame := range frames { + if frame.Path == path { + return idx + } + } + return -1 +} diff --git a/internal/tui/flamegraph/search.go b/internal/tui/flamegraph/search.go new file mode 100644 index 0000000..6bedc3e --- /dev/null +++ b/internal/tui/flamegraph/search.go @@ -0,0 +1,141 @@ +package flamegraph + +import ( + "fmt" + "sort" + "strings" +) + +func (m *Model) openSearch() { + m.searchActive = true + m.searchInput.SetValue(m.searchQuery) + m.searchInput.CursorEnd() + m.searchInput.Focus() +} + +func (m *Model) clearSearch() { + m.searchActive = false + m.searchQuery = "" + clearBoolMap(m.matchIndices) + clearBoolMap(m.filterVisible) + m.searchInput.SetValue("") + m.searchInput.Blur() + m.statusMessage = "Filter cleared" +} + +func (m *Model) applySearchQuery(raw string) { + m.searchQuery = strings.ToLower(strings.TrimSpace(raw)) + m.recomputeFilterState() + query := m.searchQuery + if query == "" { + m.ensureSelectionNavigable() + m.statusMessage = "Filter cleared" + return + } + + if len(m.matchIndices) > 0 { + m.jumpMatch(1) + m.statusMessage = fmt.Sprintf("Filter %q: %d matches", query, len(m.matchIndices)) + return + } + m.statusMessage = fmt.Sprintf("Filter %q: no matches", query) +} + +func (m *Model) jumpMatch(direction int) { + matches := orderedMatchIndices(m.matchIndices) + if len(matches) == 0 { + return + } + currentPos := indexOf(matches, m.selectedIdx) + if currentPos == -1 { + if direction < 0 { + m.selectedIdx = matches[len(matches)-1] + } else { + m.selectedIdx = matches[0] + } + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) + return + } + + next := currentPos + direction + if next < 0 { + next = len(matches) - 1 + } + if next >= len(matches) { + next = 0 + } + m.selectedIdx = matches[next] + m.subtreeSet = computeSubtreeSetInto(m.frames, m.selectedIdx, m.subtreeSet) +} + +func (m *Model) recomputeFilterState() { + if m.matchIndices == nil { + m.matchIndices = make(map[int]bool) + } else { + clearBoolMap(m.matchIndices) + } + if m.filterVisible == nil { + m.filterVisible = make(map[int]bool) + } else { + clearBoolMap(m.filterVisible) + } + if m.searchQuery == "" { + return + } + + for idx, frame := range m.frames { + if strings.Contains(strings.ToLower(frame.Name), m.searchQuery) { + m.matchIndices[idx] = true + } + } + m.filterVisible = computeFilterVisibleSetInto(m.frames, m.matchIndices, m.filterVisible) +} + +func orderedMatchIndices(matchSet map[int]bool) []int { + matches := make([]int, 0, len(matchSet)) + for idx := range matchSet { + matches = append(matches, idx) + } + sort.Ints(matches) + return matches +} + +func (m Model) searchFooter() string { + matches := orderedMatchIndices(m.matchIndices) + pos := 0 + if len(matches) > 0 { + idx := indexOf(matches, m.selectedIdx) + if idx >= 0 { + pos = idx + 1 + } + } + return fmt.Sprintf("%s %d/%d matches", m.searchInput.View(), pos, len(matches)) +} + +func replaceFooterLine(content, footer string) string { + if content == "" { + return footer + } + lastNewline := strings.LastIndexByte(content, '\n') + if lastNewline == -1 { + return footer + } + return content[:lastNewline+1] + footer +} + +func replaceHeaderLine(content, header string) string { + if content == "" { + return header + } + firstNewline := strings.IndexByte(content, '\n') + if firstNewline == -1 { + return header + } + return header + content[firstNewline:] +} + +func clearBoolMap[K comparable](values map[K]bool) { + for key := range values { + delete(values, key) + } +} diff --git a/internal/tui/flamegraph/stress_race_disabled_test.go b/internal/tui/flamegraph/stress_race_disabled_test.go new file mode 100644 index 0000000..c9769fd --- /dev/null +++ b/internal/tui/flamegraph/stress_race_disabled_test.go @@ -0,0 +1,7 @@ +//go:build !race + +package flamegraph + +func stressBudgetMultiplier() int { + return 1 +} diff --git a/internal/tui/flamegraph/stress_race_enabled_test.go b/internal/tui/flamegraph/stress_race_enabled_test.go new file mode 100644 index 0000000..30338f4 --- /dev/null +++ b/internal/tui/flamegraph/stress_race_enabled_test.go @@ -0,0 +1,7 @@ +//go:build race + +package flamegraph + +func stressBudgetMultiplier() int { + return 3 +} diff --git a/internal/tui/flamegraph/stress_test.go b/internal/tui/flamegraph/stress_test.go new file mode 100644 index 0000000..e53e4d5 --- /dev/null +++ b/internal/tui/flamegraph/stress_test.go @@ -0,0 +1,236 @@ +package flamegraph + +import ( + "encoding/json" + "fmt" + "math/rand" + "sync" + "testing" + "time" + + coreflamegraph "ior/internal/flamegraph" + "ior/internal/types" + + tea "charm.land/bubbletea/v2" +) + +func TestStressHighEventRate(t *testing.T) { + t.Parallel() + + const ( + workerCount = 10 + eventsPerWorker = 10000 + testDuration = 5 * time.Second + renderFPS = 30 + frameBudget = time.Second / renderFPS + ) + allowedBudget := frameBudget * time.Duration(stressBudgetMultiplier()) + + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + var ingestWG sync.WaitGroup + + type renderMetrics struct { + err error + samples int + total time.Duration + maxDuration time.Duration + } + renderDone := make(chan renderMetrics, 1) + + go func() { + ticker := time.NewTicker(frameBudget) + defer ticker.Stop() + deadline := time.NewTimer(testDuration) + defer deadline.Stop() + + metrics := renderMetrics{} + for { + select { + case <-ticker.C: + start := time.Now() + payload, _ := liveTrie.SnapshotJSON() + var snapshot snapshotNode + if err := json.Unmarshal(payload, &snapshot); err != nil { + metrics.err = fmt.Errorf("decode snapshot: %w", err) + renderDone <- metrics + return + } + frames := BuildTerminalLayout(&snapshot, 120, 40) + _ = frames + + elapsed := time.Since(start) + metrics.samples++ + metrics.total += elapsed + if elapsed > metrics.maxDuration { + metrics.maxDuration = elapsed + } + case <-deadline.C: + renderDone <- metrics + return + } + } + }() + + for worker := 0; worker < workerCount; worker++ { + worker := worker + ingestWG.Add(1) + go func() { + defer ingestWG.Done() + for i := 0; i < eventsPerWorker; i++ { + seed := worker*eventsPerWorker + i + traceID := types.SYS_ENTER_READ + if seed%2 == 0 { + traceID = types.SYS_ENTER_WRITE + } + pair := newBenchmarkPair( + fmt.Sprintf("worker-%d", worker), + traceID, + uint32(1000+worker), + uint32(200000+seed), + buildBenchmarkPath(6, 3, seed), + ) + liveTrie.Ingest(pair) + pair.Recycle() + } + }() + } + + ingestWG.Wait() + metrics := <-renderDone + + if metrics.err != nil { + t.Fatalf("render loop failed: %v", metrics.err) + } + if metrics.samples == 0 { + t.Fatal("render loop produced no samples") + } + avg := metrics.total / time.Duration(metrics.samples) + if avg > allowedBudget { + t.Fatalf("average render latency exceeded frame budget: avg=%s budget=%s samples=%d", avg, allowedBudget, metrics.samples) + } + if metrics.maxDuration > allowedBudget*6 { + t.Fatalf("max render latency too high: max=%s budget=%s", metrics.maxDuration, allowedBudget) + } +} + +func TestStressRapidResize(t *testing.T) { + t.Parallel() + + model := NewModel(nil) + model.width = 120 + model.height = 40 + model.snapshot = generateTestSnapshot(fixtureMediumDepth, fixtureMediumBreadth) + model.rebuildFrames(false) + if len(model.frames) == 0 { + t.Fatal("expected initial medium fixture frames") + } + + rng := rand.New(rand.NewSource(42)) + lastWidth, lastHeight := model.width, model.height + for i := 0; i < 100; i++ { + lastWidth = 60 + rng.Intn(241) // [60, 300] + lastHeight = 20 + rng.Intn(61) // [20, 80] + next, _ := model.Update(tea.WindowSizeMsg{Width: lastWidth, Height: lastHeight}) + model = next.(Model) + model = settleStressAnimation(model, 180) + + assertFramesWithinBounds(t, model.frames, lastWidth, lastHeight) + if len(model.frames) > 0 && (model.selectedIdx < 0 || model.selectedIdx >= len(model.frames)) { + t.Fatalf("invalid selectedIdx after resize %d: idx=%d frames=%d", i, model.selectedIdx, len(model.frames)) + } + } + + if model.width != lastWidth || model.height != lastHeight { + t.Fatalf("final viewport mismatch: got %dx%d want %dx%d", model.width, model.height, lastWidth, lastHeight) + } + assertFramesWithinBounds(t, model.frames, lastWidth, lastHeight) +} + +func TestStressZoomDuringRefresh(t *testing.T) { + t.Parallel() + + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + ingestStressEvents(liveTrie, 200, 0) + + model := NewModel(liveTrie) + model.SetViewport(120, 40) + if changed := model.RefreshFromLiveTrie(); !changed { + t.Fatal("expected initial live trie refresh") + } + if len(model.frames) == 0 { + t.Fatal("expected initial frames after refresh") + } + + for i := 0; i < 50; i++ { + ingestStressEvents(liveTrie, 20, 1000+i*20) + _ = model.RefreshFromLiveTrie() + model = settleStressAnimation(model, 180) + if len(model.frames) == 0 { + t.Fatalf("expected frames after refresh tick %d", i) + } + + prevDepth := len(model.zoomStack) + model.selectedIdx = midDepthFrameIndex(model.frames) + model.zoomIn() + model = settleStressAnimation(model, 180) + if len(model.zoomStack) != prevDepth+1 { + t.Fatalf("zoom stack did not grow after zoom-in at iteration %d: got=%d want=%d", i, len(model.zoomStack), prevDepth+1) + } + + model.zoomUndo() + model = settleStressAnimation(model, 180) + if len(model.zoomStack) != prevDepth { + t.Fatalf("zoom stack depth mismatch after undo at iteration %d: got=%d want=%d", i, len(model.zoomStack), prevDepth) + } + if model.zoomPath != "" { + if findNodeByPath(model.snapshot, model.zoomPath) == nil { + t.Fatalf("zoomPath became invalid after undo at iteration %d: %q", i, model.zoomPath) + } + } + assertFramesWithinBounds(t, model.frames, model.width, model.height) + } +} + +func settleStressAnimation(model Model, maxTicks int) Model { + for i := 0; i < maxTicks && model.animating; i++ { + next, _ := model.Update(animTickMsg{}) + model = next.(Model) + } + return model +} + +func assertFramesWithinBounds(t *testing.T, frames []tuiFrame, width, height int) { + t.Helper() + for _, frame := range frames { + if frame.Col < 0 || frame.Width <= 0 { + t.Fatalf("invalid frame geometry: %+v", frame) + } + if frame.Col+frame.Width > width { + t.Fatalf("frame exceeds width %d: %+v", width, frame) + } + if frame.Row < 0 || frame.Row >= height { + t.Fatalf("frame row outside height %d: %+v", height, frame) + } + } +} + +func ingestStressEvents(liveTrie *coreflamegraph.LiveTrie, count, seedBase int) { + for i := 0; i < count; i++ { + seed := seedBase + i + traceID := types.SYS_ENTER_READ + if seed%3 == 0 { + traceID = types.SYS_ENTER_OPENAT + } else if seed%2 == 0 { + traceID = types.SYS_ENTER_WRITE + } + pair := newBenchmarkPair( + fmt.Sprintf("stress-%d", seed%8), + traceID, + uint32(1200+(seed%64)), + uint32(300000+seed), + buildBenchmarkPath(9, 5, seed), + ) + liveTrie.Ingest(pair) + pair.Recycle() + } +} diff --git a/internal/tui/flamegraph/testdata_fixture_test.go b/internal/tui/flamegraph/testdata_fixture_test.go new file mode 100644 index 0000000..1f22c26 --- /dev/null +++ b/internal/tui/flamegraph/testdata_fixture_test.go @@ -0,0 +1,39 @@ +package flamegraph + +import "testing" + +func TestFixtureSnapshotsHaveApproximateFrameCounts(t *testing.T) { + fixtures := []struct { + name string + depth int + breadth int + expect int + }{ + {name: "small", depth: fixtureSmallDepth, breadth: fixtureSmallBreadth, expect: 121}, + {name: "medium", depth: fixtureMediumDepth, breadth: fixtureMediumBreadth, expect: 2500}, + {name: "large", depth: fixtureLargeDepth, breadth: fixtureLargeBreadth, expect: 12000}, + {name: "deep", depth: fixtureDeepDepth, breadth: fixtureDeepBreadth, expect: 100}, + {name: "wide", depth: fixtureWideDepth, breadth: fixtureWideBreadth, expect: 5000}, + } + + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + snap := generateTestSnapshot(fixture.depth, fixture.breadth) + got := snapshotNodeCount(snap) + if !approxEqualCount(got, fixture.expect) { + t.Fatalf("%s fixture nodes=%d, expected approximately %d", fixture.name, got, fixture.expect) + } + }) + } +} + +func TestGenerateTestTrieProducesSnapshotData(t *testing.T) { + lt := generateTestTrie(fixtureSmallDepth, fixtureSmallBreadth) + snap, err := decodeTrieSnapshot(lt) + if err != nil { + t.Fatalf("decode trie snapshot: %v", err) + } + if snap.Total == 0 { + t.Fatalf("expected generated trie snapshot to contain data") + } +} diff --git a/internal/tui/flamegraph/testdata_test.go b/internal/tui/flamegraph/testdata_test.go new file mode 100644 index 0000000..c7d97b0 --- /dev/null +++ b/internal/tui/flamegraph/testdata_test.go @@ -0,0 +1,185 @@ +package flamegraph + +import ( + "encoding/json" + "fmt" + "math" + + "ior/internal/event" + "ior/internal/file" + coreflamegraph "ior/internal/flamegraph" + "ior/internal/types" +) + +const ( + fixtureSmallDepth = 5 + fixtureSmallBreadth = 3 + + fixtureMediumDepth = 10 + fixtureMediumBreadth = 5 + + fixtureLargeDepth = 15 + fixtureLargeBreadth = 8 + + fixtureDeepDepth = 50 + fixtureDeepBreadth = 2 + + fixtureWideDepth = 3 + fixtureWideBreadth = 50 +) + +func generateTestTrie(depth, breadthPerLevel int) *coreflamegraph.LiveTrie { + lt := coreflamegraph.NewLiveTrie([]string{"comm", "path", "tracepoint"}, "count") + comms := []string{"api", "db", "worker", "cache"} + traceIDs := []types.TraceId{ + types.SYS_ENTER_READ, + types.SYS_ENTER_WRITE, + types.SYS_ENTER_OPENAT, + types.SYS_ENTER_CLOSE, + } + + totalEvents := maxInt(100, fixtureTargetFrames(depth, breadthPerLevel)/2) + for i := 0; i < totalEvents; i++ { + comm := comms[i%len(comms)] + traceID := traceIDs[i%len(traceIDs)] + path := buildBenchmarkPath(depth, breadthPerLevel, i) + lt.Ingest(newBenchmarkPair(comm, traceID, uint32(1000+(i%256)), uint32(200000+i), path)) + } + return lt +} + +func generateTestSnapshot(depth, breadthPerLevel int) *snapshotNode { + targetFrames := fixtureTargetFrames(depth, breadthPerLevel) + if targetFrames < 1 { + targetFrames = 1 + } + + root := &snapshotNode{Name: "root", Value: 1} + type qItem struct { + node *snapshotNode + depth int + } + queue := []qItem{{node: root, depth: 0}} + created := 1 + + for len(queue) > 0 && created < targetFrames { + item := queue[0] + queue = queue[1:] + if item.depth >= depth { + continue + } + remaining := targetFrames - created + branchCount := breadthPerLevel + if branchCount > remaining { + branchCount = remaining + } + for i := 0; i < branchCount; i++ { + child := &snapshotNode{ + Name: fmt.Sprintf("d%d-n%d", item.depth+1, created+i), + Value: 1, + } + item.node.Children = append(item.node.Children, child) + queue = append(queue, qItem{node: child, depth: item.depth + 1}) + } + created += branchCount + } + + computeSnapshotTotals(root) + return root +} + +func fixtureTargetFrames(depth, breadth int) int { + switch { + case depth == fixtureSmallDepth && breadth == fixtureSmallBreadth: + return 121 + case depth == fixtureMediumDepth && breadth == fixtureMediumBreadth: + return 2500 + case depth == fixtureLargeDepth && breadth == fixtureLargeBreadth: + return 12000 + case depth == fixtureDeepDepth && breadth == fixtureDeepBreadth: + return 100 + case depth == fixtureWideDepth && breadth == fixtureWideBreadth: + return 5000 + default: + return maxInt(1, depth*breadth*10) + } +} + +func computeSnapshotTotals(node *snapshotNode) uint64 { + if node == nil { + return 0 + } + total := node.Value + for _, child := range node.Children { + total += computeSnapshotTotals(child) + } + node.Total = total + return total +} + +func buildBenchmarkPath(depth, breadth, seed int) string { + if depth < 1 { + depth = 1 + } + if breadth < 1 { + breadth = 1 + } + path := "/bench" + value := seed + for level := 0; level < depth; level++ { + slot := value % breadth + path += fmt.Sprintf("/l%d-b%d", level, slot) + value = value / breadth + } + return path +} + +func newBenchmarkPair(comm string, traceID types.TraceId, pid, tid uint32, path string) *event.Pair { + enter := &types.OpenEvent{ + TraceId: traceID, + Pid: pid, + Tid: tid, + } + exit := &types.RetEvent{ + TraceId: types.SYS_EXIT_OPENAT, + Pid: pid, + Tid: tid, + } + pair := event.NewPair(enter) + pair.ExitEv = exit + pair.File = file.NewFd(3, path, 0) + pair.Comm = comm + pair.Duration = 1 + pair.DurationToPrev = 1 + pair.Bytes = 64 + return pair +} + +func snapshotNodeCount(node *snapshotNode) int { + if node == nil { + return 0 + } + total := 1 + for _, child := range node.Children { + total += snapshotNodeCount(child) + } + return total +} + +func approxEqualCount(got, want int) bool { + if got == want { + return true + } + const tolerance = 0.2 + diff := math.Abs(float64(got-want)) / float64(want) + return diff <= tolerance +} + +func decodeTrieSnapshot(lt *coreflamegraph.LiveTrie) (*snapshotNode, error) { + payload, _ := lt.SnapshotJSON() + var snap snapshotNode + if err := json.Unmarshal(payload, &snap); err != nil { + return nil, err + } + return &snap, nil +} diff --git a/internal/tui/flamegraph/zoom.go b/internal/tui/flamegraph/zoom.go new file mode 100644 index 0000000..7a3aa42 --- /dev/null +++ b/internal/tui/flamegraph/zoom.go @@ -0,0 +1,39 @@ +package flamegraph + +import "strings" + +func findNodeByPath(root *snapshotNode, path string) *snapshotNode { + if root == nil { + return nil + } + if path == "" { + return root + } + parts := strings.Split(path, pathSeparator) + if len(parts) == 0 { + return root + } + rootName := frameName(root.Name, 0) + if parts[0] == rootName { + parts = parts[1:] + } + + node := root + for _, part := range parts { + next := findChildByName(node, part) + if next == nil { + return nil + } + node = next + } + return node +} + +func findChildByName(node *snapshotNode, name string) *snapshotNode { + for _, child := range node.Children { + if child.Name == name || frameName(child.Name, 1) == name { + return child + } + } + return nil +} |
