604 lines
34 KiB
HTML
604 lines
34 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 admin</title>
|
|
<link rel="icon" href="icon.png">
|
|
<!--
|
|
The forge's admin page: Gitea site administrators only (the forge service checks the session for the
|
|
page and for every API call). Everything goes through /admin/api/: the forge service's own endpoints
|
|
(repositories, key export) and IPCR's admin API behind /admin/api/ipcr/.
|
|
-->
|
|
<style>
|
|
:root {
|
|
--bg: #f7f8fa; --card: #ffffff; --ink: #0f172a; --muted: #5b6475; --line: #e3e7ee;
|
|
--accent: #0f766e; --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; --code: #0d1626; --ok: #4ade80; --warn: #fbbf24; --bad: #f87171;
|
|
}
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; background: var(--bg); color: var(--ink);
|
|
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
main { max-width: 1080px; margin: 0 auto; padding: 24px 16px 64px; }
|
|
header { display: flex; gap: 14px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
|
header .title { display: flex; gap: 12px; align-items: center; }
|
|
header img { width: 40px; height: 40px; }
|
|
h1 { font-size: 24px; margin: 0; }
|
|
h2 { font-size: 19px; margin: 32px 0 10px; }
|
|
h3 { font-size: 15px; margin: 14px 0 6px; }
|
|
p { margin: 6px 0; }
|
|
a { color: var(--accent); }
|
|
.muted { color: var(--muted); }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
|
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; min-width: 0; }
|
|
.card h3 { margin-top: 0; }
|
|
dl { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; font-size: 14px; }
|
|
dt { color: var(--muted); }
|
|
dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
|
code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12.5px; background: var(--code);
|
|
padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; }
|
|
.ok { color: var(--ok); font-weight: 600; } .warn { color: var(--warn); font-weight: 600; } .bad { color: var(--bad); font-weight: 600; }
|
|
button, input[type=submit] { border: 1px solid var(--line); background: var(--bg); color: var(--ink); border-radius: 6px;
|
|
font: inherit; font-size: 13px; padding: 4px 10px; cursor: pointer; }
|
|
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;
|
|
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; }
|
|
th, td { text-align: left; padding: 7px 8px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
|
th { color: var(--muted); font-weight: 600; font-size: 12.5px; }
|
|
.scroll { overflow-x: auto; }
|
|
.actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.bar { height: 8px; background: var(--code); border-radius: 4px; overflow: hidden; margin: 6px 0; }
|
|
.bar span { display: block; height: 100%; background: var(--accent); }
|
|
.note { border-left: 3px solid var(--warn); padding: 4px 12px; color: var(--muted); margin: 8px 0; }
|
|
.wide { grid-column: 1 / -1; }
|
|
/* Names, CIDs and commands stay on one line; their box scrolls instead. */
|
|
.line { display: block; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; }
|
|
.copyrow { display: flex; gap: 8px; align-items: center; min-width: 0; }
|
|
.copyrow code { flex: 1; min-width: 0; white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; }
|
|
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--line); margin: 20px 0 4px; overflow-x: auto; }
|
|
.tabs button { border: 0; border-bottom: 2px solid transparent; border-radius: 0; background: none; color: var(--muted);
|
|
padding: 8px 14px; font-size: 14px; white-space: nowrap; }
|
|
.tabs button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
|
|
.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; }
|
|
.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; }
|
|
.cmd code { white-space: nowrap; overflow-x: auto; overflow-wrap: normal; padding: 3px 6px; min-width: 0; }
|
|
.spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line); border-top-color: var(--accent);
|
|
border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -1px; margin-right: 6px; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
#toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--ink); color: var(--bg);
|
|
padding: 8px 14px; border-radius: 8px; font-size: 14px; display: none; max-width: calc(100% - 32px); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<div class="title"><img src="icon.png" alt=""><div><h1>IPCR Forge admin</h1>
|
|
<div class="muted"><a href="/">Forge page</a> · signed in as <b id="me">…</b></div></div></div>
|
|
<button id="logout">Sign out</button>
|
|
</header>
|
|
|
|
<nav class="tabs" role="tablist">
|
|
<button role="tab" data-tab="overview">Overview</button>
|
|
<button role="tab" data-tab="images">Published images<span class="count" id="images-count">…</span></button>
|
|
<button role="tab" data-tab="repos">Repositories</button>
|
|
<button role="tab" data-tab="backup">Key backup</button>
|
|
</nav>
|
|
|
|
<section class="panel" id="tab-overview">
|
|
<h2>Publisher name</h2>
|
|
<div class="grid">
|
|
<div class="card wide">
|
|
<h3>Key</h3>
|
|
<dl>
|
|
<dt>Key</dt><dd><code id="key-name">…</code></dd>
|
|
<dt>IPNS name</dt><dd><div class="copyrow"><code id="key-id">…</code><button class="copy" data-copy="key-id">Copy</button></div></dd>
|
|
<dt>Pull as</dt><dd><div class="copyrow"><code id="pull-as">…</code></div></dd>
|
|
</dl>
|
|
<p class="muted">Every image is published under this one name. Back the key up (Key backup tab): it exists only on this server.</p>
|
|
</div>
|
|
<div class="card wide">
|
|
<h3>Your domain or ENS name</h3>
|
|
<p class="muted">Point a name you own at the IPNS name, then enter it here. Once it checks out, pull lines use it.</p>
|
|
<form class="row" id="name-form">
|
|
<input type="text" id="name" placeholder="example.eth or example.com" size="30" autocomplete="off">
|
|
<input type="submit" value="Save and check">
|
|
</form>
|
|
<p id="name-check" class="muted"></p>
|
|
<details><summary class="muted">How to point it here</summary>
|
|
<p><b>ENS:</b> in the ENS manager, Records → Content hash =</p>
|
|
<div class="copyrow"><code id="ens-value">ipns://k51…</code><button class="copy" data-copy="ens-value">Copy</button></div>
|
|
<p><b>DNS:</b> a TXT record <code>_dnslink.example.com</code> =</p>
|
|
<div class="copyrow"><code id="dns-value">dnslink=/ipns/k51…</code><button class="copy" data-copy="dns-value">Copy</button></div>
|
|
</details>
|
|
</div>
|
|
<div class="card wide">
|
|
<h3>Public pulls</h3>
|
|
<p class="muted">This forge's images, and only those, can also be pulled from any machine with plain Docker, without IPCR,
|
|
through <code id="public-host">ipcr-…</code>. Read-only; a pull there pins nothing. Layers are still checked by digest.</p>
|
|
<form class="row" id="public-form">
|
|
<label><input type="checkbox" id="public-on"> Serve public pulls</label>
|
|
<span id="public-state" class="muted"></span>
|
|
</form>
|
|
<div class="copyrow"><code id="public-try">docker pull …</code><button class="copy" data-copy="public-try">Copy</button></div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2>Health <button id="refresh">Check again</button> <span id="refresh-state" class="muted"></span></h2>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>IPNS record</h3>
|
|
<dl id="record"></dl>
|
|
<div class="actions" style="margin-top:8px"><button id="republish">Republish now</button></div>
|
|
<p class="muted">The record must be renewed by this node while it is online (Kubo does it every 4 h). Lifetime is how long a
|
|
record stays valid if this node goes offline; TTL is how long resolvers cache it.</p>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Storage</h3>
|
|
<div id="storage"></div>
|
|
<p class="muted">Published images are pinned and never collected. Unpublishing an image frees the blocks no other image uses.</p>
|
|
</div>
|
|
<div class="card wide">
|
|
<h3>Node</h3>
|
|
<dl id="node"></dl>
|
|
<h3 style="margin-top:14px">Listening on</h3>
|
|
<ul class="addrs" id="addrs"></ul>
|
|
<div class="actions" style="margin-top:10px"><button id="peers">What the network sees</button></div>
|
|
<div id="peers-out" class="muted"></div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel" id="tab-images" hidden>
|
|
<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 id="images" class="muted">Loading…</div>
|
|
</section>
|
|
|
|
<section class="panel" id="tab-repos" hidden>
|
|
<h2>Root organisation</h2>
|
|
<div class="card">
|
|
<p class="muted">The public repositories of this Gitea organisation are also published at the root of the forge's
|
|
name: <code>metadec/app</code> → <code>…/ipns/<name>/app</code>. Its members (Gitea teams) are the ones who
|
|
can publish there. Leave empty for none.</p>
|
|
<form class="row" id="org-form">
|
|
<input type="text" id="org" placeholder="organisation" size="24" autocomplete="off">
|
|
<input type="submit" value="Save">
|
|
</form>
|
|
<p id="org-state" class="muted"></p>
|
|
</div>
|
|
|
|
<h2>Which repositories may publish</h2>
|
|
<p class="muted">Each public repository switched on here publishes under its own name only: the forge sets an
|
|
<code>IPCR_PUSH_TOKEN</code> secret on it, and the staging registry accepts that token for that name and no other.
|
|
Workflows log in with it (see the forge page's template). New public repositories are on.</p>
|
|
<div id="repos" class="muted">Loading…</div>
|
|
</section>
|
|
|
|
<section class="panel" id="tab-backup" hidden>
|
|
<h2>Key backup</h2>
|
|
<div class="grid">
|
|
<div class="card">
|
|
<h3>Download a backup</h3>
|
|
<p class="muted">The key, encrypted with your passphrase (at least 12 characters). Keep the file and the passphrase apart.
|
|
Without a backup, losing this server loses the name for good.</p>
|
|
<form class="row" id="export-form">
|
|
<input type="password" id="ex-pass" placeholder="passphrase" autocomplete="new-password" required>
|
|
<input type="password" id="ex-pass2" placeholder="again" autocomplete="new-password" required>
|
|
<input type="submit" value="Download">
|
|
</form>
|
|
</div>
|
|
<div class="card">
|
|
<h3>Restore a backup</h3>
|
|
<p class="muted">Makes the backed-up key this forge's publisher key: to move a forge to a new server, or recover one.
|
|
The current key is kept (renamed), not deleted.</p>
|
|
<form class="row" id="import-form">
|
|
<input type="file" id="im-file" accept=".json,application/json" required>
|
|
<input type="password" id="im-pass" placeholder="passphrase" autocomplete="off" required>
|
|
<input type="number" id="im-seq" placeholder="sequence (only if asked)" min="1">
|
|
<input type="submit" value="Restore">
|
|
</form>
|
|
<p class="note">Every image name then points at this server's images under the restored name. On a fresh server, the
|
|
name's last published images are adopted.</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<div id="toast"></div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
var $ = function (id) { return document.getElementById(id); };
|
|
var publisher = "", pubName = "";
|
|
// The public front door's host: same suffix as this page, ipcr- instead of ipcr-forge-.
|
|
var publicHost = location.hostname.indexOf("ipcr-forge-") === 0 ? "ipcr-" + location.hostname.slice("ipcr-forge-".length) : "";
|
|
|
|
function toast(msg, ms) {
|
|
var t = $("toast"); t.textContent = msg; t.style.display = "block";
|
|
clearTimeout(toast.t); toast.t = setTimeout(function () { t.style.display = "none"; }, ms || 4000);
|
|
}
|
|
// Every call: same origin, with the header the forge service requires on writes.
|
|
function api(method, path, body) {
|
|
var opt = { method: method, headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin", cache: "no-store" };
|
|
if (body !== undefined) { opt.headers["Content-Type"] = "application/json"; opt.body = JSON.stringify(body); }
|
|
return fetch("/admin/api/" + path, opt).then(function (r) {
|
|
if (r.status === 401) { location.href = "/admin/login"; throw new Error("signed out"); }
|
|
return r.text().then(function (t) {
|
|
var j = null; try { j = JSON.parse(t); } catch (e) {}
|
|
if (!r.ok) throw new Error((j && j.error) || t || r.statusText);
|
|
return j;
|
|
});
|
|
});
|
|
}
|
|
function el(tag, text, cls) { var e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; }
|
|
function dl(target, rows) {
|
|
target.textContent = "";
|
|
rows.forEach(function (r) {
|
|
target.appendChild(el("dt", r[0]));
|
|
var dd = el("dd");
|
|
if (r[1] instanceof Node) dd.appendChild(r[1]); else dd.textContent = r[1] == null ? "—" : r[1];
|
|
target.appendChild(dd);
|
|
});
|
|
}
|
|
function badge(text, cls) { return el("span", text, cls); }
|
|
function size(n) {
|
|
if (!n) return "0";
|
|
var u = ["B", "KB", "MB", "GB", "TB"], i = 0;
|
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
|
return n.toFixed(i ? 1 : 0) + " " + u[i];
|
|
}
|
|
function until(iso) {
|
|
if (!iso) return "";
|
|
var h = (Date.parse(iso) - Date.now()) / 3600000;
|
|
if (h < 0) return "expired";
|
|
return h > 48 ? Math.round(h / 24) + " days left" : Math.round(h) + " h left";
|
|
}
|
|
function busy(btn, p) {
|
|
btn.disabled = true;
|
|
return p.then(function (v) { btn.disabled = false; return v; }, function (e) { btn.disabled = false; toast(e.message, 8000); throw e; });
|
|
}
|
|
|
|
api("GET", "me").then(function (me) { $("me").textContent = me.user; });
|
|
$("logout").onclick = function () {
|
|
fetch("/admin/logout", { method: "POST", headers: { "X-IPCR-Admin": "1" }, credentials: "same-origin" })
|
|
.then(function () { location.href = "/"; });
|
|
};
|
|
|
|
// ---- tabs (the open one is kept in the URL, so a reload stays on it)
|
|
var tabs = document.querySelectorAll(".tabs button");
|
|
function showTab(name) {
|
|
if (!$("tab-" + name)) name = "overview";
|
|
Array.prototype.forEach.call(tabs, function (b) {
|
|
var on = b.getAttribute("data-tab") === name;
|
|
b.setAttribute("aria-selected", on ? "true" : "false");
|
|
$("tab-" + b.getAttribute("data-tab")).hidden = !on;
|
|
});
|
|
}
|
|
Array.prototype.forEach.call(tabs, function (b) {
|
|
b.onclick = function () { history.replaceState(null, "", "#" + b.getAttribute("data-tab")); showTab(b.getAttribute("data-tab")); };
|
|
});
|
|
showTab(location.hash.slice(1));
|
|
|
|
// ---- copy
|
|
function copy(text, btn) {
|
|
var done = function () { if (btn) { btn.textContent = "Copied"; setTimeout(function () { btn.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);
|
|
}
|
|
function copyButton(text) { var b = el("button", "Copy", "copy"); b.type = "button"; b.onclick = function () { copy(text, b); }; return b; }
|
|
Array.prototype.forEach.call(document.querySelectorAll("button[data-copy]"), function (b) {
|
|
b.type = "button";
|
|
b.onclick = function () { copy($(b.getAttribute("data-copy")).textContent, b); };
|
|
});
|
|
function spinner(text) { var s = el("span"); s.appendChild(el("span", null, "spin")); s.appendChild(document.createTextNode(text)); return s; }
|
|
|
|
// ---- status
|
|
function recordView(r, err, tree) {
|
|
if (!r) return badge(err || "none", "bad");
|
|
var s = el("span");
|
|
s.appendChild(badge(r.valid ? "valid" : "invalid", r.valid ? "ok" : "bad"));
|
|
s.appendChild(document.createTextNode(" · sequence " + r.sequence + " · " + until(r.validity)));
|
|
if (tree && r.value !== tree) { s.appendChild(document.createTextNode(" · ")); s.appendChild(badge("older tree", "warn")); }
|
|
return s;
|
|
}
|
|
function showName(n) {
|
|
var nc = $("name-check"); nc.textContent = "";
|
|
if (n && n.name) {
|
|
nc.appendChild(n.ok ? badge("✓ " + n.name + " points at this forge", "ok") : badge("✗ " + (n.error || "does not resolve"), "bad"));
|
|
if (!n.ok && n.resolves) nc.appendChild(el("div", n.name + " currently resolves to " + n.resolves, "muted"));
|
|
if (n.checkedAt) nc.appendChild(el("div", "Checked " + n.checkedAt.replace("T", " ").replace("Z", " UTC"), "muted"));
|
|
} else nc.textContent = "No name set: pull lines use the IPNS name.";
|
|
}
|
|
function loadStatus(refresh) {
|
|
var st8 = $("refresh-state");
|
|
st8.textContent = ""; st8.appendChild(spinner(refresh ? "Checking the network…" : "Loading…"));
|
|
return api("GET", "ipcr/status" + (refresh ? "?refresh=1" : "")).then(function (s) {
|
|
st8.textContent = "";
|
|
publisher = s.key.id || "";
|
|
$("key-name").textContent = s.key.name || "—";
|
|
$("key-id").textContent = publisher || "not created yet (IPCR creates it when it starts: reload in a minute)";
|
|
$("ens-value").textContent = "ipns://" + (publisher || "k51…");
|
|
$("dns-value").textContent = "dnslink=/ipns/" + (publisher || "k51…");
|
|
pubName = s.name && s.name.ok ? s.name.name : "";
|
|
$("pull-as").textContent = "docker pull ipcr.localhost:4767/ipns/" + (pubName || publisher || "…") + "/<owner>/<repo>:<tag>";
|
|
if (s.name && s.name.name && document.activeElement !== $("name")) $("name").value = s.name.name;
|
|
showName(s.name);
|
|
var rec = s.record;
|
|
dl($("record"), [
|
|
["Here", recordView(rec.local, rec.localError, rec.tree)],
|
|
["Network", recordView(rec.network, rec.networkError, rec.tree)],
|
|
["Lifetime", rec.lifetime], ["TTL", rec.ttl],
|
|
["Checked", s.checkedAt.replace("T", " ").replace("Z", " UTC")]
|
|
]);
|
|
var pid = el("div", null, "copyrow"); pid.appendChild(el("code", s.node.id)); pid.appendChild(copyButton(s.node.id));
|
|
dl($("node"), [["Peer ID", pid], ["Connected peers", s.node.peers]]);
|
|
// Public addresses first; loopback and the container's own network are of no use to anyone else.
|
|
var addrs = (s.node.addresses || []).filter(function (a) { return !/^\/ip4\/(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)|^\/ip6\/(::1|f[cd])/.test(a); });
|
|
var ul = $("addrs"); ul.textContent = "";
|
|
if (!addrs.length) ul.appendChild(el("li", "no public address", "warn"));
|
|
addrs.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a.replace(/\/p2p\/[^/]+$/, ""), "line")); ul.appendChild(li); });
|
|
var sto = s.storage, pct = sto.storageMax ? Math.min(100, 100 * sto.repoSize / sto.storageMax) : 0;
|
|
$("storage").textContent = "";
|
|
$("storage").appendChild(el("div", size(sto.repoSize) + " of " + size(sto.storageMax) + " · " + sto.pins + " pins"));
|
|
var bar = el("div", null, "bar"), fill = el("span"); fill.style.width = pct.toFixed(1) + "%"; bar.appendChild(fill);
|
|
$("storage").appendChild(bar);
|
|
}).catch(function (e) { st8.textContent = ""; toast("Status: " + e.message, 8000); });
|
|
}
|
|
function loadPublic() {
|
|
$("public-host").textContent = publicHost || "ipcr-<domain>";
|
|
return api("GET", "ipcr/config").then(function (c) {
|
|
$("public-on").checked = !!c.public;
|
|
$("public-state").textContent = c.public ? "on" : "off";
|
|
$("public-try").textContent = "docker pull " + (publicHost || "ipcr-<domain>") + "/ipns/" + (pubName || publisher || "k51…") + "/<repo>:<tag>";
|
|
});
|
|
}
|
|
$("public-on").onchange = function () {
|
|
var cb = this, st = $("public-state");
|
|
cb.disabled = true; st.textContent = ""; st.appendChild(spinner("saving…"));
|
|
api("PATCH", "ipcr/config", { public: cb.checked })
|
|
.then(function (c) { toast(c.public ? "Public pulls on (within 10 s)" : "Public pulls off (within 10 s)"); return loadPublic(); },
|
|
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
|
|
.then(function () { cb.disabled = false; loadImages(); });
|
|
};
|
|
$("refresh").onclick = function () { busy(this, loadStatus(true)); };
|
|
$("republish").onclick = function () {
|
|
busy(this, api("POST", "ipcr/republish")).then(function () { toast("Published again"); loadStatus(true); });
|
|
};
|
|
$("peers").onclick = function () {
|
|
var o = $("peers-out"); o.textContent = ""; o.appendChild(spinner("Asking the network…"));
|
|
busy(this, api("GET", "ipcr/peers")).then(function (p) {
|
|
o.textContent = "";
|
|
if (p.error) o.appendChild(badge(p.error, "bad"));
|
|
else if (!p.network || !p.network.length) o.appendChild(badge("The network does not know this node's addresses yet: check that port 4768 (TCP and UDP) is reachable.", "warn"));
|
|
else {
|
|
o.appendChild(badge("✓ Other nodes can reach this one via:", "ok"));
|
|
var ul = el("ul", null, "addrs");
|
|
p.network.forEach(function (a) { var li = el("li"); li.appendChild(el("code", a, "line")); ul.appendChild(li); });
|
|
o.appendChild(ul);
|
|
}
|
|
}, function () { o.textContent = ""; });
|
|
};
|
|
$("name-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var btn = this.querySelector("input[type=submit]"), input = $("name"), name = input.value.trim();
|
|
var nc = $("name-check");
|
|
nc.textContent = "";
|
|
nc.appendChild(spinner(name ? "Saving, then resolving " + name + " (up to a minute)…" : "Clearing the name…"));
|
|
btn.disabled = input.disabled = true; btn.value = "Checking…";
|
|
api("PATCH", "ipcr/config", { name: name })
|
|
.then(function () { return loadStatus(true); })
|
|
.then(function () {
|
|
toast(name ? (pubName ? name + " checks out: pull lines use it" : "Saved: " + name + " does not point here yet") : "Name cleared");
|
|
loadImages();
|
|
}, function (err) { showName(null); toast(err.message, 8000); })
|
|
.then(function () { btn.disabled = input.disabled = false; btn.value = "Save and check"; });
|
|
};
|
|
|
|
// ---- 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) {
|
|
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]);
|
|
var box = el("div");
|
|
rows.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);
|
|
});
|
|
$("images-count").textContent = String(n);
|
|
}).catch(function (e) { $("images").textContent = "Images: " + e.message; });
|
|
}
|
|
|
|
// ---- repositories
|
|
function loadSettings() {
|
|
return api("GET", "settings").then(function (st) {
|
|
if (document.activeElement !== $("org")) $("org").value = st.rootOrg || "";
|
|
$("org-state").textContent = st.rootOrg ? "Publishing " + st.rootOrg + "'s repositories at the root." : "No root organisation.";
|
|
}).catch(function (e) { $("org-state").textContent = e.message; });
|
|
}
|
|
$("org-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var btn = this.querySelector("input[type=submit]"), st = $("org-state");
|
|
st.textContent = ""; st.appendChild(spinner("Saving, then updating credentials and IPCR's list…"));
|
|
btn.disabled = true;
|
|
api("PUT", "settings", { rootOrg: $("org").value.trim() })
|
|
.then(function (r) { toast(r.rootOrg ? r.rootOrg + " is the root organisation; short paths appear on IPCR's next pass" : "No root organisation"); return Promise.all([loadSettings(), loadRepos()]); },
|
|
function (err) { toast(err.message, 8000); return loadSettings(); })
|
|
.then(function () { btn.disabled = false; setTimeout(loadImages, 70000); });
|
|
};
|
|
function loadRepos() {
|
|
return api("GET", "repos").then(function (repos) {
|
|
var box = $("repos"); box.textContent = ""; box.className = "";
|
|
if (!repos.length) { box.appendChild(el("p", "No public repository yet.", "muted")); return; }
|
|
var t = el("table"), head = el("tr");
|
|
["Repository", "Published as", "Push credential", "Publish"].forEach(function (x) { head.appendChild(el("th", x)); });
|
|
t.appendChild(head);
|
|
repos.forEach(function (r) {
|
|
var tr = el("tr"), td1 = el("td"), td2 = el("td"), td3 = el("td"), td4 = el("td"), cb = document.createElement("input");
|
|
td1.appendChild(el("code", r.full_name));
|
|
(r.published_as || []).forEach(function (p, i) { var d = el("div"); d.appendChild(el("code", p + ":<tag>", i ? "muted" : "")); td2.appendChild(d); });
|
|
if (!r.publish) td2.appendChild(el("span", "—", "muted"));
|
|
if (r.collision) td2.appendChild(el("div", "no short path: " + r.collision, "warn"));
|
|
if (r.publish) {
|
|
td3.appendChild(r.credential ? badge("✓ IPCR_PUSH_TOKEN set", "ok") : badge("✗ missing", "bad"));
|
|
var rot = el("button", "Reissue"); rot.style.marginLeft = "8px";
|
|
rot.onclick = function () {
|
|
if (!confirm("Give " + r.full_name + " a new IPCR_PUSH_TOKEN? The old one stops working at once; running builds may fail.")) return;
|
|
busy(rot, api("POST", "repos/rotate", { full_name: r.full_name })).then(function () { toast("New token set on " + r.full_name); loadRepos(); });
|
|
};
|
|
td3.appendChild(rot);
|
|
} else td3.appendChild(el("span", "—", "muted"));
|
|
cb.type = "checkbox"; cb.checked = r.publish;
|
|
cb.onchange = function () {
|
|
cb.disabled = true;
|
|
api("PUT", "repos", { full_name: r.full_name, publish: cb.checked })
|
|
.then(function () { toast(r.full_name + (cb.checked ? ": publishing" : ": not publishing")); return loadRepos(); },
|
|
function (e) { cb.checked = !cb.checked; toast(e.message, 8000); })
|
|
.then(function () { cb.disabled = false; loadImages(); });
|
|
};
|
|
td4.appendChild(cb);
|
|
[td1, td2, td3, td4].forEach(function (x) { tr.appendChild(x); });
|
|
t.appendChild(tr);
|
|
});
|
|
var wrap = el("div", null, "scroll"); wrap.appendChild(t); box.appendChild(wrap);
|
|
}).catch(function (e) { $("repos").textContent = "Repositories: " + e.message; });
|
|
}
|
|
|
|
// ---- key backup
|
|
$("export-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var p = $("ex-pass").value;
|
|
if (p !== $("ex-pass2").value) { toast("The passphrases differ"); return; }
|
|
if (p.length < 12) { toast("At least 12 characters"); return; }
|
|
var btn = this.querySelector("input[type=submit]");
|
|
btn.disabled = true;
|
|
fetch("/admin/api/key/export", { method: "POST", credentials: "same-origin",
|
|
headers: { "X-IPCR-Admin": "1", "Content-Type": "application/json" }, body: JSON.stringify({ passphrase: p }) })
|
|
.then(function (r) {
|
|
if (!r.ok) return r.text().then(function (t) { throw new Error(t); });
|
|
var name = (r.headers.get("Content-Disposition") || "").replace(/.*filename="([^"]+)".*/, "$1") || "publisher.ipcrkey.json";
|
|
return r.blob().then(function (b) {
|
|
var a = document.createElement("a"); a.href = URL.createObjectURL(b); a.download = name;
|
|
document.body.appendChild(a); a.click(); a.remove();
|
|
$("ex-pass").value = $("ex-pass2").value = "";
|
|
toast("Saved " + name);
|
|
});
|
|
})
|
|
.catch(function (e) { toast(e.message, 8000); })
|
|
.then(function () { btn.disabled = false; });
|
|
};
|
|
$("import-form").onsubmit = function (e) {
|
|
e.preventDefault();
|
|
var f = $("im-file").files[0], btn = this.querySelector("input[type=submit]");
|
|
if (!f) return;
|
|
f.text().then(function (txt) {
|
|
var backup; try { backup = JSON.parse(txt); } catch (x) { throw new Error("Not a key backup file"); }
|
|
if (!confirm("Make " + backup.id + " this forge's publisher key?\n\nThe current key (" + (publisher || "none") + ") is kept, renamed. Pull lines with the current IPNS name stop following new releases.")) return;
|
|
var body = { backup: backup, passphrase: $("im-pass").value };
|
|
if ($("im-seq").value) body.sequence = parseInt($("im-seq").value, 10);
|
|
return busy(btn, api("POST", "ipcr/key/import", body)).then(function (r) {
|
|
toast(r.status || ("Publisher key is now " + r.id + (r.adopted ? " (network tree adopted)" : "")), 8000);
|
|
$("im-pass").value = ""; loadStatus(true); loadImages();
|
|
});
|
|
}).catch(function (e) { toast(e.message, 8000); });
|
|
};
|
|
|
|
loadStatus(false).then(loadPublic); loadImages(); loadRepos(); loadSettings();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|