diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 15:21:01 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 15:21:01 +0200 |
| commit | 4ff17c30120d657b966f8a55188ba167dc875e64 (patch) | |
| tree | 62737caf6b8e7411c2437dd995d3de5ce6aeca99 /internal | |
| parent | 1530bf2856bbb32a6e0457596b55c07f3836a0ec (diff) | |
feat(tui): add flamegraph bytes metric toggle
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/flamegraph/livetrie.go | 39 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie_test.go | 48 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 69 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 31 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 54 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer.go | 15 | ||||
| -rw-r--r-- | internal/tui/flamegraph/renderer_test.go | 18 | ||||
| -rw-r--r-- | internal/tui/tui.go | 2 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 14 |
9 files changed, 245 insertions, 45 deletions
diff --git a/internal/flamegraph/livetrie.go b/internal/flamegraph/livetrie.go index 13d7de9..9f1fd91 100644 --- a/internal/flamegraph/livetrie.go +++ b/internal/flamegraph/livetrie.go @@ -42,6 +42,9 @@ type LiveTrie struct { // NewLiveTrie constructs an empty live trie with the configured frame/count fields. func NewLiveTrie(fields []string, countField string) *LiveTrie { + if !isLiveTrieCountField(countField) { + countField = "count" + } return &LiveTrie{ root: &trieNode{ childMap: make(map[string]*trieNode), @@ -123,6 +126,33 @@ func (lt *LiveTrie) Fields() []string { return out } +// CountField returns the active metric used to aggregate node values. +func (lt *LiveTrie) CountField() string { + lt.mu.RLock() + field := lt.countField + lt.mu.RUnlock() + return field +} + +// SetCountField changes the active aggregation metric and starts a new baseline. +func (lt *LiveTrie) SetCountField(countField string) error { + field := strings.TrimSpace(countField) + if !isLiveTrieCountField(field) { + return fmt.Errorf("invalid count field %q", countField) + } + + lt.mu.Lock() + if lt.countField == field { + lt.mu.Unlock() + return nil + } + lt.countField = field + lt.resetLocked() + lt.mu.Unlock() + lt.invalidateCache() + return nil +} + // Reconfigure changes frame fields and clears accumulated data for a new baseline. func (lt *LiveTrie) Reconfigure(fields []string) error { normalized, err := normalizeLiveTrieFields(fields) @@ -239,6 +269,15 @@ func isLiveTrieField(field string) bool { } } +func isLiveTrieCountField(field string) bool { + switch field { + case "count", "duration", "durationToPrev", "bytes": + return true + default: + return false + } +} + func subtreeTotal(node *trieNode) uint64 { total := node.value for _, child := range node.children { diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index 71f645c..53bdf1f 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -223,6 +223,54 @@ func TestLiveTrieReconfigureRejectsInvalidFields(t *testing.T) { } } +func TestLiveTrieSetCountFieldSwitchesMetricAndResetsBaseline(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 10, 1, 64)) + + initial := decodeLiveSnapshot(t, lt) + if got, want := initial.Total, uint64(1); got != want { + t.Fatalf("count snapshot total = %d, want %d", got, want) + } + + if err := lt.SetCountField("bytes"); err != nil { + t.Fatalf("set count field: %v", err) + } + if got, want := lt.CountField(), "bytes"; got != want { + t.Fatalf("count field = %q, want %q", got, want) + } + + empty := decodeLiveSnapshot(t, lt) + if got := empty.Total; got != 0 { + t.Fatalf("expected reset baseline after metric switch, total=%d", got) + } + + lt.Ingest(newTestPair("svc", 42, 1002, "/tmp/b", 10, 1, 64)) + bytesSnap := decodeLiveSnapshot(t, lt) + if got, want := bytesSnap.Total, uint64(64); got != want { + t.Fatalf("bytes snapshot total = %d, want %d", got, want) + } + leaf := findSnapshotPath(t, &bytesSnap, "svc") + if got, want := leaf.Total, uint64(64); got != want { + t.Fatalf("bytes leaf total = %d, want %d", got, want) + } +} + +func TestLiveTrieSetCountFieldRejectsInvalidValue(t *testing.T) { + lt := NewLiveTrie([]string{"comm"}, "count") + lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) + beforeVersion := lt.Version() + + if err := lt.SetCountField("bogus"); err == nil { + t.Fatalf("expected invalid count field error") + } + if got, want := lt.CountField(), "count"; got != want { + t.Fatalf("count field changed unexpectedly: got %q want %q", got, want) + } + if got := lt.Version(); got != beforeVersion { + t.Fatalf("version changed on invalid count field: got %d want %d", got, beforeVersion) + } +} + func TestLiveTrieSnapshotJSONCaching(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") lt.Ingest(newTestPair("svc", 42, 1001, "/tmp/a", 1, 1, 1)) diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index cd74df5..06e6d0d 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -13,10 +13,7 @@ func (m *Model) togglePause() { m.paused = !m.paused } -func (m *Model) resetBaseline() { - if m.liveTrie != nil { - m.liveTrie.Reset() - } +func (m *Model) clearSnapshotState(clearSearch bool) { m.zoomRoot = nil m.zoomPath = "" m.zoomStack = nil @@ -25,11 +22,20 @@ func (m *Model) resetBaseline() { m.globalTotal = 0 m.frames = nil m.targetFrames = nil - m.searchQuery = "" 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" } @@ -45,21 +51,26 @@ func (m *Model) cycleFieldOrder() { return } } - 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 + 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 } @@ -70,7 +81,7 @@ func (m Model) toolbarLine() string { state = lipgloss.NewStyle().Foreground(common.ColorDanger).Bold(true).Render("[PAUSED]") } order := m.currentFieldPresetLabel() - line := fmt.Sprintf("%s | view:%s | o:order(%s) | /:search | enter:zoom | u/esc:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order) + 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 } @@ -92,7 +103,7 @@ func (m Model) helpOverlay() string { 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 ? help" + 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)) } @@ -118,17 +129,18 @@ func (m Model) selectionStatusLine() string { if m.globalTotal > 0 { systemShare = percentOfTotal(frame.Total, m.globalTotal) } - shareLabel := fmt.Sprintf("%.2f%% system", systemShare) + 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%% filter", filterShare) + shareLabel = fmt.Sprintf("%.2f%% of filtered %s", filterShare, metric) } } - line := fmt.Sprintf("[%s] sel:%d/%d %s | path:%s | depth:%d | total:%d | %s", - mode, selIdx+1, len(m.frames), frame.Name, compactFramePath(frame.Path), frame.Depth, frame.Total, shareLabel) + 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 } @@ -148,3 +160,14 @@ func (m Model) currentFieldPresetLabel() string { } 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/model.go b/internal/tui/flamegraph/model.go index 2b974fe..c4973fb 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -88,6 +88,7 @@ type Model struct { fieldPresets [][]string fieldIndex int + countField string animation AnimationState animating bool @@ -132,11 +133,13 @@ func NewModel(liveTrie *coreflamegraph.LiveTrie) Model { {"pid", "tracepoint", "path"}, {"comm", "path", "tracepoint"}, }, - isDark: true, - keys: defaultFlameKeyMap(), - animation: NewAnimationState(30, 6.0, 1.0), + isDark: true, + keys: defaultFlameKeyMap(), + animation: NewAnimationState(30, 6.0, 1.0), + countField: "count", } m.syncFieldPresetToTrie() + m.syncCountFieldToTrie() return m } @@ -213,6 +216,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case isCycleOrderKey(msg): handled = true m.cycleFieldOrder() + case isCycleMetricKey(msg): + handled = true + m.toggleCountField() case isHelpToggleKey(msg): handled = true m.toggleHelp() @@ -265,6 +271,7 @@ func (m Model) ConsumesKey(msg tea.KeyPressMsg) bool { isPauseKey(msg), isResetBaselineKey(msg), isCycleOrderKey(msg), + isCycleMetricKey(msg), isHelpToggleKey(msg): return true case isZoomInKey(msg, m.keys), @@ -293,7 +300,7 @@ func (m Model) View() tea.View { renderHeight = 3 } - content := RenderTerminalView(m.frames, m.width, renderHeight, m.selectedIdx, m.subtreeSet, m.matchIndices, m.filterVisible, m.globalTotal, m.isDark, m.searchActive, m.searchQuery) + 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()) @@ -312,6 +319,7 @@ func (m Model) View() tea.View { func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { m.liveTrie = liveTrie m.syncFieldPresetToTrie() + m.syncCountFieldToTrie() m.lastVersion = 0 m.snapshot = nil m.globalTotal = 0 @@ -349,6 +357,18 @@ func (m *Model) syncFieldPresetToTrie() { 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 { @@ -881,6 +901,9 @@ 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 { diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index 355facc..74ce8d9 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -661,11 +661,29 @@ func TestViewIncludesSelectionStatusBar(t *testing.T) { 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% system") { + 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 @@ -723,7 +741,7 @@ func TestViewFilterSelectionStatusUsesFilteredTotalAndKeepsContextVisible(t *tes m.recomputeFilterState() view := m.View().Content - if !strings.Contains(view, "100.00% filter") { + 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") { @@ -750,6 +768,30 @@ func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) { } } +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) @@ -758,6 +800,14 @@ func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) { } } +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: "?"}) diff --git a/internal/tui/flamegraph/renderer.go b/internal/tui/flamegraph/renderer.go index 3ae9a11..e4c4043 100644 --- a/internal/tui/flamegraph/renderer.go +++ b/internal/tui/flamegraph/renderer.go @@ -192,7 +192,7 @@ func semanticFrameColor(name string) (color.Color, bool) { } // 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, isDark, searchActive bool, searchQuery string) string { +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)") } @@ -202,6 +202,9 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr if len(frames) == 0 { return common.PanelStyle.Render("Flame: waiting for data...") } + if strings.TrimSpace(metricLabel) == "" { + metricLabel = "events" + } filterActive := strings.TrimSpace(searchQuery) != "" if filterActive { @@ -267,13 +270,13 @@ func RenderTerminalView(frames []tuiFrame, width, height, selectedIdx int, subtr if len(frames) > 0 { frameCoverage = 100 * float64(visibleFrames) / float64(len(frames)) } - status := fmt.Sprintf("Filter %q: %.1f%% system (%d/%d matches, %.1f%% frames shown) | Selected: %s total=%d depth=%d %.2f%% filter", - searchQuery, filterSystemShare, pos, len(matches), frameCoverage, - selected.Name, selected.Total, selected.Depth, selectedFilterShare) + 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=%d depth=%d col=%d width=%d share=%.2f%%", - selected.Name, compactFramePath(selected.Path), selected.Total, selected.Depth, selected.Col, selected.Width, selectedSystemShare) + 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) } } diff --git a/internal/tui/flamegraph/renderer_test.go b/internal/tui/flamegraph/renderer_test.go index 091aeec..c546200 100644 --- a/internal/tui/flamegraph/renderer_test.go +++ b/internal/tui/flamegraph/renderer_test.go @@ -152,7 +152,7 @@ func TestTerminalFrameColorSemanticPalette(t *testing.T) { } func TestRenderTerminalViewShowsNarrowMessage(t *testing.T) { - out := RenderTerminalView(nil, 50, 10, 0, nil, nil, nil, 0, true, false, "") + 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) } @@ -177,7 +177,7 @@ func TestRenderTerminalViewIncludesToolbarAndStatus(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 80, 6) - out := RenderTerminalView(frames, 80, 6, 1, nil, nil, nil, 0, true, false, "") + 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) } @@ -196,7 +196,7 @@ func TestRenderTerminalViewFillsAvailableHeightForShallowTree(t *testing.T) { } frames := BuildTerminalLayout(snapshot, 100, 20) - out := RenderTerminalView(frames, 100, 20, 1, nil, nil, nil, 0, true, false, "") + 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) @@ -223,7 +223,7 @@ func TestRenderTerminalViewShowsPersistentFilterContext(t *testing.T) { frames := BuildTerminalLayout(snapshot, 80, 6) matchSet := map[int]bool{1: true} - out := RenderTerminalView(frames, 140, 6, 1, nil, matchSet, nil, 0, true, false, "child") + 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) } @@ -257,9 +257,9 @@ func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) { } matchSet := map[int]bool{needleIdx: true} - out := RenderTerminalView(frames, 180, 8, needleIdx, nil, matchSet, nil, 100, true, false, "needle") - if !strings.Contains(out, `Filter "needle": 60.0% system`) { - t.Fatalf("expected filter status to report 60.0%% system share, got %q", out) + 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) @@ -267,7 +267,7 @@ func TestRenderTerminalViewFilterKeepsNonMatchingBranchesVisible(t *testing.T) { 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% filter") { + if !strings.Contains(out, "100.00% filtered bytes") { t.Fatalf("expected selected match share to be computed against filtered total, got %q", out) } } @@ -315,7 +315,7 @@ func TestRenderTerminalViewShowsDeepLevelTruncationHint(t *testing.T) { }, } frames := BuildTerminalLayout(snapshot, 80, 10) - out := RenderTerminalView(frames, 80, 4, 0, nil, nil, nil, 0, true, false, "") + 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) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4006d84..12c904d 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -890,7 +890,7 @@ func (m Model) helpSections() []helpSection { "arrows/hjkl navigate pgup top pgdn root", "enter zoom u/backspace/esc undo", "/ filter n/N match next/prev", - "space/p pause o order r reset baseline", + "space/p pause o order b metric r reset baseline", }, }, { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 87ad340..cd9e6cd 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -505,6 +505,20 @@ func TestFlameOrderKeyDoesNotOpenProbeModal(t *testing.T) { } } +func TestFlameMetricKeyDoesNotOpenProbeModal(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'b'}[0], Text: string([]rune{'b'})}) + updated := next.(Model) + if updated.probeModal.Visible() { + t.Fatalf("expected flame metric key to stay in flame tab, not open probes modal") + } +} + func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard |
