aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/dashboard/dashboard.html225
1 files changed, 218 insertions, 7 deletions
diff --git a/src/api/dashboard/dashboard.html b/src/api/dashboard/dashboard.html
index 9390af4..8e57363 100644
--- a/src/api/dashboard/dashboard.html
+++ b/src/api/dashboard/dashboard.html
@@ -235,6 +235,19 @@
border: 1.5px dashed var(--warn); }
.legend .node.focus::before { width: 14px; height: 14px; background: var(--self);
border: none; opacity: .35; }
+ .legend .node.subscribed::before { width: 6px; height: 6px; background: #000;
+ border: 4px solid var(--self); }
+ .legend .node.unreachable::before { width: 6px; height: 6px; background: #000;
+ border: 4px solid var(--muted); }
+ /* A node's menu (right-click): subscribe, its dashboard. */
+ #node-menu { position: fixed; z-index: 10; background: var(--panel);
+ border: 1px solid var(--line); border-radius: 6px; padding: 4px 0;
+ box-shadow: 0 4px 16px rgb(0 0 0 / .15); font-size: 12px; }
+ #node-menu[hidden] { display: none; }
+ #node-menu button { display: block; width: 100%; text-align: left; font: inherit;
+ padding: 4px 12px; background: none; border: none; color: var(--fg); cursor: pointer; }
+ #node-menu button:hover:not(:disabled) { background: var(--bg); }
+ #node-menu button:disabled { color: var(--muted); cursor: default; }
.scroll { overflow: auto; min-height: 0; }
#figures-box .scroll, #document-box .scroll { flex: 1; }
.tree pre { margin: 2px 0 4px 1.1em; white-space: pre-wrap; word-break: break-all;
@@ -316,6 +329,8 @@
<span class="node dormant-node">dormant (to this node)</span>
<span class="node clock-off">clock off the swarm's</span>
<span class="node focus">in focus (click a node)</span>
+ <span class="node subscribed">subscribed (its links read from it)</span>
+ <span class="node unreachable">subscribed, not reached from here</span>
</div>
<div class="legend" id="legend"></div>
<div class="legend" id="messages-legend"><button id="flows-toggle"
@@ -323,10 +338,12 @@
<div class="legend">
<span class="hint">drag to pan, wheel or pinch to zoom, turn with two fingers;
drag a node to move it, middle-click it to pin it there or let it go,
- double-click it for its dashboard</span>
+ double-click it to subscribe to its links (or not), right-click it for its
+ dashboard</span>
</div>
</div>
</div>
+ <div id="node-menu" hidden></div>
<svg id="graph"><g id="view"><g id="flows" pointer-events="none"></g><g id="flashes" pointer-events="none"></g></g></svg>
</div>
</section>
@@ -420,6 +437,21 @@ const handOver = (() => {
}
})();
+// The nodes whose own links this page reads from them (`follow`), kept in the browser,
+// and how each goes: declared before anything draws.
+const SUBSCRIPTIONS = "continuum-subscriptions";
+const subscriptions = (() => {
+ try {
+ const kept = JSON.parse(localStorage.getItem(SUBSCRIPTIONS) || "[]");
+ return new Set(Array.isArray(kept) ? kept.filter(id => typeof id === "string") : []);
+ } catch (_) {
+ return new Set();
+ }
+})();
+// Each subscription followed: `status` (`connecting`, `live`, `unreachable`), `why`
+// not, its latest `report` and when it came (`at`), and how to stop it (`stop`).
+const subscribed = new Map();
+
// Fields that grow by themselves: the node's list (`GROWING` and `is_age` in its
// dashboard), filled in when it serves the page.
const GROWING = new Set(__GROWING__);
@@ -1357,6 +1389,10 @@ function renderGraph() {
}
const pressed = element => {
element.addEventListener("pointerdown", event => { event.stopPropagation(); drag(event, id); });
+ element.addEventListener("contextmenu", event => {
+ event.preventDefault();
+ nodeMenu(id, event.clientX, event.clientY);
+ });
hoverable(element, id);
};
// A clock off the swarm's: a dashed orange ring.
@@ -1390,13 +1426,20 @@ function renderGraph() {
if (dash && countdownFrame === null) countdownFrame = requestAnimationFrame(tickCountdowns);
}
// Deciding: hollow, so its question mark shows.
- const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y, r: radius,
- fill: deciding ? "var(--panel)" : voting ? AMBER : member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)",
- stroke: dormant ? "var(--bad)" : voting ? AMBER : member.self ? "var(--self)" : "var(--fg)",
- "stroke-width": dormant ? 2.5 : 1.5 }, pressed);
+ // Subscribed (`subscriptions`): a black centre, and a ring of this node's colour a
+ // third of its diameter wide; grey while not reached from here.
+ const following = !member.self && subscriptions.has(id) ? subscribed.get(id) : undefined;
+ const ring = following && !dormant && !voting && !deciding;
+ const circle = drawing("node " + id, "circle", { cx: p.x, cy: p.y,
+ r: ring ? radius - radius / 3 : radius,
+ fill: deciding ? "var(--panel)" : voting ? AMBER : ring ? "#000" : member.self ? "var(--self)" : reported ? "var(--fg)" : "var(--panel)",
+ stroke: dormant ? "var(--bad)" : voting ? AMBER : ring ? (following.status === "live" ? "var(--self)" : "var(--muted)") : member.self ? "var(--self)" : "var(--fg)",
+ "stroke-width": dormant ? 2.5 : ring ? radius * 2 / 3 : 1.5 }, pressed);
// Its clock against the swarm's, as the members' reports chain it (the node's).
const clock = ((state.graph.clocks || {})[id]);
tooltip(circle, id + (reported ? "" : " (no links reported)") +
+ (following ? (following.status === "live" ? " (subscribed)" :
+ " (subscribed, not reached from here: " + following.why + ")") : "") +
(dormant ? " (dormant)" : "") +
(coordinating.has(id) ? " (coordinates a remake)" : voting ? " (voted for a remake)" : "") +
(clock && clock.off ? " (clock " + seconds(clock.offsetMs) + " off the swarm's, ±" +
@@ -1842,6 +1885,7 @@ function busy(box) {
let held = false;
function render(ages = false) {
if (!state) return;
+ applySubscriptions();
document.getElementById("node").textContent =
(state.node.alias ? state.node.alias + " · " : "") + state.node.id;
const skew = state.stats.clock;
@@ -2112,7 +2156,8 @@ graph.addEventListener("pointerdown", event => {
// Not the browser's drag and drop of the picture.
graph.addEventListener("dragstart", event => event.preventDefault());
// A node: dragged, it moves, held where the pointer is (`grabbed`) until let go; a click,
-// it is in focus; a second one soon after opens its dashboard (`openDashboard`). A
+// it is in focus; a second one soon after subscribes to it, or no longer
+// (`toggleSubscription`); a right click shows its menu (`nodeMenu`). A
// middle click pins it where it is (`pinned`: the layout leaves it
// there), or lets it go if pinned (`unpin`); dragged with the middle button, it is
// pinned where it's dropped. Told apart here: the map holds the pointer while pressed
@@ -2147,13 +2192,177 @@ function drag(event, id) {
const now = performance.now();
if (lastNodeClick.id === id && now - lastNodeClick.at < DOUBLE_CLICK_MS) {
lastNodeClick = { id: null, at: 0 };
- openDashboard(id);
+ toggleSubscription(id);
} else {
lastNodeClick = { id, at: now };
focus(id);
}
});
}
+// --- Subscriptions: other nodes' own links, read from them by this page directly
+// (their `dashboard/links/own/events`, with a token: `access.md`, Tokens), for the map.
+// Kept in the browser (handed over with the rest), each followed while the page is
+// open; a node the browser can't reach shows grey, with why in its tooltip. ---
+function keepSubscriptions() {
+ try { localStorage.setItem(SUBSCRIPTIONS, JSON.stringify([...subscriptions])); } catch (_) {}
+}
+function toggleSubscription(id) {
+ if (!state || id === state.node.id) return;
+ if (subscriptions.has(id)) {
+ subscriptions.delete(id);
+ const entry = subscribed.get(id);
+ if (entry) entry.stop();
+ subscribed.delete(id);
+ // Its report as this node has it, if any, at the next update.
+ if (state.graph.measured[id] && state.graph.measured[id].subscribed) {
+ delete state.graph.measured[id];
+ }
+ } else {
+ subscriptions.add(id);
+ follow(id);
+ }
+ keepSubscriptions();
+ render();
+}
+// The subscriptions' reports on the map, over what this node has of them: fresher.
+function applySubscriptions() {
+ for (const [id, entry] of subscribed) {
+ if (!entry.report || !state.graph.members[id]) continue;
+ state.graph.measured[id] = {
+ secsAgo: Math.max(0, Math.floor((stateAt - entry.at) / 1000)),
+ links: entry.report.links || [],
+ epoch: entry.report.epoch,
+ subscribed: true,
+ };
+ }
+}
+// Tokens: this page's, from logging in on this node (the browser's password for it, or
+// the proxy's), and a narrower one, reading the member list only, to send to others.
+// Each refreshed half way through its life.
+let token = null, narrowToken = null, tokenWhy = "not logged in yet", tokenTimer = null;
+const READ_MEMBERS = [{ path: ["_continuum", "members"], rights: ["read"] }];
+async function tokenCall(url, bearer, body) {
+ const headers = { "Content-Type": "application/json" };
+ if (bearer) headers.Authorization = "Bearer " + bearer;
+ const response = await fetch(url, { method: "POST", headers,
+ body: JSON.stringify(body || {}) });
+ const answer = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(response.status + " " + (answer.error || response.statusText));
+ return answer;
+}
+async function obtainTokens() {
+ clearTimeout(tokenTimer);
+ try {
+ token = token && token.expiresAt > Date.now() + 60000
+ ? await tokenCall("../auth/refresh", token.token)
+ : await tokenCall("../auth/login");
+ narrowToken = await tokenCall("../auth/refresh", token.token, { limit: READ_MEMBERS });
+ tokenWhy = null;
+ const life = Math.min(token.expiresAt, narrowToken.expiresAt) - Date.now();
+ tokenTimer = setTimeout(obtainTokens, Math.max(30000, life / 2));
+ // Those that waited for one.
+ for (const id of subscriptions) {
+ const entry = subscribed.get(id);
+ if (!entry || entry.status === "unreachable") follow(id);
+ }
+ } catch (error) {
+ token = narrowToken = null;
+ tokenWhy = "no token: " + error.message;
+ tokenTimer = setTimeout(obtainTokens, 30000);
+ }
+}
+// Follows `id`'s own links, again after a while if it fails (2 s, doubling to a minute).
+function follow(id, delay = 2000) {
+ const old = subscribed.get(id);
+ if (old) old.stop();
+ const controller = new AbortController();
+ let retry = null;
+ const entry = { status: "connecting", why: "connecting", report: old ? old.report : null,
+ at: old ? old.at : 0, stop: () => { clearTimeout(retry); controller.abort(); } };
+ subscribed.set(id, entry);
+ const fail = why => {
+ if (controller.signal.aborted || subscribed.get(id) !== entry) return;
+ entry.status = "unreachable";
+ entry.why = why;
+ render();
+ retry = setTimeout(() => {
+ if (subscriptions.has(id) && subscribed.get(id) === entry) follow(id, Math.min(60000, delay * 2));
+ }, delay);
+ };
+ const member = state && state.graph.members[id];
+ if (!member || !member.dashboard) return fail("it publishes no dashboard (--dashboard-url)");
+ if (!narrowToken) return fail(tokenWhy);
+ let url;
+ try {
+ url = new URL("dashboard/links/own/events", member.dashboard.split("#")[0].replace(/\/+$/, "")).href;
+ } catch (_) {
+ return fail("its dashboard's address isn't one");
+ }
+ (async () => {
+ const response = await fetch(url, { signal: controller.signal, cache: "no-store",
+ headers: { Authorization: "Bearer " + narrowToken.token, Accept: "text/event-stream" } });
+ if (!response.ok) {
+ const answer = await response.json().catch(() => ({}));
+ throw new Error(response.status + " " + (answer.error || response.statusText));
+ }
+ entry.status = "live";
+ entry.why = null;
+ delay = 2000;
+ render();
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
+ let buffer = "";
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) throw new Error("its stream ended");
+ buffer += value.replace(/\r/g, "");
+ let end;
+ while ((end = buffer.indexOf("\n\n")) >= 0) {
+ const block = buffer.slice(0, end);
+ buffer = buffer.slice(end + 2);
+ let kind = "message";
+ const data = [];
+ for (const line of block.split("\n")) {
+ if (line.startsWith("event:")) kind = line.slice(6).trim();
+ else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
+ }
+ if (kind === "links") {
+ entry.report = JSON.parse(data.join("\n"));
+ entry.at = Date.now();
+ render();
+ } else if (kind === "offline") {
+ throw new Error("it stopped");
+ }
+ }
+ }
+ })().catch(error => fail(error.message || String(error)));
+}
+// A node's menu (right-click): subscribing to its links, or no longer; its dashboard.
+function nodeMenu(id, x, y) {
+ const menu = document.getElementById("node-menu");
+ const member = state.graph.members[id] || {};
+ const item = (label, enabled, act) => {
+ const button = el("button", { type: "button" }, label);
+ button.disabled = !enabled;
+ button.addEventListener("click", () => { menu.hidden = true; act(); });
+ return button;
+ };
+ menu.replaceChildren(
+ item(subscriptions.has(id) ? "unsubscribe" : "subscribe to its links", !member.self,
+ () => toggleSubscription(id)),
+ item(member.dashboard ? "open its dashboard" : "no dashboard published", !member.self && !!member.dashboard,
+ () => openDashboard(id)));
+ menu.hidden = false;
+ menu.style.left = Math.min(x, innerWidth - menu.offsetWidth - 8) + "px";
+ menu.style.top = Math.min(y, innerHeight - menu.offsetHeight - 8) + "px";
+}
+addEventListener("pointerdown", event => {
+ const menu = document.getElementById("node-menu");
+ if (!menu.hidden && !menu.contains(event.target)) menu.hidden = true;
+}, true);
+addEventListener("keydown", event => {
+ if (event.key === "Escape") document.getElementById("node-menu").hidden = true;
+});
+
// A node's dashboard, where it publishes one (`--dashboard-url`, in the member list),
// opened in this page; else its halo flashes red, larger than usual, and "no dashboard"
// rises from it in the same red, both fading out. This node's own: here already.
@@ -2907,6 +3116,8 @@ function connect() {
remakesArrived();
if (treeShown && writerTree === null) loadTree();
handOverLinks();
+ if (token === null && tokenTimer === null) obtainTokens();
+ for (const id of subscriptions) if (!subscribed.has(id)) follow(id);
});
events.addEventListener("patch", event => {
if (!state) return;