With every organization removed, /org answered an administrator with a 404 while the menu kept offering it. An administrator now gets a page saying there are none and where to make one, and the menu offers Organizations only when there is one to show (to a manager, one they manage). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
218 lines
6.4 KiB
Go
218 lines
6.4 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// OrgViewer is what an organization's page reads. Nothing on it writes.
|
|
type OrgViewer interface {
|
|
ManagesOrg(ctx context.Context, userID, orgID string) (bool, error)
|
|
ManagedOrgs(ctx context.Context, userID string) ([]store.Org, error)
|
|
AllOrgs(ctx context.Context) ([]store.Org, error)
|
|
Tree(ctx context.Context) ([]store.TreeOrg, error)
|
|
AllMemberships(ctx context.Context) ([]store.Membership, error)
|
|
Users(ctx context.Context, state store.UserState) ([]store.User, error)
|
|
AllLimits(ctx context.Context) (map[string]store.Limits, error)
|
|
UsageBucketsIn(ctx context.Context, g store.Grouping, orgID string,
|
|
since time.Time, width time.Duration) ([]store.UsageSeries, error)
|
|
UsageByScope(ctx context.Context, scope store.Scope, since time.Time) ([]store.ScopeUsage, error)
|
|
}
|
|
|
|
// orgMember is a person as an organization's page lists them: once, with
|
|
// every place in the organization they are a member of.
|
|
type orgMember struct {
|
|
Email string
|
|
State store.UserState
|
|
Places []orgPlace
|
|
}
|
|
|
|
type orgPlace struct{ Where, Role string }
|
|
|
|
type orgPageData struct {
|
|
Nav Nav
|
|
Org store.Org
|
|
Orgs []store.Org // the others this person may look at
|
|
|
|
Groups []usageGroup
|
|
Windows []struct {
|
|
Key, Label string
|
|
Selected bool
|
|
}
|
|
By, Window string
|
|
|
|
Requests TimeSeries
|
|
Tokens TimeSeries
|
|
Spend TimeSeries
|
|
Rows []usageRow
|
|
Priced bool
|
|
|
|
Quotas []quotaRow
|
|
Members []orgMember
|
|
People int
|
|
|
|
Error string
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// RegisterOrgPages mounts /org: an organization's own view of itself, for the
|
|
// people who manage it.
|
|
//
|
|
// Read-only on purpose. A manager sees their organization's use, members and
|
|
// limits — what they are accountable for — without being made an administrator
|
|
// of the whole cluster, and without anything on the page that changes who may
|
|
// do what. That stays with the cluster's administrators.
|
|
func (p *AccountPages) RegisterOrgPages(mux *http.ServeMux, s OrgViewer, priced Priced) {
|
|
p.orgs = s
|
|
mux.HandleFunc("GET /org", p.orgGuard(func(w http.ResponseWriter, r *http.Request, user store.User) {
|
|
orgs, err := p.viewableOrgs(r.Context(), s, user)
|
|
if err == nil && len(orgs) == 0 && user.IsAdmin {
|
|
// Said, not a 404: an administrator following the link on a
|
|
// cluster with no organizations should learn that, and where to
|
|
// make one.
|
|
render(w, "org-none", struct {
|
|
Nav Nav
|
|
Version, Now string
|
|
}{navFrom(r, "org"), p.version, time.Now().UTC().Format(time.RFC3339)})
|
|
return
|
|
}
|
|
if err != nil || len(orgs) == 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/org/"+orgs[0].ID, http.StatusSeeOther)
|
|
}))
|
|
mux.HandleFunc("GET /org/{id}", p.orgGuard(func(w http.ResponseWriter, r *http.Request, user store.User) {
|
|
id := r.PathValue("id")
|
|
if !user.IsAdmin {
|
|
ok, err := s.ManagesOrg(r.Context(), user.ID, id)
|
|
if err != nil || !ok {
|
|
// 404 rather than 403, like /admin: it does not confirm the
|
|
// organization exists to someone who may not see it.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
}
|
|
p.renderOrg(w, r, s, user, id, priced)
|
|
}))
|
|
}
|
|
|
|
// viewableOrgs is every organization an administrator may look at, or the
|
|
// ones a person manages.
|
|
func (p *AccountPages) viewableOrgs(ctx context.Context, s OrgViewer, user store.User) ([]store.Org, error) {
|
|
if user.IsAdmin {
|
|
return s.AllOrgs(ctx)
|
|
}
|
|
return s.ManagedOrgs(ctx, user.ID)
|
|
}
|
|
|
|
func (p *AccountPages) orgGuard(next func(http.ResponseWriter, *http.Request, store.User)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
_, user, ok := p.session(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
next(w, p.withNav(r, user), user)
|
|
}
|
|
}
|
|
|
|
func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgViewer,
|
|
user store.User, orgID string, priced Priced,
|
|
) {
|
|
ctx := r.Context()
|
|
by := store.Grouping(r.URL.Query().Get("by"))
|
|
if by != store.GroupByProject && by != store.GroupByUser {
|
|
by = store.GroupByTeam
|
|
}
|
|
win := windowFor(r.URL.Query().Get("window"))
|
|
d := orgPageData{
|
|
Nav: navFrom(r, "org"),
|
|
By: string(by), Window: win.Key,
|
|
Priced: priced != nil && priced.AnythingPriced(),
|
|
Version: p.version,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
for _, g := range []store.Grouping{store.GroupByTeam, store.GroupByProject, store.GroupByUser} {
|
|
d.Groups = append(d.Groups, usageGroup{Key: string(g), Label: groupLabel(g), Selected: g == by})
|
|
}
|
|
for _, uw := range usageWindows {
|
|
d.Windows = append(d.Windows, struct {
|
|
Key, Label string
|
|
Selected bool
|
|
}{uw.Key, uw.Label, uw.Key == win.Key})
|
|
}
|
|
|
|
d.Orgs, _ = p.viewableOrgs(ctx, s, user)
|
|
tree, err := s.Tree(ctx)
|
|
if err != nil {
|
|
p.log.Error("could not read the tree", "err", err)
|
|
d.Error = "could not read the organization"
|
|
}
|
|
var mine []store.TreeOrg
|
|
inOrg := map[string]string{} // scope:id -> path within the org
|
|
for _, o := range tree {
|
|
if o.ID != orgID {
|
|
continue
|
|
}
|
|
d.Org = store.Org{ID: o.ID, Name: o.Name}
|
|
mine = []store.TreeOrg{o}
|
|
inOrg["org:"+o.ID] = "the whole organization"
|
|
for _, t := range o.Teams {
|
|
inOrg["team:"+t.ID] = t.Name
|
|
for _, pr := range t.Projects {
|
|
inOrg["project:"+pr.ID] = t.Name + " / " + pr.Name
|
|
}
|
|
}
|
|
}
|
|
if d.Org.ID == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
const quotaWindow = 30 * 24 * time.Hour
|
|
if limits, err := s.AllLimits(ctx); err == nil {
|
|
d.Quotas = quotaRows(mine, limits, p.spendByScope(ctx, s, time.Now().Add(-quotaWindow)))
|
|
}
|
|
|
|
users, _ := s.Users(ctx, "")
|
|
byID := map[string]store.User{}
|
|
for _, u := range users {
|
|
byID[u.ID] = u
|
|
}
|
|
ms, _ := s.AllMemberships(ctx)
|
|
byPerson := map[string]*orgMember{}
|
|
for _, m := range ms {
|
|
where, ok := inOrg[string(m.Scope)+":"+m.ScopeID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
om := byPerson[m.UserID]
|
|
if om == nil {
|
|
u := byID[m.UserID]
|
|
om = &orgMember{Email: u.Email, State: u.State}
|
|
byPerson[m.UserID] = om
|
|
}
|
|
om.Places = append(om.Places, orgPlace{Where: where, Role: m.Role})
|
|
}
|
|
for _, om := range byPerson {
|
|
d.Members = append(d.Members, *om)
|
|
}
|
|
sort.Slice(d.Members, func(i, j int) bool { return d.Members[i].Email < d.Members[j].Email })
|
|
d.People = len(byPerson)
|
|
|
|
rows, err := s.UsageBucketsIn(ctx, by, orgID, time.Now().Add(-win.Span), win.Width)
|
|
if err != nil {
|
|
p.log.Error("could not read an organization's usage", "org", orgID, "err", err)
|
|
d.Error = "could not read usage"
|
|
}
|
|
d.Requests, d.Tokens, d.Spend, d.Rows = usageCharts(rows)
|
|
render(w, "org", d)
|
|
}
|