aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authortv <tv@krebsco.de>2026-10-30 19:14:00 +0100
committertv <tv@krebsco.de>2026-10-30 19:14:00 +0100
commitc9f556a6d329117673adfc8a9237b6b81b9ade72 (patch)
tree57141d17c684b12ca5255e0852229120d23a82e6 /src
parent289b84b09a791b88f039664d71bb644c94de6000 (diff)
dashboard: add document view
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/dashboard.html88
1 files changed, 83 insertions, 5 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index a5181ca..dd0c3cc 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -32,12 +32,12 @@
the figures (the rest) on the right. */
main { flex: 1; min-height: 0; display: grid; gap: 16px; padding: 16px;
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
- grid-template-rows: auto minmax(0, 1fr);
- grid-template-areas: "map peers" "map figures"; }
+ grid-template-rows: auto minmax(0, 1fr) minmax(0, 1fr);
+ grid-template-areas: "map peers" "map figures" "map document"; }
@media (max-width: 900px) {
body { height: auto; }
- main { grid-template-columns: minmax(0, 1fr); grid-template-rows: 70vh auto 70vh;
- grid-template-areas: "map" "peers" "figures"; }
+ main { grid-template-columns: minmax(0, 1fr); grid-template-rows: 70vh auto 70vh 70vh;
+ grid-template-areas: "map" "peers" "figures" "document"; }
}
section { background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
padding: 12px; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
@@ -47,6 +47,7 @@
#map { grid-area: map; }
#peers-box { grid-area: peers; }
#figures-box { grid-area: figures; }
+ #document-box { grid-area: document; }
svg { flex: 1; min-height: 0; width: 100%; display: block; cursor: default; }
svg circle { cursor: pointer; }
.legend { color: var(--muted); font-size: 12px; display: flex; gap: 1em; flex-wrap: wrap;
@@ -67,7 +68,13 @@
.legend .node.focus::before { width: 14px; height: 14px; background: none;
border: 2px solid var(--self); opacity: .5; }
.scroll { overflow: auto; min-height: 0; }
- #figures-box .scroll { flex: 1; }
+ #figures-box .scroll, #document-box .scroll { flex: 1; }
+ h2 button { float: right; font: inherit; text-transform: none; letter-spacing: 0;
+ color: var(--muted); background: none; border: 1px solid var(--line); border-radius: 4px;
+ padding: 0 6px; cursor: pointer; }
+ .tree pre { margin: 2px 0 4px 1em; white-space: pre-wrap; word-break: break-all;
+ max-height: 20em; overflow: auto; }
+ .note { color: var(--muted); font-style: italic; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 3px 8px 3px 0; border-bottom: 1px solid var(--line);
white-space: nowrap; }
@@ -110,6 +117,10 @@
<h2 id="figures-title">Figures</h2>
<div class="scroll"><div id="stats" class="tree"></div></div>
</section>
+ <section id="document-box">
+ <h2>Document <button id="document-reload" title="Read again what is open">reload</button></h2>
+ <div class="scroll"><div id="document" class="tree"></div></div>
+ </section>
</main>
<script>
"use strict";
@@ -517,6 +528,73 @@ function renderGraph() {
root.replaceChildren(...children);
}
+// --- Document: read a level at a time (`/store?shallow=true`) as it is opened ---
+// What is open, by path (as JSON), kept across reloads of the tree.
+const documentOpen = new Set();
+function storeUrl(path, shallow) {
+ const where = path.length ? "../store/" + path.map(encodeURIComponent).join("/") : "../store";
+ return where + (shallow ? "?shallow=true" : "");
+}
+// Store paths split at "/": a key with one (or an empty key) can't be read on its own.
+const addressable = path => path.every(segment => segment !== "" && !segment.includes("/"));
+function describe(summary) {
+ switch (summary.type) {
+ case "map": return "{" + summary.size + (summary.size === 1 ? " key}" : " keys}");
+ case "list": return "[" + summary.size + (summary.size === 1 ? " item]" : " items]");
+ case "text": return "text, " + summary.size + " characters";
+ default: return summary.type + ("size" in summary ? ", " + summary.size + " bytes" : "");
+ }
+}
+async function readStore(path, shallow) {
+ const response = await fetch(storeUrl(path, shallow), { headers: { Accept: "application/json" } });
+ if (!response.ok) throw new Error("HTTP " + response.status);
+ return response.json();
+}
+// One entry: a short scalar as it is; anything else a branch read when opened.
+function documentEntry(key, summary, path) {
+ if ("value" in summary) {
+ return el("div", {}, el("span", { class: "key" }, key + ": "), JSON.stringify(summary.value));
+ }
+ const details = el("details", {});
+ details.append(el("summary", {}, el("span", { class: "key" }, key), " ", describe(summary)));
+ if (!addressable(path)) {
+ details.append(el("div", { class: "note" }, "Its path can't be read on its own (a key with \"/\")."));
+ return details;
+ }
+ const id = JSON.stringify(path);
+ let read = false;
+ const load = async () => {
+ if (read) return;
+ read = true;
+ try {
+ if (summary.type === "map") {
+ const shallow = await readStore(path, true);
+ const entries = Object.entries(shallow.entries || {});
+ if (!entries.length) details.append(el("div", { class: "note" }, "empty"));
+ for (const [k, child] of entries) details.append(documentEntry(k, child, [...path, k]));
+ } else {
+ const value = await readStore(path, false);
+ details.append(el("pre", {},
+ typeof value === "string" ? value : JSON.stringify(value, null, 2)));
+ }
+ } catch (error) {
+ details.append(el("div", { class: "note" }, "Not read: " + error.message));
+ }
+ };
+ details.addEventListener("toggle", () => {
+ if (details.open) { documentOpen.add(id); load(); } else documentOpen.delete(id);
+ });
+ if (documentOpen.has(id)) { details.open = true; load(); }
+ return details;
+}
+function renderDocument() {
+ const root = documentEntry("store", { type: "map", size: "…" }, []);
+ root.querySelector("summary").lastChild.textContent = "(open to read)";
+ document.getElementById("document").replaceChildren(root);
+}
+document.getElementById("document-reload").addEventListener("click", renderDocument);
+renderDocument();
+
function render() {
if (!state) return;
document.getElementById("node").textContent =