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>
183 lines
9.5 KiB
HTML
183 lines
9.5 KiB
HTML
{{define "org"}}<!doctype html>
|
||
<html lang="{{lang}}"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{{.Org.Name}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||
{{if .Edit}}<script defer src="{{asset "pages.js"}}"></script>{{end}}
|
||
</head><body><div class="wrap">
|
||
<header>
|
||
<h1>{{.Org.Name}}</h1>
|
||
<p class="sub">{{tn "org.sub" .People}}</p>
|
||
{{template "nav" .Nav}}
|
||
</header>
|
||
|
||
{{if gt (len .Orgs) 1}}
|
||
<div class="filters">
|
||
<span class="filter-label">{{t "org.filter"}}</span>
|
||
{{range .Orgs}}<a class="chip{{if eq .ID $.Org.ID}} on{{end}}" href="/org/{{.ID}}">{{.Name}}</a>{{end}}
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
||
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
|
||
|
||
<h2>{{t "org.use"}}</h2>
|
||
<div class="filters">
|
||
<span class="filter-label">{{t "usage.group_by"}}</span>
|
||
{{range .Groups}}<a class="chip{{if .Selected}} on{{end}}"
|
||
href="/org/{{$.Org.ID}}?by={{.Key}}&window={{$.Window}}">{{.Label}}</a>{{end}}
|
||
<span class="filter-label">{{t "usage.window"}}</span>
|
||
{{range .Windows}}<a class="chip{{if .Selected}} on{{end}}"
|
||
href="/org/{{$.Org.ID}}?by={{$.By}}&window={{.Key}}">{{.Label}}</a>{{end}}
|
||
</div>
|
||
|
||
<div class="chart-row">
|
||
{{template "tschart" dict "t" .Requests "title" (t "usage.requests")
|
||
"note" (t "org.requests_note")}}
|
||
{{template "tschart" dict "t" .Tokens "title" (t "usage.tokens")
|
||
"note" (t "org.tokens_note")}}
|
||
</div>
|
||
{{if .Priced}}
|
||
<div class="chart-row">
|
||
{{template "tschart" dict "t" .Spend "title" (t "usage.spend") "note" (t "org.spend_note")}}
|
||
</div>
|
||
{{end}}
|
||
|
||
{{if .Rows}}
|
||
<table class="rows compact">
|
||
<tr><th>{{if eq .By "user"}}{{t "org.col.person"}}{{else if eq .By "project"}}{{t "org.col.project"}}{{else}}{{t "org.col.team"}}{{end}}</th>
|
||
<th class="num">{{t "usage.requests"}}</th><th class="num">{{t "usage.tokens"}}</th><th class="num">{{t "usage.spend"}}</th><th class="num">{{t "usage.errors"}}</th></tr>
|
||
{{range .Rows}}<tr>
|
||
<td>{{.Name}}</td><td class="num">{{compact .Requests}}</td><td class="num">{{compact .Tokens}}</td>
|
||
<td class="num">{{.Spend}}</td><td class="num">{{if .Errors}}{{count .Errors}}{{else}}—{{end}}</td>
|
||
</tr>{{end}}
|
||
</table>
|
||
{{else}}<p class="empty">{{t "org.use_empty"}}</p>{{end}}
|
||
<p class="note">{{t "org.use_scope"}}</p>
|
||
|
||
<h2>{{t "org.limits.title"}}</h2>
|
||
{{if .Quotas}}
|
||
<table class="rows quota-tree">
|
||
<tr><th>{{t "quotas.col.where"}}</th><th class="num">{{t "quotas.col.rpm"}}</th><th class="num">{{t "quotas.col.tpm"}}</th>
|
||
<th class="num">{{t "quotas.col.budget"}}</th><th>{{t "org.col.spent_30d"}}</th></tr>
|
||
{{range .Quotas}}
|
||
<tr class="q-depth-{{.Indent}}">
|
||
<td class="q-where"><span class="q-name">{{.Path}}</span> <span class="q-kind">{{t (print "quotas.kind." .Scope)}}</span>
|
||
{{if .Models}}<div class="q-sub">{{th "quotas.only_models" .Models}}</div>{{end}}
|
||
{{if .Shadowed}}<div class="q-sub q-warn">{{.Shadowed}}</div>{{end}}</td>
|
||
<td class="num">{{template "quota-value" dict "Own" .Shown.RPM "Up" .RPMUp "From" .RPMFrom}}</td>
|
||
<td class="num">{{template "quota-value" dict "Own" .Shown.TPM "Up" .TPMUp "From" .TPMFrom}}</td>
|
||
<td class="num">{{if .Budget}}{{.Shown.Budget}}{{if .Period}}<span class="q-per">/{{.Shown.Period}}</span>{{end}}
|
||
{{else}}{{template "quota-value" dict "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}}"><i style="width:{{pct .Percent 100}}%"></i></span>
|
||
<span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</table>
|
||
<p class="note">{{t "org.limits.note"}}</p>
|
||
{{end}}
|
||
|
||
<h2>{{t "org.members.title"}}</h2>
|
||
{{if .Members}}
|
||
<table class="rows compact">
|
||
<tr><th>{{t "org.col.person"}}</th><th>{{t "org.col.member_of"}}</th>{{if .Edit}}<th>{{t "org.col.role"}}</th>{{end}}<th>{{t "org.col.state"}}</th></tr>
|
||
{{range $m := .Members}}<tr>
|
||
<td>{{$m.Email}}</td>
|
||
<td>{{range $m.Places}}<span class="chip-m">{{.Where}}{{if and (ne .Role "member") (not $.Edit)}} <b class="chip-role">{{t (print "users.role." .Role)}}</b>{{end}}
|
||
{{if $.Edit}}<form method="post" action="/org/{{$.Org.ID}}/members/{{$m.ID}}/leave" class="inline"
|
||
data-confirm="{{t "users.take_out_confirm" $m.Email .Where}}">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="where" value="{{.Value}}">
|
||
<button type="submit" class="chip-x" title="{{t "users.take_out_of" .Where}}" aria-label="{{t "users.take_out_of" .Where}}">×</button>
|
||
</form>{{end}}</span>{{end}}</td>
|
||
{{if $.Edit}}<td>{{if and $m.OrgRole (eq $m.ID $.Me)}}<span class="chip-role">{{t (print "users.role." $m.OrgRole)}}</span>{{else if $m.OrgRole}}
|
||
<form method="post" action="/org/{{$.Org.ID}}/members/{{$m.ID}}/role" class="inline">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||
<select name="role" class="chip-sel" data-autosubmit aria-label="{{t "users.role_in" $.Org.Name}}" title="{{t "users.role_note"}}">
|
||
{{$r := $m.OrgRole}}{{range $role := roles}}<option value="{{$role}}"{{if eq $role $r}} selected{{end}}>{{t (print "users.role." $role)}}</option>{{end}}
|
||
</select>
|
||
</form>{{else}}<span class="opt">—</span>{{end}}</td>{{end}}
|
||
<td class="state state-{{$m.State}}">{{with $m.State}}{{t (print "org.state." .)}}{{end}}</td>
|
||
</tr>{{end}}
|
||
</table>
|
||
{{else}}<p class="empty">{{t "org.members.empty"}}</p>{{end}}
|
||
{{if .Edit}}
|
||
<form method="post" action="/org/{{.Org.ID}}/members" class="row">
|
||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||
<input type="email" name="email" required placeholder="{{t "org.add.email"}}" aria-label="{{t "org.add.email"}}">
|
||
<select name="where" required aria-label="{{t "org.add.where"}}">
|
||
{{range .Places}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
|
||
</select>
|
||
<button type="submit">{{t "org.add.button"}}</button>
|
||
</form>
|
||
<p class="note">{{t "org.add.note"}}</p>
|
||
|
||
<h2>{{t "org.tree.title"}}</h2>
|
||
{{if .Teams}}
|
||
<table class="rows compact">
|
||
<tr><th>{{t "org.col.team"}}</th><th>{{t "org.col.projects"}}</th><th></th></tr>
|
||
{{range $tm := .Teams}}<tr>
|
||
<td><details class="edit"><summary>{{$tm.Name}}</summary>
|
||
<form method="post" action="/org/{{$.Org.ID}}/team/{{$tm.ID}}/rename" class="row">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||
<input type="text" name="name" value="{{$tm.Name}}" required aria-label="{{t "org.tree.rename"}}">
|
||
<button type="submit" class="small">{{t "org.tree.rename"}}</button>
|
||
</form></details></td>
|
||
<td>{{range $tm.Projects}}<span class="chip-m">{{.Name}}
|
||
<form method="post" action="/org/{{$.Org.ID}}/project/{{.ID}}/delete" class="inline" data-confirm="{{t "org.tree.remove_confirm" .Name}}">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||
<button type="submit" class="chip-x" title="{{t "org.tree.remove" .Name}}" aria-label="{{t "org.tree.remove" .Name}}">×</button>
|
||
</form></span>{{end}}
|
||
<form method="post" action="/org/{{$.Org.ID}}/projects" class="inline add-to">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="team" value="{{$tm.ID}}">
|
||
<input type="text" name="name" required placeholder="{{t "org.tree.new_project"}}" aria-label="{{t "org.tree.new_project_in" $tm.Name}}">
|
||
<button type="submit" class="small">{{t "common.add"}}</button>
|
||
</form></td>
|
||
<td class="num"><form method="post" action="/org/{{$.Org.ID}}/team/{{$tm.ID}}/delete" class="inline" data-confirm="{{t "org.tree.remove_confirm" $tm.Name}}">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||
<button type="submit" class="link danger">{{t "org.tree.remove_team"}}</button>
|
||
</form></td>
|
||
</tr>{{end}}
|
||
</table>
|
||
{{else}}<p class="empty">{{t "org.tree.empty"}}</p>{{end}}
|
||
<form method="post" action="/org/{{.Org.ID}}/teams" class="row">
|
||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||
<input type="text" name="name" required placeholder="{{t "org.tree.new_team"}}" aria-label="{{t "org.tree.new_team"}}">
|
||
<button type="submit">{{t "org.tree.add_team"}}</button>
|
||
</form>
|
||
<p class="note">{{t "org.tree.note"}}</p>
|
||
|
||
<h2>{{t "org.keys.title"}}</h2>
|
||
{{if .Keys}}
|
||
<table class="rows compact">
|
||
<tr><th>{{t "org.keys.col.key"}}</th><th>{{t "org.col.project"}}</th><th>{{t "org.keys.col.owner"}}</th><th>{{t "org.keys.col.used"}}</th><th></th></tr>
|
||
{{range .Keys}}<tr>
|
||
<td><code>{{.Prefix}}…</code> {{.Name}}</td><td>{{.ProjectPath}}</td><td>{{or .OwnerEmail "—"}}</td>
|
||
<td>{{if .LastUsedAt.IsZero}}—{{else}}{{ago .LastUsedAt}}{{end}}</td>
|
||
<td class="num"><form method="post" action="/org/{{$.Org.ID}}/keys/{{.ID}}/revoke" class="inline" data-confirm="{{t "org.keys.revoke_confirm" .Prefix}}">
|
||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||
<button type="submit" class="link danger">{{t "org.keys.revoke"}}</button>
|
||
</form></td>
|
||
</tr>{{end}}
|
||
</table>
|
||
{{else}}<p class="empty">{{t "org.keys.empty"}}</p>{{end}}
|
||
{{end}}
|
||
|
||
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
|
||
</div></body></html>{{end}}
|
||
|
||
|
||
{{define "org-none"}}<!doctype html>
|
||
<html lang="{{lang}}"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>{{t "org.none.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||
</head><body><div class="wrap">
|
||
<header>
|
||
<h1>{{t "org.none.title"}}</h1>
|
||
<p class="sub">{{t "org.none.sub"}}</p>
|
||
{{template "nav" .Nav}}
|
||
</header>
|
||
<p>{{t "org.none.about"}}</p>
|
||
<p>{{th "org.none.how"}}</p>
|
||
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
|
||
</div></body></html>{{end}}
|