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 { // 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 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 d.HasPeople = len(users) > 0 } models, err := usage.UsageByModel(ctx, since) if err != nil { log.Warn("could not read per-model usage", "err", err) return } d.Models2 = models } // 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 }