diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-09 22:54:11 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-09 22:54:11 +0200 |
| commit | bcaa22111ac619e317f7adfd60a1fc6bd4db8d29 (patch) | |
| tree | cef38740e879472b57961f2ddc9694773b202e2c /internal/tui | |
| parent | eb53d7c881b6b8a513c1350736c5f5df770e4089 (diff) | |
tui: export filtered stream rows from global CSV action (task 364)
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/keys.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 5 | ||||
| -rw-r--r-- | internal/tui/eventstream/export.go | 55 | ||||
| -rw-r--r-- | internal/tui/export/doc.go | 2 | ||||
| -rw-r--r-- | internal/tui/export/model.go | 4 | ||||
| -rw-r--r-- | internal/tui/tui.go | 9 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 49 |
7 files changed, 99 insertions, 27 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 3f3d807..87edec4 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -59,7 +59,7 @@ func DefaultKeyMap() KeyMap { Probes: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "probes")), Filter: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "filter")), FilterUndo: key.NewBinding(key.WithKeys("F"), key.WithHelp("F", "undo filter")), - Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "snapshot export")), + Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "stream export")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index f551d8f..a35e473 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -951,6 +951,11 @@ func (m Model) ActiveTab() Tab { return m.activeTab } +// ExportStreamCSV exports a fresh filtered snapshot of the stream ringbuffer. +func (m Model) ExportStreamCSV() (string, error) { + return m.streamModel.ExportSnapshotToCSV("") +} + // BlocksGlobalShortcuts reports whether the active tab should suppress a // top-level shortcut for the given key press. func (m Model) BlocksGlobalShortcuts(msg tea.KeyPressMsg) bool { diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go index 3924935..b9ddddc 100644 --- a/internal/tui/eventstream/export.go +++ b/internal/tui/eventstream/export.go @@ -15,25 +15,35 @@ 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") +func exportSnapshotToCSV(source Source, filter Filter, exportDir, filename string) (string, error) { + name := strings.TrimSpace(filename) + if name == "" { + name = defaultStreamExportFilename() } - if strings.HasSuffix(strings.ToLower(clean), ".csv") { - return clean, nil + + rows := make([]StreamEvent, 0) + if source != nil { + snapshot := source.Snapshot() + rows = make([]StreamEvent, 0, len(snapshot)) + for i := range snapshot { + ev := snapshot[i] + if filter.Matches(&ev) { + rows = append(rows, ev) + } + } } - return clean + ".csv", nil + + return exportRowsToCSV(rows, exportDir, name) } -func (m *Model) exportFilteredToCSV(filename string) (string, error) { +func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, error) { name, err := ensureCSVFilename(filename) if err != nil { return "", err } path := name - if m.exportDir != "" { - path = filepath.Join(m.exportDir, name) + if exportDir != "" { + path = filepath.Join(exportDir, name) } f, err := os.Create(path) @@ -61,8 +71,8 @@ func (m *Model) exportFilteredToCSV(filename string) (string, error) { if err := w.Write(header); err != nil { return fail(err) } - for i := range m.filtered { - ev := m.filtered[i] + for i := range rows { + ev := rows[i] record := []string{ fmt.Sprintf("%d", ev.Seq), fmt.Sprintf("%d", ev.TimeNs), @@ -96,6 +106,27 @@ func (m *Model) exportFilteredToCSV(filename string) (string, error) { return absPath, nil } +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 +} + +// ExportSnapshotToCSV exports a fresh filtered snapshot from the current source +// without mutating the model's paused/live view state. +func (m Model) ExportSnapshotToCSV(filename string) (string, error) { + return exportSnapshotToCSV(m.source, m.filter, m.exportDir, filename) +} + +func (m *Model) exportFilteredToCSV(filename string) (string, error) { + return exportRowsToCSV(m.filtered, m.exportDir, filename) +} + // EditorCommandForPath builds an editor command for the given path. func EditorCommandForPath(path string) (*exec.Cmd, error) { parts, _, err := resolveEditorCommand() diff --git a/internal/tui/export/doc.go b/internal/tui/export/doc.go index 356b800..5be50d7 100644 --- a/internal/tui/export/doc.go +++ b/internal/tui/export/doc.go @@ -1,2 +1,2 @@ -// Package export implements the TUI snapshot export modal and option handling. +// Package export implements the top-level TUI stream export modal and option handling. package export diff --git a/internal/tui/export/model.go b/internal/tui/export/model.go index 179754d..7cef65a 100644 --- a/internal/tui/export/model.go +++ b/internal/tui/export/model.go @@ -18,7 +18,7 @@ const ( ) var optionLabels = []string{ - "CSV snapshot", + "CSV stream rows", "Cancel", } @@ -147,7 +147,7 @@ func (m Model) View(width, height int) string { } } - lines := []string{"Export"} + lines := []string{"Export Stream CSV"} for i, label := range optionLabels { prefix := " " if i == m.selected && !m.exporting { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index fcdede9..af44b11 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -10,7 +10,6 @@ import ( "sync" "time" - coreexport "ior/internal/export" "ior/internal/flags" "ior/internal/globalfilter" "ior/internal/probemanager" @@ -399,7 +398,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return next, cmd } case tuiexport.RequestMsg: - return m, runExportCmd(m.exportEnabled, msg.Option, m.dashboard.LatestSnapshot()) + return m, runExportCmd(m.exportEnabled, msg.Option, m.dashboard) case tuiexport.CompletedMsg: var cmd tea.Cmd m.exporter, cmd = m.exporter.Update(msg) @@ -1123,14 +1122,14 @@ func isHelpOverlayQuitKey(msg tea.KeyPressMsg) bool { return msg.String() == "q" } -func runExportCmd(exportEnabled bool, option tuiexport.Option, snap *statsengine.Snapshot) tea.Cmd { +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")} } switch option { case tuiexport.OptionCSV: - path, err := coreexport.SnapshotCSV(snap) + path, err := dashboard.ExportStreamCSV() if err != nil { return tuiexport.FailedMsg{Err: err} } @@ -1211,7 +1210,7 @@ func (m Model) helpSections() []helpSection { "f filter p pid picker t tid picker o probes", } if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" { - globalLines[1] += " e snapshot export" + globalLines[1] += " e stream export" } return []helpSection{ diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 8e80860..ea2c6b0 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -2,9 +2,9 @@ package tui import ( "context" + "encoding/csv" "errors" "os" - "path/filepath" "regexp" "strings" "testing" @@ -997,7 +997,7 @@ func TestStreamFilterModalConsumesEKeyInsteadOfOpeningExport(t *testing.T) { } } -func TestRunExportCmdCSVWritesFile(t *testing.T) { +func TestRunExportCmdCSVWritesFilteredStreamSnapshot(t *testing.T) { dir := t.TempDir() prev, err := os.Getwd() if err != nil { @@ -1008,8 +1008,27 @@ func TestRunExportCmdCSVWritesFile(t *testing.T) { } t.Cleanup(func() { _ = os.Chdir(prev) }) - snap := &statsengine.Snapshot{TotalSyscalls: 1} - msg := runExportCmd(true, tuiexport.OptionCSV, snap)() + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + + buffer := m.runtime.StreamBuffer() + buffer.Push(eventstream.StreamEvent{Seq: 1, Comm: "firefox", PID: 10, TID: 100, Syscall: "read", FileName: "/tmp/a"}) + buffer.Push(eventstream.StreamEvent{Seq: 2, Comm: "bash", PID: 11, TID: 110, Syscall: "write", FileName: "/tmp/b"}) + m.setGlobalFilter(globalfilter.Filter{Comm: &globalfilter.StringFilter{Pattern: "firefox"}}) + + next, _ := m.Update(messages.StatsTickMsg{Snap: &statsengine.Snapshot{}}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: []rune{'7'}[0], Text: "7"}) + m = next.(Model) + next, _ = m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}) + m = next.(Model) + + buffer.Push(eventstream.StreamEvent{Seq: 3, Comm: "firefox", PID: 12, TID: 120, Syscall: "open", FileName: "/tmp/c"}) + next, _ = m.Update(messages.StatsTickMsg{Snap: &statsengine.Snapshot{}}) + m = next.(Model) + + msg := runExportCmd(true, tuiexport.OptionCSV, m.dashboard)() done, ok := msg.(tuiexport.CompletedMsg) if !ok { t.Fatalf("expected CompletedMsg, got %T", msg) @@ -1017,9 +1036,27 @@ func TestRunExportCmdCSVWritesFile(t *testing.T) { if done.Path == "" { t.Fatalf("expected export path") } - if _, err := os.Stat(filepath.Join(dir, done.Path)); err != nil { + if _, err := os.Stat(done.Path); err != nil { t.Fatalf("expected CSV file to exist: %v", err) } + f, err := os.Open(done.Path) + if err != nil { + t.Fatalf("open csv: %v", err) + } + t.Cleanup(func() { _ = f.Close() }) + records, err := csv.NewReader(f).ReadAll() + if err != nil { + t.Fatalf("read csv: %v", err) + } + if len(records) != 3 { + t.Fatalf("expected header + 2 filtered rows, got %d records", len(records)) + } + if records[1][0] != "1" || records[2][0] != "3" { + t.Fatalf("expected fresh filtered stream snapshot rows 1 and 3, got %v", records[1:]) + } + if records[1][4] != "firefox" || records[2][4] != "firefox" { + t.Fatalf("expected firefox rows only, got %v", records[1:]) + } } func TestHelpKeyDoesNotToggleOverlay(t *testing.T) { @@ -1656,7 +1693,7 @@ func TestStatusBarHidesExportBindingWhenExportDisabled(t *testing.T) { m.height = 30 out := m.View().Content - if strings.Contains(out, "e snapshot export") { + if strings.Contains(out, "e stream export") { t.Fatalf("did not expect export shortcut in status bar when export is disabled") } } |
