diff options
| author | Paul Buetow <paul@buetow.org> | 2026-02-24 08:59:21 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-02-24 08:59:21 +0200 |
| commit | c774072685c4768ec796c5f61a8140f9f673db8c (patch) | |
| tree | 88c663b3b39e7a24ea5c8f7085284908a55c76f6 | |
| parent | f33961304827227fa1e742d12b73dae429ca2712 (diff) | |
tui/dashboard: implement files tab with scrolling
| -rw-r--r-- | internal/tui/dashboard/files.go | 87 | ||||
| -rw-r--r-- | internal/tui/dashboard/files_test.go | 51 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 20 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 17 |
4 files changed, 171 insertions, 4 deletions
diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go new file mode 100644 index 0000000..c52e887 --- /dev/null +++ b/internal/tui/dashboard/files.go @@ -0,0 +1,87 @@ +package dashboard + +import ( + "ior/internal/statsengine" + "strconv" + + "github.com/charmbracelet/bubbles/table" +) + +func renderFiles(snap *statsengine.Snapshot, width, height int) string { + return renderFilesWithOffset(snap, width, height, 0) +} + +func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int) string { + if snap == nil { + return "Files: waiting for stats..." + } + + rows := fileRows(snap.Files()) + if len(rows) == 0 { + return "Files: no data" + } + + columns := []table.Column{ + {Title: "Path", Width: filePathWidth(width)}, + {Title: "Accesses", Width: 10}, + {Title: "Bytes Read", Width: 12}, + {Title: "Bytes Written", Width: 14}, + {Title: "Avg Latency", Width: 12}, + {Title: "Max Latency", Width: 12}, + } + + tbl := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + ) + tbl.SetHeight(syscallTableHeight(height)) + tbl.SetWidth(tableWidth(width)) + tbl.SetCursor(clampOffset(offset, len(rows))) + return tbl.View() +} + +func fileRows(files []statsengine.FileSnapshot) []table.Row { + rows := make([]table.Row, 0, len(files)) + for _, f := range files { + rows = append(rows, table.Row{ + truncatePathMiddle(f.Path, 48), + strconv.FormatUint(f.Accesses, 10), + formatBytes(float64(f.BytesRead)), + formatBytes(float64(f.BytesWritten)), + formatDurationNs(f.AvgLatencyNs), + formatDurationUintNs(f.MaxLatencyNs), + }) + } + return rows +} + +func filePathWidth(width int) int { + if width <= 0 { + return 48 + } + w := width - 66 + if w < 20 { + return 20 + } + if w > 72 { + return 72 + } + return w +} + +func truncatePathMiddle(path string, limit int) string { + if len(path) <= limit { + return path + } + if limit <= 3 { + return path[:limit] + } + + head := (limit - 3) / 2 + tail := limit - 3 - head + if tail <= 0 { + return path[:limit] + } + return path[:head] + "..." + path[len(path)-tail:] +} diff --git a/internal/tui/dashboard/files_test.go b/internal/tui/dashboard/files_test.go new file mode 100644 index 0000000..f889b66 --- /dev/null +++ b/internal/tui/dashboard/files_test.go @@ -0,0 +1,51 @@ +package dashboard + +import ( + "strings" + "testing" + + "ior/internal/statsengine" +) + +func TestRenderFilesIncludesHeaders(t *testing.T) { + snap := statsengine.NewSnapshot( + nil, + nil, + nil, + nil, + []statsengine.FileSnapshot{ + {Path: "/var/log/app.log", Accesses: 42, BytesRead: 4096, BytesWritten: 2048, AvgLatencyNs: 1500, MaxLatencyNs: 20_000}, + }, + nil, + statsengine.HistogramSnapshot{}, + statsengine.HistogramSnapshot{}, + ) + + out := renderFiles(&snap, 120, 30) + for _, token := range []string{"Path", "Accesses", "Bytes Read", "Bytes Written", "Avg Latency", "Max Latency", "app.log"} { + if !strings.Contains(out, token) { + t.Fatalf("expected token %q in files table output", token) + } + } +} + +func TestRenderFilesNoData(t *testing.T) { + snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + if got := renderFiles(&snap, 100, 20); got != "Files: no data" { + t.Fatalf("unexpected no-data output: %q", got) + } +} + +func TestTruncatePathMiddle(t *testing.T) { + longPath := "/very/long/path/with/high/cardinality/segments/and/filename.log" + got := truncatePathMiddle(longPath, 24) + if len(got) != 24 { + t.Fatalf("expected truncated path length 24, got %d (%q)", len(got), got) + } + if !strings.Contains(got, "...") { + t.Fatalf("expected ellipsis in truncated path, got %q", got) + } + if !strings.HasPrefix(got, "/very") || !strings.HasSuffix(got, "e.log") { + t.Fatalf("expected head and tail preservation, got %q", got) + } +} diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 454e8ab..ae5c60f 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -1,7 +1,6 @@ package dashboard import ( - "fmt" "ior/internal/statsengine" "ior/internal/tui" "ior/internal/tui/messages" @@ -34,6 +33,7 @@ type Model struct { refreshEvery time.Duration keys tui.KeyMap syscallsOffset int + filesOffset int processesOffset int } @@ -107,6 +107,18 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } } + if m.activeTab == TabFiles { + switch msg.String() { + case "down", "j": + m.filesOffset++ + return m, nil + case "up", "k": + if m.filesOffset > 0 { + m.filesOffset-- + } + return m, nil + } + } switch { case key.Matches(msg, m.keys.Tab): @@ -141,7 +153,7 @@ func (m Model) View() string { var b strings.Builder b.WriteString(renderTabBar(m.activeTab, m.width)) b.WriteString("\n") - b.WriteString(renderActiveTab(m.activeTab, m.latest, m.width, m.height, m.syscallsOffset, m.processesOffset)) + b.WriteString(renderActiveTab(m.activeTab, m.latest, m.width, m.height, m.syscallsOffset, m.filesOffset, m.processesOffset)) b.WriteString("\n") b.WriteString(renderHelpBar(m.keys)) return tui.ScreenStyle.Render(b.String()) @@ -151,7 +163,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, width, height, syscallsOffset, processesOffset int) string { +func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscallsOffset, filesOffset, processesOffset int) string { _ = width _ = height @@ -165,7 +177,7 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscall case TabSyscalls: return renderSyscallsWithOffset(snap, width, height, syscallsOffset) case TabFiles: - return tui.PanelStyle.Render(fmt.Sprintf("Files: %d rows", len(snap.Files()))) + return renderFilesWithOffset(snap, width, height, filesOffset) case TabProcesses: return renderProcessesWithOffset(snap, width, height, processesOffset) case TabLatency: diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index 3d93659..1d2329d 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -77,6 +77,23 @@ func TestProcessesTabScrollsWithJK(t *testing.T) { } } +func TestFilesTabScrollsWithJK(t *testing.T) { + m := NewModelWithConfig(nil, 250, tui.DefaultKeyMap()) + m.activeTab = TabFiles + + next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + model := next.(Model) + if model.filesOffset != 1 { + t.Fatalf("expected files offset 1 after j, got %d", model.filesOffset) + } + + next, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}}) + model = next.(Model) + if model.filesOffset != 0 { + t.Fatalf("expected files offset 0 after k, got %d", model.filesOffset) + } +} + func TestRefreshTickEmitsStatsTickMsg(t *testing.T) { snap := &statsengine.Snapshot{TotalSyscalls: 9} engine := &fakeSnapshotSource{snap: snap} |
