Each held file took two table rows — seven columns and its address beneath — so one sharded model filled the screen. A version is now one line: model and version, the file or "N files", total size, and the catalog models that use it. The address is behind a "copy address" button (a set's is its directory, trailing slash included) and the checksum is in the file's tooltip. The model service reports which models name each source, so a file in use shows who uses it and is not offered for serving a second time; removing one warns that the catalog uses it. Downloads and failures stay one line per file, since each has its own progress or retry. The header counts versions, not files, and the nav calls /admin/users "people" everywhere. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
255 lines
12 KiB
HTML
255 lines
12 KiB
HTML
{{define "admin_mirror"}}<!doctype html>
|
|
<html lang="en"><head>
|
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
{{/* Only while something is downloading. A reload at any other time would
|
|
take with it whatever the operator was typing into the form below. */}}
|
|
{{if .RefreshSec}}<meta http-equiv="refresh" content="{{.RefreshSec}}">{{end}}
|
|
<title>Mirror · llmgw</title><link rel="stylesheet" href="/_static/base.css">
|
|
</head><body><div class="wrap">
|
|
<header>
|
|
<h1>Model mirror</h1>
|
|
<p class="sub">
|
|
{{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">people</a>
|
|
<a href="/profile">your keys</a></nav>
|
|
</header>
|
|
|
|
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
|
|
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
|
|
|
|
{{if .Unreachable}}
|
|
{{/* Said outright rather than shown as an empty table. A mirror holding
|
|
nothing and a mirror that is not there look the same in a list of zero
|
|
rows, and they call for opposite responses. */}}
|
|
<p class="note">Nothing was read from the mirror, so this page is not a list of
|
|
what it holds — it is not currently reachable at <code>{{.Addr}}</code>. Check
|
|
that <code>llmgw-mirror</code> is running there and that its management port is
|
|
reachable from this machine.</p>
|
|
{{else}}
|
|
|
|
<h2>What the mirror holds</h2>
|
|
{{if .Rows}}
|
|
<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><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}}{{.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. */}}
|
|
{{bytes .BytesDone}} so far{{end}}
|
|
{{else if eq (printf "%s" .State) "failed"}}—
|
|
{{else}}{{bytes .BytesDone}}{{end}}
|
|
</td>
|
|
<td>
|
|
{{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
|
|
again is what somebody looking at a failed row wants, and the mirror
|
|
allows it: only a download still running is refused. */}}
|
|
<form method="post" action="/admin/mirror/fetch" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="source" value="{{.Source}}">
|
|
<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>
|
|
</form>
|
|
{{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?{{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>
|
|
</form>
|
|
{{end}}
|
|
</td>
|
|
</tr>
|
|
{{if .Err}}<tr><td colspan="5" class="error-row">{{.Err}}</td></tr>{{end}}
|
|
|
|
{{with .Serve}}
|
|
<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>
|
|
{{else}}
|
|
{{if .Warning}}<p class="note error-row">{{.Warning}}.</p>{{end}}
|
|
<form method="post" action="/admin/mirror/serve">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
<input type="hidden" name="source" value="{{.Source}}">
|
|
<input type="hidden" name="engine" value="{{.Engine}}">
|
|
<p class="sub">Serving <code>{{.Source}}</code> with <strong>{{.Engine}}</strong>.
|
|
{{if .Files}}The whole version, {{.Files}} files: the node fetches them into one
|
|
directory and the engine is given the directory.{{end}}</p>
|
|
<div class="field-row">
|
|
<label>Name <input type="text" name="name" value="{{.Model}}" required></label>
|
|
<label>Aliases <span class="opt">comma separated</span>
|
|
<input type="text" name="aliases" placeholder="coder-7b"></label>
|
|
<label>Replicas <input type="text" name="replicas" value="1" required></label>
|
|
</div>
|
|
{{/* Reaches clients through /v1/models, which for most of them is the only
|
|
view of what this gateway serves. Nine names and nothing to choose
|
|
between them is a choice made by guesswork. */}}
|
|
<label>What it is for <span class="opt">shown to clients in /v1/models</span>
|
|
<input type="text" name="description"
|
|
placeholder="general chat, strong Swedish; 14B, slower than the 8B"></label>
|
|
<div class="field-row">
|
|
<label>Context a request may use
|
|
<input type="text" name="context_length" value="4096" required></label>
|
|
<label>Output limit <span class="opt">optional</span>
|
|
<input type="text" name="max_output" value="1024"></label>
|
|
<label>Engine context pool <span class="opt">divided across slots</span>
|
|
<input type="text" name="ctx_size" value="16384"></label>
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Profile name <input type="text" name="profile" value="{{.Profile}}"></label>
|
|
<label>Needs RAM (GB) <span class="opt">estimated from the weights</span>
|
|
<input type="text" name="min_memory_gb" value="{{.MinMemoryGB}}"></label>
|
|
<label>Only nodes labelled <span class="opt">key / value, optional</span>
|
|
<input type="text" name="selector_key" placeholder="role" size="8">
|
|
<input type="text" name="selector_value" placeholder="cpu-large" size="10"></label>
|
|
</div>
|
|
<button type="submit">Add to the catalog</button>
|
|
</form>
|
|
<p class="note">The context pool is what the engine is given; llama.cpp divides
|
|
it across its parallel slots, so the number one request may use is the smaller
|
|
one above.</p>
|
|
<p class="note">The memory figure is the weights plus half again for the KV pool
|
|
plus two for the engine — an estimate, and editable. It is filled in rather than
|
|
left blank because a CPU profile has no card to be short of: with no figure,
|
|
every ready node is a candidate and the first one picked is whichever the
|
|
scheduler reached, not whichever can hold the model. A node label does the same
|
|
job by name if you would rather pin it.</p>
|
|
{{end}}
|
|
</td></tr>
|
|
{{end}}
|
|
{{end}}
|
|
</table>
|
|
<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}}
|
|
<h2>Find a model</h2>
|
|
<form method="get" action="/admin/mirror">
|
|
<label>Search Hugging Face
|
|
<input type="text" name="q" value="{{.Query}}" placeholder="qwen2.5 coder gguf"></label>
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
|
|
{{if .RegistryErr}}
|
|
{{/* Its own line, not the page's error: the registry is somebody else's server
|
|
and it being down says nothing about the mirror, which is what this page is
|
|
for and has to keep working when Hugging Face does not. */}}
|
|
<p class="note error-row">The registry: {{.RegistryErr}}</p>
|
|
{{end}}
|
|
|
|
{{if .Results}}
|
|
<table><tr><th>Repository</th><th>Downloads</th><th>Task</th><th></th></tr>
|
|
{{range .Results}}<tr>
|
|
<td><code>{{.ID}}</code>{{if .Gated.Is}} <span class="opt">gated</span>{{end}}</td>
|
|
<td class="num">{{compact .Downloads}}</td>
|
|
<td>{{or .PipelineTag "—"}}</td>
|
|
<td><a href="/admin/mirror?q={{$.Query}}&repo={{.ID}}">files</a></td>
|
|
</tr>{{end}}
|
|
</table>
|
|
<p class="note">A gated repository needs its terms accepted with a Hugging Face
|
|
account, and this browses anonymously — the files will list, and fetching one
|
|
will fail at the mirror. Download it by hand and put it in the mirror
|
|
directory, or give the mirror a token.</p>
|
|
{{else if .Query}}{{if not .RegistryErr}}
|
|
<p class="empty">Nothing matched “{{.Query}}”.</p>
|
|
{{end}}{{end}}
|
|
|
|
{{if .Repo}}
|
|
<h3>{{.Repo.ID}} <span class="opt">at {{.Repo.ShortRevision}}</span></h3>
|
|
{{if .RepoSets}}
|
|
<table>
|
|
<tr><th>What</th><th>Size</th><th>Published checksum</th><th></th></tr>
|
|
{{range .RepoSets}}<tr>
|
|
<td><code>{{.Name}}</code>
|
|
{{if .Shards}}<span class="opt">{{.Shards}} shards, fetched together</span>{{end}}</td>
|
|
<td class="num">{{bytes .Bytes}}</td>
|
|
<td>{{with .SHA256}}<code title="{{.}}">{{slice . 0 12}}</code>{{else}}—{{end}}</td>
|
|
<td>
|
|
<form method="post" action="/admin/mirror/fetch" class="inline">
|
|
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
|
{{range .Sources}}<input type="hidden" name="source" value="{{.URL}}">
|
|
<input type="hidden" name="file" value="{{.Path}}">
|
|
{{end}}
|
|
<input type="text" name="model" value="{{.Model}}" size="24">
|
|
<input type="text" name="version" value="{{.Version}}" size="10">
|
|
<button type="submit">Fetch</button>
|
|
</form>
|
|
</td>
|
|
</tr>{{end}}
|
|
</table>
|
|
<p class="note">Only the weights are listed; a repository is mostly a README and
|
|
a tokenizer. Shards of one file are one row and one button — a fifteen-shard
|
|
model is one decision, not fifteen. Two separate files are two rows because they
|
|
are usually alternatives: one quantisation or the other, not both.</p>
|
|
<p class="note">The source URLs are pinned to
|
|
<code>{{.Repo.ShortRevision}}</code> rather than to a branch, so what arrives is
|
|
what is listed here even if the branch moves in between — and a single file's
|
|
checksum above is what the registry publishes, to compare with what the mirror
|
|
computes once it has it.</p>
|
|
{{else}}
|
|
<p class="empty">No weight files in {{.Repo.ID}}.</p>
|
|
{{end}}
|
|
{{end}}
|
|
{{end}}
|
|
|
|
<h2>Fetch a model</h2>
|
|
<p class="note">By hand, for anything the search does not reach.</p>
|
|
<form method="post" action="/admin/mirror/fetch">
|
|
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
|
<label>Source URL
|
|
<input type="text" name="source" required
|
|
placeholder="https://huggingface.co/…/resolve/main/model.gguf"></label>
|
|
<div class="field-row">
|
|
<label>Model <input type="text" name="model" required placeholder="qwen2.5-coder-7b"></label>
|
|
<label>Version <input type="text" name="version" required placeholder="2026-09"></label>
|
|
<label>File <span class="opt">optional</span>
|
|
<input type="text" name="file" placeholder="taken from the URL"></label>
|
|
</div>
|
|
<button type="submit">Start download</button>
|
|
</form>
|
|
<p class="note">The mirror answers as soon as the download is under way, not when
|
|
it finishes — a model is tens of gigabytes and nothing here holds a connection
|
|
open for that. The row above will show it arriving. A version is never
|
|
overwritten: fetching into one that exists is refused, so a new build is a new
|
|
version and the old weights stay where they are.</p>
|
|
{{end}}
|
|
|
|
<footer><span>llmgw {{.Version}}</span><span>{{.Now}}</span></footer>
|
|
</div></body></html>{{end}}
|