diff options
Diffstat (limited to 'internal/tui/tracelifecycle.go')
| -rw-r--r-- | internal/tui/tracelifecycle.go | 172 |
1 files changed, 172 insertions, 0 deletions
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] +} |
