summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/dashboard/model.go151
-rw-r--r--internal/tui/dashboard/model_test.go99
-rw-r--r--internal/tui/dashboard/tabs.go103
-rw-r--r--internal/tui/dashboard/tabs_test.go24
4 files changed, 377 insertions, 0 deletions
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)
+ }
+ }
+}