summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/export/model.go173
-rw-r--r--internal/tui/export/model_test.go72
-rw-r--r--internal/tui/tui.go121
-rw-r--r--internal/tui/tui_test.go40
4 files changed, 404 insertions, 2 deletions
diff --git a/internal/tui/export/model.go b/internal/tui/export/model.go
new file mode 100644
index 0000000..1e875e5
--- /dev/null
+++ b/internal/tui/export/model.go
@@ -0,0 +1,173 @@
+package export
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Option is a selectable export target.
+type Option int
+
+const (
+ OptionFlamegraph Option = iota
+ OptionCSV
+ OptionCancel
+)
+
+var optionLabels = []string{
+ "CSV snapshot",
+ "Cancel",
+}
+
+var optionValues = []Option{
+ OptionCSV,
+ OptionCancel,
+}
+
+// RequestMsg asks the parent model to perform an export.
+type RequestMsg struct {
+ Option Option
+}
+
+// CompletedMsg reports a finished export with output path.
+type CompletedMsg struct {
+ Path string
+}
+
+// FailedMsg reports an export error.
+type FailedMsg struct {
+ Err error
+}
+
+// Model is the export modal state machine.
+type Model struct {
+ visible bool
+ selected int
+ exporting bool
+ status string
+}
+
+// NewModel creates a closed export modal.
+func NewModel() Model {
+ return Model{}
+}
+
+func (m Model) Visible() bool { return m.visible }
+
+func (m Model) Open() Model {
+ m.visible = true
+ m.selected = 0
+ m.exporting = false
+ m.status = ""
+ return m
+}
+
+func (m Model) Close() Model {
+ m.visible = false
+ m.exporting = false
+ m.status = ""
+ return m
+}
+
+// Update handles modal key navigation and export completion messages.
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.KeyMsg:
+ if !m.visible {
+ return m, nil
+ }
+ if m.exporting {
+ if msg.String() == "esc" {
+ return m.Close(), nil
+ }
+ return m, nil
+ }
+
+ switch msg.String() {
+ case "esc":
+ return m.Close(), nil
+ case "up", "k":
+ if m.selected > 0 {
+ m.selected--
+ }
+ return m, nil
+ case "down", "j":
+ if m.selected < len(optionValues)-1 {
+ m.selected++
+ }
+ return m, nil
+ case "enter":
+ option := optionValues[m.selected]
+ if option == OptionCancel {
+ return m.Close(), nil
+ }
+ m.exporting = true
+ m.status = fmt.Sprintf("Exporting %s...", optionLabels[m.selected])
+ return m, func() tea.Msg { return RequestMsg{Option: option} }
+ }
+ case CompletedMsg:
+ m.exporting = false
+ if msg.Path == "" {
+ msg.Path = "done"
+ }
+ m.status = "Exported: " + msg.Path
+ return m, nil
+ case FailedMsg:
+ m.exporting = false
+ if msg.Err == nil {
+ msg.Err = errors.New("unknown export failure")
+ }
+ m.status = "Export failed: " + msg.Err.Error()
+ return m, nil
+ }
+
+ return m, nil
+}
+
+// View renders a centered modal overlay.
+func (m Model) View(width, height int) string {
+ if !m.visible {
+ return ""
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+
+ modalWidth := 48
+ if width < modalWidth+4 {
+ modalWidth = width - 4
+ if modalWidth < 30 {
+ modalWidth = 30
+ }
+ }
+
+ lines := []string{"Export"}
+ for i, label := range optionLabels {
+ prefix := " "
+ if i == m.selected && !m.exporting {
+ prefix = "> "
+ }
+ lines = append(lines, prefix+label)
+ }
+ if m.status != "" {
+ lines = append(lines, "", m.status)
+ }
+ if !m.exporting {
+ lines = append(lines, "", "Enter confirm • 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/export/model_test.go b/internal/tui/export/model_test.go
new file mode 100644
index 0000000..a97cd8b
--- /dev/null
+++ b/internal/tui/export/model_test.go
@@ -0,0 +1,72 @@
+package export
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestOpenAndClose(t *testing.T) {
+ m := NewModel().Open()
+ if !m.Visible() {
+ t.Fatalf("expected modal to be visible after Open")
+ }
+ m = m.Close()
+ if m.Visible() {
+ t.Fatalf("expected modal to be hidden after Close")
+ }
+}
+
+func TestEnterEmitsRequest(t *testing.T) {
+ m := NewModel().Open()
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if cmd == nil {
+ t.Fatalf("expected request command on enter")
+ }
+ if !next.exporting {
+ t.Fatalf("expected exporting state after enter")
+ }
+ req, ok := cmd().(RequestMsg)
+ if !ok {
+ t.Fatalf("expected RequestMsg from enter command")
+ }
+ if req.Option != OptionCSV {
+ t.Fatalf("expected CSV as default export option, got %v", req.Option)
+ }
+}
+
+func TestCancelOptionCloses(t *testing.T) {
+ m := NewModel().Open()
+ m.selected = len(optionValues) - 1
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+ if cmd != nil {
+ t.Fatalf("expected no command when selecting cancel")
+ }
+ if next.Visible() {
+ t.Fatalf("expected modal to close on cancel option")
+ }
+}
+
+func TestStatusMessages(t *testing.T) {
+ m := NewModel().Open()
+ m.exporting = true
+
+ next, _ := m.Update(CompletedMsg{Path: "out.csv"})
+ if next.exporting {
+ t.Fatalf("expected exporting=false after completion")
+ }
+ if !strings.Contains(next.status, "out.csv") {
+ t.Fatalf("expected completion path in status")
+ }
+
+ next.exporting = true
+ next, _ = next.Update(FailedMsg{Err: errors.New("boom")})
+ if next.exporting {
+ t.Fatalf("expected exporting=false after failure")
+ }
+ if !strings.Contains(next.status, "boom") {
+ t.Fatalf("expected failure reason in status")
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index b54875a..90b0162 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -2,11 +2,14 @@ package tui
import (
"context"
+ "encoding/csv"
"errors"
"fmt"
"ior/internal/flags"
"ior/internal/statsengine"
+ tuiexport "ior/internal/tui/export"
"ior/internal/tui/pidpicker"
+ "os"
"sync"
"time"
@@ -71,6 +74,7 @@ type Model struct {
screen Screen
pidPicker pidpicker.Model
dashboard dashboardModel
+ exporter tuiexport.Model
keys KeyMap
@@ -98,6 +102,7 @@ func NewModel(initialPID int, startTrace TraceStarter) Model {
screen: ScreenPIDPicker,
pidPicker: pidpicker.New(),
dashboard: newDashboardModel(getDashboardSnapshotSource()),
+ exporter: tuiexport.NewModel(),
keys: Keys,
spin: spin,
startTrace: startTrace,
@@ -134,6 +139,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.stopTrace()
return m, tea.Quit
}
+ if m.screen == ScreenDashboard && !m.attaching && m.lastErr == nil && key.Matches(msg, m.keys.Export) && !m.exporter.Visible() {
+ m.exporter = m.exporter.Open()
+ return m, nil
+ }
+ case tuiexport.RequestMsg:
+ return m, runExportCmd(msg.Option, m.dashboard.latest)
+ case tuiexport.CompletedMsg:
+ var cmd tea.Cmd
+ m.exporter, cmd = m.exporter.Update(msg)
+ return m, cmd
+ case tuiexport.FailedMsg:
+ var cmd tea.Cmd
+ m.exporter, cmd = m.exporter.Update(msg)
+ return m, cmd
case PidSelectedMsg:
return m.handlePidSelected(msg)
case TracingStartedMsg:
@@ -153,6 +172,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spin, cmd = m.spin.Update(msg)
return m, cmd
}
+ if m.exporter.Visible() {
+ var cmd tea.Cmd
+ m.exporter, cmd = m.exporter.Update(msg)
+ return m, cmd
+ }
return m.updateActiveModel(msg)
}
@@ -236,9 +260,17 @@ func (m Model) View() string {
switch m.screen {
case ScreenPIDPicker:
- return m.pidPicker.View()
+ base := m.pidPicker.View()
+ if m.exporter.Visible() {
+ return m.exporter.View(m.width, m.height) + "\n" + base
+ }
+ return base
case ScreenDashboard:
- return m.dashboard.View()
+ base := m.dashboard.View()
+ if m.exporter.Visible() {
+ return m.exporter.View(m.width, m.height) + "\n" + base
+ }
+ return base
default:
return ""
}
@@ -290,3 +322,88 @@ func (d *dashboardModel) refresh() {
func dashboardTickCmd() tea.Cmd {
return tea.Tick(time.Second, func(time.Time) tea.Msg { return dashboardTickMsg{} })
}
+
+func runExportCmd(option tuiexport.Option, snap *statsengine.Snapshot) tea.Cmd {
+ return func() tea.Msg {
+ switch option {
+ case tuiexport.OptionCSV:
+ path, err := exportSnapshotCSV(snap)
+ if err != nil {
+ return tuiexport.FailedMsg{Err: err}
+ }
+ return tuiexport.CompletedMsg{Path: path}
+ case tuiexport.OptionFlamegraph:
+ path, err := exportFlamegraph()
+ if err != nil {
+ return tuiexport.FailedMsg{Err: err}
+ }
+ return tuiexport.CompletedMsg{Path: path}
+ default:
+ return tuiexport.FailedMsg{Err: errors.New("unknown export option")}
+ }
+ }
+}
+
+func exportSnapshotCSV(snap *statsengine.Snapshot) (string, error) {
+ filename := fmt.Sprintf("ior-snapshot-%s.csv", time.Now().Format("20060102-150405"))
+ f, err := os.Create(filename)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+
+ w := csv.NewWriter(f)
+
+ rows := [][]string{
+ {"section", "name", "value1", "value2", "value3"},
+ {"summary", "totals", fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalSyscalls })), fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalErrors })), fmt.Sprint(snapValue(snap, func(s *statsengine.Snapshot) uint64 { return s.TotalBytes }))},
+ {"summary", "rates_per_sec", fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.SyscallRatePerSec })), fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.ReadBytesPerSec })), fmt.Sprintf("%.2f", snapValueF(snap, func(s *statsengine.Snapshot) float64 { return s.WriteBytesPerSec }))},
+ }
+ for _, row := range rows {
+ if err := w.Write(row); err != nil {
+ return "", err
+ }
+ }
+
+ if snap != nil {
+ for _, s := range snap.Syscalls() {
+ if err := w.Write([]string{"syscall", s.Name, fmt.Sprint(s.Count), fmt.Sprintf("%.2f", s.RatePerSec), fmt.Sprint(s.Bytes)}); err != nil {
+ return "", err
+ }
+ }
+ for _, r := range snap.Files() {
+ if err := w.Write([]string{"file", r.Path, fmt.Sprint(r.Accesses), fmt.Sprint(r.BytesRead), fmt.Sprint(r.BytesWritten)}); err != nil {
+ return "", err
+ }
+ }
+ for _, p := range snap.Processes() {
+ if err := w.Write([]string{"process", fmt.Sprint(p.PID), fmt.Sprint(p.Syscalls), fmt.Sprintf("%.2f", p.RatePerSec), fmt.Sprint(p.Bytes)}); err != nil {
+ return "", err
+ }
+ }
+ }
+
+ w.Flush()
+ if err := w.Error(); err != nil {
+ return "", err
+ }
+ return filename, nil
+}
+
+func snapValue(snap *statsengine.Snapshot, get func(*statsengine.Snapshot) uint64) uint64 {
+ if snap == nil {
+ return 0
+ }
+ return get(snap)
+}
+
+func snapValueF(snap *statsengine.Snapshot, get func(*statsengine.Snapshot) float64) float64 {
+ if snap == nil {
+ return 0
+ }
+ return get(snap)
+}
+
+func exportFlamegraph() (string, error) {
+ return "", errors.New("flamegraph export is not yet available in TUI mode")
+}
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 40ea67b..d66ec84 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -4,6 +4,9 @@ import (
"context"
"errors"
"ior/internal/statsengine"
+ tuiexport "ior/internal/tui/export"
+ "os"
+ "path/filepath"
"strings"
"testing"
"time"
@@ -189,3 +192,40 @@ func TestDashboardRefreshPicksLateBoundSource(t *testing.T) {
t.Fatalf("expected dashboard refresh to bind and use latest global source")
}
}
+
+func TestExportKeyOpensModalOnDashboard(t *testing.T) {
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}})
+ updated := next.(Model)
+ if !updated.exporter.Visible() {
+ t.Fatalf("expected export modal to open on e key")
+ }
+}
+
+func TestRunExportCmdCSVWritesFile(t *testing.T) {
+ dir := t.TempDir()
+ prev, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ if err := os.Chdir(dir); err != nil {
+ t.Fatalf("chdir temp dir: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(prev) })
+
+ snap := &statsengine.Snapshot{TotalSyscalls: 1}
+ msg := runExportCmd(tuiexport.OptionCSV, snap)()
+ done, ok := msg.(tuiexport.CompletedMsg)
+ if !ok {
+ t.Fatalf("expected CompletedMsg, got %T", msg)
+ }
+ if done.Path == "" {
+ t.Fatalf("expected export path")
+ }
+ if _, err := os.Stat(filepath.Join(dir, done.Path)); err != nil {
+ t.Fatalf("expected CSV file to exist: %v", err)
+ }
+}