summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-09 22:35:53 +0200
committerPaul Buetow <paul@buetow.org>2026-03-09 22:35:53 +0200
commita4c72ad2cbe4ca857a5880675563b2ab4d24e1b5 (patch)
treed589398e9d02b760622716d9b2146c074eb4d66d /internal
parente40116ab0ecbbfd5cab5a33bc89e9bf6f98746aa (diff)
tui: add sortable syscalls dashboard table (task 363)
Diffstat (limited to 'internal')
-rw-r--r--internal/tui/common/keys.go6
-rw-r--r--internal/tui/common/keys_test.go24
-rw-r--r--internal/tui/dashboard/model.go77
-rw-r--r--internal/tui/dashboard/model_test.go136
-rw-r--r--internal/tui/dashboard/sort.go13
-rw-r--r--internal/tui/dashboard/syscalls.go230
-rw-r--r--internal/tui/dashboard/syscalls_test.go35
7 files changed, 510 insertions, 11 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index 6289997..3f3d807 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -21,6 +21,7 @@ type KeyMap struct {
Seven key.Binding
Visualize key.Binding
Metric key.Binding
+ Sort key.Binding
DirGroup key.Binding
SelectPID key.Binding
SelectTID key.Binding
@@ -51,6 +52,7 @@ func DefaultKeyMap() KeyMap {
Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")),
Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")),
Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")),
+ Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort table")),
DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")),
SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")),
SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")),
@@ -94,6 +96,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.Seven,
k.Visualize,
k.Metric,
+ k.Sort,
k.Filter,
k.FilterUndo,
k.SelectPID,
@@ -109,6 +112,7 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.DirGroup,
k.Visualize,
k.Metric,
+ k.Sort,
helpTextBinding("space", "stream pause"),
helpTextBinding("enter", "selected filter"),
helpTextBinding("esc", "stream undo filter"),
@@ -138,7 +142,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
controls = append(controls, k.Export)
}
controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit)
- controls = append(controls, k.Visualize, k.Metric, k.Filter, k.FilterUndo)
+ controls = append(controls, k.Visualize, k.Metric, k.Sort, k.Filter, k.FilterUndo)
return [][]key.Binding{
{k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go
index 43f4b8b..8d34285 100644
--- a/internal/tui/common/keys_test.go
+++ b/internal/tui/common/keys_test.go
@@ -39,6 +39,11 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) {
t.Fatalf("unexpected metric binding help: key=%q desc=%q", metricHelp.Key, metricHelp.Desc)
}
+ sortHelp := keys.Sort.Help()
+ if sortHelp.Key != "s" || sortHelp.Desc != "sort table" {
+ t.Fatalf("unexpected sort binding help: key=%q desc=%q", sortHelp.Key, sortHelp.Desc)
+ }
+
undoHelp := keys.FilterUndo.Help()
if undoHelp.Key != "F" || undoHelp.Desc != "undo filter" {
t.Fatalf("unexpected filter undo binding help: key=%q desc=%q", undoHelp.Key, undoHelp.Desc)
@@ -139,6 +144,18 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
found = false
for _, binding := range groups[1] {
help := binding.Help()
+ if help.Key == "s" && help.Desc == "sort table" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected sort binding in dashboard full help controls")
+ }
+
+ found = false
+ for _, binding := range groups[1] {
+ help := binding.Help()
if help.Key == "F" && help.Desc == "undo filter" {
found = true
break
@@ -156,6 +173,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
foundSelectTID := false
foundOne := false
foundUndo := false
+ foundSort := false
for _, binding := range short {
help := binding.Help()
if help.Key == "o" && help.Desc == "probes" {
@@ -170,6 +188,9 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
if help.Key == "F" && help.Desc == "undo filter" {
foundUndo = true
}
+ if help.Key == "s" && help.Desc == "sort table" {
+ foundSort = true
+ }
}
if !found {
t.Fatalf("expected probes binding in dashboard short help")
@@ -183,4 +204,7 @@ func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
if !foundUndo {
t.Fatalf("expected undo filter binding in dashboard short help")
}
+ if !foundSort {
+ t.Fatalf("expected sort binding in dashboard short help")
+ }
}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 55661fb..7fea1c4 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -71,6 +71,7 @@ type Model struct {
pidFilter int
syscallsOffset int
syscallsCol int
+ syscallsSort tableSortState[syscallSortKey]
syscallsTreemapSelection int
filesOffset int
filesCol int
@@ -225,8 +226,12 @@ func (m Model) handleBubbleTick() (tea.Model, tea.Cmd) {
}
func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) {
+ selectedSyscall := ""
+ if m.syscallsSort.active {
+ selectedSyscall = m.selectedSyscallName()
+ }
m.latest = msg.Snap
- m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows())
+ m.reanchorSyscallsOffset(selectedSyscall)
m.syscallsTreemapSelection = clampOffset(m.syscallsTreemapSelection, m.maxSyscallsRows())
m.filesOffset = clampOffset(m.filesOffset, m.maxFilesRows())
m.filesDirOffset = clampOffset(m.filesDirOffset, m.maxFilesDirRowsForMode())
@@ -272,6 +277,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
handled, cmd = m.handleEnterKey(msg)
}
if !handled {
+ handled, cmd = m.handleSortKey(msg)
+ }
+ if !handled {
handled, cmd = m.handleShortcutKey(msg)
}
if !handled {
@@ -315,14 +323,10 @@ func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
}
func (m Model) selectedSyscallFilter() (globalfilter.Filter, string, bool) {
- if m.latest == nil || m.syscallsOffset < 0 {
+ selected, ok := m.selectedSyscallSnapshot()
+ if !ok {
return globalfilter.Filter{}, "", false
}
- rows := m.latest.Syscalls()
- if len(rows) == 0 {
- return globalfilter.Filter{}, "", false
- }
- selected := rows[clampOffset(m.syscallsOffset, len(rows))]
if strings.TrimSpace(selected.Name) == "" {
return globalfilter.Filter{}, "", false
}
@@ -331,6 +335,62 @@ func (m Model) selectedSyscallFilter() (globalfilter.Filter, string, bool) {
return filter, "syscall~" + selected.Name, true
}
+func (m Model) selectedSyscallSnapshot() (statsengine.SyscallSnapshot, bool) {
+ rows := m.sortedSyscallRows()
+ if len(rows) == 0 {
+ return statsengine.SyscallSnapshot{}, false
+ }
+ index := clampOffset(m.syscallsOffset, len(rows))
+ return rows[index], true
+}
+
+func (m Model) sortedSyscallRows() []statsengine.SyscallSnapshot {
+ if m.latest == nil {
+ return nil
+ }
+ return sortedSyscallSnapshots(m.latest.Syscalls(), m.syscallsSort)
+}
+
+func (m Model) selectedSyscallName() string {
+ selected, ok := m.selectedSyscallSnapshot()
+ if !ok {
+ return ""
+ }
+ return selected.Name
+}
+
+func (m *Model) handleSortKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ if !key.Matches(msg, m.keys.Sort) {
+ return false, nil
+ }
+ if m.activeTab != TabSyscalls || m.syscallsVizMode != tabVizModeTable {
+ return false, nil
+ }
+ key, ok := syscallSortKeyForColumn(m.width, m.syscallsCol)
+ if !ok {
+ return false, nil
+ }
+ selectedName := m.selectedSyscallName()
+ m.syscallsSort = m.syscallsSort.toggled(key)
+ m.reanchorSyscallsOffset(selectedName)
+ return true, nil
+}
+
+func (m *Model) reanchorSyscallsOffset(selectedName string) {
+ rows := m.sortedSyscallRows()
+ if len(rows) == 0 {
+ m.syscallsOffset = 0
+ return
+ }
+ if selectedName != "" {
+ if index, ok := findSyscallOffset(rows, selectedName); ok {
+ m.syscallsOffset = index
+ return
+ }
+ }
+ m.syscallsOffset = clampOffset(m.syscallsOffset, len(rows))
+}
+
func (m Model) selectedFileFilter() (globalfilter.Filter, string, bool) {
if m.latest == nil {
return globalfilter.Filter{}, "", false
@@ -854,6 +914,9 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
return chart.Render("Processes", width, activeHeight)
}
}
+ if m.activeTab == TabSyscalls && m.latest != nil {
+ return renderSyscallsWithSort(m.latest, width, activeHeight, m.syscallsOffset, m.syscallsCol, m.syscallsSort)
+ }
return renderActiveTab(
m.activeTab,
m.latest,
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index dc4ac93..76a60e3 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -318,6 +318,142 @@ func TestSyscallsTabEnterEmitsGlobalFilterRequest(t *testing.T) {
}
}
+func TestSyscallsSortKeyTogglesOnSelectedColumn(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 9},
+ {Name: "read", Count: 3},
+ }, nil, nil, 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.syscallsSort.active || model.syscallsSort.key != syscallSortKeyName {
+ t.Fatalf("expected syscall name sort enabled, got %+v", model.syscallsSort)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})})
+ model = next.(Model)
+ if model.syscallsSort.active {
+ t.Fatalf("expected second s press to restore default ordering")
+ }
+}
+
+func TestSyscallsSortReanchorsSelectedSyscall(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 9},
+ {Name: "read", Count: 3},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.syscallsOffset = 1
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})})
+ model := next.(Model)
+ if model.syscallsOffset != 0 {
+ t.Fatalf("expected selected read row reanchored to offset 0, got %d", model.syscallsOffset)
+ }
+ if selected := model.selectedSyscallName(); selected != "read" {
+ t.Fatalf("expected selected syscall read after reanchor, got %q", selected)
+ }
+}
+
+func TestSyscallsSortEnterUsesSortedVisibleRow(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 9},
+ {Name: "read", Count: 3},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.syscallsOffset = 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 syscalls 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.Syscall == nil || req.Filter.Syscall.Pattern != "read" {
+ t.Fatalf("expected visible sorted row to filter read, got %+v", req.Filter.Syscall)
+ }
+}
+
+func TestSyscallsSortIgnoredOutsideTableMode(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ m.syscallsVizMode = tabVizModeTreemap
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 9},
+ }, nil, nil, 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.syscallsSort.active {
+ t.Fatalf("expected sort key ignored outside syscall table mode")
+ }
+}
+
+func TestSyscallsP95SortSurvivesWidthExpansion(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ m.width = 120
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 9, LatencyMinNs: 100, LatencyP95Ns: 10},
+ {Name: "read", Count: 3, LatencyMinNs: 1, LatencyP95Ns: 50},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.syscallsCol = 4
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'s'}[0], Text: string([]rune{'s'})})
+ model := next.(Model)
+ if first := model.sortedSyscallRows()[0].Name; first != "read" {
+ t.Fatalf("expected compact p95 sort to put read first, got %q", first)
+ }
+
+ next, _ = model.Update(tea.WindowSizeMsg{Width: 160, Height: 30})
+ model = next.(Model)
+ if first := model.sortedSyscallRows()[0].Name; first != "read" {
+ t.Fatalf("expected p95 sort to survive width expansion, got %q", first)
+ }
+}
+
+func TestStatsTickReanchorsSortedSyscallSelectionByName(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ m.syscallsSort = tableSortState[syscallSortKey]{active: true, key: syscallSortKeyName}
+ oldSnap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "read", Count: 9},
+ {Name: "write", Count: 3},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &oldSnap
+ m.syscallsOffset = 1
+
+ newSnap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "close", Count: 50},
+ {Name: "read", Count: 9},
+ {Name: "write", Count: 3},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+
+ next, _ := m.Update(messages.StatsTickMsg{Snap: &newSnap})
+ model := next.(Model)
+ if model.syscallsOffset != 2 {
+ t.Fatalf("expected selected write row reanchored to offset 2, got %d", model.syscallsOffset)
+ }
+ if selected := model.selectedSyscallName(); selected != "write" {
+ t.Fatalf("expected selected syscall write after stats refresh, got %q", selected)
+ }
+}
+
func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
diff --git a/internal/tui/dashboard/sort.go b/internal/tui/dashboard/sort.go
new file mode 100644
index 0000000..acb19d0
--- /dev/null
+++ b/internal/tui/dashboard/sort.go
@@ -0,0 +1,13 @@
+package dashboard
+
+type tableSortState[K comparable] struct {
+ active bool
+ key K
+}
+
+func (s tableSortState[K]) toggled(key K) tableSortState[K] {
+ if s.active && s.key == key {
+ return tableSortState[K]{}
+ }
+ return tableSortState[K]{active: true, key: key}
+}
diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go
index 9518f09..78eed0c 100644
--- a/internal/tui/dashboard/syscalls.go
+++ b/internal/tui/dashboard/syscalls.go
@@ -2,6 +2,7 @@ package dashboard
import (
"fmt"
+ "slices"
"strconv"
"time"
@@ -9,20 +10,52 @@ import (
common "ior/internal/tui/common"
)
+type syscallSortKey uint8
+
+const (
+ syscallSortKeyName syscallSortKey = iota
+ syscallSortKeyCount
+ syscallSortKeyRate
+ syscallSortKeyAvg
+ syscallSortKeyMin
+ syscallSortKeyMax
+ syscallSortKeyP50
+ syscallSortKeyP95
+ syscallSortKeyP99
+ syscallSortKeyBytes
+ syscallSortKeyErrors
+)
+
func renderSyscalls(snap *statsengine.Snapshot, width, height int) string {
- return renderSyscallsWithOffset(snap, width, height, 0, 0)
+ return renderSyscallsWithSort(snap, width, height, 0, 0, tableSortState[syscallSortKey]{})
}
func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
+ return renderSyscallsWithSort(snap, width, height, offset, selectedCol, tableSortState[syscallSortKey]{})
+}
+
+func renderSyscallsWithSort(snap *statsengine.Snapshot, width, height, offset, selectedCol int, sortState tableSortState[syscallSortKey]) string {
if snap == nil {
return "Syscalls: waiting for stats..."
}
- columns, rows := syscallTableData(snap.Syscalls(), width)
+ rowsData := sortedSyscallSnapshots(snap.Syscalls(), sortState)
+ columns, rows := syscallTableData(rowsData, width)
if len(rows) == 0 {
return "Syscalls: no data"
}
- return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric")
+ return renderSelectableTable(
+ columns,
+ rows,
+ height,
+ offset,
+ selectedCol,
+ "enter:filter",
+ "s:sort",
+ syscallSortHint(sortState),
+ "v:mode",
+ "b:metric",
+ )
}
func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]common.TableColumn, [][]string) {
@@ -62,6 +95,197 @@ func syscallColumns(width int) []common.TableColumn {
}
}
+func sortedSyscallSnapshots(rows []statsengine.SyscallSnapshot, sortState tableSortState[syscallSortKey]) []statsengine.SyscallSnapshot {
+ if len(rows) == 0 {
+ return nil
+ }
+ if !sortState.active {
+ return rows
+ }
+
+ sorted := slices.Clone(rows)
+ slices.SortFunc(sorted, func(left, right statsengine.SyscallSnapshot) int {
+ if cmp := compareSyscallBySort(left, right, sortState.key); cmp != 0 {
+ return cmp
+ }
+ return compareSyscallDefault(left, right)
+ })
+ return sorted
+}
+
+func compareSyscallBySort(left, right statsengine.SyscallSnapshot, key syscallSortKey) int {
+ switch key {
+ case syscallSortKeyName:
+ return compareStringAsc(left.Name, right.Name)
+ case syscallSortKeyCount:
+ return compareUint64Desc(left.Count, right.Count)
+ case syscallSortKeyRate:
+ return compareFloat64Desc(left.RatePerSec, right.RatePerSec)
+ case syscallSortKeyAvg:
+ return compareFloat64Desc(left.LatencyMeanNs, right.LatencyMeanNs)
+ case syscallSortKeyMin:
+ return compareUint64Desc(left.LatencyMinNs, right.LatencyMinNs)
+ case syscallSortKeyMax:
+ return compareUint64Desc(left.LatencyMaxNs, right.LatencyMaxNs)
+ case syscallSortKeyP50:
+ return compareUint64Desc(left.LatencyP50Ns, right.LatencyP50Ns)
+ case syscallSortKeyP95:
+ return compareUint64Desc(left.LatencyP95Ns, right.LatencyP95Ns)
+ case syscallSortKeyP99:
+ return compareUint64Desc(left.LatencyP99Ns, right.LatencyP99Ns)
+ case syscallSortKeyBytes:
+ return compareUint64Desc(left.Bytes, right.Bytes)
+ case syscallSortKeyErrors:
+ return compareUint64Desc(left.Errors, right.Errors)
+ default:
+ return 0
+ }
+}
+
+func compareSyscallDefault(left, right statsengine.SyscallSnapshot) int {
+ if cmp := compareUint64Desc(left.Count, right.Count); cmp != 0 {
+ return cmp
+ }
+ return compareStringAsc(left.Name, right.Name)
+}
+
+func compareUint64Desc(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:
+ return -1
+ case left < right:
+ return 1
+ default:
+ return 0
+ }
+}
+
+func compareStringAsc(left, right string) int {
+ switch {
+ case left < right:
+ return -1
+ case left > right:
+ return 1
+ default:
+ return 0
+ }
+}
+
+func syscallSortKeyForColumn(width, column int) (syscallSortKey, bool) {
+ if width < 140 {
+ return compactSyscallSortKey(column)
+ }
+ return fullSyscallSortKey(column)
+}
+
+func compactSyscallSortKey(column int) (syscallSortKey, bool) {
+ switch column {
+ case 0:
+ return syscallSortKeyName, true
+ case 1:
+ return syscallSortKeyCount, true
+ case 2:
+ return syscallSortKeyRate, true
+ case 3:
+ return syscallSortKeyAvg, true
+ case 4:
+ return syscallSortKeyP95, true
+ case 5:
+ return syscallSortKeyP99, true
+ case 6:
+ return syscallSortKeyBytes, true
+ case 7:
+ return syscallSortKeyErrors, true
+ default:
+ return 0, false
+ }
+}
+
+func fullSyscallSortKey(column int) (syscallSortKey, bool) {
+ switch column {
+ case 0:
+ return syscallSortKeyName, true
+ case 1:
+ return syscallSortKeyCount, true
+ case 2:
+ return syscallSortKeyRate, true
+ case 3:
+ return syscallSortKeyAvg, true
+ case 4:
+ return syscallSortKeyMin, true
+ case 5:
+ return syscallSortKeyMax, true
+ case 6:
+ return syscallSortKeyP50, true
+ case 7:
+ return syscallSortKeyP95, true
+ case 8:
+ return syscallSortKeyP99, true
+ case 9:
+ return syscallSortKeyBytes, true
+ case 10:
+ return syscallSortKeyErrors, true
+ default:
+ return 0, false
+ }
+}
+
+func syscallSortHint(sortState tableSortState[syscallSortKey]) string {
+ return "sort: " + syscallSortLabel(sortState)
+}
+
+func syscallSortLabel(sortState tableSortState[syscallSortKey]) string {
+ if !sortState.active {
+ return "default"
+ }
+ switch sortState.key {
+ case syscallSortKeyName:
+ return "Syscall asc"
+ case syscallSortKeyCount:
+ return "Count desc"
+ case syscallSortKeyRate:
+ return "Rate/s desc"
+ case syscallSortKeyAvg:
+ return "Avg desc"
+ case syscallSortKeyMin:
+ return "Min desc"
+ case syscallSortKeyMax:
+ return "Max desc"
+ case syscallSortKeyP50:
+ return "p50 desc"
+ case syscallSortKeyP95:
+ return "p95 desc"
+ case syscallSortKeyP99:
+ return "p99 desc"
+ case syscallSortKeyBytes:
+ return "Bytes desc"
+ case syscallSortKeyErrors:
+ return "Errors desc"
+ default:
+ return "default"
+ }
+}
+
+func findSyscallOffset(rows []statsengine.SyscallSnapshot, name string) (int, bool) {
+ for idx, row := range rows {
+ if row.Name == name {
+ return idx, true
+ }
+ }
+ return 0, false
+}
+
func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) [][]string {
rows := make([][]string, 0, len(syscalls))
for _, s := range syscalls {
diff --git a/internal/tui/dashboard/syscalls_test.go b/internal/tui/dashboard/syscalls_test.go
index dfb6384..5645aae 100644
--- a/internal/tui/dashboard/syscalls_test.go
+++ b/internal/tui/dashboard/syscalls_test.go
@@ -28,6 +28,12 @@ func TestRenderSyscallsIncludesHeaders(t *testing.T) {
if !strings.Contains(out, "read") {
t.Fatalf("expected syscall row in output")
}
+ if !strings.Contains(out, "s:sort") {
+ t.Fatalf("expected syscall sort hint in output")
+ }
+ if !strings.Contains(out, "sort: default") {
+ t.Fatalf("expected default sort label in output")
+ }
}
func TestFormatDurationNs(t *testing.T) {
@@ -50,3 +56,32 @@ func TestClampOffset(t *testing.T) {
t.Fatalf("expected max index clamp, got %d", got)
}
}
+
+func TestSortedSyscallSnapshotsUsesSelectedSortKey(t *testing.T) {
+ rows := []statsengine.SyscallSnapshot{
+ {Name: "write", Count: 8, LatencyP95Ns: 10},
+ {Name: "read", Count: 3, LatencyP95Ns: 50},
+ }
+
+ sorted := sortedSyscallSnapshots(rows, tableSortState[syscallSortKey]{active: true, key: syscallSortKeyName})
+ if sorted[0].Name != "read" {
+ t.Fatalf("expected syscall name sort to put read first, got %q", sorted[0].Name)
+ }
+
+ sorted = sortedSyscallSnapshots(rows, tableSortState[syscallSortKey]{active: true, key: syscallSortKeyP95})
+ if sorted[0].Name != "read" {
+ t.Fatalf("expected p95 desc sort to put read first, got %q", sorted[0].Name)
+ }
+}
+
+func TestSyscallSortKeyForColumnKeepsLogicalMeaningAcrossWidths(t *testing.T) {
+ key, ok := syscallSortKeyForColumn(120, 4)
+ if !ok || key != syscallSortKeyP95 {
+ t.Fatalf("expected compact column 4 to map to p95, got %v ok=%v", key, ok)
+ }
+
+ key, ok = syscallSortKeyForColumn(160, 7)
+ if !ok || key != syscallSortKeyP95 {
+ t.Fatalf("expected full column 7 to map to p95, got %v ok=%v", key, ok)
+ }
+}