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>
156 lines
8.4 KiB
HTML
156 lines
8.4 KiB
HTML
{{/* 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 & 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}}
|