Compare commits

...
2 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 5501f77805 Describe v0.9.1
CI / test (push) Successful in 2m22s
Release / build (push) Successful in 3m1s
Release / notes (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:37:43 +02:00
Ilker ManapandClaude Opus 5.5 75d69a6b9c Show the mirror one line per model version
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>
2026-09-28 16:36:36 +02:00
11 changed files with 153 additions and 41 deletions

No files matched your search

+12
View File
@@ -19,6 +19,18 @@ 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.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
+16
View File
@@ -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
+38 -9
View File
@@ -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.
+33 -1
View File
@@ -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")
}
}
+2
View File
@@ -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.
+4
View File
@@ -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 {
+5
View File
@@ -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; }
+39 -27
View File
@@ -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}}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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}}