CI / test (push) Successful in 1m2s
Pasting the gateway's address into a browser returned 404, which looks like a broken endpoint rather than one that tells you how to use it. Two pages, and the split between them is a security boundary rather than a layout choice. The landing page is served without a key — it has to be, because the person reading it is the one who does not have one yet — so it contains no data at all: it says how to connect, not what is here. Model names stay behind /v1/models. It reconstructs the address from the forwarded headers so the copy-pasteable example names the address the client actually used rather than the internal one; those headers are reflected into displayed text and never used for a decision. The dashboard shows nodes, models, replicas, placement failures and usage, and lives on the admin listener only. That listener is loopback-bound and also serves pprof, which can dump the process heap — where API keys sit in plaintext — so reaching the page from elsewhere is an SSH tunnel's job. It is read-only: anything that mints a credential stays in `llmgw keys`, where it needs a shell rather than a browser session this project has no way to authenticate. Mounting the landing page uncovered a hole in the exemption list. An entry ending in "/" was a prefix rule, and "/" ends in "/", so exempting the landing page would have exempted every path there is — authentication still installed, still logging, and letting the whole API through. "/" is now exact, with a test enumerating what must and must not be exempt. Server-rendered templates and one stylesheet, embedded. The single-binary goal rules out a JavaScript toolchain and nothing on either page needs one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
117 lines
3.2 KiB
Go
117 lines
3.2 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
render(w, "dashboard", d)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|