diff --git a/CHANGELOG.md b/CHANGELOG.md index dff9d90..e032cb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,21 @@ and a figure the driver does not give is left out rather than shown as zero. each card and whose quota it counts against, for joining the GPU series to models and tenants. +### People, limits and usage are one page + +Managing a team meant its members on the people page, its quota on the quotas +page and its spend on the usage page, each linking to the others. They are now +one page in three tabs — **Organizations & people**, **Limits**, **Usage** — +each at the address it had before, so links and bookmarks still land. The menu +has one entry for them, People. + +Choosing an organization, team, project or person in the tree, or a person in +the account list, opens a panel beside it with everything about that one +thing: its own limits and what it is held to from above, spend against its +budget, its per-member limit, its capacity quota, and its requests, tokens +and spend over the last week, drawn. Its limits can be set or changed there, +and the answer comes back to the same panel. + ### The chat page says whether a GPU or a CPU answered A model can have replicas on a card and on CPUs at once, and the two answer diff --git a/docs/install.md b/docs/install.md index 631300c..97e0375 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1271,7 +1271,7 @@ requests. On a slow model, set `timeouts.generation` higher. can see each other, seeding between them is the obvious next thing to build. - **A budget without a price never binds.** Set `default_price` or `pricing` (step 2) or every request costs zero, and a budget is something the dashboard - displays rather than something that stops anything. The quotas page says so + displays rather than something that stops anything. The Limits tab says so where it can, which is not the same as being told before you rely on it. - **A V100 runs vLLM 0.19.1**, not the version the rest of the cluster runs: no build from 0.20 on has Volta kernels, and the node picks the newest that diff --git a/docs/onboarding.md b/docs/onboarding.md index 91cb1f5..b11ec8c 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -259,7 +259,7 @@ Allowlists compose by intersection: a key cannot add a model its team withheld. A model's canonical name and any of its aliases name the same thing, so either form works. -Budgets are given in whole units, as the quotas page shows them, and what a unit +Budgets are given in whole units, as the Limits tab shows them, and what a unit means is yours to decide. Self-hosted models have no per-token price, so it can be GPU-seconds, a notional rate, or one unit per thousand tokens everywhere — a price of 1 000 000 in `auth.pricing`, which is in micro-units — and a budget @@ -409,10 +409,19 @@ page exists. opening onto its members and its keys. From there an admin approves people (into a project or on their own), adds them to and takes them out of organizations, teams and projects, suspends and removes them, revokes keys, and -adds, renames, moves and removes organizations, teams and projects. `/admin/quotas` -sets limits on the tree, per member of any part of it, and per person. A searchable list of every account is at the -bottom. The page works as plain forms; with scripts on, a change updates it in -place and the projects you had open stay open. +adds, renames, moves and removes organizations, teams and projects. A +searchable list of every account is at the bottom. Choosing any organization, +team, project or person opens a panel beside the tree with its limits, its +capacity quota and its use over the last week, and its limits can be changed +there. + +The same page has two more tabs, each at the address it had as a page of its +own: **Limits** (`/admin/quotas`) is every limit in the tree at once — per +branch, per member of any part of it, and per person — with the capacity quotas; +**Usage** (`/admin/usage`) is the cluster's use over time by organization, team, +project or person, and the GPU time each owner held. The page works as plain +forms; with scripts on, a change updates it in place and the projects you had +open stay open. Three things are deliberately not there: diff --git a/internal/web/account.go b/internal/web/account.go index c47a52f..60c36b3 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -88,6 +88,13 @@ type AccountPages struct { // could run it. Both nil where the mirror page is read-only. catalog CatalogAdmin fleet Source + // The stores behind the people page's three tabs — the tree and its + // people, the limits, the usage — each set when its part is mounted, so + // the page draws the parts that exist. + peopleStore AccountAdmin + quotaStore QuotaAdmin + usageStore UsageAdmin + usagePriced Priced } // ServedModels supplies the names the profile page's example should use. diff --git a/internal/web/admin_people.go b/internal/web/admin_people.go new file mode 100644 index 0000000..7cfe35d --- /dev/null +++ b/internal/web/admin_people.go @@ -0,0 +1,259 @@ +package web + +import ( + "context" + "net/http" + "strings" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// The people page's tabs. One page, because people, the limits they are held +// to and what they used are three views of one tree: managing a team meant its +// members on one page, its quota on another and its spend on a third, each +// linking to the others. Each tab keeps the address it had as a page of its +// own, so links and bookmarks to /admin/quotas and /admin/usage still land. +const ( + tabPeople = "people" + tabLimits = "limits" + tabUsage = "usage" +) + +// peopleView is what a request asks the people page for. +type peopleView struct { + Tab string + Msg, Err string + // Open is the project shown expanded in the tree. + Open string +} + +type peopleData struct { + Nav Nav + Tab string + // The tabs this server has, each built only when it is the one shown. + HasPeople, HasLimits, HasUsage bool + Users *adminUsersData + Quotas *adminQuotasData + Usage *adminUsageData + // Detail is the branch or person chosen in the tree, nil when none is. + Detail *peopleDetail + // Sel is what was chosen, "team:" or "user:", for links to keep. + Sel string + Pending int + Version string + Now string +} + +// peopleDetail is one organization, team, project or person, with everything +// the three tabs say about it in one place. +type peopleDetail struct { + Kind store.Scope + ID string + // Name is what it is called, Path where it sits. + Name, Path string + + // Limits is its own quota row, with what it is held to from above; nil + // for a person, or where limits are not mounted. + Limits *quotaRow + // Person is a person's own limit. + Person *personRow + // Memberships are where a person is. + Memberships []store.Membership + // Capacity is its capacity quota, nil when it has none. + Capacity *capacityRow + HasCapacity bool + BudgetsBind bool + + // Its use over the last week, and the totals beside it. + HasUsage bool + Requests, Tokens, Spend TimeSeries + Totals usageRow + Priced bool + UsageWindow, QuotaWindow string + + CSRF string +} + +// renderPeople draws the people page on the tab asked for. +func (p *AccountPages) renderPeople(w http.ResponseWriter, r *http.Request, admin store.User, v peopleView) { + d := peopleData{ + Nav: navFrom(r, "people"), Tab: v.Tab, + HasPeople: p.peopleStore != nil, HasLimits: p.quotaStore != nil, HasUsage: p.usageStore != nil, + Sel: r.FormValue("sel"), + Version: p.version, Now: time.Now().UTC().Format(time.RFC3339), + } + switch { + case d.Tab == tabPeople && d.HasPeople: + u := p.buildUsers(r, p.peopleStore, admin, v.Msg, v.Err, v.Open) + d.Users, d.Pending = &u, len(u.Pending) + if d.Sel != "" { + d.Detail = p.detail(r, d.Sel, &u) + } + case d.Tab == tabLimits && d.HasLimits: + q := p.buildQuotas(r, p.quotaStore, v.Msg, v.Err) + d.Quotas = &q + case d.Tab == tabUsage && d.HasUsage: + u := p.buildUsage(r, p.usageStore, p.usagePriced) + d.Usage = &u + default: + // The tab asked for is not mounted here: the first one that is. + switch { + case d.HasPeople: + v.Tab = tabPeople + case d.HasLimits: + v.Tab = tabLimits + case d.HasUsage: + v.Tab = tabUsage + default: + http.NotFound(w, r) + return + } + p.renderPeople(w, r, admin, v) + return + } + render(w, "admin_people", d) +} + +// usageGrouping is the grouping a branch's or a person's use is read under. +func usageGrouping(kind store.Scope) (store.Grouping, bool) { + switch kind { + case store.ScopeOrg: + return store.GroupByOrg, true + case store.ScopeTeam: + return store.GroupByTeam, true + case store.ScopeProject: + return store.GroupByProject, true + case store.ScopeUser: + return store.GroupByUser, true + } + return "", false +} + +// detail gathers what the page knows about one branch or person. +func (p *AccountPages) detail(r *http.Request, sel string, u *adminUsersData) *peopleDetail { + kind, id, ok := strings.Cut(sel, ":") + if !ok || id == "" { + return nil + } + ctx := r.Context() + d := &peopleDetail{Kind: store.Scope(kind), ID: id, CSRF: csrfFor(r), + UsageWindow: "last 7 days", QuotaWindow: "30 days"} + if !p.nameDetail(d, u) { + return nil + } + + if p.quotaStore != nil { + d.BudgetsBind = p.priced != nil && p.priced.AnythingPriced() + p.limitsDetail(ctx, d) + } + if p.capacity != nil && d.Kind != store.ScopeUser { + d.HasCapacity = true + if views, err := p.capacity.QuotaViews(ctx); err == nil { + for _, v := range views { + if v.Scope == sel { + c := capacityRow{QuotaView: v, + GPUs: capCell(v.Holds.GPUs, v.Guarantee.GPUs, v.Limit.GPUs), + Cores: capCell(v.Holds.CPUCores, v.Guarantee.CPUCores, v.Limit.CPUCores), + Memory: capCell(v.Holds.MemoryGB, v.Guarantee.MemoryGB, v.Limit.MemoryGB)} + d.Capacity = &c + } + } + } + } + if p.usageStore != nil { + p.usageDetail(ctx, d) + } + return d +} + +// nameDetail finds what was chosen in the tree or among the accounts, and +// says whether it is there at all. +func (p *AccountPages) nameDetail(d *peopleDetail, u *adminUsersData) bool { + if d.Kind == store.ScopeUser { + for _, a := range u.Accounts { + if a.ID == d.ID { + d.Name, d.Path, d.Memberships = a.Email, a.Name, a.Memberships + return true + } + } + return false + } + for _, o := range u.Tree { + if d.Kind == store.ScopeOrg && o.ID == d.ID { + d.Name, d.Path = o.Name, o.Name + return true + } + for _, t := range o.Teams { + if d.Kind == store.ScopeTeam && t.ID == d.ID { + d.Name, d.Path = t.Name, o.Name+" / "+t.Name + return true + } + for _, pr := range t.Projects { + if d.Kind == store.ScopeProject && pr.ID == d.ID { + d.Name, d.Path = pr.Name, o.Name+" / "+t.Name+" / "+pr.Name + return true + } + } + } + } + return false +} + +// limitsDetail is the branch's own quota row, or the person's own limit. +func (p *AccountPages) limitsDetail(ctx context.Context, d *peopleDetail) { + s := p.quotaStore + limits, err := s.AllLimits(ctx) + if err != nil { + p.log.Warn("could not read the limits for a detail panel", "err", err) + return + } + since := time.Now().Add(-quotaWindow) + if d.Kind == store.ScopeUser { + for _, row := range p.personRows(ctx, s, limits, since, "") { + if row.ID == d.ID { + row := row + d.Person = &row + } + } + return + } + tree, err := s.Tree(ctx) + if err != nil { + return + } + for _, row := range quotaRows(tree, limits, p.spendByScope(ctx, s, since)) { + if row.Scope == d.Kind && row.ID == d.ID { + ms := store.MemberScope(row.Scope) + if l, ok := limits[store.LimitOwner{Scope: ms, ID: row.ID}.CounterKey()]; ok { + row.MemberRaw, row.Member = fieldsOf(l), describeLimits(l) + } + d.Limits = &row + return + } + } +} + +// usageDetail is the branch's or person's use over the last week, drawn. +func (p *AccountPages) usageDetail(ctx context.Context, d *peopleDetail) { + g, ok := usageGrouping(d.Kind) + if !ok { + return + } + win := windowFor("7d") + rows, err := p.usageStore.UsageBucketsBy(ctx, g, time.Now().Add(-win.Span), win.Width) + if err != nil { + p.log.Warn("could not read usage for a detail panel", "sel", string(d.Kind)+":"+d.ID, "err", err) + return + } + d.HasUsage = true + d.Priced = p.usagePriced != nil && p.usagePriced.AnythingPriced() + for _, s := range rows { + if s.ID == d.ID { + var table []usageRow + d.Requests, d.Tokens, d.Spend, table = usageCharts([]store.UsageSeries{s}) + d.Totals = table[0] + return + } + } +} diff --git a/internal/web/admin_people_test.go b/internal/web/admin_people_test.go new file mode 100644 index 0000000..c9e1fed --- /dev/null +++ b/internal/web/admin_people_test.go @@ -0,0 +1,87 @@ +package web + +import ( + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// peoplePage mounts all three tabs, as the server does. +func peoplePage(t *testing.T, q *fakeQuotas, u *fakeScopeSeries) *http.ServeMux { + t.Helper() + a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}} + p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil))) + mux := http.NewServeMux() + p.RegisterAdminUsers(mux, &fakeAdmin{orgs: q.orgs}) + p.RegisterAdminQuotas(mux, q, pricedYes{}) + p.RegisterAdminUsage(mux, u, pricedYes{}) + return mux +} + +func TestABranchChosenInTheTreeShowsItsLimitsAndUseTogether(t *testing.T) { + // People, their limits and their use were three pages: a team's members + // on one, its quota on another, its spend on a third. + q := oneTeam() + rpm := 60 + q.limits["org:org-1"] = store.Limits{Scope: store.ScopeOrg, ScopeID: "org-1", RPM: &rpm} + u := &fakeScopeSeries{byGroup: map[store.Grouping][]store.UsageSeries{ + store.GroupByOrg: {{Grouping: store.GroupByOrg, ID: "org-1", Name: "Scania", Requests: 1234, + Buckets: []store.Bucket{{Start: time.Now().Add(-time.Hour), Requests: 1234}}}}, + }} + body := strings.Join(strings.Fields( + mirrorGet(t, peoplePage(t, q, u), "/admin/users?sel=org:org-1").Body.String()), " ") + + for _, want := range []string{ + `class="people-detail"`, "

Scania

", + "
Requests/min
60
", // its own limit + "1.2k requests", // its use over the week + `name="sel" value="org:org-1"`, // changing it comes back here + `aria-current="page">Organizations & people`, + } { + if !strings.Contains(body, want) { + t.Errorf("the panel lacks %q", want) + } + } + if u.asked[len(u.asked)-1] != store.GroupByOrg { + t.Errorf("use was read by %v, not by organization", u.asked) + } +} + +func TestAChangeMadeInAPanelComesBackToThePanel(t *testing.T) { + q := oneTeam() + body := mirrorPost(t, peoplePage(t, q, &fakeScopeSeries{}), "/admin/quotas", url.Values{ + "csrf": {"tok"}, "scope": {"org"}, "id": {"org-1"}, "rpm": {"30"}, + "tab": {"people"}, "sel": {"org:org-1"}, + }).Body.String() + if len(q.put) != 1 || *q.put[0].RPM != 30 { + t.Fatalf("the limit was not saved: %+v", q.put) + } + if !strings.Contains(body, `class="people-detail"`) || !strings.Contains(body, "rpm 30") { + t.Error("saving from the panel did not come back to it with the result") + } +} + +func TestEachOldAddressOpensItsTab(t *testing.T) { + mux := peoplePage(t, oneTeam(), &fakeScopeSeries{}) + for path, tab := range map[string]string{ + "/admin/users": "Organizations & people", "/admin/quotas": "Limits", "/admin/usage": "Usage", + } { + body := mirrorGet(t, mux, path).Body.String() + if !strings.Contains(body, `aria-current="page">`+tab) { + t.Errorf("%s does not open the %s tab", path, tab) + } + } +} + +func TestSomethingNotInTheTreeOpensNoPanel(t *testing.T) { + body := mirrorGet(t, peoplePage(t, oneTeam(), &fakeScopeSeries{}), "/admin/users?sel=org:gone").Body.String() + if strings.Contains(body, `class="people-detail"`) { + t.Error("a panel opened for an organization that does not exist") + } +} diff --git a/internal/web/admin_quotas.go b/internal/web/admin_quotas.go index 3c37d1e..b3bcbf3 100644 --- a/internal/web/admin_quotas.go +++ b/internal/web/admin_quotas.go @@ -159,10 +159,11 @@ type adminQuotasData struct { func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, priced Priced) { p.hasQuotas = true p.priced = priced + p.quotaStore = s mux.HandleFunc("GET /admin/quotas", p.adminOnly( - func(w http.ResponseWriter, r *http.Request, _ store.User) { - p.renderQuotas(w, r, s, "", "") + func(w http.ResponseWriter, r *http.Request, admin store.User) { + p.renderPeople(w, r, admin, peopleView{Tab: tabLimits}) })) mux.HandleFunc("POST /admin/quotas", p.adminOnly( @@ -177,10 +178,10 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri p.log.Warn("a quota change was refused", "admin", admin.Email, "scope", r.FormValue("scope"), "id", r.FormValue("id"), "err", err) - p.renderQuotas(w, r, s, "", err.Error()) + p.renderQuotas(w, r, admin, "", err.Error()) return } - p.renderQuotas(w, r, s, msg, "") + p.renderQuotas(w, r, admin, msg, "") })) mux.HandleFunc("POST /admin/quotas/capacity", p.adminOnly( @@ -194,11 +195,11 @@ func (p *AccountPages) RegisterAdminQuotas(mux *http.ServeMux, s QuotaAdmin, pri if err != nil { p.log.Warn("a capacity quota change was refused", "admin", admin.Email, "scope", r.FormValue("scope"), "err", err) - p.renderQuotas(w, r, s, "", err.Error()) + p.renderQuotas(w, r, admin, "", err.Error()) return } p.log.Info("capacity quota changed", "admin", admin.Email, "what", msg) - p.renderQuotas(w, r, s, msg, "") + p.renderQuotas(w, r, admin, msg, "") })) } @@ -270,10 +271,24 @@ func (p *AccountPages) applyQuota(r *http.Request, s QuotaAdmin, admin store.Use return string(scope) + " " + id + ": " + now, nil } +// renderQuotas answers a change to a limit: on the limits tab, or on the +// people tab when it was made from a branch's own panel there. func (p *AccountPages) renderQuotas(w http.ResponseWriter, r *http.Request, - s QuotaAdmin, msg, errMsg string, + admin store.User, msg, errMsg string, ) { - const window = 30 * 24 * time.Hour + tab := tabLimits + if r.FormValue("tab") == tabPeople { + tab = tabPeople + } + p.renderPeople(w, r, admin, peopleView{Tab: tab, Msg: msg, Err: errMsg}) +} + +// quotaWindow is how far back spend against a budget is counted. +const quotaWindow = 30 * 24 * time.Hour + +// buildQuotas is every limit, as the limits tab shows them. +func (p *AccountPages) buildQuotas(r *http.Request, s QuotaAdmin, msg, errMsg string) adminQuotasData { + const window = quotaWindow d := adminQuotasData{ Nav: navFrom(r, "quotas"), Since: "30 days", Message: msg, Error: errMsg, @@ -289,8 +304,7 @@ func (p *AccountPages) renderQuotas(w http.ResponseWriter, r *http.Request, if err != nil { p.log.Error("could not read the limits", "err", err) d.Error = "could not read the limits" - render(w, "admin_quotas", d) - return + return d } spend := p.spendByScope(r.Context(), s, time.Now().Add(-window)) @@ -314,7 +328,7 @@ func (p *AccountPages) renderQuotas(w http.ResponseWriter, r *http.Request, d.Rows[i].Editing = d.Editing == string(d.Rows[i].Scope)+":"+d.Rows[i].ID } - render(w, "admin_quotas", d) + return d } // spendByScope is what each group has used in the window, keyed like a limit. diff --git a/internal/web/admin_usage.go b/internal/web/admin_usage.go index 2092508..4f0d0b2 100644 --- a/internal/web/admin_usage.go +++ b/internal/web/admin_usage.go @@ -99,15 +99,15 @@ type adminUsageData struct { // answers "who is using the cluster, and when" — including per person, which is // not a level quotas can be written on at all. func (p *AccountPages) RegisterAdminUsage(mux *http.ServeMux, s UsageAdmin, priced Priced) { - mux.HandleFunc("GET /admin/usage", p.AdminPage( - func(w http.ResponseWriter, r *http.Request) { - p.renderUsage(w, r, s, priced) + p.usageStore, p.usagePriced = s, priced + mux.HandleFunc("GET /admin/usage", p.adminOnly( + func(w http.ResponseWriter, r *http.Request, admin store.User) { + p.renderPeople(w, r, admin, peopleView{Tab: tabUsage}) })) } -func (p *AccountPages) renderUsage(w http.ResponseWriter, r *http.Request, - s UsageAdmin, priced Priced, -) { +// buildUsage is the cluster's use over time, as the usage tab shows it. +func (p *AccountPages) buildUsage(r *http.Request, s UsageAdmin, priced Priced) adminUsageData { by := store.Grouping(r.URL.Query().Get("by")) if !by.Valid() { by = store.GroupByTeam @@ -137,13 +137,12 @@ func (p *AccountPages) renderUsage(w http.ResponseWriter, r *http.Request, if err != nil { p.log.Error("could not read usage over time", "by", by, "err", err) d.Error = "could not read usage" - render(w, "admin_usage", d) - return + return d } d.Requests, d.Tokens, d.Spend, d.Rows = usageCharts(rows) d.GPUHeld, d.GPUHours = p.gpuHours(r.Context(), win) - render(w, "admin_usage", d) + return d } // gpuHoursRow is the GPU time one owner's models held over the window. diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go index 5e52e67..fd82d3b 100644 --- a/internal/web/admin_users.go +++ b/internal/web/admin_users.go @@ -87,6 +87,9 @@ type adminUsersData struct { Projects []store.ProjectPath Orgs []store.Org Teams []store.TeamPath + // Sel is the branch or person whose panel is open, "team:", so the + // tree can mark it. + Sel string // Open is the project whose section is shown expanded: the one just acted // on, so the result of a change is in view without the reader hunting for // it. Scripted pages keep every open section open; this is for the rest. @@ -115,6 +118,7 @@ type adminUsersData struct { // shell is no hardship. Revoking a key is here, because it only ever takes // access away. func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { + p.peopleStore = s mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { p.renderUsers(w, r, s, admin, "", "", r.URL.Query().Get("open")) })) @@ -480,12 +484,17 @@ func (p *AccountPages) adminAction(s AccountAdmin, } func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request, - s AccountAdmin, admin store.User, msg, errMsg, open string, + _ AccountAdmin, admin store.User, msg, errMsg, open string, ) { + p.renderPeople(w, r, admin, peopleView{Tab: tabPeople, Msg: msg, Err: errMsg, Open: open}) +} + +// buildUsers is the tree and its people, as the first tab shows them. +func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.User, msg, errMsg, open string) adminUsersData { ctx := r.Context() d := adminUsersData{ Nav: navFrom(r, "people"), - Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, + Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, Sel: r.FormValue("sel"), CSRF: csrfFor(r), Version: p.version, HasMirror: p.hasMirror, @@ -558,7 +567,7 @@ func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request, d.Scopes = append(d.Scopes, scopeOption{"project:" + pp.ID, pp.Path}) } sort.Slice(d.Scopes, func(i, j int) bool { return d.Scopes[i].Label < d.Scopes[j].Label }) - render(w, "admin_users", d) + return d } func keyState(k store.Key, now time.Time) string { diff --git a/internal/web/nav_test.go b/internal/web/nav_test.go index cdef01e..e887861 100644 --- a/internal/web/nav_test.go +++ b/internal/web/nav_test.go @@ -25,7 +25,8 @@ func TestEveryPageDrawsTheSameMenu(t *testing.T) { p.RegisterAdminMirror(mux, MirrorDeps{Mirror: &fakeMirror{models: []mirror.Status{}}}) pages := map[string]string{ - "/admin/users": "People", "/admin/quotas": "Quotas", "/admin/mirror": "Mirror", + // Limits and usage are tabs of the people page, which they mark. + "/admin/users": "People", "/admin/quotas": "People", "/admin/mirror": "Mirror", } var first string for path, current := range pages { @@ -44,7 +45,7 @@ func TestEveryPageDrawsTheSameMenu(t *testing.T) { t.Errorf("%s draws a different menu:\n%s\nfrom:\n%s", path, plain, first) } } - for _, link := range []string{"/admin", "/admin/users", "/admin/quotas", "/admin/usage", + for _, link := range []string{"/admin", "/admin/users", "/admin/mirror", "/chat", "/profile", "/logout"} { if !strings.Contains(first, `"`+link+`"`) { t.Errorf("the menu lacks %s", link) diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 3dbae5c..0dd8266 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -869,3 +869,36 @@ table.rows td.warn { color: var(--warn); cursor: help; } .gpu-node { width: 7em; flex: none; font-size: 12px; color: var(--muted, #666); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gpu-cell { width: 26px; height: 26px; flex: none; border: 1px solid rgba(0,0,0,.18); border-radius: 4px; font-size: 10px; line-height: 26px; text-align: center; color: rgba(0,0,0,.7); cursor: default; } + +/* The people page: one page in tabs, and a panel for the branch or person + chosen in the tree, beside it on a wide screen and above it on a narrow one. */ +.page-tabs { display: flex; flex-wrap: wrap; gap: .25rem; border-bottom: 1px solid var(--line); margin: .25rem 0 1rem; } +.page-tabs a { padding: .45rem .8rem; color: var(--muted); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; } +.page-tabs a:hover { color: var(--fg); } +.page-tabs a[aria-current="page"] { color: var(--fg); border-bottom-color: var(--accent); font-weight: 600; } +.people-grid.with-detail { display: grid; gap: 1.25rem; grid-template-columns: minmax(0, 1fr) minmax(0, 24rem); align-items: start; } +.people-grid.with-detail .people-detail { grid-column: 2; grid-row: 1; position: sticky; top: .75rem; max-height: calc(100vh - 1.5rem); overflow-y: auto; } +.people-grid.with-detail .people-main { grid-column: 1; grid-row: 1; min-width: 0; } +@media (max-width: 960px) { + .people-grid.with-detail { grid-template-columns: minmax(0, 1fr); } + .people-grid.with-detail .people-detail { grid-column: 1; grid-row: 1; position: static; max-height: none; } + .people-grid.with-detail .people-main { grid-row: 2; } +} +.people-detail { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: .9rem 1rem; } +.people-detail h2 { margin: .1rem 0 0; font-size: 1.15rem; } +.people-detail h3 { margin: 1rem 0 .4rem; font-size: .9rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.people-detail .chart-wrap { margin-bottom: .75rem; } +.people-detail .q-form .field-row { flex-wrap: wrap; } +.pd-head { position: relative; padding-right: 1.5rem; } +.pd-path, .pd-sub { margin: .2rem 0 0; font-size: .85rem; color: var(--muted); } +.pd-close { position: absolute; top: 0; right: 0; font-size: 1.3rem; line-height: 1; color: var(--muted); text-decoration: none; } +.pd-limits { display: grid; grid-template-columns: auto 1fr; gap: .25rem .9rem; margin: 0; font-size: .88rem; } +.pd-limits dt { color: var(--muted); } +.pd-limits dd { margin: 0; font-variant-numeric: tabular-nums; } +a.sel-link { color: inherit; text-decoration: none; border-bottom: 1px dotted var(--line); } +a.sel-link:hover { color: var(--accent); border-bottom-color: var(--accent); } +a.sel-link.on { color: var(--accent); border-bottom: 2px solid var(--accent); } +a.sel-link.small { font-size: .78rem; color: var(--muted); margin-left: .4rem; } +.people-detail h3.chart-title { text-transform: none; letter-spacing: 0; color: var(--fg); font-size: .85rem; margin: .2rem 0; } +.people-detail details.edit { display: inline-block; margin-right: 1rem; vertical-align: top; } +.people-detail details.edit[open] { display: block; margin: .5rem 0; } diff --git a/internal/web/templates/admin_people.html b/internal/web/templates/admin_people.html new file mode 100644 index 0000000..bf53062 --- /dev/null +++ b/internal/web/templates/admin_people.html @@ -0,0 +1,148 @@ +{{/* 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"}} + + +People · llmgw + + +
+
+

People

+

Organizations, teams, projects and the people in them: what each may use, and what it used

+ {{template "nav" .Nav}} +
+ +
+ + + +{{if .Users}} +
+ {{with .Detail}}{{end}} +
{{template "people_body" .Users}}
+
+{{else if .Quotas}}{{template "limits_body" .Quotas}} +{{else if .Usage}}{{template "usage_body" .Usage}} +{{end}} +
+ +
llmgw {{.Version}}{{.Now}}
+
+ +{{end}} + +{{/* One branch or person: its limits, its capacity and its use, together. */}} +{{define "people-detail"}}{{$d := .}}{{$sel := printf "%s:%s" .Kind .ID}} +
+ {{if eq (printf "%s" .Kind) "user"}}person{{else}}{{.Kind}}{{end}} +

{{.Name}}

+ {{if and .Path (ne .Path .Name)}}

{{.Path}}

{{end}} + × +
+ +{{with .Memberships}}

In {{range $i, $m := .}}{{if $i}}, {{end}}{{$m.Path}}{{end}}

+{{else}}{{if eq (printf "%s" $d.Kind) "user"}}

On their own: in no organization, team or project.

{{end}}{{end}} + +{{if or .Limits .Person}} +

Limits

+{{if not .BudgetsBind}}

No model has a price, so budgets stay at zero; rate and token limits apply.

{{end}} +{{with .Limits}} +
+
Requests/min
{{template "quota-value" dict "Own" .RPM "Up" .RPMUp "From" .RPMFrom}}
+
Tokens/min
{{template "quota-value" dict "Own" .TPM "Up" .TPMUp "From" .TPMFrom}}
+
Budget
{{if .Budget}}{{.Budget}}{{if .Period}}/{{.Period}}{{end}}{{else}}{{template "quota-value" dict "Own" "" "Up" .BudgetUp "From" .BudgetFrom}}{{end}}
+
Spent, {{$d.QuotaWindow}}
{{.Spend}}{{if .HasPct}} {{.Percent}}%{{end}}
+
Models
{{if .Models}}only {{.Models}}{{else if .ModelsFrom}}limited by {{.ModelsFrom}}{{else}}every model{{end}}
+ {{if .Member}}
Each member
{{.Member}}
{{end}} +
+{{if .Shadowed}}

{{.Shadowed}}

{{end}} +
{{if .HasAny}}change{{else}}set{{end}} limits + {{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.")}} +
+
limit each member + {{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.")}} +
+{{end}} +{{with .Person}} +
+
Requests/min
{{or .RPM "—"}}
+
Tokens/min
{{or .TPM "—"}}
+
Budget
{{if .Budget}}{{.Budget}}{{if .Period}}/{{.Period}}{{end}}{{else}}—{{end}}
+
Spent, {{$d.QuotaWindow}}
{{.Spend}}{{if .HasPct}} {{.Percent}}%{{end}}
+
Models
{{if .Models}}only {{.Models}}{{else}}every model{{end}}
+
+
{{if .HasAny}}change{{else}}set{{end}} limits + {{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.")}} +
+{{end}} +{{end}} + +{{if .HasCapacity}} +

Capacity

+{{with .Capacity}} +
+
GPUs
{{or .GPUs "—"}}
+
Cores
{{or .Cores "—"}}
+
Memory GB
{{or .Memory "—"}}
+
+

Held of guaranteed (at most the limit). change

+{{else}}

No capacity quota: its models share the cluster by their class alone. +set one

{{end}} +{{end}} + +{{if .HasUsage}} +

Use, {{.UsageWindow}}

+{{if .Totals.Name}} +

{{compact .Totals.Requests}} requests · {{compact .Totals.Tokens}} tokens{{if .Priced}} · {{.Totals.Spend}} spent{{end}}{{if .Totals.Errors}} · {{.Totals.Errors}} errors{{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}}

Nothing used in this window.

{{end}} +{{end}} +{{end}} diff --git a/internal/web/templates/admin_quotas.html b/internal/web/templates/admin_quotas.html index 67f85dd..0e426e5 100644 --- a/internal/web/templates/admin_quotas.html +++ b/internal/web/templates/admin_quotas.html @@ -1,20 +1,5 @@ -{{define "admin_quotas"}} - - -Quotas · llmgw -{{/* Links and forms work as plain navigation; htmx, when it loads, swaps only - #quotas so opening a form or saving one does not jump the page. */}} - - -
-
-

Quotas

-

What each organization, team and project may use · spend over the last {{.Since}}

- {{template "nav" .Nav}} -
- -
+{{/* The limits tab of the people page. */}} +{{define "limits_body"}} {{if not .BudgetsBind}} {{/* A budget is spend per period and spend comes from a price table. With no @@ -112,7 +97,7 @@ budgets bind.

up — hover it to see where. A value set here is in normal type.

{{else}}

Nothing to give a quota to yet. Make an organization on the -people page first.

+Organizations & people tab first.

{{end}}

People

@@ -198,7 +183,7 @@ asked. A model's owner is set on its card on the mirror page.

{{if .CapForm.Scope}}Cancel{{end}}
{{else}}

Nothing to give a capacity quota to yet. Make an organization on the -people page first.

{{end}} +Organizations & people tab first.

{{end}} {{end}}
@@ -211,10 +196,8 @@ asked. A model's owner is set on its card on the mirror page.

putting every caller's throughput one click from an admin session is more than this page should do — llmgw keys limits --scope key.

-
+{{end}} -
llmgw {{.Version}}{{.Now}}
-{{end}} {{/* A limit as the table shows it: the row's own value, else the one it is held to from above, marked as inherited and saying from where. */}} @@ -224,6 +207,8 @@ asked. A model's owner is set on its card on the mirror page.

+ {{/* From a panel on the people tab: the answer comes back to that panel. */}} + {{with .Sel}}{{end}}

{{.Title}}. {{.Note}} A blank field means no limit of that kind.

@@ -239,7 +224,7 @@ asked. A model's owner is set on its card on the mirror page.

- Cancel + {{if not .Sel}}Cancel{{end}}
diff --git a/internal/web/templates/admin_usage.html b/internal/web/templates/admin_usage.html index 7f1ed00..456960b 100644 --- a/internal/web/templates/admin_usage.html +++ b/internal/web/templates/admin_usage.html @@ -1,13 +1,5 @@ -{{define "admin_usage"}} - - -Usage · llmgw -
-
-

Usage over time

-

Who is using the cluster, and when

- {{template "nav" .Nav}} -
+{{/* The usage tab of the people page. */}} +{{define "usage_body"}} {{if .Error}}

{{.Error}}

{{end}} @@ -88,7 +80,6 @@ is every group, in the same order.

A person is not a level of the quota tree — one person holds keys in several projects — so grouping by person crosses the tree rather than naming a level of it. Quotas are written on the other three, on the -quotas page.

+Limits tab.

-
llmgw {{.Version}}{{.Now}}
-
{{end}} +{{end}} diff --git a/internal/web/templates/admin_users.html b/internal/web/templates/admin_users.html index 68072e5..74773d0 100644 --- a/internal/web/templates/admin_users.html +++ b/internal/web/templates/admin_users.html @@ -1,21 +1,5 @@ -{{define "admin_users"}} - - -People · llmgw -{{/* Forms here post and get the whole page back, which works with no script at - all. htmx, when it loads, swaps only #people from that page instead of - reloading it, so an open project stays open and the page does not jump. */}} - - -
-
-

People

-

{{len .Pending}} waiting for approval · {{len .Accounts}} accounts · {{len .Projects}} projects

- {{template "nav" .Nav}} -
- -
+{{/* The first tab of the people page: the tree, and everyone in it. */}} +{{define "people_body"}} {{if .Error}}{{end}} {{if .Message}}

{{.Message}}

{{end}} @@ -47,8 +31,9 @@

Organizations, teams and projects

A person is in a project, a project in a team, a team in an -organization{{if .HasQuotas}}, and quotas can be set -on any of them{{end}}. Open a project to see its people and keys.

+organization{{if .HasQuotas}}, and limits can be set +on any of them{{end}}. Choose a name — or a person in the list below — to see +its limits, capacity and use beside the tree; open a project for its people and keys.

{{if not .Tree}}

No organizations yet. Create one, then a team in it, then a @@ -58,7 +43,7 @@ project in the team — an account can only be approved into a project.

{{range $org := .Tree}}
- {{$org.Name}} + {{$org.Name}} {{len $org.Teams}} team{{if ne (len $org.Teams) 1}}s{{end}} {{template "scope-members" (dict "Kind" "org" "ID" $org.ID "Name" $org.Name "D" $)}}
rename @@ -80,7 +65,7 @@ project in the team — an account can only be approved into a project.

{{range $team := $org.Teams}}
- {{$team.Name}} + {{$team.Name}} {{len $team.Projects}} project{{if ne (len $team.Projects) 1}}s{{end}} {{template "scope-members" (dict "Kind" "team" "ID" $team.ID "Name" $team.Name "D" $)}}
rename @@ -114,6 +99,7 @@ project in the team — an account can only be approved into a project.

{{$p.Name}} + limits & use {{$p.Users}} member{{if ne $p.Users 1}}s{{end}} · {{$p.ActiveKeys}} key{{if ne $p.ActiveKeys 1}}s{{end}}{{if gt $p.Keys $p.ActiveKeys}} ({{sub $p.Keys $p.ActiveKeys}} revoked){{end}} @@ -222,42 +208,8 @@ and issuing a key for someone else. Each turns a stolen admin session into more access than it had, so they need a shell on the control plane — llmgw users promote, llmgw users reset-password, llmgw keys create.

-
+{{end}} -
llmgw {{.Version}}{{.Now}}
-
- -{{end}} {{/* The actions every account row carries: suspend or reinstate, remove. */}} {{define "state-actions"}}{{$u := .U}}{{$d := .D}} @@ -295,7 +247,7 @@ access than it had, so they need a shell on the control plane — they could be. */}} {{define "account-row"}}{{$u := .U}}{{$d := .D}} - {{$u.Email}}{{if $u.IsAdmin}} admin{{end}} + {{$u.Email}}{{if $u.IsAdmin}} admin{{end}} {{$u.State}} {{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}{{.Path}}{{if eq .Role "manager"}} manager{{end}} diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html index 8314ba2..39c9718 100644 --- a/internal/web/templates/nav.html +++ b/internal/web/templates/nav.html @@ -8,11 +8,8 @@ {{- end}} {{- if .Admin}} Cluster + {{- /* Limits and usage are tabs of the people page, not pages of their own. */}} People - {{- if .Quotas}} - Quotas - Usage - {{- end}} {{- if .Mirror}} Mirror {{- end}}