web view
Build and Push Docker Image / build-and-push (push) Successful in 6s

This commit is contained in:
ciomek committed 2026-09-20 11:47:27 +02:00
1 parent 4793b8dbf8
commit 75b96c986d
8 files changed
+2029 -500

No files matched your search

+2 -2
View File
@@ -1,7 +1,7 @@
import logging import logging
from pathlib import Path from pathlib import Path
from flask import Flask, jsonify from flask import Flask, jsonify, request
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
@@ -18,7 +18,7 @@ def create_app(get_status):
@app.get("/api/status") @app.get("/api/status")
def status(): def status():
try: try:
payload = get_status() payload = get_status(compact=request.args.get("compact") == "1")
except Exception: except Exception:
app.logger.exception("Could not build the status") app.logger.exception("Could not build the status")
return jsonify(error="status unavailable"), 500 return jsonify(error="status unavailable"), 500
+39 -6
View File
@@ -39,16 +39,49 @@ class WebView(Role):
raise RuntimeError("The Unit role is not available") raise RuntimeError("The Unit role is not available")
return unit return unit
def _status(self): def _status(self, compact=False):
payload = self._unit().show_cluster() payload = self._unit().show_cluster()
payload["links"] = self._links(payload["members"]) own_key = next((member["ip"], member["port"]) for member in payload["members"] if member["self"])
views = self._fresh_views()
views[own_key] = payload["members"]
if compact:
known = {(member["ip"], member["port"]) for member in payload["members"]}
views = {key: rows for key, rows in views.items() if key in known}
payload["views"] = [list(key) for key in sorted(views)]
if compact:
payload["routes"] = self._routes(views, payload["members"])
else:
payload["links"] = self._links(views)
return payload return payload
def _links(self, own_rows): @staticmethod
own_key = next((member["ip"], member["port"]) for member in own_rows if member["self"]) def _routes(views, members):
views = self._fresh_views() """Per reporting node, only what differs from "reached directly": the relayed and the unreached members."""
views[own_key] = own_rows index = {(member["ip"], member["port"]): i for i, member in enumerate(members)}
routes = []
for source in sorted(views):
if source not in index:
continue
rows = {(row["ip"], row["port"]): row for row in views[source] if "direct" in row and not row.get("self")}
via, none = {}, []
for target, i in index.items():
if target == source or not members[i]["alive"]:
continue
row = rows.get(target)
if row is None or not row.get("alive") or not (row["direct"] or row.get("via")):
none.append(i)
elif not row["direct"]:
gateway = index.get(tuple(row["via"]))
if gateway is None:
none.append(i)
else:
via.setdefault(str(gateway), []).append(i)
if via or none:
routes.append({"from": index[source], "via": via, "none": none})
return routes
@staticmethod
def _links(views):
links = [] links = []
for source, rows in views.items(): for source, rows in views.items():
for row in rows: for row in rows:
File diff suppressed because it is too large. Load diff
+49 -14
View File
@@ -8,29 +8,64 @@
</head> </head>
<body> <body>
<header> <header>
<div class="headline">
<h1>Colony</h1> <h1>Colony</h1>
<p id="summary" class="summary">Loading...</p> <p id="summary" class="summary">Loading...</p>
</div>
<div id="alerts" class="alerts"></div> <div id="alerts" class="alerts"></div>
<p id="error" class="error" hidden></p> <p id="error" class="error" hidden></p>
<p id="viewpoint" class="route-line"></p>
<p id="ondemand" class="muted"></p>
</header> </header>
<main> <main id="stage">
<svg id="graph" viewBox="0 0 788 556" role="img" aria-label="Nodes with their hardware and the roles they hold"></svg> <svg id="map" class="lod2" role="group" aria-labelledby="map-title" aria-describedby="map-help">
<ul class="legend"> <title id="map-title">Map of the networks, the nodes in them, and the gateways that connect them</title>
<li><span class="swatch alive"></span>node alive</li> <desc id="map-help">Tab to a node, arrow keys move between nodes, Enter pins it. Plus and minus zoom, 0 fits everything, n cycles networks, Alt with arrows moves the focused item, Escape releases.</desc>
<li><span class="swatch down"></span>node down</li> <defs>
<li><span class="swatch link"></span>direct link</li> <marker id="arrow-0" class="arrow n0" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
<li><span class="swatch oneway"></span>one-way reach</li> <marker id="arrow-1" class="arrow n1" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
<li><span class="swatch bridge"></span>relays for others</li> <marker id="arrow-2" class="arrow n2" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
<li><span class="swatch held"></span>role held</li> <marker id="arrow-3" class="arrow n3" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
<li><span class="swatch unmet"></span>role unmet</li> <marker id="arrow-4" class="arrow n4" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
<li><span class="swatch duplicate"></span>held twice</li> <marker id="arrow-5" class="arrow n5" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z"/></marker>
<li><span class="swatch general"></span>runs on every node</li> </defs>
<g id="world">
<g id="layer-spokes"></g>
<g id="layer-regions"></g>
<g id="layer-ends"></g>
<g id="layer-legs"></g>
<g id="layer-nodes"></g>
</g>
</svg>
<div class="controls" role="group" aria-label="Map view">
<button id="reset" type="button" class="text" hidden title="Forget the arrangement you made and lay the map out again">Reset layout</button>
<button id="zoom-in" type="button" aria-label="Zoom in" title="Zoom in (+)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12"/></svg></button>
<button id="zoom-out" type="button" aria-label="Zoom out" title="Zoom out (-)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h12"/></svg></button>
<button id="fit" type="button" aria-label="Fit everything" title="Fit everything (0)"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M3 7V3h4M13 3h4v4M17 13v4h-4M7 17H3v-4"/></svg></button>
</div>
<div id="legend" class="legend">
<button id="legend-close" type="button" aria-label="Hide the legend" title="Hide the legend"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5L5 15"/></svg></button>
<ul>
<li><span class="swatch network"></span>network, each in its own colour</li>
<li><span class="swatch node"></span>node</li>
<li><span class="swatch gateway"></span>gateway, on the edge of its network</li>
<li><span class="swatch flow"></span>route through a gateway</li>
<li><span class="swatch none"></span>no recent contact (not the same as unreachable)</li>
</ul> </ul>
<p id="ondemand" class="muted"></p> <p>Drag the background to move, wheel or pinch to zoom. Drag a node, a gateway or a network's title to rearrange.</p>
</div>
<button id="legend-open" type="button" class="legend-open" aria-label="Show the legend" title="Show the legend" hidden>?</button>
<p id="updated" class="muted stamp"></p>
</main> </main>
<footer class="muted"><span id="updated"></span></footer> <div id="live" class="sr-only" role="status" aria-live="polite"></div>
<ul id="reader" class="sr-only" aria-label="Networks and their nodes"></ul>
<script src="/static/model.js"></script>
<script src="/static/layout.js"></script>
<script src="/static/viewport.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
</body> </body>
</html> </html>
+659
View File
@@ -0,0 +1,659 @@
"use strict";
/* Where things go. World units, independent of the window: the page maps the world
onto the screen with a pan and zoom transform. Pure and deterministic; the only
memory is the `state` object the caller keeps between calls, which is what keeps
the map from jumping when the cluster changes. */
(function () {
const M = typeof require !== "undefined" ? require("./model.js") : globalThis.Colony;
const CELL_W = 120;
const CELL_H = 104;
const GRID_INSET = 30;
const HEAD = 38;
const REGION_PAD = 14;
const GAP = 56;
const MIN_WIDTH = 240;
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;
const NOMINAL = { w: 320, h: 240 };
const GATEWAY_HOLD_MS = 60000;
const MAX_OVERRIDES = 300;
const LABEL_W = 104;
const LABEL_H = 56;
/* ---------------------------------------------------------------- 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() };
}
/* Only what the user changed by hand is ever stored. */
function newOverrides() {
return { regions: {}, slots: {}, docks: {}, legend: true };
}
const finite = (value) => typeof value === "number" && Number.isFinite(value);
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
function parseOverrides(text) {
const clean = newOverrides();
let data;
try {
data = JSON.parse(text);
} catch (error) {
return clean;
}
if (!data || typeof data !== "object") return clean;
const table = (value) => (value && typeof value === "object" && !Array.isArray(value) ? value : {});
Object.entries(table(data.regions)).forEach(([net, region]) => {
if (!region || !finite(region.x) || !finite(region.y)) return;
clean.regions[net] = { x: Math.max(-1e5, Math.min(1e5, region.x)), y: Math.max(-1e5, Math.min(1e5, region.y)) };
if (Number.isInteger(region.cols) && region.cols >= 1 && region.cols <= 6) clean.regions[net].cols = region.cols;
});
Object.entries(table(data.slots)).forEach(([net, cells]) => {
const kept = {};
Object.entries(table(cells)).forEach(([key, cell]) => {
if (Number.isInteger(cell) && cell >= 0 && cell < 400) kept[key] = cell;
});
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 (typeof data.legend === "boolean") clean.legend = data.legend;
return limitOverrides(clean);
}
function countOverrides(overrides) {
return Object.keys(overrides.regions).length + Object.values(overrides.slots).reduce((sum, cells) => sum + Object.keys(cells).length, 0) + Object.keys(overrides.docks).length;
}
/* Keep the newest entries; objects remember insertion order. */
function limitOverrides(overrides) {
while (countOverrides(overrides) > MAX_OVERRIDES) {
const nets = Object.keys(overrides.slots);
if (nets.length) {
const cells = overrides.slots[nets[0]];
delete cells[Object.keys(cells)[0]];
if (!Object.keys(cells).length) delete overrides.slots[nets[0]];
} else if (Object.keys(overrides.docks).length) {
delete overrides.docks[Object.keys(overrides.docks)[0]];
} else {
delete overrides.regions[Object.keys(overrides.regions)[0]];
}
}
return overrides;
}
const serializeOverrides = (overrides) => JSON.stringify(overrides);
function setRegion(overrides, net, region) {
delete overrides.regions[net];
overrides.regions[net] = region;
limitOverrides(overrides);
}
function setCell(overrides, net, key, cell) {
const cells = overrides.slots[net] || {};
delete cells[key];
cells[key] = cell;
delete overrides.slots[net];
overrides.slots[net] = cells;
limitOverrides(overrides);
}
function setDock(overrides, key, dock) {
delete overrides.docks[key];
overrides.docks[key] = dock;
limitOverrides(overrides);
}
const hasOverrides = (overrides) => countOverrides(overrides) > 0;
/* ------------------------------------------------- arrangement of networks */
/* Two networks per row, a lone last one centred under them, so with three
networks each is next to the other two. */
function gridSlots(count, columnLimit) {
const per = Math.max(1, Math.min(columnLimit, count <= 4 ? 2 : 3));
const rows = [];
for (let left = count; left > 0; left -= per) rows.push(Math.min(per, left));
const columns = Math.max.apply(null, rows.concat(1));
const slots = [];
rows.forEach((size, row) => {
for (let col = 0; col < size; col++) slots.push({ x: col + (columns - size) / 2, y: row, row: row });
});
return slots;
}
/* Put networks that share a gateway close together. */
function orderNetworks(ids, bridges, first, slots) {
const lead = ids.includes(first) ? [first] : [];
const rest = ids.filter((id) => id !== first);
if (rest.length > 6) return lead.concat(rest);
const cost = (order) => {
const at = new Map(order.map((id, index) => [id, slots[index]]));
return bridges.reduce((sum, bridge) => {
const spots = Array.from(bridge.touches).map((id) => at.get(id)).filter(Boolean);
let total = 0;
for (let i = 0; i < spots.length; i++) {
for (let j = i + 1; j < spots.length; j++) total += Math.hypot(spots[i].x - spots[j].x, spots[i].y - spots[j].y);
}
return sum + total;
}, 0);
};
const orders = [];
const permute = (left, chosen) => {
if (!left.length) {
orders.push(lead.concat(chosen));
return;
}
left.forEach((id, index) => permute(left.slice(0, index).concat(left.slice(index + 1)), chosen.concat(id)));
};
permute(rest, []);
return orders.reduce((best, order) => (cost(order) < cost(best) - 1e-9 ? order : best), orders[0]);
}
/* --------------------------------------------------------------- geometry */
const overlapAmount = (a, b, gap) => ({
x: Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x) + gap,
y: Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y) + gap,
});
/* 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) {
const ids = Object.keys(rects).sort();
for (let pass = 0; pass < 40; pass++) {
let moved = false;
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
const a = rects[ids[i]];
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;
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;
else self.y += self.y + self.h / 2 >= other.y + other.h / 2 ? o.y : -o.y;
moved = true;
}
}
if (!moved) break;
}
}
const centreOf = (rect) => ({ x: rect.x + rect.w / 2, y: rect.y + rect.h / 2 });
const meanPoint = (points) => points.reduce((sum, point) => ({ x: sum.x + point.x / points.length, y: sum.y + point.y / points.length }), { x: 0, y: 0 });
const netCentre = (state, id) => centreOf({ ...state.regions.get(id), ...state.sizes.get(id) });
const EMPTY_BOUNDS = { x: 0, y: 0, w: 400, h: 300 };
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";
}
const normals = { left: [-1, 0], right: [1, 0], top: [0, -1], bottom: [0, 1] };
/* Where a line from an outside point towards the centre of the rect enters it. */
function entryPoint(point, rect) {
const dx = rect.x + rect.w / 2 - point.x;
const dy = rect.y + rect.h / 2 - point.y;
const slab = (from, delta, low, high) => (delta === 0 ? -Infinity : Math.min((low - from) / delta, (high - from) / delta));
const t = Math.max(0, Math.min(1, Math.max(slab(point.x, dx, rect.x, rect.x + rect.w), slab(point.y, dy, rect.y, rect.y + rect.h))));
return { x: point.x + dx * t, y: point.y + dy * t };
}
function edgeSide(point, rect) {
const gaps = [
[Math.abs(point.x - rect.x), "left"],
[Math.abs(point.x - (rect.x + rect.w)), "right"],
[Math.abs(point.y - rect.y), "top"],
[Math.abs(point.y - (rect.y + rect.h)), "bottom"],
];
return gaps.sort((a, b) => a[0] - b[0])[0][1];
}
/* A curve that leaves the dock square to its edge and arrives square to the border it meets. */
function spokePath(start, startSide, end, endSide) {
const reach = Math.max(30, Math.min(140, Math.hypot(end.x - start.x, end.y - start.y) / 2.4));
const a = normals[startSide];
const b = normals[endSide];
const c1 = { x: start.x + a[0] * reach, y: start.y + a[1] * reach };
const c2 = { x: end.x + b[0] * reach, y: end.y + b[1] * reach };
return "M" + start.x + " " + start.y + " C" + c1.x + " " + c1.y + " " + c2.x + " " + c2.y + " " + end.x + " " + end.y;
}
/* Straight runs with rounded corners. */
function roundedPath(points, radius) {
const pts = points.filter((point, index) => index === 0 || Math.hypot(point.x - points[index - 1].x, point.y - points[index - 1].y) > 0.01);
if (pts.length < 2) return "";
let d = "M" + pts[0].x + " " + pts[0].y;
for (let i = 1; i < pts.length - 1; i++) {
const prev = pts[i - 1];
const at = pts[i];
const next = pts[i + 1];
const before = Math.hypot(at.x - prev.x, at.y - prev.y);
const after = Math.hypot(next.x - at.x, next.y - at.y);
const r = Math.min(radius, before / 2, after / 2);
const a = { x: at.x + ((prev.x - at.x) / before) * r, y: at.y + ((prev.y - at.y) / before) * r };
const b = { x: at.x + ((next.x - at.x) / after) * r, y: at.y + ((next.y - at.y) / after) * r };
d += " L" + a.x + " " + a.y + " Q" + at.x + " " + at.y + " " + b.x + " " + b.y;
}
return d + " L" + pts[pts.length - 1].x + " " + pts[pts.length - 1].y;
}
/* ------------------------------------------------------------------ layout */
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
function withGhosts(model, state, now) {
const bridges = model.bridges.slice();
model.bridges.forEach((bridge) => state.gateways.set(bridge.key, { time: now, bridge: bridge }));
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);
});
return bridges.sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
}
/* Cells of one network's grid: the user's choice wins, then where a node already was, then the first free one. */
function assignCells(state, net, plain, chosen) {
const previous = state.cells.get(net) || new Map();
const taken = new Map();
const placed = new Map();
const keys = plain.map((node) => node.key);
keys.slice().sort().forEach((key) => {
const cell = chosen[key];
if (cell !== undefined && !taken.has(cell)) {
taken.set(cell, key);
placed.set(key, cell);
}
});
keys.forEach((key) => {
const cell = previous.get(key);
if (!placed.has(key) && cell !== undefined && !taken.has(cell)) {
taken.set(cell, key);
placed.set(key, cell);
}
});
let free = 0;
keys.forEach((key) => {
if (placed.has(key)) return;
while (taken.has(free)) free += 1;
taken.set(free, key);
placed.set(key, free);
});
state.cells.set(net, placed);
return placed;
}
function computeMap(model, state, overrides, now) {
const bridges = withGhosts(model, state, now);
const bridgeAt = new Map(bridges.map((bridge) => [bridge.key, bridge]));
const nets = model.networks;
const selfNet = model.selfKey ? model.byKey.get(model.selfKey).net : null;
nets.forEach((net) => {
if (!state.colors.has(net)) state.colors.set(net, state.nextColor++ % M.COLORS);
});
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] || {};
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);
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));
return { id: id, members: members, plain: plain, gateways: members.filter((node) => bridgeAt.has(node.key)), cols: cols, cells: cells, rows: rows };
});
/* Where the networks are, or will be, for choosing the side a gateway docks on. */
const first = state.regions.size === 0;
const approx = new Map();
const slots = first ? gridSlots(nets.length, 3) : null;
const arrangement = first ? orderNetworks(nets, bridges, selfNet, slots) : null;
if (first) {
arrangement.forEach((id, index) => approx.set(id, { x: slots[index].x * 1000, y: slots[index].y * 1000 }));
} else {
groups.forEach((group) => {
if (state.regions.has(group.id)) return;
const near = bridges.filter((bridge) => bridge.touches.has(group.id)).flatMap((bridge) => Array.from(bridge.touches)).filter((id) => id !== group.id && state.regions.has(id));
const spots = (near.length ? near : Array.from(state.regions.keys())).map((id) => netCentre(state, id));
const centre = meanPoint(spots);
state.regions.set(group.id, { x: centre.x - NOMINAL.w / 2, y: centre.y - NOMINAL.h / 2 });
state.sizes.set(group.id, { w: NOMINAL.w, h: NOMINAL.h });
state.placeLater.add(group.id);
});
}
const centreFor = (id) => (approx.has(id) ? approx.get(id) : netCentre(state, id));
/* Sides, then sizes. */
groups.forEach((group) => {
group.sides = { left: [], right: [], top: [], bottom: [] };
group.gateways.forEach((node) => {
const bridge = bridgeAt.get(node.key);
const others = Array.from(bridge.touches).filter((id) => id !== group.id);
const signature = others.slice().sort().join("|");
let side = own(overrides.docks, node.key) ? overrides.docks[node.key].side : null;
if (!side) {
const kept = state.sides.get(node.key);
if (kept && kept.signature === signature) side = kept.side;
else {
const spots = others.filter((id) => approx.has(id) || state.regions.has(id)).map(centreFor);
const centre = spots.length ? meanPoint(spots) : null;
side = centre ? sideToward(centreFor(group.id), centre) : "right";
}
}
state.sides.set(node.key, { side: side, signature: signature });
group.sides[side].push(node);
});
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 };
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
);
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);
Object.assign(group, { reserve: reserve, header: header, w: width, h: height });
state.sizes.set(group.id, { w: width, h: height });
});
/* Origins: the user's, then where a network already was, then a free spot. */
const fixed = new Set();
if (first) {
const byId = new Map(groups.map((group) => [group.id, group]));
const rows = new Map();
arrangement.forEach((id, index) => rows.set(slots[index].row, (rows.get(slots[index].row) || []).concat(id)));
const widths = Array.from(rows.values()).map((ids) => ids.reduce((sum, id) => sum + byId.get(id).w, 0) + GAP * (ids.length - 1));
const widest = Math.max.apply(null, widths);
let y = 0;
Array.from(rows.keys()).sort((a, b) => a - b).forEach((row, index) => {
const ids = rows.get(row);
let x = (widest - widths[index]) / 2;
ids.forEach((id) => {
state.regions.set(id, { x: x, y: y });
x += byId.get(id).w + GAP;
});
y += Math.max.apply(null, ids.map((id) => byId.get(id).h)) + GAP;
});
} else if (state.placeLater.size) {
state.placeLater.forEach((id) => {
const group = groups.find((entry) => entry.id === id);
if (!group) return;
const placed = groups.filter((entry) => entry.id !== id && !state.placeLater.has(entry.id));
const target = centreOf({ ...state.regions.get(id), ...NOMINAL });
const candidates = [];
placed.forEach((other) => {
const at = state.regions.get(other.id);
candidates.push({ x: at.x + other.w + GAP, y: at.y }, { x: at.x, y: at.y + other.h + GAP }, { x: at.x - group.w - GAP, y: at.y }, { x: at.x, y: at.y - group.h - GAP });
});
const free = candidates.filter((spot) => placed.every((other) => {
const at = state.regions.get(other.id);
const o = overlapAmount({ x: spot.x, y: spot.y, w: group.w, h: group.h }, { x: at.x, y: at.y, w: other.w, h: other.h }, GAP - 1);
return o.x <= 0 || o.y <= 0;
}));
const pool = free.length ? free : candidates;
const best = pool.length ? pool.reduce((a, b) => (Math.hypot(a.x + group.w / 2 - target.x, a.y + group.h / 2 - target.y) <= Math.hypot(b.x + group.w / 2 - target.x, b.y + group.h / 2 - target.y) ? a : b)) : { x: 0, y: 0 };
state.regions.set(id, { x: best.x, y: best.y });
});
state.placeLater.clear();
}
const rects = {};
groups.forEach((group) => {
const at = state.regions.get(group.id);
const chosen = overrides.regions[group.id];
if (chosen) {
at.x = chosen.x;
at.y = chosen.y;
fixed.add(group.id);
}
rects[group.id] = { x: at.x, y: at.y, w: group.w, h: group.h };
});
resolveOverlaps(rects, fixed, GAP);
groups.forEach((group) => {
state.regions.set(group.id, { x: rects[group.id].x, y: rects[group.id].y });
});
/* Nodes and docks. */
const regions = [];
const nodes = [];
const nodeAt = new Map();
groups.forEach((group) => {
const at = state.regions.get(group.id);
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;
const region = {
id: group.id,
color: state.colors.get(group.id),
x: at.x,
y: at.y,
w: group.w,
h: group.h,
cols: group.cols,
rows: group.rows,
grid: { x: gridX, y: gridY, bottom: gridY + group.rows * CELL_H },
header: group.header,
count: group.members.length,
alive: group.members.filter((node) => node.alive).length,
moved: own(overrides.regions, group.id),
};
regions.push(region);
group.plain.forEach((node) => {
const cell = group.cells.get(node.key);
const col = cell % group.cols;
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 } };
nodes.push(item);
nodeAt.set(node.key, item);
});
const spot = (node) => {
const bridge = bridgeAt.get(node.key);
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";
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 }))
.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 };
nodes.push(item);
nodeAt.set(node.key, item);
});
});
});
const regionAt = new Map(regions.map((region) => [region.id, region]));
const spokes = [];
bridges.forEach((bridge) => {
const gateway = nodeAt.get(bridge.key);
if (!gateway) return;
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 pad = 40;
const left = Math.min.apply(null, regions.map((r) => r.x)) - pad;
const top = Math.min.apply(null, regions.map((r) => r.y)) - pad;
const bounds = regions.length
? { x: left, y: top, w: Math.max.apply(null, regions.map((r) => r.x + r.w)) + pad - left, h: Math.max.apply(null, regions.map((r) => r.y + r.h)) + pad - top }
: EMPTY_BOUNDS;
return { bounds: bounds, regions: regions, regionAt: regionAt, nodes: nodes, nodeAt: nodeAt, spokes: spokes, bridges: bridges, signature: model.signature };
}
/* ------------------------------------------------------------------ routes */
/* 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. */
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;
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];
}
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];
}
/* 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 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);
let middle = [];
if ((sa === "left" && sb === "right") || (sa === "right" && sb === "left")) {
const xa = sa === "left" ? x0 : x1;
const xb = sa === "left" ? x1 : x0;
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 }];
}
return [{ x: a.x, y: a.y }, ea].concat(middle, [eb, { x: b.x, y: b.y }]);
}
function legPoints(map, from, to) {
if (from.net === to.net) {
if (from.gateway && to.gateway) return ringPoints(map, from, to);
return from.gateway ? streetPoints(map, to, from).reverse() : streetPoints(map, from, to);
}
return [{ x: from.x, y: from.y }, { x: to.x, y: to.y }];
}
/* What a trace from one node lights up: legs inside a network, the spokes used, captions. */
function traceGeometry(map, model, routes) {
const legs = [];
const used = new Set();
const captions = new Map();
const kinds = new Map();
const seen = new Set();
const origin = routes ? map.nodeAt.get(routes.viewKey) : null;
if (!origin) return { legs: legs, used: used, captions: captions, kinds: kinds, origin: null };
const leg = (from, to, color) => {
const id = from.key + ">" + to.key;
if (seen.has(id)) return;
seen.add(id);
const points = legPoints(map, from, to);
legs.push({ from: from.key, to: to.key, color: color, points: points, d: from.net === to.net ? roundedPath(points, 9) : "M" + from.x + " " + from.y + " L" + to.x + " " + to.y });
};
routes.byKey.forEach((route, key) => {
const target = map.nodeAt.get(key);
if (!target) return;
kinds.set(key, route.kind);
if (route.kind === "none") captions.set(key, "no recent contact");
if (route.kind === "unknown") captions.set(key, "unknown");
if (route.kind !== "via") return;
const gateway = map.nodeAt.get(route.via);
const name = model.byKey.get(route.via);
captions.set(key, "via " + (name ? name.hostname : route.via));
if (!gateway) return;
if (gateway !== origin) leg(origin, gateway, origin.color);
if (gateway.net === target.net) leg(gateway, target, target.color);
else used.add(gateway.key + ">" + target.net);
});
if (origin.gateway) map.spokes.filter((spoke) => spoke.gateway === origin.key).forEach((spoke) => used.add(spoke.gateway + ">" + spoke.to));
return { legs: legs, used: used, captions: captions, kinds: kinds, origin: origin };
}
/* -------------------------------------------------------------- user moves */
/* The cell of a network's grid nearest to a point (world units), or null when the
network is not on the map. */
function cellAt(map, net, x, y) {
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));
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) {
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 api = {
CELL_W, CELL_H, GATEWAY_R, NODE_R, GAP, EMPTY_BOUNDS,
newState, newOverrides, parseOverrides, serializeOverrides, hasOverrides, setRegion, setCell, setDock,
gridSlots, orderNetworks, resolveOverlaps, entryPoint, roundedPath, computeMap, traceGeometry, cellAt, dockAt,
};
if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
})();
+273
View File
@@ -0,0 +1,273 @@
"use strict";
/* Pure facts about the cluster: who is in which network, who relays for whom, how
one node reaches the others. No DOM, no geometry. Loaded by the page as a classic
script (functions land on globalThis.Colony) and by the tests through require. */
(function () {
const OTHER_NETWORK = "other addresses";
const COLORS = 6;
const address = (member) => member.ip + ":" + member.port;
const keyOf = (pair) => pair[0] + ":" + pair[1];
function networkOf(ip) {
const parts = String(ip).split(".");
const octets = parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
return parts.length === 4 && octets ? parts.slice(0, 3).join(".") + ".0/24" : OTHER_NETWORK;
}
function compareNetworks(a, b) {
if (a === OTHER_NETWORK || b === OTHER_NETWORK) return (a === OTHER_NETWORK) - (b === OTHER_NETWORK);
return a.localeCompare(b, undefined, { numeric: true });
}
function compareAddresses(a, b) {
return a.ip.localeCompare(b.ip, undefined, { numeric: true }) || a.port - b.port;
}
function shorten(text, limit) {
return text.length > limit ? text.slice(0, limit - 1) + "…" : text;
}
/* Fill up to maxLines lines of at most limit characters with the items, then cut. */
function wrapLabels(items, limit, maxLines) {
const lines = [];
let line = "";
items.forEach((item) => {
const next = line ? line + " · " + item : item;
if (line && next.length > limit) {
lines.push(line);
line = item;
} else {
line = next;
}
});
if (line) lines.push(line);
const shown = lines.slice(0, maxLines);
if (lines.length > maxLines) shown[maxLines - 1] = shorten(shown[maxLines - 1] + " · …", limit);
return shown.map((text) => shorten(text, limit));
}
function roleStates(members, roles) {
return roles.map((role) => {
const holders = members.filter((node) => node.alive && node.roles.includes(role.name)).length;
let state = "held";
if (role.on_demand) state = holders ? "held" : "off";
else if (role.required === null) state = "general";
else if (holders < role.required) state = "unmet";
else if (holders > role.required) state = "duplicate";
return { name: role.name, state: state };
});
}
/* What one node reported about how it reaches the others.
Full payloads list every reached pair in `links`; compact payloads (`routes`)
list only the relayed and the unreached members, everything else is direct. */
function readRoutes(payload, nodes) {
const reporters = new Map((payload.views || []).map((pair) => [keyOf(pair), { via: new Map(), direct: null, none: null }]));
if (Array.isArray(payload.routes)) {
reporters.forEach((entry) => (entry.none = new Set()));
payload.routes.forEach((route) => {
const from = nodes[route.from];
const entry = from && reporters.get(from.key);
if (!entry) return;
(route.none || []).forEach((index) => nodes[index] && entry.none.add(nodes[index].key));
Object.keys(route.via || {}).forEach((gateway) => {
if (!nodes[gateway]) return;
route.via[gateway].forEach((index) => nodes[index] && entry.via.set(nodes[index].key, nodes[gateway].key));
});
});
} else {
reporters.forEach((entry) => (entry.direct = new Set()));
(payload.links || []).forEach((link) => {
const entry = reporters.get(keyOf(link.from));
if (!entry) return;
if (link.via) entry.via.set(keyOf(link.to), keyOf(link.via));
else entry.direct.add(keyOf(link.to));
});
}
return reporters;
}
const memo = new WeakMap();
/* Everything the page needs from a payload, computed once per payload. */
function analyze(payload) {
if (memo.has(payload)) return memo.get(payload);
const nodes = payload.members.map((member, index) => ({
index: index,
key: address(member),
net: networkOf(member.ip),
hostname: member.hostname,
ip: member.ip,
port: member.port,
alive: Boolean(member.alive),
self: Boolean(member.self),
roles: member.roles,
declined: member.declined || [],
}));
const byKey = new Map(nodes.map((node) => [node.key, node]));
const states = roleStates(nodes, payload.roles);
const reporters = readRoutes(payload, nodes);
const route = (from, to) => {
const target = byKey.get(to);
if (!target || !target.alive) return null;
const entry = reporters.get(from);
if (!entry) return { kind: "unknown", via: null };
const gateway = entry.via.get(to);
if (gateway !== undefined) return byKey.has(gateway) && byKey.get(gateway).alive ? { kind: "via", via: gateway } : { kind: "none", via: null };
const direct = entry.direct ? entry.direct.has(to) : !entry.none.has(to);
return { kind: direct ? "direct" : "none", via: null };
};
const model = {
payload: payload,
nodes: nodes,
byKey: byKey,
selfKey: (nodes.find((node) => node.self) || {}).key || null,
states: states,
general: new Set(states.filter((role) => role.state === "general").map((role) => role.name)),
reporters: reporters,
route: route,
networks: Array.from(new Set(nodes.map((node) => node.net))).sort(compareNetworks),
bridges: [],
bridgeAt: new Map(),
signature: JSON.stringify([
nodes.map((node) => [node.key, node.hostname, node.alive, node.self, node.roles, node.declined]),
payload.roles,
payload.views,
payload.routes || payload.links,
]),
};
model.bridges = bridgesOf(model);
model.bridgeAt = new Map(model.bridges.map((bridge) => [bridge.key, bridge]));
memo.set(payload, model);
return model;
}
/* A gateway is a node that really relays something for somebody, never one that only
holds the role: every node holds it. */
function bridgesOf(model) {
const found = new Map();
const counted = new Set();
model.reporters.forEach((entry, from) => {
entry.via.forEach((gateway, to) => {
const via = model.byKey.get(gateway);
const target = model.byKey.get(to);
const source = model.byKey.get(from);
if (!via || !target || !source || !via.alive || !target.alive) return;
const pair = gateway + "|" + [from, to].sort().join(">");
if (counted.has(pair)) return;
counted.add(pair);
if (!found.has(gateway)) found.set(gateway, { key: gateway, hostname: via.hostname, home: via.net, pairs: 0, touches: new Set([via.net]) });
const bridge = found.get(gateway);
bridge.pairs += 1;
bridge.touches.add(source.net);
bridge.touches.add(target.net);
});
});
return Array.from(found.values()).sort((a, b) => a.key.localeCompare(b.key, undefined, { numeric: true }));
}
function resolveView(model, wanted) {
const alive = model.nodes.filter((node) => node.alive);
if (wanted && alive.some((node) => node.key === wanted)) return wanted;
return model.selfKey || (alive.length ? alive[0].key : null);
}
/* How one node reaches every other alive node. */
function routesFrom(model, viewKey) {
const byKey = new Map();
const counts = { direct: 0, none: 0 };
const relayed = new Map();
const reported = model.reporters.has(viewKey);
model.nodes.forEach((node) => {
if (node.key === viewKey || !node.alive) return;
const route = model.route(viewKey, node.key);
byKey.set(node.key, route);
if (route.kind === "direct") counts.direct += 1;
else if (route.kind === "none") counts.none += 1;
else if (route.kind === "via") relayed.set(route.via, (relayed.get(route.via) || 0) + 1);
});
return { viewKey: viewKey, reported: reported, byKey: byKey, counts: counts, relayed: relayed };
}
function routeSummary(model, routes) {
const viewer = model.byKey.get(routes.viewKey);
if (!routes.reported) return [{ kind: "unknown", text: "no report from " + (viewer ? viewer.hostname : "it") + " yet" }];
const parts = [];
if (routes.counts.direct) parts.push({ kind: "direct", text: routes.counts.direct + " direct" });
model.bridges.forEach((bridge) => {
if (routes.relayed.has(bridge.key)) parts.push({ kind: "via", network: bridge.home, text: routes.relayed.get(bridge.key) + " via " + bridge.hostname });
});
if (routes.counts.none) parts.push({ kind: "none", text: routes.counts.none + " no recent contact" });
return parts;
}
/* The roles worth naming on the map: everything except the ones every node runs. */
function specialRoles(model, node) {
return node.roles.filter((name) => !model.general.has(name)).sort();
}
function alerts(model) {
const named = (state) => model.states.filter((role) => role.state === state).map((role) => role.name);
const down = model.nodes.filter((node) => !node.alive).map((node) => node.hostname);
const out = [];
if (named("unmet").length) out.push({ level: "bad", text: "Unmet: " + named("unmet").join(", ") });
if (named("duplicate").length) out.push({ level: "warn", text: "Held twice: " + named("duplicate").join(", ") });
if (down.length) out.push({ level: "info", text: "Down: " + down.join(", ") });
return out;
}
function onDemandHint(model) {
const off = model.states.filter((role) => role.state === "off").map((role) => role.name);
return off.length ? "Not running (on demand): " + off.join(", ") + " · start one with: colonyctl role add " + off[0] : "";
}
function summarize(model) {
const alive = model.nodes.filter((node) => node.alive).length;
const down = model.nodes.length - alive;
const assigned = model.states.filter((role) => role.state !== "general" && role.state !== "off");
const held = assigned.filter((role) => role.state !== "unmet");
let text = alive + " node" + (alive === 1 ? "" : "s") + " alive";
if (model.networks.length > 1) text += " · " + model.networks.length + " networks";
if (down) text += " · " + down + " down";
return text + " · " + held.length + "/" + assigned.length + " roles held";
}
/* 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"));
lines.push("roles: " + (node.roles.join(", ") || "none"));
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;
if (route) {
const from = (model.byKey.get(routes.viewKey) || {}).hostname || "the viewpoint";
const via = route.via && model.byKey.get(route.via);
lines.push("from " + from + ": " + {
direct: "reached directly",
via: "reached through " + (via ? via.hostname : route.via),
none: "no contact in the last 40 s (not necessarily unreachable)",
unknown: "no report from " + from + " yet",
}[route.kind]);
}
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 };
if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
})();
+124 -66
View File
@@ -4,15 +4,16 @@
--text: #1c2430; --text: #1c2430;
--muted: #6b7686; --muted: #6b7686;
--line: #d3d9e1; --line: #d3d9e1;
--track: #e6eaf0;
--alive: #2f9e62;
--down: #98a2b3; --down: #98a2b3;
--ok: #3b6fd4; --panel: rgba(255, 255, 255, 0.92);
--warn: #e0961e; --warn: #c67a08;
--crit: #d64545; --crit: #d64545;
--held: #3b6fd4; --n0: #2f6fdb;
--unmet: #d64545; --n1: #0e8a8a;
--duplicate: #e0961e; --n2: #7a4fd0;
--n3: #c03a8e;
--n4: #9a6510;
--n5: #4b8a2c;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@@ -22,31 +23,42 @@
--text: #e6eaf0; --text: #e6eaf0;
--muted: #93a0b3; --muted: #93a0b3;
--line: #313a47; --line: #313a47;
--track: #2a3340;
--alive: #4cc38a;
--down: #6b7686; --down: #6b7686;
--ok: #6c9cff; --panel: rgba(27, 33, 41, 0.92);
--warn: #ffb04a; --warn: #ffb04a;
--crit: #ff6b6b; --crit: #ff6b6b;
--held: #6c9cff; --n0: #6c9cff;
--unmet: #ff6b6b; --n1: #3cc7c7;
--duplicate: #ffb04a; --n2: #ab8cff;
--n3: #ff7cc8;
--n4: #e0b04a;
--n5: #8fd06a;
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body { height: 100%; }
body { body {
margin: 0; margin: 0;
padding: 0 16px 28px; padding: 0;
display: flex;
flex-direction: column;
height: 100dvh;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font: 15px/1.45 system-ui, sans-serif; font: 15px/1.45 system-ui, sans-serif;
} }
header, main, footer { max-width: 900px; margin: 0 auto; } [hidden] { display: none !important; }
h1 { margin: 22px 0 2px; font-size: 24px; } ::selection { background: color-mix(in srgb, var(--n0) 35%, transparent); }
header { padding: 0 16px 6px; }
h1 { margin: 10px 0 0; font-size: 22px; }
.headline { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; }
p { margin: 4px 0; } p { margin: 4px 0; }
.summary { color: var(--muted); } .summary { color: var(--muted); }
.muted { color: var(--muted); font-size: 13px; } .muted { color: var(--muted); font-size: 13px; }
@@ -59,61 +71,107 @@ p { margin: 4px 0; }
.alert.warn { color: var(--warn); border-color: var(--warn); } .alert.warn { color: var(--warn); border-color: var(--warn); }
.alert.info { color: var(--muted); border-color: var(--line); } .alert.info { color: var(--muted); border-color: var(--line); }
main { margin-top: 14px; } .route-line { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; margin: 10px 0 0; font-size: 14px; }
#graph { width: 100%; height: auto; display: block; margin: 0 auto; } .route-line:empty { display: none; }
.route-line .hint { font-size: 12.5px; }
.route { padding: 1px 9px; border-radius: 999px; border: 1.5px solid var(--line); font-size: 13px; font-weight: 600; }
.route.direct { border-color: var(--muted); }
.route.via { border-color: var(--net); background: color-mix(in srgb, var(--net) 16%, transparent); }
.route.none { border-color: var(--muted); border-style: dashed; color: var(--muted); }
.route.unknown { color: var(--muted); border-style: dashed; }
.link { stroke: var(--line); stroke-width: 2.5; }
.link.down { stroke-dasharray: 6 6; opacity: 0.7; }
.link.oneway { stroke: var(--muted); }
.arrowhead { fill: var(--muted); }
.node.bridge .card { stroke: var(--held); stroke-width: 2; }
.card { fill: var(--card); stroke: var(--line); stroke-width: 1.5; } #stage { position: relative; flex: 1; min-height: 260px; overflow: hidden; border-top: 1px solid var(--line); background: var(--bg); }
.node.self .card { stroke: var(--alive); stroke-width: 2; } #map { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; user-select: none; -webkit-user-select: none; cursor: grab; }
.node.down > :not(.card) { opacity: 0.6; } #map:active { cursor: grabbing; }
.node.down .card { stroke-dasharray: 6 4; } #map:focus { outline: none; }
.dot.alive { fill: var(--alive); } #world { transform-origin: 0 0; }
.dot.down { fill: var(--down); }
.t-title { fill: var(--text); font-size: 14px; font-weight: 600; } .n0 { --net: var(--n0); }
.t-tag { fill: var(--muted); font-size: 11px; text-anchor: end; } .n1 { --net: var(--n1); }
.t-sub { fill: var(--muted); font-size: 11px; } .n2 { --net: var(--n2); }
.t-label { fill: var(--muted); font-size: 10.5px; letter-spacing: 0.04em; } .n3 { --net: var(--n3); }
.t-value { fill: var(--text); font-size: 11px; text-anchor: end; } .n4 { --net: var(--n4); }
.t-value.na { fill: var(--muted); } .n5 { --net: var(--n5); }
.t-foot { fill: var(--muted); font-size: 10.5px; }
.track { fill: var(--track); } .region { transition: opacity 0.2s; }
.fill.ok { fill: var(--ok); } .region-box { fill: color-mix(in srgb, var(--net) 9%, var(--bg)); stroke: var(--net); stroke-width: 2; vector-effect: non-scaling-stroke; }
.fill.warn { fill: var(--warn); } .region.selected .region-box { stroke-width: 3.5; }
.fill.crit { fill: var(--crit); } .region.dim { opacity: 0.4; }
.region-grab { fill: transparent; cursor: move; }
.region-dot { fill: var(--net); pointer-events: none; }
.region-title { fill: var(--text); font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; pointer-events: none; }
.region-count { fill: var(--muted); font-size: 11.5px; font-weight: 400; }
.chip rect { fill: transparent; stroke: var(--line); stroke-width: 1.5; } .spoke { fill: none; stroke: var(--net); stroke-width: 2.5; opacity: 0.45; vector-effect: non-scaling-stroke; }
.chip text { fill: var(--muted); font-size: 10.5px; text-anchor: middle; } .spoke.hidden { opacity: 0; }
.chip.held rect { stroke: var(--held); fill: color-mix(in srgb, var(--held) 14%, transparent); } .spoke.lit { opacity: 0.95; stroke-width: 3.5; }
.chip.held text { fill: var(--text); font-weight: 600; } .spoke-end { fill: var(--net); opacity: 0.6; }
.chip.duplicate rect { stroke: var(--duplicate); fill: color-mix(in srgb, var(--duplicate) 16%, transparent); } .spoke-end.used { opacity: 1; }
.chip.duplicate text { fill: var(--text); font-weight: 600; } .flow { fill: none; stroke: var(--net); stroke-width: 3.5; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; pointer-events: none; }
.chip.unmet rect { stroke: var(--unmet); stroke-dasharray: 5 3; fill: color-mix(in srgb, var(--unmet) 12%, transparent); } .arrow path { fill: var(--net); }
.chip.unmet text { fill: var(--unmet); font-weight: 600; }
.chip.unknown rect, .chip.off rect { stroke-dasharray: 2 3; }
.chip.more text { fill: var(--muted); }
.center-box { fill: var(--card); stroke: var(--line); stroke-width: 1.5; } .node { cursor: pointer; outline: none; }
.center-box.bad { stroke: var(--unmet); } .disc { fill: var(--card); stroke: var(--net); stroke-width: 2.5; vector-effect: non-scaling-stroke; }
.center-title { text-anchor: middle; } .dot { fill: var(--net); }
.halo { fill: none; stroke: none; }
.node.down { opacity: 0.65; }
.node.down .disc { stroke-dasharray: 4 3; }
.node.down .dot { fill: var(--down); }
.node.gateway .disc { stroke-width: 4; }
.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; }
.node.origin .halo, .node.selected .halo { stroke: var(--text); stroke-width: 2.5; stroke-dasharray: none; }
.node:focus-visible .halo { stroke: var(--text); stroke-width: 3.5; stroke-dasharray: none; }
.node.dragging { cursor: grabbing; opacity: 0.85; }
.node.dim { opacity: 0.35; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 16px; list-style: none; margin: 10px 0 0; padding: 0; font-size: 12.5px; color: var(--muted); } .l-name, .l-roles, .l-caption { paint-order: stroke; stroke: color-mix(in srgb, var(--net) 9%, var(--bg)); stroke-width: 3px; stroke-linejoin: round; pointer-events: none; }
.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 6px; border-radius: 50%; vertical-align: -1px; } .l-name { fill: var(--text); font-size: 13px; font-weight: 600; }
.swatch.alive { background: var(--alive); } .l-roles { fill: var(--muted); font-size: 11px; }
.swatch.down { background: var(--down); } .l-caption { fill: var(--net); font-size: 11px; font-weight: 600; }
.swatch.held, .swatch.unmet, .swatch.duplicate, .swatch.general { border-radius: 6px; background: transparent; border: 2px solid; } .node.none .l-caption, .node.unknown .l-caption { fill: var(--muted); }
.swatch.general { border-color: var(--line); }
.swatch.oneway { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--muted); vertical-align: 3px; }
.swatch.bridge { border-radius: 4px; background: transparent; border: 2px solid var(--held); }
.swatch.link { width: 18px; height: 0; border-radius: 0; border: 0; border-top: 2.5px solid var(--line); vertical-align: 3px; }
.swatch.held { border-color: var(--held); }
.swatch.unmet { border-color: var(--unmet); border-style: dashed; }
.swatch.duplicate { border-color: var(--duplicate); }
footer { margin-top: 10px; } /* Level of detail: one class on the root, decided by the zoom. */
.lod0 .l-name, .lod0 .l-roles, .lod0 .l-caption { display: none; }
.lod0 .region-title { font-size: 38px; }
.lod0 .region-count { font-size: 24px; }
.lod0 .region-dot { r: 12px; }
.lod1 .l-roles { 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 .l-caption { display: none; }
.flow { stroke-dasharray: none; }
@media (prefers-reduced-motion: no-preference) {
.flow { stroke-dasharray: 9 7; animation: flow 0.9s linear infinite; }
.node { transition: transform 0.25s ease-out; }
.node.dragging { transition: none; }
.region { transition: opacity 0.2s, transform 0.25s ease-out; }
}
@keyframes flow { to { stroke-dashoffset: -16; } }
.controls { position: absolute; right: 12px; bottom: 12px; display: flex; gap: 6px; align-items: center; }
.controls button, .legend-open, .legend > button { display: inline-flex; align-items: center; justify-content: center; min-width: 36px; height: 36px; padding: 0; border: 1px solid var(--line); border-radius: 8px; background: var(--panel); color: var(--text); font: inherit; cursor: pointer; }
.controls button.text { padding: 0 12px; font-size: 13px; }
.controls button:hover, .legend-open:hover, .legend > button:hover { border-color: var(--muted); }
.controls button:focus-visible, .legend-open:focus-visible, .legend > button:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; }
.controls button.on svg { stroke: var(--n0); }
.controls svg, .legend > button svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@media (pointer: coarse) { .controls button, .legend-open, .legend > button { min-width: 44px; height: 44px; } }
.legend { position: absolute; left: 12px; bottom: 12px; max-width: min(360px, calc(100% - 24px)); padding: 10px 12px 10px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); font-size: 12.5px; color: var(--muted); }
.legend > button { position: absolute; top: 4px; right: 4px; min-width: 28px; height: 28px; border: 0; background: transparent; }
.legend ul { list-style: none; margin: 0 24px 6px 0; padding: 0; display: grid; gap: 3px; }
.legend p { margin: 0; }
.legend-open { position: absolute; left: 12px; bottom: 12px; font-weight: 700; }
.stamp { position: absolute; left: 14px; top: 8px; margin: 0; pointer-events: none; }
.swatch { display: inline-block; width: 11px; height: 11px; margin-right: 8px; border-radius: 50%; vertical-align: -1px; }
.swatch.network { border-radius: 4px; background: color-mix(in srgb, var(--n0) 14%, transparent); border: 2px solid var(--n0); }
.swatch.node { background: var(--card); border: 2.5px solid var(--n1); }
.swatch.gateway { width: 14px; height: 14px; border-radius: 4px; background: var(--card); border: 4px solid var(--n2); vertical-align: -2px; }
.swatch.flow { width: 20px; height: 0; border-radius: 0; border: 0; border-top: 3.5px dashed var(--text); vertical-align: 3px; }
.swatch.none { background: transparent; border: 2px dashed var(--muted); }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@@ -0,0 +1,205 @@
"use strict";
/* The window onto the map: pan and zoom maths, level of detail, and the pure state
machine that turns pointer events into actions (pan, zoom, click, drag). Nothing
here touches the DOM, so it can be tested by feeding it events. */
(function () {
const K_MAX = 4;
const K_START_MAX = 1.2;
const KEEP = 80;
const LOD_AT = [0.4, 0.9];
const HYSTERESIS = 0.1;
const THRESHOLD = { mouse: 4, pen: 4, touch: 8 };
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
function fitK(bounds, size, pad) {
return Math.min(Math.max(1, size.w - 2 * pad) / bounds.w, Math.max(1, size.h - 2 * pad) / bounds.h);
}
function minK(bounds, size) {
return Math.min(fitK(bounds, size, 0) * 0.5, 0.1);
}
/* The view that shows all of the bounds, centred. */
function fitView(bounds, size, pad, maxK) {
const k = clamp(fitK(bounds, size, pad), 0.02, maxK === undefined ? K_START_MAX : maxK);
return { k: k, tx: (size.w - bounds.w * k) / 2 - bounds.x * k, ty: (size.h - bounds.h * k) / 2 - bounds.y * k };
}
/* Zoom keeping the world point under (sx, sy) where it is. */
function zoomAt(view, sx, sy, factor, bounds, size) {
const k = clamp(view.k * factor, bounds ? minK(bounds, size) : 0.05, K_MAX);
return { k: k, tx: sx - ((sx - view.tx) * k) / view.k, ty: sy - ((sy - view.ty) * k) / view.k };
}
function panBy(view, dx, dy) {
return { k: view.k, tx: view.tx + dx, ty: view.ty + dy };
}
/* At least KEEP pixels of the content stay on screen. */
function clampView(view, bounds, size) {
const keep = Math.min(KEEP, size.w / 2, size.h / 2);
const x0 = bounds.x * view.k + view.tx;
const x1 = (bounds.x + bounds.w) * view.k + view.tx;
const y0 = bounds.y * view.k + view.ty;
const y1 = (bounds.y + bounds.h) * view.k + view.ty;
let tx = view.tx;
let ty = view.ty;
if (x1 < keep) tx += keep - x1;
else if (x0 > size.w - keep) tx -= x0 - (size.w - keep);
if (y1 < keep) ty += keep - y1;
else if (y0 > size.h - keep) ty -= y0 - (size.h - keep);
return { k: view.k, tx: tx, ty: ty };
}
/* The window changed size: refit if the user never moved the view, else keep the centre. */
function resizeView(view, from, to, autoFit, bounds, pad) {
if (autoFit) return fitView(bounds, to, pad);
const cx = (from.w / 2 - view.tx) / view.k;
const cy = (from.h / 2 - view.ty) / view.k;
return { k: view.k, tx: to.w / 2 - cx * view.k, ty: to.h / 2 - cy * view.k };
}
/* Level of detail 0 (shapes), 1 (names), 2 (everything), with hysteresis so it does not flap. */
function lodOf(k, previous) {
let level = previous === undefined ? (k < LOD_AT[0] ? 0 : k < LOD_AT[1] ? 1 : 2) : previous;
for (let guard = 0; guard < 3; guard++) {
if (level < 2 && k >= LOD_AT[level] * (1 + HYSTERESIS)) level += 1;
else if (level > 0 && k < LOD_AT[level - 1] * (1 - HYSTERESIS)) level -= 1;
}
return level;
}
/* A view that keeps the given world box in sight (for "fit this network"). */
function viewFor(box, size, pad) {
return fitView(box, size, pad, 1.6);
}
const easeOut = (t) => 1 - Math.pow(1 - clamp(t, 0, 1), 3);
function tween(from, to, t) {
const e = easeOut(t);
const k = Math.exp(Math.log(from.k) + (Math.log(to.k) - Math.log(from.k)) * e);
return { k: k, tx: from.tx + (to.tx - from.tx) * e, ty: from.ty + (to.ty - from.ty) * e };
}
function wheelFactor(deltaY, ctrl) {
return Math.exp(-deltaY * (ctrl ? 0.01 : 0.0015));
}
/* ---------------------------------------------------------------- gestures */
function initialGesture() {
return { mode: "idle", pointers: {}, start: null, last: null, target: null, selected: null, pinch: null };
}
function pinchOf(pointers) {
const [a, b] = Object.values(pointers);
if (!a || !b) return null;
return { d: Math.hypot(a.x - b.x, a.y - b.y), mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } };
}
/* One event in, the new state and the actions to perform out. Targets are
{kind: "node" | "gateway" | "region-title" | "region" | "background", key}. */
function reduceGesture(state, event) {
const next = Object.assign({}, state, { pointers: Object.assign({}, state.pointers) });
const actions = [];
const count = () => Object.keys(next.pointers).length;
if (event.type === "wheel") {
actions.push({ type: "zoom", x: event.x, y: event.y, factor: wheelFactor(event.deltaY, event.ctrl) });
} else if (event.type === "dblclick") {
actions.push({ type: "fit", target: event.target && event.target.kind === "background" ? null : event.target });
} else if (event.type === "down") {
next.pointers[event.id] = { x: event.x, y: event.y };
if (count() >= 2) {
if (next.mode === "drag") actions.push({ type: "drag-cancel", target: next.target });
next.mode = "pinch";
next.pinch = pinchOf(next.pointers);
next.target = null;
} else {
Object.assign(next, { mode: "pending", start: { x: event.x, y: event.y, type: event.pointerType }, last: { x: event.x, y: event.y }, target: event.target, selected: event.selected === undefined ? null : event.selected });
}
} else if (event.type === "move" && next.pointers[event.id]) {
next.pointers[event.id] = { x: event.x, y: event.y };
if (next.mode === "pinch") {
const now = pinchOf(next.pointers);
if (now && next.pinch) {
actions.push({ type: "pan", dx: now.mid.x - next.pinch.mid.x, dy: now.mid.y - next.pinch.mid.y });
if (next.pinch.d > 0 && now.d > 0) actions.push({ type: "zoom", x: now.mid.x, y: now.mid.y, factor: now.d / next.pinch.d });
}
next.pinch = now;
} else if (next.mode === "pending") {
const limit = THRESHOLD[next.start.type] || THRESHOLD.mouse;
if (Math.hypot(event.x - next.start.x, event.y - next.start.y) >= limit) {
const target = next.target;
const movable = target && (target.kind === "node" || target.kind === "gateway" || target.kind === "region-title");
const touch = next.start.type === "touch";
if (movable && !(touch && target.key !== next.selected)) {
next.mode = "drag";
actions.push({ type: "drag-start", target: target, x: next.start.x, y: next.start.y });
actions.push({ type: "drag-move", target: target, dx: event.x - next.start.x, dy: event.y - next.start.y, x: event.x, y: event.y });
} else {
next.mode = "pan";
actions.push({ type: "pan", dx: event.x - next.start.x, dy: event.y - next.start.y });
}
next.last = { x: event.x, y: event.y };
}
} else if (next.mode === "pan") {
actions.push({ type: "pan", dx: event.x - next.last.x, dy: event.y - next.last.y });
next.last = { x: event.x, y: event.y };
} else if (next.mode === "drag") {
actions.push({ type: "drag-move", target: next.target, dx: event.x - next.last.x, dy: event.y - next.last.y, x: event.x, y: event.y });
next.last = { x: event.x, y: event.y };
}
} else if (event.type === "up" || event.type === "cancel") {
delete next.pointers[event.id];
const cancelled = event.type === "cancel";
if (next.mode === "pending") {
if (!cancelled) actions.push({ type: "click", target: next.target, x: event.x, y: event.y });
next.mode = "idle";
} else if (next.mode === "drag") {
actions.push({ type: cancelled ? "drag-cancel" : "drag-end", target: next.target, x: event.x, y: event.y });
next.mode = "idle";
} else if (next.mode === "pan") {
next.mode = "idle";
} else if (next.mode === "pinch") {
next.mode = count() === 0 ? "idle" : "settle";
next.pinch = null;
} else if (next.mode === "settle" && count() === 0) {
next.mode = "idle";
}
}
return { state: next, actions: actions };
}
const ARROWS = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
const KEY_ACTIONS = {
"+": { type: "zoom-step", factor: 1.25 },
"=": { type: "zoom-step", factor: 1.25 },
"-": { type: "zoom-step", factor: 0.8 },
_: { type: "zoom-step", factor: 0.8 },
"0": { type: "fit" },
f: { type: "fit-selected" },
N: { type: "next-network", step: -1 },
Escape: { type: "clear" },
};
/* Keyboard: what a key press asks for. Arrows with Alt nudge the focused thing. */
function keyAction(event, context) {
if (event.ctrlKey || event.metaKey) return null;
if (ARROWS[event.key]) {
const [dx, dy] = ARROWS[event.key];
if (event.altKey) return { type: "nudge", dx: dx, dy: dy };
return context && context.onNode ? { type: "move-focus", dx: dx, dy: dy } : { type: "pan", dx: -dx * 80, dy: -dy * 80 };
}
if (event.key === "n") return { type: "next-network", step: event.shiftKey ? -1 : 1 };
if (event.key === "Enter" || event.key === " ") return context && context.onNode ? { type: "activate" } : null;
return KEY_ACTIONS[event.key] || null;
}
const api = { K_MAX, fitView, zoomAt, panBy, clampView, resizeView, lodOf, viewFor, tween, easeOut, wheelFactor, minK, initialGesture, reduceGesture, keyAction };
if (typeof module !== "undefined") module.exports = api;
else globalThis.Colony = Object.assign(globalThis.Colony || {}, api);
})();