diff options
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/tui/export/model.go | 173 | ||||
| -rw-r--r-- | internal/tui/export/model_test.go | 72 | ||||
| -rw-r--r-- | internal/tui/tui.go | 121 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 40 |
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) + } +} |
