From 4ca02bb88cffb28bb000326688c6e8e7c1cbe8a9 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 8 Mar 2026 20:33:41 +0200 Subject: task 372: restart tracing when filters change --- internal/globalfilter/filter.go | 36 +++++++++++++++++++ internal/globalfilter/filter_test.go | 31 ++++++++++++++++ internal/tui/dashboard/model.go | 21 +++++++++++ internal/tui/eventstream/model.go | 9 +++++ internal/tui/tui.go | 23 ++++++++++-- internal/tui/tui_test.go | 68 +++++++++++++++++++++++++++++++++++- 6 files changed, 185 insertions(+), 3 deletions(-) (limited to 'internal') diff --git a/internal/globalfilter/filter.go b/internal/globalfilter/filter.go index b9072a7..24cae7c 100644 --- a/internal/globalfilter/filter.go +++ b/internal/globalfilter/filter.go @@ -70,6 +70,20 @@ func (f Filter) Clone() Filter { return out } +func (f Filter) Equal(other Filter) bool { + return sameStringFilter(f.Syscall, other.Syscall) && + sameStringFilter(f.Comm, other.Comm) && + sameStringFilter(f.File, other.File) && + sameNumericFilter(f.PID, other.PID) && + sameNumericFilter(f.TID, other.TID) && + sameNumericFilter(f.FD, other.FD) && + sameNumericFilter(f.LatencyNs, other.LatencyNs) && + sameNumericFilter(f.GapNs, other.GapNs) && + sameNumericFilter(f.Bytes, other.Bytes) && + sameNumericFilter(f.RetVal, other.RetVal) && + f.ErrorsOnly == other.ErrorsOnly +} + func (f Filter) Matches(candidate Candidate) bool { if candidate == nil { return false @@ -259,6 +273,28 @@ func cloneNumericFilter(in *NumericFilter) *NumericFilter { return &out } +func sameStringFilter(left, right *StringFilter) bool { + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Pattern == right.Pattern + } +} + +func sameNumericFilter(left, right *NumericFilter) bool { + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Op == right.Op && left.Value == right.Value + } +} + func onlyDigits(s string) bool { if s == "" { return false diff --git a/internal/globalfilter/filter_test.go b/internal/globalfilter/filter_test.go index ff04ea7..a7adad4 100644 --- a/internal/globalfilter/filter_test.go +++ b/internal/globalfilter/filter_test.go @@ -142,3 +142,34 @@ func TestFilterSummaryAndDurationParsing(t *testing.T) { t.Fatalf("ParseDurationNs(garbage) expected error") } } + +func TestFilterEqual(t *testing.T) { + base := Filter{ + Syscall: &StringFilter{Pattern: "read"}, + Comm: &StringFilter{Pattern: "nginx"}, + File: &StringFilter{Pattern: "/var/log"}, + PID: &NumericFilter{Op: OpEq, Value: 42}, + TID: &NumericFilter{Op: OpNeq, Value: 99}, + FD: &NumericFilter{Op: OpEq, Value: 7}, + LatencyNs: &NumericFilter{Op: OpGt, Value: 1_000}, + GapNs: &NumericFilter{Op: OpGte, Value: 500}, + Bytes: &NumericFilter{Op: OpLt, Value: 4_096}, + RetVal: &NumericFilter{Op: OpEq, Value: -1}, + ErrorsOnly: true, + } + if !base.Equal(base.Clone()) { + t.Fatalf("expected cloned filter to compare equal") + } + + mutated := base.Clone() + mutated.File.Pattern = "/tmp" + if mutated.Equal(base) { + t.Fatalf("expected differing file pattern to compare unequal") + } + + mutated = base.Clone() + mutated.Bytes = nil + if mutated.Equal(base) { + t.Fatalf("expected missing numeric filter to compare unequal") + } +} diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index a7d415d..5c3f690 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -4,6 +4,7 @@ import ( "strings" "time" + "ior/internal/globalfilter" "ior/internal/statsengine" common "ior/internal/tui/common" "ior/internal/tui/eventstream" @@ -512,6 +513,11 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest } +// ActiveTab returns the currently selected dashboard tab. +func (m Model) ActiveTab() Tab { + return m.activeTab +} + // BlocksGlobalShortcuts reports whether the active tab should suppress a // top-level shortcut for the given key press. func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { @@ -529,6 +535,12 @@ func (m *Model) SetStreamSource(source eventstream.Source) { m.streamModel.SetSource(source) } +// SetGlobalFilter forwards the shared TUI filter into the stream tab so +// buffered rows can be re-filtered immediately. +func (m *Model) SetGlobalFilter(filter globalfilter.Filter) { + m.streamModel.SetFilter(eventstream.Filter(filter)) +} + // SetLiveTrie updates the live trie source used by the flamegraph tab. func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { m.liveTrie = liveTrie @@ -539,6 +551,15 @@ func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { m.flamegraphModel.RefreshFromLiveTrie() } +// PrepareForTraceRestart clears aggregate state while keeping the current tab +// and retained stream rows intact for the next trace session. +func (m *Model) PrepareForTraceRestart() { + m.latest = nil + m.liveTrie = nil + m.flamegraphModel.SetLiveTrie(nil) + m.refreshBubbleData() +} + // SetDarkMode updates dashboard child models for the active theme. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index ee65793..3dc774b 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -133,6 +133,15 @@ func (m *Model) SetSource(source Source) { m.Refresh() } +// SetFilter updates the active stream filter and immediately re-filters the +// current in-memory snapshot without mutating the underlying ring buffer. +func (m *Model) SetFilter(filter Filter) { + targetSeq := m.currentSelectedSeq() + m.filter = filter.Clone() + m.applyFilter() + m.restoreSelectionBySeq(targetSeq) +} + // SetDarkMode updates stream modal text input styles for the active theme. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 991a9d6..db24538 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -400,10 +400,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.attaching = false m.dashboard.SetStreamSource(m.runtime.eventStreamSource()) m.dashboard.SetLiveTrie(m.runtime.liveTrie()) + m.dashboard.SetGlobalFilter(m.globalFilter) width, height := common.EffectiveViewport(m.width, m.height) next, sizeCmd := m.dashboard.Update(tea.WindowSizeMsg{Width: width, Height: height}) m.dashboard = next.(dashboardui.Model) - return m, tea.Batch(sizeCmd, m.dashboard.Init()) + return m, tea.Batch(sizeCmd, m.dashboard.Init(), m.dashboard.SnapshotCmd()) case TracingErrorMsg: m.attaching = false m.lastErr = msg.Err @@ -540,7 +541,8 @@ func (m Model) updateFilterModal(msg tea.Msg) (tea.Model, tea.Cmd) { wasVisible := m.filterModal.Visible() m.filterModal = m.filterModal.Update(msg) if wasVisible && !m.filterModal.Visible() { - m.setGlobalFilter(m.filterModal.Filter()) + next, cmd := m.applyGlobalFilter(m.filterModal.Filter()) + return next, tea.Batch(dashboardCmd, cmd) } return m, dashboardCmd } @@ -831,6 +833,7 @@ func (m *Model) setProcessFilters(pid, tid int) { m.globalFilter.PID = eqNumericFilter(pid) m.globalFilter.TID = eqNumericFilter(tid) m.dashboard.SetPidFilter(pid) + m.dashboard.SetGlobalFilter(m.globalFilter) } func (m *Model) setGlobalFilter(filter globalfilter.Filter) { @@ -840,6 +843,22 @@ func (m *Model) setGlobalFilter(filter globalfilter.Filter) { m.pidFilter = pid m.tidFilter = tid m.dashboard.SetPidFilter(pid) + m.dashboard.SetGlobalFilter(m.globalFilter) +} + +func (m Model) applyGlobalFilter(filter globalfilter.Filter) (tea.Model, tea.Cmd) { + nextFilter := filter.Clone() + changed := !m.globalFilter.Equal(nextFilter) + m.setGlobalFilter(nextFilter) + if !changed || m.screen != ScreenDashboard { + return m, nil + } + + m.stopTrace() + m.dashboard.PrepareForTraceRestart() + m.attaching = true + m.lastErr = nil + return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) } func eqNumericFilterValue(filter *globalfilter.NumericFilter) (int, bool) { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index faacdc3..a5cd16a 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -452,7 +452,7 @@ func TestTracingStartedRebindsEventStreamSource(t *testing.T) { rb := eventstream.NewRingBuffer() rb.Push(eventstream.StreamEvent{Seq: 1, Syscall: "read", Comm: "proc", PID: 1, TID: 1}) - m := NewModel(-1, func(context.Context) error { return nil }) + m := NewModelWithConfig(flags.Config{PidFilter: -1, TidFilter: -1, TUIExportEnable: true}, -1, func(context.Context) error { return nil }) m.runtime.SetEventStreamSource(rb) m.screen = ScreenDashboard m.attaching = true @@ -1036,6 +1036,10 @@ func TestQuitClosesGlobalFilterModalWithoutQuitting(t *testing.T) { func TestGlobalFilterModalUpdatesStoredFilterState(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard + m.attaching = false + + stopped := false + m.traceStop = func() { stopped = true } next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) m = next.(Model) @@ -1056,6 +1060,68 @@ func TestGlobalFilterModalUpdatesStoredFilterState(t *testing.T) { if m.globalFilter.Syscall == nil || m.globalFilter.Syscall.Pattern != "read" { t.Fatalf("expected stored global filter updated from modal, got %+v", m.globalFilter.Syscall) } + if !stopped { + t.Fatalf("expected filter apply to stop the active trace") + } + if !m.attaching { + t.Fatalf("expected filter apply to restart tracing") + } +} + +func TestGlobalFilterCloseWithoutChangesDoesNotRestartTrace(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + stopped := false + m.traceStop = func() { stopped = true } + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) + m = next.(Model) + if !m.filterModal.Visible() { + t.Fatalf("expected filter modal to open") + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + + if cmd != nil { + t.Fatalf("expected no restart command when filter is unchanged") + } + if stopped { + t.Fatalf("expected unchanged filter close not to stop tracing") + } + if m.attaching { + t.Fatalf("expected unchanged filter close not to restart tracing") + } +} + +func TestGlobalFilterApplyPreservesActiveDashboardTab(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'4'}[0], Text: string([]rune{'4'})}) + m = next.(Model) + if m.dashboard.ActiveTab() != dashboardui.TabFiles { + t.Fatalf("expected files tab active before filter apply") + } + + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune("log")[0], Text: string([]rune("log"))}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + m = next.(Model) + + if m.dashboard.ActiveTab() != dashboardui.TabFiles { + t.Fatalf("expected active tab preserved across filter restart") + } + if !m.attaching { + t.Fatalf("expected apply to enter attaching state") + } } func TestQuestionMarkDoesNotBlockUnderlyingActions(t *testing.T) { -- cgit v1.2.3