Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c22c3a309b | ||
|
|
42fa602247 | ||
|
|
5501f77805 | ||
|
|
75d69a6b9c |
No files matched your search
@@ -19,6 +19,32 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## v0.9.2 — 2026-09-28
|
||||
|
||||
### A new control plane serves a model by itself
|
||||
|
||||
`llmgw server init` now also runs a model mirror on the same machine, makes the
|
||||
machine a node of its own control plane, and fetches Qwen2.5 0.5B into the
|
||||
mirror and serves it there — on the machine's NVIDIA GPU when `nvidia-smi` finds
|
||||
one (the node installs the CUDA build of llama.cpp), on CPU otherwise, with a
|
||||
CPU profile as the fallback. An NVIDIA card on the PCI bus with no working
|
||||
driver is reported. `--no-mirror`, `--no-node` and `--no-model` leave these out,
|
||||
and every step is skipped on a rerun when it is already done.
|
||||
|
||||
Not yet run end to end on a real machine.
|
||||
|
||||
## v0.9.1 — 2026-09-28
|
||||
|
||||
### The mirror page is one line per model
|
||||
|
||||
What the mirror holds used to take two table rows per file — seven columns and
|
||||
the address under them — so a sharded model filled the screen. Now each version
|
||||
is one line: model and version, the file (or "4 files" for a set), total size,
|
||||
and which catalog models use it. The address moved into a "copy address" button;
|
||||
the checksum is in the file's tooltip. A version already in use is not offered
|
||||
for serving again, and removing one warns that the catalog uses it. The header
|
||||
counts versions rather than files.
|
||||
|
||||
## v0.9.0 — 2026-09-28
|
||||
|
||||
### Managing people and the tenancy tree
|
||||
|
||||
@@ -104,6 +104,17 @@ That is the whole step. In order, it:
|
||||
and promoted to administrator — the password is generated and **printed
|
||||
once**
|
||||
6. installs the systemd unit, starts it, and waits until `/health` answers
|
||||
7. runs a model mirror beside it (`/srv/models`, bytes on `:8081`, its
|
||||
management API on loopback) and points the control plane at it
|
||||
8. makes this machine a node of its own control plane — on its GPUs when
|
||||
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
|
||||
engine
|
||||
9. fetches Qwen2.5 0.5B into the mirror and serves it on this node, so the
|
||||
first thing anyone opens `/chat` to find is a model that answers
|
||||
|
||||
`--no-mirror`, `--no-node` and `--no-model` leave the last three out. An NVIDIA
|
||||
card with no working driver is reported rather than silently ignored; install
|
||||
the driver and run init again to serve on it.
|
||||
|
||||
Add `--postgres` to keep accounts and keys in PostgreSQL rather than SQLite:
|
||||
init installs it and sets it up, and on a machine already running on SQLite it
|
||||
|
||||
@@ -69,6 +69,10 @@ type serverInit struct {
|
||||
// postgres keeps the store in a PostgreSQL on this machine, installed if
|
||||
// need be, moving whatever SQLite already holds.
|
||||
postgres bool
|
||||
// noMirror, noNode and noModel turn off the parts of a first install that
|
||||
// make it serve something: a mirror, this machine as a node, and a small
|
||||
// model on it.
|
||||
noMirror, noNode, noModel bool
|
||||
// pgSetup prepares PostgreSQL and returns the store address; a seam for
|
||||
// tests. Nil does it for real.
|
||||
pgSetup func(context.Context) (string, error)
|
||||
@@ -95,6 +99,11 @@ func newServerInitCmd() *cobra.Command {
|
||||
"control plane's certificate for every address this machine has, a\n" +
|
||||
"configuration, an administrator account, and the systemd unit — then\n" +
|
||||
"starts it and waits until it answers.\n\n" +
|
||||
"Then it makes the machine serve something: a model mirror beside the\n" +
|
||||
"control plane, this machine as a node of it (on its GPUs when nvidia-smi\n" +
|
||||
"finds any, on CPU otherwise), and " + starterName + " fetched into the\n" +
|
||||
"mirror and served on this node. --no-mirror, --no-node and --no-model\n" +
|
||||
"leave those out.\n\n" +
|
||||
"With --postgres the store is a PostgreSQL database on this machine:\n" +
|
||||
"installed if there is none, with its own role and a generated password\n" +
|
||||
"kept in /etc/llmgw/secrets. An existing SQLite store is copied into it,\n" +
|
||||
@@ -121,6 +130,9 @@ func newServerInitCmd() *cobra.Command {
|
||||
"an address agents dial besides the ones found on this machine; repeatable")
|
||||
f.StringVar(&in.admin, "admin", "", "email of the first administrator; a password is generated and printed once")
|
||||
f.StringVar(&in.pkiDir, "pki", in.pkiDir, "directory holding the certificate authority")
|
||||
f.BoolVar(&in.noMirror, "no-mirror", false, "do not run a model mirror on this machine")
|
||||
f.BoolVar(&in.noNode, "no-node", false, "do not make this machine a node (and serve no model on it)")
|
||||
f.BoolVar(&in.noModel, "no-model", false, "do not fetch and serve the starter model ("+starterName+")")
|
||||
f.BoolVar(&in.postgres, "postgres", false,
|
||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
||||
return cmd
|
||||
@@ -146,9 +158,33 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
if in.unitPath == "" {
|
||||
return nil
|
||||
}
|
||||
hasSystemd := true
|
||||
if _, err := exec.LookPath("systemctl"); err != nil {
|
||||
hasSystemd = false
|
||||
}
|
||||
// Before the service starts, because the mirror goes into server.yaml.
|
||||
if hasSystemd && !in.noMirror {
|
||||
if err := in.ensureMirror(ctx); err != nil {
|
||||
return fmt.Errorf("mirror: %w", err)
|
||||
}
|
||||
}
|
||||
if err := in.ensureService(ctx); err != nil {
|
||||
return fmt.Errorf("service: %w", err)
|
||||
}
|
||||
if hasSystemd && !in.noNode {
|
||||
if _, err := in.ensureNode(ctx); err != nil {
|
||||
return fmt.Errorf("node: %w", err)
|
||||
}
|
||||
switch {
|
||||
case in.noModel:
|
||||
case in.noMirror:
|
||||
fmt.Fprintln(in.out, " skip no starter model without the mirror")
|
||||
default:
|
||||
if err := in.ensureStarter(ctx); err != nil {
|
||||
return fmt.Errorf("starter model: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
in.nextSteps()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||
)
|
||||
|
||||
// A control plane on its own serves nothing: it needs a node, weights, and a
|
||||
// model, and the install guide made each of those a separate afternoon. init
|
||||
// makes the machine all three — a mirror, a node, and a small model served on
|
||||
// it — so the first thing anyone sees is an answer. Each is skipped when it is
|
||||
// already there and can be turned off.
|
||||
|
||||
const (
|
||||
mirrorDir = "/srv/models"
|
||||
mirrorAPI = "127.0.0.1:8082"
|
||||
mirrorUnitPath = "/etc/systemd/system/llmgw-mirror.service"
|
||||
agentUnitPath = "/etc/systemd/system/llmgw-agent.service"
|
||||
|
||||
// The starter: small enough to download in a minute and run on any CPU,
|
||||
// real enough to answer a question. Q4_K_M is about 400 MB.
|
||||
starterName = "qwen2.5-0.5b-instruct"
|
||||
starterAlias = "qwen-0.5b"
|
||||
starterSource = "hf:Qwen/Qwen2.5-0.5B-Instruct-GGUF/qwen2.5-0.5b-instruct-q4_k_m.gguf"
|
||||
starterVersion = "starter"
|
||||
|
||||
// localLabel marks the control plane's own node, so the starter model is
|
||||
// served where it was asked for and does not wander to the first GPU node
|
||||
// that joins later.
|
||||
localLabelKey = "llmgw.control-plane"
|
||||
localLabelValue = "true"
|
||||
)
|
||||
|
||||
// agentUnit is deploy/systemd/llmgw-agent.service; a test holds them together.
|
||||
const agentUnit = `[Unit]
|
||||
Description=llmgw node agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
||||
# Root, because the agent launches engines and needs device access. Engines
|
||||
# outlive a restart of this unit and are adopted again on the next start, so
|
||||
# KillMode must not take the whole process group with it.
|
||||
KillMode=process
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
StateDirectory=llmgw-agent
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
// reachableAddr is the address other machines use for this one: the first
|
||||
// name given with --host, else the first address it has, else its hostname.
|
||||
// It is in the server certificate either way.
|
||||
func (in *serverInit) reachableAddr() string {
|
||||
if len(in.hosts) > 0 {
|
||||
return in.hosts[0]
|
||||
}
|
||||
if ips := localAddrs(); len(ips) > 0 {
|
||||
return ips[0].String()
|
||||
}
|
||||
name, _ := os.Hostname()
|
||||
return name
|
||||
}
|
||||
|
||||
func (in *serverInit) systemctl(ctx context.Context, args ...string) error {
|
||||
out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl %s: %v: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUnit installs a unit when it differs from what is there, keeping
|
||||
// somebody's edited copy beside it, and says whether it changed.
|
||||
func (in *serverInit) writeUnit(path, body string) (bool, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil && string(b) == body {
|
||||
return false, nil
|
||||
}
|
||||
if err == nil {
|
||||
_ = os.WriteFile(path+".bak", b, 0o644)
|
||||
}
|
||||
return true, os.WriteFile(path, []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// --- the mirror ---
|
||||
|
||||
func (in *serverInit) mirrorUnit() string {
|
||||
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
|
||||
return `[Unit]
|
||||
Description=llmgw model mirror
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
# Written by 'llmgw server init'. Bytes on :8081 for every node; the
|
||||
# management API on loopback only, for the control plane beside it.
|
||||
ExecStart=/usr/local/bin/llmgw mirror serve --dir ` + mirrorDir + ` --listen :8081 --api-listen ` + mirrorAPI + ` --base ` + base + `
|
||||
User=llmgw
|
||||
Group=llmgw
|
||||
Restart=on-failure
|
||||
RestartSec=2s
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=` + mirrorDir + `
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
}
|
||||
|
||||
// ensureMirror runs a mirror on this machine and points the control plane at
|
||||
// it. Its management API is on loopback, which is why it needs no certificate:
|
||||
// nothing but this machine can reach it.
|
||||
func (in *serverInit) ensureMirror(ctx context.Context) error {
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI {
|
||||
in.kept("mirror at %s, as server.yaml says", cfg.Mirror.API)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.chown(mirrorDir, true); err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := in.writeUnit(mirrorUnitPath, in.mirrorUnit())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.systemctl(ctx, "enable", "--now", "llmgw-mirror"); err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("model mirror in %s, serving on :8081", mirrorDir)
|
||||
} else {
|
||||
in.kept("model mirror in %s", mirrorDir)
|
||||
}
|
||||
|
||||
if cfg.Mirror.API == "" {
|
||||
if err := appendMirrorBlock(in.configPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("server.yaml points at the mirror")
|
||||
}
|
||||
|
||||
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return waitFor(ctx, 30*time.Second, "the mirror to answer", func() (bool, error) {
|
||||
return m.Health(ctx) == nil, nil
|
||||
})
|
||||
}
|
||||
|
||||
// appendMirrorBlock adds the mirror to server.yaml without touching the rest,
|
||||
// keeping the file as it was beside it.
|
||||
func appendMirrorBlock(path string) error {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backup := fmt.Sprintf("%s.bak-%s", path, time.Now().Format("20060102-150405"))
|
||||
if err := os.WriteFile(backup, b, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
block := "\n# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
|
||||
"# management API is on loopback, so it needs no certificate.\n" +
|
||||
"mirror:\n api: http://" + mirrorAPI + "\n"
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, []byte(block)...), info.Mode().Perm())
|
||||
}
|
||||
|
||||
// --- this machine as a node ---
|
||||
|
||||
// gpuClasses asks nvidia-smi what cards this machine has. None is an answer,
|
||||
// not an error: the node then serves on CPU.
|
||||
func gpuClasses(ctx context.Context) ([]string, int) {
|
||||
gpus, err := discovery.DiscoverGPUsSMI(ctx, "")
|
||||
if err != nil || len(gpus) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
return discovery.Inventory{GPUs: gpus}.Classes(), len(gpus)
|
||||
}
|
||||
|
||||
// ensureNode makes this machine an agent of its own control plane.
|
||||
func (in *serverInit) ensureNode(ctx context.Context) (string, error) {
|
||||
nodeID, _ := os.Hostname()
|
||||
if nodeID == "" {
|
||||
return "", errors.New("this machine has no hostname to use as its node id")
|
||||
}
|
||||
|
||||
if classes, n := gpuClasses(ctx); n > 0 {
|
||||
in.kept("%d NVIDIA GPU(s) here: %s — the node will use them", n, strings.Join(classes, ", "))
|
||||
} else if hasNVIDIADevice() {
|
||||
fmt.Fprintf(in.out, " !! an NVIDIA card is on the PCI bus but nvidia-smi does not answer: "+
|
||||
"install the driver and run init again to serve on it; until then this node is CPU only\n")
|
||||
} else {
|
||||
in.kept("no NVIDIA GPU here; the node serves on CPU")
|
||||
}
|
||||
|
||||
certPath := filepath.Join(in.etcDir, "agent.pem")
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
in.kept("agent certificate for %s", nodeID)
|
||||
} else {
|
||||
if _, err := os.Stat(filepath.Join(in.pkiDir, "ca-key.pem")); err != nil {
|
||||
return "", fmt.Errorf("the authority is not on this machine, so init cannot give it a node " +
|
||||
"identity; onboard it from where the authority is, or pass --no-node")
|
||||
}
|
||||
ca, err := loadCA(in.pkiDir)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
leaf, err := ca.IssueClient(nodeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := leaf.Write(in.etcDir, "agent"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("agent certificate for %s", nodeID)
|
||||
}
|
||||
|
||||
agentConf := filepath.Join(in.etcDir, "agent.yaml")
|
||||
if _, err := os.Stat(agentConf); err == nil {
|
||||
in.kept("%s (never overwritten)", agentConf)
|
||||
} else {
|
||||
server := "https://" + net.JoinHostPort(in.reachableAddr(), "7443")
|
||||
body := fmt.Sprintf(`# This machine as a node of its own control plane, written by 'llmgw server init'.
|
||||
server: %q
|
||||
node_id: %q
|
||||
tls:
|
||||
ca: %s/ca.pem
|
||||
cert: %s/agent.pem
|
||||
key: %s/agent-key.pem
|
||||
labels:
|
||||
%s: %q
|
||||
`, server, nodeID, in.etcDir, in.etcDir, in.etcDir, localLabelKey, localLabelValue)
|
||||
if err := os.WriteFile(agentConf, []byte(body), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.done("%s", agentConf)
|
||||
}
|
||||
|
||||
changed, err := in.writeUnit(agentUnitPath, agentUnit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := in.systemctl(ctx, "enable", "--now", "llmgw-agent"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if changed {
|
||||
_ = in.systemctl(ctx, "restart", "llmgw-agent")
|
||||
in.done("llmgw-agent started")
|
||||
}
|
||||
|
||||
// Registered is quick. Having an engine is not: the node downloads and
|
||||
// checks its own llama.cpp, the CUDA build when it has a GPU, and a new
|
||||
// machine is often busy with its own package updates first.
|
||||
client := in.adminAPI()
|
||||
last := ""
|
||||
err = waitFor(ctx, 20*time.Minute, "this node to be ready with an engine", func() (bool, error) {
|
||||
var nodes []adminapi.NodeView
|
||||
if err := client.get(ctx, "/api/v1/nodes", &nodes); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if n.ID != nodeID {
|
||||
continue
|
||||
}
|
||||
if n.State == "ready" && slices.Contains(n.Engines, "llamacpp") {
|
||||
return true, nil
|
||||
}
|
||||
for _, p := range n.Provision {
|
||||
if line := fmt.Sprintf("%s %s: %s %s", p.Engine, p.Version, p.State, p.Detail); line != last {
|
||||
fmt.Fprintf(in.out, " .. %s\n", strings.TrimSpace(line))
|
||||
last = line
|
||||
}
|
||||
if p.State == "failed" || p.State == "unsupported" {
|
||||
return false, fmt.Errorf("installing %s on this node: %s", p.Engine, p.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
in.kept("node %s is ready", nodeID)
|
||||
return nodeID, nil
|
||||
}
|
||||
|
||||
// hasNVIDIADevice reports an NVIDIA card on the PCI bus, driver or not, so a
|
||||
// machine whose GPU is invisible for want of a driver says so instead of
|
||||
// quietly serving on CPU.
|
||||
func hasNVIDIADevice() bool {
|
||||
vendors, _ := filepath.Glob("/sys/bus/pci/devices/*/vendor")
|
||||
for _, v := range vendors {
|
||||
b, err := os.ReadFile(v)
|
||||
if err == nil && strings.TrimSpace(string(b)) == "0x10de" {
|
||||
class, _ := os.ReadFile(filepath.Join(filepath.Dir(v), "class"))
|
||||
// 0x03xxxx is a display or 3D controller; NVIDIA also makes bridges.
|
||||
if strings.HasPrefix(strings.TrimSpace(string(class)), "0x03") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- the starter model ---
|
||||
|
||||
// starterModel is the catalog entry: a GPU profile first when there is a card,
|
||||
// and a CPU profile always, both pinned to this machine.
|
||||
func starterModel(source string, gpuClasses []string) catalog.Model {
|
||||
pin := map[string]string{localLabelKey: localLabelValue}
|
||||
var profiles []catalog.Profile
|
||||
for _, class := range gpuClasses {
|
||||
profiles = append(profiles, catalog.Profile{
|
||||
Name: "gpu-" + class, Engine: "llamacpp", GPUClass: class,
|
||||
Priority: 200, MinMemoryGB: 2, NodeSelector: pin,
|
||||
Args: []string{"--ctx-size", "32768"},
|
||||
})
|
||||
}
|
||||
profiles = append(profiles, catalog.Profile{
|
||||
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2, NodeSelector: pin,
|
||||
Args: []string{"--ctx-size", "16384"},
|
||||
})
|
||||
return catalog.Model{
|
||||
Name: starterName,
|
||||
Aliases: []string{starterAlias},
|
||||
Description: "A small model init put here so there is something to try; fast, not clever.",
|
||||
Source: source,
|
||||
RoutingProfile: "chat",
|
||||
ContextLength: 4096,
|
||||
MaxOutput: 1024,
|
||||
Scaling: catalog.Scaling{Min: 1, Max: 1},
|
||||
Profiles: profiles,
|
||||
}
|
||||
}
|
||||
|
||||
// ensureStarter fetches the starter model into the mirror and serves it here.
|
||||
func (in *serverInit) ensureStarter(ctx context.Context) error {
|
||||
client := in.adminAPI()
|
||||
var models []adminapi.ModelView
|
||||
if err := client.get(ctx, "/api/v1/models", &models); err != nil {
|
||||
return fmt.Errorf("reading the catalog: %w", err)
|
||||
}
|
||||
for _, m := range models {
|
||||
if m.Name == starterName {
|
||||
in.kept("%s in the catalog", starterName)
|
||||
return in.waitForStarter(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
upstream, ok := sourcecheck.HTTPSource(starterSource)
|
||||
if !ok {
|
||||
return fmt.Errorf("cannot turn %s into a download", starterSource)
|
||||
}
|
||||
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 15*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := m.Status(ctx, starterName, starterVersion)
|
||||
var apiErr *mirror.APIError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &apiErr) && apiErr.Status == 404:
|
||||
if st, err = m.Fetch(ctx, mirror.FetchRequest{
|
||||
Source: upstream, Model: starterName, Version: starterVersion,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("asking the mirror for %s: %w", starterName, err)
|
||||
}
|
||||
fmt.Fprintf(in.out, " .. downloading %s into the mirror\n", starterName)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
|
||||
lastPct := -10
|
||||
err = waitFor(ctx, 30*time.Minute, starterName+" to download", func() (bool, error) {
|
||||
cur, err := m.Status(ctx, starterName, starterVersion)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
st = cur
|
||||
switch cur.Status.State {
|
||||
case mirror.Present:
|
||||
return true, nil
|
||||
case mirror.Failed:
|
||||
return false, fmt.Errorf("the mirror could not download %s: %s", starterName, cur.Status.Err)
|
||||
}
|
||||
if pct, ok := cur.Status.Percent(); ok && pct >= lastPct+10 {
|
||||
fmt.Fprintf(in.out, " .. %d%%\n", pct)
|
||||
lastPct = pct
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("%s in the mirror", starterName)
|
||||
|
||||
classes, _ := gpuClasses(ctx)
|
||||
body, err := json.Marshal(starterModel(st.URL, classes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.post(ctx, "/api/v1/models", body, &map[string]string{}); err != nil {
|
||||
return fmt.Errorf("adding %s to the catalog: %w", starterName, err)
|
||||
}
|
||||
where := "on CPU"
|
||||
if len(classes) > 0 {
|
||||
where = "on " + strings.Join(classes, ", ") + ", or CPU if the card is busy"
|
||||
}
|
||||
in.done("%s in the catalog, served here %s", starterName, where)
|
||||
return in.waitForStarter(ctx)
|
||||
}
|
||||
|
||||
func (in *serverInit) waitForStarter(ctx context.Context) error {
|
||||
client := in.adminAPI()
|
||||
last := ""
|
||||
return waitFor(ctx, 10*time.Minute, starterName+" to start", func() (bool, error) {
|
||||
var reps []adminapi.ReplicaView
|
||||
if err := client.get(ctx, "/api/v1/replicas?model="+starterName, &reps); err != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, r := range reps {
|
||||
if r.State == "ready" {
|
||||
in.kept("%s is serving (profile %s)", starterName, r.Profile)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
for _, r := range reps {
|
||||
line := r.State
|
||||
if r.Err != "" {
|
||||
line += ": " + r.Err
|
||||
}
|
||||
if line != last {
|
||||
fmt.Fprintf(in.out, " .. %s %s\n", starterName, line)
|
||||
last = line
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (in *serverInit) adminAPI() *ctlClient {
|
||||
addr := "127.0.0.1:9090"
|
||||
if cfg, err := config.Load(in.configPath()); err == nil && cfg.AdminListen != "" {
|
||||
addr = cfg.AdminListen
|
||||
}
|
||||
return newCtlClient(addr)
|
||||
}
|
||||
|
||||
// waitFor polls until done says so, fails, or the time runs out.
|
||||
func waitFor(ctx context.Context, limit time.Duration, what string, done func() (bool, error)) error {
|
||||
deadline := time.Now().Add(limit)
|
||||
for {
|
||||
ok, err := done()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("gave up waiting for %s after %s; run init again to keep waiting", what, limit)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
func TestTheInitAgentUnitIsTheShippedUnit(t *testing.T) {
|
||||
b, err := os.ReadFile("../../deploy/systemd/llmgw-agent.service")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(b) != agentUnit {
|
||||
t.Error("deploy/systemd/llmgw-agent.service and the unit 'server init' writes have drifted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
|
||||
m := starterModel("http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"})
|
||||
c, err := catalog.New([]catalog.Model{m})
|
||||
if err != nil {
|
||||
t.Fatalf("the starter is not a valid catalog entry: %v", err)
|
||||
}
|
||||
got, _ := c.Resolve(starterName)
|
||||
if len(got.Profiles) != 2 {
|
||||
t.Fatalf("profiles = %+v", got.Profiles)
|
||||
}
|
||||
gpu, cpu := got.Profiles[0], got.Profiles[1]
|
||||
if gpu.GPUClass != "4090-24g" || gpu.Priority <= cpu.Priority {
|
||||
t.Errorf("the GPU profile should come first: %+v", gpu)
|
||||
}
|
||||
if cpu.GPUClass != "" || cpu.Engine != "llamacpp" {
|
||||
t.Errorf("the fallback should be llama.cpp on CPU: %+v", cpu)
|
||||
}
|
||||
for _, p := range got.Profiles {
|
||||
if p.NodeSelector[localLabelKey] != localLabelValue {
|
||||
t.Errorf("profile %s is not pinned to this machine", p.Name)
|
||||
}
|
||||
}
|
||||
if got.Scaling.Min != 1 {
|
||||
t.Error("the starter should be running, not waiting to be asked for")
|
||||
}
|
||||
|
||||
if m := starterModel("http://x/q.gguf", nil); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
|
||||
t.Errorf("without a GPU only the CPU profile: %+v", m.Profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMirrorBlockIsAddedAndReads(t *testing.T) {
|
||||
in, out := newTestInit(t)
|
||||
if err := in.run(t.Context()); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if err := appendMirrorBlock(in.configPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.Load(in.configPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Mirror.API != "http://"+mirrorAPI {
|
||||
t.Errorf("mirror.api = %q", cfg.Mirror.API)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
backups, _ := filepath.Glob(in.configPath() + ".bak-*")
|
||||
if len(backups) != 1 {
|
||||
t.Errorf("backups = %v", backups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheMirrorUnitAdvertisesAnAddressNodesCanReach(t *testing.T) {
|
||||
in := &serverInit{hosts: []string{"10.0.0.140"}}
|
||||
u := in.mirrorUnit()
|
||||
if !strings.Contains(u, "--base http://10.0.0.140:8081") {
|
||||
t.Errorf("base: %s", u)
|
||||
}
|
||||
// The management port can delete models; nothing but this machine may
|
||||
// reach it, which is what lets it go without a certificate.
|
||||
if !strings.Contains(u, "--api-listen 127.0.0.1:8082") {
|
||||
t.Errorf("the management API is not on loopback: %s", u)
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,22 @@ func (s *Service) Declared(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Sources maps each weights source in the catalog to the models that name it,
|
||||
// so a page listing files can say which of them are in use.
|
||||
func (s *Service) Sources(ctx context.Context) (map[string][]string, error) {
|
||||
c, err := s.Load(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string][]string{}
|
||||
for _, m := range c.Models() {
|
||||
if m.Source != "" {
|
||||
out[m.Source] = append(out[m.Source], m.Name)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Add puts a model in the catalog and keeps it across restarts.
|
||||
//
|
||||
// Validated by building the whole catalog before anything is written. A model
|
||||
|
||||
@@ -70,6 +70,14 @@ type mirrorRow struct {
|
||||
// then not drawn at all: a progress bar that has to invent its own total is
|
||||
// worse than a byte count that only goes up.
|
||||
HasPercent bool
|
||||
|
||||
// Files is how many files a held version has. A version of several is one
|
||||
// row — a fifteen-shard model is one thing to serve or remove, and fifteen
|
||||
// rows of it pushed everything else off the page.
|
||||
Files int
|
||||
// UsedBy names the catalog models whose source is this row's address, so
|
||||
// a file that is in use does not look like one waiting to be served.
|
||||
UsedBy []string
|
||||
}
|
||||
|
||||
type adminMirrorData struct {
|
||||
@@ -276,25 +284,46 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
|
||||
d.Base = got.Base
|
||||
var sources map[string][]string
|
||||
if p.catalog != nil {
|
||||
if sources, err = p.catalog.Sources(r.Context()); err != nil {
|
||||
p.log.Warn("could not read which models use the mirror", "err", err)
|
||||
}
|
||||
}
|
||||
held := map[string]int{} // model/version -> index of its row
|
||||
for _, st := range got.Models {
|
||||
row := mirrorRow{
|
||||
Status: st,
|
||||
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
|
||||
URL(got.Base),
|
||||
}
|
||||
if pc, ok := st.Percent(); ok {
|
||||
row.Percent, row.HasPercent = pc, true
|
||||
}
|
||||
switch st.State {
|
||||
case mirror.Fetching:
|
||||
d.Fetching++
|
||||
case mirror.Failed:
|
||||
d.Failed++
|
||||
default:
|
||||
d.Present++
|
||||
key := st.Model + "/" + st.Version
|
||||
if i, ok := held[key]; ok {
|
||||
// Another file of a version already shown: counted into it.
|
||||
d.Rows[i].Files++
|
||||
d.Rows[i].BytesDone += st.BytesDone
|
||||
d.Rows[i].URL = strings.TrimSuffix(
|
||||
mirror.Entry{Model: st.Model, Version: st.Version}.URL(got.Base), "/") + "/"
|
||||
continue
|
||||
}
|
||||
held[key] = len(d.Rows)
|
||||
d.Present++ // versions, not files: what the rows below count
|
||||
}
|
||||
row := mirrorRow{
|
||||
Status: st,
|
||||
Files: 1,
|
||||
URL: mirror.Entry{Model: st.Model, Version: st.Version, File: st.File}.
|
||||
URL(got.Base),
|
||||
}
|
||||
if pc, ok := st.Percent(); ok {
|
||||
row.Percent, row.HasPercent = pc, true
|
||||
}
|
||||
d.Rows = append(d.Rows, row)
|
||||
}
|
||||
for i := range d.Rows {
|
||||
d.Rows[i].UsedBy = sources[d.Rows[i].URL]
|
||||
}
|
||||
|
||||
// The one row whose serve form is open, and only when the catalog can be
|
||||
// changed at all.
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestAFailedRowOffersRetryRatherThanRemove(t *testing.T) {
|
||||
if strings.Contains(body, "/admin/mirror/m/v/remove") {
|
||||
t.Error("a failed row should not offer a remove that can only 404")
|
||||
}
|
||||
if !strings.Contains(body, "Retry") {
|
||||
if !strings.Contains(body, ">retry<") {
|
||||
t.Error("a failed row should offer to start the download again")
|
||||
}
|
||||
if !strings.Contains(body, `value="http://upstream/model.gguf"`) {
|
||||
@@ -535,3 +535,35 @@ func TestAlternativeQuantisationsStaySeparateRows(t *testing.T) {
|
||||
t.Errorf("expected a form each plus the manual one, found %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHeldVersionIsOneLineAndSaysWhoUsesIt(t *testing.T) {
|
||||
m := &fakeMirror{models: []mirror.Status{
|
||||
{Model: "big", Version: "v1", File: "a-00001-of-00002.gguf", State: mirror.Present, BytesDone: 1 << 30},
|
||||
{Model: "big", Version: "v1", File: "a-00002-of-00002.gguf", State: mirror.Present, BytesDone: 1 << 30},
|
||||
{Model: "small", Version: "v1", File: "s.gguf", State: mirror.Present, BytesDone: 1 << 20},
|
||||
}}
|
||||
cat := &fakeCatalog{sources: map[string][]string{"http://mirror:8081/small/v1/s.gguf": {"small-chat"}}}
|
||||
body := mirrorGet(t, mirrorPageDeps(t, MirrorDeps{Mirror: m, Catalog: cat}, true),
|
||||
"/admin/mirror").Body.String()
|
||||
|
||||
if n := strings.Count(body, "/admin/mirror/big/v1/remove"); n != 1 {
|
||||
t.Errorf("a two-file version should be one row, found %d", n)
|
||||
}
|
||||
if !strings.Contains(body, "2 files") || !strings.Contains(body, "2.0 GiB") {
|
||||
t.Error("the row should count its files and add up their size")
|
||||
}
|
||||
if !strings.Contains(body, `data-url="http://mirror:8081/big/v1/"`) {
|
||||
t.Error("a set's address is its directory, trailing slash included")
|
||||
}
|
||||
if !strings.Contains(body, ">small-chat<") {
|
||||
t.Error("the model serving a file should be named on its row")
|
||||
}
|
||||
// Already served: offering to serve it again invites a second catalog
|
||||
// entry for the same weights.
|
||||
if strings.Contains(body, "serve=small:v1") {
|
||||
t.Error("a file in use should not be offered for serving")
|
||||
}
|
||||
if !strings.Contains(body, "serve=big:v1") {
|
||||
t.Error("a file nobody uses should be offered for serving")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
type CatalogAdmin interface {
|
||||
Add(ctx context.Context, m catalog.Model, by string) error
|
||||
Declared(name string) bool
|
||||
// Sources maps a weights source to the catalog models that use it.
|
||||
Sources(ctx context.Context) (map[string][]string, error)
|
||||
}
|
||||
|
||||
// serveForm is the offer to put one mirrored version into service.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
type fakeCatalog struct {
|
||||
added []catalog.Model
|
||||
declared string
|
||||
sources map[string][]string
|
||||
err error
|
||||
}
|
||||
|
||||
@@ -28,6 +29,9 @@ func (f *fakeCatalog) Add(_ context.Context, m catalog.Model, _ string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeCatalog) Declared(name string) bool { return name == f.declared }
|
||||
func (f *fakeCatalog) Sources(context.Context) (map[string][]string, error) {
|
||||
return f.sources, nil
|
||||
}
|
||||
|
||||
// fleet reports a cluster with a given number of cards and engines.
|
||||
type fakeFleet struct {
|
||||
|
||||
@@ -357,3 +357,8 @@ input.search { width: 100%; max-width: 26rem; margin: 0 0 .7rem; padding: .45rem
|
||||
font: inherit; border: 1px solid var(--line); border-radius: 6px;
|
||||
background: var(--card); color: var(--fg); }
|
||||
tr[hidden] { display: none; }
|
||||
|
||||
/* The mirror's list of what it holds: one line per version. */
|
||||
table.held td.file { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
table.held td.actions > * + * { margin-left: .7rem; }
|
||||
table.held .tag { margin: 0 .3rem 0 0; text-transform: none; letter-spacing: 0; }
|
||||
@@ -12,7 +12,7 @@
|
||||
{{if .Unreachable}}{{.Addr}} did not answer{{else}}{{.Present}} held ·
|
||||
{{.Fetching}} downloading · {{.Failed}} failed · {{.Addr}}{{end}}
|
||||
</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
|
||||
<a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
@@ -31,17 +31,15 @@ reachable from this machine.</p>
|
||||
|
||||
<h2>What the mirror holds</h2>
|
||||
{{if .Rows}}
|
||||
<table>
|
||||
<tr><th>Model</th><th>Version</th><th>File</th><th>State</th><th>Size</th>
|
||||
<th>Checksum</th><th></th></tr>
|
||||
<table class="compact held">
|
||||
<tr><th>Model</th><th>Files</th><th class="num">Size</th><th>In use</th><th></th></tr>
|
||||
{{range .Rows}}<tr>
|
||||
<td>{{.Model}}</td>
|
||||
<td>{{.Version}}</td>
|
||||
<td><code>{{.File}}</code></td>
|
||||
<td class="state state-{{.State}}">{{.State}}</td>
|
||||
<td><strong>{{.Model}}</strong> <span class="opt">{{.Version}}</span></td>
|
||||
<td class="file" title="{{.File}}{{if .SHA256}} · sha256 {{.SHA256}}{{end}}">
|
||||
{{if gt .Files 1}}{{.Files}} files{{else}}<code>{{.File}}</code>{{end}}</td>
|
||||
<td class="num">
|
||||
{{if eq (printf "%s" .State) "fetching"}}
|
||||
{{if .HasPercent}}{{bytes .BytesDone}} / {{bytes .BytesTotal}} ({{.Percent}}%)
|
||||
{{if .HasPercent}}{{.Percent}}% of {{bytes .BytesTotal}}
|
||||
{{else}}{{/* No total came from upstream, so no percentage is invented —
|
||||
a bar that guesses its own end and then stops moving is worse
|
||||
than a number that only goes up. */}}
|
||||
@@ -49,11 +47,14 @@ reachable from this machine.</p>
|
||||
{{else if eq (printf "%s" .State) "failed"}}—
|
||||
{{else}}{{bytes .BytesDone}}{{end}}
|
||||
</td>
|
||||
<td>{{if .SHA256}}<code title="{{.SHA256}}">{{slice .SHA256 0 12}}</code>{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if eq (printf "%s" .State) "fetching"}}
|
||||
<span class="note">downloading</span>
|
||||
{{else if eq (printf "%s" .State) "failed"}}
|
||||
{{if eq (printf "%s" .State) "fetching"}}<span class="state state-starting">downloading</span>
|
||||
{{else if eq (printf "%s" .State) "failed"}}<span class="state state-failed">failed</span>
|
||||
{{else if .UsedBy}}{{range .UsedBy}}<span class="tag">{{.}}</span>{{end}}
|
||||
{{else}}<span class="opt">not served</span>{{end}}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{{if eq (printf "%s" .State) "failed"}}
|
||||
{{/* Retry, not remove. A fetch that failed left nothing on disk, so the
|
||||
remove would come back "the mirror holds nothing for this" — a button
|
||||
whose only outcome is a confusing error. Starting the same download
|
||||
@@ -65,25 +66,24 @@ reachable from this machine.</p>
|
||||
<input type="hidden" name="model" value="{{.Model}}">
|
||||
<input type="hidden" name="version" value="{{.Version}}">
|
||||
<input type="hidden" name="file" value="{{.File}}">
|
||||
<button type="submit" class="link">Retry</button>
|
||||
<button type="submit" class="link">retry</button>
|
||||
</form>
|
||||
{{else}}
|
||||
{{if $.HasCatalog}}{{if .Serve}}<a href="/admin/mirror">close</a>
|
||||
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}
|
||||
{{end}}
|
||||
{{else if eq (printf "%s" .State) "present"}}
|
||||
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">copy address</button>
|
||||
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">close</a>
|
||||
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">serve</a>{{end}}{{end}}
|
||||
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
|
||||
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?')">
|
||||
class="inline" onsubmit="return confirm('Remove {{.Model}}/{{.Version}} and every file in it?{{if .UsedBy}} It is in use by the catalog.{{end}}')">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button type="submit" class="link danger">Remove</button>
|
||||
<button type="submit" class="link danger">remove</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{if .Err}}<tr><td colspan="7" class="error-row">{{.Err}}</td></tr>{{end}}
|
||||
{{if not .Err}}<tr><td colspan="7" class="url-row"><code>{{.URL}}</code></td></tr>{{end}}
|
||||
{{if .Err}}<tr><td colspan="5" class="error-row">{{.Err}}</td></tr>{{end}}
|
||||
|
||||
{{with .Serve}}
|
||||
<tr class="editing"><td colspan="7">
|
||||
<tr class="editing"><td colspan="5">
|
||||
{{if .Refusal}}
|
||||
{{/* Offered and then failing at placement is worse than not offered at all. */}}
|
||||
<p class="error">Cannot be served as it stands: {{.Refusal}}.</p>
|
||||
@@ -140,10 +140,22 @@ job by name if you would rather pin it.</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</table>
|
||||
<p class="note">The address under each row is what a catalog entry should say.
|
||||
It is rendered here rather than assembled by hand because the mirror and the
|
||||
catalog have to spell the same file the same way — two spellings is what makes a
|
||||
file that is in use look unreferenced.</p>
|
||||
<p class="note">“copy address” gives what a catalog entry's <code>source</code>
|
||||
should say — the file, or for a version of several files the directory, with its
|
||||
trailing slash. It is rendered rather than assembled by hand because the mirror
|
||||
and the catalog have to spell a file the same way, which is also how this page
|
||||
knows which models use it.</p>
|
||||
<script>
|
||||
document.addEventListener('click', function (e) {
|
||||
var b = e.target.closest('.copy-url');
|
||||
if (!b) return;
|
||||
var done = function (t) { var o = b.textContent; b.textContent = t; setTimeout(function () { b.textContent = o; }, 1500); };
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(b.dataset.url).then(function () { done('copied'); },
|
||||
function () { window.prompt('Address', b.dataset.url); });
|
||||
} else { window.prompt('Address', b.dataset.url); }
|
||||
});
|
||||
</script>
|
||||
{{else}}<p class="empty">The mirror is reachable and holds nothing yet.</p>{{end}}
|
||||
|
||||
{{if .HasRegistry}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<header>
|
||||
<h1>Quotas</h1>
|
||||
<p class="sub">What each organization, team and project may use · spend over the last {{.Since}}</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
|
||||
<a href="/admin/usage">usage</a> <a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
@@ -89,7 +89,7 @@ unaffected — they count requests and tokens, not money. Set prices in the
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="empty">Nothing to give a quota to yet. Make an organization on the
|
||||
<a href="/admin/users">accounts page</a> first.</p>
|
||||
<a href="/admin/users">people page</a> first.</p>
|
||||
{{end}}
|
||||
|
||||
<p class="note">Limits compose downward by taking the most restrictive value, so
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<header>
|
||||
<h1>Usage over time</h1>
|
||||
<p class="sub">Who is using the cluster, and when</p>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">accounts</a>
|
||||
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
|
||||
<a href="/admin/quotas">quotas</a> <a href="/profile">your keys</a></nav>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<a href="/debug/pprof/">pprof</a>
|
||||
<a href="/api/v1/nodes">api</a>
|
||||
{{else}}
|
||||
<a href="/admin/users">accounts</a>
|
||||
<a href="/admin/users">people</a>
|
||||
<a href="/admin/usage">usage</a>
|
||||
{{if .Nav.Mirror}} <a href="/admin/mirror">mirror</a>
|
||||
{{end}}
|
||||
|
||||
Reference in new issue
Block a user