Files
llmgw/internal/web/templates/admin_users.html
T
Ilker ManapandClaude Opus 5 07809490e3 Show usage over time, per organization, team, project and person
Usage could be read as a total — per key, per user, per model, per group —
and the cluster's own charts covered the fleet's hardware. What nobody could
see was when a group used it. "Research doubled its traffic three weeks ago"
is not a question a column of totals can be asked, and it is the question
somebody asks before a quota conversation.

/admin/usage draws requests, tokens and spend over a chosen window, grouped
by any level of the tenancy tree or by person. Person is not a level of that
tree — one person holds keys in several projects — so the grouping is its
own type rather than store.Scope with a fourth value nothing else could use.

Three things learned from the charts already here. Buckets are gap-filled
per group, because a line drawn straight across an absence claims traffic
that did not happen, and because the chart sums its tail by timestamp and
can only do that if every series shares a clock. Series are ranked by total
rather than by peak — a counter's question is "who used the most", and
ranking by peak would colour the team that made one burst over the team that
ran steadily all week. And the spend chart is replaced by a sentence when no
model has a price, rather than drawn flat along the bottom.

The bucket query groups by ordinal and was planned against the cluster's
PostgreSQL before being believed, with a test alongside the others that run
there: three reports have shipped working on SQLite and failing on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 08:11:31 +02:00

124 lines
5.2 KiB
HTML

{{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>
{{if .HasMirror}} <a href="/admin/mirror">mirror</a>
{{end}}{{if .HasQuotas}} <a href="/admin/quotas">quotas</a>
<a href="/admin/usage">usage</a>
{{end}} <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}}
<h2>Organizations, teams and projects</h2>
<p class="note">Where an account lives. A person is approved into a project, a
project belongs to a team, and a team to an organization — and a quota can be
written on any of the three, so the shape here is the shape the bill takes.</p>
<div class="field-row">
<form method="post" action="/admin/tenancy/org">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>New organization
<input type="text" name="name" required placeholder="Scania"></label>
<button type="submit">Create</button>
</form>
<form method="post" action="/admin/tenancy/team">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>New team
<input type="text" name="name" required placeholder="platform"></label>
<label>in
<select name="org" required>
{{range .Orgs}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
</select></label>
<button type="submit"{{if not .Orgs}} disabled{{end}}>Create</button>
</form>
<form method="post" action="/admin/tenancy/project">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>New project
<input type="text" name="name" required placeholder="gateway"></label>
<label>in
<select name="team" required>
{{range .Teams}}<option value="{{.ID}}">{{.OrgName}} / {{.Name}}</option>{{end}}
</select></label>
<button type="submit"{{if not .Teams}} disabled{{end}}>Create</button>
</form>
</div>
{{if not .Orgs}}
<p class="empty">No organizations yet. Create one, then a team in it, then a
project in the team — an account can only be approved into a project.</p>
{{else}}
{{/* One path column, not three: the store returns the chain already joined,
which is the string the approval chooser shows, and three headers over one
value line up with nothing. */}}
<table><tr><th>Organization / team / project</th><th>ID</th></tr>
{{range .Projects}}<tr><td>{{.Path}}</td><td><code>{{.ID}}</code></td></tr>{{end}}
{{if not .Projects}}<tr><td colspan="2" class="empty">No projects yet, so nobody can be approved.</td></tr>{{end}}
</table>
{{end}}
<p class="note">Four things are deliberately not here. Making someone an admin,
resetting a password, issuing keys on another
person's behalf and setting any quota all need a shell on the control plane — <code>llmgw users promote</code>,
<code>reset-password</code>, <code>llmgw keys</code>, <code>llmgw keys limits</code>.
A stolen admin session can approve, suspend and make somewhere to put people; it
cannot hand itself more access, take over an account, or raise what anybody is
allowed to spend.</p>
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
</div></body></html>{{end}}