diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/filterstack.go | 174 | ||||
| -rw-r--r-- | internal/tui/screenrouter.go | 60 | ||||
| -rw-r--r-- | internal/tui/tracelifecycle.go | 172 | ||||
| -rw-r--r-- | internal/tui/tui.go | 639 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 68 |
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") |
