diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-18 19:13:59 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-18 19:13:59 +0300 |
| commit | 65599ad9b87b1c61cb6d8232200da88952370e96 (patch) | |
| tree | 862e20468835255ed06544a2df2470678d3b97dc /internal/tui | |
| parent | a92cb0283b1ba8735a6697a8f94911397534131f (diff) | |
t6 add syscall family dashboard aggregation
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/keys.go | 6 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 10 | ||||
| -rw-r--r-- | internal/tui/dashboard/nonio.go | 99 | ||||
| -rw-r--r-- | internal/tui/dashboard/nonio_test.go | 37 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabregistry.go | 22 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/tabs_test.go | 14 | ||||
| -rw-r--r-- | internal/tui/eventstream/export.go | 3 | ||||
| -rw-r--r-- | internal/tui/help.go | 2 |
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", |
