Files
llmgw/internal/web/dashboard.go
T
Ilker ManapandClaude Opus 5 c6df0659af Put the cluster's own charts on its own admin page
The dashboard charted what the cluster had been billed for — requests, tokens,
spend, all from llmgw's own store — and nothing about whether it was healthy.
Saturation, queue depth, cache occupancy and replica counts were numbers in a
table at the instant the page loaded, with no way to see that one of them had
been climbing for an hour.

Four line charts now, read back from wherever the metrics were scraped to.

Read back rather than kept twice. llmgw publishes these already; what a gauge
cannot do is remember, and a ring buffer in this process would be a rival copy
of the same numbers with its own retention, lost on every restart and
disagreeing with the one an operator has on screen elsewhere. monitoring.prometheus
is empty by default and the panels are then absent rather than empty.

One axis per chart, always. Two measures of different scale belong in two
charts: a second y-axis lets any two lines be made to cross wherever the author
likes and the reader cannot tell it happened.

Four colours, handed out in fixed order, never cycled. A fifth series is summed
into "other" and drawn in grey — dropping it would make a chart that does not
add up, and inventing a hue for it would put two lines on the page that nobody
can tell apart. The fold is by timestamp, not by position: the tail's series do
not share sample times, and adding them by index slides points sideways onto
each other's clock.

A legend for every chart with more than one line, because colour alone is not
an encoding a reader can rely on.

A flat zero is drawn along the bottom rather than left blank: nothing happening
is a real answer, and an empty frame reads as a broken chart. A single sample
is not a line and says so instead of putting a dot in the corner.

One failed query fails all four panels. Three drawn and one blank invites
reading the blank one as "nothing is happening there".

NaN is dropped rather than plotted. Prometheus sends it as a string for a
sample it has no value for, Go's ParseFloat accepts it happily, and one NaN
coordinate is an SVG path that does not draw at all — taking the rest of the
line with it.

Axis maxima are rounded to a number a person would pick. An axis topping out at
0.8347 makes every gridline unreadable, and the shape is what a chart is for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 18:25:56 +02:00

318 lines
11 KiB
Go

package web
import (
"context"
"fmt"
"html/template"
"log/slog"
"net/http"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// DashboardData is the whole page.
type DashboardData struct {
Nodes []adminapi.NodeView
Models []adminapi.ModelView
Replicas []adminapi.ReplicaView
Unplaced []adminapi.ExplainView
Usage []store.KeyUsage
Summary adminapi.Summary
Users []store.UserUsage
Models2 []store.ModelUsage
// Tiles are the headline figures, which are numbers rather than charts.
Requests24h int64
Tokens24h int64
Errors24h int64
Spend24h int64
RequestSpark template.HTML
TokenSpark template.HTML
UserChart template.HTML
ModelChart template.HTML
HasPeople bool
// Charts are pre-rendered rather than assembled in the template: building
// SVG out of template fragments is how a chart ends up unescaped.
RequestChart template.HTML
TokenChart template.HTML
LatencyChart template.HTML
HasCharts bool
NodeCount int
ModelCount int
ReadyReplicas int
TotalReplicas int
AuthEnabled bool
// OnAdminListener decides which links the page offers.
//
// The same page is served twice, and the two have different neighbours:
// metrics, pprof and the management API exist only on the admin listener,
// while account administration and the profile exist only on the
// client-facing one. A nav that assumes one of them sends every visitor to
// the other into a 404 — or, on the public port, into "no api key was
// provided", which is a far more confusing way to say the same thing.
OnAdminListener bool
// Fleet is the operational charts, nil when no metrics store is configured.
Fleet *FleetCharts
// Nav says which optional admin pages are mounted. Asked rather than
// assumed: a link to a page that was never registered lands on whatever the
// catch-all route serves, which on the client-facing port is the landing
// page — an admin clicking "mirror" and getting the marketing copy has no
// way to tell that from a bug.
Nav NavExtras
RefreshSec int
Version string
Now string
}
// Source is what the dashboard reads. An interface so the page can be rendered
// in a test without a control plane.
type Source interface {
Nodes() []adminapi.NodeView
Models() []adminapi.ModelView
Replicas(model string) []adminapi.ReplicaView
Explain(model string) adminapi.ExplainView
// Summary is the fleet in aggregate: capacity per GPU class, free whole
// nodes per fabric, what is idle.
//
// The tables below say what exists; this says how much of it is in use,
// which is a different question and the one asked first. Deriving it in the
// page would mean two implementations of the same arithmetic drifting
// apart, so it comes from where ctl status gets it.
Summary() adminapi.Summary
}
// UsageSource is the accounting half, absent when authentication is off.
type UsageSource interface {
UsageSince(ctx context.Context, since time.Time) ([]store.KeyUsage, error)
UsageBuckets(ctx context.Context, since time.Time, width time.Duration) ([]store.Bucket, error)
// UsageByUser and UsageByModel answer the two questions a key-by-key table
// answers badly: who is using this cluster, and what are they running. One
// person holds a laptop key, a CI key and a notebook key, and adding three
// rows up by eye is a chore that gets skipped.
UsageByUser(ctx context.Context, since time.Time) ([]store.UserUsage, error)
UsageByModel(ctx context.Context, since time.Time) ([]store.ModelUsage, error)
}
// Dashboard serves the read-only cluster view.
//
// It belongs on the admin listener and nowhere else. That listener is bound to
// loopback by default and also serves pprof, which can dump the process heap —
// where API keys live in plaintext. Exposing this page publicly would mean
// exposing that, so reaching it from elsewhere is an SSH tunnel's job:
//
// ssh -L 9090:127.0.0.1:9090 llmgw-ctl
//
// Read-only on purpose. Anything that mints a credential stays in `llmgw keys`,
// where it needs a shell on the machine rather than a browser session this
// project has no way to authenticate.
// NavExtras says which optional admin pages exist.
//
// A struct rather than another bool parameter: Dashboard already takes one, and
// two adjacent booleans at a call site are swapped silently and discovered by
// somebody clicking the wrong link.
type NavExtras struct {
// Mirror is true when /admin/mirror is mounted.
Mirror bool
// History reads the cluster's past for the operational charts. Nil leaves
// them off the page rather than drawing empty frames.
History History
// Window is how far back those charts look.
Window time.Duration
}
func Dashboard(src Source, usage UsageSource, version string, refresh time.Duration,
onAdminListener bool, nav NavExtras, log *slog.Logger,
) http.HandlerFunc {
if log == nil {
log = slog.Default()
}
if refresh <= 0 {
refresh = 5 * time.Second
}
return func(w http.ResponseWriter, r *http.Request) {
d := DashboardData{
OnAdminListener: onAdminListener,
Nav: nav,
Fleet: LoadFleetCharts(r.Context(), nav.History, nav.Window, log),
RefreshSec: int(refresh.Seconds()),
Version: version,
Now: time.Now().UTC().Format(time.RFC3339),
AuthEnabled: usage != nil,
}
if src != nil {
d.Nodes = src.Nodes()
d.Models = src.Models()
d.Replicas = src.Replicas("")
d.NodeCount = len(d.Nodes)
d.ModelCount = len(d.Models)
d.TotalReplicas = len(d.Replicas)
for _, rep := range d.Replicas {
if rep.State == "ready" {
d.ReadyReplicas++
}
}
d.Unplaced = unplaced(src, d.Models)
d.Summary = src.Summary()
}
if usage != nil {
rows, err := usage.UsageSince(r.Context(), time.Now().Add(-24*time.Hour))
if err != nil {
// A reporting query failing must not take the page with it: the
// cluster state above is what someone is most likely here for.
log.Warn("could not read usage for the dashboard", "err", err)
}
d.Usage = rows
d.addCharts(r.Context(), usage, log)
d.addPeopleAndModels(r.Context(), usage, log)
}
render(w, "dashboard", d)
}
}
// addPeopleAndModels answers who is using the cluster and what they are running.
//
// Both are ranked rather than plotted over time: the question is which person
// and which model, and a reader comparing names wants them beside each other
// rather than threaded through a time axis they then have to untangle.
//
// A reporting query failing leaves the section out rather than the page: the
// cluster state above it is what someone is most likely here for.
func (d *DashboardData) addPeopleAndModels(ctx context.Context, usage UsageSource, log *slog.Logger) {
since := time.Now().Add(-24 * time.Hour)
users, err := usage.UsageByUser(ctx, since)
if err != nil {
log.Warn("could not read per-user usage", "err", err)
} else {
d.Users = users
rows := make([]Row, 0, len(users))
for _, u := range topUsers(users, 8) {
rows = append(rows, Row{
Label: userLabel(u),
Value: float64(u.Tokens()),
Note: fmt.Sprintf("%d req", u.Requests),
})
}
d.UserChart = HBarChart(rows, "--viz-1", "tok")
d.HasPeople = len(rows) > 0
}
models, err := usage.UsageByModel(ctx, since)
if err != nil {
log.Warn("could not read per-model usage", "err", err)
return
}
d.Models2 = models
rows := make([]Row, 0, len(models))
for i, m := range models {
if i >= 8 {
break
}
rows = append(rows, Row{
Label: m.Model,
Value: float64(m.Requests),
Note: fmt.Sprintf("%.0f ms", m.AvgLatency),
})
}
d.ModelChart = HBarChart(rows, "--viz-3", "req")
}
// userLabel names a person the way they would recognise themselves.
//
// A key with no owner predates accounts, and calling it "unknown" would imply
// something is wrong; it is an operator-issued key doing its job.
func userLabel(u store.UserUsage) string {
switch {
case u.Email != "":
return u.Email
case u.Name != "":
return u.Name
default:
return "operator-issued keys"
}
}
// topUsers keeps the chart readable.
//
// Eight, because a ranked bar chart stops being a comparison somewhere around
// there and becomes a table drawn slowly. The full list is in the table below
// it, which is the right form for twenty rows.
func topUsers(users []store.UserUsage, n int) []store.UserUsage {
if len(users) > n {
return users[:n]
}
return users
}
// addCharts draws the last day of traffic.
//
// Hourly, over 24 hours: fine enough to show a working day and coarse enough
// that one slow minute does not look like an incident.
func (d *DashboardData) addCharts(ctx context.Context, usage UsageSource, log *slog.Logger) {
buckets, err := usage.UsageBuckets(ctx, time.Now().Add(-24*time.Hour), time.Hour)
if err != nil {
log.Warn("could not read usage buckets for the dashboard", "err", err)
return
}
if len(buckets) == 0 {
return
}
var requests, tokens, latency Series
requests.Colour, tokens.Colour, latency.Colour = "--accent", "--ok", "--warn"
for _, b := range buckets {
requests.Values = append(requests.Values, float64(b.Requests))
requests.Labels = append(requests.Labels, hourLabel(b.Start, float64(b.Requests), "requests"))
tok := float64(b.Prompt + b.Completion)
tokens.Values = append(tokens.Values, tok)
tokens.Labels = append(tokens.Labels, hourLabel(b.Start, tok, "tokens"))
latency.Values = append(latency.Values, float64(b.LatencyMs))
latency.Labels = append(latency.Labels, hourLabel(b.Start, float64(b.LatencyMs), "ms mean"))
}
d.RequestChart = BarChart(requests, "requests/h")
d.TokenChart = BarChart(tokens, "tokens/h")
d.LatencyChart = BarChart(latency, "ms")
d.HasCharts = true
// The headline figures. Numbers rather than charts, because a single
// current value is a number — plotting one is how a chart ends up with one
// bar in it. The sparkline beside each says only whether it is rising,
// falling or flat, which is the one thing the number cannot say.
for _, b := range buckets {
d.Requests24h += b.Requests
d.Tokens24h += b.Prompt + b.Completion
d.Errors24h += b.Errors
d.Spend24h += b.CostMicros
}
d.RequestSpark = Sparkline(requests.Values, "--viz-1")
d.TokenSpark = Sparkline(tokens.Values, "--viz-3")
}
// unplaced collects the placement explanations for models short of replicas.
//
// Only for those: asking why a satisfied model was not placed produces an empty
// answer, and a page full of empty answers hides the one that matters.
func unplaced(src Source, models []adminapi.ModelView) []adminapi.ExplainView {
var out []adminapi.ExplainView
for _, m := range models {
if m.Ready >= m.Min {
continue
}
if e := src.Explain(m.Name); len(e.Rejections) > 0 {
out = append(out, e)
}
}
return out
}