summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 22:58:40 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 22:58:40 +0200
commit4c34b9efcd539c819648c927d7e3f53220df8ad2 (patch)
treef9de9fd650a2d16316ba2c159990d891c9de5189
parent67e10f34c92e93343adbd690b3b21e455e863bd3 (diff)
Fix stream paused scrolling and apply pending TUI/probe updates
-rw-r--r--internal/ior.go10
-rw-r--r--internal/probemanager/manager.go16
-rw-r--r--internal/probemanager/manager_test.go26
-rw-r--r--internal/statsengine/engine.go29
-rw-r--r--internal/statsengine/engine_reset_test.go27
-rw-r--r--internal/tui/dashboard/model.go13
-rw-r--r--internal/tui/dashboard/model_test.go85
-rw-r--r--internal/tui/eventstream/model.go88
-rw-r--r--internal/tui/eventstream/model_test.go137
-rw-r--r--internal/tui/eventstream/render.go2
-rw-r--r--internal/tui/eventstream/render_test.go24
-rw-r--r--internal/tui/probes/model.go4
-rw-r--r--internal/tui/probes/model_test.go41
-rw-r--r--internal/tui/tui.go21
-rw-r--r--internal/tui/tui_test.go37
15 files changed, 543 insertions, 17 deletions
diff --git a/internal/ior.go b/internal/ior.go
index 61c5e10..a910fc0 100644
--- a/internal/ior.go
+++ b/internal/ior.go
@@ -262,6 +262,16 @@ func runTraceWithContext(parentCtx context.Context, started chan<- struct{}, con
if configure != nil {
configure(el)
}
+ origPrintCb := el.printCb
+ el.printCb = func(ep *event.Pair) {
+ if !mgr.IsActive(ep.EnterEv.GetTraceId().Name()) {
+ ep.Recycle()
+ return
+ }
+ if origPrintCb != nil {
+ origPrintCb(ep)
+ }
+ }
duration := time.Duration(flags.Get().Duration) * time.Second
logln("Probing for", duration)
ctx, cancel := context.WithTimeout(parentCtx, duration)
diff --git a/internal/probemanager/manager.go b/internal/probemanager/manager.go
index 65dd52b..b991c7c 100644
--- a/internal/probemanager/manager.go
+++ b/internal/probemanager/manager.go
@@ -260,6 +260,22 @@ func (m *Manager) ActiveCount() (active, total int) {
return active, total
}
+// IsActive reports whether the syscall probe is currently active.
+func (m *Manager) IsActive(syscall string) bool {
+ if m == nil || syscall == "" {
+ return false
+ }
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ entry, ok := m.probes[syscall]
+ if !ok {
+ return false
+ }
+ return entry.active
+}
+
func (m *Manager) Close() error {
if m == nil {
return nil
diff --git a/internal/probemanager/manager_test.go b/internal/probemanager/manager_test.go
index 1fcce6d..b000ced 100644
--- a/internal/probemanager/manager_test.go
+++ b/internal/probemanager/manager_test.go
@@ -214,3 +214,29 @@ func TestManagerAttachAllPicksUpNewTracepointsOnLaterCall(t *testing.T) {
t.Fatalf("unexpected syscall ordering/content: %+v", states)
}
}
+
+func TestManagerIsActiveReflectsCurrentState(t *testing.T) {
+ attacher := &fakeAttacher{
+ programs: map[string]*fakeProgram{
+ "handle_sys_enter_read": {},
+ "handle_sys_exit_read": {},
+ },
+ errs: map[string]error{},
+ }
+ mgr := NewManager(attacher)
+ if err := mgr.AttachAll(nil, []string{"sys_enter_read", "sys_exit_read"}); err != nil {
+ t.Fatalf("AttachAll returned error: %v", err)
+ }
+ if !mgr.IsActive("read") {
+ t.Fatalf("expected read to be active")
+ }
+ if err := mgr.Detach("read"); err != nil {
+ t.Fatalf("Detach returned error: %v", err)
+ }
+ if mgr.IsActive("read") {
+ t.Fatalf("expected read to be inactive after detach")
+ }
+ if mgr.IsActive("does_not_exist") {
+ t.Fatalf("expected unknown syscall to be inactive")
+ }
+}
diff --git a/internal/statsengine/engine.go b/internal/statsengine/engine.go
index fd46cc3..1ef58cf 100644
--- a/internal/statsengine/engine.go
+++ b/internal/statsengine/engine.go
@@ -16,6 +16,7 @@ type Engine struct {
now func() time.Time
startedAt time.Time
+ topN int
totalSyscalls uint64
totalErrors uint64
@@ -72,6 +73,7 @@ func newEngineWithClock(topN int, now func() time.Time) *Engine {
return &Engine{
now: now,
startedAt: now(),
+ topN: topN,
syscalls: newSyscallAccumulator(),
files: newFileRankerWithConfig(topN),
processes: newProcessAccumulatorWithConfig(topN),
@@ -83,6 +85,33 @@ func newEngineWithClock(topN int, now func() time.Time) *Engine {
}
}
+// Reset clears all accumulated stats and restarts series baselines.
+func (e *Engine) Reset() {
+ if e == nil {
+ return
+ }
+
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ e.startedAt = e.now()
+ e.totalSyscalls = 0
+ e.totalErrors = 0
+ e.totalBytes = 0
+ e.totalReadBytes = 0
+ e.totalWriteBytes = 0
+ e.totalLatency = 0
+ e.totalGap = 0
+ e.syscalls = newSyscallAccumulator()
+ e.files = newFileRankerWithConfig(e.topN)
+ e.processes = newProcessAccumulatorWithConfig(e.topN)
+ e.latencyHist = newHistogram()
+ e.gapHist = newHistogram()
+ e.latencySeries = newRingTimeSeries()
+ e.gapSeries = newRingTimeSeries()
+ e.throughputSeries = newRingTimeSeries()
+}
+
// Ingest updates all aggregates for one event pair.
func (e *Engine) Ingest(pair *event.Pair) {
if e == nil || pair == nil {
diff --git a/internal/statsengine/engine_reset_test.go b/internal/statsengine/engine_reset_test.go
new file mode 100644
index 0000000..7a86c86
--- /dev/null
+++ b/internal/statsengine/engine_reset_test.go
@@ -0,0 +1,27 @@
+package statsengine
+
+import (
+ "testing"
+ "time"
+
+ "ior/internal/types"
+)
+
+func TestEngineResetClearsAccumulatedStats(t *testing.T) {
+ e := NewEngine(8)
+ e.Ingest(newEnginePair(types.SYS_ENTER_READ, 7, types.READ_CLASSIFIED, "test", 1, "/tmp/a", 7, 1000, 50))
+ before := e.Snapshot()
+ if before.TotalSyscalls == 0 {
+ t.Fatalf("expected non-zero totals before reset")
+ }
+
+ time.Sleep(1 * time.Millisecond)
+ e.Reset()
+ after := e.Snapshot()
+ if after.TotalSyscalls != 0 || after.TotalBytes != 0 || after.TotalErrors != 0 {
+ t.Fatalf("expected totals cleared after reset, got %+v", after)
+ }
+ if after.Elapsed > 2*time.Second {
+ t.Fatalf("expected elapsed to restart near zero, got %s", after.Elapsed)
+ }
+}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 8b2c814..9b425b1 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -73,6 +73,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
+ m.streamModel.SetViewport(msg.Width, msg.Height)
return m, nil
case refreshTickMsg:
snap := m.snapshot()
@@ -104,7 +105,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
prevActiveTab := m.activeTab
var cmd tea.Cmd
keyStr := msg.String()
- handled := m.handleArrowTabKey(keyStr) || m.handleScrollKey(keyStr)
+ handled := m.handleArrowTabKey(keyStr) || m.handleScrollKey(msg)
+ if handled && m.activeTab == TabStream && (keyStr == " " || keyStr == "space") && !m.streamModel.Paused() {
+ cmd = streamTickCmd()
+ }
if !handled {
switch {
@@ -171,7 +175,8 @@ func (m *Model) handleArrowTabKey(keyStr string) bool {
}
}
-func (m *Model) handleScrollKey(keyStr string) bool {
+func (m *Model) handleScrollKey(msg tea.KeyMsg) bool {
+ keyStr := msg.String()
switch m.activeTab {
case TabSyscalls:
return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows())
@@ -183,7 +188,9 @@ func (m *Model) handleScrollKey(keyStr string) bool {
case TabProcesses:
return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows())
case TabStream:
- return m.streamModel.HandleKey(keyStr)
+ streamWidth, streamHeight := common.EffectiveViewport(m.width, m.height)
+ m.streamModel.SetViewport(streamWidth, streamHeight)
+ return m.streamModel.HandleTeaKey(msg)
default:
return false
}
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index c1b2e1d..1e54b27 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -1,11 +1,15 @@
package dashboard
import (
+ "fmt"
+ "regexp"
+ "strconv"
"strings"
"testing"
"ior/internal/statsengine"
common "ior/internal/tui/common"
+ "ior/internal/tui/eventstream"
"ior/internal/tui/messages"
tea "github.com/charmbracelet/bubbletea"
@@ -161,6 +165,87 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
}
}
+func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) {
+ rb := eventstream.NewRingBuffer()
+ m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap())
+ m.activeTab = TabStream
+ m.streamModel.HandleKey("space") // pause
+
+ next, cmd := m.Update(tea.KeyMsg{Type: tea.KeySpace})
+ _ = next
+ if cmd == nil {
+ t.Fatalf("expected stream tick command when unpausing stream")
+ }
+}
+
+func TestStreamPausedSupportsJKArrowsAndPageKeys(t *testing.T) {
+ rb := eventstream.NewRingBuffer()
+ for i := 0; i < 300; i++ {
+ rb.Push(eventstream.StreamEvent{
+ Seq: uint64(i + 1),
+ Syscall: "read",
+ Comm: "proc",
+ PID: 1000,
+ TID: uint32(2000 + i),
+ FileName: fmt.Sprintf("/tmp/file-%03d", i),
+ })
+ }
+
+ m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap())
+ m.activeTab = TabStream
+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
+ m = next.(Model)
+
+ m.streamModel.Refresh()
+ _ = m.View()
+
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace}) // pause
+ m = next.(Model)
+ before := rowFromStreamView(t, m.View())
+
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ m = next.(Model)
+ afterK := rowFromStreamView(t, m.View())
+ if afterK >= before {
+ t.Fatalf("expected k to scroll up while paused: before=%d afterK=%d", before, afterK)
+ }
+
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
+ m = next.(Model)
+ afterDown := rowFromStreamView(t, m.View())
+ if afterDown <= afterK {
+ t.Fatalf("expected down arrow to scroll down while paused: afterK=%d afterDown=%d", afterK, afterDown)
+ }
+
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp})
+ m = next.(Model)
+ afterPgUp := rowFromStreamView(t, m.View())
+ if afterPgUp >= afterDown {
+ t.Fatalf("expected pgup to scroll up while paused: afterDown=%d afterPgUp=%d", afterDown, afterPgUp)
+ }
+
+ next, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown})
+ m = next.(Model)
+ afterPgDown := rowFromStreamView(t, m.View())
+ if afterPgDown <= afterPgUp {
+ t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown)
+ }
+}
+
+func rowFromStreamView(t *testing.T, view string) int {
+ t.Helper()
+ re := regexp.MustCompile(`Row ([0-9]+)/([0-9]+)`)
+ m := re.FindStringSubmatch(view)
+ if len(m) != 3 {
+ t.Fatalf("stream row status not found in view")
+ }
+ row, err := strconv.Atoi(m[1])
+ if err != nil {
+ t.Fatalf("invalid row value %q: %v", m[1], err)
+ }
+ return row
+}
+
func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index f51b7b5..0c50d0c 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -36,6 +36,17 @@ func NewModel(source *RingBuffer) Model {
}
}
+// SetViewport updates the render/scroll viewport dimensions used for
+// max-scroll and page-step calculations during key handling.
+func (m *Model) SetViewport(width, height int) {
+ if width > 0 {
+ m.width = width
+ }
+ if height > 0 {
+ m.height = height
+ }
+}
+
// SetSource updates the backing ring buffer and refreshes visible rows.
func (m *Model) SetSource(source *RingBuffer) {
m.source = source
@@ -47,6 +58,11 @@ func (m Model) FilterModalVisible() bool {
return m.filterModal.Visible()
}
+// Paused reports whether stream refresh is currently paused.
+func (m Model) Paused() bool {
+ return m.paused
+}
+
func (m *Model) HandleKey(keyStr string) bool {
if m.filterModal.Visible() {
wasVisible := m.filterModal.Visible()
@@ -66,10 +82,12 @@ func (m *Model) HandleKey(keyStr string) bool {
case " ", "space":
m.paused = !m.paused
if !m.paused {
+ // Resuming should return to live-tail behavior immediately.
+ m.autoScroll = true
m.Refresh()
}
return true
- case "f":
+ case "f", "F":
m.pauseBeforeFilter = m.paused
m.paused = true
m.filterModal = m.filterModal.Open(m.filter)
@@ -87,24 +105,44 @@ func (m *Model) HandleKey(keyStr string) bool {
m.applyFilter()
return true
case "j", "down":
- if m.scrollOffset < m.maxScrollOffset() {
- m.scrollOffset++
- }
- if m.scrollOffset < m.maxScrollOffset() {
- m.autoScroll = false
- }
+ m.scrollByLines(1)
return true
case "k", "up":
- if m.scrollOffset > 0 {
- m.scrollOffset--
- }
- m.autoScroll = false
+ m.scrollByLines(-1)
+ return true
+ case "pgdown", "pgdn", "pagedown":
+ m.scrollByLines(m.pageStep())
+ return true
+ case "pgup", "pageup":
+ m.scrollByLines(-m.pageStep())
return true
default:
return false
}
}
+// HandleTeaKey handles stream keys based on Bubble Tea key message types first,
+// then falls back to string matching for rune-driven shortcuts.
+func (m *Model) HandleTeaKey(msg tea.KeyMsg) bool {
+ switch msg.Type {
+ case tea.KeyUp:
+ return m.HandleKey("up")
+ case tea.KeyDown:
+ return m.HandleKey("down")
+ case tea.KeyPgUp:
+ return m.HandleKey("pgup")
+ case tea.KeyPgDown:
+ return m.HandleKey("pgdown")
+ case tea.KeySpace:
+ return m.HandleKey("space")
+ case tea.KeyRunes:
+ if len(msg.Runes) == 1 {
+ return m.HandleKey(string(msg.Runes[0]))
+ }
+ }
+ return m.HandleKey(msg.String())
+}
+
func (m *Model) View(width, height int) string {
if width <= 0 {
width = 100
@@ -198,6 +236,34 @@ func (m *Model) visibleRows() int {
return rows
}
+func (m *Model) pageStep() int {
+ rows := m.visibleRows()
+ if rows <= 1 {
+ return 1
+ }
+ return rows - 1
+}
+
+func (m *Model) scrollByLines(delta int) {
+ if delta == 0 {
+ return
+ }
+ max := m.maxScrollOffset()
+ next := m.scrollOffset + delta
+ if next < 0 {
+ next = 0
+ }
+ if next > max {
+ next = max
+ }
+ if next != m.scrollOffset {
+ m.scrollOffset = next
+ }
+ if m.scrollOffset < max {
+ m.autoScroll = false
+ }
+}
+
func keyMsgFromString(keyStr string) tea.KeyMsg {
switch keyStr {
case "esc":
diff --git a/internal/tui/eventstream/model_test.go b/internal/tui/eventstream/model_test.go
index 937bb33..bfcbca7 100644
--- a/internal/tui/eventstream/model_test.go
+++ b/internal/tui/eventstream/model_test.go
@@ -62,6 +62,76 @@ func TestModelScrollClamp(t *testing.T) {
}
}
+func TestModelPageScrollWithPgUpPgDown(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 12 // visibleRows=4, pageStep=3
+ pushEvents(rb, 30)
+ m.Refresh()
+ m.HandleKey("g")
+
+ if !m.HandleKey("pgdown") {
+ t.Fatalf("pgdown should be handled")
+ }
+ if m.scrollOffset != 3 {
+ t.Fatalf("expected page down to move by 3, got %d", m.scrollOffset)
+ }
+
+ if !m.HandleKey("pagedown") {
+ t.Fatalf("pagedown should be handled")
+ }
+ if m.scrollOffset != 6 {
+ t.Fatalf("expected pagedown alias to move by 3, got %d", m.scrollOffset)
+ }
+
+ if !m.HandleKey("pgup") {
+ t.Fatalf("pgup should be handled")
+ }
+ if m.scrollOffset != 3 {
+ t.Fatalf("expected page up to move up by 3, got %d", m.scrollOffset)
+ }
+ if !m.HandleKey("pageup") {
+ t.Fatalf("pageup should be handled")
+ }
+ if m.scrollOffset != 0 {
+ t.Fatalf("expected pageup alias to return to top, got %d", m.scrollOffset)
+ }
+}
+
+func TestModelArrowAndJKScroll(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 12
+ pushEvents(rb, 30)
+ m.Refresh()
+ m.HandleKey("g")
+
+ if !m.HandleKey("down") {
+ t.Fatalf("down should be handled")
+ }
+ if m.scrollOffset != 1 {
+ t.Fatalf("expected down to increment offset, got %d", m.scrollOffset)
+ }
+ if !m.HandleKey("j") {
+ t.Fatalf("j should be handled")
+ }
+ if m.scrollOffset != 2 {
+ t.Fatalf("expected j to increment offset, got %d", m.scrollOffset)
+ }
+ if !m.HandleKey("up") {
+ t.Fatalf("up should be handled")
+ }
+ if m.scrollOffset != 1 {
+ t.Fatalf("expected up to decrement offset, got %d", m.scrollOffset)
+ }
+ if !m.HandleKey("k") {
+ t.Fatalf("k should be handled")
+ }
+ if m.scrollOffset != 0 {
+ t.Fatalf("expected k to decrement offset, got %d", m.scrollOffset)
+ }
+}
+
func TestModelFilterReducesVisibleRows(t *testing.T) {
rb := NewRingBuffer()
m := NewModel(rb)
@@ -170,3 +240,70 @@ func TestFilterModalTemporarilyPausesAndRestoresState(t *testing.T) {
t.Fatalf("expected paused state preserved after modal close")
}
}
+
+func TestUnpauseRestoresLiveTailAndRefresh(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 10
+ pushEvents(rb, 20)
+ m.Refresh()
+
+ // Move off tail, then pause.
+ m.HandleKey("g")
+ if m.autoScroll {
+ t.Fatalf("expected autoScroll disabled at top")
+ }
+ m.HandleKey("space")
+ if !m.paused {
+ t.Fatalf("expected paused")
+ }
+
+ // New events arrive while paused.
+ pushEvents(rb, 5)
+ m.Refresh()
+
+ // Resume: should auto-tail and refresh immediately.
+ m.HandleKey("space")
+ if m.paused {
+ t.Fatalf("expected unpaused")
+ }
+ if !m.autoScroll {
+ t.Fatalf("expected autoScroll restored on resume")
+ }
+ if m.scrollOffset != m.maxScrollOffset() {
+ t.Fatalf("expected tail offset after resume, got offset=%d max=%d", m.scrollOffset, m.maxScrollOffset())
+ }
+}
+
+func TestPausedScrollWithJKAndPageKeys(t *testing.T) {
+ rb := NewRingBuffer()
+ m := NewModel(rb)
+ m.height = 20
+ pushEvents(rb, 100)
+ m.Refresh()
+ if !m.HandleKey("space") {
+ t.Fatalf("space should toggle pause")
+ }
+ before := rowNumber(m.scrollOffset, len(m.filtered))
+ if !m.HandleKey("k") {
+ t.Fatalf("k should be handled while paused")
+ }
+ afterK := rowNumber(m.scrollOffset, len(m.filtered))
+ if afterK >= before {
+ t.Fatalf("expected k to scroll up while paused: before=%d after=%d", before, afterK)
+ }
+ if !m.HandleKey("pgup") {
+ t.Fatalf("pgup should be handled while paused")
+ }
+ afterPgUp := rowNumber(m.scrollOffset, len(m.filtered))
+ if afterPgUp >= afterK {
+ t.Fatalf("expected pgup to scroll up while paused: afterK=%d afterPgUp=%d", afterK, afterPgUp)
+ }
+ if !m.HandleKey("pgdown") {
+ t.Fatalf("pgdown should be handled while paused")
+ }
+ afterPgDown := rowNumber(m.scrollOffset, len(m.filtered))
+ if afterPgDown <= afterPgUp {
+ t.Fatalf("expected pgdown to scroll down while paused: afterPgUp=%d afterPgDown=%d", afterPgUp, afterPgDown)
+ }
+}
diff --git a/internal/tui/eventstream/render.go b/internal/tui/eventstream/render.go
index e1781f8..24864b9 100644
--- a/internal/tui/eventstream/render.go
+++ b/internal/tui/eventstream/render.go
@@ -32,7 +32,7 @@ func RenderStreamTable(width int, paused bool, totalCount, filteredCount, buffer
lines = append(lines, renderEventRow(ev, contentWidth))
}
- return common.PanelStyle.Width(width).Render(strings.Join(lines, "\n"))
+ return common.PanelStyle.Width(contentWidth).Render(strings.Join(lines, "\n"))
}
func renderStatusLine(paused bool, totalCount, filteredCount, bufferLen, bufferCap int) string {
diff --git a/internal/tui/eventstream/render_test.go b/internal/tui/eventstream/render_test.go
index c7f32cd..65d7a61 100644
--- a/internal/tui/eventstream/render_test.go
+++ b/internal/tui/eventstream/render_test.go
@@ -3,6 +3,8 @@ package eventstream
import (
"strings"
"testing"
+
+ "github.com/charmbracelet/lipgloss"
)
func TestRenderStatusAndFilterLines(t *testing.T) {
@@ -100,3 +102,25 @@ func TestComputeColumnLayoutGivesFileMoreSpace(t *testing.T) {
t.Fatalf("expected file column to get most width, got %d", cols.file)
}
}
+
+func TestRenderStreamTableFitsRequestedWidth(t *testing.T) {
+ out := RenderStreamTable(80, false, 1, 1, 1, 10000, Filter{}, []StreamEvent{
+ {
+ Syscall: "read",
+ Comm: "worker",
+ PID: 1,
+ TID: 2,
+ DurationNs: 2000,
+ GapNs: 100,
+ Bytes: 64,
+ FileName: "/very/long/path/that/should/be/truncated/for/narrow/views/file.log",
+ RetVal: 1,
+ },
+ })
+
+ for _, line := range strings.Split(out, "\n") {
+ if lipgloss.Width(line) > 80 {
+ t.Fatalf("line exceeds width 80: %d %q", lipgloss.Width(line), line)
+ }
+ }
+}
diff --git a/internal/tui/probes/model.go b/internal/tui/probes/model.go
index dffa30f..5cec2c7 100644
--- a/internal/tui/probes/model.go
+++ b/internal/tui/probes/model.go
@@ -117,9 +117,9 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
}
return m, toggleCmd(m.manager, selected)
case "a":
- return m, bulkToggleCmd(m.manager, m.filtered(), false)
+ return m, bulkToggleCmd(m.manager, m.probes, false)
case "n":
- return m, bulkToggleCmd(m.manager, m.filtered(), true)
+ return m, bulkToggleCmd(m.manager, m.probes, true)
}
}
return m, nil
diff --git a/internal/tui/probes/model_test.go b/internal/tui/probes/model_test.go
index 74f6a6b..73a83bc 100644
--- a/internal/tui/probes/model_test.go
+++ b/internal/tui/probes/model_test.go
@@ -78,3 +78,44 @@ func TestToggleEmitsProbeToggledMsg(t *testing.T) {
}
_ = next
}
+
+func TestBulkKeysApplyGloballyNotOnlyFiltered(t *testing.T) {
+ fm := &fakeManager{
+ states: []probemanager.ProbeState{
+ {Syscall: "read", Active: true},
+ {Syscall: "write", Active: true},
+ {Syscall: "openat", Active: true},
+ },
+ }
+ m := NewModel(fm).Open()
+ m.search = "read"
+
+ _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}})
+ if cmd == nil {
+ t.Fatalf("expected bulk off command")
+ }
+ msg := cmd()
+ if toggled, ok := msg.(ProbeToggledMsg); !ok || toggled.Err != nil {
+ t.Fatalf("unexpected bulk off msg: %#v", msg)
+ }
+ if len(fm.toggles) != 3 {
+ t.Fatalf("expected all probes toggled off despite filter, got toggles=%+v", fm.toggles)
+ }
+
+ // Re-open with all inactive and filtered search still present; "a" should
+ // toggle all probes back on.
+ m = NewModel(fm).Open()
+ m.search = "read"
+ fm.toggles = nil
+ _, cmd = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'a'}})
+ if cmd == nil {
+ t.Fatalf("expected bulk on command")
+ }
+ msg = cmd()
+ if toggled, ok := msg.(ProbeToggledMsg); !ok || toggled.Err != nil {
+ t.Fatalf("unexpected bulk on msg: %#v", msg)
+ }
+ if len(fm.toggles) != 3 {
+ t.Fatalf("expected all probes toggled on despite filter, got toggles=%+v", fm.toggles)
+ }
+}
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index 5dba75c..5a8c14b 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -12,6 +12,7 @@ import (
dashboardui "ior/internal/tui/dashboard"
"ior/internal/tui/eventstream"
tuiexport "ior/internal/tui/export"
+ "ior/internal/tui/messages"
"ior/internal/tui/pidpicker"
"ior/internal/tui/probes"
"os"
@@ -104,6 +105,21 @@ func getProbeManager() ProbeManager {
return probeManagerState.manager
}
+func resetDashboardSnapshotSource() *statsengine.Snapshot {
+ src := getDashboardSnapshotSource()
+ if src == nil {
+ return nil
+ }
+ if resettable, ok := src.(interface {
+ Reset()
+ Snapshot() *statsengine.Snapshot
+ }); ok {
+ resettable.Reset()
+ return resettable.Snapshot()
+ }
+ return nil
+}
+
// Run starts the TUI program in alternate screen mode.
func Run() error {
return RunWithTraceStarter(defaultTraceStarter)
@@ -233,6 +249,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case probes.ProbeToggledMsg:
var cmd tea.Cmd
m.probeModal, cmd = m.probeModal.Update(msg)
+ if snap := resetDashboardSnapshotSource(); snap != nil {
+ next, dashboardCmd := m.dashboard.Update(messages.StatsTickMsg{Snap: snap})
+ m.dashboard = next.(dashboardui.Model)
+ return m, tea.Batch(dashboardCmd, cmd)
+ }
return m, cmd
case PidSelectedMsg:
return m.handlePidSelected(msg)
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index 0f55b40..31a2e94 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -15,6 +15,7 @@ import (
"time"
"ior/internal/flags"
+ "ior/internal/tui/probes"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
@@ -186,6 +187,20 @@ func (f fakeDashboardSource) Snapshot() *statsengine.Snapshot {
return f.snap
}
+type fakeResettableDashboardSource struct {
+ snap *statsengine.Snapshot
+ resetCalls int
+}
+
+func (f *fakeResettableDashboardSource) Snapshot() *statsengine.Snapshot {
+ return f.snap
+}
+
+func (f *fakeResettableDashboardSource) Reset() {
+ f.resetCalls++
+ f.snap = &statsengine.Snapshot{TotalSyscalls: 0}
+}
+
func TestDashboardRefreshPicksLateBoundSource(t *testing.T) {
orig := getDashboardSnapshotSource()
defer SetDashboardSnapshotSource(orig)
@@ -202,6 +217,28 @@ func TestDashboardRefreshPicksLateBoundSource(t *testing.T) {
}
}
+func TestProbeToggledMsgResetsDashboardStatsSource(t *testing.T) {
+ src := &fakeResettableDashboardSource{snap: &statsengine.Snapshot{TotalSyscalls: 99}}
+ SetDashboardSnapshotSource(src)
+ t.Cleanup(func() { SetDashboardSnapshotSource(nil) })
+
+ m := NewModel(-1, func(context.Context) error { return nil })
+ m.screen = ScreenDashboard
+ m.attaching = false
+ m.probeModal = probes.NewModel(fakeProbeManager{states: []probemanager.ProbeState{{Syscall: "read", Active: true}}}).Open()
+
+ next, _ := m.Update(probes.ProbeToggledMsg{Syscall: "read"})
+ updated := next.(Model)
+
+ if src.resetCalls != 1 {
+ t.Fatalf("expected one reset call, got %d", src.resetCalls)
+ }
+ snap := updated.dashboard.LatestSnapshot()
+ if snap == nil || snap.TotalSyscalls != 0 {
+ t.Fatalf("expected dashboard snapshot refreshed from reset source, got %+v", snap)
+ }
+}
+
func TestTracingStartedRebindsEventStreamSource(t *testing.T) {
orig := getEventStreamSource()
defer SetEventStreamSource(orig)