Place chart points by their time, so a window's samples fill the chart's width
Five minutes at five-second steps is sixty samples on a chart a hundred and thirty dots wide, and resampling by count drew them on its right half. Points are now put in the column of their time, held across the columns between samples, a series that began late starting where it began. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
e91f68a3bf
commit
0f70bdc99d
3 files changed
+109
-20
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user