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:
1 parent
c7d5ee16b5
commit
eb7dac8a67
6 files changed
+949
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}})
|
||||
}
|
||||
@@ -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, ", ")
|
||||
}
|
||||
Reference in new issue
Block a user