Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
c2238abf4e
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