diff options
| author | tv <tv@krebsco.de> | 2026-10-30 19:14:00 +0100 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-10-30 19:14:00 +0100 |
| commit | c9f556a6d329117673adfc8a9237b6b81b9ade72 (patch) | |
| tree | 57141d17c684b12ca5255e0852229120d23a82e6 /src/api/dashboard | |
| parent | 289b84b09a791b88f039664d71bb644c94de6000 (diff) | |
dashboard: add document view
Diffstat (limited to 'src/api/dashboard')
| -rw-r--r-- | src/api/dashboard/dashboard.html | 88 |
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 = |
