From a4c72ad2cbe4ca857a5880675563b2ab4d24e1b5 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Mon, 9 Mar 2026 22:35:53 +0200 Subject: tui: add sortable syscalls dashboard table (task 363) --- internal/tui/common/keys.go | 6 +- internal/tui/common/keys_test.go | 24 ++++ internal/tui/dashboard/model.go | 77 ++++++++++- internal/tui/dashboard/model_test.go | 136 +++++++++++++++++++ internal/tui/dashboard/sort.go | 13 ++ internal/tui/dashboard/syscalls.go | 230 +++++++++++++++++++++++++++++++- internal/tui/dashboard/syscalls_test.go | 35 +++++ 7 files changed, 510 insertions(+), 11 deletions(-) create mode 100644 internal/tui/dashboard/sort.go (limited to 'internal') 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) @@ -136,6 +141,18 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) { t.Fatalf("expected metric binding in dashboard full help controls") } + 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() @@ -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()) @@ -271,6 +276,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if !handled { handled, cmd = m.handleEnterKey(msg) } + if !handled { + handled, cmd = m.handleSortKey(msg) + } if !handled { handled, cmd = m.handleShortcutKey(msg) } @@ -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) + } +} -- cgit v1.2.3