diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 19:09:31 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 19:09:31 +0200 |
| commit | 0b2d40cf7ff9b26bfd020488b537bdfdd6f852ae (patch) | |
| tree | 97facea0dbd7a49c6e1a64e931fbe0e5bfe05682 | |
| parent | a76e81adca48fea5df4a16382ec7e7b0ab461e7f (diff) | |
feat(tui): use treemap as second viz for files and processes
| -rw-r--r-- | internal/tui/dashboard/model.go | 13 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 52 | ||||
| -rw-r--r-- | internal/tui/dashboard/treemap.go | 133 |
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 { |
