feat(tui): multi-row color-coded latency chart in detail panel #147

Merged
lerko merged 4 commits from feat/ntcharts-latency into main 2026-06-21 21:51:59 +00:00
3 changed files with 139 additions and 26 deletions
+132
View File
@@ -0,0 +1,132 @@
package tui
import (
"fmt"
"strings"
"time"
)
func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, height int) string {
if len(latencies) == 0 || width < 20 || height < 1 {
return ""
}
var minMs, maxMs, sumMs int64
minMs = latencies[0].Milliseconds()
maxMs = minMs
upCount := 0
for i, l := range latencies {
ms := l.Milliseconds()
if i < len(statuses) && !statuses[i] {
continue
}
if ms < minMs {
minMs = ms
}
if ms > maxMs {
maxMs = ms
}
sumMs += ms
upCount++
}
var avgMs int64
if upCount > 0 {
avgMs = sumMs / int64(upCount)
}
maxLabel := fmt.Sprintf("%dms", maxMs)
minLabel := fmt.Sprintf("%dms", minMs)
labelW := len(maxLabel)
if len(minLabel) > labelW {
labelW = len(minLabel)
}
labelW += 1
chartW := width - labelW
if chartW > len(latencies) {
chartW = len(latencies)
}
if chartW < 10 {
chartW = 10
}
samples := latencies
sampledStatuses := statuses
if len(samples) > chartW {
samples = samples[len(samples)-chartW:]
if len(sampledStatuses) > chartW {
sampledStatuses = sampledStatuses[len(sampledStatuses)-chartW:]
}
}
spread := maxMs - minMs
if spread == 0 {
spread = 1
}
totalLevels := height * 8
levels := make([]int, len(samples))
for i, l := range samples {
ms := l.Milliseconds()
if i < len(sampledStatuses) && !sampledStatuses[i] {
levels[i] = 0
continue
}
lvl := int(float64(ms-minMs) / float64(spread) * float64(totalLevels-1))
if lvl < 1 && ms > 0 {
lvl = 1
}
if lvl >= totalLevels {
lvl = totalLevels - 1
}
levels[i] = lvl
}
labelStyle := m.st.subtleStyle
var rows []string
for row := height - 1; row >= 0; row-- {
var label string
switch row {
case height - 1:
label = fmt.Sprintf("%*s", labelW, maxLabel)
case 0:
label = fmt.Sprintf("%*s", labelW, minLabel)
default:
label = strings.Repeat(" ", labelW)
}
var sb strings.Builder
sb.WriteString(labelStyle.Render(label))
rowBase := row * 8
for i := range samples {
fill := levels[i] - rowBase
if fill <= 0 {
sb.WriteString(" ")
continue
}
if fill > 8 {
fill = 8
}
ch := string(sparkChars[fill-1])
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
if isDown {
sb.WriteString(m.st.dangerStyle.Render(ch))
} else {
sb.WriteString(m.latencyStyle(samples[i].Milliseconds(), nil).Render(ch))
}
}
rows = append(rows, sb.String())
}
stats := fmt.Sprintf("%*s Min %s Avg %s Max %s",
labelW, "",
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
rows = append(rows, stats)
return strings.Join(rows, "\n")
}
+1 -1
View File
@@ -142,7 +142,7 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
const detailInlineHeight = 8
const detailInlineHeight = 11
func (m *Model) recalcLayout() {
chrome := chromeBase
+6 -25
View File
@@ -67,33 +67,14 @@ func (m Model) viewDetailInline(width int) string {
}
if len(hist.Latencies) > 0 {
sparkW := width - 30
if sparkW < 10 {
sparkW = 10
chartW := width - 4
if chartW < 20 {
chartW = 20
}
if sparkW > detailSparkWidth {
sparkW = detailSparkWidth
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}
spark := m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, m.theme.Bg)
minMs := hist.Latencies[0].Milliseconds()
maxMs := hist.Latencies[0].Milliseconds()
var sumMs int64
for _, l := range hist.Latencies {
ms := l.Milliseconds()
if ms < minMs {
minMs = ms
}
if ms > maxMs {
maxMs = ms
}
sumMs += ms
}
avgMs := sumMs / int64(len(hist.Latencies))
stats := fmt.Sprintf("Min %s Avg %s Max %s",
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
b.WriteString(" " + spark + " " + stats + "\n")
}
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")