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/catalog" "gitea.mynodes.xyz/ilker/llmgw/internal/control/facility" "gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve" "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 { m.busy = title c := m.client return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) 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 }}, {"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) }}) } 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. 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 }}) 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"} { 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", version.Version, "a release, like v0.11.1")}, submit: func(m *consoleModel, v []string) (tea.Cmd, error) { return m.call("Upgrade "+n.ID, http.MethodPost, path+"/upgrade", adminapi.UpgradeRequest{Version: v[0]}, nil), 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 { var rs []struct { Files int `json:"files"` Bytes int64 `json:"bytes"` } _ = json.Unmarshal(raw, &rs) var files int var b int64 for _, r := range rs { files, b = files+r.Files, b+r.Bytes } return fmt.Sprintf("%d files, %.1f GB freed.", files, float64(b)/(1<<30)) }) }}, 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 }