diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-25 08:43:15 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-25 08:43:15 +0200 |
| commit | d423225771a10ebae87d22c69fe88e5b65a3d378 (patch) | |
| tree | 9f701073be1e53ff06d89eb7c55f5b58b8aba1d3 /internal/tui | |
| parent | 1a6e71ac31353167ec4c614d45e8e06de411a8f9 (diff) | |
Integrate Stream tab into dashboard and TUI
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/keys.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 105 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 26 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 5 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 13 | ||||
| -rw-r--r-- | internal/tui/tui.go | 2 |
6 files changed, 108 insertions, 47 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index 805a74a..1abf214 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -12,6 +12,7 @@ type KeyMap struct { Four key.Binding Five key.Binding Six key.Binding + Seven key.Binding Export key.Binding Quit key.Binding Help key.Binding @@ -34,6 +35,7 @@ func DefaultKeyMap() KeyMap { Four: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "processes")), Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "latency")), Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "gaps")), + Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")), Export: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "export")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), @@ -62,7 +64,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { controls = append(controls, k.Refresh, k.Help, k.Quit) return [][]key.Binding{ - {k.One, k.Two, k.Three, k.Four, k.Five, k.Six}, + {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven}, controls, { helpTextBinding("left/right", "tab"), diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index c485dba..5500369 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -3,6 +3,7 @@ package dashboard import ( "ior/internal/statsengine" common "ior/internal/tui/common" + "ior/internal/tui/eventstream" "ior/internal/tui/messages" "strings" "time" @@ -12,6 +13,7 @@ import ( ) const defaultRefreshMs = 1000 +const streamRefreshMs = 200 // SnapshotSource is the dashboard data source. type SnapshotSource interface { @@ -19,6 +21,7 @@ type SnapshotSource interface { } type refreshTickMsg struct{} +type streamTickMsg struct{} // Model is the dashboard tab framework model. type Model struct { @@ -35,15 +38,16 @@ type Model struct { syscallsOffset int filesOffset int processesOffset int + streamModel eventstream.Model } // NewModel creates a dashboard model with default refresh cadence. -func NewModel(engine SnapshotSource) Model { - return NewModelWithConfig(engine, defaultRefreshMs, common.Keys) +func NewModel(engine SnapshotSource, streamSource *eventstream.RingBuffer) Model { + return NewModelWithConfig(engine, streamSource, defaultRefreshMs, common.Keys) } // NewModelWithConfig creates a dashboard model with explicit refresh and keys. -func NewModelWithConfig(engine SnapshotSource, refreshMs int, keys common.KeyMap) Model { +func NewModelWithConfig(engine SnapshotSource, streamSource *eventstream.RingBuffer, refreshMs int, keys common.KeyMap) Model { if refreshMs <= 0 { refreshMs = defaultRefreshMs } @@ -52,6 +56,7 @@ func NewModelWithConfig(engine SnapshotSource, refreshMs int, keys common.KeyMap engine: engine, refreshEvery: time.Duration(refreshMs) * time.Millisecond, keys: keys, + streamModel: eventstream.NewModel(streamSource), } } @@ -73,11 +78,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tickCmd(m.refreshEvery), func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }, ) + case streamTickMsg: + if m.activeTab != TabStream { + return m, nil + } + m.streamModel.Refresh() + return m, streamTickCmd() case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) m.filesOffset = clampOffset(m.filesOffset, m.maxFilesRows()) m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) + m.streamModel.Refresh() return m, nil case tea.KeyMsg: return m.handleKey(msg) @@ -86,36 +98,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + prevActiveTab := m.activeTab + var cmd tea.Cmd keyStr := msg.String() - if m.handleArrowTabKey(keyStr) { - return m, nil + handled := m.handleArrowTabKey(keyStr) || m.handleScrollKey(keyStr) + + if !handled { + switch { + case key.Matches(msg, m.keys.Tab): + m.activeTab = nextTab(m.activeTab) + handled = true + case key.Matches(msg, m.keys.ShiftTab): + m.activeTab = prevTab(m.activeTab) + handled = true + case key.Matches(msg, m.keys.One): + m.activeTab = TabOverview + handled = true + case key.Matches(msg, m.keys.Two): + m.activeTab = TabSyscalls + handled = true + case key.Matches(msg, m.keys.Three): + m.activeTab = TabFiles + handled = true + case key.Matches(msg, m.keys.Four): + m.activeTab = TabProcesses + handled = true + case key.Matches(msg, m.keys.Five): + m.activeTab = TabLatency + handled = true + case key.Matches(msg, m.keys.Six): + m.activeTab = TabGaps + handled = true + case key.Matches(msg, m.keys.Seven): + m.activeTab = TabStream + handled = true + case key.Matches(msg, m.keys.Refresh): + snap := m.snapshot() + cmd = func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } + handled = true + } } - if m.handleScrollKey(keyStr) { + if !handled { return m, nil } - - switch { - case key.Matches(msg, m.keys.Tab): - m.activeTab = nextTab(m.activeTab) - case key.Matches(msg, m.keys.ShiftTab): - m.activeTab = prevTab(m.activeTab) - case key.Matches(msg, m.keys.One): - m.activeTab = TabOverview - case key.Matches(msg, m.keys.Two): - m.activeTab = TabSyscalls - case key.Matches(msg, m.keys.Three): - m.activeTab = TabFiles - case key.Matches(msg, m.keys.Four): - m.activeTab = TabProcesses - case key.Matches(msg, m.keys.Five): - m.activeTab = TabLatency - case key.Matches(msg, m.keys.Six): - m.activeTab = TabGaps - case key.Matches(msg, m.keys.Refresh): - snap := m.snapshot() - return m, func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } + if prevActiveTab != TabStream && m.activeTab == TabStream { + if cmd == nil { + return m, streamTickCmd() + } + return m, tea.Batch(cmd, streamTickCmd()) } - return m, nil + return m, cmd } func (m *Model) handleArrowTabKey(keyStr string) bool { @@ -139,6 +171,8 @@ func (m *Model) handleScrollKey(keyStr string) bool { return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()) case TabProcesses: return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()) + case TabStream: + return m.streamModel.HandleKey(keyStr) default: return false } @@ -199,7 +233,7 @@ func (m Model) View() string { var b strings.Builder b.WriteString(renderTabBar(m.activeTab, m.width)) b.WriteString("\n") - b.WriteString(renderActiveTab(m.activeTab, m.latest, m.width, m.height, m.syscallsOffset, m.filesOffset, m.processesOffset)) + b.WriteString(renderActiveTab(m.activeTab, m.latest, &m.streamModel, m.width, m.height, m.syscallsOffset, m.filesOffset, m.processesOffset)) b.WriteString("\n") b.WriteString(common.HighlightStyle.Render("Press ? for help")) b.WriteString("\n") @@ -211,7 +245,14 @@ func tickCmd(d time.Duration) tea.Cmd { return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} }) } -func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscallsOffset, filesOffset, processesOffset int) string { +func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, width, height, syscallsOffset, filesOffset, processesOffset int) string { + if tab == TabStream { + if streamModel == nil { + return common.PanelStyle.Render("Stream: waiting for source...") + } + return streamModel.View(width, height) + } + if snap == nil { return common.PanelStyle.Render(tab.String() + ": waiting for stats...") } @@ -233,3 +274,7 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscall return common.PanelStyle.Render("Unknown tab") } } + +func streamTickCmd() tea.Cmd { + return tea.Tick(streamRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return streamTickMsg{} }) +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 7e4d9b4..b0ce933 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -22,7 +22,7 @@ func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot { } func TestKeySwitchingChangesActiveTab(t *testing.T) { - m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) model := next.(Model) @@ -41,10 +41,16 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) { if model.activeTab != TabSyscalls { t.Fatalf("expected previous tab to be syscalls, got %v", model.activeTab) } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'7'}}) + model = next.(Model) + if model.activeTab != TabStream { + t.Fatalf("expected stream tab on key 7, got %v", model.activeTab) + } } func TestArrowAndViKeysCycleTabs(t *testing.T) { - m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) model := next.(Model) @@ -72,7 +78,7 @@ func TestArrowAndViKeysCycleTabs(t *testing.T) { } func TestSyscallsTabScrollsWithJK(t *testing.T) { - m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}, {Name: "write", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap @@ -91,7 +97,7 @@ func TestSyscallsTabScrollsWithJK(t *testing.T) { } func TestProcessesTabScrollsWithJK(t *testing.T) { - m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabProcesses snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1}, {PID: 2}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap @@ -110,7 +116,7 @@ func TestProcessesTabScrollsWithJK(t *testing.T) { } func TestFilesTabScrollsWithJK(t *testing.T) { - m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}, {Path: "/b"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap @@ -129,7 +135,7 @@ func TestFilesTabScrollsWithJK(t *testing.T) { } func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { - m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}, {Name: "write", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) m.latest = &snap @@ -146,7 +152,7 @@ func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { func TestRefreshKeyEmitsRefreshTick(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 13} engine := &fakeSnapshotSource{snap: snap} - m := NewModelWithConfig(engine, 250, common.DefaultKeyMap()) + m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) next, cmd := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) _ = next if cmd == nil { @@ -165,7 +171,7 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { func TestRefreshTickEmitsStatsTickMsg(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 9} engine := &fakeSnapshotSource{snap: snap} - m := NewModelWithConfig(engine, 100, common.DefaultKeyMap()) + m := NewModelWithConfig(engine, nil, 100, common.DefaultKeyMap()) next, cmd := m.Update(refreshTickMsg{}) if cmd == nil { @@ -197,7 +203,7 @@ func TestRefreshTickEmitsStatsTickMsg(t *testing.T) { func TestStatsTickMsgUpdatesLatestSnapshot(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 11} - m := NewModel(nil) + m := NewModel(nil, nil) next, _ := m.Update(messages.StatsTickMsg{Snap: snap}) model := next.(Model) @@ -207,7 +213,7 @@ func TestStatsTickMsgUpdatesLatestSnapshot(t *testing.T) { } func TestViewRendersTabBarAndHelp(t *testing.T) { - m := NewModelWithConfig(nil, 1000, common.DefaultKeyMap()) + m := NewModelWithConfig(nil, nil, 1000, common.DefaultKeyMap()) out := m.View() if !strings.Contains(out, "Overview") { t.Fatalf("expected overview label in view") diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go index 799e9f1..9aae218 100644 --- a/internal/tui/dashboard/tabs.go +++ b/internal/tui/dashboard/tabs.go @@ -24,6 +24,8 @@ const ( TabLatency // TabGaps is the inter-syscall gap tab. TabGaps + // TabStream is the live event stream tab. + TabStream ) var allTabs = []Tab{ @@ -33,6 +35,7 @@ var allTabs = []Tab{ TabProcesses, TabLatency, TabGaps, + TabStream, } func (t Tab) String() string { @@ -49,6 +52,8 @@ func (t Tab) String() string { return "Latency" case TabGaps: return "Gaps" + case TabStream: + return "Stream" default: return "Unknown" } diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index 9d3db2d..d40cc68 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -6,17 +6,20 @@ import ( ) func TestTabNavigationWraps(t *testing.T) { - if got := nextTab(TabGaps); got != TabOverview { - t.Fatalf("expected wrap to overview, got %v", got) + if got := nextTab(TabGaps); got != TabStream { + t.Fatalf("expected next after gaps to be stream, got %v", got) } - if got := prevTab(TabOverview); got != TabGaps { - t.Fatalf("expected wrap to gaps, got %v", got) + if got := nextTab(TabStream); got != TabOverview { + t.Fatalf("expected wrap to overview from stream, got %v", got) + } + if got := prevTab(TabOverview); got != TabStream { + t.Fatalf("expected wrap to stream, got %v", got) } } func TestRenderTabBarContainsLabels(t *testing.T) { out := renderTabBar(TabOverview, 80) - for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency", "Gaps"} { + for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency", "Gaps", "Stream"} { if !strings.Contains(out, label) { t.Fatalf("expected tab label %q in tab bar", label) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d9dd00e..84d8cab 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -126,7 +126,7 @@ func NewModel(initialPID int, startTrace TraceStarter) Model { model := Model{ screen: ScreenPIDPicker, pidPicker: pidpicker.New(), - dashboard: dashboardui.NewModelWithConfig(lateBoundDashboardSource{}, 1000, keys), + dashboard: dashboardui.NewModelWithConfig(lateBoundDashboardSource{}, getEventStreamSource(), 1000, keys), exporter: tuiexport.NewModel(), keys: keys, spin: spin, |
