CI / test (push) Failing after 6m44s
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>
1147 lines
42 KiB
Go
1147 lines
42 KiB
Go
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 ""
|
||
}
|