summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 08:38:19 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 08:38:19 +0200
commitb01e24374398eb3d343e9472f3262668039db56c (patch)
tree139a9e02946f635adaeedb8a61fa150c874c17ff
parent24b401ac9c6a1f80b5ba7f446f1fd3e3ddf02b5c (diff)
tui: add dashboard syscalls table tab
-rw-r--r--internal/tui/dashboard/model.go24
-rw-r--r--internal/tui/dashboard/model_test.go17
-rw-r--r--internal/tui/dashboard/syscalls.go132
-rw-r--r--internal/tui/dashboard/syscalls_test.go52
4 files changed, 220 insertions, 5 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 1178dc9..a7dec14 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -31,8 +31,9 @@ type Model struct {
width int
height int
- refreshEvery time.Duration
- keys tui.KeyMap
+ refreshEvery time.Duration
+ keys tui.KeyMap
+ syscallsOffset int
}
// NewModel creates a dashboard model with default refresh cadence.
@@ -81,6 +82,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ if m.activeTab == TabSyscalls {
+ switch msg.String() {
+ case "down", "j":
+ m.syscallsOffset++
+ return m, nil
+ case "up", "k":
+ if m.syscallsOffset > 0 {
+ m.syscallsOffset--
+ }
+ return m, nil
+ }
+ }
+
switch {
case key.Matches(msg, m.keys.Tab):
m.activeTab = nextTab(m.activeTab)
@@ -114,7 +128,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))
+ b.WriteString(renderActiveTab(m.activeTab, m.latest, m.width, m.height, m.syscallsOffset))
b.WriteString("\n")
b.WriteString(renderHelpBar(m.keys))
return tui.ScreenStyle.Render(b.String())
@@ -124,7 +138,7 @@ 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 {
+func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscallsOffset int) string {
_ = width
_ = height
@@ -136,7 +150,7 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height int) str
case TabOverview:
return renderOverview(snap, width, height)
case TabSyscalls:
- return tui.PanelStyle.Render(fmt.Sprintf("Syscalls: %d rows", len(snap.Syscalls())))
+ return renderSyscallsWithOffset(snap, width, height, syscallsOffset)
case TabFiles:
return tui.PanelStyle.Render(fmt.Sprintf("Files: %d rows", len(snap.Files())))
case TabProcesses:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index ddb457a..8899e72 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -43,6 +43,23 @@ func TestKeySwitchingChangesActiveTab(t *testing.T) {
}
}
+func TestSyscallsTabScrollsWithJK(t *testing.T) {
+ m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ model := next.(Model)
+ if model.syscallsOffset != 1 {
+ t.Fatalf("expected offset 1 after j, got %d", model.syscallsOffset)
+ }
+
+ next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ model = next.(Model)
+ if model.syscallsOffset != 0 {
+ t.Fatalf("expected offset 0 after k, got %d", model.syscallsOffset)
+ }
+}
+
func TestRefreshTickEmitsStatsTickMsg(t *testing.T) {
snap := &statsengine.Snapshot{TotalSyscalls: 9}
engine := &fakeSnapshotSource{snap: snap}
diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go
new file mode 100644
index 0000000..f25781e
--- /dev/null
+++ b/internal/tui/dashboard/syscalls.go
@@ -0,0 +1,132 @@
+package dashboard
+
+import (
+ "fmt"
+ "ior/internal/statsengine"
+ "sort"
+ "strconv"
+ "time"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+func renderSyscalls(snap *statsengine.Snapshot, width, height int) string {
+ return renderSyscallsWithOffset(snap, width, height, 0)
+}
+
+func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset int) string {
+ if snap == nil {
+ return "Syscalls: waiting for stats..."
+ }
+
+ rows := syscallRows(snap.Syscalls())
+ if len(rows) == 0 {
+ return "Syscalls: no data"
+ }
+
+ columns := []table.Column{
+ {Title: "Syscall", Width: 16},
+ {Title: "Count", Width: 8},
+ {Title: "Rate/s", Width: 8},
+ {Title: "Avg", Width: 9},
+ {Title: "Min", Width: 9},
+ {Title: "Max", Width: 9},
+ {Title: "p50", Width: 9},
+ {Title: "p95", Width: 9},
+ {Title: "p99", Width: 9},
+ {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))
+ tbl.SetCursor(clampOffset(offset, len(rows)))
+ return tbl.View()
+}
+
+func syscallRows(syscalls []statsengine.SyscallSnapshot) []table.Row {
+ ordered := append([]statsengine.SyscallSnapshot(nil), syscalls...)
+ sort.SliceStable(ordered, func(i, j int) bool {
+ if ordered[i].Count == ordered[j].Count {
+ return ordered[i].Name < ordered[j].Name
+ }
+ return ordered[i].Count > ordered[j].Count
+ })
+
+ rows := make([]table.Row, 0, len(ordered))
+ for _, s := range ordered {
+ rows = append(rows, table.Row{
+ s.Name,
+ strconv.FormatUint(s.Count, 10),
+ fmt.Sprintf("%.1f", s.RatePerSec),
+ formatDurationNs(s.LatencyMeanNs),
+ formatDurationUintNs(s.LatencyMinNs),
+ formatDurationUintNs(s.LatencyMaxNs),
+ formatDurationUintNs(s.LatencyP50Ns),
+ 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))
+}
+
+func formatDurationNs(v float64) string {
+ if v < 1000 {
+ return fmt.Sprintf("%.0fns", v)
+ }
+ us := v / 1000
+ if us < 1000 {
+ return fmt.Sprintf("%.1fµs", us)
+ }
+ ms := us / 1000
+ if ms < 1000 {
+ return fmt.Sprintf("%.1fms", ms)
+ }
+ s := ms / 1000
+ return (time.Duration(s * float64(time.Second))).String()
+}
+
+func syscallTableHeight(height int) int {
+ if height <= 0 {
+ return 10
+ }
+ h := height - 6
+ if h < 5 {
+ return 5
+ }
+ return h
+}
+
+func tableWidth(width int) int {
+ if width <= 0 {
+ return 80
+ }
+ if width < 60 {
+ return 60
+ }
+ return width
+}
+
+func clampOffset(offset, size int) int {
+ if size == 0 {
+ return 0
+ }
+ if offset < 0 {
+ return 0
+ }
+ if offset >= size {
+ return size - 1
+ }
+ return offset
+}
diff --git a/internal/tui/dashboard/syscalls_test.go b/internal/tui/dashboard/syscalls_test.go
new file mode 100644
index 0000000..f998f74
--- /dev/null
+++ b/internal/tui/dashboard/syscalls_test.go
@@ -0,0 +1,52 @@
+package dashboard
+
+import (
+ "strings"
+ "testing"
+
+ "ior/internal/statsengine"
+)
+
+func TestRenderSyscallsIncludesHeaders(t *testing.T) {
+ snap := statsengine.NewSnapshot(
+ nil, nil, nil,
+ []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 5, RatePerSec: 1.2, Bytes: 1024, Errors: 1},
+ {Name: "read", Count: 10, RatePerSec: 2.4, Bytes: 2048, Errors: 0},
+ },
+ nil, nil,
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ out := renderSyscalls(&snap, 120, 30)
+ for _, token := range []string{"Syscall", "Count", "Rate/s", "p95", "Errors"} {
+ if !strings.Contains(out, token) {
+ t.Fatalf("expected token %q in syscall table view", token)
+ }
+ }
+ if !strings.Contains(out, "read") {
+ t.Fatalf("expected syscall row in output")
+ }
+}
+
+func TestFormatDurationNs(t *testing.T) {
+ if got := formatDurationNs(50); got != "50ns" {
+ t.Fatalf("unexpected ns formatting: %q", got)
+ }
+ if got := formatDurationNs(1500); !strings.Contains(got, "µs") {
+ t.Fatalf("expected µs formatting, got %q", got)
+ }
+ if got := formatDurationNs(2_500_000); !strings.Contains(got, "ms") {
+ t.Fatalf("expected ms formatting, got %q", got)
+ }
+}
+
+func TestClampOffset(t *testing.T) {
+ if got := clampOffset(-5, 10); got != 0 {
+ t.Fatalf("expected 0 for negative offset, got %d", got)
+ }
+ if got := clampOffset(99, 4); got != 3 {
+ t.Fatalf("expected max index clamp, got %d", got)
+ }
+}