summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 21:47:11 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 21:47:11 +0200
commit59f3d951dd221b21bb6459476352de61984c4c2f (patch)
tree4a1083487b6368569b48bcb7192ddcfd297b6313 /internal
parent329543c14c07776a9a1de1284ab1f825cd4fa496 (diff)
Add runtime probes modal model for TUI
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/probes/model.go315
-rw-r--r--internal/tui/probes/model_test.go80
2 files changed, 395 insertions, 0 deletions
diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go
new file mode 100644
index 0000000..5715d03
--- /dev/null
+++ b/internal/tui/probes/model.go
@@ -0,0 +1,315 @@
+package probes
+
+import (
+ "fmt"
+ "ior/internal/probemanager"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Manager defines the probe operations used by the modal.
+type Manager interface {
+ States() []probemanager.ProbeState
+ Toggle(syscall string) error
+ ActiveCount() (int, int)
+}
+
+// ProbeToggledMsg reports completion of an async toggle operation.
+type ProbeToggledMsg struct {
+ Syscall string
+ Err error
+}
+
+// Model is the probe toggle modal state.
+type Model struct {
+ visible bool
+ probes []probemanager.ProbeState
+
+ cursor int
+ offset int
+
+ search string
+ searching bool
+ textInput textinput.Model
+
+ lastErr string
+ manager Manager
+ height int
+}
+
+func NewModel(manager Manager) Model {
+ ti := textinput.New()
+ ti.Prompt = "/ "
+ ti.CharLimit = 0
+ ti.Width = 28
+ return Model{
+ manager: manager,
+ textInput: ti,
+ }
+}
+
+func (m Model) Visible() bool { return m.visible }
+
+func (m Model) Open() Model {
+ m.visible = true
+ m.searching = false
+ m.lastErr = ""
+ m.textInput.Blur()
+ m.reload()
+ m.clampCursor()
+ return m
+}
+
+func (m Model) Close() Model {
+ m.visible = false
+ m.searching = false
+ m.textInput.Blur()
+ m.lastErr = ""
+ return m
+}
+
+func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
+ if !m.visible {
+ return m, nil
+ }
+
+ switch msg := msg.(type) {
+ case ProbeToggledMsg:
+ m.reload()
+ if msg.Err != nil {
+ m.lastErr = msg.Err.Error()
+ } else {
+ m.lastErr = ""
+ }
+ m.clampCursor()
+ return m, nil
+ case tea.KeyMsg:
+ if m.searching {
+ return m.updateSearch(msg)
+ }
+ switch msg.String() {
+ case "esc":
+ return m.Close(), nil
+ case "j", "down":
+ if m.cursor < len(m.filtered())-1 {
+ m.cursor++
+ }
+ return m, nil
+ case "k", "up":
+ if m.cursor > 0 {
+ m.cursor--
+ }
+ return m, nil
+ case "/", "f":
+ m.searching = true
+ m.textInput.SetValue(m.search)
+ m.textInput.CursorEnd()
+ m.textInput.Focus()
+ return m, nil
+ case " ", "enter":
+ selected := m.selectedSyscall()
+ if selected == "" {
+ return m, nil
+ }
+ return m, toggleCmd(m.manager, selected)
+ case "a":
+ return m, bulkToggleCmd(m.manager, m.filtered(), false)
+ case "n":
+ return m, bulkToggleCmd(m.manager, m.filtered(), true)
+ }
+ }
+ return m, nil
+}
+
+func (m Model) updateSearch(msg tea.KeyMsg) (Model, tea.Cmd) {
+ switch msg.String() {
+ case "esc":
+ m.searching = false
+ m.textInput.Blur()
+ return m, nil
+ case "enter":
+ m.search = strings.TrimSpace(m.textInput.Value())
+ m.searching = false
+ m.textInput.Blur()
+ m.clampCursor()
+ return m, nil
+ default:
+ var cmd tea.Cmd
+ m.textInput, cmd = m.textInput.Update(msg)
+ m.search = strings.TrimSpace(m.textInput.Value())
+ m.clampCursor()
+ return m, cmd
+ }
+}
+
+func (m *Model) reload() {
+ if m.manager == nil {
+ m.probes = nil
+ return
+ }
+ m.probes = m.manager.States()
+}
+
+func (m *Model) clampCursor() {
+ items := m.filtered()
+ if len(items) == 0 {
+ m.cursor = 0
+ m.offset = 0
+ return
+ }
+ if m.cursor >= len(items) {
+ m.cursor = len(items) - 1
+ }
+ if m.cursor < 0 {
+ m.cursor = 0
+ }
+ rows := m.visibleRows()
+ if m.cursor < m.offset {
+ m.offset = m.cursor
+ }
+ if rows > 0 && m.cursor >= m.offset+rows {
+ m.offset = m.cursor - rows + 1
+ }
+ if m.offset < 0 {
+ m.offset = 0
+ }
+}
+
+func (m Model) filtered() []probemanager.ProbeState {
+ if m.search == "" {
+ return m.probes
+ }
+ needle := strings.ToLower(m.search)
+ out := make([]probemanager.ProbeState, 0, len(m.probes))
+ for _, p := range m.probes {
+ if strings.Contains(strings.ToLower(p.Syscall), needle) {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func (m Model) selectedSyscall() string {
+ items := m.filtered()
+ if len(items) == 0 || m.cursor < 0 || m.cursor >= len(items) {
+ return ""
+ }
+ return items[m.cursor].Syscall
+}
+
+func (m Model) visibleRows() int {
+ if m.height <= 0 {
+ return 10
+ }
+ rows := m.height - 9
+ if rows < 3 {
+ return 3
+ }
+ return rows
+}
+
+func (m Model) View(width, height int) string {
+ if !m.visible {
+ return ""
+ }
+ if width <= 0 {
+ width = 80
+ }
+ if height <= 0 {
+ height = 24
+ }
+ m.height = height
+
+ active, total := 0, len(m.probes)
+ if m.manager != nil {
+ active, total = m.manager.ActiveCount()
+ }
+
+ rows := m.visibleRows()
+ items := m.filtered()
+ modalWidth := 66
+ if width < modalWidth+4 {
+ modalWidth = width - 4
+ if modalWidth < 44 {
+ modalWidth = 44
+ }
+ }
+
+ lines := []string{fmt.Sprintf("Probes (%d/%d active)", active, total)}
+ if m.searching {
+ lines = append(lines, m.textInput.View())
+ } else if m.search != "" {
+ lines = append(lines, "Filter: "+m.search)
+ }
+ lines = append(lines, "")
+
+ start := m.offset
+ if start > len(items) {
+ start = len(items)
+ }
+ end := start + rows
+ if end > len(items) {
+ end = len(items)
+ }
+ for i := start; i < end; i++ {
+ p := items[i]
+ prefix := " "
+ if i == m.cursor {
+ prefix = "> "
+ }
+ check := "[ ]"
+ if p.Active {
+ check = "[x]"
+ }
+ line := fmt.Sprintf("%s%s %-24s", prefix, check, p.Syscall)
+ if p.Error != "" {
+ line += " ! " + p.Error
+ }
+ lines = append(lines, line)
+ }
+ if len(items) == 0 {
+ lines = append(lines, " (no probes)")
+ }
+ if m.lastErr != "" {
+ lines = append(lines, "", "Error: "+m.lastErr)
+ }
+ lines = append(lines, "", "j/k move • space|enter toggle • a all-on • n all-off • / search • esc close")
+
+ 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)
+}
+
+func toggleCmd(manager Manager, syscall string) tea.Cmd {
+ return func() tea.Msg {
+ if manager == nil {
+ return ProbeToggledMsg{Syscall: syscall, Err: fmt.Errorf("probe manager unavailable")}
+ }
+ return ProbeToggledMsg{Syscall: syscall, Err: manager.Toggle(syscall)}
+ }
+}
+
+func bulkToggleCmd(manager Manager, probes []probemanager.ProbeState, sourceActive bool) tea.Cmd {
+ return func() tea.Msg {
+ if manager == nil {
+ return ProbeToggledMsg{Err: fmt.Errorf("probe manager unavailable")}
+ }
+ var firstErr error
+ for _, p := range probes {
+ if p.Active != sourceActive {
+ continue
+ }
+ if err := manager.Toggle(p.Syscall); err != nil && firstErr == nil {
+ firstErr = err
+ }
+ }
+ return ProbeToggledMsg{Err: firstErr}
+ }
+}
diff --git a/internal/tui/probes/model_test.go b/internal/tui/probes/model_test.go
new file mode 100644
index 0000000..74f6a6b
--- /dev/null
+++ b/internal/tui/probes/model_test.go
@@ -0,0 +1,80 @@
+package probes
+
+import (
+ "testing"
+
+ "ior/internal/probemanager"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type fakeManager struct {
+ states []probemanager.ProbeState
+ toggles []string
+}
+
+func (f *fakeManager) States() []probemanager.ProbeState {
+ out := make([]probemanager.ProbeState, len(f.states))
+ copy(out, f.states)
+ return out
+}
+
+func (f *fakeManager) Toggle(syscall string) error {
+ f.toggles = append(f.toggles, syscall)
+ for i := range f.states {
+ if f.states[i].Syscall == syscall {
+ f.states[i].Active = !f.states[i].Active
+ }
+ }
+ return nil
+}
+
+func (f *fakeManager) ActiveCount() (int, int) {
+ active := 0
+ for _, s := range f.states {
+ if s.Active {
+ active++
+ }
+ }
+ return active, len(f.states)
+}
+
+func TestOpenRefreshesFromManager(t *testing.T) {
+ fm := &fakeManager{
+ states: []probemanager.ProbeState{{Syscall: "read", Active: true}},
+ }
+ m := NewModel(fm)
+ m = m.Open()
+ if len(m.probes) != 1 || m.probes[0].Syscall != "read" {
+ t.Fatalf("unexpected probes after first open: %+v", m.probes)
+ }
+
+ fm.states = append(fm.states, probemanager.ProbeState{Syscall: "write", Active: true})
+ m = m.Close().Open()
+ if len(m.probes) != 2 {
+ t.Fatalf("expected probes refreshed on open, got %+v", m.probes)
+ }
+}
+
+func TestToggleEmitsProbeToggledMsg(t *testing.T) {
+ fm := &fakeManager{
+ states: []probemanager.ProbeState{{Syscall: "read", Active: true}},
+ }
+ m := NewModel(fm).Open()
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}})
+ if cmd == nil {
+ t.Fatalf("expected toggle command")
+ }
+ msg := cmd()
+ toggled, ok := msg.(ProbeToggledMsg)
+ if !ok {
+ t.Fatalf("expected ProbeToggledMsg, got %T", msg)
+ }
+ if toggled.Err != nil {
+ t.Fatalf("unexpected toggle err: %v", toggled.Err)
+ }
+ if len(fm.toggles) != 1 || fm.toggles[0] != "read" {
+ t.Fatalf("expected read toggle, got %+v", fm.toggles)
+ }
+ _ = next
+}