Files
llmgw/internal/cli/tui_actions.go
T
Ilker ManapandClaude Opus 5.5 cba4897987
CI / test (push) Failing after 6m44s
Deleting unused model files says what went, and sets go whole
A sweep counted every file the nodes were asked to delete as freed as soon
as it asked, and a node refused a set's directory as not empty, so tens of
gigabytes were reported freed and nothing was. The agent removes a set's
directory whole (a link only as a link), and the control plane waits for
each node to list its files again, saying what it no longer lists and what
it kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:29:16 +01:00

1147 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
// What can be done from the console's tables: Enter on a row opens a menu of
// what applies to it — its details first — and each item is a form, a
// question, or done at once, through the same admin API `llmgw ctl` uses.
type menuItem struct {
label string
act func(m *consoleModel) tea.Cmd
}
type menuDialog struct {
title string
items []menuItem
sel int
}
type formField struct {
label, hint string
text textField
options []string
opt int
}
func (f formField) value() string {
if f.options != nil {
return f.options[f.opt]
}
return strings.TrimSpace(f.text.Value())
}
type formDialog struct {
title, note string
fields []formField
focus int
err string
submit func(m *consoleModel, vals []string) (tea.Cmd, error)
}
type confirmDialog struct {
title, text string
yes func(m *consoleModel) tea.Cmd
}
// actionDoneMsg is an action's outcome, shown as a dialog.
type actionDoneMsg struct {
title, text string
err error
}
func textF(label, value, hint string) formField {
t := newTextField()
t.Width = 30
t.SetValue(value)
return formField{label: label, hint: hint, text: t}
}
func choiceF(label string, options []string, current string) formField {
f := formField{label: label, options: options}
for i, o := range options {
if o == current {
f.opt = i
}
}
return f
}
func (m *consoleModel) openForm(f *formDialog) {
if len(f.fields) > 0 {
f.fields[0].text.Focus()
}
m.formD = f
}
// call runs one admin API request in the background and reports it.
func (m *consoleModel) call(title, method, path string, body any, say func(raw json.RawMessage) string) tea.Cmd {
return m.callWaiting(title, method, path, body, say, 60*time.Second)
}
// callWaiting is call for a change the server may spend longer on than the
// console's usual wait.
func (m *consoleModel) callWaiting(title, method, path string, body any, say func(raw json.RawMessage) string,
wait time.Duration,
) tea.Cmd {
m.busy = title
c := m.client
if wait > 60*time.Second {
c = c.patient(wait)
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
var raw json.RawMessage
// A change may answer with no body at all.
if err := c.do(ctx, method, path, body, &raw); err != nil && !errors.Is(err, io.EOF) {
return actionDoneMsg{title: title, err: err}
}
text := "Done."
if say != nil {
text = say(raw)
}
return actionDoneMsg{title: title, text: text}
}
}
func (m consoleModel) actionKey(k tea.KeyMsg) (tea.Model, tea.Cmd, bool) {
switch {
case m.formD != nil:
f := m.formD
switch k.Type {
case tea.KeyEsc:
m.formD = nil
case tea.KeyTab, tea.KeyDown:
// From Apply, which is past the last field, round to the first.
if f.focus < len(f.fields) {
f.fields[f.focus].text.Blur()
}
f.focus = (f.focus + 1) % (len(f.fields) + 1)
if f.focus < len(f.fields) {
f.fields[f.focus].text.Focus()
}
case tea.KeyShiftTab, tea.KeyUp:
if f.focus < len(f.fields) {
f.fields[f.focus].text.Blur()
}
f.focus = (f.focus + len(f.fields)) % (len(f.fields) + 1)
if f.focus < len(f.fields) {
f.fields[f.focus].text.Focus()
}
case tea.KeyLeft, tea.KeyRight:
if f.focus < len(f.fields) && f.fields[f.focus].options != nil {
fl := &f.fields[f.focus]
d := 1
if k.Type == tea.KeyLeft {
d = len(fl.options) - 1
}
fl.opt = (fl.opt + d) % len(fl.options)
}
case tea.KeyEnter:
// A form of one field is applied by Enter on it.
if f.focus < len(f.fields) && len(f.fields) > 1 {
f.fields[f.focus].text.Blur()
f.focus++
if f.focus < len(f.fields) {
f.fields[f.focus].text.Focus()
}
return m, nil, true
}
vals := make([]string, len(f.fields))
for i, fl := range f.fields {
vals[i] = fl.value()
}
cmd, err := f.submit(&m, vals)
if err != nil {
f.err = err.Error()
return m, nil, true
}
m.formD = nil
return m, cmd, true
default:
if f.focus < len(f.fields) && f.fields[f.focus].options == nil {
var cmd tea.Cmd
f.fields[f.focus].text, cmd = f.fields[f.focus].text.Update(k)
return m, cmd, true
}
}
return m, nil, true
case m.confirmD != nil:
c := m.confirmD
m.confirmD = nil
if strings.EqualFold(k.String(), "y") {
return m, c.yes(&m), true
}
return m, nil, true
case m.menuD != nil:
d := m.menuD
switch k.String() {
case "up", "k":
d.sel = (d.sel - 1 + len(d.items)) % len(d.items)
case "down", "j", "tab":
d.sel = (d.sel + 1) % len(d.items)
case "esc", "q":
m.menuD = nil
case "enter":
m.menuD = nil
return m, d.items[d.sel].act(&m), true
}
return m, nil, true
}
return m, nil, false
}
// actionDialog draws whichever of the three is open, or "".
func (m consoleModel) actionDialog() (title, body string) {
switch {
case m.formD != nil:
f := m.formD
var b strings.Builder
if f.note != "" {
b.WriteString(f.note + "\n\n")
}
w := 0
for _, fl := range f.fields {
w = max(w, len(fl.label))
}
for i, fl := range f.fields {
label := fmt.Sprintf("%-*s", w, fl.label)
if i == f.focus {
label = fSelected.Render(label)
}
v := fl.text.View()
if fl.options != nil && len(fl.options) > 3 {
v = fmt.Sprintf("◀ %s ▶ %s", sOK.Render(fl.options[fl.opt]), sDim.Render(fmt.Sprintf("%d of %d", fl.opt+1, len(fl.options))))
} else if fl.options != nil {
var opts []string
for j, o := range fl.options {
if j == fl.opt {
opts = append(opts, "("+sOK.Render("•")+") "+o)
} else {
opts = append(opts, "( ) "+sDim.Render(o))
}
}
v = strings.Join(opts, " ")
}
fmt.Fprintf(&b, "%s %s\n", label, v)
if fl.hint != "" {
fmt.Fprintf(&b, "%s %s\n", strings.Repeat(" ", w), sDim.Render(fl.hint))
}
}
b.WriteString("\n" + button(f.focus == len(f.fields), "Apply") + "\n")
if f.err != "" {
b.WriteString("\n" + sBad.Render(f.err) + "\n")
}
b.WriteString("\n" + sDim.Render("Tab next · ←→ choose · Enter on Apply · Esc cancel"))
return f.title, b.String()
case m.confirmD != nil:
return m.confirmD.title, m.confirmD.text + "\n\n" + sDim.Render("Y yes · any other key no")
case m.menuD != nil:
var b strings.Builder
// A long menu shows the part around the choice.
items, first := m.menuD.items, 0
if room := max(5, m.height-12); len(items) > room {
first = min(max(0, m.menuD.sel-room/2), len(items)-room)
items = items[first : first+room]
if first > 0 {
b.WriteString(sDim.Render(" ↑ more") + "\n")
}
}
for j, it := range items {
i := first + j
if i == m.menuD.sel {
b.WriteString(fSelected.Render("› "+it.label) + "\n")
} else {
b.WriteString(" " + it.label + "\n")
}
}
if first+len(items) < len(m.menuD.items) {
b.WriteString(sDim.Render(" ↓ more") + "\n")
}
b.WriteString("\n" + sDim.Render("↑↓ choose · Enter · Esc close"))
return m.menuD.title, b.String()
}
return "", ""
}
func details(m *consoleModel) tea.Cmd {
m.detail, m.scroll = true, 0
return m.fetchDetail()
}
// --- models ---
func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
path := "/api/v1/models/" + url.PathEscape(md.Name)
items := []menuItem{
{"Details", details},
{"Replicas (scale)…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Scale " + md.Name,
note: "How many replicas it keeps. Max 0 stops it; min 0 with max above loads it on demand.",
fields: []formField{
textF("min", strconv.Itoa(md.Min), "replicas kept running"),
textF("max", strconv.Itoa(md.Max), "the most it scales to"),
textF("idle timeout", "", "on demand only: unload after this long unused, like 30m"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
minR, err1 := strconv.Atoi(v[0])
maxR, err2 := strconv.Atoi(v[1])
if err1 != nil || err2 != nil || minR < 0 || maxR < minR {
return nil, fmt.Errorf("min and max are whole numbers, max at least min")
}
req := adminapi.ScaleRequest{Min: minR, Max: maxR}
if v[2] != "" {
d, err := time.ParseDuration(v[2])
if err != nil {
return nil, fmt.Errorf("the idle timeout is a duration, like 30m")
}
req.IdleTimeout = d
}
return m.call("Scale "+md.Name, http.MethodPost, path+"/scale", req,
func(json.RawMessage) string { return fmt.Sprintf("%s keeps %d to %d replicas.", md.Name, minR, maxR) }), nil
}})
return nil
}},
{"Class…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Class of " + md.Name,
note: "How much its capacity is worth against another's when there is not enough for both.",
fields: []formField{choiceF("class", []string{"critical", "standard", "opportunistic"}, md.Class)},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call("Class of "+md.Name, http.MethodPost, path+"/class", map[string]string{"class": v[0]},
func(json.RawMessage) string { return md.Name + " is " + v[0] + "." }), nil
}})
return nil
}},
{"Owner…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Owner of " + md.Name,
note: "Whose capacity quota it counts against. Empty: the cluster's, nobody's.",
fields: []formField{textF("owner", md.Owner, "org:ID, team:ID or project:ID")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call("Owner of "+md.Name, http.MethodPost, path+"/owner", map[string]string{"owner": v[0]}, nil), nil
}})
return nil
}},
{"Context length…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Context of " + md.Name,
note: "What one request may use, and the longest answer. A changed context changes the engine's arguments " +
"and memory, checked against what the cards hold, and starts its replicas again one by one; the " +
"longest answer alone changes only the gateway's limit, at once.",
fields: []formField{
textF("tokens", strconv.Itoa(md.ContextLength), "a request's prompt and answer together"),
textF("longest answer", strconv.Itoa(md.MaxOutput), "tokens; empty follows the context"),
textF("slots", "", "llama.cpp: requests at once; empty keeps it"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
tokens, err := strconv.Atoi(v[0])
if err != nil || tokens < 256 {
return nil, fmt.Errorf("the context is a whole number of tokens, at least 256")
}
maxOut := 0
if v[1] != "" {
if maxOut, err = strconv.Atoi(v[1]); err != nil || maxOut < 1 {
return nil, fmt.Errorf("the longest answer is a whole number of tokens")
}
}
slots := 0
if v[2] != "" {
if slots, err = strconv.Atoi(v[2]); err != nil || slots < 1 {
return nil, fmt.Errorf("slots is a whole number from 1")
}
}
req := modelserve.ContextRequest{Tokens: tokens, Slots: slots, MaxOutput: maxOut}
if tokens == md.ContextLength && slots == 0 && maxOut > 0 {
// The context as it is: only the longest answer, and
// no replica started again for it.
req.Tokens = 0
}
return m.call("Context of "+md.Name, http.MethodPost, path+"/context", req, contextSays), nil
}})
return nil
}},
{"GPUs…", func(m *consoleModel) tea.Cmd {
profiles := append([]string{"(its only one on cards)"}, md.Profiles...)
m.openForm(&formDialog{title: "Cards of " + md.Name,
note: "How many cards of one node each replica uses: tensor parallel for vLLM, SGLang and TensorRT-LLM, " +
"the layers split for llama.cpp. Checked against the model's heads, its cards' memory and the nodes; " +
"its replicas are then started again on it, one by one.",
fields: []formField{
choiceF("profile", profiles, profiles[0]),
textF("cards", "", "a replica's, on one node"),
choiceF("in place", []string{"no", "yes"}, "no"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
n, err := strconv.Atoi(v[1])
if err != nil || n < 1 {
return nil, fmt.Errorf("the cards are a whole number from 1")
}
profile := v[0]
if profile == profiles[0] {
profile = ""
}
return m.call("Cards of "+md.Name, http.MethodPost, path+"/gpus",
modelserve.GPUsRequest{GPUs: n, Profile: profile, InPlace: v[2] == "yes"}, gpusSays), nil
}})
return nil
}},
{"Replicas per profile…", func(m *consoleModel) tea.Cmd {
var now []string
for _, p := range md.Profiles {
now = append(now, fmt.Sprintf("%s=%d", p, md.ProfileReplicas[p]))
}
m.openForm(&formDialog{title: "Replicas per profile of " + md.Name,
note: "How many replicas run on each profile, all answering under the model's name: seven on cards and " +
"four on CPUs, the router filling the cards first. A profile not named keeps its count; cpu=N with " +
"the memory below adds a profile on CPUs to a GGUF model that has none. Those short start first, " +
"then those over stop.",
fields: []formField{
textF("replicas", strings.Join(now, " "), "profile=count, separated by spaces"),
textF("CPU memory GB", "", "only to add a profile on CPUs: what a replica reserves"),
textF("CPU cores", "", "of a replica on CPUs; empty is half the node's"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
req := adminapi.ProfileReplicasRequest{Profiles: map[string]int{}}
for _, f := range strings.Fields(v[0]) {
name, c, ok := strings.Cut(f, "=")
n, err := strconv.Atoi(c)
if !ok || err != nil || n < 0 || name == "" {
return nil, fmt.Errorf("%q: a profile and a count, as gpu=7", f)
}
req.Profiles[name] = n
}
if v[1] != "" {
mem, err := strconv.Atoi(v[1])
if err != nil || mem < 1 {
return nil, fmt.Errorf("the CPU memory is a whole number of GB")
}
cores := 0
if v[2] != "" {
if cores, err = strconv.Atoi(v[2]); err != nil || cores < 1 {
return nil, fmt.Errorf("the CPU cores are a whole number from 1")
}
}
req.CPU = &modelsvc.CPUProfile{MemoryGB: mem, CPUCores: cores}
}
return m.call("Replicas of "+md.Name, http.MethodPost, path+"/replicas", req, nil), nil
}})
return nil
}},
{"Resources…", func(m *consoleModel) tea.Cmd {
profiles := md.Profiles
if len(profiles) == 0 {
profiles = []string{""}
}
m.openForm(&formDialog{title: "Resources of " + md.Name,
note: "What one replica reserves of its node.",
fields: []formField{
choiceF("profile", profiles, profiles[0]),
textF("memory GB", strconv.Itoa(md.MemoryGB), fmt.Sprintf("measured: %.1f GB", md.MeasuredMemoryGB)),
textF("CPU cores", strconv.Itoa(md.CPUCores), "for a profile that computes on them"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
mem, err1 := strconv.Atoi(v[1])
cores, err2 := strconv.Atoi(v[2])
if err1 != nil || err2 != nil || mem < 0 || cores < 0 {
return nil, fmt.Errorf("memory and cores are whole numbers")
}
return m.call("Resources of "+md.Name, http.MethodPost, path+"/resources",
adminapi.ResourcesRequest{Profile: v[0], MemoryGB: mem, CPUCores: cores}, nil), nil
}})
return nil
}},
{"LoRA adapters…", func(m *consoleModel) tea.Cmd {
var cur []string
for _, a := range md.Adapters {
s := a.Name + "=" + a.Source
if a.Scale != 0 {
s += "@" + strconv.FormatFloat(a.Scale, 'f', -1, 64)
}
cur = append(cur, s)
}
m.openForm(&formDialog{title: "Adapters of " + md.Name,
note: "Every replica loads every adapter; a request names one as its model.",
fields: []formField{textF("adapters", strings.Join(cur, ", "), "name=source[@scale], separated by commas; empty removes all")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
var next []catalog.Adapter
for _, part := range strings.Split(v[0], ",") {
if part = strings.TrimSpace(part); part == "" {
continue
}
name, rest, ok := strings.Cut(part, "=")
if !ok {
return nil, fmt.Errorf("%q is not name=source", part)
}
a := catalog.Adapter{Name: strings.TrimSpace(name), Source: strings.TrimSpace(rest)}
if src, sc, ok := strings.Cut(a.Source, "@"); ok {
f, err := strconv.ParseFloat(sc, 64)
if err != nil {
return nil, fmt.Errorf("%q: the scale is a number", part)
}
a.Source, a.Scale = src, f
}
next = append(next, a)
}
return m.call("Adapters of "+md.Name, http.MethodPost, path+"/adapters",
adminapi.AdaptersRequest{Adapters: next}, nil), nil
}})
return nil
}},
{"Restart its replicas…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Restart " + md.Name,
note: "Each replica is replaced: a new one started before the old is stopped, unless in place.",
fields: []formField{choiceF("how", []string{"one by one", "in place"}, "one by one")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call("Restart "+md.Name, http.MethodPost, "/api/v1/restart",
adminapi.RestartRequest{Models: []string{md.Name}, InPlace: v[0] == "in place"}, nil), nil
}})
return nil
}},
{"Why is it placed where it is", func(m *consoleModel) tea.Cmd {
return m.call("Placement of "+md.Name, http.MethodGet, "/api/v1/explain-placement?model="+url.QueryEscape(md.Name), nil,
func(raw json.RawMessage) string {
var e adminapi.ExplainView
if json.Unmarshal(raw, &e) != nil {
return string(raw)
}
var b strings.Builder
if e.Placed {
b.WriteString("Placed.\n")
}
for _, r := range e.Rejections {
fmt.Fprintf(&b, "%s on %s %s: %s\n", r.Profile, r.Pool, r.Node, r.Reason)
}
if b.Len() == 0 {
return "Nothing to explain: it has what it asked for."
}
return b.String()
})
}},
}
items = append(items, m.updateItems(md)...)
items = append(items, m.evalItems(md)...)
if md.Parked {
items = append(items, menuItem{"Try again (it is parked)", func(m *consoleModel) tea.Cmd {
return m.call("Retry "+md.Name, http.MethodPost, path+"/retry", map[string]string{}, nil)
}})
}
if md.Stopped {
items = append(items, menuItem{"Start", func(m *consoleModel) tea.Cmd {
return m.call("Start "+md.Name, http.MethodPost, path+"/start", nil, func(json.RawMessage) string {
return md.Name + " runs again, scaled as it was when it was stopped."
})
}})
} else {
items = append(items, menuItem{"Stop…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Stop " + md.Name,
text: "Its replicas stop; it stays in the catalog with its files, and Start runs it again as it was.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Stop "+md.Name, http.MethodPost, path+"/stop", nil, func(json.RawMessage) string {
return md.Name + " is stopped and kept."
})
}}
return nil
}})
}
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove " + md.Name,
text: "Its replicas stop and it leaves the catalog; its files stay on the mirror and the nodes. " +
"A model from server.yaml cannot be removed here.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Remove "+md.Name, http.MethodDelete, path, nil, nil)
}}
return nil
}}, menuItem{"Remove with its files…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove " + md.Name + " and its files",
text: "It leaves the catalog, every version of it is deleted from the mirror, and the nodes' copies once " +
"its replicas have stopped. What another model uses stays. This cannot be undone: adding it again fetches it again.",
yes: func(m *consoleModel) tea.Cmd {
return m.callWaiting("Remove "+md.Name, http.MethodDelete, path+"?purge=true", nil, purgeSays, 3*time.Minute)
}}
return nil
}})
return &menuDialog{title: md.Name, items: items}
}
// --- nodes ---
func (m consoleModel) nodeMenu(n adminapi.NodeView) *menuDialog {
path := "/api/v1/nodes/" + url.PathEscape(n.ID)
state := func(s, label, title string) menuItem {
return menuItem{label, func(m *consoleModel) tea.Cmd {
return m.call(title+" "+n.ID, http.MethodPost, path+"/state", map[string]string{"state": s},
func(json.RawMessage) string { return n.ID + " is " + s + "." })
}}
}
items := []menuItem{{"Details", details}}
if n.State == "cordoned" || n.State == "draining" {
items = append(items, state("ready", "Uncordon", "Uncordon"))
} else {
items = append(items, state("cordoned", "Cordon: place nothing new here", "Cordon"))
items = append(items, menuItem{"Drain…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Drain " + n.ID,
text: "Its replicas move off one at a time, each replacement up before the original stops.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Drain "+n.ID, http.MethodPost, path+"/state", map[string]string{"state": "draining"}, nil)
}}
return nil
}})
}
items = append(items, m.locationItems(n)...)
if n.GPUTotal > 0 {
for _, engine := range []string{"vllm", "sglang", "freetoken"} {
items = append(items, menuItem{engine + " build…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: engine + " on " + n.ID,
note: "Which build of " + engine + " this node installs: auto follows its hardware, none installs none.",
fields: []formField{choiceF("build", control.EngineBuilds(engine), n.EngineBuilds[engine])},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call(engine+" on "+n.ID, http.MethodPost, path+"/engines",
adminapi.NodeEngineRequest{Engine: engine, Build: v[0]}, nil), nil
}})
return nil
}})
}
}
items = append(items,
menuItem{"Upgrade its agent…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Upgrade " + n.ID,
note: "It fetches the release for its platform, checks it, swaps it in and restarts; engines keep serving.",
fields: []formField{textF("version", releaseOrEmpty(version.Version), "a release, like v0.11.1")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if !release.Tag.MatchString(v[0]) {
return nil, fmt.Errorf("a release is vMAJOR.MINOR.PATCH, like v0.15.3")
}
// As long as the control plane waits: the download, the
// restart, the node back on the version.
return m.callWaiting("Upgrade "+n.ID, http.MethodPost, path+"/upgrade", adminapi.UpgradeRequest{Version: v[0]},
func(json.RawMessage) string { return n.ID + " is on " + v[0] + "." }, agentsvc.UpgradeWait+time.Minute), nil
}})
return nil
}},
menuItem{"Delete model files nothing uses", func(m *consoleModel) tea.Cmd {
return m.call("Unused files on "+n.ID, http.MethodPost, "/api/v1/weights/collect", map[string]string{"node": n.ID},
func(raw json.RawMessage) string {
// What the node no longer lists, and what it kept.
var rs []adminapi.CollectResult
_ = json.Unmarshal(raw, &rs)
var files, left int
var b, lb int64
var errs []string
for _, r := range rs {
files, b, left, lb = files+r.Files, b+r.Bytes, left+r.Left, lb+r.LeftBytes
if r.Error != "" {
errs = append(errs, r.Error)
}
}
if len(rs) == 0 {
return "Nothing on " + n.ID + " that no model uses."
}
say := fmt.Sprintf("%d files removed, %.1f GB freed.", files, float64(b)/(1<<30))
if left > 0 {
say += fmt.Sprintf(" %d files (%.1f GB) are still there: the node kept them, and its agent's log "+
"says why (journalctl -u llmgw-agent there).", left, float64(lb)/(1<<30))
}
if len(errs) > 0 {
say += " " + strings.Join(errs, "; ")
}
return say
})
}},
menuItem{"Remove from the cluster…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove " + n.ID,
text: "The control plane forgets it. Its replicas are placed elsewhere; the machine is not touched.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Remove "+n.ID, http.MethodDelete, path, nil, nil)
}}
return nil
}},
)
return &menuDialog{title: n.ID, items: items}
}
// --- replicas ---
func (m consoleModel) replicaMenu(r adminapi.ReplicaView) *menuDialog {
return &menuDialog{title: r.ID, items: []menuItem{
{"Restart it…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Restart " + r.ID,
fields: []formField{choiceF("how", []string{"replace", "in place"}, "replace")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call("Restart "+r.ID, http.MethodPost, "/api/v1/restart",
adminapi.RestartRequest{Replicas: []string{r.ID}, InPlace: v[0] == "in place"}, nil), nil
}})
return nil
}},
}}
}
// --- capacity quotas ---
func intPtr(s string) (*int, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return nil, fmt.Errorf("%q is not a whole number; leave it empty for none", s)
}
return &n, nil
}
func ptrText(p *int) string {
if p == nil {
return ""
}
return strconv.Itoa(*p)
}
func (m *consoleModel) quotaForm(q adminapi.QuotaView) {
scope := textF("scope", q.Scope, "org:ID, team:ID or project:ID")
m.openForm(&formDialog{title: "Capacity quota",
note: "Guaranteed is never taken for someone else's model; at most is the ceiling. Empty is not governed.",
fields: []formField{scope,
textF("guaranteed GPUs", ptrText(q.Guarantee.GPUs), ""),
textF("guaranteed cores", ptrText(q.Guarantee.CPUCores), ""),
textF("guaranteed memory GB", ptrText(q.Guarantee.MemoryGB), ""),
textF("at most GPUs", ptrText(q.Limit.GPUs), ""),
textF("at most cores", ptrText(q.Limit.CPUCores), ""),
textF("at most memory GB", ptrText(q.Limit.MemoryGB), ""),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
req := adminapi.QuotaRequest{Scope: v[0]}
dst := []**int{&req.Guarantee.GPUs, &req.Guarantee.CPUCores, &req.Guarantee.MemoryGB,
&req.Limit.GPUs, &req.Limit.CPUCores, &req.Limit.MemoryGB}
for i, d := range dst {
p, err := intPtr(v[i+1])
if err != nil {
return nil, err
}
*d = p
}
return m.call("Quota for "+v[0], http.MethodPut, "/api/v1/quotas", req, nil), nil
}})
}
func (m consoleModel) quotaMenu(q adminapi.QuotaView) *menuDialog {
return &menuDialog{title: q.Scope, items: []menuItem{
{"Change…", func(m *consoleModel) tea.Cmd { m.quotaForm(q); return nil }},
{"Remove…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove the quota on " + q.Scope,
text: "Its models then share the cluster by their class alone.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Quota on "+q.Scope, http.MethodDelete, "/api/v1/quotas/"+url.PathEscape(q.Scope), nil, nil)
}}
return nil
}},
}}
}
type consoleQuotasMsg struct {
rows []adminapi.QuotaView
err error
}
func (m consoleModel) fetchQuotas() tea.Cmd {
c := m.client
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var rows []adminapi.QuotaView
err := c.get(ctx, "/api/v1/quotas", &rows)
return consoleQuotasMsg{rows, err}
}
}
func (m consoleModel) quotasPanel(pw int) (string, string, string) {
keys := "→ or Enter open · ↑↓ choose · Q leave"
if m.inPanel {
keys = "↑↓ row · Enter actions · A add · ← back · Q leave"
}
head := "\n " + sDim.Render("Capacity each org, team or project is guaranteed and may at most hold. A to add one.") + "\n\n"
if m.quotasErr != nil {
return "Quotas", head + " " + sBad.Render(m.quotasErr.Error()), keys
}
cell := func(p *int) string {
if p == nil {
return "—"
}
return strconv.Itoa(*p)
}
var rows [][]string
for _, q := range m.quotas {
name := q.Name
if name == "" {
name = q.Scope
}
rows = append(rows, []string{name,
cell(q.Guarantee.GPUs) + " / " + cell(q.Limit.GPUs),
cell(q.Guarantee.CPUCores) + " / " + cell(q.Limit.CPUCores),
cell(q.Guarantee.MemoryGB) + " / " + cell(q.Limit.MemoryGB),
fmt.Sprintf("%d GPU, %d cores, %d GB", q.Holds.GPUs, q.Holds.CPUCores, q.Holds.MemoryGB)})
}
if len(rows) == 0 {
return "Quotas", head + " " + sDim.Render("None: models share the cluster by their class alone."), keys
}
return "Quotas", head + table([]string{"WHO", "GPUS (GUAR / MAX)", "CORES", "MEMORY GB", "HOLDS"}, rows, m.row, pw, m.inPanel), keys
}
// --- the cluster ---
// runAfter closes the screen and runs llmgw with these arguments, showing its
// output; the console comes back after.
func (m *consoleModel) runAfter(args ...string) tea.Cmd {
m.action = consoleAction{kind: "run", targets: append([]string{}, args...)}
return tea.Quit
}
func (m *consoleModel) doEntry() tea.Cmd {
server := "--server=" + m.client.base
switch m.menu {
case cUpgrade:
m.openForm(&formDialog{title: "Upgrade the cluster",
note: "This machine runs " + version.Version + ".",
fields: []formField{textF("version", "", "like v0.11.2"), choiceF("this machine too", []string{"yes", "no, nodes only"}, "yes")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if !release.Tag.MatchString(v[0]) {
return nil, fmt.Errorf("%q is not a release version (v1.2.3)", v[0])
}
args := []string{"ctl", server, "upgrade", v[0]}
if v[1] != "yes" {
args = append(args, "--skip-control-plane")
}
return m.runAfter(args...), nil
}})
case cRestartAll:
m.confirmD = &confirmDialog{title: "Restart every replica",
text: "Each is replaced in turn, the new one up before the old one stops.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Restart every replica", http.MethodPost, "/api/v1/restart", adminapi.RestartRequest{All: true}, nil)
}}
case cBackup:
return m.runAfter("server", "backup", "--config="+m.file)
case cJudge:
return m.runAfter("ctl", server, "guard", "judge", "enable", "--replace")
}
return nil
}
// canaryItems try a model's newer weights on a share of its requests: start
// one, change its share, promote it, or take it out.
func (m consoleModel) canaryItems(md adminapi.ModelView) []menuItem {
path := "/api/v1/models/" + url.PathEscape(md.Name)
result := func(raw json.RawMessage) string {
var out map[string]string
_ = json.Unmarshal(raw, &out)
return out["result"] + "."
}
if cn := md.Canary; cn != nil {
return []menuItem{
{fmt.Sprintf("Canary's share (%d%%)…", cn.Percent), func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Share of " + md.Name + " for " + cn.Model,
fields: []formField{textF("percent", strconv.Itoa(cn.Percent), "0 to 100; 0 keeps it running and gives it none")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
n, err := strconv.Atoi(v[0])
if err != nil || n < 0 || n > 100 {
return nil, fmt.Errorf("a whole number from 0 to 100")
}
return m.call("Canary of "+md.Name, http.MethodPut, path+"/canary", map[string]int{"percent": n}, result), nil
}})
return nil
}},
{"Promote the canary…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Promote " + cn.Model,
text: md.Name + " moves to the canary's weights: its replicas are replaced one at a time,\n" +
"and the canary is removed. Going back is \"Go back to\" on this menu." + gateNote(md),
yes: func(m *consoleModel) tea.Cmd {
return m.call("Promote "+cn.Model, http.MethodPost, path+"/promote", map[string]string{}, startedSays)
}}
return nil
}},
{"Take the canary out…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Take " + cn.Model + " out",
text: md.Name + " serves all of its requests again, and the canary is removed.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Canary of "+md.Name, http.MethodDelete, path+"/canary", nil, result)
}}
return nil
}},
}
}
if u := md.Update; u == nil || !u.Available || md.CanaryOf != "" {
return nil
}
return []menuItem{{"Try the new weights as a canary…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Canary of " + md.Name,
note: "The new weights run beside it, one replica, given this share of its requests.\n" +
"Taken out by itself if it errs or answers more slowly than " + md.Name + ".",
fields: []formField{textF("percent", "10", "share of its requests, 0 to 100")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
n, err := strconv.Atoi(v[0])
if err != nil || n < 0 || n > 100 {
return nil, fmt.Errorf("a whole number from 0 to 100")
}
return m.call("Canary of "+md.Name, http.MethodPost, path+"/canary", catalog.Canary{Percent: n}, startedSays), nil
}})
return nil
}}}
}
// updateItems are what can be done about a model's repository publishing new
// weights: move to them, ask now, or go back.
func (m consoleModel) updateItems(md adminapi.ModelView) []menuItem {
origin := menuItem{"Where its weights came from…", func(m *consoleModel) tea.Cmd {
repo := ""
if md.Origin != nil {
repo = md.Origin.Repo
}
m.openForm(&formDialog{title: "Origin of " + md.Name,
note: "The registry repository its files were fetched from. Checked: it must have the\n" +
"commit the mirror's directory is named after, with the same files at the same sizes.",
fields: []formField{textF("repository", repo, "owner/name, like bartowski/phi-4-GGUF")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if !strings.Contains(v[0], "/") {
return nil, fmt.Errorf("owner/name")
}
return m.call("Origin of "+md.Name, http.MethodPost, "/api/v1/models/"+url.PathEscape(md.Name)+"/origin",
map[string]string{"repo": v[0]}, func(raw json.RawMessage) string {
var o adminapi.OriginView
_ = json.Unmarshal(raw, &o)
return fmt.Sprintf("%s came from %s at %s; its updates are watched.", md.Name, o.Repo,
catalog.ShortRevision(o.Revision))
}), nil
}})
return nil
}}
if md.Origin == nil {
if strings.Contains(md.Source, "://") {
return []menuItem{origin}
}
return nil
}
var items []menuItem
if u := md.Update; u != nil && u.Available {
items = append(items, menuItem{fmt.Sprintf("Update to %s (%s)…", catalog.ShortRevision(u.Latest), bytesShort(u.Bytes)),
func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Update " + md.Name,
text: fmt.Sprintf("%s has new weights at %s: %s.\n\n"+
"They are fetched into the mirror beside the ones in use and checked, then the\n"+
"replicas are replaced one at a time. The old files stay, to go back to.%s",
u.Repo, catalog.ShortRevision(u.Latest), strings.Join(u.Changed, ", "), gateNote(md)),
yes: func(m *consoleModel) tea.Cmd {
return m.call("Update "+md.Name, http.MethodPost, "/api/v1/models/"+url.PathEscape(md.Name)+"/update",
map[string]string{}, startedSays)
}}
return nil
}})
}
items = append(items, m.canaryItems(md)...)
items = append(items, menuItem{"Ask the registry for new weights now", func(m *consoleModel) tea.Cmd {
return m.call("New weights for "+md.Name, http.MethodPost, "/api/v1/updates/check", map[string]string{"model": md.Name},
func(raw json.RawMessage) string {
var us []modelserve.UpdateInfo
if json.Unmarshal(raw, &us) != nil || len(us) == 0 {
return string(raw)
}
u := us[0]
if u.Available {
return fmt.Sprintf("%s has new weights at %s: %s, %s.", u.Repo, catalog.ShortRevision(u.Latest),
strings.Join(u.Changed, ", "), bytesShort(u.Bytes))
}
return fmt.Sprintf("%s at %s: %s", u.Repo, catalog.ShortRevision(u.Latest), updateCell(u))
})
}})
items = append(items, origin)
if prev := md.Origin.Previous; prev != "" {
items = append(items, menuItem{"Go back to " + catalog.ShortRevision(prev) + "…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Go back on " + md.Name,
text: "It runs the weights its last update replaced, " + catalog.ShortRevision(prev) +
", if they are still on the mirror;\nits replicas are replaced one at a time.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Go back on "+md.Name, http.MethodPost, "/api/v1/models/"+url.PathEscape(md.Name)+"/rollback",
map[string]string{}, func(raw json.RawMessage) string {
var out map[string]string
_ = json.Unmarshal(raw, &out)
return out["result"] + "."
})
}}
return nil
}})
}
return items
}
// locationItems are the node menu's facility entries: where the node is,
// and holding or releasing its rack.
func (m consoleModel) locationItems(n adminapi.NodeView) []menuItem {
path := "/api/v1/nodes/" + url.PathEscape(n.ID) + "/labels"
var loc facility.Location
if n.Location != nil {
loc = *n.Location
}
items := []menuItem{{"Location…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Where " + n.ID + " is",
note: "Placement spreads a model's replicas across racks and PDUs, and keeps out of held ones. " +
"Empty leaves a level unknown; a PDU list is comma separated, for a dual-fed rack.",
fields: []formField{
textF("site", loc.Site, "sto1"), textF("room", loc.Room, "hall-b"),
textF("row", loc.Row, "12"), textF("rack", loc.Rack, "r12-07"),
textF("pdu", strings.Join(loc.PDUs, ","), "r12-07-a,r12-07-b"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
req := locationRequest(n, v)
if len(req.Set) == 0 && len(req.Remove) == 0 {
return nil, fmt.Errorf("nothing changed")
}
return m.call("Location of "+n.ID, http.MethodPost, path, req,
func(json.RawMessage) string { return n.ID + "'s location is set." }), nil
}})
return nil
}}}
switch {
case n.HeldDomain != "":
d := n.HeldDomain
items = append(items, menuItem{"Release " + d, func(m *consoleModel) tea.Cmd {
return m.call("Release "+d, http.MethodDelete, "/api/v1/facility/holds/"+url.PathEscape(d), nil,
func(json.RawMessage) string { return d + " is released." })
}})
case loc.Rack != "":
d := string(facility.Of(facility.Rack, loc.Rack))
items = append(items, menuItem{"Hold rack " + loc.Rack + "…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Hold rack " + loc.Rack,
note: "Nothing new is placed in the rack. Drain also moves its replicas off, one at a time.",
fields: []formField{textF("reason", "", "PDU A replacement"),
choiceF("drain", []string{"no", "yes"}, "no")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call("Hold "+d, http.MethodPost, "/api/v1/facility/holds",
adminapi.HoldRequest{Domain: d, Reason: v[0], Drain: v[1] == "yes"},
func(json.RawMessage) string { return d + " is held." }), nil
}})
return nil
}})
}
return items
}
// locationRequest turns the location form's values, site to pdu, into the
// labels to set and the ones to remove: a level emptied that was set through
// the control plane is removed, which leaves the agent's own value if any.
func locationRequest(n adminapi.NodeView, vals []string) adminapi.NodeLabelsRequest {
req := adminapi.NodeLabelsRequest{Set: map[string]string{}}
for i, lv := range facility.Levels {
k, v := lv.Label(), strings.TrimSpace(vals[i])
switch {
case v != "" && n.Labels[k] != v:
req.Set[k] = v
case v == "" && n.OperatorLabels[k] != "":
req.Remove = append(req.Remove, k)
}
}
return req
}
// contextSays is a context change in a sentence or two.
func contextSays(raw json.RawMessage) string {
var v modelserve.ContextChange
if json.Unmarshal(raw, &v) != nil {
return "Done."
}
if v.OutputOnly {
return fmt.Sprintf("%s: answers up to %d tokens (was %d); no replica was started again.", v.Model, v.MaxOutput, v.MaxOutputBefore)
}
s := fmt.Sprintf("%s: %d → %d tokens a request, answers up to %d.", v.Model, v.Before, v.After, v.MaxOutput)
for _, p := range v.Profiles {
if p.After != "" {
s += "\n" + p.Profile + ": " + p.After
}
}
if v.Note != "" {
s += "\n" + v.Note
}
if v.Restarted > 0 {
s += fmt.Sprintf("\n%d replica(s) started again on it, one by one.", v.Restarted)
}
return s
}
// gpusSays is a change of cards in a sentence or two.
func gpusSays(raw json.RawMessage) string {
var v modelserve.GPUsChange
if json.Unmarshal(raw, &v) != nil {
return "Done."
}
s := fmt.Sprintf("%s: %d → %d %s card(s) a replica.", v.Model, v.Before, v.After, v.Class)
if v.PerCard != "" {
s += "\n" + v.PerCard
}
if v.Note != "" {
s += "\n" + v.Note
}
switch {
case v.Restarted > 0 && v.InPlace:
s += fmt.Sprintf("\n%d replica(s) stopped and started again on it, one by one.", v.Restarted)
case v.Restarted > 0:
s += fmt.Sprintf("\n%d replica(s) started again on it, one by one.", v.Restarted)
}
return s
}
// purgeSays is a model removed with its files, in a few lines.
func purgeSays(raw json.RawMessage) string {
var v adminapi.PurgeView
if json.Unmarshal(raw, &v) != nil {
return "Done."
}
s := v.Removed + " removed."
if len(v.Mirror) > 0 {
s += fmt.Sprintf("\nMirror: %d version(s) deleted.", len(v.Mirror))
}
if len(v.Kept) > 0 {
s += "\nKept on the mirror, another model uses them: " + strings.Join(v.Kept, ", ")
}
var files int
var freed int64
for _, n := range v.Nodes {
files += n.Files
freed += n.Bytes
}
if files > 0 {
s += fmt.Sprintf("\nNodes: %d file(s), %s freed.", files, humanBytes(freed))
}
if v.Note != "" {
s += "\n" + v.Note
}
return s
}
// releaseOrEmpty is this build's version when it is a release, else empty:
// a development build's is not one a node can fetch.
func releaseOrEmpty(v string) string {
if release.Tag.MatchString(v) {
return v
}
return ""
}