diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 22:58:40 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 22:58:40 +0200 |
| commit | 4c34b9efcd539c819648c927d7e3f53220df8ad2 (patch) | |
| tree | f9de9fd650a2d16316ba2c159990d891c9de5189 | |
| parent | 67e10f34c92e93343adbd690b3b21e455e863bd3 (diff) | |
Fix stream paused scrolling and apply pending TUI/probe updates
| -rw-r--r-- | internal/ior.go | 10 | ||||
| -rw-r--r-- | internal/probemanager/manager.go | 16 | ||||
| -rw-r--r-- | internal/probemanager/manager_test.go | 26 | ||||
| -rw-r--r-- | internal/statsengine/engine.go | 29 | ||||
| -rw-r--r-- | internal/statsengine/engine_reset_test.go | 27 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 13 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 85 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 88 | ||||
| -rw-r--r-- | internal/tui/eventstream/model_test.go | 137 | ||||
| -rw-r--r-- | internal/tui/eventstream/render.go | 2 | ||||
| -rw-r--r-- | internal/tui/eventstream/render_test.go | 24 | ||||
| -rw-r--r-- | internal/tui/probes/model.go | 4 | ||||
| -rw-r--r-- | internal/tui/probes/model_test.go | 41 | ||||
| -rw-r--r-- | internal/tui/tui.go | 21 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 37 |
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) |
