From ad469c86eba063fdf3e43acd81800e81a8f1da23 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 21 Jun 2026 13:05:02 -0400 Subject: [PATCH 1/4] feat(tui): ntcharts latency line chart in inline detail panel Replace block-element sparkline with ntcharts streamline chart in the inline detail panel. Renders a 4-row line chart with thin line style using the theme accent color. Auto-scales Y axis to latency range. Added github.com/NimbleMarkets/ntcharts v0.5.1 dependency (lipgloss v1 compatible). Min/Avg/Max stats rendered below the chart. --- go.mod | 1 + go.sum | 2 ++ internal/tui/chart.go | 31 ++++++++++++++++++++++++++++++ internal/tui/update.go | 2 +- internal/tui/view_detail_inline.go | 18 +++++++++-------- 5 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 internal/tui/chart.go diff --git a/go.mod b/go.mod index 5f8b4a9..3824c06 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ 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 5c69223..f00d21c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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 new file mode 100644 index 0000000..4a9594d --- /dev/null +++ b/internal/tui/chart.go @@ -0,0 +1,31 @@ +package tui + +import ( + "time" + + "github.com/NimbleMarkets/ntcharts/canvas/runes" + "github.com/NimbleMarkets/ntcharts/linechart/streamlinechart" + "github.com/charmbracelet/lipgloss" +) + +func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, height int) string { + if len(latencies) == 0 || width < 10 || height < 3 { + return "" + } + + lineStyle := lipgloss.NewStyle().Foreground(m.theme.Accent) + slc := streamlinechart.New(width, height, + streamlinechart.WithStyles(runes.ThinLineStyle, lineStyle), + ) + + for i, l := range latencies { + ms := float64(l.Milliseconds()) + if i < len(statuses) && !statuses[i] { + ms = 0 + } + slc.Push(ms) + } + slc.Draw() + + return slc.View() +} diff --git a/internal/tui/update.go b/internal/tui/update.go index 1004871..543f935 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 = 8 +const detailInlineHeight = 12 func (m *Model) recalcLayout() { chrome := chromeBase diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index da962c5..4bf394d 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -67,14 +67,16 @@ 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 + chartH := 4 + chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, chartH) + 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 @@ -89,11 +91,11 @@ func (m Model) viewDetailInline(width int) string { sumMs += ms } avgMs := sumMs / int64(len(hist.Latencies)) - stats := fmt.Sprintf("Min %s Avg %s Max %s", + 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") + b.WriteString(stats + "\n") } keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close") -- 2.52.0 From d0805f61c6947c2138c8c4b292588e8266316fc6 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 21 Jun 2026 13:41:05 -0400 Subject: [PATCH 2/4] fix(tui): size chart width to data point count --- internal/tui/chart.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/tui/chart.go b/internal/tui/chart.go index 4a9594d..b7be10c 100644 --- a/internal/tui/chart.go +++ b/internal/tui/chart.go @@ -13,8 +13,13 @@ func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, h return "" } + chartW := len(latencies) + if chartW > width { + chartW = width + } + lineStyle := lipgloss.NewStyle().Foreground(m.theme.Accent) - slc := streamlinechart.New(width, height, + slc := streamlinechart.New(chartW, height, streamlinechart.WithStyles(runes.ThinLineStyle, lineStyle), ) -- 2.52.0 From a8c43bdb8eb157a43f7d597a651cbe1bdd128e06 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 21 Jun 2026 17:12:25 -0400 Subject: [PATCH 3/4] feat(tui): ntcharts sparkline with Y-axis labels and stats Replaced streamline chart with ntcharts sparkline (block elements, auto-scaling). Height=2, Y-axis labels (max/min ms) on the left, Min/Avg/Max stats below. Denser and more readable than the line chart. --- internal/tui/chart.go | 88 +++++++++++++++++++++++++----- internal/tui/update.go | 2 +- internal/tui/view_detail_inline.go | 23 +------- 3 files changed, 77 insertions(+), 36 deletions(-) diff --git a/internal/tui/chart.go b/internal/tui/chart.go index b7be10c..9e56852 100644 --- a/internal/tui/chart.go +++ b/internal/tui/chart.go @@ -1,36 +1,98 @@ package tui import ( + "fmt" + "strings" "time" - "github.com/NimbleMarkets/ntcharts/canvas/runes" - "github.com/NimbleMarkets/ntcharts/linechart/streamlinechart" + "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 < 10 || height < 3 { + if len(latencies) == 0 || width < 20 || height < 2 { return "" } - chartW := len(latencies) - if chartW > width { - chartW = width + var minMs, maxMs, sumMs int64 + minMs = latencies[0].Milliseconds() + maxMs = minMs + 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 := 0 + for _, s := range statuses { + if s { + upCount++ + } + } + var avgMs int64 + if upCount > 0 { + avgMs = sumMs / int64(upCount) } - lineStyle := lipgloss.NewStyle().Foreground(m.theme.Accent) - slc := streamlinechart.New(chartW, height, - streamlinechart.WithStyles(runes.ThinLineStyle, lineStyle), - ) + 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 + } + + 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 } - slc.Push(ms) + vals[i] = ms } - slc.Draw() + sl.PushAll(vals) + sl.Draw() - return slc.View() + chartLines := strings.Split(sl.View(), "\n") + + var result []string + labelStyle := m.st.subtleStyle + for i, line := range chartLines { + var label string + if i == 0 { + label = fmt.Sprintf("%*s", labelW, maxLabel) + } else if i == len(chartLines)-1 { + label = fmt.Sprintf("%*s", labelW, minLabel) + } else { + label = strings.Repeat(" ", labelW) + } + result = append(result, labelStyle.Render(label)+line) + } + + 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)) + result = append(result, stats) + + return strings.Join(result, "\n") } diff --git a/internal/tui/update.go b/internal/tui/update.go index 543f935..c028bff 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 = 12 +const detailInlineHeight = 10 func (m *Model) recalcLayout() { chrome := chromeBase diff --git a/internal/tui/view_detail_inline.go b/internal/tui/view_detail_inline.go index 4bf394d..77dc15f 100644 --- a/internal/tui/view_detail_inline.go +++ b/internal/tui/view_detail_inline.go @@ -71,31 +71,10 @@ func (m Model) viewDetailInline(width int) string { if chartW < 20 { chartW = 20 } - chartH := 4 - chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, chartH) + chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 2) if chart != "" { b.WriteString(chart + "\n") } - - 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(stats + "\n") } keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close") -- 2.52.0 From 4af800a3596ec48c17b43d02f90e38ba87a731e2 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Sun, 21 Jun 2026 17:43:50 -0400 Subject: [PATCH 4/4] 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") } -- 2.52.0