Files
llmgw/internal/web/dashboard.go
T
Ilker ManapandClaude Opus 5 3b67b1fe42 Stop the dashboard linking to pages that are not where it is
Clicking "metrics" on the web dashboard answered "no api key was provided".

The page is served twice and its navigation assumed one of them. Metrics,
pprof and the management API exist on the admin listener; account
administration, the profile and the chat exist on the client-facing port. The
nav offered the first set in both places, so on the public port those links
fell through to the API-key middleware — which is a far more confusing way to
say "that is not here" than a 404 would have been.

Each page now offers what is actually reachable from it, and the public one
says where the rest lives rather than pretending it does not exist, including
why that port is not exposed: pprof can dump the process heap, and the heap
holds every API key in plaintext.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:57:58 +02:00

171 lines
5.5 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
// 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
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,
onAdminListener bool, 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,
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
}