summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-12 22:47:20 +0300
committerPaul Buetow <paul@buetow.org>2026-05-12 22:47:20 +0300
commit35df301fceabfadc8b8a4ae221cc0c2391e233cd (patch)
tree2d5da8031aacd588e31db1aad05c2956412f7566 /internal/tui
parenta2c067cc49b96968da81031275de9c44c4ba2ee9 (diff)
extract TUI Model god class into focused sub-controllers
Split the 1389-line tui.go Model into three focused sub-controllers that each own a single concern: - filterstack.go (filterStack): owns the filter chain, undo history, and label stack; provides push/pop/rebindProcessFilters API so the Model never manipulates filter slices directly. - tracelifecycle.go (traceLifecycle): owns trace start/stop and the active context.CancelFunc; provides beginCmd/stop API; also houses the recorder helpers (recorderStart/Stop/Active/Status) and the auto-reset cycle logic (nextAutoResetInterval, autoResetCycle). - screenrouter.go (screenRouter): owns the picker-return bookmark (pickerReturn) and the applyWindowSizeToPicker helper so screen transition code in tui.go delegates to it. The Model.Update switch is split into dispatchTypedMsg (framework messages) and dispatchAppMsg (app messages) to keep each helper under the 50-line limit. View is split into viewPickerScreen and viewDashboardScreen for the same reason. All functions are ≤50 lines. go test ./internal/tui/... passes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/filterstack.go174
-rw-r--r--internal/tui/screenrouter.go60
-rw-r--r--internal/tui/tracelifecycle.go172
-rw-r--r--internal/tui/tui.go639
-rw-r--r--internal/tui/tui_test.go68
5 files changed, 694 insertions, 419 deletions
diff --git a/internal/tui/filterstack.go b/internal/tui/filterstack.go
new file mode 100644
index 0000000..3aadea3
--- /dev/null
+++ b/internal/tui/filterstack.go
@@ -0,0 +1,174 @@
+package tui
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "ior/internal/globalfilter"
+)
+
+// filterStack manages the trace filter chain: the active filter, the undo
+// history, and the human-readable label stack displayed in the status bar.
+// It owns all filter mutation and label-generation logic so the top-level
+// Model only coordinates between filterStack, TraceLifecycle, and the dashboard.
+type filterStack struct {
+ global globalfilter.Filter
+ history []globalfilter.Filter
+ stack []string
+}
+
+// newFilterStack constructs a filterStack seeded with the given initial filter.
+func newFilterStack(initial globalfilter.Filter) filterStack {
+ return filterStack{global: initial.Clone()}
+}
+
+// current returns the currently active filter (read-only copy).
+func (f *filterStack) current() globalfilter.Filter {
+ return f.global
+}
+
+// push records oldFilter in the history stack, sets global to newFilter, and
+// appends an action label. Returns true when the filter actually changed.
+func (f *filterStack) push(newFilter globalfilter.Filter, action string) bool {
+ next := newFilter.Clone()
+ if f.global.Equal(next) {
+ return false
+ }
+ f.history = append(f.history, f.global.Clone())
+ f.stack = append(f.stack, globalFilterActionLabel(f.global, next, action))
+ f.global = next
+ return true
+}
+
+// pop reverts to the previous filter. Returns (previousFilter, true) when
+// history is available, or (zero, false) when already at the oldest entry.
+func (f *filterStack) pop() (globalfilter.Filter, bool) {
+ if len(f.history) == 0 {
+ return globalfilter.Filter{}, false
+ }
+ prev := f.history[len(f.history)-1]
+ f.history = f.history[:len(f.history)-1]
+ if len(f.stack) > 0 {
+ f.stack = f.stack[:len(f.stack)-1]
+ }
+ f.global = prev.Clone()
+ return prev, true
+}
+
+// setGlobal directly replaces the active filter without recording history.
+// Use this when restoring a known filter (e.g. from pop).
+func (f *filterStack) setGlobal(filter globalfilter.Filter) {
+ f.global = filter.Clone()
+}
+
+// rebindProcessFilters replaces the PID and TID equality constraints on every
+// entry in both the active filter and the full history. Call this whenever the
+// traced process changes so earlier undo states cannot restore a stale PID.
+func (f *filterStack) rebindProcessFilters(pid, tid int) {
+ f.global = applyProcessFilters(f.global, pid, tid)
+ for i := range f.history {
+ f.history[i] = applyProcessFilters(f.history[i], pid, tid)
+ }
+}
+
+// pidFromFilter extracts the PID equality value from the active filter.
+// Returns -1 (meaning "no filter") when no equality constraint is set.
+func (f *filterStack) pidFromFilter() int {
+ pid, _ := f.global.PID.EqValue()
+ return selectedPIDFilter(pid)
+}
+
+// tidFromFilter extracts the TID equality value from the active filter.
+// Returns -1 (meaning "no filter") when no equality constraint is set.
+func (f *filterStack) tidFromFilter() int {
+ tid, _ := f.global.TID.EqValue()
+ return selectedPIDFilter(tid)
+}
+
+// labelStack returns the current human-readable filter label stack (read-only).
+func (f *filterStack) labelStack() []string {
+ return f.stack
+}
+
+// applyProcessFilters clones the filter and replaces its PID/TID equality
+// constraints with the supplied values. Called by rebindProcessFilters and
+// during the PID/TID reselect flow.
+func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter {
+ out := filter.Clone()
+ out.PID = globalfilter.NewEqFilter(int64(pid))
+ out.TID = globalfilter.NewEqFilter(int64(tid))
+ return out
+}
+
+// globalFilterActionLabel builds a short human-readable summary of what
+// changed between prev and next. If action is non-empty it is returned as-is.
+func globalFilterActionLabel(prev, next globalfilter.Filter, action string) string {
+ if strings.TrimSpace(action) != "" {
+ return action
+ }
+ parts := make([]string, 0, 10)
+ if prev.ErrorsOnly != next.ErrorsOnly {
+ if next.ErrorsOnly {
+ parts = append(parts, "errors")
+ } else {
+ parts = append(parts, "clear errors")
+ }
+ }
+ parts = appendStringFilterChange(parts, "syscall", prev.Syscall, next.Syscall)
+ parts = appendStringFilterChange(parts, "comm", prev.Comm, next.Comm)
+ parts = appendStringFilterChange(parts, "file", prev.File, next.File)
+ parts = appendNumericFilterChange(parts, "pid", prev.PID, next.PID, false)
+ parts = appendNumericFilterChange(parts, "tid", prev.TID, next.TID, false)
+ parts = appendNumericFilterChange(parts, "fd", prev.FD, next.FD, false)
+ parts = appendNumericFilterChange(parts, "latency", prev.LatencyNs, next.LatencyNs, true)
+ parts = appendNumericFilterChange(parts, "gap", prev.GapNs, next.GapNs, true)
+ parts = appendNumericFilterChange(parts, "bytes", prev.Bytes, next.Bytes, false)
+ parts = appendNumericFilterChange(parts, "ret", prev.RetVal, next.RetVal, false)
+ if len(parts) == 0 {
+ return next.Summary()
+ }
+ return strings.Join(parts, " ")
+}
+
+func appendStringFilterChange(parts []string, name string, prev, next *globalfilter.StringFilter) []string {
+ if sameStringFilter(prev, next) {
+ return parts
+ }
+ if next == nil || strings.TrimSpace(next.Pattern) == "" {
+ return append(parts, "clear "+name)
+ }
+ return append(parts, fmt.Sprintf("%s~%s", name, strings.TrimSpace(next.Pattern)))
+}
+
+func appendNumericFilterChange(parts []string, name string, prev, next *globalfilter.NumericFilter, duration bool) []string {
+ if sameNumericFilter(prev, next) {
+ return parts
+ }
+ if next == nil {
+ return append(parts, "clear "+name)
+ }
+ value := strconv.FormatInt(next.Value, 10)
+ if duration {
+ value = time.Duration(next.Value).String()
+ }
+ return append(parts, fmt.Sprintf("%s%s%s", name, globalfilter.CompareOpSymbol(next.Op), value))
+}
+
+func sameStringFilter(a, b *globalfilter.StringFilter) bool {
+ if a == nil || strings.TrimSpace(a.Pattern) == "" {
+ return b == nil || strings.TrimSpace(b.Pattern) == ""
+ }
+ if b == nil {
+ return false
+ }
+ return strings.TrimSpace(a.Pattern) == strings.TrimSpace(b.Pattern)
+}
+
+func sameNumericFilter(a, b *globalfilter.NumericFilter) bool {
+ if a == nil || b == nil {
+ return a == nil && b == nil
+ }
+ return a.Op == b.Op && a.Value == b.Value
+}
diff --git a/internal/tui/screenrouter.go b/internal/tui/screenrouter.go
new file mode 100644
index 0000000..9a110c8
--- /dev/null
+++ b/internal/tui/screenrouter.go
@@ -0,0 +1,60 @@
+package tui
+
+import (
+ "ior/internal/tui/pidpicker"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// screenRouter owns screen-transition state for the TUI. It tracks the picker
+// return bookmark used when the user navigates from the dashboard to the PID or
+// TID picker and may want to cancel back to the original dashboard view.
+type screenRouter struct {
+ // pickerReturn is non-nil while the user has navigated from the dashboard
+ // back to the PID/TID picker. The stored values are used to restart the
+ // trace if the user presses Esc to cancel the picker navigation.
+ pickerReturn *pickerReturnState
+}
+
+// newScreenRouter creates an empty screenRouter with no pending return state.
+func newScreenRouter() screenRouter {
+ return screenRouter{}
+}
+
+// savePendingReturn records the current pid/tid so Esc can restore the
+// dashboard if the user decides not to select a new process.
+func (r *screenRouter) savePendingReturn(pid, tid int) {
+ r.pickerReturn = &pickerReturnState{
+ pidFilter: pid,
+ tidFilter: tid,
+ }
+}
+
+// takePendingReturn consumes and returns the stored picker return state,
+// clearing it in the process. Returns (zero, false) when no pending state exists.
+func (r *screenRouter) takePendingReturn() (pickerReturnState, bool) {
+ if r.pickerReturn == nil {
+ return pickerReturnState{}, false
+ }
+ state := *r.pickerReturn
+ r.pickerReturn = nil
+ return state, true
+}
+
+// hasPendingReturn reports whether the user navigated from the dashboard to
+// the picker (meaning Esc should return to the dashboard instead of quitting).
+func (r *screenRouter) hasPendingReturn() bool {
+ return r.pickerReturn != nil
+}
+
+// applyWindowSizeToPicker sends the current window size to the pid picker when
+// valid dimensions are available. Returns the updated picker and an optional
+// size command.
+func applyWindowSizeToPicker(picker pidpicker.Model, width, height int) (pidpicker.Model, tea.Cmd) {
+ if width <= 0 || height <= 0 {
+ return picker, nil
+ }
+ msg := tea.WindowSizeMsg{Width: width, Height: height}
+ next, cmd := picker.Update(msg)
+ return next.(pidpicker.Model), cmd
+}
diff --git a/internal/tui/tracelifecycle.go b/internal/tui/tracelifecycle.go
new file mode 100644
index 0000000..5877cee
--- /dev/null
+++ b/internal/tui/tracelifecycle.go
@@ -0,0 +1,172 @@
+package tui
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "ior/internal/globalfilter"
+ "ior/internal/parquet"
+
+ tea "charm.land/bubbletea/v2"
+)
+
+// traceLifecycle manages trace start/stop, recording start/stop, and the
+// auto-reset interval cycle. It owns the context.CancelFunc for the running
+// trace so the Model can stop tracing without understanding the context
+// machinery.
+type traceLifecycle struct {
+ startTrace TraceStarter
+ traceStop context.CancelFunc
+}
+
+// newTraceLifecycle creates a traceLifecycle bound to the given starter.
+// If starter is nil, a no-op default is used so the model can operate
+// in tests without a real BPF trace.
+func newTraceLifecycle(starter TraceStarter) traceLifecycle {
+ if starter == nil {
+ starter = defaultTraceStarter
+ }
+ return traceLifecycle{startTrace: starter}
+}
+
+// beginCmd creates a tea.Cmd that runs the trace starter in a goroutine and
+// returns a TracingStartedMsg or TracingErrorMsg. It also cancels any
+// previously running trace and stores the new cancel function.
+func (t *traceLifecycle) beginCmd(runtime *runtimeBindings, filter globalfilter.Filter) tea.Cmd {
+ ctx, cancel := context.WithCancel(context.Background())
+ t.traceStop = cancel
+ ctx = ContextWithRuntimeBindings(ctx, runtime)
+ ctx = ContextWithTraceFilters(ctx, filter)
+ return startTraceCmd(t.startTrace, ctx)
+}
+
+// stop cancels the running trace and clears the cancel function. Safe to call
+// multiple times or when no trace is running.
+func (t *traceLifecycle) stop() {
+ if t.traceStop != nil {
+ t.traceStop()
+ t.traceStop = nil
+ }
+}
+
+// startTraceCmd wraps a TraceStarter in a tea.Cmd that handles context
+// cancellation gracefully (returns nil so the caller does not treat a
+// user-initiated stop as an error).
+func startTraceCmd(starter TraceStarter, ctx context.Context) tea.Cmd {
+ return func() tea.Msg {
+ if err := starter(ctx); err != nil {
+ if errors.Is(err, context.Canceled) {
+ return nil
+ }
+ return TracingErrorMsg{Err: err}
+ }
+ return TracingStartedMsg{}
+ }
+}
+
+func defaultTraceStarter(context.Context) error {
+ return nil
+}
+
+// recorderStart opens the parquet recorder at the given path.
+// It calls syncFn (typically syncDashboardFilterState) after the attempt
+// (success or failure) so the status bar stays in sync.
+func recorderStart(recorder *parquet.Recorder, path string, syncFn func()) error {
+ if recorder == nil {
+ return errors.New("recording runtime is unavailable")
+ }
+ err := recorder.Start(path, parquet.StartOptions{Metadata: tuiParquetMetadata()})
+ syncFn()
+ return err
+}
+
+// recorderStop closes the active parquet recorder.
+// Returns nil without error when no recording is active.
+// Calls syncFn after the attempt so the status bar stays in sync.
+func recorderStop(recorder *parquet.Recorder, syncFn func()) error {
+ if recorder == nil {
+ return nil
+ }
+ if !recorder.Status().Active {
+ syncFn()
+ return nil
+ }
+ err := recorder.Stop()
+ syncFn()
+ return err
+}
+
+// recorderActive returns true when the recorder is currently recording.
+func recorderActive(recorder *parquet.Recorder) bool {
+ if recorder == nil {
+ return false
+ }
+ return recorder.Status().Active
+}
+
+// recorderStatus returns the human-readable recording status string shown
+// in the status bar.
+func recorderStatus(recorder *parquet.Recorder) string {
+ if recorder == nil {
+ return "rec: unavailable"
+ }
+ status := recorder.Status()
+ if status.Active {
+ return "rec: " + shortenRecordingPath(status.Path)
+ }
+ if status.LastError != nil {
+ return "rec err: " + status.LastError.Error()
+ }
+ return "rec: off"
+}
+
+func defaultParquetRecordingFilename() string {
+ return fmt.Sprintf("ior-recording-%s.parquet", time.Now().Format("20060102-150405"))
+}
+
+// tuiParquetMetadata delegates to the canonical parquet.NewFileMetadata.
+func tuiParquetMetadata() parquet.FileMetadata {
+ return parquet.NewFileMetadata("tui")
+}
+
+func shortenRecordingPath(path string) string {
+ const maxLen = 36
+ if len(path) <= maxLen {
+ return path
+ }
+ return "..." + path[len(path)-maxLen+3:]
+}
+
+// autoResetCycle is the ordered set of cadences exposed via the `I` hotkey.
+// The first entry (0) disables the timer; the rest are progressively longer
+// to give users a quick way to slow auto-resets down on long traces or turn
+// them off entirely. The cycle wraps so pressing `I` past the last preset
+// returns to off.
+var autoResetCycle = []time.Duration{
+ 0,
+ 10 * time.Second,
+ 30 * time.Second,
+ 60 * time.Second,
+ 2 * time.Minute,
+ 5 * time.Minute,
+}
+
+// nextAutoResetInterval returns the next entry in autoResetCycle after
+// current. If current is not in the cycle (e.g. a custom -resetTimer like
+// 47s), the next entry is the first cycle value strictly greater than current;
+// if there is none, we wrap to 0 (off).
+func nextAutoResetInterval(current time.Duration) time.Duration {
+ for i, d := range autoResetCycle {
+ if d == current {
+ return autoResetCycle[(i+1)%len(autoResetCycle)]
+ }
+ }
+ for _, d := range autoResetCycle {
+ if d > current {
+ return d
+ }
+ }
+ return autoResetCycle[0]
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 06fafea..6124fcd 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -2,11 +2,8 @@ package tui
import (
"context"
- "errors"
"fmt"
"log"
- "strconv"
- "strings"
"sync"
"sync/atomic"
"time"
@@ -308,23 +305,16 @@ type keyboardState struct {
suppressUntil time.Time
}
-// filterState groups the trace filter chain: the active filter, the undo
-// history, and the human-readable label stack used in the status bar.
-type filterState struct {
- global globalfilter.Filter
- history []globalfilter.Filter
- stack []string
-}
-
// processState groups PID/TID filter values and the picker navigation
// return bookmark used to restore the dashboard after re-selecting a process.
type processState struct {
- pid int
- tid int
- pickerReturn *pickerReturnState
+ pid int
+ tid int
}
-// Model is the top-level Bubble Tea model that routes between PID picker and dashboard.
+// Model is the top-level Bubble Tea model that routes between PID picker and
+// dashboard. It delegates filter management to filterStack, trace lifecycle
+// to traceLifecycle, and screen transitions to screenRouter.
type Model struct {
screen Screen
pidPicker pidpicker.Model
@@ -347,16 +337,19 @@ type Model struct {
spin spinner.Model
lastErr error
- startTrace TraceStarter
- traceStop context.CancelFunc
-
- kb keyboardState
- filter filterState
- proc processState
+ // tracer owns trace start/stop and the active context.CancelFunc.
+ tracer traceLifecycle
+ // filters owns the filter chain, undo history, and label stack.
+ filters filterStack
+ // router owns screen-transition state (pending picker return).
+ router screenRouter
+ proc processState
exportEnabled bool
isDark bool
focused bool
+
+ kb keyboardState
}
type pickerReturnState struct {
@@ -384,42 +377,32 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter
common.ApplyPalette(true)
syncStylesFromCommon()
- spin := spinner.New()
- spin.Spinner = spinner.MiniDot
- if startTrace == nil {
- startTrace = defaultTraceStarter
- }
keys := Keys
if !exportEnabled {
keys.Export = key.NewBinding()
}
- runtime := newRuntimeBindings()
- dashboard := dashboardui.NewModelWithConfig(lateBoundDashboardSource{runtime: runtime}, runtime.eventStreamSource(), 1000, keys)
- dashboard.SetDarkMode(true)
- pidFilter := selectedPIDFilter(startupPidFilter)
- if initialPID > 0 {
- pidFilter = selectedPIDFilter(initialPID)
- }
- tidFilter := selectedPIDFilter(startupTidFilter)
- if initialPID > 0 {
- tidFilter = -1
- }
- dashboard.SetPidFilter(pidFilter)
+ rt := newRuntimeBindings()
+ pidFilter, tidFilter := resolveStartupPIDFilters(initialPID, startupPidFilter, startupTidFilter)
+ dashboard := newDashboardWithRuntime(rt, pidFilter, keys)
+
+ spin := spinner.New()
+ spin.Spinner = spinner.MiniDot
model := Model{
screen: ScreenPIDPicker,
pidPicker: pidpicker.New().SetDarkMode(true),
dashboard: dashboard,
exporter: tuiexport.NewModel(),
- probeModal: probes.NewModel(runtime.currentProbeManager()).SetDarkMode(true),
+ probeModal: probes.NewModel(rt.currentProbeManager()).SetDarkMode(true),
filterModal: tracefilterui.NewModel().SetDarkMode(true),
recordModal: newRecordingModal().SetDarkMode(true),
- runtime: runtime,
+ runtime: rt,
keys: keys,
spin: spin,
- startTrace: startTrace,
- filter: filterState{global: startupFilter.Clone()},
+ tracer: newTraceLifecycle(startTrace),
+ filters: newFilterStack(startupFilter),
+ router: newScreenRouter(),
exportEnabled: exportEnabled,
isDark: true,
focused: true,
@@ -434,6 +417,28 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter
return model
}
+// resolveStartupPIDFilters computes the effective pid/tid filter values from
+// the startup arguments. When initialPID is provided it overrides the config
+// PID filter and forces tid to -1 (no TID filter).
+func resolveStartupPIDFilters(initialPID, startupPidFilter, startupTidFilter int) (pid, tid int) {
+ pid = selectedPIDFilter(startupPidFilter)
+ tid = selectedPIDFilter(startupTidFilter)
+ if initialPID > 0 {
+ pid = selectedPIDFilter(initialPID)
+ tid = -1
+ }
+ return pid, tid
+}
+
+// newDashboardWithRuntime creates a dark-mode dashboard bound to the given
+// runtime and pre-configured with the initial PID filter.
+func newDashboardWithRuntime(rt *runtimeBindings, pidFilter int, keys KeyMap) dashboardui.Model {
+ dashboard := dashboardui.NewModelWithConfig(lateBoundDashboardSource{runtime: rt}, rt.eventStreamSource(), 1000, keys)
+ dashboard.SetDarkMode(true)
+ dashboard.SetPidFilter(pidFilter)
+ return dashboard
+}
+
// Init initializes the active child model and optional tracing startup command.
func (m Model) Init() tea.Cmd {
sizeCmd := initialWindowSizeCmd()
@@ -458,93 +463,141 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
msg = normalizedMsg
+ if next, cmd, handled := m.dispatchTypedMsg(msg); handled {
+ return next, cmd
+ }
+ if next, cmd, handled := m.dispatchAppMsg(msg); handled {
+ return next, cmd
+ }
+
+ if next, cmd, handled := m.handleModalDispatch(msg); handled {
+ return next, cmd
+ }
+
+ return m.updateActiveModel(msg)
+}
+
+// dispatchTypedMsg handles all typed message cases that require no modal check.
+// Returns (model, cmd, true) when the message was consumed, or (_, _, false)
+// to fall through to modal dispatch and then active-model routing.
+func (m Model) dispatchTypedMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
- return m.updateActiveModel(msg)
+ next, cmd := m.updateActiveModel(msg)
+ return next, cmd, true
case tea.BackgroundColorMsg:
m.applyTheme(msg.IsDark())
- return m, nil
+ return m, nil, true
case tea.KeyboardEnhancementsMsg:
m.kb.enhancements = msg
m.kb.enhancementsKnown = true
if msg.SupportsKeyDisambiguation() {
log.Printf("tui: keyboard enhancements enabled (flags=%d, eventTypes=%t)", msg.Flags, msg.SupportsEventTypes())
}
- return m, nil
+ return m, nil, true
case tea.FocusMsg:
- m.focused = true
- // SetFocused returns a tea.Cmd that arms a fresh auto-reset tick
- // when focus returns (or nil if the timer is disabled). It also
- // bumps the dashboard's autoResetGen so any tick that was scheduled
- // before the blur and is still in flight is dropped on arrival.
- focusCmd := m.dashboard.SetFocused(true)
- if m.screen == ScreenDashboard && !m.attaching {
- // Init() arms its own auto-reset tick at the post-bump
- // generation, so discard focusCmd here to avoid two
- // concurrently-live ticks racing the cadence.
- return m, tea.Batch(m.dashboard.Init(), m.dashboard.SnapshotCmd())
- }
- return m, focusCmd
+ next, cmd := m.handleFocusMsg()
+ return next, cmd, true
case tea.BlurMsg:
m.focused = false
// SetFocused returns nil on blur but still bumps autoResetGen so
// that any in-flight tick scheduled before the blur is ignored.
m.dashboard.SetFocused(false)
- return m, nil
+ return m, nil, true
case tea.KeyPressMsg:
if next, cmd, handled := m.handleGlobalKeyPress(msg); handled {
- return next, cmd
+ return next, cmd, true
}
+ return m, nil, false
+ }
+ return m, nil, false
+}
+
+// dispatchAppMsg handles application-level message types (export, probe, trace,
+// filter) that are not tea framework messages.
+// It is called after dispatchTypedMsg returns unhandled for non-framework types.
+func (m Model) dispatchAppMsg(msg tea.Msg) (tea.Model, tea.Cmd, bool) {
+ switch msg := msg.(type) {
case tuiexport.RequestMsg:
- return m, runExportCmd(m.exportEnabled, msg.Option, m.dashboard)
+ return m, runExportCmd(m.exportEnabled, msg.Option, m.dashboard), true
case tuiexport.CompletedMsg:
var cmd tea.Cmd
m.exporter, cmd = m.exporter.Update(msg)
- return m, cmd
+ return m, cmd, true
case tuiexport.FailedMsg:
var cmd tea.Cmd
m.exporter, cmd = m.exporter.Update(msg)
- return m, cmd
+ return m, cmd, true
case probes.ProbeToggledMsg:
- var cmd tea.Cmd
- m.probeModal, cmd = m.probeModal.Update(msg)
- if snap := m.runtime.resetDashboardSnapshotSource(); snap != nil {
- next, dashboardCmd := m.dashboard.Update(messages.StatsTickMsg{Snap: snap})
- m.dashboard = next.(dashboardui.Model)
- return m, tea.Batch(dashboardCmd, cmd)
- }
- return m, cmd
+ next, cmd := m.handleProbeToggledMsg(msg)
+ return next, cmd, true
case PidSelectedMsg:
- return m.handlePidSelected(msg)
+ next, cmd := m.handlePidSelected(msg)
+ return next, cmd, true
case TidSelectedMsg:
- return m.handleTidSelected(msg)
+ next, cmd := m.handleTidSelected(msg)
+ return next, cmd, true
case TracingStartedMsg:
- m.attaching = false
- m.dashboard.SetStreamSource(m.runtime.eventStreamSource())
- m.dashboard.SetLiveTrie(m.runtime.liveTrie())
- m.dashboard.SetGlobalFilter(m.filter.global)
- m.syncDashboardFilterState()
- width, height := common.EffectiveViewport(m.width, m.height)
- next, sizeCmd := m.dashboard.Update(tea.WindowSizeMsg{Width: width, Height: height})
- m.dashboard = next.(dashboardui.Model)
- return m, tea.Batch(sizeCmd, m.dashboard.Init(), m.dashboard.SnapshotCmd())
+ next, cmd := m.handleTracingStarted()
+ return next, cmd, true
case TracingErrorMsg:
m.attaching = false
m.lastErr = msg.Err
- return m, nil
+ return m, nil, true
case messages.GlobalFilterRequestedMsg:
- return m.applyGlobalFilter(msg.Filter, msg.Action)
+ next, cmd := m.applyGlobalFilter(msg.Filter, msg.Action)
+ return next, cmd, true
case messages.GlobalFilterUndoRequestedMsg:
- return m.undoGlobalFilter()
+ next, cmd := m.undoGlobalFilter()
+ return next, cmd, true
}
+ return m, nil, false
+}
- if next, cmd, handled := m.handleModalDispatch(msg); handled {
- return next, cmd
+// handleFocusMsg restores focus and re-arms the dashboard's auto-reset tick.
+func (m Model) handleFocusMsg() (tea.Model, tea.Cmd) {
+ m.focused = true
+ // SetFocused returns a tea.Cmd that arms a fresh auto-reset tick
+ // when focus returns (or nil if the timer is disabled). It also
+ // bumps the dashboard's autoResetGen so any tick that was scheduled
+ // before the blur and is still in flight is dropped on arrival.
+ focusCmd := m.dashboard.SetFocused(true)
+ if m.screen == ScreenDashboard && !m.attaching {
+ // Init() arms its own auto-reset tick at the post-bump
+ // generation, so discard focusCmd here to avoid two
+ // concurrently-live ticks racing the cadence.
+ return m, tea.Batch(m.dashboard.Init(), m.dashboard.SnapshotCmd())
}
+ return m, focusCmd
+}
- return m.updateActiveModel(msg)
+// handleProbeToggledMsg resets the dashboard aggregates after a probe toggle
+// so the new probe set is reflected immediately.
+func (m Model) handleProbeToggledMsg(msg probes.ProbeToggledMsg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ m.probeModal, cmd = m.probeModal.Update(msg)
+ if snap := m.runtime.resetDashboardSnapshotSource(); snap != nil {
+ next, dashboardCmd := m.dashboard.Update(messages.StatsTickMsg{Snap: snap})
+ m.dashboard = next.(dashboardui.Model)
+ return m, tea.Batch(dashboardCmd, cmd)
+ }
+ return m, cmd
+}
+
+// handleTracingStarted wires live sources into the dashboard once the trace
+// starter confirms the trace is running.
+func (m Model) handleTracingStarted() (tea.Model, tea.Cmd) {
+ m.attaching = false
+ m.dashboard.SetStreamSource(m.runtime.eventStreamSource())
+ m.dashboard.SetLiveTrie(m.runtime.liveTrie())
+ m.dashboard.SetGlobalFilter(m.filters.current())
+ m.syncDashboardFilterState()
+ width, height := common.EffectiveViewport(m.width, m.height)
+ next, sizeCmd := m.dashboard.Update(tea.WindowSizeMsg{Width: width, Height: height})
+ m.dashboard = next.(dashboardui.Model)
+ return m, tea.Batch(sizeCmd, m.dashboard.Init(), m.dashboard.SnapshotCmd())
}
func (m *Model) keyNormalizer(msg tea.Msg) (tea.Msg, bool) {
@@ -564,7 +617,7 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool {
func (m Model) shouldCancelPickerToDashboard(msg tea.KeyPressMsg) bool {
return m.screen == ScreenPIDPicker &&
- m.proc.pickerReturn != nil &&
+ m.router.hasPendingReturn() &&
(isEscKey(msg) || key.Matches(msg, m.keys.Quit))
}
@@ -617,12 +670,12 @@ func (m Model) handleHelpOverlayKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cm
// so modals close before the user needs to press q again.
func (m Model) handleQuitKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) {
if m.canHandleDashboardShortcut(msg) {
- if err := m.stopRecording(); err != nil {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
m.lastErr = err
return m, nil, true
}
m.quitting = true
- m.stopTrace()
+ m.tracer.stop()
return m, tea.Quit, true
}
if m.shouldRouteQuitToEsc(msg) {
@@ -666,21 +719,14 @@ func (m Model) handleDashboardShortcutKeys(msg tea.KeyPressMsg) (tea.Model, tea.
return m, nil, true
}
if key.Matches(msg, m.keys.Record) {
- if m.recordingActive() {
- if err := m.stopRecording(); err != nil {
- m.lastErr = err
- }
- return m, nil, true
- }
- m.recordModal = m.recordModal.Open(defaultParquetRecordingFilename())
- return m, nil, true
+ return m.handleRecordKey()
}
if key.Matches(msg, m.keys.Probes) {
m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open()
return m, nil, true
}
if key.Matches(msg, m.keys.Filter) {
- m.filterModal = m.filterModal.Open(m.filter.global)
+ m.filterModal = m.filterModal.Open(m.filters.current())
return m, nil, true
}
if key.Matches(msg, m.keys.FilterUndo) {
@@ -702,36 +748,17 @@ func (m Model) handleDashboardShortcutKeys(msg tea.KeyPressMsg) (tea.Model, tea.
return m, nil, false
}
-// autoResetCycle is the ordered set of cadences exposed via the `I`
-// hotkey. The first entry (0) disables the timer; the rest are
-// progressively longer to give users a quick way to slow auto-resets
-// down on long traces or turn them off entirely. The cycle wraps so
-// pressing `I` past the last preset returns to off.
-var autoResetCycle = []time.Duration{
- 0,
- 10 * time.Second,
- 30 * time.Second,
- 60 * time.Second,
- 2 * time.Minute,
- 5 * time.Minute,
-}
-
-// nextAutoResetInterval returns the next entry in autoResetCycle after
-// `current`. If `current` is not in the cycle (e.g. the user passed a
-// custom -resetTimer like 47s), the next entry is the first cycle value
-// strictly greater than current; if there is none, we wrap to 0 (off).
-func nextAutoResetInterval(current time.Duration) time.Duration {
- for i, d := range autoResetCycle {
- if d == current {
- return autoResetCycle[(i+1)%len(autoResetCycle)]
- }
- }
- for _, d := range autoResetCycle {
- if d > current {
- return d
+// handleRecordKey either stops an active recording or opens the record modal
+// to start a new one.
+func (m Model) handleRecordKey() (tea.Model, tea.Cmd, bool) {
+ if recorderActive(m.runtime.Recorder()) {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
+ m.lastErr = err
}
+ return m, nil, true
}
- return autoResetCycle[0]
+ m.recordModal = m.recordModal.Open(defaultParquetRecordingFilename())
+ return m, nil, true
}
// cycleAutoResetInterval advances the dashboard's auto-reset cadence to
@@ -788,7 +815,7 @@ func (m Model) updateRecordModal(msg tea.Msg) (tea.Model, tea.Cmd) {
if !submit {
return m, dashboardCmd
}
- if err := m.startRecording(path); err != nil {
+ if err := recorderStart(m.runtime.Recorder(), path, m.syncDashboardFilterState); err != nil {
m.recordModal = m.recordModal.SetError(err)
return m, dashboardCmd
}
@@ -836,52 +863,55 @@ func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
+// handlePidSelected stops any running trace, resets buffers, and starts a new
+// trace for the newly selected PID.
func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) {
pid := selectedPIDFilter(msg.Pid)
- if err := m.stopRecording(); err != nil {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
m.lastErr = err
return m, nil
}
- m.stopTrace()
+ m.tracer.stop()
m.runtime.resetStreamBuffer()
m.setProcessFilters(pid, -1)
- m.proc.pickerReturn = nil
+ m.router.pickerReturn = nil
m.screen = ScreenDashboard
m.attaching = true
m.lastErr = nil
return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
}
+// handleTidSelected stops any running trace, resets buffers, and starts a new
+// trace filtered to the selected TID within the current (or provided) PID.
func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) {
tid := selectedPIDFilter(msg.Tid)
pid := m.proc.pid
if msg.Pid > 0 {
pid = msg.Pid
}
- if err := m.stopRecording(); err != nil {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
m.lastErr = err
return m, nil
}
- m.stopTrace()
+ m.tracer.stop()
m.runtime.resetStreamBuffer()
m.setProcessFilters(pid, tid)
- m.proc.pickerReturn = nil
+ m.router.pickerReturn = nil
m.screen = ScreenDashboard
m.attaching = true
m.lastErr = nil
return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
}
+// reselectPID saves a return bookmark and switches to the PID picker so the
+// user can choose a different process without losing dashboard state.
func (m Model) reselectPID() (tea.Model, tea.Cmd) {
- if err := m.stopRecording(); err != nil {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
m.lastErr = err
return m, nil
}
- m.proc.pickerReturn = &pickerReturnState{
- pidFilter: m.proc.pid,
- tidFilter: m.proc.tid,
- }
- m.stopTrace()
+ m.router.savePendingReturn(m.proc.pid, m.proc.tid)
+ m.tracer.stop()
m.screen = ScreenPIDPicker
m.attaching = false
m.lastErr = nil
@@ -890,29 +920,21 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) {
m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark)
m.recordModal = newRecordingModal().SetDarkMode(m.isDark)
m.pidPicker = pidpicker.New().SetDarkMode(m.isDark)
-
var sizeCmd tea.Cmd
- if m.width > 0 && m.height > 0 {
- msg := tea.WindowSizeMsg{Width: m.width, Height: m.height}
- next, cmd := m.pidPicker.Update(msg)
- m.pidPicker = next.(pidpicker.Model)
- sizeCmd = cmd
- }
+ m.pidPicker, sizeCmd = applyWindowSizeToPicker(m.pidPicker, m.width, m.height)
return m, tea.Batch(sizeCmd, m.pidPicker.Init())
}
+// reselectTID saves a return bookmark and switches to the TID picker within
+// the current PID so the user can narrow tracing to a specific thread.
func (m Model) reselectTID() (tea.Model, tea.Cmd) {
pid := m.proc.pid
-
- if err := m.stopRecording(); err != nil {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
m.lastErr = err
return m, nil
}
- m.proc.pickerReturn = &pickerReturnState{
- pidFilter: m.proc.pid,
- tidFilter: m.proc.tid,
- }
- m.stopTrace()
+ m.router.savePendingReturn(m.proc.pid, m.proc.tid)
+ m.tracer.stop()
m.screen = ScreenPIDPicker
m.attaching = false
m.lastErr = nil
@@ -921,14 +943,8 @@ func (m Model) reselectTID() (tea.Model, tea.Cmd) {
m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark)
m.recordModal = newRecordingModal().SetDarkMode(m.isDark)
m.pidPicker = pidpicker.NewTIDWithKeys(pid, pidpicker.DefaultKeyMap()).SetDarkMode(m.isDark)
-
var sizeCmd tea.Cmd
- if m.width > 0 && m.height > 0 {
- msg := tea.WindowSizeMsg{Width: m.width, Height: m.height}
- next, cmd := m.pidPicker.Update(msg)
- m.pidPicker = next.(pidpicker.Model)
- sizeCmd = cmd
- }
+ m.pidPicker, sizeCmd = applyWindowSizeToPicker(m.pidPicker, m.width, m.height)
return m, tea.Batch(sizeCmd, m.pidPicker.Init())
}
@@ -939,17 +955,20 @@ func selectedPIDFilter(pid int) int {
return pid
}
+// cancelPickerToDashboard restores the dashboard when the user presses Esc
+// while in the picker after a reselectPID/reselectTID navigation.
func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) {
- if m.proc.pickerReturn == nil {
+ returnState, ok := m.router.takePendingReturn()
+ if !ok {
return m, nil
}
- if err := m.stopRecording(); err != nil {
+ if err := recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState); err != nil {
m.lastErr = err
+ // Restore the pending return state since we didn't complete the transition.
+ m.router.pickerReturn = &returnState
return m, nil
}
- returnState := *m.proc.pickerReturn
- m.proc.pickerReturn = nil
- m.stopTrace()
+ m.tracer.stop()
m.setProcessFilters(returnState.pidFilter, returnState.tidFilter)
m.screen = ScreenDashboard
m.attaching = true
@@ -957,28 +976,10 @@ func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) {
return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
}
+// beginTraceCmd creates a tea.Cmd that starts the trace with the current
+// runtime bindings and active filter. It cancels any previously running trace.
func (m *Model) beginTraceCmd() tea.Cmd {
- ctx, cancel := context.WithCancel(context.Background())
- m.traceStop = cancel
- ctx = ContextWithRuntimeBindings(ctx, m.runtime)
- ctx = ContextWithTraceFilters(ctx, m.filter.global)
- return startTraceCmd(m.startTrace, ctx)
-}
-
-func startTraceCmd(starter TraceStarter, ctx context.Context) tea.Cmd {
- return func() tea.Msg {
- if err := starter(ctx); err != nil {
- if errors.Is(err, context.Canceled) {
- return nil
- }
- return TracingErrorMsg{Err: err}
- }
- return TracingStartedMsg{}
- }
-}
-
-func defaultTraceStarter(context.Context) error {
- return nil
+ return m.tracer.beginCmd(m.runtime, m.filters.current())
}
// filterFromConfig delegates to the canonical Config.TraceFilter method.
@@ -986,49 +987,39 @@ func filterFromConfig(cfg flags.Config) globalfilter.Filter {
return cfg.TraceFilter()
}
+// setProcessFilters updates the proc pid/tid, rebinds filter process constraints,
+// and synchronises the dashboard filter display.
func (m *Model) setProcessFilters(pid, tid int) {
m.proc.pid = pid
m.proc.tid = tid
- m.filter.global = applyProcessFilters(m.filter.global, pid, tid)
- for i := range m.filter.history {
- m.filter.history[i] = applyProcessFilters(m.filter.history[i], pid, tid)
- }
+ m.filters.rebindProcessFilters(pid, tid)
m.syncDashboardFilterState()
}
+// setGlobalFilter directly replaces the active filter, extracts the new
+// PID/TID values, and synchronises the dashboard.
func (m *Model) setGlobalFilter(filter globalfilter.Filter) {
- m.filter.global = filter.Clone()
- // EqValue returns (0, false) when no equality filter is set;
- // selectedPIDFilter maps non-positive values to -1 ("no filter").
- pid, _ := m.filter.global.PID.EqValue()
- tid, _ := m.filter.global.TID.EqValue()
- m.proc.pid = selectedPIDFilter(pid)
- m.proc.tid = selectedPIDFilter(tid)
+ m.filters.setGlobal(filter)
+ m.proc.pid = m.filters.pidFromFilter()
+ m.proc.tid = m.filters.tidFromFilter()
m.syncDashboardFilterState()
}
+// syncDashboardFilterState pushes all filter-related state (PID, global
+// filter, label stack, recording status) into the dashboard model so the
+// status bar stays consistent.
func (m *Model) syncDashboardFilterState() {
m.dashboard.SetPidFilter(m.proc.pid)
- m.dashboard.SetGlobalFilter(m.filter.global)
- m.dashboard.SetFilterStack(m.filter.stack)
- m.dashboard.SetRecordingStatus(m.recordingStatus())
-}
-
-func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter {
- out := filter.Clone()
- out.PID = globalfilter.NewEqFilter(int64(pid))
- out.TID = globalfilter.NewEqFilter(int64(tid))
- return out
+ m.dashboard.SetGlobalFilter(m.filters.current())
+ m.dashboard.SetFilterStack(m.filters.labelStack())
+ m.dashboard.SetRecordingStatus(recorderStatus(m.runtime.Recorder()))
}
+// applyGlobalFilter pushes a new filter onto the filter stack, applies it
+// in-place when possible, or falls back to a full trace restart.
func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea.Model, tea.Cmd) {
- nextFilter := filter.Clone()
- changed := !m.filter.global.Equal(nextFilter)
- if changed {
- m.filter.history = append(m.filter.history, m.filter.global.Clone())
- m.filter.stack = append(m.filter.stack, globalFilterActionLabel(m.filter.global, nextFilter, action))
- }
- m.setGlobalFilter(nextFilter)
+ changed := m.filters.push(filter, action)
+ m.setGlobalFilter(m.filters.current())
if !changed || m.screen != ScreenDashboard {
return m, nil
}
@@ -1039,7 +1030,7 @@ func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea
// aggregates so the displayed counts reflect the new filter going
// forward. The BPF probes stay attached, so the user no longer sees
// the multi-second 'Attaching tracepoints' overlay on filter changes.
- if m.runtime.applyLiveFilter(nextFilter) {
+ if m.runtime.applyLiveFilter(m.filters.current()) {
m.dashboard.PrepareForTraceRestart()
// PrepareForTraceRestart nils the dashboard's live-trie reference
// because the full-restart path expects TracingStartedMsg to
@@ -1054,22 +1045,20 @@ func (m Model) applyGlobalFilter(filter globalfilter.Filter, action string) (tea
// Fallback: no trace currently running (e.g. first invocation), so
// restart the pipeline so the new filter takes effect on the next
// trace start.
- m.stopTrace()
+ m.tracer.stop()
m.dashboard.PrepareForTraceRestart()
m.attaching = true
m.lastErr = nil
return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
}
+// undoGlobalFilter pops the filter stack and re-applies the previous filter,
+// using the same in-place swap or restart logic as applyGlobalFilter.
func (m Model) undoGlobalFilter() (tea.Model, tea.Cmd) {
- if len(m.filter.history) == 0 {
+ prev, ok := m.filters.pop()
+ if !ok {
return m, nil
}
- prev := m.filter.history[len(m.filter.history)-1]
- m.filter.history = m.filter.history[:len(m.filter.history)-1]
- if len(m.filter.stack) > 0 {
- m.filter.stack = m.filter.stack[:len(m.filter.stack)-1]
- }
m.setGlobalFilter(prev)
if m.screen != ScreenDashboard {
return m, nil
@@ -1084,87 +1073,23 @@ func (m Model) undoGlobalFilter() (tea.Model, tea.Cmd) {
return m, nil
}
- m.stopTrace()
+ m.tracer.stop()
m.dashboard.PrepareForTraceRestart()
m.attaching = true
m.lastErr = nil
return m, tea.Batch(m.spin.Tick, m.beginTraceCmd())
}
-func globalFilterActionLabel(prev, next globalfilter.Filter, action string) string {
- if strings.TrimSpace(action) != "" {
- return action
- }
- parts := make([]string, 0, 10)
- if prev.ErrorsOnly != next.ErrorsOnly {
- if next.ErrorsOnly {
- parts = append(parts, "errors")
- } else {
- parts = append(parts, "clear errors")
- }
- }
- parts = appendStringFilterChange(parts, "syscall", prev.Syscall, next.Syscall)
- parts = appendStringFilterChange(parts, "comm", prev.Comm, next.Comm)
- parts = appendStringFilterChange(parts, "file", prev.File, next.File)
- parts = appendNumericFilterChange(parts, "pid", prev.PID, next.PID, false)
- parts = appendNumericFilterChange(parts, "tid", prev.TID, next.TID, false)
- parts = appendNumericFilterChange(parts, "fd", prev.FD, next.FD, false)
- parts = appendNumericFilterChange(parts, "latency", prev.LatencyNs, next.LatencyNs, true)
- parts = appendNumericFilterChange(parts, "gap", prev.GapNs, next.GapNs, true)
- parts = appendNumericFilterChange(parts, "bytes", prev.Bytes, next.Bytes, false)
- parts = appendNumericFilterChange(parts, "ret", prev.RetVal, next.RetVal, false)
- if len(parts) == 0 {
- return next.Summary()
- }
- return strings.Join(parts, " ")
-}
-
-func appendStringFilterChange(parts []string, name string, prev, next *globalfilter.StringFilter) []string {
- if sameStringFilter(prev, next) {
- return parts
- }
- if next == nil || strings.TrimSpace(next.Pattern) == "" {
- return append(parts, "clear "+name)
- }
- return append(parts, fmt.Sprintf("%s~%s", name, strings.TrimSpace(next.Pattern)))
-}
-
-func appendNumericFilterChange(parts []string, name string, prev, next *globalfilter.NumericFilter, duration bool) []string {
- if sameNumericFilter(prev, next) {
- return parts
- }
- if next == nil {
- return append(parts, "clear "+name)
- }
- value := strconv.FormatInt(next.Value, 10)
- if duration {
- value = time.Duration(next.Value).String()
- }
- return append(parts, fmt.Sprintf("%s%s%s", name, globalfilter.CompareOpSymbol(next.Op), value))
-}
-
-func sameStringFilter(a, b *globalfilter.StringFilter) bool {
- if a == nil || strings.TrimSpace(a.Pattern) == "" {
- return b == nil || strings.TrimSpace(b.Pattern) == ""
- }
- if b == nil {
- return false
- }
- return strings.TrimSpace(a.Pattern) == strings.TrimSpace(b.Pattern)
-}
-
-func sameNumericFilter(a, b *globalfilter.NumericFilter) bool {
- if a == nil || b == nil {
- return a == nil && b == nil
- }
- return a.Op == b.Op && a.Value == b.Value
+// startRecording opens the parquet recorder at path and syncs dashboard status.
+// Tests and the Model's record-modal handler call this method.
+func (m *Model) startRecording(path string) error {
+ return recorderStart(m.runtime.Recorder(), path, m.syncDashboardFilterState)
}
-func (m *Model) stopTrace() {
- if m.traceStop != nil {
- m.traceStop()
- m.traceStop = nil
- }
+// stopRecording closes an active parquet recorder and syncs dashboard status.
+// Tests and the quit/reselect paths call this method.
+func (m *Model) stopRecording() error {
+ return recorderStop(m.runtime.Recorder(), m.syncDashboardFilterState)
}
func (m *Model) applyTheme(isDark bool) {
@@ -1217,31 +1142,42 @@ func (m Model) View() tea.View {
switch m.screen {
case ScreenPIDPicker:
- base := m.pidPicker.View().Content
- if m.exporter.Visible() {
- return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title)
- }
- return altScreenView(placeToViewport(width, height, base), title)
+ return m.viewPickerScreen(width, height, title)
case ScreenDashboard:
- base := m.dashboard.View().Content
- if m.filterModal.Visible() {
- return altScreenView(placeToViewport(width, height, m.filterModal.View(width, height)), title)
- }
- if m.recordModal.Visible() {
- return altScreenView(placeToViewport(width, height, m.recordModal.View(width, height)), title)
- }
- if m.probeModal.Visible() {
- return altScreenView(placeToViewport(width, height, m.probeModal.View(width, height)), title)
- }
- if m.exporter.Visible() {
- return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title)
- }
- return altScreenView(placeToViewport(width, height, base), title)
+ return m.viewDashboardScreen(width, height, title)
default:
return altScreenView("", title)
}
}
+// viewPickerScreen renders the PID picker screen with optional export overlay.
+func (m Model) viewPickerScreen(width, height int, title string) tea.View {
+ base := m.pidPicker.View().Content
+ if m.exporter.Visible() {
+ return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title)
+ }
+ return altScreenView(placeToViewport(width, height, base), title)
+}
+
+// viewDashboardScreen renders the dashboard screen with the appropriate modal
+// overlay (filter, record, probes, export) if one is active.
+func (m Model) viewDashboardScreen(width, height int, title string) tea.View {
+ base := m.dashboard.View().Content
+ if m.filterModal.Visible() {
+ return altScreenView(placeToViewport(width, height, m.filterModal.View(width, height)), title)
+ }
+ if m.recordModal.Visible() {
+ return altScreenView(placeToViewport(width, height, m.recordModal.View(width, height)), title)
+ }
+ if m.probeModal.Visible() {
+ return altScreenView(placeToViewport(width, height, m.probeModal.View(width, height)), title)
+ }
+ if m.exporter.Visible() {
+ return altScreenView(placeToViewport(width, height, m.exporter.View(width, height)+"\n"+base), title)
+ }
+ return altScreenView(placeToViewport(width, height, base), title)
+}
+
func isHelpOverlayOpenKey(msg tea.KeyPressMsg) bool {
return msg.String() == "H"
}
@@ -1261,7 +1197,7 @@ func isHelpOverlayQuitKey(msg tea.KeyPressMsg) bool {
func runExportCmd(exportEnabled bool, option tuiexport.Option, dashboard dashboardui.Model) tea.Cmd {
return func() tea.Msg {
if !exportEnabled {
- return tuiexport.FailedMsg{Err: errors.New("tui export is disabled by -tuiExport=false")}
+ return tuiexport.FailedMsg{Err: fmt.Errorf("tui export is disabled by -tuiExport=false")}
}
switch option {
case tuiexport.OptionCSV:
@@ -1271,78 +1207,11 @@ func runExportCmd(exportEnabled bool, option tuiexport.Option, dashboard dashboa
}
return tuiexport.CompletedMsg{Path: path}
default:
- return tuiexport.FailedMsg{Err: errors.New("unknown export option")}
+ return tuiexport.FailedMsg{Err: fmt.Errorf("unknown export option")}
}
}
}
-func (m *Model) startRecording(path string) error {
- recorder := m.runtime.Recorder()
- if recorder == nil {
- return errors.New("recording runtime is unavailable")
- }
- if err := recorder.Start(path, parquet.StartOptions{Metadata: tuiParquetMetadata()}); err != nil {
- m.syncDashboardFilterState()
- return err
- }
- m.syncDashboardFilterState()
- return nil
-}
-
-func (m *Model) stopRecording() error {
- recorder := m.runtime.Recorder()
- if recorder == nil {
- return nil
- }
- if !m.recordingActive() {
- m.syncDashboardFilterState()
- return nil
- }
- err := recorder.Stop()
- m.syncDashboardFilterState()
- return err
-}
-
-func (m Model) recordingActive() bool {
- recorder := m.runtime.Recorder()
- if recorder == nil {
- return false
- }
- return recorder.Status().Active
-}
-
-func (m Model) recordingStatus() string {
- recorder := m.runtime.Recorder()
- if recorder == nil {
- return "rec: unavailable"
- }
- status := recorder.Status()
- if status.Active {
- return "rec: " + shortenRecordingPath(status.Path)
- }
- if status.LastError != nil {
- return "rec err: " + status.LastError.Error()
- }
- return "rec: off"
-}
-
-func defaultParquetRecordingFilename() string {
- return fmt.Sprintf("ior-recording-%s.parquet", time.Now().Format("20060102-150405"))
-}
-
-// tuiParquetMetadata delegates to the canonical parquet.NewFileMetadata.
-func tuiParquetMetadata() parquet.FileMetadata {
- return parquet.NewFileMetadata("tui")
-}
-
-func shortenRecordingPath(path string) string {
- const maxLen = 36
- if len(path) <= maxLen {
- return path
- }
- return "..." + path[len(path)-maxLen+3:]
-}
-
type lateBoundDashboardSource struct {
runtime *runtimeBindings
}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index b8d6108..f0d4c2f 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -226,7 +226,7 @@ func TestQuitInvokesTraceStop(t *testing.T) {
m.screen = ScreenDashboard
m.attaching = false
done := make(chan struct{})
- m.traceStop = func() {
+ m.tracer.traceStop = func() {
close(done)
}
@@ -291,7 +291,7 @@ func TestQuitKeyOnReselectPIDPickerReturnsToDashboardLikeEsc(t *testing.T) {
if updated.quitting {
t.Fatalf("expected q in reselect picker to behave like esc, not quit")
}
- if updated.proc.pickerReturn != nil {
+ if updated.router.pickerReturn != nil {
t.Fatalf("expected picker return context to clear after cancel")
}
if updated.proc.pid != 1111 || updated.proc.tid != 2222 {
@@ -332,7 +332,7 @@ func TestEscOnReselectPIDPickerReturnsToDashboard(t *testing.T) {
if updated.quitting {
t.Fatalf("expected esc in reselect picker not to quit app")
}
- if updated.proc.pickerReturn != nil {
+ if updated.router.pickerReturn != nil {
t.Fatalf("expected picker return context to clear after cancel")
}
if updated.proc.pid != 3333 || updated.proc.tid != 4444 {
@@ -1073,7 +1073,7 @@ func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) {
m.width = 120
m.height = 30
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
m = next.(Model)
@@ -1090,7 +1090,7 @@ func TestSelectPIDKeyReturnsToFreshPickerAndStopsTrace(t *testing.T) {
if updated.attaching {
t.Fatalf("expected attaching=false on picker screen")
}
- if updated.traceStop != nil {
+ if updated.tracer.traceStop != nil {
t.Fatalf("expected traceStop to be cleared after stopping")
}
if cmd == nil {
@@ -1118,7 +1118,7 @@ func TestSelectTIDKeyReturnsToPickerWhenPIDFilterIsAll(t *testing.T) {
m.height = 30
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
m = next.(Model)
@@ -1146,7 +1146,7 @@ func TestSelectTIDKeyReturnsToPickerWhenSinglePIDSelected(t *testing.T) {
m.height = 30
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'2'}[0], Text: string([]rune{'2'})})
m = next.(Model)
@@ -1245,8 +1245,8 @@ func TestStreamFilterModalConsumesEKeyInsteadOfOpeningExport(t *testing.T) {
if m.exporter.Visible() {
t.Fatalf("expected export modal to remain closed while stream filter modal handles typing")
}
- if m.filter.global.Syscall == nil || m.filter.global.Syscall.Pattern != "ope" {
- t.Fatalf("expected typed syscall filter to be stored globally, got %+v", m.filter.global.Syscall)
+ if m.filters.global.Syscall == nil || m.filters.global.Syscall.Pattern != "ope" {
+ t.Fatalf("expected typed syscall filter to be stored globally, got %+v", m.filters.global.Syscall)
}
}
@@ -1416,7 +1416,7 @@ func TestGlobalFilterModalOpensFromDashboardShortcut(t *testing.T) {
func TestQuitClosesGlobalFilterModalWithoutQuitting(t *testing.T) {
m := NewModel(-1, func(context.Context) error { return nil })
m.screen = ScreenDashboard
- m.filterModal = m.filterModal.Open(m.filter.global)
+ m.filterModal = m.filterModal.Open(m.filters.global)
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: string([]rune{'q'})})
m = next.(Model)
@@ -1437,7 +1437,7 @@ func TestGlobalFilterModalUpdatesStoredFilterState(t *testing.T) {
m.attaching = false
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
m = next.(Model)
@@ -1455,8 +1455,8 @@ func TestGlobalFilterModalUpdatesStoredFilterState(t *testing.T) {
if m.filterModal.Visible() {
t.Fatalf("expected global filter modal to close after esc")
}
- if m.filter.global.Syscall == nil || m.filter.global.Syscall.Pattern != "read" {
- t.Fatalf("expected stored global filter updated from modal, got %+v", m.filter.global.Syscall)
+ if m.filters.global.Syscall == nil || m.filters.global.Syscall.Pattern != "read" {
+ t.Fatalf("expected stored global filter updated from modal, got %+v", m.filters.global.Syscall)
}
if !stopped {
t.Fatalf("expected filter apply to stop the active trace")
@@ -1472,7 +1472,7 @@ func TestGlobalFilterCloseWithoutChangesDoesNotRestartTrace(t *testing.T) {
m.attaching = false
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'f'}[0], Text: string([]rune{'f'})})
m = next.(Model)
@@ -1502,7 +1502,7 @@ func TestPausedStreamEnterAppliesSelectedCellAsGlobalFilter(t *testing.T) {
m.height = 30
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
rb := eventstream.NewRingBuffer()
rb.Push(eventstream.StreamEvent{
@@ -1540,8 +1540,8 @@ func TestPausedStreamEnterAppliesSelectedCellAsGlobalFilter(t *testing.T) {
if cmd == nil {
t.Fatalf("expected applying selected-cell global filter to restart tracing")
}
- if m.filter.global.Comm == nil || m.filter.global.Comm.Pattern != "systemd" {
- t.Fatalf("expected selected comm applied globally, got %+v", m.filter.global.Comm)
+ if m.filters.global.Comm == nil || m.filters.global.Comm.Pattern != "systemd" {
+ t.Fatalf("expected selected comm applied globally, got %+v", m.filters.global.Comm)
}
if !stopped {
t.Fatalf("expected selected-cell global filter to stop the active trace")
@@ -1549,8 +1549,8 @@ func TestPausedStreamEnterAppliesSelectedCellAsGlobalFilter(t *testing.T) {
if !m.attaching {
t.Fatalf("expected selected-cell global filter to restart tracing")
}
- if len(m.filter.stack) != 1 || m.filter.stack[0] != "comm~systemd" {
- t.Fatalf("expected selected-cell action pushed to filter stack, got %+v", m.filter.stack)
+ if len(m.filters.stack) != 1 || m.filters.stack[0] != "comm~systemd" {
+ t.Fatalf("expected selected-cell action pushed to filter stack, got %+v", m.filters.stack)
}
}
@@ -1570,18 +1570,18 @@ func TestGlobalFilterUndoKeyPopsLatestStackEntry(t *testing.T) {
m.attaching = false
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'F'}[0], Text: string([]rune{'F'})})
m = next.(Model)
if cmd == nil {
t.Fatalf("expected F to trigger global filter undo")
}
- if m.filter.global.IsActive() {
- t.Fatalf("expected undo to restore the previous all-filter state, got %+v", m.filter.global)
+ if m.filters.global.IsActive() {
+ t.Fatalf("expected undo to restore the previous all-filter state, got %+v", m.filters.global)
}
- if len(m.filter.stack) != 0 || len(m.filter.history) != 0 {
- t.Fatalf("expected filter stack/history cleared after undo, got stack=%+v history=%d", m.filter.stack, len(m.filter.history))
+ if len(m.filters.stack) != 0 || len(m.filters.history) != 0 {
+ t.Fatalf("expected filter stack/history cleared after undo, got stack=%+v history=%d", m.filters.stack, len(m.filters.history))
}
if !stopped {
t.Fatalf("expected undo to stop the active trace")
@@ -1612,7 +1612,7 @@ func TestPausedStreamEscUndoesLatestGlobalFilter(t *testing.T) {
m.dashboard.SetStreamSource(rb)
m.attaching = false
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
next, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
m = next.(Model)
@@ -1631,11 +1631,11 @@ func TestPausedStreamEscUndoesLatestGlobalFilter(t *testing.T) {
if cmd == nil {
t.Fatalf("expected esc undo to restart tracing")
}
- if m.filter.global.IsActive() {
- t.Fatalf("expected esc undo to restore all-filter state, got %+v", m.filter.global)
+ if m.filters.global.IsActive() {
+ t.Fatalf("expected esc undo to restore all-filter state, got %+v", m.filters.global)
}
- if len(m.filter.stack) != 0 {
- t.Fatalf("expected filter stack cleared after esc undo, got %+v", m.filter.stack)
+ if len(m.filters.stack) != 0 {
+ t.Fatalf("expected filter stack cleared after esc undo, got %+v", m.filters.stack)
}
if !stopped {
t.Fatalf("expected esc undo to stop the active trace")
@@ -1681,7 +1681,7 @@ func TestProcessesTabEnterAppliesSelectedProcessAsGlobalFilter(t *testing.T) {
m.height = 30
stopped := false
- m.traceStop = func() { stopped = true }
+ m.tracer.traceStop = func() { stopped = true }
snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{
{PID: 111, Comm: "alpha", Syscalls: 9},
@@ -1706,11 +1706,11 @@ func TestProcessesTabEnterAppliesSelectedProcessAsGlobalFilter(t *testing.T) {
if cmd == nil {
t.Fatalf("expected selected process filter to restart tracing")
}
- if m.filter.global.PID == nil || m.filter.global.PID.Value != 222 {
- t.Fatalf("expected selected process pid applied globally, got %+v", m.filter.global.PID)
+ if m.filters.global.PID == nil || m.filters.global.PID.Value != 222 {
+ t.Fatalf("expected selected process pid applied globally, got %+v", m.filters.global.PID)
}
- if len(m.filter.stack) != 1 || m.filter.stack[0] != "pid=222" {
- t.Fatalf("expected pid filter pushed to stack, got %+v", m.filter.stack)
+ if len(m.filters.stack) != 1 || m.filters.stack[0] != "pid=222" {
+ t.Fatalf("expected pid filter pushed to stack, got %+v", m.filters.stack)
}
if !stopped {
t.Fatalf("expected selected process filter to stop the active trace")