diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 08:27:29 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 08:27:29 +0200 |
| commit | 19e15dae40cdbc9402e081760c9c40d29174221e (patch) | |
| tree | 65836726ec81bd0426b2d602952e8e6d2881c5f6 /internal/tui | |
| parent | 8d4f799220632411784037783c9275964df98718 (diff) | |
Add event stream filtering and duration parsing
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/eventstream/filter.go | 222 | ||||
| -rw-r--r-- | internal/tui/eventstream/filter_test.go | 140 |
2 files changed, 362 insertions, 0 deletions
diff --git a/internal/tui/eventstream/filter.go b/internal/tui/eventstream/filter.go new file mode 100644 index 0000000..458ecdd --- /dev/null +++ b/internal/tui/eventstream/filter.go @@ -0,0 +1,222 @@ +package eventstream + +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 Filter struct { + Syscall *StringFilter + Comm *StringFilter + File *StringFilter + PID *NumericFilter + TID *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.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.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, "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 +} diff --git a/internal/tui/eventstream/filter_test.go b/internal/tui/eventstream/filter_test.go new file mode 100644 index 0000000..556d4ef --- /dev/null +++ b/internal/tui/eventstream/filter_test.go @@ -0,0 +1,140 @@ +package eventstream + +import ( + "strings" + "testing" +) + +func sampleEvent() StreamEvent { + return StreamEvent{ + Seq: 7, + TimeNs: 999, + Syscall: "read", + Comm: "nginx", + PID: 1234, + TID: 1235, + FileName: "/var/log/access.log", + DurationNs: 1500000, + GapNs: 12000, + Bytes: 4096, + RetVal: -1, + IsError: true, + } +} + +func TestFilterZeroValueMatchesAll(t *testing.T) { + ev := sampleEvent() + f := Filter{} + if !f.Matches(&ev) { + t.Fatalf("zero-value filter should match all events") + } + if f.IsActive() { + t.Fatalf("zero-value filter should be inactive") + } + if got := f.Summary(); got != "all" { + t.Fatalf("Summary() = %q, want all", got) + } +} + +func TestFilterStringDimensions(t *testing.T) { + ev := sampleEvent() + cases := []struct { + name string + filter Filter + want bool + }{ + {name: "syscall match", filter: Filter{Syscall: &StringFilter{Pattern: "ea"}}, want: true}, + {name: "comm match case-insensitive", filter: Filter{Comm: &StringFilter{Pattern: "NGI"}}, want: true}, + {name: "file match", filter: Filter{File: &StringFilter{Pattern: "access"}}, want: true}, + {name: "syscall mismatch", filter: Filter{Syscall: &StringFilter{Pattern: "write"}}, want: false}, + } + for _, tc := range cases { + if got := tc.filter.Matches(&ev); got != tc.want { + t.Fatalf("%s: Matches() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestFilterNumericDimensions(t *testing.T) { + ev := sampleEvent() + cases := []struct { + name string + filter Filter + want bool + }{ + {name: "pid eq", filter: Filter{PID: &NumericFilter{Op: OpEq, Value: 1234}}, want: true}, + {name: "tid neq", filter: Filter{TID: &NumericFilter{Op: OpNeq, Value: 1}}, want: true}, + {name: "latency gt", filter: Filter{LatencyNs: &NumericFilter{Op: OpGt, Value: 1000000}}, want: true}, + {name: "gap lte", filter: Filter{GapNs: &NumericFilter{Op: OpLte, Value: 12000}}, want: true}, + {name: "bytes lt", filter: Filter{Bytes: &NumericFilter{Op: OpLt, Value: 8192}}, want: true}, + {name: "ret gte", filter: Filter{RetVal: &NumericFilter{Op: OpGte, Value: -1}}, want: true}, + {name: "pid mismatch", filter: Filter{PID: &NumericFilter{Op: OpEq, Value: 22}}, want: false}, + } + for _, tc := range cases { + if got := tc.filter.Matches(&ev); got != tc.want { + t.Fatalf("%s: Matches() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestFilterErrorsOnlyAndCombined(t *testing.T) { + ev := sampleEvent() + + f := Filter{ + ErrorsOnly: true, + Syscall: &StringFilter{Pattern: "read"}, + PID: &NumericFilter{Op: OpEq, Value: 1234}, + LatencyNs: &NumericFilter{Op: OpGt, Value: 1000000}, + } + if !f.Matches(&ev) { + t.Fatalf("combined filter should match") + } + + ev.IsError = false + if f.Matches(&ev) { + t.Fatalf("errors-only filter should reject non-error events") + } +} + +func TestFilterSummaryIncludesActivePredicates(t *testing.T) { + f := Filter{ + ErrorsOnly: true, + Syscall: &StringFilter{Pattern: "read"}, + PID: &NumericFilter{Op: OpEq, Value: 1234}, + LatencyNs: &NumericFilter{Op: OpGt, Value: 1000000}, + } + got := f.Summary() + for _, wantPart := range []string{"errors", "syscall~read", "pid=1234", "latency>1ms"} { + if !strings.Contains(got, wantPart) { + t.Fatalf("Summary() = %q, missing %q", got, wantPart) + } + } + if !f.IsActive() { + t.Fatalf("filter with predicates should be active") + } +} + +func TestParseDurationNs(t *testing.T) { + cases := []struct { + in string + want int64 + }{ + {in: "1ms", want: 1000000}, + {in: "10us", want: 10000}, + {in: "500ns", want: 500}, + {in: "42", want: 42}, + } + for _, tc := range cases { + got, err := ParseDurationNs(tc.in) + if err != nil { + t.Fatalf("ParseDurationNs(%q) err = %v", tc.in, err) + } + if got != tc.want { + t.Fatalf("ParseDurationNs(%q) = %d, want %d", tc.in, got, tc.want) + } + } + + if _, err := ParseDurationNs("garbage"); err == nil { + t.Fatalf("ParseDurationNs(garbage) expected error") + } +} |
