Every setting of a served model from the mirror page, and its replicas per profile
CI / test (push) Successful in 10m51s

The row of a served model leads to its card, which now changes its context
and longest answer, its cards, its replicas on each profile with a profile
on CPUs to add and its speed beside, stops, starts and restarts it, and
removes it with its files or without. Stopped on the row stops the model
rather than setting its scaling to nothing. The console gains Replicas per
profile, and ctl models shows the counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-11-03 09:40:06 +01:00
1 parent e23732fbbb
commit 1a3f385be4
15 files changed
+721 -20

No files matched your search

+18
View File
@@ -30,6 +30,24 @@ APIs change between minors without a deprecation period.
### Added
- **Replicas counted per profile: seven on cards and four on CPUs, under one
name.** `llmgw ctl model replicas <model> gpu=7 cpu=4`, the console's
Replicas per profile… and the mirror page's model card set how many of a
model's replicas run on each of its profiles, rather than every replica
on the best profile that fits. For a GGUF model with no profile on CPUs
one is added (`--cpu-memory`), preferred below the cards, so the router
fills the cards first and sends to the CPUs once they are full. A change
starts the replicas short before stopping those over: the model never
runs fewer. `ctl models` shows the counts; a scaling of another total
ends them.
- **Every setting of a served model from the mirror page.** A served
model's row has a Settings link, and its card now changes the context
and longest answer, the cards a replica takes, the replicas on each
profile (with the CPU's speed beside), stops and starts it, restarts its
replicas, and takes it out of the catalog with its files or without —
beside the replicas, class, owner, memory and adapters it held before.
Stopped on the row is now the model stopped and kept, started again as
it was, rather than its scaling set to nothing.
- **How fast a model answers on the CPU, before it is served.** The serve
form says, as the cores are changed, about how many tokens a second one
reply gets on the CPU and on one card of the fleet's fastest class, and
+14 -1
View File
@@ -418,7 +418,7 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
}
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\t%s\t%s\n",
name, serving, m.Ready, m.Min, modelStatus(m), m.Class, reserves(m),
join(m.Aliases), join(m.Profiles), join(m.Fallbacks))
join(m.Aliases), join(profilesWithCounts(m)), join(m.Fallbacks))
for _, x := range m.Warnings {
warnings = append(warnings, m.Name+": "+x)
}
@@ -696,3 +696,16 @@ func reserves(m adminapi.ModelView) string {
}
return out
}
// profilesWithCounts are a model's profiles, each with its replicas when
// they are counted per profile: gpu=7, cpu=4.
func profilesWithCounts(m adminapi.ModelView) []string {
if len(m.ProfileReplicas) == 0 {
return m.Profiles
}
out := make([]string, len(m.Profiles))
for i, p := range m.Profiles {
out[i] = fmt.Sprintf("%s=%d", p, m.ProfileReplicas[p])
}
return out
}
+43
View File
@@ -20,6 +20,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
@@ -406,6 +407,48 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
}})
return nil
}},
{"Replicas per profile…", func(m *consoleModel) tea.Cmd {
var now []string
for _, p := range md.Profiles {
now = append(now, fmt.Sprintf("%s=%d", p, md.ProfileReplicas[p]))
}
m.openForm(&formDialog{title: "Replicas per profile of " + md.Name,
note: "How many replicas run on each profile, all answering under the model's name: seven on cards and " +
"four on CPUs, the router filling the cards first. A profile not named keeps its count; cpu=N with " +
"the memory below adds a profile on CPUs to a GGUF model that has none. Those short start first, " +
"then those over stop.",
fields: []formField{
textF("replicas", strings.Join(now, " "), "profile=count, separated by spaces"),
textF("CPU memory GB", "", "only to add a profile on CPUs: what a replica reserves"),
textF("CPU cores", "", "of a replica on CPUs; empty is half the node's"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
req := adminapi.ProfileReplicasRequest{Profiles: map[string]int{}}
for _, f := range strings.Fields(v[0]) {
name, c, ok := strings.Cut(f, "=")
n, err := strconv.Atoi(c)
if !ok || err != nil || n < 0 || name == "" {
return nil, fmt.Errorf("%q: a profile and a count, as gpu=7", f)
}
req.Profiles[name] = n
}
if v[1] != "" {
mem, err := strconv.Atoi(v[1])
if err != nil || mem < 1 {
return nil, fmt.Errorf("the CPU memory is a whole number of GB")
}
cores := 0
if v[2] != "" {
if cores, err = strconv.Atoi(v[2]); err != nil || cores < 1 {
return nil, fmt.Errorf("the CPU cores are a whole number from 1")
}
}
req.CPU = &modelsvc.CPUProfile{MemoryGB: mem, CPUCores: cores}
}
return m.call("Replicas of "+md.Name, http.MethodPost, path+"/replicas", req, nil), nil
}})
return nil
}},
{"Resources…", func(m *consoleModel) tea.Cmd {
profiles := md.Profiles
if len(profiles) == 0 {
+32 -13
View File
@@ -2,6 +2,8 @@ package adminapi
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
@@ -48,10 +50,29 @@ type PurgeView struct {
// purgeModel removes a model from the catalog and its files from the mirror
// and the nodes.
func (a *API) purgeModel(w http.ResponseWriter, r *http.Request, name string) {
out, err := a.Purge(r.Context(), name, actorOf(r))
switch {
case errors.Is(err, errNoSuchModel):
writeError(w, http.StatusNotFound, err.Error())
case err != nil:
writeError(w, http.StatusBadRequest, err.Error())
default:
writeJSON(w, http.StatusOK, out)
}
}
// errNoSuchModel is a model the catalog does not have.
var errNoSuchModel = errors.New("no such model in the catalog")
// Purge removes a model from the catalog and its files from the mirror and
// the nodes, waiting a while for its replicas to stop before the nodes'.
func (a *API) Purge(ctx context.Context, name, by string) (PurgeView, error) {
if a.models == nil {
return PurgeView{}, errors.New("this control plane has no store")
}
m, ok := a.ctl.Catalog().Resolve(name)
if !ok {
writeError(w, http.StatusNotFound, "no model "+name+" in the catalog")
return
return PurgeView{}, fmt.Errorf("%w: %s", errNoSuchModel, name)
}
name = m.Name
others := []catalog.Model{}
@@ -60,37 +81,35 @@ func (a *API) purgeModel(w http.ResponseWriter, r *http.Request, name string) {
others = append(others, o)
}
}
if err := a.models.Remove(r.Context(), name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
if err := a.models.Remove(ctx, name); err != nil {
return PurgeView{}, err
}
a.presetDeclined(r.Context(), name, actorOf(r))
a.presetDeclined(ctx, name, by)
out := PurgeView{Removed: name}
sources := purgeSources(m)
if a.purgeMirror == nil {
out.Note = "this control plane has no mirror; its files there, if any, are left"
} else {
out.Mirror, out.Kept, out.Note = purgeFromMirror(r.Context(), a.purgeMirror, sources, others)
out.Mirror, out.Kept, out.Note = purgeFromMirror(ctx, a.purgeMirror, sources, others)
}
// The nodes' copies, once nothing runs on them.
deadline := time.Now().Add(purgeWait)
for a.runs(name) && time.Now().Before(deadline) {
select {
case <-r.Context().Done():
return
case <-ctx.Done():
return out, ctx.Err()
case <-time.After(time.Second):
}
}
if a.runs(name) {
out.Note = strings.TrimSpace(out.Note + " Its replicas had not stopped yet: its copies on the nodes are " +
"left, and llmgw ctl weights --collect deletes them.")
writeJSON(w, http.StatusOK, out)
return
return out, nil
}
out.Nodes = a.purgeFromNodes(r.Context(), sources, others)
writeJSON(w, http.StatusOK, out)
out.Nodes = a.purgeFromNodes(ctx, sources, others)
return out, nil
}
// runs says whether any replica of a model is still on a node.
+59
View File
@@ -0,0 +1,59 @@
package adminapi
import (
"context"
"errors"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
)
// What the portal changes of a served model beside what the catalog holds:
// the same as the API's routes, called in the process.
// SetModelContext changes a served model's context and longest answer.
func (a *API) SetModelContext(ctx context.Context, name string, req modelserve.ContextRequest, by string) (modelserve.ContextChange, error) {
if a.serve == nil {
return modelserve.ContextChange{}, errors.New("this control plane cannot change a model's context")
}
return a.serve.SetContext(ctx, name, req, by)
}
// SetModelGPUs spreads a served model over more cards a replica, or fewer.
func (a *API) SetModelGPUs(ctx context.Context, name string, req modelserve.GPUsRequest, by string) (modelserve.GPUsChange, error) {
if a.serve == nil {
return modelserve.GPUsChange{}, errors.New("this control plane cannot change a model's cards")
}
return a.serve.SetGPUs(ctx, name, req, by)
}
// RestartModel starts each of a model's ready replicas again, one at a time,
// and says how many.
func (a *API) RestartModel(name string) (int, error) {
var ids []string
for _, rep := range a.ctl.Reconciler().ReadyReplicas() {
if rep.Model == name {
ids = append(ids, rep.ID)
}
}
if len(ids) == 0 {
return 0, errors.New("none of its replicas is ready to restart")
}
return len(ids), a.ctl.Reconciler().RestartPaced(reconciler.RestartPace{}, false, ids...)
}
// RemoveModel takes a model out of the catalog, with its files on the mirror
// and the nodes when purge.
func (a *API) RemoveModel(ctx context.Context, name string, purge bool, by string) (PurgeView, error) {
if purge {
return a.Purge(ctx, name, by)
}
if a.models == nil {
return PurgeView{}, errors.New("this control plane has no store")
}
if err := a.models.Remove(ctx, name); err != nil {
return PurgeView{}, err
}
a.presetDeclined(ctx, name, by)
return PurgeView{Removed: name}, nil
}
+31
View File
@@ -108,6 +108,23 @@ type modelUse struct {
// Declared models are read from server.yaml on every start, so a mode
// changed here would revert; the page says where to change it instead.
Declared bool
// What its card changes beside: the context and longest answer, whether
// it is stopped, its profile on cards and the cards a replica takes.
ContextLength, MaxOutput int
Stopped bool
GPUProfile string
GPUs int
// Profiles are where its replicas run and, Split, how many on each.
Profiles []profileUse
Split bool
// CanCPU says a profile on CPUs can be added — a llama.cpp model with
// none — reserving CPUMemoryGB and NewCPUCores, answering about
// CPUSpeed tokens a second on CPUSpeedNode.
CanCPU bool
CPUMemoryGB int
NewCPUCores int
CPUSpeed float64
CPUSpeedNode string
}
// mirrorRow is one model version as the page shows it.
@@ -324,6 +341,18 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
if name == "" || (mode != modeAlways && mode != modeOnDemand && mode != modeStopped) {
return "", errors.New(l.T("mirror.err.choose_mode"))
}
// Stopped is the model kept as it was, and started again so:
// not its scaling set to nothing, which forgot what it was —
// and its replicas per profile with it.
if mode == modeStopped {
if _, err := d.Catalog.Stop(ctx, name, admin.Email); err != nil {
return "", err
}
return l.T("mirror.msg.stopped", name), nil
}
if _, err := d.Catalog.Start(ctx, name, admin.Email); err == nil && mode == modeAlways {
return l.T("mirror.msg.model_started", name), nil
}
replicas, _ := strconv.Atoi(r.FormValue("replicas"))
sc := scalingFor(mode, replicas, 0)
if _, err := d.Catalog.Scale(ctx, name, sc, admin.Email); err != nil {
@@ -339,6 +368,7 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
}))
mux.HandleFunc("POST /admin/mirror/replicas", p.mirrorAction(m, p.setModel))
p.registerModelSettings(mux, m)
mux.HandleFunc("POST /admin/mirror/adapters", p.mirrorAction(m, p.setAdapters))
mux.HandleFunc("POST /admin/mirror/serve", p.mirrorAction(m,
@@ -565,6 +595,7 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request,
if len(m.Profiles) > 0 {
u.MemoryGB, u.CPUCores = m.Profiles[0].MinMemoryGB, m.Profiles[0].CPUCores
}
p.describeProfiles(&u, m, d.Rows[i].Status)
if v, ok := views[m.Name]; ok {
u.Fleet, u.Ready, u.Wanted = true, v.Ready, v.Desired
u.Parked, u.Unplaceable = v.Parked, v.Unplaceable
+7
View File
@@ -12,6 +12,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
@@ -34,6 +35,12 @@ type CatalogAdmin interface {
SetOwner(ctx context.Context, name, owner, by string) (catalog.Model, error)
Resources(ctx context.Context, name, profile string, memoryGB, cpuCores int, by string) (catalog.Model, error)
SetAdapters(ctx context.Context, name string, adapters []catalog.Adapter, by string) (catalog.Model, error)
// Stop takes a model out of service and keeps it; Start runs it again
// as it was.
Stop(ctx context.Context, name, by string) (catalog.Model, error)
Start(ctx context.Context, name, by string) (catalog.Model, error)
// SetProfileReplicas counts its replicas per profile, adding cpu.
SetProfileReplicas(ctx context.Context, name string, counts map[string]int, cpu *modelsvc.CPUProfile, by string) (catalog.Model, error)
}
// serveForm is the offer to put one mirrored version into service.
+19
View File
@@ -12,6 +12,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
)
@@ -27,6 +28,22 @@ type fakeCatalog struct {
owners []string
resources []string
adapterSets [][]catalog.Adapter
// stops, starts and splits are the models stopped, started and counted
// per profile, splitCPU the profile on CPUs last asked for.
stops, starts []string
splits []map[string]int
splitCPU *modelsvc.CPUProfile
}
func (f *fakeCatalog) SetProfileReplicas(_ context.Context, name string, counts map[string]int, cpu *modelsvc.CPUProfile,
_ string,
) (catalog.Model, error) {
f.splits, f.splitCPU = append(f.splits, counts), cpu
m := catalog.Model{Name: name}
for p, n := range counts {
m.Profiles = append(m.Profiles, catalog.Profile{Name: p, Replicas: n})
}
return m, f.err
}
func (f *fakeCatalog) Add(_ context.Context, m catalog.Model, _ string) error {
@@ -549,10 +566,12 @@ func TestTheCPUWithGPUsFreeIsAChoiceMadeOnPurpose(t *testing.T) {
}
func (f *fakeCatalog) Stop(_ context.Context, name, _ string) (catalog.Model, error) {
f.stops = append(f.stops, name)
return catalog.Model{Name: name, Stopped: &catalog.Scaling{Min: 1, Max: 1}}, f.err
}
func (f *fakeCatalog) Start(_ context.Context, name, _ string) (catalog.Model, error) {
f.starts = append(f.starts, name)
return catalog.Model{Name: name, Scaling: catalog.Scaling{Min: 1, Max: 1}}, f.err
}
+40 -1
View File
@@ -279,5 +279,44 @@
"strata.msg.served": "%s added: its first start fetches about %s GB onto the node and prepares it, which can take hours on a slow link; Replicas shows how far it is.",
"strata.pack_files": "part of Strata's ready-made %s %s pack: served whole from the mirror's Strata tab, Strata fetching it onto the node",
"strata.unknown_pack": "a pack made for Strata that Strata 0.2.7 does not know: its setup refuses it, and llama.cpp cannot read it",
"strata.old_card": "its engine needs an NVIDIA card of compute capability %s or newer (RTX 30 on); the nodes with Strata have older ones"
"strata.old_card": "its engine needs an NVIDIA card of compute capability %s or newer (RTX 30 on); the nodes with Strata have older ones",
"mirror.settings.open": "Settings",
"mirror.settings.heading": "Settings",
"mirror.settings.context": "Change the context",
"mirror.settings.context_note": "Leave one empty to keep it. The replicas start again on the new context, one at a time; a longest answer alone changes at once.",
"mirror.settings.gpus": "Cards a replica",
"mirror.settings.gpus_apply": "Spread it",
"mirror.settings.gpus_note": "Each replica moves to this many cards of its class, started again one at a time.",
"mirror.settings.start": "Start",
"mirror.settings.stop": "Stop",
"mirror.settings.stop_confirm": "Stop %s? Its replicas stop; it is kept, with its files, and Start runs it again as it was.",
"mirror.settings.restart": "Restart its replicas",
"mirror.settings.restart_confirm": "Start each of %s's replicas again, one at a time?",
"mirror.settings.unserve": "Remove from the catalog",
"mirror.settings.unserve_confirm": "Take %s out of the catalog? Its replicas stop; its files stay on the mirror and the nodes.",
"mirror.settings.purge": "Remove with its files",
"mirror.settings.purge_confirm": "Take %s out of the catalog and delete its files from the mirror and the nodes? What another model uses stays.",
"mirror.split.heading": "Replicas on each profile — all answer under the model's name; the cards are used first, the CPUs when they are full.",
"mirror.split.replicas": "replicas",
"mirror.split.on": "%d on %s",
"mirror.split.on_cpu": "On CPUs (%s)",
"mirror.split.on_gpu": "On cards (%s, %s)",
"mirror.split.add_cpu": "Also on CPUs",
"mirror.split.cpu_speed": "On the CPU about %s tokens a second for one reply, at best on %s.",
"mirror.split.note": "Those short start first, then those over stop: a change never runs fewer.",
"mirror.split.scaling_note": "Its replicas are counted per profile below; min and max are their sum.",
"mirror.msg.model_started": "%s is started again, as it was",
"mirror.msg.context": "%s: a context of %s tokens, answers of at most %s; %d replicas starting again",
"mirror.msg.gpus": "%s: %d cards a replica; %d replicas starting again",
"mirror.msg.restarting.one": "%[2]s: its replica is starting again",
"mirror.msg.restarting.other": "%[2]s: its %[1]d replicas are starting again, one at a time",
"mirror.msg.split": "%s runs %s",
"mirror.msg.unserved": "%s is out of the catalog; its files stay",
"mirror.msg.purged.one": "%[2]s is out of the catalog, its version removed from the mirror and its copies from the nodes.",
"mirror.msg.purged.other": "%[2]s is out of the catalog, %[1]d versions removed from the mirror and its copies from the nodes.",
"mirror.err.no_settings": "this control plane cannot change that",
"mirror.err.gpus": "the cards a replica takes is a whole number, 1 or more",
"mirror.err.action": "stop, start or restart",
"mirror.err.count": "%s is a whole number, 0 or more",
"mirror.err.choose_model": "choose a model"
}
+38 -1
View File
@@ -272,5 +272,42 @@
"strata.msg.served": "%s eklendi: ilk açılışında node'a yaklaşık %s GB indirip hazırlar; yavaş bağlantıda saatler sürebilir. İlerleyişi Replikalar gösterir.",
"strata.pack_files": "Strata'nın hazır %s %s paketinin parçası: aynanın Strata sekmesinden bütün olarak sunulur, Strata onu node'a kendisi indirir",
"strata.unknown_pack": "Strata için hazırlanmış ama Strata 0.2.7'nin tanımadığı bir paket: kurulumu onu reddeder, llama.cpp de okuyamaz",
"strata.old_card": "motoru compute capability %s ya da daha yeni bir NVIDIA kartı ister (RTX 30 ve sonrası); Strata kurulu node'ların kartları daha eski"
"strata.old_card": "motoru compute capability %s ya da daha yeni bir NVIDIA kartı ister (RTX 30 ve sonrası); Strata kurulu node'ların kartları daha eski",
"mirror.settings.open": "Ayarlar",
"mirror.settings.heading": "Ayarlar",
"mirror.settings.context": "Bağlamı değiştir",
"mirror.settings.context_note": "Değişmeyecek olanı boş bırakın. Replikalar yeni bağlamla teker teker yeniden başlar; yalnızca en uzun cevap değişirse hemen geçerli olur.",
"mirror.settings.gpus": "Replika başına kart",
"mirror.settings.gpus_apply": "Dağıt",
"mirror.settings.gpus_note": "Her replika kendi sınıfından bu kadar karta taşınır, teker teker yeniden başlatılarak.",
"mirror.settings.start": "Başlat",
"mirror.settings.stop": "Durdur",
"mirror.settings.stop_confirm": "%s durdurulsun mu? Replikaları kapanır; model dosyalarıyla birlikte saklanır, Başlat onu olduğu gibi yeniden çalıştırır.",
"mirror.settings.restart": "Replikaları yeniden başlat",
"mirror.settings.restart_confirm": "%s replikaları teker teker yeniden başlatılsın mı?",
"mirror.settings.unserve": "Katalogdan çıkar",
"mirror.settings.unserve_confirm": "%s katalogdan çıkarılsın mı? Replikaları kapanır; dosyaları aynada ve node'larda kalır.",
"mirror.settings.purge": "Dosyalarıyla birlikte sil",
"mirror.settings.purge_confirm": "%s katalogdan çıkarılıp dosyaları aynadan ve node'lardan silinsin mi? Başka bir modelin kullandıkları kalır.",
"mirror.split.heading": "Her profilde çalışan replika sayısı — hepsi modelin adıyla cevap verir; önce kartlar kullanılır, dolunca CPU'lar.",
"mirror.split.replicas": "replika sayısı",
"mirror.split.on": "%[2]s üzerinde %[1]d",
"mirror.split.on_cpu": "CPU'larda (%s)",
"mirror.split.on_gpu": "Kartlarda (%s, %s)",
"mirror.split.add_cpu": "CPU'larda da",
"mirror.split.cpu_speed": "CPU üzerinde tek bir cevap için yaklaşık %s token/sn, en iyi %s üzerinde.",
"mirror.split.note": "Önce eksik olanlar başlar, sonra fazlası kapanır: bir değişiklik sırasında daha az replika çalışmaz.",
"mirror.split.scaling_note": "Replikaları aşağıda profil başına sayılıyor; en az ve en çok bunların toplamıdır.",
"mirror.msg.model_started": "%s olduğu gibi yeniden başlatıldı",
"mirror.msg.context": "%s: %s token bağlam, en fazla %s token cevap; %d replika yeniden başlıyor",
"mirror.msg.gpus": "%s: replika başına %d kart; %d replika yeniden başlıyor",
"mirror.msg.restarting.other": "%[2]s: %[1]d replikası teker teker yeniden başlıyor",
"mirror.msg.split": "%s şöyle çalışıyor: %s",
"mirror.msg.unserved": "%s katalogdan çıkarıldı; dosyaları duruyor",
"mirror.msg.purged.other": "%[2]s katalogdan çıkarıldı, aynadan %[1]d sürümü ve node'lardaki kopyaları silindi.",
"mirror.err.no_settings": "bu control plane bunu değiştiremez",
"mirror.err.gpus": "replika başına kart sayısı 1 ya da daha büyük bir tam sayıdır",
"mirror.err.action": "durdur, başlat ya da yeniden başlat",
"mirror.err.count": "%s 0 ya da daha büyük bir tam sayıdır",
"mirror.err.choose_model": "bir model seçin"
}
+89
View File
@@ -2,14 +2,17 @@ package web
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
@@ -333,3 +336,89 @@ func TestAnAdapterHeldInTheMirrorIsAttachedFromItsModelsCard(t *testing.T) {
}
}
}
// settingsFleet is roomyFleet that can change what the catalog does not
// hold of a model, and says what it was asked.
type settingsFleet struct {
roomyFleet
asked []string
}
func (f *settingsFleet) SetModelContext(_ context.Context, name string, req modelserve.ContextRequest, _ string) (modelserve.ContextChange, error) {
f.asked = append(f.asked, fmt.Sprintf("context %s %d %d", name, req.Tokens, req.MaxOutput))
return modelserve.ContextChange{Model: name, After: req.Tokens, MaxOutput: req.MaxOutput}, nil
}
func (f *settingsFleet) SetModelGPUs(_ context.Context, name string, req modelserve.GPUsRequest, _ string) (modelserve.GPUsChange, error) {
f.asked = append(f.asked, fmt.Sprintf("gpus %s %d", name, req.GPUs))
return modelserve.GPUsChange{Model: name, After: req.GPUs}, nil
}
func (f *settingsFleet) RestartModel(name string) (int, error) {
f.asked = append(f.asked, "restart "+name)
return 1, nil
}
func (f *settingsFleet) RemoveModel(_ context.Context, name string, purge bool, _ string) (adminapi.PurgeView, error) {
f.asked = append(f.asked, fmt.Sprintf("remove %s %v", name, purge))
return adminapi.PurgeView{Removed: name}, nil
}
// A served model's row leads to its settings, and its card holds them all:
// context, cards, replicas per profile with CPUs to add, stop, restart,
// removal with its files or without.
func TestAServedModelsSettingsAreWhereItsFileIs(t *testing.T) {
cat := &fakeCatalog{sources: map[string][]catalog.Model{"http://mirror:8081/coder/v1/c.gguf": {
{Name: "coder", ContextLength: 8192, MaxOutput: 1024, Scaling: catalog.Scaling{Min: 1, Max: 1},
Profiles: []catalog.Profile{{Name: "gpu-1x6000-24g", Engine: "llamacpp", GPUClass: "6000-24g", GPUsPerNode: 1}}}}}}
fleet := &settingsFleet{roomyFleet: roomyFleet{more: 1}}
m := &fakeMirror{models: []mirror.Status{
{Model: "coder", Version: "v1", File: "c.gguf", State: mirror.Present, BytesDone: 5 << 30},
}}
mux := mirrorPageDeps(t, MirrorDeps{Mirror: m, Catalog: cat, Fleet: fleet}, true)
if row := mirrorGet(t, mux, "/admin/mirror").Body.String(); !strings.Contains(row, `open=coder%3av1">Settings</a>`) {
t.Error("the row of a served model does not lead to its settings")
}
card := mirrorGet(t, mux, "/admin/mirror?tab=held&open=coder:v1").Body.String()
for _, want := range []string{`action="/admin/mirror/context"`, `name="tokens" value="8192"`, `name="max_output" value="1024"`,
`action="/admin/mirror/gpus"`, `name="gpus" value="1"`, `action="/admin/mirror/split"`, `name="cpu_replicas"`,
`value="stop"`, `value="restart"`, `action="/admin/mirror/unserve"`, `name="purge" value="yes"`} {
if !strings.Contains(card, want) {
t.Errorf("the card has no %s", want)
}
}
post := func(path string, kv ...string) string {
v := url.Values{"csrf": {"tok"}, "model": {"coder"}}
for i := 0; i+1 < len(kv); i += 2 {
v.Add(kv[i], kv[i+1])
}
return mirrorPost(t, mux, path, v).Body.String()
}
post("/admin/mirror/context", "tokens", "16384", "max_output", "")
post("/admin/mirror/gpus", "gpus", "2")
post("/admin/mirror/state", "action", "restart")
post("/admin/mirror/unserve", "purge", "yes")
if want := []string{"context coder 16384 0", "gpus coder 2", "restart coder", "remove coder true"}; !slices.Equal(fleet.asked, want) {
t.Errorf("asked %v, want %v", fleet.asked, want)
}
post("/admin/mirror/state", "action", "stop")
post("/admin/mirror/state", "action", "start")
if !slices.Equal(cat.stops, []string{"coder"}) || !slices.Equal(cat.starts, []string{"coder"}) {
t.Errorf("stopped %v, started %v", cat.stops, cat.starts)
}
post("/admin/mirror/split", "profile", "gpu-1x6000-24g", "replicas_gpu-1x6000-24g", "7",
"cpu_replicas", "4", "cpu_memory", "12", "cpu_cores", "8")
if len(cat.splits) != 1 || cat.splits[0]["gpu-1x6000-24g"] != 7 || cat.splits[0]["cpu"] != 4 ||
cat.splitCPU == nil || cat.splitCPU.MemoryGB != 12 || cat.splitCPU.CPUCores != 8 {
t.Errorf("split %v, cpu %+v", cat.splits, cat.splitCPU)
}
}
// Stopped on the row is the model kept, not its scaling set to nothing.
func TestStoppingFromTheRowKeepsTheModel(t *testing.T) {
cat := coderCatalog()
mux := mirrorPageDeps(t, MirrorDeps{Mirror: &fakeMirror{}, Catalog: cat}, true)
mirrorPost(t, mux, "/admin/mirror/mode", url.Values{"csrf": {"tok"}, "model": {"coder"}, "mode": {"stopped"}, "replicas": {"1"}})
if !slices.Equal(cat.stops, []string{"coder"}) || len(cat.scaled) != 0 {
t.Errorf("stopped %v, scaled %v", cat.stops, cat.scaled)
}
}
+237
View File
@@ -0,0 +1,237 @@
package web
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// ModelSettings changes a served model beyond what the catalogue holds: its
// context, its cards, its replicas started again, its removal. The cluster
// view answers it where it can.
type ModelSettings interface {
SetModelContext(ctx context.Context, name string, req modelserve.ContextRequest, by string) (modelserve.ContextChange, error)
SetModelGPUs(ctx context.Context, name string, req modelserve.GPUsRequest, by string) (modelserve.GPUsChange, error)
RestartModel(name string) (int, error)
RemoveModel(ctx context.Context, name string, purge bool, by string) (adminapi.PurgeView, error)
}
// settings is the cluster view's ModelSettings, nil where it has none.
func (p *AccountPages) settings() ModelSettings {
s, _ := p.fleet.(ModelSettings)
return s
}
// registerModelSettings mounts what the card of a served model changes.
func (p *AccountPages) registerModelSettings(mux *http.ServeMux, m MirrorAdmin) {
need := func(r *http.Request) (string, ModelSettings, error) {
name := strings.TrimSpace(r.FormValue("model"))
if name == "" {
return "", nil, errors.New(langOf(r).T("mirror.err.choose_model"))
}
s := p.settings()
if s == nil {
return "", nil, errors.New(langOf(r).T("mirror.err.no_settings"))
}
return name, s, nil
}
mux.HandleFunc("POST /admin/mirror/context", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name, s, err := need(r)
if err != nil {
return "", err
}
tokens, err := optionalCount(l, r.FormValue("tokens"), "serve.context_length")
if err != nil {
return "", err
}
maxOut, err := optionalCount(l, r.FormValue("max_output"), "serve.max_output")
if err != nil {
return "", err
}
ch, err := s.SetModelContext(ctx, name, modelserve.ContextRequest{Tokens: tokens, MaxOutput: maxOut}, admin.Email)
if err != nil {
return "", err
}
return l.T("mirror.msg.context", name, l.Int(int64(ch.After)), l.Int(int64(ch.MaxOutput)), ch.Restarted), nil
}))
mux.HandleFunc("POST /admin/mirror/gpus", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name, s, err := need(r)
if err != nil {
return "", err
}
n, err := strconv.Atoi(strings.TrimSpace(r.FormValue("gpus")))
if err != nil || n < 1 {
return "", errors.New(l.T("mirror.err.gpus"))
}
ch, err := s.SetModelGPUs(ctx, name, modelserve.GPUsRequest{GPUs: n, Profile: r.FormValue("profile")}, admin.Email)
if err != nil {
return "", err
}
return l.T("mirror.msg.gpus", name, ch.After, ch.Restarted), nil
}))
mux.HandleFunc("POST /admin/mirror/state", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name := strings.TrimSpace(r.FormValue("model"))
switch r.FormValue("action") {
case "stop":
if _, err := p.catalog.Stop(ctx, name, admin.Email); err != nil {
return "", err
}
return l.T("mirror.msg.stopped", name), nil
case "start":
if _, err := p.catalog.Start(ctx, name, admin.Email); err != nil {
return "", err
}
return l.T("mirror.msg.model_started", name), nil
case "restart":
s := p.settings()
if s == nil {
return "", errors.New(l.T("mirror.err.no_settings"))
}
n, err := s.RestartModel(name)
if err != nil {
return "", err
}
return l.N("mirror.msg.restarting", int64(n), name), nil
}
return "", errors.New(l.T("mirror.err.action"))
}))
mux.HandleFunc("POST /admin/mirror/split", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name := strings.TrimSpace(r.FormValue("model"))
counts := map[string]int{}
for _, prof := range r.Form["profile"] {
n, err := count(l, r.FormValue("replicas_"+prof), "mirror.split.replicas")
if err != nil {
return "", err
}
counts[prof] = n
}
var cpu *modelsvc.CPUProfile
if v := strings.TrimSpace(r.FormValue("cpu_replicas")); v != "" && v != "0" {
n, err := count(l, v, "mirror.split.replicas")
if err != nil {
return "", err
}
mem, err := count(l, r.FormValue("cpu_memory"), "serve.memory")
if err != nil {
return "", err
}
cores, err := optionalCount(l, r.FormValue("cpu_cores"), "serve.cpu_cores")
if err != nil {
return "", err
}
cpu = &modelsvc.CPUProfile{Name: "cpu", MemoryGB: mem, CPUCores: cores}
counts["cpu"] = n
}
got, err := p.catalog.SetProfileReplicas(ctx, name, counts, cpu, admin.Email)
if err != nil {
return "", err
}
var parts []string
for _, pr := range got.Profiles {
parts = append(parts, l.T("mirror.split.on", pr.Replicas, pr.Name))
}
return l.T("mirror.msg.split", name, strings.Join(parts, ", ")), nil
}))
mux.HandleFunc("POST /admin/mirror/unserve", p.mirrorAction(m,
func(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name, s, err := need(r)
if err != nil {
return "", err
}
purge := r.FormValue("purge") == "yes"
out, err := s.RemoveModel(ctx, name, purge, admin.Email)
if err != nil {
return "", err
}
if purge {
msg := l.N("mirror.msg.purged", int64(len(out.Mirror)), name)
if out.Note != "" {
msg += " " + out.Note
}
return msg, nil
}
return l.T("mirror.msg.unserved", name), nil
}))
}
// count is a whole number of at least zero from a form field, or why not.
func count(l *Lang, v, field string) (int, error) {
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil || n < 0 {
return 0, errors.New(l.T("mirror.err.count", l.T(field)))
}
return n, nil
}
// optionalCount is count, with an empty field zero: left as it is.
func optionalCount(l *Lang, v, field string) (int, error) {
if strings.TrimSpace(v) == "" {
return 0, nil
}
return count(l, v, field)
}
// profileUse is one of a model's profiles as its card shows it.
type profileUse struct {
Name string
CPU bool
Class string
GPUs int
Replicas int
}
// describeProfiles fills in what the card of a served model shows of its
// profiles, and of a profile on CPUs it could have.
func (p *AccountPages) describeProfiles(u *modelUse, m catalog.Model, st mirror.Status) {
u.ContextLength, u.MaxOutput, u.Stopped, u.Split = m.ContextLength, m.MaxOutput, m.Stopped != nil, m.Split()
hasCPU, llama := false, false
for _, pr := range m.Profiles {
pu := profileUse{Name: pr.Name, CPU: pr.CPUOnly(), Class: pr.GPUClass, Replicas: pr.Replicas,
GPUs: max(pr.GPUsPerNode, pr.TP*max(pr.PP, 1))}
u.Profiles = append(u.Profiles, pu)
hasCPU = hasCPU || pu.CPU
llama = llama || pr.Engine == "llamacpp"
if !pu.CPU && u.GPUProfile == "" && pr.GPU == nil {
u.GPUProfile, u.GPUs = pr.Name, max(pu.GPUs, 1)
}
}
if !llama || hasCPU || m.IsExternal() {
return
}
u.CanCPU = true
u.CPUMemoryGB = memoryFor(st.BytesTotal, st.BytesDone)
hosts := cpuHosts(p.fleet, u.CPUMemoryGB)
var cpus []int
for _, h := range hosts {
cpus = append(cpus, h.CPUs)
}
u.NewCPUCores = catalog.SuggestCores(cpus)
weights := st.BytesTotal
if weights <= 0 {
weights = st.BytesDone
}
u.CPUSpeed, u.CPUSpeedNode = bestCPUSpeed(hosts, weights, u.NewCPUCores)
}
+8
View File
@@ -828,6 +828,14 @@ tr.held-detail > td { background: var(--bg); padding: .75rem 1rem 1rem; }
.held-card .replicas-form .field-row { align-items: flex-end; }
.held-card .replicas-form .field-row label { flex: 0 0 auto; }
.held-card .replicas-form input[type=number] { width: 8rem; margin: .25rem 0 0; }
.held-card .model-settings { margin-top: .75rem; padding-top: .5rem; border-top: 1px dashed var(--line); }
.held-card .model-settings > summary { cursor: pointer; list-style: none; }
.held-card .model-settings > summary h4 { display: inline; font-size: .95rem; margin: 0; }
.held-card .settings-form { margin: .6rem 0; }
.held-card .settings-form .field-row { align-items: flex-end; }
.held-card .settings-form .field-row label { flex: 0 0 auto; }
.held-card .settings-form input[type=number] { width: 8rem; margin: .25rem 0 0; }
.held-card .settings-actions { display: flex; gap: .75rem; flex-wrap: wrap; align-items: center; margin-top: .6rem; }
.held-card .replicas-form button { margin: 0; }
a.open-held { text-decoration: none; color: inherit; }
a.open-held:hover strong { text-decoration: underline; }
+4 -1
View File
@@ -15,7 +15,10 @@
}
});
document.addEventListener('submit', function (e) {
var msg = e.target.getAttribute && e.target.getAttribute('data-confirm');
// The button's own, where a form has several that mean different things.
var b = e.submitter;
var msg = (b && b.getAttribute && b.getAttribute('data-confirm')) ||
(e.target.getAttribute && e.target.getAttribute('data-confirm'));
if (msg && !window.confirm(msg)) { e.preventDefault(); }
});
// A form being filled in is not refreshed away. The live parts of the
+82 -3
View File
@@ -109,6 +109,7 @@
</form>
{{else if eq (printf "%s" .State) "present"}}
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">{{t "mirror.copy_address"}}</button>
{{if and .UsedBy (not .Detail)}}<a href="/admin/mirror?tab=held&amp;open={{.Open}}">{{t "mirror.settings.open"}}</a>{{end}}
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">{{t "mirror.close"}}</a>
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">{{t "mirror.serve"}}</a>{{end}}{{end}}
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
@@ -663,9 +664,9 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<input type="hidden" name="open" value="{{$row.Open}}">
<div class="field-row">
<label>{{t "mirror.form.min"}}
<input type="number" name="min" value="{{.Min}}" min="0"{{if .MostNow}} max="{{.MostNow}}"{{end}} required></label>
<input type="number" name="min" value="{{.Min}}" min="0"{{if .MostNow}} max="{{.MostNow}}"{{end}} required{{if .Split}} readonly{{end}}></label>
<label>{{t "mirror.form.max"}}
<input type="number" name="max" value="{{.Max}}" min="0" max="64" required></label>
<input type="number" name="max" value="{{.Max}}" min="0" max="64" required{{if .Split}} readonly{{end}}></label>
<label>{{t "mirror.form.class"}}
<select name="class">
<option value=""{{if eq .Class ""}} selected{{end}}>{{if eq .Mode "ondemand"}}{{t "mirror.class.default" (t "mirror.class.opportunistic")}}{{else}}{{t "mirror.class.default" (t "mirror.class.standard")}}{{end}}</option>
@@ -686,10 +687,88 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<input type="number" name="cores" value="{{.CPUCores}}" min="0"></label>
<button type="submit">{{t "common.save"}}</button>
</div>
<p class="note">{{if .MostNow}}{{if and .Headroom .Headroom.ByPreempting}}{{t "mirror.form.most_now_preempting" .MostNow}}{{else}}{{t "mirror.form.most_now" .MostNow}}{{end}} {{end}}{{t "mirror.form.note"}}</p>
<p class="note">{{if .Split}}{{t "mirror.split.scaling_note"}} {{end}}{{if .MostNow}}{{if and .Headroom .Headroom.ByPreempting}}{{t "mirror.form.most_now_preempting" .MostNow}}{{else}}{{t "mirror.form.most_now" .MostNow}}{{end}} {{end}}{{t "mirror.form.note"}}</p>
</form>
{{template "mirror_model_settings" dict "M" . "Row" $row "CSRF" $csrf}}
{{end}}
</section>
{{end}}
</div>
{{end}}
{{/* The rest of what can be changed of a served model, where its file is:
its context, its cards, its replicas on each profile, stopped or
started, started again, and taken out of the catalog. */}}
{{define "mirror_model_settings"}}{{$m := .M}}{{$row := .Row}}{{$csrf := .CSRF}}
<details class="model-settings" open>
<summary><h4>{{t "mirror.settings.heading"}}</h4></summary>
<form method="post" action="/admin/mirror/context" class="settings-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
<div class="field-row">
<label>{{t "serve.context_length"}} <input type="number" name="tokens" value="{{if $m.ContextLength}}{{$m.ContextLength}}{{end}}" min="1"></label>
<label>{{t "serve.max_output"}} <input type="number" name="max_output" value="{{if $m.MaxOutput}}{{$m.MaxOutput}}{{end}}" min="1"></label>
<button type="submit">{{t "mirror.settings.context"}}</button>
</div>
<p class="note">{{t "mirror.settings.context_note"}}</p>
</form>
{{if $m.GPUProfile}}
<form method="post" action="/admin/mirror/gpus" class="settings-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="profile" value="{{$m.GPUProfile}}">
<input type="hidden" name="open" value="{{$row.Open}}">
<div class="field-row">
<label>{{t "mirror.settings.gpus"}} <input type="number" name="gpus" value="{{$m.GPUs}}" min="1" max="16"></label>
<button type="submit">{{t "mirror.settings.gpus_apply"}}</button>
</div>
<p class="note">{{t "mirror.settings.gpus_note"}}</p>
</form>
{{end}}
{{if or (gt (len $m.Profiles) 1) $m.CanCPU}}
<form method="post" action="/admin/mirror/split" class="settings-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
<p class="sub">{{t "mirror.split.heading"}}</p>
<div class="field-row">
{{range $m.Profiles}}<input type="hidden" name="profile" value="{{.Name}}">
<label>{{if .CPU}}{{t "mirror.split.on_cpu" .Name}}{{else}}{{t "mirror.split.on_gpu" .Name .Class}}{{end}}
<input type="number" name="replicas_{{.Name}}" value="{{.Replicas}}" min="0" max="64"></label>{{end}}
{{if $m.CanCPU}}<label>{{t "mirror.split.add_cpu"}} <input type="number" name="cpu_replicas" value="0" min="0" max="64"></label>
<label>{{t "serve.memory"}} <input type="number" name="cpu_memory" value="{{$m.CPUMemoryGB}}" min="1"></label>
<label>{{t "serve.cpu_cores"}} <input type="number" name="cpu_cores" value="{{if $m.NewCPUCores}}{{$m.NewCPUCores}}{{end}}" min="1"></label>{{end}}
<button type="submit">{{t "common.save"}}</button>
</div>
{{if and $m.CanCPU $m.CPUSpeed}}<p class="note{{if lt $m.CPUSpeed 5.0}} error{{end}}">{{t "mirror.split.cpu_speed" (num $m.CPUSpeed 1) $m.CPUSpeedNode}}</p>{{end}}
<p class="note">{{t "mirror.split.note"}}</p>
</form>
{{end}}
<div class="settings-actions">
<form method="post" action="/admin/mirror/state" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
{{if $m.Stopped}}<button type="submit" name="action" value="start">{{t "mirror.settings.start"}}</button>
{{else}}<button type="submit" name="action" value="stop" data-confirm="{{t "mirror.settings.stop_confirm" $m.Name}}">{{t "mirror.settings.stop"}}</button>
<button type="submit" name="action" value="restart" data-confirm="{{t "mirror.settings.restart_confirm" $m.Name}}">{{t "mirror.settings.restart"}}</button>{{end}}
</form>
<form method="post" action="/admin/mirror/unserve" class="inline" data-confirm="{{t "mirror.settings.unserve_confirm" $m.Name}}">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<button type="submit" class="link danger">{{t "mirror.settings.unserve"}}</button>
</form>
<form method="post" action="/admin/mirror/unserve" class="inline" data-confirm="{{t "mirror.settings.purge_confirm" $m.Name}}">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="purge" value="yes">
<button type="submit" class="link danger">{{t "mirror.settings.purge"}}</button>
</form>
</div>
</details>
{{end}}