This commit is contained in:
ciomek committed 2026-09-20 13:20:26 +02:00
1 parent cb735d4fa2
commit 1484483100
6 files changed
+376 -102

No files matched your search

+149 -22
View File
@@ -18,6 +18,7 @@
const layers = {
spokes: document.getElementById("layer-spokes"),
regions: document.getElementById("layer-regions"),
ports: document.getElementById("layer-ports"),
ends: document.getElementById("layer-ends"),
legs: document.getElementById("layer-legs"),
nodes: document.getElementById("layer-nodes"),
@@ -93,6 +94,7 @@
let focusKey = null;
let gesture = C.initialGesture();
let drag = null;
let lastPressed = null;
let tween = null;
const nodeRecords = new Map();
const regionRecords = new Map();
@@ -165,6 +167,31 @@
/* ------------------------------------------------------------ drawing: map */
/* One bar of hardware: "CPU 9% · 6C/12T" over a track with a fill. Nodes and networks both use it. */
function statBar(parent, width) {
const label = svg("text", { class: "st-text" });
const name = svg("tspan", { class: "st-name" });
const value = svg("tspan", { class: "st-value" });
label.appendChild(name);
label.appendChild(value);
const track = svg("rect", { class: "st-track", height: 5, rx: 2.5 });
const fill = svg("rect", { class: "st-fill", height: 5, rx: 2.5 });
if (width) put(track, "width", width);
[label, track, fill].forEach((part) => parent.appendChild(part));
return { label: label, name: name, value: value, track: track, fill: fill };
}
function paintBar(bar, name, usage, width) {
putText(bar.name, name + " ");
putText(bar.value, usage ? usage.text : "n/a");
put(bar.fill, "width", usage && usage.pct ? Math.max((width * usage.pct) / 100, 4) : 0);
putClass(bar.fill, "st-fill " + (usage && usage.pct !== null ? C.barLevel(usage.pct) : "ok"));
}
const FOOT_PAD = 14;
const FOOT_GAP = 8;
const footColumn = (region) => (region.w - 2 * FOOT_PAD - (C.STAT_COUNT - 1) * FOOT_GAP) / C.STAT_COUNT;
function regionRecord(region) {
let record = regionRecords.get(region.id);
if (record) return record;
@@ -178,8 +205,17 @@
title.appendChild(count);
[box, grab, dot, title].forEach((part) => group.appendChild(part));
[box, grab].forEach((part) => (part.dataset.net = region.id));
/* The network's hardware added up: the same three bars a node has, across the bottom. */
const foot = svg("g", { class: "stats foot" });
const rule = svg("line", { class: "foot-rule" });
const columns = Array.from({ length: C.STAT_COUNT }, () => statBar(foot));
const note = svg("text", { class: "st-foot" });
const hint = svg("title");
[rule, note, hint].forEach((part) => foot.appendChild(part));
group.appendChild(foot);
layers.regions.appendChild(group);
record = { group: group, box: box, grab: grab, title: title, count: count };
record = { group: group, box: box, grab: grab, title: title, count: count, rule: rule, columns: columns, note: note, hint: hint };
regionRecords.set(region.id, record);
return record;
}
@@ -192,6 +228,28 @@
});
}
/* Three columns across the bottom band of a network. */
function placeFooter(record, region) {
const top = region.footer.y - region.y;
const column = footColumn(region);
put(record.rule, "x1", FOOT_PAD);
put(record.rule, "x2", region.w - FOOT_PAD);
put(record.rule, "y1", top + 2);
put(record.rule, "y2", top + 2);
record.columns.forEach((col, index) => {
const x = FOOT_PAD + index * (column + FOOT_GAP);
put(col.label, "x", x);
put(col.label, "y", top + 20);
put(col.track, "x", x);
put(col.track, "y", top + 26);
put(col.track, "width", column);
put(col.fill, "x", x);
put(col.fill, "y", top + 26);
});
put(record.note, "x", FOOT_PAD);
put(record.note, "y", top + 50);
}
function drawRegions() {
const present = new Set();
map.regions.forEach((region) => {
@@ -201,6 +259,7 @@
put(record.box, "width", region.w);
put(record.box, "height", region.h);
put(record.grab, "width", region.w);
placeFooter(record, region);
const pinnedNet = selectedNet();
const state = pinnedNet ? (pinnedNet === region.id ? " selected" : " dim") : "";
putClass(record.group, "region n" + region.color + state + (region.moved ? " moved" : ""));
@@ -219,8 +278,16 @@
const dot = svg("circle", { class: "dot" });
const texts = { name: svg("text", { class: "l-name" }), roles: [svg("text", { class: "l-roles" }), svg("text", { class: "l-roles" })], caption: svg("text", { class: "l-caption" }) };
[title, halo, shape, dot, texts.name, texts.roles[0], texts.roles[1], texts.caption].forEach((part) => group.appendChild(part));
/* Hardware: three bars with their numbers, and a footer. */
const stats = svg("g", { class: "stats" });
const rows = Array.from({ length: C.STAT_COUNT }, () => statBar(stats, C.STAT_W));
const footer = svg("text", { class: "st-foot" });
stats.appendChild(footer);
group.appendChild(stats);
layers.nodes.appendChild(group);
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, gateway: node.gateway };
return { group: group, title: title, halo: halo, shape: shape, dot: dot, texts: texts, stats: stats, rows: rows, footer: footer, gateway: node.gateway };
}
function drawNodes() {
@@ -253,27 +320,69 @@
put(record.halo, "r", r + 6);
put(record.dot, "r", node.gateway ? 5.5 : 4.5);
/* Labels sit in the box the layout reserved for them. */
/* Every node, gateway or not, is the same stack: name, roles, caption, then its stats. */
const lines = C.wrapLabels((node.gateway ? ["gateway"] : []).concat(C.specialRoles(model, info)), 17, 2);
const side = node.dock ? node.dock.side : null;
const anchor = side === "right" ? "end" : side ? "start" : "middle";
const x = side === "right" ? node.box.x + node.box.w - node.x : side ? node.box.x - node.x : 0;
const first = node.box.y - node.y + 11;
const place = (element, index, text) => {
put(element, "x", x);
put(element, "x", 0);
put(element, "y", first + index * 13);
put(element, "text-anchor", anchor);
put(element, "text-anchor", "middle");
putText(element, text);
};
place(record.texts.name, 0, C.shorten(info.hostname, 15));
record.texts.roles.forEach((element, index) => place(element, 1 + index, lines[index] || ""));
put(record.texts.caption, "x", x);
put(record.texts.caption, "x", 0);
put(record.texts.caption, "y", first + (1 + lines.length) * 13);
put(record.texts.caption, "text-anchor", anchor);
put(record.texts.caption, "text-anchor", "middle");
put(record.stats, "transform", "translate(" + -C.STAT_W / 2 + "," + (node.box.y - node.y + C.STAT_TOP) + ")");
record.rows.forEach((row, index) => {
put(row.label, "y", index * C.STAT_ROW + 10);
put(row.track, "y", index * C.STAT_ROW + 14);
put(row.fill, "y", index * C.STAT_ROW + 14);
});
put(record.footer, "y", C.STAT_COUNT * C.STAT_ROW + 10);
});
prune(nodeRecords, present);
}
/* The tooltip and the screen reader's text carry the numbers too, so they follow them. */
function describe(record, info) {
const text = C.describeNode(model, info, routes);
putText(record.title, text);
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
}
/* The numbers move every second and never change the layout, so they are written on their own. */
function drawStats() {
nodeRecords.forEach((record, key) => {
const info = model.byKey.get(key);
if (!info) return;
if (routes) describe(record, info);
const stats = C.describeStats(info.stats);
stats.rows.forEach(([name, usage], index) => paintBar(record.rows[index], name, usage, C.STAT_W));
putText(record.footer, stats.footer);
});
map.regions.forEach((region) => {
const record = regionRecords.get(region.id);
const totals = C.networkStats(model, region.id);
totals.rows.forEach(([name, usage], index) => paintBar(record.columns[index], name, usage, footColumn(region)));
putText(record.note, totals.nodes ? "total of " + C.plural(totals.nodes, "node") + " on " + C.plural(totals.machines, "machine") : totals.footer);
putText(record.hint, "Cumulative hardware of " + region.id + ": " + C.describeRows(totals.rows).join(", ") + ". Nodes that report the same totals are counted as one machine.");
});
}
/* A gateway's door on the edge of its network, joined to the gateway node inside. */
function drawPorts() {
layers.ports.replaceChildren();
map.nodes.filter((node) => node.gateway).forEach((node) => {
const cls = "n" + node.color;
layers.ports.appendChild(svg("line", { class: "port-link " + cls, x1: node.port.x, y1: node.port.y, x2: node.x, y2: node.y }));
layers.ports.appendChild(svg("rect", { class: "port " + cls, x: node.port.x - 9, y: node.port.y - 9, width: 18, height: 18, rx: 5 }));
});
}
function drawSpokes() {
layers.spokes.replaceChildren();
spokeRecords = map.spokes.map((spoke) => {
@@ -319,9 +428,7 @@
putClass(record.group, classes.join(" "));
putText(record.texts.caption, trace.captions.get(key) || "");
const text = C.describeNode(model, info, routes);
putText(record.title, text);
put(record.group, "aria-label", text.replace(/\n/g, ". ") + ". Show routes from this node.");
describe(record, info);
put(record.group, "aria-pressed", isPinned(key) ? "true" : "false");
put(record.group, "tabindex", key === focusKey ? "0" : "-1");
});
@@ -371,14 +478,16 @@
function recompute(now) {
map = C.computeMap(model, layoutState, overrides, now);
lastCompute = now;
mapSignature = model.signature;
drawRegions();
drawNodes();
drawSpokes();
drawPorts();
if (autoFit) fitAll(false);
else setView(view, false);
drawTrace();
drawStats();
updateControls();
mapSignature = model.signature;
}
function update(payload) {
@@ -390,10 +499,13 @@
if (hovered && !model.byKey.has(hovered)) hovered = null;
drawHeader();
C.noteGateways(model, layoutState, now);
const ghosts = layoutState.gateways.size > model.bridges.length;
if (model.signature !== mapSignature || (ghosts && now - lastCompute > 5000)) {
recompute(now);
drawReaderList();
} else {
drawStats();
}
}
@@ -411,6 +523,7 @@
}
function zoomStep(factor) {
cancelTween();
setView(C.zoomAt(view, size.w / 2, size.h / 2, factor, map && map.bounds, size), true);
}
@@ -418,7 +531,9 @@
document.getElementById("zoom-out").addEventListener("click", () => zoomStep(0.8));
document.getElementById("fit").addEventListener("click", () => fitAll(true));
resetButton.addEventListener("click", () => {
const legendWas = overrides.legend;
overrides = C.newOverrides();
overrides.legend = legendWas;
layoutState = C.newState();
store.save(overrides);
recompute(Date.now());
@@ -466,7 +581,13 @@
const same = (a, b) => Boolean(a && b && a.kind === b.kind && a.id === b.id);
const toggle = (next) => select(same(selection, next) ? null : next);
function cancelTween() {
if (tween) cancelAnimationFrame(tween.frame);
tween = null;
}
function perform(action) {
if (["pan", "zoom", "drag-start"].includes(action.type)) cancelTween();
if (action.type === "pan") setView(C.panBy(view, action.dx, action.dy), true);
else if (action.type === "zoom") setView(C.zoomAt(view, action.x, action.y, action.factor, map && map.bounds, size), true);
else if (action.type === "fit") {
@@ -489,9 +610,12 @@
if (target.kind === "region-title") {
const region = map.regionAt.get(target.key);
drag = { kind: "region", net: target.key, x: region.x, y: region.y, before: overrides.regions[target.key] };
autoFit = false;
updateControls();
return;
}
const node = map.nodeAt.get(target.key);
if (!node) return;
drag = { kind: target.kind, key: target.key, net: node.net, x: node.x, y: node.y, from: node.cell };
nodeRecords.get(target.key).group.classList.add("dragging");
}
@@ -508,7 +632,7 @@
dragFrame = null;
if (drag) recompute(Date.now());
});
} else {
} else if (nodeRecords.has(drag.key)) {
nodeRecords.get(drag.key).group.style.transform = "translate(" + drag.x + "px," + drag.y + "px)";
}
}
@@ -519,7 +643,8 @@
drag = null;
if (dragFrame) cancelAnimationFrame(dragFrame);
dragFrame = null;
if (done.kind !== "region") nodeRecords.get(done.key).group.classList.remove("dragging");
if (done.kind !== "region" && nodeRecords.has(done.key)) nodeRecords.get(done.key).group.classList.remove("dragging");
if (done.kind !== "region" && !map.nodeAt.has(done.key)) return;
if (!keep) {
if (done.kind === "region") {
@@ -534,7 +659,7 @@
if (other) C.setCell(overrides, done.net, other.key, done.from);
}
} else if (done.kind === "gateway") {
C.setDock(overrides, done.key, C.dockAt(map, done.net, done.x, done.y));
C.setDock(overrides, done.key, C.dockAt(map, done.net, done.x, done.y, map.nodeAt.get(done.key).dock.side));
}
saveSoon();
recompute(Date.now());
@@ -548,8 +673,8 @@
} catch (error) {
/* a synthetic event has no capturable pointer */
}
const selected = selection && selection.kind === "node" ? selection.id : null;
const result = C.reduceGesture(gesture, { type: "down", id: event.pointerId, x: point.x, y: point.y, pointerType: event.pointerType, target: targetOf(event.target), selected: selected });
lastPressed = targetOf(event.target);
const result = C.reduceGesture(gesture, { type: "down", id: event.pointerId, x: point.x, y: point.y, pointerType: event.pointerType, target: lastPressed, selected: selection ? selection.id : null });
gesture = result.state;
result.actions.forEach(perform);
});
@@ -578,7 +703,8 @@
});
});
svgRoot.addEventListener("pointerleave", (event) => {
if (event.pointerType === "mouse" && hovered && gesture.mode === "idle" && model) {
const focused = nodeFromElement(document.activeElement);
if (event.pointerType === "mouse" && hovered && gesture.mode === "idle" && model && !(focused && focused.dataset.key === hovered)) {
hovered = null;
drawTrace();
}
@@ -590,7 +716,8 @@
result.actions.forEach(perform);
}, { passive: false });
svgRoot.addEventListener("dblclick", (event) => {
C.reduceGesture(gesture, { type: "dblclick", target: targetOf(event.target) }).actions.forEach(perform);
/* With the pointer captured, the event's own target is the svg: use what was pressed. */
C.reduceGesture(gesture, { type: "dblclick", target: lastPressed || targetOf(event.target) }).actions.forEach(perform);
});
/* ----------------------------------------------------------------- keyboard */
@@ -642,7 +769,7 @@
const region = map.regionAt.get(node.net);
const col = node.col + dx;
const row = node.row + dy;
if (col < 0 || col >= region.cols || row < 0) return;
if (col < 0 || col >= region.cols || row < 0 || row > region.rows) return;
const cell = row * region.cols + col;
const other = map.nodes.find((entry) => entry.net === node.net && entry.cell === cell);
C.setCell(overrides, node.net, node.key, cell);
@@ -33,6 +33,7 @@
<g id="world">
<g id="layer-spokes"></g>
<g id="layer-regions"></g>
<g id="layer-ports"></g>
<g id="layer-ends"></g>
<g id="layer-legs"></g>
<g id="layer-nodes"></g>
+103 -77
View File
@@ -7,30 +7,34 @@
(function () {
const M = typeof require !== "undefined" ? require("./model.js") : globalThis.Colony;
const CELL_W = 120;
const CELL_H = 104;
const CELL_W = 168;
const CELL_H = 196;
const GRID_INSET = 30;
const HEAD = 38;
const REGION_PAD = 14;
const GAP = 56;
const MIN_WIDTH = 240;
const MIN_WIDTH = 420;
const NODE_R = 14;
const GATEWAY_R = 19;
const DOCK_LANE = 136;
const DOCK_ROW = 64;
const DOCK_PITCH = 152;
const RESERVE_TOP = 32;
const RESERVE_BOTTOM = 70;
/* The band at the bottom of every network for its cumulative hardware. */
const FOOTER = 58;
const RING_GAP = 4;
const NOMINAL = { w: 320, h: 240 };
const GATEWAY_HOLD_MS = 60000;
const MAX_OVERRIDES = 300;
const LABEL_W = 104;
const LABEL_H = 56;
/* The box every node owns for its text: name, roles and route caption (four lines), then its stats. */
const LINE = 13;
const BOX_W = 160;
const STAT_W = 148;
const STAT_COUNT = 3;
const STAT_ROW = 24;
const STAT_TOP = 4 * LINE + 4;
const BOX_H = STAT_TOP + STAT_COUNT * STAT_ROW + 14;
/* ---------------------------------------------------------------- state */
function newState() {
return { regions: new Map(), sizes: new Map(), cols: new Map(), cells: new Map(), sides: new Map(), colors: new Map(), gateways: new Map(), nextColor: 0, placeLater: new Set() };
return { regions: new Map(), sizes: new Map(), cols: new Map(), cells: new Map(), sides: new Map(), colors: new Map(), age: new Map(), colsFor: new Map(), gateways: new Map(), nextColor: 0, nextAge: 0, placeLater: new Set() };
}
/* Only what the user changed by hand is ever stored. */
@@ -65,7 +69,7 @@
if (Object.keys(kept).length) clean.slots[net] = kept;
});
Object.entries(table(data.docks)).forEach(([key, dock]) => {
if (dock && ["left", "right", "top", "bottom"].includes(dock.side) && finite(dock.t)) clean.docks[key] = { side: dock.side, t: dock.t };
if (dock && ["left", "right", "top"].includes(dock.side) && finite(dock.t)) clean.docks[key] = { side: dock.side, t: dock.t };
});
if (typeof data.legend === "boolean") clean.legend = data.legend;
return limitOverrides(clean);
@@ -172,7 +176,7 @@
/* Push apart regions that overlap or sit closer than the gap: the one the user
did not place, along the axis with the smaller overlap. */
function resolveOverlaps(rects, fixed, gap) {
function resolveOverlaps(rects, fixed, gap, age) {
const ids = Object.keys(rects).sort();
for (let pass = 0; pass < 40; pass++) {
let moved = false;
@@ -182,8 +186,10 @@
const b = rects[ids[j]];
const o = overlapAmount(a, b, gap);
if (o.x <= 0 || o.y <= 0) continue;
const mover = fixed.has(ids[j]) ? (fixed.has(ids[i]) ? null : ids[i]) : ids[j];
if (!mover) continue;
/* A network the user placed stays and the other gives way. When both were placed by hand, or neither was, the newer one moves: two hand-placed networks are never left overlapping, so its drawn spot can differ from the saved one. */
let mover;
if (fixed.has(ids[i]) !== fixed.has(ids[j])) mover = fixed.has(ids[i]) ? ids[j] : ids[i];
else mover = age.get(ids[i]) > age.get(ids[j]) ? ids[i] : ids[j];
const other = mover === ids[j] ? a : b;
const self = rects[mover];
if (o.x <= o.y) self.x += self.x + self.w / 2 >= other.x + other.w / 2 ? o.x : -o.x;
@@ -203,8 +209,9 @@
function sideToward(from, to) {
const dx = to.x - from.x;
const dy = to.y - from.y;
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? "right" : "left";
return dy > 0 ? "bottom" : "top";
/* Only a neighbour above and more up than across is on top; there is no bottom edge to dock on. */
if (dy <= 0 && Math.abs(dy) >= Math.abs(dx)) return "top";
return dx >= 0 ? "right" : "left";
}
const normals = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
@@ -261,9 +268,16 @@
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
/* Remember, on every payload and not only when the map is laid out, who is relaying: that is
what the minute of grace for a gateway that went quiet is measured from. */
function noteGateways(model, state, now) {
model.bridges.forEach((bridge) => state.gateways.set(bridge.key, { time: now, bridge: bridge }));
}
/* The page also calls noteGateways on every payload; repeating it here covers a fresh state (reset, drag). */
function withGhosts(model, state, now) {
const bridges = model.bridges.slice();
model.bridges.forEach((bridge) => state.gateways.set(bridge.key, { time: now, bridge: bridge }));
noteGateways(model, state, now);
state.gateways.forEach((entry, key) => {
if (now - entry.time > GATEWAY_HOLD_MS || !model.byKey.has(key)) state.gateways.delete(key);
else if (!model.bridgeAt.has(key)) bridges.push(entry.bridge);
@@ -311,14 +325,19 @@
nets.forEach((net) => {
if (!state.colors.has(net)) state.colors.set(net, state.nextColor++ % M.COLORS);
if (!state.age.has(net)) state.age.set(net, state.nextAge++);
});
const groups = nets.map((id) => {
const members = model.nodes.filter((node) => node.net === id).sort(M.compareAddresses);
const plain = members.filter((node) => !bridgeAt.has(node.key));
const region = overrides.regions[id] || {};
/* Columns are chosen once so nothing reflows, and again only when the network has more than doubled. */
let cols = region.cols || state.cols.get(id);
if (!cols) cols = clamp(Math.ceil(Math.sqrt(1.6 * Math.max(1, plain.length))), 1, 6);
if (!cols || (!region.cols && plain.length > 2 * (state.colsFor.get(id) || 1))) {
cols = clamp(Math.ceil(Math.sqrt(1.6 * Math.max(1, plain.length))), 1, 6);
state.colsFor.set(id, Math.max(1, plain.length));
}
state.cols.set(id, cols);
const cells = assignCells(state, id, plain, overrides.slots[id] || {});
const rows = Math.max(1, Math.ceil((Math.max(-1, ...cells.values()) + 1) / cols));
@@ -347,7 +366,7 @@
/* Sides, then sizes. */
groups.forEach((group) => {
group.sides = { left: [], right: [], top: [], bottom: [] };
group.sides = { left: [], right: [], top: [] };
group.gateways.forEach((node) => {
const bridge = bridgeAt.get(node.key);
const others = Array.from(bridge.touches).filter((id) => id !== group.id);
@@ -367,16 +386,16 @@
});
const count = (side) => group.sides[side].length;
const reserve = { left: count("left") ? DOCK_LANE : 0, right: count("right") ? DOCK_LANE : 0, top: count("top") ? RESERVE_TOP : 0, bottom: count("bottom") ? RESERVE_BOTTOM : 0 };
/* A gateway is a node like any other, in a cell of a strip along the edge it docks on. */
const reserve = { left: count("left") ? CELL_W : 0, right: count("right") ? CELL_W : 0, top: count("top") ? CELL_H + 8 : 0 };
const header = 33 + 8.4 * group.id.length + 78;
const width = Math.max(
MIN_WIDTH,
2 * REGION_PAD + reserve.left + reserve.right + group.cols * CELL_W,
count("top") ? Math.max(header, reserve.left) + count("top") * DOCK_PITCH + 10 + reserve.right : 0,
count("bottom") ? reserve.left + count("bottom") * DOCK_PITCH + 25 + reserve.right : 0
count("top") ? Math.max(header + 20, reserve.left) + count("top") * CELL_W + reserve.right : 0
);
const lane = Math.max(count("left"), count("right"));
const height = Math.max(HEAD + reserve.top + group.rows * CELL_H + reserve.bottom, lane ? HEAD + 30 + (lane - 1) * DOCK_ROW + LABEL_H + 10 : 0, HEAD + CELL_H);
const height = Math.max(HEAD + reserve.top + group.rows * CELL_H, HEAD + lane * CELL_H, HEAD + CELL_H) + FOOTER;
Object.assign(group, { reserve: reserve, header: header, w: width, h: height });
state.sizes.set(group.id, { w: width, h: height });
});
@@ -433,7 +452,7 @@
}
rects[group.id] = { x: at.x, y: at.y, w: group.w, h: group.h };
});
resolveOverlaps(rects, fixed, GAP);
resolveOverlaps(rects, fixed, GAP, state.age);
groups.forEach((group) => {
state.regions.set(group.id, { x: rects[group.id].x, y: rects[group.id].y });
});
@@ -447,6 +466,9 @@
const inner = group.w - group.reserve.left - group.reserve.right;
const gridX = at.x + group.reserve.left + (inner - group.cols * CELL_W) / 2;
const gridY = at.y + HEAD + group.reserve.top;
/* The lines the legs run along: inside the side strips and just outside the grid, where no label reaches. */
const ringLeft = group.reserve.left ? at.x + group.reserve.left : gridX;
const ringRight = group.reserve.right ? at.x + group.w - group.reserve.right : gridX + group.cols * CELL_W;
const region = {
id: group.id,
color: state.colors.get(group.id),
@@ -456,7 +478,9 @@
h: group.h,
cols: group.cols,
rows: group.rows,
grid: { x: gridX, y: gridY, bottom: gridY + group.rows * CELL_H },
grid: { x: gridX, y: gridY },
ring: { x0: ringLeft, x1: ringRight, y0: gridY - RING_GAP, y1: gridY + group.rows * CELL_H + RING_GAP },
footer: { y: at.y + group.h - FOOTER, h: FOOTER },
header: group.header,
count: group.members.length,
alive: group.members.filter((node) => node.alive).length,
@@ -470,7 +494,7 @@
const row = Math.floor(cell / group.cols);
const x = gridX + col * CELL_W + CELL_W / 2;
const y = gridY + row * CELL_H + GRID_INSET;
const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: NODE_R, cell: cell, col: col, row: row, gateway: false, dock: null, box: { x: x - LABEL_W / 2, y: y + 18, w: LABEL_W, h: LABEL_H - 4 } };
const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: NODE_R, cell: cell, col: col, row: row, gateway: false, dock: null, port: null, box: { x: x - BOX_W / 2, y: y + NODE_R + 4, w: BOX_W, h: BOX_H } };
nodes.push(item);
nodeAt.set(node.key, item);
});
@@ -480,21 +504,16 @@
const others = Array.from(bridge.touches).filter((id) => id !== group.id && state.regions.has(id));
return others.length ? meanPoint(others.map((id) => netCentre(state, id))) : centreOf(region);
};
["left", "right", "top", "bottom"].forEach((side) => {
const horizontal = side === "top" || side === "bottom";
["left", "right", "top"].forEach((side) => {
const ordered = group.sides[side]
.map((node) => ({ node: node, key: own(overrides.docks, node.key) ? overrides.docks[node.key].t : horizontal ? spot(node).x : spot(node).y }))
.map((node) => ({ node: node, key: own(overrides.docks, node.key) ? overrides.docks[node.key].t : side === "top" ? spot(node).x : spot(node).y }))
.sort((a, b) => a.key - b.key || a.node.key.localeCompare(b.node.key))
.map((entry) => entry.node);
ordered.forEach((node, index) => {
const [x, y] = side === "left" || side === "right"
? [side === "left" ? at.x : at.x + group.w, at.y + HEAD + 30 + index * DOCK_ROW]
: [side === "top" ? at.x + Math.max(group.header, group.reserve.left) + 30 + index * DOCK_PITCH : at.x + group.reserve.left + 40 + index * DOCK_PITCH, side === "top" ? at.y : at.y + group.h];
const box = side === "left" ? { x: x + 27, y: y + 6, w: LABEL_W, h: LABEL_H }
: side === "right" ? { x: x - 27 - LABEL_W, y: y + 6, w: LABEL_W, h: LABEL_H }
: side === "top" ? { x: x + 27, y: y + 6, w: LABEL_W, h: LABEL_H }
: { x: x + 27, y: y - 6 - LABEL_H, w: LABEL_W, h: LABEL_H };
const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: GATEWAY_R, cell: null, gateway: true, dock: { side: side, index: index }, box: box };
const x = side === "top" ? at.x + Math.max(group.header + 20, group.reserve.left) + index * CELL_W + CELL_W / 2 : side === "left" ? at.x + CELL_W / 2 : at.x + group.w - CELL_W / 2;
const y = at.y + HEAD + (side === "top" ? 0 : index * CELL_H) + GRID_INSET;
const port = side === "left" ? { x: at.x, y: y } : side === "right" ? { x: at.x + group.w, y: y } : { x: x, y: at.y };
const item = { key: node.key, net: group.id, color: region.color, x: x, y: y, r: GATEWAY_R, cell: null, gateway: true, dock: { side: side, index: index }, port: port, box: { x: x - BOX_W / 2, y: y + GATEWAY_R + 4, w: BOX_W, h: BOX_H } };
nodes.push(item);
nodeAt.set(node.key, item);
});
@@ -509,8 +528,8 @@
bridge.touches.forEach((id) => {
if (id === gateway.net || !regionAt.has(id)) return;
const target = regionAt.get(id);
const end = entryPoint(gateway, target);
spokes.push({ gateway: bridge.key, to: id, color: target.color, x1: gateway.x, y1: gateway.y, x2: end.x, y2: end.y, d: spokePath(gateway, gateway.dock.side, end, edgeSide(end, target)) });
const end = entryPoint(gateway.port, target);
spokes.push({ gateway: bridge.key, to: id, color: target.color, x1: gateway.port.x, y1: gateway.port.y, x2: end.x, y2: end.y, d: spokePath(gateway.port, gateway.dock.side, end, edgeSide(end, target)) });
});
});
@@ -526,39 +545,43 @@
/* ------------------------------------------------------------------ routes */
/* From a gateway's disc out to the ring of gaps around the grid: sideways for a strip on the
left or right, and along the gap beside its cell for a strip on top. */
function lead(region, dock) {
const disc = { x: dock.x, y: dock.y };
const side = dock.dock.side;
if (side === "left") return [disc, { x: region.ring.x0, y: dock.y }];
if (side === "right") return [disc, { x: region.ring.x1, y: dock.y }];
const column = dock.x - CELL_W / 2;
return [disc, { x: column, y: dock.y }, { x: column, y: region.ring.y0 }];
}
/* From a member of a network to a gateway docked on it (or back), along the gaps between
cells and the lanes beside them, so the line never crosses a node or a label. It leaves the
node upwards (its label is below), runs along the gap above its row, then along the edge
of the grid to the gateway. */
cells, so the line never crosses a node or a label. It leaves the node upwards (its label
is below), runs along the gap above its row, then along the ring to the gateway. */
function streetPoints(map, plain, dock) {
const region = map.regionAt.get(plain.net);
const gap = region.grid.y + plain.row * CELL_H;
const start = { x: plain.x, y: plain.y };
const up = { x: plain.x, y: gap };
const end = { x: dock.x, y: dock.y };
const side = dock.dock.side;
const way = lead(region, dock).reverse();
const ring = way[0];
if (side === "left" || side === "right") {
const edge = side === "left" ? region.grid.x : region.grid.x + region.cols * CELL_W;
return [start, up, { x: edge, y: gap }, { x: edge, y: end.y }, end];
}
if (dock.dock.side !== "top") return [start, up, { x: ring.x, y: gap }].concat(way);
const left = region.grid.x + plain.col * CELL_W;
const column = Math.abs(left - end.x) <= Math.abs(left + CELL_W - end.x) ? left : left + CELL_W;
const lane = side === "top" ? region.grid.y - 4 : region.grid.bottom + 4;
return [start, up, { x: column, y: gap }, { x: column, y: lane }, { x: end.x, y: lane }, end];
const column = Math.abs(left - ring.x) <= Math.abs(left + CELL_W - ring.x) ? left : left + CELL_W;
return [start, up, { x: column, y: gap }, { x: column, y: ring.y }].concat(way);
}
/* Between two gateways of one network: out of the first onto the ring of gaps around the
grid, along it, and into the second. */
function ringPoints(map, a, b) {
const region = map.regionAt.get(a.net);
const x0 = region.grid.x;
const x1 = region.grid.x + region.cols * CELL_W;
const y0 = region.grid.y - 4;
const y1 = region.grid.bottom + 4;
const onRing = (dock) => ({ left: { x: x0, y: dock.y }, right: { x: x1, y: dock.y }, top: { x: dock.x, y: y0 }, bottom: { x: dock.x, y: y1 } })[dock.dock.side];
const ea = onRing(a);
const eb = onRing(b);
const { x0, x1, y0, y1 } = region.ring;
const outA = lead(region, a);
const outB = lead(region, b);
const ea = outA[outA.length - 1];
const eb = outB[outB.length - 1];
const sa = a.dock.side;
const sb = b.dock.side;
const length = (points) => points.reduce((sum, point, i) => (i ? sum + Math.hypot(point.x - points[i - 1].x, point.y - points[i - 1].y) : 0), 0);
@@ -570,18 +593,11 @@
const over = [{ x: xa, y: y0 }, { x: xb, y: y0 }];
const under = [{ x: xa, y: y1 }, { x: xb, y: y1 }];
middle = length([ea].concat(over, [eb])) <= length([ea].concat(under, [eb])) ? over : under;
} else if ((sa === "top" && sb === "bottom") || (sa === "bottom" && sb === "top")) {
const ya = sa === "top" ? y0 : y1;
const yb = sa === "top" ? y1 : y0;
const west = [{ x: x0, y: ya }, { x: x0, y: yb }];
const east = [{ x: x1, y: ya }, { x: x1, y: yb }];
middle = length([ea].concat(west, [eb])) <= length([ea].concat(east, [eb])) ? west : east;
} else if (sa !== sb) {
const horizontal = sa === "left" || sa === "right" ? sa : sb;
const vertical = sa === "top" || sa === "bottom" ? sa : sb;
middle = [{ x: horizontal === "left" ? x0 : x1, y: vertical === "top" ? y0 : y1 }];
const side = sa === "top" ? sb : sa;
middle = [{ x: side === "left" ? x0 : x1, y: y0 }];
}
return [{ x: a.x, y: a.y }, ea].concat(middle, [eb, { x: b.x, y: b.y }]);
return outA.concat(middle, outB.reverse());
}
function legPoints(map, from, to) {
@@ -620,7 +636,7 @@
const gateway = map.nodeAt.get(route.via);
const name = model.byKey.get(route.via);
captions.set(key, "via " + (name ? name.hostname : route.via));
captions.set(key, "via " + M.shorten(name ? name.hostname : route.via, 20));
if (!gateway) return;
if (gateway !== origin) leg(origin, gateway, origin.color);
if (gateway.net === target.net) leg(gateway, target, target.color);
@@ -638,21 +654,31 @@
const region = map.regionAt.get(net);
if (!region) return null;
const col = clamp(Math.floor((x - region.grid.x) / CELL_W), 0, region.cols - 1);
const row = Math.max(0, Math.floor((y - region.grid.y) / CELL_H));
const row = clamp(Math.floor((y - region.grid.y) / CELL_H), 0, region.rows);
return row * region.cols + col;
}
/* Which edge of a network a dropped gateway belongs to, and the position that orders it along that edge. */
function dockAt(map, net, x, y) {
/* Which edge of a network a dropped gateway belongs to, and the position that orders it along that
edge. The edge is the one whose strip is nearest; where two strips meet (a corner) the gateway's
current edge wins, so a gateway put back where it was stays put. */
function dockAt(map, net, x, y, current) {
const region = map.regionAt.get(net);
const side = edgeSide({ x: clamp(x, region.x, region.x + region.w), y: clamp(y, region.y, region.y + region.h) }, region);
return { side: side, t: side === "top" || side === "bottom" ? x : y };
const top = region.y + HEAD + GRID_INSET;
const bottom = region.footer.y;
const away = (x0, y0, x1, y1) => Math.hypot(x - clamp(x, x0, x1), y - clamp(y, y0, y1));
const strips = [
[away(region.x + CELL_W / 2, top, region.x + CELL_W / 2, bottom), "left"],
[away(region.x + region.w - CELL_W / 2, top, region.x + region.w - CELL_W / 2, bottom), "right"],
[away(region.x + region.header + 20, top, region.x + region.w - CELL_W / 2, top), "top"],
];
const side = strips.sort((p, q) => Math.round(p[0]) - Math.round(q[0]) || (q[1] === current) - (p[1] === current))[0][1];
return { side: side, t: side === "top" ? x : y };
}
const api = {
CELL_W, CELL_H, GATEWAY_R, NODE_R, GAP, EMPTY_BOUNDS,
CELL_W, CELL_H, GATEWAY_R, NODE_R, GAP, EMPTY_BOUNDS, STAT_W, STAT_COUNT, STAT_TOP, STAT_ROW,
newState, newOverrides, parseOverrides, serializeOverrides, hasOverrides, setRegion, setCell, setDock,
gridSlots, orderNetworks, resolveOverlaps, entryPoint, roundedPath, computeMap, traceGeometry, cellAt, dockAt,
gridSlots, orderNetworks, resolveOverlaps, entryPoint, roundedPath, computeMap, traceGeometry, cellAt, dockAt, noteGateways,
};
if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
+101 -2
View File
@@ -107,6 +107,7 @@
self: Boolean(member.self),
roles: member.roles,
declined: member.declined || [],
stats: member.stats || {},
}));
const byKey = new Map(nodes.map((node) => [node.key, node]));
const states = roleStates(nodes, payload.roles);
@@ -245,12 +246,110 @@
return text + " · " + held.length + "/" + assigned.length + " roles held";
}
/* ------------------------------------------------------------------ hardware */
function formatSize(gb) {
if (gb >= 1000) return (gb / 1024).toFixed(1) + " TB";
if (gb >= 100) return Math.round(gb) + " GB";
return gb.toFixed(1) + " GB";
}
function formatUptime(hours) {
if (hours < 1) return Math.round(hours * 60) + "m";
if (hours < 48) return Math.round(hours) + "h";
return Math.floor(hours / 24) + "d " + Math.round(hours % 24) + "h";
}
const percent = (used, total) => (total > 0 ? Math.max(0, Math.min(100, Math.round((100 * used) / total))) : null);
const barLevel = (pct) => (pct >= 90 ? "crit" : pct >= 75 ? "warn" : "ok");
/* What one node reports about itself, ready to draw: three bars and a footer. */
function describeStats(stats) {
stats = stats || {};
const num = (key) => (typeof stats[key] === "number" ? stats[key] : null);
const usage = (usedKey, totalKey) => {
const used = num(usedKey);
const total = num(totalKey);
return used === null || total === null ? null : { pct: percent(used, total), text: formatSize(used) + " / " + formatSize(total) };
};
const load = num("cpu_percent");
const cores = num("cpu_cores");
const threads = num("cpu_threads");
let cpu = null;
if (load !== null) {
let text = Math.round(load) + "%";
if (cores && threads) text += " · " + cores + "C/" + threads + "T";
else if (threads) text += " · " + threads + "T";
cpu = { pct: Math.round(load), text: text };
}
const gpuCount = num("gpu_count");
const gpuUsage = usage("gpu_mem_used_gb", "gpu_mem_total_gb");
const uptime = num("uptime_hours");
const ram = usage("mem_used_gb", "mem_total_gb");
const disk = usage("disk_used_gb", "disk_total_gb");
const gpu = gpuCount && gpuUsage ? (gpuCount > 1 ? gpuCount + "× " : "") + "GPU " + gpuUsage.text : null;
const footer = [uptime === null ? null : "up " + formatUptime(uptime), gpu].filter(Boolean).join(" · ");
return { rows: [["CPU", cpu], ["RAM", ram], ["DISK", disk]], footer: cpu || ram || disk ? footer : "no stats reported" };
}
/* Per machine these are the same for every node on it; the "used" ones move and are averaged. */
const TOTAL_KEYS = ["cpu_cores", "cpu_threads", "mem_total_gb", "disk_total_gb", "gpu_count", "gpu_mem_total_gb"];
const USED_KEYS = ["mem_used_gb", "disk_used_gb", "gpu_mem_used_gb"];
const plural = (count, word) => count + " " + word + (count === 1 ? "" : "s");
/* "CPU 9% · 6C/12T, RAM ..." for the rows that have numbers. */
const describeRows = (rows) => rows.filter(([, usage]) => usage).map(([name, usage]) => name + " " + usage.text);
/* The hardware of one network added up. Nodes that report the same complete totals are one
machine (containers on a host all see the host), so a machine is counted once and its
numbers are the average of what its nodes report. Load is weighted by threads. */
function networkStats(model, net) {
const reporting = model.nodes.filter((node) => node.net === net && node.alive && Object.keys(node.stats).length);
const machines = new Map();
reporting.forEach((node) => {
const s = node.stats;
const complete = typeof s.mem_total_gb === "number" && typeof s.disk_total_gb === "number";
const fingerprint = complete ? TOTAL_KEYS.map((key) => s[key]).join("|") : node.key;
if (!machines.has(fingerprint)) machines.set(fingerprint, []);
machines.get(fingerprint).push(s);
});
const numbers = (list, key) => list.map((s) => s[key]).filter((value) => typeof value === "number");
const mean = (list, key) => {
const values = numbers(list, key);
return values.length ? values.reduce((a, b) => a + b, 0) / values.length : null;
};
const stats = {};
const add = (key, value) => {
if (typeof value === "number") stats[key] = (stats[key] || 0) + value;
};
let weighted = 0;
let weight = 0;
machines.forEach((list) => {
TOTAL_KEYS.forEach((key) => add(key, numbers(list, key)[0]));
USED_KEYS.forEach((key) => add(key, mean(list, key)));
const load = mean(list, "cpu_percent");
if (load !== null) {
const threads = numbers(list, "cpu_threads")[0] || 1;
weighted += load * threads;
weight += threads;
}
});
if (weight) stats.cpu_percent = weighted / weight;
return Object.assign(describeStats(stats), { machines: machines.size, nodes: reporting.length });
}
/* The full text of a node: tooltip and screen reader. */
function describeNode(model, node, routes) {
const lines = [node.hostname + " " + node.key + (node.self ? " (this node)" : "") + (node.alive ? "" : " (down)")];
const bridge = model.bridgeAt.get(node.key);
if (bridge) lines.push("gateway: relays " + bridge.pairs + " node pair" + (bridge.pairs === 1 ? "" : "s"));
if (bridge) lines.push("gateway: relays " + plural(bridge.pairs, "node pair"));
lines.push("roles: " + (node.roles.join(", ") || "none"));
const hardware = describeStats(node.stats);
lines.push(describeRows(hardware.rows).concat(hardware.footer || []).join(" · "));
if (node.declined.length) lines.push("declined: " + node.declined.join(", "));
const route = routes && node.alive && node.key !== routes.viewKey ? routes.byKey.get(node.key) : null;
@@ -267,7 +366,7 @@
return lines.join("\n");
}
const api = { OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, summarize, describeNode };
const api = { describeStats, describeRows, networkStats, plural, barLevel, OTHER_NETWORK, COLORS, address, keyOf, networkOf, compareNetworks, compareAddresses, shorten, wrapLabels, analyze, bridgesOf, resolveView, routesFrom, routeSummary, specialRoles, alerts, onDemandHint, summarize, describeNode };
if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
})();
@@ -5,6 +5,7 @@
--muted: #6b7686;
--line: #d3d9e1;
--down: #98a2b3;
--track: #dde2ea;
--panel: rgba(255, 255, 255, 0.92);
--warn: #c67a08;
--crit: #d64545;
@@ -24,6 +25,7 @@
--muted: #93a0b3;
--line: #313a47;
--down: #6b7686;
--track: #2a3340;
--panel: rgba(27, 33, 41, 0.92);
--warn: #ffb04a;
--crit: #ff6b6b;
@@ -119,6 +121,12 @@ p { margin: 4px 0; }
.node.down .disc { stroke-dasharray: 4 3; }
.node.down .dot { fill: var(--down); }
.node.gateway .disc { stroke-width: 4; }
/* The door on the network's edge and its link to the gateway node inside. */
.port { fill: var(--card); stroke: var(--net); stroke-width: 3.5; vector-effect: non-scaling-stroke; pointer-events: none; }
.port-link { stroke: var(--net); stroke-width: 3; stroke-linecap: round; vector-effect: non-scaling-stroke; pointer-events: none; }
.foot-rule { stroke: color-mix(in srgb, var(--net) 40%, transparent); stroke-width: 1; vector-effect: non-scaling-stroke; }
.foot .st-text { font-weight: 600; }
.node.none .halo, .node.unknown .halo { stroke: var(--muted); stroke-width: 2; stroke-dasharray: 4 3; }
.node.reached .halo { stroke: var(--net); stroke-width: 2; }
.node:hover .halo { stroke: var(--muted); stroke-dasharray: none; }
@@ -131,6 +139,15 @@ p { margin: 4px 0; }
.l-name { fill: var(--text); font-size: 13px; font-weight: 600; }
.l-roles { fill: var(--muted); font-size: 11px; }
.l-caption { fill: var(--net); font-size: 11px; font-weight: 600; }
.st-text, .st-foot { paint-order: stroke; stroke: color-mix(in srgb, var(--net) 9%, var(--bg)); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; font-size: 11px; font-variant-numeric: tabular-nums; }
.st-name { fill: var(--muted); }
.st-value { fill: var(--text); }
.st-foot { fill: var(--muted); }
.st-track { fill: color-mix(in srgb, var(--text) 13%, transparent); }
.st-fill { fill: var(--net); }
.st-fill.warn { fill: var(--warn); }
.st-fill.crit { fill: var(--crit); }
.node.none .l-caption, .node.unknown .l-caption { fill: var(--muted); }
/* Level of detail: one class on the root, decided by the zoom. */
@@ -139,7 +156,11 @@ p { margin: 4px 0; }
.lod0 .region-count { font-size: 24px; }
.lod0 .region-dot { r: 12px; }
.lod1 .l-roles { display: none; }
.lod0 .stats { display: none; }
.lod1 .st-text, .lod1 .st-foot { display: none; }
.lod0 .node.full .l-name, .lod0 .node.full .l-roles, .lod0 .node.full .l-caption, .lod1 .node.full .l-roles { display: inline; }
.lod0 .node.full .stats { display: inline; }
.lod1 .node.full .st-text, .lod1 .node.full .st-foot { display: inline; }
.lod0 .l-caption { display: none; }
.flow { stroke-dasharray: none; }
+1 -1
View File
@@ -7,7 +7,7 @@
const K_MAX = 4;
const K_START_MAX = 1.2;
const KEEP = 80;
const LOD_AT = [0.4, 0.9];
const LOD_AT = [0.35, 0.7];
const HYSTERESIS = 0.1;
const THRESHOLD = { mouse: 4, pen: 4, touch: 8 };