Merge chart-fill
CI / test (push) Successful in 4m9s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-04 08:55:19 +02:00
commit c2238abf4e
3 files changed
+109 -20

No files matched your search

+23
View File
@@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"io"
"math"
"net/http"
"net/http/httptest"
"os"
@@ -591,3 +592,25 @@ func TestANodesPageHasItsCharts(t *testing.T) {
t.Errorf("%s", v)
}
}
func TestAWindowsSamplesFillTheChartsWidth(t *testing.T) {
// Five minutes at five-second steps is sixty samples; the chart is a
// hundred and twenty dots across. Every column is drawn.
to := time.Now()
s := chartSeries{from: to.Add(-5 * time.Minute), to: to}
for i := 0; i < 60; i++ {
s.at = append(s.at, to.Add(-5*time.Minute+time.Duration(i)*5*time.Second))
s.values = append(s.values, 50)
}
for c, v := range s.fit(120) {
if math.IsNaN(v) {
t.Fatalf("column %d of 120 empty", c)
}
}
// A series that began two minutes ago fills the last two fifths only.
s.at, s.values = s.at[36:], s.values[36:]
got := s.fit(100)
if !math.IsNaN(got[50]) || math.IsNaN(got[70]) || math.IsNaN(got[99]) {
t.Errorf("a late start: %v", got)
}
}
+61 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"math"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
)
@@ -42,6 +43,54 @@ var lineColors = []lipgloss.Color{"#61afef", "#e5c07b", "#c678dd", "#56b6c2", "#
type chartSeries struct {
name string
values []float64
// at is when each value was sampled, and from and to the window drawn:
// with them points are placed by time, across the whole width. Without,
// the values are spread evenly over it.
at []time.Time
from, to time.Time
}
// fit lays a series out in n columns.
func (s chartSeries) fit(n int) []float64 {
if len(s.at) == len(s.values) && len(s.at) > 0 && s.to.After(s.from) {
return byTime(s.values, s.at, s.from, s.to, n)
}
return resample(s.values, n)
}
// byTime puts each sample in the column of its time; a column with none
// carries the last value on, unless the last is further back than a gap in
// the samples, which is drawn as one. Before the first sample is nothing.
func byTime(vals []float64, at []time.Time, from, to time.Time, n int) []float64 {
out := make([]float64, n)
width := to.Sub(from) / time.Duration(n)
// The spacing the samples were taken at; twice it is a gap.
gap := width
if len(at) > 1 {
gap = max(gap, 2*at[len(at)-1].Sub(at[0])/time.Duration(len(at)-1))
}
j := 0
last, lastAt := math.NaN(), time.Time{}
for c := range out {
end := from.Add(time.Duration(c+1) * width)
sum, k := 0.0, 0
for ; j < len(vals) && !at[j].After(end); j++ {
if !math.IsNaN(vals[j]) {
sum += vals[j]
k++
last, lastAt = vals[j], at[j]
}
}
switch {
case k > 0:
out[c] = sum / float64(k)
case !lastAt.IsZero() && end.Sub(lastAt) <= gap:
out[c] = last
default:
out[c] = math.NaN()
}
}
return out
}
// brailleChart draws series into width×height cells. top is the value at the
@@ -81,7 +130,7 @@ func brailleChart(series []chartSeries, width, height int, top float64) []string
}
area := len(series) == 1
for si, s := range series {
vals := resample(s.values, cols)
vals := s.fit(cols)
prev := -1
for x, v := range vals {
if math.IsNaN(v) {
@@ -137,7 +186,11 @@ func resample(vals []float64, n int) []float64 {
return out
}
if len(vals) <= n {
copy(out[n-len(vals):], vals)
// Fewer values than columns: each held across its share of them,
// so the series spans the chart rather than huddling at its right.
for i := range out {
out[i] = vals[i*len(vals)/n]
}
return out
}
per := float64(len(vals)) / float64(n)
@@ -161,7 +214,12 @@ var sparkRunes = []rune("▁▂▃▄▅▆▇█")
// sparkline is values in one row of block characters, coloured by height.
func sparkline(vals []float64, width int, top float64) string {
vals = resample(vals, width)
return sparkSeries(chartSeries{values: vals}, width, top)
}
// sparkSeries is sparkline for a series that knows its times.
func sparkSeries(s chartSeries, width int, top float64) string {
vals := s.fit(width)
if top <= 0 {
for _, v := range vals {
if !math.IsNaN(v) && v > top {
+25 -17
View File
@@ -201,11 +201,27 @@ func (m consoleModel) series(id string) []chartSeries {
if !m.mon.res.Configured {
src = m.mon.localSeries(id)
}
return toChart(src, m.mon.res.Configured, m.mon.at, monRanges[m.mon.rng].window)
}
// toChart turns what Prometheus gave into chart series placed by time over
// the window that ended when it was read; sampled here, they are spread
// over what was seen.
func toChart(src []promq.Series, timed bool, readAt time.Time, window time.Duration) []chartSeries {
out := make([]chartSeries, 0, len(src))
for _, s := range src {
cs := chartSeries{name: s.Name}
stamped := timed
for _, p := range s.Points {
cs.values = append(cs.values, p.Value)
cs.at = append(cs.at, p.At)
stamped = stamped && !p.At.IsZero()
}
if !stamped {
cs.at = nil
}
if stamped && !readAt.IsZero() {
cs.from, cs.to = readAt.Add(-window), readAt
}
out = append(out, cs)
}
@@ -332,10 +348,10 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
}
netCol := ""
if v, ok := netBy[nd.ID]; ok {
netCol = fmt.Sprintf(" net %s %9s", sparkline(v, 12, 0), bps(last(v)))
netCol = fmt.Sprintf(" net %s %9s", sparkSeries(v, 12, 0), bps(last(v.values)))
}
fmt.Fprintf(&b, " %-16s %s %4.0f%% %s %s %4.0f%% %s%s\n", truncate(nd.ID, 16),
meter(c/100, 8), c, sparkline(cpuBy[nd.ID], sw, 100), meter(mm/100, 8), mm, sparkline(memBy[nd.ID], sw, 100), netCol)
meter(c/100, 8), c, sparkSeries(cpuBy[nd.ID], sw, 100), meter(mm/100, 8), mm, sparkSeries(memBy[nd.ID], sw, 100), netCol)
}
// Each card: busy, memory, heat and power.
if gpus := m.series("gpu"); len(gpus) > 0 {
@@ -343,15 +359,15 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
b.WriteString("\n " + mdBold.Render("GPUs") + "\n")
for _, g := range gpus {
fmt.Fprintf(&b, " %-16s busy %s %4.0f%% memory %s %4.0f%% %3.0f°C %s %4.0f W\n", truncate(g.name, 16),
sparkline(g.values, sw/2, 100), last(g.values), sparkline(vram[g.name], sw/2, 100), last(vram[g.name]),
last(temp[g.name]), sparkline(temp[g.name], 8, 100), last(pow[g.name]))
sparkSeries(g, sw/2, 100), last(g.values), sparkSeries(vram[g.name], sw/2, 100), last(vram[g.name].values),
last(temp[g.name].values), sparkSeries(temp[g.name], 8, 100), last(pow[g.name].values))
}
}
// Each model's requests.
if rps := m.series("rpsby"); len(rps) > 0 {
b.WriteString("\n " + mdBold.Render("Models") + sDim.Render(" requests a second") + "\n")
for _, s := range rps {
fmt.Fprintf(&b, " %-30s %s %s\n", truncate(s.name, 30), sparkline(s.values, max(20, pw-50), 0), rate(last(s.values)))
fmt.Fprintf(&b, " %-30s %s %s\n", truncate(s.name, 30), sparkSeries(s, max(20, pw-50), 0), rate(last(s.values)))
}
}
return "Monitor", head.String() + b.String(), keys
@@ -364,10 +380,10 @@ func named(s []chartSeries, name string) []chartSeries {
return s
}
func byName(s []chartSeries) map[string][]float64 {
out := map[string][]float64{}
func byName(s []chartSeries) map[string]chartSeries {
out := map[string]chartSeries{}
for _, x := range s {
out[x.name] = x.values
out[x.name] = x
}
return out
}
@@ -469,15 +485,7 @@ func (m consoleModel) detailCharts(pw int) string {
return head + " " + sBad.Render(m.det.err.Error())
}
get := func(qid string) []chartSeries {
var out []chartSeries
for _, s := range m.det.res.Series[qid] {
cs := chartSeries{name: s.Name}
for _, p := range s.Points {
cs.values = append(cs.values, p.Value)
}
out = append(out, cs)
}
return out
return toChart(m.det.res.Series[qid], true, m.det.at, r.window)
}
if !m.det.res.Configured {
// Without Prometheus, a node's own samples from this console.