The cluster page and the console say which picture and 3D models are here, and add them
A site whose catalog had no flux-schnell or triposr showed no Images or 3D page, and nothing said they were one click away. The cluster page now has a Pictures and 3D models card under the models: each preset, what it makes, the classes of card here it fits, whether and by whom it was added, and why the leader has not added it by itself; an Add button, which says first when it would install ComfyUI on the GPU nodes and restart the control planes; and the switch for presets.auto. The console's models screen shows the same under its table: A adds one, asking first in the same case, and P turns automatic adding on or off. `ctl model add PRESET` and `ctl model presets` go through the presets API, so the control plane keeps who added one, and `ctl model presets auto on|off` flips the setting; against a control plane from before, add sends the entry as v0.14.4 did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e0adcc5c4e
commit
85a2bdd0e3
16 files changed
+746
-21
No files matched your search
@@ -37,6 +37,15 @@ APIs change between minors without a deprecation period.
|
||||
stands and why one is not added; `POST /api/v1/presets/{name}` adds one
|
||||
(and sets `engines.comfyui` if the cluster has none); `PUT
|
||||
/api/v1/presets/auto`. Each automatic addition is in the audit log.
|
||||
- **Presets on the portal and in the console**: the cluster page's
|
||||
**Pictures and 3D models** card lists each preset — what it makes, the
|
||||
classes it fits here, whether and by whom it was added, why the leader
|
||||
has not — with an **Add** button that says first when it would install
|
||||
ComfyUI and restart the control planes, and the switch for `presets.auto`.
|
||||
The console's models screen shows the same; **A** adds one, **P** flips
|
||||
the switch. `ctl model add PRESET` and `ctl model presets` go through the
|
||||
API (and `ctl model presets auto on|off`), falling back to the entry as
|
||||
v0.14.4 sent it on an older control plane.
|
||||
|
||||
## v0.14.4 — 2026-10-13
|
||||
|
||||
|
||||
+17
-2
@@ -1313,8 +1313,23 @@ A preset's profile says the card it needs, not a class, so it places on
|
||||
whatever cards the site has. When the cluster's settings name no ComfyUI,
|
||||
adding one sets `engines.comfyui` to the version this release installs, and
|
||||
every node with an NVIDIA GPU installs it; a cluster that names a version,
|
||||
or `""` for none, keeps it. In the console it is **A** on the models screen.
|
||||
The rest of this section is for a model of your own.
|
||||
or `""` for none, keeps it; the control planes then start again on that
|
||||
setting, standbys first.
|
||||
|
||||
They also come by themselves. Once a node has a card that fits one, and the
|
||||
catalog has nothing that makes what it makes, the leader adds it, on demand —
|
||||
where the cluster already installs ComfyUI; it never sets `engines.comfyui`
|
||||
by itself. A preset somebody removes stays out until it is added again.
|
||||
`llmgw ctl model presets auto off` (the setting `presets.auto`) stops it.
|
||||
|
||||
The cluster page on the portal has a **Pictures and 3D models** card under
|
||||
the models: each preset, the classes of card here it fits, whether it is in
|
||||
the catalog and who added it, why the leader has not, an **Add** button (one
|
||||
that would also install ComfyUI says so first) and the switch for adding
|
||||
them by themselves. In the console, the models screen shows the same: **A**
|
||||
adds one, **P** turns adding them by themselves on or off.
|
||||
`llmgw ctl model presets` prints it. The rest of this section is for a model
|
||||
of your own.
|
||||
|
||||
The node must offer the engine. Ask for it in the cluster's settings, and every
|
||||
node with an NVIDIA GPU installs it:
|
||||
|
||||
+16
-1
@@ -3,6 +3,7 @@ package cli
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/user"
|
||||
"slices"
|
||||
@@ -32,7 +33,7 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
Use: "model",
|
||||
Short: "Add or remove models while the cluster is running",
|
||||
}
|
||||
c.AddCommand(newModelAddCmd(client), newModelPresetsCmd(), newModelRemoveCmd(client), newModelPlanCmd(client),
|
||||
c.AddCommand(newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
|
||||
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
|
||||
newTargetCmd(client, asJSON), newAdaptersCmd(client),
|
||||
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
|
||||
@@ -73,6 +74,20 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
|
||||
return fmt.Errorf("no preset %q: there are %s; any other model is added from a file (-f)",
|
||||
args[0], strings.Join(catalog.PresetNames(), ", "))
|
||||
}
|
||||
// The control plane adds it, and keeps that it was added as
|
||||
// a preset, by whom; one from before it could is sent the
|
||||
// entry, as it was then.
|
||||
var res adminapi.ModelAdded
|
||||
err := client().post(cmd.Context(), "/api/v1/presets/"+url.PathEscape(p.Model.Name), []byte("{}"), &res)
|
||||
if err == nil {
|
||||
fmt.Fprintf(cmd.OutOrStdout(),
|
||||
"%s added, and ComfyUI with it where the cluster had none. It is placed on the next\n"+
|
||||
"reconcile pass and loads when first asked for; watch it with:\n llmgw ctl models\n", p.Model.Name)
|
||||
return nil
|
||||
}
|
||||
if !olderControlPlane(err) {
|
||||
return err
|
||||
}
|
||||
m = p.Model
|
||||
case path == "":
|
||||
return fmt.Errorf("a preset (%s) or --file, the model definition as YAML",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -11,25 +13,106 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
func newModelPresetsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
func newModelPresetsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "presets",
|
||||
Short: "The models that come with llmgw, added by name",
|
||||
Short: "The models that come with llmgw: whether each fits and is here",
|
||||
Long: "Each is a catalog entry whose workflow, custom nodes, packages and licence\n" +
|
||||
"were found and tried, and whose profile says the card it needs rather than\n" +
|
||||
"a class, so it places on any site's cards. Add one with\n" +
|
||||
"`llmgw ctl model add NAME`.",
|
||||
"`llmgw ctl model add NAME`.\n\n" +
|
||||
"While presets.auto is on, the leader adds one by itself when a card fits it\n" +
|
||||
"and the catalog has nothing that makes what it makes; one somebody removed\n" +
|
||||
"is not added again by itself.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
w := tabTo(cmd.OutOrStdout())
|
||||
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tWHAT IT IS FOR")
|
||||
for _, p := range catalog.Presets() {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.Model.Name, p.Makes, presetCard(p.Model),
|
||||
presetLicence(p.Model), p.Model.Description)
|
||||
var v adminapi.PresetsView
|
||||
err := client().get(cmd.Context(), "/api/v1/presets", &v)
|
||||
if err != nil && !olderControlPlane(err) {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
if *asJSON && err == nil {
|
||||
return emitJSON(v)
|
||||
}
|
||||
w := tabTo(cmd.OutOrStdout())
|
||||
if err != nil {
|
||||
// A control plane from before it kept them: what this
|
||||
// release has, with nothing about the cluster.
|
||||
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tWHAT IT IS FOR")
|
||||
for _, p := range catalog.Presets() {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.Model.Name, p.Makes, presetCard(p.Model),
|
||||
presetLicence(p.Model), p.Model.Description)
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tFITS\tHERE\tWHAT IT IS FOR")
|
||||
for _, p := range v.Presets {
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Makes, p.Card, licenceText(p.Licence),
|
||||
orDash(strings.Join(p.FitsClasses, ",")), presetState(p), p.Description)
|
||||
}
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if v.Auto {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "\nAdded by themselves where a card fits (presets.auto); "+
|
||||
"llmgw ctl model presets auto off stops it.")
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "\nNot added by themselves; llmgw ctl model presets auto on starts it.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.AddCommand(&cobra.Command{
|
||||
Use: "auto on|off",
|
||||
Short: "Whether the leader adds a preset by itself where a card fits it",
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgs: []string{"on", "off"},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if args[0] != "on" && args[0] != "off" {
|
||||
return fmt.Errorf("on or off, not %q", args[0])
|
||||
}
|
||||
body, _ := json.Marshal(adminapi.PresetsAutoRequest{On: args[0] == "on"})
|
||||
var v adminapi.PresetsView
|
||||
if err := client().put(cmd.Context(), "/api/v1/presets/auto", body, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if v.Auto {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Presets are added by themselves where a card fits.")
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Presets are no longer added by themselves.")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
// olderControlPlane is an error from a control plane that has no such path:
|
||||
// one from before it was added.
|
||||
func olderControlPlane(err error) bool {
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "answered with a page") || strings.HasPrefix(s, "404 Not Found: 404 page not found")
|
||||
}
|
||||
|
||||
func presetState(p adminapi.PresetView) string {
|
||||
switch {
|
||||
case p.InCatalog && p.AddedBy == "auto":
|
||||
return "added by itself"
|
||||
case p.InCatalog && p.AddedBy != "":
|
||||
return "added by " + p.AddedBy
|
||||
case p.InCatalog:
|
||||
return "here"
|
||||
case p.Declined:
|
||||
return "removed; stays out"
|
||||
}
|
||||
return "—"
|
||||
}
|
||||
|
||||
func licenceText(l catalog.License) string {
|
||||
if l.Commercial != nil && !*l.Commercial {
|
||||
return l.Name + ", not commercial"
|
||||
}
|
||||
return l.Name
|
||||
}
|
||||
|
||||
func presetCard(m catalog.Model) string {
|
||||
|
||||
@@ -17,6 +17,10 @@ import (
|
||||
// presetAdmin is an admin API holding settings, which records the models
|
||||
// added and the settings put.
|
||||
type presetAdmin struct {
|
||||
// api is a control plane that adds presets itself; without it, one
|
||||
// from before, which answers their paths with a 404.
|
||||
api bool
|
||||
byAPI []string
|
||||
mu sync.Mutex
|
||||
settings string
|
||||
added []catalog.Model
|
||||
@@ -30,6 +34,10 @@ func (a *presetAdmin) client(t *testing.T) func() *ctlClient {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case a.api && r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/presets/"):
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/v1/presets/")
|
||||
a.byAPI = append(a.byAPI, name)
|
||||
_ = json.NewEncoder(w).Encode(adminapi.ModelAdded{Added: name})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/models":
|
||||
var m catalog.Model
|
||||
_ = json.Unmarshal(body, &m)
|
||||
@@ -62,6 +70,19 @@ func runModelAdd(t *testing.T, client func() *ctlClient, args ...string) (string
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestAPresetIsAddedByTheControlPlane(t *testing.T) {
|
||||
a := &presetAdmin{api: true}
|
||||
out, err := runModelAdd(t, a.client(t), "triposr")
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if len(a.byAPI) != 1 || a.byAPI[0] != "triposr" || len(a.added) != 0 || len(a.put) != 0 {
|
||||
t.Errorf("by the API %v; sent the entry %v; settings %v", a.byAPI, a.added, a.put)
|
||||
}
|
||||
}
|
||||
|
||||
// A control plane from before presets were its own is sent the entry, and
|
||||
// ComfyUI set beside it, as the release that brought presets did.
|
||||
func TestAPresetIsAddedByNameAndHasTheNodesInstallComfyUI(t *testing.T) {
|
||||
a := &presetAdmin{settings: "router:\n retries: 2\n"}
|
||||
out, err := runModelAdd(t, a.client(t), "triposr")
|
||||
|
||||
@@ -90,7 +90,10 @@ type consoleData struct {
|
||||
// federation is the other llmgw clusters whose models are offered
|
||||
// here; empty on a control plane without a store.
|
||||
federation adminapi.FederationView
|
||||
err error
|
||||
// presets is the models that come with llmgw as they stand here; nil
|
||||
// from a control plane that cannot say.
|
||||
presets *adminapi.PresetsView
|
||||
err error
|
||||
}
|
||||
|
||||
type (
|
||||
@@ -275,6 +278,10 @@ func (m consoleModel) fetch() tea.Cmd {
|
||||
_ = c.get(ctx, "/api/v1/jobs", &d.jobs)
|
||||
_ = c.get(ctx, "/api/v1/audit?since=7d&limit=300", &d.audit)
|
||||
_ = c.get(ctx, "/api/v1/federation", &d.federation)
|
||||
var presets adminapi.PresetsView
|
||||
if c.get(ctx, "/api/v1/presets", &presets) == nil {
|
||||
d.presets = &presets
|
||||
}
|
||||
return consoleDataMsg(d)
|
||||
}
|
||||
}
|
||||
@@ -599,6 +606,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.hf.models, m.hf.err, m.row = nil, nil, 0
|
||||
return m, m.fetchHF()
|
||||
}
|
||||
case "p":
|
||||
if m.menu == cModels && m.inPanel && m.data.presets != nil {
|
||||
m.autoPresetsConfirm()
|
||||
}
|
||||
case "a":
|
||||
switch m.menu {
|
||||
case cQuotas:
|
||||
@@ -1238,8 +1249,12 @@ func (m consoleModel) panel() (title, body, keys string) {
|
||||
}
|
||||
if m.inPanel {
|
||||
keys = "↑↓ row · Enter actions · A add a model · ← back · Q leave"
|
||||
if m.data.presets != nil {
|
||||
keys = "↑↓ row · Enter actions · A add a model · P pictures and 3D added by themselves · ← back · Q leave"
|
||||
}
|
||||
}
|
||||
return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", "UPDATE", ""}, rows, m.row, pw, m.inPanel), keys
|
||||
return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", "UPDATE", ""}, rows, m.row, pw, m.inPanel) +
|
||||
presetsBlock(m.data.presets, pw), keys
|
||||
case cReplicas:
|
||||
var rows [][]string
|
||||
for _, r := range m.data.replicas {
|
||||
|
||||
@@ -269,15 +269,20 @@ func (m *consoleModel) addModelForm() {
|
||||
}
|
||||
note.WriteString("A ComfyUI one has the nodes install ComfyUI if the cluster has none.\n" +
|
||||
"Or a definition file: the YAML a model takes in server.yaml's models.")
|
||||
var choices []string
|
||||
for _, n := range catalog.PresetNames() {
|
||||
choices = append(choices, presetChoice(m.data.presets, n))
|
||||
}
|
||||
m.openForm(&formDialog{title: "Add a model",
|
||||
note: note.String(),
|
||||
fields: []formField{
|
||||
choiceF("model", append(catalog.PresetNames(), fromFile), catalog.PresetNames()[0]),
|
||||
choiceF("model", append(choices, fromFile), choices[0]),
|
||||
textF("file", "", "for "+fromFile+": like /root/qwen.yaml"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if v[0] != fromFile {
|
||||
return m.runCLI("Add "+v[0], "ctl", server, "model", "add", v[0]), nil
|
||||
// The name, without what the choice says beside it.
|
||||
return m.addPreset(strings.Fields(v[0])[0]), nil
|
||||
}
|
||||
if v[1] == "" {
|
||||
return nil, fmt.Errorf("a file, or a model that comes with llmgw")
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
// presetsBlock is the models that come with llmgw, under the models table:
|
||||
// whether each fits a card here and whether it is in the catalog, and
|
||||
// whether the leader adds them by itself. Empty from a control plane that
|
||||
// cannot say.
|
||||
func presetsBlock(v *adminapi.PresetsView, width int) string {
|
||||
if v == nil || len(v.Presets) == 0 {
|
||||
return ""
|
||||
}
|
||||
var rows [][]string
|
||||
var why []string
|
||||
for _, p := range v.Presets {
|
||||
rows = append(rows, []string{p.Name, p.Makes, orDash(strings.Join(p.FitsClasses, ",")), presetState(p)})
|
||||
// Why the leader leaves it, under the table: a reason is longer than
|
||||
// any column the panel has room for. Automatic adding being off is
|
||||
// said once, below.
|
||||
if p.Blocked != "" && !strings.HasPrefix(p.Blocked, "presets.auto") && !p.Declined {
|
||||
why = append(why, " "+wrap(p.Name+": "+p.Blocked, width-4, " "))
|
||||
}
|
||||
}
|
||||
auto := "Added by themselves where a card fits · P stops that"
|
||||
if !v.Auto {
|
||||
auto = "Not added by themselves · P starts that · A adds one"
|
||||
}
|
||||
out := "\n\n Pictures and 3D models that come with llmgw\n" +
|
||||
table([]string{"PRESET", "MAKES", "FITS", "HERE"}, rows, -1, width, false)
|
||||
if len(why) > 0 {
|
||||
out += "\n" + sDim.Render(strings.Join(why, "\n"))
|
||||
}
|
||||
return out + "\n " + sDim.Render(auto)
|
||||
}
|
||||
|
||||
// presetChoice is how a preset is offered in the add dialog: its name, and
|
||||
// where it stands when that is worth knowing before choosing it.
|
||||
func presetChoice(v *adminapi.PresetsView, name string) string {
|
||||
if v == nil {
|
||||
return name
|
||||
}
|
||||
for _, p := range v.Presets {
|
||||
if p.Name != name {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case p.InCatalog:
|
||||
return name + " (already here)"
|
||||
case presetAddsComfyUI(v, p):
|
||||
return name + " (installs ComfyUI too)"
|
||||
case !p.Fits:
|
||||
return name + " (no card here fits it)"
|
||||
case p.Declined:
|
||||
return name + " (removed before)"
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// presetAddsComfyUI is whether adding a preset by hand also sets
|
||||
// engines.comfyui, which has the GPU nodes install it and the control planes
|
||||
// restart on the setting.
|
||||
func presetAddsComfyUI(v *adminapi.PresetsView, p adminapi.PresetView) bool {
|
||||
return v != nil && !p.InCatalog && v.ComfyUI == ""
|
||||
}
|
||||
|
||||
// addPreset adds a preset through the control plane, or, from one from
|
||||
// before it could, the way the release that brought presets did. One whose
|
||||
// adding installs ComfyUI and restarts the control planes is asked about
|
||||
// first.
|
||||
func (m *consoleModel) addPreset(name string) tea.Cmd {
|
||||
if m.data.presets != nil {
|
||||
for _, p := range m.data.presets.Presets {
|
||||
if p.Name == name && presetAddsComfyUI(m.data.presets, p) {
|
||||
m.confirmD = &confirmDialog{title: "Add " + name + " and ComfyUI",
|
||||
text: "The cluster installs no ComfyUI yet. Adding " + name + " sets engines.comfyui:\n" +
|
||||
"every GPU node installs it, about 10 GB each, and the control planes restart\n" +
|
||||
"on the setting, standbys first. Engines keep serving.",
|
||||
yes: func(m *consoleModel) tea.Cmd { return m.postPreset(name) }}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return m.postPreset(name)
|
||||
}
|
||||
|
||||
func (m *consoleModel) postPreset(name string) tea.Cmd {
|
||||
if m.data.presets == nil {
|
||||
return m.runCLI("Add "+name, "ctl", "--server="+m.client.base, "model", "add", name)
|
||||
}
|
||||
return m.call("Add "+name, http.MethodPost, "/api/v1/presets/"+url.PathEscape(name), map[string]string{},
|
||||
func(_ json.RawMessage) string {
|
||||
return name + " is added, and ComfyUI with it where the cluster had none.\n" +
|
||||
"It is placed on the next pass, and loads when first asked for."
|
||||
})
|
||||
}
|
||||
|
||||
// autoPresetsConfirm turns the adding of presets by themselves on or off,
|
||||
// after saying what that does.
|
||||
func (m *consoleModel) autoPresetsConfirm() {
|
||||
on := !m.data.presets.Auto
|
||||
title, text := "Stop adding pictures and 3D models by themselves",
|
||||
"Presets already added stay. A preset is added only when somebody adds it."
|
||||
if on {
|
||||
title, text = "Add pictures and 3D models by themselves",
|
||||
"Where a card fits one and the catalog has nothing that makes what it makes, the leader adds it.\n"+
|
||||
"That also has every GPU node install ComfyUI, about 10 GB each, and the mirror fetch its weights.\n"+
|
||||
"A preset somebody removed is not added again."
|
||||
}
|
||||
m.confirmD = &confirmDialog{title: title, text: text, yes: func(m *consoleModel) tea.Cmd {
|
||||
return m.call(title, http.MethodPut, "/api/v1/presets/auto", adminapi.PresetsAutoRequest{On: on},
|
||||
func(_ json.RawMessage) string {
|
||||
if on {
|
||||
return "Presets are added by themselves where a card fits."
|
||||
}
|
||||
return "Presets are no longer added by themselves."
|
||||
})
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func presetsHere() *adminapi.PresetsView {
|
||||
return &adminapi.PresetsView{Auto: true, Presets: []adminapi.PresetView{
|
||||
{Name: "flux-schnell", Makes: "pictures", Card: "cc 7.0+, 24 GB+", Fits: true, FitsClasses: []string{"v100-32g"},
|
||||
Blocked: "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"},
|
||||
{Name: "triposr", Makes: "meshes", Card: "cc 7.0+, 8 GB+", Fits: true, InCatalog: true, AddedBy: "auto"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestThePresetsAreOnTheModelsScreen(t *testing.T) {
|
||||
m, _ := consoleWith(t, cModels)
|
||||
m.data.presets = presetsHere()
|
||||
v := m.View()
|
||||
for _, want := range []string{"Pictures and 3D models that come with llmgw", "flux-schnell", "v100-32g",
|
||||
"installs no ComfyUI", "added by itself", "P pictures and 3D"} {
|
||||
if !strings.Contains(v, want) {
|
||||
t.Errorf("%q not shown:\n%s", want, v)
|
||||
}
|
||||
}
|
||||
// A control plane that cannot say shows none of it.
|
||||
m.data.presets = nil
|
||||
if v := m.View(); strings.Contains(v, "come with llmgw") || strings.Contains(v, "P pictures") {
|
||||
t.Errorf("presets shown from nothing:\n%s", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPresetThatInstallsComfyUIIsAddedOnlyWhenConfirmed(t *testing.T) {
|
||||
m, s := consoleWith(t, cModels)
|
||||
m.data.presets = presetsHere()
|
||||
var tm tea.Model = m
|
||||
tm = press(tm, "a")
|
||||
f := tm.(consoleModel).formD
|
||||
if f == nil || !strings.Contains(f.fields[0].value(), "installs ComfyUI too") {
|
||||
t.Fatalf("no add dialog saying what flux-schnell brings: %+v", f)
|
||||
}
|
||||
tm = press(tm, "enter", "enter", "enter")
|
||||
if tm.(consoleModel).confirmD == nil || s.last() != "" {
|
||||
t.Fatalf("added without asking: %q", s.last())
|
||||
}
|
||||
tm = pressRun(tm, "y")
|
||||
if got := s.last(); got != "POST /api/v1/presets/flux-schnell {}" {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
|
||||
tm = press(tm, "esc", "p") // past what the add said
|
||||
if c := tm.(consoleModel).confirmD; c == nil || !strings.HasPrefix(c.title, "Stop adding") {
|
||||
t.Fatalf("P did not ask to stop: %+v", c)
|
||||
}
|
||||
_ = pressRun(tm, "y")
|
||||
if got := s.last(); got != `PUT /api/v1/presets/auto {"on":false}` {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
}
|
||||
@@ -329,6 +329,10 @@ func (r *Router) Handler() http.Handler {
|
||||
if e, ok := r.clusterView.(web.NodeEngineAdmin); ok {
|
||||
pages.RegisterNodeEngines(mux, e)
|
||||
}
|
||||
// The models that come with llmgw, added from the same page.
|
||||
if a, ok := r.clusterView.(web.PresetAdmin); ok {
|
||||
pages.RegisterPresets(mux, a)
|
||||
}
|
||||
pages.RegisterAdminQuotas(mux, r.store, r.acct)
|
||||
// And the report of what that tree actually used.
|
||||
if r.history != nil {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// PresetAdmin is the models that come with llmgw, as the cluster page shows
|
||||
// and adds them: flux-schnell for pictures, triposr for 3D models. A site
|
||||
// whose catalog had neither showed no Images or 3D page, and nothing said
|
||||
// they were one click away.
|
||||
type PresetAdmin interface {
|
||||
Presets(ctx context.Context) (adminapi.PresetsView, error)
|
||||
AddPreset(ctx context.Context, name, by string) error
|
||||
SetAutoPresets(ctx context.Context, on bool, by string) error
|
||||
}
|
||||
|
||||
// presetsCard is the presets as the cluster page shows them.
|
||||
type presetsCard struct {
|
||||
Auto bool
|
||||
Rows []presetRow
|
||||
}
|
||||
|
||||
// presetRow is one preset, its words already in the reader's language.
|
||||
type presetRow struct {
|
||||
Name, Description, Makes, Card, Licence string
|
||||
// Fits says which of the cluster's classes can run it; empty when none.
|
||||
Fits string
|
||||
// State is where it stands: added, and by whom; removed, so not added
|
||||
// again by itself; or neither.
|
||||
State string
|
||||
InCatalog bool
|
||||
Declined bool
|
||||
// Blocked is why the leader does not add it by itself, in the control
|
||||
// plane's words (English); not said when automatic adding is off, which
|
||||
// the line under the table says once.
|
||||
Blocked string
|
||||
// AddsComfyUI is an Add that also has the GPU nodes install ComfyUI and
|
||||
// the control planes restart on that setting.
|
||||
AddsComfyUI bool
|
||||
}
|
||||
|
||||
// presetAddsComfyUI is whether adding a preset by hand sets engines.comfyui:
|
||||
// the cluster installs none yet.
|
||||
func presetAddsComfyUI(v adminapi.PresetsView, p adminapi.PresetView) bool {
|
||||
return !p.InCatalog && v.ComfyUI == ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerKeys("dash.presets.makes.pictures", "dash.presets.makes.meshes")
|
||||
}
|
||||
|
||||
// presetsCardOf reads the presets for the page; nil, and the card left off,
|
||||
// where the control plane cannot say.
|
||||
func presetsCardOf(ctx context.Context, l *Lang, a PresetAdmin) *presetsCard {
|
||||
v, err := a.Presets(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c := &presetsCard{Auto: v.Auto}
|
||||
for _, p := range v.Presets {
|
||||
row := presetRow{
|
||||
Name: p.Name, Description: p.Description, Card: p.Card,
|
||||
Makes: l.T("dash.presets.makes." + p.Makes),
|
||||
Licence: p.Licence.Name,
|
||||
Fits: strings.Join(p.FitsClasses, ", "),
|
||||
InCatalog: p.InCatalog, Declined: p.Declined,
|
||||
AddsComfyUI: presetAddsComfyUI(v, p),
|
||||
}
|
||||
if !strings.HasPrefix(p.Blocked, "presets.auto") && !p.Declined {
|
||||
row.Blocked = p.Blocked
|
||||
}
|
||||
if c := p.Licence.Commercial; c != nil && !*c {
|
||||
row.Licence = l.T("dash.presets.not_commercial", p.Licence.Name)
|
||||
}
|
||||
at := ""
|
||||
if !p.AddedAt.IsZero() {
|
||||
at = p.AddedAt.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
switch {
|
||||
case p.InCatalog && p.AddedBy == "auto":
|
||||
row.State = l.T("dash.presets.added_auto", at)
|
||||
case p.InCatalog && p.AddedBy != "":
|
||||
row.State = l.T("dash.presets.added_by", p.AddedBy, at)
|
||||
case p.InCatalog:
|
||||
row.State = l.T("dash.presets.in_catalog")
|
||||
case p.Declined:
|
||||
row.State = l.T("dash.presets.declined")
|
||||
}
|
||||
c.Rows = append(c.Rows, row)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// RegisterPresets mounts adding a preset, and turning their automatic adding
|
||||
// on and off, on the cluster page: for an administrator, with the session's
|
||||
// CSRF token, answered by going back to the page with what happened.
|
||||
func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) {
|
||||
back := func(w http.ResponseWriter, r *http.Request, q url.Values) {
|
||||
http.Redirect(w, r, "/admin?"+q.Encode()+"#presets", http.StatusSeeOther)
|
||||
}
|
||||
checked := func(h func(w http.ResponseWriter, r *http.Request, admin store.User)) http.HandlerFunc {
|
||||
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
sess, _, _ := p.session(r)
|
||||
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h(w, r, admin)
|
||||
})
|
||||
}
|
||||
// Before the one by name, which would otherwise take "auto" for a preset.
|
||||
mux.HandleFunc("POST /admin/presets/auto", checked(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
on := r.FormValue("on") == "true"
|
||||
q := url.Values{}
|
||||
if err := a.SetAutoPresets(r.Context(), on, admin.Email); err != nil {
|
||||
p.log.Warn("presets.auto was not changed", "admin", admin.Email, "err", err)
|
||||
q.Set("problem", err.Error())
|
||||
} else {
|
||||
p.log.Info("presets.auto changed", "admin", admin.Email, "on", on)
|
||||
key := "dash.presets.notice.auto_off"
|
||||
if on {
|
||||
key = "dash.presets.notice.auto_on"
|
||||
}
|
||||
q.Set("notice", langOf(r).T(key))
|
||||
}
|
||||
back(w, r, q)
|
||||
}))
|
||||
mux.HandleFunc("POST /admin/presets/{name}", checked(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
name := r.PathValue("name")
|
||||
q := url.Values{}
|
||||
if err := a.AddPreset(r.Context(), name, admin.Email); err != nil {
|
||||
p.log.Warn("a preset was not added", "admin", admin.Email, "preset", name, "err", err)
|
||||
// The control plane's reason, as it gave it: in English.
|
||||
q.Set("problem", name+": "+err.Error())
|
||||
} else {
|
||||
p.log.Info("a preset was added", "admin", admin.Email, "preset", name)
|
||||
q.Set("notice", langOf(r).T("dash.presets.notice.added", name))
|
||||
}
|
||||
back(w, r, q)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// presetFleet is a cluster whose control plane says its presets: triposr
|
||||
// added by itself, flux-schnell removed by somebody.
|
||||
type presetFleet struct {
|
||||
fakeFleet
|
||||
view adminapi.PresetsView
|
||||
err error
|
||||
added []string
|
||||
auto []bool
|
||||
}
|
||||
|
||||
func (f *presetFleet) Presets(context.Context) (adminapi.PresetsView, error) { return f.view, f.err }
|
||||
|
||||
func (f *presetFleet) AddPreset(_ context.Context, name, by string) error {
|
||||
f.added = append(f.added, name+" by "+by)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *presetFleet) SetAutoPresets(_ context.Context, on bool, _ string) error {
|
||||
f.auto = append(f.auto, on)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPresetFleet() *presetFleet {
|
||||
no := false
|
||||
return &presetFleet{view: adminapi.PresetsView{Auto: true, Presets: []adminapi.PresetView{
|
||||
{Name: "flux-schnell", Makes: "pictures", Card: "cc 7.0+, 24 GB+", Licence: catalog.License{Name: "Apache-2.0"},
|
||||
FitsClasses: []string{"v100-32g"}, Fits: true, Declined: true,
|
||||
Blocked: "removed from the catalog after it was added; add it again to undo"},
|
||||
{Name: "sdxl", Makes: "pictures", Card: "cc 8.0+", Licence: catalog.License{Name: "x"},
|
||||
Blocked: "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"},
|
||||
{Name: "triposr", Makes: "meshes", Card: "cc 7.0+, 8 GB+", Licence: catalog.License{Name: "Made-up", Commercial: &no},
|
||||
FitsClasses: []string{"t4-15g", "v100-32g"}, Fits: true, InCatalog: true, AddedBy: "auto",
|
||||
AddedAt: time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)},
|
||||
}}}
|
||||
}
|
||||
|
||||
func TestThePicturesAnd3DModelsAreAddedFromTheClusterPage(t *testing.T) {
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
f := newPresetFleet()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /admin", p.AdminPage(Dashboard(f, nil, "v1", time.Second, false, NavExtras{}, nil)))
|
||||
p.RegisterPresets(mux, f)
|
||||
|
||||
body := mirrorGet(t, mux, "/admin").Body.String()
|
||||
for _, want := range []string{
|
||||
`id="presets"`, "Pictures and 3D models",
|
||||
`action="/admin/presets/flux-schnell"`, "Add again", "removed, so not added again by itself",
|
||||
"added automatically, 2026-10-13 09:30", "Made-up, not for commercial use", "t4-15g, v100-32g",
|
||||
`action="/admin/presets/auto"`, `name="on" value="false"`, "Stop adding them",
|
||||
"(the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that)",
|
||||
"restarts the control planes",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%q not on the page", want)
|
||||
}
|
||||
}
|
||||
// The button's title and the line beside it, on each preset not here:
|
||||
// the cluster installs no ComfyUI yet.
|
||||
if n := strings.Count(body, "restarts the control planes"); n != 4 {
|
||||
t.Errorf("the ComfyUI warning is said %d times, not on the two presets an Add would install it for", n)
|
||||
}
|
||||
if strings.Contains(body, "(removed from the catalog") {
|
||||
t.Error("a decline is said twice")
|
||||
}
|
||||
if strings.Contains(body, `action="/admin/presets/triposr"`) {
|
||||
t.Error("a preset in the catalog is offered to be added")
|
||||
}
|
||||
|
||||
w := mirrorPost(t, mux, "/admin/presets/flux-schnell", url.Values{"csrf": {"tok"}})
|
||||
if w.Code != http.StatusSeeOther || len(f.added) != 1 || f.added[0] != "flux-schnell by a@example.com" {
|
||||
t.Fatalf("status %d, added %v", w.Code, f.added)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); !strings.Contains(loc, "notice=") || !strings.HasSuffix(loc, "#presets") {
|
||||
t.Errorf("redirected to %s", loc)
|
||||
}
|
||||
w = mirrorPost(t, mux, "/admin/presets/auto", url.Values{"csrf": {"tok"}, "on": {"false"}})
|
||||
if w.Code != http.StatusSeeOther || len(f.auto) != 1 || f.auto[0] || len(f.added) != 1 {
|
||||
t.Fatalf("status %d, auto %v, added %v: auto was taken for a preset's name", w.Code, f.auto, f.added)
|
||||
}
|
||||
if w := mirrorPost(t, mux, "/admin/presets/triposr", url.Values{"csrf": {"bad"}}); w.Code != http.StatusBadRequest || len(f.added) != 1 {
|
||||
t.Errorf("a forged form was accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoComfyUIWarningWhereTheClusterInstallsIt(t *testing.T) {
|
||||
f := newPresetFleet()
|
||||
f.view.ComfyUI = "0.39.2"
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /admin", p.AdminPage(Dashboard(f, nil, "v1", time.Second, false, NavExtras{}, nil)))
|
||||
body := mirrorGet(t, mux, "/admin").Body.String()
|
||||
if !strings.Contains(body, `action="/admin/presets/flux-schnell"`) {
|
||||
t.Fatal("no Add on the page")
|
||||
}
|
||||
if strings.Contains(body, "restarts the control planes") {
|
||||
t.Error("warned of installing a ComfyUI the cluster already installs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePresetsCardIsLeftOffWhereTheControlPlaneCannotSay(t *testing.T) {
|
||||
f := newPresetFleet()
|
||||
f.err = errors.New("501")
|
||||
_, body := get(t, Dashboard(f, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
|
||||
if strings.Contains(body, `id="presets"`) {
|
||||
t.Error("an empty presets card was drawn")
|
||||
}
|
||||
// The read-only page shows them, with nothing to press.
|
||||
f.err = nil
|
||||
_, body = get(t, Dashboard(f, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
|
||||
if !strings.Contains(body, `id="presets"`) || strings.Contains(body, `/admin/presets/`) {
|
||||
t.Errorf("the admin listener's page: card %v, forms %v",
|
||||
strings.Contains(body, `id="presets"`), strings.Contains(body, `/admin/presets/`))
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,11 @@ type DashboardData struct {
|
||||
Models []adminapi.ModelView
|
||||
Replicas []adminapi.ReplicaView
|
||||
Unplaced []adminapi.ExplainView
|
||||
Usage []store.KeyUsage
|
||||
Summary adminapi.Summary
|
||||
// Presets is the models that come with llmgw — pictures, 3D models —
|
||||
// and whether each is here; nil where the control plane cannot say.
|
||||
Presets *presetsCard
|
||||
Usage []store.KeyUsage
|
||||
Summary adminapi.Summary
|
||||
// Fabrics is the physical fabrics and what disagrees about them, from a
|
||||
// source that can say.
|
||||
Fabrics *fabrics.Report
|
||||
@@ -236,6 +239,9 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
}
|
||||
}
|
||||
d.Unplaced = unplaced(src, d.Models)
|
||||
if pa, ok := src.(PresetAdmin); ok {
|
||||
d.Presets = presetsCardOf(r.Context(), l, pa)
|
||||
}
|
||||
ids := make([]string, 0, len(d.Nodes))
|
||||
for _, n := range d.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
|
||||
@@ -196,5 +196,31 @@
|
||||
"dash.usage.none": "No requests recorded in the last 24 hours.",
|
||||
"dash.usage.auth_off": "Authentication is off, so there is no identity to account against.",
|
||||
|
||||
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:"
|
||||
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:",
|
||||
|
||||
"dash.presets.title": "Pictures and 3D models",
|
||||
"dash.presets.about": "Models that come with llmgw, their workflow, nodes and licence already tried. Adding one also has the GPU nodes install ComfyUI, about 10 GB each, when the cluster has none.",
|
||||
"dash.presets.makes": "Makes",
|
||||
"dash.presets.makes.pictures": "pictures",
|
||||
"dash.presets.makes.meshes": "3D models",
|
||||
"dash.presets.card": "Needs",
|
||||
"dash.presets.licence": "Licence",
|
||||
"dash.presets.not_commercial": "%s, not for commercial use",
|
||||
"dash.presets.fits": "Fits",
|
||||
"dash.presets.fits_none": "no card here",
|
||||
"dash.presets.state": "Here",
|
||||
"dash.presets.added_auto": "added automatically, %s",
|
||||
"dash.presets.added_by": "added by %s, %s",
|
||||
"dash.presets.in_catalog": "in the catalog",
|
||||
"dash.presets.declined": "removed, so not added again by itself",
|
||||
"dash.presets.add": "Add",
|
||||
"dash.presets.add_again": "Add again",
|
||||
"dash.presets.auto_on": "Added by themselves: when a card fits one and the catalog has nothing that makes what it makes.",
|
||||
"dash.presets.auto_off": "Not added by themselves: only when somebody adds one.",
|
||||
"dash.presets.turn_off": "Stop adding them",
|
||||
"dash.presets.turn_on": "Add them by themselves",
|
||||
"dash.presets.notice.added": "%s added; it is placed on the next pass, and loads when first asked for",
|
||||
"dash.presets.notice.auto_on": "Pictures and 3D models are added by themselves where a card fits",
|
||||
"dash.presets.notice.auto_off": "Pictures and 3D models are no longer added by themselves",
|
||||
"dash.presets.adds_comfyui": "Adding it installs ComfyUI on the GPU nodes, about 10 GB each, and restarts the control planes, standbys first"
|
||||
}
|
||||
@@ -194,5 +194,31 @@
|
||||
"dash.usage.none": "Son 24 saatte hiç istek kaydedilmedi.",
|
||||
"dash.usage.auth_off": "Kimlik doğrulama kapalı; bu yüzden kullanımın yazılacağı bir kimlik yok.",
|
||||
|
||||
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:"
|
||||
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:",
|
||||
|
||||
"dash.presets.title": "Resim ve 3D modelleri",
|
||||
"dash.presets.about": "llmgw ile gelen modeller; iş akışları, düğümleri ve lisansları denenmiş. Birini eklemek, kümede ComfyUI yoksa GPU düğümlerine onu da kurdurur (her birine yaklaşık 10 GB).",
|
||||
"dash.presets.makes": "Ürettiği",
|
||||
"dash.presets.makes.pictures": "resim",
|
||||
"dash.presets.makes.meshes": "3D model",
|
||||
"dash.presets.card": "Gerekli kart",
|
||||
"dash.presets.licence": "Lisans",
|
||||
"dash.presets.not_commercial": "%s, ticari kullanım yok",
|
||||
"dash.presets.fits": "Uyan sınıflar",
|
||||
"dash.presets.fits_none": "burada uyan kart yok",
|
||||
"dash.presets.state": "Durum",
|
||||
"dash.presets.added_auto": "kendiliğinden eklendi, %s",
|
||||
"dash.presets.added_by": "%s ekledi, %s",
|
||||
"dash.presets.in_catalog": "katalogda",
|
||||
"dash.presets.declined": "kaldırıldı; kendiliğinden yeniden eklenmez",
|
||||
"dash.presets.add": "Ekle",
|
||||
"dash.presets.add_again": "Yeniden ekle",
|
||||
"dash.presets.auto_on": "Kendiliğinden ekleniyorlar: bir kart uyduğunda ve katalogda aynı işi yapan model olmadığında.",
|
||||
"dash.presets.auto_off": "Kendiliğinden eklenmiyorlar: yalnızca biri eklediğinde.",
|
||||
"dash.presets.turn_off": "Kendiliğinden eklemeyi durdur",
|
||||
"dash.presets.turn_on": "Kendiliğinden ekle",
|
||||
"dash.presets.notice.added": "%s eklendi; sonraki turda yerleştirilir, ilk istendiğinde yüklenir",
|
||||
"dash.presets.notice.auto_on": "Resim ve 3D modelleri, kart uyan yerde kendiliğinden eklenecek",
|
||||
"dash.presets.notice.auto_off": "Resim ve 3D modelleri artık kendiliğinden eklenmeyecek",
|
||||
"dash.presets.adds_comfyui": "Eklemek GPU düğümlerine ComfyUI kurar (her birine yaklaşık 10 GB) ve kontrol düzlemlerini yeniden başlatır, önce yedekler"
|
||||
}
|
||||
@@ -163,6 +163,34 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) {
|
||||
{{end}}</table>
|
||||
{{else}}<p class="empty">{{t "dash.models.empty"}}</p>{{end}}
|
||||
|
||||
{{with .Presets}}
|
||||
<h3 id="presets">{{t "dash.presets.title"}}</h3>
|
||||
<p class="note">{{t "dash.presets.about"}}</p>
|
||||
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th>{{t "dash.presets.makes"}}</th><th>{{t "dash.presets.card"}}</th><th>{{t "dash.presets.licence"}}</th><th>{{t "dash.presets.fits"}}</th><th>{{t "dash.presets.state"}}</th><th></th></tr>
|
||||
{{range .Rows}}<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td title="{{.Description}}">{{.Makes}}</td>
|
||||
<td>{{.Card}}</td>
|
||||
<td>{{.Licence}}</td>
|
||||
<td>{{or .Fits (t "dash.presets.fits_none")}}</td>
|
||||
{{/* Why the leader does not add it itself is the control plane's, in English. */}}
|
||||
<td{{if .Declined}} class="note"{{end}}>{{or .State "—"}}{{with .Blocked}} <span class="note">({{.}})</span>{{end}}</td>
|
||||
<td>{{if and $.Menu.CSRF (not .InCatalog)}}
|
||||
<form method="post" action="/admin/presets/{{.Name}}" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
|
||||
<button type="submit"{{if .AddsComfyUI}} title="{{t "dash.presets.adds_comfyui"}}"{{end}}>{{if .Declined}}{{t "dash.presets.add_again"}}{{else}}{{t "dash.presets.add"}}{{end}}</button>
|
||||
</form>{{if .AddsComfyUI}} <span class="warn">{{t "dash.presets.adds_comfyui"}}</span>{{end}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}</table>
|
||||
<p>{{if .Auto}}{{t "dash.presets.auto_on"}}{{else}}{{t "dash.presets.auto_off"}}{{end}}
|
||||
{{if $.Menu.CSRF}}
|
||||
<form method="post" action="/admin/presets/auto" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
|
||||
<input type="hidden" name="on" value="{{if .Auto}}false{{else}}true{{end}}">
|
||||
<button type="submit">{{if .Auto}}{{t "dash.presets.turn_off"}}{{else}}{{t "dash.presets.turn_on"}}{{end}}</button>
|
||||
</form>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Unplaced}}
|
||||
<h2>{{t "dash.unplaced"}}</h2>
|
||||
{{/* The scheduler's reasons, as it gives them. */}}
|
||||
|
||||
Reference in new issue
Block a user