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>
216 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|