summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 08:59:21 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 08:59:21 +0200
commitc774072685c4768ec796c5f61a8140f9f673db8c (patch)
tree88c663b3b39e7a24ea5c8f7085284908a55c76f6
parentf33961304827227fa1e742d12b73dae429ca2712 (diff)
tui/dashboard: implement files tab with scrolling
-rw-r--r--internal/tui/dashboard/files.go87
-rw-r--r--internal/tui/dashboard/files_test.go51
-rw-r--r--internal/tui/dashboard/model.go20
-rw-r--r--internal/tui/dashboard/model_test.go17
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}