diff options
| -rw-r--r-- | internal/tui/dashboard/model.go | 25 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 17 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 75 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes_test.go | 61 |
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) + } +} |
