diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-06 18:08:19 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-06 18:08:19 +0200 |
| commit | 99a6cf4787fd92a25a53acbc9c0bae8bca87cc96 (patch) | |
| tree | 0c6f6506e944a0eed0fcefa7b9394681c5f178da /internal/tui/dashboard | |
| parent | 1561987330cb898f5ff64383a9c78e7e6559f118 (diff) | |
feat(tui): add dashboard bubble viz and expand help shortcuts
Diffstat (limited to 'internal/tui/dashboard')
| -rw-r--r-- | internal/tui/dashboard/bubbles.go | 785 | ||||
| -rw-r--r-- | internal/tui/dashboard/files.go | 4 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 261 | ||||
| -rw-r--r-- | internal/tui/dashboard/model_test.go | 148 | ||||
| -rw-r--r-- | internal/tui/dashboard/processes.go | 2 | ||||
| -rw-r--r-- | internal/tui/dashboard/syscalls.go | 2 |
6 files changed, 1188 insertions, 14 deletions
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go new file mode 100644 index 0000000..f94015d --- /dev/null +++ b/internal/tui/dashboard/bubbles.go @@ -0,0 +1,785 @@ +package dashboard + +import ( + "fmt" + "image/color" + "math" + "path/filepath" + "sort" + "strings" + "unicode/utf8" + + "ior/internal/statsengine" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/harmonica" +) + +type bubbleMetric string + +const ( + bubbleMetricCount bubbleMetric = "count" + bubbleMetricBytes bubbleMetric = "bytes" +) + +const ( + bubbleFPS = 30 + bubbleAngularVelocity = 6.0 + bubbleDamping = 1.0 + bubbleSpringEpsilon = 0.01 + bubbleMaxItems = 28 +) + +type bubbleDatum struct { + ID string + Label string + Count uint64 + Bytes uint64 + Detail string +} + +type bubbleNode struct { + ID string + Label string + Detail string + Count uint64 + Bytes uint64 + Value uint64 + + radiusSpring harmonica.Spring + xSpring harmonica.Spring + ySpring harmonica.Spring + + targetRadius float64 + targetX float64 + targetY float64 + + radius float64 + x float64 + y float64 + + velocityRadius float64 + velocityX float64 + velocityY float64 +} + +type bubbleCell struct { + char rune + colorSlot int + bold bool +} + +type bubbleChart struct { + nodes []bubbleNode + selected int + metric bubbleMetric + width int + height int + animating bool + statusHint string + isDark bool +} + +func newBubbleChart() bubbleChart { + return bubbleChart{ + metric: bubbleMetricCount, + isDark: true, + } +} + +func (c *bubbleChart) SetViewport(width, height int) { + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 18 + } + if c.width == width && c.height == height { + return + } + c.width = width + c.height = height + if len(c.nodes) == 0 { + return + } + data := make([]bubbleDatum, 0, len(c.nodes)) + for _, node := range c.nodes { + data = append(data, bubbleDatum{ + ID: node.ID, + Label: node.Label, + Count: node.Count, + Bytes: node.Bytes, + Detail: node.Detail, + }) + } + c.SetData(data) +} + +func (c *bubbleChart) SetMetric(metric bubbleMetric) { + if metric != bubbleMetricBytes { + metric = bubbleMetricCount + } + c.metric = metric +} + +func (c *bubbleChart) Metric() bubbleMetric { + if c.metric == bubbleMetricBytes { + return bubbleMetricBytes + } + return bubbleMetricCount +} + +func (c *bubbleChart) SetStatusHint(hint string) { + c.statusHint = hint +} + +func (c *bubbleChart) SetDarkMode(isDark bool) { + c.isDark = isDark +} + +func (c *bubbleChart) SetData(data []bubbleDatum) bool { + targets := buildBubbleTargets(data, c.Metric(), c.width, c.height) + + selectedID := "" + if c.selected >= 0 && c.selected < len(c.nodes) { + selectedID = c.nodes[c.selected].ID + } + + existing := make(map[string]bubbleNode, len(c.nodes)) + for _, node := range c.nodes { + existing[node.ID] = node + } + + next := make([]bubbleNode, 0, len(targets)) + for _, target := range targets { + node := bubbleNode{ + ID: target.ID, + Label: target.Label, + Detail: target.Detail, + Count: target.Count, + Bytes: target.Bytes, + Value: target.Value, + targetRadius: target.targetRadius, + targetX: target.targetX, + targetY: target.targetY, + radiusSpring: harmonica.NewSpring(harmonica.FPS(bubbleFPS), bubbleAngularVelocity, bubbleDamping), + xSpring: harmonica.NewSpring(harmonica.FPS(bubbleFPS), bubbleAngularVelocity, bubbleDamping), + ySpring: harmonica.NewSpring(harmonica.FPS(bubbleFPS), bubbleAngularVelocity, bubbleDamping), + } + if prev, ok := existing[target.ID]; ok { + node.radius = prev.radius + node.x = prev.x + node.y = prev.y + node.velocityRadius = prev.velocityRadius + node.velocityX = prev.velocityX + node.velocityY = prev.velocityY + // New metrics or topology can otherwise produce stale springs. + if node.radius == 0 { + node.radius = target.targetRadius + } + } else { + node.radius = target.targetRadius + node.x = target.targetX + node.y = target.targetY + } + next = append(next, node) + } + c.nodes = next + if len(c.nodes) == 0 { + c.selected = 0 + c.animating = false + return false + } + c.selected = c.selectIndexByID(selectedID) + c.animating = c.hasMotion() + if c.animating { + c.Tick(0) + } + return c.animating +} + +func (c *bubbleChart) selectIndexByID(id string) int { + if id == "" { + return 0 + } + for idx, node := range c.nodes { + if node.ID == id { + return idx + } + } + return 0 +} + +func (c *bubbleChart) hasMotion() bool { + for _, node := range c.nodes { + if math.Abs(node.radius-node.targetRadius) > bubbleSpringEpsilon { + return true + } + if math.Abs(node.x-node.targetX) > bubbleSpringEpsilon { + return true + } + if math.Abs(node.y-node.targetY) > bubbleSpringEpsilon { + return true + } + if math.Abs(node.velocityRadius) > bubbleSpringEpsilon || + math.Abs(node.velocityX) > bubbleSpringEpsilon || + math.Abs(node.velocityY) > bubbleSpringEpsilon { + return true + } + } + return false +} + +func (c *bubbleChart) Tick(delta float64) bool { + if len(c.nodes) == 0 { + c.animating = false + return false + } + baseDelta := harmonica.FPS(bubbleFPS) + if delta <= 0 { + delta = baseDelta + } + active := false + for idx := range c.nodes { + node := &c.nodes[idx] + if delta != baseDelta { + node.radiusSpring = harmonica.NewSpring(delta, bubbleAngularVelocity, bubbleDamping) + node.xSpring = harmonica.NewSpring(delta, bubbleAngularVelocity, bubbleDamping) + node.ySpring = harmonica.NewSpring(delta, bubbleAngularVelocity, bubbleDamping) + } + node.radius, node.velocityRadius = node.radiusSpring.Update(node.radius, node.velocityRadius, node.targetRadius) + node.x, node.velocityX = node.xSpring.Update(node.x, node.velocityX, node.targetX) + node.y, node.velocityY = node.ySpring.Update(node.y, node.velocityY, node.targetY) + if c.nodeAnimating(*node) { + active = true + } + } + c.animating = active + return active +} + +func (c *bubbleChart) nodeAnimating(node bubbleNode) bool { + if math.Abs(node.radius-node.targetRadius) > bubbleSpringEpsilon { + return true + } + if math.Abs(node.x-node.targetX) > bubbleSpringEpsilon { + return true + } + if math.Abs(node.y-node.targetY) > bubbleSpringEpsilon { + return true + } + if math.Abs(node.velocityRadius) > bubbleSpringEpsilon || + math.Abs(node.velocityX) > bubbleSpringEpsilon || + math.Abs(node.velocityY) > bubbleSpringEpsilon { + return true + } + return false +} + +func (c *bubbleChart) MoveSelection(delta int) bool { + if len(c.nodes) == 0 { + return false + } + next := c.selected + delta + if next < 0 { + next = 0 + } + if next >= len(c.nodes) { + next = len(c.nodes) - 1 + } + if next == c.selected { + return false + } + c.selected = next + return true +} + +func (c *bubbleChart) Render(tabLabel string, width, height int) string { + if width <= 0 { + width = c.width + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = c.height + } + if height <= 0 { + height = 18 + } + header := fmt.Sprintf("%s bubbles | metric:%s | v table | b metric | j/k select", tabLabel, c.metricLabel()) + if len(c.nodes) == 0 { + body := "No data yet." + if c.statusHint != "" { + body = c.statusHint + } + return header + "\n" + body + "\n" + "sel: none" + } + + chartHeight := height - 2 + if chartHeight < 4 { + chartHeight = 4 + } + grid := make([][]bubbleCell, chartHeight) + for row := 0; row < chartHeight; row++ { + grid[row] = make([]bubbleCell, width) + for col := range grid[row] { + grid[row][col] = bubbleCell{ + char: ' ', + colorSlot: -1, + } + } + } + c.renderBubblesToGrid(grid, width, chartHeight) + lines := make([]string, 0, chartHeight+2) + lines = append(lines, padOrTrim(header, width)) + palette := c.palette() + for _, row := range grid { + lines = append(lines, renderBubbleRow(row, palette)) + } + lines = append(lines, padOrTrim(c.statusLine(width), width)) + return strings.Join(lines, "\n") +} + +func (c *bubbleChart) renderBubblesToGrid(grid [][]bubbleCell, width, height int) { + order := make([]int, 0, len(c.nodes)) + for idx := range c.nodes { + order = append(order, idx) + } + sort.Slice(order, func(i, j int) bool { + return c.nodes[order[i]].radius < c.nodes[order[j]].radius + }) + if c.selected >= 0 && c.selected < len(c.nodes) { + filtered := order[:0] + for _, idx := range order { + if idx != c.selected { + filtered = append(filtered, idx) + } + } + order = append(filtered, c.selected) + } + + for _, idx := range order { + node := c.nodes[idx] + drawBubble(grid, width, height, node, idx == c.selected, idx) + } + + for idx, node := range c.nodes { + drawBubbleLabel(grid, width, height, node, idx == c.selected, idx) + } +} + +func drawBubble(grid [][]bubbleCell, width, height int, node bubbleNode, selected bool, colorSlot int) { + if len(grid) == 0 || width == 0 || height == 0 { + return + } + radius := node.radius + if radius < 1.0 { + radius = 1.0 + } + cx := int(math.Round(node.x)) + cy := int(math.Round(node.y)) + minX := maxInt(0, int(math.Floor(float64(cx)-radius))) + maxX := minInt(width-1, int(math.Ceil(float64(cx)+radius))) + minY := maxInt(0, int(math.Floor(float64(cy)-radius))) + maxY := minInt(height-1, int(math.Ceil(float64(cy)+radius))) + fill := '█' + innerFill := fill + for y := minY; y <= maxY; y++ { + for x := minX; x <= maxX; x++ { + dx := float64(x - cx) + dy := float64(y - cy) + dist := math.Sqrt(dx*dx + dy*dy) + switch { + case dist <= radius-0.65: + grid[y][x] = bubbleCell{char: fill, colorSlot: colorSlot, bold: selected} + case dist <= radius: + grid[y][x] = bubbleCell{char: innerFill, colorSlot: colorSlot, bold: selected} + } + } + } +} + +func drawBubbleLabel(grid [][]bubbleCell, width, height int, node bubbleNode, selected bool, colorSlot int) { + if len(grid) == 0 || width == 0 || height == 0 { + return + } + maxLabelRunes := maxInt(2, int(math.Round(node.radius*1.6))) + label := abbreviateLabel(node.Label, maxLabelRunes) + if selected { + label = "[" + abbreviateLabel(node.Label, maxInt(1, maxLabelRunes-2)) + "]" + } + cx := int(math.Round(node.x)) + cy := int(math.Round(node.y)) + if cy < 0 || cy >= height { + return + } + labelRunes := []rune(label) + start := cx - len(labelRunes)/2 + for idx, r := range labelRunes { + x := start + idx + if x < 0 || x >= width { + continue + } + grid[cy][x] = bubbleCell{char: r, colorSlot: colorSlot, bold: selected} + } +} + +func abbreviateLabel(label string, maxRunes int) string { + label = strings.TrimSpace(label) + if label == "" { + return "?" + } + if maxRunes <= 0 { + return "" + } + if utf8.RuneCountInString(label) <= maxRunes { + return label + } + if maxRunes == 1 { + return "…" + } + r := []rune(label) + return string(r[:maxRunes-1]) + "…" +} + +func (c *bubbleChart) statusLine(width int) string { + if len(c.nodes) == 0 { + return padOrTrim("sel: none", width) + } + if c.selected < 0 { + c.selected = 0 + } + if c.selected >= len(c.nodes) { + c.selected = len(c.nodes) - 1 + } + node := c.nodes[c.selected] + metricText := fmt.Sprintf("%s=%s", c.metricLabel(), c.formatMetricValue(node)) + base := fmt.Sprintf("sel:%d/%d %s | %s | bytes=%s", c.selected+1, len(c.nodes), node.Label, metricText, formatBytes(float64(node.Bytes))) + if c.statusHint != "" { + base += " | " + c.statusHint + } + if node.Detail != "" { + base += " | " + node.Detail + } + return padOrTrim(base, width) +} + +func (c *bubbleChart) metricLabel() string { + if c.Metric() == bubbleMetricBytes { + return "bytes" + } + return "events" +} + +func (c *bubbleChart) formatMetricValue(node bubbleNode) string { + if c.Metric() == bubbleMetricBytes { + return formatBytes(float64(node.Bytes)) + } + return fmt.Sprintf("%d", node.Count) +} + +func (c *bubbleChart) palette() []color.Color { + if c.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 renderBubbleRow(cells []bubbleCell, 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() +} + +func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height int) []bubbleNode { + if len(data) == 0 { + return nil + } + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 18 + } + chartHeight := height - 2 + if chartHeight < 4 { + chartHeight = 4 + } + filtered := make([]bubbleDatum, 0, len(data)) + for _, datum := range data { + if datum.ID == "" { + continue + } + filtered = append(filtered, datum) + } + if len(filtered) == 0 { + return nil + } + sort.Slice(filtered, func(i, j int) bool { + vi := bubbleValue(filtered[i], metric) + vj := bubbleValue(filtered[j], metric) + if vi != vj { + return vi > vj + } + return filtered[i].Label < filtered[j].Label + }) + if len(filtered) > bubbleMaxItems { + filtered = filtered[:bubbleMaxItems] + } + maxValue := uint64(0) + for _, datum := range filtered { + value := bubbleValue(datum, metric) + if value > maxValue { + maxValue = value + } + } + if maxValue == 0 { + maxValue = 1 + } + minRadius := 1.7 + maxRadius := math.Min(float64(width)/6.0, float64(chartHeight)/2.6) + if maxRadius < 2.4 { + maxRadius = 2.4 + } + targets := make([]bubbleNode, 0, len(filtered)) + cx := float64(width-1) / 2.0 + cy := float64(chartHeight-1) / 2.0 + goldenAngle := math.Pi * (3.0 - math.Sqrt(5.0)) + spacingBase := maxRadius * 0.95 + for idx, datum := range filtered { + value := bubbleValue(datum, metric) + ratio := math.Sqrt(float64(value) / float64(maxValue)) + targetRadius := minRadius + ratio*(maxRadius-minRadius) + distance := spacingBase * math.Sqrt(float64(idx)+0.6) + angle := float64(idx) * goldenAngle + targetX := cx + math.Cos(angle)*distance + targetY := cy + math.Sin(angle)*distance*0.68 + targets = append(targets, bubbleNode{ + ID: datum.ID, + Label: datum.Label, + Detail: datum.Detail, + Count: datum.Count, + Bytes: datum.Bytes, + Value: value, + targetRadius: targetRadius, + targetX: targetX, + targetY: targetY, + }) + } + relaxTargets(targets, width, chartHeight) + return targets +} + +func relaxTargets(nodes []bubbleNode, width, height int) { + if len(nodes) <= 1 { + for idx := range nodes { + clampNodeToViewport(&nodes[idx], width, height) + } + return + } + for iter := 0; iter < 28; iter++ { + for left := 0; left < len(nodes); left++ { + for right := left + 1; right < len(nodes); right++ { + a := &nodes[left] + b := &nodes[right] + dx := b.targetX - a.targetX + dy := b.targetY - a.targetY + distSq := dx*dx + dy*dy + minDist := a.targetRadius + b.targetRadius + 0.8 + if distSq >= minDist*minDist { + continue + } + if distSq < 0.0001 { + dx = 0.01 + dy = 0.01 + distSq = dx*dx + dy*dy + } + dist := math.Sqrt(distSq) + overlap := (minDist - dist) / 2.0 + nx := dx / dist + ny := dy / dist + a.targetX -= nx * overlap + a.targetY -= ny * overlap + b.targetX += nx * overlap + b.targetY += ny * overlap + } + } + for idx := range nodes { + clampNodeToViewport(&nodes[idx], width, height) + } + } +} + +func clampNodeToViewport(node *bubbleNode, width, height int) { + minX := node.targetRadius + 1.0 + maxX := float64(width-1) - node.targetRadius - 1.0 + minY := node.targetRadius + maxY := float64(height-1) - node.targetRadius + if maxX < minX { + mid := float64(width-1) / 2.0 + node.targetX = mid + } else { + node.targetX = clampFloat(node.targetX, minX, maxX) + } + if maxY < minY { + mid := float64(height-1) / 2.0 + node.targetY = mid + } else { + node.targetY = clampFloat(node.targetY, minY, maxY) + } +} + +func clampFloat(value, minValue, maxValue float64) float64 { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func bubbleValue(d bubbleDatum, metric bubbleMetric) uint64 { + if metric == bubbleMetricBytes { + return d.Bytes + } + return d.Count +} + +func syscallBubbleData(snap *statsengine.Snapshot) []bubbleDatum { + if snap == nil { + return nil + } + rows := snap.Syscalls() + data := make([]bubbleDatum, 0, len(rows)) + for _, syscall := range rows { + detail := fmt.Sprintf("rate %.1f/s, errors %d, p95 %s", syscall.RatePerSec, syscall.Errors, formatDurationUintNs(syscall.LatencyP95Ns)) + data = append(data, bubbleDatum{ + ID: syscall.Name, + Label: syscall.Name, + Count: syscall.Count, + Bytes: syscall.Bytes, + Detail: detail, + }) + } + return data +} + +func filesDirBubbleData(snap *statsengine.Snapshot) []bubbleDatum { + if snap == nil { + return nil + } + 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, + Count: dir.Accesses, + Bytes: totalBytes, + Detail: detail, + }) + } + return data +} + +func processBubbleData(snap *statsengine.Snapshot) []bubbleDatum { + if snap == nil { + return nil + } + rows := snap.Processes() + data := make([]bubbleDatum, 0, len(rows)) + for _, proc := range rows { + label := fmt.Sprintf("%d", proc.PID) + if comm := strings.TrimSpace(proc.Comm); comm != "" { + label = fmt.Sprintf("%d:%s", proc.PID, comm) + } + detail := fmt.Sprintf("pid %d, rate %.1f/s, avg %s", proc.PID, proc.RatePerSec, formatDurationNs(proc.AvgLatencyNs)) + data = append(data, bubbleDatum{ + ID: fmt.Sprintf("%d/%s", proc.PID, proc.Comm), + Label: label, + Count: proc.Syscalls, + Bytes: proc.Bytes, + Detail: detail, + }) + } + return data +} + +func padOrTrim(value string, width int) string { + if width <= 0 { + return value + } + value = truncatePlain(value, width) + padding := width - utf8.RuneCountInString(value) + if padding <= 0 { + return value + } + return value + strings.Repeat(" ", padding) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index d43e215..98158d8 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -56,7 +56,7 @@ func renderFilesWithOffset(snap *statsengine.Snapshot, width, height, offset int tbl.SetWidth(tableWidth(width)) cursor := clampOffset(offset, len(rows)) tbl.SetCursor(cursor) - return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs]", cursor+1, len(rows)) + return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:dirs] [v:bubbles in dirs]", cursor+1, len(rows)) } func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int) string { @@ -89,7 +89,7 @@ func renderFilesDirGrouped(snap *statsengine.Snapshot, width, height, offset int tbl.SetWidth(tableWidth(width)) cursor := clampOffset(offset, len(rows)) tbl.SetCursor(cursor) - return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files]", cursor+1, len(rows)) + return tbl.View() + fmt.Sprintf("\nRow %d/%d [d:files] [v:bubbles] [b:metric]", cursor+1, len(rows)) } func fileRows(files []statsengine.FileSnapshot, pathWidth int) []table.Row { diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index d10a91a..8a7d85c 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -17,6 +17,7 @@ import ( const defaultRefreshMs = 1000 const streamRefreshMs = 200 const flameRefreshMs = 200 +const bubbleRefreshMs = 33 const streamChromeRows = 4 const dashboardHelpHintRows = 1 const dashboardExpandedHelpRows = 2 @@ -27,9 +28,15 @@ type SnapshotSource interface { Snapshot() *statsengine.Snapshot } +type resettableSnapshotSource interface { + Reset() + Snapshot() *statsengine.Snapshot +} + type refreshTickMsg struct{} type streamTickMsg struct{} type flameTickMsg struct{} +type bubbleTickMsg struct{} type streamEditorDoneMsg struct { err error } @@ -53,8 +60,14 @@ type Model struct { filesDirGrouped bool filesDirOffset int processesOffset int + syscallsBubble bool + filesBubble bool + processesBubble bool streamModel eventstream.Model flamegraphModel flamegraphtui.Model + syscallsChart bubbleChart + filesChart bubbleChart + processesChart bubbleChart showHelp bool isDark bool focused bool @@ -78,6 +91,9 @@ func NewModelWithConfig(engine SnapshotSource, streamSource eventstream.Source, pidFilter: -1, streamModel: eventstream.NewModel(streamSource), flamegraphModel: flamegraphtui.NewModel(nil), + syscallsChart: newBubbleChart(), + filesChart: newBubbleChart(), + processesChart: newBubbleChart(), isDark: true, focused: true, } @@ -93,6 +109,10 @@ func (m Model) Init() tea.Cmd { cmds = append(cmds, streamTickCmd()) case TabFlame: cmds = append(cmds, flameTickCmd()) + default: + if m.bubbleEnabledForTab(m.activeTab) { + cmds = append(cmds, bubbleTickCmdFn()) + } } if len(cmds) == 1 { return cmds[0] @@ -110,6 +130,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.streamModel.SetViewport(streamWidth, streamHeight) flameWidth, flameHeight := flameViewport(msg.Width, msg.Height, m.showHelp) m.flamegraphModel.SetViewport(flameWidth, flameHeight) + m.setBubbleViewports(flameWidth, flameHeight) + if m.bubbleEnabledForTab(m.activeTab) && m.refreshBubbleData() { + return m, bubbleTickCmdFn() + } return m, nil case refreshTickMsg: if !m.focused { @@ -144,6 +168,17 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(flameTickCmd(), animCmd) } return m, flameTickCmd() + case bubbleTickMsg: + if !m.focused { + return m, nil + } + if !m.bubbleEnabledForTab(m.activeTab) { + return m, nil + } + if m.tickActiveBubbleChart() { + return m, bubbleTickCmdFn() + } + return m, nil case messages.StatsTickMsg: m.latest = msg.Snap m.syscallsOffset = clampOffset(m.syscallsOffset, m.maxSyscallsRows()) @@ -151,6 +186,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filesDirOffset = clampOffset(m.filesDirOffset, m.maxFilesDirRows()) m.processesOffset = clampOffset(m.processesOffset, m.maxProcessesRows()) m.streamModel.Refresh() + if m.refreshBubbleData() { + return m, bubbleTickCmdFn() + } return m, nil case tea.KeyPressMsg: return m.handleKey(msg) @@ -220,13 +258,24 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.Seven): m.activeTab = TabStream handled = true + case key.Matches(msg, m.keys.Visualize): + handled = true + cmd = m.toggleBubbleVisualization() + case key.Matches(msg, m.keys.Metric): + handled = true + cmd = m.toggleBubbleMetric() case key.Matches(msg, m.keys.Refresh): - snap := m.snapshot() - cmd = func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } + cmd = m.resetBaselineCmd() handled = true case key.Matches(msg, m.keys.DirGroup): if m.activeTab == TabFiles { m.filesDirGrouped = !m.filesDirGrouped + if !m.filesDirGrouped && m.filesBubble { + m.filesBubble = false + } + if m.filesDirGrouped && m.filesBubble && m.refreshBubbleData() { + cmd = bubbleTickCmdFn() + } handled = true } } @@ -249,6 +298,9 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if prevActiveTab != TabFlame && m.activeTab == TabFlame { batch = append(batch, flameTickCmd()) } + if prevActiveTab != m.activeTab && m.bubbleEnabledForTab(m.activeTab) { + batch = append(batch, bubbleTickCmdFn()) + } switch len(batch) { case 0: return m, nil @@ -261,6 +313,16 @@ func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { func (m *Model) handleScrollKey(msg tea.KeyPressMsg) (bool, tea.Cmd) { keyStr := msg.String() + if m.bubbleEnabledForTab(m.activeTab) { + switch keyStr { + case "down", "j", "right", "l": + return m.moveBubbleSelection(1), nil + case "up", "k", "left", "h": + return m.moveBubbleSelection(-1), nil + default: + return false, nil + } + } switch m.activeTab { case TabSyscalls: return scrollOffset(keyStr, &m.syscallsOffset, m.maxSyscallsRows()), nil @@ -343,6 +405,21 @@ func (m Model) snapshot() *statsengine.Snapshot { return m.engine.Snapshot() } +func (m *Model) resetBaselineCmd() tea.Cmd { + if m.liveTrie != nil { + m.liveTrie.Reset() + } + + var snap *statsengine.Snapshot + if resettable, ok := m.engine.(resettableSnapshotSource); ok { + resettable.Reset() + snap = resettable.Snapshot() + } else { + snap = m.snapshot() + } + return func() tea.Msg { return messages.StatsTickMsg{Snap: snap} } +} + // LatestSnapshot returns the most recently received snapshot. func (m Model) LatestSnapshot() *statsengine.Snapshot { return m.latest @@ -380,6 +457,9 @@ func (m *Model) SetDarkMode(isDark bool) { m.isDark = isDark m.streamModel.SetDarkMode(isDark) m.flamegraphModel.SetDarkMode(isDark) + m.syscallsChart.SetDarkMode(isDark) + m.filesChart.SetDarkMode(isDark) + m.processesChart.SetDarkMode(isDark) } // SetFocused controls whether periodic refresh ticks are processed. @@ -411,10 +491,34 @@ func (m Model) View() tea.View { var b strings.Builder b.WriteString(renderTabBar(m.activeTab, width)) b.WriteString("\n") - b.WriteString(renderActiveTab( + b.WriteString(m.renderActiveContent(width, activeHeight, &streamModel)) + b.WriteString("\n") + if m.showHelp { + b.WriteString(renderHelpBar(m.keys, width)) + } else { + b.WriteString(renderHelpHint(width)) + } + return tea.NewView(common.ScreenStyle.Render(b.String())) +} + +func (m Model) renderActiveContent(width, activeHeight int, streamModel *eventstream.Model) string { + if m.bubbleEnabledForTab(m.activeTab) { + switch m.activeTab { + case TabSyscalls: + chart := m.syscallsChart + return chart.Render("Syscalls", width, activeHeight) + case TabFiles: + chart := m.filesChart + return chart.Render("Files/Dirs", width, activeHeight) + case TabProcesses: + chart := m.processesChart + return chart.Render("Processes", width, activeHeight) + } + } + return renderActiveTab( m.activeTab, m.latest, - &streamModel, + streamModel, &m.flamegraphModel, width, activeHeight, @@ -424,14 +528,147 @@ func (m Model) View() tea.View { m.filesDirGrouped, m.filesDirOffset, m.processesOffset, - )) - b.WriteString("\n") - if m.showHelp { - b.WriteString(renderHelpBar(m.keys, width)) + ) +} + +func (m *Model) setBubbleViewports(width, height int) { + m.syscallsChart.SetViewport(width, height) + m.filesChart.SetViewport(width, height) + m.processesChart.SetViewport(width, height) +} + +func (m *Model) refreshBubbleData() bool { + flameWidth, flameHeight := flameViewport(m.width, m.height, m.showHelp) + m.setBubbleViewports(flameWidth, flameHeight) + + syscallsAnimating := m.syscallsChart.SetData(syscallBubbleData(m.latest)) + + if m.filesDirGrouped { + m.filesChart.SetStatusHint("") } else { - b.WriteString(renderHelpHint(width)) + m.filesChart.SetStatusHint("Files bubble view requires directory mode (press d).") + } + filesAnimating := false + if m.filesDirGrouped { + filesAnimating = m.filesChart.SetData(filesDirBubbleData(m.latest)) + } else { + m.filesChart.SetData(nil) + } + processesAnimating := m.processesChart.SetData(processBubbleData(m.latest)) + + switch m.activeTab { + case TabSyscalls: + return m.syscallsBubble && syscallsAnimating + case TabFiles: + return m.filesBubble && filesAnimating + case TabProcesses: + return m.processesBubble && processesAnimating + default: + return false } - return tea.NewView(common.ScreenStyle.Render(b.String())) +} + +func (m *Model) tickActiveBubbleChart() bool { + switch m.activeTab { + case TabSyscalls: + if !m.syscallsBubble { + return false + } + return m.syscallsChart.Tick(0) + case TabFiles: + if !m.filesBubble { + return false + } + return m.filesChart.Tick(0) + case TabProcesses: + if !m.processesBubble { + return false + } + return m.processesChart.Tick(0) + default: + return false + } +} + +func (m *Model) moveBubbleSelection(delta int) bool { + switch m.activeTab { + case TabSyscalls: + return m.syscallsChart.MoveSelection(delta) + case TabFiles: + return m.filesChart.MoveSelection(delta) + case TabProcesses: + return m.processesChart.MoveSelection(delta) + default: + return false + } +} + +func (m Model) bubbleEnabledForTab(tab Tab) bool { + switch tab { + case TabSyscalls: + return m.syscallsBubble + case TabFiles: + return m.filesBubble && m.filesDirGrouped + case TabProcesses: + return m.processesBubble + default: + return false + } +} + +func (m *Model) toggleBubbleVisualization() tea.Cmd { + switch m.activeTab { + case TabSyscalls: + m.syscallsBubble = !m.syscallsBubble + if m.syscallsBubble && m.refreshBubbleData() { + return bubbleTickCmdFn() + } + case TabFiles: + if !m.filesDirGrouped { + return nil + } + m.filesBubble = !m.filesBubble + if m.filesBubble && m.refreshBubbleData() { + return bubbleTickCmdFn() + } + case TabProcesses: + m.processesBubble = !m.processesBubble + if m.processesBubble && m.refreshBubbleData() { + return bubbleTickCmdFn() + } + } + return nil +} + +func (m *Model) toggleBubbleMetric() tea.Cmd { + switch m.activeTab { + case TabSyscalls: + m.syscallsChart.SetMetric(nextBubbleMetric(m.syscallsChart.Metric())) + if m.refreshBubbleData() { + return bubbleTickCmdFn() + } + case TabFiles: + if !m.filesDirGrouped { + return nil + } + m.filesChart.SetMetric(nextBubbleMetric(m.filesChart.Metric())) + if m.refreshBubbleData() { + return bubbleTickCmdFn() + } + case TabProcesses: + m.processesChart.SetMetric(nextBubbleMetric(m.processesChart.Metric())) + if m.refreshBubbleData() { + return bubbleTickCmdFn() + } + } + return nil +} + +func nextBubbleMetric(metric bubbleMetric) bubbleMetric { + if metric == bubbleMetricBytes { + return bubbleMetricCount + } + return bubbleMetricBytes } func tickCmd(d time.Duration) tea.Cmd { @@ -484,6 +721,10 @@ func flameTickCmd() tea.Cmd { return tea.Tick(flameRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return flameTickMsg{} }) } +func bubbleTickCmdFn() tea.Cmd { + return tea.Tick(bubbleRefreshMs*time.Millisecond, func(time.Time) tea.Msg { return bubbleTickMsg{} }) +} + func streamViewport(width, height int) (int, int) { width, height = common.EffectiveViewport(width, height) height -= streamChromeRows diff --git a/internal/tui/dashboard/model_test.go b/internal/tui/dashboard/model_test.go index d5b78e0..934577d 100644 --- a/internal/tui/dashboard/model_test.go +++ b/internal/tui/dashboard/model_test.go @@ -26,6 +26,21 @@ func (f *fakeSnapshotSource) Snapshot() *statsengine.Snapshot { return f.snap } +type fakeResettableSnapshotSource struct { + resetCount int + snapCount int + snap *statsengine.Snapshot +} + +func (f *fakeResettableSnapshotSource) Reset() { + f.resetCount++ +} + +func (f *fakeResettableSnapshotSource) Snapshot() *statsengine.Snapshot { + f.snapCount++ + return f.snap +} + func TestKeySwitchingChangesActiveTab(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) @@ -327,6 +342,98 @@ func TestDirGroupKeyTogglesOnlyOnFilesTab(t *testing.T) { } } +func TestBubbleVisualizationToggleForSyscallsTab(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 + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model := next.(Model) + if !model.syscallsBubble { + t.Fatalf("expected syscalls bubble mode enabled") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model = next.(Model) + if model.syscallsBubble { + t.Fatalf("expected syscalls bubble mode toggled off") + } +} + +func TestBubbleMetricToggleForSyscallsTab(t *testing.T) { + snap := statsengine.NewSnapshot(nil, nil, nil, []statsengine.SyscallSnapshot{ + {Name: "read", Count: 9, Bytes: 512}, + }, nil, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabSyscalls + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'b'}[0], Text: string([]rune{'b'})}) + model := next.(Model) + if got := model.syscallsChart.Metric(); got != bubbleMetricBytes { + t.Fatalf("expected syscalls bubble metric bytes, got %q", got) + } +} + +func TestFilesBubbleRequiresDirectoryMode(t *testing.T) { + snap := statsengine.NewSnapshot(nil, nil, nil, nil, []statsengine.FileSnapshot{ + {Path: "/tmp/a", Accesses: 3}, + {Path: "/tmp/b", Accesses: 1}, + }, nil, statsengine.HistogramSnapshot{}, statsengine.HistogramSnapshot{}) + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabFiles + m.latest = &snap + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model := next.(Model) + if model.filesBubble { + t.Fatalf("expected files bubble mode to stay disabled without directory mode") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) + model = next.(Model) + if !model.filesDirGrouped { + t.Fatalf("expected files dir mode enabled") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'v'}[0], Text: string([]rune{'v'})}) + model = next.(Model) + if !model.filesBubble { + t.Fatalf("expected files bubble mode enabled in directory mode") + } + + next, _ = model.Update(tea.KeyPressMsg{Code: []rune{'d'}[0], Text: string([]rune{'d'})}) + model = next.(Model) + if model.filesBubble { + t.Fatalf("expected files bubble mode disabled when leaving directory mode") + } +} + +func TestBubbleModeUsesJKForSelection(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.syscallsBubble = true + m.refreshBubbleData() + if len(m.syscallsChart.nodes) < 2 { + t.Fatalf("expected at least two syscall bubbles") + } + + next, _ := m.Update(tea.KeyPressMsg{Code: []rune{'j'}[0], Text: string([]rune{'j'})}) + model := next.(Model) + if model.syscallsChart.selected != 1 { + t.Fatalf("expected bubble selection to move to index 1, got %d", model.syscallsChart.selected) + } +} + func TestScrollOffsetDoesNotGrowUnbounded(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabSyscalls @@ -362,6 +469,47 @@ func TestRefreshKeyEmitsRefreshTick(t *testing.T) { } } +func TestRefreshKeyResetsBaselineWhenSourceSupportsReset(t *testing.T) { + snap := &statsengine.Snapshot{TotalSyscalls: 5} + engine := &fakeResettableSnapshotSource{snap: snap} + m := NewModelWithConfig(engine, nil, 250, common.DefaultKeyMap()) + m.activeTab = TabOverview + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) + _ = next + if cmd == nil { + t.Fatalf("expected reset baseline command") + } + if engine.resetCount != 1 { + t.Fatalf("expected reset count 1, got %d", engine.resetCount) + } + msg := cmd() + stats, ok := msg.(messages.StatsTickMsg) + if !ok { + t.Fatalf("expected StatsTickMsg from reset baseline, got %T", msg) + } + if stats.Snap != snap { + t.Fatalf("expected snapshot after reset") + } +} + +func TestRefreshKeyResetsLiveTrieOutsideFlameTab(t *testing.T) { + liveTrie := coreflamegraph.NewLiveTrie([]string{"comm", "path"}, "count") + m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) + m.SetLiveTrie(liveTrie) + m.activeTab = TabSyscalls + before := liveTrie.Version() + + next, cmd := m.Update(tea.KeyPressMsg{Code: []rune{'r'}[0], Text: string([]rune{'r'})}) + _ = next + if cmd == nil { + t.Fatalf("expected baseline reset command") + } + if liveTrie.Version() == before { + t.Fatalf("expected live trie version to change after baseline reset") + } +} + func TestFlameTabReceivesSlashKey(t *testing.T) { m := NewModelWithConfig(nil, nil, 250, common.DefaultKeyMap()) m.activeTab = TabFlame diff --git a/internal/tui/dashboard/processes.go b/internal/tui/dashboard/processes.go index a5e8d79..74a185f 100644 --- a/internal/tui/dashboard/processes.go +++ b/internal/tui/dashboard/processes.go @@ -43,7 +43,7 @@ func renderProcessesWithOffset(snap *statsengine.Snapshot, width, height, offset cursor := clampOffset(offset, len(rows)) tbl.SetCursor(cursor) - out := tbl.View() + fmt.Sprintf("\nRow %d/%d", cursor+1, len(rows)) + out := tbl.View() + fmt.Sprintf("\nRow %d/%d [v:bubbles] [b:metric]", cursor+1, len(rows)) if pidFilter > 0 { out += "\n" + "Note: this tab is most useful with All PIDs." } diff --git a/internal/tui/dashboard/syscalls.go b/internal/tui/dashboard/syscalls.go index 87acc80..bf7909e 100644 --- a/internal/tui/dashboard/syscalls.go +++ b/internal/tui/dashboard/syscalls.go @@ -33,7 +33,7 @@ func renderSyscallsWithOffset(snap *statsengine.Snapshot, width, height, offset tbl.SetWidth(tableWidth(width)) cursor := clampOffset(offset, len(rows)) tbl.SetCursor(cursor) - return tbl.View() + fmt.Sprintf("\nRow %d/%d", cursor+1, len(rows)) + return tbl.View() + fmt.Sprintf("\nRow %d/%d [v:bubbles] [b:metric]", cursor+1, len(rows)) } func syscallTableData(syscalls []statsengine.SyscallSnapshot, width int) ([]table.Column, []table.Row) { |
