diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-08 20:21:06 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-08 20:21:06 +0200 |
| commit | cfd1319f06725df4e2501cdfc67983b1a44e7e16 (patch) | |
| tree | c8d382af0850738fb3080704154173b390363399 /internal | |
| parent | 83ff18252be5ad4d667084a3a6edbf7cd5271e6b (diff) | |
task 370: extract reusable trace filter modal
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/eventstream/filtermodal.go | 398 | ||||
| -rw-r--r-- | internal/tui/eventstream/filtermodal_test.go | 108 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model.go | 419 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model_test.go | 102 |
4 files changed, 524 insertions, 503 deletions
diff --git a/internal/tui/eventstream/filtermodal.go b/internal/tui/eventstream/filtermodal.go index bd20a03..b5f67ee 100644 --- a/internal/tui/eventstream/filtermodal.go +++ b/internal/tui/eventstream/filtermodal.go @@ -1,401 +1,9 @@ package eventstream -import ( - "fmt" - "strconv" - "strings" +import tracefilter "ior/internal/tui/tracefilter" - "charm.land/bubbles/v2/textinput" - tea "charm.land/bubbletea/v2" - "charm.land/lipgloss/v2" -) - -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{">", "<", "=", ">=", "<=", "!="} +type FilterModal = tracefilter.Model func NewFilterModal() FilterModal { - input := textinput.New() - input.Prompt = "" - input.CharLimit = 0 - input.SetWidth(24) - input.SetStyles(textinput.DefaultStyles(true)) - - 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 -} - -// SetDarkMode updates filter modal text input styles. -func (m FilterModal) SetDarkMode(isDark bool) FilterModal { - m.textInput.SetStyles(textinput.DefaultStyles(isDark)) - return m -} - -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.KeyPressMsg); 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 " ", "space": - 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 + return tracefilter.NewModel() } diff --git a/internal/tui/eventstream/filtermodal_test.go b/internal/tui/eventstream/filtermodal_test.go deleted file mode 100644 index a33cbb1..0000000 --- a/internal/tui/eventstream/filtermodal_test.go +++ /dev/null @@ -1,108 +0,0 @@ -package eventstream - -import ( - "testing" - - tea "charm.land/bubbletea/v2" -) - -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.KeyPressMsg{Code: 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.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - if m.activeField != 1 { - t.Fatalf("activeField=%d, want 1", m.activeField) - } - m = m.Update(tea.KeyPressMsg{Code: []rune("k")[0], Text: string([]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.KeyPressMsg{Code: tea.KeyEnter}) - m = m.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))}) - m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - - // PID >= 123 - m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - m = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // '=' -> '>' - m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - m = m.Update(tea.KeyPressMsg{Code: []rune("123")[0], Text: string([]rune("123"))}) - m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - - // Latency >= 1ms - m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - m = m.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // '=' -> '>=' - m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - m = m.Update(tea.KeyPressMsg{Code: []rune("1ms")[0], Text: string([]rune("1ms"))}) - m = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) - - // ErrorsOnly = true - for m.activeField < len(m.fields)-1 { - m = m.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) - } - m = m.Update(tea.KeyPressMsg{Code: tea.KeySpace}) - - m = m.Update(tea.KeyPressMsg{Code: 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.KeyPressMsg{Code: []rune("c")[0], Text: string([]rune("c"))}) - m = m.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) - - f := m.Filter() - if f.IsActive() { - t.Fatalf("expected cleared filter to be inactive: %+v", f) - } -} diff --git a/internal/tui/tracefilter/model.go b/internal/tui/tracefilter/model.go new file mode 100644 index 0000000..a9f2c08 --- /dev/null +++ b/internal/tui/tracefilter/model.go @@ -0,0 +1,419 @@ +package tracefilter + +import ( + "fmt" + "strconv" + "strings" + + "ior/internal/globalfilter" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type fieldKey int + +const ( + fieldSyscall fieldKey = iota + fieldComm + fieldFile + fieldPID + fieldTID + fieldFD + fieldLatency + fieldGap + fieldBytes + fieldReturn + fieldErrorsOnly +) + +type filterField struct { + label string + fieldKey fieldKey + value string + opIndex int +} + +type Model struct { + visible bool + fields []filterField + activeField int + editing bool + textInput textinput.Model + filter globalfilter.Filter +} + +var compareOps = []globalfilter.CompareOp{ + globalfilter.OpGt, + globalfilter.OpLt, + globalfilter.OpEq, + globalfilter.OpGte, + globalfilter.OpLte, + globalfilter.OpNeq, +} + +var compareOpLabels = []string{">", "<", "=", ">=", "<=", "!="} + +func NewModel() Model { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.SetWidth(24) + input.SetStyles(textinput.DefaultStyles(true)) + + model := Model{textInput: input} + model.fields = defaultFilterFields() + return model +} + +func (m Model) Visible() bool { + return m.visible +} + +func (m Model) Filter() globalfilter.Filter { + return m.filter +} + +func (m Model) SetDarkMode(isDark bool) Model { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + +func (m Model) Open(initial globalfilter.Filter) Model { + m.visible = true + m.activeField = 0 + m.editing = false + m.textInput.Blur() + m.fields = defaultFilterFields() + m.applyFilterToFields(initial) + m.filter = initial.Clone() + return m +} + +func (m Model) Close() Model { + m.visible = false + m.editing = false + m.textInput.Blur() + return m +} + +func (m Model) Update(msg tea.Msg) Model { + if !m.visible { + return m + } + + if keyMsg, ok := msg.(tea.KeyPressMsg); 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 " ", "space": + 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 Model) 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, field := range m.fields { + prefix := " " + if i == m.activeField { + prefix = "> " + } + lines = append(lines, prefix+m.renderField(field, 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 *Model) 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 *Model) startEdit() { + m.editing = true + m.textInput.SetValue(m.fields[m.activeField].value) + m.textInput.CursorEnd() + m.textInput.Focus() +} + +func (m *Model) commitEdit() { + m.fields[m.activeField].value = strings.TrimSpace(m.textInput.Value()) + m.editing = false + m.textInput.Blur() +} + +func (m Model) renderField(field filterField, active bool) string { + if field.fieldKey == fieldErrorsOnly { + checked := "[ ]" + if strings.TrimSpace(field.value) == "true" { + checked = "[x]" + } + return fmt.Sprintf("%-8s %s", field.label+":", checked) + } + + value := field.value + if active && m.editing { + value = m.textInput.View() + } + if field.fieldKey == fieldLatency || field.fieldKey == fieldGap || m.isNumericFieldByKey(field.fieldKey) { + return fmt.Sprintf("%-8s [%2s] %s", field.label+":", compareOpLabels[field.opIndex], value) + } + return fmt.Sprintf("%-8s %s", field.label+":", value) +} + +func (m Model) buildFilterFromFields() globalfilter.Filter { + var out globalfilter.Filter + for _, field := range m.fields { + value := strings.TrimSpace(field.value) + switch field.fieldKey { + case fieldSyscall: + if value != "" { + out.Syscall = &globalfilter.StringFilter{Pattern: value} + } + case fieldComm: + if value != "" { + out.Comm = &globalfilter.StringFilter{Pattern: value} + } + case fieldFile: + if value != "" { + out.File = &globalfilter.StringFilter{Pattern: value} + } + case fieldPID: + if filter, ok := parseNumericFilter(value, field.opIndex, false); ok { + out.PID = filter + } + case fieldTID: + if filter, ok := parseNumericFilter(value, field.opIndex, false); ok { + out.TID = filter + } + case fieldFD: + if filter, ok := parseNumericFilter(value, field.opIndex, false); ok { + out.FD = filter + } + case fieldLatency: + if filter, ok := parseNumericFilter(value, field.opIndex, true); ok { + out.LatencyNs = filter + } + case fieldGap: + if filter, ok := parseNumericFilter(value, field.opIndex, true); ok { + out.GapNs = filter + } + case fieldBytes: + if filter, ok := parseNumericFilter(value, field.opIndex, false); ok { + out.Bytes = filter + } + case fieldReturn: + if filter, ok := parseNumericFilter(value, field.opIndex, false); ok { + out.RetVal = filter + } + case fieldErrorsOnly: + out.ErrorsOnly = strings.EqualFold(value, "true") + } + } + return out +} + +func parseNumericFilter(value string, opIndex int, duration bool) (*globalfilter.NumericFilter, bool) { + if value == "" { + return nil, false + } + if opIndex < 0 || opIndex >= len(compareOps) { + opIndex = 2 + } + + var ( + number int64 + err error + ) + if duration { + number, err = globalfilter.ParseDurationNs(value) + } else { + number, err = strconv.ParseInt(value, 10, 64) + } + if err != nil { + return nil, false + } + return &globalfilter.NumericFilter{Op: compareOps[opIndex], Value: number}, true +} + +func (m *Model) applyFilterToFields(filter globalfilter.Filter) { + m.setStringField(fieldSyscall, filter.Syscall) + m.setStringField(fieldComm, filter.Comm) + m.setStringField(fieldFile, filter.File) + m.setNumericField(fieldPID, filter.PID, false) + m.setNumericField(fieldTID, filter.TID, false) + m.setNumericField(fieldFD, filter.FD, false) + m.setNumericField(fieldLatency, filter.LatencyNs, true) + m.setNumericField(fieldGap, filter.GapNs, true) + m.setNumericField(fieldBytes, filter.Bytes, false) + m.setNumericField(fieldReturn, filter.RetVal, false) + if filter.ErrorsOnly { + m.fields[fieldErrorsOnly].value = "true" + } else { + m.fields[fieldErrorsOnly].value = "false" + } +} + +func (m *Model) setStringField(key fieldKey, filter *globalfilter.StringFilter) { + if filter == nil { + m.fields[key].value = "" + return + } + m.fields[key].value = filter.Pattern +} + +func (m *Model) setNumericField(key fieldKey, filter *globalfilter.NumericFilter, duration bool) { + if filter == nil { + m.fields[key].value = "" + m.fields[key].opIndex = 2 + return + } + m.fields[key].opIndex = opToIndex(filter.Op) + if duration { + m.fields[key].value = formatDurationField(filter.Value) + return + } + m.fields[key].value = strconv.FormatInt(filter.Value, 10) +} + +func formatDurationField(value int64) string { + if value%1_000_000 == 0 { + return fmt.Sprintf("%dms", value/1_000_000) + } + if value%1_000 == 0 { + return fmt.Sprintf("%dus", value/1_000) + } + return fmt.Sprintf("%dns", value) +} + +func opToIndex(op globalfilter.CompareOp) int { + for i, candidate := range compareOps { + if candidate == op { + return i + } + } + return 2 +} + +func (m Model) isNumericField(index int) bool { + if index < 0 || index >= len(m.fields) { + return false + } + return m.isNumericFieldByKey(m.fields[index].fieldKey) +} + +func (m Model) isNumericFieldByKey(key fieldKey) bool { + switch key { + case fieldPID, fieldTID, fieldFD, 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: "FD", fieldKey: fieldFD}, + {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/tracefilter/model_test.go b/internal/tui/tracefilter/model_test.go new file mode 100644 index 0000000..83754b5 --- /dev/null +++ b/internal/tui/tracefilter/model_test.go @@ -0,0 +1,102 @@ +package tracefilter + +import ( + "testing" + + "ior/internal/globalfilter" + + tea "charm.land/bubbletea/v2" +) + +func TestModelOpenClose(t *testing.T) { + model := NewModel() + if model.Visible() { + t.Fatalf("new modal should not be visible") + } + + model = model.Open(globalfilter.Filter{}) + if !model.Visible() { + t.Fatalf("modal should be visible after open") + } + + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + if model.Visible() { + t.Fatalf("modal should close on esc") + } +} + +func TestModelNavigateFields(t *testing.T) { + model := NewModel().Open(globalfilter.Filter{}) + if model.activeField != 0 { + t.Fatalf("activeField=%d, want 0", model.activeField) + } + + model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + if model.activeField != 1 { + t.Fatalf("activeField=%d, want 1", model.activeField) + } + model = model.Update(tea.KeyPressMsg{Code: []rune("k")[0], Text: string([]rune("k"))}) + if model.activeField != 0 { + t.Fatalf("activeField=%d, want 0", model.activeField) + } +} + +func TestModelEditAndBuildFilter(t *testing.T) { + model := NewModel().Open(globalfilter.Filter{}) + + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = model.Update(tea.KeyPressMsg{Code: []rune("read")[0], Text: string([]rune("read"))}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + for model.activeField < 3 { + model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + } + model = model.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = model.Update(tea.KeyPressMsg{Code: []rune("123")[0], Text: string([]rune("123"))}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + model = model.Update(tea.KeyPressMsg{Code: []rune("7")[0], Text: string([]rune("7"))}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + + for model.activeField < len(model.fields)-1 { + model = model.Update(tea.KeyPressMsg{Code: []rune("j")[0], Text: string([]rune("j"))}) + } + model = model.Update(tea.KeyPressMsg{Code: tea.KeySpace}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + + filter := model.Filter() + if filter.Syscall == nil || filter.Syscall.Pattern != "read" { + t.Fatalf("syscall filter not applied: %+v", filter.Syscall) + } + if filter.PID == nil || filter.PID.Op != globalfilter.OpGte || filter.PID.Value != 123 { + t.Fatalf("pid filter mismatch: %+v", filter.PID) + } + if filter.FD == nil || filter.FD.Value != 7 { + t.Fatalf("fd filter mismatch: %+v", filter.FD) + } + if !filter.ErrorsOnly { + t.Fatalf("errors-only expected true") + } +} + +func TestModelClearAll(t *testing.T) { + initial := globalfilter.Filter{ + Syscall: &globalfilter.StringFilter{Pattern: "open"}, + PID: &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: 42}, + FD: &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: 7}, + ErrorsOnly: true, + } + model := NewModel().Open(initial) + + model = model.Update(tea.KeyPressMsg{Code: []rune("c")[0], Text: string([]rune("c"))}) + model = model.Update(tea.KeyPressMsg{Code: tea.KeyEsc}) + + filter := model.Filter() + if filter.IsActive() { + t.Fatalf("expected cleared filter to be inactive: %+v", filter) + } +} |
