CI / test (push) Successful in 1m19s
Two things the dashboard was missing: it could not act, and it was a column of numbers. Account administration moves to /admin/users — approving into a project, suspending, reinstating. That is the work an operator does constantly and it should not need a terminal. Three things stay on the CLI, and the line is not arbitrary: promoting someone to admin, resetting a password, and issuing a key on another person's behalf each turn a stolen admin session into more than it was — permanent access, or somebody else's account. Approving and suspending do not. Suspending signs the account out everywhere, or suspension is advisory until the session happens to expire, and an admin cannot suspend themselves, which is not a thing an interface should let you do by misclicking. The charts are inline SVG generated in Go. A charting library would mean a JavaScript toolchain in CI and a megabyte of bundle for what is a list of rectangles, and the single-binary goal rules that out. Hourly over a day: fine enough to show a working day, coarse enough that one slow minute is not an incident. Empty hours are filled in, because a chart with hours missing where nothing happened reads as a chart with hours missing from the data. The SVG is marked as trusted HTML, so everything that reaches it is either a number this package computed or escaped explicitly, and a bar's colour can only be one of four names — a colour that could be anything is a way to write CSS into every page the chart appears on. There are tests for both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
161 lines
4.9 KiB
Go
161 lines
4.9 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
|
|
// 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
|
|
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
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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.
|
|
func Dashboard(src Source, usage UsageSource, version string, refresh time.Duration,
|
|
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{
|
|
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)
|
|
}
|
|
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)
|
|
}
|
|
render(w, "dashboard", d)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|