diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-23 23:52:55 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-23 23:52:55 +0200 |
| commit | b79a868fbc85cd7fb2829e978174629ab8a9c986 (patch) | |
| tree | 6d80b9b1a45a43b8d251a518f3538817c34e3165 /internal | |
| parent | 570b7b5d9283b9e443e7da25661e9f2098cc2305 (diff) | |
tui: add top-level model and run entrypoint
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/flags/flags.go | 12 | ||||
| -rw-r--r-- | internal/tui/messages/messages.go | 24 | ||||
| -rw-r--r-- | internal/tui/msg.go | 18 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model.go | 55 | ||||
| -rw-r--r-- | internal/tui/pidpicker/model_test.go | 16 | ||||
| -rw-r--r-- | internal/tui/tui.go | 233 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 166 |
7 files changed, 491 insertions, 33 deletions
diff --git a/internal/flags/flags.go b/internal/flags/flags.go index 6010b51..8760d33 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -8,6 +8,7 @@ import ( "slices" "strings" "sync" + "sync/atomic" bpf "github.com/aquasecurity/libbpfgo" ) @@ -15,6 +16,7 @@ import ( var ( singleton Flags once sync.Once + pidFilter atomic.Int64 ) const flamegraphToolDefault = "$HOME/git/FlameGraph/flamegraph.pl" @@ -64,7 +66,14 @@ type Flags struct { } func Get() Flags { - return singleton + out := singleton + out.PidFilter = int(pidFilter.Load()) + return out +} + +// SetPidFilter updates the active PID filter used for subsequent tracing runs. +func SetPidFilter(pid int) { + pidFilter.Store(int64(pid)) } func Parse() { @@ -100,6 +109,7 @@ func parse() { flag.StringVar(&singleton.FlamegraphTool, "flamegraphTool", "", "Path to the flamegraph tool (e.g. flamegraph.pl or inferno-flamegraph)") flag.Parse() + pidFilter.Store(int64(singleton.PidFilter)) if singleton.FlamegraphTool == "" { singleton.FlamegraphTool = flamegraphToolDefault diff --git a/internal/tui/messages/messages.go b/internal/tui/messages/messages.go new file mode 100644 index 0000000..35232b9 --- /dev/null +++ b/internal/tui/messages/messages.go @@ -0,0 +1,24 @@ +package messages + +import "ior/internal/statsengine" + +// PidSelectedMsg is emitted when the user selects a PID from the process table. +type PidSelectedMsg struct { + Pid int +} + +// StatsTickMsg carries a fresh immutable snapshot from the stats engine. +type StatsTickMsg struct { + Snap *statsengine.Snapshot +} + +// ExportRequestMsg requests an export of the current UI state. +type ExportRequestMsg struct{} + +// TracingStartedMsg signals that tracing started successfully. +type TracingStartedMsg struct{} + +// TracingErrorMsg reports an error while starting or running tracing. +type TracingErrorMsg struct { + Err error +} diff --git a/internal/tui/msg.go b/internal/tui/msg.go index ba2ec53..c69e806 100644 --- a/internal/tui/msg.go +++ b/internal/tui/msg.go @@ -1,24 +1,18 @@ package tui -import "ior/internal/statsengine" +import "ior/internal/tui/messages" // PidSelectedMsg is emitted when the user selects a PID from the process table. -type PidSelectedMsg struct { - Pid int -} +type PidSelectedMsg = messages.PidSelectedMsg // StatsTickMsg carries a fresh immutable snapshot from the stats engine. -type StatsTickMsg struct { - Snap *statsengine.Snapshot -} +type StatsTickMsg = messages.StatsTickMsg // ExportRequestMsg requests an export of the current UI state. -type ExportRequestMsg struct{} +type ExportRequestMsg = messages.ExportRequestMsg // TracingStartedMsg signals that tracing started successfully. -type TracingStartedMsg struct{} +type TracingStartedMsg = messages.TracingStartedMsg // TracingErrorMsg reports an error while starting or running tracing. -type TracingErrorMsg struct { - Err error -} +type TracingErrorMsg = messages.TracingErrorMsg diff --git a/internal/tui/pidpicker/model.go b/internal/tui/pidpicker/model.go index 4e63429..34da674 100644 --- a/internal/tui/pidpicker/model.go +++ b/internal/tui/pidpicker/model.go @@ -2,7 +2,7 @@ package pidpicker import ( "fmt" - "ior/internal/tui" + "ior/internal/tui/messages" "strings" "github.com/charmbracelet/bubbles/key" @@ -13,6 +13,37 @@ import ( const allPIDsLabel = "All PIDs" +// KeyMap defines picker-specific key bindings. +type KeyMap struct { + Enter key.Binding + Esc key.Binding + Refresh key.Binding +} + +// DefaultKeyMap returns picker defaults. +func DefaultKeyMap() KeyMap { + return KeyMap{ + Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + } +} + +func (k KeyMap) PickerShortHelp() []key.Binding { + return []key.Binding{k.Enter, k.Refresh, k.Esc} +} + +var ( + screenStyle = lipgloss.NewStyle() + headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("75")) + helpBarStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("246")). + BorderTop(true). + BorderForeground(lipgloss.Color("238")) + highlightStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("222")) + errorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("203")) +) + type processesLoadedMsg struct { processes []ProcessInfo err error @@ -26,17 +57,17 @@ type Model struct { selectedIndex int width int height int - keys tui.KeyMap + keys KeyMap lastErr error } // New creates a PID picker model with default shared key bindings. func New() Model { - return NewWithKeys(tui.Keys) + return NewWithKeys(DefaultKeyMap()) } // NewWithKeys creates a PID picker model with the provided key bindings. -func NewWithKeys(keys tui.KeyMap) Model { +func NewWithKeys(keys KeyMap) Model { input := textinput.New() input.Prompt = "Filter: " input.Placeholder = "pid, comm, or cmdline" @@ -117,16 +148,16 @@ func (m Model) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m Model) emitSelection() tea.Cmd { if m.selectedIndex <= 0 { - return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} } + return func() tea.Msg { return messages.PidSelectedMsg{Pid: 0} } } idx := m.selectedIndex - 1 if idx < 0 || idx >= len(m.filtered) { - return func() tea.Msg { return tui.PidSelectedMsg{Pid: 0} } + return func() tea.Msg { return messages.PidSelectedMsg{Pid: 0} } } pid := m.filtered[idx].Pid - return func() tea.Msg { return tui.PidSelectedMsg{Pid: pid} } + return func() tea.Msg { return messages.PidSelectedMsg{Pid: pid} } } func (m *Model) applyFilter() { @@ -175,7 +206,7 @@ func cloneProcesses(in []ProcessInfo) []ProcessInfo { // View renders the PID picker with filter input, list, and help bar. func (m Model) View() string { var b strings.Builder - b.WriteString(tui.HeaderStyle.Render("Select PID")) + b.WriteString(headerStyle.Render("Select PID")) b.WriteString("\n") b.WriteString(m.input.View()) b.WriteString("\n\n") @@ -185,12 +216,12 @@ func (m Model) View() string { if m.lastErr != nil { b.WriteString("\n") - b.WriteString(tui.ErrorStyle.Render("scan error: " + m.lastErr.Error())) + b.WriteString(errorStyle.Render("scan error: " + m.lastErr.Error())) } b.WriteString("\n") - b.WriteString(tui.HelpBarStyle.Render(renderHelp(m.keys.PickerShortHelp()))) - return tui.ScreenStyle.Render(b.String()) + b.WriteString(helpBarStyle.Render(renderHelp(m.keys.PickerShortHelp()))) + return screenStyle.Render(b.String()) } func (m Model) renderRows() string { @@ -221,7 +252,7 @@ func (m Model) renderRow(index int, label string) string { style := lipgloss.NewStyle() if index == m.selectedIndex { prefix = "> " - style = tui.HighlightStyle + style = highlightStyle } return style.Render(prefix + label) } diff --git a/internal/tui/pidpicker/model_test.go b/internal/tui/pidpicker/model_test.go index c8e59af..7347eca 100644 --- a/internal/tui/pidpicker/model_test.go +++ b/internal/tui/pidpicker/model_test.go @@ -1,7 +1,7 @@ package pidpicker import ( - "ior/internal/tui" + "ior/internal/tui/messages" "strings" "testing" @@ -9,7 +9,7 @@ import ( ) func TestApplyFilterByPIDCommAndCmdline(t *testing.T) { - m := NewWithKeys(tui.DefaultKeyMap()) + m := NewWithKeys(DefaultKeyMap()) m.processes = []ProcessInfo{ {Pid: 100, Comm: "bash", Cmdline: "bash -l"}, {Pid: 200, Comm: "sshd", Cmdline: "/usr/sbin/sshd -D"}, @@ -35,14 +35,14 @@ func TestApplyFilterByPIDCommAndCmdline(t *testing.T) { } func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) { - m := NewWithKeys(tui.DefaultKeyMap()) + m := NewWithKeys(DefaultKeyMap()) m.processes = []ProcessInfo{{Pid: 7, Comm: "vim"}, {Pid: 9, Comm: "top"}} m.applyFilter() modelAny, cmdAny := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) _ = modelAny msgAny := cmdAny() - pidAny, ok := msgAny.(tui.PidSelectedMsg) + pidAny, ok := msgAny.(messages.PidSelectedMsg) if !ok { t.Fatalf("expected PidSelectedMsg for all-pids selection, got %T", msgAny) } @@ -54,7 +54,7 @@ func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) { modelOne, cmdOne := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) _ = modelOne msgOne := cmdOne() - pidOne, ok := msgOne.(tui.PidSelectedMsg) + pidOne, ok := msgOne.(messages.PidSelectedMsg) if !ok { t.Fatalf("expected PidSelectedMsg for concrete selection, got %T", msgOne) } @@ -64,7 +64,7 @@ func TestEnterEmitsAllPIDsAndSelectedPID(t *testing.T) { } func TestEscQuitsAndRefreshTriggersScan(t *testing.T) { - m := NewWithKeys(tui.DefaultKeyMap()) + m := NewWithKeys(DefaultKeyMap()) _, escCmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) if escCmd == nil { @@ -84,7 +84,7 @@ func TestEscQuitsAndRefreshTriggersScan(t *testing.T) { } func TestRuneRDoesNotTriggerRefreshWhileFilterFocused(t *testing.T) { - m := NewWithKeys(tui.DefaultKeyMap()) + m := NewWithKeys(DefaultKeyMap()) next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) if cmd == nil { @@ -98,7 +98,7 @@ func TestRuneRDoesNotTriggerRefreshWhileFilterFocused(t *testing.T) { } func TestRenderRowsKeepsSelectionVisible(t *testing.T) { - m := NewWithKeys(tui.DefaultKeyMap()) + m := NewWithKeys(DefaultKeyMap()) m.height = 8 // visible rows == 2 m.processes = []ProcessInfo{ {Pid: 1, Comm: "p1"}, diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..758213d --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,233 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "ior/internal/flags" + "ior/internal/tui/pidpicker" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +// Screen identifies the currently active TUI screen. +type Screen int + +const ( + // ScreenPIDPicker is the PID selection screen. + ScreenPIDPicker Screen = iota + // ScreenDashboard is the runtime dashboard screen. + ScreenDashboard +) + +// TraceStarter starts tracing and returns when startup succeeds or fails. +// Long-lived tracing work should continue in background goroutines. +type TraceStarter func(context.Context) error + +// Run starts the TUI program in alternate screen mode. +func Run() error { + model := NewModel(flags.Get().PidFilter, defaultTraceStarter) + program := tea.NewProgram(model, tea.WithAltScreen()) + _, err := program.Run() + return err +} + +// Model is the top-level Bubble Tea model that routes between PID picker and dashboard. +type Model struct { + screen Screen + pidPicker pidpicker.Model + dashboard dashboardModel + + keys KeyMap + + width int + height int + quitting bool + + attaching bool + spin spinner.Model + lastErr error + + startTrace TraceStarter + traceStop context.CancelFunc +} + +// NewModel creates the top-level TUI model. +func NewModel(initialPID int, startTrace TraceStarter) Model { + spin := spinner.New() + spin.Spinner = spinner.MiniDot + if startTrace == nil { + startTrace = defaultTraceStarter + } + + model := Model{ + screen: ScreenPIDPicker, + pidPicker: pidpicker.New(), + dashboard: newDashboardModel(), + keys: Keys, + spin: spin, + startTrace: startTrace, + } + + if initialPID > 0 { + flags.SetPidFilter(initialPID) + model.dashboard.selectedPID = initialPID + model.screen = ScreenDashboard + model.attaching = true + } + + return model +} + +// Init initializes the active child model and optional tracing startup command. +func (m Model) Init() tea.Cmd { + if m.screen == ScreenDashboard && m.attaching { + return tea.Batch(m.spin.Tick, m.beginTraceCmd()) + } + return m.pidPicker.Init() +} + +// Update routes messages, transitions screens, and manages tracing startup state. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m.updateActiveModel(msg) + case tea.KeyMsg: + if key.Matches(msg, m.keys.Quit) { + m.quitting = true + m.stopTrace() + return m, tea.Quit + } + case PidSelectedMsg: + return m.handlePidSelected(msg) + case TracingStartedMsg: + m.attaching = false + return m, nil + case TracingErrorMsg: + m.attaching = false + m.lastErr = msg.Err + return m, nil + } + + if m.attaching { + var cmd tea.Cmd + m.spin, cmd = m.spin.Update(msg) + return m, cmd + } + + return m.updateActiveModel(msg) +} + +func (m Model) updateActiveModel(msg tea.Msg) (tea.Model, tea.Cmd) { + switch m.screen { + case ScreenPIDPicker: + next, cmd := m.pidPicker.Update(msg) + m.pidPicker = next.(pidpicker.Model) + return m, cmd + case ScreenDashboard: + next, cmd := m.dashboard.Update(msg) + m.dashboard = next.(dashboardModel) + return m, cmd + default: + return m, nil + } +} + +func (m Model) handlePidSelected(msg PidSelectedMsg) (tea.Model, tea.Cmd) { + pid := selectedPIDFilter(msg.Pid) + m.stopTrace() + flags.SetPidFilter(pid) + m.dashboard.selectedPID = pid + m.screen = ScreenDashboard + m.attaching = true + m.lastErr = nil + return m, tea.Batch(m.spin.Tick, m.beginTraceCmd()) +} + +func selectedPIDFilter(pid int) int { + if pid <= 0 { + return -1 + } + return pid +} + +func (m *Model) beginTraceCmd() tea.Cmd { + ctx, cancel := context.WithCancel(context.Background()) + m.traceStop = cancel + return startTraceCmd(m.startTrace, ctx) +} + +func startTraceCmd(starter TraceStarter, ctx context.Context) tea.Cmd { + return func() tea.Msg { + if err := starter(ctx); err != nil { + if errors.Is(err, context.Canceled) { + return nil + } + return TracingErrorMsg{Err: err} + } + return TracingStartedMsg{} + } +} + +func defaultTraceStarter(context.Context) error { + return nil +} + +func (m *Model) stopTrace() { + if m.traceStop != nil { + m.traceStop() + m.traceStop = nil + } +} + +// View renders the currently active screen and startup overlay state. +func (m Model) View() string { + if m.quitting { + return "" + } + + if m.attaching { + line := fmt.Sprintf("%s Attaching tracepoints...", m.spin.View()) + return ScreenStyle.Render(PanelStyle.Render(line)) + } + + if m.lastErr != nil { + return ScreenStyle.Render(ErrorStyle.Render(m.lastErr.Error())) + } + + switch m.screen { + case ScreenPIDPicker: + return m.pidPicker.View() + case ScreenDashboard: + return m.dashboard.View() + default: + return "" + } +} + +type dashboardModel struct { + selectedPID int +} + +func newDashboardModel() dashboardModel { + return dashboardModel{selectedPID: -1} +} + +func (d dashboardModel) Init() tea.Cmd { + return nil +} + +func (d dashboardModel) Update(tea.Msg) (tea.Model, tea.Cmd) { + return d, nil +} + +func (d dashboardModel) View() string { + if d.selectedPID > 0 { + return PanelStyle.Render(fmt.Sprintf("Dashboard (PID %d)", d.selectedPID)) + } + return PanelStyle.Render("Dashboard (All PIDs)") +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..3801813 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,166 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "ior/internal/flags" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +func TestPidSelectedTransitionsToDashboardAndSetsPIDFilter(t *testing.T) { + flags.SetPidFilter(-1) + m := NewModel(-1, func(context.Context) error { return nil }) + + next, cmd := m.Update(PidSelectedMsg{Pid: 42}) + if cmd == nil { + t.Fatalf("expected tracing start command") + } + + updated := next.(Model) + if updated.screen != ScreenDashboard { + t.Fatalf("expected dashboard screen, got %v", updated.screen) + } + if !updated.attaching { + t.Fatalf("expected attaching state to be true") + } + if got := flags.Get().PidFilter; got != 42 { + t.Fatalf("expected pid filter 42, got %d", got) + } +} + +func TestInitialPIDSkipsPickerAndStartsTracing(t *testing.T) { + flags.SetPidFilter(-1) + m := NewModel(7, func(context.Context) error { return nil }) + + if m.screen != ScreenDashboard { + t.Fatalf("expected initial screen dashboard, got %v", m.screen) + } + + cmd := m.Init() + if cmd == nil { + t.Fatalf("expected init command when initial pid is set") + } +} + +func TestPidSelectedAllSetsNoFilter(t *testing.T) { + flags.SetPidFilter(999) + m := NewModel(-1, func(context.Context) error { return nil }) + + next, _ := m.Update(PidSelectedMsg{Pid: 0}) + updated := next.(Model) + + if got := flags.Get().PidFilter; got != -1 { + t.Fatalf("expected pid filter -1 for all pids, got %d", got) + } + if updated.dashboard.selectedPID != -1 { + t.Fatalf("expected dashboard selected pid -1, got %d", updated.dashboard.selectedPID) + } +} + +func TestTracingErrorMessageClearsAttachingState(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.attaching = true + + next, _ := m.Update(TracingErrorMsg{Err: errors.New("boom")}) + updated := next.(Model) + if updated.attaching { + t.Fatalf("expected attaching to be false after tracing error") + } + if updated.lastErr == nil || updated.lastErr.Error() != "boom" { + t.Fatalf("expected tracing error to be stored") + } +} + +func TestViewShowsAttachingAndErrorStates(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.attaching = true + attachingView := m.View() + if !strings.Contains(attachingView, "Attaching tracepoints...") { + t.Fatalf("expected attaching view, got %q", attachingView) + } + + m.attaching = false + m.lastErr = errors.New("failed") + errorView := m.View() + if !strings.Contains(errorView, "failed") { + t.Fatalf("expected error view, got %q", errorView) + } +} + +func TestQuitKeySetsQuittingState(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if cmd == nil { + t.Fatalf("expected quit cmd") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("expected tea.QuitMsg") + } + + updated := next.(Model) + if !updated.quitting { + t.Fatalf("expected quitting state") + } +} + +func TestQuitKeyMatchesSingleBindingWithoutPanic(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.keys.Quit = key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "quit")) + + _, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + + next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + if cmd == nil { + t.Fatalf("expected quit cmd") + } + updated := next.(Model) + if !updated.quitting { + t.Fatalf("expected quitting state") + } +} + +func TestStartTraceCmdLaunchesBeforeStarterReturns(t *testing.T) { + cmd := startTraceCmd(func(context.Context) error { return nil }, context.Background()) + msg := cmd() + if _, ok := msg.(TracingStartedMsg); !ok { + t.Fatalf("expected TracingStartedMsg, got %T", msg) + } +} + +func TestStartTraceCmdEmitsErrorMsg(t *testing.T) { + cmd := startTraceCmd(func(context.Context) error { return errors.New("trace failed") }, context.Background()) + msg := cmd() + traceErr, ok := msg.(TracingErrorMsg) + if !ok { + t.Fatalf("expected TracingErrorMsg, got %T", msg) + } + if traceErr.Err == nil || traceErr.Err.Error() != "trace failed" { + t.Fatalf("unexpected trace error message: %+v", traceErr) + } +} + +func TestQuitInvokesTraceStop(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + done := make(chan struct{}) + m.traceStop = func() { + close(done) + } + + _, quitCmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if quitCmd == nil { + t.Fatalf("expected quit command") + } + + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatalf("expected stopTrace to be invoked on quit") + } +} |
