Files
llmgw/internal/cli/tui_people.go
T
Ilker ManapandClaude Opus 5.5 eb7dac8a67 The console manages people, keys, organizations and the mirror, and scrolls a tall sidebar
Accounts, API keys and Organizations views read the store and change it by
running the users, keys and org commands in-process, so their checks and
messages are the ones shown. A Mirror view lists what the mirror holds and
which models use it, downloads into it and removes from it. Models gain
adding from a definition file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:30:24 +02:00

546 lines
18 KiB
Go

package cli
import (
"bytes"
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// The people side of the console: accounts, keys and the organizations they
// belong to. These live in the store rather than behind the admin API — the
// API mints no credentials — so the console reads the store as `llmgw users`
// does, and changes it by running those same commands, their checks and
// messages included.
// storeHolder is the store, opened on first use and shared by every copy of
// the console's model.
type storeHolder struct {
mu sync.Mutex
file string
db *store.DB
}
func (h *storeHolder) get(ctx context.Context) (*store.DB, error) {
h.mu.Lock()
defer h.mu.Unlock()
if h.db == nil {
sf := storeFlags{config: h.file}
db, err := sf.openCtx(ctx)
if err != nil {
return nil, err
}
h.db = db
}
return h.db, nil
}
func (h *storeHolder) close() {
h.mu.Lock()
defer h.mu.Unlock()
if h.db != nil {
_ = h.db.Close()
h.db = nil
}
}
// orgRow is one line of the flattened tenancy tree.
type orgRow struct {
level string // org, team, project
id, name, path string
members, keys int
}
type peopleData struct {
users []store.User
where map[string][]string // user id → paths it is a member of
keys []store.KeyRow
orgs []orgRow
limits map[string]store.Limits
threads map[string]int // project id → hours kept, conversations
notes map[string]int // project id → hours kept, agent notes
err error
}
type consolePeopleMsg peopleData
func (m consoleModel) fetchPeople() tea.Cmd {
h := m.st
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var d peopleData
s, err := h.get(ctx)
if err != nil {
d.err = err
return consolePeopleMsg(d)
}
if d.users, d.err = s.Users(ctx, ""); d.err != nil {
return consolePeopleMsg(d)
}
all, _ := s.AllMemberships(ctx)
d.where = map[string][]string{}
for _, ms := range all {
d.where[ms.UserID] = append(d.where[ms.UserID], ms.Path)
}
d.keys, _ = s.AllKeys(ctx)
d.limits, _ = s.AllLimits(ctx)
tree, _ := s.Tree(ctx)
for _, o := range tree {
d.orgs = append(d.orgs, orgRow{level: "org", id: o.ID, name: o.Name, path: o.Name, members: o.Members})
for _, t := range o.Teams {
tp := o.Name + "/" + t.Name
d.orgs = append(d.orgs, orgRow{level: "team", id: t.ID, name: t.Name, path: tp, members: t.Members})
for _, p := range t.Projects {
d.orgs = append(d.orgs, orgRow{level: "project", id: p.ID, name: p.Name, path: tp + "/" + p.Name,
members: p.Users, keys: p.ActiveKeys})
}
}
}
d.threads, d.notes = map[string]int{}, map[string]int{}
for _, r := range d.orgs {
if r.level != "project" {
continue
}
if t, err := s.ThreadRetention(ctx, r.id); err == nil {
d.threads[r.id] = int(t.Hours())
}
if t, err := s.MemoryRetention(ctx, r.id); err == nil {
d.notes[r.id] = int(t.Hours())
}
}
return consolePeopleMsg(d)
}
}
// runCLI runs llmgw with these arguments in the background, against this
// console's store, and shows what it printed.
func (m *consoleModel) runCLI(title string, args ...string) tea.Cmd {
m.busy = title
file := m.file
return func() tea.Msg {
var out bytes.Buffer
c := Root()
c.SetArgs(append(append([]string{"--log-level=error"}, args...), "--config="+file))
c.SetOut(&out)
c.SetErr(&out)
c.SetIn(strings.NewReader(""))
err := c.ExecuteContext(context.Background())
text := strings.TrimSpace(out.String())
if text == "" {
text = "Done."
}
if err != nil {
if text != "Done." {
err = fmt.Errorf("%v\n%s", err, text)
}
return actionDoneMsg{title: title, err: err}
}
return actionDoneMsg{title: title, text: text}
}
}
func pwF(label, hint string) formField {
f := textF(label, "", hint)
f.text.Mask = true
return f
}
// --- accounts ---
func (m consoleModel) userMenu(u store.User) *menuDialog {
who := u.Email
cli := func(title string, args ...string) menuItem {
return menuItem{title, func(m *consoleModel) tea.Cmd { return m.runCLI(title+" "+who, args...) }}
}
var items []menuItem
switch u.State {
case store.UserPending:
items = append(items, menuItem{"Approve…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Approve " + who,
note: "Approved, they sign in and issue their own keys.",
fields: []formField{textF("project", "", "optional: a project to put them in, like Acme/ml/training")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
args := []string{"users", "approve", u.ID, "--by=console"}
if v[0] != "" {
args = append(args, "--project="+v[0])
}
return m.runCLI("Approve "+who, args...), nil
}})
return nil
}})
case store.UserSuspended:
items = append(items, cli("Reinstate", "users", "reinstate", u.ID))
default:
items = append(items, menuItem{"Suspend…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Suspend " + who,
text: "The account stops working and its sessions close; it and its history are kept.",
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Suspend "+who, "users", "suspend", u.ID) }}
return nil
}})
}
if u.IsAdmin {
items = append(items, cli("Take away the cluster dashboard", "users", "demote", u.ID))
} else {
items = append(items, cli("Give the cluster dashboard (/admin)", "users", "promote", u.ID))
}
items = append(items,
menuItem{"Add to an organization, team or project…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Membership for " + who,
note: "A member of a team or organization may use every project in it.",
fields: []formField{
choiceF("level", []string{"org", "team", "project"}, "team"),
textF("which", "", "Acme, Acme/ml or Acme/ml/training"),
choiceF("role", []string{store.RoleMember, "manager"}, store.RoleMember),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[1] == "" {
return nil, fmt.Errorf("which %s?", v[0])
}
return m.runCLI("Membership for "+who, "users", "join", u.ID, "--"+v[0]+"="+v[1], "--role="+v[2], "--by=console"), nil
}})
return nil
}},
menuItem{"Take out of one…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Take " + who + " out",
note: "Keys they made in a project they can no longer reach are revoked.",
fields: []formField{
choiceF("level", []string{"org", "team", "project"}, "team"),
textF("which", "", strings.Join(m.people.where[u.ID], "; ")),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[1] == "" {
return nil, fmt.Errorf("which %s?", v[0])
}
return m.runCLI("Take "+who+" out", "users", "leave", u.ID, "--"+v[0]+"="+v[1]), nil
}})
return nil
}},
menuItem{"Their limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.ScopeUser, u.ID, who); return nil }},
menuItem{"Set a new password…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "New password for " + who,
note: "Their sessions close; tell them out of band and have them change it.",
fields: []formField{pwF("password", "at least 12 characters"), pwF("again", "")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] == "" || v[0] != v[1] {
return nil, fmt.Errorf("the two are not the same")
}
return m.runCLI("Password for "+who, "users", "reset-password", u.ID, "--password="+v[0]), nil
}})
return nil
}},
menuItem{"Delete the account…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Delete " + who,
text: "They are signed out, every key they made is revoked, and the account is removed.\nTo keep it, suspend it instead.",
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Delete "+who, "users", "delete", u.ID, "--yes") }}
return nil
}},
)
return &menuDialog{title: who, items: items}
}
func (m *consoleModel) createUserForm() {
m.openForm(&formDialog{title: "New account",
note: "Created already approved. The password should be changed by its owner.",
fields: []formField{
textF("email", "", ""),
pwF("password", "at least 12 characters"),
textF("project", "", "optional: a project to put them in"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if !strings.Contains(v[0], "@") || v[1] == "" {
return nil, fmt.Errorf("an email and a password")
}
args := []string{"users", "create", v[0], "--password=" + v[1]}
if v[2] != "" {
args = append(args, "--project="+v[2])
}
return m.runCLI("New account", args...), nil
}})
}
// --- keys ---
func (m consoleModel) keyMenu(k store.KeyRow) *menuDialog {
name := k.Name + " (" + k.Prefix + ")"
return &menuDialog{title: name, items: []menuItem{
{"Its limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.ScopeKey, k.ID, name); return nil }},
{"Revoke…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Revoke " + name,
text: "It stops working at once. It is kept, revoked, for the usage that refers to it.",
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Revoke "+name, "keys", "revoke", k.ID) }}
return nil
}},
}}
}
func (m *consoleModel) createKeyForm() {
m.openForm(&formDialog{title: "New API key",
note: "Shown once, here, and never again: only its hash is kept.",
fields: []formField{
textF("name", "", "what it is for"),
textF("project", "", "Acme/ml/training; empty with a person below is their personal key"),
textF("for", "", "optional: the person it belongs to, by email"),
textF("expires in", "", "optional: like 720h; empty never expires"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] == "" {
return nil, fmt.Errorf("a name")
}
args := []string{"keys", "create", v[0]}
if v[1] != "" {
args = append(args, "--project="+v[1])
}
if v[2] != "" {
args = append(args, "--for="+v[2])
}
if v[3] != "" {
if _, err := time.ParseDuration(v[3]); err != nil {
return nil, fmt.Errorf("expires in is a duration, like 720h")
}
args = append(args, "--expires-in="+v[3])
}
return m.runCLI("New key "+v[0], args...), nil
}})
}
// --- limits, at any level ---
func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
l := m.people.limits[store.LimitOwner{Scope: scope, ID: id}.CounterKey()]
budget, period := "", "month"
if l.Budget != nil {
budget = strconv.FormatFloat(float64(*l.Budget)/1e6, 'f', -1, 64)
}
if l.BudgetPeriod != "" {
period = string(l.BudgetPeriod)
}
models := "every model"
if l.AllowedModels != nil {
models = strings.Join(l.AllowedModels, ",")
}
m.openForm(&formDialog{title: "Limits on " + who,
note: "Each level only narrows what is above it. Empty is no limit here.",
fields: []formField{
textF("requests/min", ptrText(l.RPM), ""),
textF("tokens/min", ptrText(l.TPM), ""),
textF("budget", budget, "spend per period, in whole units"),
choiceF("per", []string{"day", "month"}, period),
textF("models", models, "allowed, separated by commas; \"every model\" for no list"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
args := []string{"keys", "limits", "--scope=" + string(scope), "--id=" + id}
for i, flag := range []string{"rpm", "tpm"} {
if v[i] != "" {
if _, err := strconv.Atoi(v[i]); err != nil {
return nil, fmt.Errorf("%s is a whole number", flag)
}
args = append(args, "--"+flag+"="+v[i])
}
}
if v[2] != "" {
if _, err := strconv.ParseFloat(v[2], 64); err != nil {
return nil, fmt.Errorf("the budget is a number")
}
args = append(args, "--budget="+v[2], "--period="+v[3])
}
if v[4] != "every model" {
args = append(args, "--models="+strings.ReplaceAll(v[4], " ", ""))
}
return m.runCLI("Limits on "+who, args...), nil
}})
}
// --- organizations ---
func (m consoleModel) orgMenu(r orgRow) *menuDialog {
child := map[string]string{"org": "team", "team": "project"}[r.level]
parent := map[string]string{"team": "organization", "project": "team"}[r.level]
var items []menuItem
if child != "" {
items = append(items, menuItem{"Add a " + child + " in it…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "New " + child + " in " + r.path,
fields: []formField{textF("name", "", "")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] == "" {
return nil, fmt.Errorf("a name")
}
return m.runCLI("New "+child, "org", "add", child, v[0], "--in="+r.id), nil
}})
return nil
}})
}
items = append(items, menuItem{"Rename…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Rename " + r.path,
fields: []formField{textF("name", r.name, "")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.runCLI("Rename "+r.path, "org", "rename", r.level, r.id, v[0]), nil
}})
return nil
}})
if parent != "" {
items = append(items, menuItem{"Move to another " + parent + "…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Move " + r.path,
fields: []formField{textF(parent, "", "")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.runCLI("Move "+r.path, "org", "move", r.level, r.id, "--to="+v[0]), nil
}})
return nil
}})
}
items = append(items,
menuItem{"Limits…", func(m *consoleModel) tea.Cmd { m.limitsForm(store.Scope(r.level), r.id, r.path); return nil }},
menuItem{"Limits for each member…", func(m *consoleModel) tea.Cmd {
m.limitsForm(store.MemberScope(store.Scope(r.level)), r.id, "each member of "+r.path)
return nil
}},
menuItem{"Capacity quota…", func(m *consoleModel) tea.Cmd {
q := adminapi.QuotaView{Scope: r.level + ":" + r.id}
for _, x := range m.quotas {
if x.Scope == q.Scope {
q = x
}
}
m.quotaForm(q)
return nil
}},
)
if r.level == "project" {
retention := func(label, sub, what string, now int) menuItem {
return menuItem{label + "…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: label + " · " + r.path,
note: "Off by default: llmgw keeps no " + what + " unless a project asks for it.",
fields: []formField{textF("hours", strconv.Itoa(now), "how long they are kept; 0 keeps none")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
h, err := strconv.Atoi(v[0])
if err != nil || h < 0 {
return nil, fmt.Errorf("hours is a whole number")
}
args := []string{"keys", sub, "--project=" + r.id}
if h == 0 {
args = append(args, "--off")
} else {
args = append(args, "--retain-hours="+v[0])
}
return m.runCLI(label, args...), nil
}})
return nil
}}
}
items = append(items,
retention("Keep conversations", "threads", "conversations", m.people.threads[r.id]),
retention("Keep agent notes", "memory", "notes", m.people.notes[r.id]))
}
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove " + r.path,
text: "Only something empty is removed: an organization with no teams, a team with\nno projects, a project with no accounts and no keys.",
yes: func(m *consoleModel) tea.Cmd { return m.runCLI("Remove "+r.path, "org", "remove", r.level, r.id) }}
return nil
}})
return &menuDialog{title: r.level + " " + r.path, items: items}
}
func (m *consoleModel) createOrgForm() {
m.openForm(&formDialog{title: "New organization",
fields: []formField{textF("name", "", "")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] == "" {
return nil, fmt.Errorf("a name")
}
return m.runCLI("New organization", "org", "add", "org", v[0]), nil
}})
}
// --- the views ---
func (m consoleModel) peoplePanel(pw int) (string, string, string) {
title := consoleLabels[m.menu]
keys := "→ or Enter open · ↑↓ choose · Q leave"
if m.inPanel {
keys = "↑↓ row · Enter actions · A add · ← back · Q leave"
}
if m.people.err != nil {
return title, "\n " + sBad.Render("Cannot read the store: ") + m.people.err.Error(), keys
}
if m.people.users == nil && m.people.orgs == nil {
return title, "\n " + m.spin.View() + " reading the store…", keys
}
now := time.Now()
var head []string
var rows [][]string
switch m.menu {
case cAccounts:
head = []string{"EMAIL", "STATE", "ADMIN", "MEMBER OF", "SIGNED UP", "LAST LOGIN"}
for _, u := range m.people.users {
state := string(u.State)
if u.State == store.UserPending {
state = sWarn.Render(state)
}
admin := ""
if u.IsAdmin {
admin = "yes"
}
where := strings.Join(m.people.where[u.ID], "; ")
if where == "" {
where = sDim.Render("on their own")
}
rows = append(rows, []string{u.Email, state, admin, where, ago(u.CreatedAt, now), ago(u.LastLoginAt, now)})
}
case cKeys:
head = []string{"NAME", "KEY", "PROJECT", "OWNER", "STATE", "LAST USED"}
for _, k := range m.people.keys {
where := k.ProjectPath
if where == "" {
where = "personal"
}
rows = append(rows, []string{k.Name, k.Prefix, where, dash(k.OwnerEmail), keyState(k.Key, now), ago(k.LastUsedAt, now)})
}
case cOrgs:
head = []string{"NAME", "LEVEL", "MEMBERS", "KEYS", "LIMITS"}
for _, r := range m.people.orgs {
name := r.name
switch r.level {
case "team":
name = " " + name
case "project":
name = " " + name
}
keysCell := ""
if r.level == "project" {
keysCell = strconv.Itoa(r.keys)
}
rows = append(rows, []string{name, r.level, strconv.Itoa(r.members), keysCell,
limitsCell(m.people.limits[store.LimitOwner{Scope: store.Scope(r.level), ID: r.id}.CounterKey()])})
}
}
if len(rows) == 0 {
return title, "\n " + sDim.Render("None yet. A adds one."), keys
}
return title, "\n" + table(head, rows, m.row, pw, m.inPanel), keys
}
func limitsCell(l store.Limits) string {
var p []string
if l.RPM != nil {
p = append(p, fmt.Sprintf("%d rpm", *l.RPM))
}
if l.TPM != nil {
p = append(p, fmt.Sprintf("%s tpm", compactNum(int64(*l.TPM))))
}
if l.Budget != nil {
p = append(p, fmt.Sprintf("%.2f/%s", float64(*l.Budget)/1e6, l.BudgetPeriod))
}
if l.AllowedModels != nil {
p = append(p, fmt.Sprintf("%d models", len(l.AllowedModels)))
}
return strings.Join(p, ", ")
}