package web import ( "context" "net/http" "net/url" "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 } // A member's state is a word the page shows and a class it styles by. func init() { registerKeys("org.state.pending", "org.state.active", "org.state.suspended") } type orgPageData struct { Nav Nav Org store.Org Orgs []store.Org // the others this person may look at Groups []usageGroup Windows []windowChoice 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) { if p.toPeople(w, r, user, "") { return } 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, r, "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 p.toPeople(w, r, user, id) { return } 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) })) } // toPeople sends an administrator to the people page, which shows an // organization's members, limits and use beside the whole tree and can change // them; this page stays for the managers who may not see that one. It says // whether it did. func (p *AccountPages) toPeople(w http.ResponseWriter, r *http.Request, user store.User, orgID string) bool { if !user.IsAdmin || p.peopleStore == nil { return false } to := "/admin/users" if orgID != "" { to += "?sel=org:" + url.QueryEscape(orgID) } http.Redirect(w, r, to, http.StatusSeeOther) return true } // 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")) l := langOf(r) 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(l, g), Selected: g == by}) } d.Windows = windowChoices(l, 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 = l.T("org.err.read_tree") } 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] = l.T("org.whole") 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 = quotaRowsIn(l, 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 = l.T("usage.err.read") } d.Requests, d.Tokens, d.Spend, d.Rows = usageChartsIn(l, rows) render(w, r, "org", d) }