From 9c400937d9e32f3ce85c668d9ca52c351f8b5d13 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 24 Feb 2026 08:32:39 +0200 Subject: tui: add dashboard tab framework model --- internal/tui/dashboard/model.go | 151 +++++++++++++++++++++++++++++++++++ internal/tui/dashboard/model_test.go | 99 +++++++++++++++++++++++ internal/tui/dashboard/tabs.go | 103 ++++++++++++++++++++++++ internal/tui/dashboard/tabs_test.go | 24 ++++++ 4 files changed, 377 insertions(+) create mode 100644 internal/tui/dashboard/model.go create mode 100644 internal/tui/dashboard/model_test.go create mode 100644 internal/tui/dashboard/tabs.go create mode 100644 internal/tui/dashboard/tabs_test.go (limited to 'internal') diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go new file mode 100644 index 0000000..3c4d9b8 --- /dev/null +++ b/internal/tui/dashboard/model.go @@ -0,0 +1,151 @@ +package dashboard + +import ( + "fmt" + "ior/internal/statsengine" + "ior/internal/tui" + "ior/internal/tui/messages" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" +) + +const defaultRefreshMs = 1000 + +// SnapshotSource is the dashboard data source. +type SnapshotSource interface { + Snapshot() *statsengine.Snapshot +} + +type refreshTickMsg struct{} + +// Model is the dashboard tab framework model. +type Model struct { + activeTab Tab + + engine SnapshotSource + latest *statsengine.Snapshot + + width int + height int + + refreshEvery time.Duration + keys tui.KeyMap +} + +// NewModel creates a dashboard model with default refresh cadence. +func NewModel(engine SnapshotSource) Model { + return NewModelWithConfig(engine, defaultRefreshMs, tui.Keys) +} + +// NewModelWithConfig creates a dashboard model with explicit refresh and keys. +func NewModelWithConfig(engine SnapshotSource, refreshMs int, keys tui.KeyMap) Model { + if refreshMs <= 0 { + refreshMs = defaultRefreshMs + } + return Model{ + activeTab: TabOverview, + engine: engine, + refreshEvery: time.Duration(refreshMs) * time.Millisecond, + keys: keys, + } +} + +// Init starts periodic refresh ticks. +func (m Model) Init() tea.Cmd { + return tickCmd(m.refreshEvery) +} + +// Update handles ticks, snapshots, tab changes, and resize events. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + case refreshTickMsg: + snap := m.snapshot() + return m, tea.Batch( + tickCmd(m.refreshEvery), + func() tea.Msg { return messages.StatsTickMsg{Snap: snap} }, + ) + case messages.StatsTickMsg: + m.latest = msg.Snap + return m, nil + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + 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 + } + return m, nil +} + +func (m Model) snapshot() *statsengine.Snapshot { + if m.engine == nil { + return nil + } + return m.engine.Snapshot() +} + +// View renders the tab bar, active tab scaffold, and help bar. +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)) + b.WriteString("\n") + b.WriteString(renderHelpBar(m.keys)) + return tui.ScreenStyle.Render(b.String()) +} + +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 int) string { + _ = width + _ = height + + if snap == nil { + return tui.PanelStyle.Render(tab.String() + ": waiting for stats...") + } + + switch tab { + case TabOverview: + return tui.PanelStyle.Render(fmt.Sprintf("Overview: %d syscalls", snap.TotalSyscalls)) + case TabSyscalls: + return tui.PanelStyle.Render(fmt.Sprintf("Syscalls: %d rows", len(snap.Syscalls()))) + case TabFiles: + return tui.PanelStyle.Render(fmt.Sprintf("Files: %d rows", len(snap.Files()))) + case TabProcesses: + return tui.PanelStyle.Render(fmt.Sprintf("Processes: %d rows", len(snap.Processes()))) + case TabLatency: + return tui.PanelStyle.Render("Latency histogram") + case TabGaps: + return tui.PanelStyle.Render("Gap histogram") + default: + return tui.PanelStyle.Render("Unknown tab") + } +} diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go new file mode 100644 index 0000000..ddb457a --- /dev/null +++ b/internal/tui/dashboard/model_test.go @@ -0,0 +1,99 @@ +package dashboard + +import ( + "strings" + "testing" + + "ior/internal/statsengine" + "ior/internal/tui" + "ior/internal/tui/messages" + + tea "github.com/charmbracelet/bubbletea" +) + +type fakeSnapshotSource struct { + snapshots int + snap *statsengine.Snapshot +} + +func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot { + f.snapshots++ + return f.snap +} + +func TestKeySwitchingChangesActiveTab(t *testing.T) { + m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap()) + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'2'}}) + model := next.(Model) + if model.activeTab != TabSyscalls { + t.Fatalf("expected syscalls tab, got %v", model.activeTab) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyTab}) + model = next.(Model) + if model.activeTab != TabFiles { + t.Fatalf("expected next tab to be files, got %v", model.activeTab) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyShiftTab}) + model = next.(Model) + if model.activeTab != TabSyscalls { + t.Fatalf("expected previous tab to be syscalls, got %v", model.activeTab) + } +} + +func TestRefreshTickEmitsStatsTickMsg(t *testing.T) { + snap := &statsengine.Snapshot{TotalSyscalls: 9} + engine := &fakeSnapshotSource{snap: snap} + m := NewModelWithConfig(engine, 100, tui.DefaultKeyMap()) + + next, cmd := m.Update(refreshTickMsg{}) + if cmd == nil { + t.Fatalf("expected tick command batch") + } + if engine.snapshots != 1 { + t.Fatalf("expected one snapshot call, got %d", engine.snapshots) + } + + msg := cmd() + switch v := msg.(type) { + case tea.BatchMsg: + var sawStats bool + for _, c := range v { + out := c() + if stats, ok := out.(messages.StatsTickMsg); ok && stats.Snap == snap { + sawStats = true + } + } + if !sawStats { + t.Fatalf("expected StatsTickMsg in batch output") + } + default: + t.Fatalf("expected batch message, got %T", msg) + } + + _ = next +} + +func TestStatsTickMsgUpdatesLatestSnapshot(t *testing.T) { + snap := &statsengine.Snapshot{TotalSyscalls: 11} + m := NewModel(nil) + + next, _ := m.Update(messages.StatsTickMsg{Snap: snap}) + model := next.(Model) + if model.latest != snap { + t.Fatalf("expected latest snapshot to be updated") + } +} + +func TestViewRendersTabBarAndHelp(t *testing.T) { + m := NewModelWithConfig(nil, 1000, tui.DefaultKeyMap()) + out := m.View() + if !strings.Contains(out, "Overview") { + t.Fatalf("expected overview label in view") + } + if !strings.Contains(out, "tab next tab") { + t.Fatalf("expected help bar text in view") + } +} diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go new file mode 100644 index 0000000..d456b44 --- /dev/null +++ b/internal/tui/dashboard/tabs.go @@ -0,0 +1,103 @@ +package dashboard + +import ( + "ior/internal/tui" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// Tab is a dashboard tab identifier. +type Tab int + +const ( + // TabOverview is the high-level summary tab. + TabOverview Tab = iota + // TabSyscalls is the syscall table tab. + TabSyscalls + // TabFiles is the file ranking tab. + TabFiles + // TabProcesses is the process breakdown tab. + TabProcesses + // TabLatency is the latency histogram tab. + TabLatency + // TabGaps is the inter-syscall gap tab. + TabGaps +) + +var allTabs = []Tab{ + TabOverview, + TabSyscalls, + TabFiles, + TabProcesses, + TabLatency, + TabGaps, +} + +func (t Tab) String() string { + switch t { + case TabOverview: + return "Overview" + case TabSyscalls: + return "Syscalls" + case TabFiles: + return "Files" + case TabProcesses: + return "Processes" + case TabLatency: + return "Latency" + case TabGaps: + return "Gaps" + default: + return "Unknown" + } +} + +func nextTab(tab Tab) Tab { + idx := tabIndex(tab) + return allTabs[(idx+1)%len(allTabs)] +} + +func prevTab(tab Tab) Tab { + idx := tabIndex(tab) + if idx == 0 { + return allTabs[len(allTabs)-1] + } + return allTabs[idx-1] +} + +func tabIndex(tab Tab) int { + for i, candidate := range allTabs { + if candidate == tab { + return i + } + } + return 0 +} + +func renderTabBar(active Tab, width int) string { + parts := make([]string, 0, len(allTabs)) + for _, tab := range allTabs { + label := tab.String() + if tab == active { + parts = append(parts, tui.TabActiveStyle.Render(label)) + } else { + parts = append(parts, tui.TabInactiveStyle.Render(label)) + } + } + + bar := lipgloss.JoinHorizontal(lipgloss.Left, parts...) + if width <= 0 { + return bar + } + return lipgloss.NewStyle().Width(width).Render(bar) +} + +func renderHelpBar(keys tui.KeyMap) string { + parts := make([]string, 0, len(keys.DashboardShortHelp())) + for _, binding := range keys.DashboardShortHelp() { + help := binding.Help() + parts = append(parts, help.Key+" "+help.Desc) + } + return tui.HelpBarStyle.Render(strings.Join(parts, " • ")) +} diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go new file mode 100644 index 0000000..9d3db2d --- /dev/null +++ b/internal/tui/dashboard/tabs_test.go @@ -0,0 +1,24 @@ +package dashboard + +import ( + "strings" + "testing" +) + +func TestTabNavigationWraps(t *testing.T) { + if got := nextTab(TabGaps); got != TabOverview { + t.Fatalf("expected wrap to overview, got %v", got) + } + if got := prevTab(TabOverview); got != TabGaps { + t.Fatalf("expected wrap to gaps, got %v", got) + } +} + +func TestRenderTabBarContainsLabels(t *testing.T) { + out := renderTabBar(TabOverview, 80) + for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency", "Gaps"} { + if !strings.Contains(out, label) { + t.Fatalf("expected tab label %q in tab bar", label) + } + } +} -- cgit v1.2.3