diff options
| -rw-r--r-- | internal/tui/dashboard/model.go | 51 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 45 | ||||
| -rw-r--r-- | internal/tui/dashboard/treemap.go | 394 |
3 files changed, 468 insertions, 22 deletions
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 0eef629..c0bcc11 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -46,6 +46,7 @@ type tabVizMode uint8 const ( tabVizModeTable tabVizMode = iota tabVizModeBubbles + tabVizModeTreemap ) // Model is the dashboard tab framework model. @@ -59,25 +60,26 @@ type Model struct { width int height int - refreshEvery time.Duration - keys common.KeyMap - pidFilter int - syscallsOffset int - filesOffset int - filesDirGrouped bool - filesDirOffset int - processesOffset int - syscallsVizMode tabVizMode - filesVizMode tabVizMode - processesVizMode tabVizMode - streamModel eventstream.Model - flamegraphModel flamegraphtui.Model - syscallsChart bubbleChart - filesChart bubbleChart - processesChart bubbleChart - showHelp bool - isDark bool - focused bool + refreshEvery time.Duration + keys common.KeyMap + pidFilter int + syscallsOffset int + syscallsTreemapSelection int + filesOffset int + filesDirGrouped bool + filesDirOffset int + processesOffset int + syscallsVizMode tabVizMode + filesVizMode tabVizMode + processesVizMode tabVizMode + streamModel eventstream.Model + flamegraphModel flamegraphtui.Model + syscallsChart bubbleChart + filesChart bubbleChart + processesChart bubbleChart + showHelp bool + isDark bool + focused bool } // NewModel creates a dashboard model with default refresh cadence. @@ -193,6 +195,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) + m.syscallsTreemapSelection = clampOffset(m.syscallsTreemapSelection, m.maxSyscallsRows()) m.filesOffset = clampOffset(m.filesOffset, m.maxFilesRows()) m.filesDirOffset = clampOffset(m.filesDirOffset, m.maxFilesDirRows()) m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) @@ -336,6 +339,9 @@ func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { } switch m.activeTab { case TabSyscalls: + if m.syscallsVizMode == tabVizModeTreemap { + return scrollOffset(keyStr, &m.syscallsTreemapSelection, m.maxSyscallsRows()), nil + } return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil case TabFiles: if m.filesDirGrouped { @@ -513,6 +519,9 @@ func (m Model) View() tea.View { } func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model) string { + if m.activeTab == TabSyscalls && m.syscallsVizMode == tabVizModeTreemap { + return renderSyscallsTreemap(m.latest, width, activeHeight, m.syscallsChart.Metric(), m.syscallsTreemapSelection, m.isDark) + } if m.bubbleEnabledForTab(m.activeTab) { switch m.activeTab { case TabSyscalls: @@ -711,7 +720,9 @@ func (m *Model) setTabVizMode(tab Tab, mode tabVizMode) { func (m Model) allowedVizModes(tab Tab) []tabVizMode { switch tab { - case TabSyscalls, TabProcesses: + case TabSyscalls: + return []tabVizMode{tabVizModeTable, tabVizModeBubbles, tabVizModeTreemap} + case TabProcesses: return []tabVizMode{tabVizModeTable, tabVizModeBubbles} case TabFiles: if m.filesDirGrouped { diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index e33271b..a92a77c 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -342,7 +342,7 @@ func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) { } } -func TestBubbleVisualizationToggleForSyscallsTab(t *testing.T) { +func TestVisualizationCycleForSyscallsTab(t *testing.T) { snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{ {Name: "read", Count: 9, Bytes: 512}, {Name: "write", Count: 3, Bytes: 1024}, @@ -359,8 +359,14 @@ func TestBubbleVisualizationToggleForSyscallsTab(t *testing.T) { 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") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model = next.(Model) if got := model.syscallsVizMode; got != tabVizModeTable { - t.Fatalf("expected syscalls bubble mode toggled off") + t.Fatalf("expected syscalls mode cycled back to table") } } @@ -434,6 +440,41 @@ func TestBubbleModeUsesJKForSelection(t *testing.T) { } } +func TestTreemapModeUsesJKForSelection(t *testing.T) { + snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{ + {Name: "read", Count: 9, Bytes: 512}, + {Name: "write", Count: 3, Bytes: 1024}, + }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabSyscalls + m.latest = &snap + m.syscallsVizMode = tabVizModeTreemap + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) + model := next.(Model) + if model.syscallsTreemapSelection != 1 { + t.Fatalf("expected treemap selection to move to index 1, got %d", model.syscallsTreemapSelection) + } +} + +func TestTreemapModeRendersTreemapHeader(t *testing.T) { + snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{ + {Name: "read", Count: 9, Bytes: 512}, + {Name: "write", Count: 3, Bytes: 1024}, + }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabSyscalls + m.latest = &snap + m.syscallsVizMode = tabVizModeTreemap + m.width = 120 + m.height = 28 + + out := m.View().Content + if !strings.Contains(out, "Syscalls treemap") { + t.Fatalf("expected treemap header in syscalls view") + } +} + func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go new file mode 100644 index 0000000..8202f10 --- /dev/null +++ b/internal/tui/dashboard/treemap.go @@ -0,0 +1,394 @@ +package dashboard + +import ( + "fmt" + "image/color" + "math" + "sort" + "strings" + "unicode/utf8" + + "ior/internal/statsengine" + + "charm.land/lipgloss/v2" +) + +const maxSyscallTreemapItems = 20 + +type syscallTreemapItem struct { + Name string + Count uint64 + Bytes uint64 + Errors uint64 + P95Ns uint64 + Value uint64 +} + +type syscallTreemapTile struct { + item syscallTreemapItem + index int + x int + y int + w int + h int +} + +type treemapCell struct { + char rune + colorSlot int + bold bool +} + +func renderSyscallsTreemap(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string { + if snap == nil { + return "Syscalls treemap: waiting for stats..." + } + 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)) + if len(items) == 0 { + return header + "\nSyscalls treemap: no data\nsel: none" + } + + selected = clampOffset(selected, len(items)) + chartHeight := height - 2 + if chartHeight < 4 { + chartHeight = 4 + } + + tiles := layoutSyscallTreemap(items, 0, 0, width, chartHeight) + grid := make([][]treemapCell, chartHeight) + for row := 0; row < chartHeight; row++ { + grid[row] = make([]treemapCell, width) + for col := 0; col < width; col++ { + grid[row][col] = treemapCell{char: ' ', colorSlot: -1} + } + } + fillTreemapGrid(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(treemapStatusLine(items, selected, metric), width)) + return strings.Join(lines, "\n") +} + +func buildSyscallTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []syscallTreemapItem { + if snap == nil { + return nil + } + syscalls := snap.Syscalls() + items := make([]syscallTreemapItem, 0, len(syscalls)) + for _, syscall := range syscalls { + item := syscallTreemapItem{ + Name: syscall.Name, + Count: syscall.Count, + Bytes: syscall.Bytes, + Errors: syscall.Errors, + P95Ns: 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 treemapValue(item syscallTreemapItem, metric bubbleMetric) uint64 { + if metric == bubbleMetricBytes { + return item.Bytes + } + return item.Count +} + +func layoutSyscallTreemap(items []syscallTreemapItem, x, y, w, h int) []syscallTreemapTile { + tiles := make([]syscallTreemapTile, 0, len(items)) + layoutSyscallTreemapInto(items, x, y, w, h, 0, &tiles) + return tiles +} + +func layoutSyscallTreemapInto(items []syscallTreemapItem, x, y, w, h, baseIndex int, out *[]syscallTreemapTile) { + if len(items) == 0 || w <= 0 || h <= 0 { + return + } + if len(items) == 1 { + *out = append(*out, syscallTreemapTile{ + item: items[0], + index: baseIndex, + x: x, + y: y, + w: w, + h: h, + }) + return + } + + total := uint64(0) + for _, item := range items { + total += item.Value + } + if total == 0 { + *out = append(*out, syscallTreemapTile{ + item: items[0], + index: baseIndex, + x: x, + y: y, + w: w, + h: h, + }) + return + } + + splitAt := findTreemapSplitIndex(items, total) + first := items[:splitAt] + second := items[splitAt:] + firstTotal := uint64(0) + for _, item := range first { + firstTotal += item.Value + } + + splitVertical := w >= h + if splitVertical && w <= 1 { + splitVertical = false + } + if !splitVertical && h <= 1 { + splitVertical = true + } + + if splitVertical { + w1 := int(math.Round(float64(w) * float64(firstTotal) / float64(total))) + if w1 < 1 { + w1 = 1 + } + if w1 >= w { + w1 = w - 1 + } + if w1 <= 0 { + w1 = 1 + } + layoutSyscallTreemapInto(first, x, y, w1, h, baseIndex, out) + layoutSyscallTreemapInto(second, x+w1, y, w-w1, h, baseIndex+splitAt, out) + return + } + + h1 := int(math.Round(float64(h) * float64(firstTotal) / float64(total))) + if h1 < 1 { + h1 = 1 + } + if h1 >= h { + h1 = h - 1 + } + if h1 <= 0 { + h1 = 1 + } + layoutSyscallTreemapInto(first, x, y, w, h1, baseIndex, out) + layoutSyscallTreemapInto(second, x, y+h1, w, h-h1, baseIndex+splitAt, out) +} + +func findTreemapSplitIndex(items []syscallTreemapItem, total uint64) int { + target := float64(total) / 2.0 + running := float64(0) + for idx, item := range items { + running += float64(item.Value) + if running >= target { + if idx == 0 { + return 1 + } + if idx >= len(items)-1 { + return len(items) - 1 + } + return idx + 1 + } + } + return len(items) / 2 +} + +func fillTreemapGrid(grid [][]treemapCell, tiles []syscallTreemapTile, selected int) { + height := len(grid) + if height == 0 { + return + } + width := len(grid[0]) + if width == 0 { + return + } + for idx, tile := range tiles { + isSelected := tile.index == selected + for row := tile.y; row < minInt(height, tile.y+tile.h); row++ { + for col := tile.x; col < minInt(width, tile.x+tile.w); col++ { + grid[row][col] = treemapCell{ + char: '█', + colorSlot: idx, + bold: isSelected, + } + } + } + drawTreemapLabel(grid, tile, isSelected, idx) + } +} + +func drawTreemapLabel(grid [][]treemapCell, tile syscallTreemapTile, selected bool, colorSlot int) { + height := len(grid) + if height == 0 { + return + } + width := len(grid[0]) + if width == 0 || tile.h < 1 || tile.w < 2 { + return + } + row := tile.y + if row < 0 || row >= height { + return + } + maxLabel := tile.w - 1 + if maxLabel < 1 { + return + } + label := abbreviateTreemapLabel(tile.item.Name, maxLabel) + col := tile.x + for _, r := range []rune(label) { + if col >= width { + break + } + if col >= 0 { + grid[row][col] = treemapCell{ + char: r, + colorSlot: colorSlot, + bold: selected, + } + } + col++ + } +} + +func abbreviateTreemapLabel(label string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + label = strings.TrimSpace(label) + if label == "" { + label = "?" + } + if utf8.RuneCountInString(label) <= maxRunes { + return label + } + if maxRunes == 1 { + return "…" + } + r := []rune(label) + return string(r[:maxRunes-1]) + "…" +} + +func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMetric) string { + if len(items) == 0 { + return "sel:none" + } + selected = clampOffset(selected, len(items)) + item := items[selected] + metricValue := item.Count + if metric == bubbleMetricBytes { + metricValue = item.Bytes + } + metricText := fmt.Sprintf("%d", metricValue) + if metric == bubbleMetricBytes { + metricText = formatBytes(float64(metricValue)) + } + return fmt.Sprintf( + "sel:%d/%d %s | %s=%s | bytes=%s | errors=%d | p95=%s", + selected+1, + len(items), + item.Name, + treemapMetricLabel(metric), + metricText, + formatBytes(float64(item.Bytes)), + item.Errors, + formatDurationUintNs(item.P95Ns), + ) +} + +func treemapMetricLabel(metric bubbleMetric) string { + if metric == bubbleMetricBytes { + return "bytes" + } + return "events" +} + +func treemapPalette(isDark bool) []color.Color { + if isDark { + return []color.Color{ + lipgloss.Color("81"), + lipgloss.Color("75"), + lipgloss.Color("117"), + lipgloss.Color("186"), + lipgloss.Color("214"), + lipgloss.Color("177"), + lipgloss.Color("39"), + lipgloss.Color("203"), + } + } + return []color.Color{ + lipgloss.Color("24"), + lipgloss.Color("31"), + lipgloss.Color("30"), + lipgloss.Color("64"), + lipgloss.Color("94"), + lipgloss.Color("130"), + lipgloss.Color("161"), + lipgloss.Color("25"), + } +} + +func renderTreemapRow(cells []treemapCell, palette []color.Color) string { + if len(cells) == 0 { + return "" + } + var b strings.Builder + styleCache := make(map[string]lipgloss.Style, 8) + for _, cell := range cells { + if cell.colorSlot < 0 { + if cell.bold { + b.WriteString(lipgloss.NewStyle().Bold(true).Render(string(cell.char))) + } else { + b.WriteRune(cell.char) + } + continue + } + slot := cell.colorSlot + if len(palette) > 0 { + slot = slot % len(palette) + } + key := fmt.Sprintf("%d/%t", slot, cell.bold) + style, ok := styleCache[key] + if !ok { + style = lipgloss.NewStyle().Foreground(palette[slot]) + if cell.bold { + style = style.Bold(true) + } + styleCache[key] = style + } + b.WriteString(style.Render(string(cell.char))) + } + return b.String() +} |
