Approve accounts from the browser, and draw the traffic
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>
This commit is contained in:
1 parent
e519dc587c
commit
859f08da9b
13 files changed
+687
-5
No files matched your search
+23
-3
@@ -261,9 +261,29 @@ llmgw users promote usr-k4mq… --store …
|
||||
```
|
||||
|
||||
Someone without the flag gets a 404 rather than a 403, because 403 confirms the
|
||||
page exists. The dashboard is read-only in both places: everything that changes
|
||||
the cluster or issues a credential stays on the CLI, which keeps the worst case
|
||||
of a stolen admin cookie to "saw the cluster".
|
||||
page exists.
|
||||
|
||||
## Managing accounts from the browser
|
||||
|
||||
`/admin/users` lists who is waiting and who is active. Approving (into a project)
|
||||
and suspending are there, because that is the work an operator does constantly
|
||||
and it should not need a terminal.
|
||||
|
||||
Three things are deliberately not there:
|
||||
|
||||
| | Why |
|
||||
|---|---|
|
||||
| `llmgw users promote` | An admin who can make admins turns a stolen session into permanent access |
|
||||
| `llmgw users reset-password` | Setting someone's password is taking over their account |
|
||||
| `llmgw keys create` | Issuing a credential on another person's behalf |
|
||||
|
||||
Each of those turns a stolen admin cookie into more than it was, and each is
|
||||
rare enough that a shell is no hardship. Cordon, drain and everything else that
|
||||
touches the cluster are also CLI-only for the same reason: the dashboard shows
|
||||
the cluster, it does not steer it.
|
||||
|
||||
Every administrative action is logged with who did it, including the ones that
|
||||
were refused.
|
||||
|
||||
## What this does not do
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ func (r *Router) Handler() http.Handler {
|
||||
// the heap that holds every API key in plaintext.
|
||||
mux.HandleFunc("GET /admin", pages.AdminPage(web.Dashboard(
|
||||
r.clusterView, r.clusterUsage, version.Version, 5*time.Second, r.log)))
|
||||
pages.RegisterAdminUsers(mux, r.store)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +127,7 @@ func (r *Router) Handler() http.Handler {
|
||||
// stays open because a load balancer has no key.
|
||||
return auth.Middleware(r.auth, r.log,
|
||||
"/health", "/", "/_static/", "/claim/",
|
||||
"/signup", "/login", "/logout", "/profile", "/profile/", "/admin")(mux)
|
||||
"/signup", "/login", "/logout", "/profile", "/profile/", "/admin", "/admin/")(mux)
|
||||
}
|
||||
|
||||
// parsedRequest is what the router needs from a request. Everything else in the
|
||||
|
||||
@@ -544,3 +544,56 @@ func (s *SQLite) UsageSince(ctx context.Context, since time.Time) ([]KeyUsage, e
|
||||
return k, err
|
||||
})
|
||||
}
|
||||
|
||||
// UsageBuckets groups usage into fixed time slices.
|
||||
//
|
||||
// Empty slices are filled in, which a GROUP BY cannot do: a chart with hours
|
||||
// missing where nothing happened reads as a chart with hours missing from the
|
||||
// data, and the two mean very different things.
|
||||
func (s *SQLite) UsageBuckets(ctx context.Context, since time.Time, width time.Duration) ([]Bucket, error) {
|
||||
if width <= 0 {
|
||||
width = time.Hour
|
||||
}
|
||||
ms := width.Milliseconds()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT (ts / ?) * ?, COUNT(*),
|
||||
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END),
|
||||
SUM(prompt_tokens), SUM(completion_tokens), SUM(cost_micros),
|
||||
AVG(latency_ms)
|
||||
FROM usage WHERE ts >= ?
|
||||
GROUP BY ts / ? ORDER BY 1`, ms, ms, millis(since), ms)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
found := map[int64]Bucket{}
|
||||
for rows.Next() {
|
||||
var start int64
|
||||
var b Bucket
|
||||
var latency float64
|
||||
if err := rows.Scan(&start, &b.Requests, &b.Errors,
|
||||
&b.Prompt, &b.Completion, &b.CostMicros, &latency); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Start = fromMillis(start)
|
||||
b.LatencyMs = int64(latency)
|
||||
found[start] = b
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var out []Bucket
|
||||
first := (millis(since) / ms) * ms
|
||||
last := (millis(time.Now()) / ms) * ms
|
||||
for t := first; t <= last; t += ms {
|
||||
if b, ok := found[t]; ok {
|
||||
out = append(out, b)
|
||||
continue
|
||||
}
|
||||
out = append(out, Bucket{Start: fromMillis(t)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -214,3 +214,17 @@ type KeyUsage struct {
|
||||
Errors int64
|
||||
LastAt time.Time
|
||||
}
|
||||
|
||||
// Bucket is one time slice of usage, for the dashboard's charts.
|
||||
type Bucket struct {
|
||||
Start time.Time
|
||||
Requests int64
|
||||
Errors int64
|
||||
Prompt int64
|
||||
Completion int64
|
||||
CostMicros int64
|
||||
// LatencyMs is the mean over the bucket. A mean rather than a percentile
|
||||
// because the raw rows are already gone by rollup time; this is a shape, not
|
||||
// an SLO measurement.
|
||||
LatencyMs int64
|
||||
}
|
||||
@@ -235,3 +235,24 @@ func (s *SQLite) SetPassword(ctx context.Context, userID, hash string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllProjects lists every project with the path that identifies it.
|
||||
//
|
||||
// For the approval form, which has to offer somewhere to put a new account's
|
||||
// keys. Walking the tree in the page would mean three round trips per render.
|
||||
func (s *SQLite) AllProjects(ctx context.Context) ([]ProjectPath, error) {
|
||||
return queryAll(ctx, s.db, `
|
||||
SELECT p.id, o.name || ' / ' || t.name || ' / ' || p.name
|
||||
FROM projects p
|
||||
JOIN teams t ON t.id = p.team_id
|
||||
JOIN organizations o ON o.id = t.org_id
|
||||
ORDER BY 2`, nil, func(rows *sql.Rows) (ProjectPath, error) {
|
||||
var p ProjectPath
|
||||
return p, rows.Scan(&p.ID, &p.Path)
|
||||
})
|
||||
}
|
||||
|
||||
// ProjectPath is a project and where it sits in the tenancy tree.
|
||||
type ProjectPath struct {
|
||||
ID, Path string
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// AccountAdmin is the store half of account administration.
|
||||
type AccountAdmin interface {
|
||||
Users(ctx context.Context, state store.UserState) ([]store.User, error)
|
||||
UserByID(ctx context.Context, id string) (store.User, error)
|
||||
ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error
|
||||
SetUserState(ctx context.Context, id string, state store.UserState) error
|
||||
DeleteUserSessions(ctx context.Context, userID string) error
|
||||
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
|
||||
}
|
||||
|
||||
type adminUsersData struct {
|
||||
Pending []store.User
|
||||
Active []store.User
|
||||
Projects []store.ProjectPath
|
||||
Message string
|
||||
Error string
|
||||
CSRF string
|
||||
Version string
|
||||
Now string
|
||||
}
|
||||
|
||||
// RegisterAdminUsers mounts account administration under /admin/users.
|
||||
//
|
||||
// What is here and what is not is the whole design. Approving and suspending
|
||||
// are the work an operator does constantly and should not need a terminal for.
|
||||
// Making someone an admin, resetting a password and issuing keys on another
|
||||
// person's behalf are not here: each of them turns a stolen admin session into
|
||||
// more than it was — more access, or somebody else's account — and each is rare
|
||||
// enough that a shell is no hardship.
|
||||
func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
||||
mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
p.renderUsers(w, r, s, "", "")
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s,
|
||||
func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) {
|
||||
project := r.FormValue("project")
|
||||
if project == "" {
|
||||
return "", errors.New("choose a project for their keys")
|
||||
}
|
||||
if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "approved into " + project, nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s,
|
||||
func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) {
|
||||
if id == admin.ID {
|
||||
// Locking yourself out is not a thing an interface should let
|
||||
// you do by misclicking.
|
||||
return "", errors.New("you cannot suspend your own account")
|
||||
}
|
||||
if err := s.SetUserState(ctx, id, store.UserSuspended); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Otherwise they stay signed in and suspension is advisory until
|
||||
// their session happens to expire.
|
||||
if err := s.DeleteUserSessions(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "suspended, and signed out everywhere", nil
|
||||
}))
|
||||
|
||||
mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s,
|
||||
func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) {
|
||||
if err := s.SetUserState(ctx, id, store.UserActive); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "reinstated", nil
|
||||
}))
|
||||
}
|
||||
|
||||
// adminOnly is the guard every page under /admin shares.
|
||||
func (p *AccountPages) adminOnly(
|
||||
next func(w http.ResponseWriter, r *http.Request, admin store.User),
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
noStore(w)
|
||||
_, user, ok := p.session(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if !user.IsAdmin {
|
||||
// 404 rather than 403: a 403 confirms the page exists.
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next(w, r, user)
|
||||
}
|
||||
}
|
||||
|
||||
// adminAction wraps a state change: admin session, CSRF, audit line, redirect.
|
||||
func (p *AccountPages) adminAction(s AccountAdmin,
|
||||
do func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error),
|
||||
) http.HandlerFunc {
|
||||
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
sess, _, _ := p.session(r)
|
||||
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
|
||||
subject := id
|
||||
if u, err := s.UserByID(r.Context(), id); err == nil {
|
||||
subject = u.Email
|
||||
}
|
||||
|
||||
msg, err := do(r.Context(), admin, id, r)
|
||||
if err != nil {
|
||||
// Logged at warn whether it worked or not: a refused administrative
|
||||
// action is as worth knowing about as one that went through.
|
||||
p.log.Warn("an administrative action was refused",
|
||||
"admin", admin.Email, "subject", subject, "err", err)
|
||||
p.renderUsers(w, r, s, "", err.Error())
|
||||
return
|
||||
}
|
||||
p.log.Info("an account was changed",
|
||||
"admin", admin.Email, "subject", subject, "what", msg)
|
||||
p.renderUsers(w, r, s, subject+" "+msg, "")
|
||||
})
|
||||
}
|
||||
|
||||
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
|
||||
s AccountAdmin, msg, errMsg string,
|
||||
) {
|
||||
d := adminUsersData{
|
||||
Message: msg, Error: errMsg,
|
||||
CSRF: csrfFor(r),
|
||||
Version: p.version,
|
||||
Now: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
all, err := s.Users(r.Context(), "")
|
||||
if err != nil {
|
||||
p.log.Error("could not list accounts", "err", err)
|
||||
d.Error = "could not read the accounts"
|
||||
}
|
||||
for _, u := range all {
|
||||
if u.State == store.UserPending {
|
||||
d.Pending = append(d.Pending, u)
|
||||
} else {
|
||||
d.Active = append(d.Active, u)
|
||||
}
|
||||
}
|
||||
if d.Projects, err = s.AllProjects(r.Context()); err != nil {
|
||||
p.log.Error("could not list projects", "err", err)
|
||||
}
|
||||
render(w, "admin_users", d)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Inline SVG, generated here.
|
||||
//
|
||||
// A chart is a shape, and a column of numbers makes the reader compute the
|
||||
// shape themselves. Drawing it costs about a hundred lines and no dependency:
|
||||
// a charting library would mean a JavaScript toolchain in CI, a build step, and
|
||||
// a megabyte of bundle for what is fundamentally a list of rectangles.
|
||||
//
|
||||
// The output is marked template.HTML, so everything interpolated into it must
|
||||
// be a number this file produced. No caller-supplied string is ever written
|
||||
// into the markup except through escapeText.
|
||||
|
||||
// Series is one set of bars.
|
||||
type Series struct {
|
||||
Values []float64
|
||||
Labels []string
|
||||
// Colour is a CSS custom property name, so the chart follows the page's
|
||||
// light and dark palettes instead of pinning its own.
|
||||
Colour string
|
||||
}
|
||||
|
||||
const (
|
||||
chartW = 720.0
|
||||
chartH = 120.0
|
||||
chartPad = 18.0
|
||||
)
|
||||
|
||||
// BarChart renders a series as bars with a baseline and a peak label.
|
||||
//
|
||||
// Empty and all-zero series render as an empty frame rather than nothing: a
|
||||
// missing chart reads as a broken page, and a flat one reads as no traffic,
|
||||
// which is the truth.
|
||||
func BarChart(s Series, unit string) template.HTML {
|
||||
if s.Colour == "" {
|
||||
s.Colour = "--accent"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg class="chart" viewBox="0 0 %.0f %.0f" preserveAspectRatio="none" role="img">`,
|
||||
chartW, chartH)
|
||||
|
||||
maxV := 0.0
|
||||
for _, v := range s.Values {
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
}
|
||||
// Baseline first so bars sit on it.
|
||||
fmt.Fprintf(&b, `<line x1="0" y1="%.1f" x2="%.0f" y2="%.1f" class="axis"/>`,
|
||||
chartH-chartPad, chartW, chartH-chartPad)
|
||||
|
||||
if n := len(s.Values); n > 0 && maxV > 0 {
|
||||
slot := chartW / float64(n)
|
||||
width := slot * 0.72
|
||||
for i, v := range s.Values {
|
||||
h := (v / maxV) * (chartH - chartPad*2)
|
||||
if h < 1 && v > 0 {
|
||||
// A bar for a real value must be visible, or an hour with
|
||||
// traffic looks identical to one without.
|
||||
h = 1
|
||||
}
|
||||
x := float64(i)*slot + (slot-width)/2
|
||||
y := chartH - chartPad - h
|
||||
title := ""
|
||||
if i < len(s.Labels) {
|
||||
title = fmt.Sprintf("<title>%s</title>", escapeText(s.Labels[i]))
|
||||
}
|
||||
fmt.Fprintf(&b,
|
||||
`<rect x="%.2f" y="%.2f" width="%.2f" height="%.2f" style="fill:var(%s)">%s</rect>`,
|
||||
x, y, width, h, cssVar(s.Colour), title)
|
||||
}
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
|
||||
peak := fmt.Sprintf("peak %s %s", compact(maxV), unit)
|
||||
if maxV == 0 {
|
||||
peak = "nothing recorded"
|
||||
}
|
||||
return template.HTML(`<div class="chart-wrap">` + b.String() + //nolint:gosec // built above
|
||||
`<span class="chart-peak">` + string(escapeText(peak)) + `</span></div>`)
|
||||
}
|
||||
|
||||
// cssVar restricts what can reach a style attribute to names this package
|
||||
// defines, so a colour can never become a way to inject CSS.
|
||||
func cssVar(name string) string {
|
||||
switch name {
|
||||
case "--accent", "--ok", "--warn", "--bad":
|
||||
return name
|
||||
default:
|
||||
return "--accent"
|
||||
}
|
||||
}
|
||||
|
||||
// escapeText is the only path by which non-numeric content enters the markup.
|
||||
func escapeText(s string) template.HTML {
|
||||
return template.HTML(template.HTMLEscapeString(s)) //nolint:gosec // escaped on the line above
|
||||
}
|
||||
|
||||
// compact renders a number the way a person reads one at a glance.
|
||||
func compact(v float64) string {
|
||||
switch {
|
||||
case v >= 1e9:
|
||||
return fmt.Sprintf("%.1fB", v/1e9)
|
||||
case v >= 1e6:
|
||||
return fmt.Sprintf("%.1fM", v/1e6)
|
||||
case v >= 1e3:
|
||||
return fmt.Sprintf("%.1fk", v/1e3)
|
||||
case v == float64(int64(v)):
|
||||
return fmt.Sprintf("%d", int64(v))
|
||||
default:
|
||||
return fmt.Sprintf("%.1f", v)
|
||||
}
|
||||
}
|
||||
|
||||
// hourLabel is what a bar's tooltip says.
|
||||
func hourLabel(t time.Time, v float64, unit string) string {
|
||||
return fmt.Sprintf("%s — %s %s", t.Local().Format("Mon 15:04"), compact(v), unit)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestABarChartDrawsOneBarPerValue(t *testing.T) {
|
||||
out := string(BarChart(Series{Values: []float64{1, 5, 3}}, "requests"))
|
||||
if n := strings.Count(out, "<rect"); n != 3 {
|
||||
t.Errorf("%d bars for 3 values", n)
|
||||
}
|
||||
if !strings.Contains(out, "peak 5 requests") {
|
||||
t.Errorf("the peak is not labelled: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnHourWithTrafficNeverLooksLikeOneWithout(t *testing.T) {
|
||||
// One request in an hour against a peak of ten thousand rounds to a bar of
|
||||
// zero height, and a chart where a busy hour and an idle one look identical
|
||||
// is worse than no chart.
|
||||
out := string(BarChart(Series{Values: []float64{10000, 1}}, "requests"))
|
||||
for _, frag := range []string{`height="0.00"`, `height="0"`} {
|
||||
if strings.Contains(out, frag) {
|
||||
t.Errorf("a non-zero value was drawn with no height: %s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptySeriesStillRendersAFrame(t *testing.T) {
|
||||
// A missing chart reads as a broken page; a flat one reads as no traffic,
|
||||
// which is the truth.
|
||||
for _, s := range []Series{{}, {Values: []float64{0, 0, 0}}} {
|
||||
out := string(BarChart(s, "requests"))
|
||||
if !strings.Contains(out, "<svg") {
|
||||
t.Error("no frame was drawn")
|
||||
}
|
||||
if !strings.Contains(out, "nothing recorded") {
|
||||
t.Errorf("an empty chart does not say so: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "<rect") {
|
||||
t.Error("bars were drawn for an empty series")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabelsAreEscaped(t *testing.T) {
|
||||
// Tooltips carry text that came from somewhere else, and the output of this
|
||||
// function is marked as trusted HTML.
|
||||
out := string(BarChart(Series{
|
||||
Values: []float64{1},
|
||||
Labels: []string{`</title><script>alert(1)</script>`},
|
||||
}, "requests"))
|
||||
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Fatalf("a label reached the markup unescaped: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "<script>") {
|
||||
t.Errorf("the label was dropped rather than escaped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUnitIsEscapedToo(t *testing.T) {
|
||||
out := string(BarChart(Series{Values: []float64{1}}, `"><script>alert(1)</script>`))
|
||||
if strings.Contains(out, "<script>") {
|
||||
t.Fatalf("the unit reached the markup unescaped: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyKnownColoursReachTheStyleAttribute(t *testing.T) {
|
||||
// A colour that could be anything is a way to write CSS into every page the
|
||||
// chart appears on.
|
||||
out := string(BarChart(Series{Values: []float64{1}, Colour: "red;}</style><script>x"}, "u"))
|
||||
if strings.Contains(out, "</style>") || strings.Contains(out, "script") {
|
||||
t.Fatalf("a colour escaped its attribute: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "var(--accent)") {
|
||||
t.Errorf("an unknown colour did not fall back: %s", out)
|
||||
}
|
||||
|
||||
for _, ok := range []string{"--accent", "--ok", "--warn", "--bad"} {
|
||||
if got := cssVar(ok); got != ok {
|
||||
t.Errorf("cssVar(%q) = %q", ok, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactNumbers(t *testing.T) {
|
||||
cases := map[float64]string{
|
||||
0: "0", 7: "7", 1500: "1.5k", 2_400_000: "2.4M", 3_100_000_000: "3.1B", 1.25: "1.2",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := compact(in); got != want {
|
||||
t.Errorf("compact(%v) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHourLabelsSayWhenAndHowMuch(t *testing.T) {
|
||||
at := time.Date(2026, 9, 16, 14, 0, 0, 0, time.UTC)
|
||||
got := hourLabel(at, 1500, "requests")
|
||||
if !strings.Contains(got, "1.5k requests") {
|
||||
t.Errorf("label = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -18,6 +19,13 @@ type DashboardData struct {
|
||||
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
|
||||
@@ -41,6 +49,7 @@ type Source interface {
|
||||
// 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.
|
||||
@@ -93,11 +102,46 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
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
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{{define "admin_users"}}<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Accounts · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
<h1>Accounts</h1>
|
||||
<p class="sub">{{len .Pending}} waiting for approval · {{len .Active}} active</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||||
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
|
||||
|
||||
<h2>Waiting for approval</h2>
|
||||
{{if .Pending}}
|
||||
<table><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Project</th><th></th></tr>
|
||||
{{range .Pending}}<tr>
|
||||
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
|
||||
<td colspan="2">
|
||||
<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<select name="project">
|
||||
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Approve</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{.ID}}/suspend" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Reject</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p class="empty">Nobody is waiting.</p>{{end}}
|
||||
|
||||
<h2>Accounts</h2>
|
||||
{{if .Active}}
|
||||
<table><tr><th>Email</th><th>State</th><th>Admin</th><th>Project</th><th>Last login</th><th></th></tr>
|
||||
{{range .Active}}<tr>
|
||||
<td>{{.Email}}</td>
|
||||
<td class="state state-{{.State}}">{{.State}}</td>
|
||||
<td>{{if .IsAdmin}}yes{{else}}—{{end}}</td>
|
||||
<td>{{or .ProjectID "—"}}</td>
|
||||
<td>{{ago .LastLoginAt}}</td>
|
||||
<td>
|
||||
{{if eq (printf "%s" .State) "active"}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/suspend" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Suspend</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/reinstate" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link">Reinstate</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p class="empty">No accounts yet.</p>{{end}}
|
||||
|
||||
<p class="note">Three things are deliberately not here. Making someone an admin,
|
||||
resetting a password and issuing keys on another person's behalf all need a
|
||||
shell on the control plane — <code>llmgw users promote</code>,
|
||||
<code>reset-password</code>, <code>llmgw keys</code>. A stolen admin session can
|
||||
approve and suspend; it cannot hand itself more access or take over an account.</p>
|
||||
|
||||
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
||||
</div></body></html>{{end}}
|
||||
@@ -72,3 +72,17 @@ form.inline { display: inline; }
|
||||
button.link { background: none; color: var(--accent); padding: 0;
|
||||
text-decoration: underline; font-weight: 400; font-size: .9rem; }
|
||||
button.link.danger { color: var(--bad); }
|
||||
.ok { color: var(--ok); border: 1px solid var(--ok); border-radius: 6px;
|
||||
padding: .6rem .8rem; background: var(--card); }
|
||||
select { font: inherit; padding: .35rem .4rem; border: 1px solid var(--line);
|
||||
border-radius: 6px; background: var(--card); color: var(--fg); }
|
||||
.chart-wrap { position: relative; margin: .5rem 0 1.5rem; }
|
||||
.chart { width: 100%; height: 120px; display: block;
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 6px; }
|
||||
.chart rect { transition: opacity .1s; }
|
||||
.chart rect:hover { opacity: .65; }
|
||||
.chart .axis { stroke: var(--line); stroke-width: 1; }
|
||||
.chart-peak { position: absolute; top: .4rem; right: .6rem;
|
||||
font-size: .72rem; color: var(--muted); }
|
||||
.chart-row { display: grid; gap: 1rem; grid-template-columns: 1fr 1fr; }
|
||||
@media (max-width: 720px) { .chart-row { grid-template-columns: 1fr; } }
|
||||
@@ -8,6 +8,7 @@
|
||||
<h1>Cluster</h1>
|
||||
<p class="sub">{{.NodeCount}} nodes · {{.ReadyReplicas}} of {{.TotalReplicas}} replicas ready · {{.ModelCount}} models</p>
|
||||
<nav>
|
||||
<a href="/admin/users">accounts</a>
|
||||
<a href="/metrics">metrics</a>
|
||||
<a href="/debug/pprof/">pprof</a>
|
||||
<a href="/api/v1/nodes">api</a>
|
||||
@@ -67,6 +68,17 @@
|
||||
{{end}}</table>
|
||||
{{else}}<p class="empty">No replicas.</p>{{end}}
|
||||
|
||||
<h2>Traffic <span class="note">— last 24 hours, hourly</span></h2>
|
||||
{{if .HasCharts}}
|
||||
<p class="note">Requests</p>{{.RequestChart}}
|
||||
<div class="chart-row">
|
||||
<div><p class="note">Tokens</p>{{.TokenChart}}</div>
|
||||
<div><p class="note">Mean latency</p>{{.LatencyChart}}</div>
|
||||
</div>
|
||||
{{else if .AuthEnabled}}
|
||||
<p class="empty">No traffic recorded yet.</p>
|
||||
{{end}}
|
||||
|
||||
<h2>Usage <span class="note">— last 24 hours</span></h2>
|
||||
{{if .AuthEnabled}}
|
||||
{{if .Usage}}
|
||||
|
||||
@@ -131,7 +131,17 @@ func (fakeSource) Explain(model string) adminapi.ExplainView {
|
||||
}}
|
||||
}
|
||||
|
||||
type fakeUsage struct{ err error }
|
||||
type fakeUsage struct {
|
||||
err error
|
||||
buckets []store.Bucket
|
||||
}
|
||||
|
||||
func (f fakeUsage) UsageBuckets(context.Context, time.Time, time.Duration) ([]store.Bucket, error) {
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.buckets, nil
|
||||
}
|
||||
|
||||
func (f fakeUsage) UsageSince(context.Context, time.Time) ([]store.KeyUsage, error) {
|
||||
if f.err != nil {
|
||||
@@ -320,3 +330,34 @@ func TestWithSignupOpenTheLandingPagePointsAtIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDashboardDrawsTrafficWhenThereIsSome(t *testing.T) {
|
||||
now := time.Now().Truncate(time.Hour)
|
||||
usage := fakeUsage{buckets: []store.Bucket{
|
||||
{Start: now.Add(-2 * time.Hour), Requests: 40, Prompt: 4000, Completion: 900, LatencyMs: 120},
|
||||
{Start: now.Add(-time.Hour)},
|
||||
{Start: now, Requests: 95, Prompt: 9000, Completion: 2100, LatencyMs: 310},
|
||||
}}
|
||||
h := Dashboard(fakeSource{}, usage, "v1", time.Second, nil)
|
||||
_, body := get(t, h, "http://127.0.0.1:9090/", nil)
|
||||
|
||||
if !strings.Contains(body, "<svg") {
|
||||
t.Fatal("no chart was drawn")
|
||||
}
|
||||
// The empty hour in the middle must still be a slot, or the shape lies
|
||||
// about when traffic happened.
|
||||
if n := strings.Count(body, `<rect`); n < 4 {
|
||||
t.Errorf("%d bars across three charts and three hours", n)
|
||||
}
|
||||
if !strings.Contains(body, "peak 95 requests/h") {
|
||||
t.Error("the request peak is not labelled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDashboardSaysWhenThereIsNoTrafficYet(t *testing.T) {
|
||||
h := Dashboard(fakeSource{}, fakeUsage{}, "v1", time.Second, nil)
|
||||
_, body := get(t, h, "http://127.0.0.1:9090/", nil)
|
||||
if !strings.Contains(body, "No traffic recorded yet") {
|
||||
t.Error("an empty history renders nothing at all")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user