summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/tui-global-filter-architecture.md160
-rw-r--r--internal/globalfilter/filter.go272
-rw-r--r--internal/globalfilter/filter_test.go144
-rw-r--r--internal/tui/eventstream/filter.go230
-rw-r--r--internal/tui/eventstream/model.go37
-rw-r--r--internal/tui/eventstream/streamevent.go44
6 files changed, 635 insertions, 252 deletions
diff --git a/docs/tui-global-filter-architecture.md b/docs/tui-global-filter-architecture.md
new file mode 100644
index 0000000..386ef75
--- /dev/null
+++ b/docs/tui-global-filter-architecture.md
@@ -0,0 +1,160 @@
+# TUI Global Filter Architecture
+
+## Overview
+
+Add one global filter flow for the TUI that is accessible from any dashboard
+screen/tab and applies consistently across:
+
+- Flame
+- Overview
+- Syscalls
+- Files
+- Processes
+- Latency+Gaps
+- Stream
+
+The filter UI should reuse the current stream filter concepts, but the filter
+state must move to the top-level TUI model so there is a single source of truth.
+
+## Goals
+
+- One shared filter modal opened from anywhere in the dashboard.
+- One shared filter state owned by the top-level TUI model.
+- Aggregate dashboards must only reflect matching live events.
+- The stream tab must preserve its existing ring buffer across filter changes.
+- Existing stream rows must be re-filtered locally after a filter change.
+- String filters must remain substring-based. File path matching is explicitly a
+ partial substring match, not exact-only.
+
+## Supported Filter Fields
+
+The global filter supports the fields currently exposed by the stream filter
+workflow, plus the existing runtime PID/TID controls:
+
+- `syscall`
+- `comm`
+- `file/path`
+- `pid`
+- `tid`
+- `fd`
+- `latency`
+- `gap`
+- `bytes`
+- `retval`
+- `errors only`
+
+## Matching Semantics
+
+- String fields use case-insensitive substring matching.
+- `file/path` uses the same case-insensitive substring matching as the other
+ string fields.
+- Numeric fields use the existing comparison operators (`=`, `!=`, `>`, `>=`,
+ `<`, `<=`).
+- `errors only` keeps only events with negative return values / error-marked
+ events.
+
+## Architecture
+
+```
+BPF events -> eventLoop / print callback
+ |
+ +-> global runtime matcher
+ |
+ +-> statsengine.Ingest() (filtered live aggregates)
+ +-> liveTrie.Ingest() (filtered flamegraph)
+ +-> eventstream.Push() (filtered new stream rows)
+
+TUI state
+ top-level model
+ |
+ +-> owns shared global filter state
+ +-> owns global filter modal lifecycle
+ +-> restarts tracing when filter changes
+ +-> preserves current screen/tab
+ +-> asks Stream to re-filter buffered rows in place
+```
+
+## Runtime Behavior
+
+Applying a new global filter does all of the following:
+
+1. Preserve the current screen/tab.
+2. Stop the active trace runtime.
+3. Reset aggregate dashboard state and flamegraph baseline.
+4. Restart tracing with the new global filter.
+5. Keep the stream ring buffer contents intact.
+6. Re-filter existing buffered stream rows locally so the stream updates
+ immediately.
+
+This means aggregate tabs only show post-change matching data, while Stream can
+still show matching historical rows from before the restart.
+
+## Ownership and Data Flow
+
+### Top-level TUI model
+
+The top-level TUI model owns:
+
+- active global filter state
+- global filter modal visibility
+- filter apply/cancel/clear behavior
+- trace restart lifecycle
+- publication of filter state to child models that need local re-filtering
+
+### Stream model
+
+The stream model no longer owns the primary filter system. It must:
+
+- accept the shared global filter
+- re-filter its retained `allEvents` slice on demand
+- preserve the ring buffer across filter changes
+- keep regex search as a separate feature
+- drop the stream-local add/undo filter stack
+
+### Runtime / trace startup
+
+The TUI trace context currently carries only PID/TID. It must be expanded to
+carry the full global filter payload. The trace startup path then uses that
+payload to construct a runtime matcher before forwarding events into:
+
+- stats engine
+- flamegraph live trie
+- new stream events
+
+## Key Implementation Areas
+
+- `internal/tui/tui.go`
+ - own shared filter state
+ - open modal globally
+ - restart trace on apply
+ - preserve current screen/tab
+- `internal/tui/dashboard/model.go`
+ - route global shortcut access cleanly across tabs
+ - expose active filter summary in dashboard rendering/help
+- `internal/tui/eventstream/*`
+ - refactor modal for reuse
+ - keep stream history
+ - re-filter buffered rows in place
+ - remove stream-local filter stack behavior
+- `internal/ior.go`
+ - plumb full filter payload through trace startup
+ - apply runtime matcher before aggregate/flame/live stream ingestion
+
+## UX Rules
+
+- `f` opens the global filter modal from any dashboard tab.
+- `Enter` in the modal applies the filter.
+- `Esc` closes the modal without applying.
+- clear action resets to the unfiltered state.
+- active filter summary is visible in dashboard status/help areas.
+- stream regex search (`/`, `?`, `n`, `N`) remains separate from filtering.
+
+## Testing Requirements
+
+- context round-trip of the full global filter payload
+- runtime matcher coverage for all supported fields
+- stream ring buffer retention across filter changes
+- local re-filtering of buffered stream rows
+- file path substring matching coverage
+- aggregate dashboards only reflecting matching live events after restart
+- help/status rendering updates for the shared filter workflow
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