diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-13 20:04:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-13 20:04:48 +0300 |
| commit | 251894cf3375812564ecf28392179b395cdda9c7 (patch) | |
| tree | 83c3609ab591702e29a375923670e7622a33b5c7 /internal | |
| parent | 78ea9e22e596255c5e23ce445d80641870674ca9 (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.go | 139 | ||||
| -rw-r--r-- | internal/eventloop_exit.go | 38 | ||||
| -rw-r--r-- | internal/eventloop_runtime.go | 33 | ||||
| -rw-r--r-- | internal/export/snapshot_csv.go | 114 | ||||
| -rw-r--r-- | internal/flags/flags.go | 57 | ||||
| -rw-r--r-- | internal/generate/bpfhandler.go | 120 | ||||
| -rw-r--r-- | internal/generate/format.go | 84 | ||||
| -rw-r--r-- | internal/ior.go | 101 | ||||
| -rw-r--r-- | internal/ior_parquet_sink.go | 107 | ||||
| -rw-r--r-- | internal/ior_profiling.go | 81 | ||||
| -rw-r--r-- | internal/probemanager/manager.go | 154 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 60 | ||||
| -rw-r--r-- | internal/tui/dashboard/bubbles.go | 121 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 63 | ||||
| -rw-r--r-- | internal/tui/dashboard/icicle.go | 88 | ||||
| -rw-r--r-- | internal/tui/dashboard/treemap.go | 91 | ||||
| -rw-r--r-- | internal/tui/eventstream/export.go | 129 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 390 | ||||
| -rw-r--r-- | internal/tui/export/model.go | 69 | ||||
| -rw-r--r-- | internal/tui/probes/model.go | 172 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model.go | 233 |
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, ¤t) 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, ¤t) 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, ¤t) 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 |
