summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
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")