summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/dashboard/bubbles.go114
-rw-r--r--internal/tui/dashboard/files.go14
-rw-r--r--internal/tui/dashboard/files_test.go6
-rw-r--r--internal/tui/dashboard/icicle.go20
-rw-r--r--internal/tui/dashboard/model.go17
-rw-r--r--internal/tui/dashboard/treemap.go71
-rw-r--r--internal/tui/flamegraph/controls.go14
-rw-r--r--internal/tui/flamegraph/model_test.go12
8 files changed, 173 insertions, 95 deletions
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) {