diff options
| -rw-r--r-- | internal/eventloop.go | 3 | ||||
| -rw-r--r-- | internal/eventloop_error_handling_test.go | 29 | ||||
| -rw-r--r-- | internal/globalfilter/filter.go | 20 | ||||
| -rw-r--r-- | internal/globalfilter/filter_test.go | 25 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 93 | ||||
| -rw-r--r-- | internal/tui/common/keys_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 19 | ||||
| -rw-r--r-- | internal/tui/eventstream/filter_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 132 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 50 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 9 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 19 | ||||
| -rw-r--r-- | internal/tui/messages/messages.go | 14 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model.go | 38 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/tui.go | 151 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 179 |
17 files changed, 741 insertions, 98 deletions
diff --git a/internal/eventloop.go b/internal/eventloop.go index 6500508..278387f 100644 --- a/internal/eventloop.go +++ b/internal/eventloop.go @@ -595,8 +595,7 @@ func (e *eventLoop) tracepointEntered(enterEv event.Event) { if _, ok := e.cachedComm(tid); ok { e.enterEvs[tid] = event.NewPair(enterEv) } else { - // Probably not an issue. - fmt.Println("WARN: No comm name for", enterEv, "process probably already vanished?") + e.notifyWarning(fmt.Sprintf("No comm name for %v process probably already vanished?", enterEv)) } } } diff --git a/internal/eventloop_error_handling_test.go b/internal/eventloop_error_handling_test.go index 7f2c572..e025343 100644 --- a/internal/eventloop_error_handling_test.go +++ b/internal/eventloop_error_handling_test.go @@ -144,3 +144,32 @@ func TestProcessRawEventMalformedKnownTypeDoesNotPanicAndNotifies(t *testing.T) t.Fatalf("expected warning notification") } } + +func TestTracepointEnteredMissingCommWithCommFilterNotifies(t *testing.T) { + el := mustNewEventLoop(t, eventLoopConfig{commFilter: "system"}) + warnings := make(chan string, 1) + el.warningCb = func(message string) { warnings <- message } + + _, enterRaw := makeEnterFdEvent(t, defaulTime, defaultPid, defaultTid, 20, types.SYS_ENTER_WRITE) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("tracepointEntered panicked: %v", r) + } + }() + + el.tracepointEntered(types.NewFdEvent(enterRaw)) + + select { + case msg := <-warnings: + if msg == "" { + t.Fatalf("expected non-empty warning message") + } + default: + t.Fatalf("expected warning notification") + } + + if _, ok := el.enterEvs[defaultTid]; ok { + t.Fatalf("expected no enter event to be stored for tid %d", defaultTid) + } +} diff --git a/internal/globalfilter/filter.go b/internal/globalfilter/filter.go index 24cae7c..aaac35b 100644 --- a/internal/globalfilter/filter.go +++ b/internal/globalfilter/filter.go @@ -213,7 +213,25 @@ func matchString(sf *StringFilter, value string) bool { if pattern == "" { return true } - return strings.Contains(strings.ToLower(value), pattern) + value = strings.ToLower(value) + anchoredStart := strings.HasPrefix(pattern, "^") + anchoredEnd := strings.HasSuffix(pattern, "$") + if anchoredStart { + pattern = pattern[1:] + } + if anchoredEnd && len(pattern) > 0 { + pattern = pattern[:len(pattern)-1] + } + switch { + case anchoredStart && anchoredEnd: + return value == pattern + case anchoredStart: + return strings.HasPrefix(value, pattern) + case anchoredEnd: + return strings.HasSuffix(value, pattern) + default: + return strings.Contains(value, pattern) + } } func matchNumeric(nf *NumericFilter, value int64) bool { diff --git a/internal/globalfilter/filter_test.go b/internal/globalfilter/filter_test.go index a7adad4..d081f7d 100644 --- a/internal/globalfilter/filter_test.go +++ b/internal/globalfilter/filter_test.go @@ -80,6 +80,31 @@ func TestFilterStringAndNumericMatching(t *testing.T) { } } +func TestFilterStringAnchorsSupportExactPrefixAndSuffix(t *testing.T) { + candidate := testCandidate() + + if !(Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(candidate) { + t.Fatalf("expected ^read$ to exactly match read") + } + if !(Filter{Syscall: &StringFilter{Pattern: "^re"}}).Matches(candidate) { + t.Fatalf("expected ^re to match read by prefix") + } + if !(Filter{File: &StringFilter{Pattern: ".log$"}}).Matches(candidate) { + t.Fatalf("expected .log$ to match by suffix") + } + + candidate.syscall = "readlink" + if (Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(candidate) { + t.Fatalf("expected ^read$ not to match readlink") + } + if !(Filter{Syscall: &StringFilter{Pattern: "^read"}}).Matches(candidate) { + t.Fatalf("expected ^read to match readlink by prefix") + } + if !(Filter{Syscall: &StringFilter{Pattern: "link$"}}).Matches(candidate) { + t.Fatalf("expected link$ to match readlink by suffix") + } +} + func TestFilterErrorsOnlyAndClone(t *testing.T) { filter := Filter{ ErrorsOnly: true, diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index fd7bef1..f2e1271 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -10,27 +10,28 @@ type HelpSection struct { // KeyMap groups all key bindings shared by TUI screens. type KeyMap struct { - Tab key.Binding - ShiftTab key.Binding - One key.Binding - Two key.Binding - Three key.Binding - Four key.Binding - Five key.Binding - Six key.Binding - Seven key.Binding - Visualize key.Binding - Metric key.Binding - DirGroup key.Binding - SelectPID key.Binding - SelectTID key.Binding - Probes key.Binding - Filter key.Binding - Export key.Binding - Quit key.Binding - Enter key.Binding - Esc key.Binding - Refresh key.Binding + Tab key.Binding + ShiftTab key.Binding + One key.Binding + Two key.Binding + Three key.Binding + Four key.Binding + Five key.Binding + Six key.Binding + Seven key.Binding + Visualize key.Binding + Metric key.Binding + DirGroup key.Binding + SelectPID key.Binding + SelectTID key.Binding + Probes key.Binding + Filter key.Binding + FilterUndo key.Binding + Export key.Binding + Quit key.Binding + Enter key.Binding + Esc key.Binding + Refresh key.Binding } // Keys contains the default shared key map. @@ -39,27 +40,28 @@ var Keys = DefaultKeyMap() // DefaultKeyMap builds the default key bindings used by models. func DefaultKeyMap() KeyMap { return KeyMap{ - Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), - ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), - One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), - Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), - Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), - Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), - Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), - Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), - Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), - Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")), - Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")), - DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), - SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), - SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), - Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), - Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), - Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")), + Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next tab")), + ShiftTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev tab")), + One: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "flame")), + Two: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "overview")), + Three: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "syscalls")), + Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "files")), + Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")), + Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")), + Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), + Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")), + Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")), + DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")), + SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")), + SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")), + Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), + Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), + FilterUndo: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "undo filter")), + Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")), } } @@ -93,6 +95,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, k.Filter, + k.FilterUndo, k.SelectPID, k.SelectTID, k.Probes, @@ -107,6 +110,8 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.Visualize, k.Metric, helpTextBinding("space", "stream pause"), + helpTextBinding("enter", "stream push filter"), + helpTextBinding("esc", "stream undo filter"), helpTextBinding("g/G", "stream top/tail"), helpTextBinding("left/right", "stream col"), helpTextBinding("h/l", "stream col"), @@ -132,13 +137,15 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { controls = append(controls, k.Export) } controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit) - controls = append(controls, k.Visualize, k.Metric, k.Filter) + controls = append(controls, k.Visualize, k.Metric, k.Filter, k.FilterUndo) return [][]key.Binding{ {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven}, controls, { helpTextBinding("space", "stream pause"), + helpTextBinding("enter", "stream push filter"), + helpTextBinding("esc", "stream undo filter"), helpTextBinding("g/G", "stream top/tail"), helpTextBinding("left/right", "stream col"), helpTextBinding("h/l", "stream col"), diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go index a2b5940..43f4b8b 100644 --- a/internal/tui/common/keys_test.go +++ b/internal/tui/common/keys_test.go @@ -38,6 +38,11 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) { if metricHelp.Key != "b" || metricHelp.Desc != "metric" { t.Fatalf("unexpected metric binding help: key=%q desc=%q", metricHelp.Key, metricHelp.Desc) } + + undoHelp := keys.FilterUndo.Help() + if undoHelp.Key != "F" || undoHelp.Desc != "undo filter" { + t.Fatalf("unexpected filter undo binding help: key=%q desc=%q", undoHelp.Key, undoHelp.Desc) + } } func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { @@ -130,6 +135,18 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { if !found { t.Fatalf("expected metric binding in dashboard full help controls") } + + found = false + for _, binding := range groups[1] { + help := binding.Help() + if help.Key == "F" && help.Desc == "undo filter" { + found = true + break + } + } + if !found { + t.Fatalf("expected undo filter binding in dashboard full help controls") + } } func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { @@ -138,6 +155,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { found := false foundSelectTID := false foundOne := false + foundUndo := false for _, binding := range short { help := binding.Help() if help.Key == "o" && help.Desc == "probes" { @@ -149,6 +167,9 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if help.Key == "1" && help.Desc == "flame" { foundOne = true } + if help.Key == "F" && help.Desc == "undo filter" { + foundUndo = true + } } if !found { t.Fatalf("expected probes binding in dashboard short help") @@ -159,4 +180,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) { if !foundOne { t.Fatalf("expected flame tab binding in dashboard short help") } + if !foundUndo { + t.Fatalf("expected undo filter binding in dashboard short help") + } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index c5effe2..22d09c3 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -65,6 +65,7 @@ type Model struct { refreshEvery time.Duration keys common.KeyMap globalFilter globalfilter.Filter + filterStack []string pidFilter int syscallsOffset int syscallsTreemapSelection int @@ -418,6 +419,12 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { streamWidth, streamHeight := streamViewport(m.width, m.height) m.streamModel.SetViewport(streamWidth, streamHeight) handled := m.streamModel.HandleTeaKey(msg) + if m.streamModel.ConsumeGlobalFilterUndoRequest() { + return true, func() tea.Msg { return messages.GlobalFilterUndoRequestedMsg{} } + } + if filter, action, ok := m.streamModel.ConsumeGlobalFilterRequest(); ok { + return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} } + } if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok { editorCmd, err := eventstream.EditorCommandForPath(path) if err != nil { @@ -543,6 +550,12 @@ func (m *Model) SetGlobalFilter(filter globalfilter.Filter) { m.streamModel.SetFilter(eventstream.Filter(filter)) } +// SetFilterStack forwards the shared global filter stack into dashboard views. +func (m *Model) SetFilterStack(stack []string) { + m.filterStack = append(m.filterStack[:0], stack...) + m.streamModel.SetFilterStack(stack) +} + // SetLiveTrie updates the live trie source used by the flamegraph tab. func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { m.liveTrie = liveTrie @@ -612,7 +625,11 @@ func (m Model) View() tea.View { } func (m Model) filterSummary() string { - return "filter: " + m.globalFilter.Summary() + summary := "filter: " + m.globalFilter.Summary() + if len(m.filterStack) == 0 { + return summary + } + return summary + " | stack: " + strings.Join(m.filterStack, " | ") } func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model) string { diff --git a/internal/tui/eventstream/filter_test.go b/internal/tui/eventstream/filter_test.go index 556d4ef..0413904 100644 --- a/internal/tui/eventstream/filter_test.go +++ b/internal/tui/eventstream/filter_test.go @@ -55,6 +55,23 @@ func TestFilterStringDimensions(t *testing.T) { } } +func TestFilterStringDimensionsSupportAnchors(t *testing.T) { + ev := sampleEvent() + if !(Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(&ev) { + t.Fatalf("expected ^read$ to exactly match read") + } + ev.Syscall = "readlink" + if (Filter{Syscall: &StringFilter{Pattern: "^read$"}}).Matches(&ev) { + t.Fatalf("expected ^read$ not to match readlink") + } + if !(Filter{Syscall: &StringFilter{Pattern: "^read"}}).Matches(&ev) { + t.Fatalf("expected ^read to match readlink") + } + if !(Filter{Syscall: &StringFilter{Pattern: "link$"}}).Matches(&ev) { + t.Fatalf("expected link$ to match readlink") + } +} + func TestFilterNumericDimensions(t *testing.T) { ev := sampleEvent() cases := []struct { diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 467c137..600d40d 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -36,25 +36,30 @@ type Model struct { allEvents []StreamEvent filtered []StreamEvent - filter Filter + filter Filter + filterStack []string paused bool - scrollOffset int - autoScroll bool - selectedIdx int - selectedCol int - fdTraceView fdTraceViewState - exportModal ExportModal - searchModal SearchModal - searchPattern string - searchRegex *regexp.Regexp - searchDirection SearchDirection - lastExportPath string - pendingOpenPath string - statusMessage string - exportDir string - isDark bool + scrollOffset int + autoScroll bool + selectedIdx int + selectedCol int + fdTraceView fdTraceViewState + exportModal ExportModal + searchModal SearchModal + searchPattern string + searchRegex *regexp.Regexp + searchDirection SearchDirection + lastExportPath string + pendingOpenPath string + pendingFilter Filter + pendingFilterAction string + hasPendingFilter bool + pendingUndo bool + statusMessage string + exportDir string + isDark bool width int height int @@ -135,6 +140,11 @@ func (m *Model) SetFilter(filter Filter) { m.restoreSelectionBySeq(targetSeq) } +// SetFilterStack updates the visible shared filter stack summary. +func (m *Model) SetFilterStack(stack []string) { + m.filterStack = append(m.filterStack[:0], stack...) +} + // SetDarkMode updates stream modal text input styles for the active theme. func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark @@ -231,6 +241,23 @@ func (m *Model) HandleKey(keyStr string) bool { } switch keyStr { + case "enter": + if m.paused { + return m.requestGlobalFilterFromSelectedCell() + } + return false + case "F": + if len(m.filterStack) == 0 { + return false + } + m.pendingUndo = true + return true + case "esc": + if m.paused && len(m.filterStack) > 0 { + m.pendingUndo = true + return true + } + return false case "/": m.openSearch(SearchForward) return true @@ -458,7 +485,7 @@ func (m *Model) View(width, height int) string { if m.paused && selectedVisibleIdx >= 0 { selectedCol = m.selectedCol } - base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) + base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, m.filterStack, visible, selectedVisibleIdx, selectedCol) if !m.showFooter { if m.exportModal.Visible() { return m.exportModal.View(width, height) @@ -471,7 +498,7 @@ func (m *Model) View(width, height int) string { status := fmt.Sprintf("Row %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount) } out := base + "\n" + status if m.statusMessage != "" { @@ -699,6 +726,53 @@ func (m *Model) moveSelectedColBy(delta int) { m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1) } +func (m *Model) requestGlobalFilterFromSelectedCell() bool { + if m.fdTraceView.visible || m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { + return false + } + ev := m.filtered[m.selectedIdx] + next := m.filter.Clone() + + switch m.selectedCol { + case streamColGap: + next.GapNs = &NumericFilter{Op: OpGte, Value: int64(ev.GapNs)} + m.pendingFilterAction = fmt.Sprintf("gap>=%s", formatDurationNs(ev.GapNs)) + case streamColLatency: + next.LatencyNs = &NumericFilter{Op: OpGte, Value: int64(ev.DurationNs)} + m.pendingFilterAction = fmt.Sprintf("latency>=%s", formatDurationNs(ev.DurationNs)) + case streamColComm: + next.Comm = &StringFilter{Pattern: ev.Comm} + m.pendingFilterAction = "comm~" + ev.Comm + case streamColPID: + next.PID = &NumericFilter{Op: OpEq, Value: int64(ev.PID)} + m.pendingFilterAction = fmt.Sprintf("pid=%d", ev.PID) + case streamColTID: + next.TID = &NumericFilter{Op: OpEq, Value: int64(ev.TID)} + m.pendingFilterAction = fmt.Sprintf("tid=%d", ev.TID) + case streamColSyscall: + next.Syscall = &StringFilter{Pattern: ev.Syscall} + m.pendingFilterAction = "syscall~" + ev.Syscall + case streamColFD: + next.FD = &NumericFilter{Op: OpEq, Value: int64(ev.FD)} + m.pendingFilterAction = fmt.Sprintf("fd=%d", ev.FD) + case streamColRet: + next.RetVal = &NumericFilter{Op: OpEq, Value: ev.RetVal} + m.pendingFilterAction = fmt.Sprintf("ret=%d", ev.RetVal) + case streamColBytes: + next.Bytes = &NumericFilter{Op: OpEq, Value: int64(ev.Bytes)} + m.pendingFilterAction = fmt.Sprintf("bytes=%d", ev.Bytes) + case streamColFile: + next.File = &StringFilter{Pattern: ev.FileName} + m.pendingFilterAction = "file~" + ev.FileName + default: + return false + } + + m.pendingFilter = next + m.hasPendingFilter = true + return true +} + func (m *Model) currentSelectedSeq() uint64 { if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { return 0 @@ -790,6 +864,28 @@ func (m *Model) ConsumeOpenEditorRequest() (string, bool) { return path, true } +// ConsumeGlobalFilterRequest returns the pending global-filter request once. +func (m *Model) ConsumeGlobalFilterRequest() (Filter, string, bool) { + if !m.hasPendingFilter { + return Filter{}, "", false + } + filter := m.pendingFilter.Clone() + action := m.pendingFilterAction + m.pendingFilter = Filter{} + m.pendingFilterAction = "" + m.hasPendingFilter = false + return filter, action, true +} + +// ConsumeGlobalFilterUndoRequest returns the pending undo request once. +func (m *Model) ConsumeGlobalFilterUndoRequest() bool { + if !m.pendingUndo { + return false + } + m.pendingUndo = false + return true +} + // SetStatusMessage updates the stream footer status line. func (m *Model) SetStatusMessage(message string) { m.statusMessage = message diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index 9848042..a97fbea 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -377,30 +377,68 @@ func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) { } } -func TestPausedEnterDoesNotMutateGlobalFilter(t *testing.T) { +func TestPausedEnterQueuesGlobalFilterRequestFromSelectedCell(t *testing.T) { rb := NewRingBuffer() rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a", DurationNs: 100, GapNs: 5}) rb.Push(StreamEvent{Seq: 2, PID: 1, TID: 2, Comm: "b", DurationNs: 200, GapNs: 6}) m := NewModel(rb) m.height = 20 m.Refresh() + m.SetFilter(Filter{PID: &NumericFilter{Op: OpEq, Value: 1}}) if !m.HandleKey("space") { t.Fatalf("space should pause") } m.selectedIdx = 0 - m.selectedCol = streamColLatency - if m.HandleKey("enter") { - t.Fatalf("expected enter not to mutate filters in paused stream mode") + m.selectedCol = streamColComm + if !m.HandleKey("enter") { + t.Fatalf("expected enter to queue a global filter request in paused stream mode") + } + if m.filter.Comm != nil { + t.Fatalf("expected local stream filter state to remain unchanged until parent applies it") + } + req, action, ok := m.ConsumeGlobalFilterRequest() + if !ok { + t.Fatalf("expected pending global filter request") + } + if action != "comm~a" { + t.Fatalf("expected action label comm~a, got %q", action) } - if m.filter.IsActive() { - t.Fatalf("expected enter to leave global filter unchanged") + if req.PID == nil || req.PID.Op != OpEq || req.PID.Value != 1 { + t.Fatalf("expected existing pid filter preserved, got %+v", req.PID) + } + if req.Comm == nil || req.Comm.Pattern != "a" { + t.Fatalf("expected selected comm folded into global filter, got %+v", req.Comm) + } + if _, _, ok := m.ConsumeGlobalFilterRequest(); ok { + t.Fatalf("expected global filter request to be one-shot") } if m.HandleKey("esc") { t.Fatalf("expected esc not to act as local filter undo anymore") } } +func TestPausedEscQueuesGlobalFilterUndoWhenStackPresent(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, PID: 1, TID: 1, Comm: "a"}) + m := NewModel(rb) + m.height = 20 + m.Refresh() + m.SetFilterStack([]string{"comm~a"}) + if !m.HandleKey("space") { + t.Fatalf("space should pause") + } + if !m.HandleKey("esc") { + t.Fatalf("expected esc to queue undo when a global filter stack exists") + } + if !m.ConsumeGlobalFilterUndoRequest() { + t.Fatalf("expected pending global filter undo request") + } + if m.ConsumeGlobalFilterUndoRequest() { + t.Fatalf("expected global filter undo request to be one-shot") + } +} + func TestSetFilterKeepsPausedSelectionCentered(t *testing.T) { rb := NewRingBuffer() for i := 0; i < 300; i++ { diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go index 3ec4d65..819b6f4 100644 --- a/internal/tui/eventstream/render.go +++ b/internal/tui/eventstream/render.go @@ -33,7 +33,7 @@ var selectedCellStyle = lipgloss.NewStyle(). Foreground(common.ColorBackground). Background(common.ColorAccent) -func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string { +func RenderStreamTable(width int, paused bool, totalCount, filteredCount, bufferLen, bufferCap int, filter Filter, filterStack []string, events []StreamEvent, selectedVisibleIdx int, selectedCol int) string { if width <= 0 { width = 100 } @@ -42,6 +42,9 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer lines := make([]string, 0, len(events)+3) lines = append(lines, renderStatusLine(paused, totalCount, filteredCount, bufferLen, bufferCap)) lines = append(lines, renderFilterLine(filter)) + if len(filterStack) > 0 { + lines = append(lines, renderFilterStackLine(filterStack)) + } lines = append(lines, renderColumnHeader(contentWidth)) for i, ev := range events { col := -1 @@ -91,6 +94,10 @@ func renderFilterLine(filter Filter) string { return common.HeaderStyle.Render("Filter:") + " " + summary } +func renderFilterStackLine(filterStack []string) string { + return common.HeaderStyle.Render("Stack:") + " " + strings.Join(filterStack, " | ") +} + func renderColumnHeader(width int) string { cols := computeColumnLayout(width) header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %-*s %s", diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go index 6240c69..d5b2af5 100644 --- a/internal/tui/eventstream/render_test.go +++ b/internal/tui/eventstream/render_test.go @@ -10,7 +10,7 @@ import ( func TestRenderStatusAndFilterLines(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "nginx", PID: 1, TID: 2, DurationNs: 1200, GapNs: 300, Bytes: 64, FileName: "/tmp/a", RetVal: 64}} f := Filter{Syscall: &StringFilter{Pattern: "read"}, PID: &NumericFilter{Op: OpEq, Value: 1}} - out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, events, -1, -1) + out := RenderStreamTable(120, false, 100, 1, 100, 10000, f, nil, events, -1, -1) for _, want := range []string{"LIVE", "total:100", "filtered:1", "buffer:100/10000", "Filter:", "syscall~read", "pid=1"} { if !strings.Contains(out, want) { @@ -21,7 +21,7 @@ func TestRenderStatusAndFilterLines(t *testing.T) { func TestRenderPausedAndErrorRow(t *testing.T) { events := []StreamEvent{{Syscall: "write", Comm: "worker", PID: 1, TID: 2, DurationNs: 1000000, GapNs: 5000, Bytes: 32, FileName: "/tmp/b", RetVal: -1, IsError: true}} - out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, events, -1, -1) + out := RenderStreamTable(120, true, 10, 1, 10, 10000, Filter{}, nil, events, -1, -1) if !strings.Contains(out, "PAUSED") { t.Fatalf("expected PAUSED indicator\n%s", out) @@ -36,7 +36,7 @@ func TestRenderPausedAndErrorRow(t *testing.T) { func TestRenderShowsFDWhenPresent(t *testing.T) { events := []StreamEvent{{Syscall: "read", Comm: "worker", PID: 1, TID: 2, FD: 9, DurationNs: 10, GapNs: 1, Bytes: 8, FileName: "/tmp/b", RetVal: 8}} - out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, events, -1, -1) + out := RenderStreamTable(120, false, 1, 1, 1, 10000, Filter{}, nil, events, -1, -1) if !strings.Contains(out, "FD") || !strings.Contains(out, " 9 ") { t.Fatalf("expected FD column/value in output\n%s", out) } @@ -54,7 +54,7 @@ func TestRenderHeaderAndTruncate(t *testing.T) { FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log", RetVal: 1, }} - out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, events, -1, -1) + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, nil, events, -1, -1) for _, col := range []string{"Gap", "Latency", "Comm", "PID", "TID", "Syscall", "FD", "Ret", "Bytes", "File"} { if !strings.Contains(out, col) { @@ -112,7 +112,7 @@ func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) { } func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { - out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, []StreamEvent{ + out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, nil, []StreamEvent{ { Syscall: "read", Comm: "worker", @@ -133,6 +133,15 @@ func TestRenderStreamTableFitsRequestedWidth(t *testing.T) { } } +func TestRenderShowsFilterStackLine(t *testing.T) { + out := RenderStreamTable(100, true, 3, 1, 3, 10000, Filter{Comm: &StringFilter{Pattern: "system"}}, []string{"comm~system", "fd=20"}, []StreamEvent{{Comm: "systemd", Syscall: "write"}}, -1, -1) + for _, want := range []string{"Stack:", "comm~system", "fd=20"} { + if !strings.Contains(out, want) { + t.Fatalf("expected stack output missing %q\n%s", want, out) + } + } +} + func TestRenderFDTraceTableShowsHeaderAndScope(t *testing.T) { out := RenderFDTraceTable(100, 123, 7, 2, []StreamEvent{ {Syscall: "read", PID: 123, TID: 1, FD: 7, FileName: "/tmp/a"}, diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go index 7d0273b..9826e25 100644 --- a/internal/tui/messages/messages.go +++ b/internal/tui/messages/messages.go @@ -1,6 +1,9 @@ package messages -import "ior/internal/statsengine" +import ( + "ior/internal/globalfilter" + "ior/internal/statsengine" +) // PidSelectedMsg is emitted when the user selects a PID from the process table. type PidSelectedMsg struct { @@ -21,6 +24,15 @@ type StatsTickMsg struct { // ExportRequestMsg requests an export of the current UI state. type ExportRequestMsg struct{} +// GlobalFilterRequestedMsg requests applying a new shared TUI filter. +type GlobalFilterRequestedMsg struct { + Filter globalfilter.Filter + Action string +} + +// GlobalFilterUndoRequestedMsg requests popping the latest shared filter layer. +type GlobalFilterUndoRequestedMsg struct{} + // TracingStartedMsg signals that tracing started successfully. type TracingStartedMsg struct{} diff --git a/internal/tui/tracefilter/model.go b/internal/tui/tracefilter/model.go index a9f2c08..caef948 100644 --- a/internal/tui/tracefilter/model.go +++ b/internal/tui/tracefilter/model.go @@ -104,11 +104,32 @@ func (m Model) Update(msg tea.Msg) Model { } if keyMsg, ok := msg.(tea.KeyPressMsg); ok { - switch keyMsg.String() { - case "esc": - if m.editing { + if m.editing { + switch keyMsg.String() { + case "esc": + m.commitEdit() + m.filter = m.buildFilterFromFields() + return m.Close() + case "enter": + if m.fields[m.activeField].fieldKey == fieldErrorsOnly { + if strings.TrimSpace(m.fields[m.activeField].value) == "true" { + m.fields[m.activeField].value = "false" + } else { + m.fields[m.activeField].value = "true" + } + return m + } m.commitEdit() + return m } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + return m + } + + switch keyMsg.String() { + case "esc": m.filter = m.buildFilterFromFields() return m.Close() case "c": @@ -147,20 +168,10 @@ func (m Model) Update(msg tea.Msg) Model { } return m } - if m.editing { - m.commitEdit() - return m - } m.startEdit() return m } } - - if m.editing { - var cmd tea.Cmd - m.textInput, cmd = m.textInput.Update(msg) - _ = cmd - } |
