@takodotid/circuit 0.19.0 → 0.21.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 +2 -2
- package/dist/chunks/index-n4zpd25n.js +936 -0
- package/dist/chunks/{index-dczpxzzm.js → index-v8zgr7wm.js} +33 -14
- package/dist/chunks/{new-7n8qvtjd.js → new-ny89s7y7.js} +4 -6
- package/dist/chunks/server-zapfzhv4.js +244 -0
- package/dist/cli/index.js +855 -803
- package/dist/index.js +20 -21
- package/dist/presets/index.js +20 -21
- package/dist/types/adapters/devices/derive.d.ts +2 -0
- package/dist/types/core/across.d.ts +4 -0
- package/dist/types/core/addr.d.ts +10 -0
- package/dist/types/core/addresses.d.ts +46 -0
- package/dist/types/core/files.d.ts +6 -0
- package/dist/types/core/graph.d.ts +31 -1
- package/dist/types/core/schema-docs.d.ts +16 -0
- package/package.json +7 -7
- package/src/adapters/devices/derive.ts +32 -0
- package/src/adapters/devices/raisecom-ros/index.ts +23 -3
- package/src/adapters/devices/routeros/index.ts +10 -0
- package/src/adapters/devices/vrp/index.ts +10 -1
- package/src/cli/graph.ts +1 -15
- package/src/cli/index.ts +154 -20
- package/src/cli/rename.ts +129 -0
- package/src/core/across.ts +322 -0
- package/src/core/addr.ts +29 -0
- package/src/core/addresses.ts +316 -0
- package/src/core/files.ts +32 -0
- package/src/core/graph.ts +27 -6
- package/src/core/schema-docs.ts +166 -0
- package/src/core/validate.ts +2 -52
- package/src/studio/data.ts +255 -0
- package/src/studio/git.ts +119 -0
- package/src/studio/public/app.js +165 -0
- package/src/studio/public/compare.js +243 -0
- package/src/studio/public/diff.js +97 -0
- package/src/studio/public/dom.js +108 -0
- package/src/studio/public/evaluate.js +648 -0
- package/src/studio/public/export.js +122 -0
- package/src/studio/public/favicon.svg +12 -0
- package/src/studio/public/graph.js +983 -0
- package/src/studio/public/index.html +71 -0
- package/src/studio/public/net.js +128 -0
- package/src/studio/public/special-gothic-latin-ext.woff2 +0 -0
- package/src/studio/public/special-gothic-latin.woff2 +0 -0
- package/src/studio/public/special-gothic-license.txt +93 -0
- package/src/studio/public/style.css +1334 -0
- package/src/studio/public/tables.js +1223 -0
- package/src/studio/public/tools.js +578 -0
- package/src/studio/server.ts +219 -0
- package/templates/project/AGENTS.md +2 -0
- package/dist/chunks/index-fq57hya4.js +0 -20
- package/dist/chunks/studio-femhydvd.js +0 -764
- package/src/cli/studio-page.ts +0 -876
- package/src/cli/studio.ts +0 -126
package/src/cli/studio-page.ts
DELETED
|
@@ -1,876 +0,0 @@
|
|
|
1
|
-
/// <reference lib="dom" />
|
|
2
|
-
// The page `circuit studio` serves. `page` runs in the browser: the server sends its source, so it uses nothing from outside its own body but types.
|
|
3
|
-
|
|
4
|
-
import type { CarriedVlan, GraphEdge, GraphNode, Layer } from "../core/graph";
|
|
5
|
-
import type { Annotated } from "./graph";
|
|
6
|
-
|
|
7
|
-
export const STYLE = `
|
|
8
|
-
:root {
|
|
9
|
-
--bg: #f6f7f9;
|
|
10
|
-
--surface: #ffffff;
|
|
11
|
-
--line: #d7dbe0;
|
|
12
|
-
--text: #1d2228;
|
|
13
|
-
--muted: #5d6670;
|
|
14
|
-
--edge: #8a939c;
|
|
15
|
-
--accent: #2f6fdb;
|
|
16
|
-
--error: #d1242f;
|
|
17
|
-
--warning: #b07800;
|
|
18
|
-
--segment: #e9eef6;
|
|
19
|
-
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
20
|
-
--mono: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
|
|
21
|
-
}
|
|
22
|
-
@media (prefers-color-scheme: dark) {
|
|
23
|
-
:root {
|
|
24
|
-
--bg: #14171b;
|
|
25
|
-
--surface: #1c2026;
|
|
26
|
-
--line: #333a42;
|
|
27
|
-
--text: #e4e8ec;
|
|
28
|
-
--muted: #98a2ad;
|
|
29
|
-
--edge: #6c7680;
|
|
30
|
-
--accent: #6aa0ff;
|
|
31
|
-
--error: #ff6b6b;
|
|
32
|
-
--warning: #e3b341;
|
|
33
|
-
--segment: #232a33;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
* { box-sizing: border-box; }
|
|
37
|
-
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 14px/1.45 var(--font); }
|
|
38
|
-
body { display: flex; flex-direction: column; }
|
|
39
|
-
button { font: inherit; color: inherit; background: var(--surface); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer; }
|
|
40
|
-
button:hover { border-color: var(--accent); }
|
|
41
|
-
button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
42
|
-
header { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; padding: 10px 16px; background: var(--surface); border-bottom: 1px solid var(--line); }
|
|
43
|
-
header .brand { font-weight: 600; }
|
|
44
|
-
header nav, header .actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
45
|
-
header .count { opacity: 0.7; margin-left: 4px; font-size: 12px; }
|
|
46
|
-
header .summary { color: var(--muted); font-size: 13px; }
|
|
47
|
-
header .live { margin-left: auto; color: var(--muted); font-size: 12px; }
|
|
48
|
-
header .live::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--warning); margin-right: 6px; }
|
|
49
|
-
header .live.on::before { background: #2da44e; }
|
|
50
|
-
.banner { margin: 0; padding: 10px 16px; background: var(--error); color: #fff; white-space: pre-wrap; font: 12px/1.4 var(--mono); max-height: 30vh; overflow: auto; }
|
|
51
|
-
main { flex: 1; display: flex; min-height: 0; }
|
|
52
|
-
svg.canvas { flex: 1; min-width: 0; height: 100%; cursor: grab; touch-action: none; user-select: none; }
|
|
53
|
-
svg.canvas.panning { cursor: grabbing; }
|
|
54
|
-
aside { width: 340px; overflow: auto; padding: 14px 16px; background: var(--surface); border-left: 1px solid var(--line); }
|
|
55
|
-
aside h2 { font-size: 15px; margin: 0 0 4px; overflow-wrap: anywhere; }
|
|
56
|
-
aside h3 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 18px 0 6px; }
|
|
57
|
-
aside ul { list-style: none; margin: 0; padding: 0; }
|
|
58
|
-
aside li { padding: 3px 0; overflow-wrap: anywhere; }
|
|
59
|
-
aside .kind { color: var(--muted); font-size: 12px; }
|
|
60
|
-
aside pre { margin: 0; font: 12px/1.45 var(--mono); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--bg); padding: 8px; border-radius: 6px; }
|
|
61
|
-
aside a { color: var(--accent); cursor: pointer; text-decoration: none; }
|
|
62
|
-
aside a:hover { text-decoration: underline; }
|
|
63
|
-
aside .muted { color: var(--muted); }
|
|
64
|
-
.badge { display: inline-block; font-size: 11px; font-weight: 600; padding: 0 6px; border-radius: 4px; margin-right: 6px; color: #fff; }
|
|
65
|
-
.badge.error { background: var(--error); }
|
|
66
|
-
.badge.warning { background: var(--warning); }
|
|
67
|
-
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
68
|
-
.chips button { padding: 2px 8px; font-size: 12px; }
|
|
69
|
-
.legend li { display: flex; align-items: center; gap: 8px; }
|
|
70
|
-
.legend svg { flex: none; }
|
|
71
|
-
.edge path { fill: none; stroke: var(--edge); stroke-width: 1.6; }
|
|
72
|
-
.edge.one-sided path:not(.hit) { stroke: var(--warning); stroke-dasharray: 6 4; }
|
|
73
|
-
.edge text { font-size: 11px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 4px; stroke-linejoin: round; }
|
|
74
|
-
.edge .hit { stroke: transparent; stroke-width: 12; cursor: pointer; }
|
|
75
|
-
.edge.selected path:not(.hit), .edge.lit path:not(.hit) { stroke: var(--accent); stroke-width: 2.6; }
|
|
76
|
-
.edge.selected text, .edge.lit text { fill: var(--text); }
|
|
77
|
-
.node { cursor: pointer; }
|
|
78
|
-
.node rect { fill: var(--surface); stroke: var(--line); stroke-width: 1.5; }
|
|
79
|
-
.node.segment rect { fill: var(--segment); }
|
|
80
|
-
.node.outside rect { stroke-dasharray: 5 3; }
|
|
81
|
-
.node.error rect { stroke: var(--error); stroke-width: 3; }
|
|
82
|
-
.node.warning rect { stroke: var(--warning); stroke-width: 3; }
|
|
83
|
-
.node.selected rect { stroke: var(--accent); stroke-width: 3; }
|
|
84
|
-
.node text { fill: var(--text); font-size: 12px; text-anchor: middle; dominant-baseline: central; pointer-events: none; }
|
|
85
|
-
.node text.name { font-weight: 600; font-size: 13px; }
|
|
86
|
-
.node text.sub { fill: var(--muted); font-size: 11px; }
|
|
87
|
-
.node.segment text, .node.outside text { font-family: var(--mono); font-size: 11px; }
|
|
88
|
-
.dim { opacity: 0.18; }
|
|
89
|
-
svg.far .edge text { display: none; }
|
|
90
|
-
@media (max-width: 760px) {
|
|
91
|
-
main { flex-direction: column; }
|
|
92
|
-
aside { width: auto; height: 40%; border-left: 0; border-top: 1px solid var(--line); }
|
|
93
|
-
}
|
|
94
|
-
`;
|
|
95
|
-
|
|
96
|
-
/** Runs in the browser. */
|
|
97
|
-
export function page(): void {
|
|
98
|
-
type Point = { x: number; y: number };
|
|
99
|
-
type Data = Annotated & { error?: undefined };
|
|
100
|
-
type Failed = { error: string };
|
|
101
|
-
type Selection = { node: string } | { edge: GraphEdge } | undefined;
|
|
102
|
-
|
|
103
|
-
const LAYER_NAMES: Record<Layer, string> = {
|
|
104
|
-
l1: "Cables",
|
|
105
|
-
l2: "VLANs",
|
|
106
|
-
l3: "Addresses",
|
|
107
|
-
bgp: "BGP",
|
|
108
|
-
ospf: "OSPF",
|
|
109
|
-
tunnels: "Tunnels",
|
|
110
|
-
};
|
|
111
|
-
|
|
112
|
-
const LAYER_HELP: Record<Layer, string> = {
|
|
113
|
-
l1: "Each cable a port's link names. A LAG's cables are one line.",
|
|
114
|
-
l2: "The VLANs each cable carries. Pick a VLAN to light up every cable that carries it.",
|
|
115
|
-
l3: "Prefixes two or more devices hold an address in, and point-to-point addresses. Loopbacks and prefixes only one device holds are left out.",
|
|
116
|
-
bgp: "BGP sessions: between our devices, matched by the address each names, and to every AS outside.",
|
|
117
|
-
ospf: "OSPF adjacencies: devices with OSPF in one area on one prefix, neither side passive.",
|
|
118
|
-
tunnels: "GRE and VXLAN tunnels matched by their endpoints, and WireGuard peers.",
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
const LAYERS = Object.keys(LAYER_NAMES) as Layer[];
|
|
122
|
-
const SVG = "http://www.w3.org/2000/svg";
|
|
123
|
-
|
|
124
|
-
// Browser storage can be missing or refuse, in a private window for example. The page works without it.
|
|
125
|
-
const store = {
|
|
126
|
-
get<T>(key: string): T | undefined {
|
|
127
|
-
try {
|
|
128
|
-
const value = localStorage.getItem(`circuit-studio:${key}`);
|
|
129
|
-
return value === null ? undefined : (JSON.parse(value) as T);
|
|
130
|
-
} catch {
|
|
131
|
-
return undefined;
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
set(key: string, value: unknown): void {
|
|
135
|
-
try {
|
|
136
|
-
localStorage.setItem(`circuit-studio:${key}`, JSON.stringify(value));
|
|
137
|
-
} catch {}
|
|
138
|
-
},
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
/** An element with attributes and children. Text is always set as text, never parsed as HTML. */
|
|
142
|
-
function h<K extends keyof HTMLElementTagNameMap>(
|
|
143
|
-
tag: K,
|
|
144
|
-
attributes: Record<string, string> = {},
|
|
145
|
-
...children: (Node | string | undefined | false)[]
|
|
146
|
-
): HTMLElementTagNameMap[K] {
|
|
147
|
-
const element = document.createElement(tag);
|
|
148
|
-
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
|
|
149
|
-
for (const child of children) if (child !== undefined && child !== false) element.append(child);
|
|
150
|
-
return element;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function s<K extends keyof SVGElementTagNameMap>(tag: K, attributes: Record<string, string | number> = {}): SVGElementTagNameMap[K] {
|
|
154
|
-
const element = document.createElementNS(SVG, tag);
|
|
155
|
-
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, String(value));
|
|
156
|
-
return element;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function link(text: string, action: () => void): HTMLAnchorElement {
|
|
160
|
-
const anchor = h("a", { role: "button", tabindex: "0" }, text);
|
|
161
|
-
anchor.addEventListener("click", action);
|
|
162
|
-
anchor.addEventListener("keydown", (event) => event.key === "Enter" && action());
|
|
163
|
-
return anchor;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// The page's parts.
|
|
167
|
-
const layerButtons = new Map<Layer, HTMLButtonElement>();
|
|
168
|
-
const nav = h("nav", { "aria-label": "Layers" });
|
|
169
|
-
const summary = h("span", { class: "summary" });
|
|
170
|
-
const live = h("span", { class: "live" }, "connecting");
|
|
171
|
-
const fitButton = h("button", { title: "Fit the drawing to the window" }, "Fit");
|
|
172
|
-
const layoutButton = h("button", { title: "Forget where nodes were moved, and lay them out again" }, "Lay out again");
|
|
173
|
-
|
|
174
|
-
const banner = h("pre", { class: "banner", hidden: "" });
|
|
175
|
-
const svg = s("svg", { class: "canvas", role: "img", "aria-label": "The network" });
|
|
176
|
-
const viewport = s("g");
|
|
177
|
-
const edgeLayer = s("g");
|
|
178
|
-
const nodeLayer = s("g");
|
|
179
|
-
viewport.append(edgeLayer, nodeLayer);
|
|
180
|
-
svg.append(viewport);
|
|
181
|
-
const panel = h("aside");
|
|
182
|
-
|
|
183
|
-
document.body.append(
|
|
184
|
-
h(
|
|
185
|
-
"header",
|
|
186
|
-
{},
|
|
187
|
-
h("span", { class: "brand" }, "Circuit studio"),
|
|
188
|
-
nav,
|
|
189
|
-
h("div", { class: "actions" }, fitButton, layoutButton),
|
|
190
|
-
summary,
|
|
191
|
-
live
|
|
192
|
-
),
|
|
193
|
-
banner,
|
|
194
|
-
h("main", {}, svg, panel)
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
// State.
|
|
198
|
-
let data: Data | undefined;
|
|
199
|
-
// The layer named in the address, such as `#bgp`, or the one shown last.
|
|
200
|
-
const requested = [location.hash.slice(1), store.get<string>("layer")].find((name) => LAYERS.includes(name as Layer));
|
|
201
|
-
let layer = (requested ?? "l1") as Layer;
|
|
202
|
-
let selection: Selection;
|
|
203
|
-
let hovered: string | undefined;
|
|
204
|
-
let vlanFocus: number | undefined;
|
|
205
|
-
let view = { x: 0, y: 0, k: 1 };
|
|
206
|
-
|
|
207
|
-
const saved = store.get<Record<string, [number, number]>>("positions") ?? {};
|
|
208
|
-
const positions = new Map<string, Point>(Object.entries(saved).map(([id, [x, y]]) => [id, { x, y }]));
|
|
209
|
-
|
|
210
|
-
const nodeElements = new Map<string, SVGGElement>();
|
|
211
|
-
const edgeElements: {
|
|
212
|
-
edge: GraphEdge;
|
|
213
|
-
group: SVGGElement;
|
|
214
|
-
path: SVGPathElement;
|
|
215
|
-
hit: SVGPathElement;
|
|
216
|
-
label: SVGTextElement;
|
|
217
|
-
offset: number;
|
|
218
|
-
}[] = [];
|
|
219
|
-
|
|
220
|
-
function savePositions(): void {
|
|
221
|
-
store.set("positions", Object.fromEntries([...positions].map(([id, point]) => [id, [Math.round(point.x), Math.round(point.y)]])));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/** A number from 0 to 1 that is always the same for the same text, so a layout comes out the same each time. */
|
|
225
|
-
function hash(text: string): number {
|
|
226
|
-
let value = 2166136261;
|
|
227
|
-
for (const char of text) value = Math.imul(value ^ char.charCodeAt(0), 16777619);
|
|
228
|
-
return (value >>> 0) / 4294967296;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Place nodes with a force layout: every node pushes every other away, and each edge pulls its ends together. A node placed before keeps its place unless `fresh`, so the drawing does not jump when the config changes.
|
|
233
|
-
* Every layer is laid out together, so a device stays where it is when the layer changes.
|
|
234
|
-
*/
|
|
235
|
-
function layout(nodes: GraphNode[], edges: GraphEdge[], fresh: boolean): void {
|
|
236
|
-
if (fresh) positions.clear();
|
|
237
|
-
|
|
238
|
-
const fixed = new Set(nodes.filter((node) => positions.has(node.id)).map((node) => node.id));
|
|
239
|
-
const neighbors = new Map<string, Set<string>>();
|
|
240
|
-
|
|
241
|
-
for (const edge of edges) {
|
|
242
|
-
if (edge.from === edge.to) continue;
|
|
243
|
-
if (!neighbors.has(edge.from)) neighbors.set(edge.from, new Set());
|
|
244
|
-
if (!neighbors.has(edge.to)) neighbors.set(edge.to, new Set());
|
|
245
|
-
neighbors.get(edge.from)!.add(edge.to);
|
|
246
|
-
neighbors.get(edge.to)!.add(edge.from);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// A new node starts beside a neighbor already placed, or on a circle.
|
|
250
|
-
const radius = 60 * Math.sqrt(nodes.length + 1);
|
|
251
|
-
|
|
252
|
-
for (const node of nodes) {
|
|
253
|
-
if (positions.has(node.id)) continue;
|
|
254
|
-
|
|
255
|
-
const angle = hash(node.id) * Math.PI * 2;
|
|
256
|
-
const placed = [...(neighbors.get(node.id) ?? [])].map((id) => positions.get(id)).find(Boolean);
|
|
257
|
-
const distance = placed ? 90 : radius;
|
|
258
|
-
const origin = placed ?? { x: 0, y: 0 };
|
|
259
|
-
|
|
260
|
-
positions.set(node.id, { x: origin.x + Math.cos(angle) * distance, y: origin.y + Math.sin(angle) * distance });
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const moving = nodes.filter((node) => !fixed.has(node.id));
|
|
264
|
-
if (!moving.length) return;
|
|
265
|
-
|
|
266
|
-
const pairs = [...neighbors].flatMap(([from, tos]) => [...tos].filter((to) => from < to).map((to) => [from, to] as const));
|
|
267
|
-
const ideal = 230;
|
|
268
|
-
const rounds = fixed.size ? 200 : 500;
|
|
269
|
-
|
|
270
|
-
for (let round = 0; round < rounds; round++) {
|
|
271
|
-
const heat = 40 * (1 - round / rounds) + 1;
|
|
272
|
-
const push = new Map(nodes.map((node) => [node.id, { x: 0, y: 0 }]));
|
|
273
|
-
|
|
274
|
-
for (const [index, a] of nodes.entries()) {
|
|
275
|
-
for (const b of nodes.slice(index + 1)) {
|
|
276
|
-
const pa = positions.get(a.id)!;
|
|
277
|
-
const pb = positions.get(b.id)!;
|
|
278
|
-
let dx = pa.x - pb.x;
|
|
279
|
-
let dy = pa.y - pb.y;
|
|
280
|
-
|
|
281
|
-
// Two nodes in one place are pulled apart in a direction of their own.
|
|
282
|
-
if (dx === 0 && dy === 0) {
|
|
283
|
-
dx = hash(a.id + b.id) - 0.5;
|
|
284
|
-
dy = hash(b.id + a.id) - 0.5;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
// Nodes far apart no longer push, so parts that are not connected stay near each other.
|
|
288
|
-
const distance = Math.max(Math.hypot(dx, dy), 1);
|
|
289
|
-
if (distance > ideal * 3) continue;
|
|
290
|
-
|
|
291
|
-
const force = (ideal * ideal) / distance;
|
|
292
|
-
push.get(a.id)!.x += (dx / distance) * force;
|
|
293
|
-
push.get(a.id)!.y += (dy / distance) * force;
|
|
294
|
-
push.get(b.id)!.x -= (dx / distance) * force;
|
|
295
|
-
push.get(b.id)!.y -= (dy / distance) * force;
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
for (const [from, to] of pairs) {
|
|
300
|
-
const pa = positions.get(from);
|
|
301
|
-
const pb = positions.get(to);
|
|
302
|
-
if (!pa || !pb) continue;
|
|
303
|
-
|
|
304
|
-
const dx = pa.x - pb.x;
|
|
305
|
-
const dy = pa.y - pb.y;
|
|
306
|
-
const distance = Math.max(Math.hypot(dx, dy), 1);
|
|
307
|
-
const force = (distance * distance) / ideal;
|
|
308
|
-
push.get(from)!.x -= (dx / distance) * force;
|
|
309
|
-
push.get(from)!.y -= (dy / distance) * force;
|
|
310
|
-
push.get(to)!.x += (dx / distance) * force;
|
|
311
|
-
push.get(to)!.y += (dy / distance) * force;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
for (const node of moving) {
|
|
315
|
-
const point = positions.get(node.id)!;
|
|
316
|
-
const force = push.get(node.id)!;
|
|
317
|
-
|
|
318
|
-
// A pull to the middle keeps parts that are not connected from drifting away.
|
|
319
|
-
force.x -= point.x * 0.15;
|
|
320
|
-
force.y -= point.y * 0.15;
|
|
321
|
-
|
|
322
|
-
const length = Math.max(Math.hypot(force.x, force.y), 1);
|
|
323
|
-
const step = Math.min(length, heat);
|
|
324
|
-
point.x += (force.x / length) * step;
|
|
325
|
-
point.y += (force.y / length) * step;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
savePositions();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// What is drawn.
|
|
333
|
-
const visibleEdges = () => data?.edges.filter((edge) => edge.layer === layer) ?? [];
|
|
334
|
-
|
|
335
|
-
function visibleNodes(): GraphNode[] {
|
|
336
|
-
const ends = new Set(visibleEdges().flatMap((edge) => [edge.from, edge.to]));
|
|
337
|
-
return data?.nodes.filter((node) => node.kind === "device" || ends.has(node.id)) ?? [];
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const nodeById = (id: string) => data?.nodes.find((node) => node.id === id);
|
|
341
|
-
|
|
342
|
-
function markOf(node: GraphNode): "error" | "warning" | undefined {
|
|
343
|
-
if (!data || node.kind !== "device") return undefined;
|
|
344
|
-
|
|
345
|
-
const findings = data.findings.filter((finding) => finding.device === node.label);
|
|
346
|
-
if (findings.some((finding) => finding.level === "error")) return "error";
|
|
347
|
-
|
|
348
|
-
const drift = data.drift[node.label]?.status;
|
|
349
|
-
if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
|
|
350
|
-
|
|
351
|
-
return undefined;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function curve(edge: GraphEdge, offset: number): { d: string; label: Point } {
|
|
355
|
-
const a = positions.get(edge.from) ?? { x: 0, y: 0 };
|
|
356
|
-
const b = positions.get(edge.to) ?? { x: 0, y: 0 };
|
|
357
|
-
const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
358
|
-
|
|
359
|
-
const length = Math.max(Math.hypot(b.x - a.x, b.y - a.y), 1);
|
|
360
|
-
const normal = { x: -(b.y - a.y) / length, y: (b.x - a.x) / length };
|
|
361
|
-
const control = { x: mid.x + normal.x * offset * 2, y: mid.y + normal.y * offset * 2 };
|
|
362
|
-
|
|
363
|
-
return {
|
|
364
|
-
d: `M${a.x},${a.y} Q${control.x},${control.y} ${b.x},${b.y}`,
|
|
365
|
-
label: { x: mid.x + normal.x * offset, y: mid.y + normal.y * offset },
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Move what is drawn to where the nodes are now, without drawing it again. */
|
|
370
|
-
function place(): void {
|
|
371
|
-
viewport.setAttribute("transform", `translate(${view.x},${view.y}) scale(${view.k})`);
|
|
372
|
-
svg.classList.toggle("far", view.k < 0.4);
|
|
373
|
-
|
|
374
|
-
for (const [id, group] of nodeElements) {
|
|
375
|
-
const point = positions.get(id)!;
|
|
376
|
-
group.setAttribute("transform", `translate(${point.x},${point.y})`);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
for (const { edge, path, hit, label, offset } of edgeElements) {
|
|
380
|
-
const { d, label: at } = curve(edge, offset);
|
|
381
|
-
path.setAttribute("d", d);
|
|
382
|
-
hit.setAttribute("d", d);
|
|
383
|
-
label.setAttribute("x", String(at.x));
|
|
384
|
-
label.setAttribute("y", String(at.y));
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
function draw(): void {
|
|
389
|
-
edgeLayer.replaceChildren();
|
|
390
|
-
nodeLayer.replaceChildren();
|
|
391
|
-
nodeElements.clear();
|
|
392
|
-
edgeElements.length = 0;
|
|
393
|
-
|
|
394
|
-
const edges = visibleEdges();
|
|
395
|
-
|
|
396
|
-
// Edges between the same two nodes bend apart, so each stays readable.
|
|
397
|
-
const between = new Map<string, number>();
|
|
398
|
-
const pairOf = (edge: GraphEdge) => [edge.from, edge.to].sort().join("|");
|
|
399
|
-
for (const edge of edges) between.set(pairOf(edge), (between.get(pairOf(edge)) ?? 0) + 1);
|
|
400
|
-
const seen = new Map<string, number>();
|
|
401
|
-
|
|
402
|
-
for (const edge of edges) {
|
|
403
|
-
const pair = pairOf(edge);
|
|
404
|
-
const index = seen.get(pair) ?? 0;
|
|
405
|
-
seen.set(pair, index + 1);
|
|
406
|
-
|
|
407
|
-
// Bend in one direction whichever way round the edge was written.
|
|
408
|
-
const sign = edge.from < edge.to ? 1 : -1;
|
|
409
|
-
const offset = (index - ((between.get(pair) ?? 1) - 1) / 2) * 34 * sign;
|
|
410
|
-
|
|
411
|
-
const group = s("g", { class: `edge${edge.oneSided ? " one-sided" : ""}` });
|
|
412
|
-
const hit = s("path", { class: "hit" });
|
|
413
|
-
const path = s("path");
|
|
414
|
-
const label = s("text", { "text-anchor": "middle", "dominant-baseline": "central" });
|
|
415
|
-
label.textContent = edge.label;
|
|
416
|
-
|
|
417
|
-
group.append(path, hit, label);
|
|
418
|
-
|
|
419
|
-
// Selected on press: the canvas captures the pointer for panning, so no click would reach the edge.
|
|
420
|
-
group.addEventListener("pointerdown", (event) => {
|
|
421
|
-
event.stopPropagation();
|
|
422
|
-
select({ edge });
|
|
423
|
-
});
|
|
424
|
-
|
|
425
|
-
edgeLayer.append(group);
|
|
426
|
-
edgeElements.push({ edge, group, path, hit, label, offset });
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
for (const node of visibleNodes()) {
|
|
430
|
-
const group = s("g", { class: `node ${node.kind} ${markOf(node) ?? ""}`, tabindex: 0 });
|
|
431
|
-
const rect = s("rect", { rx: node.kind === "segment" ? 14 : 8 });
|
|
432
|
-
group.append(rect);
|
|
433
|
-
|
|
434
|
-
const lines = node.kind === "device" ? [node.label, node.detail[0] ?? ""] : [node.label];
|
|
435
|
-
const texts = lines.map((line, index) => {
|
|
436
|
-
const text = s("text", { class: index === 0 ? "name" : "sub", y: lines.length === 1 ? 0 : index === 0 ? -8 : 9 });
|
|
437
|
-
text.textContent = line;
|
|
438
|
-
group.append(text);
|
|
439
|
-
return text;
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
nodeLayer.append(group);
|
|
443
|
-
|
|
444
|
-
const width = Math.max(...texts.map((text) => text.getComputedTextLength())) + 24;
|
|
445
|
-
const height = lines.length === 1 ? 28 : 44;
|
|
446
|
-
rect.setAttribute("x", String(-width / 2));
|
|
447
|
-
rect.setAttribute("y", String(-height / 2));
|
|
448
|
-
rect.setAttribute("width", String(width));
|
|
449
|
-
rect.setAttribute("height", String(height));
|
|
450
|
-
|
|
451
|
-
group.addEventListener("pointerdown", (event) => startDrag(event, node.id));
|
|
452
|
-
group.addEventListener("pointerenter", () => {
|
|
453
|
-
hovered = node.id;
|
|
454
|
-
emphasize();
|
|
455
|
-
});
|
|
456
|
-
group.addEventListener("pointerleave", () => {
|
|
457
|
-
hovered = undefined;
|
|
458
|
-
emphasize();
|
|
459
|
-
});
|
|
460
|
-
group.addEventListener("keydown", (event) => event.key === "Enter" && select({ node: node.id }));
|
|
461
|
-
|
|
462
|
-
nodeElements.set(node.id, group);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
place();
|
|
466
|
-
emphasize();
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/** Light up what the pointer is on, or what is selected, and dim the rest. */
|
|
470
|
-
function emphasize(): void {
|
|
471
|
-
const focus = hovered ?? (selection && "node" in selection ? selection.node : undefined);
|
|
472
|
-
const selectedEdge = selection && "edge" in selection ? selection.edge : undefined;
|
|
473
|
-
|
|
474
|
-
const touching = (edge: GraphEdge) => edge.from === focus || edge.to === focus;
|
|
475
|
-
const carries = (edge: GraphEdge) => edge.vlans?.some((vlan) => vlan.id === vlanFocus) ?? false;
|
|
476
|
-
|
|
477
|
-
let litEdges: Set<GraphEdge> | undefined;
|
|
478
|
-
if (focus) litEdges = new Set(edgeElements.filter(({ edge }) => touching(edge)).map(({ edge }) => edge));
|
|
479
|
-
else if (vlanFocus !== undefined && layer === "l2")
|
|
480
|
-
litEdges = new Set(edgeElements.filter(({ edge }) => carries(edge)).map(({ edge }) => edge));
|
|
481
|
-
else if (selectedEdge) litEdges = new Set([selectedEdge]);
|
|
482
|
-
|
|
483
|
-
const litNodes = litEdges ? new Set([...litEdges].flatMap((edge) => [edge.from, edge.to])) : undefined;
|
|
484
|
-
if (focus) litNodes?.add(focus);
|
|
485
|
-
|
|
486
|
-
for (const { edge, group } of edgeElements) {
|
|
487
|
-
group.classList.toggle("lit", Boolean(litEdges?.has(edge)) && !selectedEdge);
|
|
488
|
-
group.classList.toggle("selected", edge === selectedEdge);
|
|
489
|
-
group.classList.toggle("dim", Boolean(litEdges) && !litEdges!.has(edge));
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
for (const [id, group] of nodeElements) {
|
|
493
|
-
group.classList.toggle("selected", Boolean(selection && "node" in selection && selection.node === id));
|
|
494
|
-
group.classList.toggle("dim", Boolean(litNodes) && !litNodes!.has(id));
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Panning, zooming and dragging.
|
|
499
|
-
let drag: { id?: string; start: Point; origin: Point; moved: boolean } | undefined;
|
|
500
|
-
|
|
501
|
-
const toWorld = (event: { clientX: number; clientY: number }): Point => {
|
|
502
|
-
const box = svg.getBoundingClientRect();
|
|
503
|
-
return { x: (event.clientX - box.left - view.x) / view.k, y: (event.clientY - box.top - view.y) / view.k };
|
|
504
|
-
};
|
|
505
|
-
|
|
506
|
-
function startDrag(event: PointerEvent, id?: string): void {
|
|
507
|
-
if (event.button !== 0) return;
|
|
508
|
-
event.stopPropagation();
|
|
509
|
-
|
|
510
|
-
svg.setPointerCapture(event.pointerId);
|
|
511
|
-
const origin = id ? { ...positions.get(id)! } : { x: view.x, y: view.y };
|
|
512
|
-
drag = { id, start: { x: event.clientX, y: event.clientY }, origin, moved: false };
|
|
513
|
-
if (!id) svg.classList.add("panning");
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
svg.addEventListener("pointerdown", (event) => startDrag(event));
|
|
517
|
-
|
|
518
|
-
svg.addEventListener("pointermove", (event) => {
|
|
519
|
-
if (!drag) return;
|
|
520
|
-
|
|
521
|
-
const dx = event.clientX - drag.start.x;
|
|
522
|
-
const dy = event.clientY - drag.start.y;
|
|
523
|
-
if (Math.hypot(dx, dy) > 3) drag.moved = true;
|
|
524
|
-
if (!drag.moved) return;
|
|
525
|
-
|
|
526
|
-
if (drag.id) positions.set(drag.id, { x: drag.origin.x + dx / view.k, y: drag.origin.y + dy / view.k });
|
|
527
|
-
else view = { ...view, x: drag.origin.x + dx, y: drag.origin.y + dy };
|
|
528
|
-
|
|
529
|
-
place();
|
|
530
|
-
});
|
|
531
|
-
|
|
532
|
-
svg.addEventListener("pointerup", () => {
|
|
533
|
-
if (!drag) return;
|
|
534
|
-
|
|
535
|
-
const ended = drag;
|
|
536
|
-
drag = undefined;
|
|
537
|
-
svg.classList.remove("panning");
|
|
538
|
-
|
|
539
|
-
if (ended.moved && ended.id) savePositions();
|
|
540
|
-
if (ended.moved) return;
|
|
541
|
-
|
|
542
|
-
// A press without a move is a click: on a node it selects the node, and on an empty spot it clears the selection.
|
|
543
|
-
select(ended.id ? { node: ended.id } : undefined);
|
|
544
|
-
});
|
|
545
|
-
|
|
546
|
-
svg.addEventListener(
|
|
547
|
-
"wheel",
|
|
548
|
-
(event) => {
|
|
549
|
-
event.preventDefault();
|
|
550
|
-
|
|
551
|
-
const at = toWorld(event);
|
|
552
|
-
const k = Math.min(Math.max(view.k * Math.exp(-event.deltaY * 0.0015), 0.15), 4);
|
|
553
|
-
const box = svg.getBoundingClientRect();
|
|
554
|
-
|
|
555
|
-
view = { k, x: event.clientX - box.left - at.x * k, y: event.clientY - box.top - at.y * k };
|
|
556
|
-
place();
|
|
557
|
-
},
|
|
558
|
-
{ passive: false }
|
|
559
|
-
);
|
|
560
|
-
|
|
561
|
-
function fit(): void {
|
|
562
|
-
const points = visibleNodes()
|
|
563
|
-
.map((node) => positions.get(node.id))
|
|
564
|
-
.filter((point): point is Point => Boolean(point));
|
|
565
|
-
if (!points.length) return;
|
|
566
|
-
|
|
567
|
-
const box = svg.getBoundingClientRect();
|
|
568
|
-
const xs = points.map((point) => point.x);
|
|
569
|
-
const ys = points.map((point) => point.y);
|
|
570
|
-
const width = Math.max(...xs) - Math.min(...xs) + 240;
|
|
571
|
-
const height = Math.max(...ys) - Math.min(...ys) + 160;
|
|
572
|
-
const k = Math.min(Math.max(Math.min(box.width / width, box.height / height), 0.15), 1.5);
|
|
573
|
-
|
|
574
|
-
const centre = { x: (Math.max(...xs) + Math.min(...xs)) / 2, y: (Math.max(...ys) + Math.min(...ys)) / 2 };
|
|
575
|
-
view = { k, x: box.width / 2 - centre.x * k, y: box.height / 2 - centre.y * k };
|
|
576
|
-
place();
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
fitButton.addEventListener("click", fit);
|
|
580
|
-
|
|
581
|
-
layoutButton.addEventListener("click", () => {
|
|
582
|
-
if (!data) return;
|
|
583
|
-
layout(data.nodes, data.edges, true);
|
|
584
|
-
draw();
|
|
585
|
-
fit();
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
// The side panel.
|
|
589
|
-
function select(next: Selection): void {
|
|
590
|
-
selection = next;
|
|
591
|
-
emphasize();
|
|
592
|
-
showPanel();
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
function findingList(findings: Annotated["findings"], withDevice: boolean): HTMLUListElement {
|
|
596
|
-
return h(
|
|
597
|
-
"ul",
|
|
598
|
-
{},
|
|
599
|
-
...findings.map((finding) =>
|
|
600
|
-
h(
|
|
601
|
-
"li",
|
|
602
|
-
{},
|
|
603
|
-
h("span", { class: `badge ${finding.level}` }, finding.level),
|
|
604
|
-
withDevice && finding.device ? link(finding.device, () => select({ node: `device:${finding.device}` })) : undefined,
|
|
605
|
-
withDevice && finding.device ? ": " : undefined,
|
|
606
|
-
finding.message
|
|
607
|
-
)
|
|
608
|
-
)
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function vlanChips(vlans: CarriedVlan[]): HTMLDivElement {
|
|
613
|
-
const chips = h("div", { class: "chips" });
|
|
614
|
-
|
|
615
|
-
for (const vlan of vlans) {
|
|
616
|
-
const button = h("button", { "aria-pressed": String(vlan.id === vlanFocus) }, `${vlan.name} ${vlan.id}`);
|
|
617
|
-
button.addEventListener("click", () => {
|
|
618
|
-
vlanFocus = vlanFocus === vlan.id ? undefined : vlan.id;
|
|
619
|
-
selection = undefined;
|
|
620
|
-
emphasize();
|
|
621
|
-
showPanel();
|
|
622
|
-
});
|
|
623
|
-
chips.append(button);
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
return chips;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
function legend(): HTMLUListElement {
|
|
630
|
-
const sample = (dashed: boolean, color: string) => {
|
|
631
|
-
const icon = s("svg", { width: 34, height: 10 });
|
|
632
|
-
icon.append(
|
|
633
|
-
s("line", { x1: 2, y1: 5, x2: 32, y2: 5, stroke: color, "stroke-width": 2, "stroke-dasharray": dashed ? "6 4" : "" })
|
|
634
|
-
);
|
|
635
|
-
return icon;
|
|
636
|
-
};
|
|
637
|
-
|
|
638
|
-
const box = (style: string) => {
|
|
639
|
-
const icon = s("svg", { width: 34, height: 16 });
|
|
640
|
-
icon.append(s("rect", { x: 2, y: 2, width: 30, height: 12, rx: 3, fill: "none", "stroke-width": 2, style }));
|
|
641
|
-
return icon;
|
|
642
|
-
};
|
|
643
|
-
|
|
644
|
-
return h(
|
|
645
|
-
"ul",
|
|
646
|
-
{ class: "legend" },
|
|
647
|
-
h("li", {}, sample(false, "var(--edge)"), "both ends declare it"),
|
|
648
|
-
h("li", {}, sample(true, "var(--warning)"), "only one end declares it"),
|
|
649
|
-
h("li", {}, box("stroke: var(--error)"), "a device with an error"),
|
|
650
|
-
h("li", {}, box("stroke: var(--warning)"), "a warning, or unlike its snapshot"),
|
|
651
|
-
h("li", {}, box("stroke: var(--line); stroke-dasharray: 5 3"), "outside the network")
|
|
652
|
-
);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
function showPanel(): void {
|
|
656
|
-
panel.replaceChildren();
|
|
657
|
-
if (!data) return;
|
|
658
|
-
|
|
659
|
-
if (selection && "node" in selection) {
|
|
660
|
-
const node = nodeById(selection.node);
|
|
661
|
-
if (node) return showNode(node);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (selection && "edge" in selection) return showEdge(selection.edge);
|
|
665
|
-
|
|
666
|
-
const devices = data.nodes.filter((node) => node.kind === "device");
|
|
667
|
-
const drifted = devices.filter((node) => data!.drift[node.label]?.status === "differs");
|
|
668
|
-
const unsnapped = devices.filter((node) => data!.drift[node.label]?.status === "no snapshot");
|
|
669
|
-
|
|
670
|
-
panel.append(h("h2", {}, LAYER_NAMES[layer]), h("p", { class: "muted" }, LAYER_HELP[layer]));
|
|
671
|
-
|
|
672
|
-
if (layer === "l2") {
|
|
673
|
-
const vlans = new Map<number, CarriedVlan>();
|
|
674
|
-
for (const edge of visibleEdges()) for (const vlan of edge.vlans ?? []) vlans.set(vlan.id, vlan);
|
|
675
|
-
|
|
676
|
-
const sorted = [...vlans.values()].sort((a, b) => a.id - b.id);
|
|
677
|
-
if (sorted.length) panel.append(h("h3", {}, "VLANs"), vlanChips(sorted));
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
if (data.findings.length) panel.append(h("h3", {}, "Found by validate"), findingList(data.findings, true));
|
|
681
|
-
|
|
682
|
-
if (drifted.length) {
|
|
683
|
-
panel.append(
|
|
684
|
-
h("h3", {}, "Unlike their last snapshot"),
|
|
685
|
-
h(
|
|
686
|
-
"ul",
|
|
687
|
-
{},
|
|
688
|
-
...drifted.map((node) =>
|
|
689
|
-
h(
|
|
690
|
-
"li",
|
|
691
|
-
{},
|
|
692
|
-
link(node.label, () => select({ node: node.id }))
|
|
693
|
-
)
|
|
694
|
-
)
|
|
695
|
-
)
|
|
696
|
-
);
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
if (unsnapped.length) {
|
|
700
|
-
panel.append(h("h3", {}, "No snapshot yet"), h("p", { class: "muted" }, unsnapped.map((node) => node.label).join(", ")));
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
panel.append(h("h3", {}, "Legend"), legend());
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function showNode(node: GraphNode): void {
|
|
707
|
-
const kinds = { device: "device", segment: "shared segment", outside: "outside the network" };
|
|
708
|
-
panel.append(h("h2", {}, node.label), h("div", { class: "kind" }, kinds[node.kind]));
|
|
709
|
-
|
|
710
|
-
if (node.detail.length) panel.append(h("h3", {}, "Details"), h("pre", {}, node.detail.join("\n")));
|
|
711
|
-
|
|
712
|
-
if (node.kind === "device" && data) {
|
|
713
|
-
const findings = data.findings.filter((finding) => finding.device === node.label);
|
|
714
|
-
if (findings.length) panel.append(h("h3", {}, "Found by validate"), findingList(findings, false));
|
|
715
|
-
|
|
716
|
-
const drift = data.drift[node.label];
|
|
717
|
-
if (drift) {
|
|
718
|
-
const notes = {
|
|
719
|
-
matches: "Matches its last snapshot.",
|
|
720
|
-
differs: "Differs from its last snapshot. This is what diff would send:",
|
|
721
|
-
"no snapshot": "No snapshot yet: run circuit snapshot.",
|
|
722
|
-
"cannot plan": "Could not be planned against its snapshot:",
|
|
723
|
-
};
|
|
724
|
-
|
|
725
|
-
panel.append(h("h3", {}, "Against its snapshot"), h("p", {}, notes[drift.status]));
|
|
726
|
-
if (drift.lines.length) panel.append(h("pre", {}, drift.lines.join("\n")));
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const edges = visibleEdges().filter((edge) => edge.from === node.id || edge.to === node.id);
|
|
731
|
-
|
|
732
|
-
panel.append(h("h3", {}, `${LAYER_NAMES[layer]} here`));
|
|
733
|
-
if (!edges.length) panel.append(h("p", { class: "muted" }, "None on this layer."));
|
|
734
|
-
|
|
735
|
-
panel.append(
|
|
736
|
-
h(
|
|
737
|
-
"ul",
|
|
738
|
-
{},
|
|
739
|
-
...edges.map((edge) => {
|
|
740
|
-
const other = nodeById(edge.from === node.id ? edge.to : edge.from);
|
|
741
|
-
return h(
|
|
742
|
-
"li",
|
|
743
|
-
{},
|
|
744
|
-
link(edge.label, () => select({ edge })),
|
|
745
|
-
h("span", { class: "muted" }, ` with ${other?.label ?? "?"}`)
|
|
746
|
-
);
|
|
747
|
-
})
|
|
748
|
-
)
|
|
749
|
-
);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
function showEdge(edge: GraphEdge): void {
|
|
753
|
-
const from = nodeById(edge.from);
|
|
754
|
-
const to = nodeById(edge.to);
|
|
755
|
-
|
|
756
|
-
panel.append(
|
|
757
|
-
h("h2", {}, edge.label),
|
|
758
|
-
h(
|
|
759
|
-
"div",
|
|
760
|
-
{ class: "kind" },
|
|
761
|
-
`${LAYER_NAMES[edge.layer]}, between `,
|
|
762
|
-
link(from?.label ?? edge.from, () => select({ node: edge.from })),
|
|
763
|
-
" and ",
|
|
764
|
-
link(to?.label ?? edge.to, () => select({ node: edge.to }))
|
|
765
|
-
)
|
|
766
|
-
);
|
|
767
|
-
|
|
768
|
-
if (edge.oneSided) panel.append(h("p", {}, h("span", { class: "badge warning" }, "one end"), "Only one end declares it."));
|
|
769
|
-
if (edge.detail.length) panel.append(h("h3", {}, "Details"), h("pre", {}, edge.detail.join("\n")));
|
|
770
|
-
if (edge.vlans?.length) panel.append(h("h3", {}, "VLANs"), vlanChips(edge.vlans));
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
function showHeader(): void {
|
|
774
|
-
nav.replaceChildren();
|
|
775
|
-
layerButtons.clear();
|
|
776
|
-
|
|
777
|
-
for (const [index, name] of LAYERS.entries()) {
|
|
778
|
-
const count = data?.edges.filter((edge) => edge.layer === name).length ?? 0;
|
|
779
|
-
const button = h(
|
|
780
|
-
"button",
|
|
781
|
-
{ "aria-pressed": String(name === layer), title: `${LAYER_HELP[name]} Key ${index + 1}.` },
|
|
782
|
-
LAYER_NAMES[name],
|
|
783
|
-
h("span", { class: "count" }, String(count))
|
|
784
|
-
);
|
|
785
|
-
button.addEventListener("click", () => chooseLayer(name));
|
|
786
|
-
nav.append(button);
|
|
787
|
-
layerButtons.set(name, button);
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
if (!data) return;
|
|
791
|
-
|
|
792
|
-
const devices = data.nodes.filter((node) => node.kind === "device").length;
|
|
793
|
-
const errors = data.findings.filter((finding) => finding.level === "error").length;
|
|
794
|
-
const warnings = data.findings.length - errors;
|
|
795
|
-
const drifted = Object.values(data.drift).filter((drift) => drift.status === "differs").length;
|
|
796
|
-
|
|
797
|
-
summary.textContent = [
|
|
798
|
-
`${devices} ${devices === 1 ? "device" : "devices"}`,
|
|
799
|
-
`${errors} ${errors === 1 ? "error" : "errors"}`,
|
|
800
|
-
`${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
|
|
801
|
-
`${drifted} unlike the snapshot`,
|
|
802
|
-
].join(" · ");
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
function chooseLayer(next: Layer): void {
|
|
806
|
-
layer = next;
|
|
807
|
-
store.set("layer", layer);
|
|
808
|
-
history.replaceState(null, "", `#${layer}`);
|
|
809
|
-
|
|
810
|
-
if (selection && "edge" in selection) selection = undefined;
|
|
811
|
-
vlanFocus = undefined;
|
|
812
|
-
|
|
813
|
-
showHeader();
|
|
814
|
-
draw();
|
|
815
|
-
showPanel();
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
document.addEventListener("keydown", (event) => {
|
|
819
|
-
if (event.target instanceof HTMLInputElement) return;
|
|
820
|
-
|
|
821
|
-
if (event.key === "Escape") select(undefined);
|
|
822
|
-
|
|
823
|
-
const chosen = LAYERS[Number(event.key) - 1];
|
|
824
|
-
if (chosen) chooseLayer(chosen);
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
// Loading the graph, at the start and whenever the server says the config changed.
|
|
828
|
-
async function load(): Promise<void> {
|
|
829
|
-
let next: Data | Failed;
|
|
830
|
-
|
|
831
|
-
try {
|
|
832
|
-
next = (await (await fetch("/graph", { cache: "no-store" })).json()) as Data | Failed;
|
|
833
|
-
} catch (error) {
|
|
834
|
-
next = { error: `could not reach circuit studio: ${(error as Error).message}` };
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
if (next.error !== undefined) {
|
|
838
|
-
banner.textContent = `The config could not be read, so the drawing is the last one that could:\n${next.error}`;
|
|
839
|
-
banner.hidden = false;
|
|
840
|
-
return;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
banner.hidden = true;
|
|
844
|
-
const first = !data;
|
|
845
|
-
data = next;
|
|
846
|
-
|
|
847
|
-
// A selection whose node or edge is gone is dropped.
|
|
848
|
-
if (selection && "node" in selection && !nodeById(selection.node)) selection = undefined;
|
|
849
|
-
if (selection && "edge" in selection) {
|
|
850
|
-
const { layer: was, from, to, label } = selection.edge;
|
|
851
|
-
const same = data.edges.find((edge) => edge.layer === was && edge.from === from && edge.to === to && edge.label === label);
|
|
852
|
-
selection = same ? { edge: same } : undefined;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
layout(data.nodes, data.edges, false);
|
|
856
|
-
showHeader();
|
|
857
|
-
draw();
|
|
858
|
-
showPanel();
|
|
859
|
-
if (first) fit();
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const events = new EventSource("/events");
|
|
863
|
-
events.addEventListener("open", () => {
|
|
864
|
-
live.textContent = "watching the config";
|
|
865
|
-
live.classList.add("on");
|
|
866
|
-
});
|
|
867
|
-
events.addEventListener("error", () => {
|
|
868
|
-
live.textContent = "not connected: is circuit studio still running?";
|
|
869
|
-
live.classList.remove("on");
|
|
870
|
-
});
|
|
871
|
-
events.addEventListener("message", () => void load());
|
|
872
|
-
|
|
873
|
-
window.addEventListener("resize", () => place());
|
|
874
|
-
|
|
875
|
-
void load();
|
|
876
|
-
}
|