summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-08 08:59:02 +0200
committerPaul Buetow <paul@buetow.org>2026-03-08 08:59:02 +0200
commit77b993ded6e8cfa15e053a09ef79581afd0b7e4b (patch)
treed72b0a66152bfb2fd9404f47101fe7159ada33fb
parent9950c77981ce06be34e877a6729abb23a36789c6 (diff)
dashboard: wire files icicle mode and root path labels
-rw-r--r--internal/tui/dashboard/bubbles.go11
-rw-r--r--internal/tui/dashboard/icicle.go4
-rw-r--r--internal/tui/dashboard/model.go5
-rw-r--r--internal/tui/dashboard/model_test.go27
-rw-r--r--internal/tui/dashboard/pathlabel.go17
-rw-r--r--internal/tui/dashboard/pathlabel_test.go81
-rw-r--r--internal/tui/dashboard/treemap.go12
7 files changed, 141 insertions, 16 deletions
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go
index 0ec91a9..e6e2909 100644
--- a/internal/tui/dashboard/bubbles.go
+++ b/internal/tui/dashboard/bubbles.go
@@ -5,7 +5,6 @@ import (
"hash/fnv"
"image/color"
"math"
- "path/filepath"
"sort"
"strings"
"unicode/utf8"
@@ -587,6 +586,7 @@ func renderBubbleRow(cells []bubbleCell, palette []color.Color) string {
}
var b strings.Builder
styleCache := make(map[string]lipgloss.Style, 8)
+ selectedColor := lipgloss.Color("129")
for _, cell := range cells {
if cell.colorSlot < 0 {
if cell.bold {
@@ -605,6 +605,9 @@ func renderBubbleRow(cells []bubbleCell, palette []color.Color) string {
if !ok {
style = lipgloss.NewStyle().Foreground(palette[slot])
if cell.bold {
+ style = style.Foreground(selectedColor)
+ }
+ if cell.bold {
style = style.Bold(true)
}
styleCache[key] = style
@@ -795,15 +798,11 @@ func filesDirBubbleData(snap *statsengine.Snapshot) []bubbleDatum {
dirs := aggregateFilesByDir(snap.Files())
data := make([]bubbleDatum, 0, len(dirs))
for _, dir := range dirs {
- label := filepath.Base(dir.Dir)
- if label == "." || label == "/" || label == "" {
- label = dir.Dir
- }
totalBytes := dir.BytesRead + dir.BytesWritten
detail := fmt.Sprintf("dir %s, files %d, read %s, write %s", dir.Dir, dir.FileCount, formatBytes(float64(dir.BytesRead)), formatBytes(float64(dir.BytesWritten)))
data = append(data, bubbleDatum{
ID: dir.Dir,
- Label: label,
+ Label: rootPathLabelFromFSPath(dir.Dir),
Count: dir.Accesses,
Bytes: totalBytes,
Detail: detail,
diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go
index d868ae3..3761fc8 100644
--- a/internal/tui/dashboard/icicle.go
+++ b/internal/tui/dashboard/icicle.go
@@ -239,7 +239,7 @@ func drawIcicleLabel(grid [][]treemapCell, tile icicleTile, selected bool) {
}
width := len(grid[0])
maxLabel := tile.w - 1
- label := abbreviateTreemapLabel(tile.node.name, maxLabel)
+ label := abbreviateTreemapLabel(rootPathLabelFromFSPath(tile.node.fullPath), maxLabel)
col := tile.x
for _, r := range []rune(label) {
if col < 0 {
@@ -273,7 +273,7 @@ func icicleStatusLine(tiles []icicleTile, selected int, metric bubbleMetric) str
"sel:%d/%d %s | %s=%s | accesses=%d | bytes=%s",
selected+1,
len(tiles),
- tile.node.fullPath,
+ rootPathLabelFromFSPath(tile.node.fullPath),
treemapMetricLabel(metric),
metricText,
tile.node.accesses,
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 5949755..e464af8 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -526,6 +526,9 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst
if m.activeTab == TabFiles && m.filesVizMode == tabVizModeTreemap && m.filesDirGrouped {
return renderFilesTreemap(m.latest, width, activeHeight, m.filesChart.Metric(), m.filesDirOffset, m.isDark)
}
+ if m.activeTab == TabFiles && m.filesVizMode == tabVizModeIcicle && m.filesDirGrouped {
+ return renderFilesIcicle(m.latest, width, activeHeight, m.filesChart.Metric(), m.filesDirOffset, m.isDark)
+ }
if m.activeTab == TabProcesses && m.processesVizMode == tabVizModeTreemap {
return renderProcessesTreemap(m.latest, width, activeHeight, m.processesChart.Metric(), m.processesOffset, m.isDark)
}
@@ -733,7 +736,7 @@ func (m Model) allowedVizModes(tab Tab) []tabVizMode {
return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
case TabFiles:
if m.filesDirGrouped {
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
+ return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap, tabVizModeIcicle}
}
return []tabVizMode{tabVizModeTable}
default:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 8b03b2b..5a3be89 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -402,7 +402,7 @@ func TestMetricToggleAppliesInFilesTreemapMode(t *testing.T) {
}
}
-func TestFilesTreemapRequiresDirectoryMode(t *testing.T) {
+func TestFilesVisualizationRequiresDirectoryMode(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
{Path: "/tmp/a", Accesses: 3},
{Path: "/tmp/b", Accesses: 1},
@@ -437,6 +437,12 @@ func TestFilesTreemapRequiresDirectoryMode(t *testing.T) {
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model = next.(Model)
+ if got := model.filesVizMode; got != tabVizModeIcicle {
+ t.Fatalf("expected files icicle mode enabled in directory mode")
+ }
+
+ next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
+ model = next.(Model)
if got := model.filesVizMode; got != tabVizModeTable {
t.Fatalf("expected files mode cycled back to table")
}
@@ -523,6 +529,25 @@ func TestTreemapModeRendersFilesHeader(t *testing.T) {
}
}
+func TestIcicleModeRendersFilesHeader(t *testing.T) {
+ snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
+ {Path: "/srv/log/a", Accesses: 9, BytesRead: 400, BytesWritten: 200},
+ {Path: "/srv/log/b", Accesses: 4, BytesRead: 100, BytesWritten: 40},
+ }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabFiles
+ m.latest = &snap
+ m.filesDirGrouped = true
+ m.filesVizMode = tabVizModeIcicle
+ m.width = 120
+ m.height = 28
+
+ out := m.View().Content
+ if !strings.Contains(out, "Files icicle") {
+ t.Fatalf("expected icicle header in files view")
+ }
+}
+
func TestTreemapModeRendersProcessesHeader(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, nil, []statsengine.ProcessSnapshot{
{PID: 10, Comm: "worker", Syscalls: 12, Bytes: 500},
diff --git a/internal/tui/dashboard/pathlabel.go b/internal/tui/dashboard/pathlabel.go
new file mode 100644
index 0000000..4782dc0
--- /dev/null
+++ b/internal/tui/dashboard/pathlabel.go
@@ -0,0 +1,17 @@
+package dashboard
+
+import (
+ "path/filepath"
+ "strings"
+)
+
+func rootPathLabelFromFSPath(path string) string {
+ cleaned := filepath.ToSlash(filepath.Clean(strings.TrimSpace(path)))
+ if cleaned == "" || cleaned == "." || cleaned == "/" {
+ return "root"
+ }
+ if strings.HasPrefix(cleaned, "/") {
+ return "root" + cleaned
+ }
+ return "root/" + cleaned
+}
diff --git a/internal/tui/dashboard/pathlabel_test.go b/internal/tui/dashboard/pathlabel_test.go
new file mode 100644
index 0000000..66d3ff0
--- /dev/null
+++ b/internal/tui/dashboard/pathlabel_test.go
@@ -0,0 +1,81 @@
+package dashboard
+
+import (
+ "testing"
+
+ "ior/internal/statsengine"
+)
+
+func TestRootPathLabelFromFSPath(t *testing.T) {
+ cases := []struct {
+ in string
+ want string
+ }{
+ {in: "", want: "root"},
+ {in: "/", want: "root"},
+ {in: "/var/log", want: "root/var/log"},
+ {in: "var/log", want: "root/var/log"},
+ {in: "./tmp", want: "root/tmp"},
+ }
+ for _, tc := range cases {
+ if got := rootPathLabelFromFSPath(tc.in); got != tc.want {
+ t.Fatalf("rootPathLabelFromFSPath(%q)=%q want %q", tc.in, got, tc.want)
+ }
+ }
+}
+
+func TestTreemapRootPathTextDirectoryOnly(t *testing.T) {
+ snap := statsengine.NewSnapshot(
+ nil,
+ nil,
+ nil,
+ []statsengine.SyscallSnapshot{{Name: "read", Count: 3, Bytes: 12}},
+ []statsengine.FileSnapshot{{Path: "/var/log/app.log", Accesses: 4, BytesRead: 16, BytesWritten: 8}},
+ []statsengine.ProcessSnapshot{{PID: 42, Comm: "proc", Syscalls: 5, Bytes: 20}},
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ sysItems := buildSyscallTreemapItems(&snap, bubbleMetricCount)
+ if len(sysItems) == 0 || sysItems[0].Name != "read" {
+ t.Fatalf("expected syscall treemap label to stay native, got %#v", sysItems)
+ }
+
+ fileItems := buildFilesTreemapItems(&snap, bubbleMetricCount)
+ if len(fileItems) == 0 || fileItems[0].Name != "root/var/log" {
+ t.Fatalf("expected files treemap label root path, got %#v", fileItems)
+ }
+
+ procItems := buildProcessesTreemapItems(&snap, bubbleMetricCount)
+ if len(procItems) == 0 || procItems[0].Name != "42:proc" {
+ t.Fatalf("expected process treemap label to stay native, got %#v", procItems)
+ }
+}
+
+func TestBubbleRootPathTextDirectoryOnly(t *testing.T) {
+ snap := statsengine.NewSnapshot(
+ nil,
+ nil,
+ nil,
+ []statsengine.SyscallSnapshot{{Name: "write", Count: 2, Bytes: 64}},
+ []statsengine.FileSnapshot{{Path: "/home/paul/.config/a", Accesses: 1, BytesRead: 5, BytesWritten: 7}},
+ []statsengine.ProcessSnapshot{{PID: 7, Comm: "worker", Syscalls: 9, Bytes: 70}},
+ statsengine.HistogramSnapshot{},
+ statsengine.HistogramSnapshot{},
+ )
+
+ sys := syscallBubbleData(&snap)
+ if len(sys) == 0 || sys[0].Label != "write" {
+ t.Fatalf("expected syscall bubble label to stay native, got %#v", sys)
+ }
+
+ files := filesDirBubbleData(&snap)
+ if len(files) == 0 || files[0].Label != "root/home/paul/.config" {
+ t.Fatalf("expected files bubble label full root path, got %#v", files)
+ }
+
+ procs := processBubbleData(&snap)
+ if len(procs) == 0 || procs[0].Label != "7:worker" {
+ t.Fatalf("expected process bubble label to stay native, got %#v", procs)
+ }
+}
diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go
index 24b7f55..7193952 100644
--- a/internal/tui/dashboard/treemap.go
+++ b/internal/tui/dashboard/treemap.go
@@ -4,7 +4,6 @@ import (
"fmt"
"image/color"
"math"
- "path/filepath"
"sort"
"strings"
"unicode/utf8"
@@ -151,13 +150,10 @@ func buildFilesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []s
dirs := aggregateFilesByDir(snap.Files())
items := make([]syscallTreemapItem, 0, len(dirs))
for _, dir := range dirs {
- label := filepath.Base(dir.Dir)
- if label == "." || label == "/" || label == "" {
- label = dir.Dir
- }
+ pathLabel := rootPathLabelFromFSPath(dir.Dir)
totalBytes := dir.BytesRead + dir.BytesWritten
item := syscallTreemapItem{
- Name: label,
+ Name: pathLabel,
Count: dir.Accesses,
Bytes: totalBytes,
Detail: fmt.Sprintf(
@@ -485,6 +481,7 @@ func renderTreemapRow(cells []treemapCell, palette []color.Color) string {
}
var b strings.Builder
styleCache := make(map[string]lipgloss.Style, 8)
+ selectedColor := lipgloss.Color("129")
for _, cell := range cells {
if cell.colorSlot < 0 {
if cell.bold {
@@ -503,6 +500,9 @@ func renderTreemapRow(cells []treemapCell, palette []color.Color) string {
if !ok {
style = lipgloss.NewStyle().Foreground(palette[slot])
if cell.bold {
+ style = style.Foreground(selectedColor)
+ }
+ if cell.bold {
style = style.Bold(true)
}
styleCache[key] = style