diff options
| -rw-r--r-- | internal/tui/dashboard/histogram.go | 108 | ||||
| -rw-r--r-- | internal/tui/dashboard/histogram_test.go | 76 | ||||
| -rw-r--r-- | internal/tui/dashboard/model.go | 4 |
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") } |
