IPCR 1.8.1: Published images tab for many repositories. A filter (repository, alias, published tag; / to focus), sort by recently published or name, pages of 100 computed on the server; one line per repository, then one per manifest (tags on one digest together, Copy pull line), then one tag's pull lines and actions; what is open survives reloads, filter/sort/page kept in the URL. GET /admin/images takes ?q=&sort=&page=&per= and probes the staging registry for that page's repositories only; without page/per, the whole list as before (IPCR Forge admin page)
ipcr / image (push) Successful in 18m52s

This commit is contained in:
Worph
2026-10-26 12:12:15 +00:00
parent ecb8340834
commit 4589e489a4
5 changed files with 408 additions and 106 deletions
+2 -2
View File
@@ -72,7 +72,7 @@ services:
# bound to this container's address on the ipcr-console network.
# 127.0.0.1:4769 the admin API, for the dashboard, over loopback.
ipcr-gateway:
image: ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.0
image: ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.1
container_name: ipcr-gateway
# Root for one reason: it writes its CA into the host's Docker trust store (the narrow
# bind below), which is root-owned. See rationale.md.
@@ -173,7 +173,7 @@ services:
# ── Stock nerdctl IPFS registry ─────────────────────────────────────────────
# `nerdctl ipfs registry serve`, unmodified: translates the registry API into IPFS reads.
ipcr-registry:
image: ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.0
image: ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.1
container_name: ipcr-registry
# Root because nerdctl run as any other uid assumes rootless mode and refuses to start
# without a rootless containerd — which `ipfs registry serve` never uses. The service has
+4 -4
View File
@@ -64,7 +64,7 @@ is a one-off command, not something the installed app exposes:
docker pull nginxdemos/hello:0.4 # or any image you built
docker run --rm --network ipcr_ipcr-internal \
-v /run/containerd/containerd.sock:/run/containerd/containerd.sock \
ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.0 ipcr push nginxdemos/hello:0.4 hello:0.4
ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.1 ipcr push nginxdemos/hello:0.4 hello:0.4
```
```
@@ -160,10 +160,10 @@ docs/ spec and design notes
## Release
Images are built on IPCR Forge by its Gitea workflow ([.gitea/workflows/ipcr.yml](.gitea/workflows/ipcr.yml))
and published on IPFS under the forge's name: a tag `v1.8.0` pushed to `origin` (the forge's Gitea)
gives `ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.0`, `:1.8` and `:latest`, pullable from any machine with plain Docker (and from
and published on IPFS under the forge's name: a tag `v1.8.1` pushed to `origin` (the forge's Gitea)
gives `ipcr-yunderalabs.nsl.sh/ipns/ipcrteam.eth/ipcr/ipcr:1.8.1`, `:1.8` and `:latest`, pullable from any machine with plain Docker (and from
`ipcr.localhost:4767/ipns/ipcrteam.eth/ipcr/ipcr:<tag>` on any IPCR host). GitHub is no longer used;
`ghcr.io/yundera/ipcr` stops at 1.6.1.
To release: bump the image tag in `Apps/IPCR*/docker-compose.yml` and in this README, commit, then
`git tag v1.8.0 && git push origin main v1.8.0`. A pushed tag is never moved: a fix is a new version.
`git tag v1.8.1 && git push origin main v1.8.1`. A pushed tag is never moved: a fix is a new version.
+98 -22
View File
@@ -10,6 +10,7 @@ package main
// POST /admin/announce {cid} announce cid now
// GET /admin/peers this node's addresses as the network knows them
// GET /admin/images what is published, what the source registry holds, the allowlist
// (?q=&sort=&page=&per= for one page of repositories)
// POST /admin/unpublish {repo,tag}
// POST /admin/retag {repo,tag,from}
// POST /admin/reimport {repo,tag}
@@ -40,6 +41,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -569,12 +571,19 @@ type stagingTag struct {
State string `json:"state"` // published | unpublished | pending
}
// handleImages lists the repositories: what is published and what the source registry holds. With
// page or per it returns one page of them (?q= filters on repository, alias and published tag;
// ?sort=recent (default) or name), and asks the source registry about that page's repositories only:
// one request per tag, so on a big registry the whole list is too slow to probe. Without either, the
// whole list (IPCR Forge's admin page).
func (a *admin) handleImages(w http.ResponseWriter, r *http.Request) {
wt, err := publisherWatcher()
if err != nil {
fail(w, http.StatusConflict, err)
return
}
qv := r.URL.Query()
paged := qv.Get("page") != "" || qv.Get("per") != ""
cfg := loadConfig(a.stateDir)
byRepo := map[string]*imageView{}
view := func(repo string) *imageView {
@@ -585,6 +594,7 @@ func (a *admin) handleImages(w http.ResponseWriter, r *http.Request) {
byRepo[repo] = v
return v
}
tags := 0
wt.mu.Lock()
out := map[string]any{"publisher": wt.pub.Publisher, "name": wt.pub.Name, "registry": wt.pub.Registry, "public": cfg.publicOn()}
for repo, pr := range wt.pub.Images {
@@ -593,6 +603,7 @@ func (a *admin) handleImages(w http.ResponseWriter, r *http.Request) {
for tag, t := range pr.Tags {
v.Tags = append(v.Tags, tagView{tag, t})
}
tags += len(pr.Tags)
}
state := make(map[string]string, len(wt.state))
for k, d := range wt.state {
@@ -605,38 +616,103 @@ func (a *admin) handleImages(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
reg := newRegistry(wt.u.Scheme, wt.u.Host)
if repos, err := reg.catalog(ctx); err != nil {
repos, err := reg.catalog(ctx)
if err != nil {
out["stagingError"] = err.Error()
} else {
for _, repo := range repos {
tags, err := reg.tags(ctx, repo)
if err != nil {
continue
}
v := view(repo)
for _, tag := range tags {
st := stagingTag{Tag: tag, State: "pending"}
st.Digest, _ = reg.headDigest(ctx, repo, tag)
switch prev := state[wt.stateKey(repo, tag)]; {
case st.Digest != "" && prev == st.Digest:
st.State = "published"
case st.Digest != "" && prev == tombstone+st.Digest:
st.State = "unpublished"
}
v.Staging = append(v.Staging, st)
}
}
}
for _, repo := range repos {
view(repo)
}
list := make([]*imageView, 0, len(byRepo))
q := strings.ToLower(strings.TrimSpace(qv.Get("q")))
for _, v := range byRepo {
sort.Slice(v.Tags, func(i, j int) bool { return v.Tags[i].At > v.Tags[j].At })
list = append(list, v)
if q == "" || v.matches(q) {
list = append(list, v)
}
}
if qv.Get("sort") == "name" || !paged {
sort.Slice(list, func(i, j int) bool { return list[i].Repo < list[j].Repo })
} else {
// Newest published tag first; repositories with none (staging only) last.
sort.Slice(list, func(i, j int) bool {
ai, aj := list[i].newest(), list[j].newest()
if ai != aj {
return ai > aj
}
return list[i].Repo < list[j].Repo
})
}
out["repos"], out["tags"], out["total"] = len(byRepo), tags, len(list)
if paged {
per, _ := strconv.Atoi(qv.Get("per"))
if per <= 0 {
per = 100
}
per = min(per, 500)
page, _ := strconv.Atoi(qv.Get("page"))
page = max(1, min(page, (len(list)+per-1)/per))
lo := min((page-1)*per, len(list))
list = list[lo:min(lo+per, len(list))]
out["page"], out["per"] = page, per
}
if err == nil {
for _, v := range list {
v.Staging = stagingFor(ctx, reg, wt, state, v.Repo)
}
}
sort.Slice(list, func(i, j int) bool { return list[i].Repo < list[j].Repo })
out["images"] = list
reply(w, out)
}
// matches reports whether q (lower case) is in the repository's name, an alias or a published tag.
func (v *imageView) matches(q string) bool {
if strings.Contains(strings.ToLower(v.Repo), q) {
return true
}
for _, a := range v.Aliases {
if strings.Contains(strings.ToLower(a), q) {
return true
}
}
for _, t := range v.Tags {
if strings.Contains(strings.ToLower(t.Tag), q) {
return true
}
}
return false
}
// newest is when the repository's last tag was published ("" when none is); Tags are sorted newest first.
func (v *imageView) newest() string {
if len(v.Tags) == 0 {
return ""
}
return v.Tags[0].At
}
// stagingFor returns the tags the source registry holds for repo, and whether each is published.
func stagingFor(ctx context.Context, reg *registry, wt *watcher, state map[string]string, repo string) []stagingTag {
tags, err := reg.tags(ctx, repo)
if err != nil {
return nil
}
var out []stagingTag
for _, tag := range tags {
st := stagingTag{Tag: tag, State: "pending"}
st.Digest, _ = reg.headDigest(ctx, repo, tag)
switch prev := state[wt.stateKey(repo, tag)]; {
case st.Digest != "" && prev == st.Digest:
st.State = "published"
case st.Digest != "" && prev == tombstone+st.Digest:
st.State = "unpublished"
}
out = append(out, st)
}
return out
}
type repoTag struct {
Repo string `json:"repo"`
Tag string `json:"tag"`
+69
View File
@@ -97,6 +97,7 @@ type fakeRegistry struct {
mu sync.Mutex
tags map[string]map[string]string
deleted []string
listed []string // repositories whose tags were listed
}
func (f *fakeRegistry) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -112,6 +113,7 @@ func (f *fakeRegistry) ServeHTTP(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]any{"repositories": repos})
case strings.HasSuffix(p, "/tags/list"):
repo := strings.TrimSuffix(p, "/tags/list")
f.listed = append(f.listed, repo)
var tags []string
for tag := range f.tags[repo] {
tags = append(tags, tag)
@@ -181,6 +183,73 @@ func TestPassAllowlistAndCleanup(t *testing.T) {
}
}
// The images list: filter, sort, one page, and the source registry asked about that page's
// repositories only; without page or per, the whole list as before.
func TestImagesPaging(t *testing.T) {
reg := &fakeRegistry{tags: map[string]map[string]string{
"owner/app": {"2.0.0": "sha256:a2"},
"owner/staging": {"0.1.0": "sha256:s1"}, // never published
}}
srv := httptest.NewServer(reg)
defer srv.Close()
dir := t.TempDir()
w, err := newWatcher(srv.URL, time.Minute, dir)
if err != nil {
t.Fatal(err)
}
w.publisher = "forge"
w.pub.set("owner/app", "k51x/owner/app", "1.0.0", publishedTag{CID: "bafy1", Digest: "sha256:a1", At: "2026-01-01T00:00:00Z"})
w.pub.set("owner/app", "k51x/owner/app", "2.0.0", publishedTag{CID: "bafy2", Digest: "sha256:a2", At: "2026-03-01T00:00:00Z"})
w.state[w.stateKey("owner/app", "2.0.0")] = "sha256:a2"
w.pub.set("owner/web", "k51x/owner/web", "beta", publishedTag{CID: "bafy3", Digest: "sha256:w1", At: "2026-02-01T00:00:00Z"})
w.pub.Images["owner/web"].Aliases = []string{"site"}
old := theWatcher
theWatcher = w
defer func() { theWatcher = old }()
a := &admin{stateDir: dir}
type page struct {
Total, Repos, Tags, Page int
Images []imageView
}
get := func(query string) (p page, repos []string) {
reg.mu.Lock()
reg.listed = nil
reg.mu.Unlock()
rec := httptest.NewRecorder()
a.handleImages(rec, httptest.NewRequest("GET", "/admin/images?"+query, nil))
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("%s: %v: %s", query, err, rec.Body)
}
for _, v := range p.Images {
repos = append(repos, v.Repo)
}
return p, repos
}
if p, repos := get(""); strings.Join(repos, " ") != "owner/app owner/staging owner/web" || p.Repos != 3 || p.Tags != 3 || len(reg.listed) != 3 {
t.Errorf("whole list: %v %+v, listed %v", repos, p, reg.listed)
}
if p, repos := get("per=2"); strings.Join(repos, " ") != "owner/app owner/web" || p.Total != 3 || p.Page != 1 || strings.Join(reg.listed, " ") != "owner/app owner/web" {
t.Errorf("recent, page 1: %v %+v, listed %v", repos, p, reg.listed)
}
if p, repos := get("per=2&page=2"); strings.Join(repos, " ") != "owner/staging" || p.Page != 2 || len(p.Images[0].Staging) != 1 {
t.Errorf("recent, page 2: %v %+v", repos, p)
}
if p, repos := get("per=2&page=9&sort=name"); strings.Join(repos, " ") != "owner/web" || p.Page != 2 {
t.Errorf("name, past the end: %v %+v", repos, p)
}
if _, repos := get("per=50&q=SITE"); strings.Join(repos, " ") != "owner/web" {
t.Errorf("filter on alias: %v", repos)
}
if p, repos := get("per=50&q=1.0"); strings.Join(repos, " ") != "owner/app" || p.Total != 1 || p.Repos != 3 {
t.Errorf("filter on tag: %v %+v", repos, p)
}
if p, _ := get("page=1&q=nothing"); p.Total != 0 || p.Page != 1 || len(reg.listed) != 0 {
t.Errorf("no match: %+v, listed %v", p, reg.listed)
}
}
// fakeKubo answers the Kubo RPC calls the key restore makes, and records them.
type fakeKubo struct {
mu sync.Mutex
+235 -78
View File
@@ -48,7 +48,7 @@
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button.danger { color: var(--bad); }
button:disabled { opacity: .5; cursor: default; }
input[type=text], input[type=password], input[type=number], input[type=file] { font: inherit; font-size: 14px; padding: 5px 8px;
select, input[type=search], input[type=text], input[type=password], input[type=number], input[type=file] { font: inherit; font-size: 14px; padding: 5px 8px;
border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); max-width: 100%; }
form.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
@@ -71,8 +71,24 @@
.count { font-size: 12px; background: var(--code); border-radius: 10px; padding: 0 7px; margin-left: 4px; }
.addrs { list-style: none; padding: 0; margin: 4px 0 0; font-size: 13px; }
.addrs li { margin: 2px 0; }
.tag { border-top: 1px solid var(--line); padding: 10px 0; }
.tag:first-of-type { border-top: 0; }
/* Published images: one line per repository, then one per manifest, then one tag's pull lines. */
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: 12px 0 8px; }
.toolbar input { flex: 1 1 220px; }
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; margin: 8px 0; font-size: 13px; }
.repo { background: var(--card); border: 1px solid var(--line); border-radius: 10px; margin-bottom: 6px; min-width: 0; }
.irow { display: flex; gap: 8px; align-items: center; min-width: 0; }
.toggle { flex: 1; min-width: 0; display: flex; gap: 4px 12px; align-items: baseline; flex-wrap: wrap; text-align: left;
border: 0; background: none; border-radius: 10px; padding: 8px 12px; font-size: 14px; }
.toggle:hover { background: var(--code); }
.chev { display: inline-block; width: 1em; color: var(--muted); transition: transform .15s; }
.toggle[aria-expanded="true"] .chev { transform: rotate(90deg); }
.rname { font-weight: 600; overflow-wrap: anywhere; }
.rmeta { color: var(--muted); font-size: 13px; }
.rbody { padding: 0 12px 10px 30px; }
.mrow { border-top: 1px solid var(--line); }
.mrow .toggle { padding: 6px 4px; font-size: 13.5px; }
.mbody { padding: 2px 0 10px 22px; }
.mbody .taghead { margin-top: 8px; }
.taghead { display: flex; gap: 10px; align-items: baseline; justify-content: space-between; flex-wrap: wrap; margin-bottom: 6px; }
.cmd { display: grid; grid-template-columns: 44px 1fr auto; gap: 8px; align-items: center; margin: 3px 0; }
.cmd .k { font-size: 11.5px; font-weight: 600; color: var(--muted); text-transform: uppercase; }
@@ -167,7 +183,14 @@
<h2>Published images</h2>
<p class="muted">Every tag can be pulled three ways: by <b>IPFS</b> CID (this exact image, forever), by <b>IPNS</b> name (follows the tag),
and by your ENS or DNS name once it checks out (Overview).</p>
<div class="toolbar">
<input type="search" id="img-q" placeholder="Filter by repository, alias or tag ( / )" aria-label="Filter images">
<select id="img-sort" aria-label="Sort"><option value="recent">Recently published</option><option value="name">Name</option></select>
<span id="img-total" class="muted"></span>
</div>
<div id="img-pager-top" class="pager" hidden></div>
<div id="images" class="muted">Loading…</div>
<div id="img-pager" class="pager" hidden></div>
</section>
<section class="panel" id="tab-pinned" hidden>
@@ -326,9 +349,12 @@
});
}
Array.prototype.forEach.call(tabs, function (b) {
b.onclick = function () { history.replaceState(null, "", "#" + b.getAttribute("data-tab")); showTab(b.getAttribute("data-tab")); };
b.onclick = function () {
var name = b.getAttribute("data-tab");
history.replaceState(null, "", name === "images" ? imagesHash() : "#" + name); showTab(name);
};
});
showTab(location.hash.slice(1));
showTab(location.hash.slice(1).split("?")[0]);
// ---- copy
function copy(text, btn) {
@@ -448,93 +474,224 @@
// ---- images
// The pull commands for one tag: by CID, by IPNS name, by the configured name when it checks out.
// The root organisation's repositories have a short path too: it comes first.
function pullCommands(reg, d, img, tg) {
function pullRows(reg, d, img, tg) {
var short = img.aliases && img.aliases.length ? img.aliases[0] : "";
var path = (short || img.repo) + ":" + tg.tag, rows = [["IPFS", reg + "/ipfs/" + tg.cid]];
if (d.publisher) rows.push(["IPNS", reg + "/ipns/" + d.publisher + "/" + path]);
if (d.name) rows.push([/\.eth$/.test(d.name) ? "ENS" : "DNS", reg + "/ipns/" + d.name + "/" + path]);
if (d.public && publicHost && (d.name || d.publisher)) rows.push(["Public", publicHost + "/ipns/" + (d.name || d.publisher) + "/" + path]);
if (short && (d.name || d.publisher)) rows.push(["Also", reg + "/ipns/" + (d.name || d.publisher) + "/" + img.repo + ":" + tg.tag]);
return rows;
}
function pullCommands(reg, d, img, tg) {
var box = el("div");
rows.forEach(function (r) {
pullRows(reg, d, img, tg).forEach(function (r) {
var cmd = "docker pull " + r[1], row = el("div", null, "cmd");
row.appendChild(el("span", r[0], "k")); row.appendChild(el("code", cmd)); row.appendChild(copyButton(cmd));
box.appendChild(row);
});
return box;
}
function loadImages() {
return api("GET", "ipcr/images").then(function (d) {
var box = $("images"); box.textContent = ""; box.className = "";
var reg = d.registry || "ipcr.localhost:4767", n = 0;
if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn"));
if (!d.images || !d.images.length) { box.appendChild(el("p", "Nothing published yet.", "muted")); $("images-count").textContent = "0"; return; }
d.images.forEach(function (img) {
n += img.tags.length;
var card = el("div", null, "card"); card.style.marginBottom = "12px";
var h = el("h3", (img.aliases && img.aliases.length ? img.aliases[0] + " · " : "") + img.repo + " ");
h.appendChild(img.allowed ? badge("publishing", "ok") : badge("not allowed: new tags are not published", "warn"));
card.appendChild(h);
img.tags.forEach(function (tg) {
var t = el("div", null, "tag"), head = el("div", null, "taghead"), left = el("div");
left.appendChild(el("code", tg.tag));
if (tg.at) left.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted"));
var prov = el("span", "", "muted"); prov.style.marginLeft = "10px"; left.appendChild(prov);
var acts = el("div", null, "actions");
var chk = el("button", "Findable?");
chk.onclick = function () {
prov.textContent = ""; prov.appendChild(spinner("asking the network…"));
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(tg.cid))).then(function (p) {
prov.textContent = "";
prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn"));
if (!p.self) {
var ann = el("button", "Announce");
ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: tg.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); };
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
}
}, function () { prov.textContent = ""; });
};
acts.appendChild(chk);
if (tg.tag !== "latest") {
var lat = el("button", "Make latest");
lat.onclick = function () {
busy(lat, api("POST", "ipcr/retag", { repo: img.repo, tag: "latest", from: tg.tag })).then(function () { toast(img.repo + ":latest → " + tg.tag); loadImages(); });
};
acts.appendChild(lat);
}
var un = el("button", "Unpublish", "danger");
un.onclick = function () {
if (!confirm("Unpublish " + img.repo + ":" + tg.tag + "?\n\nThe name stops resolving to it. Servers that already pulled it keep their copy, and its CID keeps working wherever it is still pinned.")) return;
busy(un, api("POST", "ipcr/unpublish", { repo: img.repo, tag: tg.tag })).then(function (r) {
toast("Unpublished (" + r.unpinned + " objects unpinned)"); loadImages(); loadStatus(true);
});
};
acts.appendChild(un);
head.appendChild(left); head.appendChild(acts);
t.appendChild(head);
t.appendChild(pullCommands(reg, d, img, tg));
card.appendChild(t);
});
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; });
if (pending.length) {
card.appendChild(el("h3", "In staging, not published"));
var tbl = el("table");
pending.forEach(function (s) {
var tr = el("tr"), td1 = el("td"), td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "not imported yet" : "repository not allowed"), "muted"), td3 = el("td");
td1.appendChild(el("code", s.tag));
if (img.allowed) {
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued: imported on the next pass"); setTimeout(loadImages, 5000); }); };
td3.appendChild(re);
}
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr);
});
card.appendChild(tbl);
}
box.appendChild(card);
// The one line to copy without opening a tag: by name (follows the tag), the configured name first.
function mainPull(reg, d, img, tg) {
var rows = pullRows(reg, d, img, tg), pick = rows[0];
rows.forEach(function (r) { if (r[0] === "ENS" || r[0] === "DNS" || (r[0] === "IPNS" && pick[0] === "IPFS")) pick = r; });
return "docker pull " + pick[1];
}
function ago(iso) {
var t = Date.parse(iso);
if (!iso || isNaN(t)) return "";
var m = (Date.now() - t) / 60000;
if (m < 1) return "just now";
if (m < 60) return Math.round(m) + " min ago";
if (m < 48 * 60) return Math.round(m / 60) + " h ago";
if (m < 60 * 24 * 60) return Math.round(m / 1440) + " days ago";
return iso.slice(0, 10);
}
function shortCid(c) { return c && c.length > 16 ? c.slice(0, 8) + "…" + c.slice(-6) : (c || ""); }
// What is open survives a reload of the list (after Make latest, Unpublish, …), and the filter,
// sort and page are kept in the URL (#images?q=…&sort=name&page=2).
var imgQ = { q: "", sort: "recent", page: 1 }, imgPer = 100, imgSeq = 0;
var imgOpen = { repo: {}, man: {}, all: {} };
(function () {
var h = location.hash.split("?");
if (h[0] !== "#images" || !h[1]) return;
var p = new URLSearchParams(h[1]);
imgQ.q = p.get("q") || ""; imgQ.sort = p.get("sort") === "name" ? "name" : "recent"; imgQ.page = parseInt(p.get("page"), 10) || 1;
})();
$("img-q").value = imgQ.q; $("img-sort").value = imgQ.sort;
function imagesHash() {
var p = new URLSearchParams();
if (imgQ.q) p.set("q", imgQ.q);
if (imgQ.sort !== "recent") p.set("sort", imgQ.sort);
if (imgQ.page > 1) p.set("page", String(imgQ.page));
return "#images" + (p.toString() ? "?" + p : "");
}
var qTimer;
$("img-q").oninput = function () {
clearTimeout(qTimer);
var v = this.value.trim();
qTimer = setTimeout(function () { if (v === imgQ.q) return; imgQ.q = v; imgQ.page = 1; loadImages(); }, 250);
};
$("img-sort").onchange = function () { imgQ.sort = this.value; imgQ.page = 1; loadImages(); };
document.addEventListener("keydown", function (e) {
if (e.key !== "/" || $("tab-images").hidden || /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName)) return;
e.preventDefault(); $("img-q").focus();
});
// A line that opens and closes what is under it; open[key] remembers it.
function fold(cls, open, key, head, render) {
var box = el("div", null, cls), row = el("div", null, "irow"), btn = el("button", null, "toggle"), body = el("div");
btn.type = "button"; btn.appendChild(el("span", "▸", "chev"));
head.forEach(function (h) { btn.appendChild(h); });
row.appendChild(btn); box.appendChild(row); box.appendChild(body);
function set(on) {
btn.setAttribute("aria-expanded", on ? "true" : "false"); body.hidden = !on; body.textContent = "";
if (on) render(body);
}
btn.onclick = function () { if (open[key]) delete open[key]; else open[key] = true; set(!!open[key]); };
set(!!open[key]);
return { box: box, row: row };
}
// The tags of one repository, one line per manifest: tags on the same digest (1.8.0 and latest) together.
function manifests(img) {
var by = {}, list = [];
img.tags.forEach(function (tg) {
var k = tg.digest || tg.cid;
if (!by[k]) list.push(by[k] = { key: k, cid: tg.cid, at: tg.at, tags: [] });
by[k].tags.push(tg);
});
list.forEach(function (m) { m.tags.sort(function (a, b) { return (a.tag === "latest") - (b.tag === "latest"); }); });
return list;
}
function tagActions(img, tg) {
var acts = el("div", null, "actions");
if (tg.tag !== "latest") {
var lat = el("button", "Make latest");
lat.onclick = function () {
busy(lat, api("POST", "ipcr/retag", { repo: img.repo, tag: "latest", from: tg.tag })).then(function () { toast(img.repo + ":latest → " + tg.tag); loadImages(); });
};
acts.appendChild(lat);
}
var un = el("button", "Unpublish", "danger");
un.onclick = function () {
if (!confirm("Unpublish " + img.repo + ":" + tg.tag + "?\n\nThe name stops resolving to it. Servers that already pulled it keep their copy, and its CID keeps working wherever it is still pinned.")) return;
busy(un, api("POST", "ipcr/unpublish", { repo: img.repo, tag: tg.tag })).then(function (r) {
toast("Unpublished (" + r.unpinned + " objects unpinned)"); loadImages(); loadStatus(true);
});
$("images-count").textContent = String(n);
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
};
acts.appendChild(un);
return acts;
}
function renderManifest(body, reg, d, img, m) {
var top = el("div", null, "taghead"), left = el("div"), prov = el("span", "", "muted");
left.appendChild(el("span", "IPFS ", "muted")); left.appendChild(el("code", m.cid)); left.appendChild(document.createTextNode(" "));
left.appendChild(prov);
var chk = el("button", "Findable?");
chk.onclick = function () {
prov.textContent = ""; prov.appendChild(spinner("asking the network…"));
busy(chk, api("GET", "ipcr/providers?cid=" + encodeURIComponent(m.cid))).then(function (p) {
prov.textContent = "";
prov.appendChild(p.self ? badge("✓ findable: this node is listed", "ok") : badge("✗ not listed yet (" + p.providers + " other providers)", "warn"));
if (!p.self) {
var ann = el("button", "Announce");
ann.onclick = function () { busy(ann, api("POST", "ipcr/announce", { cid: m.cid })).then(function () { toast("Queued: Kubo announces it within a minute or two; check again then.", 6000); }); };
prov.appendChild(document.createTextNode(" ")); prov.appendChild(ann);
}
}, function () { prov.textContent = ""; });
};
top.appendChild(left); top.appendChild(chk); body.appendChild(top);
m.tags.forEach(function (tg) {
var h = el("div", null, "taghead"), l = el("div");
l.appendChild(el("code", tg.tag));
if (tg.at) l.appendChild(el("span", " " + tg.at.replace("T", " ").replace("Z", " UTC"), "muted"));
h.appendChild(l); h.appendChild(tagActions(img, tg));
body.appendChild(h); body.appendChild(pullCommands(reg, d, img, tg));
});
}
function renderRepo(body, reg, d, img) {
var ms = manifests(img), all = imgOpen.all[img.repo], shown = all ? ms : ms.slice(0, 10);
if (!ms.length) body.appendChild(el("p", "Nothing published yet.", "muted"));
shown.forEach(function (m) {
var head = [], names = el("span");
m.tags.forEach(function (tg, i) { if (i) names.appendChild(document.createTextNode(" ")); names.appendChild(el("code", tg.tag)); });
head.push(names, el("span", shortCid(m.cid), "rmeta"), el("span", ago(m.at), "rmeta"));
var f = fold("mrow", imgOpen.man, img.repo + "@" + m.key, head, function (b) { b.className = "mbody"; renderManifest(b, reg, d, img, m); });
f.row.appendChild(copyButton(mainPull(reg, d, img, m.tags[0])));
body.appendChild(f.box);
});
if (ms.length > shown.length) {
var more = el("button", "Show all " + ms.length);
more.onclick = function () { imgOpen.all[img.repo] = true; body.textContent = ""; renderRepo(body, reg, d, img); };
body.appendChild(more);
}
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; });
if (pending.length) {
body.appendChild(el("h3", "In staging, not published"));
var tbl = el("table");
pending.forEach(function (s) {
var tr = el("tr"), td1 = el("td"), td2 = el("td", s.state === "unpublished" ? "unpublished; still in staging" : (img.allowed ? "not imported yet" : "repository not allowed"), "muted"), td3 = el("td");
td1.appendChild(el("code", s.tag));
if (img.allowed) {
var re = el("button", s.state === "unpublished" ? "Publish again" : "Import now");
re.onclick = function () { busy(re, api("POST", "ipcr/reimport", { repo: img.repo, tag: s.tag })).then(function () { toast("Queued: imported on the next pass"); setTimeout(loadImages, 5000); }); };
td3.appendChild(re);
}
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tbl.appendChild(tr);
});
body.appendChild(tbl);
}
}
function repoHead(img) {
var short = img.aliases && img.aliases.length ? img.aliases[0] : "", head = [];
head.push(el("span", (short ? short + " · " : "") + img.repo, "rname"));
var meta = [img.tags.length + (img.tags.length === 1 ? " tag" : " tags")];
var latest = img.tags.filter(function (t) { return t.tag === "latest"; })[0];
if (latest) {
var same = latest.digest && img.tags.filter(function (t) { return t.tag !== "latest" && t.digest === latest.digest; })[0];
if (same) meta.push("latest → " + same.tag);
}
if (img.tags.length) meta.push(ago(img.tags[0].at));
head.push(el("span", meta.join(" · "), "rmeta"));
if (!img.allowed) head.push(badge("not allowed: new tags are not published", "warn"));
var pending = (img.staging || []).filter(function (s) { return s.state !== "published"; }).length;
if (pending) head.push(badge("⚠ " + pending + " in staging", "warn"));
return head;
}
function pager(d) {
var pages = Math.ceil(d.total / d.per), box = el("span");
var from = (d.page - 1) * d.per + 1, to = Math.min(d.page * d.per, d.total);
function go(p, btn) { imgQ.page = p; busy(btn, loadImages()).then(function () { if (btn.closest("#img-pager")) $("tab-images").scrollIntoView(); }); }
var prev = el("button", "‹ Prev"), next = el("button", "Next ›");
prev.disabled = d.page <= 1; next.disabled = d.page >= pages;
prev.onclick = function () { go(d.page - 1, prev); }; next.onclick = function () { go(d.page + 1, next); };
box.appendChild(prev); box.appendChild(el("span", " " + from + "–" + to + " of " + d.total + " · page " + d.page + " / " + pages + " ", "muted")); box.appendChild(next);
return box;
}
function loadImages() {
var seq = ++imgSeq, p = new URLSearchParams({ q: imgQ.q, sort: imgQ.sort, page: String(imgQ.page), per: String(imgPer) });
return api("GET", "ipcr/images?" + p).then(function (d) {
if (seq !== imgSeq) return; // a newer filter is on its way
imgQ.page = d.page || 1;
if (location.hash.split("?")[0] === "#images") history.replaceState(null, "", imagesHash());
var box = $("images"); box.textContent = ""; box.className = "";
var reg = d.registry || "ipcr.localhost:4767";
$("images-count").textContent = String(d.tags || 0);
$("img-total").textContent = imgQ.q ? d.total + " of " + d.repos + " repositories" : d.repos + " repositories · " + d.tags + " tags";
["img-pager-top", "img-pager"].forEach(function (id) {
$(id).textContent = ""; $(id).hidden = d.total <= d.per;
if (d.total > d.per) $(id).appendChild(pager(d));
});
if (d.stagingError) box.appendChild(el("p", "Staging registry: " + d.stagingError, "warn"));
if (!d.repos) { box.appendChild(el("p", "Nothing published yet.", "muted")); return; }
if (!d.images.length) { box.appendChild(el("p", "No repository matches “" + imgQ.q + "”.", "muted")); return; }
d.images.forEach(function (img) {
box.appendChild(fold("repo", imgOpen.repo, img.repo, repoHead(img), function (b) { b.className = "rbody"; renderRepo(b, reg, d, img); }).box);
});
}).catch(function (e) { if (seq === imgSeq) $("images").textContent = "Images: " + e.message; });
}
// ---- pins and follows