Files
llmgw/internal/web/templates/admin_people.html
T
Ilker ManapandClaude Opus 5.5 9f479b9a70 Show each team's first-token and latency percentiles, errors and refusals
Percentiles are read from the histograms kept with the usage sums and shown
per group on the usage tab and in the people page's panels. Requests refused
by a limit are recorded as 429s against their owner, free of charge, so a team
hitting its limits shows as such. Usage can be grouped by model.

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

156 lines
8.4 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{/* People, their limits and their use: one page in three tabs, because all
three are about the same tree. Each tab keeps its old address. Forms and
links work as plain navigation; htmx, when it loads, swaps only #people,
so a change does not jump the page. */}}
{{define "admin_people"}}<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>People · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false}'>
<script src="/_static/htmx-2.0.11.min.js" defer></script>
</head><body><div class="wrap">
<header>
<h1>People</h1>
<p class="sub">Organizations, teams, projects and the people in them: what each may use, and what it used</p>
{{template "nav" .Nav}}
</header>
<div id="people" hx-boost="true" hx-target="#people" hx-select="#people"
hx-swap="outerHTML" hx-push-url="false">
<nav class="page-tabs" aria-label="People">
{{if .HasPeople}}<a href="/admin/users" hx-push-url="true"{{if eq .Tab "people"}} aria-current="page"{{end}}>Organizations &amp; people{{if .Pending}} <span class="tag">{{.Pending}} waiting</span>{{end}}</a>{{end}}
{{if .HasLimits}}<a href="/admin/quotas" hx-push-url="true"{{if eq .Tab "limits"}} aria-current="page"{{end}}>Limits</a>{{end}}
{{if .HasUsage}}<a href="/admin/usage" hx-push-url="true"{{if eq .Tab "usage"}} aria-current="page"{{end}}>Usage</a>{{end}}
</nav>
{{if .Users}}
<div class="people-grid{{if .Detail}} with-detail{{end}}">
{{with .Detail}}<aside class="people-detail" id="detail" aria-label="{{.Name}}">{{template "people-detail" .}}</aside>{{end}}
<div class="people-main">{{template "people_body" .Users}}</div>
</div>
{{else if .Quotas}}{{template "limits_body" .Quotas}}
{{else if .Usage}}{{template "usage_body" .Usage}}
{{end}}
</div>
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
</div>
<script>
// Search the account list. Outside #people so what was typed survives a swap,
// and applied again after every swap.
(function () {
var box = document.createElement('input');
box.type = 'search'; box.placeholder = 'Find a person by email, name or project';
box.className = 'search'; box.setAttribute('aria-label', 'Find a person');
function apply() {
var q = box.value.trim().toLowerCase();
document.querySelectorAll('#account-list tr[data-q]').forEach(function (tr) {
tr.hidden = q !== '' && tr.dataset.q.indexOf(q) < 0;
});
}
box.addEventListener('input', apply);
function place() {
var h = document.getElementById('accounts');
if (h && box.parentNode !== h.parentNode) h.after(box);
apply();
}
place();
// Projects the reader had open stay open across a change.
var open = [];
document.addEventListener('htmx:beforeSwap', function () {
open = Array.prototype.map.call(document.querySelectorAll('details.project[open]'), function (d) { return d.id; });
});
document.addEventListener('htmx:afterSettle', function () {
open.forEach(function (id) { var d = document.getElementById(id); if (d) d.open = true; });
place();
});
})();
</script>
</body></html>{{end}}
{{/* One branch or person: its limits, its capacity and its use, together. */}}
{{define "people-detail"}}{{$d := .}}{{$sel := printf "%s:%s" .Kind .ID}}
<div class="pd-head">
<span class="q-kind">{{if eq (printf "%s" .Kind) "user"}}person{{else}}{{.Kind}}{{end}}</span>
<h2>{{.Name}}</h2>
{{if and .Path (ne .Path .Name)}}<p class="pd-path">{{.Path}}</p>{{end}}
<a href="/admin/users" class="pd-close" aria-label="Close">×</a>
</div>
{{with .Memberships}}<p class="pd-sub">In {{range $i, $m := .}}{{if $i}}, {{end}}<a href="/admin/users?sel={{$m.Scope}}:{{$m.ScopeID}}">{{$m.Path}}</a>{{end}}</p>
{{else}}{{if eq (printf "%s" $d.Kind) "user"}}<p class="pd-sub">On their own: in no organization, team or project.</p>{{end}}{{end}}
{{if or .Limits .Person}}
<h3>Limits</h3>
{{if not .BudgetsBind}}<p class="note">No model has a price, so budgets stay at zero; rate and token limits apply.</p>{{end}}
{{with .Limits}}
<dl class="pd-limits">
<dt>Requests/min</dt><dd>{{template "quota-value" dict "Own" .RPM "Up" .RPMUp "From" .RPMFrom}}</dd>
<dt>Tokens/min</dt><dd>{{template "quota-value" dict "Own" .TPM "Up" .TPMUp "From" .TPMFrom}}</dd>
<dt>Budget</dt><dd>{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}{{template "quota-value" dict "Own" "" "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</dd>
<dt>Spent, {{$d.QuotaWindow}}</dt><dd><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}}</dd>
<dt>Models</dt><dd>{{if .Models}}only <code>{{.Models}}</code>{{else if .ModelsFrom}}limited by {{.ModelsFrom}}{{else}}every model{{end}}</dd>
{{if .Member}}<dt>Each member</dt><dd>{{.Member}}</dd>{{end}}
</dl>
{{if .Shadowed}}<p class="q-sub q-warn">{{.Shadowed}}</p>{{end}}
<details class="edit"><summary>{{if .HasAny}}change{{else}}set{{end}} limits</summary>
{{template "quota-form" (dict "Scope" (printf "%s" .Scope) "ID" .ID "CSRF" $d.CSRF "Sel" $sel
"F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny)
"Title" (printf "Quota for %s" .Crumb)
"Note" "Shared by everything beneath it.")}}
</details>
<details class="edit"><summary>limit each member</summary>
{{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $d.CSRF "Sel" $sel
"Title" (printf "Each member of %s, separately" .Crumb)
"Note" "Every member gets this much here, counted apart from the others.")}}
</details>
{{end}}
{{with .Person}}
<dl class="pd-limits">
<dt>Requests/min</dt><dd>{{or .RPM "—"}}</dd>
<dt>Tokens/min</dt><dd>{{or .TPM "—"}}</dd>
<dt>Budget</dt><dd>{{if .Budget}}{{.Budget}}{{if .Period}}<span class="q-per">/{{.Period}}</span>{{end}}{{else}}—{{end}}</dd>
<dt>Spent, {{$d.QuotaWindow}}</dt><dd><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}}</dd>
<dt>Models</dt><dd>{{if .Models}}only <code>{{.Models}}</code>{{else}}every model{{end}}</dd>
</dl>
<details class="edit"><summary>{{if .HasAny}}change{{else}}set{{end}} limits</summary>
{{template "quota-form" (dict "Scope" "user" "ID" .ID "CSRF" $d.CSRF "Sel" $sel
"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.")}}
</details>
{{end}}
{{end}}
{{if .HasCapacity}}
<h3>Capacity</h3>
{{with .Capacity}}
<dl class="pd-limits">
<dt>GPUs</dt><dd>{{or .GPUs "—"}}</dd>
<dt>Cores</dt><dd>{{or .Cores "—"}}</dd>
<dt>Memory GB</dt><dd>{{or .Memory "—"}}</dd>
</dl>
<p class="note">Held of guaranteed (at most the limit). <a href="/admin/quotas?cap={{$sel}}#capacity">change</a></p>
{{else}}<p class="note">No capacity quota: its models share the cluster by their class alone.
<a href="/admin/quotas?cap={{$sel}}#capacity">set one</a></p>{{end}}
{{end}}
{{if .HasUsage}}
<h3>Use, {{.UsageWindow}}</h3>
{{if .Totals.Name}}
<p class="pd-sub">{{compact .Totals.Requests}} requests · {{compact .Totals.Tokens}} tokens{{if .Priced}} · {{.Totals.Spend}} spent{{end}}{{if .Totals.Errors}} · {{.Totals.Errors}} errors{{end}}</p>
{{if and .HasStats .Stats.Requests}}{{with .Stats}}
<dl class="pd-limits">
<dt>Error rate</dt><dd{{if ge .ErrorRate 5.0}} class="q-warn"{{end}}>{{pct1 .ErrorRate}}%</dd>
<dt>Refused by a limit</dt><dd{{if .Limited}} class="q-warn"{{end}}>{{if .Limited}}{{compact .Limited}}{{else}}none{{end}}</dd>
<dt>First token</dt><dd>p50 {{pctile .TTFT 50}} · p95 {{pctile .TTFT 95}} · p99 {{pctile .TTFT 99}}</dd>
<dt>Latency</dt><dd>p50 {{pctile .Latency 50}} · p95 {{pctile .Latency 95}} · p99 {{pctile .Latency 99}}</dd>
</dl>{{end}}{{end}}
{{template "tschart" dict "t" .Requests "title" "Requests" "note" ""}}
{{template "tschart" dict "t" .Tokens "title" "Tokens" "note" ""}}
{{if .Priced}}{{template "tschart" dict "t" .Spend "title" "Spend" "note" ""}}{{end}}
{{else}}<p class="empty">Nothing used in this window.</p>{{end}}
{{end}}
{{end}}