@takodotid/circuit 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/cli/index.js +33 -10
- package/package.json +1 -1
- package/src/adapters/devices/vrp/index.ts +5 -0
- package/src/core/across.ts +36 -11
- package/src/studio/public/graph.js +224 -44
- package/src/studio/public/index.html +3 -1
- package/src/studio/public/style.css +60 -0
- package/templates/single-site/router.ts +4 -7
package/README.md
CHANGED
|
@@ -25,6 +25,15 @@ Documentation: [circuit.tako.id](https://circuit.tako.id)
|
|
|
25
25
|
<img src="docs/public/screenshots/studio-light.png" alt="Studio's VLAN graph with devices that differ from their snapshots marked by orange dashed borders, beside the layer details and legend.">
|
|
26
26
|
</picture>
|
|
27
27
|
|
|
28
|
+
## Circuit Academy
|
|
29
|
+
|
|
30
|
+
New to networking? [Circuit Academy](https://circuit.tako.id/academy/) teaches it from the very start, in lessons you try in the browser rather than only read: packets, addresses, switches and routers first, then VLANs, DHCP, NAT and firewalls written down in Circuit, up to BGP and an edge router of your own. A certification is coming.
|
|
31
|
+
|
|
32
|
+
<picture>
|
|
33
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/public/screenshots/academy-dark.png">
|
|
34
|
+
<img src="docs/public/screenshots/academy-light.png" alt="A Circuit Academy lesson on switches: a frame from a laptop to a printer has just been flooded out of every port, and the switch has learned which port the laptop is behind.">
|
|
35
|
+
</picture>
|
|
36
|
+
|
|
28
37
|
## Start
|
|
29
38
|
|
|
30
39
|
You need [Node.js](https://nodejs.org) 20.12 or newer, or [Bun](https://bun.sh). Then:
|
package/dist/cli/index.js
CHANGED
|
@@ -3256,6 +3256,8 @@ function unsupported3(device) {
|
|
|
3256
3256
|
found.push(`interfaces.${name}.addresses, LAGs are switched`);
|
|
3257
3257
|
if (iface.mtu !== undefined)
|
|
3258
3258
|
found.push(`interfaces.${name}.mtu`);
|
|
3259
|
+
if (iface.ipv6_ra)
|
|
3260
|
+
found.push(`interfaces.${name}.ipv6_ra`);
|
|
3259
3261
|
for (const group of iface.vrrp ?? [])
|
|
3260
3262
|
if (familyOf2(group.address) === "ipv6")
|
|
3261
3263
|
found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
@@ -3271,6 +3273,10 @@ function unsupported3(device) {
|
|
|
3271
3273
|
found.push("routing.rpki");
|
|
3272
3274
|
if (routing?.ospf?.families?.includes("ipv6"))
|
|
3273
3275
|
found.push("routing.ospf for IPv6");
|
|
3276
|
+
for (const [index, route] of (routing?.static ?? []).entries()) {
|
|
3277
|
+
if (route.description && familyOf2(split(route.prefix).addr) === "ipv6")
|
|
3278
|
+
found.push(`routing.static[${index}].description for IPv6`);
|
|
3279
|
+
}
|
|
3274
3280
|
for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"]) {
|
|
3275
3281
|
if (device[field] !== undefined)
|
|
3276
3282
|
found.push(field);
|
|
@@ -3629,14 +3635,25 @@ function checkAcross(devices) {
|
|
|
3629
3635
|
...checkTunnels(devices, owners)
|
|
3630
3636
|
];
|
|
3631
3637
|
}
|
|
3632
|
-
function carried(device,
|
|
3638
|
+
function carried(device, settings) {
|
|
3633
3639
|
const id = (vlan) => vlan ? device.vlans?.[vlan]?.id : undefined;
|
|
3634
3640
|
return {
|
|
3635
|
-
access: id(
|
|
3636
|
-
native: id(
|
|
3637
|
-
trunk: (
|
|
3641
|
+
access: id(settings.access_vlan),
|
|
3642
|
+
native: id(settings.native_vlan),
|
|
3643
|
+
trunk: (settings.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0))
|
|
3638
3644
|
};
|
|
3639
3645
|
}
|
|
3646
|
+
function describeCarried({ access, native, trunk }) {
|
|
3647
|
+
const parts = [];
|
|
3648
|
+
const tagged = trunk.filter((id) => id !== undefined);
|
|
3649
|
+
if (access !== undefined)
|
|
3650
|
+
parts.push(`VLAN ${access} untagged`);
|
|
3651
|
+
if (tagged.length)
|
|
3652
|
+
parts.push(`${tagged.length === 1 ? "VLAN" : "VLANs"} ${tagged.join(", ")} tagged`);
|
|
3653
|
+
if (native !== undefined)
|
|
3654
|
+
parts.push(`VLAN ${native} untagged as native`);
|
|
3655
|
+
return parts.length ? parts.join(" and ") : "no VLANs";
|
|
3656
|
+
}
|
|
3640
3657
|
function vlanNames(device, settings) {
|
|
3641
3658
|
const names = [settings.access_vlan, settings.native_vlan, ...settings.trunk_vlans ?? []].filter((name) => name !== undefined);
|
|
3642
3659
|
return new Map(names.flatMap((name) => device.vlans?.[name] ? [[device.vlans[name].id, name]] : []));
|
|
@@ -3695,15 +3712,21 @@ function checkLinks(devices) {
|
|
|
3695
3712
|
report(`${link.device} ${link.port} does not link back to it`);
|
|
3696
3713
|
continue;
|
|
3697
3714
|
}
|
|
3698
|
-
const
|
|
3699
|
-
const
|
|
3700
|
-
|
|
3701
|
-
|
|
3715
|
+
const near = carrierOf(device, portName, port);
|
|
3716
|
+
const away = carrierOf(far, link.port, farPort);
|
|
3717
|
+
const inLag = near.name !== portName;
|
|
3718
|
+
const farInLag = away.name !== link.port;
|
|
3719
|
+
if (inLag !== farInLag) {
|
|
3720
|
+
report(inLag ? `${portName} is in ${near.name}, and ${link.device} ${link.port} is in no LAG` : `${portName} is in no LAG, and ${link.device} ${link.port} is in ${away.name}`);
|
|
3721
|
+
}
|
|
3722
|
+
const here = carried(device, near.settings);
|
|
3723
|
+
const there = carried(far, away.settings);
|
|
3724
|
+
if (JSON.stringify(here) !== JSON.stringify(there)) {
|
|
3725
|
+
report(`${near.name} carries ${describeCarried(here)}, and ${link.device} ${away.name} carries ${describeCarried(there)}`);
|
|
3726
|
+
}
|
|
3702
3727
|
if (port.speed && farPort.speed && port.speed !== farPort.speed) {
|
|
3703
3728
|
report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
|
|
3704
3729
|
}
|
|
3705
|
-
const near = carrierOf(device, portName, port);
|
|
3706
|
-
const away = carrierOf(far, link.port, farPort);
|
|
3707
3730
|
const mtu = near.settings.mtu ?? 1500;
|
|
3708
3731
|
const farMtu = away.settings.mtu ?? 1500;
|
|
3709
3732
|
if (mtu !== farMtu)
|
package/package.json
CHANGED
|
@@ -609,6 +609,7 @@ export function unsupported(device: Device): string[] {
|
|
|
609
609
|
if (iface.type !== "vlan" && iface.type !== "lag") found.push(`interfaces.${name}, a ${iface.type}`);
|
|
610
610
|
if (iface.type === "lag" && iface.addresses?.length) found.push(`interfaces.${name}.addresses, LAGs are switched`);
|
|
611
611
|
if (iface.mtu !== undefined) found.push(`interfaces.${name}.mtu`);
|
|
612
|
+
if (iface.ipv6_ra) found.push(`interfaces.${name}.ipv6_ra`);
|
|
612
613
|
for (const group of iface.vrrp ?? []) if (familyOf(group.address) === "ipv6") found.push(`interfaces.${name}.vrrp for IPv6`);
|
|
613
614
|
}
|
|
614
615
|
|
|
@@ -621,6 +622,10 @@ export function unsupported(device: Device): string[] {
|
|
|
621
622
|
if (routing?.rpki) found.push("routing.rpki");
|
|
622
623
|
if (routing?.ospf?.families?.includes("ipv6")) found.push("routing.ospf for IPv6");
|
|
623
624
|
|
|
625
|
+
for (const [index, route] of (routing?.static ?? []).entries()) {
|
|
626
|
+
if (route.description && familyOf(split(route.prefix).addr) === "ipv6") found.push(`routing.static[${index}].description for IPv6`);
|
|
627
|
+
}
|
|
628
|
+
|
|
624
629
|
for (const field of ["firewall", "dhcp", "acls", "policies", "prefix_sets", "hardware_offload", "certificates"] as const) {
|
|
625
630
|
if (device[field] !== undefined) found.push(field);
|
|
626
631
|
}
|
package/src/core/across.ts
CHANGED
|
@@ -25,16 +25,28 @@ export function checkAcross(devices: readonly Device[]): Finding[] {
|
|
|
25
25
|
];
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
|
|
29
|
-
function carried(device: Device,
|
|
28
|
+
/** The VLAN ids a port or LAG carries, by how it carries them, so two ends can be compared across devices. */
|
|
29
|
+
function carried(device: Device, settings: Switched<string>) {
|
|
30
30
|
const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
|
|
31
31
|
return {
|
|
32
|
-
access: id(
|
|
33
|
-
native: id(
|
|
34
|
-
trunk: (
|
|
32
|
+
access: id(settings.access_vlan),
|
|
33
|
+
native: id(settings.native_vlan),
|
|
34
|
+
trunk: (settings.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** How a port carries its VLANs, in words: "VLANs 10, 20 tagged and VLAN 30 untagged as native". */
|
|
39
|
+
function describeCarried({ access, native, trunk }: ReturnType<typeof carried>): string {
|
|
40
|
+
const parts: string[] = [];
|
|
41
|
+
const tagged = trunk.filter((id) => id !== undefined);
|
|
42
|
+
|
|
43
|
+
if (access !== undefined) parts.push(`VLAN ${access} untagged`);
|
|
44
|
+
if (tagged.length) parts.push(`${tagged.length === 1 ? "VLAN" : "VLANs"} ${tagged.join(", ")} tagged`);
|
|
45
|
+
if (native !== undefined) parts.push(`VLAN ${native} untagged as native`);
|
|
46
|
+
|
|
47
|
+
return parts.length ? parts.join(" and ") : "no VLANs";
|
|
48
|
+
}
|
|
49
|
+
|
|
38
50
|
/** VLAN names by id, for the VLANs a port or LAG carries. */
|
|
39
51
|
function vlanNames(device: Device, settings: Switched<string>): Map<number, string> {
|
|
40
52
|
const names = [settings.access_vlan, settings.native_vlan, ...(settings.trunk_vlans ?? [])].filter((name) => name !== undefined);
|
|
@@ -114,17 +126,30 @@ function checkLinks(devices: readonly Device[]): Finding[] {
|
|
|
114
126
|
continue;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
129
|
+
const near = carrierOf(device, portName, port);
|
|
130
|
+
const away = carrierOf(far, link.port, farPort);
|
|
131
|
+
|
|
132
|
+
// A LAG's VLANs are on the LAG, so its member ports are compared by what their LAGs carry.
|
|
133
|
+
const inLag = near.name !== portName;
|
|
134
|
+
const farInLag = away.name !== link.port;
|
|
135
|
+
if (inLag !== farInLag) {
|
|
136
|
+
report(
|
|
137
|
+
inLag
|
|
138
|
+
? `${portName} is in ${near.name}, and ${link.device} ${link.port} is in no LAG`
|
|
139
|
+
: `${portName} is in no LAG, and ${link.device} ${link.port} is in ${away.name}`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const here = carried(device, near.settings);
|
|
144
|
+
const there = carried(far, away.settings);
|
|
145
|
+
if (JSON.stringify(here) !== JSON.stringify(there)) {
|
|
146
|
+
report(`${near.name} carries ${describeCarried(here)}, and ${link.device} ${away.name} carries ${describeCarried(there)}`);
|
|
147
|
+
}
|
|
120
148
|
|
|
121
149
|
if (port.speed && farPort.speed && port.speed !== farPort.speed) {
|
|
122
150
|
report(`speed ${port.speed}, and ${link.device} ${link.port} is ${farPort.speed}`);
|
|
123
151
|
}
|
|
124
152
|
|
|
125
|
-
const near = carrierOf(device, portName, port);
|
|
126
|
-
const away = carrierOf(far, link.port, farPort);
|
|
127
|
-
|
|
128
153
|
const mtu = near.settings.mtu ?? 1500;
|
|
129
154
|
const farMtu = away.settings.mtu ?? 1500;
|
|
130
155
|
if (mtu !== farMtu) report(`MTU ${mtu} on ${near.name}, and ${link.device} ${away.name} has ${farMtu}`);
|
|
@@ -85,10 +85,28 @@ let tour;
|
|
|
85
85
|
/** @param {GraphEdge} edge */
|
|
86
86
|
const edgeKey = (edge) => `${edge.layer} ${edge.from} ${edge.to} ${edge.label}`;
|
|
87
87
|
let view = { x: 0, y: 0, k: 1 };
|
|
88
|
+
/** Where the reader left each layer's view, as each layer is a drawing of its own. @type {Map<Layer, typeof view>} */
|
|
89
|
+
const views = new Map();
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
91
|
+
/** @typedef {Record<string, [number, number]>} SavedPlaces */
|
|
92
|
+
|
|
93
|
+
const saved = /** @type {Partial<Record<Layer, SavedPlaces>>} */ (store.get("layouts") ?? {});
|
|
94
|
+
// The one layout an earlier Studio kept for every layer carries on as the cables layer's.
|
|
95
|
+
const earlier = /** @type {SavedPlaces | undefined} */ (store.get("positions"));
|
|
96
|
+
|
|
97
|
+
/** Where each node is on each layer: every layer is laid out for its own lines, and keeps where its devices were moved. @type {Map<Layer, Map<string, Point>>} */
|
|
98
|
+
const layouts = new Map(
|
|
99
|
+
LAYERS.map((name) => {
|
|
100
|
+
const places = saved[name] ?? (name === "l1" ? earlier : undefined) ?? {};
|
|
101
|
+
return [name, new Map(Object.entries(places).map(([id, [x, y]]) => [id, { x, y }]))];
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
/** @param {Layer} name */
|
|
106
|
+
const layoutOf = (name) => /** @type {Map<string, Point>} */ (layouts.get(name));
|
|
107
|
+
|
|
108
|
+
/** Where nodes are drawn now: the layer's own places, or, while the layer changes, the places on the way there. */
|
|
109
|
+
let positions = layoutOf(layer);
|
|
92
110
|
|
|
93
111
|
/** @type {Map<string, SVGGElement>} */
|
|
94
112
|
const nodeElements = new Map();
|
|
@@ -96,7 +114,9 @@ const nodeElements = new Map();
|
|
|
96
114
|
const edgeElements = [];
|
|
97
115
|
|
|
98
116
|
function savePositions() {
|
|
99
|
-
|
|
117
|
+
/** @param {Map<string, Point>} places */
|
|
118
|
+
const rounded = (places) => Object.fromEntries([...places].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]]));
|
|
119
|
+
store.set("layouts", Object.fromEntries([...layouts].map(([name, places]) => [name, rounded(places)])));
|
|
100
120
|
}
|
|
101
121
|
|
|
102
122
|
/**
|
|
@@ -111,17 +131,23 @@ function hash(text) {
|
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
/**
|
|
114
|
-
* Place nodes with a force layout: every node pushes every other away, and each
|
|
115
|
-
*
|
|
134
|
+
* Place one layer's nodes with a force layout: every node pushes every other away, and each of the layer's edges pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
|
|
135
|
+
* A node new to a layer starts where it is on the cables layer, so each layer is laid out for its own lines yet still looks like the same network.
|
|
116
136
|
*
|
|
117
137
|
* @param {GraphNode[]} nodes
|
|
118
138
|
* @param {GraphEdge[]} edges
|
|
119
139
|
* @param {boolean} fresh
|
|
140
|
+
* @param {Map<string, Point>} places the layer's places, filled in
|
|
141
|
+
* @param {Map<string, Point>} [seed] places to start new nodes from
|
|
120
142
|
*/
|
|
121
|
-
function layout(nodes, edges, fresh) {
|
|
122
|
-
if (fresh)
|
|
143
|
+
function layout(nodes, edges, fresh, places, seed) {
|
|
144
|
+
if (fresh) places.clear();
|
|
123
145
|
|
|
124
|
-
|
|
146
|
+
/** @param {GraphNode} node */
|
|
147
|
+
const halfWidth = (node) =>
|
|
148
|
+
node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
|
|
149
|
+
|
|
150
|
+
const fixed = new Set(nodes.filter((node) => places.has(node.id)).map((node) => node.id));
|
|
125
151
|
/** @type {Map<string, Set<string>>} */
|
|
126
152
|
const neighbors = new Map();
|
|
127
153
|
|
|
@@ -133,29 +159,64 @@ function layout(nodes, edges, fresh) {
|
|
|
133
159
|
neighbors.get(edge.to)?.add(edge.from);
|
|
134
160
|
}
|
|
135
161
|
|
|
162
|
+
// A device with no line on this layer is set in a row under the drawing, rather than pushed out to its edges by the rest.
|
|
163
|
+
const loose = edges.length ? nodes.filter((node) => !neighbors.has(node.id) && !places.has(node.id)) : [];
|
|
164
|
+
const linked = nodes.filter((node) => !loose.includes(node));
|
|
165
|
+
|
|
166
|
+
function park() {
|
|
167
|
+
if (!loose.length) return;
|
|
168
|
+
|
|
169
|
+
const placed = linked.flatMap((node) => places.get(node.id) ?? []);
|
|
170
|
+
const left = placed.length ? Math.min(...placed.map((point) => point.x)) : 0;
|
|
171
|
+
const right = left + Math.max(placed.length ? Math.max(...placed.map((point) => point.x)) - left : 0, 900);
|
|
172
|
+
let x = left;
|
|
173
|
+
let y = (placed.length ? Math.max(...placed.map((point) => point.y)) : 0) + 130;
|
|
174
|
+
|
|
175
|
+
for (const node of [...loose].sort((a, b) => a.label.localeCompare(b.label))) {
|
|
176
|
+
const half = halfWidth(node);
|
|
177
|
+
if (x > left && x + half * 2 > right) {
|
|
178
|
+
x = left;
|
|
179
|
+
y += 96;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
places.set(node.id, { x: x + half, y });
|
|
183
|
+
x += half * 2 + 24;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
savePositions();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
nodes = linked;
|
|
190
|
+
|
|
136
191
|
// A new node starts beside a neighbor already placed, or on a circle.
|
|
137
192
|
const radius = 60 * Math.sqrt(nodes.length + 1);
|
|
138
193
|
|
|
139
194
|
for (const node of nodes) {
|
|
140
|
-
if (
|
|
195
|
+
if (places.has(node.id)) continue;
|
|
196
|
+
|
|
197
|
+
const start = seed?.get(node.id);
|
|
198
|
+
if (start) {
|
|
199
|
+
places.set(node.id, { ...start });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
141
202
|
|
|
142
203
|
const angle = hash(node.id) * Math.PI * 2;
|
|
143
|
-
const placed = [...(neighbors.get(node.id) ?? [])].map((id) =>
|
|
204
|
+
const placed = [...(neighbors.get(node.id) ?? [])].map((id) => places.get(id)).find(Boolean);
|
|
144
205
|
const distance = placed ? 90 : radius;
|
|
145
206
|
const origin = placed ?? { x: 0, y: 0 };
|
|
146
207
|
|
|
147
|
-
|
|
208
|
+
places.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
|
|
148
209
|
}
|
|
149
210
|
|
|
150
211
|
const moving = nodes.filter((node) => !fixed.has(node.id));
|
|
151
|
-
if (!moving.length) return;
|
|
212
|
+
if (!moving.length) return park();
|
|
152
213
|
|
|
153
214
|
const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to]));
|
|
154
215
|
const ideal = 170;
|
|
155
216
|
const rounds = fixed.size ? 200 : 500;
|
|
156
217
|
|
|
157
218
|
/** @param {string} id */
|
|
158
|
-
const at = (id) => /** @type {Point} */ (
|
|
219
|
+
const at = (id) => /** @type {Point} */ (places.get(id));
|
|
159
220
|
|
|
160
221
|
for (let round = 0; round < rounds; round++) {
|
|
161
222
|
const heat = 40 * (1 - round / rounds) + 1;
|
|
@@ -187,7 +248,7 @@ function layout(nodes, edges, fresh) {
|
|
|
187
248
|
}
|
|
188
249
|
|
|
189
250
|
for (const [from, to] of pairs) {
|
|
190
|
-
if (!from || !to || !
|
|
251
|
+
if (!from || !to || !places.has(from) || !places.has(to)) continue;
|
|
191
252
|
|
|
192
253
|
const dx = at(from).x - at(to).x;
|
|
193
254
|
const dy = at(from).y - at(to).y;
|
|
@@ -215,9 +276,6 @@ function layout(nodes, edges, fresh) {
|
|
|
215
276
|
}
|
|
216
277
|
|
|
217
278
|
// No box may cover another: two that overlap are pushed apart the shorter way out, with room for the label of the line between them.
|
|
218
|
-
/** @param {GraphNode} node */
|
|
219
|
-
const halfWidth = (node) =>
|
|
220
|
-
node.kind === "device" ? Math.max(node.label.length * 10, (node.detail[0] ?? "").length * 7) / 2 + 60 : node.label.length * 4 + 30;
|
|
221
279
|
|
|
222
280
|
for (let round = 0; round < 80; round++) {
|
|
223
281
|
let overlapping = false;
|
|
@@ -248,27 +306,99 @@ function layout(nodes, edges, fresh) {
|
|
|
248
306
|
if (!overlapping) break;
|
|
249
307
|
}
|
|
250
308
|
|
|
309
|
+
park();
|
|
251
310
|
savePositions();
|
|
252
311
|
}
|
|
253
312
|
|
|
254
313
|
// What is drawn.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* A layer's edges, with those a comparison says are gone.
|
|
317
|
+
*
|
|
318
|
+
* @param {Layer} name
|
|
319
|
+
* @returns {DrawnEdge[]}
|
|
320
|
+
*/
|
|
321
|
+
const edgesOn = (name) => {
|
|
322
|
+
const current = data?.edges.filter((edge) => edge.layer === name) ?? [];
|
|
258
323
|
if (!comparison) return current;
|
|
259
324
|
|
|
260
325
|
// An edge the comparison says is gone is drawn when both its ends are still drawn.
|
|
261
326
|
const nodes = new Set(data?.nodes.map((node) => node.id));
|
|
262
|
-
const gone = comparison.removed.filter((edge) => edge.layer ===
|
|
327
|
+
const gone = comparison.removed.filter((edge) => edge.layer === name && nodes.has(edge.from) && nodes.has(edge.to));
|
|
263
328
|
return [...current, ...gone.map((edge) => ({ ...edge, gone: /** @type {const} */ (true) }))];
|
|
264
329
|
};
|
|
265
330
|
|
|
266
|
-
/**
|
|
267
|
-
|
|
268
|
-
|
|
331
|
+
/**
|
|
332
|
+
* A layer's nodes: every device, and whatever else its edges end at.
|
|
333
|
+
*
|
|
334
|
+
* @param {Layer} name
|
|
335
|
+
* @returns {GraphNode[]}
|
|
336
|
+
*/
|
|
337
|
+
function nodesOn(name) {
|
|
338
|
+
const ends = new Set(edgesOn(name).flatMap((edge) => [edge.from, edge.to]));
|
|
269
339
|
return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
270
340
|
}
|
|
271
341
|
|
|
342
|
+
const visibleEdges = () => edgesOn(layer);
|
|
343
|
+
const visibleNodes = () => nodesOn(layer);
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Lay out a layer for its own lines. The cables layer comes first, as every other layer's new nodes start from it.
|
|
347
|
+
*
|
|
348
|
+
* @param {Layer} name
|
|
349
|
+
* @param {boolean} fresh forget where the layer's nodes were, and lay it out again
|
|
350
|
+
*/
|
|
351
|
+
function layOut(name, fresh) {
|
|
352
|
+
if (!data) return;
|
|
353
|
+
if (name !== "l1") layOut("l1", false);
|
|
354
|
+
|
|
355
|
+
layout(nodesOn(name), edgesOn(name), fresh, layoutOf(name), name === "l1" ? undefined : layoutOf("l1"));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The node glide between two layers' places, which a press stops. @type {number | undefined} */
|
|
359
|
+
let regrouping;
|
|
360
|
+
|
|
361
|
+
/** Put the nodes at their own layer's places now, ending a glide between layers. */
|
|
362
|
+
function settle() {
|
|
363
|
+
if (regrouping !== undefined) cancelAnimationFrame(regrouping);
|
|
364
|
+
regrouping = undefined;
|
|
365
|
+
positions = layoutOf(layer);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Move the nodes from where they are drawn to their places on the layer shown, so the eye can follow each device to where it is on this layer.
|
|
370
|
+
*
|
|
371
|
+
* @param {Map<string, Point>} from
|
|
372
|
+
*/
|
|
373
|
+
function glideNodes(from) {
|
|
374
|
+
settle();
|
|
375
|
+
|
|
376
|
+
const to = layoutOf(layer);
|
|
377
|
+
if (still.matches || from === to) return;
|
|
378
|
+
|
|
379
|
+
/** @type {Map<string, Point>} */
|
|
380
|
+
const shown = new Map();
|
|
381
|
+
const began = performance.now();
|
|
382
|
+
|
|
383
|
+
/** @param {number} now */
|
|
384
|
+
const step = (now) => {
|
|
385
|
+
const t = Math.min((now - began) / 700, 1);
|
|
386
|
+
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
|
387
|
+
|
|
388
|
+
for (const [id, end] of to) {
|
|
389
|
+
const start = from.get(id) ?? end;
|
|
390
|
+
shown.set(id, { x: start.x + (end.x - start.x) * eased, y: start.y + (end.y - start.y) * eased });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
positions = t < 1 ? shown : to;
|
|
394
|
+
place();
|
|
395
|
+
regrouping = t < 1 ? requestAnimationFrame(step) : undefined;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
positions = shown;
|
|
399
|
+
step(began);
|
|
400
|
+
}
|
|
401
|
+
|
|
272
402
|
/** @param {string} id */
|
|
273
403
|
const nodeById = (id) => data?.nodes.find((node) => node.id === id);
|
|
274
404
|
|
|
@@ -449,10 +579,13 @@ function arrive(fresh) {
|
|
|
449
579
|
if (edge.oneSided || edge.gone) {
|
|
450
580
|
path.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 420, delay, easing: EASE, fill: "backwards" });
|
|
451
581
|
} else {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
582
|
+
// Measured as one whole length, so the line draws in right even while its ends glide to a new layer's places.
|
|
583
|
+
path.setAttribute("pathLength", "1");
|
|
584
|
+
const start = { strokeDasharray: "1 1", strokeDashoffset: forward ? 1 : -1 };
|
|
585
|
+
const end = { strokeDasharray: "1 1", strokeDashoffset: 0 };
|
|
586
|
+
const drawing = path.animate([start, end], { duration: 560, delay, easing: EASE, fill: "backwards" });
|
|
587
|
+
drawing.addEventListener("finish", () => path.removeAttribute("pathLength"));
|
|
588
|
+
drawing.addEventListener("cancel", () => path.removeAttribute("pathLength"));
|
|
456
589
|
}
|
|
457
590
|
|
|
458
591
|
for (const part of [label, ...group.querySelectorAll(".signal")]) {
|
|
@@ -540,6 +673,11 @@ function draw(arrival) {
|
|
|
540
673
|
rect.setAttribute("width", String(width));
|
|
541
674
|
rect.setAttribute("height", String(height));
|
|
542
675
|
|
|
676
|
+
// The mark of the device presenting lights now: a ring around its frame, shown only then.
|
|
677
|
+
const beacon = s("g", { class: "beacon", "aria-hidden": "true" });
|
|
678
|
+
beacon.append(s("rect", { x: -width / 2 - 10, y: -height / 2 - 10, width: width + 20, height: height + 20, rx: 16 }));
|
|
679
|
+
group.insertBefore(beacon, rect);
|
|
680
|
+
|
|
543
681
|
if (device) {
|
|
544
682
|
for (const text of texts) text.setAttribute("x", String(-width / 2 + 16 + mark));
|
|
545
683
|
|
|
@@ -609,6 +747,7 @@ function emphasize() {
|
|
|
609
747
|
|
|
610
748
|
for (const [id, group] of nodeElements) {
|
|
611
749
|
group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
|
|
750
|
+
group.classList.toggle("touring", presenting && id === touring && !hovered);
|
|
612
751
|
group.classList.toggle("dim", litNodes !== undefined && !litNodes.has(id));
|
|
613
752
|
}
|
|
614
753
|
}
|
|
@@ -634,6 +773,12 @@ function startDrag(event, id) {
|
|
|
634
773
|
if (event.button !== 0) return;
|
|
635
774
|
if (gliding !== undefined) cancelAnimationFrame(gliding);
|
|
636
775
|
gliding = undefined;
|
|
776
|
+
|
|
777
|
+
// A press ends a glide between layers, so a device is moved from its own place on this layer.
|
|
778
|
+
if (regrouping !== undefined) {
|
|
779
|
+
settle();
|
|
780
|
+
place();
|
|
781
|
+
}
|
|
637
782
|
event.stopPropagation();
|
|
638
783
|
|
|
639
784
|
svg.setPointerCapture(event.pointerId);
|
|
@@ -757,8 +902,10 @@ function fit(readable = false, glide = false) {
|
|
|
757
902
|
* @returns {typeof view | undefined}
|
|
758
903
|
*/
|
|
759
904
|
function framing(nodes, readable) {
|
|
905
|
+
// Framed where the nodes are going, not where a glide between layers has them now.
|
|
906
|
+
const places = layoutOf(layer);
|
|
760
907
|
const points = nodes.flatMap((node) => {
|
|
761
|
-
const point =
|
|
908
|
+
const point = places.get(node.id);
|
|
762
909
|
return point ? [point] : [];
|
|
763
910
|
});
|
|
764
911
|
if (!points.length) return undefined;
|
|
@@ -776,8 +923,8 @@ function framing(nodes, readable) {
|
|
|
776
923
|
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
777
924
|
const hub = [...nodes].sort((a, b) => degree(b.id) - degree(a.id))[0];
|
|
778
925
|
const centre =
|
|
779
|
-
k > whole && hub &&
|
|
780
|
-
? /** @type {Point} */ (
|
|
926
|
+
k > whole && hub && places.has(hub.id)
|
|
927
|
+
? /** @type {Point} */ (places.get(hub.id))
|
|
781
928
|
: { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
782
929
|
|
|
783
930
|
// Presenting leaves room for the slow drift and the caption, so nothing framed is cut at the screen's edge.
|
|
@@ -807,7 +954,8 @@ byId("fit").addEventListener("click", () => fit());
|
|
|
807
954
|
|
|
808
955
|
byId("lay-out").addEventListener("click", () => {
|
|
809
956
|
if (!data) return;
|
|
810
|
-
|
|
957
|
+
layOut(layer, true);
|
|
958
|
+
settle();
|
|
811
959
|
draw();
|
|
812
960
|
fit();
|
|
813
961
|
});
|
|
@@ -842,6 +990,9 @@ const LIT_PER_LAYER = 4;
|
|
|
842
990
|
/** How far presenting has gone on this layer: 0 while the whole layer is shown, then each lit device in turn. */
|
|
843
991
|
let tourStep = 0;
|
|
844
992
|
|
|
993
|
+
/** The move to the next layer, waiting while the view zooms out to the whole of this one. @type {ReturnType<typeof setTimeout> | undefined} */
|
|
994
|
+
let leaving;
|
|
995
|
+
|
|
845
996
|
/** The next layer that has lines to show, after this one; this one when no other has any. */
|
|
846
997
|
function nextLayer() {
|
|
847
998
|
const after = [...LAYERS.slice(LAYERS.indexOf(layer) + 1), ...LAYERS.slice(0, LAYERS.indexOf(layer) + 1)];
|
|
@@ -850,7 +1001,7 @@ function nextLayer() {
|
|
|
850
1001
|
|
|
851
1002
|
/** While presenting: show the whole layer, light its busiest devices one at a time, then move on to the next layer. */
|
|
852
1003
|
function tourNext() {
|
|
853
|
-
if (graphView.hidden || document.hidden || hovered || drag) return;
|
|
1004
|
+
if (graphView.hidden || document.hidden || hovered || drag || leaving) return;
|
|
854
1005
|
|
|
855
1006
|
const edges = visibleEdges();
|
|
856
1007
|
const degree = (/** @type {string} */ id) => edges.filter((edge) => edge.from === id || edge.to === id).length;
|
|
@@ -870,8 +1021,27 @@ function tourNext() {
|
|
|
870
1021
|
return;
|
|
871
1022
|
}
|
|
872
1023
|
|
|
873
|
-
// Every lit device has had its turn
|
|
874
|
-
|
|
1024
|
+
// Every lit device has had its turn: the view zooms out to the whole layer first, so the next layer is reached from the whole drawing.
|
|
1025
|
+
tourStep = 0;
|
|
1026
|
+
emphasize();
|
|
1027
|
+
showCaption();
|
|
1028
|
+
frameTour();
|
|
1029
|
+
|
|
1030
|
+
const next = nextLayer();
|
|
1031
|
+
leaving = setTimeout(() => {
|
|
1032
|
+
leaving = undefined;
|
|
1033
|
+
|
|
1034
|
+
// Moving on replaces the address rather than adding to the history, so Back still leaves Studio's last place.
|
|
1035
|
+
if (presenting && !graphView.hidden) location.replace(`#graph/${next}`);
|
|
1036
|
+
}, 2600);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/** Start the turns over, so the layer shown now has its full time. */
|
|
1040
|
+
function restartTour() {
|
|
1041
|
+
clearInterval(tour);
|
|
1042
|
+
clearTimeout(leaving);
|
|
1043
|
+
leaving = undefined;
|
|
1044
|
+
if (presenting) tour = setInterval(tourNext, 6000);
|
|
875
1045
|
}
|
|
876
1046
|
|
|
877
1047
|
/** @param {boolean} on */
|
|
@@ -882,12 +1052,9 @@ function setPresenting(on) {
|
|
|
882
1052
|
document.body.classList.toggle("presenting", on);
|
|
883
1053
|
presentButton.setAttribute("aria-pressed", String(on));
|
|
884
1054
|
|
|
885
|
-
clearInterval(tour);
|
|
886
1055
|
touring = undefined;
|
|
887
|
-
if (on)
|
|
888
|
-
|
|
889
|
-
tour = setInterval(tourNext, 6000);
|
|
890
|
-
}
|
|
1056
|
+
if (on) selection = undefined;
|
|
1057
|
+
restartTour();
|
|
891
1058
|
|
|
892
1059
|
if (graphView.hidden || !data) return;
|
|
893
1060
|
|
|
@@ -1200,7 +1367,7 @@ export function setGraph(next) {
|
|
|
1200
1367
|
selection = same ? { edge: same } : undefined;
|
|
1201
1368
|
}
|
|
1202
1369
|
|
|
1203
|
-
|
|
1370
|
+
layOut(layer, false);
|
|
1204
1371
|
}
|
|
1205
1372
|
|
|
1206
1373
|
/**
|
|
@@ -1215,6 +1382,7 @@ export function showLayer(next) {
|
|
|
1215
1382
|
pathFocus = undefined;
|
|
1216
1383
|
touring = undefined;
|
|
1217
1384
|
tourStep = 0;
|
|
1385
|
+
restartTour();
|
|
1218
1386
|
}
|
|
1219
1387
|
|
|
1220
1388
|
if (pendingVlan !== undefined) {
|
|
@@ -1227,20 +1395,32 @@ export function showLayer(next) {
|
|
|
1227
1395
|
pendingPath = undefined;
|
|
1228
1396
|
}
|
|
1229
1397
|
|
|
1230
|
-
// The first view draws the whole network in; a new layer draws in its own lines.
|
|
1398
|
+
// The first view draws the whole network in; a new layer draws in its own lines while its devices glide to their places on it.
|
|
1231
1399
|
const arrival = !drawn && data ? "network" : next !== layer ? "cables" : undefined;
|
|
1400
|
+
const from = positions;
|
|
1401
|
+
if (drawn) views.set(layer, { ...view });
|
|
1232
1402
|
|
|
1233
1403
|
layer = next;
|
|
1234
1404
|
store.set("layer", layer);
|
|
1405
|
+
layOut(layer, false);
|
|
1406
|
+
|
|
1407
|
+
if (arrival === "cables") glideNodes(from);
|
|
1408
|
+
else settle();
|
|
1235
1409
|
|
|
1236
1410
|
showLayerButtons();
|
|
1237
1411
|
draw(arrival);
|
|
1238
1412
|
showPanel();
|
|
1239
1413
|
showCaption();
|
|
1240
1414
|
|
|
1241
|
-
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read.
|
|
1415
|
+
// Presenting shows the whole layer as it comes in; a first view on a laptop starts where names can be read. A layer shown before
|
|
1416
|
+
// comes back as the reader left it, and one shown for the first time glides into view.
|
|
1242
1417
|
if (presenting && arrival) fit();
|
|
1243
1418
|
else if (!drawn && data) fit(true);
|
|
1419
|
+
else if (arrival === "cables") {
|
|
1420
|
+
const left = views.get(layer);
|
|
1421
|
+
if (left) glideTo(left);
|
|
1422
|
+
else fit(true, true);
|
|
1423
|
+
}
|
|
1244
1424
|
drawn = data !== undefined;
|
|
1245
1425
|
}
|
|
1246
1426
|
|
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
<nav id="layers" aria-label="Layers"></nav>
|
|
45
45
|
<div class="actions">
|
|
46
46
|
<button id="fit" title="Fit the drawing to the window">Fit</button>
|
|
47
|
-
<button id="lay-out" title="Forget where nodes were moved, and lay
|
|
47
|
+
<button id="lay-out" title="Forget where nodes were moved on this layer, and lay it out again">
|
|
48
|
+
Lay out again
|
|
49
|
+
</button>
|
|
48
50
|
<button
|
|
49
51
|
id="present"
|
|
50
52
|
aria-pressed="false"
|
|
@@ -712,6 +712,66 @@ svg.canvas.panning {
|
|
|
712
712
|
fill: var(--yellow);
|
|
713
713
|
}
|
|
714
714
|
|
|
715
|
+
/* The device presenting lights now: a highlighter fill, lit sockets, and a ring around it that keeps leaving it, the way the docs drawing marks the changed device. A warning or error keeps its own frame. */
|
|
716
|
+
.node .beacon {
|
|
717
|
+
display: none;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.node.touring .beacon {
|
|
721
|
+
display: inline;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.node .beacon rect {
|
|
725
|
+
fill: none;
|
|
726
|
+
stroke: var(--yellow-deep);
|
|
727
|
+
stroke-width: 3;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.node.touring > rect {
|
|
731
|
+
fill: var(--yellow);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.node.touring:not(.error):not(.warning) > rect {
|
|
735
|
+
stroke: #0a1a36;
|
|
736
|
+
stroke-width: 4;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.node.touring text.name {
|
|
740
|
+
fill: #11141b;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.node.touring text.sub {
|
|
744
|
+
fill: #2a3550;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
.node.touring .chassis > rect:first-child {
|
|
748
|
+
fill: #0a1a36;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.node.touring .chassis .socket {
|
|
752
|
+
fill: var(--yellow);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
756
|
+
.node.touring .beacon rect {
|
|
757
|
+
transform-box: fill-box;
|
|
758
|
+
transform-origin: center;
|
|
759
|
+
animation: beacon 1.8s cubic-bezier(0.16, 1, 0.3, 1) infinite;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
@keyframes beacon {
|
|
764
|
+
from {
|
|
765
|
+
opacity: 1;
|
|
766
|
+
transform: scale(0.96);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
to {
|
|
770
|
+
opacity: 0;
|
|
771
|
+
transform: scale(1.18);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
715
775
|
.node text {
|
|
716
776
|
fill: var(--text);
|
|
717
777
|
font-size: 14px;
|
|
@@ -69,16 +69,13 @@ export default defineDevice({
|
|
|
69
69
|
{ description: "DNS from guests", match: { in_interface: "guests", protocol: "udp", dst_port: 53 }, action: "accept" },
|
|
70
70
|
],
|
|
71
71
|
},
|
|
72
|
-
// Traffic passing through the router.
|
|
72
|
+
// Traffic passing through the router: home and guests reach the internet, and nothing else gets through.
|
|
73
73
|
forward: {
|
|
74
|
+
default: "drop",
|
|
74
75
|
rules: [
|
|
75
76
|
{ description: "Replies", match: { state: ["established", "related"] }, action: "accept", offload: true },
|
|
76
|
-
{
|
|
77
|
-
|
|
78
|
-
match: { in_interface: "guests", out_interface: "home" },
|
|
79
|
-
action: "drop",
|
|
80
|
-
},
|
|
81
|
-
{ description: "Guests stay off management", match: { in_interface: "guests", out_interface: "mgmt" }, action: "drop" },
|
|
77
|
+
{ description: "Home to the internet", match: { in_interface: "home", out_interface: "1g-1" }, action: "accept" },
|
|
78
|
+
{ description: "Guests to the internet", match: { in_interface: "guests", out_interface: "1g-1" }, action: "accept" },
|
|
82
79
|
],
|
|
83
80
|
},
|
|
84
81
|
},
|