@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/core/validate.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { X509Certificate } from "node:crypto";
|
|
|
4
4
|
import { adapters } from "../adapters/devices";
|
|
5
5
|
import { portsOf } from "../adapters/devices/catalog";
|
|
6
6
|
import type { Device } from "../schema";
|
|
7
|
+
import { checkAcross } from "./across";
|
|
7
8
|
import { contains, familyOf, isAddress, isCidr } from "./addr";
|
|
8
9
|
import type { Finding, Network } from "./define";
|
|
9
10
|
|
|
@@ -28,7 +29,7 @@ export function validate(network: Network): Finding[] {
|
|
|
28
29
|
else for (const field of adapter.unsupported(device)) report(`${device.platform} cannot express ${field}`);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
findings.push(...
|
|
32
|
+
findings.push(...checkAcross(network.devices));
|
|
32
33
|
for (const check of network.checks ?? []) findings.push(...check(network.devices));
|
|
33
34
|
return findings;
|
|
34
35
|
}
|
|
@@ -327,54 +328,3 @@ function checkCertificates(device: Device, names: Names, report: Report): void {
|
|
|
327
328
|
}
|
|
328
329
|
}
|
|
329
330
|
}
|
|
330
|
-
|
|
331
|
-
/** The VLAN ids a port carries, by how it carries them, so two ends can be compared across devices. */
|
|
332
|
-
function carried(device: Device, port: NonNullable<NonNullable<Device["ports"]>[string]>) {
|
|
333
|
-
const id = (vlan: string | undefined) => (vlan ? device.vlans?.[vlan]?.id : undefined);
|
|
334
|
-
return {
|
|
335
|
-
access: id(port.access_vlan),
|
|
336
|
-
native: id(port.native_vlan),
|
|
337
|
-
trunk: (port.trunk_vlans ?? []).map(id).sort((a, b) => (a ?? 0) - (b ?? 0)),
|
|
338
|
-
};
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** A link names a device and port that exist, the far end links back, and both ends carry the same VLANs the same way. */
|
|
342
|
-
function checkLinks(devices: readonly Device[]): Finding[] {
|
|
343
|
-
const findings: Finding[] = [];
|
|
344
|
-
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
345
|
-
|
|
346
|
-
for (const device of devices) {
|
|
347
|
-
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
348
|
-
const link = port?.link;
|
|
349
|
-
if (!port || !link) continue;
|
|
350
|
-
|
|
351
|
-
const report = (message: string) =>
|
|
352
|
-
findings.push({
|
|
353
|
-
level: "error",
|
|
354
|
-
device: device.name,
|
|
355
|
-
message: `port ${portName} link: ${message}`,
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const far = byName.get(link.device);
|
|
359
|
-
if (!far) {
|
|
360
|
-
report(`device ${link.device} is not in the network`);
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const farPort = far.ports?.[link.port];
|
|
365
|
-
if (!farPort) {
|
|
366
|
-
report(`${link.device} does not declare port ${link.port}`);
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const linksBack = farPort.link?.device === device.name && farPort.link.port === portName;
|
|
371
|
-
if (!linksBack) report(`${link.device} ${link.port} does not link back to it`);
|
|
372
|
-
|
|
373
|
-
const here = JSON.stringify(carried(device, port));
|
|
374
|
-
const there = JSON.stringify(carried(far, farPort));
|
|
375
|
-
if (here !== there) report(`carries ${here}, and ${link.device} ${link.port} carries ${there}`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
return findings;
|
|
380
|
-
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
// What Studio shows, gathered from the config in one pass: the graph with its findings and drift, each device as the config holds it, what each would run, the address plan, every BGP session and cable, and the schema's descriptions of each field.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { portsOf } from "../adapters/devices/catalog";
|
|
7
|
+
import { familyOf, host } from "../core/addr";
|
|
8
|
+
import { type AddressReport, addressesOf } from "../core/addresses";
|
|
9
|
+
import { type Annotated, type Holder, holdersOf, keyOf } from "../core/graph";
|
|
10
|
+
import { SCHEMA_FILES, schemaTypes } from "../core/schema-docs";
|
|
11
|
+
import type { Device } from "../schema";
|
|
12
|
+
|
|
13
|
+
/** One BGP session, with its group's settings filled in, and what is at its far end. */
|
|
14
|
+
export type Session = {
|
|
15
|
+
device: string;
|
|
16
|
+
name: string;
|
|
17
|
+
address: string;
|
|
18
|
+
group?: string;
|
|
19
|
+
remoteAs?: number;
|
|
20
|
+
role?: string;
|
|
21
|
+
import?: string;
|
|
22
|
+
export?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
/** One of our devices, by name, or another network, as `AS64501`. */
|
|
25
|
+
far?: string;
|
|
26
|
+
/** The far device has no session back. */
|
|
27
|
+
oneSided?: true;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** One cable, from the end that names it first. */
|
|
31
|
+
export type Cable = {
|
|
32
|
+
device: string;
|
|
33
|
+
port: string;
|
|
34
|
+
farDevice: string;
|
|
35
|
+
farPort: string;
|
|
36
|
+
/** The LAG each end's port is in. */
|
|
37
|
+
lag?: string;
|
|
38
|
+
farLag?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
/** VLANs carried, as `name (id)`, from the port or its LAG. */
|
|
41
|
+
vlans: string[];
|
|
42
|
+
/** The far end does not name this cable back. */
|
|
43
|
+
oneSided?: true;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** A schema type's description and its fields' descriptions, by field path. */
|
|
47
|
+
export type TypeDocs = {
|
|
48
|
+
description: string;
|
|
49
|
+
includes: string[];
|
|
50
|
+
fields: Record<string, { type: string; description: string }>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type StudioData = {
|
|
54
|
+
graph: Annotated;
|
|
55
|
+
devices: readonly Device[];
|
|
56
|
+
/** Every port each device's model has, in order, so Studio can show the ones the config leaves shut. */
|
|
57
|
+
ports: Record<string, string[]>;
|
|
58
|
+
/** What each device would run, as `circuit build` prints it, or why it cannot be rendered. */
|
|
59
|
+
rendered: Record<string, string>;
|
|
60
|
+
/** What each device ran when Circuit last read it, secrets removed, for the devices with a snapshot. */
|
|
61
|
+
snapshots: Record<string, string>;
|
|
62
|
+
/** Each device's file and snapshot, relative to the config, for its history. */
|
|
63
|
+
files: Record<string, DeviceFiles>;
|
|
64
|
+
addresses: AddressReport;
|
|
65
|
+
sessions: Session[];
|
|
66
|
+
cables: Cable[];
|
|
67
|
+
/** By type name. */
|
|
68
|
+
docs: Record<string, TypeDocs>;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function sessionsOf(devices: readonly Device[], owners: Map<string, Holder>): Session[] {
|
|
72
|
+
const all = devices.flatMap((device) => {
|
|
73
|
+
const bgp = device.routing?.bgp;
|
|
74
|
+
|
|
75
|
+
return Object.entries(bgp?.neighbors ?? {}).map(([name, neighbor]): Session & { deviceObject: Device } => {
|
|
76
|
+
const group = neighbor.group ? bgp?.groups?.[neighbor.group] : undefined;
|
|
77
|
+
const settings = { ...group, ...neighbor };
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
deviceObject: device,
|
|
81
|
+
device: device.name,
|
|
82
|
+
name,
|
|
83
|
+
address: neighbor.address,
|
|
84
|
+
group: neighbor.group,
|
|
85
|
+
remoteAs: settings.remote_as,
|
|
86
|
+
role: settings.role,
|
|
87
|
+
import: settings.import,
|
|
88
|
+
export: settings.export,
|
|
89
|
+
description: settings.description,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const ownerOf = (address: string) => owners.get(keyOf(address) ?? "")?.device;
|
|
95
|
+
|
|
96
|
+
return all.map(({ deviceObject, ...session }) => {
|
|
97
|
+
const far = ownerOf(session.address);
|
|
98
|
+
|
|
99
|
+
if (far && far !== deviceObject) {
|
|
100
|
+
// The session back is one of the same family: a v4 session back does not answer a v6 session.
|
|
101
|
+
const back = all.some(
|
|
102
|
+
(other) =>
|
|
103
|
+
other.deviceObject === far &&
|
|
104
|
+
ownerOf(other.address) === deviceObject &&
|
|
105
|
+
familyOf(other.address) === familyOf(session.address)
|
|
106
|
+
);
|
|
107
|
+
return { ...session, far: far.name, ...(back ? {} : { oneSided: true as const }) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { ...session, ...(session.remoteAs ? { far: `AS${session.remoteAs}` } : {}) };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cablesOf(devices: readonly Device[]): Cable[] {
|
|
115
|
+
const byName = new Map(devices.map((device) => [device.name, device]));
|
|
116
|
+
const drawn = new Set<string>();
|
|
117
|
+
const cables: Cable[] = [];
|
|
118
|
+
|
|
119
|
+
const vlansOf = (device: Device, portName: string) => {
|
|
120
|
+
const port = device.ports?.[portName];
|
|
121
|
+
const lag = port?.lag ? device.interfaces?.[port.lag] : undefined;
|
|
122
|
+
const carrier = lag?.type === "lag" ? lag : port;
|
|
123
|
+
const names = [carrier?.access_vlan, carrier?.native_vlan, ...(carrier?.trunk_vlans ?? [])].filter((name) => name !== undefined);
|
|
124
|
+
|
|
125
|
+
return [...new Set(names)].map((name) => `${name} (${device.vlans?.[name]?.id ?? "?"})`);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
for (const device of devices) {
|
|
129
|
+
for (const [portName, port] of Object.entries(device.ports ?? {})) {
|
|
130
|
+
const link = port?.link;
|
|
131
|
+
if (!port || !link) continue;
|
|
132
|
+
|
|
133
|
+
const here = `${device.name} ${portName}`;
|
|
134
|
+
const there = `${link.device} ${link.port}`;
|
|
135
|
+
if (drawn.has(`${there}|${here}`)) continue;
|
|
136
|
+
drawn.add(`${here}|${there}`);
|
|
137
|
+
|
|
138
|
+
const farPort = byName.get(link.device)?.ports?.[link.port];
|
|
139
|
+
const linksBack = farPort?.link?.device === device.name && farPort.link.port === portName;
|
|
140
|
+
|
|
141
|
+
cables.push({
|
|
142
|
+
device: device.name,
|
|
143
|
+
port: portName,
|
|
144
|
+
farDevice: link.device,
|
|
145
|
+
farPort: link.port,
|
|
146
|
+
...(port.lag ? { lag: port.lag } : {}),
|
|
147
|
+
...(farPort?.lag ? { farLag: farPort.lag } : {}),
|
|
148
|
+
...(port.description ? { description: port.description } : {}),
|
|
149
|
+
vlans: vlansOf(device, portName),
|
|
150
|
+
...(linksBack ? {} : { oneSided: true as const }),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return cables;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The directory Circuit is installed in: the nearest one up with its package.json. Its sources ship with it, in src: the schema, and the files of Studio's page. */
|
|
159
|
+
export function packageRoot(): string | undefined {
|
|
160
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
161
|
+
|
|
162
|
+
while (true) {
|
|
163
|
+
const manifest = join(directory, "package.json");
|
|
164
|
+
if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === "@takodotid/circuit") return directory;
|
|
165
|
+
|
|
166
|
+
const parent = dirname(directory);
|
|
167
|
+
if (parent === directory) return undefined;
|
|
168
|
+
directory = parent;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The schema's descriptions, or none when its sources cannot be found. */
|
|
173
|
+
function schemaDocs(): Record<string, TypeDocs> {
|
|
174
|
+
const root = packageRoot();
|
|
175
|
+
if (!root) return {};
|
|
176
|
+
|
|
177
|
+
const sources = SCHEMA_FILES.map(([file]) => readFileSync(join(root, "src", "schema", file), "utf8"));
|
|
178
|
+
const docs: Record<string, TypeDocs> = {};
|
|
179
|
+
|
|
180
|
+
for (const type of schemaTypes(sources).flat()) {
|
|
181
|
+
docs[type.name] = {
|
|
182
|
+
description: type.description,
|
|
183
|
+
includes: (type.includes ?? []).map((name) => name.replace(/<.*$/, "")),
|
|
184
|
+
fields: Object.fromEntries(
|
|
185
|
+
type.fields.map((field) => [field.path.replace(/\?$/, ""), { type: field.type, description: field.description }])
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return docs;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Where a device is kept, relative to the config: the file that declares it, and its snapshot. Either is absent when it cannot be found. */
|
|
194
|
+
export type DeviceFiles = { source?: string; snapshot?: string };
|
|
195
|
+
|
|
196
|
+
/** How Studio learns what a device would run and what it ran, from the caller, which knows the adapters and the state directory. */
|
|
197
|
+
export type DeviceTexts = {
|
|
198
|
+
/** What the device would run, as `circuit build` prints it. */
|
|
199
|
+
render: (device: Device) => string;
|
|
200
|
+
/** Its last snapshot, or undefined when it has none. */
|
|
201
|
+
snapshot: (device: Device) => string | undefined;
|
|
202
|
+
/** Its file and its snapshot's file. */
|
|
203
|
+
files: (device: Device) => DeviceFiles;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Everything Studio shows. `graph` comes ready from the caller, with each device compared with its snapshot. */
|
|
207
|
+
export function studioData(devices: readonly Device[], graph: Annotated, texts: DeviceTexts): StudioData {
|
|
208
|
+
const owners = new Map<string, Holder>();
|
|
209
|
+
for (const holder of holdersOf(devices)) {
|
|
210
|
+
const key = keyOf(host(holder.address))!;
|
|
211
|
+
if (!owners.has(key)) owners.set(key, holder);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const attempt = <T>(work: () => T, otherwise: (error: Error) => T) => {
|
|
215
|
+
try {
|
|
216
|
+
return work();
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return otherwise(error as Error);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
graph,
|
|
224
|
+
devices,
|
|
225
|
+
ports: Object.fromEntries(
|
|
226
|
+
devices.map((device) => [
|
|
227
|
+
device.name,
|
|
228
|
+
attempt(
|
|
229
|
+
() => portsOf(device.platform, device.model),
|
|
230
|
+
() => []
|
|
231
|
+
),
|
|
232
|
+
])
|
|
233
|
+
),
|
|
234
|
+
rendered: Object.fromEntries(
|
|
235
|
+
devices.map((device) => [
|
|
236
|
+
device.name,
|
|
237
|
+
attempt(
|
|
238
|
+
() => texts.render(device),
|
|
239
|
+
(error) => `Cannot be rendered: ${error.message}`
|
|
240
|
+
),
|
|
241
|
+
])
|
|
242
|
+
),
|
|
243
|
+
snapshots: Object.fromEntries(
|
|
244
|
+
devices.flatMap((device) => {
|
|
245
|
+
const text = texts.snapshot(device);
|
|
246
|
+
return text === undefined ? [] : [[device.name, text]];
|
|
247
|
+
})
|
|
248
|
+
),
|
|
249
|
+
files: Object.fromEntries(devices.map((device) => [device.name, texts.files(device)])),
|
|
250
|
+
addresses: addressesOf(devices),
|
|
251
|
+
sessions: sessionsOf(devices, owners),
|
|
252
|
+
cables: cablesOf(devices),
|
|
253
|
+
docs: schemaDocs(),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Git, for Studio's history and comparisons: the commits that changed a device, and the network as it was at another commit or branch.
|
|
2
|
+
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/** One commit, as Studio lists it. */
|
|
8
|
+
export type Commit = {
|
|
9
|
+
commit: string;
|
|
10
|
+
short: string;
|
|
11
|
+
author: string;
|
|
12
|
+
/** ISO 8601, with the author's time zone. */
|
|
13
|
+
date: string;
|
|
14
|
+
subject: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The environment without git's own variables, such as GIT_DIR in a hook, which would point git at another repository than the directory's. */
|
|
18
|
+
const environment = () => Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith("GIT_")));
|
|
19
|
+
|
|
20
|
+
/** Run git in a directory, and give what it printed, or fail with what it said. */
|
|
21
|
+
function git(cwd: string, args: string[]): Promise<string> {
|
|
22
|
+
return new Promise((done, fail) => {
|
|
23
|
+
execFile("git", args, { cwd, env: environment(), maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
24
|
+
if (error) fail(new Error((stderr || error.message).trim()));
|
|
25
|
+
else done(stdout);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FORMAT = "--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s";
|
|
31
|
+
|
|
32
|
+
function commitsOf(text: string): Commit[] {
|
|
33
|
+
return text
|
|
34
|
+
.split("\n")
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.map((line) => {
|
|
37
|
+
const [commit = "", short = "", author = "", date = "", subject = ""] = line.split("\x1f");
|
|
38
|
+
return { commit, short, author, date, subject };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The top of the git repository the directory is in, or undefined when it is in none, or git is missing. */
|
|
43
|
+
export async function gitTop(directory: string): Promise<string | undefined> {
|
|
44
|
+
try {
|
|
45
|
+
return (await git(directory, ["rev-parse", "--show-toplevel"])).trim();
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The latest commits that changed any of these files, relative to the directory, newest first. */
|
|
52
|
+
export async function history(directory: string, files: string[], limit = 50): Promise<Commit[]> {
|
|
53
|
+
if (!files.length) return [];
|
|
54
|
+
return commitsOf(await git(directory, ["log", `-n${limit}`, FORMAT, "--", ...files]));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Branches and tags to compare with, and the latest commits. */
|
|
58
|
+
export async function references(directory: string): Promise<{ names: string[]; commits: Commit[] }> {
|
|
59
|
+
const names = (
|
|
60
|
+
await git(directory, [
|
|
61
|
+
"for-each-ref",
|
|
62
|
+
"--count=100",
|
|
63
|
+
"--sort=-committerdate",
|
|
64
|
+
"--format=%(refname:short)",
|
|
65
|
+
"refs/heads",
|
|
66
|
+
"refs/remotes",
|
|
67
|
+
"refs/tags",
|
|
68
|
+
])
|
|
69
|
+
)
|
|
70
|
+
.split("\n")
|
|
71
|
+
.filter((name) => name && !name.endsWith("/HEAD"));
|
|
72
|
+
|
|
73
|
+
return { names, commits: commitsOf(await git(directory, ["log", "-n30", FORMAT])) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A branch, tag or commit, as the commit it names, or undefined when it names none. Only the characters a git name can hold are accepted. */
|
|
77
|
+
export async function commitOf(directory: string, name: string): Promise<Commit | undefined> {
|
|
78
|
+
if (!/^[A-Za-z0-9][\w./@^~-]*$/.test(name)) return undefined;
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const [commit] = commitsOf(await git(directory, ["log", "-n1", FORMAT, `${name}^{commit}`, "--"]));
|
|
82
|
+
return commit;
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Read the network as it was at a commit: check that commit out beside the project, in `.circuit/cache/`, which git ignores, gather it there with `gather`, then remove the checkout. The config is the file of that name in the same directory, `configDirectory`.
|
|
90
|
+
*
|
|
91
|
+
* The checkout sits inside the project, so its imports of Circuit resolve to the version installed now.
|
|
92
|
+
*/
|
|
93
|
+
export async function networkAt(
|
|
94
|
+
configDirectory: string,
|
|
95
|
+
configPath: string,
|
|
96
|
+
commit: string,
|
|
97
|
+
gather: (configPath: string) => Promise<string>
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const top = await gitTop(configDirectory);
|
|
100
|
+
if (!top) throw new Error("the config is not in a git repository");
|
|
101
|
+
|
|
102
|
+
const cache = join(configDirectory, ".circuit", "cache");
|
|
103
|
+
mkdirSync(cache, { recursive: true });
|
|
104
|
+
writeFileSync(join(cache, ".gitignore"), "# Written by Circuit Studio: checkouts of other commits, removed once read.\n*\n");
|
|
105
|
+
|
|
106
|
+
// A checkout left behind by a run that stopped is forgotten first.
|
|
107
|
+
await git(top, ["worktree", "prune"]);
|
|
108
|
+
|
|
109
|
+
const checkout = join(cache, commit.slice(0, 12));
|
|
110
|
+
await git(top, ["worktree", "add", "--detach", "--force", checkout, commit]);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
// Where the config sits inside the repository, as git says: comparing paths would trip on two spellings of one directory, such as Windows' short names.
|
|
114
|
+
const prefix = (await git(configDirectory, ["rev-parse", "--show-prefix"])).trim();
|
|
115
|
+
return await gather(join(checkout, prefix, basename(configPath)));
|
|
116
|
+
} finally {
|
|
117
|
+
await git(top, ["worktree", "remove", "--force", checkout]).catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/// <reference lib="dom" />
|
|
3
|
+
// Studio's page: loads what the server gathered from the config, shows the place the address names, and loads again whenever the server says the config changed.
|
|
4
|
+
|
|
5
|
+
import { byId, go } from "./dom.js";
|
|
6
|
+
import { currentLayer, isLayer, setGraph, showLayer } from "./graph.js";
|
|
7
|
+
import { problemCount, showTables } from "./tables.js";
|
|
8
|
+
import { showTools } from "./tools.js";
|
|
9
|
+
import { showCompare } from "./compare.js";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("../data").StudioData} StudioData */
|
|
12
|
+
|
|
13
|
+
const views = byId("views");
|
|
14
|
+
const search = /** @type {HTMLInputElement} */ (byId("search"));
|
|
15
|
+
const summary = byId("summary");
|
|
16
|
+
const live = byId("live");
|
|
17
|
+
const banner = byId("banner");
|
|
18
|
+
const graphView = byId("graph-view");
|
|
19
|
+
const tablesView = byId("tables-view");
|
|
20
|
+
const tables = byId("tables");
|
|
21
|
+
const problems = byId("problem-count");
|
|
22
|
+
|
|
23
|
+
/** What the server gathered last. @type {StudioData | undefined} */
|
|
24
|
+
let studio;
|
|
25
|
+
/** The address last shown, so drawing the same place again keeps where the reader had scrolled to. */
|
|
26
|
+
let shownRoute = "";
|
|
27
|
+
|
|
28
|
+
for (const button of views.querySelectorAll("button")) {
|
|
29
|
+
const view = button.dataset.view ?? "graph";
|
|
30
|
+
button.addEventListener("click", () => go(view === "graph" ? `graph/${currentLayer()}` : view));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function showSummary() {
|
|
34
|
+
if (!studio) return;
|
|
35
|
+
|
|
36
|
+
const { graph } = studio;
|
|
37
|
+
const devices = studio.devices.length;
|
|
38
|
+
const errors = graph.findings.filter((finding) => finding.level === "error").length;
|
|
39
|
+
const warnings = graph.findings.length - errors;
|
|
40
|
+
const drifted = Object.values(graph.drift).filter((drift) => drift.status === "differs").length;
|
|
41
|
+
|
|
42
|
+
const count = problemCount(studio);
|
|
43
|
+
problems.textContent = count ? String(count) : "";
|
|
44
|
+
|
|
45
|
+
const items = [
|
|
46
|
+
{ value: devices, label: devices === 1 ? "device" : "devices", state: "neutral" },
|
|
47
|
+
{ value: errors, label: errors === 1 ? "error" : "errors", state: errors ? "error" : "quiet" },
|
|
48
|
+
{ value: warnings, label: warnings === 1 ? "warning" : "warnings", state: warnings ? "warning" : "quiet" },
|
|
49
|
+
{ value: drifted, label: "unlike the snapshot", state: drifted ? "changed" : "quiet" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
summary.replaceChildren(
|
|
53
|
+
...items.map(({ value, label, state }) => {
|
|
54
|
+
const item = document.createElement("span");
|
|
55
|
+
item.className = `summary-item ${state}`;
|
|
56
|
+
item.append(String(value), ` ${label}`);
|
|
57
|
+
return item;
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Show the place the address names, such as `#graph/bgp` or `#devices/edge-01/ports`. */
|
|
63
|
+
function route() {
|
|
64
|
+
if (!studio) return;
|
|
65
|
+
|
|
66
|
+
const parts = location.hash.slice(1).split("/").map(decodeURIComponent).filter(Boolean);
|
|
67
|
+
const [view] = parts;
|
|
68
|
+
|
|
69
|
+
// An address from before the tables, such as `#bgp`, still opens that layer.
|
|
70
|
+
const isGraph = !view || view === "graph" || isLayer(view);
|
|
71
|
+
const asked = view === "graph" ? parts[1] : view;
|
|
72
|
+
|
|
73
|
+
const moved = location.hash !== shownRoute;
|
|
74
|
+
shownRoute = location.hash;
|
|
75
|
+
|
|
76
|
+
for (const button of views.querySelectorAll("button")) {
|
|
77
|
+
const pressed = isGraph ? button.dataset.view === "graph" : button.dataset.view === view;
|
|
78
|
+
button.setAttribute("aria-pressed", String(pressed));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
graphView.hidden = !isGraph;
|
|
82
|
+
tablesView.hidden = isGraph;
|
|
83
|
+
|
|
84
|
+
if (view === "search") {
|
|
85
|
+
if (search.value !== (parts[1] ?? "")) search.value = parts[1] ?? "";
|
|
86
|
+
} else if (document.activeElement !== search) {
|
|
87
|
+
search.value = "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
showSummary();
|
|
91
|
+
|
|
92
|
+
if (isGraph) {
|
|
93
|
+
showLayer(isLayer(asked) ? asked : currentLayer());
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const scrolled = tables.scrollTop;
|
|
98
|
+
if (view === "tools") showTools(parts, tables, studio);
|
|
99
|
+
else if (view === "compare") showCompare(parts, tables, studio);
|
|
100
|
+
else showTables(parts, tables, studio);
|
|
101
|
+
tables.scrollTop = moved ? 0 : scrolled;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
window.addEventListener("hashchange", route);
|
|
105
|
+
|
|
106
|
+
// A menu closes once one of its items is chosen, or when the reader clicks anywhere else.
|
|
107
|
+
document.addEventListener("click", (event) => {
|
|
108
|
+
const target = event.target instanceof Element ? event.target : undefined;
|
|
109
|
+
|
|
110
|
+
for (const menu of document.querySelectorAll("details.menu[open]")) {
|
|
111
|
+
if (!target || !menu.contains(target) || target.closest(".menu-items button")) menu.removeAttribute("open");
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
116
|
+
let searching;
|
|
117
|
+
|
|
118
|
+
search.addEventListener("input", () => {
|
|
119
|
+
clearTimeout(searching);
|
|
120
|
+
|
|
121
|
+
const query = search.value.trim();
|
|
122
|
+
searching = setTimeout(() => go(query ? `search/${encodeURIComponent(query)}` : "devices"), 200);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/** Load what Studio shows, at the start and whenever the server says the config changed. */
|
|
126
|
+
async function load() {
|
|
127
|
+
/** @type {(StudioData & { error?: undefined }) | { error: string }} */
|
|
128
|
+
let next;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
next = await (await fetch("data", { cache: "no-store" })).json();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
next = { error: `could not reach circuit studio: ${error instanceof Error ? error.message : String(error)}` };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (next.error !== undefined) {
|
|
137
|
+
banner.textContent = `The config could not be read, so what is shown is the last that could:\n${next.error}`;
|
|
138
|
+
banner.hidden = false;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
banner.hidden = true;
|
|
143
|
+
studio = next;
|
|
144
|
+
setGraph(next.graph);
|
|
145
|
+
route();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const events = new EventSource("events");
|
|
149
|
+
|
|
150
|
+
events.addEventListener("open", () => {
|
|
151
|
+
live.textContent = "live";
|
|
152
|
+
live.title = "Watching the config: a saved change shows here at once.";
|
|
153
|
+
live.classList.add("on");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
events.addEventListener("error", () => {
|
|
157
|
+
live.textContent = "not connected";
|
|
158
|
+
live.title = "Circuit Studio is not answering. Is circuit studio still running?";
|
|
159
|
+
live.classList.remove("on");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The config changed: load it again. The page itself changed, while working on Circuit: load the page again.
|
|
163
|
+
events.addEventListener("message", (event) => (event.data === "page" ? location.reload() : void load()));
|
|
164
|
+
|
|
165
|
+
void load();
|