From 21aa0cd0f96087fa040750643109c496e7a1b3ee Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sun, 8 Mar 2026 20:10:20 +0200 Subject: task 366: extract shared global filter types --- internal/globalfilter/filter.go | 272 ++++++++++++++++++++++++++++++++ internal/globalfilter/filter_test.go | 144 +++++++++++++++++ internal/tui/eventstream/filter.go | 230 ++------------------------- internal/tui/eventstream/model.go | 37 +---- internal/tui/eventstream/streamevent.go | 44 ++++++ 5 files changed, 475 insertions(+), 252 deletions(-) create mode 100644 internal/globalfilter/filter.go create mode 100644 internal/globalfilter/filter_test.go (limited to 'internal') diff --git a/internal/globalfilter/filter.go b/internal/globalfilter/filter.go new file mode 100644 index 0000000..b9072a7 --- /dev/null +++ b/internal/globalfilter/filter.go @@ -0,0 +1,272 @@ +package globalfilter + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type CompareOp int + +const ( + OpEq CompareOp = iota + OpNeq + OpGt + OpGte + OpLt + OpLte +) + +type NumericFilter struct { + Op CompareOp + Value int64 +} + +type StringFilter struct { + Pattern string +} + +type Candidate interface { + SyscallValue() string + CommValue() string + FileValue() string + PIDValue() uint32 + TIDValue() uint32 + FDValue() int32 + LatencyValue() uint64 + GapValue() uint64 + BytesValue() uint64 + ReturnValue() int64 + ErrorValue() bool +} + +type Filter struct { + Syscall *StringFilter + Comm *StringFilter + File *StringFilter + PID *NumericFilter + TID *NumericFilter + FD *NumericFilter + LatencyNs *NumericFilter + GapNs *NumericFilter + Bytes *NumericFilter + RetVal *NumericFilter + ErrorsOnly bool +} + +func (f Filter) Clone() Filter { + out := f + out.Syscall = cloneStringFilter(f.Syscall) + out.Comm = cloneStringFilter(f.Comm) + out.File = cloneStringFilter(f.File) + out.PID = cloneNumericFilter(f.PID) + out.TID = cloneNumericFilter(f.TID) + out.FD = cloneNumericFilter(f.FD) + out.LatencyNs = cloneNumericFilter(f.LatencyNs) + out.GapNs = cloneNumericFilter(f.GapNs) + out.Bytes = cloneNumericFilter(f.Bytes) + out.RetVal = cloneNumericFilter(f.RetVal) + return out +} + +func (f Filter) Matches(candidate Candidate) bool { + if candidate == nil { + return false + } + if f.ErrorsOnly && !candidate.ErrorValue() { + return false + } + if !matchString(f.Syscall, candidate.SyscallValue()) { + return false + } + if !matchString(f.Comm, candidate.CommValue()) { + return false + } + if !matchString(f.File, candidate.FileValue()) { + return false + } + if !matchNumeric(f.PID, int64(candidate.PIDValue())) { + return false + } + if !matchNumeric(f.TID, int64(candidate.TIDValue())) { + return false + } + if !matchNumeric(f.FD, int64(candidate.FDValue())) { + return false + } + if !matchNumeric(f.LatencyNs, int64(candidate.LatencyValue())) { + return false + } + if !matchNumeric(f.GapNs, int64(candidate.GapValue())) { + return false + } + if !matchNumeric(f.Bytes, int64(candidate.BytesValue())) { + return false + } + if !matchNumeric(f.RetVal, candidate.ReturnValue()) { + return false + } + return true +} + +func (f Filter) IsActive() bool { + if f.ErrorsOnly { + return true + } + for _, sf := range []*StringFilter{f.Syscall, f.Comm, f.File} { + if sf != nil && strings.TrimSpace(sf.Pattern) != "" { + return true + } + } + for _, nf := range []*NumericFilter{f.PID, f.TID, f.FD, f.LatencyNs, f.GapNs, f.Bytes, f.RetVal} { + if nf != nil { + return true + } + } + return false +} + +func (f Filter) Summary() string { + parts := make([]string, 0, 10) + if f.ErrorsOnly { + parts = append(parts, "errors") + } + parts = appendStringSummary(parts, "syscall", f.Syscall) + parts = appendStringSummary(parts, "comm", f.Comm) + 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) + parts = appendNumericSummary(parts, "ret", f.RetVal, false) + if len(parts) == 0 { + return "all" + } + return strings.Join(parts, " ") +} + +func ParseDurationNs(input string) (int64, error) { + s := strings.TrimSpace(strings.ToLower(input)) + if s == "" { + return 0, fmt.Errorf("empty duration") + } + s = strings.ReplaceAll(s, "µs", "us") + s = strings.ReplaceAll(s, "μs", "us") + if onlyDigits(s) || strings.HasPrefix(s, "-") && onlyDigits(s[1:]) { + v, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, err + } + return v, nil + } + d, err := time.ParseDuration(s) + if err != nil { + return 0, err + } + return d.Nanoseconds(), nil +} + +func appendStringSummary(parts []string, name string, sf *StringFilter) []string { + if sf == nil { + return parts + } + pattern := strings.TrimSpace(sf.Pattern) + if pattern == "" { + return parts + } + return append(parts, fmt.Sprintf("%s~%s", name, pattern)) +} + +func appendNumericSummary(parts []string, name string, nf *NumericFilter, duration bool) []string { + if nf == nil { + return parts + } + value := strconv.FormatInt(nf.Value, 10) + if duration { + value = time.Duration(nf.Value).String() + } + return append(parts, fmt.Sprintf("%s%s%s", name, compareOpSymbol(nf.Op), value)) +} + +func matchString(sf *StringFilter, value string) bool { + if sf == nil { + return true + } + pattern := strings.ToLower(strings.TrimSpace(sf.Pattern)) + if pattern == "" { + return true + } + return strings.Contains(strings.ToLower(value), pattern) +} + +func matchNumeric(nf *NumericFilter, value int64) bool { + if nf == nil { + return true + } + switch nf.Op { + case OpEq: + return value == nf.Value + case OpNeq: + return value != nf.Value + case OpGt: + return value > nf.Value + case OpGte: + return value >= nf.Value + case OpLt: + return value < nf.Value + case OpLte: + return value <= nf.Value + default: + return false + } +} + +func compareOpSymbol(op CompareOp) string { + switch op { + case OpEq: + return "=" + case OpNeq: + return "!=" + case OpGt: + return ">" + case OpGte: + return ">=" + case OpLt: + return "<" + case OpLte: + return "<=" + default: + return "?" + } +} + +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 onlyDigits(s string) bool { + if s == "" { + return false + } + for _, ch := range s { + if ch < '0' || ch > '9' { + return false + } + } + return true +} diff --git a/internal/globalfilter/filter_test.go b/internal/globalfilter/filter_test.go new file mode 100644 index 0000000..ff04ea7 --- /dev/null +++ b/internal/globalfilter/filter_test.go @@ -0,0 +1,144 @@ +package globalfilter + +import ( + "strings" + "testing" +) + +type sampleCandidate struct { + syscall string + comm string + file string + pid uint32 + tid uint32 + fd int32 + latency uint64 + gap uint64 + bytes uint64 + ret int64 + isError bool +} + +func (s sampleCandidate) SyscallValue() string { return s.syscall } +func (s sampleCandidate) CommValue() string { return s.comm } +func (s sampleCandidate) FileValue() string { return s.file } +func (s sampleCandidate) PIDValue() uint32 { return s.pid } +func (s sampleCandidate) TIDValue() uint32 { return s.tid } +func (s sampleCandidate) FDValue() int32 { return s.fd } +func (s sampleCandidate) LatencyValue() uint64 { return s.latency } +func (s sampleCandidate) GapValue() uint64 { return s.gap } +func (s sampleCandidate) BytesValue() uint64 { return s.bytes } +func (s sampleCandidate) ReturnValue() int64 { return s.ret } +func (s sampleCandidate) ErrorValue() bool { return s.isError } + +func testCandidate() sampleCandidate { + return sampleCandidate{ + syscall: "read", + comm: "nginx", + file: "/var/log/access.log", + pid: 1234, + tid: 1235, + fd: 7, + latency: 1_500_000, + gap: 12_000, + bytes: 4_096, + ret: -1, + isError: true, + } +} + +func TestFilterZeroValueMatchesAll(t *testing.T) { + candidate := testCandidate() + filter := Filter{} + if !filter.Matches(candidate) { + t.Fatalf("zero-value filter should match all candidates") + } + if filter.IsActive() { + t.Fatalf("zero-value filter should be inactive") + } + if got := filter.Summary(); got != "all" { + t.Fatalf("Summary() = %q, want all", got) + } +} + +func TestFilterStringAndNumericMatching(t *testing.T) { + candidate := testCandidate() + filter := Filter{ + Syscall: &StringFilter{Pattern: "ea"}, + Comm: &StringFilter{Pattern: "NGI"}, + File: &StringFilter{Pattern: "access"}, + PID: &NumericFilter{Op: OpEq, Value: 1234}, + TID: &NumericFilter{Op: OpNeq, Value: 1}, + FD: &NumericFilter{Op: OpEq, Value: 7}, + LatencyNs: &NumericFilter{Op: OpGt, Value: 1_000_000}, + GapNs: &NumericFilter{Op: OpLte, Value: 12_000}, + Bytes: &NumericFilter{Op: OpLt, Value: 8_192}, + RetVal: &NumericFilter{Op: OpGte, Value: -1}, + } + if !filter.Matches(candidate) { + t.Fatalf("combined filter should match candidate") + } +} + +func TestFilterErrorsOnlyAndClone(t *testing.T) { + filter := Filter{ + ErrorsOnly: true, + File: &StringFilter{Pattern: "access"}, + FD: &NumericFilter{Op: OpEq, Value: 7}, + } + clone := filter.Clone() + clone.File.Pattern = "different" + clone.FD.Value = 3 + + if filter.File.Pattern != "access" { + t.Fatalf("Clone() should deep-copy string filters") + } + if filter.FD.Value != 7 { + t.Fatalf("Clone() should deep-copy numeric filters") + } + if !filter.Matches(testCandidate()) { + t.Fatalf("errors-only filter should match error candidate") + } + + candidate := testCandidate() + candidate.isError = false + if filter.Matches(candidate) { + t.Fatalf("errors-only filter should reject non-error candidate") + } +} + +func TestFilterSummaryAndDurationParsing(t *testing.T) { + filter := Filter{ + ErrorsOnly: true, + Syscall: &StringFilter{Pattern: "read"}, + PID: &NumericFilter{Op: OpEq, Value: 1234}, + LatencyNs: &NumericFilter{Op: OpGt, Value: 1_000_000}, + } + got := filter.Summary() + for _, want := range []string{"errors", "syscall~read", "pid=1234", "latency>1ms"} { + if !strings.Contains(got, want) { + t.Fatalf("Summary() = %q, missing %q", got, want) + } + } + + for _, tc := range []struct { + in string + want int64 + }{ + {in: "1ms", want: 1_000_000}, + {in: "10us", want: 10_000}, + {in: "500ns", want: 500}, + {in: "42", want: 42}, + } { + gotNs, err := ParseDurationNs(tc.in) + if err != nil { + t.Fatalf("ParseDurationNs(%q) err = %v", tc.in, err) + } + if gotNs != tc.want { + t.Fatalf("ParseDurationNs(%q) = %d, want %d", tc.in, gotNs, tc.want) + } + } + if _, err := ParseDurationNs("garbage"); err == nil { + t.Fatalf("ParseDurationNs(garbage) expected error") + } +} diff --git a/internal/tui/eventstream/filter.go b/internal/tui/eventstream/filter.go index 4e0daf7..61d8c33 100644 --- a/internal/tui/eventstream/filter.go +++ b/internal/tui/eventstream/filter.go @@ -1,227 +1,21 @@ package eventstream -import ( - "fmt" - "strconv" - "strings" - "time" -) +import "ior/internal/globalfilter" -type CompareOp int +type CompareOp = globalfilter.CompareOp +type NumericFilter = globalfilter.NumericFilter +type StringFilter = globalfilter.StringFilter +type Filter = globalfilter.Filter const ( - OpEq CompareOp = iota - OpNeq - OpGt - OpGte - OpLt - OpLte + OpEq = globalfilter.OpEq + OpNeq = globalfilter.OpNeq + OpGt = globalfilter.OpGt + OpGte = globalfilter.OpGte + OpLt = globalfilter.OpLt + OpLte = globalfilter.OpLte ) -type NumericFilter struct { - Op CompareOp - Value int64 -} - -type StringFilter struct { - Pattern string -} - -type Filter struct { - Syscall *StringFilter - Comm *StringFilter - File *StringFilter - PID *NumericFilter - TID *NumericFilter - FD *NumericFilter - LatencyNs *NumericFilter - GapNs *NumericFilter - Bytes *NumericFilter - RetVal *NumericFilter - ErrorsOnly bool -} - -func (f Filter) Matches(ev *StreamEvent) bool { - if ev == nil { - return false - } - if f.ErrorsOnly && !ev.IsError { - return false - } - if !matchString(f.Syscall, ev.Syscall) { - return false - } - if !matchString(f.Comm, ev.Comm) { - return false - } - if !matchString(f.File, ev.FileName) { - return false - } - if !matchNumeric(f.PID, int64(ev.PID)) { - return false - } - 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 - } - if !matchNumeric(f.GapNs, int64(ev.GapNs)) { - return false - } - if !matchNumeric(f.Bytes, int64(ev.Bytes)) { - return false - } - if !matchNumeric(f.RetVal, ev.RetVal) { - return false - } - return true -} - -func (f Filter) IsActive() bool { - if f.ErrorsOnly { - return true - } - for _, sf := range []*StringFilter{f.Syscall, f.Comm, f.File} { - if sf != nil && strings.TrimSpace(sf.Pattern) != "" { - return true - } - } - for _, nf := range []*NumericFilter{f.PID, f.TID, f.FD, f.LatencyNs, f.GapNs, f.Bytes, f.RetVal} { - if nf != nil { - return true - } - } - return false -} - -func (f Filter) Summary() string { - parts := make([]string, 0, 10) - if f.ErrorsOnly { - parts = append(parts, "errors") - } - parts = appendStringSummary(parts, "syscall", f.Syscall) - parts = appendStringSummary(parts, "comm", f.Comm) - 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) - parts = appendNumericSummary(parts, "ret", f.RetVal, false) - if len(parts) == 0 { - return "all" - } - return strings.Join(parts, " ") -} - func ParseDurationNs(input string) (int64, error) { - s := strings.TrimSpace(strings.ToLower(input)) - if s == "" { - return 0, fmt.Errorf("empty duration") - } - s = strings.ReplaceAll(s, "µs", "us") - s = strings.ReplaceAll(s, "μs", "us") - if onlyDigits(s) || strings.HasPrefix(s, "-") && onlyDigits(s[1:]) { - v, err := strconv.ParseInt(s, 10, 64) - if err != nil { - return 0, err - } - return v, nil - } - d, err := time.ParseDuration(s) - if err != nil { - return 0, err - } - return d.Nanoseconds(), nil -} - -func appendStringSummary(parts []string, name string, sf *StringFilter) []string { - if sf == nil { - return parts - } - pattern := strings.TrimSpace(sf.Pattern) - if pattern == "" { - return parts - } - return append(parts, fmt.Sprintf("%s~%s", name, pattern)) -} - -func appendNumericSummary(parts []string, name string, nf *NumericFilter, duration bool) []string { - if nf == nil { - return parts - } - value := strconv.FormatInt(nf.Value, 10) - if duration { - value = time.Duration(nf.Value).String() - } - return append(parts, fmt.Sprintf("%s%s%s", name, compareOpSymbol(nf.Op), value)) -} - -func matchString(sf *StringFilter, value string) bool { - if sf == nil { - return true - } - pattern := strings.ToLower(strings.TrimSpace(sf.Pattern)) - if pattern == "" { - return true - } - return strings.Contains(strings.ToLower(value), pattern) -} - -func matchNumeric(nf *NumericFilter, value int64) bool { - if nf == nil { - return true - } - switch nf.Op { - case OpEq: - return value == nf.Value - case OpNeq: - return value != nf.Value - case OpGt: - return value > nf.Value - case OpGte: - return value >= nf.Value - case OpLt: - return value < nf.Value - case OpLte: - return value <= nf.Value - default: - return false - } -} - -func compareOpSymbol(op CompareOp) string { - switch op { - case OpEq: - return "=" - case OpNeq: - return "!=" - case OpGt: - return ">" - case OpGte: - return ">=" - case OpLt: - return "<" - case OpLte: - return "<=" - default: - return "?" - } -} - -func onlyDigits(s string) bool { - if s == "" { - return false - } - for _, ch := range s { - if ch < '0' || ch > '9' { - return false - } - } - return true + return globalfilter.ParseDurationNs(input) } diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go index 12aff4d..ee65793 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -751,7 +751,7 @@ func (m *Model) applyFilterFromSelectedCell() bool { } ev := m.filtered[m.selectedIdx] targetSeq := ev.Seq - next := cloneFilter(m.filter) + next := m.filter.Clone() action := "" switch m.selectedCol { @@ -789,7 +789,7 @@ func (m *Model) applyFilterFromSelectedCell() bool { return false } - m.filterStack = append(m.filterStack, cloneFilter(m.filter)) + m.filterStack = append(m.filterStack, m.filter.Clone()) m.filterActionStack = append(m.filterActionStack, action) m.filter = next m.applyFilter() @@ -807,7 +807,7 @@ func (m *Model) popFilter() bool { if len(m.filterActionStack) > 0 { m.filterActionStack = m.filterActionStack[:len(m.filterActionStack)-1] } - m.filter = cloneFilter(last) + m.filter = last.Clone() m.applyFilter() m.restoreSelectionBySeq(targetSeq) return true @@ -833,37 +833,6 @@ func (m *Model) restoreSelectionBySeq(seq uint64) { } } -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/streamevent.go b/internal/tui/eventstream/streamevent.go index 5f1e27f..9238a84 100644 --- a/internal/tui/eventstream/streamevent.go +++ b/internal/tui/eventstream/streamevent.go @@ -23,6 +23,50 @@ type StreamEvent struct { FD int32 } +func (e StreamEvent) SyscallValue() string { + return e.Syscall +} + +func (e StreamEvent) CommValue() string { + return e.Comm +} + +func (e StreamEvent) FileValue() string { + return e.FileName +} + +func (e StreamEvent) PIDValue() uint32 { + return e.PID +} + +func (e StreamEvent) TIDValue() uint32 { + return e.TID +} + +func (e StreamEvent) FDValue() int32 { + return e.FD +} + +func (e StreamEvent) LatencyValue() uint64 { + return e.DurationNs +} + +func (e StreamEvent) GapValue() uint64 { + return e.GapNs +} + +func (e StreamEvent) BytesValue() uint64 { + return e.Bytes +} + +func (e StreamEvent) ReturnValue() int64 { + return e.RetVal +} + +func (e StreamEvent) ErrorValue() bool { + return e.IsError +} + // UnknownFD marks events that are not associated with a file descriptor. const UnknownFD int32 = -1 -- cgit v1.2.3