diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 14:33:52 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 14:33:52 +0200 |
| commit | 3e08a3d199fdf603b7c0a4002ca9822b6ecf2575 (patch) | |
| tree | 7e096a07cc515ffc18f0eca308819e8162df1d60 | |
| parent | aa4f638206b9b79de267f9a1daab7ec6698b241d (diff) | |
flamegraph: make esc undo one zoom level and aggregate syscalls by default
| -rw-r--r-- | internal/flags/flags.go | 4 | ||||
| -rw-r--r-- | internal/flags/flags_test.go | 2 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml.go | 7 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml_browser_test.go | 2 | ||||
| -rw-r--r-- | internal/flamegraph/livehtml_interaction_test.go | 2 | ||||
| -rw-r--r-- | internal/flamegraph/livetrie_test.go | 33 | ||||
| -rw-r--r-- | internal/tui/flamegraph/controls.go | 4 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model.go | 40 | ||||
| -rw-r--r-- | internal/tui/flamegraph/model_test.go | 42 |
9 files changed, 112 insertions, 24 deletions
diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 0df1d2d..503aefb 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -83,7 +83,7 @@ func NewFlags() Flags { LiveInterval: 200 * time.Millisecond, FlamegraphName: "default", TUIExportEnable: true, - CollapsedFields: []string{"comm", "path", "tracepoint"}, + CollapsedFields: []string{"comm", "tracepoint", "path"}, CountField: "count", } } @@ -221,7 +221,7 @@ func parse() error { // If future kernels regress, add targeted exclusions here. if *fields == "" { - cfg.CollapsedFields = []string{"comm", "path", "tracepoint"} + cfg.CollapsedFields = []string{"comm", "tracepoint", "path"} } else { cfg.CollapsedFields = strings.Split(*fields, ",") } diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go index 63b668c..7323438 100644 --- a/internal/flags/flags_test.go +++ b/internal/flags/flags_test.go @@ -153,7 +153,7 @@ func TestParseDefaultCollapsedFieldsOrder(t *testing.T) { t.Fatalf("parse returned error: %v", err) } - want := []string{"comm", "path", "tracepoint"} + want := []string{"comm", "tracepoint", "path"} if len(cfg.CollapsedFields) != len(want) { t.Fatalf("default collapsed fields len = %d, want %d", len(cfg.CollapsedFields), len(want)) } diff --git a/internal/flamegraph/livehtml.go b/internal/flamegraph/livehtml.go index 90a6d3d..71b955e 100644 --- a/internal/flamegraph/livehtml.go +++ b/internal/flamegraph/livehtml.go @@ -116,7 +116,7 @@ const liveHTML = `<!doctype html> <button id="btn-undo-zoom" type="button">Undo Zoom</button> <button id="btn-reset-zoom" type="button">Reset Zoom</button> <button id="btn-reset-baseline" type="button">Reset Baseline</button> - <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > path > tracepoint</button> + <button id="btn-toggle-order" class="order-toggle" type="button">Order: comm > tracepoint > path</button> <span id="status">LIVE</span> </div> @@ -146,10 +146,11 @@ const liveHTML = `<!doctype html> resetBaselineBtn: document.getElementById('btn-reset-baseline'), toggleOrderBtn: document.getElementById('btn-toggle-order'), orderPresets: [ - 'comm,path,tracepoint', + 'comm,tracepoint,path', 'path,tracepoint,comm', 'tracepoint,comm,path', - 'pid,path,tracepoint' + 'pid,tracepoint,path', + 'comm,path,tracepoint' ], orderIndex: 0, cfg: { diff --git a/internal/flamegraph/livehtml_browser_test.go b/internal/flamegraph/livehtml_browser_test.go index c7a16c7..10252a9 100644 --- a/internal/flamegraph/livehtml_browser_test.go +++ b/internal/flamegraph/livehtml_browser_test.go @@ -166,7 +166,7 @@ global.prompt = function(){ return ""; }; global.fetch = function() { return Promise.resolve({ ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); }, + json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } }); }; diff --git a/internal/flamegraph/livehtml_interaction_test.go b/internal/flamegraph/livehtml_interaction_test.go index 0de1466..4c947f5 100644 --- a/internal/flamegraph/livehtml_interaction_test.go +++ b/internal/flamegraph/livehtml_interaction_test.go @@ -574,7 +574,7 @@ global.prompt = function(){ return ""; }; global.fetch = function() { return Promise.resolve({ ok: true, - json: function() { return Promise.resolve({ fields: ["comm", "path", "tracepoint"], snapshot: { n: "", v: 0, t: 0 } }); }, + json: function() { return Promise.resolve({ fields: ["comm", "tracepoint", "path"], snapshot: { n: "", v: 0, t: 0 } }); }, text: function() { return Promise.resolve("{\"n\":\"\",\"v\":0,\"t\":0}"); } }); }; diff --git a/internal/flamegraph/livetrie_test.go b/internal/flamegraph/livetrie_test.go index c5ed32c..71f645c 100644 --- a/internal/flamegraph/livetrie_test.go +++ b/internal/flamegraph/livetrie_test.go @@ -48,6 +48,39 @@ func TestLiveTrieIngestIsAdditive(t *testing.T) { } } +func TestLiveTrieCommTracepointPathAggregatesSameSyscallAcrossPaths(t *testing.T) { + lt := NewLiveTrie([]string{"comm", "tracepoint", "path"}, "count") + lt.AddRecord(IterRecord{ + Path: "/srv/a", + TraceID: types.SYS_ENTER_READ, + Comm: "svc", + Pid: 1001, + Tid: 1001, + Cnt: Counter{Count: 1}, + }) + lt.AddRecord(IterRecord{ + Path: "/srv/b", + TraceID: types.SYS_ENTER_READ, + Comm: "svc", + Pid: 1002, + Tid: 1002, + Cnt: Counter{Count: 1}, + }) + + snap := decodeLiveSnapshot(t, lt) + commNode := findSnapshotPath(t, &snap, "svc") + if len(commNode.Children) != 1 { + t.Fatalf("expected one syscall child under comm node, got %d", len(commNode.Children)) + } + syscallNode := commNode.Children[0] + if got, want := syscallNode.Name, "enter_read"; got != want { + t.Fatalf("syscall child name = %q, want %q", got, want) + } + if got, want := syscallNode.Total, uint64(2); got != want { + t.Fatalf("syscall aggregate total = %d, want %d", got, want) + } +} + func TestLiveTrieVersionIncrementsPerIngest(t *testing.T) { lt := NewLiveTrie([]string{"comm"}, "count") if got := lt.Version(); got != 0 { diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index f411a13..b307717 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -70,7 +70,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:undo | r:reset | space/p:pause", state, compactFramePath(m.currentRootPath()), order) + 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) if m.searchQuery != "" { line += " | filter:" + m.searchQuery } @@ -92,7 +92,7 @@ func (m Model) helpOverlay() string { if width <= 0 { width = 80 } - help := "Flame help: j/k depth h/l sibling enter zoom u/backspace undo esc reset / search n/N matches space/p pause r reset baseline o order ? help" + help := "Flame help: j/k depth h/l sibling enter zoom u/backspace/esc undo / search n/N matches space/p pause r reset baseline o order ? help" return common.HelpBarStyle.Width(width).Render(padOrTrim(help, width)) } diff --git a/internal/tui/flamegraph/model.go b/internal/tui/flamegraph/model.go index 66fefc9..07bae5d 100644 --- a/internal/tui/flamegraph/model.go +++ b/internal/tui/flamegraph/model.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "image/color" + "slices" "sort" "strings" "time" @@ -49,8 +50,8 @@ func defaultFlameKeyMap() flameKeyMap { PrevSibling: key.NewBinding(key.WithKeys("h", "left")), NextSibling: key.NewBinding(key.WithKeys("l", "right")), ZoomIn: key.NewBinding(key.WithKeys("enter")), - ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u")), - ZoomReset: key.NewBinding(key.WithKeys("esc")), + ZoomUndo: key.NewBinding(key.WithKeys("backspace", "u", "esc")), + ZoomReset: key.NewBinding(), } } @@ -114,22 +115,25 @@ func NewModel(liveTrie *coreflamegraph.LiveTrie) Model { searchInput.SetWidth(32) searchInput.SetStyles(textinput.DefaultStyles(true)) - return Model{ + m := Model{ liveTrie: liveTrie, matchIndices: make(map[int]bool), filterVisible: make(map[int]bool), subtreeSet: make(map[int]bool), searchInput: searchInput, fieldPresets: [][]string{ - {"comm", "path", "tracepoint"}, + {"comm", "tracepoint", "path"}, {"path", "tracepoint", "comm"}, {"tracepoint", "comm", "path"}, - {"pid", "path", "tracepoint"}, + {"pid", "tracepoint", "path"}, + {"comm", "path", "tracepoint"}, }, isDark: true, keys: defaultFlameKeyMap(), animation: NewAnimationState(30, 6.0, 1.0), } + m.syncFieldPresetToTrie() + return m } // Init starts the flamegraph model. @@ -286,6 +290,7 @@ func (m Model) View() tea.View { // SetLiveTrie updates the data source used by the flamegraph model. func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { m.liveTrie = liveTrie + m.syncFieldPresetToTrie() m.lastVersion = 0 m.snapshot = nil m.globalTotal = 0 @@ -302,6 +307,27 @@ func (m *Model) SetLiveTrie(liveTrie *coreflamegraph.LiveTrie) { 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 +} + // RefreshFromLiveTrie loads a new snapshot when the source version changes. func (m *Model) RefreshFromLiveTrie() bool { if m.liveTrie == nil { @@ -760,11 +786,11 @@ func isZoomInKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { } func isZoomUndoKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { - return key.Matches(msg, keys.ZoomUndo) || msg.Code == tea.KeyBackspace + 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) || msg.Code == tea.KeyEsc + return key.Matches(msg, keys.ZoomReset) } func isMoveShallowerKey(msg tea.KeyPressMsg, keys flameKeyMap) bool { diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index e98d936..e253c76 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -21,6 +21,9 @@ func TestNewModelDefaults(t *testing.T) { 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") } @@ -399,7 +402,7 @@ func TestFilteredNavigationSkipsHiddenBranches(t *testing.T) { } } -func TestZoomInUndoResetAndNestedZoom(t *testing.T) { +func TestZoomInUndoSingleLevelAndNestedEsc(t *testing.T) { m := newZoomModel() m.selectedIdx = mustFrameIndex(t, m.frames, "root"+pathSeparator+"A") @@ -423,17 +426,33 @@ func TestZoomInUndoResetAndNestedZoom(t *testing.T) { t.Fatalf("expected nested zoom stack to preserve parent path, got %#v", m.zoomStack) } - m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyBackspace}) + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: tea.KeyEsc}) if got, want := m.zoomPath, "root"+pathSeparator+"A"; got != want { - t.Fatalf("expected zoomPath after undo %q, got %q", want, got) + t.Fatalf("expected zoomPath after esc undo %q, got %q", want, got) } if len(m.zoomStack) != 1 { - t.Fatalf("expected one stack entry after undo, got %d", len(m.zoomStack)) + 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 zoom reset to root state, got path=%q root=%+v stack=%d", m.zoomPath, m.zoomRoot, len(m.zoomStack)) + 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)) } } @@ -620,10 +639,11 @@ 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 != 1 { - t.Fatalf("expected field index to advance to 1, got %d", m.fieldIndex) + 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) { @@ -634,6 +654,14 @@ func TestControlCycleFieldOrderReconfiguresLiveTrie(t *testing.T) { } } +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 TestControlHelpToggle(t *testing.T) { m := NewModel(nil) m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'?'}[0], Text: "?"}) |
