summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-09 22:45:05 +0200
committerPaul Buetow <paul@buetow.org>2026-03-09 22:45:05 +0200
commiteb53d7c881b6b8a513c1350736c5f5df770e4089 (patch)
tree2bdaa31d3275cf8077c47ad0a8ba5018f0b85e37 /internal/tui
parent1af7cf5fe51fa13e828cdef6268348ec9cd7bd7c (diff)
tui: add sortable processes dashboard table (task 365)
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/dashboard/model.go58
-rw-r--r--internal/tui/dashboard/model_test.go95
-rw-r--r--internal/tui/dashboard/processes.go123
-rw-r--r--internal/tui/dashboard/processes_test.go23
-rw-r--r--internal/tui/dashboard/sort.go11
-rw-r--r--internal/tui/dashboard/tabs_test.go7
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)
+ }
+}