@takodotid/circuit 0.19.0 → 0.20.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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-dczpxzzm.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +544 -487
  6. package/dist/index.js +1 -1
  7. package/dist/presets/index.js +1 -1
  8. package/dist/types/adapters/devices/derive.d.ts +2 -0
  9. package/dist/types/core/across.d.ts +4 -0
  10. package/dist/types/core/addr.d.ts +10 -0
  11. package/dist/types/core/addresses.d.ts +46 -0
  12. package/dist/types/core/files.d.ts +6 -0
  13. package/dist/types/core/graph.d.ts +31 -1
  14. package/dist/types/core/schema-docs.d.ts +16 -0
  15. package/package.json +1 -1
  16. package/src/adapters/devices/derive.ts +32 -0
  17. package/src/adapters/devices/raisecom-ros/index.ts +23 -3
  18. package/src/adapters/devices/routeros/index.ts +10 -0
  19. package/src/adapters/devices/vrp/index.ts +10 -1
  20. package/src/cli/graph.ts +1 -15
  21. package/src/cli/index.ts +154 -20
  22. package/src/cli/rename.ts +129 -0
  23. package/src/core/across.ts +322 -0
  24. package/src/core/addr.ts +29 -0
  25. package/src/core/addresses.ts +316 -0
  26. package/src/core/files.ts +30 -0
  27. package/src/core/graph.ts +27 -6
  28. package/src/core/schema-docs.ts +166 -0
  29. package/src/core/validate.ts +2 -52
  30. package/src/studio/data.ts +255 -0
  31. package/src/studio/git.ts +119 -0
  32. package/src/studio/public/app.js +147 -0
  33. package/src/studio/public/compare.js +243 -0
  34. package/src/studio/public/diff.js +97 -0
  35. package/src/studio/public/dom.js +108 -0
  36. package/src/studio/public/evaluate.js +648 -0
  37. package/src/studio/public/export.js +122 -0
  38. package/src/studio/public/favicon.svg +6 -0
  39. package/src/studio/public/graph.js +914 -0
  40. package/src/studio/public/index.html +67 -0
  41. package/src/studio/public/net.js +128 -0
  42. package/src/studio/public/style.css +693 -0
  43. package/src/studio/public/tables.js +1223 -0
  44. package/src/studio/public/tools.js +578 -0
  45. package/src/studio/server.ts +218 -0
  46. package/templates/project/AGENTS.md +2 -0
  47. package/dist/chunks/studio-femhydvd.js +0 -764
  48. package/src/cli/studio-page.ts +0 -876
  49. package/src/cli/studio.ts +0 -126
@@ -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,147 @@
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
+ summary.textContent = [
46
+ `${devices} ${devices === 1 ? "device" : "devices"}`,
47
+ `${errors} ${errors === 1 ? "error" : "errors"}`,
48
+ `${warnings} ${warnings === 1 ? "warning" : "warnings"}`,
49
+ `${drifted} unlike the snapshot`,
50
+ ].join(" · ");
51
+ }
52
+
53
+ /** Show the place the address names, such as `#graph/bgp` or `#devices/edge-01/ports`. */
54
+ function route() {
55
+ if (!studio) return;
56
+
57
+ const parts = location.hash.slice(1).split("/").map(decodeURIComponent).filter(Boolean);
58
+ const [view] = parts;
59
+
60
+ // An address from before the tables, such as `#bgp`, still opens that layer.
61
+ const isGraph = !view || view === "graph" || isLayer(view);
62
+ const asked = view === "graph" ? parts[1] : view;
63
+
64
+ const moved = location.hash !== shownRoute;
65
+ shownRoute = location.hash;
66
+
67
+ for (const button of views.querySelectorAll("button")) {
68
+ const pressed = isGraph ? button.dataset.view === "graph" : button.dataset.view === view;
69
+ button.setAttribute("aria-pressed", String(pressed));
70
+ }
71
+
72
+ graphView.hidden = !isGraph;
73
+ tablesView.hidden = isGraph;
74
+
75
+ if (view === "search") {
76
+ if (search.value !== (parts[1] ?? "")) search.value = parts[1] ?? "";
77
+ } else if (document.activeElement !== search) {
78
+ search.value = "";
79
+ }
80
+
81
+ showSummary();
82
+
83
+ if (isGraph) {
84
+ showLayer(isLayer(asked) ? asked : currentLayer());
85
+ return;
86
+ }
87
+
88
+ const scrolled = tables.scrollTop;
89
+ if (view === "tools") showTools(parts, tables, studio);
90
+ else if (view === "compare") showCompare(parts, tables, studio);
91
+ else showTables(parts, tables, studio);
92
+ tables.scrollTop = moved ? 0 : scrolled;
93
+ }
94
+
95
+ window.addEventListener("hashchange", route);
96
+
97
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
98
+ let searching;
99
+
100
+ search.addEventListener("input", () => {
101
+ clearTimeout(searching);
102
+
103
+ const query = search.value.trim();
104
+ searching = setTimeout(() => go(query ? `search/${encodeURIComponent(query)}` : "devices"), 200);
105
+ });
106
+
107
+ /** Load what Studio shows, at the start and whenever the server says the config changed. */
108
+ async function load() {
109
+ /** @type {(StudioData & { error?: undefined }) | { error: string }} */
110
+ let next;
111
+
112
+ try {
113
+ next = await (await fetch("data", { cache: "no-store" })).json();
114
+ } catch (error) {
115
+ next = { error: `could not reach circuit studio: ${error instanceof Error ? error.message : String(error)}` };
116
+ }
117
+
118
+ if (next.error !== undefined) {
119
+ banner.textContent = `The config could not be read, so what is shown is the last that could:\n${next.error}`;
120
+ banner.hidden = false;
121
+ return;
122
+ }
123
+
124
+ banner.hidden = true;
125
+ studio = next;
126
+ setGraph(next.graph);
127
+ route();
128
+ }
129
+
130
+ const events = new EventSource("events");
131
+
132
+ events.addEventListener("open", () => {
133
+ live.textContent = "live";
134
+ live.title = "Watching the config: a saved change shows here at once.";
135
+ live.classList.add("on");
136
+ });
137
+
138
+ events.addEventListener("error", () => {
139
+ live.textContent = "not connected";
140
+ live.title = "Circuit Studio is not answering. Is circuit studio still running?";
141
+ live.classList.remove("on");
142
+ });
143
+
144
+ // The config changed: load it again. The page itself changed, while working on Circuit: load the page again.
145
+ events.addEventListener("message", (event) => (event.data === "page" ? location.reload() : void load()));
146
+
147
+ void load();