diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-13 07:23:24 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-13 07:23:24 +0200 |
| commit | a83156ac084fdaf97e6cefc76ea3ddfd3f6a993a (patch) | |
| tree | 6f586234e616d57ed0a938c98fa3b0aef9e20f6b /internal/tui/tui.go | |
| parent | c9d22e32dc9d8d0447beb4ffa78f47a03d0cddc4 (diff) | |
feat: add tui parquet recording controls
Diffstat (limited to 'internal/tui/tui.go')
| -rw-r--r-- | internal/tui/tui.go | 149 |
1 files changed, 148 insertions, 1 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 29d1fc8..ef94748 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "os" "strconv" "strings" "sync" @@ -270,6 +271,7 @@ type Model struct { exporter tuiexport.Model probeModal probes.Model filterModal tracefilterui.Model + recordModal recordingModal runtime *runtimeBindings keys KeyMap @@ -359,6 +361,7 @@ func newModelWithRuntimeConfig(initialPID int, startupFilter globalfilter.Filter exporter: tuiexport.NewModel(), probeModal: probes.NewModel(runtime.currentProbeManager()).SetDarkMode(true), filterModal: tracefilterui.NewModel().SetDarkMode(true), + recordModal: newRecordingModal().SetDarkMode(true), runtime: runtime, keys: keys, spin: spin, @@ -460,6 +463,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.dashboard.SetStreamSource(m.runtime.eventStreamSource()) m.dashboard.SetLiveTrie(m.runtime.liveTrie()) m.dashboard.SetGlobalFilter(m.globalFilter) + 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) @@ -491,6 +495,7 @@ func (m Model) canHandleDashboardShortcut(msg tea.KeyPressMsg) bool { m.lastErr == nil && !m.filterModal.Visible() && !m.exporter.Visible() && + !m.recordModal.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) } @@ -501,6 +506,7 @@ func (m Model) canQuitFromMainDashboard(msg tea.KeyPressMsg) bool { m.lastErr == nil && !m.filterModal.Visible() && !m.exporter.Visible() && + !m.recordModal.Visible() && !m.probeModal.Visible() && !m.dashboard.BlocksGlobalShortcuts(msg) } @@ -516,7 +522,7 @@ func (m Model) shouldRouteQuitToEsc(msg tea.KeyPressMsg) bool { return false } return m.screen == ScreenDashboard && - (m.filterModal.Visible() || m.exporter.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg)) + (m.filterModal.Visible() || m.exporter.Visible() || m.recordModal.Visible() || m.probeModal.Visible() || m.dashboard.BlocksGlobalShortcuts(msg)) } func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bool) { @@ -532,6 +538,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo } if key.Matches(msg, m.keys.Quit) { if m.canQuitFromMainDashboard(msg) { + if err := m.stopRecording(); err != nil { + m.lastErr = err + return m, nil, true + } m.quitting = true m.stopTrace() return m, tea.Quit, true @@ -546,6 +556,10 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo next, cmd := m.updateFilterModal(esc) return next, cmd, true } + if m.recordModal.Visible() { + next, cmd := m.updateRecordModal(esc) + return next, cmd, true + } if m.exporter.Visible() { next, cmd := m.updateExportModal(esc) return next, cmd, true @@ -564,6 +578,16 @@ func (m Model) handleGlobalKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd, bo m.exporter = m.exporter.Open() return m, nil, true } + if m.canHandleDashboardShortcut(msg) && 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 + } if m.canHandleDashboardShortcut(msg) && key.Matches(msg, m.keys.Probes) { m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark).Open() return m, nil, true @@ -621,6 +645,24 @@ func (m Model) updateExportModal(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(dashboardCmd, cmd) } +func (m Model) updateRecordModal(msg tea.Msg) (tea.Model, tea.Cmd) { + m, dashboardCmd := m.updateDashboardForModal(msg) + var ( + path string + submit bool + ) + m.recordModal, path, submit = m.recordModal.Update(msg) + if !submit { + return m, dashboardCmd + } + if err := m.startRecording(path); err != nil { + m.recordModal = m.recordModal.SetError(err) + return m, dashboardCmd + } + m.recordModal = m.recordModal.Close() + return m, dashboardCmd +} + func (m Model) handleModalDispatch(msg tea.Msg) (tea.Model, tea.Cmd, bool) { if m.attaching { var cmd tea.Cmd @@ -631,6 +673,10 @@ func (m Model) handleModalDispatch(msg tea.Msg) (tea.Model, tea.Cmd, bool) { next, cmd := m.updateFilterModal(msg) return next, cmd, true } + if m.recordModal.Visible() { + next, cmd := m.updateRecordModal(msg) + return next, cmd, true + } if m.probeModal.Visible() { next, cmd := m.updateProbeModal(msg) return next, cmd, true @@ -659,6 +705,10 @@ func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) { pid := selectedPIDFilter(msg.Pid) + if err := m.stopRecording(); err != nil { + m.lastErr = err + return m, nil + } m.stopTrace() m.runtime.resetStreamBuffer() m.setProcessFilters(pid, -1) @@ -675,6 +725,10 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) { if msg.Pid > 0 { pid = msg.Pid } + if err := m.stopRecording(); err != nil { + m.lastErr = err + return m, nil + } m.stopTrace() m.runtime.resetStreamBuffer() m.setProcessFilters(pid, tid) @@ -686,6 +740,10 @@ func (m Model) handleTidSelected(msg TidSelectedMsg) (tea.Model, tea.Cmd) { } func (m Model) reselectPID() (tea.Model, tea.Cmd) { + if err := m.stopRecording(); err != nil { + m.lastErr = err + return m, nil + } m.pickerReturn = &pickerReturnState{ pidFilter: m.pidFilter, tidFilter: m.tidFilter, @@ -697,6 +755,7 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { m.exporter = tuiexport.NewModel() m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark) m.filterModal = tracefilterui.NewModel().SetDarkMode(m.isDark) + m.recordModal = newRecordingModal().SetDarkMode(m.isDark) m.pidPicker = pidpicker.New().SetDarkMode(m.isDark) var sizeCmd tea.Cmd @@ -712,6 +771,10 @@ func (m Model) reselectPID() (tea.Model, tea.Cmd) { func (m Model) reselectTID() (tea.Model, tea.Cmd) { pid := m.pidFilter + if err := m.stopRecording(); err != nil { + m.lastErr = err + return m, nil + } m.pickerReturn = &pickerReturnState{ pidFilter: m.pidFilter, tidFilter: m.tidFilter, @@ -723,6 +786,7 @@ func (m Model) reselectTID() (tea.Model, tea.Cmd) { m.exporter = tuiexport.NewModel() m.probeModal = probes.NewModel(m.runtime.currentProbeManager()).SetDarkMode(m.isDark) 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 @@ -746,6 +810,10 @@ func (m Model) cancelPickerToDashboard() (tea.Model, tea.Cmd) { if m.pickerReturn == nil { return m, nil } + if err := m.stopRecording(); err != nil { + m.lastErr = err + return m, nil + } returnState := *m.pickerReturn m.pickerReturn = nil m.stopTrace() @@ -830,6 +898,7 @@ func (m *Model) syncDashboardFilterState() { m.dashboard.SetPidFilter(m.pidFilter) m.dashboard.SetGlobalFilter(m.globalFilter) m.dashboard.SetFilterStack(m.filterStack) + m.dashboard.SetRecordingStatus(m.recordingStatus()) } func applyProcessFilters(filter globalfilter.Filter, pid, tid int) globalfilter.Filter { @@ -975,6 +1044,7 @@ func (m *Model) applyTheme(isDark bool) { m.pidPicker = m.pidPicker.SetDarkMode(isDark) m.probeModal = m.probeModal.SetDarkMode(isDark) m.filterModal = m.filterModal.SetDarkMode(isDark) + m.recordModal = m.recordModal.SetDarkMode(isDark) } func (m Model) windowTitle() string { @@ -1023,6 +1093,9 @@ func (m Model) View() tea.View { 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) } @@ -1069,6 +1142,80 @@ func runExportCmd(exportEnabled bool, option tuiexport.Option, dashboard dashboa } } +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")) +} + +func tuiParquetMetadata() parquet.FileMetadata { + meta := parquet.FileMetadata{ + StartedAtUnixNano: uint64(time.Now().UnixNano()), + Mode: "tui", + IORVersion: flags.Version, + } + if hostname, err := os.Hostname(); err == nil { + meta.Hostname = hostname + } + return meta +} + +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 } |
