Files
llmgw/internal/web/templates/admin_quotas.html
T
Ilker ManapandClaude Opus 5.5 fffed716be Make people, limits and usage one page, with a panel per branch
A team's members, quota and spend were on three pages that linked to each
other. They are three tabs of one page now, each at its old address, and
choosing anything in the tree opens its limits, capacity and use beside it,
editable in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:10:48 +02:00

235 lines
13 KiB
HTML

{{/* The limits tab of the people page. */}}
{{define "limits_body"}}
{{if not .BudgetsBind}}
{{/* A budget is spend per period and spend comes from a price table. With no
prices every request costs nothing, so these numbers read as limits and
bind on nothing. Self-hosted models having no per-token price is ordinary;
showing somebody a budget without saying so is not. */}}
<p class="banner-warn" role="note">No model has a price, so nothing is charged and
budgets stay at zero however much is used. Rate and token limits still apply. Set
prices in the <code>pricing</code> block of the server configuration to make
budgets bind.</p>
{{end}}
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
{{if .Message}}<p class="ok" role="status">{{.Message}}</p>{{end}}
{{if .Rows}}
{{/* The tree is the interface. A quota belongs to a branch, so the form to set
one opens on that branch rather than asking somebody to name it again in a
box at the bottom. */}}
<table class="rows quota-tree">
<tr><th>Where</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
<th class="num">Budget</th><th>Spent</th><th></th></tr>
{{range .Rows}}
<tr class="q-depth-{{.Indent}}{{if .Editing}} editing{{end}}">
<td class="q-where">
<span class="q-name">{{.Path}}</span> <span class="q-kind">{{.Scope}}</span>
{{if .Models}}<div class="q-sub">only <code>{{.Models}}</code></div>
{{else if .ModelsFrom}}<div class="q-sub">models limited by {{.ModelsFrom}}</div>{{end}}
{{if .Member}}<div class="q-sub">each member here: {{.Member}}</div>{{end}}
{{if .Shadowed}}
{{/* A value that is written here and never reached. Resolution takes the
most restrictive along the chain, so without saying so the page would
show a number as though it applied. */}}
<div class="q-sub q-warn">{{.Shadowed}}</div>{{end}}
</td>
<td class="num">{{template "quota-value" dict "Own" .RPM "Up" .RPMUp "From" .RPMFrom}}</td>
<td class="num">{{template "quota-value" dict "Own" .TPM "Up" .TPMUp "From" .TPMFrom}}</td>
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}
{{else}}{{template "quota-value" dict "Own" "" "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</td>
<td class="q-spent">
<span class="q-spend">{{.Spend}}</span>
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}" title="{{.Percent}}% of its budget"><i style="width:{{pct .Percent 100}}%"></i></span>
<span class="q-pct">{{.Percent}}%</span>{{end}}
</td>
<td class="q-act">
{{if .Editing}}<a href="/admin/quotas">close</a>
{{else}}<a href="/admin/quotas?edit={{.Scope}}:{{.ID}}">{{if .HasAny}}change{{else}}set{{end}}</a>{{end}}
{{if .MemberEditing}}<a href="/admin/quotas">close</a>
{{else}}<a href="/admin/quotas?edit={{.Scope}}-member:{{.ID}}" title="A limit each member here has separately">per member</a>{{end}}
</td>
</tr>
{{if .MemberEditing}}
<tr class="editing"><td colspan="6">
{{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $.CSRF
"Title" (printf "Each member of %s, separately" .Crumb)
"Note" "Every member gets this much here, counted apart from the others — a fair share rather than one pool.")}}
</td></tr>
{{end}}
{{if .Editing}}
<tr class="editing"><td colspan="6">
<form method="post" action="/admin/quotas" class="q-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="id" value="{{.ID}}">
<p class="q-form-title">Quota for <strong>{{.Crumb}}</strong>, shared by everything beneath it.
A blank field means no limit of that kind, not a limit of zero.</p>
<div class="field-row">
<label>Requests / minute
<input type="text" name="rpm" value="{{.RPM}}" placeholder="{{if .RPMUp}}{{.RPMUp}} from {{.RPMFrom}}{{else}}none{{end}}"></label>
<label>Tokens / minute
<input type="text" name="tpm" value="{{.TPM}}" placeholder="{{if .TPMUp}}{{.TPMUp}} from {{.TPMFrom}}{{else}}none{{end}}"></label>
<label>Budget
<input type="text" name="budget" value="{{.Budget}}" placeholder="{{if .BudgetUp}}{{.BudgetUp}} from {{.BudgetFrom}}{{else}}none{{end}}"></label>
<label class="q-period">per
<select name="period">
<option value="month"{{if eq .Period "month"}} selected{{end}}>month</option>
<option value="day"{{if eq .Period "day"}} selected{{end}}>day</option>
</select></label>
</div>
<label>Only these models <span class="opt">comma separated; blank allows all</span>
<input type="text" name="models" value="{{.Models}}" placeholder="every model"></label>
<div class="q-form-actions">
<button type="submit">Save</button>
<a href="/admin/quotas">Cancel</a>
<button type="submit" name="clear" value="1" class="link danger"
{{if not .HasAny}}disabled{{end}}>Remove every limit here</button>
</div>
</form>
</td></tr>
{{end}}
{{end}}
</table>
<p class="q-legend"><span class="q-up">60 ↑</span> is held by a limit set higher
up — hover it to see where. A value set here is in normal type.</p>
{{else}}
<p class="empty">Nothing to give a quota to yet. Make an organization on the
<a href="/admin/users">Organizations &amp; people</a> tab first.</p>
{{end}}
<h2 id="people-limits">People</h2>
<p class="note">A person's own limit covers everything they do, across all their
keys, projects and the chat page. {{.Personal}}</p>
{{if .People}}
<table class="rows quota-tree people-limits">
<tr><th>Person</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
<th class="num">Budget</th><th>Spent</th><th></th></tr>
{{range .People}}
<tr{{if .Editing}} class="editing"{{end}}>
<td class="q-where"><span class="q-name">{{.Email}}</span>
{{if .Models}}<div class="q-sub">only <code>{{.Models}}</code></div>{{end}}</td>
<td class="num">{{or .RPM "—"}}</td>
<td class="num">{{or .TPM "—"}}</td>
<td class="num">{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}—{{end}}</td>
<td class="q-spent"><span class="q-spend">{{.Spend}}</span>
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span>
<span class="q-pct">{{.Percent}}%</span>{{end}}</td>
<td class="q-act">{{if .Editing}}<a href="/admin/quotas">close</a>
{{else}}<a href="/admin/quotas?edit=user:{{.ID}}">{{if .HasAny}}change{{else}}set{{end}}</a>{{end}}</td>
</tr>
{{if .Editing}}
<tr class="editing"><td colspan="6">
{{template "quota-form" (dict "Scope" "user" "ID" .ID "CSRF" $.CSRF
"F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny)
"Title" (printf "%s, everywhere" .Email)
"Note" "Across every key this person holds, every project they use and the chat page.")}}
</td></tr>
{{end}}
{{end}}
</table>
{{else}}<p class="empty">Nobody has been approved yet.</p>{{end}}
{{if .HasCapacity}}
<h2 id="capacity">Capacity</h2>
<p class="note">What a part of the tree is guaranteed of the cluster, and the most
it may hold: GPUs, CPU cores and memory, counted from the replicas of the models
it owns and of those beneath it. What it holds within its guarantee is never taken
for someone else's model; beyond it, it borrows free room and gives it back when
asked. A model's owner is set on its card on the mirror page.</p>
{{if .Capacity}}
<table class="rows quota-tree">
<tr><th>Where</th><th class="num">GPUs</th><th class="num">Cores</th><th class="num">Memory GB</th><th>Set by</th><th></th></tr>
{{range .Capacity}}<tr>
<td class="q-where"><span class="q-name">{{or .Name .Scope}}</span></td>
<td class="num">{{or .GPUs "—"}}</td>
<td class="num">{{or .Cores "—"}}</td>
<td class="num">{{or .Memory "—"}}</td>
<td class="note">{{.UpdatedBy}}</td>
<td class="q-act"><a href="/admin/quotas?cap={{.Scope}}#capacity">change</a>
<form method="post" action="/admin/quotas/capacity" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<button type="submit" name="clear" value="1" class="link danger">remove</button>
</form></td>
</tr>{{end}}
</table>
<p class="q-legend">Each figure is held of guaranteed (at most the limit); — is not governed.</p>
{{else}}<p class="empty">No capacity quotas: every model shares the cluster by its class alone.</p>{{end}}
{{if .Scopes}}
<form method="post" action="/admin/quotas/capacity" class="q-form capacity-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<p class="q-form-title"><strong>{{if .CapForm.Scope}}Change{{else}}Set{{end}} a capacity quota.</strong>
A blank field is not governed; 0 is none at all.</p>
<label>Where
<select name="scope" required>
<option value="">choose…</option>
{{range .Scopes}}<option value="{{.Key}}"{{if eq .Key $.CapForm.Scope}} selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
<div class="field-row">
<label>Guaranteed GPUs <input type="text" name="gpus" value="{{.CapForm.GPUs}}" placeholder="—"></label>
<label>Cores <input type="text" name="cores" value="{{.CapForm.Cores}}" placeholder="—"></label>
<label>Memory GB <input type="text" name="memory" value="{{.CapForm.Memory}}" placeholder="—"></label>
</div>
<div class="field-row">
<label>At most GPUs <input type="text" name="limit_gpus" value="{{.CapForm.LGPUs}}" placeholder="—"></label>
<label>Cores <input type="text" name="limit_cores" value="{{.CapForm.LCores}}" placeholder="—"></label>
<label>Memory GB <input type="text" name="limit_memory" value="{{.CapForm.LMem}}" placeholder="—"></label>
</div>
<div class="q-form-actions"><button type="submit">Save</button>
{{if .CapForm.Scope}}<a href="/admin/quotas#capacity">Cancel</a>{{end}}</div>
</form>
{{else}}<p class="empty">Nothing to give a capacity quota to yet. Make an organization on the
<a href="/admin/users">Organizations &amp; people</a> tab first.</p>{{end}}
{{end}}
<details class="explain">
<summary>How limits combine</summary>
<p>Along a branch the most restrictive value wins, so a team cannot raise what
its organization set and a project cannot raise its team's. Only an
organization's own limit is bounded by nothing. Model lists intersect: a
project cannot allow a model its team withheld.</p>
<p>Per-key limits are not set here: a key is bounded by its project, and
putting every caller's throughput one click from an admin session is more
than this page should do — <code>llmgw keys limits --scope key</code>.</p>
</details>
{{end}}
{{/* A limit as the table shows it: the row's own value, else the one it is
held to from above, marked as inherited and saying from where. */}}
{{/* The form for a limit that is not a branch's own: per member, or a person. */}}
{{define "quota-form"}}
<form method="post" action="/admin/quotas" class="q-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="id" value="{{.ID}}">
{{/* From a panel on the people tab: the answer comes back to that panel. */}}
{{with .Sel}}<input type="hidden" name="tab" value="people"><input type="hidden" name="sel" value="{{.}}">{{end}}
<p class="q-form-title"><strong>{{.Title}}</strong>. {{.Note}} A blank field means no limit of that kind.</p>
<div class="field-row">
<label>Requests / minute <input type="text" name="rpm" value="{{.F.RPM}}" placeholder="none"></label>
<label>Tokens / minute <input type="text" name="tpm" value="{{.F.TPM}}" placeholder="none"></label>
<label>Budget <input type="text" name="budget" value="{{.F.Budget}}" placeholder="none"></label>
<label class="q-period">per
<select name="period">
<option value="month"{{if eq .F.Period "month"}} selected{{end}}>month</option>
<option value="day"{{if eq .F.Period "day"}} selected{{end}}>day</option>
</select></label>
</div>
<label>Only these models <span class="opt">comma separated; blank allows all</span>
<input type="text" name="models" value="{{.F.Models}}" placeholder="every model"></label>
<div class="q-form-actions">
<button type="submit">Save</button>
{{if not .Sel}}<a href="/admin/quotas">Cancel</a>{{end}}
<button type="submit" name="clear" value="1" class="link danger"
{{if not .F.HasAny}}disabled{{end}}>Remove these limits</button>
</div>
</form>
{{end}}
{{define "quota-value"}}{{if .Own}}{{.Own}}{{else if .Up}}<span class="q-up" title="set on {{.From}}">{{.Up}} ↑</span>{{else}}<span class="q-none">—</span>{{end}}{{end}}