diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 18:33:44 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 18:33:44 +0200 |
| commit | a76e81adca48fea5df4a16382ec7e7b0ab461e7f (patch) | |
| tree | 633e239adddceb7ef1ca5fc3c1301fe4e1206716 | |
| parent | b3bbf184dcdff908abbd4413c77e1455b24de0c9 (diff) | |
feat(tui): add files icicle visualization mode (task 383)
| -rw-r--r-- | internal/tui/dashboard/icicle.go | 292 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 8 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 42 |
3 files changed, 340 insertions, 2 deletions
diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go new file mode 100644 index 0000000..d868ae3 --- /dev/null +++ b/internal/tui/dashboard/icicle.go @@ -0,0 +1,292 @@ +package dashboard + +import ( + "fmt" + "math" + "path/filepath" + "sort" + "strings" + + "ior/internal/statsengine" +) + +type icicleNode struct { + name string + fullPath string + accesses uint64 + bytes uint64 + children map[string]*icicleNode +} + +type icicleTile struct { + node *icicleNode + depth int + x int + w int + colorSlot int +} + +func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string { + if snap == nil { + return "Files icicle: waiting for stats..." + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 18 + } + header := fmt.Sprintf("Files icicle | metric:%s | v mode | b metric | j/k select", treemapMetricLabel(metric)) + dirs := aggregateFilesByDir(snap.Files()) + if len(dirs) == 0 { + return header + "\nFiles icicle: no directory data\nsel: none" + } + + root := buildIcicleTree(dirs) + children := sortedIcicleChildren(root, metric) + if len(children) == 0 { + return header + "\nFiles icicle: no directory data\nsel: none" + } + + chartHeight := height - 2 + if chartHeight < 4 { + chartHeight = 4 + } + + tiles := make([]icicleTile, 0, 64) + layoutIcicle(children, 0, width, 0, chartHeight, 0, metric, &tiles) + if len(tiles) == 0 { + return header + "\nFiles icicle: no visible tiles\nsel: none" + } + + selected = clampOffset(selected, len(tiles)) + grid := make([][]treemapCell, chartHeight) + for row := 0; row < chartHeight; row++ { + grid[row] = make([]treemapCell, width) + for col := range grid[row] { + grid[row][col] = treemapCell{char: ' ', colorSlot: -1} + } + } + fillIcicleGrid(grid, tiles, selected) + palette := treemapPalette(isDark) + + lines := make([]string, 0, chartHeight+2) + lines = append(lines, padOrTrim(header, width)) + for _, row := range grid { + lines = append(lines, renderTreemapRow(row, palette)) + } + lines = append(lines, padOrTrim(icicleStatusLine(tiles, selected, metric), width)) + return strings.Join(lines, "\n") +} + +func buildIcicleTree(dirs []DirSnapshot) *icicleNode { + root := &icicleNode{ + name: "/", + fullPath: "/", + children: make(map[string]*icicleNode), + } + for _, dir := range dirs { + segments := splitIcicleSegments(dir.Dir) + current := root + metricBytes := dir.BytesRead + dir.BytesWritten + current.accesses += dir.Accesses + current.bytes += metricBytes + currentPath := "/" + for _, segment := range segments { + if segment == "" { + continue + } + if currentPath == "/" { + currentPath = "/" + segment + } else { + currentPath = currentPath + "/" + segment + } + child := current.children[segment] + if child == nil { + child = &icicleNode{ + name: segment, + fullPath: currentPath, + children: make(map[string]*icicleNode), + } + current.children[segment] = child + } + child.accesses += dir.Accesses + child.bytes += metricBytes + current = child + } + } + return root +} + +func splitIcicleSegments(dir string) []string { + cleaned := filepath.Clean(strings.TrimSpace(dir)) + if cleaned == "." || cleaned == "/" || cleaned == "" { + return nil + } + cleaned = strings.TrimPrefix(cleaned, "/") + if cleaned == "" { + return nil + } + return strings.Split(cleaned, "/") +} + +func sortedIcicleChildren(node *icicleNode, metric bubbleMetric) []*icicleNode { + if node == nil || len(node.children) == 0 { + return nil + } + out := make([]*icicleNode, 0, len(node.children)) + for _, child := range node.children { + out = append(out, child) + } + sort.Slice(out, func(i, j int) bool { + vi := icicleValue(out[i], metric) + vj := icicleValue(out[j], metric) + if vi != vj { + return vi > vj + } + return out[i].name < out[j].name + }) + return out +} + +func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, metric bubbleMetric, out *[]icicleTile) { + if len(nodes) == 0 || width <= 0 || depth >= maxDepth { + return + } + total := uint64(0) + for _, node := range nodes { + total += icicleValue(node, metric) + } + if total == 0 { + return + } + + remainingWidth := width + remainingValue := total + cursor := x + for idx, node := range nodes { + value := icicleValue(node, metric) + tileWidth := remainingWidth + if idx < len(nodes)-1 { + tileWidth = int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue))) + minRemaining := len(nodes) - idx - 1 + if tileWidth < 1 { + tileWidth = 1 + } + if tileWidth > remainingWidth-minRemaining { + tileWidth = remainingWidth - minRemaining + } + } + if tileWidth <= 0 { + continue + } + colorSlot := rootSlot + if depth == 0 { + colorSlot = idx + } + *out = append(*out, icicleTile{ + node: node, + depth: depth, + x: cursor, + w: tileWidth, + colorSlot: colorSlot, + }) + if depth+1 < maxDepth { + layoutIcicle(sortedIcicleChildren(node, metric), cursor, tileWidth, depth+1, maxDepth, colorSlot, metric, out) + } + + cursor += tileWidth + remainingWidth -= tileWidth + remainingValue -= value + if remainingWidth <= 0 { + break + } + } +} + +func fillIcicleGrid(grid [][]treemapCell, tiles []icicleTile, selected int) { + height := len(grid) + if height == 0 { + return + } + width := len(grid[0]) + if width == 0 { + return + } + for idx, tile := range tiles { + if tile.depth < 0 || tile.depth >= height { + continue + } + isSelected := idx == selected + for col := tile.x; col < minInt(width, tile.x+tile.w); col++ { + if col < 0 { + continue + } + grid[tile.depth][col] = treemapCell{ + char: '█', + colorSlot: tile.colorSlot, + bold: isSelected, + } + } + drawIcicleLabel(grid, tile, isSelected) + } +} + +func drawIcicleLabel(grid [][]treemapCell, tile icicleTile, selected bool) { + height := len(grid) + if height == 0 || tile.depth < 0 || tile.depth >= height || tile.w <= 1 { + return + } + width := len(grid[0]) + maxLabel := tile.w - 1 + label := abbreviateTreemapLabel(tile.node.name, maxLabel) + col := tile.x + for _, r := range []rune(label) { + if col < 0 { + col++ + continue + } + if col >= width { + break + } + grid[tile.depth][col] = treemapCell{ + char: r, + colorSlot: tile.colorSlot, + bold: selected, + } + col++ + } +} + +func icicleStatusLine(tiles []icicleTile, selected int, metric bubbleMetric) string { + if len(tiles) == 0 { + return "sel:none" + } + selected = clampOffset(selected, len(tiles)) + tile := tiles[selected] + metricValue := icicleValue(tile.node, metric) + metricText := fmt.Sprintf("%d", metricValue) + if metric == bubbleMetricBytes { + metricText = formatBytes(float64(metricValue)) + } + return fmt.Sprintf( + "sel:%d/%d %s | %s=%s | accesses=%d | bytes=%s", + selected+1, + len(tiles), + tile.node.fullPath, + treemapMetricLabel(metric), + metricText, + tile.node.accesses, + formatBytes(float64(tile.node.bytes)), + ) +} + +func icicleValue(node *icicleNode, metric bubbleMetric) uint64 { + if node == nil { + return 0 + } + if metric == bubbleMetricBytes { + return node.bytes + } + return node.accesses +} diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index c0bcc11..08272b9 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -47,6 +47,7 @@ const ( tabVizModeTable tabVizMode = iota tabVizModeBubbles tabVizModeTreemap + tabVizModeIcicle ) // Model is the dashboard tab framework model. @@ -284,7 +285,7 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.DirGroup): if m.activeTab == TabFiles { m.filesDirGrouped = !m.filesDirGrouped - if !m.filesDirGrouped && m.filesVizMode == tabVizModeBubbles { + if !m.filesDirGrouped && m.filesVizMode != tabVizModeTable { m.filesVizMode = tabVizModeTable } if m.bubbleEnabledForTab(m.activeTab) && m.refreshBubbleData() { @@ -522,6 +523,9 @@ func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventst if m.activeTab == TabSyscalls && m.syscallsVizMode == tabVizModeTreemap { return renderSyscallsTreemap(m.latest, width, activeHeight, m.syscallsChart.Metric(), m.syscallsTreemapSelection, 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.bubbleEnabledForTab(m.activeTab) { switch m.activeTab { case TabSyscalls: @@ -726,7 +730,7 @@ func (m Model) allowedVizModes(tab Tab) []tabVizMode { return []tabVizMode{tabVizModeTable, tabVizModeBubbles} case TabFiles: if m.filesDirGrouped { - return []tabVizMode{tabVizModeTable, tabVizModeBubbles} + return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeIcicle} } return []tabVizMode{tabVizModeTable} default: diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index a92a77c..522e97e 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -385,6 +385,23 @@ func TestBubbleMetricToggleForSyscallsTab(t *testing.T) { } } +func TestMetricToggleAppliesInFilesIcicleMode(t *testing.T) { + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/var/log/a", Accesses: 5, BytesRead: 120, 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 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'b'}[0], Text: string([]rune{'b'})}) + model := next.(Model) + if got := model.filesChart.Metric(); got != bubbleMetricBytes { + t.Fatalf("expected files metric toggle to bytes in icicle mode, got %q", got) + } +} + func TestFilesBubbleRequiresDirectoryMode(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ {Path: "/tmp/a", Accesses: 3}, @@ -412,6 +429,12 @@ func TestFilesBubbleRequiresDirectoryMode(t *testing.T) { t.Fatalf("expected files bubble 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 != tabVizModeIcicle { + t.Fatalf("expected files icicle mode enabled in directory mode") + } + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) model = next.(Model) if got := model.filesVizMode; got != tabVizModeTable { @@ -475,6 +498,25 @@ func TestTreemapModeRendersTreemapHeader(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 TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls |
