From 7972812fcd2acc4b1d4ab86045d8160b0f0f6ae8 Mon Sep 17 00:00:00 2001 From: tv Date: Fri, 6 Nov 2026 20:56:23 +0100 Subject: dashboard: remake --- docs/dashboard.md | 9 +++ src/api/dashboard/dashboard.html | 166 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) diff --git a/docs/dashboard.md b/docs/dashboard.md index 5a36dd2..143fcc5 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -271,6 +271,15 @@ key, is listed but can't be opened. In the protocol data, values and keys get the same readings as in the figures: the aliases of node IDs (a member's entry, a vote, a coordinator), times and sizes. +**`remake…`** at the right of the title proposes a remake ([`remakes.md`](remakes.md)), +this node coordinating it: `compact` (the same content, without its history), +`replace` (the new store root as JSON, typed or from a file, which fills the text to +look at first) or `import` (an Automerge document, a backup, uploaded as it is); for the +last two, rules for the writes made meanwhile (a path prefix moved to another, or +dropped; what no rule covers is quarantined on the node that made it), and for any, how +long votes are collected. The answer shows below: the remake's ID, or why it was refused +(and the API's override, if any). The map shows the votes as they come. + ## Console The messages this node exchanged with its peers, one by one: when (on the 24-hour diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html index 3eda30a..da370e9 100644 --- a/src/api/dashboard/dashboard.html +++ b/src/api/dashboard/dashboard.html @@ -119,7 +119,26 @@ /* Its title on one line: what doesn't fit ends in an ellipsis (the whole of it its tooltip). */ #figures-box h2 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - #document-box { grid-area: document; } + #document-box { grid-area: document; position: relative; } + /* The remake's options, below the document's title, over the document. */ + .popdown { position: absolute; top: 34px; right: 12px; z-index: 5; width: min(28em, 90%); + background: var(--panel); border: 1px solid var(--line); border-radius: 6px; + padding: 10px; box-shadow: 0 4px 16px rgb(0 0 0 / .15); font-size: 12px; + display: flex; flex-direction: column; gap: 8px; } + .popdown[hidden] { display: none; } + .popdown textarea { width: 100%; box-sizing: border-box; font-family: ui-monospace, monospace; + font-size: 12px; background: var(--bg); color: var(--fg); border: 1px solid var(--line); } + .popdown input, .popdown select { font: inherit; background: var(--bg); color: var(--fg); + border: 1px solid var(--line); } + .popdown label.file { display: block; margin-top: 4px; } + .popdown .note { margin: 0; } + .popdown .actions { display: flex; gap: 6px; } + .popdown .actions button.toggle { float: none; margin-left: 0; } + #remake-result.bad { color: var(--bad); } + .popdown .rule { display: flex; gap: 4px; align-items: center; margin-bottom: 4px; } + .popdown .rule input[type=text] { flex: 1; min-width: 0; font-family: ui-monospace, monospace; } + .popdown .rules-head .note { font-style: italic; } + .popdown button.toggle { float: none; margin-left: 0; } #console-box { grid-area: console; } #console-box .scroll { flex: 1; } .console { font-family: ui-monospace, monospace; font-size: 12px; white-space: pre-wrap; } @@ -311,7 +330,39 @@
-

Document

+

Document

+
Drag the space left of these boxes to the right to @@ -2803,6 +2854,117 @@ function connect() { } }); } +// --- Remakes: proposed from here (`POST /admin/remakes`, `docs/remakes.md`): compact, +// replace by JSON (typed, or from a file), import an Automerge document (uploaded as +// it is). This node coordinates it and votes for it; the map shows it as it goes. --- +const remakeForm = document.getElementById("remake"); +const remakeToggle = document.getElementById("remake-toggle"); +function showRemake(shown) { + remakeForm.hidden = !shown; + remakeToggle.setAttribute("aria-expanded", String(shown)); + if (shown) remakeKindChanged(); +} +function remakeKindChanged() { + const kind = document.getElementById("remake-kind").value; + for (const part of remakeForm.querySelectorAll("[data-kind]")) { + part.hidden = !part.dataset.kind.split(" ").includes(kind); + } +} +// A rule (`Rule` in the API): writes under `from` (a path, `/` between keys; empty: all) +// go under `to`, the rest of their path after it, or are dropped; the longest `from` +// that matches wins. +function addRemakeRule() { + const from = el("input", { type: "text", placeholder: "from (e.g. users)", + title: "A path prefix, keys separated by /; empty: every path" }); + const to = el("input", { type: "text", placeholder: "to (e.g. accounts)", + title: "Where those writes go instead (the rest of their path after it); empty: the root, so from and to both empty keeps every write where it is" }); + const drop = el("input", { type: "checkbox", title: "Drop those writes" }); + const remove = el("button", { type: "button", class: "toggle", title: "Remove this rule" }, "×"); + drop.addEventListener("change", () => { to.disabled = drop.checked; }); + const row = el("div", { class: "rule" }, from, "→", to, + el("label", {}, drop, "drop"), remove); + remove.addEventListener("click", () => row.remove()); + document.getElementById("remake-rules").append(row); +} +document.getElementById("remake-add-rule").addEventListener("click", addRemakeRule); +// The rules as the API takes them; a path as its keys (`/` between them). +function remakeRules() { + const keys = path => path.split("/").map(key => key.trim()).filter(key => key !== ""); + return [...document.querySelectorAll("#remake-rules .rule")].map(row => { + const [from, to] = row.querySelectorAll("input[type=text]"); + const drop = row.querySelector("input[type=checkbox]").checked; + return drop ? { from: keys(from.value), drop: true } + : { from: keys(from.value), to: keys(to.value) }; + }); +} +function remakeResult(text, bad) { + const result = document.getElementById("remake-result"); + result.hidden = false; + result.textContent = text; + result.classList.toggle("bad", bad); +} +remakeToggle.addEventListener("click", () => showRemake(remakeForm.hidden)); +document.getElementById("remake-cancel").addEventListener("click", () => showRemake(false)); +document.getElementById("remake-kind").addEventListener("change", remakeKindChanged); +document.addEventListener("keydown", event => { + if (event.key === "Escape" && !remakeForm.hidden) showRemake(false); +}); +// A JSON file fills the text area, to be looked at (and edited) before proposing. +document.getElementById("remake-json-file").addEventListener("change", event => { + const file = event.target.files[0]; + if (!file) return; + file.text().then(text => { document.getElementById("remake-json").value = text; }) + .catch(error => remakeResult("Reading " + file.name + ": " + error, true)); +}); +remakeForm.addEventListener("submit", event => { + event.preventDefault(); + const kind = document.getElementById("remake-kind").value; + const settle = document.getElementById("remake-settle").value.trim(); + let request; + if (kind === "import") { + // The document as it is (octet-stream): its options in the query. + const file = document.getElementById("remake-import-file").files[0]; + if (!file) { remakeResult("Choose the Automerge document to import.", true); return; } + const options = new URLSearchParams(); + if (settle) options.set("settle", settle); + const rules = remakeRules(); + if (rules.length) options.set("rules", JSON.stringify(rules)); + const query = options.size ? "?" + options : ""; + request = fetch("remakes" + query, { method: "POST", body: file, + headers: { "Content-Type": "application/octet-stream" } }); + } else { + const body = { kind }; + if (settle) body.settle = settle; + if (kind === "replace") { + let parsed; + try { + parsed = JSON.parse(document.getElementById("remake-json").value); + } catch (error) { + remakeResult("The new document isn't JSON: " + error.message, true); + return; + } + body.spec = { document: parsed }; + const rules = remakeRules(); + if (rules.length) body.spec.rules = rules; + } + request = fetch("remakes", { method: "POST", body: JSON.stringify(body), + headers: { "Content-Type": "application/json" } }); + } + remakeResult("Proposing…", false); + request + .then(response => response.json().catch(() => ({})).then(report => ({ response, report }))) + .then(({ response, report }) => { + if (response.ok) { + remakeResult("Proposed" + (report.id ? ": " + report.id : "") + + ". The map shows the votes as they come.", false); + } else { + remakeResult(response.status + ": " + (report.error || response.statusText) + + (report.force ? " (the API takes ?force=" + report.force + ")" : ""), true); + } + }) + .catch(error => remakeResult("Proposing failed: " + error, true)); +}); + connect(); // A page not shown (another tab, a minimised window) is paused: hidden for a while, it // closes its stream, so the node stops building updates and asking members for their -- cgit v1.3.1