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>
157 lines
8.9 KiB
HTML
157 lines
8.9 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="{{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}}
|