summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-25 08:43:15 +0200
committerPaul Buetow <paul@buetow.org>2026-02-25 08:43:15 +0200
commitd423225771a10ebae87d22c69fe88e5b65a3d378 (patch)
tree9f701073be1e53ff06d89eb7c55f5b58b8aba1d3
parent1a6e71ac31353167ec4c614d45e8e06de411a8f9 (diff)
Integrate Stream tab into dashboard and TUI
-rw-r--r--internal/tui/common/keys.go4
-rw-r--r--internal/tui/dashboard/model.go105
-rw-r--r--internal/tui/dashboard/model_test.go26
-rw-r--r--internal/tui/dashboard/tabs.go5
-rw-r--r--internal/tui/dashboard/tabs_test.go13
-rw-r--r--internal/tui/tui.go2
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,