summaryrefslogtreecommitdiff
path: root/internal/tui/dashboard
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 22:35:34 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 22:35:34 +0200
commitd8bf918e83515f48564e0d0b98d30907944a1e0d (patch)
treeed155b0c365b0d322494a96ff799c3a59a1d9ec8 /internal/tui/dashboard
parent9f21f1004beeac10be9223bc8c5514261e397b6e (diff)
tui: unify table navigation and rendering
Diffstat (limited to 'internal/tui/dashboard')
-rw-r--r--internal/tui/dashboard/files.go90
-rw-r--r--internal/tui/dashboard/model.go114
-rw-r--r--internal/tui/dashboard/model_test.go144
-rw-r--r--internal/tui/dashboard/processes.go40
-rw-r--r--internal/tui/dashboard/processes_test.go2
-rw-r--r--internal/tui/dashboard/syscalls.go55
-rw-r--r--internal/tui/dashboard/table.go46
7 files changed, 368 insertions, 123 deletions
diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go
index e9bb218..e868e3a 100644
--- a/internal/tui/dashboard/files.go
+++ b/internal/tui/dashboard/files.go
@@ -1,14 +1,12 @@
package dashboard
import (
- "fmt"
"path/filepath"
"sort"
"strconv"
"ior/internal/statsengine"
-
- "charm.land/bubbles/v2/table"
+ common "ior/internal/tui/common"
)
type DirSnapshot struct {
@@ -24,10 +22,10 @@ type DirSnapshot struct {
}
func renderFiles(snap *statsengine.Snapshot, width, height int) string {
- return renderFilesWithOffset(snap, width, height, 0)
+ return renderFilesWithOffset(snap, width, height, 0, 0)
}
-func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int) string {
+func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
if snap == nil {
return "Files: waiting for stats..."
}
@@ -38,28 +36,11 @@ func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int
return "Files: no data"
}
- columns := []table.Column{
- {Title: "Accesses", Width: 8},
- {Title: "Read", Width: 9},
- {Title: "Write", Width: 9},
- {Title: "Avg Latency", Width: 11},
- {Title: "Max Latency", Width: 11},
- {Title: "Path", Width: pathWidth},
- }
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs] [v:mode in dirs]", cursor+1, len(rows))
+ columns := fileColumns(width)
+ return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:dirs", "v:mode in dirs")
}
-func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int) string {
+func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
if snap == nil {
return "Files (dirs): waiting for stats..."
}
@@ -70,32 +51,14 @@ func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int
return "Files (dirs): no data"
}
- columns := []table.Column{
- {Title: "Accesses", Width: 8},
- {Title: "Read", Width: 9},
- {Title: "Write", Width: 9},
- {Title: "Avg Latency", Width: 11},
- {Title: "Max Latency", Width: 11},
- {Title: "Files", Width: 5},
- {Title: "Directory", Width: pathWidth},
- }
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files] [v:mode] [b:metric]", cursor+1, len(rows))
+ columns := fileDirColumns(width)
+ return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "d:files", "v:mode", "b:metric")
}
-func fileRows(files []statsengine.FileSnapshot, pathWidth int) []table.Row {
- rows := make([]table.Row, 0, len(files))
+func fileRows(files []statsengine.FileSnapshot, pathWidth int) [][]string {
+ rows := make([][]string, 0, len(files))
for _, f := range files {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
strconv.FormatUint(f.Accesses, 10),
formatBytes(float64(f.BytesRead)),
formatBytes(float64(f.BytesWritten)),
@@ -132,6 +95,31 @@ func dirPathWidth(width int) int {
return w
}
+func fileColumns(width int) []common.TableColumn {
+ pathWidth := filePathWidth(width)
+ return []common.TableColumn{
+ {Title: "Accesses", Width: 8},
+ {Title: "Read", Width: 9},
+ {Title: "Write", Width: 9},
+ {Title: "Avg Latency", Width: 11},
+ {Title: "Max Latency", Width: 11},
+ {Title: "Path", Width: pathWidth},
+ }
+}
+
+func fileDirColumns(width int) []common.TableColumn {
+ pathWidth := dirPathWidth(width)
+ return []common.TableColumn{
+ {Title: "Accesses", Width: 8},
+ {Title: "Read", Width: 9},
+ {Title: "Write", Width: 9},
+ {Title: "Avg Latency", Width: 11},
+ {Title: "Max Latency", Width: 11},
+ {Title: "Files", Width: 5},
+ {Title: "Directory", Width: pathWidth},
+ }
+}
+
func truncatePathMiddle(path string, limit int) string {
if len(path) <= limit {
return path
@@ -187,10 +175,10 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot {
return out
}
-func dirRows(dirs []DirSnapshot, pathWidth int) []table.Row {
- rows := make([]table.Row, 0, len(dirs))
+func dirRows(dirs []DirSnapshot, pathWidth int) [][]string {
+ rows := make([][]string, 0, len(dirs))
for _, d := range dirs {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
strconv.FormatUint(d.Accesses, 10),
formatBytes(float64(d.BytesRead)),
formatBytes(float64(d.BytesWritten)),
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 1ffb6e8..3a19b74 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -70,11 +70,15 @@ type Model struct {
filterStack []string
pidFilter int
syscallsOffset int
+ syscallsCol int
syscallsTreemapSelection int
filesOffset int
+ filesCol int
filesDirGrouped bool
filesDirOffset int
+ filesDirCol int
processesOffset int
+ processesCol int
syscallsVizMode tabVizMode
filesVizMode tabVizMode
processesVizMode tabVizMode
@@ -164,6 +168,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m Model) handleWindowSize(msg tea.WindowSizeMsg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
+ m.clampTableColumns()
streamWidth, streamHeight := streamViewport(msg.Width, msg.Height)
m.streamModel.SetViewport(streamWidth, streamHeight)
flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp)
@@ -226,6 +231,7 @@ func (m Model) handleStatsTick(msg messages.StatsTickMsg) (tea.Model, tea.Cmd) {
m.filesOffset = clampOffset(m.filesOffset, m.maxFilesRows())
m.filesDirOffset = clampOffset(m.filesDirOffset, m.maxFilesDirRowsForMode())
m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows())
+ m.clampTableColumns()
m.streamModel.Refresh()
if m.refreshBubbleData() {
return m, bubbleTickCmdFn()
@@ -279,6 +285,24 @@ func (m Model) handleEnterKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
return false, nil
}
switch m.activeTab {
+ case TabSyscalls:
+ if m.syscallsVizMode != tabVizModeTable {
+ return false, nil
+ }
+ filter, action, ok := m.selectedSyscallFilter()
+ if !ok {
+ return false, nil
+ }
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
+ case TabFiles:
+ if m.filesVizMode != tabVizModeTable {
+ return false, nil
+ }
+ filter, action, ok := m.selectedFileFilter()
+ if !ok {
+ return false, nil
+ }
+ return true, func() tea.Msg { return messages.GlobalFilterRequestedMsg{Filter: filter, Action: action} }
case TabProcesses:
filter, action, ok := m.selectedProcessFilter()
if !ok {
@@ -290,6 +314,52 @@ 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 {
+ 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
+ }
+ filter := m.globalFilter.Clone()
+ filter.Syscall = &globalfilter.StringFilter{Pattern: selected.Name}
+ return filter, "syscall~" + selected.Name, true
+}
+
+func (m Model) selectedFileFilter() (globalfilter.Filter, string, bool) {
+ if m.latest == nil {
+ return globalfilter.Filter{}, "", false
+ }
+ filter := m.globalFilter.Clone()
+ if m.filesDirGrouped {
+ dirs := aggregateFilesByDir(m.latest.Files())
+ if len(dirs) == 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ selected := dirs[clampOffset(m.filesDirOffset, len(dirs))]
+ if strings.TrimSpace(selected.Dir) == "" {
+ return globalfilter.Filter{}, "", false
+ }
+ filter.File = &globalfilter.StringFilter{Pattern: selected.Dir}
+ return filter, "file~" + selected.Dir, true
+ }
+ files := m.latest.Files()
+ if len(files) == 0 {
+ return globalfilter.Filter{}, "", false
+ }
+ selected := files[clampOffset(m.filesOffset, len(files))]
+ if strings.TrimSpace(selected.Path) == "" {
+ return globalfilter.Filter{}, "", false
+ }
+ filter.File = &globalfilter.StringFilter{Pattern: selected.Path}
+ return filter, "file~" + selected.Path, true
+}
+
func (m Model) handleHelpToggleKey(msg tea.KeyPressMsg) (bool, tea.Model, tea.Cmd) {
if msg.String() != "H" {
return false, m, nil
@@ -380,9 +450,15 @@ func (m Model) selectedProcessFilter() (globalfilter.Filter, string, bool) {
return globalfilter.Filter{}, "", false
}
filter := m.globalFilter.Clone()
+ if m.processesCol == 1 {
+ comm := strings.TrimSpace(proc.Comm)
+ if comm != "" {
+ filter.Comm = &globalfilter.StringFilter{Pattern: comm}
+ return filter, "comm~" + comm, true
+ }
+ }
filter.PID = &globalfilter.NumericFilter{Op: globalfilter.OpEq, Value: int64(proc.PID)}
- action := fmt.Sprintf("pid=%d", proc.PID)
- return filter, action, true
+ return filter, fmt.Sprintf("pid=%d", proc.PID), true
}
func (m Model) selectedProcessSnapshot() (statsengine.ProcessSnapshot, bool) {
@@ -515,14 +591,14 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) {
if m.syscallsVizMode == tabVizModeTreemap {
return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil
}
- return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.syscallsOffset, &m.syscallsCol, m.maxSyscallsRows(), len(syscallColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
case TabFiles:
if m.filesDirGrouped {
- return scrollOffset(keyStr, &m.filesDirOffset, m.maxFilesDirRowsForMode()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.filesDirOffset, &m.filesDirCol, m.maxFilesDirRowsForMode(), len(fileDirColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
}
- return scrollOffset(keyStr, &m.filesOffset, m.maxFilesRows()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.filesOffset, &m.filesCol, m.maxFilesRows(), len(fileColumns(m.width)), tablePageStep(m.activeTableHeight())), nil
case TabProcesses:
- return scrollOffset(keyStr, &m.processesOffset, m.maxProcessesRows()), nil
+ return common.HandleTableNavigationKey(keyStr, &m.processesOffset, &m.processesCol, m.maxProcessesRows(), len(processColumns()), tablePageStep(m.activeTableHeight())), nil
case TabStream:
streamWidth, streamHeight := streamViewport(m.width, m.height)
m.streamModel.SetViewport(streamWidth, streamHeight)
@@ -566,6 +642,13 @@ func scrollOffset(keyStr string, offset *int, maxRows int) bool {
}
}
+func (m *Model) clampTableColumns() {
+ m.syscallsCol = common.ClampTableCol(m.syscallsCol, len(syscallColumns(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()))
+}
+
func (m Model) maxSyscallsRows() int {
if m.latest == nil {
return 0
@@ -775,13 +858,22 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
activeHeight,
m.pidFilter,
m.syscallsOffset,
+ m.syscallsCol,
m.filesOffset,
+ m.filesCol,
m.filesDirGrouped,
m.filesDirOffset,
+ m.filesDirCol,
m.processesOffset,
+ m.processesCol,
)
}
+func (m Model) activeTableHeight() int {
+ _, activeHeight := flameViewport(m.width, m.height, m.showHelp)
+ return activeHeight
+}
+
func (m *Model) setBubbleViewports(width, height int) {
m.syscallsChart.SetViewport(width, height)
m.filesChart.SetViewport(width, height)
@@ -988,7 +1080,7 @@ func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg { return refreshTickMsg{} })
}
-func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, filesOffset int, filesDirGrouped bool, filesDirOffset, processesOffset int) string {
+func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstream.Model, flameModel *flamegraphtui.Model, width, height, pidFilter, syscallsOffset, syscallsCol, filesOffset, filesCol int, filesDirGrouped bool, filesDirOffset, filesDirCol, processesOffset, processesCol int) string {
if tab == TabStream {
if streamModel == nil {
return common.PanelStyle.Render("Stream: waiting for source...")
@@ -1011,14 +1103,14 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, streamModel *eventstre
case TabOverview:
return renderOverview(snap, width, height)
case TabSyscalls:
- return renderSyscallsWithOffset(snap, width, height, syscallsOffset)
+ return renderSyscallsWithOffset(snap, width, height, syscallsOffset, syscallsCol)
case TabFiles:
if filesDirGrouped {
- return renderFilesDirGrouped(snap, width, height, filesDirOffset)
+ return renderFilesDirGrouped(snap, width, height, filesDirOffset, filesDirCol)
}
- return renderFilesWithOffset(snap, width, height, filesOffset)
+ return renderFilesWithOffset(snap, width, height, filesOffset, filesCol)
case TabProcesses:
- return renderProcessesWithOffset(snap, width, height, processesOffset, pidFilter)
+ return renderProcessesWithOffset(snap, width, height, processesOffset, processesCol, pidFilter)
case TabLatency:
return renderLatencyGapsTab(snap, width, height)
default:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index cfeb5c8..dbcde07 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -142,6 +142,63 @@ func TestProcessesTabScrollsWithJK(t *testing.T) {
}
}
+func TestSyscallsTabSupportsHorizontalColumnNavigation(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{{Name: "read", Count: 1}}, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ model := next.(Model)
+ if model.syscallsCol != 1 {
+ t.Fatalf("expected syscalls selected column 1 after right, got %d", model.syscallsCol)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ model = next.(Model)
+ if model.syscallsCol != 0 {
+ t.Fatalf("expected syscalls selected column 0 after left, got %d", model.syscallsCol)
+ }
+}
+
+func TestFilesTabSupportsHorizontalColumnNavigation(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{{Path: "/a"}}, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ model := next.(Model)
+ if model.filesCol != 1 {
+ t.Fatalf("expected files selected column 1 after right, got %d", model.filesCol)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ model = next.(Model)
+ if model.filesCol != 0 {
+ t.Fatalf("expected files selected column 0 after left, got %d", model.filesCol)
+ }
+}
+
+func TestProcessesTabSupportsHorizontalColumnNavigation(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabProcesses
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{{PID: 1, Comm: "alpha"}}, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+
+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})
+ model := next.(Model)
+ if model.processesCol != 1 {
+ t.Fatalf("expected processes selected column 1 after right, got %d", model.processesCol)
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: tea.KeyLeft})
+ model = next.(Model)
+ if model.processesCol != 0 {
+ t.Fatalf("expected processes selected column 0 after left, got %d", model.processesCol)
+ }
+}
+
func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabProcesses
@@ -170,6 +227,35 @@ func TestProcessesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
}
}
+func TestProcessesTabEnterCommColumnEmitsCommFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabProcesses
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{
+ {PID: 111, Comm: "alpha", Syscalls: 9},
+ {PID: 222, Comm: "beta", Syscalls: 4},
+ }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.processesOffset = 1
+ m.processesCol = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on processes comm column to emit a filter request")
+ }
+ msg := cmd()
+ req, ok := msg.(messages.GlobalFilterRequestedMsg)
+ if !ok {
+ t.Fatalf("expected GlobalFilterRequestedMsg, got %T", msg)
+ }
+ if req.Filter.Comm == nil || req.Filter.Comm.Pattern != "beta" {
+ t.Fatalf("expected comm beta filter, got %+v", req.Filter.Comm)
+ }
+ if req.Action != "comm~beta" {
+ t.Fatalf("expected action comm~beta, got %q", req.Action)
+ }
+}
+
func TestFilesTabScrollsWithJK(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
@@ -189,6 +275,34 @@ func TestFilesTabScrollsWithJK(t *testing.T) {
}
}
+func TestSyscallsTabEnterEmitsGlobalFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabSyscalls
+ snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{
+ {Name: "read", Count: 9},
+ {Name: "write", Count: 4},
+ }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.syscallsOffset = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on 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 != "write" {
+ t.Fatalf("expected syscall write filter, got %+v", req.Filter.Syscall)
+ }
+ if req.Action != "syscall~write" {
+ t.Fatalf("expected action syscall~write, got %q", req.Action)
+ }
+}
+
func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
m.activeTab = TabFiles
@@ -210,6 +324,34 @@ func TestFilesTabGroupedScrollUsesDirectoryOffset(t *testing.T) {
}
}
+func TestFilesTabEnterEmitsGlobalFilterRequest(t *testing.T) {
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
+ {Path: "/tmp/a"},
+ {Path: "/tmp/b"},
+ }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m.latest = &snap
+ m.filesOffset = 1
+
+ next, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
+ m = next.(Model)
+ if cmd == nil {
+ t.Fatalf("expected enter on files 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.File == nil || req.Filter.File.Pattern != "/tmp/b" {
+ t.Fatalf("expected file /tmp/b filter, got %+v", req.Filter.File)
+ }
+ if req.Action != "file~/tmp/b" {
+ t.Fatalf("expected action file~/tmp/b, got %q", req.Action)
+ }
+}
+
func TestStreamSpaceUnpauseSchedulesStreamTick(t *testing.T) {
rb := eventstream.NewRingBuffer()
m := NewModelWithConfig(nil, rb, 250, common.DefaultKeyMap())
@@ -853,7 +995,7 @@ func TestRenderActiveTabUsesDirectoryFilesViewWhenGrouped(t *testing.T) {
statsengine.HistogramSnapshot{},
statsengine.HistogramSnapshot{},
)
- out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, true, 0, 0)
+ out := renderActiveTab(TabFiles, &snap, nil, nil, 120, 30, -1, 0, 0, 0, 0, true, 0, 0, 0, 0)
if !strings.Contains(out, "Directory") {
t.Fatalf("expected grouped directory files view header, got %q", out)
}
diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go
index b71b22c..dbd8fbe 100644
--- a/internal/tui/dashboard/processes.go
+++ b/internal/tui/dashboard/processes.go
@@ -6,15 +6,14 @@ import (
"strings"
"ior/internal/statsengine"
-
- "charm.land/bubbles/v2/table"
+ common "ior/internal/tui/common"
)
func renderProcesses(snap *statsengine.Snapshot, width, height int) string {
- return renderProcessesWithOffset(snap, width, height, 0, -1)
+ return renderProcessesWithOffset(snap, width, height, 0, 0, -1)
}
-func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, pidFilter int) string {
+func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol, pidFilter int) string {
if snap == nil {
return "Processes: waiting for stats..."
}
@@ -24,7 +23,16 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset
return "Processes: no data"
}
- columns := []table.Column{
+ columns := processColumns()
+ out := renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric")
+ if pidFilter > 0 {
+ out += "\n" + "Note: this tab is most useful with All PIDs."
+ }
+ return out
+}
+
+func processColumns() []common.TableColumn {
+ return []common.TableColumn{
{Title: "PID", Width: 8},
{Title: "Comm", Width: 18},
{Title: "Syscalls", Width: 10},
@@ -32,28 +40,12 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset
{Title: "Total Bytes", Width: 12},
{Title: "Avg Latency", Width: 12},
}
-
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
-
- out := tbl.View() + fmt.Sprintf("\nRow %d/%d [enter:filter] [v:mode] [b:metric]", cursor+1, len(rows))
- if pidFilter > 0 {
- out += "\n" + "Note: this tab is most useful with All PIDs."
- }
- return out
}
-func processRows(processes []statsengine.ProcessSnapshot) []table.Row {
- rows := make([]table.Row, 0, len(processes))
+func processRows(processes []statsengine.ProcessSnapshot) [][]string {
+ rows := make([][]string, 0, len(processes))
for _, p := range processes {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
strconv.FormatUint(uint64(p.PID), 10),
truncateText(p.Comm, 18),
strconv.FormatUint(p.Syscalls, 10),
diff --git a/internal/tui/dashboard/processes_test.go b/internal/tui/dashboard/processes_test.go
index 24c1c1b..bfc26fc 100644
--- a/internal/tui/dashboard/processes_test.go
+++ b/internal/tui/dashboard/processes_test.go
@@ -41,7 +41,7 @@ func TestRenderProcessesShowsSinglePIDNote(t *testing.T) {
statsengine.HistogramSnapshot{},
)
- out := renderProcessesWithOffset(&snap, 100, 20, 0, 77)
+ out := renderProcessesWithOffset(&snap, 100, 20, 0, 0, 77)
if !strings.Contains(out, "most useful with All PIDs") {
t.Fatalf("expected single-pid guidance note")
}
diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go
index 5235b3e..9518f09 100644
--- a/internal/tui/dashboard/syscalls.go
+++ b/internal/tui/dashboard/syscalls.go
@@ -6,15 +6,14 @@ import (
"time"
"ior/internal/statsengine"
-
- "charm.land/bubbles/v2/table"
+ common "ior/internal/tui/common"
)
func renderSyscalls(snap *statsengine.Snapshot, width, height int) string {
- return renderSyscallsWithOffset(snap, width, height, 0)
+ return renderSyscallsWithOffset(snap, width, height, 0, 0)
}
-func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset int) string {
+func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset, selectedCol int) string {
if snap == nil {
return "Syscalls: waiting for stats..."
}
@@ -23,22 +22,20 @@ func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset
if len(rows) == 0 {
return "Syscalls: no data"
}
+ return renderSelectableTable(columns, rows, height, offset, selectedCol, "enter:filter", "v:mode", "b:metric")
+}
- tbl := table.New(
- table.WithColumns(columns),
- table.WithRows(rows),
- table.WithFocused(true),
- )
- tbl.SetHeight(syscallTableHeight(height))
- tbl.SetWidth(tableWidth(width))
- cursor := clampOffset(offset, len(rows))
- tbl.SetCursor(cursor)
- return tbl.View() + fmt.Sprintf("\nRow %d/%d [v:mode] [b:metric]", cursor+1, len(rows))
+func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]common.TableColumn, [][]string) {
+ columns := syscallColumns(width)
+ if width < 140 {
+ return columns, syscallRowsCompact(syscalls)
+ }
+ return columns, syscallRowsFull(syscalls)
}
-func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]table.Column, []table.Row) {
+func syscallColumns(width int) []common.TableColumn {
if width < 140 {
- columns := []table.Column{
+ return []common.TableColumn{
{Title: "Syscall", Width: 14},
{Title: "Count", Width: 6},
{Title: "Rate/s", Width: 7},
@@ -48,10 +45,9 @@ func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]tabl
{Title: "Bytes", Width: 8},
{Title: "Errors", Width: 6},
}
- return columns, syscallRowsCompact(syscalls)
}
- columns := []table.Column{
+ return []common.TableColumn{
{Title: "Syscall", Width: 16},
{Title: "Count", Width: 8},
{Title: "Rate/s", Width: 8},
@@ -64,13 +60,12 @@ func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]tabl
{Title: "Bytes", Width: 10},
{Title: "Errors", Width: 8},
}
- return columns, syscallRowsFull(syscalls)
}
-func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) []table.Row {
- rows := make([]table.Row, 0, len(syscalls))
+func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) [][]string {
+ rows := make([][]string, 0, len(syscalls))
for _, s := range syscalls {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
s.Name,
strconv.FormatUint(s.Count, 10),
fmt.Sprintf("%.1f", s.RatePerSec),
@@ -87,10 +82,10 @@ func syscallRowsFull(syscalls []statsengine.SyscallSnapshot) []table.Row {
return rows
}
-func syscallRowsCompact(syscalls []statsengine.SyscallSnapshot) []table.Row {
- rows := make([]table.Row, 0, len(syscalls))
+func syscallRowsCompact(syscalls []statsengine.SyscallSnapshot) [][]string {
+ rows := make([][]string, 0, len(syscalls))
for _, s := range syscalls {
- rows = append(rows, table.Row{
+ rows = append(rows, []string{
s.Name,
strconv.FormatUint(s.Count, 10),
fmt.Sprintf("%.1f", s.RatePerSec),
@@ -135,16 +130,6 @@ func syscallTableHeight(height int) int {
return h
}
-func tableWidth(width int) int {
- if width <= 0 {
- return 80
- }
- if width < 60 {
- return 60
- }
- return width
-}
-
func clampOffset(offset, size int) int {
if size == 0 {
return 0
diff --git a/internal/tui/dashboard/table.go b/internal/tui/dashboard/table.go
new file mode 100644
index 0000000..c47b305
--- /dev/null
+++ b/internal/tui/dashboard/table.go
@@ -0,0 +1,46 @@
+package dashboard
+
+import (
+ "fmt"
+ "strings"
+
+ common "ior/internal/tui/common"
+
+ "charm.land/lipgloss/v2"
+)
+
+func renderSelectableTable(columns []common.TableColumn, rows [][]string, height, selectedRow, selectedCol int, rowHint string, extraHints ...string) string {
+ if len(rows) == 0 {
+ return ""
+ }
+
+ selectedRow = clampOffset(selectedRow, len(rows))
+ selectedCol = common.ClampTableCol(selectedCol, len(columns))
+ start, end := common.VisibleTableWindow(selectedRow, len(rows), syscallTableHeight(height))
+
+ lines := make([]string, 0, end-start+2)
+ lines = append(lines, common.RenderTableHeader(columns))
+ for idx := start; idx < end; idx++ {
+ col := -1
+ if idx == selectedRow {
+ col = selectedCol
+ }
+ lines = append(lines, common.RenderTableRow(columns, rows[idx], idx == selectedRow, col, lipgloss.Style{}))
+ }
+
+ hints := []string{fmt.Sprintf("Row %d/%d Col %d/%d", selectedRow+1, len(rows), selectedCol+1, len(columns))}
+ if rowHint != "" {
+ hints = append(hints, rowHint)
+ }
+ hints = append(hints, extraHints...)
+ lines = append(lines, "["+strings.Join(hints, "] [")+"]")
+ return strings.Join(lines, "\n")
+}
+
+func tablePageStep(height int) int {
+ rows := syscallTableHeight(height)
+ if rows <= 1 {
+ return 1
+ }
+ return rows - 1
+}