Files
llmgw/internal/web/mirror_detail.go
T
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

499 lines
16 KiB
Go

package web
import (
"context"
"errors"
"html/template"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// HeadroomSource is a fleet that can say how many more replicas of a model
// fit now. Optional: without it the card shows what runs and offers no change
// it cannot check.
type HeadroomSource interface {
Headroom(model string, limit int) (adminapi.HeadroomView, bool)
}
// maxReplicas bounds what the form accepts. Not a cluster limit, a typing one:
// 300 where 3 was meant would otherwise be taken as a ceiling.
const maxReplicas = 64
// heldDetail is the card opened beneath a held version: what runs it, where,
// and room to change it.
type heldDetail struct {
Models []heldModel
// Scopes are the parts of the tenancy tree a model can belong to; empty
// when capacity quotas are not set up, and the owner choice is left off.
Scopes []adminapi.ScopeName
}
// heldModel is one catalog model running a held version.
type heldModel struct {
modelUse
// Adapters are the model's own; AdapterChoices the held adapter files
// of its architecture not attached yet; FindAdapters the explorer's
// list of adapters made for it.
Adapters []catalog.Adapter
AdapterChoices []adapterChoice
FindAdapters template.URL
View adminapi.ModelView
Replicas []heldReplica
Headroom *adminapi.HeadroomView
// MostNow is how many replicas could always run, Current plus the room
// found; zero when the room was not measured or had no end in sight.
MostNow int
// RoomText and PreemptText say the headroom in a sentence each, built
// here because the nodes it names are a list inside the sentence.
RoomText, PreemptText string
}
// heldReplica is one replica, by the nodes it runs on.
type heldReplica struct {
ID string
State string
// StateText is State as the page says it; Restart is already worded.
StateText string
Restart string
Age int64
Endpoint string
Nodes []heldShard
}
type heldShard struct {
Node string
MemoryGB int // the node's RAM, 0 when unknown
CPU float64
MemBytes int64
}
// openKey names a row the way ?open= does.
func openKey(model, version string) string { return model + ":" + version }
// heldDetailFor reads, for each model using a row, how it runs and how much
// more room it has.
func (p *AccountPages) heldDetailFor(ctx context.Context, l *Lang, row mirrorRow) *heldDetail {
d := &heldDetail{}
if p.capacity != nil {
d.Scopes, _ = p.capacity.Scopes(ctx)
}
if p.fleet == nil {
for _, u := range row.UsedBy {
d.Models = append(d.Models, heldModel{modelUse: u, Adapters: u.Adapters})
}
return d
}
views := map[string]adminapi.ModelView{}
for _, v := range p.fleet.Models() {
views[v.Name] = v
}
memory := map[string]int{}
for _, n := range p.fleet.Nodes() {
memory[n.ID] = n.MemoryGB
}
hs, _ := p.fleet.(HeadroomSource)
for _, u := range row.UsedBy {
hm := heldModel{modelUse: u, View: views[u.Name], Adapters: u.Adapters}
for _, rep := range p.fleet.Replicas(u.Name) {
// Built field by field: a ReplicaView carries the engine's key,
// which is for the admin listener and never for a page.
hr := heldReplica{ID: rep.ID, State: rep.State, StateText: replicaState(l, rep.State),
Restart: restartState(l, rep.Restart), Age: rep.AgeSec, Endpoint: rep.Endpoint}
for _, sh := range rep.Shards {
hr.Nodes = append(hr.Nodes, heldShard{Node: sh.Node, MemoryGB: memory[sh.Node],
CPU: sh.CPUCores, MemBytes: sh.MemoryBytes})
}
hm.Replicas = append(hm.Replicas, hr)
}
if hs != nil {
if h, ok := hs.Headroom(u.Name, 16); ok {
hm.Headroom = &h
if !h.Limited {
hm.MostNow = h.Current + h.More + h.ByPreempting
}
hm.RoomText, hm.PreemptText = roomText(l, h)
}
}
d.Models = append(d.Models, hm)
}
return d
}
// replicaStates are the states a replica reports, each with its word.
var replicaStates = []string{"placing", "starting", "ready", "draining", "stopped", "failed", "unknown"}
func init() {
for _, s := range replicaStates {
registerKeys("mirror.replica." + s)
}
registerKeys("mirror.mode.always", "mirror.mode.ondemand", "mirror.mode.stopped",
"mirror.class.critical", "mirror.class.standard", "mirror.class.opportunistic")
}
// replicaState is a replica's state in words; one this page does not know
// is shown as the control plane said it.
func replicaState(l *Lang, s string) string {
if slices.Contains(replicaStates, s) {
return l.T("mirror.replica." + s)
}
return s
}
// restartState is where an operator's restart stands. A refusal carries
// the control plane's own reason, which is shown as it came.
func restartState(l *Lang, s string) string {
switch s {
case "queued":
return l.T("mirror.restart.queued")
case "replacing":
return l.T("mirror.restart.replacing")
case "in place":
return l.T("mirror.restart.in_place")
}
return s
}
// roomText says how much more of a model fits, and how much more it could
// have by stopping what its class outranks.
func roomText(l *Lang, h adminapi.HeadroomView) (room, preempt string) {
switch {
case h.Limited:
room = l.T("mirror.room.at_least", h.More)
case h.More > 0 && len(h.Nodes) > 0:
var on []string
for _, n := range h.Nodes {
if len(n) == 0 {
on = append(on, "—")
continue
}
on = append(on, strings.Join(n, ", "))
}
room = l.T("mirror.room.more_on", h.More, strings.Join(on, ", "))
case h.More > 0:
room = l.T("mirror.room.more", h.More)
default:
room = l.T("mirror.room.none")
}
if h.ByPreempting > 0 {
who := "—"
if len(h.Preempts) > 0 {
who = strings.Join(h.Preempts, ", ")
}
if h.More > 0 {
preempt = l.T("mirror.room.and_preempting", h.ByPreempting, who)
} else {
preempt = l.T("mirror.room.preempting", h.ByPreempting, who)
}
}
return room, preempt
}
// setReplicas changes how many replicas a model always runs and how far it
// may grow, refusing an always-on count the cluster has no room for.
//
// Refused rather than taken and left short: a minimum placement cannot meet
// is a model reported as unplaceable on every pass from then on, which is the
// same answer given later and less plainly. A maximum above the room is
// accepted and said: it is reached only under load, by which time something
// may have made room, and refusing it would make the ceiling depend on what
// happened to be running when it was set.
func (p *AccountPages) setReplicas(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name := r.FormValue("model")
lo, err1 := strconv.Atoi(strings.TrimSpace(r.FormValue("min")))
hi, err2 := strconv.Atoi(strings.TrimSpace(r.FormValue("max")))
switch {
case name == "":
return "", errors.New(l.T("mirror.err.which_model"))
case err1 != nil || err2 != nil:
return "", errors.New(l.T("mirror.err.replicas_whole"))
case lo < 0 || hi < lo:
return "", errors.New(l.T("mirror.err.min_over_max", lo, hi))
case hi > maxReplicas:
return "", errors.New(l.T("mirror.err.at_most", maxReplicas))
}
current, err := p.currentModel(ctx, l, name)
if err != nil {
return "", err
}
if p.catalog.Declared(name) {
return "", errors.New(l.T("mirror.err.declared", name))
}
note := ""
if hs, ok := p.fleet.(HeadroomSource); ok && hi > 0 {
h, ok := hs.Headroom(name, hi)
if !ok {
return "", errors.New(l.T("mirror.err.no_model", name))
}
fits := h.Current + h.More + h.ByPreempting
if lo > fits && !h.Limited {
why := l.T("mirror.err.nothing_fits")
if len(h.Blocked) > 0 {
why = strings.Join(h.Blocked, "; ")
}
return "", errors.New(l.T("mirror.err.no_room", name, fits, h.Current, h.More, why))
}
if hi > fits && !h.Limited {
note = "; " + l.T("mirror.msg.room_for", fits)
}
// Room its class would make by stopping others: said, since it is
// somebody else's replica that goes.
if lo > h.Current+h.More && len(h.Preempts) > 0 {
note += "; " + l.T("mirror.msg.will_stop", strings.Join(h.Preempts, ", "))
}
}
sc := catalog.Scaling{Min: lo, Max: hi, IdleTimeout: current.Scaling.IdleTimeout}
if sc.OnDemand() && sc.IdleTimeout <= 0 {
sc.IdleTimeout = defaultIdle
}
if _, err := p.catalog.Scale(ctx, name, sc, admin.Email); err != nil {
return "", err
}
// Each says the model's name first and the rest after it, which setModel
// relies on to join this onto what else it changed.
switch {
case hi == 0:
return l.T("mirror.msg.stopped", name), nil
case lo == 0:
return l.T("mirror.msg.loads_up_to", name, hi) + note, nil
case lo == hi:
return l.T("mirror.msg.runs_n", name, l.N("serve.replicas_n", int64(lo))) + note, nil
}
return l.T("mirror.msg.runs_up_to", name, lo, hi) + note, nil
}
// currentModel is a model as the catalog holds it now.
func (p *AccountPages) currentModel(ctx context.Context, l *Lang, name string) (catalog.Model, error) {
sources, err := p.catalog.Sources(ctx)
if err != nil {
return catalog.Model{}, err
}
for _, ms := range sources {
for _, m := range ms {
if m.Name == name {
return m, nil
}
}
}
return catalog.Model{}, errors.New(l.T("mirror.err.not_from_mirror", name))
}
// age renders seconds the way the replicas table does.
func age(sec int64) string { return english.Age(sec) }
// setModel applies the card's settings form: what a replica reserves, the
// model's class and owner, then how many replicas it runs — in that order, so
// the room checked for the replicas is the room its new class and reservation
// would get. Only what changed is applied, and each change is said.
func (p *AccountPages) setModel(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name := r.FormValue("model")
current, err := p.currentModel(ctx, l, name)
if err != nil {
return "", err
}
if p.catalog.Declared(name) {
return "", errors.New(l.T("mirror.err.declared", name))
}
var done []string
if r.Form.Has("memory") && len(current.Profiles) > 0 {
mem, err1 := strconv.Atoi(strings.TrimSpace(r.FormValue("memory")))
cores, err2 := strconv.Atoi(strings.TrimSpace(r.FormValue("cores")))
if err1 != nil || err2 != nil || mem < 0 || cores < 0 {
return "", errors.New(l.T("mirror.err.memory_whole"))
}
if cur := current.Profiles[0]; mem != cur.MinMemoryGB || cores != cur.CPUCores {
if _, err := p.catalog.Resources(ctx, name, "", mem, cores, admin.Email); err != nil {
return "", err
}
done = append(done, l.T("mirror.msg.reserves", mem, cores))
}
}
if r.Form.Has("class") {
if c := r.FormValue("class"); c != current.Class {
m, err := p.catalog.SetClass(ctx, name, c, admin.Email)
if err != nil {
return "", err
}
done = append(done, l.T("mirror.msg.is_class", l.T("mirror.class."+m.EffectiveClass())))
}
}
if r.Form.Has("owner") && p.capacity != nil {
if o := r.FormValue("owner"); o != current.Owner {
if _, err := p.catalog.SetOwner(ctx, name, o, admin.Email); err != nil {
return "", err
}
if o == "" {
done = append(done, l.T("mirror.msg.is_shared"))
} else {
done = append(done, l.T("mirror.msg.counts_against", o))
}
}
}
lo, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("min")))
hi, _ := strconv.Atoi(strings.TrimSpace(r.FormValue("max")))
if r.Form.Has("min") && (lo != current.Scaling.Min || hi != current.Scaling.Max) {
msg, err := p.setReplicas(ctx, admin, r)
if err != nil {
if len(done) > 0 {
return "", errors.New(l.T("mirror.err.partly_set", name, strings.Join(done, ", "), l.Err(err)))
}
return "", err
}
done = append(done, strings.TrimPrefix(msg, name+" "))
}
if len(done) == 0 {
return l.T("mirror.msg.nothing_changed", name), nil
}
return l.T("mirror.msg.changed", name, strings.Join(done, "; ")), nil
}
// heldKinds reads what each held single-file GGUF is — an adapter or a model —
// once per file, remembered after.
func (p *AccountPages) heldKinds(ctx context.Context, rows []mirrorRow) {
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i := range rows {
row := &rows[i]
if row.State != mirror.Present || row.Files != 1 || !gguf.IsGGUF(row.File) {
continue
}
if h, ok := p.kinds.Load(row.URL); ok {
row.Kind, row.Arch = kindName(h.(gguf.Header)), h.(gguf.Header).Architecture
continue
}
wg.Add(1)
go func() {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
cctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if h, err := kindOf(cctx, row.URL); err == nil {
p.kinds.Store(row.URL, h)
row.Kind, row.Arch = kindName(h), h.Architecture
}
}()
}
wg.Wait()
}
func kindName(h gguf.Header) string {
if h.IsAdapter() {
return "adapter"
}
return "model"
}
// adapterChoice is a held adapter file that could be attached to a model.
type adapterChoice struct {
URL, Label, Suggest string
}
// addAdapterChoices offers, on each model of a held model's card, the held
// adapter files of the same architecture, and a way to find more.
func (d *heldDetail) addAdapterChoices(row mirrorRow, rows []mirrorRow) {
for i := range d.Models {
hm := &d.Models[i]
hm.FindAdapters = findAdaptersHref(row.Source)
if row.Kind == "adapter" {
continue
}
for _, r := range rows {
if r.Kind != "adapter" || (row.Arch != "" && r.Arch != row.Arch) {
continue
}
attached := false
for _, a := range hm.Adapters {
attached = attached || a.Source == r.URL
}
if !attached {
hm.AdapterChoices = append(hm.AdapterChoices, adapterChoice{URL: r.URL,
Label: r.Model + " " + r.Version + " · " + r.File, Suggest: hm.Name + "-" + r.Model})
}
}
}
}
// findAdaptersHref is the explorer's list of adapters for a held model: those
// made for the model its repository was made from, when it came from the
// registry, and every adapter otherwise.
func findAdaptersHref(source string) template.URL {
const base = "/admin/mirror?tab=explore&view=adapters"
u, err := url.Parse(source)
if err != nil || u.Host != "huggingface.co" {
return base
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 {
return base
}
return template.URL(base + "&from=" + url.QueryEscape(parts[0]+"/"+parts[1]))
}
// setAdapters attaches or detaches one adapter on a model, from its card.
// An adapter is checked before it is attached: a file that is not an adapter,
// or one made for another architecture, is a replica that fails to start.
func (p *AccountPages) setAdapters(ctx context.Context, admin store.User, r *http.Request) (string, error) {
l := langOf(r)
name := r.FormValue("model")
current, err := p.currentModel(ctx, l, name)
if err != nil {
return "", err
}
adapters := slices.Clone(current.Adapters)
switch r.FormValue("op") {
case "remove":
gone := r.FormValue("name")
adapters = slices.DeleteFunc(adapters, func(a catalog.Adapter) bool { return a.Name == gone })
if len(adapters) == len(current.Adapters) {
return "", errors.New(l.T("mirror.err.no_adapter", name, gone))
}
if _, err := p.catalog.SetAdapters(ctx, name, adapters, admin.Email); err != nil {
return "", err
}
return l.T("mirror.msg.adapter_removed", name, gone), nil
case "add":
an := strings.TrimSpace(r.FormValue("name"))
src := strings.TrimSpace(r.FormValue("source"))
scale := 1.0
if v := strings.TrimSpace(r.FormValue("scale")); v != "" {
if scale, err = strconv.ParseFloat(v, 64); err != nil || scale <= 0 {
return "", errors.New(l.T("mirror.err.scale"))
}
}
if an == "" || src == "" {
return "", errors.New(l.T("mirror.err.adapter_name_file"))
}
h, err := kindOf(ctx, src)
if err != nil {
return "", errors.New(l.T("mirror.err.adapter_unreadable", l.Err(err)))
}
if !h.IsAdapter() {
return "", errors.New(l.T("mirror.err.not_adapter"))
}
if base, err := kindOf(ctx, current.Source); err == nil && base.Architecture != h.Architecture {
return "", errors.New(l.T("mirror.err.wrong_arch", h.Architecture, name, base.Architecture))
}
adapters = append(adapters, catalog.Adapter{Name: an, Source: src, Scale: scale})
if _, err := p.catalog.SetAdapters(ctx, name, adapters, admin.Email); err != nil {
return "", err
}
return l.T("mirror.msg.adapter_added", name, an), nil
}
return "", errors.New(l.T("mirror.err.adapter_op"))
}