From f86699a94bdde7d973ba5d6fa3e7ca4ab2f234fb Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 8 May 2026 19:43:33 +0300 Subject: add duration metric, tolerate missing tracepoints, ship el8 build - Bubbles, treemap, icicle, and the live flamegraph 'b' cycle now include syscall duration (sum) as a third metric alongside events and bytes. Statsengine snapshots expose TotalLatencyNs to support this. - AttachAll takes an optional warn callback. Production passes one so older kernels that lack newer tracepoints log a warning and keep going instead of aborting startup. - Dockerfile.el8 + scripts/build-with-docker-el8.sh + mage buildDockerEl8 produce ior.el8, a static binary built against Rocky Linux 8 glibc for RHEL/Rocky/Alma 8 hosts. - README.md documents installing mage and the new el8 target. --- internal/tui/dashboard/bubbles.go | 114 +++++++++++++++++++++------------- internal/tui/dashboard/files.go | 14 ++--- internal/tui/dashboard/files_test.go | 6 +- internal/tui/dashboard/icicle.go | 20 ++++-- internal/tui/dashboard/model.go | 17 +++-- internal/tui/dashboard/treemap.go | 71 ++++++++++++--------- internal/tui/flamegraph/controls.go | 14 ++++- internal/tui/flamegraph/model_test.go | 12 +++- 8 files changed, 173 insertions(+), 95 deletions(-) (limited to 'internal/tui') diff --git a/internal/tui/dashboard/bubbles.go b/internal/tui/dashboard/bubbles.go index f50eba8..f4fa6d5 100644 --- a/internal/tui/dashboard/bubbles.go +++ b/internal/tui/dashboard/bubbles.go @@ -19,8 +19,9 @@ import ( type bubbleMetric string const ( - bubbleMetricCount bubbleMetric = "count" - bubbleMetricBytes bubbleMetric = "bytes" + bubbleMetricCount bubbleMetric = "count" + bubbleMetricBytes bubbleMetric = "bytes" + bubbleMetricDuration bubbleMetric = "duration" ) const ( @@ -32,20 +33,22 @@ const ( ) type bubbleDatum struct { - ID string - Label string - Count uint64 - Bytes uint64 - Detail string + ID string + Label string + Count uint64 + Bytes uint64 + Duration uint64 + Detail string } type bubbleNode struct { - ID string - Label string - Detail string - Count uint64 - Bytes uint64 - Value uint64 + ID string + Label string + Detail string + Count uint64 + Bytes uint64 + Duration uint64 + Value uint64 radiusSpring harmonica.Spring xSpring harmonica.Spring @@ -114,28 +117,35 @@ func (c *bubbleChart) SetViewport(width, height int) { 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, + ID: node.ID, + Label: node.Label, + Count: node.Count, + Bytes: node.Bytes, + Duration: node.Duration, + Detail: node.Detail, }) } c.SetData(data) } func (c *bubbleChart) SetMetric(metric bubbleMetric) { - if metric != bubbleMetricBytes { - metric = bubbleMetricCount + switch metric { + case bubbleMetricBytes, bubbleMetricDuration: + c.metric = metric + default: + c.metric = bubbleMetricCount } - c.metric = metric } func (c *bubbleChart) Metric() bubbleMetric { - if c.metric == bubbleMetricBytes { + switch c.metric { + case bubbleMetricBytes: return bubbleMetricBytes + case bubbleMetricDuration: + return bubbleMetricDuration + default: + return bubbleMetricCount } - return bubbleMetricCount } func (c *bubbleChart) SetStatusHint(hint string) { @@ -543,17 +553,25 @@ func (c *bubbleChart) statusLine(width int) string { } func (c *bubbleChart) metricLabel() string { - if c.Metric() == bubbleMetricBytes { + switch c.Metric() { + case bubbleMetricBytes: return "bytes" + case bubbleMetricDuration: + return "duration" + default: + return "events" } - return "events" } func (c *bubbleChart) formatMetricValue(node bubbleNode) string { - if c.Metric() == bubbleMetricBytes { + switch c.Metric() { + case bubbleMetricBytes: return formatBytes(float64(node.Bytes)) + case bubbleMetricDuration: + return formatDurationUintNs(node.Duration) + default: + return fmt.Sprintf("%d", node.Count) } - return fmt.Sprintf("%d", node.Count) } func (c *bubbleChart) palette() []color.Color { @@ -687,6 +705,7 @@ func buildBubbleTargets(data []bubbleDatum, metric bubbleMetric, width, height i Detail: datum.Detail, Count: datum.Count, Bytes: datum.Bytes, + Duration: datum.Duration, Value: value, targetRadius: targetRadius, targetX: targetX, @@ -767,10 +786,14 @@ func clampFloat(value, minValue, maxValue float64) float64 { } func bubbleValue(d bubbleDatum, metric bubbleMetric) uint64 { - if metric == bubbleMetricBytes { + switch metric { + case bubbleMetricBytes: return d.Bytes + case bubbleMetricDuration: + return d.Duration + default: + return d.Count } - return d.Count } func syscallBubbleData(snap *statsengine.Snapshot) []bubbleDatum { @@ -782,11 +805,12 @@ func syscallBubbleData(snap *statsengine.Snapshot) []bubbleDatum { 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, + ID: syscall.Name, + Label: syscall.Name, + Count: syscall.Count, + Bytes: syscall.Bytes, + Duration: syscall.TotalLatencyNs, + Detail: detail, }) } return data @@ -802,11 +826,12 @@ func filesDirBubbleData(snap *statsengine.Snapshot) []bubbleDatum { 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: rootPathLabelFromFSPath(dir.Dir), - Count: dir.Accesses, - Bytes: totalBytes, - Detail: detail, + ID: dir.Dir, + Label: rootPathLabelFromFSPath(dir.Dir), + Count: dir.Accesses, + Bytes: totalBytes, + Duration: dir.TotalLatencyNs, + Detail: detail, }) } return data @@ -825,11 +850,12 @@ func processBubbleData(snap *statsengine.Snapshot) []bubbleDatum { } 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, + ID: fmt.Sprintf("%d/%s", proc.PID, proc.Comm), + Label: label, + Count: proc.Syscalls, + Bytes: proc.Bytes, + Duration: proc.TotalLatencyNs, + Detail: detail, }) } return data diff --git a/internal/tui/dashboard/files.go b/internal/tui/dashboard/files.go index df850ab..3b85a73 100644 --- a/internal/tui/dashboard/files.go +++ b/internal/tui/dashboard/files.go @@ -17,9 +17,10 @@ type DirSnapshot struct { BytesRead uint64 BytesWritten uint64 - AvgLatencyNs float64 - MaxLatencyNs uint64 - FileCount uint64 + AvgLatencyNs float64 + MaxLatencyNs uint64 + TotalLatencyNs uint64 + FileCount uint64 } type fileSortKey uint8 @@ -401,7 +402,6 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot { } dirs := make(map[string]DirSnapshot, len(files)) - weightedLatency := make(map[string]float64, len(files)) for _, f := range files { dir := filepath.Dir(f.Path) s := dirs[dir] @@ -413,14 +413,14 @@ func aggregateFilesByDir(files []statsengine.FileSnapshot) []DirSnapshot { s.MaxLatencyNs = f.MaxLatencyNs } s.FileCount++ - weightedLatency[dir] += f.AvgLatencyNs * float64(f.Accesses) + s.TotalLatencyNs += f.TotalLatencyNs dirs[dir] = s } out := make([]DirSnapshot, 0, len(dirs)) - for dir, s := range dirs { + for _, s := range dirs { if s.Accesses > 0 { - s.AvgLatencyNs = weightedLatency[dir] / float64(s.Accesses) + s.AvgLatencyNs = float64(s.TotalLatencyNs) / float64(s.Accesses) } out = append(out, s) } diff --git a/internal/tui/dashboard/files_test.go b/internal/tui/dashboard/files_test.go index 480c25f..848aa33 100644 --- a/internal/tui/dashboard/files_test.go +++ b/internal/tui/dashboard/files_test.go @@ -65,9 +65,9 @@ func TestFilePathWidthExpandsOnWideTerminal(t *testing.T) { func TestAggregateFilesByDir(t *testing.T) { files := []statsengine.FileSnapshot{ - {Path: "/var/log/a.log", Accesses: 10, BytesRead: 100, BytesWritten: 40, AvgLatencyNs: 100, MaxLatencyNs: 300}, - {Path: "/var/log/b.log", Accesses: 20, BytesRead: 200, BytesWritten: 60, AvgLatencyNs: 200, MaxLatencyNs: 500}, - {Path: "/tmp/c.log", Accesses: 5, BytesRead: 50, BytesWritten: 10, AvgLatencyNs: 1000, MaxLatencyNs: 1200}, + {Path: "/var/log/a.log", Accesses: 10, BytesRead: 100, BytesWritten: 40, AvgLatencyNs: 100, MaxLatencyNs: 300, TotalLatencyNs: 1000}, + {Path: "/var/log/b.log", Accesses: 20, BytesRead: 200, BytesWritten: 60, AvgLatencyNs: 200, MaxLatencyNs: 500, TotalLatencyNs: 4000}, + {Path: "/tmp/c.log", Accesses: 5, BytesRead: 50, BytesWritten: 10, AvgLatencyNs: 1000, MaxLatencyNs: 1200, TotalLatencyNs: 5000}, } got := aggregateFilesByDir(files) diff --git a/internal/tui/dashboard/icicle.go b/internal/tui/dashboard/icicle.go index 768783b..560bb2a 100644 --- a/internal/tui/dashboard/icicle.go +++ b/internal/tui/dashboard/icicle.go @@ -16,6 +16,7 @@ type icicleNode struct { fullPath string accesses uint64 bytes uint64 + duration uint64 children map[string]*icicleNode } @@ -123,6 +124,7 @@ func buildIcicleTree(dirs []DirSnapshot) *icicleNode { metricBytes := dir.BytesRead + dir.BytesWritten current.accesses += dir.Accesses current.bytes += metricBytes + current.duration += dir.TotalLatencyNs currentPath := "/" for _, segment := range segments { if segment == "" { @@ -144,6 +146,7 @@ func buildIcicleTree(dirs []DirSnapshot) *icicleNode { } child.accesses += dir.Accesses child.bytes += metricBytes + child.duration += dir.TotalLatencyNs current = child } } @@ -297,9 +300,14 @@ func icicleStatusLine(tiles []icicleTile, selected int, metric bubbleMetric) str selected = clampOffset(selected, len(tiles)) tile := tiles[selected] metricValue := icicleValue(tile.node, metric) - metricText := fmt.Sprintf("%d", metricValue) - if metric == bubbleMetricBytes { + var metricText string + switch metric { + case bubbleMetricBytes: metricText = formatBytes(float64(metricValue)) + case bubbleMetricDuration: + metricText = formatDurationUintNs(metricValue) + default: + metricText = fmt.Sprintf("%d", metricValue) } return fmt.Sprintf( "sel:%d/%d %s | %s=%s | accesses=%d | bytes=%s", @@ -317,8 +325,12 @@ func icicleValue(node *icicleNode, metric bubbleMetric) uint64 { if node == nil { return 0 } - if metric == bubbleMetricBytes { + switch metric { + case bubbleMetricBytes: return node.bytes + case bubbleMetricDuration: + return node.duration + default: + return node.accesses } - return node.accesses } diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go index 850a483..42a9ad4 100644 --- a/internal/tui/dashboard/model.go +++ b/internal/tui/dashboard/model.go @@ -713,10 +713,14 @@ func sortedProcessSnapshots(rows []statsengine.ProcessSnapshot, metric bubbleMet } func processMetricValue(proc statsengine.ProcessSnapshot, metric bubbleMetric) uint64 { - if metric == bubbleMetricBytes { + switch metric { + case bubbleMetricBytes: return proc.Bytes + case bubbleMetricDuration: + return proc.TotalLatencyNs + default: + return proc.Syscalls } - return proc.Syscalls } func processSelectionLabel(proc statsengine.ProcessSnapshot) string { @@ -1289,10 +1293,15 @@ func nextVizMode(current tabVizMode, allowed []tabVizMode) tabVizMode { } func nextBubbleMetric(metric bubbleMetric) bubbleMetric { - if metric == bubbleMetricBytes { + // 3-way cycle: count (events) → bytes → duration → count. + switch metric { + case bubbleMetricCount: + return bubbleMetricBytes + case bubbleMetricBytes: + return bubbleMetricDuration + default: return bubbleMetricCount } - return bubbleMetricBytes } func tickCmd(d time.Duration) tea.Cmd { diff --git a/internal/tui/dashboard/treemap.go b/internal/tui/dashboard/treemap.go index dd62d13..03c2917 100644 --- a/internal/tui/dashboard/treemap.go +++ b/internal/tui/dashboard/treemap.go @@ -17,13 +17,14 @@ import ( const maxSyscallTreemapItems = 20 type syscallTreemapItem struct { - Name string - Count uint64 - Bytes uint64 - Errors uint64 - P95Ns uint64 - Detail string - Value uint64 + Name string + Count uint64 + Bytes uint64 + Duration uint64 + Errors uint64 + P95Ns uint64 + Detail string + Value uint64 } type syscallTreemapTile struct { @@ -111,11 +112,12 @@ func buildSyscallTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) [ items := make([]syscallTreemapItem, 0, len(syscalls)) for _, syscall := range syscalls { item := syscallTreemapItem{ - Name: syscall.Name, - Count: syscall.Count, - Bytes: syscall.Bytes, - Errors: syscall.Errors, - P95Ns: syscall.LatencyP95Ns, + Name: syscall.Name, + Count: syscall.Count, + Bytes: syscall.Bytes, + Duration: syscall.TotalLatencyNs, + Errors: syscall.Errors, + P95Ns: syscall.LatencyP95Ns, Detail: fmt.Sprintf( "rate %.1f/s, errors %d, p95 %s", syscall.RatePerSec, @@ -154,9 +156,10 @@ func buildFilesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) []s pathLabel := rootPathLabelFromFSPath(dir.Dir) totalBytes := dir.BytesRead + dir.BytesWritten item := syscallTreemapItem{ - Name: pathLabel, - Count: dir.Accesses, - Bytes: totalBytes, + Name: pathLabel, + Count: dir.Accesses, + Bytes: totalBytes, + Duration: dir.TotalLatencyNs, Detail: fmt.Sprintf( "dir %s, files %d, read %s, write %s, max %s", dir.Dir, @@ -199,9 +202,10 @@ func buildProcessesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) label = fmt.Sprintf("%d:%s", proc.PID, comm) } item := syscallTreemapItem{ - Name: label, - Count: proc.Syscalls, - Bytes: proc.Bytes, + Name: label, + Count: proc.Syscalls, + Bytes: proc.Bytes, + Duration: proc.TotalLatencyNs, Detail: fmt.Sprintf( "pid %d, rate %.1f/s, avg %s", proc.PID, @@ -231,10 +235,14 @@ func buildProcessesTreemapItems(snap *statsengine.Snapshot, metric bubbleMetric) } func treemapValue(item syscallTreemapItem, metric bubbleMetric) uint64 { - if metric == bubbleMetricBytes { + switch metric { + case bubbleMetricBytes: return item.Bytes + case bubbleMetricDuration: + return item.Duration + default: + return item.Count } - return item.Count } func layoutSyscallTreemap(items []syscallTreemapItem, x, y, w, h int) []syscallTreemapTile { @@ -421,13 +429,14 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe } selected = clampOffset(selected, len(items)) item := items[selected] - metricValue := item.Count - if metric == bubbleMetricBytes { - metricValue = item.Bytes - } - metricText := fmt.Sprintf("%d", metricValue) - if metric == bubbleMetricBytes { - metricText = formatBytes(float64(metricValue)) + var metricText string + switch metric { + case bubbleMetricBytes: + metricText = formatBytes(float64(item.Bytes)) + case bubbleMetricDuration: + metricText = formatDurationUintNs(item.Duration) + default: + metricText = fmt.Sprintf("%d", item.Count) } status := fmt.Sprintf( "sel:%d/%d %s | %s=%s | bytes=%s", @@ -445,10 +454,14 @@ func treemapStatusLine(items []syscallTreemapItem, selected int, metric bubbleMe } func treemapMetricLabel(metric bubbleMetric) string { - if metric == bubbleMetricBytes { + switch metric { + case bubbleMetricBytes: return "bytes" + case bubbleMetricDuration: + return "duration" + default: + return "events" } - return "events" } func treemapPalette(isDark bool) []color.Color { diff --git a/internal/tui/flamegraph/controls.go b/internal/tui/flamegraph/controls.go index 2033416..bd588b3 100644 --- a/internal/tui/flamegraph/controls.go +++ b/internal/tui/flamegraph/controls.go @@ -57,8 +57,16 @@ func (m *Model) cycleFieldOrder() { } func (m *Model) toggleCountField() { - next := "bytes" - if m.countField == "bytes" { + // 3-way cycle: count → bytes → duration → count. + // durationToPrev (inter-syscall gap) is reachable via the CLI flag but + // kept out of the toolbar cycle for now. + var next string + switch m.countField { + case "count": + next = "bytes" + case "bytes": + next = "duration" + default: next = "count" } if m.liveTrie != nil { @@ -168,6 +176,8 @@ func (m Model) countFieldLabel() string { return "events" case "bytes": return "bytes" + case "duration": + return "duration" default: return m.countField } diff --git a/internal/tui/flamegraph/model_test.go b/internal/tui/flamegraph/model_test.go index c2626cd..e864e88 100644 --- a/internal/tui/flamegraph/model_test.go +++ b/internal/tui/flamegraph/model_test.go @@ -987,12 +987,20 @@ func TestControlMetricToggleReconfiguresLiveTrieCountField(t *testing.T) { } m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) - if got, want := m.countField, "count"; got != want { + if got, want := m.countField, "duration"; got != want { t.Fatalf("expected model count field %q after second toggle, got %q", want, got) } - if got, want := liveTrie.CountField(), "count"; got != want { + if got, want := liveTrie.CountField(), "duration"; got != want { t.Fatalf("expected live trie count field %q after second toggle, got %q", want, got) } + + m = pressFlameKey(t, m, tea.KeyPressMsg{Code: []rune{'b'}[0], Text: "b"}) + if got, want := m.countField, "count"; got != want { + t.Fatalf("expected model count field %q after third toggle, got %q", want, got) + } + if got, want := liveTrie.CountField(), "count"; got != want { + t.Fatalf("expected live trie count field %q after third toggle, got %q", want, got) + } } func TestNewModelAlignsPresetIndexToLiveTrieFields(t *testing.T) { -- cgit v1.2.3