From 2ae0b33c9f196634eaa55bd6997d1feae9147385 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 24 Feb 2026 17:16:17 +0200 Subject: tui: improve dashboard tab navigation and live updates --- internal/tui/common/keys.go | 2 ++ internal/tui/dashboard/files.go | 24 +++++++------ internal/tui/dashboard/files_test.go | 2 +- internal/tui/dashboard/model.go | 9 +++++ internal/tui/dashboard/model_test.go | 28 +++++++++++++++ internal/tui/dashboard/syscalls.go | 60 ++++++++++++++++++++++++++------- internal/tui/dashboard/syscalls_test.go | 2 +- internal/tui/tui.go | 9 ++++- internal/tui/tui_test.go | 15 +++++++++ 9 files changed, 124 insertions(+), 27 deletions(-) (limited to 'internal/tui') diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go index a7ded71..0f9c54d 100644 --- a/internal/tui/common/keys.go +++ b/internal/tui/common/keys.go @@ -54,6 +54,8 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding { {k.One, k.Two, k.Three, k.Four, k.Five, k.Six}, {k.Tab, k.ShiftTab, k.Export, k.Refresh, k.Help, k.Quit}, { + key.NewBinding(key.WithKeys("left/right"), key.WithHelp("left/right", "tab")), + key.NewBinding(key.WithKeys("h/l"), key.WithHelp("h/l", "tab")), key.NewBinding(key.WithKeys("j/k"), key.WithHelp("j/k", "scroll")), key.NewBinding(key.WithKeys("up/down"), key.WithHelp("up/down", "scroll")), }, diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index 9887f45..945869e 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -24,11 +24,11 @@ func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int columns := []table.Column{ {Title: "Path", Width: filePathWidth(width)}, - {Title: "Accesses", Width: 10}, - {Title: "Bytes Read", Width: 12}, - {Title: "Bytes Written", Width: 14}, - {Title: "Avg Latency", Width: 12}, - {Title: "Max Latency", Width: 12}, + {Title: "Accesses", Width: 8}, + {Title: "Read", Width: 9}, + {Title: "Write", Width: 9}, + {Title: "Avg Latency", Width: 11}, + {Title: "Max Latency", Width: 11}, } tbl := table.New( @@ -60,14 +60,16 @@ func fileRows(files []statsengine.FileSnapshot) []table.Row { func filePathWidth(width int) int { if width <= 0 { - return 48 + return 24 } - w := width - 66 - if w < 20 { - return 20 + // Reserve enough room for non-path columns and table separators so + // latency columns remain visible even on narrower terminals. + w := width - 70 + if w < 14 { + return 14 } - if w > 72 { - return 72 + if w > 52 { + return 52 } return w } diff --git a/internal/tui/dashboard/files_test.go b/internal/tui/dashboard/files_test.go index f889b66..b0a5dbf 100644 --- a/internal/tui/dashboard/files_test.go +++ b/internal/tui/dashboard/files_test.go @@ -22,7 +22,7 @@ func TestRenderFilesIncludesHeaders(t *testing.T) { ) out := renderFiles(&snap, 120, 30) - for _, token := range []string{"Path", "Accesses", "Bytes Read", "Bytes Written", "Avg Latency", "Max Latency", "app.log"} { + for _, token := range []string{"Path", "Accesses", "Read", "Write", "Avg Latency", "Max Latency", "app.log"} { if !strings.Contains(out, token) { t.Fatalf("expected token %q in files table output", token) } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 8eb7619..285b715 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -86,6 +86,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "right", "l": + m.activeTab = nextTab(m.activeTab) + return m, nil + case "left", "h": + m.activeTab = prevTab(m.activeTab) + return m, nil + } + if m.activeTab == TabSyscalls { switch msg.String() { case "down", "j": diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index f1e6f35..7e4d9b4 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -43,6 +43,34 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) { } } +func TestArrowAndViKeysCycleTabs(t *testing.T) { + m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight}) + model := next.(Model) + if model.activeTab != TabSyscalls { + t.Fatalf("expected right arrow to move to syscalls, got %v", model.activeTab) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + model = next.(Model) + if model.activeTab != TabFiles { + t.Fatalf("expected l to move to files, got %v", model.activeTab) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyLeft}) + model = next.(Model) + if model.activeTab != TabSyscalls { + t.Fatalf("expected left arrow to move back to syscalls, got %v", model.activeTab) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + model = next.(Model) + if model.activeTab != TabOverview { + t.Fatalf("expected h to move back to overview, got %v", model.activeTab) + } +} + func TestSyscallsTabScrollsWithJK(t *testing.T) { m := NewModelWithConfig(nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index bdaa3a0..23fe37c 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -18,11 +18,38 @@ func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset return "Syscalls: waiting for stats..." } - rows := syscallRows(snap.Syscalls()) + columns, rows := syscallTableData(snap.Syscalls(), width) if len(rows) == 0 { return "Syscalls: no data" } + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + tbl.SetHeight(syscallTableHeight(height)) + tbl.SetWidth(tableWidth(width)) + cursor := clampOffset(offset, len(rows)) + tbl.SetCursor(cursor) + return tbl.View() + fmt.Sprintf("\nRow %d/%d", cursor+1, len(rows)) +} + +func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]table.Column, []table.Row) { + if width < 140 { + columns := []table.Column{ + {Title: "Syscall", Width: 14}, + {Title: "Count", Width: 6}, + {Title: "Rate/s", Width: 7}, + {Title: "Avg", Width: 8}, + {Title: "p95", Width: 8}, + {Title: "p99", Width: 8}, + {Title: "Bytes", Width: 8}, + {Title: "Errors", Width: 6}, + } + return columns, syscallRowsCompact(syscalls) + } + columns := []table.Column{ {Title: "Syscall", Width: 16}, {Title: "Count", Width: 8}, @@ -36,20 +63,10 @@ func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset {Title: "Bytes", Width: 10}, {Title: "Errors", Width: 8}, } - - tbl := table.New( - table.WithColumns(columns), - table.WithRows(rows), - table.WithFocused(true), - ) - tbl.SetHeight(syscallTableHeight(height)) - tbl.SetWidth(tableWidth(width)) - cursor := clampOffset(offset, len(rows)) - tbl.SetCursor(cursor) - return tbl.View() + fmt.Sprintf("\nRow %d/%d", cursor+1, len(rows)) + return columns, syscallRowsFull(syscalls) } -func syscallRows(syscalls []statsengine.SyscallSnapshot) []table.Row { +func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) []table.Row { rows := make([]table.Row, 0, len(syscalls)) for _, s := range syscalls { rows = append(rows, table.Row{ @@ -69,6 +86,23 @@ func syscallRows(syscalls []statsengine.SyscallSnapshot) []table.Row { return rows } +func syscallRowsCompact(syscalls []statsengine.SyscallSnapshot) []table.Row { + rows := make([]table.Row, 0, len(syscalls)) + for _, s := range syscalls { + rows = append(rows, table.Row{ + s.Name, + strconv.FormatUint(s.Count, 10), + fmt.Sprintf("%.1f", s.RatePerSec), + formatDurationNs(s.LatencyMeanNs), + formatDurationUintNs(s.LatencyP95Ns), + formatDurationUintNs(s.LatencyP99Ns), + formatBytes(float64(s.Bytes)), + strconv.FormatUint(s.Errors, 10), + }) + } + return rows +} + func formatDurationUintNs(v uint64) string { return formatDurationNs(float64(v)) } diff --git a/internal/tui/dashboard/syscalls_test.go b/internal/tui/dashboard/syscalls_test.go index f998f74..dfb6384 100644 --- a/internal/tui/dashboard/syscalls_test.go +++ b/internal/tui/dashboard/syscalls_test.go @@ -20,7 +20,7 @@ func TestRenderSyscallsIncludesHeaders(t *testing.T) { ) out := renderSyscalls(&snap, 120, 30) - for _, token := range []string{"Syscall", "Count", "Rate/s", "p95", "Errors"} { + for _, token := range []string{"Syscall", "Count", "Rate/s", "p95", "p99", "Bytes", "Errors"} { if !strings.Contains(out, token) { t.Fatalf("expected token %q in syscall table view", token) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d988fa1..143cf02 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -182,9 +182,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } if m.exporter.Visible() { + var dashboardCmd tea.Cmd + // Keep dashboard refresh/data flow alive while export modal is open. + if _, isKey := msg.(tea.KeyMsg); !isKey && m.screen == ScreenDashboard { + next, cmd := m.dashboard.Update(msg) + m.dashboard = next.(dashboardui.Model) + dashboardCmd = cmd + } var cmd tea.Cmd m.exporter, cmd = m.exporter.Update(msg) - return m, cmd + return m, tea.Batch(dashboardCmd, cmd) } return m.updateActiveModel(msg) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index d62d283..fd7adfa 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -314,6 +314,21 @@ func TestHelpToggleDoesNotBreakExportModalInput(t *testing.T) { } } +func TestExportModalStillAllowsDashboardStatsUpdates(t *testing.T) { + m := NewModel(-1, func(context.Context) error { return nil }) + m.screen = ScreenDashboard + m.attaching = false + m.exporter = m.exporter.Open() + + snap := &statsengine.Snapshot{TotalSyscalls: 99} + next, _ := m.Update(StatsTickMsg{Snap: snap}) + updated := next.(Model) + + if got := updated.dashboard.LatestSnapshot(); got != snap { + t.Fatalf("expected dashboard snapshot update while export modal visible") + } +} + func TestDashboardTabKeysChangeActiveView(t *testing.T) { m := NewModel(-1, func(context.Context) error { return nil }) m.screen = ScreenDashboard -- cgit v1.2.3