Files
llmgw/internal/web/templates/admin_people.html
T
Ilker ManapandClaude Opus 5.5 102ca9556f An organization's admin, and forms only for those who may send them
C1. A membership of an organization may be admin as well as member and
manager. An admin sees /org as a manager does and changes the
organization from it: its teams and projects, who of the approved
accounts is in them, its members' roles, and the keys spent in its
projects. Every id a form names is checked against the organization;
limits, accounts, the organization's name and the cluster stay with the
cluster's staff, and an admin cannot take their own role away.

A group from OIDC, SCIM or a directory no longer rewrites a membership
it names as a plain member at every sign-in, which took a manager's role
away (EnsureMembership).

Pages draw a form only for someone who may send it: Nav.May asks the
staff rule staffMay keeps, and a page whose forms a person may not send
gets no form token. A viewer sees the people, limits and mirror pages
without forms; an operator the mirror's.

Forms are compact: labels close to their fields, fields as wide as what
goes in them, smaller buttons, and the short forms on one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:38:06 +01:00

157 lines
8.9 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="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{t "people.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false,"allowEval":false}'>
<script src="/_static/htmx-2.0.11.min.js" defer></script>
<script defer src="{{asset "pages.js"}}"></script>
</head><body><div class="wrap">
<header>
<h1>{{t "people.title"}}</h1>
<p class="sub">{{t "people.sub"}}</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="{{t "people.title"}}">
{{if .HasPeople}}<a href="/admin/users" hx-push-url="true"{{if eq .Tab "people"}} aria-current="page"{{end}}>{{t "people.tab.people"}}{{if .Pending}} <span class="tag">{{t "people.waiting" .Pending}}</span>{{end}}</a>{{end}}
{{if .HasLimits}}<a href="/admin/quotas" hx-push-url="true"{{if eq .Tab "limits"}} aria-current="page"{{end}}>{{t "people.tab.limits"}}</a>{{end}}
{{if .HasUsage}}<a href="/admin/usage" hx-push-url="true"{{if eq .Tab "usage"}} aria-current="page"{{end}}>{{t "people.tab.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>{{stamp .Now}}</span>{{template "langs"}}</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 = {{t "people.search.placeholder"}};
box.className = 'search'; box.setAttribute('aria-label', {{t "people.search.label"}});
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">{{t (print "people.kind." .Kind)}}</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="{{t "people.close"}}">×</a>
</div>
{{with .Memberships}}<p class="pd-sub">{{t "people.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">{{t "people.on_own"}}</p>{{end}}{{end}}
{{if or .Limits .Person}}
<h3>{{t "people.limits"}}</h3>
{{if not .BudgetsBind}}<p class="note">{{t "people.budgets_unpriced"}}</p>{{end}}
{{with .Limits}}
<dl class="pd-limits">
<dt>{{t "people.rpm"}}</dt><dd>{{template "quota-value" dict "Own" .Shown.RPM "Up" .RPMUp "From" .RPMFrom}}</dd>
<dt>{{t "people.tpm"}}</dt><dd>{{template "quota-value" dict "Own" .Shown.TPM "Up" .TPMUp "From" .TPMFrom}}</dd>
<dt>{{t "people.budget"}}</dt><dd>{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}{{else}}{{template "quota-value" dict "Up" .BudgetUp "From" .BudgetFrom}}{{end}}</dd>
<dt>{{t "people.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">{{t "quotas.pct" .Percent}}</span>{{end}}</dd>
<dt>{{t "people.models"}}</dt><dd>{{if .Models}}{{th "people.models_only" .Models}}{{else if .ModelsFrom}}{{t "people.models_limited_by" .ModelsFrom}}{{else}}{{t "people.models_every"}}{{end}}</dd>
{{if .Member}}<dt>{{t "people.each_member"}}</dt><dd>{{.Member}}</dd>{{end}}
</dl>
{{if .Shadowed}}<p class="q-sub q-warn">{{.Shadowed}}</p>{{end}}
{{if $d.CSRF}}<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</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" (t "people.quota_for" .Crumb)
"Note" (t "people.note.shared"))}}
</details>{{end}}
{{if $d.CSRF}}<details class="edit"><summary>{{t "people.limit_each_member"}}</summary>
{{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $d.CSRF "Sel" $sel
"Title" (t "people.each_member_of" .Crumb)
"Note" (t "people.note.each_member"))}}
</details>{{end}}
{{end}}
{{with .Person}}
<dl class="pd-limits">
<dt>{{t "people.rpm"}}</dt><dd>{{or .Shown.RPM "—"}}</dd>
<dt>{{t "people.tpm"}}</dt><dd>{{or .Shown.TPM "—"}}</dd>
<dt>{{t "people.budget"}}</dt><dd>{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}{{else}}—{{end}}</dd>
<dt>{{t "people.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">{{t "quotas.pct" .Percent}}</span>{{end}}</dd>
<dt>{{t "people.models"}}</dt><dd>{{if .Models}}{{th "people.models_only" .Models}}{{else}}{{t "people.models_every"}}{{end}}</dd>
</dl>
{{if $d.CSRF}}<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</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" (t "people.everywhere" .Email)
"Note" (t "people.note.person"))}}
</details>{{end}}
{{end}}
{{end}}
{{if .HasCapacity}}
<h3>{{t "people.capacity"}}</h3>
{{with .Capacity}}
<dl class="pd-limits">
<dt>GPUs</dt><dd>{{or .GPUs "—"}}</dd>
<dt>{{t "people.cores"}}</dt><dd>{{or .Cores "—"}}</dd>
<dt>{{t "people.memory_gb"}}</dt><dd>{{or .Memory "—"}}</dd>
</dl>
<p class="note">{{t "people.capacity_note"}} <a href="/admin/quotas?cap={{$sel}}#capacity">{{t "people.change"}}</a></p>
{{else}}<p class="note">{{t "people.no_capacity"}}
<a href="/admin/quotas?cap={{$sel}}#capacity">{{t "people.set_one"}}</a></p>{{end}}
{{end}}
{{if .HasUsage}}
<h3>{{t "people.use" .UsageWindow}}</h3>
{{if .Totals.Name}}
<p class="pd-sub">{{t "people.n_requests" (compact .Totals.Requests)}} · {{t "people.n_tokens" (compact .Totals.Tokens)}}{{if .Priced}} · {{t "people.spent_amount" .Totals.Spend}}{{end}}{{if .Totals.Errors}} · {{t "people.n_errors" (count .Totals.Errors)}}{{end}}</p>
{{if and .HasStats .Stats.Requests}}{{with .Stats}}
<dl class="pd-limits">
<dt>{{t "people.error_rate"}}</dt><dd{{if ge .ErrorRate 5.0}} class="q-warn"{{end}}>{{pct1 .ErrorRate}}%</dd>
<dt>{{t "people.refused"}}</dt><dd{{if .Limited}} class="q-warn"{{end}}>{{if .Limited}}{{compact .Limited}}{{else}}{{t "common.none"}}{{end}}</dd>
<dt>{{t "people.first_token"}}</dt><dd>p50 {{pctile .TTFT 50}} · p95 {{pctile .TTFT 95}} · p99 {{pctile .TTFT 99}}</dd>
<dt>{{t "people.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" (t "people.chart.requests") "note" ""}}
{{template "tschart" dict "t" .Tokens "title" (t "people.chart.tokens") "note" ""}}
{{if .Priced}}{{template "tschart" dict "t" .Spend "title" (t "people.chart.spend") "note" ""}}{{end}}
{{else}}<p class="empty">{{t "people.nothing_used"}}</p>{{end}}
{{end}}
{{end}}