diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-09 22:45:05 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-09 22:45:05 +0200 |
| commit | eb53d7c881b6b8a513c1350736c5f5df770e4089 (patch) | |
| tree | 2bdaa31d3275cf8077c47ad0a8ba5018f0b85e37 /internal/tui | |
| parent | 1af7cf5fe51fa13e828cdef6268348ec9cd7bd7c (diff) | |
tui: add sortable processes dashboard table (task 365)
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/dashboard/model.go | 58 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 95 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 123 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes_test.go | 23 | ||||
| -rw-r--r-- | internal/tui/dashboard/sort.go | 11 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 7 |
6 files changed, 312 insertions, 5 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index e418367..f551d8f 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -82,6 +82,7 @@ type Model struct { filesDirSort tableSortState[fileDirSortKey] processesOffset int processesCol int + processesSort tableSortState[processSortKey] syscallsVizMode tabVizMode filesVizMode tabVizMode processesVizMode tabVizMode @@ -231,6 +232,7 @@ func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) { selectedSyscall := "" selectedFile := "" selectedDir := "" + selectedProcess := uint32(0) if m.syscallsSort.active { selectedSyscall = m.selectedSyscallName() } @@ -242,12 +244,15 @@ func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) { selectedDir = m.selectedDirPath() } } + if m.processesVizMode == tabVizModeTable && m.processesSort.active { + selectedProcess = m.selectedProcessPID() + } m.latest = msg.Snap m.reanchorSyscallsOffset(selectedSyscall) m.reanchorFilesOffset(selectedFile) m.reanchorFilesDirOffset(selectedDir) + m.reanchorProcessesOffset(selectedProcess) m.syscallsTreemapSelection = clampOffset(m.syscallsTreemapSelection, m.maxSyscallsRows()) - m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) m.clampTableColumns() m.streamModel.Refresh() if m.refreshBubbleData() { @@ -380,6 +385,8 @@ func (m *Model) handleSortKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { return m.handleSyscallsSortKey() case TabFiles: return m.handleFilesSortKey() + case TabProcesses: + return m.handleProcessesSortKey() default: return false, nil } @@ -423,6 +430,20 @@ func (m *Model) handleFilesSortKey() (bool, tea.Cmd) { return true, nil } +func (m *Model) handleProcessesSortKey() (bool, tea.Cmd) { + if m.processesVizMode != tabVizModeTable { + return false, nil + } + key, ok := processSortKeyForColumn(m.processesCol) + if !ok { + return false, nil + } + selectedPID := m.selectedProcessPID() + m.processesSort = m.processesSort.toggled(key) + m.reanchorProcessesOffset(selectedPID) + return true, nil +} + func (m *Model) reanchorSyscallsOffset(selectedName string) { rows := m.sortedSyscallRows() if len(rows) == 0 { @@ -468,6 +489,21 @@ func (m *Model) reanchorFilesDirOffset(selectedDir string) { m.filesDirOffset = clampOffset(m.filesDirOffset, len(rows)) } +func (m *Model) reanchorProcessesOffset(selectedPID uint32) { + rows := m.sortedProcessTableRows() + if len(rows) == 0 { + m.processesOffset = 0 + return + } + if selectedPID != 0 { + if index, ok := findProcessOffset(rows, selectedPID); ok { + m.processesOffset = index + return + } + } + m.processesOffset = clampOffset(m.processesOffset, len(rows)) +} + func (m Model) selectedFileFilter() (globalfilter.Filter, string, bool) { if m.latest == nil { return globalfilter.Filter{}, "", false @@ -659,8 +695,23 @@ func (m Model) selectedProcessSnapshot() (statsengine.ProcessSnapshot, bool) { case m.processesVizMode == tabVizModeBubbles: return indexedProcessSnapshot(sortedProcessSnapshots(rows, m.processesChart.Metric(), bubbleMaxItems), m.processesChart.selected) default: - return indexedProcessSnapshot(rows, m.processesOffset) + return indexedProcessSnapshot(m.sortedProcessTableRows(), m.processesOffset) + } +} + +func (m Model) sortedProcessTableRows() []statsengine.ProcessSnapshot { + if m.latest == nil { + return nil } + return sortedProcessTableRows(m.latest.Processes(), m.processesSort) +} + +func (m Model) selectedProcessPID() uint32 { + selected, ok := m.selectedProcessSnapshot() + if !ok { + return 0 + } + return selected.PID } func indexedProcessSnapshot(rows []statsengine.ProcessSnapshot, index int) (statsengine.ProcessSnapshot, bool) { @@ -1046,6 +1097,9 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst } return renderFilesWithSort(m.latest, width, activeHeight, m.filesOffset, m.filesCol, m.filesSort) } + if m.activeTab == TabProcesses && m.latest != nil && m.processesVizMode == tabVizModeTable { + return renderProcessesWithSort(m.latest, width, activeHeight, m.processesOffset, m.processesCol, m.pidFilter, m.processesSort) + } return renderActiveTab( m.activeTab, m.latest, diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index aa9b774..576f06f 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -271,6 +271,101 @@ func TestProcessesTabEnterCommColumnEmitsCommFilterRequest(t *testing.T) { } } +func TestProcessesSortKeyTogglesOnSelectedColumn(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 200, Comm: "worker", Syscalls: 9}, + {PID: 100, Comm: "agent", Syscalls: 3}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.processesCol = 1 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + model := next.(Model) + if !model.processesSort.active || model.processesSort.key != processSortKeyComm { + t.Fatalf("expected process comm sort enabled, got %+v", model.processesSort) + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + model = next.(Model) + if model.processesSort.active { + t.Fatalf("expected second s press to restore default process ordering") + } +} + +func TestProcessesSortEnterUsesSortedVisibleRow(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 200, Comm: "worker", Syscalls: 9}, + {PID: 100, Comm: "agent", Syscalls: 3}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + m.processesOffset = 1 + m.processesCol = 1 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + m = next.(Model) + next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = next.(Model) + if cmd == nil { + t.Fatalf("expected enter on sorted processes tab to emit a filter request") + } + msg := cmd() + req, ok := msg.(messages.GlobalFilterRequestedMsg) + if !ok { + t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg) + } + if req.Filter.Comm == nil || req.Filter.Comm.Pattern != "agent" { + t.Fatalf("expected visible sorted row to filter agent comm, got %+v", req.Filter.Comm) + } +} + +func TestProcessesSortIgnoredOutsideTableMode(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + m.processesVizMode = tabVizModeTreemap + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 200, Comm: "worker", Syscalls: 9}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})}) + model := next.(Model) + if model.processesSort.active { + t.Fatalf("expected sort key ignored outside processes table mode") + } +} + +func TestStatsTickReanchorsSortedProcessSelectionByPID(t *testing.T) { + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabProcesses + m.processesSort = tableSortState[processSortKey]{active: true, key: processSortKeyComm} + oldSnap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 100, Comm: "agent", Syscalls: 3}, + {PID: 200, Comm: "worker", Syscalls: 9}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m.latest = &oldSnap + m.processesOffset = 1 + m.processesCol = 1 + + newSnap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{ + {PID: 50, Comm: "alpha", Syscalls: 12}, + {PID: 100, Comm: "agent", Syscalls: 3}, + {PID: 200, Comm: "worker", Syscalls: 9}, + }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + + next, _ := m.Update(messages.StatsTickMsg{Snap: &newSnap}) + model := next.(Model) + if model.processesOffset != 2 { + t.Fatalf("expected selected worker row reanchored to offset 2, got %d", model.processesOffset) + } + if selected := model.selectedProcessPID(); selected != 200 { + t.Fatalf("expected selected process PID 200 after stats refresh, got %d", selected) + } +} + func TestFilesTabScrollsWithJK(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFiles diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index dbd8fbe..aae30f8 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -2,6 +2,7 @@ package dashboard import ( "fmt" + "slices" "strconv" "strings" @@ -9,22 +10,37 @@ import ( common "ior/internal/tui/common" ) +type processSortKey uint8 + +const ( + processSortKeyPID processSortKey = iota + processSortKeyComm + processSortKeySyscalls + processSortKeyRate + processSortKeyBytes + processSortKeyAvgLatency +) + func renderProcesses(snap *statsengine.Snapshot, width, height int) string { - return renderProcessesWithOffset(snap, width, height, 0, 0, -1) + return renderProcessesWithSort(snap, width, height, 0, 0, -1, tableSortState[processSortKey]{}) } func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol, pidFilter int) string { + return renderProcessesWithSort(snap, width, height, offset, selectedCol, pidFilter, tableSortState[processSortKey]{}) +} + +func renderProcessesWithSort(snap *statsengine.Snapshot, width, height, offset, selectedCol, pidFilter int, sortState tableSortState[processSortKey]) string { if snap == nil { return "Processes: waiting for stats..." } - rows := processRows(snap.Processes()) + rows := processRows(sortedProcessTableRows(snap.Processes(), sortState)) if len(rows) == 0 { return "Processes: no data" } columns := processColumns() - out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric") + out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "s:sort", processSortHint(sortState), "v:mode", "b:metric") if pidFilter > 0 { out += "\n" + "Note: this tab is most useful with All PIDs." } @@ -42,6 +58,107 @@ func processColumns() []common.TableColumn { } } +func sortedProcessTableRows(rows []statsengine.ProcessSnapshot, sortState tableSortState[processSortKey]) []statsengine.ProcessSnapshot { + if len(rows) == 0 { + return nil + } + if !sortState.active { + return rows + } + + sorted := slices.Clone(rows) + slices.SortFunc(sorted, func(left, right statsengine.ProcessSnapshot) int { + if cmp := compareProcessBySort(left, right, sortState.key); cmp != 0 { + return cmp + } + return compareProcessDefault(left, right) + }) + return sorted +} + +func compareProcessBySort(left, right statsengine.ProcessSnapshot, key processSortKey) int { + switch key { + case processSortKeyPID: + return compareUint64Asc(uint64(left.PID), uint64(right.PID)) + case processSortKeyComm: + return compareStringAsc(left.Comm, right.Comm) + case processSortKeySyscalls: + return compareUint64Desc(left.Syscalls, right.Syscalls) + case processSortKeyRate: + return compareFloat64Desc(left.RatePerSec, right.RatePerSec) + case processSortKeyBytes: + return compareUint64Desc(left.Bytes, right.Bytes) + case processSortKeyAvgLatency: + return compareFloat64Desc(left.AvgLatencyNs, right.AvgLatencyNs) + default: + return 0 + } +} + +func compareProcessDefault(left, right statsengine.ProcessSnapshot) int { + if cmp := compareUint64Desc(left.Syscalls, right.Syscalls); cmp != 0 { + return cmp + } + if cmp := compareUint64Desc(left.Bytes, right.Bytes); cmp != 0 { + return cmp + } + return compareUint64Asc(uint64(left.PID), uint64(right.PID)) +} + +func processSortKeyForColumn(column int) (processSortKey, bool) { + switch column { + case 0: + return processSortKeyPID, true + case 1: + return processSortKeyComm, true + case 2: + return processSortKeySyscalls, true + case 3: + return processSortKeyRate, true + case 4: + return processSortKeyBytes, true + case 5: + return processSortKeyAvgLatency, true + default: + return 0, false + } +} + +func processSortHint(sortState tableSortState[processSortKey]) string { + return "sort: " + processSortLabel(sortState) +} + +func processSortLabel(sortState tableSortState[processSortKey]) string { + if !sortState.active { + return "default" + } + switch sortState.key { + case processSortKeyPID: + return "PID asc" + case processSortKeyComm: + return "Comm asc" + case processSortKeySyscalls: + return "Syscalls desc" + case processSortKeyRate: + return "Rate/s desc" + case processSortKeyBytes: + return "Total Bytes desc" + case processSortKeyAvgLatency: + return "Avg Latency desc" + default: + return "default" + } +} + +func findProcessOffset(rows []statsengine.ProcessSnapshot, pid uint32) (int, bool) { + for idx, row := range rows { + if row.PID == pid { + return idx, true + } + } + return 0, false +} + func processRows(processes []statsengine.ProcessSnapshot) [][]string { rows := make([][]string, 0, len(processes)) for _, p := range processes { diff --git a/internal/tui/dashboard/processes_test.go b/internal/tui/dashboard/processes_test.go index bfc26fc..7c04497 100644 --- a/internal/tui/dashboard/processes_test.go +++ b/internal/tui/dashboard/processes_test.go @@ -28,6 +28,12 @@ func TestRenderProcessesIncludesHeaders(t *testing.T) { if !strings.Contains(out, "100") || !strings.Contains(out, "proc-a") { t.Fatalf("expected process row in output") } + if !strings.Contains(out, "s:sort") { + t.Fatalf("expected processes sort hint in output") + } + if !strings.Contains(out, "sort: default") { + t.Fatalf("expected processes default sort label in output") + } } func TestRenderProcessesShowsSinglePIDNote(t *testing.T) { @@ -53,3 +59,20 @@ func TestTruncateText(t *testing.T) { t.Fatalf("unexpected truncation result: %q", got) } } + +func TestSortedProcessTableRowsUsesSelectedSortKey(t *testing.T) { + rows := []statsengine.ProcessSnapshot{ + {PID: 200, Comm: "worker", Syscalls: 9}, + {PID: 100, Comm: "agent", Syscalls: 3}, + } + + sorted := sortedProcessTableRows(rows, tableSortState[processSortKey]{active: true, key: processSortKeyComm}) + if sorted[0].PID != 100 { + t.Fatalf("expected comm sort to put PID 100 first, got %d", sorted[0].PID) + } + + sorted = sortedProcessTableRows(rows, tableSortState[processSortKey]{active: true, key: processSortKeyPID}) + if sorted[0].PID != 100 { + t.Fatalf("expected pid asc sort to put PID 100 first, got %d", sorted[0].PID) + } +} diff --git a/internal/tui/dashboard/sort.go b/internal/tui/dashboard/sort.go index 5c4fe54..7d985a6 100644 --- a/internal/tui/dashboard/sort.go +++ b/internal/tui/dashboard/sort.go @@ -23,6 +23,17 @@ func compareUint64Desc(left, right uint64) int { } } +func compareUint64Asc(left, right uint64) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} + func compareFloat64Desc(left, right float64) int { switch { case left > right: diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go index 54a1d16..498d606 100644 --- a/internal/tui/dashboard/tabs_test.go +++ b/internal/tui/dashboard/tabs_test.go @@ -69,3 +69,10 @@ func TestRenderHelpBarWithStatusIncludesFilterSummary(t *testing.T) { t.Fatalf("expected filter summary in help bar, got %q", out) } } + +func TestRenderHelpBarIncludesSortBinding(t *testing.T) { + out := renderHelpBar(common.DefaultKeyMap(), 0) + if !strings.Contains(out, "s sort table") { + t.Fatalf("expected sort binding in rendered help bar, got %q", out) + } +} |
