@takodotid/circuit 0.18.5 → 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 (47) hide show
  1. package/README.md +18 -0
  2. package/dist/chunks/index-jtzfw679.js +936 -0
  3. package/dist/chunks/{index-ex1wcst7.js → index-xm9nma2t.js} +20 -1
  4. package/dist/chunks/server-5shmsmse.js +246 -0
  5. package/dist/cli/index.js +701 -106
  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 +74 -0
  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 +89 -0
  21. package/src/cli/index.ts +205 -2
  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 +634 -0
  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 +12 -9
  47. package/templates/project/README.md +1 -0
@@ -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();
@@ -0,0 +1,243 @@
1
+ // @ts-check
2
+ /// <reference lib="dom" />
3
+ // Comparing the network now with another branch, tag or commit: which devices changed and how their config differs, what the graph gains and loses, and what validate finds newly or no longer.
4
+
5
+ import { go, h, link } from "./dom.js";
6
+ import { compareNetworks } from "./evaluate.js";
7
+ import { LAYER_NAMES, setComparison } from "./graph.js";
8
+ import { deviceLink, empty, heading, lineDiff, prose, table } from "./tables.js";
9
+
10
+ /** @typedef {import("../data").StudioData} StudioData */
11
+ /** @typedef {import("../git").Commit} Commit */
12
+ /** @typedef {{ commit: Commit, network: StudioData & { error?: undefined }, error?: undefined } | { error: string }} Comparison */
13
+
14
+ /** Comparisons asked for, by the name asked for, kept while the page is open. @type {Map<string, Promise<Comparison>>} */
15
+ const asked = new Map();
16
+
17
+ /** @type {Promise<{ names: string[], commits: Commit[] } | { error: string }> | undefined} */
18
+ let references;
19
+
20
+ /**
21
+ * Read JSON from the server, or what went wrong.
22
+ *
23
+ * @template T
24
+ * @param {string} path
25
+ * @returns {Promise<T | { error: string }>}
26
+ */
27
+ export async function fetchJson(path) {
28
+ try {
29
+ return await (await fetch(path, { cache: "no-store" })).json();
30
+ } catch (error) {
31
+ return { error: error instanceof Error ? error.message : String(error) };
32
+ }
33
+ }
34
+
35
+ /**
36
+ * A commit in a line: its short name, subject, author and day.
37
+ *
38
+ * @param {Commit} commit
39
+ */
40
+ export const describeCommit = (commit) =>
41
+ `${commit.short}, ${commit.subject}, by ${commit.author} on ${new Date(commit.date).toLocaleDateString()}`;
42
+
43
+ /**
44
+ * The picker: a branch, tag or recent commit, or any name typed.
45
+ *
46
+ * @param {string | undefined} current
47
+ */
48
+ function picker(current) {
49
+ const input = h("input", { type: "text", value: current ?? "", placeholder: "main, a tag, or a commit", spellcheck: "false" });
50
+ const list = h("select", {}, h("option", { value: "" }, "Pick one…"));
51
+ list.addEventListener("change", () => {
52
+ if (list.value) go(`compare/${encodeURIComponent(list.value)}`);
53
+ });
54
+
55
+ references ??= fetchJson("refs");
56
+ void references.then((found) => {
57
+ if ("error" in found) return list.replaceChildren(h("option", { value: "" }, "No git history here"));
58
+ const names = found.names.map((name) => h("option", { value: name }, name));
59
+ const commits = found.commits.map((commit) => h("option", { value: commit.commit }, describeCommit(commit)));
60
+ list.append(h("optgroup", { label: "Branches and tags" }, ...names), h("optgroup", { label: "Latest commits" }, ...commits));
61
+ });
62
+
63
+ const compare = h("button", { type: "submit", class: "primary" }, "Compare");
64
+ compare.addEventListener("click", (event) => {
65
+ event.preventDefault();
66
+ if (input.value.trim()) go(`compare/${encodeURIComponent(input.value.trim())}`);
67
+ });
68
+
69
+ return h(
70
+ "form",
71
+ { class: "tool-form" },
72
+ h("label", { class: "field" }, h("span", {}, "Branch, tag or commit"), list),
73
+ h("label", { class: "field" }, h("span", {}, "Or a name"), input),
74
+ compare
75
+ );
76
+ }
77
+
78
+ /**
79
+ * @param {HTMLElement} area
80
+ * @param {string} name
81
+ * @param {Commit} commit
82
+ * @param {StudioData} before
83
+ * @param {StudioData} after
84
+ */
85
+ function showDifferences(area, name, commit, before, after) {
86
+ const found = compareNetworks(before, after);
87
+ const { devices, edges, findings } = found;
88
+ const nothing =
89
+ !devices.added.length && !devices.removed.length && !devices.changed.length && !edges.added.length && !edges.removed.length;
90
+
91
+ const layers = [...new Set([...edges.added, ...edges.removed].map((edge) => edge.layer))];
92
+ const showGraph = link("Show the changes in the graph", () => {
93
+ setComparison({ name, added: edges.added, removed: edges.removed });
94
+ go(`graph/${layers[0] ?? "l1"}`);
95
+ });
96
+
97
+ area.append(
98
+ h("p", {}, "Your files now, beside ", h("strong", {}, name), `: ${describeCommit(commit)}.`),
99
+ nothing
100
+ ? empty("Nothing differs: every device would run the same, and the graph is the same.")
101
+ : h(
102
+ "p",
103
+ {},
104
+ `${devices.changed.length} changed, ${devices.added.length} added and ${devices.removed.length} removed ${devices.changed.length + devices.added.length + devices.removed.length === 1 ? "device" : "devices"}; ${edges.added.length} ${edges.added.length === 1 ? "line" : "lines"} of the graph added and ${edges.removed.length} gone. `,
105
+ layers.length ? showGraph : undefined
106
+ )
107
+ );
108
+
109
+ if (devices.added.length || devices.removed.length) {
110
+ area.append(
111
+ heading("Devices added and removed"),
112
+ table({
113
+ head: ["Device", "Change"].map((label) => ({ label })),
114
+ rows: [
115
+ ...devices.added.map((device) => ({ cells: [deviceLink(device), "added"] })),
116
+ ...devices.removed.map((device) => ({ cells: [device, "removed"] })),
117
+ ],
118
+ })
119
+ );
120
+ }
121
+
122
+ if (devices.changed.length) {
123
+ area.append(
124
+ heading("Config that changes"),
125
+ prose(
126
+ "What each device would run, ",
127
+ h("span", { class: "removed" }, "then -"),
128
+ " and ",
129
+ h("span", { class: "added" }, "now +"),
130
+ "."
131
+ )
132
+ );
133
+
134
+ for (const device of devices.changed) {
135
+ area.append(
136
+ h(
137
+ "details",
138
+ { class: "change" },
139
+ h("summary", {}, device),
140
+ lineDiff(before.rendered[device] ?? "", after.rendered[device] ?? "")
141
+ )
142
+ );
143
+ }
144
+ }
145
+
146
+ if (edges.added.length || edges.removed.length) {
147
+ const nameOf = (/** @type {string} */ id) =>
148
+ [...after.graph.nodes, ...before.graph.nodes].find((node) => node.id === id)?.label ?? id;
149
+
150
+ area.append(
151
+ heading("The graph"),
152
+ table({
153
+ csv: `compare-${name}-graph`,
154
+ head: ["Layer", "Change", "What", "From", "To"].map((label) => ({ label })),
155
+ rows: [
156
+ ...edges.added.map((edge) => ({
157
+ cells: [
158
+ LAYER_NAMES[edge.layer],
159
+ h("span", { class: "added" }, "added"),
160
+ edge.label,
161
+ nameOf(edge.from),
162
+ nameOf(edge.to),
163
+ ],
164
+ })),
165
+ ...edges.removed.map((edge) => ({
166
+ cells: [
167
+ LAYER_NAMES[edge.layer],
168
+ h("span", { class: "removed" }, "gone"),
169
+ edge.label,
170
+ nameOf(edge.from),
171
+ nameOf(edge.to),
172
+ ],
173
+ })),
174
+ ],
175
+ })
176
+ );
177
+ }
178
+
179
+ if (findings.added.length || findings.resolved.length) {
180
+ area.append(
181
+ h("h3", {}, "Found by ", h("code", {}, "circuit validate")),
182
+ table({
183
+ head: ["", "Device", "Message"].map((label) => ({ label })),
184
+ rows: [
185
+ ...findings.added.map((one) => ({
186
+ cells: [h("span", { class: "removed" }, `new ${one.level}`), one.device ?? "the network", one.message],
187
+ })),
188
+ ...findings.resolved.map((one) => ({
189
+ cells: [h("span", { class: "added" }, `no longer`), one.device ?? "the network", one.message],
190
+ })),
191
+ ],
192
+ })
193
+ );
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Draw the comparison the address names, such as `["compare", "main"]`, into a container.
199
+ *
200
+ * @param {string[]} route
201
+ * @param {HTMLElement} container
202
+ * @param {StudioData} now
203
+ */
204
+ export function showCompare(route, container, now) {
205
+ container.replaceChildren();
206
+ const [, name] = route;
207
+
208
+ container.append(
209
+ heading("Compare", "h2"),
210
+ prose(
211
+ "Your files now, beside another branch, tag or commit: which devices change and how, what the graph gains and loses, and what ",
212
+ h("code", {}, "circuit validate"),
213
+ " finds newly or no longer. Studio reads the other commit with the Circuit installed now."
214
+ ),
215
+ picker(name)
216
+ );
217
+
218
+ if (!name) return;
219
+
220
+ const area = h("div", {}, empty(`Reading the network at ${name}…`));
221
+ container.append(area);
222
+
223
+ if (!asked.has(name)) asked.set(name, /** @type {Promise<Comparison>} */ (fetchJson(`compare?ref=${encodeURIComponent(name)}`)));
224
+ const shown = location.hash;
225
+
226
+ void asked.get(name)?.then((result) => {
227
+ // The reader may have gone elsewhere while it was read.
228
+ if (location.hash !== shown) return;
229
+ area.replaceChildren();
230
+
231
+ if (result.error !== undefined) {
232
+ asked.delete(name);
233
+ return area.append(empty(`It could not be read: ${result.error}`));
234
+ }
235
+
236
+ if (result.network.error !== undefined) {
237
+ asked.delete(name);
238
+ return area.append(empty(`Its config could not be read: ${result.network.error}`));
239
+ }
240
+
241
+ showDifferences(area, name, result.commit, result.network, now);
242
+ });
243
+ }
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ // A line diff, after Myers' "An O(ND) Difference Algorithm": the fewest lines added and removed that turn one text into another.
3
+
4
+ /** @typedef {{ kind: "same" | "removed" | "added", text: string }} DiffLine */
5
+
6
+ /** Past this many differences, two texts are too far apart to show line by line. */
7
+ const MOST_DIFFERENCES = 4000;
8
+
9
+ /**
10
+ * The lines of `before` and `after` in order, each marked as kept, removed or added. Undefined when they differ in more than a few thousand lines.
11
+ *
12
+ * @param {readonly string[]} before
13
+ * @param {readonly string[]} after
14
+ * @returns {DiffLine[] | undefined}
15
+ */
16
+ export function diffLines(before, after) {
17
+ const n = before.length;
18
+ const m = after.length;
19
+ const limit = Math.min(n + m, MOST_DIFFERENCES);
20
+ const offset = limit + 1;
21
+
22
+ // How far along `before` each diagonal reaches, and a copy of that for each number of differences, to walk back through.
23
+ const reach = new Int32Array(2 * offset + 1);
24
+ /** @type {Int32Array[]} */
25
+ const trace = [];
26
+
27
+ /**
28
+ * Where diagonal k reached after d differences.
29
+ *
30
+ * @param {number} d
31
+ * @param {number} k
32
+ */
33
+ const reached = (d, k) => /** @type {Int32Array} */ (trace[d])[k + d] ?? 0;
34
+
35
+ for (let d = 0; d <= limit; d++) {
36
+ for (let k = -d; k <= d; k += 2) {
37
+ const down = k === -d || (k !== d && (reach[k - 1 + offset] ?? 0) < (reach[k + 1 + offset] ?? 0));
38
+ let x = down ? (reach[k + 1 + offset] ?? 0) : (reach[k - 1 + offset] ?? 0) + 1;
39
+ let y = x - k;
40
+
41
+ while (x < n && y < m && before[x] === after[y]) {
42
+ x++;
43
+ y++;
44
+ }
45
+
46
+ reach[k + offset] = x;
47
+
48
+ if (x >= n && y >= m) {
49
+ trace.push(reach.slice(offset - d, offset + d + 1));
50
+ return walkBack(before, after, trace, reached);
51
+ }
52
+ }
53
+
54
+ trace.push(reach.slice(offset - d, offset + d + 1));
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * The edits, found by walking back from the end through what each step reached.
62
+ *
63
+ * @param {readonly string[]} before
64
+ * @param {readonly string[]} after
65
+ * @param {Int32Array[]} trace
66
+ * @param {(d: number, k: number) => number} reached
67
+ * @returns {DiffLine[]}
68
+ */
69
+ function walkBack(before, after, trace, reached) {
70
+ /** @type {DiffLine[]} */
71
+ const lines = [];
72
+ let x = before.length;
73
+ let y = after.length;
74
+
75
+ for (let d = trace.length - 1; d > 0; d--) {
76
+ const k = x - y;
77
+ const down = k === -d || (k !== d && reached(d - 1, k - 1) < reached(d - 1, k + 1));
78
+ const previousK = down ? k + 1 : k - 1;
79
+ const previousX = reached(d - 1, previousK);
80
+ const previousY = previousX - previousK;
81
+
82
+ while (x > previousX && y > previousY) {
83
+ lines.push({ kind: "same", text: before[--x] ?? "" });
84
+ y--;
85
+ }
86
+
87
+ if (down) lines.push({ kind: "added", text: after[--y] ?? "" });
88
+ else lines.push({ kind: "removed", text: before[--x] ?? "" });
89
+ }
90
+
91
+ while (x > 0 && y > 0) {
92
+ lines.push({ kind: "same", text: before[--x] ?? "" });
93
+ y--;
94
+ }
95
+
96
+ return lines.reverse();
97
+ }
@@ -0,0 +1,108 @@
1
+ // @ts-check
2
+ /// <reference lib="dom" />
3
+ // Small helpers every part of Studio's page uses: making elements, links that act, and browser storage that may be missing.
4
+
5
+ const SVG = "http://www.w3.org/2000/svg";
6
+
7
+ /** @typedef {Node | string | undefined | false} Child */
8
+
9
+ /**
10
+ * An element with attributes and children. Text is always set as text, never parsed as HTML.
11
+ *
12
+ * @template {keyof HTMLElementTagNameMap} K
13
+ * @param {K} tag
14
+ * @param {Record<string, string>} [attributes]
15
+ * @param {...Child} children
16
+ * @returns {HTMLElementTagNameMap[K]}
17
+ */
18
+ export function h(tag, attributes = {}, ...children) {
19
+ const element = document.createElement(tag);
20
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
21
+ for (const child of children) if (child !== undefined && child !== false) element.append(child);
22
+ return element;
23
+ }
24
+
25
+ /**
26
+ * An SVG element with attributes.
27
+ *
28
+ * @template {keyof SVGElementTagNameMap} K
29
+ * @param {K} tag
30
+ * @param {Record<string, string | number>} [attributes]
31
+ * @returns {SVGElementTagNameMap[K]}
32
+ */
33
+ export function s(tag, attributes = {}) {
34
+ const element = document.createElementNS(SVG, tag);
35
+ for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, String(value));
36
+ return element;
37
+ }
38
+
39
+ /**
40
+ * A link that runs an action, by mouse or keyboard.
41
+ *
42
+ * @param {string} text
43
+ * @param {() => void} action
44
+ */
45
+ export function link(text, action) {
46
+ const anchor = h("a", { role: "button", tabindex: "0" }, text);
47
+ anchor.addEventListener("click", action);
48
+ anchor.addEventListener("keydown", (event) => event.key === "Enter" && action());
49
+ return anchor;
50
+ }
51
+
52
+ /**
53
+ * The element with this id, which index.html always has.
54
+ *
55
+ * @param {string} id
56
+ */
57
+ export function byId(id) {
58
+ const element = document.getElementById(id);
59
+ if (!(element instanceof HTMLElement)) throw new Error(`index.html has no element ${id}`);
60
+ return element;
61
+ }
62
+
63
+ /**
64
+ * The SVG element with this id, which index.html always has.
65
+ *
66
+ * @param {string} id
67
+ */
68
+ export function svgById(id) {
69
+ const element = document.getElementById(id);
70
+ if (!(element instanceof SVGElement)) throw new Error(`index.html has no SVG element ${id}`);
71
+ return element;
72
+ }
73
+
74
+ /** Browser storage can be missing or refuse, in a private window for example. The page works without it. */
75
+ export const store = {
76
+ /**
77
+ * @template T
78
+ * @param {string} key
79
+ * @returns {T | undefined}
80
+ */
81
+ get(key) {
82
+ try {
83
+ const value = localStorage.getItem(`circuit-studio:${key}`);
84
+ return value === null ? undefined : /** @type {T} */ (JSON.parse(value));
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ },
89
+
90
+ /**
91
+ * @param {string} key
92
+ * @param {unknown} value
93
+ */
94
+ set(key, value) {
95
+ try {
96
+ localStorage.setItem(`circuit-studio:${key}`, JSON.stringify(value));
97
+ } catch {}
98
+ },
99
+ };
100
+
101
+ /**
102
+ * Go to a place in Studio, such as `graph/bgp` or `devices/edge-01/ports`. The place is the address's fragment, so the back button and a bookmark work.
103
+ *
104
+ * @param {string} route
105
+ */
106
+ export function go(route) {
107
+ location.hash = route;
108
+ }