package dashboard import ( "fmt" "math" "strconv" "strings" "ior/internal/statsengine" common "ior/internal/tui/common" ) func renderLatencyTab(snap *statsengine.Snapshot, width, height int) string { if snap == nil { return common.PanelStyle.Render("Latency: waiting for stats...") } panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.LatencyHistogram, "Latency Histogram", width, height) spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Latency sparkline:", snap.LatencySeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") } func renderGapsTab(snap *statsengine.Snapshot, width, height int) string { if snap == nil { return common.PanelStyle.Render("Gaps: waiting for stats...") } panelW := panelWidth(width) panelInner := panelInnerWidth(width) hist := renderHistogram(snap.GapHistogram, "Gap Histogram", width, height) spark := common.PanelStyle.Width(panelW).Render( renderOverviewSparkline("Gap sparkline:", snap.GapSeriesNs(), panelInner), ) return strings.Join([]string{hist, spark}, "\n") } func renderLatencyGapsTab(snap *statsengine.Snapshot, width, height int) string { if snap == nil { return common.PanelStyle.Render("Latency+Gaps: waiting for stats...") } lat := renderLatencyTab(snap, width, height) gap := renderGapsTab(snap, width, height) 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) buckets = clampHistogramBuckets(buckets, height) maxCount, labelWidth, countWidth := histogramMetrics(hist, buckets) barWidth := panelInner - labelWidth - countWidth - 6 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 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 "" } 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) }