summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 17:16:17 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 17:16:17 +0200
commit2ae0b33c9f196634eaa55bd6997d1feae9147385 (patch)
treeea4e0e705c693e44f29924014431186af635b7e5 /internal/tui
parent92114c3b6bfe8a3d28487fcfb34fd291c573500c (diff)
tui: improve dashboard tab navigation and live updates
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/common/keys.go2
-rw-r--r--internal/tui/dashboard/files.go24
-rw-r--r--internal/tui/dashboard/files_test.go2
-rw-r--r--internal/tui/dashboard/model.go9
-rw-r--r--internal/tui/dashboard/model_test.go28
-rw-r--r--internal/tui/dashboard/syscalls.go60
-rw-r--r--internal/tui/dashboard/syscalls_test.go2
-rw-r--r--internal/tui/tui.go9
-rw-r--r--internal/tui/tui_test.go15
9 files changed, 124 insertions, 27 deletions
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