summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/common/keys.go60
-rw-r--r--internal/tui/dashboard/bubbles.go121
-rw-r--r--internal/tui/dashboard/histogram.go63
-rw-r--r--internal/tui/dashboard/icicle.go88
-rw-r--r--internal/tui/dashboard/treemap.go91
-rw-r--r--internal/tui/eventstream/export.go129
-rw-r--r--internal/tui/eventstream/model.go390
-rw-r--r--internal/tui/export/model.go69
-rw-r--r--internal/tui/probes/model.go172
-rw-r--r--internal/tui/tracefilter/model.go233
10 files changed, 797 insertions, 619 deletions
diff --git a/internal/tui/common/keys.go b/internal/tui/common/keys.go
index d1f26cf..e50ee94 100644
--- a/internal/tui/common/keys.go
+++ b/internal/tui/common/keys.go
@@ -98,40 +98,35 @@ func (k KeyMap) DashboardStatusHelp() []key.Binding {
// DashboardStatusHelpSections returns grouped bindings for dashboard status bars.
func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
- global := []key.Binding{
+ return []HelpSection{
+ {Title: "Global", Bindings: k.globalStatusBindings()},
+ {Title: "Dashboard", Bindings: dashboardStatusBindings(k)},
+ }
+}
+
+// globalStatusBindings returns the global key bindings shown in the status bar,
+// appending the optional export binding when it has a non-empty label.
+func (k KeyMap) globalStatusBindings() []key.Binding {
+ bindings := []key.Binding{
helpTextBinding("H", "toggle help"),
- k.Tab,
- k.ShiftTab,
- k.One,
- k.Two,
- k.Three,
- k.Four,
- k.Five,
- k.Six,
- k.Seven,
- k.Visualize,
- k.Metric,
- k.Sort,
- k.ReverseSort,
- k.Filter,
- k.FilterUndo,
- k.SelectPID,
- k.SelectTID,
- k.Probes,
- k.Record,
- k.Refresh,
- k.AutoReset,
- k.Quit,
+ k.Tab, k.ShiftTab,
+ k.One, k.Two, k.Three, k.Four, k.Five, k.Six, k.Seven,
+ k.Visualize, k.Metric, k.Sort, k.ReverseSort,
+ k.Filter, k.FilterUndo,
+ k.SelectPID, k.SelectTID,
+ k.Probes, k.Record, k.Refresh, k.AutoReset, k.Quit,
}
if help := k.Export.Help(); help.Key != "" || help.Desc != "" {
- global = append(global, k.Export)
+ bindings = append(bindings, k.Export)
}
- dashboard := []key.Binding{
- k.DirGroup,
- k.Visualize,
- k.Metric,
- k.Sort,
- k.ReverseSort,
+ return bindings
+}
+
+// dashboardStatusBindings returns the dashboard-specific bindings shown in
+// the status bar (table navigation, stream controls, and export shortcuts).
+func dashboardStatusBindings(k KeyMap) []key.Binding {
+ return []key.Binding{
+ k.DirGroup, k.Visualize, k.Metric, k.Sort, k.ReverseSort,
helpTextBinding("space", "stream pause"),
helpTextBinding("enter", "selected filter"),
helpTextBinding("esc", "stream undo filter"),
@@ -147,11 +142,6 @@ func (k KeyMap) DashboardStatusHelpSections() []HelpSection {
helpTextBinding("X", "stream export as"),
helpTextBinding("E", "stream open last"),
}
-
- return []HelpSection{
- {Title: "Global", Bindings: global},
- {Title: "Dashboard", Bindings: dashboard},
- }
}
// DashboardFullHelp returns grouped bindings for dashboard overlays.
diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go
index 96ebea1..eec75a5 100644
--- a/internal/tui/dashboard/bubbles.go
+++ b/internal/tui/dashboard/bubbles.go
@@ -156,6 +156,9 @@ func (c *bubbleChart) SetDarkMode(isDark bool) {
c.isDark = isDark
}
+// SetData recomputes bubble targets from data and merges them with existing
+// animation state so that live updates animate smoothly. Returns true when
+// at least one node has motion and a Tick should be scheduled.
func (c *bubbleChart) SetData(data []bubbleDatum) bool {
targets := buildBubbleTargets(data, c.Metric(), c.width, c.height)
@@ -169,6 +172,23 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
existing[node.ID] = node
}
+ c.nodes = c.mergeTargetNodes(targets, existing)
+ 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
+}
+
+// mergeTargetNodes converts target positions into live nodes, carrying over
+// spring velocities and drift state from existing nodes where available.
+func (c *bubbleChart) mergeTargetNodes(targets []bubbleNode, existing map[string]bubbleNode) []bubbleNode {
next := make([]bubbleNode, 0, len(targets))
for _, target := range targets {
node := bubbleNode{
@@ -188,25 +208,7 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
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
- node.driftPhase = prev.driftPhase
- node.driftSpeed = prev.driftSpeed
- node.driftAmpX = prev.driftAmpX
- node.driftAmpY = prev.driftAmpY
- // New metrics or topology can otherwise produce stale springs.
- if node.radius == 0 {
- node.radius = target.targetRadius
- }
- if node.driftSpeed == 0 {
- c.initNodeDrift(&node)
- } else {
- c.updateNodeDriftAmplitude(&node)
- }
+ c.inheritPrevNodeState(&node, prev, target)
} else {
node.radius = target.targetRadius
node.x = target.targetX
@@ -216,18 +218,31 @@ func (c *bubbleChart) SetData(data []bubbleDatum) bool {
node.applyDrift(c.driftTime, c.width, c.height)
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 next
+}
+
+// inheritPrevNodeState copies physics and drift state from a previous node
+// into node so that the transition animates rather than snapping.
+func (c *bubbleChart) inheritPrevNodeState(node *bubbleNode, prev bubbleNode, target bubbleNode) {
+ node.radius = prev.radius
+ node.x = prev.x
+ node.y = prev.y
+ node.velocityRadius = prev.velocityRadius
+ node.velocityX = prev.velocityX
+ node.velocityY = prev.velocityY
+ node.driftPhase = prev.driftPhase
+ node.driftSpeed = prev.driftSpeed
+ node.driftAmpX = prev.driftAmpX
+ node.driftAmpY = prev.driftAmpY
+ // New metrics or topology can otherwise produce stale springs.
+ if node.radius == 0 {
+ node.radius = target.targetRadius
+ }
+ if node.driftSpeed == 0 {
+ c.initNodeDrift(node)
+ } else {
+ c.updateNodeDriftAmplitude(node)
}
- return c.animating
}
func (c *bubbleChart) selectIndexByID(id string) int {
@@ -641,10 +656,10 @@ func renderBubbleRow(cells []bubbleCell, palette []color.Color) string {
return b.String()
}
+// buildBubbleTargets computes initial target positions and radii for each
+// bubble, then runs a short relaxation pass to reduce overlap. Returns nil
+// when there is nothing to render.
func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height int) []bubbleNode {
- if len(data) == 0 {
- return nil
- }
if width <= 0 {
width = 80
}
@@ -655,6 +670,20 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
if chartHeight < 4 {
chartHeight = 4
}
+
+ filtered := filterAndSortBubbleData(data, metric)
+ if len(filtered) == 0 {
+ return nil
+ }
+
+ targets := placeBubbleNodes(filtered, metric, width, chartHeight)
+ relaxTargets(targets, width, chartHeight)
+ return targets
+}
+
+// filterAndSortBubbleData removes datums without an ID, sorts by descending
+// metric value (ties broken by label), and caps the result to bubbleMaxItems.
+func filterAndSortBubbleData(data []bubbleDatum, metric bubbleMetric) []bubbleDatum {
filtered := make([]bubbleDatum, 0, len(data))
for _, datum := range data {
if datum.ID == "" {
@@ -662,9 +691,6 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
}
filtered = append(filtered, datum)
}
- if len(filtered) == 0 {
- return nil
- }
slices.SortFunc(filtered, func(a, b bubbleDatum) int {
va := bubbleValue(a, metric)
vb := bubbleValue(b, metric)
@@ -676,34 +702,40 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
if len(filtered) > bubbleMaxItems {
filtered = filtered[:bubbleMaxItems]
}
+ return filtered
+}
+
+// placeBubbleNodes converts sorted bubble data into node structs with target
+// positions arranged in a golden-angle spiral around the chart centre.
+func placeBubbleNodes(filtered []bubbleDatum, metric bubbleMetric, width, chartHeight int) []bubbleNode {
maxValue := uint64(0)
for _, datum := range filtered {
- value := bubbleValue(datum, metric)
- if value > maxValue {
- maxValue = value
+ if v := bubbleValue(datum, metric); v > maxValue {
+ maxValue = v
}
}
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
+
+ targets := make([]bubbleNode, 0, len(filtered))
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,
@@ -713,11 +745,10 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i
Duration: datum.Duration,
Value: value,
targetRadius: targetRadius,
- targetX: targetX,
- targetY: targetY,
+ targetX: cx + math.Cos(angle)*distance,
+ targetY: cy + math.Sin(angle)*distance*0.68,
})
}
- relaxTargets(targets, width, chartHeight)
return targets
}
diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go
index 28f5b2b..80b6309 100644
--- a/internal/tui/dashboard/histogram.go
+++ b/internal/tui/dashboard/histogram.go
@@ -47,43 +47,20 @@ func renderLatencyGapsTab(snap *statsengine.Snapshot, width, height int) string
return strings.Join([]string{lat, gap}, "\n")
}
+// renderHistogram renders a histogram snapshot as a bar chart panel.
func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, height int) string {
buckets := hist.Buckets()
if len(buckets) == 0 {
return common.PanelStyle.Render(title + ": no data")
}
-
if width <= 0 {
width = 80
}
panelW := panelWidth(width)
panelInner := panelInnerWidth(width)
- if height > 0 {
- maxRows := height - 3
- if maxRows < 1 {
- maxRows = 1
- }
- if len(buckets) > maxRows {
- buckets = buckets[:maxRows]
- }
- }
-
- maxCount := uint64(0)
- labelWidth := 0
- countWidth := len(strconv.FormatUint(hist.Total, 10))
- for _, bucket := range buckets {
- if bucket.Count > maxCount {
- maxCount = bucket.Count
- }
- if len(bucket.Label) > labelWidth {
- labelWidth = len(bucket.Label)
- }
- if digits := len(strconv.FormatUint(bucket.Count, 10)); digits > countWidth {
- countWidth = digits
- }
- }
-
+ buckets = clampHistogramBuckets(buckets, height)
+ maxCount, labelWidth, countWidth := histogramMetrics(hist, buckets)
barWidth := panelInner - labelWidth - countWidth - 6
if barWidth < 8 {
barWidth = 8
@@ -96,10 +73,42 @@ func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, he
lines = append(lines, fmt.Sprintf("%-*s | %-*s %*d", labelWidth, bucket.Label, barWidth, bar, countWidth, bucket.Count))
}
lines = append(lines, "Scale: █▓▒░")
-
return common.PanelStyle.Width(panelW).Render(strings.Join(lines, "\n"))
}
+// clampHistogramBuckets trims the bucket slice to fit within the available rows.
+func clampHistogramBuckets(buckets []statsengine.HistogramBucketSnapshot, height int) []statsengine.HistogramBucketSnapshot {
+ if height <= 0 {
+ return buckets
+ }
+ maxRows := height - 3
+ if maxRows < 1 {
+ maxRows = 1
+ }
+ if len(buckets) > maxRows {
+ return buckets[:maxRows]
+ }
+ return buckets
+}
+
+// histogramMetrics computes the maximum count, maximum label width, and maximum
+// count-digit width needed to align the histogram columns.
+func histogramMetrics(hist statsengine.HistogramSnapshot, buckets []statsengine.HistogramBucketSnapshot) (maxCount uint64, labelWidth, countWidth int) {
+ countWidth = len(strconv.FormatUint(hist.Total, 10))
+ for _, bucket := range buckets {
+ if bucket.Count > maxCount {
+ maxCount = bucket.Count
+ }
+ if len(bucket.Label) > labelWidth {
+ labelWidth = len(bucket.Label)
+ }
+ if digits := len(strconv.FormatUint(bucket.Count, 10)); digits > countWidth {
+ countWidth = digits
+ }
+ }
+ return maxCount, labelWidth, countWidth
+}
+
func renderHistogramBar(count, maxCount uint64, width int) string {
if count == 0 || maxCount == 0 || width <= 0 {
return ""
diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go
index 560bb2a..019100c 100644
--- a/internal/tui/dashboard/icicle.go
+++ b/internal/tui/dashboard/icicle.go
@@ -28,6 +28,7 @@ type icicleTile struct {
colorSlot int
}
+// renderFilesIcicle renders the icicle chart for directory-based file stats.
func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bubbleMetric, selected int, isDark bool) string {
if snap == nil {
return "Files icicle: waiting for stats..."
@@ -39,28 +40,44 @@ func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bub
height = 18
}
header := fmt.Sprintf("Files icicle | metric:%s | v mode | b metric | j/k select", treemapMetricLabel(metric))
- dirs := aggregateFilesByDir(snap.Files())
- if len(dirs) == 0 {
+
+ tiles, ok := buildIcicleTiles(snap, width, height, metric)
+ if !ok {
return header + "\nFiles icicle: no directory data\nsel: none"
}
+ if len(tiles) == 0 {
+ return header + "\nFiles icicle: no visible tiles\nsel: none"
+ }
+ return renderIcicleGrid(header, tiles, width, height, metric, selected, isDark)
+}
+// buildIcicleTiles constructs the icicle tile layout from the snapshot's file data.
+// Returns (nil, false) when there is no data to display.
+func buildIcicleTiles(snap *statsengine.Snapshot, width, height int, metric bubbleMetric) ([]icicleTile, bool) {
+ dirs := aggregateFilesByDir(snap.Files())
+ if len(dirs) == 0 {
+ return nil, false
+ }
root := buildIcicleTree(dirs)
children := sortedIcicleChildren(root, metric)
if len(children) == 0 {
- return header + "\nFiles icicle: no directory data\nsel: none"
+ return nil, false
}
-
chartHeight := height - 2
if chartHeight < 4 {
chartHeight = 4
}
-
tiles := make([]icicleTile, 0, 64)
layoutIcicle(children, 0, width, 0, chartHeight, 0, metric, &tiles)
- if len(tiles) == 0 {
- return header + "\nFiles icicle: no visible tiles\nsel: none"
- }
+ return tiles, true
+}
+// renderIcicleGrid fills a 2-D grid with icicle tiles and assembles the final string.
+func renderIcicleGrid(header string, tiles []icicleTile, width, height int, metric bubbleMetric, selected int, isDark bool) string {
+ chartHeight := height - 2
+ if chartHeight < 4 {
+ chartHeight = 4
+ }
selected = clampOffset(selected, len(tiles))
grid := make([][]treemapCell, chartHeight)
for row := 0; row < chartHeight; row++ {
@@ -71,7 +88,6 @@ func renderFilesIcicle(snap *statsengine.Snapshot, width, height int, metric bub
}
fillIcicleGrid(grid, tiles, selected)
palette := treemapPalette(isDark)
-
lines := make([]string, 0, chartHeight+2)
lines = append(lines, padOrTrim(header, width))
for _, row := range grid {
@@ -184,14 +200,13 @@ func sortedIcicleChildren(node *icicleNode, metric bubbleMetric) []*icicleNode {
return out
}
+// layoutIcicle recursively lays out icicle chart tiles for one depth level,
+// distributing the available width among nodes proportional to their metric values.
func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int, metric bubbleMetric, out *[]icicleTile) {
if len(nodes) == 0 || width <= 0 || depth >= maxDepth {
return
}
- total := uint64(0)
- for _, node := range nodes {
- total += icicleValue(node, metric)
- }
+ total := icicleNodeTotal(nodes, metric)
if total == 0 {
return
}
@@ -201,17 +216,7 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int,
cursor := x
for idx, node := range nodes {
value := icicleValue(node, metric)
- tileWidth := remainingWidth
- if idx < len(nodes)-1 {
- tileWidth = int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue)))
- minRemaining := len(nodes) - idx - 1
- if tileWidth < 1 {
- tileWidth = 1
- }
- if tileWidth > remainingWidth-minRemaining {
- tileWidth = remainingWidth - minRemaining
- }
- }
+ tileWidth := icicleTileWidth(idx, len(nodes), value, remainingWidth, remainingValue)
if tileWidth <= 0 {
continue
}
@@ -219,17 +224,10 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int,
if depth == 0 {
colorSlot = idx
}
- *out = append(*out, icicleTile{
- node: node,
- depth: depth,
- x: cursor,
- w: tileWidth,
- colorSlot: colorSlot,
- })
+ *out = append(*out, icicleTile{node: node, depth: depth, x: cursor, w: tileWidth, colorSlot: colorSlot})
if depth+1 < maxDepth {
layoutIcicle(sortedIcicleChildren(node, metric), cursor, tileWidth, depth+1, maxDepth, colorSlot, metric, out)
}
-
cursor += tileWidth
remainingWidth -= tileWidth
remainingValue -= value
@@ -239,6 +237,32 @@ func layoutIcicle(nodes []*icicleNode, x, width, depth, maxDepth, rootSlot int,
}
}
+// icicleNodeTotal sums the metric values of all nodes in the slice.
+func icicleNodeTotal(nodes []*icicleNode, metric bubbleMetric) uint64 {
+ total := uint64(0)
+ for _, node := range nodes {
+ total += icicleValue(node, metric)
+ }
+ return total
+}
+
+// icicleTileWidth computes the pixel width to allocate to the node at idx.
+// The last node gets the full remaining width to avoid rounding gaps.
+func icicleTileWidth(idx, total int, value uint64, remainingWidth int, remainingValue uint64) int {
+ if idx == total-1 {
+ return remainingWidth
+ }
+ tileWidth := int(math.Round(float64(remainingWidth) * float64(value) / float64(remainingValue)))
+ minRemaining := total - idx - 1
+ if tileWidth < 1 {
+ tileWidth = 1
+ }
+ if tileWidth > remainingWidth-minRemaining {
+ tileWidth = remainingWidth - minRemaining
+ }
+ return tileWidth
+}
+
func fillIcicleGrid(grid [][]treemapCell, tiles []icicleTile, selected int) {
height := len(grid)
if height == 0 {
diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go
index 4d5486a..cc9f44d 100644
--- a/internal/tui/dashboard/treemap.go
+++ b/internal/tui/dashboard/treemap.go
@@ -251,70 +251,74 @@ func layoutSyscallTreemap(items []syscallTreemapItem, x, y, w, h int) []syscallT
return tiles
}
+// layoutSyscallTreemapInto recursively partitions items into tiles using a
+// binary split strategy. Items are bisected near the median value and placed
+// into the left/right (vertical split) or top/bottom (horizontal split) halves.
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 {
+
+ total := sumTreemapValues(items)
+ if len(items) == 1 || total == 0 {
+ // Degenerate case: single item or all-zero values — fill the whole rect.
*out = append(*out, syscallTreemapTile{
- item: items[0],
- index: baseIndex,
- x: x,
- y: y,
- w: w,
- h: h,
+ item: items[0], index: baseIndex,
+ x: x, y: y, w: w, h: h,
})
return
}
+ splitAt := findTreemapSplitIndex(items, total)
+ first, second := items[:splitAt], items[splitAt:]
+ firstTotal := sumTreemapValues(first)
+
+ if chooseSplitVertical(w, h) {
+ layoutTreemapVertical(first, second, x, y, w, h, baseIndex, splitAt, firstTotal, total, out)
+ } else {
+ layoutTreemapHorizontal(first, second, x, y, w, h, baseIndex, splitAt, firstTotal, total, out)
+ }
+}
+
+// sumTreemapValues returns the sum of Value fields across items.
+func sumTreemapValues(items []syscallTreemapItem) uint64 {
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
- }
+ return total
+}
+// chooseSplitVertical returns true when the rectangle should be split along
+// the vertical axis (left/right), using aspect-ratio heuristics.
+func chooseSplitVertical(w, h int) bool {
splitVertical := w >= h
if splitVertical && w <= 1 {
- splitVertical = false
+ return false
}
if !splitVertical && h <= 1 {
- splitVertical = true
+ return true
}
+ return splitVertical
+}
- 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
+// layoutTreemapVertical splits the items into left (first) and right (second)
+// columns proportional to their value totals and recurses into each column.
+func layoutTreemapVertical(first, second []syscallTreemapItem, x, y, w, h, baseIndex, splitAt int, firstTotal, total uint64, out *[]syscallTreemapTile) {
+ w1 := int(math.Round(float64(w) * float64(firstTotal) / float64(total)))
+ if w1 < 1 {
+ w1 = 1
+ }
+ if w1 >= w {
+ w1 = w - 1
}
+ layoutSyscallTreemapInto(first, x, y, w1, h, baseIndex, out)
+ layoutSyscallTreemapInto(second, x+w1, y, w-w1, h, baseIndex+splitAt, out)
+}
+// layoutTreemapHorizontal splits items into top (first) and bottom (second)
+// rows proportional to their value totals and recurses into each row.
+func layoutTreemapHorizontal(first, second []syscallTreemapItem, x, y, w, h, baseIndex, splitAt int, firstTotal, total uint64, out *[]syscallTreemapTile) {
h1 := int(math.Round(float64(h) * float64(firstTotal) / float64(total)))
if h1 < 1 {
h1 = 1
@@ -322,9 +326,6 @@ func layoutSyscallTreemapInto(items []syscallTreemapItem, x, y, w, h, baseIndex
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)
}
diff --git a/internal/tui/eventstream/export.go b/internal/tui/eventstream/export.go
index 1aa4313..46e3a23 100644
--- a/internal/tui/eventstream/export.go
+++ b/internal/tui/eventstream/export.go
@@ -31,50 +31,14 @@ func shellSplit(s string) []string {
ch := s[i]
switch {
case ch == '\'':
- // Single-quote: copy until the matching closing quote verbatim.
inToken = true
- i++
- for i < len(s) && s[i] != '\'' {
- current.WriteByte(s[i])
- i++
- }
- // Skip closing quote if present; if missing we just fall through.
- if i < len(s) {
- i++ // consume the closing '
- }
-
+ i = consumeSingleQuoted(s, i+1, &current)
case ch == '"':
- // Double-quote: process backslash escapes for \" and \\.
inToken = true
- i++
- for i < len(s) && s[i] != '"' {
- if s[i] == '\\' && i+1 < len(s) {
- next := s[i+1]
- if next == '"' || next == '\\' {
- current.WriteByte(next)
- i += 2
- continue
- }
- }
- current.WriteByte(s[i])
- i++
- }
- if i < len(s) {
- i++ // consume the closing "
- }
-
+ i = consumeDoubleQuoted(s, i+1, &current)
case ch == '\\':
- // Backslash outside quotes: escape the next character.
inToken = true
- if i+1 < len(s) {
- current.WriteByte(s[i+1])
- i += 2
- } else {
- // Trailing backslash: keep it.
- current.WriteByte('\\')
- i++
- }
-
+ i = consumeBackslash(s, i, &current)
case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r':
// Whitespace: flush current token if any.
if inToken {
@@ -83,7 +47,6 @@ func shellSplit(s string) []string {
inToken = false
}
i++
-
default:
inToken = true
current.WriteByte(ch)
@@ -97,6 +60,55 @@ func shellSplit(s string) []string {
return tokens
}
+// consumeSingleQuoted copies characters verbatim from s starting at i until
+// the closing single-quote (or end-of-string). Returns the index after the
+// closing quote.
+func consumeSingleQuoted(s string, i int, out *strings.Builder) int {
+ for i < len(s) && s[i] != '\'' {
+ out.WriteByte(s[i])
+ i++
+ }
+ if i < len(s) {
+ i++ // consume the closing '
+ }
+ return i
+}
+
+// consumeDoubleQuoted copies characters from s starting at i until the
+// closing double-quote, processing \" and \\ escape sequences. Returns the
+// index after the closing quote.
+func consumeDoubleQuoted(s string, i int, out *strings.Builder) int {
+ for i < len(s) && s[i] != '"' {
+ if s[i] == '\\' && i+1 < len(s) {
+ next := s[i+1]
+ if next == '"' || next == '\\' {
+ out.WriteByte(next)
+ i += 2
+ continue
+ }
+ }
+ out.WriteByte(s[i])
+ i++
+ }
+ if i < len(s) {
+ i++ // consume the closing "
+ }
+ return i
+}
+
+// consumeBackslash handles a backslash outside any quoted context: if a next
+// character exists it is treated as escaped; a trailing backslash is kept as-is.
+// i must point at the backslash character. Returns the index after consumed bytes.
+func consumeBackslash(s string, i int, out *strings.Builder) int {
+ if i+1 < len(s) {
+ out.WriteByte(s[i+1])
+ return i + 2
+ }
+ // Trailing backslash: keep it.
+ out.WriteByte('\\')
+ return i + 1
+}
+
func defaultStreamExportFilename() string {
return fmt.Sprintf("ior-stream-%s.csv", time.Now().Format("20060102-150405"))
}
@@ -122,6 +134,8 @@ func exportSnapshotToCSV(source Source, filter Filter, exportDir, filename strin
return exportRowsToCSV(rows, exportDir, name)
}
+// exportRowsToCSV writes rows to a CSV file under exportDir with the given
+// filename (which is validated and sanitised by ensureCSVFilename).
func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, error) {
name, err := ensureCSVFilename(filename)
if err != nil {
@@ -136,6 +150,7 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
if err != nil {
return "", err
}
+ // closeFile is idempotent; fail wraps any write error with a best-effort close.
closed := false
closeFile := func() error {
if closed {
@@ -151,11 +166,26 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
return "", baseErr
}
- w := csv.NewWriter(f)
+ if err := writeStreamCSV(csv.NewWriter(f), rows, fail); err != nil {
+ return "", err
+ }
+ if err := closeFile(); err != nil {
+ return "", err
+ }
+ absPath, err := filepath.Abs(path)
+ if err != nil {
+ return path, nil
+ }
+ return absPath, nil
+}
+// writeStreamCSV writes the CSV header and all event rows to w, calling fail
+// on the first write error to close the underlying file before returning.
+func writeStreamCSV(w *csv.Writer, rows []StreamEvent, fail func(error) (string, error)) error {
header := []string{"seq", "time_ns", "gap_ns", "latency_ns", "comm", "pid", "tid", "syscall", "fd", "ret", "bytes", "file", "error"}
if err := w.Write(header); err != nil {
- return fail(err)
+ _, err = fail(err)
+ return err
}
for i := range rows {
ev := rows[i]
@@ -175,21 +205,16 @@ func exportRowsToCSV(rows []StreamEvent, exportDir, filename string) (string, er
fmt.Sprintf("%t", ev.IsError),
}
if err := w.Write(record); err != nil {
- return fail(err)
+ _, err = fail(err)
+ return err
}
}
w.Flush()
if err := w.Error(); err != nil {
- return fail(err)
- }
- if err := closeFile(); err != nil {
- return "", err
+ _, err = fail(err)
+ return err
}
- absPath, err := filepath.Abs(path)
- if err != nil {
- return path, nil
- }
- return absPath, nil
+ return nil
}
// ensureCSVFilename validates and normalises a user-supplied export filename.
diff --git a/internal/tui/eventstream/model.go b/internal/tui/eventstream/model.go
index a8f399c..55b4f6e 100644
--- a/internal/tui/eventstream/model.go
+++ b/internal/tui/eventstream/model.go
@@ -174,72 +174,136 @@ func (m Model) Paused() bool {
return m.paused
}
+// HandleKey dispatches keyStr to the active modal or live/paused stream handlers.
+// It returns true if the key was consumed, false if the caller should handle it.
func (m *Model) HandleKey(keyStr string) bool {
if m.searchModal.Visible() {
- m.statusMessage = ""
- var (
- term string
- submit bool
- )
- m.searchModal, term, submit = m.searchModal.Update(keyMsgFromString(keyStr))
- if !submit {
- return true
- }
- return m.submitSearch(term, m.searchModal.Direction())
+ return m.handleSearchModalKey(keyStr)
}
if m.exportModal.Visible() {
- m.statusMessage = ""
- var (
- filename string
- submit bool
- )
- m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr))
- if !submit {
- return true
+ return m.handleExportModalKey(keyStr)
+ }
+ if m.fdTraceView.visible {
+ return m.handleFDTraceKey(keyStr)
+ }
+ return m.handleStreamKey(keyStr)
+}
+
+// handleSearchModalKey routes a key press while the search modal is open.
+func (m *Model) handleSearchModalKey(keyStr string) bool {
+ m.statusMessage = ""
+ var (
+ term string
+ submit bool
+ )
+ m.searchModal, term, submit = m.searchModal.Update(keyMsgFromString(keyStr))
+ if !submit {
+ return true
+ }
+ return m.submitSearch(term, m.searchModal.Direction())
+}
+
+// handleExportModalKey routes a key press while the export modal is open.
+func (m *Model) handleExportModalKey(keyStr string) bool {
+ m.statusMessage = ""
+ var (
+ filename string
+ submit bool
+ )
+ m.exportModal, filename, submit = m.exportModal.Update(keyMsgFromString(keyStr))
+ if !submit {
+ return true
+ }
+ path, err := m.exportFilteredToCSV(filename)
+ if err != nil {
+ m.statusMessage = fmt.Sprintf("Export failed: %v", err)
+ return true
+ }
+ m.lastExportPath = path
+ m.statusMessage = "Exported: " + path
+ return true
+}
+
+// handleFDTraceKey routes a key press while the FD-trace overlay is visible.
+func (m *Model) handleFDTraceKey(keyStr string) bool {
+ switch keyStr {
+ case "enter", " ", "space":
+ return true
+ case "j", "down":
+ m.scrollFDTraceByLines(1)
+ return true
+ case "k", "up":
+ m.scrollFDTraceByLines(-1)
+ return true
+ case "left", "h":
+ return true
+ case "right", "l":
+ return true
+ case "pgdown", "pgdn", "pagedown":
+ m.scrollFDTraceByLines(m.pageStep())
+ return true
+ case "pgup", "pageup":
+ m.scrollFDTraceByLines(-m.pageStep())
+ return true
+ case "g":
+ m.fdTraceView.offset = 0
+ return true
+ case "G":
+ m.fdTraceView.offset = m.maxFDTraceOffset()
+ return true
+ case "esc", "q":
+ m.fdTraceView.visible = false
+ m.fdTraceView.events = nil
+ m.fdTraceView.offset = 0
+ return true
+ default:
+ return false
+ }
+}
+
+// handleStreamExportKey handles x/X/E export shortcuts while the stream is paused.
+func (m *Model) handleStreamExportKey(keyStr string) (bool, bool) {
+ switch keyStr {
+ case "x":
+ if !m.paused {
+ return false, true
}
- path, err := m.exportFilteredToCSV(filename)
+ m.statusMessage = ""
+ path, err := m.exportFilteredToCSV(defaultStreamExportFilename())
if err != nil {
m.statusMessage = fmt.Sprintf("Export failed: %v", err)
- return true
+ return true, true
}
m.lastExportPath = path
m.statusMessage = "Exported: " + path
- return true
- }
- if m.fdTraceView.visible {
- switch keyStr {
- case "enter", " ", "space":
- return true
- case "j", "down":
- m.scrollFDTraceByLines(1)
- return true
- case "k", "up":
- m.scrollFDTraceByLines(-1)
- return true
- case "left", "h":
- return true
- case "right", "l":
- return true
- case "pgdown", "pgdn", "pagedown":
- m.scrollFDTraceByLines(m.pageStep())
- return true
- case "pgup", "pageup":
- m.scrollFDTraceByLines(-m.pageStep())
- return true
- case "g":
- m.fdTraceView.offset = 0
- return true
- case "G":
- m.fdTraceView.offset = m.maxFDTraceOffset()
- retu