summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/common/keys.go11
-rw-r--r--internal/tui/common/keys_test.go34
-rw-r--r--internal/tui/dashboard/bubbles.go785
-rw-r--r--internal/tui/dashboard/files.go4
-rw-r--r--internal/tui/dashboard/model.go261
-rw-r--r--internal/tui/dashboard/model_test.go148
-rw-r--r--internal/tui/dashboard/processes.go2
-rw-r--r--internal/tui/dashboard/syscalls.go2
-rw-r--r--internal/tui/tui.go44
-rw-r--r--internal/tui/tui_test.go7
10 files changed, 1262 insertions, 36 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index ab9865d..02fee2b 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -19,6 +19,8 @@ type KeyMap struct {
Five key.Binding
Six key.Binding
Seven key.Binding
+ Visualize key.Binding
+ Metric key.Binding
DirGroup key.Binding
SelectPID key.Binding
SelectTID key.Binding
@@ -45,6 +47,8 @@ func DefaultKeyMap() KeyMap {
Five: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "processes")),
Six: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "lat+gaps")),
Seven: key.NewBinding(key.WithKeys("7"), key.WithHelp("7", "stream")),
+ Visualize: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "viz")),
+ Metric: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "metric")),
DirGroup: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "dir group")),
SelectPID: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "select pid")),
SelectTID: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "select tid")),
@@ -53,7 +57,7 @@ func DefaultKeyMap() KeyMap {
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")),
Esc: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")),
- Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
+ Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reset baseline")),
}
}
@@ -84,6 +88,8 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
k.Five,
k.Six,
k.Seven,
+ k.Visualize,
+ k.Metric,
k.SelectPID,
k.SelectTID,
k.Probes,
@@ -95,6 +101,8 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
}
dashboard := []key.Binding{
k.DirGroup,
+ k.Visualize,
+ k.Metric,
helpTextBinding("space", "stream pause"),
helpTextBinding("f", "stream filter"),
helpTextBinding("g/G", "stream top/tail"),
@@ -125,6 +133,7 @@ func (k KeyMap) DashboardFullHelp() [][]key.Binding {
controls = append(controls, k.Export)
}
controls = append(controls, k.DirGroup, k.SelectPID, k.SelectTID, k.Probes, k.Refresh, k.Quit)
+ controls = append(controls, k.Visualize, k.Metric)
return [][]key.Binding{
{k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven},
diff --git a/internal/tui/common/keys_test.go b/internal/tui/common/keys_test.go
index 4284faf..a2b5940 100644
--- a/internal/tui/common/keys_test.go
+++ b/internal/tui/common/keys_test.go
@@ -28,6 +28,16 @@ func TestDefaultKeyMapIncludesDirGroupBinding(t *testing.T) {
if flameHelp.Key != "1" || flameHelp.Desc != "flame" {
t.Fatalf("unexpected flame binding help: key=%q desc=%q", flameHelp.Key, flameHelp.Desc)
}
+
+ visualizeHelp := keys.Visualize.Help()
+ if visualizeHelp.Key != "v" || visualizeHelp.Desc != "viz" {
+ t.Fatalf("unexpected visualize binding help: key=%q desc=%q", visualizeHelp.Key, visualizeHelp.Desc)
+ }
+
+ metricHelp := keys.Metric.Help()
+ if metricHelp.Key != "b" || metricHelp.Desc != "metric" {
+ t.Fatalf("unexpected metric binding help: key=%q desc=%q", metricHelp.Key, metricHelp.Desc)
+ }
}
func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
@@ -96,6 +106,30 @@ func TestDashboardFullHelpIncludesDirGroupBinding(t *testing.T) {
if !found {
t.Fatalf("expected select tid binding in dashboard full help controls")
}
+
+ found = false
+ for _, binding := range groups[1] {
+ help := binding.Help()
+ if help.Key == "v" && help.Desc == "viz" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected viz binding in dashboard full help controls")
+ }
+
+ found = false
+ for _, binding := range groups[1] {
+ help := binding.Help()
+ if help.Key == "b" && help.Desc == "metric" {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("expected metric binding in dashboard full help controls")
+ }
}
func TestDashboardStatusHelpIncludesProbesBinding(t *testing.T) {
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) {
diff --git a/internal/tui/tui.go b/internal/tui/tui.go
index d60ee4b..09b69e5 100644
--- a/internal/tui/tui.go
+++ b/internal/tui/tui.go
@@ -813,6 +813,19 @@ func (s lateBoundDashboardSource) Snapshot() *statsengine.Snapshot {
return source.Snapshot()
}
+func (s lateBoundDashboardSource) Reset() {
+ if s.runtime == nil {
+ return
+ }
+ source := s.runtime.dashboardSnapshotSource()
+ if source == nil {
+ return
+ }
+ if resettable, ok := source.(interface{ Reset() }); ok {
+ resettable.Reset()
+ }
+}
+
func renderHelpOverlay(width, height int, groups [][]key.Binding) string {
if width <= 0 {
width = 80
@@ -851,12 +864,11 @@ type helpSection struct {
func (m Model) helpSections() []helpSection {
globalLines := []string{
- "H help esc close help q quit",
- "tab/shift+tab cycle tabs 1..7 jump tab",
- "p pid picker t tid picker o probes r refresh",
+ "H help esc/? close help q quit",
+ "p pid picker t tid picker o probes",
}
if help := m.keys.Export.Help(); help.Key != "" || help.Desc != "" {
- globalLines = append(globalLines, "e snapshot export")
+ globalLines[1] += " e snapshot export"
}
return []helpSection{
@@ -865,22 +877,16 @@ func (m Model) helpSections() []helpSection {
lines: globalLines,
},
{
- title: "Flame Tab",
- lines: []string{
- "arrows/hjkl navigate pgup top pgdn root",
- "enter zoom u/backspace/esc undo",
- "/ filter n/N match next/prev",
- "space/p pause o order b metric r reset baseline",
- },
- },
- {
- title: "Stream Tab",
+ title: "Dashboard Tabs",
lines: []string{
- "space pause/live f add filter esc undo filter",
- "enter apply filter / or ? search n/N next/prev",
- "j/k/up/down scroll pgup/pgdn page g/G top/tail",
- "left/right or h/l switch columns",
- "c clear x export X export-as E open last",
+ "tab/shift+tab tabs 1..7 jump tab r reset baseline",
+ "sys/files/proc tables: j/k or up/down scroll",
+ "sys/proc: v bubbles b metric events/bytes",
+ "files: d dirs toggle v bubbles (dirs only) b metric",
+ "flame: arrows/hjkl nav enter zoom u/bs/esc undo o order",
+ "flame: / filter n/N match next/prev space/p pause b metric",
+ "stream: space pause f filter enter apply esc undo /? n/N",
+ "stream: j/k/pg scroll g/G top/tail h/l cols c x/X E open",
},
},
{
diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go
index ad529fc..7c1d886 100644
--- a/internal/tui/tui_test.go
+++ b/internal/tui/tui_test.go
@@ -1006,7 +1006,10 @@ func TestGlobalHelpOverlayFitsStandardTerminal(t *testing.T) {
if maxWidth > 80 {
t.Fatalf("expected help overlay width <= 80, got %d", maxWidth)
}
- if !strings.Contains(out, "Flame Tab") || !strings.Contains(out, "Stream Tab") {
- t.Fatalf("expected overlay to include tab-specific help sections")
+ if !strings.Contains(out, "Dashboard Tabs") {
+ t.Fatalf("expected overlay to include dashboard help section")
+ }
+ if !strings.Contains(out, "v bubbles") || !strings.Contains(out, "b metric") {
+ t.Fatalf("expected overlay to include bubble dashboard hotkeys")
}
}