From 4af800a3596ec48c17b43d02f90e38ba87a731e2 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 21 Jun 2026 17:43:50 -0400 Subject: [PATCH] feat(tui): multi-row color-coded sparkline chart with Y-axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace ntcharts with custom multi-row sparkline. Each bar is color- coded by latency threshold (green < 200ms, yellow < 500ms, red > 500ms) and DOWN checks render in red. 3 rows tall with 24 discrete levels. Y-axis labels (max/min ms) on the left, Min/Avg/Max stats below. Zero external dependencies — removed ntcharts. --- go.mod | 1 - go.sum | 2 - internal/tui/chart.go | 94 ++++++++++++++++++++---------- internal/tui/update.go | 2 +- internal/tui/view_detail_inline.go | 2 +- 5 files changed, 66 insertions(+), 35 deletions(-) diff --git a/go.mod b/go.mod index 3824c06..5f8b4a9 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f00d21c..5c69223 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/tui/chart.go b/internal/tui/chart.go index 9e56852..4ba4f10 100644 --- a/internal/tui/chart.go +++ b/internal/tui/chart.go @@ -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,12 +27,7 @@ func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, h maxMs = ms } sumMs += ms - } - upCount := 0 - for _, s := range statuses { - if s { - upCount++ - } + upCount++ } var avgMs int64 if upCount > 0 { @@ -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") } diff --git a/internal/tui/update.go b/internal/tui/update.go index c028bff..0e7c1b0 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -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 diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 77dc15f..4c39db6 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -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") }