diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 23:17:42 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 23:17:42 +0200 |
| commit | e5cb5db2292ae84680935767d455a777125e0fe9 (patch) | |
| tree | 5210c1c4d6aaff8f6de459b9af4cffdaac11f20e /internal | |
| parent | c3106802208b18f78d4ff4b22e1d889ac19f817f (diff) | |
tui: add stackable enter-filters with esc undo in stream
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/eventstream/filter.go | 7 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 161 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 171 |
3 files changed, 299 insertions, 40 deletions
diff --git a/internal/tui/eventstream/filter.go b/internal/tui/eventstream/filter.go index 458ecdd..4e0daf7 100644 --- a/internal/tui/eventstream/filter.go +++ b/internal/tui/eventstream/filter.go @@ -33,6 +33,7 @@ type Filter struct { File *StringFilter PID *NumericFilter TID *NumericFilter + FD *NumericFilter LatencyNs *NumericFilter GapNs *NumericFilter Bytes *NumericFilter @@ -62,6 +63,9 @@ func (f Filter) Matches(ev *StreamEvent) bool { if !matchNumeric(f.TID, int64(ev.TID)) { return false } + if !matchNumeric(f.FD, int64(ev.FD)) { + return false + } if !matchNumeric(f.LatencyNs, int64(ev.DurationNs)) { return false } @@ -86,7 +90,7 @@ func (f Filter) IsActive() bool { return true } } - for _, nf := range []*NumericFilter{f.PID, f.TID, f.LatencyNs, f.GapNs, f.Bytes, f.RetVal} { + for _, nf := range []*NumericFilter{f.PID, f.TID, f.FD, f.LatencyNs, f.GapNs, f.Bytes, f.RetVal} { if nf != nil { return true } @@ -104,6 +108,7 @@ func (f Filter) Summary() string { parts = appendStringSummary(parts, "file", f.File) parts = appendNumericSummary(parts, "pid", f.PID, false) parts = appendNumericSummary(parts, "tid", f.TID, false) + parts = appendNumericSummary(parts, "fd", f.FD, false) parts = appendNumericSummary(parts, "latency", f.LatencyNs, true) parts = appendNumericSummary(parts, "gap", f.GapNs, true) parts = appendNumericSummary(parts, "bytes", f.Bytes, false) diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index d757209..3258954 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -7,7 +7,19 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -const streamColumnCount = 10 +const ( + streamColGap = iota + streamColLatency + streamColComm + streamColPID + streamColTID + streamColSyscall + streamColFD + streamColRet + streamColBytes + streamColFile + streamColumnCount +) type Model struct { source *RingBuffer @@ -23,11 +35,13 @@ type Model struct { // can pause refresh temporarily without losing the user's prior state. pauseBeforeFilter bool - scrollOffset int - autoScroll bool - selectedIdx int - selectedCol int - fdTraceView fdTraceViewState + scrollOffset int + autoScroll bool + selectedIdx int + selectedCol int + fdTraceView fdTraceViewState + filterStack []Filter + filterActionStack []string width int height int @@ -84,6 +98,8 @@ func (m *Model) HandleKey(keyStr string) bool { m.filterModal = m.filterModal.Update(keyMsgFromString(keyStr)) if wasVisible && !m.filterModal.Visible() { m.filter = m.filterModal.Filter() + m.filterStack = nil + m.filterActionStack = nil m.paused = m.pauseBeforeFilter m.applyFilter() if !m.paused { @@ -131,7 +147,7 @@ func (m *Model) HandleKey(keyStr string) bool { switch keyStr { case "enter": if m.paused { - return m.openFDTraceView() + return m.applyFilterFromSelectedCell() } return false case " ", "space": @@ -170,6 +186,8 @@ func (m *Model) HandleKey(keyStr string) bool { return true case "c": m.filter = Filter{} + m.filterStack = nil + m.filterActionStack = nil m.applyFilter() return true case "j", "down": @@ -212,6 +230,11 @@ func (m *Model) HandleKey(keyStr string) bool { m.scrollByLines(-m.pageStep()) } return true + case "esc": + if m.paused { + return m.popFilter() + } + return false default: return false } @@ -285,9 +308,12 @@ func (m *Model) View(width, height int) string { base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", 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 | enter:fd-trace space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", 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:add-filter esc:undo(%d) space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) } out := base + "\n" + status + if len(m.filterActionStack) > 0 { + out += "\n" + "Stack: " + strings.Join(m.filterActionStack, " | ") + } if m.filterModal.Visible() { // While editing filters, show a dedicated modal screen to avoid @@ -518,6 +544,125 @@ func (m *Model) moveSelectedColBy(delta int) { m.selectedCol = clamp(m.selectedCol+delta, 0, streamColumnCount-1) } +func (m *Model) applyFilterFromSelectedCell() bool { + if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { + return false + } + ev := m.filtered[m.selectedIdx] + targetSeq := ev.Seq + next := cloneFilter(m.filter) + action := "" + + switch m.selectedCol { + case streamColGap: + next.GapNs = &NumericFilter{Op: OpGte, Value: int64(ev.GapNs)} + action = fmt.Sprintf("gap>=%dns", ev.GapNs) + case streamColLatency: + next.LatencyNs = &NumericFilter{Op: OpGte, Value: int64(ev.DurationNs)} + action = fmt.Sprintf("latency>=%dns", ev.DurationNs) + case streamColComm: + next.Comm = &StringFilter{Pattern: ev.Comm} + action = fmt.Sprintf("comm~%s", ev.Comm) + case streamColPID: + next.PID = &NumericFilter{Op: OpEq, Value: int64(ev.PID)} + action = fmt.Sprintf("pid=%d", ev.PID) + case streamColTID: + next.TID = &NumericFilter{Op: OpEq, Value: int64(ev.TID)} + action = fmt.Sprintf("tid=%d", ev.TID) + case streamColSyscall: + next.Syscall = &StringFilter{Pattern: ev.Syscall} + action = fmt.Sprintf("syscall~%s", ev.Syscall) + case streamColFD: + next.FD = &NumericFilter{Op: OpEq, Value: int64(ev.FD)} + action = fmt.Sprintf("fd=%d", ev.FD) + case streamColRet: + next.RetVal = &NumericFilter{Op: OpEq, Value: ev.RetVal} + action = fmt.Sprintf("ret=%d", ev.RetVal) + case streamColBytes: + next.Bytes = &NumericFilter{Op: OpEq, Value: int64(ev.Bytes)} + action = fmt.Sprintf("bytes=%d", ev.Bytes) + case streamColFile: + next.File = &StringFilter{Pattern: ev.FileName} + action = fmt.Sprintf("file~%s", ev.FileName) + default: + return false + } + + m.filterStack = append(m.filterStack, cloneFilter(m.filter)) + m.filterActionStack = append(m.filterActionStack, action) + m.filter = next + m.applyFilter() + m.restoreSelectionBySeq(targetSeq) + return true +} + +func (m *Model) popFilter() bool { + if len(m.filterStack) == 0 { + return false + } + targetSeq := m.currentSelectedSeq() + last := m.filterStack[len(m.filterStack)-1] + m.filterStack = m.filterStack[:len(m.filterStack)-1] + if len(m.filterActionStack) > 0 { + m.filterActionStack = m.filterActionStack[:len(m.filterActionStack)-1] + } + m.filter = cloneFilter(last) + m.applyFilter() + m.restoreSelectionBySeq(targetSeq) + return true +} + +func (m *Model) currentSelectedSeq() uint64 { + if m.selectedIdx < 0 || m.selectedIdx >= len(m.filtered) { + return 0 + } + return m.filtered[m.selectedIdx].Seq +} + +func (m *Model) restoreSelectionBySeq(seq uint64) { + if !m.paused || seq == 0 || len(m.filtered) == 0 { + return + } + for i := range m.filtered { + if m.filtered[i].Seq == seq { + m.selectedIdx = i + m.centerSelection() + return + } + } +} + +func cloneFilter(in Filter) Filter { + out := in + out.Syscall = cloneStringFilter(in.Syscall) + out.Comm = cloneStringFilter(in.Comm) + out.File = cloneStringFilter(in.File) + out.PID = cloneNumericFilter(in.PID) + out.TID = cloneNumericFilter(in.TID) + out.FD = cloneNumericFilter(in.FD) + out.LatencyNs = cloneNumericFilter(in.LatencyNs) + out.GapNs = cloneNumericFilter(in.GapNs) + out.Bytes = cloneNumericFilter(in.Bytes) + out.RetVal = cloneNumericFilter(in.RetVal) + return out +} + +func cloneStringFilter(in *StringFilter) *StringFilter { + if in == nil { + return nil + } + out := *in + return &out +} + +func cloneNumericFilter(in *NumericFilter) *NumericFilter { + if in == nil { + return nil + } + out := *in + return &out +} + func (m *Model) clampSelection() { if len(m.filtered) == 0 { m.selectedIdx = -1 diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index e58144a..d55da61 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -414,63 +414,172 @@ func TestPausedSelectionMovesAcrossColumnsWithLeftRightAndHL(t *testing.T) { } } -func TestPausedEnterOpensFDTraceViewScopedByPIDAndFD(t *testing.T) { +func TestPausedEnterAddsStackableFiltersAndEscUndoes(t *testing.T) { rb := NewRingBuffer() - rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"}) - rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"}) // same pid/fd, different tid - rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"}) // different fd - rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"}) // different pid + rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, Comm: "firefox", Syscall: "read", FD: 5, RetVal: 10, Bytes: 10, GapNs: 50, DurationNs: 100, FileName: "/a"}) + rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, Comm: "firefox", Syscall: "write", FD: 6, RetVal: 11, Bytes: 11, GapNs: 51, DurationNs: 101, FileName: "/b"}) + rb.Push(StreamEvent{Seq: 3, PID: 11, TID: 201, Comm: "bash", Syscall: "read", FD: 7, RetVal: 12, Bytes: 12, GapNs: 52, DurationNs: 102, FileName: "/c"}) m := NewModel(rb) m.height = 20 m.Refresh() if !m.HandleKey("space") { - t.Fatalf("space should pause") + t.Fatalf("space should toggle pause") } - // Pick the first row (pid=10, fd=3). m.selectedIdx = 0 + m.selectedCol = streamColComm if !m.HandleKey("enter") { - t.Fatalf("enter should open fd trace view") + t.Fatalf("enter should add filter from selected comm cell") } - if !m.fdTraceView.visible { - t.Fatalf("expected fd trace view visible") + if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { + t.Fatalf("expected comm filter firefox, got %+v", m.filter.Comm) } - if m.fdTraceView.pid != 10 || m.fdTraceView.fd != 3 { - t.Fatalf("expected pid/fd 10/3, got %d/%d", m.fdTraceView.pid, m.fdTraceView.fd) + if len(m.filtered) != 2 { + t.Fatalf("expected 2 rows after comm filter, got %d", len(m.filtered)) } - if len(m.fdTraceView.events) != 2 { - t.Fatalf("expected 2 matching events, got %d", len(m.fdTraceView.events)) + if len(m.filterStack) != 1 { + t.Fatalf("expected filter stack len 1, got %d", len(m.filterStack)) } - for _, ev := range m.fdTraceView.events { - if ev.PID != 10 || ev.FD != 3 { - t.Fatalf("unexpected event in fd trace view: pid=%d fd=%d", ev.PID, ev.FD) - } + if got := m.View(120, 24); !strings.Contains(got, "Stack: comm~firefox") { + t.Fatalf("expected stack status line with comm filter, got:\n%s", got) + } + + m.selectedIdx = 1 + m.selectedCol = streamColTID + if !m.HandleKey("enter") { + t.Fatalf("enter should add filter from selected tid cell") + } + if m.filter.TID == nil || m.filter.TID.Value != 102 { + t.Fatalf("expected tid filter 102, got %+v", m.filter.TID) + } + if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { + t.Fatalf("expected comm filter preserved, got %+v", m.filter.Comm) + } + if len(m.filtered) != 1 { + t.Fatalf("expected 1 row after comm+tid filters, got %d", len(m.filtered)) + } + + if !m.HandleKey("esc") { + t.Fatalf("esc should undo last filter") + } + if m.filter.TID != nil { + t.Fatalf("expected tid filter cleared after undo") + } + if m.filter.Comm == nil || m.filter.Comm.Pattern != "firefox" { + t.Fatalf("expected comm filter kept after first undo") + } + if len(m.filtered) != 2 { + t.Fatalf("expected 2 rows after undo tid filter, got %d", len(m.filtered)) + } + + if !m.HandleKey("esc") { + t.Fatalf("esc should undo previous filter") + } + if m.filter.IsActive() { + t.Fatalf("expected no active filters after second undo, got summary=%q", m.filter.Summary()) + } + if len(m.filtered) != 3 { + t.Fatalf("expected all rows after second undo, got %d", len(m.filtered)) + } + if got := m.View(120, 24); strings.Contains(got, "Stack:") { + t.Fatalf("did not expect stack status line after all undos, got:\n%s", got) } } -func TestFDTraceViewRendersAndClosesOnEsc(t *testing.T) { +func TestPausedEnterCanFilterLatencyAndGapColumns(t *testing.T) { rb := NewRingBuffer() - rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 5, Syscall: "read", FileName: "/x"}) - rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 5, Syscall: "write", FileName: "/x"}) + 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.HandleKey("space") + m.selectedIdx = 0 - _ = m.HandleKey("enter") + m.selectedCol = streamColLatency + if !m.HandleKey("enter") { + t.Fatalf("expected enter to filter by latency") + } + if m.filter.LatencyNs == nil || m.filter.LatencyNs.Op != OpGte || m.filter.LatencyNs.Value != 100 { + t.Fatalf("expected latency filter >=100ns, got %+v", m.filter.LatencyNs) + } + if len(m.filtered) != 2 { + t.Fatalf("expected both rows after latency >=100 filter, got %d", len(m.filtered)) + } - view := m.View(120, 24) - if !strings.Contains(view, "FD Trace (ring snapshot)") { - t.Fatalf("expected fd trace header in view") + m.selectedCol = streamColGap + if !m.HandleKey("enter") { + t.Fatalf("expected enter to filter by gap") } - if !strings.Contains(view, "PID:10 FD:5 matched:2") { - t.Fatalf("expected pid/fd summary in fd trace view") + if m.filter.GapNs == nil || m.filter.GapNs.Op != OpGte || m.filter.GapNs.Value != 5 { + t.Fatalf("expected gap filter >=5ns, got %+v", m.filter.GapNs) } - if !m.HandleKey("esc") { - t.Fatalf("esc should close fd trace view") + if len(m.filtered) != 2 { + t.Fatalf("expected both rows after gap >=5 filter, got %d", len(m.filtered)) + } +} + +func TestPausedEnterKeepsSelectedRowCenteredAfterFilter(t *testing.T) { + rb := NewRingBuffer() + for i := 0; i < 300; i++ { + comm := "other" + if i >= 90 && i <= 210 { + comm = "match" + } + rb.Push(StreamEvent{ + Seq: uint64(i + 1), + PID: 1000, + TID: uint32(2000 + i), + Comm: comm, + Syscall: "read", + FileName: "/tmp/f", + }) + } + + m := NewModel(rb) + m.height = 20 + m.Refresh() + _ = m.HandleKey("space") + m.moveSelectionTo(150) + before := m.selectedIdx - m.scrollOffset + if before < 4 || before > 8 { + t.Fatalf("expected initial selected row near middle, got relative idx %d", before) + } + + m.selectedCol = streamColComm + if !m.HandleKey("enter") { + t.Fatalf("expected enter to apply filter") + } + after := m.selectedIdx - m.scrollOffset + if after < 4 || after > 8 { + t.Fatalf("expected selected row to stay near middle after filter, got relative idx %d", after) + } +} + +func TestPausedEnterCanFilterByFDColumn(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, PID: 10, TID: 101, FD: 3, Syscall: "read", FileName: "/a"}) + rb.Push(StreamEvent{Seq: 2, PID: 10, TID: 102, FD: 3, Syscall: "write", FileName: "/a"}) + rb.Push(StreamEvent{Seq: 3, PID: 10, TID: 103, FD: 4, Syscall: "read", FileName: "/b"}) + rb.Push(StreamEvent{Seq: 4, PID: 11, TID: 104, FD: 3, Syscall: "read", FileName: "/c"}) + + m := NewModel(rb) + m.height = 20 + m.Refresh() + if !m.HandleKey("space") { + t.Fatalf("space should pause") + } + + m.selectedIdx = 0 + m.selectedCol = streamColFD + if !m.HandleKey("enter") { + t.Fatalf("enter should add fd filter") } - if m.fdTraceView.visible { - t.Fatalf("expected fd trace view closed") + if m.filter.FD == nil || m.filter.FD.Value != 3 { + t.Fatalf("expected fd filter 3, got %+v", m.filter.FD) + } + if len(m.filtered) != 3 { + t.Fatalf("expected 3 rows with fd=3, got %d", len(m.filtered)) } } |
