From eb7dac8a67d8aea1b1e91e9645f3a026f2f22e69 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Sat, 3 Oct 2026 23:30:24 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 29 ++ internal/cli/tui_actions_test.go | 106 ++++++ internal/cli/tui_console.go | 70 +++- internal/cli/tui_frame.go | 25 +- internal/cli/tui_mirror.go | 182 +++++++++++ internal/cli/tui_people.go | 545 +++++++++++++++++++++++++++++++ 6 files changed, 949 insertions(+), 8 deletions(-) create mode 100644 internal/cli/tui_mirror.go create mode 100644 internal/cli/tui_people.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c706e7..759d9cda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,35 @@ is only released once its **exit criterion** is met, not when its code lands. Until v1.0.0 the minor version is the breaking one: configuration formats and Go APIs change between minors without a deprecation period. +## Unreleased + +### Everything that can be changed on a running cluster, from the console + +`sudo llmgw` only watched the cluster and edited the configuration files; +changing a model's replicas still meant `llmgw ctl`. Now Enter on a row opens +what can be done to it, as forms and questions: + +- **Models**: replicas (min, max, idle timeout), class, owner, resources, LoRA + adapters, restart, retry when parked, why it is placed where it is, remove; + A adds one from a definition file. +- **Nodes**: cordon, uncordon, drain, the vLLM and SGLang builds, upgrade its + agent, delete model files nothing uses, remove. +- **Replicas**: restart, replaced or in place. +- **Quotas** (new): capacity quotas, set and removed. +- **Mirror** (new): what it holds and which models use it; download into it, + remove from it. +- **People** (new): accounts — approve, suspend, reinstate, the cluster + dashboard, memberships, limits, a new password, delete, create; API keys — + issue (shown once), limits, revoke; organizations, teams and projects — add, + rename, move, remove, limits for the level and for each member, capacity + quotas, how long conversations and agent notes are kept. +- **Do**: serve a model from Hugging Face, upgrade the cluster, restart every + replica, back up now. + +The people side runs the same commands as `llmgw users`, `keys` and `org`, +against the same store, so their checks and messages are the ones shown. A +sidebar taller than the terminal now scrolls with the current entry. + ## v0.11.1 — 2026-10-03 ### The console's overview no longer spills over the screen diff --git a/internal/cli/tui_actions_test.go b/internal/cli/tui_actions_test.go index e5ab9b41..79e05c8e 100644 --- a/internal/cli/tui_actions_test.go +++ b/internal/cli/tui_actions_test.go @@ -5,13 +5,16 @@ import ( "io" "net/http" "net/http/httptest" + "os" "strings" "sync" "testing" tea "github.com/charmbracelet/bubbletea" + "gitea.mynodes.xyz/ilker/llmgw/internal/config" "gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi" + "gitea.mynodes.xyz/ilker/llmgw/internal/store" ) type seen struct { @@ -151,3 +154,106 @@ func TestAQuotaIsAddedWithOnlyWhatIsGiven(t *testing.T) { t.Fatalf("sent %q", got) } } + +func TestPeopleAreManagedFromTheConsoleAgainstItsStore(t *testing.T) { + dir := t.TempDir() + cfgPath := dir + "/server.yaml" + yaml := "auth:\n store: " + dir + "/llmgw.db\n" + if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil { + t.Fatal(err) + } + if _, err := config.Load(cfgPath); err != nil { + t.Fatalf("the test's configuration: %v", err) + } + m := newConsoleModel("127.0.0.1:1", cfgPath) + defer m.st.close() + m.loaded, m.inPanel = true, true + + refresh := func(tm tea.Model) tea.Model { + cm := tm.(consoleModel) + msg := cm.fetchPeople()() + tm, _ = tm.Update(msg) + return tm + } + submit := func(tm tea.Model, vals ...string) tea.Model { + f := tm.(consoleModel).formD + if f == nil { + t.Fatal("no form") + } + for i, v := range vals { + f.fields[i].text.SetValue(v) + } + f.focus = len(f.fields) + tm = pressRun(tm, "enter") + if v := tm.View(); !strings.Contains(v, "✓") { + t.Fatalf("did not work:\n%s", v) + } + return refresh(press(tm, "x")) + } + + m.menu = cOrgs + var tm tea.Model = m + tm = submit(press(tm, "a"), "Acme") + // Into the organization: a team, then a project in it. + tm = submit(press(tm, "enter", "enter"), "ml") + cm := tm.(consoleModel) + if len(cm.people.orgs) != 2 || cm.people.orgs[1].path != "Acme/ml" { + t.Fatalf("tree: %+v", cm.people.orgs) + } + tm = submit(press(tm, "down", "enter", "enter"), "training") + + cm = tm.(consoleModel) + cm.menu, cm.row = cAccounts, 0 + tm = submit(press(cm, "a"), "ayse@example.com", "a-long-s3cret-pass", "Acme/ml/training") + cm = tm.(consoleModel) + if len(cm.people.users) != 1 || cm.people.users[0].State != store.UserActive || + !strings.Contains(strings.Join(cm.people.where[cm.people.users[0].ID], ","), "training") { + t.Fatalf("account: %+v %v", cm.people.users, cm.people.where) + } + + cm.menu, cm.row = cKeys, 0 + tm = press(cm, "a") + f := tm.(consoleModel).formD + f.fields[0].text.SetValue("ci") + f.fields[1].text.SetValue("Acme/ml/training") + f.focus = len(f.fields) + tm = pressRun(tm, "enter") + if v := tm.View(); !strings.Contains(v, "sk-") { + t.Fatalf("the new key was not shown:\n%s", v) + } + tm = refresh(press(tm, "x")) + if n := len(tm.(consoleModel).people.keys); n != 1 { + t.Fatalf("%d keys", n) + } + // Limits on the project, read back in its row. + cm = tm.(consoleModel) + cm.menu, cm.row = cOrgs, 2 + tm = press(cm, "enter") + items := tm.(consoleModel).menuD.items + i := 0 + for ; items[i].label != "Limits…"; i++ { + } + keys := []string{} + for range i { + keys = append(keys, "down") + } + tm = submit(press(tm, append(keys, "enter")...), "60") + if c := limitsCell(tm.(consoleModel).people.limits[store.LimitOwner{Scope: store.ScopeProject, + ID: tm.(consoleModel).people.orgs[2].id}.CounterKey()]); c != "60 rpm" { + t.Fatalf("limits: %q", c) + } +} + +func TestTheSidebarFitsAShortScreen(t *testing.T) { + m, _ := consoleWith(t, cOrgs) + m.inPanel = false + for _, h := range []int{20, 30, 50} { + m.height = h + if n := len(strings.Split(m.View(), "\n")); n > h { + t.Errorf("height %d: %d lines drawn", h, n) + } + if !strings.Contains(m.View(), "Organizations") { + t.Errorf("height %d: the current entry is not shown", h) + } + } +} diff --git a/internal/cli/tui_console.go b/internal/cli/tui_console.go index bd7f5e2a..b93c0e20 100644 --- a/internal/cli/tui_console.go +++ b/internal/cli/tui_console.go @@ -36,7 +36,11 @@ const ( cReplicas cUsage cQuotas + cMirror cLogs + cAccounts + cKeys + cOrgs cSettings cAddNode cReplicate @@ -48,7 +52,7 @@ const ( cCount ) -var consoleLabels = [cCount]string{"Overview", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Logs", +var consoleLabels = [cCount]string{"Overview", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Mirror", "Logs", "Accounts", "API keys", "Organizations", "server.yaml", "Add a node", "Replicate the store", "Serve a model", "Upgrade the cluster", "Restart all replicas", "Back up now", "Leave"} @@ -143,12 +147,17 @@ type consoleModel struct { quotas []adminapi.QuotaView quotasErr error + + st *storeHolder + people peopleData + mirror consoleMirrorMsg } var usageGroupings = []string{"team", "project", "org"} func newConsoleModel(addr, file string) consoleModel { - m := consoleModel{client: newCtlClient(addr), spin: newSpinner(), width: 100, height: 30, file: file} + m := consoleModel{client: newCtlClient(addr), spin: newSpinner(), width: 100, height: 30, file: file, + st: &storeHolder{file: file}} m.node = strings.HasSuffix(file, "agent.yaml") if m.node { m.menu = cSettings @@ -254,6 +263,12 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.menu == cQuotas { cmds = append(cmds, m.fetchQuotas()) } + if m.menu == cMirror { + cmds = append(cmds, m.fetchMirror()) + } + if m.peopleView() { + cmds = append(cmds, m.fetchPeople()) + } if m.menu == cLogs && m.detail && m.logs.node != "" { cmds = append(cmds, m.fetchLogs(m.logs.node)) } @@ -269,6 +284,10 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.usage, m.usageErr = msg.rows, msg.err case consoleLogsMsg: m.logs = msg + case consoleMirrorMsg: + m.mirror = msg + case consolePeopleMsg: + m.people = peopleData(msg) case consoleQuotasMsg: m.quotas, m.quotasErr = msg.rows, msg.err case actionDoneMsg: @@ -387,10 +406,29 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) { m.menuD = m.replicaMenu(m.data.replicas[m.row]) case cQuotas: m.menuD = m.quotaMenu(m.quotas[m.row]) + case cMirror: + m.menuD = m.mirrorMenu(m.mirror.res.Models[m.row]) + case cAccounts: + m.menuD = m.userMenu(m.people.users[m.row]) + case cKeys: + m.menuD = m.keyMenu(m.people.keys[m.row]) + case cOrgs: + m.menuD = m.orgMenu(m.people.orgs[m.row]) } case "a": - if m.menu == cQuotas { + switch m.menu { + case cQuotas: m.quotaForm(adminapi.QuotaView{}) + case cModels: + m.addModelForm() + case cMirror: + m.mirrorFetchForm() + case cAccounts: + m.createUserForm() + case cKeys: + m.createKeyForm() + case cOrgs: + m.createOrgForm() } case "right", "l": if m.menu == cUsage { @@ -416,10 +454,19 @@ func (m consoleModel) onEnter() tea.Cmd { return m.fetchUsage() case cQuotas: return m.fetchQuotas() + case cMirror: + return m.fetchMirror() + case cAccounts, cKeys, cOrgs: + // The quotas too, for an organization's quota form. + return tea.Batch(m.fetchPeople(), m.fetchQuotas()) } return nil } +func (m consoleModel) peopleView() bool { + return m.menu == cAccounts || m.menu == cKeys || m.menu == cOrgs +} + func (m consoleModel) leave() (tea.Model, tea.Cmd) { if m.doc != nil && m.doc.Changed > 0 { m.dialog, m.dialogTitle = "confirm-leave", "Unsaved changes" @@ -717,6 +764,14 @@ func (m consoleModel) rowCount() int { return len(m.usage) case cQuotas: return len(m.quotas) + case cMirror: + return len(m.mirror.res.Models) + case cAccounts: + return len(m.people.users) + case cKeys: + return len(m.people.keys) + case cOrgs: + return len(m.people.orgs) } return 0 } @@ -732,6 +787,8 @@ func (m consoleModel) View() string { continue } switch i { + case cAccounts: + nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "People", Heading: true}) case cSettings: nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "Settings", Heading: true}) case cAddNode, cLeave: @@ -871,7 +928,7 @@ func (m consoleModel) panel() (title, body, keys string) { strings.Join(md.Profiles, ","), warn}) } if m.inPanel { - keys = rowKeys + keys = "↑↓ row · Enter actions · A add from a file · ← back · Q leave" } return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", ""}, rows, m.row, pw, m.inPanel), keys case cReplicas: @@ -905,6 +962,10 @@ func (m consoleModel) panel() (title, body, keys string) { return "Usage", head + table([]string{"WHO", "REQUESTS", "TOKENS", "ERRORS", "SPENT"}, rows, m.row, pw, m.inPanel), keys case cQuotas: return m.quotasPanel(pw) + case cAccounts, cKeys, cOrgs: + return m.peoplePanel(pw) + case cMirror: + return m.mirrorPanel(pw) case cSettings: return m.settingsPanel(pw) } @@ -1174,6 +1235,7 @@ func runConsole(ctx context.Context, out io.Writer, file string) error { return err } fm := final.(consoleModel) + fm.st.close() menu = fm.menu act := fm.action switch act.kind { diff --git a/internal/cli/tui_frame.go b/internal/cli/tui_frame.go index 1c25c267..5786e0e7 100644 --- a/internal/cli/tui_frame.go +++ b/internal/cli/tui_frame.go @@ -68,14 +68,18 @@ func (f frame) render() string { ph := h - 2 inner := ph - 2 // borders - var nav strings.Builder + var navLines []string + cur := 0 for _, it := range f.Nav { mark := " " label := it.Label if it.Heading { - nav.WriteString(sDim.Render(label) + "\n") + navLines = append(navLines, sDim.Render(label)) continue } + if it.Current || it.State == "current" { + cur = len(navLines) + } switch { case it.State == "done": mark = sOK.Render("✓ ") @@ -88,9 +92,22 @@ func (f frame) render() string { case it.Dim || it.State == "todo": label = sDim.Render(label) } - nav.WriteString(truncateWidth(mark+label, navWidth-2) + "\n") + navLines = append(navLines, truncateWidth(mark+label, navWidth-2)) } - left := box(fBorder, f.NavTitle, nav.String(), navWidth, inner) + // Taller than the screen: the part around the current entry, with a mark + // at an edge that has more beyond it. + if len(navLines) > inner { + total := len(navLines) + top := min(max(0, cur-inner/2), total-inner) + navLines = append([]string{}, navLines[top:top+inner]...) + if top > 0 { + navLines[0] = sDim.Render(" ↑ more") + } + if top+inner < total { + navLines[inner-1] = sDim.Render(" ↓ more") + } + } + left := box(fBorder, f.NavTitle, strings.Join(navLines, "\n"), navWidth, inner) pw := w - navWidth - 2 - 2 lines := strings.Split(strings.TrimRight(f.Body, "\n"), "\n") diff --git a/internal/cli/tui_mirror.go b/internal/cli/tui_mirror.go new file mode 100644 index 00000000..bd2295ab --- /dev/null +++ b/internal/cli/tui_mirror.go @@ -0,0 +1,182 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "gitea.mynodes.xyz/ilker/llmgw/internal/config" + "gitea.mynodes.xyz/ilker/llmgw/internal/mirror" +) + +// The mirror, as the control plane reaches it: its management API, with the +// address and certificate server.yaml gives under mirror. + +type consoleMirrorMsg struct { + res mirror.ModelsResponse + err error +} + +func (m consoleModel) mirrorClient() (*mirror.Client, error) { + cfg, err := config.Load(m.file) + if err != nil { + return nil, err + } + if cfg.Mirror.API == "" { + return nil, errors.New("no mirror: mirror.api in server.yaml is empty") + } + return mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 10*time.Second) +} + +func (m consoleModel) fetchMirror() tea.Cmd { + return func() tea.Msg { + c, err := m.mirrorClient() + if err != nil { + return consoleMirrorMsg{err: err} + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + res, err := c.List(ctx) + return consoleMirrorMsg{res, err} + } +} + +// mirrorCall runs one request to the mirror in the background and reports it. +func (m *consoleModel) mirrorCall(title string, do func(context.Context, *mirror.Client) (string, error)) tea.Cmd { + m.busy = title + mm := *m + return func() tea.Msg { + c, err := mm.mirrorClient() + if err != nil { + return actionDoneMsg{title: title, err: err} + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + text, err := do(ctx, c) + return actionDoneMsg{title: title, text: text, err: err} + } +} + +// usedBy names the catalog's models whose source is this file. +func (m consoleModel) usedBy(base string, s mirror.Status) []string { + u := mirror.Entry{Model: s.Model, Version: s.Version, File: s.File}.URL(base) + dir := strings.TrimSuffix(mirror.Entry{Model: s.Model, Version: s.Version}.URL(base), "/") + "/" + var names []string + for _, md := range m.data.models { + if md.Source == u || strings.HasPrefix(md.Source, dir) { + names = append(names, md.Name) + } + } + return names +} + +func (m consoleModel) mirrorMenu(s mirror.Status) *menuDialog { + what := s.Model + "/" + s.Version + used := m.usedBy(m.mirror.res.Base, s) + items := []menuItem{{"Where nodes fetch it", func(m *consoleModel) tea.Cmd { + m.dialog, m.dialogTitle = "error", what + m.dialogText = mirror.Entry{Model: s.Model, Version: s.Version, File: s.File}.URL(m.mirror.res.Base) + + "\n\n" + sDim.Render("the source to give a model definition · any key") + return nil + }}} + items = append(items, menuItem{"Remove from the mirror…", func(m *consoleModel) tea.Cmd { + text := "Every file of this version is deleted from the mirror." + if len(used) > 0 { + text += "\n\n" + sWarn.Render("! ") + "The catalog uses it: " + strings.Join(used, ", ") + + ".\nNodes that do not hold it already cannot start them." + } + m.confirmD = &confirmDialog{title: "Remove " + what, text: text, + yes: func(m *consoleModel) tea.Cmd { + return m.mirrorCall("Remove "+what, func(ctx context.Context, c *mirror.Client) (string, error) { + removed, err := c.Remove(ctx, s.Model, s.Version) + return fmt.Sprintf("Removed %d file(s).", len(removed)), err + }) + }} + return nil + }}) + return &menuDialog{title: what, items: items} +} + +func (m *consoleModel) mirrorFetchForm() { + m.openForm(&formDialog{title: "Download into the mirror", + note: "The mirror downloads it once; nodes then fetch it from the mirror.", + fields: []formField{ + textF("from", "", "an https:// URL, like a Hugging Face resolve link"), + textF("model", "", "one directory level, like qwen2.5-7b"), + textF("version", time.Now().Format("2006-01-02"), "one directory level; a date works well"), + textF("file", "", "optional: taken from the URL"), + }, + submit: func(m *consoleModel, v []string) (tea.Cmd, error) { + if !strings.HasPrefix(v[0], "http://") && !strings.HasPrefix(v[0], "https://") { + return nil, fmt.Errorf("from is a URL") + } + if v[1] == "" || v[2] == "" { + return nil, fmt.Errorf("a model and a version") + } + req := mirror.FetchRequest{Source: v[0], Model: v[1], Version: v[2], File: v[3]} + return m.mirrorCall("Download "+v[1], func(ctx context.Context, c *mirror.Client) (string, error) { + r, err := c.Fetch(ctx, req) + if err != nil { + return "", err + } + return "Downloading. Once it is here, a model's source is\n" + r.URL, nil + }), nil + }}) +} + +func (m consoleModel) mirrorPanel(pw int) (string, string, string) { + keys := "→ or Enter open · ↑↓ choose · Q leave" + if m.inPanel { + keys = "↑↓ row · Enter actions · A download one · ← back · Q leave" + } + if m.mirror.err != nil { + return "Mirror", "\n " + sBad.Render(m.mirror.err.Error()), keys + } + head := "\n " + sDim.Render("Nodes fetch from "+m.mirror.res.Base) + "\n\n" + var rows [][]string + for _, s := range m.mirror.res.Models { + state := string(s.State) + switch s.State { + case mirror.Fetching: + if p, ok := s.Percent(); ok { + state = sWarn.Render(fmt.Sprintf("fetching %d%%", p)) + } else { + state = sWarn.Render("fetching " + bytesShort(s.BytesDone)) + } + case mirror.Failed: + state = sBad.Render("failed") + } + size := bytesShort(s.BytesDone) + used := strings.Join(m.usedBy(m.mirror.res.Base, s), ", ") + if used == "" { + used = sDim.Render("unused") + } + rows = append(rows, []string{s.Model, s.Version, s.File, state, size, used}) + } + if len(rows) == 0 { + return "Mirror", head + " " + sDim.Render("Empty. A downloads one."), keys + } + return "Mirror", head + table([]string{"MODEL", "VERSION", "FILE", "STATE", "SIZE", "USED BY"}, rows, m.row, pw, m.inPanel), keys +} + +// addModelForm adds a model from a definition file on this machine: the +// same YAML a model takes in server.yaml. +func (m *consoleModel) addModelForm() { + server := "--server=" + m.client.base + m.openForm(&formDialog{title: "Add a model", + note: "From a definition file on this machine: the YAML a model takes in\nserver.yaml's models. Serve a model, under Do, needs none.", + fields: []formField{ + textF("file", "", "like /root/qwen.yaml"), + }, + submit: func(m *consoleModel, v []string) (tea.Cmd, error) { + if _, err := os.Stat(v[0]); err != nil { + return nil, err + } + return m.runCLI("Add a model", "ctl", server, "model", "add", "-f", v[0]), nil + }}) +} diff --git a/internal/cli/tui_people.go b/internal/cli/tui_people.go new file mode 100644 index 00000000..a1003f1f --- /dev/null +++ b/internal/cli/tui_people.go @@ -0,0 +1,545 @@ +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, ", ") +}