summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 08:27:29 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 08:27:29 +0200
commit19e15dae40cdbc9402e081760c9c40d29174221e (patch)
tree65836726ec81bd0426b2d602952e8e6d2881c5f6 /internal/tui
parent8d4f799220632411784037783c9275964df98718 (diff)
Add event stream filtering and duration parsing
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/eventstream/filter.go222
-rw-r--r--internal/tui/eventstream/filter_test.go140
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")
+ }
+}