The ranked bars said "8825 ms" beside a table that said "8.8 s". One formatter, humanLatency, now serves both, with a test. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
349 lines
11 KiB
Go
349 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 {
|
|
// CPU is the fleet's capacity when it has no GPUs, which is shown instead
|
|
// of a GPU table that would only say there are none.
|
|
CPU cpuCapacity
|
|
// Menu is the shared top menu; Nav below is what the cluster page adds.
|
|
Menu Nav
|
|
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{
|
|
Menu: navFrom(r, "cluster"),
|
|
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.CPU = cpuCapacityOf(d.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)
|
|
}
|
|
// Only ever shown to an administrator, whatever wrapped it.
|
|
d.Menu.Admin = true
|
|
d.Menu.Mirror = d.Menu.Mirror || nav.Mirror
|
|
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: humanLatency(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
|
|
}
|
|
|
|
// cpuCapacity is what a fleet without GPUs has to offer.
|
|
type cpuCapacity struct {
|
|
Nodes, Ready int
|
|
MemoryGB int
|
|
DiskFreeGB, DiskTotal int
|
|
}
|
|
|
|
func cpuCapacityOf(nodes []adminapi.NodeView) cpuCapacity {
|
|
var c cpuCapacity
|
|
for _, n := range nodes {
|
|
c.Nodes++
|
|
if n.State == "ready" {
|
|
c.Ready++
|
|
}
|
|
c.MemoryGB += n.MemoryGB
|
|
c.DiskFreeGB += n.DiskFreeGB
|
|
c.DiskTotal += n.DiskTotalGB
|
|
}
|
|
return c
|
|
}
|