summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/globalfilter/filter.go36
-rw-r--r--internal/globalfilter/filter_test.go31
-rw-r--r--internal/tui/dashboard/model.go21
-rw-r--r--internal/tui/eventstream/model.go9
-rw-r--r--internal/tui/tui.go23
-rw-r--r--internal/tui/tui_test.go68
6 files changed, 185 insertions, 3 deletions
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) {