summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-18 19:13:59 +0300
committerPaul Buetow <paul@buetow.org>2026-05-18 19:13:59 +0300
commit65599ad9b87b1c61cb6d8232200da88952370e96 (patch)
tree862e20468835255ed06544a2df2470678d3b97dc /internal/tui
parenta92cb0283b1ba8735a6697a8f94911397534131f (diff)
t6 add syscall family dashboard aggregation
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/common/keys.go6
-rw-r--r--internal/tui/dashboard/model.go10
-rw-r--r--internal/tui/dashboard/nonio.go99
-rw-r--r--internal/tui/dashboard/nonio_test.go37
-rw-r--r--internal/tui/dashboard/tabregistry.go22
-rw-r--r--internal/tui/dashboard/tabs.go2
-rw-r--r--internal/tui/dashboard/tabs_test.go14
-rw-r--r--internal/tui/eventstream/export.go3
-rw-r--r--internal/tui/help.go2
9 files changed, 187 insertions, 8 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index e50ee94..ffdc31a 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -19,6 +19,7 @@ type KeyMap struct {
Five key.Binding
Six key.Binding
Seven key.Binding
+ Eight key.Binding
Visualize key.Binding
Metric key.Binding
Sort key.Binding
@@ -62,6 +63,7 @@ func DefaultKeyMap() KeyMap {
Five: keyBinding("processes", "5"),
Six: keyBinding("lat+gaps", "6"),
Seven: keyBinding("stream", "7"),
+ Eight: keyBinding("non-io", "8"),
Visualize: keyBinding("viz", "v"),
Metric: keyBinding("metric", "b"),
Sort: keyBinding("sort table", "s"),
@@ -110,7 +112,7 @@ func (k KeyMap) globalStatusBindings() []key.Binding {
bindings := []key.Binding{
helpTextBinding("H", "toggle help"),
k.Tab, k.ShiftTab,
- k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven,
+ k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven, k.Eight,
k.Visualize, k.Metric, k.Sort, k.ReverseSort,
k.Filter, k.FilterUndo,
k.SelectPID, k.SelectTID,
@@ -154,7 +156,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
controls = append(controls, k.Visualize, k.Metric, k.Sort, k.ReverseSort, k.Filter, k.FilterUndo)
return [][]key.Binding{
- {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
+ {k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven, k.Eight},
controls,
{
helpTextBinding("space", "stream pause"),
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 1c6f66c..1f479c7 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -103,6 +103,8 @@ type Model struct {
syscallsCol int
syscallsSort tableSortState[syscallSortKey]
syscallsTreemapSelection int
+ nonIOOffset int
+ nonIOCol int
filesOffset int
filesCol int
filesSort tableSortState[fileSortKey]
@@ -304,6 +306,7 @@ func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) {
m.reanchorFilesDirOffset(selectedDir)
m.reanchorProcessesOffset(selectedProcess)
m.syscallsTreemapSelection = clampOffset(m.syscallsTreemapSelection, m.maxSyscallsRows())
+ m.nonIOOffset = clampOffset(m.nonIOOffset, m.maxNonIORows())
m.clampTableColumns()
m.streamModel.Refresh()
if m.refreshBubbleData() {
@@ -870,6 +873,7 @@ func scrollOffset(keyStr string, offset *int, maxRows int) bool {
func (m *Model) clampTableColumns() {
m.syscallsCol = common.ClampTableCol(m.syscallsCol, len(syscallColumns(m.width)))
+ m.nonIOCol = common.ClampTableCol(m.nonIOCol, len(nonIOColumns(m.width)))
m.filesCol = common.ClampTableCol(m.filesCol, len(fileColumns(m.width)))
m.filesDirCol = common.ClampTableCol(m.filesDirCol, len(fileDirColumns(m.width)))
m.processesCol = common.ClampTableCol(m.processesCol, len(processColumns()))
@@ -879,6 +883,10 @@ func (m Model) maxSyscallsRows() int {
return m.snapshotOrZero().SyscallsCount()
}
+func (m Model) maxNonIORows() int {
+ return m.snapshotOrZero().NonIOFamiliesCount()
+}
+
func (m Model) maxFilesRows() int {
return m.snapshotOrZero().FilesCount()
}
@@ -1269,6 +1277,8 @@ func (m Model) renderActiveContentTable(width, activeHeight int) (string, bool)
switch {
case m.activeTab == TabSyscalls && m.latest != nil:
return renderSyscallsWithSort(m.latest, width, activeHeight, m.syscallsOffset, m.syscallsCol, m.syscallsSort), true
+ case m.activeTab == TabNonIO && m.latest != nil:
+ return renderNonIOWithOffset(m.latest, width, activeHeight, m.nonIOOffset, m.nonIOCol), true
case m.activeTab == TabFiles && m.latest != nil && m.filesVizMode == tabVizModeTable:
if m.filesDirGrouped {
return renderFilesDirGroupedWithSort(m.latest, width, activeHeight, m.filesDirOffset, m.filesDirCol, m.filesDirSort), true
diff --git a/internal/tui/dashboard/nonio.go b/internal/tui/dashboard/nonio.go
new file mode 100644
index 0000000..aef63f4
--- /dev/null
+++ b/internal/tui/dashboard/nonio.go
@@ -0,0 +1,99 @@
+package dashboard
+
+import (
+ "fmt"
+ "strconv"
+
+ "ior/internal/statsengine"
+ common "ior/internal/tui/common"
+)
+
+func renderNonIO(snap *statsengine.Snapshot, width, height int) string {
+ return renderNonIOWithOffset(snap, width, height, 0, 0)
+}
+
+func renderNonIOWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
+ if snap == nil {
+ return "Non-IO: waiting for stats..."
+ }
+
+ rowsData := snap.NonIOFamilies()
+ columns, rows := nonIOTableData(rowsData, width)
+ if len(rows) == 0 {
+ return "Non-IO: no data"
+ }
+ return renderSelectableTable(
+ columns,
+ rows,
+ height,
+ offset,
+ selectedCol,
+ "families excluding file/fd",
+ )
+}
+
+func nonIOTableData(families []statsengine.FamilySnapshot, width int) ([]common.TableColumn, [][]string) {
+ columns := nonIOColumns(width)
+ if width < 130 {
+ return columns, nonIORowsCompact(families)
+ }
+ return columns, nonIORowsFull(families)
+}
+
+func nonIOColumns(width int) []common.TableColumn {
+ if width < 130 {
+ return []common.TableColumn{
+ {Title: "Family", Width: 10},
+ {Title: "Count", Width: 7},
+ {Title: "Rate/s", Width: 7},
+ {Title: "Avg", Width: 8},
+ {Title: "Bytes", Width: 8},
+ {Title: "Errors", Width: 6},
+ }
+ }
+
+ return []common.TableColumn{
+ {Title: "Family", Width: 12},
+ {Title: "Count", Width: 8},
+ {Title: "Rate/s", Width: 8},
+ {Title: "Avg", Width: 9},
+ {Title: "Min", Width: 9},
+ {Title: "Max", Width: 9},
+ {Title: "Total", Width: 10},
+ {Title: "Bytes", Width: 10},
+ {Title: "Errors", Width: 8},
+ }
+}
+
+func nonIORowsFull(families []statsengine.FamilySnapshot) [][]string {
+ rows := make([][]string, 0, len(families))
+ for _, f := range families {
+ rows = append(rows, []string{
+ f.Name,
+ strconv.FormatUint(f.Count, 10),
+ fmt.Sprintf("%.1f", f.RatePerSec),
+ formatDurationNs(f.LatencyMeanNs),
+ formatDurationUintNs(f.LatencyMinNs),
+ formatDurationUintNs(f.LatencyMaxNs),
+ formatDurationUintNs(f.TotalLatencyNs),
+ formatBytes(float64(f.Bytes)),
+ strconv.FormatUint(f.Errors, 10),
+ })
+ }
+ return rows
+}
+
+func nonIORowsCompact(families []statsengine.FamilySnapshot) [][]string {
+ rows := make([][]string, 0, len(families))
+ for _, f := range families {
+ rows = append(rows, []string{
+ f.Name,
+ strconv.FormatUint(f.Count, 10),
+ fmt.Sprintf("%.1f", f.RatePerSec),
+ formatDurationNs(f.LatencyMeanNs),
+ formatBytes(float64(f.Bytes)),
+ strconv.FormatUint(f.Errors, 10),
+ })
+ }
+ return rows
+}
diff --git a/internal/tui/dashboard/nonio_test.go b/internal/tui/dashboard/nonio_test.go
new file mode 100644
index 0000000..5fabc76
--- /dev/null
+++ b/internal/tui/dashboard/nonio_test.go
@@ -0,0 +1,37 @@
+package dashboard
+
+import (
+ "strings"
+ "testing"
+
+ "ior/internal/statsengine"
+ "ior/internal/types"
+)
+
+func TestRenderNonIOIncludesExpectedFamilyRows(t *testing.T) {
+ snap := statsengine.NewSnapshotWithFamilies(
+ nil,
+ nil,
+ nil,
+ nil,
+ []statsengine.FamilySnapshot{
+ {Family: types.FamilyFS, Name: "FS", Count: 99},
+ {Family: types.FamilyPolling, Name: "Polling", Count: 7, RatePerSec: 3.5, Errors: 1},
+ {Family: types.FamilyProcess, Name: "Process", Count: 2},
+ },
+ nil,
+ nil,
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ out := renderNonIO(&snap, 120, 20)
+ for _, token := range []string{"Family", "Count", "Rate/s", "Polling", "Process"} {
+ if !strings.Contains(out, token) {
+ t.Fatalf("expected token %q in non-io table:\n%s", token, out)
+ }
+ }
+ if strings.Contains(out, "FS") {
+ t.Fatalf("non-io table should exclude FS rows:\n%s", out)
+ }
+}
diff --git a/internal/tui/dashboard/tabregistry.go b/internal/tui/dashboard/tabregistry.go
index 76fa216..c9f6d06 100644
--- a/internal/tui/dashboard/tabregistry.go
+++ b/internal/tui/dashboard/tabregistry.go
@@ -90,6 +90,15 @@ var tabDescriptors = map[Tab]tabDescriptor{
HandleScroll: tabScrollSyscalls,
ShortcutKey: func(k common.KeyMap) key.Binding { return k.Three },
},
+ TabNonIO: {
+ Name: "Non-IO",
+ ShortName: "NIO",
+ Position: 75,
+ AllowedVizModes: []tabVizMode{tabVizModeTable},
+ Render: tabRenderNonIO,
+ HandleScroll: tabScrollNonIO,
+ ShortcutKey: func(k common.KeyMap) key.Binding { return k.Eight },
+ },
TabFiles: {
Name: "Files",
ShortName: "Fil",
@@ -202,6 +211,12 @@ func tabRenderSyscalls(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Mode
return renderSyscalls(snap, width, height)
}
+// tabRenderNonIO adapts renderNonIO to the tabRenderFn signature.
+// Offset rendering is handled by renderActiveContentTable before this path.
+func tabRenderNonIO(_ *Model, snap *statsengine.Snapshot, _ *eventstream.Model, _ *flamegraphtui.Model, width, height int) string {
+ return renderNonIO(snap, width, height)
+}
+
// tabRenderFiles adapts renderFiles to the tabRenderFn signature, choosing
// between the dir-grouped and plain view based on model state.
// Sort-state rendering is handled by renderActiveContentTable before this path.
@@ -241,6 +256,13 @@ func tabScrollSyscalls(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
+// tabScrollNonIO handles navigation keys for the non-IO family table.
+func tabScrollNonIO(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
+ keyStr := msg.String()
+ return common.HandleTableNavigationKey(keyStr, &m.nonIOOffset, &m.nonIOCol,
+ m.maxNonIORows(), len(nonIOColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
+}
+
// tabScrollFiles handles navigation keys for the files tab, selecting between
// the dir-grouped and plain navigation paths based on model state.
func tabScrollFiles(m *Model, msg tea.KeyPressMsg) (bool, tea.Cmd) {
diff --git a/internal/tui/dashboard/tabs.go b/internal/tui/dashboard/tabs.go
index 8fc5132..2772a0a 100644
--- a/internal/tui/dashboard/tabs.go
+++ b/internal/tui/dashboard/tabs.go
@@ -18,6 +18,8 @@ const (
TabOverview Tab = iota
// TabSyscalls is the syscall table tab.
TabSyscalls
+ // TabNonIO is the syscall-family summary tab for non-FS families.
+ TabNonIO
// TabFiles is the file ranking tab.
TabFiles
// TabProcesses is the process breakdown tab.
diff --git a/internal/tui/dashboard/tabs_test.go b/internal/tui/dashboard/tabs_test.go
index cbf1810..4e9dce6 100644
--- a/internal/tui/dashboard/tabs_test.go
+++ b/internal/tui/dashboard/tabs_test.go
@@ -11,8 +11,14 @@ func TestTabNavigationWraps(t *testing.T) {
if got := nextTab(TabLatency); got != TabStream {
t.Fatalf("expected next after latency+gaps to be stream, got %v", got)
}
- if got := nextTab(TabStream); got != TabFlame {
- t.Fatalf("expected next after stream to be flame, got %v", got)
+ if got := nextTab(TabStream); got != TabNonIO {
+ t.Fatalf("expected next after stream to be non-io, got %v", got)
+ }
+ if got := prevTab(TabFlame); got != TabNonIO {
+ t.Fatalf("expected prev before flame to be non-io, got %v", got)
+ }
+ if got := nextTab(TabNonIO); got != TabFlame {
+ t.Fatalf("expected next after non-io to be flame, got %v", got)
}
if got := nextTab(TabFlame); got != TabOverview {
t.Fatalf("expected wrap to overview from flame, got %v", got)
@@ -23,8 +29,8 @@ func TestTabNavigationWraps(t *testing.T) {
}
func TestRenderTabBarContainsLabels(t *testing.T) {
- out := renderTabBar(TabOverview, 100)
- for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream", "Flame"} {
+ out := renderTabBar(TabOverview, 140)
+ for _, label := range []string{"Overview", "Syscalls", "Files", "Processes", "Latency+Gaps", "Stream", "Non-IO", "Flame"} {
if !strings.Contains(out, label) {
t.Fatalf("expected tab label %q in tab bar", label)
}
diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go
index 46e3a23..accec8a 100644
--- a/internal/tui/eventstream/export.go
+++ b/internal/tui/eventstream/export.go
@@ -182,7 +182,7 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
// writeStreamCSV writes the CSV header and all event rows to w, calling fail
// on the first write error to close the underlying file before returning.
func writeStreamCSV(w *csv.Writer, rows []StreamEvent, fail func(error) (string, error)) error {
- header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "fd", "ret", "bytes", "file", "error"}
+ header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "family", "fd", "ret", "bytes", "file", "error"}
if err := w.Write(header); err != nil {
_, err = fail(err)
return err
@@ -198,6 +198,7 @@ func writeStreamCSV(w *csv.Writer, rows []StreamEvent, fail func(error) (string,
fmt.Sprintf("%d", ev.PID),
fmt.Sprintf("%d", ev.TID),
ev.Syscall,
+ ev.Family,
fmt.Sprintf("%d", ev.FD),
fmt.Sprintf("%d", ev.RetVal),
fmt.Sprintf("%d", ev.Bytes),
diff --git a/internal/tui/help.go b/internal/tui/help.go
index 5a343cb..04362b8 100644
--- a/internal/tui/help.go
+++ b/internal/tui/help.go
@@ -69,7 +69,7 @@ func (m Model) helpSections() []helpSection {
{
title: "Dashboard Tabs",
lines: []string{
- "tab/shift+tab tabs 1..7 jump tab r reset baseline R parquet rec",
+ "tab/shift+tab tabs 1..8 jump tab r reset baseline R parquet rec",
"I cycle auto-reset (off → 10s → 30s → 1m → 2m → 5m); status shows remaining/total",
"sys/files/proc/stream tables: arrows or hjkl move pgup/pgdown page g/G top/bottom",
"sys/files/proc tables: s sort S reverse sort",