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>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-03 23:30:24 +02:00
1 parent c7d5ee16b5
commit eb7dac8a67
6 files changed
+949 -8

No files matched your search

+106
View File
@@ -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)
}
}
}
+66 -4
View File
@@ -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 {
+21 -4
View File
@@ -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")
+182
View File
@@ -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
}})
}
+545
View File
@@ -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, ", ")
}