diff --git a/dDashboardServer/static/js/network-graph.js b/dDashboardServer/static/js/network-graph.js index 649dce328..bfe1875e6 100644 --- a/dDashboardServer/static/js/network-graph.js +++ b/dDashboardServer/static/js/network-graph.js @@ -12,8 +12,15 @@ * (single connections, or instances) and `shown` (the ids of those matching the group's filter). Each shown member is * a link end of its own, and the open box itself has none; `graph.members` finds a member by id. * + * Servers carry their listening `port` and their machine (`host`, a token) from master's server list. When they run on + * more than one machine, `graph.hosts` lists the machines (master's first) with their totals and the boxes on each; + * every server box has its `host`, a zone with instances on several machines is a box per machine, a machine the + * viewer collapsed (options.collapsedHosts) is one box of kind 'host', and links between two machines are `cross`. + * With one machine there are no hosts and nothing changes. + * * layout(graph, width, moved, scroll) places the nodes in six columns in a fixed order, grows open boxes by their rows - * and gives each link its path; rows in view of an open box's list are anchors (`anchors`). + * and gives each link its path; rows in view of an open box's list are anchors (`anchors`). With several machines + * each machine's boxes (columns 1 to 4) are a band of their own, one under the other, framed (`frames`). */ (function (root) { 'use strict'; @@ -22,6 +29,8 @@ // Chat has a column of its own between the worlds and master: every world has a link to it // An open group's box: its header, the filter, the rows (at most MAX_ROWS in view, the rest scroll) and a margin var GROUP = { head: 58, filter: 30, row: 30, maxRows: 8, foot: 6 }; // head: a name on up to two lines and one line under it + // A machine's frame: room for its label above its boxes, around them, and between two frames + var FRAME = { head: 28, side: 8, gap: 6 }; function num(v) { return typeof v === 'number' && isFinite(v) ? v : 0; } function rates(r) { r = r || {}; return { packets_in: num(r.packets_in), packets_out: num(r.packets_out), bytes_in: num(r.bytes_in), bytes_out: num(r.bytes_out) }; } @@ -55,6 +64,28 @@ return ugcOnly ? 'clients' : 'web'; } + // ":1001", or '' without a port + function portLabel(port) { return typeof port === 'number' && port > 0 ? ':' + port : ''; } + + // A box's name with its listening port ("Auth :1001"); a zone box has its instance's port while it has one instance + function nameOf(n) { + var port = portLabel(n.port); + return port ? n.label + ' ' + port : n.label; + } + + // A machine for people: its address when the viewer may see it (`names`: token -> address), else its token + function hostLabel(host, names) { + if (host == null || host === '') return 'unknown machine'; + return (names && names[host]) || host; + } + + // A connection's address with its remote ports (only there with network_ips): "203.0.113.5:51234" + function peerAddress(peer) { + var ports = []; + (peer.servers || []).forEach(function (e) { if (!e.http && typeof e.port === 'number' && ports.indexOf(e.port) === -1) ports.push(e.port); }); + return String(peer.address || '') + (ports.length ? ':' + ports.join('/') : ''); + } + // What a group's filter matches a member by function matches(member, filter) { filter = String(filter || '').trim().toLowerCase(); @@ -69,10 +100,33 @@ var expanded = options.expanded || {}, filters = options.filters || {}; var nodes = {}, edges = {}, members = {}; + // The machines the servers run on; with more than one, boxes are grouped by machine + var hostList = []; + keys.forEach(function (k) { var h = servers[k].host; if (h && hostList.indexOf(h) === -1) hostList.push(h); }); + var multi = hostList.length > 1; + var collapsed = multi ? (options.collapsedHosts || {}) : {}; + var hostNames = options.hostNames || {}; + var masterHost = servers.master ? servers.master.host || null : null; + var zoneHosts = {}; // zone -> {host: true} + keys.forEach(function (k) { + var s = servers[k]; + if (s.type === 'WORLD') (zoneHosts[num(s.zone)] = zoneHosts[num(s.zone)] || {})[s.host || ''] = true; + }); + // The box a server is in: nodeOf's, a box per machine for a zone on several, or its machine's box when collapsed + function place(server) { + var spec = nodeOf(server); + if (!multi) return spec; + var h = server.host || null; + if (h && collapsed[h]) return { id: 'host:' + h, kind: 'host', label: hostLabel(h, hostNames), column: h === masterHost ? 3 : 1, order: -1, host: h }; + if (spec.kind === 'zone' && Object.keys(zoneHosts[spec.zone] || {}).length > 1) spec.id += '@' + (h || '?'); + spec.host = h; + return spec; + } + function node(spec) { var n = nodes[spec.id]; if (!n) { - n = nodes[spec.id] = { id: spec.id, kind: spec.kind, label: spec.label, column: spec.column, order: spec.order, zone: spec.zone, servers: [], instances: 0, + n = nodes[spec.id] = { id: spec.id, kind: spec.kind, label: spec.label, column: spec.column, order: spec.order, zone: spec.zone, host: spec.host, port: null, servers: [], instances: 0, connections: 0, pingSum: 0, pingCount: 0, resends: 0, resendQueue: 0, workersBusy: 0, workersThreads: 0, workersQueued: 0, websockets: 0, packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0, http: 0, noSplit: false, open: false, members: [], shown: [] }; } @@ -91,6 +145,7 @@ } // Adds a link's rates; links between the same two nodes add up (every instance of a zone) function edge(from, to, fwdPackets, fwdBytes, backPackets, backBytes, exact, kind, note) { + if (from === to) return null; // inside a collapsed machine var id = from + '>' + to + (kind && kind !== 'packets' ? ':' + kind : ''); var e = edges[id]; if (!e) e = edges[id] = { id: id, from: from, to: to, kind: kind || 'packets', exact: true, notes: [], fwd: { packets: 0, bytes: 0 }, back: { packets: 0, bytes: 0 }, bytesKnown: { fwd: true, back: true } }; @@ -103,7 +158,15 @@ return e; } // Where a link to a server ends: the server's box, or its row while its zone is open - function endOf(server) { return server.type === 'WORLD' && expanded[num(server.zone)] ? server.key : nodeOf(server).id; } + function endOf(server) { + var spec = place(server); + return spec.kind === 'zone' && expanded[num(server.zone)] ? server.key : spec.id; + } + // Master, chat and the UGC server as link ends (their machine's box while it is collapsed) + var byType = function (type) { return keys.filter(function (k) { return servers[k].type === type; }).map(function (k) { return servers[k]; })[0]; }; + var masterId = byType('MASTER') ? endOf(byType('MASTER')) : 'master'; + var chatId = byType('CHAT') ? endOf(byType('CHAT')) : 'chat'; + var ugcId = byType('UGC') ? endOf(byType('UGC')) : 'ugc'; var clients = node({ id: 'clients', kind: 'clients', label: 'Game clients', column: 0, order: 0 }); var web = node({ id: 'web', kind: 'web', label: 'Web clients', column: 5, order: 0 }); @@ -111,15 +174,18 @@ keys.forEach(function (key) { var s = servers[key]; - var n = node(nodeOf(s)); + var n = node(place(s)); var link = s.link || {}, gauges = s.gauges || {}; // Links end at the zone's box, or at the instance's row while the zone is open var id = s.type === 'WORLD' ? endOf(s) : n.id; n.servers.push(key); + // A box of one server has its port (a zone's while it has one instance) + n.port = n.kind !== 'host' && n.servers.length === 1 && typeof s.port === 'number' ? s.port : null; if (s.type === 'WORLD') { n.instances++; if (id !== n.id) { member(n, { id: key, kind: 'world', label: '#' + num(s.instance), title: s.label || key, server: key, instance: num(s.instance), + port: typeof s.port === 'number' ? s.port : null, host: s.host || null, connections: num(link.connections), pingSum: num(link.connections) > 0 ? num(link.ping_ms) : 0, pingCount: num(link.connections) > 0 ? 1 : 0, resends: num(link.resends), search: (String(s.label || key) + ' #' + num(s.instance) + ' ' + key).toLowerCase() }); } @@ -145,10 +211,10 @@ if (s.type !== 'MASTER') { if (split) { var m = rates(split.master); - if (any(m)) edge(id, 'master', m.packets_out, m.bytes_out, m.packets_in, m.bytes_in, true); + if (any(m)) edge(id, masterId, m.packets_out, m.bytes_out, m.packets_in, m.bytes_in, true); } else if (s.type === 'DASHBOARD' || s.type === 'UGC' || s.type === 'CHAT') { // Nearly all of their packets are with master (chat's are with the worlds too) - edge(id, 'master', totals.packets_out, totals.bytes_out, totals.packets_in, totals.bytes_in, false, 'packets', ESTIMATED); + edge(id, masterId, totals.packets_out, totals.bytes_out, totals.packets_in, totals.bytes_in, false, 'packets', ESTIMATED); } } @@ -159,7 +225,7 @@ var who = s.type === 'UGC' ? 'clients' : 'web'; if (requests > 0 || bytesOut > 0) edge(who, id, requests, null, requests, bytesOut, !!split, 'http', split ? '' : 'other servers\' requests not told apart (older server)'); // The dashboard's own requests to the UGC server, counted where they were made - if (split && num(split.http_out) > 0) edge(id, 'ugc', split.http_out, null, split.http_out, split.http_out_bytes_in, true, 'http'); + if (split && num(split.http_out) > 0) edge(id, ugcId, split.http_out, null, split.http_out, split.http_out_bytes_in, true, 'http'); return; } if (s.type === 'MASTER') return; @@ -171,8 +237,8 @@ if (any(c) || players > 0) edge('clients', id, c.packets_in, c.bytes_in, c.packets_out, c.bytes_out, true); var o = rates(split.servers); // A world's "other servers" is its chat link; chat's are the worlds, drawn from their side - if (s.type === 'WORLD' && any(o)) edge(id, hasChat ? 'chat' : 'master', o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, hasChat, 'packets', hasChat ? '' : 'chat link, chat not reporting'); - else if (s.type !== 'CHAT' && any(o)) edge(id, 'master', o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, false, 'servers', 'with other servers, drawn to master'); + if (s.type === 'WORLD' && any(o)) edge(id, hasChat ? chatId : masterId, o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, hasChat, 'packets', hasChat ? '' : 'chat link, chat not reporting'); + else if (s.type !== 'CHAT' && any(o)) edge(id, masterId, o.packets_out, o.bytes_out, o.packets_in, o.bytes_in, false, 'servers', 'with other servers, drawn to master'); } else if (s.type !== 'CHAT') { edge('clients', id, totals.packets_in, totals.bytes_in, totals.packets_out, totals.bytes_out, false, 'packets', ESTIMATED); } @@ -200,7 +266,7 @@ seen[id] = true; var names = [peer.character, peer.account, peer.user]; var p = member(nodes[groupId], { id: id, kind: 'peer', label: String(peer.character || peer.account || peer.user || peer.address || ''), peer: peer, - search: names.concat([options.addressesShown ? peer.address : ''], (peer.servers || []).map(function (e) { return e.label; })) + search: names.concat([options.addressesShown ? peerAddress(peer) : ''], (peer.servers || []).map(function (e) { return e.label; })) .filter(Boolean).join(' ').toLowerCase() }); (peer.servers || []).forEach(function (entry) { var target = servers[entry.server]; @@ -233,8 +299,33 @@ var linked = {}; Object.keys(edges).forEach(function (k) { linked[edges[k].from] = linked[edges[k].to] = true; }); list = list.filter(function (n) { return n.servers.length || n.open || linked[n.id] || (n.kind === 'clients' && n.connections > 0); }); - list.sort(function (a, b) { return a.column - b.column || a.order - b.order || String(a.label).localeCompare(String(b.label)); }); - return { nodes: list, edges: edgeList, members: members }; + list.sort(function (a, b) { return a.column - b.column || a.order - b.order || String(a.label).localeCompare(String(b.label)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); }); + + // Several machines: each with its totals and its boxes, master's first; links between two machines are marked + var hosts = []; + if (multi) { + var byHost = {}; + hosts = hostList.slice().sort(function (a, b) { return (b === masterHost) - (a === masterHost) || (a < b ? -1 : a > b ? 1 : 0); }).map(function (h) { + return byHost[h] = { id: h, label: hostLabel(h, hostNames), master: h === masterHost, collapsed: !!collapsed[h], nodes: [], servers: 0, connections: 0, + packetsIn: 0, packetsOut: 0, bytesIn: 0, bytesOut: 0 }; + }); + keys.forEach(function (k) { + var s = servers[k], h = byHost[s.host]; + if (!h) return; + h.servers++; + h.connections += num((s.link || {}).connections); + h.packetsIn += num(s.packets_in); h.packetsOut += num(s.packets_out); + h.bytesIn += num(s.bytes_in); h.bytesOut += num(s.bytes_out); + }); + list.forEach(function (n) { if (n.host && byHost[n.host]) byHost[n.host].nodes.push(n.id); }); + } + var hostOf = function (id) { return (nodes[id] && nodes[id].host) || (members[id] && members[id].host) || null; }; + edgeList.forEach(function (e) { + var a = multi ? hostOf(e.from) : null, b = multi ? hostOf(e.to) : null; + e.cross = !!(a && b && a !== b); + if (e.cross) e.hosts = [a, b]; + }); + return { nodes: list, edges: edgeList, members: members, hosts: hosts }; } // Load of a link against its recent peak: 'idle', 'low', 'mid' or 'high' @@ -279,15 +370,47 @@ var xs = [0, 0.2, 0.4, 0.6, 0.8, 1].map(function (f) { return Math.round(pad + nodeWidth / 2 + f * usable); }); var heightOf = function (n) { return n.open ? groupHeight(n.shown.length) : nodeHeight; }; var at = {}; - // Rows at a fixed pitch from the top, each column centred on the tallest; an open box pushes the boxes under it - // down by what it grew, so opening a group only moves the boxes of its own column - byColumn.forEach(function (column, c) { - var top = pad + (rows - column.length) * rowHeight / 2, extra = 0; + // Rows at a fixed pitch from `top`; an open box pushes the boxes under it down by what it grew, so opening a group + // only moves the boxes of its own column. Returns where the column ends. + var stackColumn = function (column, c, top) { + var extra = 0; column.forEach(function (n, i) { at[n.id] = { x: xs[c], y: Math.round(top + rowHeight * (i + 0.5) + extra), h: heightOf(n), column: c }; extra += at[n.id].h - nodeHeight; }); - }); + return top + rowHeight * column.length + extra; + }; + var hosts = graph.hosts || []; + if (hosts.length < 2) { + // One machine: each column centred on the tallest + byColumn.forEach(function (column, c) { stackColumn(column, c, pad + (rows - column.length) * rowHeight / 2); }); + } else { + // Several machines: each machine's boxes of columns 1 to 4 are a band, one under the other (master's first, the + // servers of no known machine last, unframed); game and web clients are centred on them all + var bandOf = {}; + hosts.forEach(function (h, i) { bandOf[h.id] = i; }); + var bands = hosts.map(function () { return COLUMNS.map(function () { return []; }); }).concat([COLUMNS.map(function () { return []; })]); + graph.nodes.forEach(function (n) { + if (n.column === 0 || n.column === COLUMNS.length - 1) return; + var b = n.host != null && bandOf[n.host] != null ? bandOf[n.host] : hosts.length; + bands[b][n.column].push(n); + }); + var cursor = pad; + bands.forEach(function (band, b) { + var bandRows = Math.max.apply(null, band.map(function (c) { return c.length; })); + if (!bandRows) return; + var start = cursor + (b < hosts.length ? FRAME.head : 0), bottom = start; + band.forEach(function (column, c) { + if (column.length) bottom = Math.max(bottom, stackColumn(column, c, start + (bandRows - column.length) * rowHeight / 2)); + }); + cursor = bottom + FRAME.gap; + }); + [0, COLUMNS.length - 1].forEach(function (c) { + var column = byColumn[c]; + stackColumn(column, c, pad + Math.max(0, (cursor - pad - column.length * rowHeight) / 2)); + }); + height = Math.max(height, cursor + pad); + } moved = moved || {}; scroll = scroll || {}; var clampX = function (x) { return Math.round(Math.min(totalWidth - pad - nodeWidth / 2, Math.max(pad + nodeWidth / 2, x))); }; @@ -300,6 +423,22 @@ }); graph.nodes.forEach(function (n) { var a = at[n.id]; height = Math.max(height, a.y - nodeHeight / 2 + a.h + pad); }); + // Each machine's frame: around its boxes (wherever they were dragged), its label above them + var frames = {}; + if (hosts.length > 1) { + hosts.forEach(function (h) { + var ids = h.nodes.filter(function (id) { return at[id]; }); + if (!ids.length) return; + var x1 = Infinity, x2 = -Infinity, y1 = Infinity, y2 = -Infinity; + ids.forEach(function (id) { + var a = at[id]; + x1 = Math.min(x1, a.x - nodeWidth / 2); x2 = Math.max(x2, a.x + nodeWidth / 2); + y1 = Math.min(y1, a.y - nodeHeight / 2); y2 = Math.max(y2, a.y - nodeHeight / 2 + a.h); + }); + frames[h.id] = { x: Math.round(x1 - FRAME.side), y: Math.round(y1 - FRAME.head + 2), w: Math.round(x2 - x1 + FRAME.side * 2), h: Math.round(y2 - y1 + FRAME.head - 2 + FRAME.side) }; + }); + } + // An open box's rows in view of its list are link ends of their own, on the box's sides var lists = {}, anchors = {}; graph.nodes.forEach(function (n) { @@ -343,9 +482,12 @@ var a = endOf(e.from), b = endOf(e.to); if (!a || !b) return; var pa = withPort(a, ports[e.id] && ports[e.id].a), pb = withPort(b, ports[e.id] && ports[e.id].b); - paths[e.id] = { fwd: curve(pa, pb, nodeWidth, 3.5, nodeHeight), back: curve(pb, pa, nodeWidth, 3.5, nodeHeight) }; + var fwd = curve(pa, pb, nodeWidth, 3.5, nodeHeight); + paths[e.id] = { fwd: fwd.d, back: curve(pb, pa, nodeWidth, 3.5, nodeHeight).d }; + // A link between two machines gets a mark half way + if (e.cross) paths[e.id].mid = fwd.mid; }); - return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, lists: lists, anchors: anchors, paths: paths }; + return { width: totalWidth, height: height, nodeWidth: nodeWidth, nodeHeight: nodeHeight, nodes: at, lists: lists, anchors: anchors, paths: paths, frames: frames }; } // A curve between two boxes (or an open box's rows), leaving and entering on the sides that face each other @@ -381,9 +523,12 @@ var dx = x2 - x1, dy = y2 - y1, len = Math.sqrt(dx * dx + dy * dy) || 1; var nx = -dy / len * offset, ny = dx / len * offset; var p = function (x, y) { return (Math.round((x + nx) * 10) / 10) + ',' + (Math.round((y + ny) * 10) / 10); }; - return 'M' + p(x1, y1) + ' C' + p(c1[0], c1[1]) + ' ' + p(c2[0], c2[1]) + ' ' + p(x2, y2); + // The curve's point half way (t = 0.5) + var mid = { x: Math.round(((x1 + 3 * c1[0] + 3 * c2[0] + x2) / 8 + nx) * 10) / 10, y: Math.round(((y1 + 3 * c1[1] + 3 * c2[1] + y2) / 8 + ny) * 10) / 10 }; + return { d: 'M' + p(x1, y1) + ' C' + p(c1[0], c1[1]) + ' ' + p(c2[0], c2[1]) + ' ' + p(x2, y2), mid: mid }; } - var api = { build: build, layout: layout, loadClass: loadClass, width: width, speed: speed, matches: matches, groupHeight: groupHeight, GROUP: GROUP }; + var api = { build: build, layout: layout, loadClass: loadClass, width: width, speed: speed, matches: matches, groupHeight: groupHeight, GROUP: GROUP, FRAME: FRAME, + portLabel: portLabel, nameOf: nameOf, hostLabel: hostLabel, peerAddress: peerAddress }; root.NetworkGraph = api; })(typeof window !== 'undefined' ? window : globalThis); diff --git a/tests/dWebTests/network-graph.test.mjs b/tests/dWebTests/network-graph.test.mjs index e58727638..3cff7ec14 100644 --- a/tests/dWebTests/network-graph.test.mjs +++ b/tests/dWebTests/network-graph.test.mjs @@ -164,6 +164,87 @@ same(G.layout(graph, 1000), L, 'the same layout every time'); same(Z.paths['clients>world:1200:1'] && Z.paths['world:1200:1>chat'] ? true : false, true, 'instance rows are linked on both sides'); } +// Listening ports: on a server's box, and on an open zone's instance rows; remote ports only when the list has them +{ + const withPorts = JSON.parse(JSON.stringify(summary)); + withPorts.servers.auth.port = 1001; + withPorts.servers['world:1200:1'].port = 3015; + withPorts.servers['world:1200:2'].port = 3016; + same(G.portLabel(1001), ':1001', 'port label'); + same(G.portLabel(null), '', 'no port, no label'); + const g = G.build(withPorts, {}); + same(G.nameOf(g.nodes.find((n) => n.id === 'auth')), 'Auth :1001', 'auth box with its port'); + same(g.nodes.find((n) => n.id === 'zone:1200').port, null, 'a zone of two instances has no one port'); + same(G.nameOf(g.nodes.find((n) => n.id === 'chat')), 'Chat', 'no port known: just the name'); + const one = JSON.parse(JSON.stringify(withPorts)); + delete one.servers['world:1200:2']; + same(G.nameOf(G.build(one, {}).nodes.find((n) => n.id === 'zone:1200')), 'World 1200 Nimbus Station :3015', 'a zone of one instance has its port'); + const open = G.build(withPorts, { expanded: { 1200: true } }); + same(open.members['world:1200:2'].port, 3016, 'an instance row has its port'); + same(G.peerAddress({ address: '203.0.113.5', servers: [{ port: 51234 }, { http: true, port: 80 }] }), '203.0.113.5:51234', 'a player\'s remote port'); + same(G.peerAddress({ address: 'peer-0001', servers: [{ port: null }] }), 'peer-0001', 'no remote port without network_ips'); + same(G.build(withPorts, { clientsExpanded: true, addressesShown: true, peers: [{ address: '203.0.113.5', kind: 'game', account: 'x', servers: [{ server: 'auth', port: 51234 }] }], + filters: { clients: '51234' } }).nodes.find((n) => n.id === 'clients').shown.length, 1, 'filter by remote port with network_ips'); +} + +// One machine: no frames, nothing marked, the same layout as without machines +{ + const same1 = JSON.parse(JSON.stringify(summary)); + Object.values(same1.servers).forEach((s) => { s.host = 'peer-aaaa'; }); + const g = G.build(same1, {}); + same(g.hosts, [], 'one machine: no hosts'); + same(g.edges.some((e) => e.cross), false, 'no link crosses machines'); + same(g.nodes.map((n) => n.id), ids, 'the same boxes'); + const L1 = G.layout(g, 1000); + same(L1.frames, {}, 'no frames'); + same(L1.nodes, G.layout(graph, 1000).nodes, 'the same places'); +} + +// Two machines: the second world, chat and UGC on another; frames, totals and marked links +{ + const two = JSON.parse(JSON.stringify(summary)); + Object.values(two.servers).forEach((s) => { s.host = 'peer-aaaa'; }); + two.servers['world:1200:2'].host = 'peer-bbbb'; + two.servers.chat.host = 'peer-bbbb'; + delete two.servers['service:99:0:0']; + const g = G.build(two, { hostNames: { 'peer-bbbb': '198.51.100.20' } }); + same(g.hosts.map((h) => h.id), ['peer-aaaa', 'peer-bbbb'], 'two machines, master\'s first'); + same(g.hosts[0].master, true, 'master\'s machine is marked'); + same(g.hosts[1].label, '198.51.100.20', 'an address when the viewer may see it'); + same(g.hosts[0].label, 'peer-aaaa', 'else its token'); + same(g.hosts[1].servers, 2, 'servers on the second machine'); + same(g.hosts[1].connections, 6 + 2, 'its connections'); + same(g.hosts[1].packetsIn, 10 + 2, 'its packets'); + same(g.nodes.some((n) => n.id === 'zone:1200@peer-aaaa') && g.nodes.some((n) => n.id === 'zone:1200@peer-bbbb'), true, 'a zone on both machines is a box on each'); + same(g.hosts[1].nodes.sort(), ['chat', 'zone:1200@peer-bbbb'], 'the second machine\'s boxes'); + same(g.edges.find((e) => e.id === 'zone:1200@peer-aaaa>chat').cross, true, 'a world to chat on another machine is marked'); + same(g.edges.find((e) => e.id === 'zone:1200@peer-bbbb>master').cross, true, 'the other machine\'s world to master too'); + same(g.edges.find((e) => e.id === 'zone:1200@peer-aaaa>master').cross, false, 'on the same machine it is not'); + same(g.edges.find((e) => e.id === 'auth>master').cross, false, 'auth and master share a machine'); + same(g.edges.find((e) => e.id === 'clients>auth').cross, false, 'players are no machine'); + const L2 = G.layout(g, 1000); + same(Object.keys(L2.frames), ['peer-aaaa', 'peer-bbbb'], 'a frame per machine'); + const fa = L2.frames['peer-aaaa'], fb = L2.frames['peer-bbbb']; + same(fa.y + fa.h <= fb.y, true, 'the frames do not overlap: one under the other'); + const inside = (f, id) => { const a = L2.nodes[id]; return a.x - L2.nodeWidth / 2 >= f.x && a.x + L2.nodeWidth / 2 <= f.x + f.w && a.y - L2.nodeHeight / 2 >= f.y && a.y - L2.nodeHeight / 2 + a.h <= f.y + f.h; }; + same(['auth', 'master', 'dashboard', 'ugc', 'zone:1200@peer-aaaa'].every((id) => inside(fa, id)), true, 'master\'s machine frames its boxes'); + same(['chat', 'zone:1200@peer-bbbb'].every((id) => inside(fb, id)), true, 'and the other its own'); + same(L2.paths['zone:1200@peer-aaaa>chat'].mid && typeof L2.paths['zone:1200@peer-aaaa>chat'].mid.x === 'number', true, 'a crossing link has a mark half way'); + same(L2.paths['auth>master'].mid, undefined, 'others have none'); + // A dragged box takes its frame along + const D = G.layout(g, 1000, { chat: { fx: 0.5, y: 1500 } }); + same(D.frames['peer-bbbb'].y + D.frames['peer-bbbb'].h >= 1500 + D.nodeHeight / 2, true, 'the frame follows a dragged box'); + // Collapsed: the machine is one box, its links go to it, links inside it are gone + const c = G.build(two, { collapsedHosts: { 'peer-bbbb': true } }); + same(c.nodes.some((n) => n.id === 'chat'), false, 'the collapsed machine\'s boxes are gone'); + const hostBox = c.nodes.find((n) => n.id === 'host:peer-bbbb'); + same(hostBox && hostBox.kind, 'host', 'one box for the machine'); + same(c.edges.find((e) => e.id === 'zone:1200@peer-aaaa>host:peer-bbbb').cross, true, 'links into it are marked'); + same(c.edges.some((e) => e.from === e.to), false, 'links inside it are gone'); + same(c.hosts[1].collapsed, true, 'collapsed'); + same(Object.keys(G.layout(c, 1000).frames).length, 2, 'still framed'); +} + if (failures) { console.error(`${failures} failure(s)`); process.exit(1);