summaryrefslogtreecommitdiff
path: root/internal/tui/eventstream
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 08:35:21 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 08:35:21 +0200
commit39bbe57ca8022a516b8139710ec879e81ab21736 (patch)
treead15f7c6caa4f8b7856981de8fb42bf3d0e436cf /internal/tui/eventstream
parentfb705185b5add84eb1f4b17c01a5e249215d0859 (diff)
Add event stream filter modal
Diffstat (limited to 'internal/tui/eventstream')
-rw-r--r--internal/tui/eventstream/filtermodal.go394
-rw-r--r--internal/tui/eventstream/filtermodal_test.go108
2 files changed, 502 insertions, 0 deletions
diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go
new file mode 100644
index 0000000..f98db7f
--- /dev/null
+++ b/internal/tui/eventstream/filtermodal.go
@@ -0,0 +1,394 @@
+package eventstream
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type fieldKey int
+
+const (
+ fieldSyscall fieldKey = iota
+ fieldComm
+ fieldFile
+ fieldPID
+ fieldTID
+ fieldLatency
+ fieldGap
+ fieldBytes
+ fieldReturn
+ fieldErrorsOnly
+)
+
+type filterField struct {
+ label string
+ fieldKey fieldKey
+ value string
+ opIndex int
+}
+
+type FilterModal struct {
+ visible bool
+ fields []filterField
+ activeField int
+ editing bool
+ textInput textinput.Model
+ filter Filter
+}
+
+var compareOps = []CompareOp{OpGt, OpLt, OpEq, OpGte, OpLte, OpNeq}
+var compareOpLabels = []string{">", "<", "=", ">=", "<=", "!="}
+
+func NewFilterModal() FilterModal {
+ input := textinput.New()
+ input.Prompt = ""
+ input.CharLimit = 0
+ input.Width = 24
+
+ m := FilterModal{textInput: input}
+ m.fields = defaultFilterFields()
+ return m
+}
+
+func (m FilterModal) Visible() bool {
+ return m.visible
+}
+
+func (m FilterModal) Filter() Filter {
+ return m.filter
+}
+
+func (m FilterModal) Open(initial Filter) FilterModal {
+ m.visible = true
+ m.activeField = 0
+ m.editing = false
+ m.textInput.Blur()
+ m.fields = defaultFilterFields()
+ m.applyFilterToFields(initial)
+ m.filter = initial
+ return m
+}
+
+func (m FilterModal) Close() FilterModal {
+ m.visible = false
+ m.editing = false
+ m.textInput.Blur()
+ return m
+}
+
+func (m FilterModal) Update(msg tea.Msg) FilterModal {
+ if !m.visible {
+ return m
+ }
+
+ if keyMsg, ok := msg.(tea.KeyMsg); ok {
+ switch keyMsg.String() {
+ case "esc":
+ if m.editing {
+ m.commitEdit()
+ }
+ m.filter = m.buildFilterFromFields()
+ return m.Close()
+ case "c":
+ m.clearAll()
+ return m
+ case "j", "down":
+ if !m.editing && m.activeField < len(m.fields)-1 {
+ m.activeField++
+ }
+ return m
+ case "k", "up":
+ if !m.editing && m.activeField > 0 {
+ m.activeField--
+ }
+ return m
+ case "tab":
+ if !m.editing && m.isNumericField(m.activeField) {
+ m.fields[m.activeField].opIndex = (m.fields[m.activeField].opIndex + 1) % len(compareOps)
+ }
+ return m
+ case " ":
+ if !m.editing && 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
+ 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
+ }
+ 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
+ }
+ return m
+}
+
+func (m FilterModal) View(width, height int) string {
+ if !m.visible {
+ return ""
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+
+ modalWidth := 64
+ if width < modalWidth+4 {
+ modalWidth = width - 4
+ if modalWidth < 40 {
+ modalWidth = 40
+ }
+ }
+
+ lines := []string{"Filter"}
+ for i, f := range m.fields {
+ prefix := " "
+ if i == m.activeField {
+ prefix = "> "
+ }
+ lines = append(lines, prefix+m.renderField(f, i == m.activeField))
+ }
+ lines = append(lines, "", "j/k move • Enter edit/apply • Tab op • Space toggle errors • c clear • Esc apply+close")
+
+ box := lipgloss.NewStyle().
+ Border(lipgloss.RoundedBorder()).
+ Padding(1, 2).
+ Width(modalWidth).
+ Render(strings.Join(lines, "\n"))
+
+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
+}
+
+func (m *FilterModal) clearAll() {
+ for i := range m.fields {
+ m.fields[i].value = ""
+ if m.isNumericField(i) {
+ m.fields[i].opIndex = 2
+ }
+ }
+ m.fields[len(m.fields)-1].value = "false"
+ m.editing = false
+ m.textInput.Blur()
+}
+
+func (m *FilterModal) startEdit() {
+ m.editing = true
+ m.textInput.SetValue(m.fields[m.activeField].value)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+}
+
+func (m *FilterModal) commitEdit() {
+ m.fields[m.activeField].value = strings.TrimSpace(m.textInput.Value())
+ m.editing = false
+ m.textInput.Blur()
+}
+
+func (m FilterModal) renderField(f filterField, active bool) string {
+ if f.fieldKey == fieldErrorsOnly {
+ checked := "[ ]"
+ if strings.TrimSpace(f.value) == "true" {
+ checked = "[x]"
+ }
+ return fmt.Sprintf("%-8s %s", f.label+":", checked)
+ }
+
+ value := f.value
+ if active && m.editing {
+ value = m.textInput.View()
+ }
+ if f.fieldKey == fieldLatency || f.fieldKey == fieldGap || m.isNumericFieldByKey(f.fieldKey) {
+ return fmt.Sprintf("%-8s [%2s] %s", f.label+":", compareOpLabels[f.opIndex], value)
+ }
+ return fmt.Sprintf("%-8s %s", f.label+":", value)
+}
+
+func (m FilterModal) buildFilterFromFields() Filter {
+ out := Filter{}
+ for _, f := range m.fields {
+ value := strings.TrimSpace(f.value)
+ switch f.fieldKey {
+ case fieldSyscall:
+ if value != "" {
+ out.Syscall = &StringFilter{Pattern: value}
+ }
+ case fieldComm:
+ if value != "" {
+ out.Comm = &StringFilter{Pattern: value}
+ }
+ case fieldFile:
+ if value != "" {
+ out.File = &StringFilter{Pattern: value}
+ }
+ case fieldPID:
+ if nf, ok := parseNumericFilter(value, f.opIndex, false); ok {
+ out.PID = nf
+ }
+ case fieldTID:
+ if nf, ok := parseNumericFilter(value, f.opIndex, false); ok {
+ out.TID = nf
+ }
+ case fieldLatency:
+ if nf, ok := parseNumericFilter(value, f.opIndex, true); ok {
+ out.LatencyNs = nf
+ }
+ case fieldGap:
+ if nf, ok := parseNumericFilter(value, f.opIndex, true); ok {
+ out.GapNs = nf
+ }
+ case fieldBytes:
+ if nf, ok := parseNumericFilter(value, f.opIndex, false); ok {
+ out.Bytes = nf
+ }
+ case fieldReturn:
+ if nf, ok := parseNumericFilter(value, f.opIndex, false); ok {
+ out.RetVal = nf
+ }
+ case fieldErrorsOnly:
+ out.ErrorsOnly = strings.EqualFold(value, "true")
+ }
+ }
+ return out
+}
+
+func parseNumericFilter(value string, opIndex int, duration bool) (*NumericFilter, bool) {
+ if value == "" {
+ return nil, false
+ }
+ if opIndex < 0 || opIndex >= len(compareOps) {
+ opIndex = 2
+ }
+
+ var n int64
+ var err error
+ if duration {
+ n, err = ParseDurationNs(value)
+ } else {
+ n, err = strconv.ParseInt(value, 10, 64)
+ }
+ if err != nil {
+ return nil, false
+ }
+ return &NumericFilter{Op: compareOps[opIndex], Value: n}, true
+}
+
+func (m *FilterModal) applyFilterToFields(f Filter) {
+ m.setStringField(fieldSyscall, f.Syscall)
+ m.setStringField(fieldComm, f.Comm)
+ m.setStringField(fieldFile, f.File)
+ m.setNumericField(fieldPID, f.PID, false)
+ m.setNumericField(fieldTID, f.TID, false)
+ m.setNumericField(fieldLatency, f.LatencyNs, true)
+ m.setNumericField(fieldGap, f.GapNs, true)
+ m.setNumericField(fieldBytes, f.Bytes, false)
+ m.setNumericField(fieldReturn, f.RetVal, false)
+ if f.ErrorsOnly {
+ m.fields[fieldErrorsOnly].value = "true"
+ } else {
+ m.fields[fieldErrorsOnly].value = "false"
+ }
+}
+
+func (m *FilterModal) setStringField(k fieldKey, sf *StringFilter) {
+ if sf == nil {
+ m.fields[k].value = ""
+ return
+ }
+ m.fields[k].value = sf.Pattern
+}
+
+func (m *FilterModal) setNumericField(k fieldKey, nf *NumericFilter, duration bool) {
+ if nf == nil {
+ m.fields[k].value = ""
+ m.fields[k].opIndex = 2
+ return
+ }
+ m.fields[k].opIndex = opToIndex(nf.Op)
+ if duration {
+ m.fields[k].value = formatDurationField(nf.Value)
+ return
+ }
+ m.fields[k].value = strconv.FormatInt(nf.Value, 10)
+}
+
+func formatDurationField(v int64) string {
+ if v%1_000_000 == 0 {
+ return fmt.Sprintf("%dms", v/1_000_000)
+ }
+ if v%1_000 == 0 {
+ return fmt.Sprintf("%dus", v/1_000)
+ }
+ return fmt.Sprintf("%dns", v)
+}
+
+func opToIndex(op CompareOp) int {
+ for i, o := range compareOps {
+ if o == op {
+ return i
+ }
+ }
+ return 2
+}
+
+func (m FilterModal) isNumericField(i int) bool {
+ if i < 0 || i >= len(m.fields) {
+ return false
+ }
+ return m.isNumericFieldByKey(m.fields[i].fieldKey)
+}
+
+func (m FilterModal) isNumericFieldByKey(k fieldKey) bool {
+ switch k {
+ case fieldPID, fieldTID, fieldLatency, fieldGap, fieldBytes, fieldReturn:
+ return true
+ default:
+ return false
+ }
+}
+
+func defaultFilterFields() []filterField {
+ fields := []filterField{
+ {label: "Syscall", fieldKey: fieldSyscall},
+ {label: "Comm", fieldKey: fieldComm},
+ {label: "File", fieldKey: fieldFile},
+ {label: "PID", fieldKey: fieldPID},
+ {label: "TID", fieldKey: fieldTID},
+ {label: "Latency", fieldKey: fieldLatency},
+ {label: "Gap", fieldKey: fieldGap},
+ {label: "Bytes", fieldKey: fieldBytes},
+ {label: "Return", fieldKey: fieldReturn},
+ {label: "Errors", fieldKey: fieldErrorsOnly, value: "false"},
+ }
+ for i := range fields {
+ if fields[i].fieldKey != fieldErrorsOnly {
+ fields[i].opIndex = 2
+ }
+ }
+ return fields
+}
diff --git a/internal/tui/eventstream/filtermodal_test.go b/internal/tui/eventstream/filtermodal_test.go
new file mode 100644
index 0000000..ee53c82
--- /dev/null
+++ b/internal/tui/eventstream/filtermodal_test.go
@@ -0,0 +1,108 @@
+package eventstream
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestFilterModalOpenClose(t *testing.T) {
+ m := NewFilterModal()
+ if m.Visible() {
+ t.Fatalf("new modal should not be visible")
+ }
+
+ m = m.Open(Filter{})
+ if !m.Visible() {
+ t.Fatalf("modal should be visible after open")
+ }
+
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if m.Visible() {
+ t.Fatalf("modal should close on esc")
+ }
+}
+
+func TestFilterModalNavigateFields(t *testing.T) {
+ m := NewFilterModal().Open(Filter{})
+ if m.activeField != 0 {
+ t.Fatalf("activeField=%d, want 0", m.activeField)
+ }
+
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ if m.activeField != 1 {
+ t.Fatalf("activeField=%d, want 1", m.activeField)
+ }
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("k")})
+ if m.activeField != 0 {
+ t.Fatalf("activeField=%d, want 0", m.activeField)
+ }
+}
+
+func TestFilterModalEditAndBuildFilter(t *testing.T) {
+ m := NewFilterModal().Open(Filter{})
+
+ // Syscall = read
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("read")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+ // PID >= 123
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyTab}) // '=' -> '>'
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("123")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+ // Latency >= 1ms
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyTab}) // '=' -> '>='
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1ms")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+ // ErrorsOnly = true
+ for m.activeField < len(m.fields)-1 {
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("j")})
+ }
+ m = m.Update(tea.KeyMsg{Type: tea.KeySpace})
+
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if m.Visible() {
+ t.Fatalf("modal should close on esc")
+ }
+
+ f := m.Filter()
+ if f.Syscall == nil || f.Syscall.Pattern != "read" {
+ t.Fatalf("syscall filter not applied: %+v", f.Syscall)
+ }
+ if f.PID == nil || f.PID.Op != OpGte || f.PID.Value != 123 {
+ t.Fatalf("pid filter mismatch: %+v", f.PID)
+ }
+ if f.LatencyNs == nil || f.LatencyNs.Op != OpGte || f.LatencyNs.Value != 1_000_000 {
+ t.Fatalf("latency filter mismatch: %+v", f.LatencyNs)
+ }
+ if !f.ErrorsOnly {
+ t.Fatalf("errors-only expected true")
+ }
+}
+
+func TestFilterModalClearAll(t *testing.T) {
+ initial := Filter{
+ Syscall: &StringFilter{Pattern: "open"},
+ PID: &NumericFilter{Op: OpEq, Value: 42},
+ ErrorsOnly: true,
+ }
+ m := NewFilterModal().Open(initial)
+
+ m = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("c")})
+ m = m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+
+ f := m.Filter()
+ if f.IsActive() {
+ t.Fatalf("expected cleared filter to be inactive: %+v", f)
+ }
+}