summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/tui/dashboard/model.go25
-rw-r--r--internal/tui/dashboard/model_test.go17
-rw-r--r--internal/tui/dashboard/processes.go75
-rw-r--r--internal/tui/dashboard/processes_test.go61
4 files changed, 172 insertions, 6 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index a7dec14..44a6da7 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -31,9 +31,10 @@ type Model struct {
width int
height int
- refreshEvery time.Duration
- keys tui.KeyMap
- syscallsOffset int
+ refreshEvery time.Duration
+ keys tui.KeyMap
+ syscallsOffset int
+ processesOffset int
}
// NewModel creates a dashboard model with default refresh cadence.
@@ -94,6 +95,18 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
}
+ if m.activeTab == TabProcesses {
+ switch msg.String() {
+ case "down", "j":
+ m.processesOffset++
+ return m, nil
+ case "up", "k":
+ if m.processesOffset > 0 {
+ m.processesOffset--
+ }
+ return m, nil
+ }
+ }
switch {
case key.Matches(msg, m.keys.Tab):
@@ -128,7 +141,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, m.syscallsOffset))
+ b.WriteString(renderActiveTab(m.activeTab, m.latest, m.width, m.height, m.syscallsOffset, m.processesOffset))
b.WriteString("\n")
b.WriteString(renderHelpBar(m.keys))
return tui.ScreenStyle.Render(b.String())
@@ -138,7 +151,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, syscallsOffset int) string {
+func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscallsOffset, processesOffset int) string {
_ = width
_ = height
@@ -154,7 +167,7 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscall
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())))
+ return renderProcessesWithOffset(snap, width, height, processesOffset)
case TabLatency:
return tui.PanelStyle.Render("Latency histogram")
case TabGaps:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 8899e72..3d93659 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -60,6 +60,23 @@ func TestSyscallsTabScrollsWithJK(t *testing.T) {
}
}
+func TestProcessesTabScrollsWithJK(t *testing.T) {
+ m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap())
+ m.activeTab = TabProcesses
+
+ next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}})
+ model := next.(Model)
+ if model.processesOffset != 1 {
+ t.Fatalf("expected processes offset 1 after j, got %d", model.processesOffset)
+ }
+
+ next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}})
+ model = next.(Model)
+ if model.processesOffset != 0 {
+ t.Fatalf("expected processes offset 0 after k, got %d", model.processesOffset)
+ }
+}
+
func TestRefreshTickEmitsStatsTickMsg(t *testing.T) {
snap := &statsengine.Snapshot{TotalSyscalls: 9}
engine := &fakeSnapshotSource{snap: snap}
diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go
new file mode 100644
index 0000000..d229c10
--- /dev/null
+++ b/internal/tui/dashboard/processes.go
@@ -0,0 +1,75 @@
+package dashboard
+
+import (
+ "fmt"
+ "ior/internal/flags"
+ "ior/internal/statsengine"
+ "strconv"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/table"
+)
+
+func renderProcesses(snap *statsengine.Snapshot, width, height int) string {
+ return renderProcessesWithOffset(snap, width, height, 0)
+}
+
+func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset int) string {
+ if snap == nil {
+ return "Processes: waiting for stats..."
+ }
+
+ rows := processRows(snap.Processes())
+ if len(rows) == 0 {
+ return "Processes: no data"
+ }
+
+ columns := []table.Column{
+ {Title: "PID", Width: 8},
+ {Title: "Comm", Width: 18},
+ {Title: "Syscalls", Width: 10},
+ {Title: "Rate/s", Width: 8},
+ {Title: "Total Bytes", Width: 12},
+ {Title: "Avg Latency", Width: 12},
+ }
+
+ 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)))
+
+ out := tbl.View()
+ if flags.Get().PidFilter > 0 {
+ out += "\n" + "Note: this tab is most useful with All PIDs."
+ }
+ return out
+}
+
+func processRows(processes []statsengine.ProcessSnapshot) []table.Row {
+ rows := make([]table.Row, 0, len(processes))
+ for _, p := range processes {
+ rows = append(rows, table.Row{
+ strconv.FormatUint(uint64(p.PID), 10),
+ truncateText(p.Comm, 18),
+ strconv.FormatUint(p.Syscalls, 10),
+ fmt.Sprintf("%.1f", p.RatePerSec),
+ formatBytes(float64(p.Bytes)),
+ formatDurationNs(p.AvgLatencyNs),
+ })
+ }
+ return rows
+}
+
+func truncateText(value string, limit int) string {
+ if len(value) <= limit {
+ return value
+ }
+ if limit <= 3 {
+ return value[:limit]
+ }
+ return strings.TrimSpace(value[:limit-3]) + "..."
+}
diff --git a/internal/tui/dashboard/processes_test.go b/internal/tui/dashboard/processes_test.go
new file mode 100644
index 0000000..4db490d
--- /dev/null
+++ b/internal/tui/dashboard/processes_test.go
@@ -0,0 +1,61 @@
+package dashboard
+
+import (
+ "strings"
+ "testing"
+
+ "ior/internal/flags"
+ "ior/internal/statsengine"
+)
+
+func TestRenderProcessesIncludesHeaders(t *testing.T) {
+ flags.SetPidFilter(-1)
+
+ snap := statsengine.NewSnapshot(
+ nil, nil, nil,
+ nil, nil,
+ []statsengine.ProcessSnapshot{
+ {PID: 200, Comm: "proc-b", Syscalls: 10, RatePerSec: 2.3, Bytes: 2048, AvgLatencyNs: 1500},
+ {PID: 100, Comm: "proc-a", Syscalls: 20, RatePerSec: 4.6, Bytes: 4096, AvgLatencyNs: 2500},
+ },
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ out := renderProcesses(&snap, 120, 30)
+ for _, token := range []string{"PID", "Comm", "Syscalls", "Total Bytes", "Avg Latency"} {
+ if !strings.Contains(out, token) {
+ t.Fatalf("expected header token %q", token)
+ }
+ }
+ if !strings.Contains(out, "100") || !strings.Contains(out, "proc-a") {
+ t.Fatalf("expected process row in output")
+ }
+}
+
+func TestRenderProcessesShowsSinglePIDNote(t *testing.T) {
+ flags.SetPidFilter(77)
+ t.Cleanup(func() { flags.SetPidFilter(-1) })
+
+ snap := statsengine.NewSnapshot(
+ nil, nil, nil,
+ nil, nil,
+ []statsengine.ProcessSnapshot{
+ {PID: 77, Comm: "proc", Syscalls: 1},
+ },
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ out := renderProcesses(&snap, 100, 20)
+ if !strings.Contains(out, "most useful with All PIDs") {
+ t.Fatalf("expected single-pid guidance note")
+ }
+}
+
+func TestTruncateText(t *testing.T) {
+ got := truncateText("very-long-process-name", 10)
+ if got != "very-lo..." {
+ t.Fatalf("unexpected truncation result: %q", got)
+ }
+}