summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-02-24 08:55:40 +0200
committerPaul Buetow <paul@buetow.org>2026-02-24 08:55:40 +0200
commitf33961304827227fa1e742d12b73dae429ca2712 (patch)
tree6711c8b4366d7e2561b434a732710ea153ea837b
parent13a81c7fdbaab8ef650a7487270abb6776f0b4cd (diff)
tui/dashboard: add latency and gap histogram tabs
-rw-r--r--internal/tui/dashboard/histogram.go108
-rw-r--r--internal/tui/dashboard/histogram_test.go76
-rw-r--r--internal/tui/dashboard/model.go4
3 files changed, 186 insertions, 2 deletions
diff --git a/internal/tui/dashboard/histogram.go b/internal/tui/dashboard/histogram.go
new file mode 100644
index 0000000..a95159a
--- /dev/null
+++ b/internal/tui/dashboard/histogram.go
@@ -0,0 +1,108 @@
+package dashboard
+
+import (
+ "fmt"
+ "ior/internal/statsengine"
+ "ior/internal/tui"
+ "math"
+ "strconv"
+ "strings"
+)
+
+func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string {
+ if snap == nil {
+ return tui.PanelStyle.Render("Latency: waiting for stats...")
+ }
+
+ hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height)
+ spark := tui.PanelStyle.Render("Latency sparkline: " + renderSparkline(snap.LatencySeriesNs(), sparklineWidth(width)))
+ return strings.Join([]string{hist, spark}, "\n")
+}
+
+func renderGapsTab(snap *statsengine.Snapshot, width, height int) string {
+ if snap == nil {
+ return tui.PanelStyle.Render("Gaps: waiting for stats...")
+ }
+
+ hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height)
+ spark := tui.PanelStyle.Render("Gap sparkline: " + renderSparkline(snap.GapSeriesNs(), sparklineWidth(width)))
+ return strings.Join([]string{hist, spark}, "\n")
+}
+
+func renderHistogram(hist statsengine.HistogramSnapshot, title string, width, height int) string {
+ buckets := hist.Buckets()
+ if len(buckets) == 0 {
+ return tui.PanelStyle.Render(title + ": no data")
+ }
+
+ if width <= 0 {
+ width = 80
+ }
+
+ 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
+ }
+ }
+
+ barWidth := width - labelWidth - countWidth - 10
+ if barWidth < 8 {
+ barWidth = 8
+ }
+
+ lines := make([]string, 0, len(buckets)+2)
+ lines = append(lines, fmt.Sprintf("%s (total=%d)", title, hist.Total))
+ for _, bucket := range buckets {
+ bar := renderHistogramBar(bucket.Count, maxCount, barWidth)
+ lines = append(lines, fmt.Sprintf("%-*s | %-*s %*d", labelWidth, bucket.Label, barWidth, bar, countWidth, bucket.Count))
+ }
+ lines = append(lines, "Scale: █▓▒░")
+
+ return tui.PanelStyle.Render(strings.Join(lines, "\n"))
+}
+
+func renderHistogramBar(count, maxCount uint64, width int) string {
+ if count == 0 || maxCount == 0 || width <= 0 {
+ return ""
+ }
+
+ ratio := float64(count) / float64(maxCount)
+ length := int(math.Round(ratio * float64(width)))
+ if length < 1 {
+ length = 1
+ }
+ if length > width {
+ length = width
+ }
+
+ char := '░'
+ switch {
+ case ratio >= 0.75:
+ char = '█'
+ case ratio >= 0.5:
+ char = '▓'
+ case ratio >= 0.25:
+ char = '▒'
+ }
+
+ return strings.Repeat(string(char), length)
+}
diff --git a/internal/tui/dashboard/histogram_test.go b/internal/tui/dashboard/histogram_test.go
new file mode 100644
index 0000000..9da1c47
--- /dev/null
+++ b/internal/tui/dashboard/histogram_test.go
@@ -0,0 +1,76 @@
+package dashboard
+
+import (
+ "strings"
+ "testing"
+
+ "ior/internal/statsengine"
+)
+
+func TestRenderHistogramNoBuckets(t *testing.T) {
+ out := renderHistogram(statsengine.HistogramSnapshot{}, "Latency Histogram", 80, 20)
+ if !strings.Contains(out, "no data") {
+ t.Fatalf("expected no data placeholder, got %q", out)
+ }
+}
+
+func TestRenderHistogramIncludesLabelsCountsAndScale(t *testing.T) {
+ hist := statsengine.NewHistogramSnapshot(13, []statsengine.HistogramBucketSnapshot{
+ {Label: "[0,1us)", Count: 1},
+ {Label: "[1us,10us)", Count: 3},
+ {Label: "[10us,100us)", Count: 9},
+ })
+
+ out := renderHistogram(hist, "Latency Histogram", 100, 20)
+ for _, token := range []string{"Latency Histogram", "[0,1us)", "[1us,10us)", "[10us,100us)", "9", "Scale: █▓▒░"} {
+ if !strings.Contains(out, token) {
+ t.Fatalf("expected token %q in histogram output", token)
+ }
+ }
+ if !strings.Contains(out, "█") {
+ t.Fatalf("expected high-intensity bar rune in histogram output")
+ }
+}
+
+func TestRenderLatencyAndGapsTabIncludeSparkline(t *testing.T) {
+ snap := statsengine.NewSnapshot(
+ []float64{10, 20, 15, 30},
+ []float64{2, 4, 3, 5},
+ nil,
+ nil,
+ nil,
+ nil,
+ statsengine.NewHistogramSnapshot(2, []statsengine.HistogramBucketSnapshot{
+ {Label: "[0,1us)", Count: 2},
+ }),
+ statsengine.NewHistogramSnapshot(1, []statsengine.HistogramBucketSnapshot{
+ {Label: "[1us,10us)", Count: 1},
+ }),
+ )
+
+ lat := renderLatencyTab(&snap, 100, 24)
+ if !strings.Contains(lat, "Latency Histogram") || !strings.Contains(lat, "Latency sparkline:") {
+ t.Fatalf("latency tab missing expected sections: %q", lat)
+ }
+
+ gap := renderGapsTab(&snap, 100, 24)
+ if !strings.Contains(gap, "Gap Histogram") || !strings.Contains(gap, "Gap sparkline:") {
+ t.Fatalf("gaps tab missing expected sections: %q", gap)
+ }
+}
+
+func TestRenderHistogramTruncatesForSmallHeight(t *testing.T) {
+ hist := statsengine.NewHistogramSnapshot(3, []statsengine.HistogramBucketSnapshot{
+ {Label: "[0,1us)", Count: 1},
+ {Label: "[1us,10us)", Count: 1},
+ {Label: "[10us,100us)", Count: 1},
+ })
+
+ out := renderHistogram(hist, "Latency Histogram", 100, 3)
+ if !strings.Contains(out, "[0,1us)") {
+ t.Fatalf("expected first bucket in output: %q", out)
+ }
+ if strings.Contains(out, "[1us,10us)") || strings.Contains(out, "[10us,100us)") {
+ t.Fatalf("expected histogram rows to be truncated for small height: %q", out)
+ }
+}
diff --git a/internal/tui/dashboard/model.go b/internal/tui/dashboard/model.go
index 44a6da7..454e8ab 100644
--- a/internal/tui/dashboard/model.go
+++ b/internal/tui/dashboard/model.go
@@ -169,9 +169,9 @@ func renderActiveTab(tab Tab, snap *statsengine.Snapshot, width, height, syscall
case TabProcesses:
return renderProcessesWithOffset(snap, width, height, processesOffset)
case TabLatency:
- return tui.PanelStyle.Render("Latency histogram")
+ return renderLatencyTab(snap, width, height)
case TabGaps:
- return tui.PanelStyle.Render("Gap histogram")
+ return renderGapsTab(snap, width, height)
default:
return tui.PanelStyle.Render("Unknown tab")
}