Files
llmgw/internal/web/timeseries_test.go
T
Ilker ManapandClaude Opus 5.5 9ae526302b Label chart axes in binary sizes and small percents, and windows in words
A bytes axis read "20.0BB" - compact's billion with a B after it - and
a quiet node's CPU axis read 0% all the way up. Sizes are now binary like
every other on these pages, a percent under ten keeps a decimal, and a
window of an hour is "the last hour" rather than "1h0m0s".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:30:01 +02:00

216 lines
6.7 KiB
Go

package web
import (
"context"
"errors"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
)
func ts(name string, vals ...float64) promq.Series {
s := promq.Series{Name: name}
base := time.Unix(1700000000, 0)
for i, v := range vals {
s.Points = append(s.Points, promq.Point{At: base.Add(time.Duration(i) * time.Minute), Value: v})
}
return s
}
func TestEveryLineGetsItsOwnColourAndALegend(t *testing.T) {
c := LineChart([]promq.Series{ts("a", 1, 2), ts("b", 2, 1), ts("c", 3, 3)}, "", 4)
if len(c.Legend) != 3 {
t.Fatalf("expected a legend entry per line, got %+v", c.Legend)
}
seen := map[string]bool{}
for _, e := range c.Legend {
if seen[e.Colour] {
// Two lines in one colour is two lines nobody can tell apart.
t.Errorf("colour %s used twice", e.Colour)
}
seen[e.Colour] = true
}
}
func TestATailIsFoldedRatherThanGivenNewColours(t *testing.T) {
in := []promq.Series{
ts("big", 10, 10), ts("m2", 5, 5), ts("m3", 4, 4),
ts("m4", 1, 1), ts("m5", 2, 2), ts("m6", 3, 3),
}
c := LineChart(in, "", 4)
// The palette has four colours and a fifth line does not get a generated
// hue. The tail is summed so the chart still adds up.
if len(c.Legend) != 4 {
t.Fatalf("expected four lines, got %d: %+v", len(c.Legend), c.Legend)
}
last := c.Legend[3]
if last.Name != "other" {
t.Errorf("the tail should be named, got %q", last.Name)
}
// m4+m5+m6 at the same instant is 6, not any one of them.
if last.Last != "6" {
t.Errorf("the tail should be summed, got %q", last.Last)
}
}
func TestTheTailIsSummedByTimestampNotByPosition(t *testing.T) {
// Two tail series sampled at different times. Added by index their points
// would slide sideways onto each other's clock.
a := promq.Series{Name: "a", Points: []promq.Point{{At: time.Unix(100, 0), Value: 1}}}
b := promq.Series{Name: "b", Points: []promq.Point{{At: time.Unix(200, 0), Value: 2}}}
big := ts("big", 9, 9, 9)
c := LineChart([]promq.Series{big, ts("x", 8, 8), ts("y", 7, 7), a, b}, "", 4)
if c.Legend[3].Name != "other" || c.Legend[3].Last != "2" {
t.Errorf("expected the latest tail point alone, got %+v", c.Legend[3])
}
}
func TestAFlatZeroIsDrawnRatherThanLeftBlank(t *testing.T) {
c := LineChart([]promq.Series{ts("idle", 0, 0, 0)}, "", 4)
// Nothing happening is a real answer, and a line along the bottom says so
// where an empty frame reads as a broken chart.
if c.Empty {
t.Fatal("a quiet series is not an absent one")
}
if !strings.Contains(string(c.SVG), "polyline") {
t.Error("the line should be drawn")
}
}
func TestNothingToDrawSaysSoInsteadOfDrawingAFrame(t *testing.T) {
if got := LineChart(nil, "", 4); !got.Empty {
t.Error("no series is empty")
}
// One instant is not a line: drawn, it is a dot in the corner.
one := promq.Series{Name: "a", Points: []promq.Point{{At: time.Unix(1, 0), Value: 5}}}
if got := LineChart([]promq.Series{one}, "", 4); !got.Empty {
t.Error("a single sample is not a line")
}
}
func TestTheAxisTopsOutAtANumberAPersonWouldChoose(t *testing.T) {
c := LineChart([]promq.Series{ts("a", 0.1, 0.8347)}, "", 4)
// 0.8347 as the top makes every gridline unreadable, and the shape of the
// data is what the chart is for.
if c.Peak != "1" {
t.Errorf("expected a rounded ceiling, got %q", c.Peak)
}
}
func TestPercentagesAndSecondsReadAsThemselves(t *testing.T) {
if got := formatValue(0.42, "%"); got != "42%" {
t.Errorf("percent: %q", got)
}
if got := formatValue(0.035, "s"); got != "35ms" {
t.Errorf("sub-second latency should read in ms, got %q", got)
}
if got := formatValue(12.5, "s"); got != "12.5s" {
t.Errorf("seconds: %q", got)
}
}
func TestAValueAboveTheCeilingIsClampedNotDrawnOffTheChart(t *testing.T) {
// Only reachable if a series changes between the bounds pass and drawing,
// but an SVG point outside the viewBox is a line that leaves the frame.
if got := clamp01(1.4); got != 1 {
t.Errorf("expected clamping, got %v", got)
}
if got := clamp01(-2); got != 0 {
t.Errorf("expected clamping, got %v", got)
}
}
// fakeHistory stands in for a metrics store.
type fakeHistory struct {
byExpr map[string][]promq.Series
err error
asked []string
}
func (f *fakeHistory) RangeQuery(_ context.Context, expr, _ string,
_, _ time.Duration,
) ([]promq.Series, error) {
f.asked = append(f.asked, expr)
return f.byExpr[expr], f.err
}
func TestTheFleetPanelsAreEachQueried(t *testing.T) {
h := &fakeHistory{byExpr: map[string][]promq.Series{
"llmgw_pool_saturation": {ts("qwen", 0.1, 0.4)},
"llmgw_endpoint_waiting": {ts("r1", 0, 2)},
"llmgw_endpoint_kv_cache_usage": {ts("r1", 0.1, 0.2)},
"llmgw_model_replicas_ready": {ts("qwen", 3, 3)},
}}
got := LoadFleetCharts(context.Background(), h, time.Hour, nil)
if len(h.asked) != 4 {
t.Fatalf("expected one query per panel, got %v", h.asked)
}
for name, c := range map[string]TimeSeries{
"saturation": got.Saturation, "queue": got.Queue,
"kv": got.KV, "ready": got.Ready,
} {
if c.Empty {
t.Errorf("%s came back empty", name)
}
}
}
func TestOneFailedQueryFailsThemAll(t *testing.T) {
h := &fakeHistory{err: errors.New("reaching Prometheus: refused")}
got := LoadFleetCharts(context.Background(), h, time.Hour, nil)
// Three charts drawn and one blank invites reading the blank one as
// "nothing is happening there". Either the store answered or it did not.
if got.Err == "" {
t.Fatal("the failure should be reported")
}
if !strings.Contains(got.Err, "refused") {
t.Errorf("the reason should survive: %q", got.Err)
}
}
func TestWithNoMetricsStoreThereAreNoPanels(t *testing.T) {
// A deployment with no Prometheus leaves them off rather than showing
// empty frames where charts should be.
if got := LoadFleetCharts(context.Background(), nil, time.Hour, nil); got != nil {
t.Errorf("expected nothing, got %+v", got)
}
}
func TestAUsageLegendGivesTotalsNotTheLastSlice(t *testing.T) {
// A counter's latest slice is usually quiet; "0" beside a line that
// carried hundreds read as a broken chart.
c := LineChartTotals([]promq.Series{ts("ml", 5, 7, 0), ts("vision", 1, 2, 0)}, "", 4)
got := map[string]string{}
for _, l := range c.Legend {
got[l.Name] = l.Last
}
if got["ml"] != "12" || got["vision"] != "3" {
t.Errorf("legend = %v, expected the totals", got)
}
}
func TestChartAxesReadAsSizesAndSmallPercents(t *testing.T) {
// compact's suffixes are for counts: on bytes they read as "20.0BB".
if got := formatValue(20e9, "bytes"); got != "18.6GiB" {
t.Errorf("20e9 bytes = %q", got)
}
// A quiet machine is not 0%, 0%, 0% up its axis.
if got := formatValue(0.005, "%"); got != "0.5%" {
t.Errorf("0.005 = %q", got)
}
if got := formatValue(0.62, "%"); got != "62%" {
t.Errorf("0.62 = %q", got)
}
if got := windowWord(time.Hour); got != "hour" {
t.Errorf("an hour reads %q", got)
}
}