Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5501f77805 | ||
|
|
75d69a6b9c |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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