diff options
Diffstat (limited to 'internal/tui/dashboard')
| -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 |
6 files changed, 180 insertions, 4 deletions
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) } |
