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
ipcr / image (push) Successful in 18m52s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user