diff options
| author | tv <tv@krebsco.de> | 2026-11-06 20:56:23 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-06 20:56:23 +0100 |
| commit | 7972812fcd2acc4b1d4ab86045d8160b0f0f6ae8 (patch) | |
| tree | 78adf8464c4821afdff5b34f6d3c372913a6897c /src/api/dashboard/dashboard.html | |
| parent | e9ba6e3119b41935a36addca9cb0afa85074b7ac (diff) | |
dashboard: remake
Diffstat (limited to 'src/api/dashboard/dashboard.html')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 166 |
1 files changed, 164 insertions, 2 deletions
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 @@ <div class="scroll"><div id="stats" class="tree"></div></div> </section> <section id="document-box"> - <h2>Document</h2> + <h2>Document<button id="remake-toggle" class="toggle" aria-expanded="false" + title="Propose a remake of the document: compacted, replaced by JSON, or imported from an Automerge document (docs/remakes.md)">remake…</button></h2> + <form id="remake" class="popdown" hidden> + <label>kind <select id="remake-kind"> + <option value="compact">compact</option> + <option value="replace">replace</option> + <option value="import">import</option> + </select></label> + <p class="note" data-kind="compact">The same content, without its history.</p> + <div data-kind="replace" hidden> + <textarea id="remake-json" rows="8" spellcheck="false" + placeholder='The new store root, as JSON: {"accounts": {}}'></textarea> + <label class="file">or from a file <input type="file" id="remake-json-file" + accept="application/json,.json"></label> + </div> + <div data-kind="import" hidden> + <label class="file">Automerge document (a backup) <input type="file" + id="remake-import-file"></label> + </div> + <div data-kind="replace import" hidden> + <div class="rules-head">rules <span class="note">where writes made after the base + go: a path prefix moved, or dropped</span></div> + <div id="remake-rules"></div> + <button type="button" id="remake-add-rule" class="toggle">add rule</button> + </div> + <label>settle <input id="remake-settle" size="6" placeholder="default" + title="How long votes are collected: seconds, or e.g. 500ms, 8m; empty: twice the dormancy threshold"></label> + <p class="note">Writes made meanwhile that no rule covers are quarantined on the node + that made them.</p> + <div class="actions"><button type="submit" class="toggle">propose</button> + <button type="button" id="remake-cancel" class="toggle">cancel</button></div> + <p id="remake-result" class="note" hidden></p> + </form> <div class="scroll"><div id="document" class="tree"></div></div> </section> <div id="column-hint" class="note">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 |
