diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 08:38:19 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 08:38:19 +0200 |
| commit | b01e24374398eb3d343e9472f3262668039db56c (patch) | |
| tree | 139a9e02946f635adaeedb8a61fa150c874c17ff | |
| parent | 24b401ac9c6a1f80b5ba7f446f1fd3e3ddf02b5c (diff) | |
tui: add dashboard syscalls table tab
| -rw-r--r-- | internal/tui/dashboard/model.go | 24 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 132 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls_test.go | 52 |
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) + } +} |
