ipcr-public: a second ipcrd behind Caddy at ipcr-<domain>, plain HTTP, no pinning, off the admin and CI networks. With PUBLIC_POLICY it serves only this forge's publisher name, its verified ENS/DNS name and its published image roots; everything else is 404, pushes 405. So docker pull ipcr-<domain>/ipns/metadec.eth/ipcr-hello:1.0.0 works on any machine with plain Docker. Switch in the admin page (config.json "public"), mirrored into published.json for the pages, which show the public pull lines while it is on. ipcr 1.5.0, bridge 0.4.0. Verified from a second machine (docs/forge.md).
411 lines
22 KiB
HTML
411 lines
22 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>IPCR Forge</title>
|
|
<link rel="icon" href="icon.png">
|
|
<!--
|
|
Served by the forge's bridge (ipcr-forge-bridge, embedded in its binary). Every address is
|
|
derived in the browser from this page's own hostname (ipcr-forge-<domain> → gitea-<domain>,
|
|
radicle-<domain>, radicle-api-<domain>), so the nip.io / sslip.io aliases work too.
|
|
Live data: /images.json (IPCR's published.json), /repos.json (the bridge's Gitea → Radicle
|
|
mirror state) and the Radicle API's node info.
|
|
-->
|
|
<style>
|
|
:root {
|
|
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
|
--accent: #0f766e; --accent-2: #1e3a8a; --code: #f1f4f8; --ok: #15803d; --warn: #b45309; --bad: #b91c1c;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #0b1220; --card: #111a2c; --ink: #e5e9f0; --muted: #94a0b5; --line: #1f2a40;
|
|
--accent: #2dd4bf; --accent-2: #93c5fd; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--ink);
|
|
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
main { max-width: 980px; margin: 0 auto; padding: 32px 16px 64px; }
|
|
header { display: flex; gap: 18px; align-items: center; margin-bottom: 8px; }
|
|
header img { width: 64px; height: 64px; }
|
|
h1 { font-size: 30px; margin: 0; letter-spacing: -0.01em; }
|
|
h2 { font-size: 21px; margin: 40px 0 12px; }
|
|
h3 { font-size: 16px; margin: 20px 0 6px; }
|
|
p { margin: 8px 0; }
|
|
.lede { color: var(--muted); font-size: 18px; margin: 4px 0 24px; }
|
|
a { color: var(--accent); }
|
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
|
text-decoration: none; color: inherit; display: block; }
|
|
a.card:hover { border-color: var(--accent); }
|
|
.card b { display: block; font-size: 17px; margin-bottom: 4px; color: var(--accent); }
|
|
.card span { color: var(--muted); font-size: 14px; }
|
|
.pipe { display: flex; flex-wrap: wrap; align-items: stretch; gap: 8px; margin: 12px 0; }
|
|
.step { flex: 1 1 140px; background: var(--card); border: 1px solid var(--line); border-radius: 10px;
|
|
padding: 10px 12px; font-size: 14px; }
|
|
.step b { display: block; font-size: 15px; }
|
|
.step small { color: var(--muted); }
|
|
.arrow { align-self: center; color: var(--muted); }
|
|
pre, code { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: 13.5px; }
|
|
code { background: var(--code); padding: 1px 5px; border-radius: 4px; }
|
|
pre { background: var(--code); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px;
|
|
overflow-x: auto; line-height: 1.5; }
|
|
pre code { background: none; padding: 0; }
|
|
ol { padding-left: 22px; }
|
|
li { margin: 6px 0; }
|
|
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line);
|
|
border-radius: 10px; overflow: hidden; font-size: 14px; }
|
|
th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
|
tr:last-child td { border-bottom: 0; }
|
|
td code { word-break: break-all; }
|
|
button.copy { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
|
|
font-size: 12px; padding: 3px 8px; cursor: pointer; white-space: nowrap; }
|
|
.muted { color: var(--muted); }
|
|
.note { border-left: 3px solid var(--accent); padding: 4px 12px; color: var(--muted); }
|
|
.scroll { overflow-x: auto; }
|
|
.state { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
|
|
.state.synced { color: var(--ok); } .state.waiting, .state.frozen { color: var(--warn); } .state.error { color: var(--bad); }
|
|
footer { margin-top: 48px; color: var(--muted); font-size: 13px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
|
|
<header>
|
|
<img src="icon.png" alt="">
|
|
<div>
|
|
<h1>IPCR Forge</h1>
|
|
<div class="muted" id="host"></div>
|
|
</div>
|
|
</header>
|
|
<p class="lede">From <code>git push</code> to <code>docker pull</code> on any server. Work in Gitea as
|
|
usual: its Actions build your images, IPCR serves them from IPFS, and every public repository is
|
|
mirrored to the Radicle peer-to-peer network.</p>
|
|
|
|
<div class="cards">
|
|
<a class="card" id="link-gitea" href="#"><b>Gitea</b><span>Your repositories, pull requests, issues and builds. Sign in as <code>gitea_admin</code>.</span></a>
|
|
<a class="card" href="#images"><b>Published images</b><span>What this forge serves from IPFS, with the line to put in a compose file.</span></a>
|
|
<a class="card" href="#repos"><b>On Radicle</b><span>The public repositories mirrored to the Radicle network. Public.</span></a>
|
|
</div>
|
|
|
|
<h2 id="how">How it works</h2>
|
|
<div class="pipe">
|
|
<div class="step"><b>1 · Push</b><small><code>git push</code> a <code>v*</code> tag to Gitea, or create it in Gitea's web UI</small></div>
|
|
<div class="arrow">→</div>
|
|
<div class="step"><b>2 · Build</b><small>Gitea Actions runs <code>.gitea/workflows/</code> and pushes the image to <code>localhost:5000</code></small></div>
|
|
<div class="arrow">→</div>
|
|
<div class="step"><b>3 · IPFS</b><small>IPCR imports each tag and publishes it under this forge's IPNS name</small></div>
|
|
<div class="arrow">→</div>
|
|
<div class="step"><b>4 · Pull</b><small>any server with IPCR: <code>ipcr.localhost:4767/ipns/…</code></small></div>
|
|
</div>
|
|
<p>Steps 2 and 3 are automatic and usually take a few minutes. The very first build on this server
|
|
takes longer (about 5 to 10 minutes) while it downloads its build images.</p>
|
|
<p>Alongside, every <b>public</b> repository is copied to <a class="radicle-link" href="#">Radicle</a>
|
|
within seconds of each push: branches and tags, signed by this server's Radicle node. Gitea stays
|
|
the one place to work. The Radicle copy is for distribution: anyone can <code>rad clone</code> it,
|
|
and it stays available even when this server is not. Private repositories are never copied.</p>
|
|
|
|
<h2 id="example">Try it with the example</h2>
|
|
<p>The install created <a id="link-hello" href="#"><code>gitea_admin/ipcr-hello</code></a>, a tiny web
|
|
page with a Dockerfile and the workflow that publishes it.</p>
|
|
<ol>
|
|
<li>Open it in Gitea, then <b>Tags → New tag</b> (or <b>Releases → New release</b>): name
|
|
<code>v1.0.0</code>, target <code>main</code>.</li>
|
|
<li>Follow the build under the repository's <b>Actions</b> tab.</li>
|
|
<li>About a minute after it finishes, the image appears below under
|
|
<a href="#images">Published images</a>, and the tag on <a href="#repos">Radicle</a>.</li>
|
|
</ol>
|
|
|
|
<h2 id="images">Published images</h2>
|
|
<div id="images-body"><p class="muted">Loading…</p></div>
|
|
|
|
<h2 id="publish">Publish your own project</h2>
|
|
<ol>
|
|
<li><b>Create a repository</b> in <a class="gitea-link" href="#">Gitea</a> (<b>+ → New repository</b>)
|
|
and push your code to it:
|
|
<pre><code id="clone-cmd">git remote add origin https://gitea-<domain>/<owner>/<repo>.git
|
|
git push -u origin main</code></pre>
|
|
Git asks for your Gitea user name and password (or an access token, from <b>Settings →
|
|
Applications</b>).</li>
|
|
<li><b>Add a workflow</b> at <code>.gitea/workflows/build.yml</code>. This one builds the
|
|
<code>Dockerfile</code> at the root for every <code>v*</code> tag. It is a GitHub-style
|
|
workflow with three differences: it logs in to <code>localhost:5000</code>, this forge's staging
|
|
registry, as the repository itself (the forge sets the <code>IPCR_PUSH_TOKEN</code> secret on
|
|
every public repository allowed to publish), the image goes there under the repository's own
|
|
name, and buildx's builder runs with <code>network=host</code> so it can reach that registry.
|
|
<pre><code>name: image
|
|
on:
|
|
push:
|
|
tags: ['v*']
|
|
jobs:
|
|
image:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: docker/login-action@v3
|
|
with:
|
|
registry: localhost:5000
|
|
username: ${{ github.repository }}
|
|
password: ${{ secrets.IPCR_PUSH_TOKEN }}
|
|
- uses: docker/metadata-action@v5
|
|
id: meta
|
|
with:
|
|
images: localhost:5000/${{ github.repository }}
|
|
tags: |
|
|
type=semver,pattern={{version}}
|
|
type=raw,value=latest
|
|
- uses: docker/setup-buildx-action@v3
|
|
with:
|
|
driver-opts: network=host
|
|
- uses: docker/build-push-action@v6
|
|
with:
|
|
context: .
|
|
push: true
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}</code></pre></li>
|
|
<li><b>Release:</b>
|
|
<pre><code>git add .gitea && git commit -m "Build the image"
|
|
git tag v1.0.0
|
|
git push origin main v1.0.0</code></pre>
|
|
The image is published as <code><owner>/<repo>:1.0.0</code> under this forge's name.
|
|
Repositories of the forge's own organisation are also published at the root:
|
|
<code><repo>:1.0.0</code>.</li>
|
|
</ol>
|
|
<p class="note">Who can publish: each public repository publishes under its own name only, with its
|
|
own push credential; the short root names belong to the forge's organisation, so to its members.
|
|
Workflows from forks get no secrets, so they cannot publish. Private repositories never publish.</p>
|
|
|
|
<h2 id="use">Use an image</h2>
|
|
<p>Copy the line from <a href="#images">Published images</a> into a compose file. It works on this
|
|
server and on any server running IPCR:</p>
|
|
<pre><code>services:
|
|
myapp:
|
|
image: ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/<owner>/<repo>:1.0.0 # a tag: follows new builds of 1.0.0
|
|
# image: ipcr.localhost:4767/ipfs/bafkrei… # a CID: this exact image, forever</code></pre>
|
|
<p>Or pull it by hand: <code>docker pull ipcr.localhost:4767/ipns/<span class="publisher">k51…</span>/<owner>/<repo>:1.0.0</code>.
|
|
<code>/ipns/…</code> is a name this forge can move to a new build; <code>/ipfs/<cid></code> is the
|
|
image's content address and can never change.</p>
|
|
<p class="note">For a readable name, point a name you own at this forge's publisher name
|
|
(<code id="publisher-name">k51…</code>), which holds every image it builds:</p>
|
|
<ul>
|
|
<li><b>ENS:</b> set the content hash of <code>example.eth</code> to
|
|
<code>ipns://<span class="publisher">k51…</span></code> (one transaction, once). Images are then
|
|
<code>ipcr.localhost:4767/ipns/example.eth/<owner>/<repo>:1.0.0</code>.</li>
|
|
<li><b>DNS:</b> add a TXT record <code>_dnslink.example.com</code> =
|
|
<code>dnslink=/ipns/<span class="publisher">k51…</span></code>. Images are then
|
|
<code>ipcr.localhost:4767/ipns/example.com/<owner>/<repo>:1.0.0</code>.</li>
|
|
</ul>
|
|
<p class="muted">New releases need no change to either record: they update the publisher name.</p>
|
|
|
|
<h2 id="repos">On Radicle</h2>
|
|
<p>Public repositories of this forge, as mirrored to the Radicle network by this server's node
|
|
(<code id="node-id">…</code>). Clone one with <code>rad clone <id></code> from any Radicle node.</p>
|
|
<div id="repos-body"><p class="muted">Loading…</p></div>
|
|
<p class="muted">A repository that turns private or is deleted in Gitea is no longer synced
|
|
(<i>frozen</i>). What was already published stays on the Radicle network: it cannot be taken back.</p>
|
|
|
|
<h2 id="anywhere">Pull without IPCR</h2>
|
|
<p>To try an image on a machine without IPCR, pull it through this forge's public front door, with plain Docker
|
|
and nothing to install:</p>
|
|
<pre><code id="anywhere-cmd">docker pull ipcr-<domain>/ipns/<name>/<repo>:1.0.0</code></pre>
|
|
<p class="muted">It serves this forge's images only, read-only, and depends on this server being online. For
|
|
anything that should keep working without it, use IPCR's local address (below): any IPCR node can serve the image.</p>
|
|
|
|
<h2 id="install">Pull from another server</h2>
|
|
<p>Any server with IPCR resolves the same lines and fetches the image from the IPFS network,
|
|
from this server and from every other server that already pulled it.</p>
|
|
<h3>On a Yundera server</h3>
|
|
<p>Install <b>IPCR</b> from the App Store. There is nothing to configure.</p>
|
|
<h3>On any Linux server with Docker 29+</h3>
|
|
<pre><code>mkdir -p /DATA/AppData/ipcr && cd /DATA/AppData/ipcr
|
|
curl -fsSLO https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/docker-compose.yml
|
|
install -d -m 0700 -o 1000 -g 1000 kubo && install -d -o 1000 -g 1000 init.d
|
|
curl -fsSL -o init.d/01-ipcr-node.sh https://raw.githubusercontent.com/Yundera/ipcr/main/Apps/IPCR/seed/init.d/01-ipcr-node.sh
|
|
chmod 0755 init.d/01-ipcr-node.sh
|
|
printf 'PUID=1000\nPGID=1000\nTZ=UTC\nAppID=ipcr\n' > .env
|
|
docker compose up -d</code></pre>
|
|
<p class="muted">IPCR listens on <code>127.0.0.1:4767</code> only, and trusts itself through
|
|
<code>/etc/docker/certs.d/ipcr.localhost:4767/ca.crt</code>. It publishes port 4768 (TCP and UDP)
|
|
for IPFS peers. Details: <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">github.com/Yundera/ipcr</a>.</p>
|
|
|
|
<h2 id="addresses">Addresses on this server</h2>
|
|
<div class="scroll"><table>
|
|
<tr><th>Address</th><th>What</th></tr>
|
|
<tr><td><a id="t-gitea" href="#"></a></td><td>Gitea: repositories, Actions, users (<code>gitea_admin</code>)</td></tr>
|
|
<tr><td><a id="t-forge" href="#"></a></td><td>This page (public)</td></tr>
|
|
<tr><td><a id="t-radicle" href="#"></a></td><td>Radicle explorer (public)</td></tr>
|
|
<tr><td><a id="t-radicle-api" href="#"></a></td><td>Radicle API and read-only git over HTTPS (public)</td></tr>
|
|
<tr><td><code>ipcr.localhost:4767</code></td><td>IPCR registry, for this server's Docker only</td></tr>
|
|
<tr><td><a id="t-ipcr" href="#"></a></td><td>Public front door: <code>docker pull</code> this forge's images from anywhere (read-only)</td></tr>
|
|
<tr><td>TCP 8776 · TCP/UDP 4768</td><td>Radicle peers · IPFS peers</td></tr>
|
|
</table></div>
|
|
|
|
<footer>IPCR Forge · Gitea · act_runner · Radicle · Kubo · nerdctl · <a href="https://github.com/Yundera/ipcr" target="_blank" rel="noopener">IPCR</a></footer>
|
|
</main>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
var host = location.hostname;
|
|
var base = host.indexOf("ipcr-forge-") === 0 ? host.slice("ipcr-forge-".length) : null;
|
|
function url(prefix) { return base ? "https://" + prefix + "-" + base : "#"; }
|
|
function setLink(id, prefix, text) {
|
|
var a = document.getElementById(id);
|
|
if (!a) return;
|
|
a.href = url(prefix);
|
|
if (text) a.textContent = base ? prefix + "-" + base : prefix + "-<domain>";
|
|
}
|
|
function all(sel, fn) { Array.prototype.forEach.call(document.querySelectorAll(sel), fn); }
|
|
document.getElementById("host").textContent = base || host;
|
|
setLink("link-gitea", "gitea");
|
|
all("a.gitea-link", function (a) { a.href = url("gitea"); });
|
|
all("a.radicle-link", function (a) { a.href = url("radicle"); });
|
|
document.getElementById("link-hello").href = base ? url("gitea") + "/gitea_admin/ipcr-hello" : "#";
|
|
if (base) {
|
|
document.getElementById("clone-cmd").textContent =
|
|
"git remote add origin " + url("gitea") + "/<owner>/<repo>.git\ngit push -u origin main";
|
|
}
|
|
setLink("t-gitea", "gitea", true);
|
|
setLink("t-forge", "ipcr-forge", true);
|
|
setLink("t-radicle", "radicle", true);
|
|
setLink("t-radicle-api", "radicle-api", true);
|
|
setLink("t-ipcr", "ipcr", true);
|
|
if (base) document.getElementById("anywhere-cmd").textContent = "docker pull ipcr-" + base + "/ipns/<name>/<repo>:1.0.0";
|
|
|
|
function el(tag, text, cls) {
|
|
var e = document.createElement(tag);
|
|
if (text != null) e.textContent = text;
|
|
if (cls) e.className = cls;
|
|
return e;
|
|
}
|
|
function link(href, text) { var a = el("a", text); a.href = href; return a; }
|
|
function copyButton(text) {
|
|
var b = el("button", "Copy", "copy");
|
|
b.type = "button";
|
|
b.addEventListener("click", function () {
|
|
var done = function () { b.textContent = "Copied"; setTimeout(function () { b.textContent = "Copy"; }, 1500); };
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).then(done, function () { window.prompt("Copy:", text); });
|
|
} else { window.prompt("Copy:", text); }
|
|
});
|
|
return b;
|
|
}
|
|
function when(s) { return s ? s.replace("T", " ").replace(/\..*$/, "").replace("Z", "") + " UTC" : ""; }
|
|
function cmpTags(a, b) {
|
|
if (a === "latest") return -1;
|
|
if (b === "latest") return 1;
|
|
var pa = a.split(/[.-]/), pb = b.split(/[.-]/);
|
|
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
var x = pa[i] || "", y = pb[i] || "";
|
|
var nx = parseInt(x, 10), ny = parseInt(y, 10);
|
|
if (!isNaN(nx) && !isNaN(ny) && nx !== ny) return ny - nx;
|
|
if (x !== y) return x < y ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
function table(headers) {
|
|
var wrap = el("div", null, "scroll"), t = el("table"), head = el("tr");
|
|
headers.forEach(function (h) { head.appendChild(el("th", h)); });
|
|
t.appendChild(head);
|
|
wrap.appendChild(t);
|
|
return { wrap: wrap, t: t };
|
|
}
|
|
|
|
// Published images: IPCR's published.json.
|
|
var imagesBody = document.getElementById("images-body");
|
|
fetch("images.json", { cache: "no-store" })
|
|
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
|
.then(function (pub) {
|
|
var reg = pub.registry || "ipcr.localhost:4767";
|
|
if (pub.publisher) {
|
|
document.getElementById("publisher-name").textContent = pub.publisher;
|
|
all("span.publisher", function (e) { e.textContent = pub.publisher; });
|
|
}
|
|
var repos = Object.keys(pub.images || {}).sort();
|
|
if (!repos.length) throw new Error("empty");
|
|
imagesBody.textContent = "";
|
|
repos.forEach(function (repo) {
|
|
var r = pub.images[repo];
|
|
imagesBody.appendChild(el("h3", repo));
|
|
var tb = table(["Tag", "Compose line", ""]);
|
|
Object.keys(r.tags).sort(cmpTags).forEach(function (tag) {
|
|
var info = r.tags[tag];
|
|
// The configured domain or ENS name, while IPCR's check says it points here.
|
|
// The short path (root organisation) when there is one, under the name when it checks out.
|
|
var rest = r.aliases && r.aliases.length ? "/" + r.aliases[0] : r.ipns.slice(pub.publisher ? pub.publisher.length : 0);
|
|
var path = (pub.name || pub.publisher || r.ipns.split("/")[0]) + rest;
|
|
var line = "image: " + reg + "/ipns/" + path + ":" + tag;
|
|
var tr = el("tr");
|
|
var td1 = el("td"); td1.appendChild(el("code", tag));
|
|
if (info.at) td1.appendChild(el("div", when(info.at), "muted"));
|
|
var td2 = el("td"); td2.appendChild(el("code", line));
|
|
var pin = el("div", null, "muted");
|
|
pin.appendChild(document.createTextNode("this build only: "));
|
|
pin.appendChild(el("code", reg + "/ipfs/" + info.cid));
|
|
td2.appendChild(pin);
|
|
if (pub.public && base) {
|
|
var anywhere = el("div", null, "muted");
|
|
anywhere.appendChild(document.createTextNode("without IPCR: "));
|
|
anywhere.appendChild(el("code", "docker pull ipcr-" + base + "/ipns/" + path + ":" + tag));
|
|
td2.appendChild(anywhere);
|
|
}
|
|
var td3 = el("td"); td3.appendChild(copyButton(line));
|
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
|
tb.t.appendChild(tr);
|
|
});
|
|
imagesBody.appendChild(tb.wrap);
|
|
});
|
|
})
|
|
.catch(function () {
|
|
imagesBody.innerHTML = "";
|
|
imagesBody.appendChild(el("p", "Nothing published yet. Tag v1.0.0 on the example repository (above), or push a v* tag from a repository with a workflow; its image appears here a few minutes later.", "muted"));
|
|
});
|
|
|
|
// Radicle mirrors: the bridge's state.
|
|
var reposBody = document.getElementById("repos-body");
|
|
fetch("repos.json", { cache: "no-store" })
|
|
.then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
|
|
.then(function (repos) {
|
|
if (!repos || !repos.length) throw new Error("empty");
|
|
reposBody.textContent = "";
|
|
var tb = table(["Repository", "Radicle", "State"]);
|
|
repos.forEach(function (r) {
|
|
var tr = el("tr");
|
|
var td1 = el("td");
|
|
// Not r.html_url: the bridge talks to Gitea's internal address, which is what Gitea puts there.
|
|
td1.appendChild(base ? link(url("gitea") + "/" + r.full_name, r.full_name) : el("span", r.full_name));
|
|
var td2 = el("td");
|
|
if (r.rid) {
|
|
td2.appendChild(base ? link(url("radicle") + "/nodes/radicle-api-" + base + "/" + r.rid, r.rid) : el("code", r.rid));
|
|
var clone = el("div", null, "muted");
|
|
clone.appendChild(el("code", "rad clone " + r.rid));
|
|
td2.appendChild(clone);
|
|
} else { td2.appendChild(el("span", "—", "muted")); }
|
|
var td3 = el("td");
|
|
td3.appendChild(el("span", r.state, "state " + r.state));
|
|
if (r.last_sync) td3.appendChild(el("div", when(r.last_sync), "muted"));
|
|
if (r.error) td3.appendChild(el("div", r.error, "muted"));
|
|
tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3);
|
|
tb.t.appendChild(tr);
|
|
});
|
|
reposBody.appendChild(tb.wrap);
|
|
})
|
|
.catch(function () {
|
|
reposBody.innerHTML = "";
|
|
reposBody.appendChild(el("p", "No public repository mirrored yet. Every public repository in Gitea appears here within a minute of its first push.", "muted"));
|
|
});
|
|
|
|
// The node's ID. The Radicle API is public and CORS-enabled (the explorer calls it from the browser).
|
|
if (base) {
|
|
fetch(url("radicle-api") + "/api/v1/node", { cache: "no-store" })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (node) { if (node.id) document.getElementById("node-id").textContent = node.id; })
|
|
.catch(function () {});
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|