summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-13 20:04:48 +0300
committerPaul Buetow <paul@buetow.org>2026-05-13 20:04:48 +0300
commit251894cf3375812564ecf28392179b395cdda9c7 (patch)
tree83c3609ab591702e29a375923670e7622a33b5c7 /internal
parent78ea9e22e596255c5e23ce445d80641870674ca9 (diff)
refactor: break down functions exceeding 50 lines into smaller helpers
Split 22 production files across the codebase — event loop, TUI models, probe manager, dashboard, export, flag parsing, code generation, and ioworkload scenarios — so that no function body exceeds 50 lines. Each extracted helper carries its own comment explaining its role. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/benchutil/eventmix.go139
-rw-r--r--internal/eventloop_exit.go38
-rw-r--r--internal/eventloop_runtime.go33
-rw-r--r--internal/export/snapshot_csv.go114
-rw-r--r--internal/flags/flags.go57
-rw-r--r--internal/generate/bpfhandler.go120
-rw-r--r--internal/generate/format.go84
-rw-r--r--internal/ior.go101
-rw-r--r--internal/ior_parquet_sink.go107
-rw-r--r--internal/ior_profiling.go81
-rw-r--r--internal/probemanager/manager.go154
-rw-r--r--internal/tui/common/keys.go60
-rw-r--r--internal/tui/dashboard/bubbles.go121
-rw-r--r--internal/tui/dashboard/histogram.go63
-rw-r--r--internal/tui/dashboard/icicle.go88
-rw-r--r--internal/tui/dashboard/treemap.go91
-rw-r--r--internal/tui/eventstream/export.go129
-rw-r--r--internal/tui/eventstream/model.go390
-rw-r--r--internal/tui/export/model.go69
-rw-r--r--internal/tui/probes/model.go172
-rw-r--r--internal/tui/tracefilter/model.go233
21 files changed, 1453 insertions, 991 deletions
diff --git a/internal/benchutil/eventmix.go b/internal/benchutil/eventmix.go
index 914bb93..7961f2e 100644
--- a/internal/benchutil/eventmix.go
+++ b/internal/benchutil/eventmix.go
@@ -184,63 +184,114 @@ func sumWeights(entries []MixEntry) int {
return total
}
+// pair generates a matched enter/exit raw event pair for the mix event type.
+// fd/seq provide per-call identity for fd-based and path-based events.
func (e MixEvent) pair(gen EventGenerator, time uint64, pid, tid uint32, fd int32, seq int) ([]byte, []byte, error) {
switch e {
- case MixRead:
- return gen.FdPair(time, pid, tid, fd, types.SYS_ENTER_READ, types.SYS_EXIT_READ, 128)
- case MixWrite:
- return gen.FdPair(time, pid, tid, fd, types.SYS_ENTER_WRITE, types.SYS_EXIT_WRITE, 256)
+ case MixRead, MixWrite, MixFsync:
+ return e.pairFd(gen, time, pid, tid, fd)
case MixOpen:
return gen.OpenPair(time, pid, tid)
case MixClose:
- _, enter, err := gen.EnterFdEvent(time, pid, tid, fd, types.SYS_ENTER_CLOSE)
- if err != nil {
- return nil, nil, err
- }
- _, exit, err := gen.ExitFdEvent(time+gen.pairDelta(), pid, tid, fd, types.SYS_EXIT_CLOSE)
- if err != nil {
- return nil, nil, err
- }
- return enter, exit, nil
- case MixStat:
- path := fmt.Sprintf("/tmp/ior-stat-%d-%d", tid, seq)
- return gen.PathPair(time, pid, tid, path, types.SYS_ENTER_NEWSTAT, types.SYS_EXIT_NEWSTAT, 0)
+ return pairClose(gen, time, pid, tid, fd)
+ case MixStat, MixAccess, MixMkdir, MixUnlink:
+ return e.pairPath(gen, time, pid, tid, tid, seq)
case MixSync:
return gen.NullPair(time, pid, tid, types.SYS_ENTER_SYNC, types.SYS_EXIT_SYNC)
- case MixFsync:
- return gen.FdPair(time, pid, tid, fd, types.SYS_ENTER_FSYNC, types.SYS_EXIT_FSYNC, 0)
- case MixAccess:
- path := fmt.Sprintf("/tmp/ior-access-%d-%d", tid, seq)
- return gen.PathPair(time, pid, tid, path, types.SYS_ENTER_ACCESS, types.SYS_EXIT_ACCESS, 0)
- case MixMkdir:
- path := fmt.Sprintf("/tmp/ior-mkdir-%d-%d", tid, seq)
- return gen.PathPair(time, pid, tid, path, types.SYS_ENTER_MKDIR, types.SYS_EXIT_MKDIR, 0)
- case MixUnlink:
- path := fmt.Sprintf("/tmp/ior-unlink-%d-%d", tid, seq)
- return gen.PathPair(time, pid, tid, path, types.SYS_ENTER_UNLINK, types.SYS_EXIT_UNLINK, 0)
- case MixRename:
- oldname := fmt.Sprintf("/tmp/ior-old-%d-%d", tid, seq)
- newname := fmt.Sprintf("/tmp/ior-new-%d-%d", tid, seq)
- return gen.NamePair(time, pid, tid, oldname, newname, types.SYS_ENTER_RENAME, types.SYS_EXIT_RENAME, 0)
- case MixLink:
- oldname := fmt.Sprintf("/tmp/ior-link-old-%d-%d", tid, seq)
- newname := fmt.Sprintf("/tmp/ior-link-new-%d-%d", tid, seq)
- return gen.NamePair(time, pid, tid, oldname, newname, types.SYS_ENTER_LINK, types.SYS_EXIT_LINK, 0)
+ case MixRename, MixLink:
+ return e.pairName(gen, time, pid, tid, tid, seq)
case MixFcntl:
return gen.FcntlPair(time, pid, tid, uint32(fd), syscall.F_SETFL, syscall.O_NONBLOCK, types.SYS_EXIT_FCNTL, 0)
case MixDup3:
return gen.Dup3Pair(time, pid, tid, fd, syscall.O_CLOEXEC, types.SYS_EXIT_DUP3, int64(fd+1))
case MixOpenByHandleAt:
- _, enter, err := gen.EnterOpenByHandleAtEvent(time, pid, tid, syscall.O_RDWR)
- if err != nil {
- return nil, nil, err
- }
- _, exit, err := gen.ExitRetEvent(time+gen.pairDelta(), pid, tid, types.SYS_EXIT_OPEN_BY_HANDLE_AT, int64(fd))
- if err != nil {
- return nil, nil, err
- }
- return enter, exit, nil
+ return pairOpenByHandleAt(gen, time, pid, tid, fd)
default:
return gen.NullPair(time, pid, tid, types.SYS_ENTER_SYNC, types.SYS_EXIT_SYNC)
}
}
+
+// pairFd generates fd-based pairs for read/write/fsync-like events.
+func (e MixEvent) pairFd(gen EventGenerator, time uint64, pid, tid uint32, fd int32) ([]byte, []byte, error) {
+ switch e {
+ case MixRead:
+ return gen.FdPair(time, pid, tid, fd, types.SYS_ENTER_READ, types.SYS_EXIT_READ, 128)
+ case MixWrite:
+ return gen.FdPair(time, pid, tid, fd, types.SYS_ENTER_WRITE, types.SYS_EXIT_WRITE, 256)
+ default: // MixFsync
+ return gen.FdPair(time, pid, tid, fd, types.SYS_ENTER_FSYNC, types.SYS_EXIT_FSYNC, 0)
+ }
+}
+
+// pairPath generates path-based pairs for stat/access/mkdir/unlink-like events.
+func (e MixEvent) pairPath(gen EventGenerator, time uint64, pid, tid uint32, seqTid uint32, seq int) ([]byte, []byte, error) {
+ name := mixEventPathName(e, seqTid, seq)
+ enterEv, exitEv := mixEventPathTraceIDs(e)
+ return gen.PathPair(time, pid, tid, name, enterEv, exitEv, 0)
+}
+
+// mixEventPathTraceIDs returns the enter/exit TraceId constants for a path-based mix event.
+func mixEventPathTraceIDs(e MixEvent) (types.TraceId, types.TraceId) {
+ switch e {
+ case MixStat:
+ return types.SYS_ENTER_NEWSTAT, types.SYS_EXIT_NEWSTAT
+ case MixAccess:
+ return types.SYS_ENTER_ACCESS, types.SYS_EXIT_ACCESS
+ case MixMkdir:
+ return types.SYS_ENTER_MKDIR, types.SYS_EXIT_MKDIR
+ default: // MixUnlink
+ return types.SYS_ENTER_UNLINK, types.SYS_EXIT_UNLINK
+ }
+}
+
+// mixEventPathName returns the synthetic /tmp path for path-based mix events.
+func mixEventPathName(e MixEvent, tid uint32, seq int) string {
+ switch e {
+ case MixStat:
+ return fmt.Sprintf("/tmp/ior-stat-%d-%d", tid, seq)
+ case MixAccess:
+ return fmt.Sprintf("/tmp/ior-access-%d-%d", tid, seq)
+ case MixMkdir:
+ return fmt.Sprintf("/tmp/ior-mkdir-%d-%d", tid, seq)
+ default: // MixUnlink
+ return fmt.Sprintf("/tmp/ior-unlink-%d-%d", tid, seq)
+ }
+}
+
+// pairName generates name-pair (two-path) events for rename/link-like events.
+func (e MixEvent) pairName(gen EventGenerator, time uint64, pid, tid uint32, seqTid uint32, seq int) ([]byte, []byte, error) {
+ if e == MixRename {
+ oldname := fmt.Sprintf("/tmp/ior-old-%d-%d", seqTid, seq)
+ newname := fmt.Sprintf("/tmp/ior-new-%d-%d", seqTid, seq)
+ return gen.NamePair(time, pid, tid, oldname, newname, types.SYS_ENTER_RENAME, types.SYS_EXIT_RENAME, 0)
+ }
+ oldname := fmt.Sprintf("/tmp/ior-link-old-%d-%d", seqTid, seq)
+ newname := fmt.Sprintf("/tmp/ior-link-new-%d-%d", seqTid, seq)
+ return gen.NamePair(time, pid, tid, oldname, newname, types.SYS_ENTER_LINK, types.SYS_EXIT_LINK, 0)
+}
+
+// pairClose generates a close syscall pair (not available as a single gen helper).
+func pairClose(gen EventGenerator, time uint64, pid, tid uint32, fd int32) ([]byte, []byte, error) {
+ _, enter, err := gen.EnterFdEvent(time, pid, tid, fd, types.SYS_ENTER_CLOSE)
+ if err != nil {
+ return nil, nil, err
+ }
+ _, exit, err := gen.ExitFdEvent(time+gen.pairDelta(), pid, tid, fd, types.SYS_EXIT_CLOSE)
+ if err != nil {
+ return nil, nil, err
+ }
+ return enter, exit, nil
+}
+
+// pairOpenByHandleAt generates an open_by_handle_at pair using enter and ret events.
+func pairOpenByHandleAt(gen EventGenerator, time uint64, pid, tid uint32, fd int32) ([]byte, []byte, error) {
+ _, enter, err := gen.EnterOpenByHandleAtEvent(time, pid, tid, syscall.O_RDWR)
+ if err != nil {
+ return nil, nil, err
+ }
+ _, exit, err := gen.ExitRetEvent(time+gen.pairDelta(), pid, tid, types.SYS_EXIT_OPEN_BY_HANDLE_AT, int64(fd))
+ if err != nil {
+ return nil, nil, err
+ }
+ return enter, exit, nil
+}
diff --git a/internal/eventloop_exit.go b/internal/eventloop_exit.go
index e4ae6eb..79c1b5b 100644
--- a/internal/eventloop_exit.go
+++ b/internal/eventloop_exit.go
@@ -94,12 +94,33 @@ func (e *eventLoop) handlePathExit(ep *event.Pair, pathEv *types.PathEvent) bool
return true
}
+// handleFdExit processes exit events for fd-based syscalls. It resolves the fd
+// to a file, applies close/close_range state transitions, filters the pair, and
+// handles dup/pidfd_getfd fd-transfer operations before finalising bytes.
func (e *eventLoop) handleFdExit(ep *event.Pair, fdEv *types.FdEvent) bool {
fd := fdEv.Fd
ep.File = e.fdState().resolve(fd, fdEv.Pid)
+ e.applyFdCloseState(ep, fd, fdEv.Pid)
+ ep.Comm = e.comm(fdEv.GetTid())
+ if !e.Filter().MatchPair(ep) {
+ ep.Recycle()
+ return false
+ }
+ if ok := e.applyFdTransferOp(ep, fdEv); !ok {
+ return false
+ }
+ if retEv, ok := ep.ExitEv.(*types.RetEvent); ok {
+ ep.Bytes = bytesFromRet(retEv)
+ }
+ return true
+}
+
+// applyFdCloseState updates fd-tracking state for close and close_range syscalls.
+func (e *eventLoop) applyFdCloseState(ep *event.Pair, fd int32, pid uint32) {
if ep.Is(types.SYS_ENTER_CLOSE) {
e.fdState().delete(fd)
- e.fdState().deleteProcFdCache(fd, fdEv.Pid)
+ e.fdState().deleteProcFdCache(fd, pid)
+ return
}
if ep.Is(types.SYS_ENTER_CLOSE_RANGE) {
// close_range provides (first, last), but fd_event only carries the first
@@ -107,15 +128,14 @@ func (e *eventLoop) handleFdExit(ep *event.Pair, fdEv *types.FdEvent) bool {
retEv, ok := ep.ExitEv.(*types.RetEvent)
if ok && retEv.Ret == 0 {
e.fdState().closeRangeFrom(fd)
- e.fdState().deleteProcFdCacheFrom(fd, fdEv.Pid)
+ e.fdState().deleteProcFdCacheFrom(fd, pid)
}
}
- ep.Comm = e.comm(fdEv.GetTid())
- if !e.Filter().MatchPair(ep) {
- ep.Recycle()
- return false
- }
+}
+// applyFdTransferOp handles dup/dup2 and pidfd_getfd fd-transfer operations.
+// Returns false if the pair should be dropped due to a malformed event.
+func (e *eventLoop) applyFdTransferOp(ep *event.Pair, fdEv *types.FdEvent) bool {
if ep.Is(types.SYS_ENTER_DUP) || ep.Is(types.SYS_ENTER_DUP2) {
fdFile, ok := ep.File.(*file.FdFile)
if !ok {
@@ -127,7 +147,6 @@ func (e *eventLoop) handleFdExit(ep *event.Pair, fdEv *types.FdEvent) bool {
e.recyclePair(ep, "Dropped malformed dup exit event")
return false
}
- // Duplicating fd
e.registerDup(fdFile, int32(retEvent.Ret), 0)
}
if ep.Is(types.SYS_ENTER_PIDFD_GETFD) {
@@ -142,9 +161,6 @@ func (e *eventLoop) handleFdExit(ep *event.Pair, fdEv *types.FdEvent) bool {
ep.File = transferredFile
}
}
- if retEv, ok := ep.ExitEv.(*types.RetEvent); ok {
- ep.Bytes = bytesFromRet(retEv)
- }
return true
}
diff --git a/internal/eventloop_runtime.go b/internal/eventloop_runtime.go
index 5addd46..74571c8 100644
--- a/internal/eventloop_runtime.go
+++ b/internal/eventloop_runtime.go
@@ -129,6 +129,10 @@ func (e *eventLoop) processRawEvent(raw []byte, ch chan<- *event.Pair) {
handler(raw, ch)
}
+// initRawHandlers registers all BPF event-type dispatch callbacks. It is
+// idempotent: a second call after the map is populated is a no-op. Handlers
+// are grouped by event class (open, fd, null, ret, name/path, misc) so that
+// each helper stays under 30 lines.
func (e *eventLoop) initRawHandlers() {
if e.rawHandlers == nil {
e.rawHandlers = make(map[types.EventType]rawEventHandler)
@@ -136,7 +140,16 @@ func (e *eventLoop) initRawHandlers() {
if len(e.rawHandlers) != 0 {
return
}
+ e.registerOpenHandlers()
+ e.registerFdHandlers()
+ e.registerNullHandlers()
+ e.registerRetHandlers()
+ e.registerNamePathHandlers()
+ e.registerMiscHandlers()
+}
+// registerOpenHandlers wires enter/exit handlers for open-family events.
+func (e *eventLoop) registerOpenHandlers() {
e.rawHandlers[types.ENTER_OPEN_EVENT] = func(raw []byte, _ chan<- *event.Pair) {
openEv, ok := decodeRawEvent(e, types.ENTER_OPEN_EVENT, raw, types.NewOpenEventFast)
if !ok {
@@ -153,6 +166,10 @@ func (e *eventLoop) initRawHandlers() {
}
e.tracepointExited(retEv, ch)
}
+}
+
+// registerFdHandlers wires enter/exit handlers for fd-family events (read/write/close…).
+func (e *eventLoop) registerFdHandlers() {
e.rawHandlers[types.ENTER_FD_EVENT] = func(raw []byte, _ chan<- *event.Pair) {
fdEv, ok := decodeRawEvent(e, types.ENTER_FD_EVENT, raw, types.NewFdEventFast)
if !ok {
@@ -167,6 +184,10 @@ func (e *eventLoop) initRawHandlers() {
}
e.tracepointExited(fdEv, ch)
}
+}
+
+// registerNullHandlers wires enter/exit handlers for syscalls with no interesting arguments.
+func (e *eventLoop) registerNullHandlers() {
e.rawHandlers[types.ENTER_NULL_EVENT] = func(raw []byte, _ chan<- *event.Pair) {
nullEv, ok := decodeRawEvent(e, types.ENTER_NULL_EVENT, raw, types.NewNullEventFast)
if !ok {
@@ -181,6 +202,10 @@ func (e *eventLoop) initRawHandlers() {
}
e.tracepointExited(nullEv, ch)
}
+}
+
+// registerRetHandlers wires the exit handler for generic return-value events.
+func (e *eventLoop) registerRetHandlers() {
e.rawHandlers[types.EXIT_RET_EVENT] = func(raw []byte, ch chan<- *event.Pair) {
retEv, ok := decodeRawEvent(e, types.EXIT_RET_EVENT, raw, types.NewRetEventFast)
if !ok {
@@ -188,6 +213,10 @@ func (e *eventLoop) initRawHandlers() {
}
e.tracepointExited(retEv, ch)
}
+}
+
+// registerNamePathHandlers wires enter handlers for name- and path-carrying events.
+func (e *eventLoop) registerNamePathHandlers() {
e.rawHandlers[types.ENTER_NAME_EVENT] = func(raw []byte, _ chan<- *event.Pair) {
nameEv, ok := decodeRawEvent(e, types.ENTER_NAME_EVENT, raw, types.NewNameEventFast)
if !ok {
@@ -206,6 +235,10 @@ func (e *eventLoop) initRawHandlers() {
e.tracepointEntered(pathEv)
}
}
+}
+
+// registerMiscHandlers wires enter handlers for fcntl, open_by_handle_at, and dup3.
+func (e *eventLoop) registerMiscHandlers() {
e.rawHandlers[types.ENTER_FCNTL_EVENT] = func(raw []byte, _ chan<- *event.Pair) {
fcntlEv, ok := decodeRawEvent(e, types.ENTER_FCNTL_EVENT, raw, types.NewFcntlEventFast)
if !ok {
diff --git a/internal/export/snapshot_csv.go b/internal/export/snapshot_csv.go
index 591bd67..6f7312a 100644
--- a/internal/export/snapshot_csv.go
+++ b/internal/export/snapshot_csv.go
@@ -24,65 +24,89 @@ func SnapshotCSV(snap *statsengine.Snapshot) (filename string, retErr error) {
}()
w := csv.NewWriter(f)
+ if err := writeSnapshotRows(w, snap); err != nil {
+ return "", err
+ }
+ w.Flush()
+ if err := w.Error(); err != nil {
+ return "", err
+ }
+ return filename, nil
+}
- rows := [][]string{
+// writeSnapshotRows writes all CSV sections to w in order:
+// header, summary, per-syscall stats, file stats, process stats, histograms.
+func writeSnapshotRows(w *csv.Writer, snap *statsengine.Snapshot) error {
+ summaryRows := [][]string{
{"section", "name", "value1", "value2", "value3"},
- {"summary", "totals", fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalSyscalls })), fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalErrors })), fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalBytes }))},
- {"summary", "rates_per_sec", fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.SyscallRatePerSec })), fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.ReadBytesPerSec })), fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.WriteBytesPerSec }))},
- {"summary", "latency_gap_mean_ns", fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.LatencyMeanNs })), fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.GapMeanNs })), ""},
- {"summary", "trend", trendSummary(snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.LatencyTrend }), trendSummary(snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.GapTrend }), trendSummary(snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.ThroughputTrend })},
+ {"summary", "totals",
+ fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalSyscalls })),
+ fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalErrors })),
+ fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalBytes }))},
+ {"summary", "rates_per_sec",
+ fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.SyscallRatePerSec })),
+ fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.ReadBytesPerSec })),
+ fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.WriteBytesPerSec }))},
+ {"summary", "latency_gap_mean_ns",
+ fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.LatencyMeanNs })),
+ fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.GapMeanNs })), ""},
+ {"summary", "trend",
+ trendSummary(snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.LatencyTrend }),
+ trendSummary(snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.GapTrend }),
+ trendSummary(snap, func(s *statsengine.Snapshot) statsengine.Trend { return s.ThroughputTrend })},
}
- for _, row := range rows {
+ for _, row := range summaryRows {
if err := w.Write(row); err != nil {
- return "", err
+ return err
}
}
+ if snap == nil {
+ return nil
+ }
+ return writeSnapshotDetailRows(w, snap)
+}
- if snap != nil {
- for _, s := range snap.Syscalls() {
- if err := w.Write([]string{"syscall", s.Name, fmt.Sprint(s.Count), fmt.Sprintf("%.2f", s.RatePerSec), fmt.Sprint(s.Bytes)}); err != nil {
- return "", err
- }
- if err := w.Write([]string{"syscall_latency_ns", s.Name, fmt.Sprintf("%.2f", s.LatencyMeanNs), fmt.Sprint(s.LatencyMinNs), fmt.Sprint(s.LatencyMaxNs)}); err != nil {
- return "", err
- }
- if err := w.Write([]string{"syscall_percentiles_ns", s.Name, fmt.Sprint(s.LatencyP50Ns), fmt.Sprint(s.LatencyP95Ns), fmt.Sprint(s.LatencyP99Ns)}); err != nil {
- return "", err
- }
+// writeSnapshotDetailRows writes per-item rows for syscalls, files, processes,
+// and histograms. It is called only when snap is non-nil.
+func writeSnapshotDetailRows(w *csv.Writer, snap *statsengine.Snapshot) error {
+ for _, s := range snap.Syscalls() {
+ if err := w.Write([]string{"syscall", s.Name, fmt.Sprint(s.Count), fmt.Sprintf("%.2f", s.RatePerSec), fmt.Sprint(s.Bytes)}); err != nil {
+ return err
}
- for _, r := range snap.Files() {
- if err := w.Write([]string{"file", r.Path, fmt.Sprint(r.Accesses), fmt.Sprint(r.BytesRead), fmt.Sprint(r.BytesWritten)}); err != nil {
- return "", err
- }
- if err := w.Write([]string{"file_latency_ns", r.Path, fmt.Sprintf("%.2f", r.AvgLatencyNs), fmt.Sprint(r.MaxLatencyNs), ""}); err != nil {
- return "", err
- }
+ if err := w.Write([]string{"syscall_latency_ns", s.Name, fmt.Sprintf("%.2f", s.LatencyMeanNs), fmt.Sprint(s.LatencyMinNs), fmt.Sprint(s.LatencyMaxNs)}); err != nil {
+ return err
}
- for _, p := range snap.Processes() {
- if err := w.Write([]string{"process", fmt.Sprint(p.PID), fmt.Sprint(p.Syscalls), fmt.Sprintf("%.2f", p.RatePerSec), fmt.Sprint(p.Bytes)}); err != nil {
- return "", err
- }
- if err := w.Write([]string{"process_latency_ns", fmt.Sprint(p.PID), fmt.Sprintf("%.2f", p.AvgLatencyNs), "", ""}); err != nil {
- return "", err
- }
+ if err := w.Write([]string{"syscall_percentiles_ns", s.Name, fmt.Sprint(s.LatencyP50Ns), fmt.Sprint(s.LatencyP95Ns), fmt.Sprint(s.LatencyP99Ns)}); err != nil {
+ return err
}
- for _, b := range snap.LatencyHistogram.Buckets() {
- if err := w.Write([]string{"latency_hist", b.Label, fmt.Sprint(b.Count), fmt.Sprint(b.LowerNs), fmt.Sprint(b.UpperNs)}); err != nil {
- return "", err
- }
+ }
+ for _, r := range snap.Files() {
+ if err := w.Write([]string{"file", r.Path, fmt.Sprint(r.Accesses), fmt.Sprint(r.BytesRead), fmt.Sprint(r.BytesWritten)}); err != nil {
+ return err
}
- for _, b := range snap.GapHistogram.Buckets() {
- if err := w.Write([]string{"gap_hist", b.Label, fmt.Sprint(b.Count), fmt.Sprint(b.LowerNs), fmt.Sprint(b.UpperNs)}); err != nil {
- return "", err
- }
+ if err := w.Write([]string{"file_latency_ns", r.Path, fmt.Sprintf("%.2f", r.AvgLatencyNs), fmt.Sprint(r.MaxLatencyNs), ""}); err != nil {
+ return err
}
}
-
- w.Flush()
- if err := w.Error(); err != nil {
- return "", err
+ for _, p := range snap.Processes() {
+ if err := w.Write([]string{"process", fmt.Sprint(p.PID), fmt.Sprint(p.Syscalls), fmt.Sprintf("%.2f", p.RatePerSec), fmt.Sprint(p.Bytes)}); err != nil {
+ return err
+ }
+ if err := w.Write([]string{"process_latency_ns", fmt.Sprint(p.PID), fmt.Sprintf("%.2f", p.AvgLatencyNs), "", ""}); err != nil {
+ return err
+ }
}
- return filename, nil
+ for _, b := range snap.LatencyHistogram.Buckets() {
+ if err := w.Write([]string{"latency_hist", b.Label, fmt.Sprint(b.Count), fmt.Sprint(b.LowerNs), fmt.Sprint(b.UpperNs)}); err != nil {
+ return err
+ }
+ }
+ for _, b := range snap.GapHistogram.Buckets() {
+ if err := w.Write([]string{"gap_hist", b.Label, fmt.Sprint(b.Count), fmt.Sprint(b.LowerNs), fmt.Sprint(b.UpperNs)}); err != nil {
+ return err
+ }
+ }
+ return nil
}
func snapValue(snap *statsengine.Snapshot, get func(*statsengine.Snapshot) uint64) uint64 {
diff --git a/internal/flags/flags.go b/internal/flags/flags.go
index 2544007..a46f6b3 100644
--- a/internal/flags/flags.go
+++ b/internal/flags/flags.go
@@ -131,6 +131,23 @@ func Parse() (Config, error) {
// fresh FlagSet and custom argument slices without touching global state.
func parseFromFlagSet(fs *flag.FlagSet, args []string) (Config, error) {
cfg := NewFlags()
+ tpsAttach, tpsExclude, fields := registerFlags(fs, &cfg)
+
+ if err := fs.Parse(args); err != nil {
+ return Config{}, err
+ }
+ if err := resolvePostParseFields(&cfg, tpsAttach, tpsExclude, fields); err != nil {
+ return Config{}, err
+ }
+ if err := validateConfig(cfg); err != nil {
+ return Config{}, err
+ }
+ return cfg, nil
+}
+
+// registerFlags binds all CLI flags to cfg and returns the string pointers for
+// fields that require post-parse resolution (tracepoint regexes, collapse fields).
+func registerFlags(fs *flag.FlagSet, cfg *Config) (tpsAttach, tpsExclude, fields *string) {
validFields := collapse.ValidFields()
validCounts := collapse.ValidCountFields()
@@ -141,11 +158,10 @@ func parseFromFlagSet(fs *flag.FlagSet, args []string) (Config, error) {
fs.StringVar(&cfg.CommFilter, "comm", "", "Command to filter for")
fs.StringVar(&cfg.PathFilter, "path", "", "Path to filter for")
-
fs.BoolVar(&cfg.PprofEnable, "pprof", false, "Enable profiling")
- tpsAttach := fs.String("tps", "", "Comma separated list regexes for tracepoints to load")
- tpsExclude := fs.String("tpsExclude", "", "Comma separated list regexes for tracepoints to exclude")
+ tpsAttach = fs.String("tps", "", "Comma separated list regexes for tracepoints to load")
+ tpsExclude = fs.String("tpsExclude", "", "Comma separated list regexes for tracepoints to exclude")
fs.BoolVar(&cfg.PlainMode, "plain", false, "Enable plain CSV output mode (disable TUI)")
fs.BoolVar(&cfg.FlamegraphOutput, "flamegraph", false, "Write aggregated .ior.zst output for trace/integration workflows")
@@ -158,20 +174,21 @@ func parseFromFlagSet(fs *flag.FlagSet, args []string) (Config, error) {
fs.DurationVar(&cfg.ResetTimer, "resetTimer", cfg.ResetTimer,
"Auto-reset interval for aggregate dashboard state (flamegraph trie + stats engine); set to 0 to disable")
fs.BoolVar(&cfg.ShowVersion, "version", false, "Print version banner and exit")
- fields := fs.String("fields", "",
+ fields = fs.String("fields", "",
fmt.Sprintf("Comma separated list of fields to collapse, valid are: %v", validFields))
fs.StringVar(&cfg.CountField, "count", cfg.CountField,
fmt.Sprintf("Count field to collapse, valid are: %v", validCounts))
+ return tpsAttach, tpsExclude, fields
+}
- if err := fs.Parse(args); err != nil {
- return Config{}, err
- }
-
+// resolvePostParseFields compiles the tracepoint selector and collapse field
+// list from the raw string flags that cannot be bound directly to cfg fields.
+func resolvePostParseFields(cfg *Config, tpsAttach, tpsExclude, fields *string) error {
// Parse the tracepoint include/exclude regex lists into a Selector.
// The Selector owns all matching logic; Config is purely a data carrier.
sel, err := tracepoints.ParseSelector(*tpsAttach, *tpsExclude)
if err != nil {
- return Config{}, err
+ return err
}
cfg.TracepointSelector = sel
@@ -179,7 +196,6 @@ func parseFromFlagSet(fs *flag.FlagSet, args []string) (Config, error) {
// As of February 23, 2026, open_by_handle_at and name_to_handle_at were
// re-evaluated on newer kernels and do not require CO-RE-based exclusions.
// If future kernels regress, add targeted exclusions here.
-
if *fields == "" {
cfg.CollapsedFields = []string{"comm", "tracepoint", "path"}
} else {
@@ -188,32 +204,33 @@ func parseFromFlagSet(fs *flag.FlagSet, args []string) (Config, error) {
for _, field := range cfg.CollapsedFields {
if !collapse.IsValidField(field) {
- return Config{}, fmt.Errorf("invalid field for collapse: %s", field)
+ return fmt.Errorf("invalid field for collapse: %s", field)
}
}
-
if !collapse.IsValidCountField(cfg.CountField) {
- return Config{}, fmt.Errorf("invalid count field: %s", cfg.CountField)
+ return fmt.Errorf("invalid count field: %s", cfg.CountField)
}
+ return nil
+}
+// validateConfig checks numeric/duration bounds that cannot be enforced by the
+// flag package itself and returns a descriptive error on the first violation.
+func validateConfig(cfg Config) error {
// A zero or negative duration would cause the trace context to cancel
// immediately, capturing no events. Require at least one second.
if cfg.Duration <= 0 {
- return Config{}, fmt.Errorf("invalid duration: %d (must be > 0)", cfg.Duration)
+ return fmt.Errorf("invalid duration: %d (must be > 0)", cfg.Duration)
}
-
// A negative reset timer would imply auto-resets in the past, which is
// nonsensical. 0 disables, anything positive enables.
if cfg.ResetTimer < 0 {
- return Config{}, fmt.Errorf("invalid resetTimer: %s (must be >= 0; 0 disables)", cfg.ResetTimer)
+ return fmt.Errorf("invalid resetTimer: %s (must be >= 0; 0 disables)", cfg.ResetTimer)
}
-
// A non-positive mapSize would wrap to a huge uint32 when cast in
// resizeBPFMaps, causing libbpf to fail with a confusing "map too large"
// error. Reject it here with a clear diagnostic instead.
if cfg.EventMapSize <= 0 {
- return Config{}, fmt.Errorf("invalid mapSize: %d (must be > 0)", cfg.EventMapSize)
+ return fmt.Errorf("invalid mapSize: %d (must be > 0)", cfg.EventMapSize)
}
-
- return cfg, nil
+ return nil
}
diff --git a/internal/generate/bpfhandler.go b/internal/generate/bpfhandler.go
index cf9a0c9..549e80b 100644
--- a/internal/generate/bpfhandler.go
+++ b/internal/generate/bpfhandler.go
@@ -62,77 +62,93 @@ func renderHandler(name, ctxStruct, eventStruct, comment, eventTypeConst, extra
return b.String()
}
+// generateExtra returns the kind-specific C body lines for a tracepoint handler,
+// dispatching to a per-kind helper so that each case stays concise.
func generateExtra(tp GeneratedTracepoint, isEnter bool) string {
f := tp.Format
-
switch tp.Classification.Kind {
case KindFd:
- if f.Name == "sys_enter_pidfd_getfd" {
- return " ev->fd = (__s32)ctx->args[0];\n"
- }
- fdIdx := f.FieldNumber("fd")
- if fdIdx >= 0 {
- return fmt.Sprintf(" ev->fd = (__s32)ctx->args[%d];\n", fdIdx)
- }
- return " ev->fd = (__s32)ctx->args[0];\n"
-
+ return generateExtraFd(f)
case KindDup3:
return " ev->fd = (__s32)ctx->args[0];\n ev->flags = (__s32)ctx->args[2];\n"
-
case KindOpenByHandleAt:
return " ev->flags = (__s32)ctx->args[2];\n"
-
case KindOpen:
- filenameIdx := f.FieldNumber("filename")
- flagsIdx := f.FieldNumber("flags")
- var b strings.Builder
- b.WriteString(" __builtin_memset(&(ev->filename), 0, sizeof(ev->filename) + sizeof(ev->comm));\n")
- fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->filename, sizeof(ev->filename), (void *)ctx->args[%d]);\n", filenameIdx)
- b.WriteString(" bpf_get_current_comm(&ev->comm, sizeof(ev->comm));\n")
- if flagsIdx > -1 {
- fmt.Fprintf(&b, " ev->flags = ctx->args[%d];\n", flagsIdx)
- } else {
- b.WriteString(" ev->flags = -1; // Probably OK\n")
- }
- return b.String()
-
+ return generateExtraOpen(f)
case KindPathname:
- fieldName := tp.Classification.PathnameField
- fieldIdx := f.FieldNumber(fieldName)
- var b strings.Builder
- b.WriteString(" __builtin_memset(&(ev->pathname), 0, sizeof(ev->pathname));\n")
- fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->pathname, sizeof(ev->pathname), (void*)ctx->args[%d]);\n", fieldIdx)
- return b.String()
-
+ return generateExtraPathname(tp, f)
case KindName:
- oldIdx := f.FieldNumber("oldname")
- newIdx := f.FieldNumber("newname")
- var b strings.Builder
- b.WriteString(" __builtin_memset(&(ev->oldname), 0, sizeof(ev->oldname) + sizeof(ev->newname));\n")
- fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->oldname, sizeof(ev->oldname), (void*)ctx->args[%d]);\n", oldIdx)
- fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->newname, sizeof(ev->newname), (void*)ctx->args[%d]);\n", newIdx)
- return b.String()
-
+ return generateExtraName(f)
case KindFcntl:
- fdIdx := f.FieldNumber("fd")
- cmdIdx := f.FieldNumber("cmd")
- argIdx := f.FieldNumber("arg")
- return fmt.Sprintf(
- " ev->fd = ctx->args[%d];\n ev->cmd = ctx->args[%d];\n ev->arg = ctx->args[%d];\n",
- fdIdx, cmdIdx, argIdx,
- )
-
+ return generateExtraFcntl(f)
case KindRet:
- classification := ClassifyRet(f.Name)
- return fmt.Sprintf(" ev->ret = ctx->ret;\n ev->ret_type = %s;\n", classification)
-
+ return fmt.Sprintf(" ev->ret = ctx->ret;\n ev->ret_type = %s;\n", ClassifyRet(f.Name))
case KindNull:
return ""
}
-
return ""
}
+// generateExtraFd returns the fd-capture lines for fd-family events.
+func generateExtraFd(f *Format) string {
+ if f.Name == "sys_enter_pidfd_getfd" {
+ return " ev->fd = (__s32)ctx->args[0];\n"
+ }
+ fdIdx := f.FieldNumber("fd")
+ if fdIdx >= 0 {
+ return fmt.Sprintf(" ev->fd = (__s32)ctx->args[%d];\n", fdIdx)
+ }
+ return " ev->fd = (__s32)ctx->args[0];\n"
+}
+
+// generateExtraOpen returns the filename/comm/flags capture lines for open-family events.
+func generateExtraOpen(f *Format) string {
+ filenameIdx := f.FieldNumber("filename")
+ flagsIdx := f.FieldNumber("flags")
+ var b strings.Builder
+ b.WriteString(" __builtin_memset(&(ev->filename), 0, sizeof(ev->filename) + sizeof(ev->comm));\n")
+ fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->filename, sizeof(ev->filename), (void *)ctx->args[%d]);\n", filenameIdx)
+ b.WriteString(" bpf_get_current_comm(&ev->comm, sizeof(ev->comm));\n")
+ if flagsIdx > -1 {
+ fmt.Fprintf(&b, " ev->flags = ctx->args[%d];\n", flagsIdx)
+ } else {
+ b.WriteString(" ev->flags = -1; // Probably OK\n")
+ }
+ return b.String()
+}
+
+// generateExtraPathname returns the pathname capture lines for path-family events.
+func generateExtraPathname(tp GeneratedTracepoint, f *Format) string {
+ fieldName := tp.Classification.PathnameField
+ fieldIdx := f.FieldNumber(fieldName)
+ var b strings.Builder
+ b.WriteString(" __builtin_memset(&(ev->pathname), 0, sizeof(ev->pathname));\n")
+ fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->pathname, sizeof(ev->pathname), (void*)ctx->args[%d]);\n", fieldIdx)
+ return b.String()
+}
+
+// generateExtraName returns the oldname/newname capture lines for rename/link-family events.
+func generateExtraName(f *Format) string {
+ oldIdx := f.FieldNumber("oldname")
+ newIdx := f.FieldNumber("newname")
+ var b strings.Builder
+ b.WriteString(" __builtin_memset(&(ev->oldname), 0, sizeof(ev->oldname) + sizeof(ev->newname));\n")
+ fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->oldname, sizeof(ev->oldname), (void*)ctx->args[%d]);\n", oldIdx)
+ fmt.Fprintf(&b, " bpf_probe_read_user_str(ev->newname, sizeof(ev->newname), (void*)ctx->args[%d]);\n", newIdx)
+ return b.String()
+}
+
+// generateExtraFcntl returns the fd/cmd/arg capture lines for fcntl events.
+func generateExtraFcntl(f *Format) string {
+ fdIdx := f.FieldNumber("fd")
+ cmdIdx := f.FieldNumber("cmd")
+ argIdx := f.FieldNumber("arg")
+ return fmt.Sprintf(
+ " ev->fd = ctx->args[%d];\n ev->cmd = ctx->args[%d];\n ev->arg = ctx->args[%d];\n",
+ fdIdx, cmdIdx, argIdx,
+ )
+}
+
// eventStructName returns the C struct name for a TracepointKind. The mapping
// is driven by kindRegistry so adding a new kind only requires a registry entry.
func eventStructName(kind TracepointKind) string {
diff --git a/internal/generate/format.go b/internal/generate/format.go
index ea579b6..ef51ba8 100644
--- a/internal/generate/format.go
+++ b/internal/generate/format.go
@@ -44,52 +44,60 @@ func ParseFormats(r io.Reader) ([]Format, error) {
isExternal := false
for scanner.Scan() {
- line := scanner.Text()
- trimmed := strings.TrimSpace(line)
-
- switch {
- case strings.HasPrefix(trimmed, "name:"):
- f := Format{}
- f.Name = strings.TrimSpace(strings.TrimPrefix(trimmed, "name:"))
- formats = append(formats, f)
- current = &formats[len(formats)-1]
- isExternal = false
-
- case strings.HasPrefix(trimmed, "ID:"):
- if current == nil {
- return nil, fmt.Errorf("ID without name")
- }
- id, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(trimmed, "ID:")))
- if err != nil {
- return nil, fmt.Errorf("parsing ID: %w", err)
- }
- current.ID = id
-
- case strings.HasPrefix(trimmed, "field:"):
- if current == nil {
- return nil, fmt.Errorf("field without name")
- }
- field, err := parseField(trimmed)
- if err != nil {
- return nil, fmt.Errorf("parsing field in %s: %w", current.Name, err)
- }
- if field.Name == "__syscall_nr" {
- isExternal = true
- }
- if isExternal {
- current.ExternalFields = append(current.ExternalFields, field)
- } else {
- current.InternalFields = append(current.InternalFields, field)
- }
+ var err error
+ current, isExternal, err = applyFormatLine(scanner.Text(), formats, current, isExternal, &formats)
+ if err != nil {
+ return nil, err
}
}
-
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanning input: %w", err)
}
return formats, nil
}
+// applyFormatLine processes one line from the format file, updating the
+// running parse state (current format, isExternal flag, and formats slice).
+func applyFormatLine(line string, _ []Format, current *Format, isExternal bool, formats *[]Format) (*Format, bool, error) {
+ trimmed := strings.TrimSpace(line)
+ switch {
+ case strings.HasPrefix(trimmed, "name:"):
+ f := Format{}
+ f.Name = strings.TrimSpace(strings.TrimPrefix(trimmed, "name:"))
+ *formats = append(*formats, f)
+ current = &(*formats)[len(*formats)-1]
+ isExternal = false
+
+ case strings.HasPrefix(trimmed, "ID:"):
+ if current == nil {
+ return nil, false, fmt.Errorf("ID without name")
+ }
+ id, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(trimmed, "ID:")))
+ if err != nil {
+ return nil, false, fmt.Errorf("parsing ID: %w", err)
+ }
+ current.ID = id
+
+ case strings.HasPrefix(trimmed, "field:"):
+ if current == nil {
+ return nil, false, fmt.Errorf("field without name")
+ }
+ field, err := parseField(trimmed)
+ if err != nil {
+ return nil, false, fmt.Errorf("parsing field in %s: %w", current.Name, err)
+ }
+ if field.Name == "__syscall_nr" {
+ isExternal = true
+ }
+ if isExternal {
+ current.ExternalFields = append(current.ExternalFields, field)
+ } else {
+ current.InternalFields = append(current.InternalFields, field)
+ }
+ }
+ return current, isExternal, nil
+}
+
func parseField(line string) (Field, error) {
// Format: "field:TYPE NAME; offset:N; size:N; signed:N;"
line = strings.TrimPrefix(line, "field:")
diff --git a/internal/ior.go b/internal/ior.go
index c8aa47b..decdf12 100644
--- a/internal/ior.go
+++ b/internal/ior.go
@@ -495,61 +495,92 @@ func finaliseTrace(watcherDone <-chan struct{}, recorder *flamegraph.Recorder, p
// is checked by the mode handler (via runnerDeps.getEUID) before calling this
// function; the handler is the authoritative place for the EUID gate.
func runTraceWithContext(parentCtx context.Context, cfg flags.Config, started chan<- struct{}, configure func(*eventLoop)) error {
-
verbose := started == nil
logln := newLogger(verbose)
configure, recorder := maybePrependFlamegraphConfigure(cfg, configure)
- bpfModule, mgr, releaseBindings, err := setupBPFModule(parentCtx, cfg)
+ ch, ctx, cancel, profiling, el, mgr, teardown, err := setupTraceInfra(parentCtx, cfg, started, logln)
if err != nil {
return err
}
- defer bpfModule.Close()
- // mgr.Close() detaches BPF probes and releases kernel resources; log any
- // error so that probe-detach failures are not silently discarded.
- defer func() {
- if err := mgr.Close(); err != nil {
- logln("BPF probe manager close error:", err)
- }
- }()
- defer releaseBindings()
+ defer teardown()
+ defer profiling.stop(logln)
+ defer cancel()
+
+ configureEventLoopOutput(el, mgr, configure)
+ watcherDone := startTraceShutdownWatcher(ctx, verbose, el, profiling, logln)
+
+ startTime := time.Now()
+ el.run(ctx, ch)
+ return finaliseTrace(watcherDone, recorder, profiling, time.Since(startTime), logln)
+}
+
+// setupTraceInfra creates all the BPF/runtime infrastructure for a trace run:
+// BPF module + probe manager, event channel + ring buffer, trace context,
+// profiling control, and event loop. teardown must be deferred by the caller.
+// started is signalled once setup completes (nil in non-TUI modes).
+func setupTraceInfra(
+ parentCtx context.Context,
+ cfg flags.Config,
+ started chan<- struct{},
+ logln func(...any),
+) (
+ ch <-chan []byte,
+ ctx context.Context,
+ cancel context.CancelFunc,
+ profiling *profilingControl,
+ el *eventLoop,
+ mgr *probemanager.Manager,
+ teardown func(),
+ err error,
+) {
+ bpfModule, mgr, releaseBindings, err := setupBPFModule(parentCtx, cfg)
+ if err != nil {
+ return nil, nil, nil, nil, nil, nil, func() {}, err
+ }
- ch, rb, err := setupEventChannel(bpfModule)
+ eventCh, rb, err := setupEventChannel(bpfModule)
if err != nil {
- return err
+ bpfModule.Close()
+ return nil, nil, nil, nil, nil, nil, func() {}, err
}
- // Stop the ring-buffer polling goroutine before the module is closed.
- // rb.Stop() signals the background goroutine, drains the channel, and
- // waits for the goroutine to exit; bpfModule.Close() (deferred above)
- // then calls rb.Close() which frees the C ring_buffer struct. Both are
- // idempotent so double-calling is safe.
- defer rb.Stop()
+
ctx, cancel, stopSignals := setupTraceContext(parentCtx, cfg, logln)
- defer cancel()
- defer stopSignals()
- profiling, err := setupProfiling(ctx, cfg, started)
+ profiling, err = setupProfiling(ctx, cfg, started)
if err != nil {
- return err
+ cancel()
+ stopSignals()
+ rb.Stop()
+ bpfModule.Close()
+ return nil, nil, nil, nil, nil, nil, func() {}, err
}
- // Guarantee the profiling file descriptors (cpu/mem/exec-trace profiles) are
- // closed even if a later setup step fails before the shutdown watcher is
- // registered. profiling.stop is idempotent via sync.Once, so double-calling
- // it from the watcher goroutine and from this defer is safe.
- defer profiling.stop(logln)
signalTraceStarted(started)
- el, err := newEventLoop(newEventLoopConfig(cfg))
+ el, err = newEventLoop(newEventLoopConfig(cfg))
if err != nil {
- return err
+ cancel()
+ stopSignals()
+ rb.Stop()
+ bpfModule.Close()
+ return nil, nil, nil, nil, nil, nil, func() {}, err
}
- configureEventLoopOutput(el, mgr, configure)
- watcherDone := startTraceShutdownWatcher(ctx, verbose, el, profiling, logln)
- startTime := time.Now()
- el.run(ctx, ch)
- return finaliseTrace(watcherDone, recorder, profiling, time.Since(startTime), logln)
+ teardown = func() {
+ // Stop the ring-buffer polling goroutine before the module is closed.
+ // rb.Stop() is idempotent; bpfModule.Close() calls rb.Close() for the C struct.
+ rb.Stop()
+ // mgr.Close() detaches BPF probes and releases kernel resources; log any
+ // error so that probe-detach failures are not silently discarded.
+ if err := mgr.Close(); err != nil {
+ logln("BPF probe manager close error:", err)
+ }
+ releaseBindings()
+ bpfModule.Close()
+ stopSignals()
+ }
+ return eventCh, ctx, cancel, profiling, el, mgr, teardown, nil
}
func chainEventLoopConfigure(fns ...func(*eventLoop)) func(*eventLoop) {
diff --git a/internal/ior_parquet_sink.go b/internal/ior_parquet_sink.go
index 279c9be..6869e1e 100644
--- a/internal/ior_parquet_sink.go
+++ b/internal/ior_parquet_sink.go
@@ -11,6 +11,7 @@ import (
"ior/internal/flags"
"ior/internal/globalfilter"
"ior/internal/parquet"
+ "ior/internal/probemanager"
"ior/internal/streamrow"
)
@@ -93,52 +94,16 @@ func headlessParquetTraceConfig(cfg flags.Config) flags.Config {
// without starting the TUI. Root privilege is checked by the mode handler
// (via runnerDeps.getEUID) before this function is invoked.
func runHeadlessParquet(cfg flags.Config) error {
-
cfg = headlessParquetTraceConfig(cfg)
logln := newLogger(true)
- bpfModule, mgr, releaseBindings, err := setupBPFModule(context.Background(), cfg)
+ ch, ctx, cancel, profiling, el, mgr, cleanup, err := setupHeadlessParquetInfra(cfg, logln)
if err != nil {
return err
}
- defer bpfModule.Close()
- // mgr.Close() detaches BPF probes and releases kernel resources; log any
- // error so that probe-detach failures are not silently discarded.
- defer func() {
- if err := mgr.Close(); err != nil {
- logln("BPF probe manager close error:", err)
- }
- }()
- defer releaseBindings()
-
- ch, rb, err := setupEventChannel(bpfModule)
- if err != nil {
- return err
- }
- // Stop the ring-buffer polling goroutine before the module is closed.
- // rb.Stop() signals the background goroutine, drains the channel, and
- // waits for the goroutine to exit; bpfModule.Close() (deferred above)
- // then calls rb.Close() which frees the C ring_buffer struct. Both are
- // idempotent so double-calling is safe.
- defer rb.Stop()
- ctx, cancel, stopSignals := setupTraceContext(context.Background(), cfg, logln)
- defer cancel()
- defer stopSignals()
-
- profiling, err := setupProfiling(ctx, cfg, nil)
- if err != nil {
- return err
- }
- // Guarantee the profiling file descriptors (cpu/mem/exec-trace profiles) are
- // closed even if a later setup step fails before the shutdown watcher is
- // registered. profiling.stop is idempotent via sync.Once, so double-calling
- // it from the watcher goroutine and from this defer is safe.
+ defer cleanup()
defer profiling.stop(logln)
-
- el, err := newEventLoop(newEventLoopConfig(cfg))
- if err != nil {
- return err
- }
+ defer cancel()
recorder := parquet.NewRecorder(parquet.RecorderConfig{})
if err := recorder.Start(cfg.ParquetPath, parquet.StartOptions{Metadata: parquet.NewFileMetadata("headless")}); err != nil {
@@ -146,6 +111,8 @@ func runHeadlessParquet(cfg flags.Config) error {
}
sink := newHeadlessParquetSink(recorder, cancel)
+ // sink.configure wires the event loop's print callback to record each pair
+ // to Parquet; the mgr filter wraps it to skip inactive probes.
configureEventLoopOutput(el, mgr, sink.configure)
// startTraceShutdownWatcher returns a done channel that must be drained
// before returning to prevent a goroutine leak when ctx is cancelled but
@@ -168,7 +135,67 @@ func runHeadlessParquet(cfg flags.Config) error {
if stopErr != nil {
return stopErr
}
-
logln("Good bye... (unloading BPF tracepoints will take a few seconds...) after", totalDuration)
return nil
}
+
+// setupHeadlessParquetInfra creates the BPF module, event channel, trace
+// context, profiling control, and event loop for a headless Parquet run.
+// mgr is returned so the caller can pass it to configureEventLoopOutput with
+// the sink callback after the Parquet recorder has been started.
+// cleanup must be deferred by the caller; it stops ring-buffer polling,
+// detaches probes, releases BPF bindings, and stops signal handling.
+func setupHeadlessParquetInfra(cfg flags.Config, logln func(...any)) (
+ ch <-chan []byte,
+ ctx context.Context,
+ cancel context.CancelFunc,
+ profiling *profilingControl,
+ el *eventLoop,
+ mgr *probemanager.Manager,
+ cleanup func(),
+ err error,
+) {
+ bpfModule, mgr, releaseBindings, err := setupBPFModule(context.Background(), cfg)
+ if err != nil {
+ return nil, nil, nil, nil, nil, nil, func() {}, err
+ }
+
+ eventCh, rb, err := setupEventChannel(bpfModule)
+ if err != nil {
+ bpfModule.Close()
+ return nil, nil, nil, nil, nil, nil, func() {}, err
+ }
+
+ ctx, cancel, stopSignals := setupTraceContext(context.Background(), cfg, logln)
+
+ profiling, err = setupProfiling(ctx, cfg, nil)
+ if err != nil {
+ cancel()
+ stopSignals()
+ rb.Stop()
+ bpfModule.Close()
+ return nil, nil, nil, nil, nil, nil, func() {}, err
+ }
+
+ el, err = newEventLoop(newEventLoopConfig(cfg))
+ if err != nil {
+ cancel()
+ stopSignals()
+ rb.Stop()
+ bpfModule.Close()
+ return nil, nil, nil, nil, nil, nil, func() {}, err
+ }
+
+ cleanup = func() {
+ // Stop the ring-buffer polling goroutine before the module is closed.
+ // rb.Stop() is idempotent; bpfModule.Close() calls rb.Close() for the C struct.
+ rb.Stop()
+ if err := mgr.Close(); err != nil {
+ logln("BPF probe manager close error:", err)
+ }
+ releaseBindings()
+ bpfModule.Close()
+ stopSignals()
+ }
+ return eventCh, ctx, cancel, profiling, el, mgr, cleanup, nil
+}
diff --git a/internal/ior_profiling.go b/internal/ior_profiling.go
index ddae088..77790b9 100644
--- a/internal/ior_profiling.go
+++ b/internal/ior_profiling.go
@@ -37,50 +37,21 @@ func setupProfiling(ctx context.Context, cfg flags.Config, started chan<- struct
}
control.enabled = true
- isTUIMode := started != nil
- cpuProfilePath, memProfilePath, execTracePath, execTraceDuration := profilingFilesForMode(isTUIMode)
+ cpuProfilePath, memProfilePath, execTracePath, execTraceDuration := profilingFilesForMode(started != nil)
- cpuProfile, err := os.Create(cpuProfilePath)
+ cpuProfile, memProfile, err := openProfilingFiles(cpuProfilePath, memProfilePath)
if err != nil {
return nil, err
}
- memProfile, err := os.Create(memProfilePath)
- if err != nil {
- _ = cpuProfile.Close()
- return nil, err
- }
control.cpuProfile = cpuProfile
control.memProfile = memProfile
if execTracePath != "" {
- execTraceProfile, err := os.Create(execTracePath)
- if err != nil {
+ if err := startExecTrace(ctx, execTracePath, execTraceDuration, control); err != nil {
_ = cpuProfile.Close()
_ = memProfile.Close()
return nil, err
}
- if err := trace.Start(execTraceProfile); err != nil {
- _ = cpuProfile.Close()
- _ = memProfile.Close()
- _ = execTraceProfile.Close()
- return nil, err
- }
- var stopOnce sync.Once
- control.stopExecTrace = func() {
- stopOnce.Do(func() {
- trace.Stop()
- _ = execTraceProfile.Close()
- })
- }
- go func() {
- timer := time.NewTimer(execTraceDuration)
- defer timer.Stop()
- select {
- case <-ctx.Done():
- case <-timer.C:
- }
- control.stopExecTrace()
- }()
}
if err := pprof.StartCPUProfile(cpuProfile); err != nil {
@@ -92,6 +63,52 @@ func setupProfiling(ctx context.Context, cfg flags.Config, started chan<- struct
return control, nil
}
+// openProfilingFiles creates the CPU and memory profile output files. On
+// error any successfully opened file is closed before returning.
+func openProfilingFiles(cpuPath, memPath string) (*os.File, *os.File, error) {
+ cpuProfile, err := os.Create(cpuPath)
+ if err != nil {
+ return nil, nil, err
+ }
+ memProfile, err := os.Create(memPath)
+ if err != nil {
+ _ = cpuProfile.Close()
+ return nil, nil, err
+ }
+ return cpuProfile, memProfile, nil
+}
+
+// startExecTrace creates the execution-trace output file, starts the runtime
+// tracer, and wires a goroutine that stops it on context cancellation or after
+// execTraceDuration, whichever comes first.
+func startExecTrace(ctx context.Context, tracePath string, execTraceDuration time.Duration, control *profilingControl) error {
+ execTraceProfile, err := os.Create(tracePath)
+ if err != nil {
+ return err
+ }
+ if err := trace.Start(execTraceProfile); err != nil {
+ _ = execTraceProfile.Close()
+ return err
+ }
+ var stopOnce sync.Once
+ control.stopExecTrace = func() {
+ stopOnce.Do(func() {
+ trace.Stop()
+ _ = execTraceProfile.Close()
+ })
+ }
+ go func() {
+ timer := time.NewTimer(execTraceDuration)
+ defer timer.Stop()
+ select {
+ case <-ctx.Done():
+ case <-timer.C:
+ }
+ control.stopExecTrace()
+ }()
+ return nil
+}
+
func (p *profilingControl) stop(logln func(...any)) {
p.stopOnce.Do(func() {
if !p.enabled {
diff --git a/internal/probemanager/manager.go b/internal/probemanager/manager.go
index 677762b..8b15f94 100644
--- a/internal/probemanager/manager.go
+++ b/internal/probemanager/manager.go
@@ -138,6 +138,7 @@ func (m *Manager) Toggle(syscall string) error {
}
// Attach attaches enter/exit tracepoints for a registered syscall.
+// Attach attaches enter/exit tracepoints for a registered syscall.
func (m *Manager) Attach(syscall string) error {
if syscall == "" {
return errors.New("syscall is required")
@@ -153,25 +154,47 @@ func (m *Manager) Attach(syscall string) error {
entry.attachMu.Lock()
defer entry.attachMu.Unlock()
+ // Re-acquire the lock after the per-entry mutex to prevent races with
+ // concurrent Detach calls on the same syscall.
+ enterTP, exitTP, attacher, err := m.snapshotAttachParams(syscall, entry)
+ if err != nil {
+ return err
+ }
+ if attacher == nil {
+ return nil // entry was already active
+ }
+
+ enterLink, exitLink, attachErr := attachPair(attacher, enterTP, exitTP)
+ return m.commitAttach(syscall, entry, enterLink, exitLink, attachErr)
+}
+
+// snapshotAttachParams re-validates the entry under the manager lock and
+// returns the tracepoint names and attacher needed for attachPair. It returns
+// (nil attacher, nil error) when the probe is already active.
+func (m *Manager) snapshotAttachParams(syscall string, entry *probeEntry) (enterTP, exitTP string, attacher Attacher, err error) {
m.mu.Lock()
entry, err = m.entryLocked(syscall)
if err != nil {
m.mu.Unlock()
- return err
+ return "", "", nil, err
}
if entry.active {
m.mu.Unlock()
- return nil
+ return "", "", nil, nil
}
- enterTP := entry.enterTP
- exitTP := entry.exitTP
- attacher := m.attacher
+ enterTP = entry.enterTP
+ exitTP = entry.exitTP
+ attacher = m.attacher
m.mu.Unlock()
+ return enterTP, exitTP, attacher, nil
+}
- enterLink, exitLink, attachErr := attachPair(attacher, enterTP, exitTP)
-
+// commitAttach stores the newly attached link pair in entry under the manager
+// lock, recording any attach error or cleaning up on a concurrent manager close.
+func (m *Manager) commitAttach(syscall string, entry *probeEntry, enterLink, exitLink Link, attachErr error) error {
m.mu.Lock()
defer m.mu.Unlock()
+ var err error
entry, err = m.entryLocked(syscall)
if err != nil {
return errors.Join(
@@ -180,13 +203,11 @@ func (m *Manager) Attach(syscall string) error {
destroyLink(fmt.Sprintf("cleanup exit %s", syscall), exitLink),
)
}
-
if attachErr != nil {
entry.lastErr = attachErr
entry.active = entry.enterLink != nil || entry.exitLink != nil
return attachErr
}
-
entry.enterLink = enterLink
entry.exitLink = exitLink
entry.lastErr = nil
@@ -210,6 +231,8 @@ func (m *Manager) Detach(syscall string) error {
entry.attachMu.Lock()
defer entry.attachMu.Unlock()
+ // Re-acquire the lock after the per-entry mutex to prevent races with
+ // concurrent Attach calls on the same syscall.
m.mu.Lock()
entry, err = m.entryLocked(syscall)
if err != nil {
@@ -220,22 +243,31 @@ func (m *Manager) Detach(syscall string) error {
exitLink := entry.exitLink
m.mu.Unlock()
- var errs []string
- enterErr := error(nil)
+ enterErr, exitErr, errs := destroyLinkPair(syscall, enterLink, exitLink)
+ return m.commitDetach(entry, enterErr, exitErr, errs)
+}
+
+// destroyLinkPair destroys both BPF links and collects any errors into a slice.
+// It returns each link's error separately so partial-success can be recorded.
+func destroyLinkPair(syscall string, enterLink, exitLink Link) (enterErr, exitErr error, errs []string) {
if enterLink != nil {
if err := enterLink.Destroy(); err != nil {
enterErr = err
errs = append(errs, fmt.Sprintf("detach enter %s: %v", syscall, err))
}
}
- exitErr := error(nil)
if exitLink != nil {
if err := exitLink.Destroy(); err != nil {
exitErr = err
errs = append(errs, fmt.Sprintf("detach exit %s: %v", syscall, err))
}
}
+ return enterErr, exitErr, errs
+}
+// commitDetach updates entry link pointers and active flag under the manager
+// lock, then returns a combined error if any link destroy failed.
+func (m *Manager) commitDetach(entry *probeEntry, enterErr, exitErr error, errs []string) error {
m.mu.Lock()
defer m.mu.Unlock()
if enterErr == nil {
@@ -313,20 +345,40 @@ func (m *Manager) IsActive(syscall string) bool {
}
// Close detaches all registered probes and marks the manager closed.
+// It returns the first detach error encountered (subsequent errors are
+// recorded on the probe entry but not returned).
func (m *Manager) Close() error {
if m == nil {
return nil
}
+ entries, ok := m.snapshotAndMarkClosed()
+ if !ok {
+ return nil // already closed
+ }
+
+ var firstErr error
+ for _, item := range entries {
+ if err := m.detachProbeEntry(item); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return firstErr
+}
+
+// pairEntry groups a probe entry with its syscall name for use during Close.
+type pairEntry struct {
+ syscall string
+ entry *probeEntry
+ hasLinks bool
+}
+// snapshotAndMarkClosed atomically marks the manager as closed and returns a
+// snapshot of all probe entries. Returns (nil, false) if already closed.
+func (m *Manager) snapshotAndMarkClosed() ([]pairEntry, bool) {
m.mu.Lock()
+ defer m.mu.Unlock()
if m.closed {
- m.mu.Unlock()
- return nil
- }
- type pairEntry struct {
- syscall string
- entry *probeEntry
- hasLinks bool
+ return nil, false
}
entries := make([]pairEntry, 0, len(m.probes))
for syscall, entry := range m.probes {
@@ -337,47 +389,39 @@ func (m *Manager) Close() error {
})
}
m.closed = true
- m.mu.Unlock()
+ return entries, true
+}
- var firstErr error
- for _, item := range entries {
- if item.hasLinks {
- item.entry.attachMu.Lock()
- }
- var errForSyscall error
- m.mu.Lock()
- enterLink := item.entry.enterLink
- exitLink := item.entry.exitLink
- item.entry.enterLink = nil
- item.entry.exitLink = nil
- item.entry.active = false
- item.entry.lastErr = nil
- m.mu.Unlock()
+// detachProbeEntry destroys the BPF links for a single probe entry under its
+// per-entry mutex, clears the link pointers, and records any error.
+func (m *Manager) detachProbeEntry(item pairEntry) error {
+ if item.hasLinks {
+ item.entry.attachMu.Lock()
+ defer item.entry.attachMu.Unlock()
+ }
- if enterLink != nil {
- if err := enterLink.Destroy(); err != nil {
- errForSyscall = err
- if firstErr == nil {
- firstErr = err
- }
- }
- }
- if exitLink != nil {
- if err := exitLink.Destroy(); err != nil {
- if errForSyscall == nil {
- errForSyscall = err
- }
- if firstErr == nil {
- firstErr = err
- }
- }
+ m.mu.Lock()
+ enterLink := item.entry.enterLink
+ exitLink := item.entry.exitLink
+ item.entry.enterLink = nil
+ item.entry.exitLink = nil
+ item.entry.active = false
+ item.entry.lastErr = nil
+ m.mu.Unlock()
+
+ var errForSyscall error
+ if enterLink != nil {
+ if err := enterLink.Destroy(); err != nil {
+ errForSyscall = err
}
- m.setLastError(item.syscall, errForSyscall)
- if item.hasLinks {
- item.entry.attachMu.Unlock()
+ }
+ if exitLink != nil {
+ if err := exitLink.Destroy(); err != nil && errForSyscall == nil {
+ errForSyscall = err
}
}
- return firstErr
+ m.setLastError(item.syscall, errForSyscall)
+ return errForSyscall
}
func (m *Manager) entryLocked(syscall string) (*probeEntry, error) {
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index d1f26cf..e50ee94 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -98,40 +98,35 @@ func (k KeyMap) DashboardStatusHelp() []key.Binding {
// DashboardStatusHelpSections returns grouped bindings for dashboard status bars.
func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
- global := []key.Binding{
+ return []HelpSection{
+ {Title: "Global", Bindings: k.globalStatusBindings()},
+ {Title: "Dashboard", Bindings: dashboardStatusBindings(k)},
+ }
+}
+
+// globalStatusBindings returns the global key bindings shown in the status bar,
+// appending the optional export binding when it has a non-empty label.
+func (k KeyMap) globalStatusBindings() []key.Binding {
+ bindings := []key.Binding{
helpTextBinding("H", "toggle help"),
- k.Tab,
- k.ShiftTab,
- k.One,
- k.Two,
- k.Three,
- k.Four,
- k.Five,
- k.Six,
- k.Seven,
- k.Visualize,
- k.Metric,
- k.Sort,
- k.ReverseSort,
- k.Filter,
- k.FilterUndo,
- k.SelectPID,
- k.SelectTID,
- k.Probes,
- k.Record,
- k.Refresh,
- k.AutoReset,
- k.Quit,
+ k.Tab, k.ShiftTab,
+ k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven,
+ k.Visualize, k.Metric, k.Sort, k.ReverseSort,
+ k.Filter, k.FilterUndo,
+ k.SelectPID, k.SelectTID,
+ k.Probes, k.Record, k.Refresh, k.AutoReset, k.Quit,
}
if help := k.Export.Help(); help.Key != "" || help.Desc != "" {
- global = append(global, k.Export)
+ bindings = append(bindings, k.Export)
}
- dashboard := []key.Binding{
- k.DirGroup,
- k.Visualize,
- k.Metric,
- k.Sort,
- k.ReverseSort,
+ return bindings
+}
+
+// dashboardStatusBindings returns the dashboard-specific bindings shown in
+// the status bar (table navigation, stream controls, and export shortcuts).
+func dashboardStatusBindings(k KeyMap) []key.Binding {
+ return []key.Binding{
+ k.DirGroup, k.Visualize, k.Metric, k.Sort, k.ReverseSort,
helpTextBinding("space", "stream pause"),
helpTextBinding("enter", "selected filter"),
helpTextBinding("esc", "stream undo filter"),
@@ -147,11 +142,6 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
helpTextBinding("X", "stream export as"),
helpTextBinding("E", "stream open last"),
}
-
- return []HelpSection{
- {Title: "Global", Bindings: global},
- {Title: "Dashboard", Bindings: dashboard},
- }
}
// DashboardFullHelp returns grouped bindings for dashboard overlays.
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go
index 96ebea1..eec75a5 100644
--- a/internal/tui/dashboard/bubbles.go
+++ b/internal/tui/dashboard/bubbles.go
@@ -156,6 +156,9 @@ func (c *bubbleChart) SetDarkMode(isDark bool) {
c.isDark = isDark
}
+// SetData recomputes bubble targets from data and merges them with existing
+// animation state so that live updates animate smoothly. Returns true when
+// at least one node has motion and a Tick should be scheduled.
func (c *bubbleChart) SetData(data []bubbleDatum) bool {
targets := buildBubbleTargets(data, c.Metric(), c.width, c.height)
@@ -169,6 +172,23 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
existing[node.ID] = node
}
+ c.nodes = c.mergeTargetNodes(targets, existing)
+ if len(c.nodes) == 0 {
+ c.selected = 0
+ c.animating = false
+ return false
+ }
+ c.selected = c.selectIndexByID(selectedID)
+ c.animating = c.hasMotion()
+ if c.animating {
+ c.Tick(0)
+ }
+ return c.animating
+}
+
+// mergeTargetNodes converts target positions into live nodes, carrying over
+// spring velocities and drift state from existing nodes where available.
+func (c *bubbleChart) mergeTargetNodes(targets []bubbleNode, existing map[string]bubbleNode) []bubbleNode {
next := make([]bubbleNode, 0, len(targets))
for _, target := range targets {
node := bubbleNode{
@@ -188,25 +208,7 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
ySpring: harmonica.NewSpring(harmonica.FPS(bubbleFPS), bubbleAngularVelocity, bubbleDamping),
}
if prev, ok := existing[target.ID]; ok {
- node.radius = prev.radius
- node.x = prev.x
- node.y = prev.y
- node.velocityRadius = prev.velocityRadius
- node.velocityX = prev.velocityX
- node.velocityY = prev.velocityY
- node.driftPhase = prev.driftPhase
- node.driftSpeed = prev.driftSpeed
- node.driftAmpX = prev.driftAmpX
- node.driftAmpY = prev.driftAmpY
- // New metrics or topology can otherwise produce stale springs.
- if node.radius == 0 {
- node.radius = target.targetRadius
- }
- if node.driftSpeed == 0 {
- c.initNodeDrift(&node)
- } else {
- c.updateNodeDriftAmplitude(&node)
- }
+ c.inheritPrevNodeState(&node, prev, target)
} else {
node.radius = target.targetRadius
node.x = target.targetX
@@ -216,18 +218,31 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
node.applyDrift(c.driftTime, c.width, c.height)
next = append(next, node)
}
- c.nodes = next
- if len(c.nodes) == 0 {
- c.selected = 0
- c.animating = false
- return false
- }
- c.selected = c.selectIndexByID(selectedID)
- c.animating = c.hasMotion()
- if c.animating {
- c.Tick(0)
+ return next
+}
+
+// inheritPrevNodeState copies physics and drift state from a previous node
+// into node so that the transition animates rather than snapping.
+func (c *bubbleChart) inheritPrevNodeState(node *bubbleNode, prev bubbleNode, target bubbleNode) {
+ node.radius = prev.radius
+ node.x = prev.x
+ node.y = prev.y
+ node.velocityRadius = prev.velocityRadius
+ node.velocityX = prev.velocityX
+ node.velocityY = prev.velocityY
+ node.driftPhase = prev.driftPhase
+ node.driftSpeed = prev.driftSpeed
+ node.driftAmpX = prev.driftAmpX
+ node.driftAmpY = prev.driftAmpY
+ // New metrics or topology can otherwise produce stale springs.
+ if node.radius == 0 {
+ node.radius = target.targetRadius
+ }
+ if node.driftSpeed == 0 {
+ c.initNodeDrift(node)
+ } else {
+ c.updateNodeDriftAmplitude(node)
}
- return c.animating
}
func (c *bubbleChart) selectIndexByID(id string) int {
@@ -641,10 +656,10 @@ func renderBubbleRow(cells []bubbleCell, palette []color.Color) string {
return b.String()
}
+// buildBubbleTargets computes initial target positions and radii for each
+// bubble, then runs a short relaxation pass to reduce overlap. Returns nil
+// when there is nothing to render.
func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height int) []bubbleNode {
- if len(data) == 0 {
- return nil
- }
if width <= 0 {
width = 80
}
@@ -655,6 +670,20 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
if chartHeight < 4 {
chartHeight = 4
}
+
+ filtered := filterAndSortBubbleData(data, metric)
+ if len(filtered) == 0 {
+ return nil
+ }
+
+ targets := placeBubbleNodes(filtered, metric, width, chartHeight)
+ relaxTargets(targets, width, chartHeight)
+ return targets
+}
+
+// filterAndSortBubbleData removes datums without an ID, sorts by descending
+// metric value (ties broken by label), and caps the result to bubbleMaxItems.
+func filterAndSortBubbleData(data []bubbleDatum, metric bubbleMetric) []bubbleDatum {
filtered := make([]bubbleDatum, 0, len(data))
for _, datum := range data {
if datum.ID == "" {
@@ -662,9 +691,6 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
}
filtered = append(filtered, datum)
}
- if len(filtered) == 0 {
- return nil
- }
slices.SortFunc(filtered, func(a, b bubbleDatum) int {
va := bubbleValue(a, metric)
vb := bubbleValue(b, metric)
@@ -676,34 +702,40 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
if len(filtered) > bubbleMaxItems {
filtered = filtered[:bubbleMaxItems]
}
+ return filtered
+}
+
+// placeBubbleNodes converts sorted bubble data into node structs with target
+// positions arranged in a golden-angle spiral around the chart centre.
+func placeBubbleNodes(filtered []bubbleDatum, metric bubbleMetric, width, chartHeight int) []bubbleNode {
maxValue := uint64(0)
for _, datum := range filtered {
- value := bubbleValue(datum, metric)
- if value > maxValue {
- maxValue = value
+ if v := bubbleValue(datum, metric); v > maxValue {
+ maxValue = v
}
}
if maxValue == 0 {
maxValue = 1
}
+
minRadius := 1.7
maxRadius := math.Min(float64(width)/6.0, float64(chartHeight)/2.6)
if maxRadius < 2.4 {
maxRadius = 2.4
}
- targets := make([]bubbleNode, 0, len(filtered))
+
cx := float64(width-1) / 2.0
cy := float64(chartHeight-1) / 2.0
goldenAngle := math.Pi * (3.0 - math.Sqrt(5.0))
spacingBase := maxRadius * 0.95
+
+ targets := make([]bubbleNode, 0, len(filtered))
for idx, datum := range filtered {
value := bubbleValue(datum, metric)
ratio := math.Sqrt(float64(value) / float64(maxValue))
targetRadius := minRadius + ratio*(maxRadius-minRadius)
distance := spacingBase * math.Sqrt(float64(idx)+0.6)
angle := float64(idx) * goldenAngle
- targetX := cx + math.Cos(angle)*distance
- targetY := cy + math.Sin(angle)*distance*0.68
targets = append(targets, bubbleNode{
ID: datum.ID,
Label: datum.Label,
@@ -713,11 +745,10 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
Duration: datum.Duration,
Value: value,
targetRadius: targetRadius,
- targetX: targetX,
- targetY: targetY,
+ targetX: cx + math.Cos(angle)*distance,
+ targetY: cy + math.Sin(angle)*distance*0.68,
})
}
- relaxTargets(targets, width, chartHeight)
return targets
}
diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go
index 28f5b2b..80b6309 100644
--- a/internal/tui/dashboard/histogram.go
+++ b/internal/tui/dashboard/histogram.go
@@ -47,43 +47,20 @@ func renderLatencyGapsTab(snap *statsengine.Snapshot, width, height int) string
return strings.Join([]string{lat, gap}, "\n")
}
+// renderHistogram renders a histogram snapshot as a bar chart panel.
func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, height int) string {
buckets := hist.Buckets()
if len(buckets) == 0 {
return common.PanelStyle.Render(title + ": no data")
}
-
if width <= 0 {
width = 80
}
panelW := panelWidth(width)
panelInner := panelInnerWidth(width)
- if height > 0 {
- maxRows := height - 3
- if maxRows < 1 {
- maxRows = 1
- }
- if len(buckets) > maxRows {
- buckets = buckets[:maxRows]
- }
- }
-
- maxCount := uint64(0)
- labelWidth := 0
- countWidth := len(strconv.FormatUint(hist.Total, 10))
- for _, bucket := range buckets {
- if bucket.Count > maxCount {
- maxCount = bucket.Count
- }
- if len(bucket.Label) > labelWidth {
- labelWidth = len(bucket.Label)
- }
- if digits := len(strconv.FormatUint(bucket.Count, 10)); digits > countWidth {
- countWidth = digits
- }
- }
-
+ buckets = clampHistogramBuckets(buckets, height)
+ maxCount, labelWidth, countWidth := histogramMetrics(hist, buckets)
barWidth := panelInner - labelWidth - countWidth - 6
if barWidth < 8 {
barWidth = 8
@@ -96,10 +73,42 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he
lines = append(lines, fmt.Sprintf("%-*s | %-*s %*d", labelWidth, bucket.Label, barWidth, bar, countWidth, bucket.Count))
}
lines = append(lines, "Scale: █▓▒░")
-
return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n"))
}
+// clampHistogramBuckets trims the bucket slice to fit within the available rows.
+func clampHistogramBuckets(buckets []statsengine.HistogramBucketSnapshot, height int) []statsengine.HistogramBucketSnapshot {
+ if height <= 0 {
+ return buckets
+ }
+ maxRows := height - 3
+ if maxRows < 1 {
+ maxRows = 1
+ }
+ if len(buckets) > maxRows {
+ return buckets[:maxRows]
+ }
+ return buckets
+}
+
+// histogramMetrics computes the maximum count, maximum label width, and maximum
+// count-digit width needed to align the histogram columns.
+func histogramMetrics(hist statsengine.HistogramSnapshot, buckets []statsengine.HistogramBucketSnapshot) (maxCount uint64, labelWidth, countWidth int) {
+ countWidth = len(strconv.FormatUint(hist.Total, 10))
+ for _, bucket := range buckets {
+ if bucket.Count > maxCount {
+ maxCount = bucket.Count
+ }
+ if len(bucket.Label) > labelWidth {
+ labelWidth = len(bucket.Label)
+ }
+ if digits := len(strconv.FormatUint(bucket.Count, 10)); digits > countWidth {
+ countWidth = digits
+ }
+ }
+ return maxCount, labelWidth, countWidth
+}
+
func renderHistogramBar(count, maxCount uint64, width int) string {
if count == 0 || maxCount == 0 || width <= 0 {
return ""
diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go
index 560bb2a..019100c 100644
--- a/internal/tui/dashboard/icicle.go
+++ b/internal/tui/dashboard/icicle.go
@@ -28,6 +28,7 @@ type icicleTile struct {
colorSlot int
}
+// renderFilesIcicle renders the icicle chart for directory-based file stats.
func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string {
if snap == nil {
return "Files icicle: waiting for stats..."
@@ -39,28 +40,44 @@ func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bub
height = 18
}
header := fmt.Sprintf("Files icicle | metric:%s | v mode | b metric | j/k select", treemapMetricLabel(metric))
- dirs := aggregateFilesByDir(snap.Files())
- if len(dirs) == 0 {
+
+ tiles, ok := buildIcicleTiles(snap, width, height, metric)
+ if !ok {
return header + "\nFiles icicle: no directory data\nsel: none"
}
+ if len(tiles) == 0 {
+ return header + "\nFiles icicle: no visible tiles\nsel: none"
+ }
+ return renderIcicleGrid(header, tiles, width, height, metric, selected, isDark)
+}
+// buildIcicleTiles constructs the icicle tile layout from the snapshot's file data.
+// Returns (nil, false) when there is no data to display.
+func buildIcicleTiles(snap *statsengine.Snapshot, width, height int, metric bubbleMetric) ([]icicleTile, bool) {
+ dirs := aggregateFilesByDir(snap.Files())
+ if len(dirs) == 0 {
+ return nil, false
+ }
root := buildIcicleTree(dirs)
children := sortedIcicleChildren(root, metric)
if len(children) == 0 {
- return header + "\nFiles icicle: no directory data\nsel: none"
+ return nil, false
}
-
chartHeight := height - 2
if chartHeight < 4 {
chartHeight = 4
}
-
tiles := make([]icicleTile, 0, 64)
layoutIcicle(children, 0, width, 0, chartHeight, 0, metric, &tiles)
- if len(tiles) == 0 {
- return header + "\nFiles icicle: no visible tiles\nsel: none"
- }
+ return tiles, true
+}
+// renderIcicleGrid fills a 2-D grid with icicle tiles and assembles the final string.
+func renderIcicleGrid(header string, tiles []icicleTile, width, height int, metric bubbleMetric, selected int, isDark bool) string {
+ chartHeight := height - 2
+ if chartHeight < 4 {
+ chartHeight = 4
+ }
selected = clampOffset(selected, len(tiles))
grid := make([][]treemapCell, chartHeight)
for row := 0; row < chartHeight; row++ {
@@ -71,7 +88,6 @@ func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bub
}
fillIcicleGrid(grid, tiles, selected)
palette := treemapPalette(isDark)
-
lines := make([]string, 0, chartHeight+2)
lines = append(lines, padOrTrim(header, width))
for _, row := range grid {
@@ -184,14 +200,13 @@ func sortedIcicleChildren(node *icicleNode, metric bubbleMetric) []*icicleNode {
return out
}
+// layoutIcicle recursively lays out icicle chart tiles for one depth level,
+// distributing the available width among nodes proportional to their metric values.
func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, metric bubbleMetric, out *[]icicleTile) {
if len(nodes) == 0 || width <= 0 || depth >= maxDepth {
return
}
- total := uint64(0)
- for _, node := range nodes {
- total += icicleValue(node, metric)
- }
+ total := icicleNodeTotal(nodes, metric)
if total == 0 {
return
}
@@ -201,17 +216,7 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int,
cursor := x
for idx, node := range nodes {
value := icicleValue(node, metric)
- tileWidth := remainingWidth
- if idx < len(nodes)-1 {
- tileWidth = int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue)))
- minRemaining := len(nodes) - idx - 1
- if tileWidth < 1 {
- tileWidth = 1
- }
- if tileWidth > remainingWidth-minRemaining {
- tileWidth = remainingWidth - minRemaining
- }
- }
+ tileWidth := icicleTileWidth(idx, len(nodes), value, remainingWidth, remainingValue)
if tileWidth <= 0 {
continue
}
@@ -219,17 +224,10 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int,
if depth == 0 {
colorSlot = idx
}
- *out = append(*out, icicleTile{
- node: node,
- depth: depth,
- x: cursor,
- w: tileWidth,
- colorSlot: colorSlot,
- })
+ *out = append(*out, icicleTile{node: node, depth: depth, x: cursor, w: tileWidth, colorSlot: colorSlot})
if depth+1 < maxDepth {
layoutIcicle(sortedIcicleChildren(node, metric), cursor, tileWidth, depth+1, maxDepth, colorSlot, metric, out)
}
-
cursor += tileWidth
remainingWidth -= tileWidth
remainingValue -= value
@@ -239,6 +237,32 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int,
}
}
+// icicleNodeTotal sums the metric values of all nodes in the slice.
+func icicleNodeTotal(nodes []*icicleNode, metric bubbleMetric) uint64 {
+ total := uint64(0)
+ for _, node := range nodes {
+ total += icicleValue(node, metric)
+ }
+ return total
+}
+
+// icicleTileWidth computes the pixel width to allocate to the node at idx.
+// The last node gets the full remaining width to avoid rounding gaps.
+func icicleTileWidth(idx, total int, value uint64, remainingWidth int, remainingValue uint64) int {
+ if idx == total-1 {
+ return remainingWidth
+ }
+ tileWidth := int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue)))
+ minRemaining := total - idx - 1
+ if tileWidth < 1 {
+ tileWidth = 1
+ }
+ if tileWidth > remainingWidth-minRemaining {
+ tileWidth = remainingWidth - minRemaining
+ }
+ return tileWidth
+}
+
func fillIcicleGrid(grid [][]treemapCell, tiles []icicleTile, selected int) {
height := len(grid)
if height == 0 {
diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go
index 4d5486a..cc9f44d 100644
--- a/internal/tui/dashboard/treemap.go
+++ b/internal/tui/dashboard/treemap.go
@@ -251,70 +251,74 @@ func layoutSyscallTreemap(items []syscallTreemapItem, x, y, w, h int) []syscallT
return tiles
}
+// layoutSyscallTreemapInto recursively partitions items into tiles using a
+// binary split strategy. Items are bisected near the median value and placed
+// into the left/right (vertical split) or top/bottom (horizontal split) halves.
func layoutSyscallTreemapInto(items []syscallTreemapItem, x, y, w, h, baseIndex int, out *[]syscallTreemapTile) {
if len(items) == 0 || w <= 0 || h <= 0 {
return
}
- if len(items) == 1 {
+
+ total := sumTreemapValues(items)
+ if len(items) == 1 || total == 0 {
+ // Degenerate case: single item or all-zero values — fill the whole rect.
*out = append(*out, syscallTreemapTile{
- item: items[0],
- index: baseIndex,
- x: x,
- y: y,
- w: w,
- h: h,
+ item: items[0], index: baseIndex,
+ x: x, y: y, w: w, h: h,
})
return
}
+ splitAt := findTreemapSplitIndex(items, total)
+ first, second := items[:splitAt], items[splitAt:]
+ firstTotal := sumTreemapValues(first)
+
+ if chooseSplitVertical(w, h) {
+ layoutTreemapVertical(first, second, x, y, w, h, baseIndex, splitAt, firstTotal, total, out)
+ } else {
+ layoutTreemapHorizontal(first, second, x, y, w, h, baseIndex, splitAt, firstTotal, total, out)
+ }
+}
+
+// sumTreemapValues returns the sum of Value fields across items.
+func sumTreemapValues(items []syscallTreemapItem) uint64 {
total := uint64(0)
for _, item := range items {
total += item.Value
}
- if total == 0 {
- *out = append(*out, syscallTreemapTile{
- item: items[0],
- index: baseIndex,
- x: x,
- y: y,
- w: w,
- h: h,
- })
- return
- }
-
- splitAt := findTreemapSplitIndex(items, total)
- first := items[:splitAt]
- second := items[splitAt:]
- firstTotal := uint64(0)
- for _, item := range first {
- firstTotal += item.Value
- }
+ return total
+}
+// chooseSplitVertical returns true when the rectangle should be split along
+// the vertical axis (left/right), using aspect-ratio heuristics.
+func chooseSplitVertical(w, h int) bool {
splitVertical := w >= h
if splitVertical && w <= 1 {
- splitVertical = false
+ return false
}
if !splitVertical && h <= 1 {
- splitVertical = true
+ return true
}
+ return splitVertical
+}
- if splitVertical {
- w1 := int(math.Round(float64(w) * float64(firstTotal) / float64(total)))
- if w1 < 1 {
- w1 = 1
- }
- if w1 >= w {
- w1 = w - 1
- }
- if w1 <= 0 {
- w1 = 1
- }
- layoutSyscallTreemapInto(first, x, y, w1, h, baseIndex, out)
- layoutSyscallTreemapInto(second, x+w1, y, w-w1, h, baseIndex+splitAt, out)
- return
+// layoutTreemapVertical splits the items into left (first) and right (second)
+// columns proportional to their value totals and recurses into each column.
+func layoutTreemapVertical(first, second []syscallTreemapItem, x, y, w, h, baseIndex, splitAt int, firstTotal, total uint64, out *[]syscallTreemapTile) {
+ w1 := int(math.Round(float64(w) * float64(firstTotal) / float64(total)))
+ if w1 < 1 {
+ w1 = 1
+ }
+ if w1 >= w {
+ w1 = w - 1
}
+ layoutSyscallTreemapInto(first, x, y, w1, h, baseIndex, out)
+ layoutSyscallTreemapInto(second, x+w1, y, w-w1, h, baseIndex+splitAt, out)
+}
+// layoutTreemapHorizontal splits items into top (first) and bottom (second)
+// rows proportional to their value totals and recurses into each row.
+func layoutTreemapHorizontal(first, second []syscallTreemapItem, x, y, w, h, baseIndex, splitAt int, firstTotal, total uint64, out *[]syscallTreemapTile) {
h1 := int(math.Round(float64(h) * float64(firstTotal) / float64(total)))
if h1 < 1 {
h1 = 1
@@ -322,9 +326,6 @@ func layoutSyscallTreemapInto(items []syscallTreemapItem, x, y, w, h, baseIndex
if h1 >= h {
h1 = h - 1
}
- if h1 <= 0 {
- h1 = 1
- }
layoutSyscallTreemapInto(first, x, y, w, h1, baseIndex, out)
layoutSyscallTreemapInto(second, x, y+h1, w, h-h1, baseIndex+splitAt, out)
}
diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go
index 1aa4313..46e3a23 100644
--- a/internal/tui/eventstream/export.go
+++ b/internal/tui/eventstream/export.go
@@ -31,50 +31,14 @@ func shellSplit(s string) []string {
ch := s[i]
switch {
case ch == '\'':
- // Single-quote: copy until the matching closing quote verbatim.
inToken = true
- i++
- for i < len(s) && s[i] != '\'' {
- current.WriteByte(s[i])
- i++
- }
- // Skip closing quote if present; if missing we just fall through.
- if i < len(s) {
- i++ // consume the closing '
- }
-
+ i = consumeSingleQuoted(s, i+1, &current)
case ch == '"':
- // Double-quote: process backslash escapes for \" and \\.
inToken = true
- i++
- for i < len(s) && s[i] != '"' {
- if s[i] == '\\' && i+1 < len(s) {
- next := s[i+1]
- if next == '"' || next == '\\' {
- current.WriteByte(next)
- i += 2
- continue
- }
- }
- current.WriteByte(s[i])
- i++
- }
- if i < len(s) {
- i++ // consume the closing "
- }
-
+ i = consumeDoubleQuoted(s, i+1, &current)
case ch == '\\':
- // Backslash outside quotes: escape the next character.
inToken = true
- if i+1 < len(s) {
- current.WriteByte(s[i+1])
- i += 2
- } else {
- // Trailing backslash: keep it.
- current.WriteByte('\\')
- i++
- }
-
+ i = consumeBackslash(s, i, &current)
case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r':
// Whitespace: flush current token if any.
if inToken {
@@ -83,7 +47,6 @@ func shellSplit(s string) []string {
inToken = false
}
i++
-
default:
inToken = true
current.WriteByte(ch)
@@ -97,6 +60,55 @@ func shellSplit(s string) []string {
return tokens
}
+// consumeSingleQuoted copies characters verbatim from s starting at i until
+// the closing single-quote (or end-of-string). Returns the index after the
+// closing quote.
+func consumeSingleQuoted(s string, i int, out *strings.Builder) int {
+ for i < len(s) && s[i] != '\'' {
+ out.WriteByte(s[i])
+ i++
+ }
+ if i < len(s) {
+ i++ // consume the closing '
+ }
+ return i
+}
+
+// consumeDoubleQuoted copies characters from s starting at i until the
+// closing double-quote, processing \" and \\ escape sequences. Returns the
+// index after the closing quote.
+func consumeDoubleQuoted(s string, i int, out *strings.Builder) int {
+ for i < len(s) && s[i] != '"' {
+ if s[i] == '\\' && i+1 < len(s) {
+ next := s[i+1]
+ if next == '"' || next == '\\' {
+ out.WriteByte(next)
+ i += 2
+ continue
+ }
+ }
+ out.WriteByte(s[i])
+ i++
+ }
+ if i < len(s) {
+ i++ // consume the closing "
+ }
+ return i
+}
+
+// consumeBackslash handles a backslash outside any quoted context: if a next
+// character exists it is treated as escaped; a trailing backslash is kept as-is.
+// i must point at the backslash character. Returns the index after consumed bytes.
+func consumeBackslash(s string, i int, out *strings.Builder) int {
+ if i+1 < len(s) {
+ out.WriteByte(s[i+1])
+ return i + 2
+ }
+ // Trailing backslash: keep it.
+ out.WriteByte('\\')
+ return i + 1
+}
+
func defaultStreamExportFilename() string {
return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405"))
}
@@ -122,6 +134,8 @@ func exportSnapshotToCSV(source Source, filter Filter, exportDir, filename strin
return exportRowsToCSV(rows, exportDir, name)
}
+// exportRowsToCSV writes rows to a CSV file under exportDir with the given
+// filename (which is validated and sanitised by ensureCSVFilename).
func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, error) {
name, err := ensureCSVFilename(filename)
if err != nil {
@@ -136,6 +150,7 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
if err != nil {
return "", err
}
+ // closeFile is idempotent; fail wraps any write error with a best-effort close.
closed := false
closeFile := func() error {
if closed {
@@ -151,11 +166,26 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
return "", baseErr
}
- w := csv.NewWriter(f)
+ if err := writeStreamCSV(csv.NewWriter(f), rows, fail); err != nil {
+ return "", err
+ }
+ if err := closeFile(); err != nil {
+ return "", err
+ }
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return path, nil
+ }
+ return absPath, nil
+}
+// writeStreamCSV writes the CSV header and all event rows to w, calling fail
+// on the first write error to close the underlying file before returning.
+func writeStreamCSV(w *csv.Writer, rows []StreamEvent, fail func(error) (string, error)) error {
header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "fd", "ret", "bytes", "file", "error"}
if err := w.Write(header); err != nil {
- return fail(err)
+ _, err = fail(err)
+ return err
}
for i := range rows {
ev := rows[i]
@@ -175,21 +205,16 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
fmt.Sprintf("%t", ev.IsError),
}
if err := w.Write(record); err != nil {
- return fail(err)
+ _, err = fail(err)
+ return err
}
}
w.Flush()
if err := w.Error(); err != nil {
- return fail(err)
- }
- if err := closeFile(); err != nil {
- return "", err
+ _, err = fail(err)
+ return err
}
- absPath, err := filepath.Abs(path)
- if err != nil {
- return path, nil
- }
- return absPath, nil
+ return nil
}
// ensureCSVFilename validates and normalises a user-supplied export filename.
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index a8f399c..55b4f6e 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -174,72 +174,136 @@ func (m Model) Paused() bool {
return m.paused
}
+// HandleKey dispatches keyStr to the active modal or live/paused stream handlers.
+// It returns true if the key was consumed, false if the caller should handle it.
func (m *Model) HandleKey(keyStr string) bool {
if m.searchModal.Visible() {
- m.statusMessage = ""
- var (
- term string
- submit bool
- )
- m.searchModal, term, submit = m.searchModal.Update(keyMsgFromString(keyStr))
- if !submit {
- return true
- }
- return m.submitSearch(term, m.searchModal.Direction())
+ return m.handleSearchModalKey(keyStr)
}
if m.exportModal.Visible() {
- m.statusMessage = ""
- var (
- filename string
- submit bool
- )
- m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr))
- if !submit {
- return true
+ return m.handleExportModalKey(keyStr)
+ }
+ if m.fdTraceView.visible {
+ return m.handleFDTraceKey(keyStr)
+ }
+ return m.handleStreamKey(keyStr)
+}
+
+// handleSearchModalKey routes a key press while the search modal is open.
+func (m *Model) handleSearchModalKey(keyStr string) bool {
+ m.statusMessage = ""
+ var (
+ term string
+ submit bool
+ )
+ m.searchModal, term, submit = m.searchModal.Update(keyMsgFromString(keyStr))
+ if !submit {
+ return true
+ }
+ return m.submitSearch(term, m.searchModal.Direction())
+}
+
+// handleExportModalKey routes a key press while the export modal is open.
+func (m *Model) handleExportModalKey(keyStr string) bool {
+ m.statusMessage = ""
+ var (
+ filename string
+ submit bool
+ )
+ m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr))
+ if !submit {
+ return true
+ }
+ path, err := m.exportFilteredToCSV(filename)
+ if err != nil {
+ m.statusMessage = fmt.Sprintf("Export failed: %v", err)
+ return true
+ }
+ m.lastExportPath = path
+ m.statusMessage = "Exported: " + path
+ return true
+}
+
+// handleFDTraceKey routes a key press while the FD-trace overlay is visible.
+func (m *Model) handleFDTraceKey(keyStr string) bool {
+ switch keyStr {
+ case "enter", " ", "space":
+ return true
+ case "j", "down":
+ m.scrollFDTraceByLines(1)
+ return true
+ case "k", "up":
+ m.scrollFDTraceByLines(-1)
+ return true
+ case "left", "h":
+ return true
+ case "right", "l":
+ return true
+ case "pgdown", "pgdn", "pagedown":
+ m.scrollFDTraceByLines(m.pageStep())
+ return true
+ case "pgup", "pageup":
+ m.scrollFDTraceByLines(-m.pageStep())
+ return true
+ case "g":
+ m.fdTraceView.offset = 0
+ return true
+ case "G":
+ m.fdTraceView.offset = m.maxFDTraceOffset()
+ return true
+ case "esc", "q":
+ m.fdTraceView.visible = false
+ m.fdTraceView.events = nil
+ m.fdTraceView.offset = 0
+ return true
+ default:
+ return false
+ }
+}
+
+// handleStreamExportKey handles x/X/E export shortcuts while the stream is paused.
+func (m *Model) handleStreamExportKey(keyStr string) (bool, bool) {
+ switch keyStr {
+ case "x":
+ if !m.paused {
+ return false, true
}
- path, err := m.exportFilteredToCSV(filename)
+ m.statusMessage = ""
+ path, err := m.exportFilteredToCSV(defaultStreamExportFilename())
if err != nil {
m.statusMessage = fmt.Sprintf("Export failed: %v", err)
- return true
+ return true, true
}
m.lastExportPath = path
m.statusMessage = "Exported: " + path
- return true
- }
- if m.fdTraceView.visible {
- switch keyStr {
- case "enter", " ", "space":
- return true
- case "j", "down":
- m.scrollFDTraceByLines(1)
- return true
- case "k", "up":
- m.scrollFDTraceByLines(-1)
- return true
- case "left", "h":
- return true
- case "right", "l":
- return true
- case "pgdown", "pgdn", "pagedown":
- m.scrollFDTraceByLines(m.pageStep())
- return true
- case "pgup", "pageup":
- m.scrollFDTraceByLines(-m.pageStep())
- return true
- case "g":
- m.fdTraceView.offset = 0
- return true
- case "G":
- m.fdTraceView.offset = m.maxFDTraceOffset()
- return true
- case "esc", "q":
- m.fdTraceView.visible = false
- m.fdTraceView.events = nil
- m.fdTraceView.offset = 0
- return true
- default:
- return false
+ return true, true
+ case "X":
+ if !m.paused {
+ return false, true
}
+ m.statusMessage = ""
+ m.exportModal = m.exportModal.Open(defaultStreamExportFilename())
+ return true, true
+ case "E":
+ if !m.paused {
+ return false, true
+ }
+ m.statusMessage = ""
+ if m.lastExportPath == "" {
+ m.statusMessage = "No stream export yet"
+ return true, true
+ }
+ m.pendingOpenPath = m.lastExportPath
+ m.statusMessage = "Opening in editor: " + m.lastExportPath
+ return true, true
+ }
+ return false, false
+}
+
+// handleStreamKey handles keys for the main live/paused stream table.
+func (m *Model) handleStreamKey(keyStr string) bool {
+ if consumed, handled := m.handleStreamExportKey(keyStr); handled {
+ return consumed
}
switch keyStr {
@@ -276,112 +340,80 @@ func (m *Model) HandleKey(keyStr string) bool {
return false
}
return m.jumpSearch(-m.searchDirection)
- case "x":
- if !m.paused {
- return false
- }
- m.statusMessage = ""
- path, err := m.exportFilteredToCSV(defaultStreamExportFilename())
- if err != nil {
- m.statusMessage = fmt.Sprintf("Export failed: %v", err)
- return true
- }
- m.lastExportPath = path
- m.statusMessage = "Exported: " + path
- return true
- case "X":
- if !m.paused {
- return false
- }
- m.statusMessage = ""
- m.exportModal = m.exportModal.Open(defaultStreamExportFilename())
- return true
- case "E":
- if !m.paused {
- return false
- }
- m.statusMessage = ""
- if m.lastExportPath == "" {
- m.statusMessage = "No stream export yet"
- return true
- }
- m.pendingOpenPath = m.lastExportPath
- m.statusMessage = "Opening in editor: " + m.lastExportPath
- return true
case " ", "space":
- m.paused = !m.paused
- if !m.paused {
- // Resuming should return to live-tail behavior immediately.
- m.autoScroll = true
- m.selectedIdx = -1
- m.Refresh()
- } else {
- m.ensureSelection()
- m.ensureSelectedCol()
- m.centerSelection()
- }
- return true
- case "G":
- if m.paused {
- return m.handlePausedTableNavigation("G")
- } else {
- m.autoScroll = true
- m.viewport.GotoBottom()
- m.scrollOffset = clamp(m.viewport.YOffset(), 0, m.maxScrollOffset())
- }
- return true
- case "g":
- if m.paused {
- return m.handlePausedTableNavigation("g")
- } else {
- m.autoScroll = false
- m.viewport.GotoTop()
- m.scrollOffset = 0
- }
- return true
- case "j", "down":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("down"))
- }
- return true
- case "k", "up":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("up"))
- }
- return true
- case "left", "h":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- }
- return m.handleViewportUpdate(keyMsgFromString("left"))
- case "right", "l":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- }
- return m.handleViewportUpdate(keyMsgFromString("right"))
- case "pgdown", "pgdn", "pagedown":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("pgdown"))
- }
- return true
- case "pgup", "pageup":
- if m.paused {
- return m.handlePausedTableNavigation(keyStr)
- } else {
- m.handleViewportUpdate(keyMsgFromString("pgup"))
- }
- return true
+ return m.handleSpaceKey()
+ case "G", "g", "j", "down", "k", "up", "left", "h", "right", "l",
+ "pgdown", "pgdn", "pagedown", "pgup", "pageup":
+ return m.handleNavigationKey(keyStr)
default:
return false
}
}
+// handleSpaceKey toggles the paused/live state of the stream.
+func (m *Model) handleSpaceKey() bool {
+ m.paused = !m.paused
+ if !m.paused {
+ // Resuming returns to live-tail behavior immediately.
+ m.autoScroll = true
+ m.selectedIdx = -1
+ m.Refresh()
+ } else {
+ m.ensureSelection()
+ m.ensureSelectedCol()
+ m.centerSelection()
+ }
+ return true
+}
+
+// handleNavigationKey dispatches scroll/cursor navigation in live and paused
+// modes. It delegates g/G (goto edges) to handleGotoKey and directional keys
+// (arrows, hjkl, page up/down) to handleDirectionalKey.
+func (m *Model) handleNavigationKey(keyStr string) bool {
+ switch keyStr {
+ case "G", "g":
+ return m.handleGotoKey(keyStr)
+ case "j", "down", "k", "up", "left", "h", "right", "l",
+ "pgdown", "pgdn", "pagedown", "pgup", "pageup":
+ return m.handleDirectionalKey(keyStr)
+ default:
+ return false
+ }
+}
+
+// handleGotoKey handles g (top) and G (bottom) in both live and paused modes.
+func (m *Model) handleGotoKey(keyStr string) bool {
+ if m.paused {
+ return m.handlePausedTableNavigation(keyStr)
+ }
+ if keyStr == "G" {
+ m.autoScroll = true
+ m.viewport.GotoBottom()
+ m.scrollOffset = clamp(m.viewport.YOffset(), 0, m.maxScrollOffset())
+ } else {
+ m.autoScroll = false
+ m.viewport.GotoTop()
+ m.scrollOffset = 0
+ }
+ return true
+}
+
+// handleDirectionalKey handles arrow/hjkl/page keys in both live and paused modes.
+func (m *Model) handleDirectionalKey(keyStr string) bool {
+ if m.paused {
+ return m.handlePausedTableNavigation(keyStr)
+ }
+ // Map multi-word key names to canonical viewport key strings.
+ vpKey := keyStr
+ switch keyStr {
+ case "pgdown", "pgdn", "pagedown":
+ vpKey = "pgdown"
+ case "pgup", "pageup":
+ vpKey = "pgup"
+ }
+ return m.handleViewportUpdate(keyMsgFromString(vpKey))
+}
+
// HandleTeaKey handles stream keys based on Bubble Tea key message types first,
// then falls back to string matching for rune-driven shortcuts.
func (m *Model) HandleTeaKey(msg tea.KeyPressMsg) bool {
@@ -447,6 +479,8 @@ func (m *Model) handleViewportUpdate(msg tea.KeyPressMsg) bool {
return true
}
+// View renders the stream table (or FD-trace overlay) for the given dimensions.
+// It also renders any open modal on top of the base view.
func (m *Model) View(width, height int) string {
if width <= 0 {
width = 100
@@ -463,6 +497,24 @@ func (m *Model) View(width, height int) string {
return m.viewFDTrace(width)
}
+ base, start := m.renderStreamBase(width)
+
+ // Modals overlay the full view regardless of footer visibility.
+ if m.exportModal.Visible() {
+ return m.exportModal.View(width, height)
+ }
+ if m.searchModal.Visible() {
+ return m.searchModal.View(width, height)
+ }
+ if !m.showFooter {
+ return base
+ }
+ return m.appendStreamFooter(base, start)
+}
+
+// renderStreamBase computes the visible row slice and renders the stream table.
+// It returns the rendered string and the start index used for the status line.
+func (m *Model) renderStreamBase(width int) (string, int) {
rows := m.visibleRows()
start := clamp(m.viewport.YOffset(), 0, m.maxScrollOffset())
m.scrollOffset = start
@@ -475,30 +527,27 @@ func (m *Model) View(width, height int) string {
if m.paused && m.selectedIdx >= start && m.selectedIdx < end {
selectedVisibleIdx = m.selectedIdx - start
}
-
bufferLen := 0
if m.source != nil {
bufferLen = m.source.Len()
}
-
selectedCol := -1
if m.paused && selectedVisibleIdx >= 0 {
selectedCol = m.selectedCol
}
base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, m.filterStack, visible, selectedVisibleIdx, selectedCol)
- if !m.showFooter {
- if m.exportModal.Visible() {
- return m.exportModal.View(width, height)
- }
- if m.searchModal.Visible() {
- return m.searchModal.View(width, height)
- }
- return base
- }
+ return base, start
+}
+// appendStreamFooter appends the status line (and optional status message) to
+// the rendered table string using a Builder to minimise allocations.
+func (m *Model) appendStreamFooter(base string, start int) string {
status := fmt.Sprintf("Row %d/%d", rowNumber(start, len(m.filtered)), len(m.filtered))
if m.paused && m.selectedIdx >= 0 {
- status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount)
+ status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | Enter push-filter | Esc/F undo",
+ rowNumber(start, len(m.filtered)), len(m.filtered),
+ rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered),
+ m.selectedCol+1, streamColumnCount)
}
// Use a Builder to avoid a redundant allocation for the optional status-message
// line appended conditionally on every render call.
@@ -510,13 +559,6 @@ func (m *Model) View(width, height int) string {
b.WriteString("\n")
b.WriteString(m.statusMessage)
}
-
- if m.exportModal.Visible() {
- return m.exportModal.View(width, height)
- }
- if m.searchModal.Visible() {
- return m.searchModal.View(width, height)
- }
return b.String()
}
diff --git a/internal/tui/export/model.go b/internal/tui/export/model.go
index 7cef65a..085645d 100644
--- a/internal/tui/export/model.go
+++ b/internal/tui/export/model.go
@@ -76,38 +76,7 @@ func (m Model) Close() Model {
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
- if !m.visible {
- return m, nil
- }
- if m.exporting {
- if msg.String() == "esc" {
- return m.Close(), nil
- }
- return m, nil
- }
-
- switch msg.String() {
- case "esc":
- return m.Close(), nil
- case "up", "k":
- if m.selected > 0 {
- m.selected--
- }
- return m, nil
- case "down", "j":
- if m.selected < len(optionValues)-1 {
- m.selected++
- }
- return m, nil
- case "enter":
- option := optionValues[m.selected]
- if option == OptionCancel {
- return m.Close(), nil
- }
- m.exporting = true
- m.status = fmt.Sprintf("Exporting %s...", optionLabels[m.selected])
- return m, func() tea.Msg { return RequestMsg{Option: option} }
- }
+ return m.handleKeyMsg(msg)
case CompletedMsg:
m.exporting = false
if msg.Path == "" {
@@ -123,7 +92,43 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
m.status = "Export failed: " + msg.Err.Error()
return m, nil
}
+ return m, nil
+}
+// handleKeyMsg processes key presses when the modal is visible. It delegates
+// to the export-in-progress handler or the navigation handler as appropriate.
+func (m Model) handleKeyMsg(msg tea.KeyPressMsg) (Model, tea.Cmd) {
+ if !m.visible {
+ return m, nil
+ }
+ if m.exporting {
+ if msg.String() == "esc" {
+ return m.Close(), nil
+ }
+ return m, nil
+ }
+ switch msg.String() {
+ case "esc":
+ return m.Close(), nil
+ case "up", "k":
+ if m.selected > 0 {
+ m.selected--
+ }
+ return m, nil
+ case "down", "j":
+ if m.selected < len(optionValues)-1 {
+ m.selected++
+ }
+ return m, nil
+ case "enter":
+ option := optionValues[m.selected]
+ if option == OptionCancel {
+ return m.Close(), nil
+ }
+ m.exporting = true
+ m.status = fmt.Sprintf("Exporting %s...", optionLabels[m.selected])
+ return m, func() tea.Msg { return RequestMsg{Option: option} }
+ }
return m, nil
}
diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go
index 986b697..55be14d 100644
--- a/internal/tui/probes/model.go
+++ b/internal/tui/probes/model.go
@@ -83,55 +83,68 @@ func (m Model) SetDarkMode(isDark bool) Model {
return m
}
+// Update dispatches Bubble Tea messages to the appropriate handler.
+// ProbeToggledMsg refreshes the probe list; key presses are forwarded to
+// the search or navigation handlers.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.visible {
return m, nil
}
-
switch msg := msg.(type) {
case ProbeToggledMsg:
- m.reload()
- if msg.Err != nil {
- m.lastErr = msg.Err.Error()
- } else {
- m.lastErr = ""
- }
- m.clampCursor()
- return m, nil
+ return m.handleProbeToggled(msg)
case tea.KeyPressMsg:
if m.searching {
return m.updateSearch(msg)
}
- switch msg.String() {
- case "esc":
- return m.Close(), nil
- case "j", "down":
- if m.cursor < len(m.filtered())-1 {
- m.cursor++
- }
- return m, nil
- case "k", "up":
- if m.cursor > 0 {
- m.cursor--
- }
- return m, nil
- case "/", "f":
- m.searching = true
- m.textInput.SetValue(m.search)
- m.textInput.CursorEnd()
- m.textInput.Focus()
+ return m.handleKeyPress(msg)
+ }
+ return m, nil
+}
+
+// handleProbeToggled refreshes probe state after an async toggle completes.
+func (m Model) handleProbeToggled(msg ProbeToggledMsg) (Model, tea.Cmd) {
+ m.reload()
+ if msg.Err != nil {
+ m.lastErr = msg.Err.Error()
+ } else {
+ m.lastErr = ""
+ }
+ m.clampCursor()
+ return m, nil
+}
+
+// handleKeyPress processes navigation and toggle keys while not in search mode.
+func (m Model) handleKeyPress(msg tea.KeyPressMsg) (Model, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ return m.Close(), nil
+ case "j", "down":
+ if m.cursor < len(m.filtered())-1 {
+ m.cursor++
+ }
+ return m, nil
+ case "k", "up":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ return m, nil
+ case "/", "f":
+ m.searching = true
+ m.textInput.SetValue(m.search)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+ return m, nil
+ case " ", "space", "enter":
+ selected := m.selectedSyscall()
+ if selected == "" {
return m, nil
- case " ", "space", "enter":
- selected := m.selectedSyscall()
- if selected == "" {
- return m, nil
- }
- return m, toggleCmd(m.manager, selected)
- case "a":
- return m, bulkToggleCmd(m.manager, m.probes, false)
- case "n":
- return m, bulkToggleCmd(m.manager, m.probes, true)
}
+ return m, toggleCmd(m.manager, selected)
+ case "a":
+ return m, bulkToggleCmd(m.manager, m.probes, false)
+ case "n":
+ return m, bulkToggleCmd(m.manager, m.probes, true)
}
return m, nil
}
@@ -223,6 +236,8 @@ func (m Model) visibleRows() int {
return rows
}
+// View renders the probe modal centered on the terminal. It returns an empty
+// string when the modal is not visible.
func (m Model) View(width, height int) string {
if !m.visible {
return ""
@@ -235,20 +250,39 @@ func (m Model) View(width, height int) string {
}
m.height = height
- active, total := 0, len(m.probes)
- if m.manager != nil {
- active, total = m.manager.ActiveCount()
- }
+ modalWidth := probeModalWidth(width)
+ lines := m.buildProbeLines()
- rows := m.visibleRows()
- items := m.filtered()
+ 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)
+}
+
+// probeModalWidth returns the clamped modal width for the given terminal width.
+func probeModalWidth(termWidth int) int {
modalWidth := 66
- if width < modalWidth+4 {
- modalWidth = width - 4
+ if termWidth < modalWidth+4 {
+ modalWidth = termWidth - 4
if modalWidth < 44 {
modalWidth = 44
}
}
+ return modalWidth
+}
+
+// buildProbeLines assembles the text lines that make up the modal content:
+// header, optional search bar, probe rows, and the help footer.
+func (m Model) buildProbeLines() []string {
+ active, total := 0, len(m.probes)
+ if m.manager != nil {
+ active, total = m.manager.ActiveCount()
+ }
+ rows := m.visibleRows()
+ items := m.filtered()
lines := []string{fmt.Sprintf("Probes (%d/%d active)", active, total)}
if m.searching {
@@ -267,24 +301,7 @@ func (m Model) View(width, height int) string {
end = len(items)
}
for i := start; i < end; i++ {
- p := items[i]
- prefix := " "
- if i == m.cursor {
- prefix = "> "
- }
- check := "[ ]"
- if p.Active {
- check = "[x]"
- }
- // Use a Builder to avoid an extra allocation for the optional error suffix
- // emitted per probe row on every render call.
- var lb strings.Builder
- lb.WriteString(fmt.Sprintf("%s%s %-24s", prefix, check, p.Syscall))
- if p.Error != "" {
- lb.WriteString(" ! ")
- lb.WriteString(truncateText(sanitizeOneLine(p.Error), 28))
- }
- lines = append(lines, lb.String())
+ lines = append(lines, m.renderProbeRow(items[i], i == m.cursor))
}
if len(items) == 0 {
lines = append(lines, " (no probes)")
@@ -293,14 +310,29 @@ func (m Model) View(width, height int) string {
lines = append(lines, "", "Error: "+m.lastErr)
}
lines = append(lines, "", "j/k move • space|enter toggle • a all-on • n all-off • / search • esc close")
+ return lines
+}
- 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)
+// renderProbeRow formats a single probe entry with selection prefix, checkbox,
+// syscall name, and an optional truncated error annotation.
+func (m Model) renderProbeRow(p probemanager.ProbeState, selected bool) string {
+ prefix := " "
+ if selected {
+ prefix = "> "
+ }
+ check := "[ ]"
+ if p.Active {
+ check = "[x]"
+ }
+ // Use a Builder to avoid an extra allocation for the optional error suffix
+ // emitted per probe row on every render call.
+ var lb strings.Builder
+ lb.WriteString(fmt.Sprintf("%s%s %-24s", prefix, check, p.Syscall))
+ if p.Error != "" {
+ lb.WriteString(" ! ")
+ lb.WriteString(truncateText(sanitizeOneLine(p.Error), 28))
+ }
+ return lb.String()
}
func toggleCmd(manager Manager, syscall string) tea.Cmd {
diff --git a/internal/tui/tracefilter/model.go b/internal/tui/tracefilter/model.go
index b46d50a..9443a07 100644
--- a/internal/tui/tracefilter/model.go
+++ b/internal/tui/tracefilter/model.go
@@ -99,83 +99,94 @@ func (m Model) Close() Model {
return m
}
+// Update processes a Bubble Tea message and returns the updated model.
+// Key handling is split between an active text-edit state and navigation state.
func (m Model) Update(msg tea.Msg) Model {
if !m.visible {
return m
}
+ keyMsg, ok := msg.(tea.KeyPressMsg)
+ if !ok {
+ return m
+ }
+ if m.editing {
+ return m.updateEditing(keyMsg)
+ }
+ return m.updateNavigating(keyMsg)
+}
- if keyMsg, ok := msg.(tea.KeyPressMsg); ok {
- if m.editing {
- switch keyMsg.String() {
- case "esc":
- m.commitEdit()
- m.filter = m.buildFilterFromFields()
- return m.Close()
- 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
- }
- m.commitEdit()
- return m
- }
- var cmd tea.Cmd
- m.textInput, cmd = m.textInput.Update(msg)
- _ = cmd
+// updateEditing handles key presses while the user is typing into the text
+// input for the active field. Esc commits and closes; Enter confirms the value.
+func (m Model) updateEditing(keyMsg tea.KeyPressMsg) Model {
+ switch keyMsg.String() {
+ case "esc":
+ m.commitEdit()
+ m.filter = m.buildFilterFromFields()
+ return m.Close()
+ case "enter":
+ if m.fields[m.activeField].fieldKey == fieldErrorsOnly {
+ m.toggleBoolField(m.activeField)
return m
}
+ m.commitEdit()
+ return m
+ }
+ var cmd tea.Cmd
+ m.textInput, cmd = m.textInput.Update(keyMsg)
+ _ = cmd
+ return m
+}
- switch keyMsg.String() {
- case "esc":
- 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
- }
- m.startEdit()
+// updateNavigating handles key presses while the user is navigating the field
+// list (not actively editing a text input).
+func (m Model) updateNavigating(keyMsg tea.KeyPressMsg) Model {
+ switch keyMsg.String() {
+ case "esc":
+ m.filter = m.buildFilterFromFields()
+ return m.Close()
+ case "c":
+ m.clearAll()
+ return m
+ case "j", "down":
+ if m.activeField < len(m.fields)-1 {
+ m.activeField++
+ }
+ return m
+ case "k", "up":
+ if m.activeField > 0 {
+ m.activeField--
+ }
+ return m
+ case "tab":
+ if m.isNumericField(m.activeField) {
+ m.fields[m.activeField].opIndex = (m.fields[m.activeField].opIndex + 1) % len(compareOps)
+ }
+ return m
+ case " ", "space":
+ if m.fields[m.activeField].fieldKey == fieldErrorsOnly {
+ m.toggleBoolField(m.activeField)
+ }
+ return m
+ case "enter":
+ if m.fields[m.activeField].fieldKey == fieldErrorsOnly {
+ m.toggleBoolField(m.activeField)
return m
}
+ m.startEdit()
+ return m
}
return m
}
+// toggleBoolField flips the string "true"/"false" value for a boolean field.
+func (m *Model) toggleBoolField(index int) {
+ if strings.TrimSpace(m.fields[index].value) == "true" {
+ m.fields[index].value = "false"
+ } else {
+ m.fields[index].value = "true"
+ }
+}
+
func (m Model) View(width, height int) string {
if !m.visible {
return ""
@@ -259,58 +270,66 @@ func (m Model) renderField(field filterField, active bool) string {
return fmt.Sprintf("%-8s %s", field.label+":", value)
}
+// buildFilterFromFields converts the current field values into a globalfilter.Filter.
+// String fields use substring matching; numeric fields use the selected compare op.
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")
- }
+ applyFieldToFilter(field, value, &out)
}
return out
}
+// applyFieldToFilter writes a single field value into the appropriate slot of
+// out. It is split out of buildFilterFromFields to keep each function concise.
+func applyFieldToFilter(field filterField, value string, out *globalfilter.Filter) {
+ 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 f, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.PID = f
+ }
+ case fieldTID:
+ if f, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.TID = f
+ }
+ case fieldFD:
+ if f, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.FD = f
+ }
+ case fieldLatency:
+ if f, ok := parseNumericFilter(value, field.opIndex, true); ok {
+ out.LatencyNs = f
+ }
+ case fieldGap:
+ if f, ok := parseNumericFilter(value, field.opIndex, true); ok {
+ out.GapNs = f
+ }
+ case fieldBytes:
+ if f, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.Bytes = f
+ }
+ case fieldReturn:
+ if f, ok := parseNumericFilter(value, field.opIndex, false); ok {
+ out.RetVal = f
+ }
+ case fieldErrorsOnly:
+ out.ErrorsOnly = strings.EqualFold(value, "true")
+ }
+}
+
func parseNumericFilter(value string, opIndex int, duration bool) (*globalfilter.NumericFilter, bool) {
if value == "" {
return nil, false