CI / test (push) Successful in 9m29s
mirror add-peer, run on the mirror's machine, sets up a mirror on another over ssh, kept in step with this one: llmgw there if it has none, mirror init --peer, this mirror told of it and allowed to be read by it, and mirror.peers in the cluster's settings. The setup screens offer one on either database machine, as server init --second-mirror. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
765 lines
29 KiB
Go
765 lines
29 KiB
Go
package cli
|
||
|
||
import (
|
||
"fmt"
|
||
"net/mail"
|
||
"net/url"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||
)
|
||
|
||
// The parts of an install, each chosen and each with its settings: what the
|
||
// setup screens show before anything is installed, for one machine and for
|
||
// three alike. Beside each part is what it is for and what is missing
|
||
// without it; Enter opens its settings, which say the same at more length.
|
||
|
||
type installPart struct {
|
||
title string
|
||
// about is what the part is, at the length its dialog has room for.
|
||
about string
|
||
// fixed parts are always installed: they have settings, not a box.
|
||
fixed bool
|
||
on bool
|
||
// fields are its settings; none is a part only to tick.
|
||
fields []*setupField
|
||
// summary is a line of what it will be; off what is missing without it.
|
||
summary func() string
|
||
off string
|
||
// needs, when set, says why the part cannot be had with the rest as
|
||
// chosen: a starter model with no mirror to fetch it into.
|
||
needs func() string
|
||
}
|
||
|
||
type installForm struct {
|
||
ha bool
|
||
host string
|
||
found []string
|
||
parts []*installPart
|
||
// focus is a part, or len(parts) for the button that goes on.
|
||
focus int
|
||
// open is the part whose settings are open in a dialog.
|
||
open *installPart
|
||
dialog *setupDialog
|
||
// err is said in a dialog of its own; when errField is set, closing it
|
||
// opens that field's part with the focus on it.
|
||
err string
|
||
errPart *installPart
|
||
errField *setupField
|
||
height int
|
||
|
||
standby *installPart
|
||
vipF *setupField
|
||
|
||
control, store, mirror, node, starter, media, judge, firewall, alerts, limits, turns, bundle *installPart
|
||
|
||
admin, hosts, language, signup *setupField
|
||
storeKind *setupField
|
||
mirrorWhere, mirrorDirF, mirrorURL *setupField
|
||
secondMirror *setupField
|
||
starterPick, starterSource, starterCtx *setupField
|
||
judgeFail, judgeCopies, judgeBudget *setupField
|
||
judgeCats []*setupField
|
||
webhooks *setupField
|
||
rpm, tpm, keyDays *setupField
|
||
idle, safe *setupField
|
||
bundlePath, bundleSHA *setupField
|
||
}
|
||
|
||
// newInstallForm is the parts as in has them: its flags, or the defaults.
|
||
func newInstallForm(in *serverInit, host string, found []string, ha bool) *installForm {
|
||
f := &installForm{ha: ha, host: host, found: found, height: 24}
|
||
|
||
// --- the control plane ---
|
||
f.admin = textSetupField("Administrator's email", in.admin, "you@example.com",
|
||
"A password is made for this account and shown once, at the end.")
|
||
if !ha {
|
||
f.admin.Hint += " Leave it empty to make accounts later with 'llmgw users create'."
|
||
}
|
||
known := "none found"
|
||
if len(found) > 0 {
|
||
known = strings.Join(found, ", ")
|
||
}
|
||
f.hosts = textSetupField("Other names nodes reach this machine by", strings.Join(in.hosts, ", "),
|
||
"llm.example.com, 203.0.113.10",
|
||
"Already in its certificate: "+known+". Add a DNS name, a NAT address or a floating address, comma-separated.")
|
||
lang := 0
|
||
if in.language == "tr" {
|
||
lang = 1
|
||
}
|
||
f.language = choiceField("The portal speaks", []string{"English", "Türkçe"}, lang,
|
||
"To a browser that asks for neither. Each person can switch at the foot of any page.")
|
||
f.signup = choiceField("Sign-up form", []string{"closed", "open"}, map[bool]int{false: 0, true: 1}[in.signupOpen], "")
|
||
f.signup.Notes = []string{
|
||
"Accounts are made by an administrator, or come from single sign-on.",
|
||
"Anyone who reaches the portal can ask for an account; it waits for an administrator's approval.",
|
||
}
|
||
f.control = &installPart{
|
||
title: "Control plane", fixed: true, on: true,
|
||
about: "The gateway every request comes through, the admin API, the web portal on port 8080, and the " +
|
||
"certificate authority that signs every node's identity. Always installed: it is what llmgw is.\n\n" +
|
||
"Its authority's key stays in " + orText(in.pkiDir, defaultPKIDir) + " on this machine. Back it up: nothing recovers it.",
|
||
fields: []*setupField{f.admin, f.hosts, f.language, f.signup},
|
||
summary: func() string {
|
||
s := []string{}
|
||
if a := f.admin.Value(); a != "" {
|
||
s = append(s, "administrator "+a)
|
||
} else {
|
||
s = append(s, "no administrator")
|
||
}
|
||
s = append(s, f.language.Options[f.language.Choice], "sign-up "+f.signup.Options[f.signup.Choice])
|
||
return strings.Join(s, " · ")
|
||
},
|
||
}
|
||
|
||
// --- the store ---
|
||
f.storeKind = choiceField("Kept in", []string{"SQLite", "PostgreSQL"}, map[bool]int{false: 0, true: 1}[in.postgres], "")
|
||
f.storeKind.Notes = []string{
|
||
"A file on this machine. Nothing to look after; enough for one control plane and a team's traffic.",
|
||
"Installed here, with its own role and a generated password. For a larger store, or before adding a second control plane.",
|
||
}
|
||
f.store = &installPart{
|
||
title: "Store", fixed: true, on: true,
|
||
about: "Accounts, API keys, the catalog of models, usage and the cluster's settings live here.",
|
||
summary: func() string {
|
||
if ha {
|
||
return "PostgreSQL under Patroni, on the other two"
|
||
}
|
||
return f.storeKind.Options[f.storeKind.Choice] + " on this machine"
|
||
},
|
||
}
|
||
if ha {
|
||
f.store.about += "\n\nWith three machines it is PostgreSQL on the other two, replicated under Patroni: " +
|
||
"if the primary is lost the standby takes over in about thirty seconds, and the control plane follows. " +
|
||
"The machines are asked for on the next page."
|
||
} else {
|
||
f.store.fields = []*setupField{f.storeKind}
|
||
}
|
||
|
||
// --- control planes on all three ---
|
||
if ha {
|
||
f.vipF = textSetupField("Floating address", in.vip, "10.0.0.160/24",
|
||
"Optional. An unused address on these machines' network, with its prefix: keepalived keeps it on "+
|
||
"whichever control plane leads, and clients use it. Without one, put a load balancer in front, "+
|
||
"or give clients the three addresses.")
|
||
f.standby = &installPart{
|
||
title: "Control plane on all three", on: in.standbys,
|
||
about: "The two database machines become control planes too, standing by while this one leads. " +
|
||
"If the leader is lost, one of them takes over within seconds: it places models, and every node " +
|
||
"is told of it and fails over without being touched. With the store on two machines and etcd's " +
|
||
"three votes on the three, losing any one machine loses neither the store nor the gateway.\n\n" +
|
||
"The model mirror stays on this machine: while it is down, models the nodes already have keep " +
|
||
"serving, and new ones wait for it.",
|
||
fields: []*setupField{f.vipF},
|
||
off: "the gateway runs on this machine only: if it is lost, the store survives but nothing is served until it is back",
|
||
summary: func() string {
|
||
if v := f.vipF.Value(); v != "" {
|
||
return "two standbys; " + v + " follows whichever leads"
|
||
}
|
||
return "two standbys; no floating address"
|
||
},
|
||
}
|
||
}
|
||
|
||
// --- the mirror ---
|
||
where := 0
|
||
if in.mirrorURL != "" {
|
||
where = 1
|
||
}
|
||
f.mirrorWhere = choiceField("Where", []string{"on this machine", "on another machine"}, where, "")
|
||
f.mirrorWhere.Notes = []string{
|
||
"Serves model files on port 8081 to every node; its management API is on loopback only.",
|
||
"Set up there first with 'llmgw mirror init --control-plane <this machine>'.",
|
||
}
|
||
f.mirrorDirF = textSetupField("Directory", orText(in.mirrorPath, mirrorDir), mirrorDir,
|
||
"Models are large: a 7B model is about 5 GB, a 70B about 40 GB. Put it on the biggest disk.")
|
||
f.mirrorDirF.Show = func() bool { return f.mirrorWhere.Choice == 0 }
|
||
f.mirrorURL = textSetupField("Its management API", in.mirrorURL, "http://10.0.0.20:8082", "")
|
||
f.mirrorURL.Show = func() bool { return f.mirrorWhere.Choice == 1 }
|
||
f.secondMirror = choiceField("A second mirror", []string{"none", "on database machine 1", "on database machine 2"},
|
||
in.secondMirror, "Kept in step with this one, each copying what the other has: while either machine is down, "+
|
||
"models are still added and fetched. It needs room for every model, as this one does.")
|
||
f.secondMirror.Show = func() bool { return ha && f.mirrorWhere.Choice == 0 }
|
||
f.mirror = &installPart{
|
||
title: "Model mirror", on: !in.noMirror || in.mirrorURL != "",
|
||
about: "Downloads each model once, from Hugging Face or wherever it comes from, and serves the files to " +
|
||
"every node on the network, so ten nodes do not download the same 40 GB ten times. " +
|
||
"The starter model, the judge and an offline bundle go into it.",
|
||
fields: []*setupField{f.mirrorWhere, f.mirrorDirF, f.mirrorURL, f.secondMirror},
|
||
off: "each model's files must already be on a mirror somewhere; no starter model, no judge",
|
||
summary: func() string {
|
||
if f.mirrorWhere.Choice == 1 {
|
||
return "at " + orText(f.mirrorURL.Value(), "(an address)")
|
||
}
|
||
s := "on this machine, in " + orText(f.mirrorDirF.Value(), mirrorDir)
|
||
if ha && f.secondMirror.Choice > 0 {
|
||
s += "; a second " + f.secondMirror.Options[f.secondMirror.Choice]
|
||
}
|
||
return s
|
||
},
|
||
}
|
||
|
||
// --- this machine as a node ---
|
||
f.node = &installPart{
|
||
title: "This machine as a node", on: !in.noNode,
|
||
about: "Runs models here as well, on this machine's NVIDIA GPUs when it has them and on its CPU otherwise. " +
|
||
"The node installs its own engines (llama.cpp first, vLLM after where the card can run it).\n\n" +
|
||
"Leave it off when this machine has neither the memory nor the cards to spare: models then run on " +
|
||
"the nodes added later with 'llmgw node add'.",
|
||
off: "models run only on nodes added later",
|
||
summary: func() string { return "serves models here, on its GPUs or its CPU" },
|
||
}
|
||
if ha {
|
||
f.node.about += " With three machines the control plane usually serves none."
|
||
}
|
||
|
||
// --- the starter model ---
|
||
var opts, notes []string
|
||
pick := 0
|
||
cur := in.starterModelSpec()
|
||
for i, c := range starterChoices {
|
||
opts = append(opts, strings.TrimSuffix(c.Name, "-instruct"))
|
||
notes = append(notes, fmt.Sprintf("%s, about %.1f GB: %s.", c.Name, c.SizeGB, c.About))
|
||
if c.Name == cur.Name {
|
||
pick = i
|
||
}
|
||
}
|
||
opts = append(opts, "other")
|
||
notes = append(notes, "Any single GGUF file on Hugging Face.")
|
||
custom := ""
|
||
if in.starter.Source != "" && !slices.ContainsFunc(starterChoices, func(c starterSpec) bool { return c.Name == in.starter.Name }) {
|
||
pick, custom = len(starterChoices), in.starter.Source
|
||
}
|
||
f.starterPick = choiceField("Model", opts, pick, "")
|
||
f.starterPick.Notes = notes
|
||
f.starterSource = textSetupField("Its file", custom, "hf:owner/repo/file.gguf", "")
|
||
f.starterSource.Show = func() bool { return f.starterPick.Choice == len(starterChoices) }
|
||
f.starterCtx = textSetupField("Context a client may use, in tokens", strconv.Itoa(in.starterContext()), "4096",
|
||
"The engine holds four times this, for four requests at once; more context takes more memory.")
|
||
f.starter = &installPart{
|
||
title: "Starter model", on: !in.noModel,
|
||
about: "A model fetched into the mirror and served on this machine, so there is something to ask the " +
|
||
"moment the install finishes. Small ones answer on any CPU; the larger want a GPU. More models are " +
|
||
"added later from /admin/mirror or with 'llmgw ctl model add'.",
|
||
fields: []*setupField{f.starterPick, f.starterSource, f.starterCtx},
|
||
off: "nothing to ask until a model is added",
|
||
summary: func() string {
|
||
name := opts[f.starterPick.Choice]
|
||
if f.starterPick.Choice == len(starterChoices) {
|
||
name = orText(f.starterSource.Value(), "(a file)")
|
||
} else {
|
||
name = fmt.Sprintf("%s (%.1f GB)", starterChoices[f.starterPick.Choice].Name, starterChoices[f.starterPick.Choice].SizeGB)
|
||
}
|
||
return name + ", " + f.starterCtx.Value() + " tokens"
|
||
},
|
||
needs: func() string {
|
||
if !f.mirror.on {
|
||
return "the starter model is fetched into the mirror: turn the mirror on, or the starter model off"
|
||
}
|
||
return ""
|
||
},
|
||
}
|
||
if ha {
|
||
f.starter.about += " Unless this machine is a node, it runs on the first node that joins with room for it."
|
||
}
|
||
|
||
// --- pictures and 3D ---
|
||
f.media = &installPart{
|
||
title: "Pictures and 3D", on: in.media,
|
||
about: "The nodes install ComfyUI, and once a node has a card that fits one the control plane adds a " +
|
||
"picture model (FLUX.1 schnell) and a 3D model (TripoSR) by itself, so the portal's pictures and 3D " +
|
||
"pages and their APIs work without anybody writing a definition. They load when asked for and give " +
|
||
"their card back when idle.\n\n" +
|
||
"Each comes under its own licence, kept with every file it makes. A preset someone removes is not " +
|
||
"added again. Later: 'llmgw ctl model presets auto off|on'.",
|
||
off: "no pictures or 3D until a preset is added from the console or /admin",
|
||
summary: func() string { return "ComfyUI; a picture model and a 3D model once a card fits" },
|
||
}
|
||
|
||
// --- the guardrails' judge ---
|
||
j := in.judgeFor()
|
||
f.judgeFail = choiceField("When no judge answers in time", []string{"refuse", "serve flagged"},
|
||
map[string]int{"closed": 0, "open": 1}[j.Fail], "")
|
||
f.judgeFail.Notes = []string{
|
||
"The request is refused (503). Safe, and why the judge runs twice.",
|
||
"The request is served unjudged and marked so in the logs. Nothing is refused for the judge's sake.",
|
||
}
|
||
f.judgeCopies = textSetupField("Copies", strconv.Itoa(j.Replicas), "2",
|
||
"On one machine one copy, on up to four cores; with more machines two, two cores each, on different nodes. Each reserves 2 GB.")
|
||
f.judgeBudget = textSetupField("Waits at most", durationYAML(j.Budget), "5s",
|
||
"A short text is judged in about two seconds on CPUs.")
|
||
judgeFields := []*setupField{f.judgeFail, f.judgeCopies, f.judgeBudget, headingField("Refuses")}
|
||
catNotes := map[string]string{
|
||
"PII": "the patterns already catch secrets and card numbers",
|
||
"Unethical Acts": "refuses much ordinary work",
|
||
"Politically Sensitive Topics": "refuses much ordinary work",
|
||
"Copyright Violation": "refuses much ordinary work",
|
||
}
|
||
for _, c := range judgeCategories {
|
||
t := toggleField(c, slices.Contains(j.Categories, c), catNotes[c])
|
||
f.judgeCats = append(f.judgeCats, t)
|
||
judgeFields = append(judgeFields, t)
|
||
}
|
||
f.judge = &installPart{
|
||
title: "Guardrails' judge", on: !in.noGuardModel,
|
||
about: "Qwen3Guard 0.6B reads every prompt and every answer for what patterns cannot see: violence, " +
|
||
"crime, sexual content, self-harm, jailbreaks. It runs on CPUs and takes no card. Without it the " +
|
||
"baseline still blocks secrets and card numbers and flags prompt injection.",
|
||
fields: judgeFields,
|
||
off: "requests are checked by patterns only: secrets, card numbers, injection",
|
||
summary: func() string {
|
||
n := 0
|
||
for _, c := range f.judgeCats {
|
||
if c.On {
|
||
n++
|
||
}
|
||
}
|
||
return fmt.Sprintf("Qwen3Guard ×%s on CPUs, %d categories, unanswered: %s",
|
||
f.judgeCopies.Value(), n, f.judgeFail.Options[f.judgeFail.Choice])
|
||
},
|
||
needs: func() string {
|
||
if !f.mirror.on {
|
||
return "the judge's model is fetched into the mirror: turn the mirror on, or the judge off"
|
||
}
|
||
return ""
|
||
},
|
||
}
|
||
|
||
// --- the firewall ---
|
||
f.firewall = &installPart{
|
||
title: "Firewall", on: !in.noFirewall,
|
||
about: "An nftables table of llmgw's own keeps its internal ports — the agents' 7443, the mirror's 8081, " +
|
||
"the store's — to the networks the cluster is on. The portal and API on 8080 stay open. " +
|
||
"Other rules on the machine are left as they are.",
|
||
off: "llmgw's internal ports answer every network this machine is on",
|
||
summary: func() string { return "internal ports to the cluster's networks only" },
|
||
}
|
||
|
||
// --- notifications ---
|
||
f.webhooks = textSetupField("Webhooks", strings.Join(in.webhooks, ", "), "https://hooks.slack.com/services/…",
|
||
"Slack, Mattermost, Rocket.Chat, or any address taking JSON; comma-separated. slack: or json: in front says which. A test is sent to each.")
|
||
f.alerts = &installPart{
|
||
title: "Notifications", on: len(in.webhooks) > 0,
|
||
about: "Where the cluster says what went wrong: a model parked because it fits nowhere, a node lost, " +
|
||
"a judge not answering, a budget spent.",
|
||
fields: []*setupField{f.webhooks},
|
||
off: "nothing is said outside the dashboards",
|
||
summary: func() string {
|
||
hooks, err := parseWebhooks([]string{f.webhooks.Value()})
|
||
if err != nil || len(hooks) == 0 {
|
||
return "(no webhook yet)"
|
||
}
|
||
var where []string
|
||
for _, h := range hooks {
|
||
where = append(where, notify.Redact(h.URL))
|
||
}
|
||
return strings.Join(where, ", ")
|
||
},
|
||
}
|
||
|
||
// --- limits ---
|
||
f.rpm = textSetupField("Requests a minute", strconv.Itoa(orInt(in.defaultRPM, defaultRPM)), "60", "")
|
||
f.tpm = textSetupField("Tokens a minute", strconv.Itoa(orInt(in.defaultTPM, defaultTPM)), "200000", "")
|
||
ttl := in.maxKeyTTL
|
||
if ttl <= 0 {
|
||
ttl = defaultMaxKeyTTL
|
||
}
|
||
f.keyDays = textSetupField("The longest a key lives, in days", strconv.Itoa(int(ttl.Hours()/24)), "90",
|
||
"A key made without an expiry gets this. Shorter per organization, team or project later.")
|
||
f.limits = &installPart{
|
||
title: "Limits and keys", fixed: true, on: true,
|
||
about: "What a key may use when nothing more particular gives it a limit: a project made without limits " +
|
||
"would otherwise be metered by nothing. Organizations, teams and projects get their own later.",
|
||
fields: []*setupField{f.rpm, f.tpm, f.keyDays},
|
||
summary: func() string {
|
||
return fmt.Sprintf("%s requests and %s tokens a minute; keys live %s days", f.rpm.Value(), f.tpm.Value(), f.keyDays.Value())
|
||
},
|
||
}
|
||
|
||
// --- on-demand turns ---
|
||
f.idle = textSetupField("Unused for", durationYAML(orDur(in.evictAfterIdle, 2*time.Minute)), "2m",
|
||
"before a model loaded on demand gives its card to another that wants it.")
|
||
f.safe = textSetupField("Safe after loading for", durationYAML(orDur(in.minLifetime, 5*time.Minute)), "5m",
|
||
"so two models do not take turns paying for loads. 1m each suits one card shared by a picture model and a 3D model.")
|
||
f.turns = &installPart{
|
||
title: "Taking turns on a card", fixed: true, on: true,
|
||
about: "Models that load when asked for share the cards: on a site with fewer cards than such models, " +
|
||
"one waits for another to go idle. These are the waits.",
|
||
fields: []*setupField{f.idle, f.safe},
|
||
summary: func() string {
|
||
return "after " + f.idle.Value() + " unused; safe for " + f.safe.Value() + " after loading"
|
||
},
|
||
}
|
||
|
||
// --- offline bundle ---
|
||
f.bundlePath = textSetupField("Bundle", in.bundle, "/media/usb/llmgw-bundle.tar",
|
||
"Made on a machine with internet by 'llmgw bundle create'.")
|
||
f.bundleSHA = textSetupField("Its sha256", in.bundleSHA, "the sha256 'llmgw bundle create' printed", "")
|
||
f.bundleSHA.Text.Width = 64
|
||
f.bundle = &installPart{
|
||
title: "Offline bundle", on: in.bundle != "",
|
||
about: "For a site with no internet: engines, releases and models carried in on a disk. They go into the " +
|
||
"mirror, and the cluster is set to reach nothing outside.",
|
||
fields: []*setupField{f.bundlePath, f.bundleSHA},
|
||
off: "engines and models are downloaded from the internet",
|
||
summary: func() string { return orText(f.bundlePath.Value(), "(a file)") },
|
||
needs: func() string {
|
||
if !f.mirror.on {
|
||
return "an offline bundle goes into the model mirror: turn the mirror on"
|
||
}
|
||
return ""
|
||
},
|
||
}
|
||
|
||
f.parts = []*installPart{f.control, f.store}
|
||
if ha {
|
||
f.parts = append(f.parts, f.standby)
|
||
}
|
||
f.parts = append(f.parts, f.mirror, f.node, f.starter, f.media, f.judge, f.firewall,
|
||
f.alerts, f.limits, f.turns, f.bundle)
|
||
return f
|
||
}
|
||
|
||
func orText(s, d string) string {
|
||
if strings.TrimSpace(s) == "" {
|
||
return d
|
||
}
|
||
return s
|
||
}
|
||
|
||
func orInt(n, d int) int {
|
||
if n <= 0 {
|
||
return d
|
||
}
|
||
return n
|
||
}
|
||
|
||
func orDur(v, d time.Duration) time.Duration {
|
||
if v <= 0 {
|
||
return d
|
||
}
|
||
return v
|
||
}
|
||
|
||
// openPart opens a part's settings.
|
||
func (f *installForm) openPart(p *installPart) {
|
||
f.open = p
|
||
f.dialog = &setupDialog{Title: p.title, About: p.about, Fields: p.fields}
|
||
f.dialog.first()
|
||
}
|
||
|
||
// fail says what is wrong, and with what, so that closing the message opens it.
|
||
func (f *installForm) fail(p *installPart, field *setupField, msg string) error {
|
||
f.err, f.errPart, f.errField = msg, p, field
|
||
return fmt.Errorf("%s", msg)
|
||
}
|
||
|
||
// key takes a key; next is whether the person asked to go on, the choices
|
||
// already put into in.
|
||
func (f *installForm) key(k tea.KeyMsg, in *serverInit) (next bool) {
|
||
if f.err != "" {
|
||
p, field := f.errPart, f.errField
|
||
f.err, f.errPart, f.errField = "", nil, nil
|
||
if p != nil {
|
||
f.openPart(p)
|
||
if field != nil {
|
||
f.dialog.focusOn(field)
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
if f.dialog != nil {
|
||
if f.dialog.key(k) {
|
||
f.dialog, f.open = nil, nil
|
||
}
|
||
return false
|
||
}
|
||
n := len(f.parts) + 1
|
||
switch k.String() {
|
||
case "down", "tab", "j":
|
||
f.focus = (f.focus + 1) % n
|
||
case "up", "shift+tab", "k":
|
||
f.focus = (f.focus + n - 1) % n
|
||
case " ", "x":
|
||
if f.focus < len(f.parts) {
|
||
p := f.parts[f.focus]
|
||
if p.fixed {
|
||
break
|
||
}
|
||
p.on = !p.on
|
||
// A part that is nothing until it is told something is told
|
||
// at once.
|
||
if p.on && (p == f.alerts && f.webhooks.Value() == "" || p == f.bundle && f.bundlePath.Value() == "") {
|
||
f.openPart(p)
|
||
}
|
||
}
|
||
case "enter":
|
||
if f.focus < len(f.parts) {
|
||
f.openPart(f.parts[f.focus])
|
||
return false
|
||
}
|
||
return f.apply(in) == nil
|
||
}
|
||
return false
|
||
}
|
||
|
||
// apply checks the choices and puts them into in; what is wrong is kept to
|
||
// be said.
|
||
func (f *installForm) apply(in *serverInit) error {
|
||
for _, p := range f.parts {
|
||
if p.on && p.needs != nil {
|
||
if why := p.needs(); why != "" {
|
||
return f.fail(nil, nil, why)
|
||
}
|
||
}
|
||
}
|
||
admin := f.admin.Value()
|
||
if admin != "" || f.ha {
|
||
if _, err := mail.ParseAddress(admin); err != nil {
|
||
msg := "the administrator's email is not an address"
|
||
if admin == "" {
|
||
msg = "the administrator's email is needed"
|
||
}
|
||
return f.fail(f.control, f.admin, msg)
|
||
}
|
||
}
|
||
var hosts []string
|
||
for _, h := range strings.Split(f.hosts.Value(), ",") {
|
||
if h = strings.TrimSpace(h); h != "" {
|
||
hosts = append(hosts, h)
|
||
}
|
||
}
|
||
|
||
mirrorURL := ""
|
||
if f.mirror.on && f.mirrorWhere.Choice == 1 {
|
||
u, err := url.Parse(f.mirrorURL.Value())
|
||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||
return f.fail(f.mirror, f.mirrorURL, "the other machine's mirror: its management API, as http://host:8082")
|
||
}
|
||
mirrorURL = f.mirrorURL.Value()
|
||
}
|
||
mirrorPath := ""
|
||
if f.mirror.on && f.mirrorWhere.Choice == 0 {
|
||
mirrorPath = f.mirrorDirF.Value()
|
||
if !strings.HasPrefix(mirrorPath, "/") {
|
||
return f.fail(f.mirror, f.mirrorDirF, "the mirror's directory: a path from /")
|
||
}
|
||
}
|
||
|
||
var starter starterSpec
|
||
ctx := 0
|
||
if f.starter.on {
|
||
if f.starterPick.Choice < len(starterChoices) {
|
||
starter = starterChoices[f.starterPick.Choice]
|
||
} else {
|
||
s, err := starterFrom(f.starterSource.Value())
|
||
if err != nil || !strings.HasPrefix(s.Source, "hf:") {
|
||
return f.fail(f.starter, f.starterSource, "the starter model's file: hf:<owner>/<repo>/<file>.gguf")
|
||
}
|
||
starter = s
|
||
}
|
||
n, err := strconv.Atoi(f.starterCtx.Value())
|
||
if err != nil || n < 512 || n > 262144 {
|
||
return f.fail(f.starter, f.starterCtx, "the starter's context: a number of tokens from 512 to 262144")
|
||
}
|
||
ctx = n
|
||
}
|
||
|
||
var judge judgeSpec
|
||
if f.judge.on {
|
||
judge.Fail = []string{"closed", "open"}[f.judgeFail.Choice]
|
||
n, err := strconv.Atoi(f.judgeCopies.Value())
|
||
if err != nil || n < 1 || n > 8 {
|
||
return f.fail(f.judge, f.judgeCopies, "the judge's copies: from 1 to 8")
|
||
}
|
||
judge.Replicas = n
|
||
d, err := time.ParseDuration(f.judgeBudget.Value())
|
||
if err != nil || d < 500*time.Millisecond || d > time.Minute {
|
||
return f.fail(f.judge, f.judgeBudget, "how long the judge is waited for: a duration such as 5s, at most 1m")
|
||
}
|
||
judge.Budget = d
|
||
for i, c := range f.judgeCats {
|
||
if c.On {
|
||
judge.Categories = append(judge.Categories, judgeCategories[i])
|
||
}
|
||
}
|
||
if len(judge.Categories) == 0 {
|
||
return f.fail(f.judge, f.judgeCats[0], "the judge refuses nothing: tick a category, or turn the judge off")
|
||
}
|
||
}
|
||
|
||
var hooks []string
|
||
if f.alerts.on {
|
||
h := f.webhooks.Value()
|
||
parsed, err := parseWebhooks([]string{h})
|
||
if err != nil {
|
||
return f.fail(f.alerts, f.webhooks, err.Error())
|
||
}
|
||
if len(parsed) == 0 {
|
||
return f.fail(f.alerts, f.webhooks, "give a webhook, or turn notifications off")
|
||
}
|
||
hooks = []string{h}
|
||
}
|
||
|
||
rpm, err := strconv.Atoi(f.rpm.Value())
|
||
if err != nil || rpm < 1 {
|
||
return f.fail(f.limits, f.rpm, "requests a minute: a whole number, 1 or more")
|
||
}
|
||
tpm, err := strconv.Atoi(f.tpm.Value())
|
||
if err != nil || tpm < 1 {
|
||
return f.fail(f.limits, f.tpm, "tokens a minute: a whole number, 1 or more")
|
||
}
|
||
keyDays, err := strconv.Atoi(f.keyDays.Value())
|
||
if err != nil || keyDays < 1 || keyDays > 3650 {
|
||
return f.fail(f.limits, f.keyDays, "the longest a key lives: days, from 1 to 3650")
|
||
}
|
||
idle, err := time.ParseDuration(f.idle.Value())
|
||
if err != nil || idle < 0 {
|
||
return f.fail(f.turns, f.idle, "a duration such as 2m or 90s")
|
||
}
|
||
safe, err := time.ParseDuration(f.safe.Value())
|
||
if err != nil || safe < 0 {
|
||
return f.fail(f.turns, f.safe, "a duration such as 5m or 90s")
|
||
}
|
||
|
||
bundle, sum := "", ""
|
||
if f.bundle.on {
|
||
bundle, sum = f.bundlePath.Value(), strings.ToLower(f.bundleSHA.Value())
|
||
switch {
|
||
case bundle == "":
|
||
return f.fail(f.bundle, f.bundlePath, "give the bundle's path, or turn the offline bundle off")
|
||
case sum == "":
|
||
return f.fail(f.bundle, f.bundleSHA, "give the bundle's sha256, as 'llmgw bundle create' printed it")
|
||
}
|
||
}
|
||
|
||
in.admin, in.hosts = admin, hosts
|
||
in.language = map[int]string{0: "", 1: "tr"}[f.language.Choice]
|
||
in.signupOpen = f.signup.Choice == 1
|
||
in.postgres = !f.ha && f.storeKind.Choice == 1
|
||
in.noMirror = !f.mirror.on || mirrorURL != ""
|
||
in.mirrorURL, in.mirrorPath = mirrorURL, mirrorPath
|
||
in.secondMirror = 0
|
||
if f.ha && f.mirror.on && f.mirrorWhere.Choice == 0 {
|
||
in.secondMirror = f.secondMirror.Choice
|
||
}
|
||
in.noNode = !f.node.on
|
||
in.noModel = !f.starter.on
|
||
in.starter, in.starterCtx = starter, ctx
|
||
in.media = f.media.on
|
||
in.noGuardModel = !f.judge.on
|
||
in.judge = judge
|
||
in.noFirewall = !f.firewall.on
|
||
in.webhooks = hooks
|
||
in.defaultRPM, in.defaultTPM = rpm, tpm
|
||
in.maxKeyTTL = time.Duration(keyDays) * 24 * time.Hour
|
||
in.evictAfterIdle, in.minLifetime = 0, 0
|
||
if idle != 2*time.Minute {
|
||
in.evictAfterIdle = idle
|
||
}
|
||
if safe != 5*time.Minute {
|
||
in.minLifetime = safe
|
||
}
|
||
in.bundle, in.bundleSHA = bundle, sum
|
||
in.standbys, in.vip = false, ""
|
||
if f.standby != nil && f.standby.on {
|
||
in.standbys, in.vip = true, f.vipF.Value()
|
||
}
|
||
if err := in.checkChoices(); err != nil {
|
||
if strings.HasPrefix(err.Error(), "floating address") {
|
||
return f.fail(f.standby, f.vipF, err.Error())
|
||
}
|
||
return f.fail(nil, nil, err.Error())
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// view is the list of parts, and what the focused one is, under it.
|
||
func (f *installForm) view(width int) string {
|
||
var b strings.Builder
|
||
b.WriteString("\n " + sDim.Render("Space turns a part on or off · Enter opens its settings and says more") + "\n\n")
|
||
titleW := 0
|
||
for _, p := range f.parts {
|
||
titleW = max(titleW, len(p.title))
|
||
}
|
||
for i, p := range f.parts {
|
||
box := "[ ]"
|
||
switch {
|
||
case p.fixed:
|
||
box = " • "
|
||
case p.on:
|
||
box = "[" + sOK.Render("x") + "]"
|
||
}
|
||
title := fmt.Sprintf("%-*s", titleW, p.title)
|
||
line := ""
|
||
if p.on {
|
||
line = p.summary()
|
||
} else {
|
||
line = sDim.Render(p.off)
|
||
}
|
||
if i == f.focus {
|
||
title = fSelected.Render(title)
|
||
b.WriteString(fSelected.Render("› ") + box + " " + title + " " + truncateWidth(line, max(20, width-titleW-8)) + "\n")
|
||
} else {
|
||
b.WriteString(" " + box + " " + title + " " + truncateWidth(line, max(20, width-titleW-8)) + "\n")
|
||
}
|
||
}
|
||
b.WriteString("\n" + button(f.focus == len(f.parts), "Next") + "\n")
|
||
if f.focus < len(f.parts) {
|
||
p := f.parts[f.focus]
|
||
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(p.title) + "\n")
|
||
b.WriteString(paragraphs(p.about, max(40, width-6), " ") + "\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// overlay is the dialog over the list, if one is open: its title and body.
|
||
func (f *installForm) overlay() (string, string) {
|
||
if f.err != "" {
|
||
return "Not yet", f.err + "\n\n" + sDim.Render("any key to go back")
|
||
}
|
||
if f.dialog != nil {
|
||
return f.dialog.Title, f.dialog.view(max(8, f.height-8))
|
||
}
|
||
return "", ""
|
||
}
|
||
|
||
// keys is the key bar for the list or its dialog.
|
||
func (f *installForm) keys() string {
|
||
switch {
|
||
case f.err != "":
|
||
return "any key back"
|
||
case f.dialog != nil:
|
||
return setupDialogKeys(f.dialog)
|
||
}
|
||
return "↑↓ move · Space on/off · Enter settings, or on Next go on · Ctrl+C leave"
|
||
}
|
||
|
||
// planView says what will be installed and what will not, part by part, in
|
||
// words: the last look before anything is done.
|
||
func (f *installForm) planView(width int) string {
|
||
var b strings.Builder
|
||
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Installed on "+f.host) + "\n\n")
|
||
var left []*installPart
|
||
for _, p := range f.parts {
|
||
if !p.on {
|
||
left = append(left, p)
|
||
continue
|
||
}
|
||
fmt.Fprintf(&b, " %s %s\n %s\n", sOK.Render("✓"), p.title, wrap(p.summary(), max(40, width-8), " "))
|
||
}
|
||
if len(left) > 0 {
|
||
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Left out") + "\n\n")
|
||
for _, p := range left {
|
||
fmt.Fprintf(&b, " %s %s\n %s\n", sDim.Render("-"), p.title, sDim.Render(wrap(p.off, max(40, width-8), " ")))
|
||
}
|
||
}
|
||
return b.String()
|
||
}
|