diff --git a/docs/onboarding.md b/docs/onboarding.md index 676cf21d..8c36aeb6 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -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 diff --git a/internal/router/handlers.go b/internal/router/handlers.go index 453b6b4a..d011b877 100644 --- a/internal/router/handlers.go +++ b/internal/router/handlers.go @@ -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 diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 3bb6d0be..f9e97a8a 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go index 09434910..559a0d72 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/users.go b/internal/store/users.go index 3d81ecae..83be4349 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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 +} diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go new file mode 100644 index 00000000..f226388a --- /dev/null +++ b/internal/web/admin_users.go @@ -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) +} diff --git a/internal/web/chart.go b/internal/web/chart.go new file mode 100644 index 00000000..8c4ba3a6 --- /dev/null +++ b/internal/web/chart.go @@ -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, ``) + + peak := fmt.Sprintf("peak %s %s", compact(maxV), unit) + if maxV == 0 { + peak = "nothing recorded" + } + return template.HTML(`