diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-26 23:33:55 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-26 23:33:55 +0200 |
| commit | 4ca34f040203c8e31603bbb39fd38632b68067d8 (patch) | |
| tree | eed81b39e169eb6d0cd7d2eca6b338c7c0914ba4 | |
| parent | e5cb5db2292ae84680935767d455a777125e0fe9 (diff) | |
tui: add paused stream CSV export and foreground editor open
| -rw-r--r-- | internal/ior.go | 18 | ||||
| -rw-r--r-- | internal/ior_mode_test.go | 25 | ||||
| -rw-r--r-- | internal/tui/common/keys.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 40 | ||||
| -rw-r--r-- | internal/tui/eventstream/export.go | 107 | ||||
| -rw-r--r-- | internal/tui/eventstream/export_test.go | 37 | ||||
| -rw-r--r-- | internal/tui/eventstream/exportmodal.go | 105 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 90 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 111 |
9 files changed, 526 insertions, 13 deletions
diff --git a/internal/ior.go b/internal/ior.go index a910fc0..a19d2f7 100644 --- a/internal/ior.go +++ b/internal/ior.go @@ -272,9 +272,17 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con origPrintCb(ep) } } - duration := time.Duration(flags.Get().Duration) * time.Second - logln("Probing for", duration) - ctx, cancel := context.WithTimeout(parentCtx, duration) + cfg := flags.Get() + ctx := parentCtx + cancel := func() {} + if shouldAutoStopByDuration(cfg) { + duration := time.Duration(cfg.Duration) * time.Second + logln("Probing for", duration) + ctx, cancel = context.WithTimeout(parentCtx, duration) + } else { + logln("Probing until stopped...") + ctx, cancel = context.WithCancel(parentCtx) + } defer cancel() signalCh := make(chan os.Signal, 1) @@ -318,3 +326,7 @@ func signalTraceStarted(started chan<- struct{}) { } close(started) } + +func shouldAutoStopByDuration(cfg flags.Flags) bool { + return cfg.PlainMode || cfg.FlamegraphEnable || cfg.PprofEnable +} diff --git a/internal/ior_mode_test.go b/internal/ior_mode_test.go index 84ff651..42b0a48 100644 --- a/internal/ior_mode_test.go +++ b/internal/ior_mode_test.go @@ -36,6 +36,31 @@ func TestShouldRunTraceMode(t *testing.T) { } } +func TestShouldAutoStopByDuration(t *testing.T) { + base := flags.Flags{} + if shouldAutoStopByDuration(base) { + t.Fatalf("expected default TUI mode not to auto-stop by duration") + } + + withPlain := base + withPlain.PlainMode = true + if !shouldAutoStopByDuration(withPlain) { + t.Fatalf("expected plain mode to auto-stop by duration") + } + + withFlamegraph := base + withFlamegraph.FlamegraphEnable = true + if !shouldAutoStopByDuration(withFlamegraph) { + t.Fatalf("expected flamegraph mode to auto-stop by duration") + } + + withPprof := base + withPprof.PprofEnable = true + if !shouldAutoStopByDuration(withPprof) { + t.Fatalf("expected pprof mode to auto-stop by duration") + } +} + func TestDispatchRunUsesTraceModeWhenRequested(t *testing.T) { origRunTrace := runTraceFn origRunTUI := runTUIFn diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 30bc848..f25886b 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -68,6 +68,9 @@ func (k KeyMap) DashboardStatusHelp() []key.Binding { helpTextBinding("h/l", "stream col"), helpTextBinding("j/k", "scroll"), helpTextBinding("up/down", "scroll"), + helpTextBinding("x", "stream export"), + helpTextBinding("X", "stream export as"), + helpTextBinding("E", "stream open last"), ) return bindings } @@ -88,6 +91,9 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { helpTextBinding("h/l", "stream col"), helpTextBinding("j/k", "scroll"), helpTextBinding("up/down", "scroll"), + helpTextBinding("x", "stream export"), + helpTextBinding("X", "stream export as"), + helpTextBinding("E", "stream open last"), }, } } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index fae6a1b..0e485d5 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -23,6 +23,9 @@ type SnapshotSource interface { type refreshTickMsg struct{} type streamTickMsg struct{} +type streamEditorDoneMsg struct { + err error +} // Model is the dashboard tab framework model. type Model struct { @@ -99,6 +102,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyMsg: return m.handleKey(msg) + case streamEditorDoneMsg: + if msg.err != nil { + m.streamModel.SetStatusMessage("Open failed: " + msg.err.Error()) + } + return m, nil } return m, nil } @@ -107,7 +115,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { prevActiveTab := m.activeTab var cmd tea.Cmd keyStr := msg.String() - handled := m.handleScrollKey(msg) + handled, scrollCmd := m.handleScrollKey(msg) + if scrollCmd != nil { + cmd = scrollCmd + } if handled && m.activeTab == TabStream && (keyStr == " " || keyStr == "space") && !m.streamModel.Paused() { cmd = streamTickCmd() } @@ -164,24 +175,35 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } -func (m *Model) handleScrollKey(msg tea.KeyMsg) bool { +func (m *Model) handleScrollKey(msg tea.KeyMsg) (bool, tea.Cmd) { keyStr := msg.String() switch m.activeTab { case TabSyscalls: - return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()) + return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil case TabFiles: if m.filesDirGrouped { - return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRows()) + return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRows()), nil } - return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()) + return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()), nil case TabProcesses: - return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()) + return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()), nil case TabStream: streamWidth, streamHeight := streamViewport(m.width, m.height) m.streamModel.SetViewport(streamWidth, streamHeight) - return m.streamModel.HandleTeaKey(msg) + handled := m.streamModel.HandleTeaKey(msg) + if path, ok := m.streamModel.ConsumeOpenEditorRequest(); ok { + editorCmd, err := eventstream.EditorCommandForPath(path) + if err != nil { + m.streamModel.SetStatusMessage("Open failed: " + err.Error()) + return true, nil + } + return true, tea.ExecProcess(editorCmd, func(err error) tea.Msg { + return streamEditorDoneMsg{err: err} + }) + } + return handled, nil default: - return false + return false, nil } } @@ -245,7 +267,7 @@ func (m Model) LatestSnapshot() *statsengine.Snapshot { // BlocksGlobalShortcuts reports whether modal UI in the active tab should // suppress top-level shortcuts (for example global export key handling). func (m Model) BlocksGlobalShortcuts() bool { - return m.activeTab == TabStream && m.streamModel.FilterModalVisible() + return m.activeTab == TabStream && (m.streamModel.FilterModalVisible() || m.streamModel.ExportModalVisible()) } // SetStreamSource updates the live stream source used by the stream tab. diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go new file mode 100644 index 0000000..679fddb --- /dev/null +++ b/internal/tui/eventstream/export.go @@ -0,0 +1,107 @@ +package eventstream + +import ( + "encoding/csv" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +func defaultStreamExportFilename() string { + return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405")) +} + +func ensureCSVFilename(name string) (string, error) { + clean := strings.TrimSpace(name) + if clean == "" { + return "", errors.New("filename cannot be empty") + } + if strings.HasSuffix(strings.ToLower(clean), ".csv") { + return clean, nil + } + return clean + ".csv", nil +} + +func (m *Model) exportFilteredToCSV(filename string) (string, error) { + name, err := ensureCSVFilename(filename) + if err != nil { + return "", err + } + path := name + if m.exportDir != "" { + path = filepath.Join(m.exportDir, name) + } + + f, err := os.Create(path) + if err != nil { + return "", err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "fd", "ret", "bytes", "file", "error"} + if err := w.Write(header); err != nil { + return "", err + } + for i := range m.filtered { + ev := m.filtered[i] + record := []string{ + fmt.Sprintf("%d", ev.Seq), + fmt.Sprintf("%d", ev.TimeNs), + fmt.Sprintf("%d", ev.GapNs), + fmt.Sprintf("%d", ev.DurationNs), + ev.Comm, + fmt.Sprintf("%d", ev.PID), + fmt.Sprintf("%d", ev.TID), + ev.Syscall, + fmt.Sprintf("%d", ev.FD), + fmt.Sprintf("%d", ev.RetVal), + fmt.Sprintf("%d", ev.Bytes), + ev.FileName, + fmt.Sprintf("%t", ev.IsError), + } + if err := w.Write(record); err != nil { + return "", err + } + } + if err := w.Error(); err != nil { + return "", err + } + absPath, err := filepath.Abs(path) + if err != nil { + return path, nil + } + return absPath, nil +} + +// EditorCommandForPath builds an editor command for the given path. +func EditorCommandForPath(path string) (*exec.Cmd, error) { + parts, _, err := resolveEditorCommand() + if err != nil { + return nil, err + } + args := append(parts[1:], path) + return exec.Command(parts[0], args...), nil +} + +func resolveEditorCommand() ([]string, string, error) { + candidates := []string{"SUDO_EDITOR", "VISUAL", "EDITOR"} + for _, key := range candidates { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + continue + } + parts := strings.Fields(value) + if len(parts) == 0 { + continue + } + return parts, key, nil + } + return []string{"vi"}, "fallback", nil +} diff --git a/internal/tui/eventstream/export_test.go b/internal/tui/eventstream/export_test.go new file mode 100644 index 0000000..e60e932 --- /dev/null +++ b/internal/tui/eventstream/export_test.go @@ -0,0 +1,37 @@ +package eventstream + +import "testing" + +func TestResolveEditorCommandPrefersSudoEditor(t *testing.T) { + t.Setenv("SUDO_EDITOR", "nano") + t.Setenv("VISUAL", "vim") + t.Setenv("EDITOR", "nvim") + + parts, source, err := resolveEditorCommand() + if err != nil { + t.Fatalf("resolve editor: %v", err) + } + if source != "SUDO_EDITOR" { + t.Fatalf("expected SUDO_EDITOR source, got %q", source) + } + if len(parts) != 1 || parts[0] != "nano" { + t.Fatalf("expected nano command, got %#v", parts) + } +} + +func TestResolveEditorCommandFallsBackToVi(t *testing.T) { + t.Setenv("SUDO_EDITOR", "") + t.Setenv("VISUAL", "") + t.Setenv("EDITOR", "") + + parts, source, err := resolveEditorCommand() + if err != nil { + t.Fatalf("resolve editor: %v", err) + } + if source != "fallback" { + t.Fatalf("expected fallback source, got %q", source) + } + if len(parts) != 1 || parts[0] != "vi" { + t.Fatalf("expected vi fallback, got %#v", parts) + } +} diff --git a/internal/tui/eventstream/exportmodal.go b/internal/tui/eventstream/exportmodal.go new file mode 100644 index 0000000..cf020f7 --- /dev/null +++ b/internal/tui/eventstream/exportmodal.go @@ -0,0 +1,105 @@ +package eventstream + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type ExportModal struct { + visible bool + textInput textinput.Model + err string +} + +func NewExportModal() ExportModal { + input := textinput.New() + input.Prompt = "" + input.CharLimit = 0 + input.Width = 44 + return ExportModal{textInput: input} +} + +func (m ExportModal) Visible() bool { + return m.visible +} + +func (m ExportModal) Open(defaultName string) ExportModal { + m.visible = true + m.err = "" + m.textInput.SetValue(defaultName) + m.textInput.CursorEnd() + m.textInput.Focus() + return m +} + +func (m ExportModal) Close() ExportModal { + m.visible = false + m.err = "" + m.textInput.Blur() + return m +} + +// Update returns updated modal, submitted filename, and whether submit occurred. +func (m ExportModal) Update(msg tea.Msg) (ExportModal, string, bool) { + if !m.visible { + return m, "", false + } + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "esc": + return m.Close(), "", false + case "enter": + filename := strings.TrimSpace(m.textInput.Value()) + if filename == "" { + m.err = "filename is required" + return m, "", false + } + return m.Close(), filename, true + } + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + _ = cmd + return m, "", false +} + +func (m ExportModal) 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{ + "Export Stream CSV", + "", + "Filename:", + m.textInput.View(), + } + if m.err != "" { + lines = append(lines, "Error: "+m.err) + } + lines = append(lines, "", "Enter save • 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/eventstream/model.go b/internal/tui/eventstream/model.go index 3258954..7f67702 100644 --- a/internal/tui/eventstream/model.go +++ b/internal/tui/eventstream/model.go @@ -42,6 +42,11 @@ type Model struct { fdTraceView fdTraceViewState filterStack []Filter filterActionStack []string + exportModal ExportModal + lastExportPath string + pendingOpenPath string + statusMessage string + exportDir string width int height int @@ -59,9 +64,11 @@ func NewModel(source *RingBuffer) Model { return Model{ source: source, filterModal: NewFilterModal(), + exportModal: NewExportModal(), autoScroll: true, selectedIdx: -1, selectedCol: 0, + exportDir: ".", } } @@ -87,12 +94,36 @@ func (m Model) FilterModalVisible() bool { return m.filterModal.Visible() } +// ExportModalVisible reports whether the stream export modal is currently open. +func (m Model) ExportModalVisible() bool { + return m.exportModal.Visible() +} + // Paused reports whether stream refresh is currently paused. func (m Model) Paused() bool { return m.paused } func (m *Model) HandleKey(keyStr string) bool { + if m.exportModal.Visible() { + m.statusMessage = "" + var ( + filename string + submit bool + ) + m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr)) + if !submit { + return true + } + path, err := m.exportFilteredToCSV(filename) + if err != nil { + m.statusMessage = fmt.Sprintf("Export failed: %v", err) + return true + } + m.lastExportPath = path + m.statusMessage = "Exported: " + path + return true + } if m.filterModal.Visible() { wasVisible := m.filterModal.Visible() m.filterModal = m.filterModal.Update(keyMsgFromString(keyStr)) @@ -150,6 +181,38 @@ func (m *Model) HandleKey(keyStr string) bool { return m.applyFilterFromSelectedCell() } return false + case "x": + if !m.paused { + return false + } + m.statusMessage = "" + path, err := m.exportFilteredToCSV(defaultStreamExportFilename()) + if err != nil { + m.statusMessage = fmt.Sprintf("Export failed: %v", err) + return true + } + m.lastExportPath = path + m.statusMessage = "Exported: " + path + return true + case "X": + if !m.paused { + return false + } + m.statusMessage = "" + m.exportModal = m.exportModal.Open(defaultStreamExportFilename()) + return true + case "E": + if !m.paused { + return false + } + m.statusMessage = "" + if m.lastExportPath == "" { + m.statusMessage = "No stream export yet" + return true + } + m.pendingOpenPath = m.lastExportPath + m.statusMessage = "Opening in editor: " + m.lastExportPath + return true case " ", "space": m.paused = !m.paused if !m.paused { @@ -308,18 +371,24 @@ func (m *Model) View(width, height int) string { base := RenderStreamTable(width, m.paused, len(m.allEvents), len(m.filtered), bufferLen, ringBufferCapacity, m.filter, visible, selectedVisibleIdx, selectedCol) status := fmt.Sprintf("Row %d/%d | space:pause f:filter G:tail g:top c:clear j/k:scroll", rowNumber(start, len(m.filtered)), len(m.filtered)) if m.paused && m.selectedIdx >= 0 { - status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:add-filter esc:undo(%d) space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) + status = fmt.Sprintf("Row %d/%d | Sel %d/%d Col %d/%d | enter:add-filter esc:undo(%d) x:export X:export-as E:open-last space:pause f:filter G:tail g:top c:clear j/k:row h/l:col", rowNumber(start, len(m.filtered)), len(m.filtered), rowNumber(m.selectedIdx, len(m.filtered)), len(m.filtered), m.selectedCol+1, streamColumnCount, len(m.filterStack)) } out := base + "\n" + status if len(m.filterActionStack) > 0 { out += "\n" + "Stack: " + strings.Join(m.filterActionStack, " | ") } + if m.statusMessage != "" { + out += "\n" + m.statusMessage + } if m.filterModal.Visible() { // While editing filters, show a dedicated modal screen to avoid // visual mixing with the live stream table underneath. return m.filterModal.View(width, height) } + if m.exportModal.Visible() { + return m.exportModal.View(width, height) + } return out } @@ -720,6 +789,25 @@ func (m *Model) setFilterForTest(f Filter) { m.filter = f } +func (m *Model) setExportDirForTest(dir string) { + m.exportDir = dir +} + +// ConsumeOpenEditorRequest returns the pending editor-open path once. +func (m *Model) ConsumeOpenEditorRequest() (string, bool) { + if m.pendingOpenPath == "" { + return "", false + } + path := m.pendingOpenPath + m.pendingOpenPath = "" + return path, true +} + +// SetStatusMessage updates the stream footer status line. +func (m *Model) SetStatusMessage(message string) { + m.statusMessage = message +} + func (m *Model) dumpVisibleForTest() string { rows := make([]string, 0, len(m.filtered)) for _, ev := range m.filtered { diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go index d55da61..74bccb6 100644 --- a/internal/tui/eventstream/model_test.go +++ b/internal/tui/eventstream/model_test.go @@ -1,6 +1,8 @@ package eventstream import ( + "encoding/csv" + "os" "strings" "testing" ) @@ -583,3 +585,112 @@ func TestPausedEnterCanFilterByFDColumn(t *testing.T) { t.Fatalf("expected 3 rows with fd=3, got %d", len(m.filtered)) } } + +func TestPausedQuickExportWritesFilteredRows(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, Comm: "firefox", PID: 10, TID: 100, Syscall: "read", FileName: "/a"}) + rb.Push(StreamEvent{Seq: 2, Comm: "bash", PID: 11, TID: 200, Syscall: "write", FileName: "/b"}) + rb.Push(StreamEvent{Seq: 3, Comm: "firefox", PID: 12, TID: 300, Syscall: "open", FileName: "/c"}) + + m := NewModel(rb) + m.height = 20 + m.setExportDirForTest(t.TempDir()) + m.Refresh() + if !m.HandleKey("space") { + t.Fatalf("space should pause") + } + + m.selectedIdx = 0 + m.selectedCol = streamColComm + if !m.HandleKey("enter") { + t.Fatalf("enter should apply comm filter") + } + if len(m.filtered) != 2 { + t.Fatalf("expected 2 filtered rows before export, got %d", len(m.filtered)) + } + + if !m.HandleKey("x") { + t.Fatalf("x should quick-export while paused") + } + if m.lastExportPath == "" { + t.Fatalf("expected last export path to be set") + } + records := readCSVRecords(t, m.lastExportPath) + if len(records) != 3 { + t.Fatalf("expected header + 2 rows in export, got %d records", len(records)) + } + if records[1][4] != "firefox" || records[2][4] != "firefox" { + t.Fatalf("expected only firefox rows exported, got %q and %q", records[1][4], records[2][4]) + } +} + +func TestPausedExportAsModalSavesWithProvidedFilename(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, Comm: "proc", PID: 1, TID: 1, Syscall: "read"}) + m := NewModel(rb) + m.height = 20 + m.setExportDirForTest(t.TempDir()) + m.Refresh() + _ = m.HandleKey("space") + + if !m.HandleKey("X") { + t.Fatalf("X should open export modal while paused") + } + if !m.exportModal.Visible() { + t.Fatalf("expected export modal visible") + } + // Replace default value fully and submit. + m.exportModal = m.exportModal.Open("custom-name") + if !m.HandleKey("enter") { + t.Fatalf("enter should submit export modal") + } + if m.exportModal.Visible() { + t.Fatalf("expected export modal closed after submit") + } + if !strings.HasSuffix(m.lastExportPath, "custom-name.csv") { + t.Fatalf("expected custom-name.csv export path, got %q", m.lastExportPath) + } + if _, err := os.Stat(m.lastExportPath); err != nil { + t.Fatalf("expected exported file to exist: %v", err) + } +} + +func TestPausedOpenLastExportQueuesRequest(t *testing.T) { + rb := NewRingBuffer() + rb.Push(StreamEvent{Seq: 1, Comm: "proc", PID: 1, TID: 1, Syscall: "read"}) + m := NewModel(rb) + m.height = 20 + m.setExportDirForTest(t.TempDir()) + m.Refresh() + _ = m.HandleKey("space") + _ = m.HandleKey("x") + + if !m.HandleKey("E") { + t.Fatalf("E should queue opening last export while paused") + } + path, ok := m.ConsumeOpenEditorRequest() + if !ok { + t.Fatalf("expected queued open-editor request") + } + if path != m.lastExportPath { + t.Fatalf("expected opened path %q, got %q", m.lastExportPath, path) + } + if _, ok := m.ConsumeOpenEditorRequest(); ok { + t.Fatalf("expected request to be consumed once") + } +} + +func readCSVRecords(t *testing.T, path string) [][]string { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatalf("open csv: %v", err) + } + defer f.Close() + r := csv.NewReader(f) + records, err := r.ReadAll() + if err != nil { + t.Fatalf("read csv: %v", err) + } + return records +} |
