aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/dashboard/dashboard.html
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-11-06 20:56:23 +0100
committertv <tv@krebsco.de>2026-11-06 20:56:23 +0100
commit7972812fcd2acc4b1d4ab86045d8160b0f0f6ae8 (patch)
tree78adf8464c4821afdff5b34f6d3c372913a6897c /src/api/dashboard/dashboard.html
parente9ba6e3119b41935a36addca9cb0afa85074b7ac (diff)
dashboard: remake
Diffstat (limited to 'src/api/dashboard/dashboard.html')
-rw-r--r--src/api/dashboard/dashboard.html166
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