diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/common/keys.go | 60 | ||||
| -rw-r--r-- | internal/tui/dashboard/bubbles.go | 121 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 63 | ||||
| -rw-r--r-- | internal/tui/dashboard/icicle.go | 88 | ||||
| -rw-r--r-- | internal/tui/dashboard/treemap.go | 91 | ||||
| -rw-r--r-- | internal/tui/eventstream/export.go | 129 | ||||
| -rw-r--r-- | internal/tui/eventstream/model.go | 390 | ||||
| -rw-r--r-- | internal/tui/export/model.go | 69 | ||||
| -rw-r--r-- | internal/tui/probes/model.go | 172 | ||||
| -rw-r--r-- | internal/tui/tracefilter/model.go | 233 |
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, ¤t) 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, ¤t) 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, ¤t) 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 |
