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
5 changed files with 66 additions and 35 deletions
Showing only changes of commit 4af800a359 - Show all commits
-1
View File
@@ -3,7 +3,6 @@ module gitea.lerkolabs.com/lerkolabs/uptop
go 1.26.4
require (
github.com/NimbleMarkets/ntcharts v0.5.1
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/harmonica v0.2.0
-2
View File
@@ -1,7 +1,5 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/NimbleMarkets/ntcharts v0.5.1 h1:HWtekubEXfESwi24pyFynwGo2Hulbb9fPh7INMUc1dg=
github.com/NimbleMarkets/ntcharts v0.5.1/go.mod h1:zVeRqYkh2n59YPe1bflaSL4O2aD2ZemNmrbdEqZ70hk=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+63 -29
View File
@@ -4,19 +4,17 @@ import (
"fmt"
"strings"
"time"
"github.com/NimbleMarkets/ntcharts/sparkline"
"github.com/charmbracelet/lipgloss"
)
func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, height int) string {
if len(latencies) == 0 || width < 20 || height < 2 {
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] {
@@ -29,13 +27,8 @@ func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, h
maxMs = ms
}
sumMs += ms
}
upCount := 0
for _, s := range statuses {
if s {
upCount++
}
}
var avgMs int64
if upCount > 0 {
avgMs = sumMs / int64(upCount)
@@ -57,34 +50,75 @@ func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, h
chartW = 10
}
style := lipgloss.NewStyle().Foreground(m.theme.Accent)
sl := sparkline.New(chartW, height, sparkline.WithStyle(style))
vals := make([]float64, len(latencies))
for i, l := range latencies {
ms := float64(l.Milliseconds())
if i < len(statuses) && !statuses[i] {
ms = 0
samples := latencies
sampledStatuses := statuses
if len(samples) > chartW {
samples = samples[len(samples)-chartW:]
if len(sampledStatuses) > chartW {
sampledStatuses = sampledStatuses[len(sampledStatuses)-chartW:]
}
vals[i] = ms
}
sl.PushAll(vals)
sl.Draw()
chartLines := strings.Split(sl.View(), "\n")
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
}
var result []string
labelStyle := m.st.subtleStyle
for i, line := range chartLines {
var rows []string
for row := height - 1; row >= 0; row-- {
var label string
if i == 0 {
switch row {
case height - 1:
label = fmt.Sprintf("%*s", labelW, maxLabel)
} else if i == len(chartLines)-1 {
case 0:
label = fmt.Sprintf("%*s", labelW, minLabel)
} else {
default:
label = strings.Repeat(" ", labelW)
}
result = append(result, labelStyle.Render(label)+line)
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",
@@ -92,7 +126,7 @@ func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, h
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
result = append(result, stats)
rows = append(rows, stats)
return strings.Join(result, "\n")
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 = 10
const detailInlineHeight = 11
func (m *Model) recalcLayout() {
chrome := chromeBase
+1 -1
View File
@@ -71,7 +71,7 @@ func (m Model) viewDetailInline(width int) string {
if chartW < 20 {
chartW = 20
}
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 2)
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
if chart != "" {
b.WriteString(chart + "\n")
}