summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-06 19:09:31 +0200
committerPaul Buetow <paul@buetow.org>2026-03-06 19:09:31 +0200
commit0b2d40cf7ff9b26bfd020488b537bdfdd6f852ae (patch)
tree97facea0dbd7a49c6e1a64e931fbe0e5bfe05682
parenta76e81adca48fea5df4a16382ec7e7b0ab461e7f (diff)
feat(tui): use treemap as second viz for files and processes
-rw-r--r--internal/tui/dashboard/model.go13
-rw-r--r--internal/tui/dashboard/model_test.go52
-rw-r--r--internal/tui/dashboard/treemap.go133
3 files changed, 166 insertions, 32 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 08272b9..aae98b1 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -523,8 +523,11 @@ 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.activeTab == TabFiles && m.filesVizMode == tabVizModeTreemap && m.filesDirGrouped {
+ return renderFilesTreemap(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)
}
if m.bubbleEnabledForTab(m.activeTab) {
switch m.activeTab {
@@ -725,12 +728,12 @@ func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) {
func (m Model) allowedVizModes(tab Tab) []tabVizMode {
switch tab {
case TabSyscalls:
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap}
+ return []tabVizMode{tabVizModeTable, tabVizModeTreemap}
case TabProcesses:
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles}
+ return []tabVizMode{tabVizModeTable, tabVizModeTreemap}
case TabFiles:
if m.filesDirGrouped {
- return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeIcicle}
+ return []tabVizMode{tabVizModeTable, tabVizModeTreemap}
}
return []tabVizMode{tabVizModeTable}
default:
diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go
index 522e97e..fafcad3 100644
--- a/internal/tui/dashboard/model_test.go
+++ b/internal/tui/dashboard/model_test.go
@@ -353,12 +353,6 @@ func TestVisualizationCycleForSyscallsTab(t *testing.T) {
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model := next.(Model)
- if got := model.syscallsVizMode; got != tabVizModeBubbles {
- t.Fatalf("expected syscalls bubble mode enabled")
- }
-
- next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
- model = next.(Model)
if got := model.syscallsVizMode; got != tabVizModeTreemap {
t.Fatalf("expected syscalls treemap mode enabled")
}
@@ -385,7 +379,7 @@ func TestBubbleMetricToggleForSyscallsTab(t *testing.T) {
}
}
-func TestMetricToggleAppliesInFilesIcicleMode(t *testing.T) {
+func TestMetricToggleAppliesInFilesTreemapMode(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{})
@@ -393,16 +387,16 @@ func TestMetricToggleAppliesInFilesIcicleMode(t *testing.T) {
m.activeTab = TabFiles
m.latest = &snap
m.filesDirGrouped = true
- m.filesVizMode = tabVizModeIcicle
+ m.filesVizMode = tabVizModeTreemap
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)
+ t.Fatalf("expected files metric toggle to bytes in treemap mode, got %q", got)
}
}
-func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
+func TestFilesTreemapRequiresDirectoryMode(t *testing.T) {
snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{
{Path: "/tmp/a", Accesses: 3},
{Path: "/tmp/b", Accesses: 1},
@@ -414,7 +408,7 @@ func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model := next.(Model)
if got := model.filesVizMode; got != tabVizModeTable {
- t.Fatalf("expected files bubble mode to stay disabled without directory mode")
+ t.Fatalf("expected files treemap mode to stay disabled without directory mode")
}
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
@@ -425,20 +419,20 @@ func TestFilesBubbleRequiresDirectoryMode(t *testing.T) {
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})})
model = next.(Model)
- if got := model.filesVizMode; got != tabVizModeBubbles {
- t.Fatalf("expected files bubble mode enabled in directory mode")
+ if got := model.filesVizMode; got != tabVizModeTreemap {
+ t.Fatalf("expected files treemap 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")
+ if got := model.filesVizMode; got != tabVizModeTable {
+ t.Fatalf("expected files mode cycled back to table")
}
next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})})
model = next.(Model)
if got := model.filesVizMode; got != tabVizModeTable {
- t.Fatalf("expected files bubble mode disabled when leaving directory mode")
+ t.Fatalf("expected files mode reset to table when leaving directory mode")
}
}
@@ -498,7 +492,7 @@ func TestTreemapModeRendersTreemapHeader(t *testing.T) {
}
}
-func TestIcicleModeRendersFilesHeader(t *testing.T) {
+func TestTreemapModeRendersFilesHeader(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},
@@ -507,13 +501,31 @@ func TestIcicleModeRendersFilesHeader(t *testing.T) {
m.activeTab = TabFiles
m.latest = &snap
m.filesDirGrouped = true
- m.filesVizMode = tabVizModeIcicle
+ m.filesVizMode = tabVizModeTreemap
+ m.width = 120
+ m.height = 28
+
+ out := m.View().Content
+ if !strings.Contains(out, "Files treemap") {
+ t.Fatalf("expected treemap 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},
+ {PID: 11, Comm: "agent", Syscalls: 4, Bytes: 120},
+ }, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{})
+ m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap())
+ m.activeTab = TabProcesses
+ m.latest = &snap
+ m.processesVizMode = tabVizModeTreemap
m.width = 120
m.height = 28
out := m.View().Content
- if !strings.Contains(out, "Files icicle") {
- t.Fatalf("expected icicle header in files view")
+ if !strings.Contains(out, "Processes treemap") {
+ t.Fatalf("expected treemap header in processes view")
}
}
diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go
index 8202f10..24b7f55 100644
--- a/internal/tui/dashboard/treemap.go
+++ b/internal/tui/dashboard/treemap.go
@@ -4,6 +4,7 @@ import (
"fmt"
"image/color"
"math"
+ "path/filepath"
"sort"
"strings"
"unicode/utf8"
@@ -21,6 +22,7 @@ type syscallTreemapItem struct {
Bytes uint64
Errors uint64
P95Ns uint64
+ Detail string
Value uint64
}
@@ -43,16 +45,36 @@ func renderSyscallsTreemap(snap *statsengine.Snapshot, width, height int, metric
if snap == nil {
return "Syscalls treemap: waiting for stats..."
}
+ items := buildSyscallTreemapItems(snap, metric)
+ return renderTreemapPanel("Syscalls treemap", "Syscalls treemap: no data", items, width, height, metric, selected, isDark)
+}
+
+func renderFilesTreemap(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string {
+ if snap == nil {
+ return "Files treemap: waiting for stats..."
+ }
+ items := buildFilesTreemapItems(snap, metric)
+ return renderTreemapPanel("Files treemap", "Files treemap: no directory data", items, width, height, metric, selected, isDark)
+}
+
+func renderProcessesTreemap(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string {
+ if snap == nil {
+ return "Processes treemap: waiting for stats..."
+ }
+ items := buildProcessesTreemapItems(snap, metric)
+ return renderTreemapPanel("Processes treemap", "Processes treemap: no data", items, width, height, metric, selected, isDark)
+}
+
+func renderTreemapPanel(title, emptyText string, items []syscallTreemapItem, width, height int, metric bubbleMetric, selected int, isDark bool) string {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 18
}
- items := buildSyscallTreemapItems(snap, metric)
- header := fmt.Sprintf("Syscalls treemap | metric:%s | v mode | b metric | j/k select", treemapMetricLabel(metric))
+ header := fmt.Sprintf("%s | metric:%s | v mode | b metric | j/k select", title, treemapMetricLabel(metric))
if len(items) == 0 {
- return header + "\nSyscalls treemap: no data\nsel: none"
+ return header + "\n" + emptyText + "\nsel: none"
}
selected = clampOffset(selected, len(items))
@@ -94,6 +116,101 @@ func buildSyscallTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) [
Bytes: syscall.Bytes,
Errors: syscall.Errors,
P95Ns: syscall.LatencyP95Ns,
+ Detail: fmt.Sprintf(
+ "rate %.1f/s, errors %d, p95 %s",
+ syscall.RatePerSec,
+ syscall.Errors,
+ formatDurationUintNs(syscall.LatencyP95Ns),
+ ),
+ }
+ item.Value = treemapValue(item, metric)
+ if item.Value == 0 {
+ continue
+ }
+ items = append(items, item)
+ }
+ if len(items) == 0 {
+ return nil
+ }
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Value != items[j].Value {
+ return items[i].Value > items[j].Value
+ }
+ return items[i].Name < items[j].Name
+ })
+ if len(items) > maxSyscallTreemapItems {
+ items = items[:maxSyscallTreemapItems]
+ }
+ return items
+}
+
+func buildFilesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []syscallTreemapItem {
+ if snap == nil {
+ return nil
+ }
+ 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
+ }
+ totalBytes := dir.BytesRead + dir.BytesWritten
+ item := syscallTreemapItem{
+ Name: label,
+ Count: dir.Accesses,
+ Bytes: totalBytes,
+ Detail: fmt.Sprintf(
+ "dir %s, files %d, read %s, write %s, max %s",
+ dir.Dir,
+ dir.FileCount,
+ formatBytes(float64(dir.BytesRead)),
+ formatBytes(float64(dir.BytesWritten)),
+ formatDurationUintNs(dir.MaxLatencyNs),
+ ),
+ }
+ item.Value = treemapValue(item, metric)
+ if item.Value == 0 {
+ continue
+ }
+ items = append(items, item)
+ }
+ if len(items) == 0 {
+ return nil
+ }
+ sort.Slice(items, func(i, j int) bool {
+ if items[i].Value != items[j].Value {
+ return items[i].Value > items[j].Value
+ }
+ return items[i].Name < items[j].Name
+ })
+ if len(items) > maxSyscallTreemapItems {
+ items = items[:maxSyscallTreemapItems]
+ }
+ return items
+}
+
+func buildProcessesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []syscallTreemapItem {
+ if snap == nil {
+ return nil
+ }
+ processes := snap.Processes()
+ items := make([]syscallTreemapItem, 0, len(processes))
+ for _, proc := range processes {
+ label := fmt.Sprintf("%d", proc.PID)
+ if comm := strings.TrimSpace(proc.Comm); comm != "" {
+ label = fmt.Sprintf("%d:%s", proc.PID, comm)
+ }
+ item := syscallTreemapItem{
+ Name: label,
+ Count: proc.Syscalls,
+ Bytes: proc.Bytes,
+ Detail: fmt.Sprintf(
+ "pid %d, rate %.1f/s, avg %s",
+ proc.PID,
+ proc.RatePerSec,
+ formatDurationNs(proc.AvgLatencyNs),
+ ),
}
item.Value = treemapValue(item, metric)
if item.Value == 0 {
@@ -315,17 +432,19 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe
if metric == bubbleMetricBytes {
metricText = formatBytes(float64(metricValue))
}
- return fmt.Sprintf(
- "sel:%d/%d %s | %s=%s | bytes=%s | errors=%d | p95=%s",
+ status := fmt.Sprintf(
+ "sel:%d/%d %s | %s=%s | bytes=%s",
selected+1,
len(items),
item.Name,
treemapMetricLabel(metric),
metricText,
formatBytes(float64(item.Bytes)),
- item.Errors,
- formatDurationUintNs(item.P95Ns),
)
+ if detail := strings.TrimSpace(item.Detail); detail != "" {
+ status += " | " + detail
+ }
+ return status
}
func treemapMetricLabel(metric bubbleMetric) string {