// The web UI: `/dag` drawn as a layered graph, kept live from `/events`.
//
// Holds no state the server does not. The picture is the last `/dag`
// snapshot; what moves between snapshots is applied from the event stream
// subscribed at that snapshot's `seq`, and anything that changes the node
// set or settles convergence (`declared`, `cleared`, `converge-stop`, a
// `gap`, a lost stream) is answered by fetching `/dag` again and
// resubscribing from its `seq`. Reload is the same thing by hand.
//
// Every write is `POST /command?async`: the line is queued, the answer is
// the sequence number it was queued at and the origin it was queued under,
// and what it did is read off `/events` like everything else — the reports
// carrying that origin are the command's, which is what lights the nodes it
// touched, fills the log under the command line and says when it is done
// (`hung-up`). Never the synchronous form: a sync `up` holds the request for
// the whole pass, and this page is the thing that would be waiting.
//
// The seed form is `/help/seed` (the binary's own `config --help`) in a
// `<pre>`, a text field for the words, and the three declaring verbs; the
// history under it is `/history`, fetched again on `declared`/`cleared`.
// `quit` is deliberately not offered: a page cannot answer for the socket it
// is served on, and leaving the loop is not something to click.
const $ = (id) => document.getElementById(id);
const SVG = "http://www.w3.org/2000/svg";
// ---------------------------------------------------------------------------
// state
const state = {
nodes: new Map(), // full ref -> node object from /dag, plus live overlay
order: [], // full refs in dagOrder
seq: 0,
mode: "–",
converge: null, // last converge-stop {ok, remaining}
selected: null, // full ref
source: null, // the EventSource
reloadTimer: null,
retryDelay: 1000,
requests: new Map(), // origin name -> {seq, origin, line, reports, done, li}
unclaimed: [], // events with an origin no request has claimed yet
history: null, // last /history: {seeds: [...], elided}
seedHelpLoaded: false,
tails: new Map(), // full ref -> {lines, paused, held, pre} for each pinned live tail
zoom: { scale: 1, tx: 0, ty: 0 }, // the #viewport transform; world units are the raw layout() pixels
zoomInit: false, // true once the first fit-to-view has run
worldSize: null, // the current layout()'s {width, height}, for fitView
};
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 4;
// The dock holds a few windows, not one per node: a tail is for the node
// being watched right now.
const TAIL_MAX_WINDOWS = 4;
const TAIL_MAX_LINES = 500;
const TAIL_STORE = "salmon-tails";
// How long a node stays marked as touched once the command that touched it
// has been handled.
const TOUCH_LINGER_MS = 4000;
const TOAST_MS = 6000;
// ---------------------------------------------------------------------------
// fetching and subscribing
async function loadDag() {
clearTimeout(state.reloadTimer);
state.reloadTimer = null;
closeStream();
let dag;
try {
const r = await fetch("dag", { cache: "no-store" });
if (r.status === 401) return signedOut();
if (!r.ok) throw new Error(`/dag answered ${r.status}`);
dag = await r.json();
} catch (err) {
setStream("lost", String(err.message || err));
state.retryDelay = Math.min(state.retryDelay * 2, 15000);
state.reloadTimer = setTimeout(loadDag, state.retryDelay);
return;
}
state.retryDelay = 1000;
state.seq = dag.seq;
state.mode = dag.mode;
const before = state.nodes;
state.nodes = new Map();
state.order = [];
for (const n of dag.nodes) {
n.last = null;
n.lastReason = null;
n.machine = null;
// a snapshot is fetched on `declared` and `converge-stop`, mid-command,
// and which command touched a node is this page's knowledge, not /dag's
n.touched = before.has(n.ref.full) ? before.get(n.ref.full).touched : null;
state.nodes.set(n.ref.full, n);
state.order.push(n.ref.full);
}
if (state.selected && !state.nodes.has(state.selected)) state.selected = null;
reconcileTails();
render();
subscribe(dag.seq);
}
// Coalesce: several events in a row that each want a fresh snapshot cost
// one fetch.
function scheduleReload() {
if (state.reloadTimer) return;
state.reloadTimer = setTimeout(loadDag, 250);
}
function closeStream() {
if (state.source) {
state.source.close();
state.source = null;
}
}
function subscribe(since) {
const es = new EventSource(`events?since=${since}`);
state.source = es;
es.onopen = () => setStream("live");
es.onmessage = (m) => {
let e;
try {
e = JSON.parse(m.data);
} catch {
return;
}
if (typeof e.seq === "number") state.seq = e.seq;
applyEvent(e);
attribute(e);
renderHeader();
};
es.onerror = () => {
// EventSource would reconnect on its own, but with Last-Event-ID, which
// the server does not read; resuming is `/dag` then `?since=` its seq.
if (state.source !== es) return;
closeStream();
setStream("lost");
scheduleReload();
};
}
function setStream(cls, detail) {
const el = $("h-stream");
el.className = cls;
el.textContent = cls === "live" ? "live" : detail ? `lost: ${detail}` : "reconnecting";
}
// ---------------------------------------------------------------------------
// events onto nodes
function applyEvent(e) {
switch (e.stream) {
case "server":
if (e.kind === "gap") scheduleReload();
return;
case "serve":
applyServe(e);
return;
case "updown":
applyUpDown(e);
return;
case "upkeep":
applyUpkeep(e);
return;
case "output":
applyOutput(e);
return;
default:
return;
}
}
// ---------------------------------------------------------------------------
// live tails: a dock of pinned mini terminals, one per node. Besides a
// managed action's own `output` lines, a tailed node's updown/upkeep reports
// are formatted into the same window (prefixed by stream) — those exist for
// every node, so a one-shot builtin's tail is not silent just because it has
// no `managed` action to produce raw output.
function storedPins() {
try {
const v = JSON.parse(localStorage.getItem(TAIL_STORE) || "[]");
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
} catch {
return [];
}
}
function storePins() {
try {
localStorage.setItem(TAIL_STORE, JSON.stringify([...state.tails.keys()]));
} catch {
// page storage may be unavailable; windows just do not survive a reload
}
}
// After every /dag: a window for a node that is no longer there closes (the
// node was retired and pruned), and the pins remembered from a previous page
// load are opened for the nodes that are. The ring the snapshot carries is
// the backlog a window is seeded from, but only when it has nothing yet:
// lines that arrived live since are newer than that snapshot.
function reconcileTails() {
for (const ref of storedPins()) {
if (state.nodes.has(ref) && !state.tails.has(ref) && state.tails.size < TAIL_MAX_WINDOWS) {
state.tails.set(ref, { lines: [], paused: false, held: [], pre: null });
}
}
for (const ref of [...state.tails.keys()]) {
const n = state.nodes.get(ref);
if (!n) {
state.tails.delete(ref);
continue;
}
const t = state.tails.get(ref);
if (t.lines.length === 0 && n.status && Array.isArray(n.status.output)) {
t.lines = n.status.output.slice(-TAIL_MAX_LINES);
}
}
storePins();
renderDock();
}
function toggleTail(ref) {
if (state.tails.has(ref)) {
state.tails.delete(ref);
} else {
// the oldest pin makes room
if (state.tails.size >= TAIL_MAX_WINDOWS) state.tails.delete(state.tails.keys().next().value);
const n = state.nodes.get(ref);
const seed = n && n.status && Array.isArray(n.status.output) ? n.status.output.slice(-TAIL_MAX_LINES) : [];
state.tails.set(ref, { lines: seed, paused: false, held: [], pre: null });
}
storePins();
renderDock();
renderPanel();
}
function applyOutput(e) {
if (e.ref && typeof e.line === "string") pushTailLine(e.ref.full, e.line);
}
// Shared by a managed action's raw output and by the formatted updown/upkeep
// report lines below; a no-op if that ref has no open tail window.
function pushTailLine(ref, line) {
const t = state.tails.get(ref);
if (!t) return;
// paused holds the view still; what arrives meanwhile is kept for resume
const into = t.paused ? t.held : t.lines;
into.push(line);
if (into.length > TAIL_MAX_LINES) into.splice(0, into.length - TAIL_MAX_LINES);
if (!t.paused) paintTail(t);
}
function formatUpDownLine(e) {
switch (e.kind) {
case "failed":
return `failed: ${e.error}`;
case "conflicting":
return `conflicting: kept ${e.kept && e.kept.shorthand}, replaced ${e.replaced && e.replaced.shorthand}`;
case "instructed":
return `instructed: ${e.instruction}`;
case "dropped-instructions":
return `dropped ${e.dropped} instruction(s)`;
default:
return e.kind;
}
}
function formatUpkeepLine(e) {
switch (e.kind) {
case "next-look":
return `next-look: ${(e.check && e.check.kind) || "?"}${e.delay_us != null ? ` (retry in ${Math.round(e.delay_us / 1e6)}s)` : ""}`;
case "upkeep":
return `upkeep: ${e.state}`;
case "downkeep":
return `downkeep: ${e.state}`;
case "demoted":
return `demoted by ${e.dependency && e.dependency.short}`;
case "gave-up":
return `gave up after ${e.failures} failure(s)`;
case "wedged":
return `wedged (${Math.round(e.silent_us / 1e6)}s silent)`;
case "reapplying":
return `reapplying (retry in ${Math.round(e.delay_us / 1e6)}s)`;
default:
return e.kind;
}
}
function paintTail(t) {
if (!t.pre) return;
const stick = t.pre.scrollTop + t.pre.clientHeight >= t.pre.scrollHeight - 4;
t.pre.textContent = t.lines.join("\n");
if (stick) t.pre.scrollTop = t.pre.scrollHeight;
}
function renderDock() {
const dock = $("dock");
dock.replaceChildren();
dock.hidden = state.tails.size === 0;
for (const [ref, t] of state.tails) {
const n = state.nodes.get(ref);
const win = document.createElement("section");
win.className = "tail";
const bar = document.createElement("header");
const title = document.createElement("span");
title.className = "tail-title";
title.textContent = n ? n.shorthand : ref.slice(0, 8);
const st = document.createElement("span");
st.className = `badge ${n ? stateClass(n) : ""}`;
st.textContent = n ? n.convergence : "gone";
bar.append(title, " ", st);
const button = (label, hint, fn) => {
const b = document.createElement("button");
b.type = "button";
b.textContent = label;
b.title = hint;
b.addEventListener("click", fn);
return b;
};
const pause = button(t.paused ? "resume" : "pause", "hold the window still; lines keep arriving", () => {
t.paused = !t.paused;
if (!t.paused) {
t.lines.push(...t.held);
t.held = [];
if (t.lines.length > TAIL_MAX_LINES) t.lines.splice(0, t.lines.length - TAIL_MAX_LINES);
paintTail(t);
}
pause.textContent = t.paused ? "resume" : "pause";
});
bar.append(
pause,
button("clear", "empty this window (the node's own ring is untouched)", () => {
t.lines = [];
t.held = [];
paintTail(t);
}),
button("unpin", "close this window", () => toggleTail(ref)),
);
const pre = document.createElement("pre");
pre.className = "tail-body";
t.pre = pre;
win.append(bar, pre);
dock.appendChild(win);
pre.textContent = t.lines.join("\n");
pre.scrollTop = pre.scrollHeight;
}
}
function applyServe(e) {
switch (e.kind) {
case "declared":
case "cleared":
scheduleReload();
if (state.history) loadHistory();
break;
case "converge-start":
state.converge = { running: true, down: e.down, up: e.up };
break;
case "converge-stop":
state.converge = { ok: e.ok, remaining: e.remaining };
scheduleReload();
break;
case "tended":
if (e.report) applyUpkeep({ ...e.report, stream: "upkeep" });
break;
default:
break;
}
}
function nodeOf(e) {
return e.ref && state.nodes.get(e.ref.full);
}
function applyUpDown(e) {
if (e.ref) pushTailLine(e.ref.full, `[updown] ${formatUpDownLine(e)}`);
const n = nodeOf(e);
if (!n) return;
n.last = e.kind;
switch (e.kind) {
case "eval":
n.lastReason = null;
pulse(n);
break;
case "done":
n.convergence = "converged";
pulse(n);
break;
case "skip":
n.convergence = "converged";
break;
case "failed":
n.convergence = "errored";
n.lastReason = e.error;
pulse(n);
break;
case "blocked":
n.convergence = "blocked";
break;
case "conflicting":
n.lastReason = `conflicting: kept ${e.kept && e.kept.shorthand}, replaced ${e.replaced && e.replaced.shorthand}`;
break;
default:
break;
}
paintNode(n);
}
function applyUpkeep(e) {
if (e.kind === "acted" && e.report) {
applyUpDown({ ...e.report, stream: "updown" });
return;
}
if (e.ref) pushTailLine(e.ref.full, `[upkeep] ${formatUpkeepLine(e)}`);
const n = nodeOf(e);
if (!n) return;
n.last = e.kind;
switch (e.kind) {
case "next-look":
if (!n.status) n.status = {};
n.status.check = e.check;
n.lastReason = e.check && e.check.reason ? e.check.reason : null;
break;
case "upkeep":
case "downkeep":
n.machine = e.state;
break;
case "demoted":
n.lastReason = `sent back by ${e.dependency && e.dependency.short}`;
break;
case "gave-up":
n.lastReason = `gave up after ${e.failures} failure(s)`;
break;
case "escaped":
n.lastReason = e.error;
break;
case "wedged":
n.lastReason = `silent for ${Math.round(e.silent_us / 1e6)}s`;
break;
case "unwedged":
n.lastReason = null;
break;
default:
break;
}
paintNode(n);
}
function pulse(n) {
n.pulse = true;
}
// ---------------------------------------------------------------------------
// layout: longest-path layering, then barycentre ordering (Sugiyama-lite)
const BOX_W = 176;
const BOX_H = 66;
const GAP_X = 28;
const GAP_Y = 64;
const PAD = 12;
function layout() {
const ids = state.order;
const depsOf = (id) => (state.nodes.get(id).dependencies || []).map((r) => r.full).filter((d) => state.nodes.has(d));
const dependantsOf = (id) => (state.nodes.get(id).dependants || []).map((r) => r.full).filter((d) => state.nodes.has(d));
// layer = longest path from a node with no dependencies; a cycle (which
// the drivers report Blocked) is cut wherever it is first re-entered.
const layer = new Map();
const visiting = new Set();
const layerOf = (id) => {
if (layer.has(id)) return layer.get(id);
if (visiting.has(id)) return 0;
visiting.add(id);
let l = 0;
for (const d of depsOf(id)) l = Math.max(l, layerOf(d) + 1);
visiting.delete(id);
layer.set(id, l);
return l;
};
ids.forEach(layerOf);
const layers = [];
for (const id of ids) {
const l = layer.get(id);
(layers[l] ||= []).push(id);
}
// order within a layer by the mean position of neighbours in the layers
// already ordered: down sweeps look at dependencies, up sweeps at
// dependants; a node with none keeps its place.
const pos = new Map();
const place = () => layers.forEach((row) => row.forEach((id, i) => pos.set(id, i)));
place();
const bary = (id, neigh) => {
const ps = neigh(id).map((d) => pos.get(d)).filter((p) => p !== undefined);
return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : pos.get(id);
};
const sortRow = (row, neigh) => {
const keyed = row.map((id) => [bary(id, neigh), pos.get(id), id]);
keyed.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
return keyed.map((k) => k[2]);
};
for (let sweep = 0; sweep < 4; sweep++) {
for (let l = 1; l < layers.length; l++) layers[l] = sortRow(layers[l], depsOf);
place();
for (let l = layers.length - 2; l >= 0; l--) layers[l] = sortRow(layers[l], dependantsOf);
place();
}
// coordinates: each layer a row, centred on the widest one
const widest = Math.max(...layers.map((r) => r.length));
const width = widest * BOX_W + (widest - 1) * GAP_X + 2 * PAD;
const coords = new Map();
layers.forEach((row, l) => {
const rowWidth = row.length * BOX_W + (row.length - 1) * GAP_X;
const x0 = PAD + (width - 2 * PAD - rowWidth) / 2;
row.forEach((id, i) => {
coords.set(id, { x: x0 + i * (BOX_W + GAP_X), y: PAD + l * (BOX_H + GAP_Y) });
});
});
const height = layers.length * BOX_H + (layers.length - 1) * GAP_Y + 2 * PAD;
return { coords, width, height };
}
// ---------------------------------------------------------------------------
// rendering
function stateClass(n) {
return n.direction === "down" ? "retiring" : n.convergence;
}
function stateLine(n) {
const parts = [n.direction, n.convergence];
if (n.machine) parts.push(n.machine);
return parts.join(" · ");
}
function lastLine(n) {
const parts = [];
if (n.last) parts.push(n.last);
const check = n.status && n.status.check;
if (check && check.verdict) parts.push(`check: ${check.verdict}`);
return parts.join(" ");
}
function render() {
renderHeader();
const empty = state.order.length === 0;
$("empty").hidden = !empty;
$("graph-viewport").style.display = empty ? "none" : "";
renderGraph();
renderList();
renderPanel();
}
function renderHeader() {
$("h-mode").textContent = state.mode;
$("h-seq").textContent = String(state.seq);
let converged = 0;
let errored = 0;
for (const n of state.nodes.values()) {
if (n.convergence === "converged") converged++;
if (n.convergence === "errored" || n.convergence === "blocked") errored++;
}
$("h-counts").textContent = `${converged} converged / ${errored} errored / ${state.nodes.size}`;
const c = state.converge;
$("h-converge").textContent = !c
? "–"
: c.running
? `running (${c.down} down, ${c.up} up)`
: `${c.ok ? "ok" : "failed"}, ${c.remaining} remaining`;
}
function renderGraph() {
const edges = $("edges");
const nodes = $("nodes");
edges.replaceChildren();
nodes.replaceChildren();
if (state.order.length === 0) return;
const { coords, width, height } = layout();
state.worldSize = { width, height };
for (const id of state.order) {
const n = state.nodes.get(id);
const to = coords.get(id);
for (const d of n.dependencies || []) {
const from = coords.get(d.full);
if (!from) continue;
const x1 = from.x + BOX_W / 2;
const y1 = from.y + BOX_H;
const x2 = to.x + BOX_W / 2;
const y2 = to.y;
const bend = Math.max(20, (y2 - y1) / 2);
const p = document.createElementNS(SVG, "path");
p.setAttribute("d", `M ${x1} ${y1} C ${x1} ${y1 + bend}, ${x2} ${y2 - bend}, ${x2} ${y2}`);
p.dataset.from = d.full;
p.dataset.to = id;
edges.appendChild(p);
}
}
for (const id of state.order) {
const n = state.nodes.get(id);
const c = coords.get(id);
const g = document.createElementNS(SVG, "g");
g.setAttribute("transform", `translate(${c.x} ${c.y})`);
g.dataset.ref = id;
const rect = document.createElementNS(SVG, "rect");
rect.setAttribute("width", BOX_W);
rect.setAttribute("height", BOX_H);
g.appendChild(rect);
g.appendChild(text("ref", 8, 15, `#${n.ref.short}`));
g.appendChild(text("shorthand", 8, 32, n.shorthand));
g.appendChild(text("state", 8, 47, ""));
g.appendChild(text("last", 8, 60, ""));
const title = document.createElementNS(SVG, "title");
title.textContent = n.help;
g.appendChild(title);
// Selection is handled by the delegated, coordinate-based hit test
// below (pointer capture retargets click's own bubble path away from
// this element), not a listener here.
g.addEventListener("animationend", () => g.classList.remove("pulse"));
nodes.appendChild(g);
n.el = g;
paintNode(n);
}
highlightEdges();
if (state.zoomInit) applyZoom();
else fitView();
}
// ---------------------------------------------------------------------------
// pan/zoom: a transform on #viewport, world units = layout()'s raw pixels.
// The SVG itself has no viewBox (so 1 user unit = 1 CSS pixel of the
// rendered element), which is what keeps the wheel/drag math below in plain
// screen pixels instead of also tracking a separate content scale.
function applyZoom() {
const z = state.zoom;
$("viewport").setAttribute("transform", `translate(${z.tx} ${z.ty}) scale(${z.scale})`);
}
// Fits the whole graph in the viewport, centred. Called once on first load
// and from the "fit" button; a later re-render (a live update) keeps
// whatever the operator has already panned/zoomed to.
function fitView() {
const vp = $("graph-viewport");
const w = vp.clientWidth || 800;
const h = vp.clientHeight || 500;
const world = state.worldSize || { width: w, height: h };
const raw = Math.min((w - 24) / world.width, (h - 24) / world.height) || 1;
const scale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, raw));
state.zoom = {
scale,
tx: (w - world.width * scale) / 2,
ty: (h - world.height * scale) / 2,
};
state.zoomInit = true;
applyZoom();
}
// Zooms by `factor`, keeping the point at (cx, cy) — viewport-relative
// screen pixels — fixed under the cursor.
function zoomAt(cx, cy, factor) {
const z = state.zoom;
const newScale = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z.scale * factor));
const localX = (cx - z.tx) / z.scale;
const localY = (cy - z.ty) / z.scale;
z.scale = newScale;
z.tx = cx - localX * newScale;
z.ty = cy - localY * newScale;
applyZoom();
}
function text(cls, x, y, content) {
const t = document.createElementNS(SVG, "text");
t.setAttribute("class", cls);
t.setAttribute("x", x);
t.setAttribute("y", y);
t.textContent = clip(content, cls === "shorthand" ? 22 : 26);
return t;
}
function clip(s, n) {
s = String(s ?? "");
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
// Repaint one box (and its list row) from the node's current fields.
function paintNode(n) {
const cls = stateClass(n);
if (n.el) {
const g = n.el;
g.setAttribute("class", `node ${cls}${n.touched ? " touched" : ""}${state.selected === n.ref.full ? " selected" : ""}`);
g.querySelector(".state").textContent = clip(stateLine(n), 26);
g.querySelector(".last").textContent = clip(lastLine(n), 30);
if (n.pulse) {
n.pulse = false;
g.classList.remove("pulse");
void g.getBoundingClientRect();
g.classList.add("pulse");
}
}
if (n.li) {
n.li.className = `${cls}${n.touched ? " touched" : ""}${state.selected === n.ref.full ? " selected" : ""}`;
n.li.querySelector(".state").textContent = stateLine(n) + (lastLine(n) ? ` — ${lastLine(n)}` : "");
}
if (state.selected === n.ref.full) renderPanel();
}
function renderList() {
const list = $("list");
list.replaceChildren();
for (const id of state.order) {
const n = state.nodes.get(id);
const li = document.createElement("li");
const ref = document.createElement("div");
ref.className = "ref mono";
ref.textContent = `#${n.ref.short}`;
const sh = document.createElement("div");
sh.textContent = n.shorthand;
sh.style.fontWeight = "600";
const st = document.createElement("div");
st.className = "state";
const deps = document.createElement("div");
deps.className = "deps";
deps.textContent = (n.dependencies || []).length ? `depends on ${n.dependencies.map((d) => "#" + d.short).join(", ")}` : "no dependencies";
li.append(ref, sh, st, deps);
li.addEventListener("click", () => select(id));
list.appendChild(li);
n.li = li;
paintNode(n);
}
}
function highlightEdges() {
for (const p of $("edges").querySelectorAll("path")) {
p.classList.toggle("hi", state.selected !== null && (p.dataset.from === state.selected || p.dataset.to === state.selected));
}
}
// ---------------------------------------------------------------------------
// the side panel
function select(id) {
const prev = state.selected;
state.selected = state.selected === id ? null : id;
if (prev && state.nodes.has(prev)) paintNode(state.nodes.get(prev));
if (state.selected) paintNode(state.nodes.get(state.selected));
highlightEdges();
renderPanel();
}
function renderPanel() {
const panel = $("panel");
const n = state.selected && state.nodes.get(state.selected);
if (!n) {
panel.hidden = true;
return;
}
panel.hidden = false;
const body = $("panel-body");
body.replaceChildren();
const h = document.createElement("h2");
h.textContent = n.shorthand;
body.appendChild(h);
body.appendChild(para("mono", `#${n.ref.short}`));
const badges = document.createElement("p");
for (const [cls, label] of [[stateClass(n), n.convergence], ["", `wanted ${n.direction}`], ...(n.machine ? [["", n.machine]] : [])]) {
const b = document.createElement("span");
b.className = `badge ${cls}`;
b.textContent = label;
badges.appendChild(b);
badges.appendChild(document.createTextNode(" "));
}
body.appendChild(badges);
// the four mailbox instructions, addressed by ref: the `#`-prefixed
// selector is a prefix of the short ref, the same text the box prints.
const actions = document.createElement("p");
actions.className = "node-actions";
for (const verb of ["force", "recheck", "pause", "resume"]) {
const b = document.createElement("button");
b.type = "button";
b.textContent = verb;
b.title = `${verb} --select #${n.ref.short}`;
b.addEventListener("click", () => post(`${verb} --select ${quote("#" + n.ref.short)}`));
actions.appendChild(b);
}
const tail = document.createElement("button");
tail.type = "button";
tail.textContent = state.tails.has(n.ref.full) ? "untail" : "tail";
tail.title = "pin a live window of this node's output at the bottom of the page";
tail.addEventListener("click", () => toggleTail(n.ref.full));
actions.appendChild(tail);
body.appendChild(actions);
section(body, "help", n.help);
section(body, "notes", n.notes);
if (n.dynamics && n.dynamics.length) list(body, "dynamics", n.dynamics.map(String));
if (n.paths && n.paths.length) list(body, "paths", n.paths, "mono");
refList(body, "dependencies", n.dependencies);
refList(body, "dependants", n.dependants);
const check = n.status && n.status.check;
const h3 = document.createElement("h3");
h3.textContent = "last check";
body.appendChild(h3);
if (check) {
body.appendChild(para("", check.verdict + (check.reason ? `: ${check.reason}` : "")));
} else {
body.appendChild(para("", "never tended"));
}
if (n.last) section(body, "last event", n.last + (n.lastReason ? `: ${n.lastReason}` : ""));
if (n.status && n.status.output && n.status.output.length) {
const h4 = document.createElement("h3");
h4.textContent = "output";
body.appendChild(h4);
const pre = document.createElement("pre");
pre.className = "output";
pre.textContent = n.status.output.join("\n");
body.appendChild(pre);
}
body.appendChild(sectionTitle("full ref"));
const full = document.createElement("pre");
full.textContent = n.ref.full;
body.appendChild(full);
// A node does not know which seed declared it, and /history does not say
// which nodes an epoch declared, so the choice is the operator's: every
// live declaration, each with its `down`.
body.appendChild(sectionTitle("retire a seed"));
if (!state.history) {
const a = document.createElement("a");
a.textContent = "load the history";
a.addEventListener("click", () => loadHistory().then(renderPanel));
body.appendChild(para("", "")).appendChild(a);
return;
}
const live = state.history.seeds.filter((h) => h.active);
if (live.length === 0) {
body.appendChild(para("", "no live declaration"));
return;
}
body.appendChild(para("muted", "the server does not say which of these declared this node"));
for (const h of live) {
const row = document.createElement("p");
row.className = "seed-row";
const words = document.createElement("span");
words.className = "words mono";
words.textContent = `${h.declaration} ${h.args.map(quote).join(" ")}`;
const b = document.createElement("button");
b.type = "button";
b.textContent = "down";
b.addEventListener("click", () => post(`down ${h.args.map(quote).join(" ")}`));
row.append(words, b);
body.appendChild(row);
}
}
function sectionTitle(t) {
const h = document.createElement("h3");
h.textContent = t;
return h;
}
function para(cls, t) {
const p = document.createElement("p");
if (cls) p.className = cls;
p.textContent = t;
return p;
}
function section(body, title, content) {
body.appendChild(sectionTitle(title));
const pre = document.createElement("pre");
pre.textContent = content == null || content === "" ? "–" : String(content);
body.appendChild(pre);
}
function list(body, title, items, cls) {
body.appendChild(sectionTitle(title));
const ul = document.createElement("ul");
for (const it of items) {
const li = document.createElement("li");
if (cls) li.className = cls;
li.textContent = it;
ul.appendChild(li);
}
body.appendChild(ul);
}
function refList(body, title, refs) {
body.appendChild(sectionTitle(title));
if (!refs || refs.length === 0) {
body.appendChild(para("", "none"));
return;
}
const ul = document.createElement("ul");
for (const r of refs) {
const li = document.createElement("li");
const a = document.createElement("a");
const target = state.nodes.get(r.full);
a.textContent = `#${r.short}${target ? ` ${target.shorthand}` : ""}`;
a.addEventListener("click", () => select(r.full));
li.appendChild(a);
ul.appendChild(li);
}
body.appendChild(ul);
}
// ---------------------------------------------------------------------------
// commands: one POST /command?async, then the events carrying its origin
// A word of the input language, quoted for `Serve.tokenize` when it needs it.
function quote(w) {
w = String(w);
return /^[A-Za-z0-9_@%+=:,./#-]+$/.test(w) ? w : `'${w.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
}
async function post(line) {
line = String(line).trim();
if (!line) return null;
let r;
let body;
try {
r = await fetch("command?async", { method: "POST", headers: { "content-type": "text/plain" }, body: line });
if (r.status === 401) return signedOut();
body = await r.json();
} catch (err) {
toast(`not sent: ${err.message || err}`, "bad");
return null;
}
if (!r.ok) {
toast(`${r.status}: ${body && body.error ? body.error : "refused"} — ${line}`, "bad");
return null;
}
const req = { seq: body.seq, origin: body.origin, line, reports: [], done: false, li: null };
state.requests.set(req.origin, req);
logRequest(req);
toast(`queued at seq ${body.seq}: ${line}`);
// its reports may already have arrived: the stream does not wait for the
// POST's answer to be read
const early = state.unclaimed.filter((e) => claimant(e) === req.origin);
state.unclaimed = state.unclaimed.filter((e) => !early.includes(e));
early.forEach((e) => reportFor(req, e));
return req;
}
// The request an event belongs to, by the origin it carries; `hung-up`
// names the origin it is about rather than being stamped with it.
function claimant(e) {
if (e.kind === "hung-up" && e.from) return e.from;
return e.origin && e.origin.kind === "other" ? e.origin.name : null;
}
function attribute(e) {
const name = claimant(e);
if (!name) return;
const req = state.requests.get(name);
if (req) {
reportFor(req, e);
return;
}
state.unclaimed.push(e);
if (state.unclaimed.length > 500) state.unclaimed.shift();
}
function reportFor(req, e) {
if (req.done) return;
req.reports.push(e);
if (e.kind === "enqueued") return;
logLine(req, e);
const n = nodeOf(e);
if (n) {
n.touched = req.origin;
paintNode(n);
}
if (e.stream !== "serve") return;
switch (e.kind) {
case "hung-up":
req.done = true;
req.li.classList.add("done");
setTimeout(() => untouch(req.origin), TOUCH_LINGER_MS);
break;
case "instructed":
toast(`${e.instruction}: ${e.nodes} node(s)`, "ok");
break;
case "declared":
toast(`declared ${e.direction}: epoch ${e.epoch}, ${e.nodes} node(s), ${e.active_seeds} live seed(s)`, "ok");
break;
case "cleared":
toast(`cleared: ${e.retired} seed(s) retired`, "ok");
break;
case "converge-stop":
toast(`converge: ${e.ok ? "ok" : "failed"}, ${e.remaining} remaining`, e.ok ? "ok" : "bad");
break;
case "fetch-requested":
toast(e.following ? "fetch requested" : "nothing is being followed", e.following ? "ok" : "bad");
break;
default:
if (e.kind.startsWith("bad-")) toast(`${errText(e.error)} — ${req.line}`, "bad");
break;
}
}
function untouch(origin) {
for (const n of state.nodes.values()) {
if (n.touched === origin) {
n.touched = null;
paintNode(n);
}
}
}
// ---------------------------------------------------------------------------
// toasts
function toast(text, cls) {
const el = document.createElement("div");
el.className = `toast${cls ? ` ${cls}` : ""}`;
el.textContent = text;
el.addEventListener("click", () => el.remove());
$("toasts").appendChild(el);
setTimeout(() => el.remove(), TOAST_MS);
}
// ---------------------------------------------------------------------------
// the log under the command line: one entry per request, its reports under it
function logRequest(req) {
const li = document.createElement("li");
const head = document.createElement("div");
head.className = "request";
head.textContent = `${req.seq} ${req.line} `;
const origin = document.createElement("span");
origin.className = "origin";
origin.textContent = req.origin;
head.appendChild(origin);
const ul = document.createElement("ul");
li.append(head, ul);
req.li = li;
const list = $("cli-log-list");
list.appendChild(li);
$("cli-log-count").textContent = `(${state.requests.size})`;
list.scrollTop = list.scrollHeight;
}
function logLine(req, e) {
const li = document.createElement("li");
li.textContent = summarize(e);
if (e.error || e.kind === "failed" || e.kind === "blocked") li.className = "bad";
req.li.querySelector("ul").appendChild(li);
if ($("cli-log").open) {
const list = $("cli-log-list");
list.scrollTop = list.scrollHeight;
}
}
function summarize(e) {
const parts = [String(e.seq ?? ""), `${e.stream}/${e.kind}`];
if (e.ref && e.ref.short) parts.push(`#${e.ref.short}`);
if (e.node && e.node.shorthand) parts.push(e.node.shorthand);
switch (e.kind) {
case "declared":
parts.push(`epoch ${e.epoch} ${e.direction}, ${e.nodes} node(s), ${e.active_seeds} live`);
break;
case "cleared":
parts.push(`${e.retired} retired`);
break;
case "converge-start":
parts.push(`${e.down} down, ${e.up} up`);
break;
case "converge-stop":
parts.push(`${e.ok ? "ok" : "failed"}, ${e.remaining} remaining`);
break;
case "instructed":
parts.push(e.instruction, e.nodes !== undefined ? `${e.nodes} node(s)` : "");
break;
case "supervised":
case "auto-converged":
parts.push(e.on ? "on" : "off");
break;
case "hung-up":
parts.push("done");
break;
case "help":
parts.push(`${(e.lines || []).length} line(s)`);
break;
case "status":
case "query":
parts.push(`${(e.nodes || []).length} node(s)`);
break;
case "history":
parts.push(`${(e.seeds || []).length} seed(s)`);
break;
default:
break;
}
if (e.error) parts.push(errText(e.error));
return parts.filter(Boolean).join(" ");
}
function errText(x) {
return typeof x === "string" ? x : JSON.stringify(x);
}
// ---------------------------------------------------------------------------
// the seed panel: /help/seed, the form, /history
async function loadSeedHelp() {
if (state.seedHelpLoaded) return;
try {
const r = await fetch("help/seed", { cache: "no-store" });
if (!r.ok) throw new Error(`/help/seed answered ${r.status}`);
const h = await r.json();
$("seed-help").textContent = h.seed || "–";
$("seed-commands").textContent = (h.commands || []).join("\n");
state.seedHelpLoaded = true;
} catch (err) {
$("seed-help").textContent = `could not load: ${err.message || err}`;
}
}
async function loadHistory() {
let h;
try {
const r = await fetch("history", { cache: "no-store" });
if (!r.ok) throw new Error(`/history answered ${r.status}`);
h = await r.json();
} catch (err) {
toast(`history: ${err.message || err}`, "bad");
return;
}
state.history = { seeds: h.seeds || [], elided: h.elided || 0 };
renderHistory();
if (state.selected) renderPanel();
}
function originText(o) {
if (!o) return "–";
switch (o.kind) {
case "stdin":
return "stdin";
case "other":
return o.name;
case "loaded":
return `loaded ${o.path}`;
case "fetched":
return `fetched ${o.label}`;
default:
return o.kind;
}
}
function renderHistory() {
const rows = $("history-rows");
rows.replaceChildren();
const h = state.history;
if (!h) return;
$("history-elided").textContent = h.elided ? `(${h.elided} older entries elided)` : "";
$("history-empty").hidden = h.seeds.length > 0;
$("history").hidden = h.seeds.length === 0;
for (const s of [...h.seeds].reverse()) {
const tr = document.createElement("tr");
tr.className = s.active ? "active" : "retired";
const words = s.args.map(quote).join(" ");
const cells = [String(s.epoch), s.declaration, words, originText(s.origin)];
cells.forEach((c, i) => {
const td = document.createElement("td");
td.textContent = c;
if (i === 2) {
td.className = "words";
td.title = "put these words in the form";
td.addEventListener("click", () => {
$("seed-words").value = words;
$("seed-words").focus();
});
}
tr.appendChild(td);
});
const st = document.createElement("td");
const badge = document.createElement("span");
badge.className = `badge${s.active ? " active" : ""}`;
badge.textContent = s.active ? "active" : "retired";
st.appendChild(badge);
tr.appendChild(st);
const act = document.createElement("td");
if (s.active) {
const b = document.createElement("button");
b.type = "button";
b.textContent = "down";
b.title = `down ${words}`;
b.addEventListener("click", () => post(`down ${words}`));
act.appendChild(b);
}
tr.appendChild(act);
rows.appendChild(tr);
}
}
function toggleSeeds(open) {
const panel = $("seeds");
const want = open === undefined ? panel.hidden : open;
panel.hidden = !want;
$("seeds-toggle").setAttribute("aria-expanded", String(want));
if (want) {
loadSeedHelp();
loadHistory();
$("seed-words").focus();
}
}
function declare(verb) {
const words = $("seed-words").value.trim();
if (!words) {
toast("no seed words", "bad");
$("seed-words").focus();
return;
}
post(`${verb} ${words}`);
}
// ---------------------------------------------------------------------------
// wiring
$("panel-close").addEventListener("click", () => select(state.selected));
$("reload").addEventListener("click", loadDag);
// wheel to zoom (centred on the cursor), drag to pan; a drag that actually
// moved suppresses the click it ends with, so panning never also selects
// whatever node the pointer happened to end up over.
{
const svg = $("graph");
const vp = $("graph-viewport");
let dragging = false;
let justPanned = false;
let start = null;
svg.addEventListener(
"wheel",
(ev) => {
ev.preventDefault();
const rect = svg.getBoundingClientRect();
zoomAt(ev.clientX - rect.left, ev.clientY - rect.top, ev.deltaY < 0 ? 1.15 : 1 / 1.15);
},
{ passive: false },
);
svg.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
dragging = true;
justPanned = false;
start = { x: ev.clientX, y: ev.clientY, tx: state.zoom.tx, ty: state.zoom.ty };
svg.setPointerCapture(ev.pointerId);
svg.classList.add("panning");
});
svg.addEventListener("pointermove", (ev) => {
if (!dragging) return;
const dx = ev.clientX - start.x;
const dy = ev.clientY - start.y;
if (Math.hypot(dx, dy) > 3) justPanned = true;
state.zoom.tx = start.tx + dx;
state.zoom.ty = start.ty + dy;
applyZoom();
});
const endDrag = (ev) => {
if (!dragging) return;
dragging = false;
svg.classList.remove("panning");
if (ev && ev.pointerId !== undefined) {
try {
svg.releasePointerCapture(ev.pointerId);
} catch {
// already released
}
}
};
svg.addEventListener("pointerup", endDrag);
svg.addEventListener("pointercancel", endDrag);
// Pointer capture is set on every pointerdown (above), which means the
// browser retargets the whole click's mouse-compat sequence to `svg`
// itself rather than whatever node is under the cursor — so a node `g`'s
// own click listener never sees it. Hit-test from coordinates instead of
// relying on the (retargeted) event target/bubble path.
svg.addEventListener(
"click",
(ev) => {
if (justPanned) {
justPanned = false;
ev.stopPropagation();
ev.preventDefault();
return;
}
const hit = document.elementFromPoint(ev.clientX, ev.clientY);
const nodeEl = hit && hit.closest ? hit.closest("[data-ref]") : null;
if (nodeEl) select(nodeEl.dataset.ref);
},
true,
);
$("zoom-in").addEventListener("click", () => zoomAt(vp.clientWidth / 2, vp.clientHeight / 2, 1.3));
$("zoom-out").addEventListener("click", () => zoomAt(vp.clientWidth / 2, vp.clientHeight / 2, 1 / 1.3));
$("zoom-reset").addEventListener("click", fitView);
}
for (const b of document.querySelectorAll("#actions button[data-line]")) {
b.addEventListener("click", () => {
if (b.dataset.confirm && !window.confirm(b.dataset.confirm)) return;
post(b.dataset.line);
});
}
$("seeds-toggle").addEventListener("click", () => toggleSeeds());
$("seed-form").addEventListener("submit", (ev) => {
ev.preventDefault();
declare("up");
});
for (const b of document.querySelectorAll("#seed-form button[data-verb]")) {
if (b.type === "submit") continue;
b.addEventListener("click", () => declare(b.dataset.verb));
}
$("cli-form").addEventListener("submit", (ev) => {
ev.preventDefault();
const input = $("cli-line");
const line = input.value.trim();
if (!line) return;
input.value = "";
post(line);
});
// `:` focuses the command line, as in `vi`/`less`; Esc leaves it.
document.addEventListener("keydown", (ev) => {
const t = ev.target;
const typing = t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA");
if (ev.key === ":" && !typing && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
ev.preventDefault();
$("cli-line").focus();
} else if (ev.key === "Escape" && typing) {
t.blur();
}
});
// Over --http-tcp the page is signed in with a session cookie it cannot
// read (HttpOnly), so it asks; on the unix socket the answer is false and
// the button stays hidden.
async function offerSignOut() {
try {
const r = await fetch("auth/session", { cache: "no-store" });
if (r.ok && (await r.json()).session === true) $("signout").hidden = false;
} catch {
// no button is the safe way to be wrong
}
}
// A 401 means the session ended — signed out in another tab, expired, or
// the server restarted — and the sign-in page is the only thing that fixes it.
function signedOut() {
closeStream();
location.assign("auth?ended");
return null;
}
offerSignOut();
loadDag();