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 | |
| parent | c9d22e32dc9d8d0447beb4ffa78f47a03d0cddc4 (diff) | |
feat: add tui parquet recording controls
| -rw-r--r-- | internal/tui/common/keys.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 15 | ||||
| -rw-r--r-- | internal/tui/help.go | 4 | ||||
| -rw-r--r-- | internal/tui/recordingmodal.go | 122 | ||||
| -rw-r--r-- | internal/tui/tui.go | 149 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 101 |
6 files changed, 391 insertions, 5 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index bdfa5e2..1dd2833 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -30,6 +30,7 @@ type KeyMap struct { Filter key.Binding FilterUndo key.Binding Export key.Binding + Record key.Binding Quit key.Binding Enter key.Binding Esc key.Binding @@ -66,6 +67,7 @@ func DefaultKeyMap() KeyMap { Filter: keyBinding("filter", "f"), FilterUndo: keyBinding("undo filter", "F"), Export: keyBinding("stream export", "e"), + Record: keyBinding("parquet rec", "R"), Quit: keyBinding("quit", "q", "ctrl+c"), Enter: keyBinding("select", "enter"), Esc: keyBinding("back", "esc"), @@ -109,6 +111,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection { k.SelectPID, k.SelectTID, k.Probes, + k.Record, k.Refresh, k.Quit, } @@ -149,7 +152,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { if help := k.Export.Help(); help.Key != "" || help.Desc != "" { controls = append(controls, k.Export) } - controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit) + controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Record, k.Refresh, k.Quit) controls = append(controls, k.Visualize, k.Metric, k.Sort, k.ReverseSort, k.Filter, k.FilterUndo) return [][]key.Binding{ diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 72290e6..850a483 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -68,6 +68,7 @@ type Model struct { keys common.KeyMap globalFilter globalfilter.Filter filterStack []string + recordingStatus string pidFilter int syscallsOffset int syscallsCol int @@ -936,6 +937,11 @@ func (m *Model) SetFilterStack(stack []string) { m.streamModel.SetFilterStack(stack) } +// SetRecordingStatus updates the visible recording state summary rendered in the dashboard chrome. +func (m *Model) SetRecordingStatus(status string) { + m.recordingStatus = status +} + // SetLiveTrie updates the live trie source used by the flamegraph tab. func (m *Model) SetLiveTrie(liveTrie flamegraphtui.LiveTrieSource) { m.liveTrie = liveTrie @@ -1012,9 +1018,16 @@ func (m Model) View() tea.View { func (m Model) filterSummary() string { summary := "filter: " + m.globalFilter.Summary() if len(m.filterStack) == 0 { + if m.recordingStatus == "" { + return summary + } + return summary + " | " + m.recordingStatus + } + summary += " | stack: " + strings.Join(m.filterStack, " | ") + if m.recordingStatus == "" { return summary } - return summary + " | stack: " + strings.Join(m.filterStack, " | ") + return summary + " | " + m.recordingStatus } func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model, flameModel *flamegraphtui.Model) string { diff --git a/internal/tui/help.go b/internal/tui/help.go index db8d9b2..d63c198 100644 --- a/internal/tui/help.go +++ b/internal/tui/help.go @@ -49,7 +49,7 @@ type helpSection struct { func (m Model) helpSections() []helpSection { globalLines := []string{ "H help esc/? close help q quit", - "f filter p pid picker t tid picker o probes", + "f filter p pid picker t tid picker o probes R parquet rec", } if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { globalLines[1] += " e stream export" @@ -63,7 +63,7 @@ func (m Model) helpSections() []helpSection { { title: "Dashboard Tabs", lines: []string{ - "tab/shift+tab tabs 1..7 jump tab r reset baseline", + "tab/shift+tab tabs 1..7 jump tab r reset baseline R parquet rec", "sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom", "sys/files/proc tables: s sort S reverse sort", "sys/proc: v bubbles b metric events/bytes", diff --git a/internal/tui/recordingmodal.go b/internal/tui/recordingmodal.go new file mode 100644 index 0000000..eff23fc --- /dev/null +++ b/internal/tui/recordingmodal.go @@ -0,0 +1,122 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +type recordingModal struct { + visible bool + textInput textinput.Model + err string +} + +func newRecordingModal() recordingModal { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.SetWidth(44) + input.SetStyles(textinput.DefaultStyles(true)) + return recordingModal{textInput: input} +} + +func (m recordingModal) Visible() bool { + return m.visible +} + +func (m recordingModal) SetDarkMode(isDark bool) recordingModal { + m.textInput.SetStyles(textinput.DefaultStyles(isDark)) + return m +} + +func (m recordingModal) Open(defaultPath string) recordingModal { + m.visible = true + m.err = "" + m.textInput.SetValue(defaultPath) + m.textInput.CursorEnd() + m.textInput.Focus() + return m +} + +func (m recordingModal) Close() recordingModal { + m.visible = false + m.err = "" + m.textInput.Blur() + return m +} + +func (m recordingModal) SetError(err error) recordingModal { + if err == nil { + m.err = "" + return m + } + m.err = err.Error() + m.visible = true + m.textInput.Focus() + return m +} + +func (m recordingModal) Update(msg tea.Msg) (recordingModal, string, bool) { + if !m.visible { + return m, "", false + } + if keyMsg, ok := msg.(tea.KeyPressMsg); ok { + switch keyMsg.String() { + case "esc": + return m.Close(), "", false + case "enter": + path := strings.TrimSpace(m.textInput.Value()) + if path == "" { + m.err = "filename is required" + return m, "", false + } + return m, path, true + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + return m, "", false +} + +func (m recordingModal) View(width, height int) string { + if !m.visible { + return "" + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + + modalWidth := 74 + if width < modalWidth+4 { + modalWidth = width - 4 + if modalWidth < 44 { + modalWidth = 44 + } + } + + lines := []string{ + "Start Parquet Recording", + "", + "Filename:", + m.textInput.View(), + } + if m.err != "" { + lines = append(lines, "Error: "+m.err) + } + lines = append(lines, "", "Enter start • Esc cancel") + + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(1, 2). + Width(modalWidth). + Render(strings.Join(lines, "\n")) + + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box) +} 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 } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 70e2b5b..70552fb 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -5,6 +5,7 @@ import ( "encoding/csv" "errors" "os" + "path/filepath" "regexp" "strings" "testing" @@ -709,6 +710,106 @@ func TestExportKeyOpensModalOnDashboard(t *testing.T) { } } +func TestRecordKeyOpensRecordingModalOnDashboard(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'R'}[0], Text: "R"}) + updated := next.(Model) + if !updated.recordModal.Visible() { + t.Fatalf("expected recording modal to open on R key") + } +} + +func TestStartRecordingUpdatesDashboardStatus(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.width = 120 + m.height = 30 + + path := filepath.Join(t.TempDir(), "capture.parquet") + if err := m.startRecording(path); err != nil { + t.Fatalf("startRecording() error = %v", err) + } + t.Cleanup(func() { + if err := m.stopRecording(); err != nil { + t.Fatalf("stopRecording() cleanup error = %v", err) + } + }) + + status := m.runtime.Recorder().Status() + if !status.Active { + t.Fatalf("expected recorder to be active after startRecording()") + } + + view := m.View().Content + if !strings.Contains(view, "rec:") || !strings.Contains(view, "capture") { + t.Fatalf("expected dashboard view to show recording status, got %q", view) + } +} + +func TestRecordKeyStopsActiveRecording(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + path := filepath.Join(t.TempDir(), "capture.parquet") + if err := m.startRecording(path); err != nil { + t.Fatalf("startRecording() error = %v", err) + } + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'R'}[0], Text: "R"}) + updated := next.(Model) + if updated.runtime.Recorder().Status().Active { + t.Fatalf("expected R key to stop active recording") + } +} + +func TestQuitStopsActiveRecording(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + path := filepath.Join(t.TempDir(), "capture.parquet") + if err := m.startRecording(path); err != nil { + t.Fatalf("startRecording() error = %v", err) + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'q'}[0], Text: "q"}) + updated := next.(Model) + if cmd == nil { + t.Fatalf("expected quit command") + } + if updated.runtime.Recorder().Status().Active { + t.Fatalf("expected quit to stop active recording") + } +} + +func TestSelectPIDStopsActiveRecording(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + path := filepath.Join(t.TempDir(), "capture.parquet") + if err := m.startRecording(path); err != nil { + t.Fatalf("startRecording() error = %v", err) + } + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'p'}[0], Text: "p"}) + updated := next.(Model) + if cmd == nil { + t.Fatalf("expected picker init command") + } + if updated.screen != ScreenPIDPicker { + t.Fatalf("expected p to switch to pid picker, got %v", updated.screen) + } + if updated.runtime.Recorder().Status().Active { + t.Fatalf("expected pid reselect to stop active recording") + } +} + func TestFlamePauseKeyDoesNotTriggerPIDReselect(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard |
