summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-09 22:54:11 +0200
committerPaul Buetow <paul@buetow.org>2026-03-09 22:54:11 +0200
commitbcaa22111ac619e317f7adfd60a1fc6bd4db8d29 (patch)
treecef38740e879472b57961f2ddc9694773b202e2c /internal/tui
parenteb53d7c881b6b8a513c1350736c5f5df770e4089 (diff)
tui: export filtered stream rows from global CSV action (task 364)
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/common/keys.go2
-rw-r--r--internal/tui/dashboard/model.go5
-rw-r--r--internal/tui/eventstream/export.go55
-rw-r--r--internal/tui/export/doc.go2
-rw-r--r--internal/tui/export/model.go4
-rw-r--r--internal/tui/tui.go9
-rw-r--r--internal/tui/tui_test.go49
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")
}
}