dsh-ensp 0.2.1 → 0.3.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 +4 -0
- package/docs/ENSP-FORMAT.md +15 -0
- package/package.json +1 -1
- package/src/core/acceptance.mjs +20 -1
- package/src/core/console.mjs +81 -4
- package/src/core/index.mjs +4 -1
- package/src/core/launcher.mjs +15 -5
- package/src/core/models.mjs +77 -0
- package/src/core/topology.mjs +29 -6
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
生成 eNSP 可直接打开并正常显示的 `.topo` 文件。支持内置场景,也可自定义设备与链路。
|
|
27
27
|
|
|
28
|
+
支持的机型来自 `src/core/models.mjs` 的一张表,其中 16 个机型各有测过的接口布局。`Router` 和 `Client` 是教学实验里常见的两个老机型:eNSP 自带的 40 个样例里一次都没出现,所以早期版本的表漏掉了它们,而实际课程作业里它们占了 43/190 台设备。解析拓扑时,端口名优先按设备自己声明的 `<interface>` 块算——实验里加装串口卡之后,扁平索引 3 在同一型号上可以是 `Serial0/0/0`,也可以不是。
|
|
29
|
+
|
|
28
30
|
```js
|
|
29
31
|
import { twoPcLab, writeTopology } from 'dsh-ensp'
|
|
30
32
|
|
|
@@ -199,6 +201,8 @@ accepted: 2/2 checks passed
|
|
|
199
201
|
|
|
200
202
|
约 30 个场景有专门写的断言(OSPF 要 `Full`、BGP 要 `Established`、VRRP 要有 `Master`/`Backup`、MPLS VPN 要有 `Established` 与 VPN 路由表),其余场景回退成“把场景里提到的验证命令都跑一遍”的冒烟检查。判定逻辑在 `src/core/acceptance.mjs` 的 `EXPECTATIONS`,断言支持 `contains`、`notContains`、`matches`、`minLines`。对应的工具是 `ensp_verify`,`ensp_scenario` 会告诉某个场景有没有专门断言。
|
|
201
203
|
|
|
204
|
+
断言里的设备名按角色对应到你实际的拓扑:蓝图叫 `R1`/`R2`,而下载来的课程作业可能叫 `RA`/`RB`/`RC`,校验时会取第一台有控制台的设备(`R2` 取第二台),不会因为名字不同就报“找不到设备”。
|
|
205
|
+
|
|
202
206
|
断言不通过时会自己重试,因为路由协议是在命令返回之后才收敛的。`ensp apply` 一结束就验收,OSPF 邻居通常还在 `ExStart` 或 `2-Way`,只取一次快照会把它报成坏的实验。默认给 60 秒(`--wait` 改),通过时会注明等了多久:
|
|
203
207
|
|
|
204
208
|
```console
|
package/docs/ENSP-FORMAT.md
CHANGED
|
@@ -95,6 +95,21 @@ shows an empty canvas with **no error message**.
|
|
|
95
95
|
| `MCS` | `Ethernet/Ethernet×1` | 1 | 1 |
|
|
96
96
|
| `Cloud` | `Ethernet/Ethernet×3`, `Ethernet/GE×0`, `Serial/Serial×0` | 3 | 4 |
|
|
97
97
|
| `FRSW` | `Serial/Serial×16` | 16 | 1 |
|
|
98
|
+
| `Router` | `Ethernet/Ethernet×2`, `Ethernet/GE×4`, `Serial/Serial×4` | 10 | 43 |
|
|
99
|
+
| `Client` | `Ethernet/Ethernet×1` | 1 | 4 |
|
|
100
|
+
|
|
101
|
+
`Router` and `Client` were counted on the 18-topology teaching corpus (190 devices,
|
|
102
|
+
185 links) rather than on the bundled samples, where they never appear. Both are
|
|
103
|
+
`isplugin="0"` in `res/items.xml`, so they ship with every eNSP install; the
|
|
104
|
+
`Router` entry does say `eNSP don't maintained this device any more, just keep it.`
|
|
105
|
+
The CLI name order was confirmed on a live device: `display interface brief` lists
|
|
106
|
+
`Ethernet0/0/0..1`, `GigabitEthernet0/0/0..3`, `Serial0/0/0..3`, `NULL0`.
|
|
107
|
+
|
|
108
|
+
> **A device may add cards, so read its own declarations.** A NAT lab in that
|
|
109
|
+
> corpus mounts a serial card on an AR2220: flat index 3 is `Serial0/0/0` there and
|
|
110
|
+
> `GigabitEthernet0/0/3` on a stock unit. `parseTopology` therefore builds the port
|
|
111
|
+
> list from each `<dev>`'s `<interface>` block and only falls back to the model
|
|
112
|
+
> table when a file declares nothing.
|
|
98
113
|
|
|
99
114
|
> **`PC` declares `interfacename="GE"`, not `"Ethernet"`.** An earlier version of
|
|
100
115
|
> this project wrote `Ethernet` for PCs on the theory that a PC has one Ethernet
|
package/package.json
CHANGED
package/src/core/acceptance.mjs
CHANGED
|
@@ -169,6 +169,25 @@ export function evaluateAssertion(output, expect = {}) {
|
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Resolve a curated check's device name against the devices that actually exist.
|
|
174
|
+
*
|
|
175
|
+
* Curated plans name devices the way the shipped blueprints do: R1, R2, R3. A lab
|
|
176
|
+
* built from somebody else's topology calls them RA/RB/RC, LSW1/LSW2, or anything
|
|
177
|
+
* at all. Reporting `no console port for device "R1"` on a healthy lab is worse
|
|
178
|
+
* than useless, so fall back to the device that plays the same role: the number in
|
|
179
|
+
* the name picks the position (R1 first, R2 second), and a name without a number
|
|
180
|
+
* picks the first console device.
|
|
181
|
+
*/
|
|
182
|
+
export function resolveDevice(on, available = []) {
|
|
183
|
+
const names = [...available].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }))
|
|
184
|
+
if (!names.length) return on
|
|
185
|
+
if (names.includes(on)) return on
|
|
186
|
+
const numbered = /(\d+)$/.exec(on)
|
|
187
|
+
const index = numbered ? Math.max(0, Number(numbered[1]) - 1) : 0
|
|
188
|
+
return names[Math.min(index, names.length - 1)]
|
|
189
|
+
}
|
|
190
|
+
|
|
172
191
|
/**
|
|
173
192
|
* Build the acceptance plan for a scenario.
|
|
174
193
|
*
|
|
@@ -185,7 +204,7 @@ export function planFor(id, { devices } = {}) {
|
|
|
185
204
|
scenario: id,
|
|
186
205
|
title: scenario.title,
|
|
187
206
|
source: 'curated',
|
|
188
|
-
checks: curated.map((c) => ({ ...c, kind: 'assert' })),
|
|
207
|
+
checks: curated.map((c) => ({ ...c, on: resolveDevice(c.on, devices), kind: 'assert' })),
|
|
189
208
|
}
|
|
190
209
|
}
|
|
191
210
|
const first = (devices && devices.length && devices[0]) || scenario.devices?.split(/[、,,\s]+/)[0]
|
package/src/core/console.mjs
CHANGED
|
@@ -36,6 +36,18 @@ const ERROR_RE =
|
|
|
36
36
|
/** A `[Y/N]`-style confirmation waiting for an answer. */
|
|
37
37
|
const CONFIRM_RE = /(?:\[Y\/N\]|\(y\/n\)|\[y\/n\]|continue\?|Are you sure)/i
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* VRP's pager marker. `screen-length 0 temporary` normally turns paging off, but
|
|
41
|
+
* it is a user-view command: issued from a sub-view it is rejected, and on some
|
|
42
|
+
* builds it is rejected even in user view. When the pager is still on, a long
|
|
43
|
+
* `display` command stops at this marker and the rest of the output is simply
|
|
44
|
+
* missing — which reads as "the configuration is not there" when it is.
|
|
45
|
+
*/
|
|
46
|
+
const PAGER_RE = /---- More ----\s*$/
|
|
47
|
+
|
|
48
|
+
/** Cap the number of pages we will advance for one command. */
|
|
49
|
+
const MAX_PAGES = 200
|
|
50
|
+
|
|
39
51
|
const VIEW = {
|
|
40
52
|
user: 'user',
|
|
41
53
|
system: 'system',
|
|
@@ -91,6 +103,50 @@ export function hostnameOf(prompt) {
|
|
|
91
103
|
return inner
|
|
92
104
|
}
|
|
93
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Does this transcript end on a pager stop rather than a prompt?
|
|
108
|
+
*
|
|
109
|
+
* Exported because "the output stops at `---- More ----`" is the difference
|
|
110
|
+
* between "the device has nothing more to say" and "we are about to lose the rest
|
|
111
|
+
* of the answer", and that distinction deserves a test of its own.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} text
|
|
114
|
+
* @returns {boolean}
|
|
115
|
+
*/
|
|
116
|
+
export function hasPagerMarker(text) {
|
|
117
|
+
return PAGER_RE.test(String(text ?? '').trimEnd())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Remove pager markers from a transcript collected across several pages.
|
|
122
|
+
*
|
|
123
|
+
* VRP prints `---- More ----` where the page broke and then continues on the next
|
|
124
|
+
* page. The continuation is kept as its own line: page breaks land on line
|
|
125
|
+
* boundaries in practice, and a spurious break inside a line is harmless, while
|
|
126
|
+
* gluing two lines together would corrupt `^keyword` style assertions.
|
|
127
|
+
*
|
|
128
|
+
* This is only a fallback: {@link ConsoleSession#prepare} normally turns paging
|
|
129
|
+
* off before any output is read.
|
|
130
|
+
*
|
|
131
|
+
* @param {string} text
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function stitchPages(text) {
|
|
135
|
+
const out = []
|
|
136
|
+
for (const line of text.split('\n')) {
|
|
137
|
+
const m = line.match(/^(.*?)---- More ----(.*)$/)
|
|
138
|
+
if (!m) {
|
|
139
|
+
out.push(line)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
const head = m[1].trimEnd()
|
|
143
|
+
if (head) out.push(head)
|
|
144
|
+
const tail = m[2].trimStart()
|
|
145
|
+
if (tail) out.push(tail)
|
|
146
|
+
}
|
|
147
|
+
return out.join('\n')
|
|
148
|
+
}
|
|
149
|
+
|
|
94
150
|
/** Strip ANSI escapes and trailing whitespace from a transcript chunk. */
|
|
95
151
|
function clean(text) {
|
|
96
152
|
return text
|
|
@@ -191,7 +247,9 @@ export class ConsoleSession {
|
|
|
191
247
|
const text = clean(this.#buf)
|
|
192
248
|
// A confirmation question wins over a stale prompt: answer it explicitly.
|
|
193
249
|
if (CONFIRM_RE.test(text.split('\n').slice(-3).join('\n'))) break
|
|
194
|
-
|
|
250
|
+
// A pager stop is a terminal state too: nothing more will arrive until we
|
|
251
|
+
// send a key, so waiting for a prompt here would burn the whole timeout.
|
|
252
|
+
if (PROMPT_RE.test(text) || PAGER_RE.test(text.trimEnd())) {
|
|
195
253
|
const before = this.#buf.length
|
|
196
254
|
await this.#waitUntil(() => this.#buf.length !== before || this.#closed, settleMs)
|
|
197
255
|
if (this.#buf.length === before) break
|
|
@@ -199,7 +257,7 @@ export class ConsoleSession {
|
|
|
199
257
|
// Poll the buffer rather than sleeping a fixed slice: most commands reply
|
|
200
258
|
// in a few milliseconds, and waiting 200 ms per line dominates the runtime
|
|
201
259
|
// of a 300-line config.
|
|
202
|
-
await this.#waitUntil(() => PROMPT_RE.test(clean(this.#buf)) || this.#closed, 25)
|
|
260
|
+
await this.#waitUntil(() => PROMPT_RE.test(clean(this.#buf)) || PAGER_RE.test(clean(this.#buf).trimEnd()) || this.#closed, 25)
|
|
203
261
|
}
|
|
204
262
|
}
|
|
205
263
|
const text = clean(this.#buf)
|
|
@@ -260,6 +318,20 @@ export class ConsoleSession {
|
|
|
260
318
|
output += await this.#readToPrompt(timeoutMs)
|
|
261
319
|
}
|
|
262
320
|
|
|
321
|
+
// Walk the pager to the end. A page ends at `---- More ----` with no prompt,
|
|
322
|
+
// so the command is not finished and the remaining output would be lost.
|
|
323
|
+
let pages = 0
|
|
324
|
+
while (PAGER_RE.test(output.trimEnd()) && pages < MAX_PAGES && !this.#closed) {
|
|
325
|
+
pages += 1
|
|
326
|
+
this.#buf = ''
|
|
327
|
+
this.writeRaw(' ')
|
|
328
|
+
output += await this.#readToPrompt(timeoutMs)
|
|
329
|
+
}
|
|
330
|
+
// The marker sits inline where the page broke: VRP prints `---- More ----`
|
|
331
|
+
// followed by the rest of the line it interrupted. Stitch that tail back onto
|
|
332
|
+
// the previous line so the caller sees what paging disabled would have shown.
|
|
333
|
+
if (pages > 0) output = stitchPages(output)
|
|
334
|
+
|
|
263
335
|
const errorMatch = output.match(ERROR_RE)
|
|
264
336
|
const prompt = this.#prompt
|
|
265
337
|
return {
|
|
@@ -311,10 +383,15 @@ export class ConsoleSession {
|
|
|
311
383
|
|
|
312
384
|
/**
|
|
313
385
|
* Enable command paging off so long `display` output arrives in one piece.
|
|
314
|
-
*
|
|
386
|
+
*
|
|
387
|
+
* Failures are non-fatal on purpose: `screen-length 0 temporary` is a user-view
|
|
388
|
+
* command and is rejected from a sub-view, and some builds reject it outright.
|
|
389
|
+
* `exec` pages through `---- More ----` stops on its own, so the only cost of a
|
|
390
|
+
* rejection here is a few extra round trips.
|
|
315
391
|
*/
|
|
316
392
|
async prepare() {
|
|
317
|
-
await this.
|
|
393
|
+
if (!this.#prompt) await this.prime()
|
|
394
|
+
if (this.view === VIEW.user) await this.exec('screen-length 0 temporary', { timeoutMs: 8000 })
|
|
318
395
|
return this
|
|
319
396
|
}
|
|
320
397
|
|
package/src/core/index.mjs
CHANGED
|
@@ -16,6 +16,8 @@ export {
|
|
|
16
16
|
flatIndexToVrp,
|
|
17
17
|
portCount,
|
|
18
18
|
describePorts,
|
|
19
|
+
portsFromDeclarations,
|
|
20
|
+
declarationsFor,
|
|
19
21
|
} from './models.mjs'
|
|
20
22
|
|
|
21
23
|
// Kept for consumers written against the old shape; the table behind it was
|
|
@@ -76,13 +78,14 @@ export {
|
|
|
76
78
|
EXPECTATIONS,
|
|
77
79
|
evaluateAssertion,
|
|
78
80
|
planFor,
|
|
81
|
+
resolveDevice,
|
|
79
82
|
hasChecks,
|
|
80
83
|
consolePorts,
|
|
81
84
|
runAcceptance,
|
|
82
85
|
formatAcceptance,
|
|
83
86
|
} from './acceptance.mjs'
|
|
84
87
|
|
|
85
|
-
export { ConsoleSession, openConsole, probeConsole, viewOf, hostnameOf } from './console.mjs'
|
|
88
|
+
export { ConsoleSession, openConsole, probeConsole, viewOf, hostnameOf, hasPagerMarker } from './console.mjs'
|
|
86
89
|
|
|
87
90
|
export {
|
|
88
91
|
enspPid,
|
package/src/core/launcher.mjs
CHANGED
|
@@ -814,8 +814,15 @@ export async function startAllDevices({
|
|
|
814
814
|
}
|
|
815
815
|
|
|
816
816
|
report({ phase: 'calibrate', hwnd })
|
|
817
|
-
//
|
|
818
|
-
//
|
|
817
|
+
// Bringing the window forward and photographing it are the same problem seen
|
|
818
|
+
// twice: calibration reads pixels off the screen, so it has to happen while
|
|
819
|
+
// eNSP is actually in front. Focus first, then ask who owns the toolbar point.
|
|
820
|
+
// Doing it the other way round reports the window that was on top a moment ago
|
|
821
|
+
// (usually the terminal that launched us) as if it were a permanent blocker.
|
|
822
|
+
report({ phase: 'focus', hwnd })
|
|
823
|
+
let focused = focusEnsp(hwnd)
|
|
824
|
+
if (focused) await new Promise((resolve) => setTimeout(resolve, 300))
|
|
825
|
+
|
|
819
826
|
const occlusion = checkOcclusion(hwnd)
|
|
820
827
|
if (occlusion.occluded) {
|
|
821
828
|
throw new Error(
|
|
@@ -843,9 +850,12 @@ export async function startAllDevices({
|
|
|
843
850
|
}
|
|
844
851
|
report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
|
|
845
852
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
if (!focused)
|
|
853
|
+
// The window was raised before calibration; re-raise it in case photographing
|
|
854
|
+
// the screen or the terminal regaining focus pushed it back down.
|
|
855
|
+
if (!focused) {
|
|
856
|
+
focused = focusEnsp(hwnd)
|
|
857
|
+
if (!focused) report({ phase: 'focus-failed', hwnd })
|
|
858
|
+
}
|
|
849
859
|
|
|
850
860
|
report({ phase: 'click', button: 'start', anchor: anchors.start })
|
|
851
861
|
const click = clickToolbar(hwnd, { button: 'start', anchors })
|
package/src/core/models.mjs
CHANGED
|
@@ -76,6 +76,29 @@ export const MODEL_SPECS = {
|
|
|
76
76
|
vrp: 'GigabitEthernet0/0/',
|
|
77
77
|
console: true,
|
|
78
78
|
},
|
|
79
|
+
// Lab builds from teaching material (the 18 ICVE topologies) use the legacy
|
|
80
|
+
// `Router` model far more than any AR: 43 of 190 devices. Its port list is two
|
|
81
|
+
// fast Ethernet, four gigabit, four serial ports, and the CLI keeps the same
|
|
82
|
+
// order — verified by booting one and reading `display interface brief`.
|
|
83
|
+
// `Router` and `Client` are `isplugin="0"` in eNSP's items.xml, so they are
|
|
84
|
+
// built in; a machine "missing" them is not out of date.
|
|
85
|
+
Router: {
|
|
86
|
+
groups: [
|
|
87
|
+
['Ethernet', 'Ethernet', 2, 0, 'Ethernet0/0/'],
|
|
88
|
+
['Ethernet', 'GE', 4, 0],
|
|
89
|
+
['Serial', 'Serial', 4, 0, 'Serial0/0/'],
|
|
90
|
+
],
|
|
91
|
+
base: 0,
|
|
92
|
+
vrp: 'Ethernet0/0/',
|
|
93
|
+
console: true,
|
|
94
|
+
},
|
|
95
|
+
// A web client: one Ethernet port, no console.
|
|
96
|
+
Client: {
|
|
97
|
+
groups: [['Ethernet', 'Ethernet', 1, 0, 'Ethernet0/0/']],
|
|
98
|
+
base: 0,
|
|
99
|
+
vrp: 'Ethernet0/0/',
|
|
100
|
+
console: false,
|
|
101
|
+
},
|
|
79
102
|
// PC and Server declare `interfacename="GE"` in the file but expose
|
|
80
103
|
// `Ethernet0/0/1` on the CLI. eNSP's file vocabulary and VRP's differ here.
|
|
81
104
|
PC: {
|
|
@@ -131,6 +154,60 @@ export const GEOMETRY = { editLeftOffset: 27, editTopOffset: 54 }
|
|
|
131
154
|
*/
|
|
132
155
|
export const ICON = { w: 86, h: 86, left: -16.2, right: 70.2, top: -16.2, bottom: 70.2 }
|
|
133
156
|
|
|
157
|
+
/**
|
|
158
|
+
* CLI namespace for one interface group.
|
|
159
|
+
*
|
|
160
|
+
* The eNSP file and VRP disagree in two places: PC/Server store `GE` but expose
|
|
161
|
+
* `Ethernet`, and firewalls store `GE` but number from `GigabitEthernet1/0/`.
|
|
162
|
+
* The model spec states those overrides by position; everything else follows from
|
|
163
|
+
* the declared interface name.
|
|
164
|
+
*/
|
|
165
|
+
function cliPrefixForGroup(sztype, enspPrefix, model, groupIndex) {
|
|
166
|
+
const override = MODEL_SPECS[model]?.groups?.[groupIndex]?.[4]
|
|
167
|
+
if (override) return override
|
|
168
|
+
if (enspPrefix === 'GE' || sztype === 'GE') return 'GigabitEthernet0/0/'
|
|
169
|
+
if (sztype === 'Serial' || enspPrefix === 'Serial') return 'Serial0/0/'
|
|
170
|
+
return 'Ethernet0/0/'
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Build a port list from a device's own `<interface>` declarations.
|
|
175
|
+
*
|
|
176
|
+
* This matters for labs that add hardware: a stock AR2220 has no serial ports, but
|
|
177
|
+
* a NAT lab in the ICVE corpus mounts a serial card, so flat index 3 is
|
|
178
|
+
* `Serial0/0/0` there and `GigabitEthernet0/0/3` on a stock unit. The file states
|
|
179
|
+
* the real layout, so the file wins over the model table.
|
|
180
|
+
*
|
|
181
|
+
* @param {Array<{sztype: string, name: string, count: number, firstNumber: number|null}>} declarations
|
|
182
|
+
* @param {string} model
|
|
183
|
+
* @returns {string[]} CLI port name per flat index, in declaration order
|
|
184
|
+
*/
|
|
185
|
+
export function portsFromDeclarations(declarations, model) {
|
|
186
|
+
if (!declarations?.length) return []
|
|
187
|
+
const base = MODEL_SPECS[model]?.base ?? 0
|
|
188
|
+
const ports = []
|
|
189
|
+
declarations.forEach((d, groupIndex) => {
|
|
190
|
+
const first = d.firstNumber ?? base
|
|
191
|
+
const cli = cliPrefixForGroup(d.sztype, d.name, model, groupIndex)
|
|
192
|
+
for (let i = 0; i < d.count; i += 1) ports.push(`${cli}${first + i}`)
|
|
193
|
+
})
|
|
194
|
+
return ports
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Resident interface declarations of a model, as `{sztype, name, count, firstNumber}`.
|
|
199
|
+
*
|
|
200
|
+
* Callers use this when they need to emit the `<interface>` block for a device.
|
|
201
|
+
*/
|
|
202
|
+
export function declarationsFor(model) {
|
|
203
|
+
return (MODEL_SPECS[model]?.groups ?? []).map(([sztype, name, count, firstNumber]) => ({
|
|
204
|
+
sztype,
|
|
205
|
+
name,
|
|
206
|
+
count,
|
|
207
|
+
firstNumber: firstNumber ?? null,
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
|
|
134
211
|
/** Total declared interface count for a model. */
|
|
135
212
|
export function portCount(model) {
|
|
136
213
|
const spec = MODEL_SPECS[model]
|
package/src/core/topology.mjs
CHANGED
|
@@ -23,7 +23,14 @@
|
|
|
23
23
|
|
|
24
24
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
MODEL_SPECS,
|
|
28
|
+
GEOMETRY,
|
|
29
|
+
portCount,
|
|
30
|
+
portsFromDeclarations,
|
|
31
|
+
flatIndexToVrp,
|
|
32
|
+
vrpToFlatIndex,
|
|
33
|
+
} from './models.mjs'
|
|
27
34
|
|
|
28
35
|
/**
|
|
29
36
|
* Icon bounding box, measured from every cable anchor in eNSP's own samples.
|
|
@@ -90,14 +97,30 @@ export function parseTopology(xml) {
|
|
|
90
97
|
const version = attr(xml.match(/<topo[^>]*>/)?.[0] ?? '', 'version')
|
|
91
98
|
|
|
92
99
|
const devices = []
|
|
100
|
+
const portsById = new Map()
|
|
93
101
|
for (const m of xml.matchAll(/<dev\b([^>]*)>([\s\S]*?)<\/dev>/g)) {
|
|
94
102
|
const head = m[1]
|
|
95
103
|
const body = m[2]
|
|
96
104
|
const model = attr(head, 'model')
|
|
97
|
-
|
|
98
|
-
|
|
105
|
+
// The device's own <interface> block is the authoritative port layout: a lab
|
|
106
|
+
// can mount extra cards (a serial card on an AR2220, for instance), and then
|
|
107
|
+
// the flat indices no longer match the stock model table.
|
|
108
|
+
const declarations = [...body.matchAll(/<interface\b([^>]*)\/?>/g)].map((decl) => {
|
|
109
|
+
const tag = decl[1]
|
|
110
|
+
const firstNumber = attr(tag, 'firstnumber')
|
|
111
|
+
return {
|
|
112
|
+
sztype: attr(tag, 'sztype') ?? 'Ethernet',
|
|
113
|
+
name: attr(tag, 'interfacename') ?? 'GE',
|
|
114
|
+
count: Number(attr(tag, 'count') ?? 0),
|
|
115
|
+
firstNumber: firstNumber === null ? null : Number(firstNumber),
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
const declared = declarations.reduce((sum, x) => sum + x.count, 0)
|
|
119
|
+
const ports = portsFromDeclarations(declarations, model)
|
|
120
|
+
const id = attr(head, 'id')
|
|
121
|
+
if (id) portsById.set(id, ports)
|
|
99
122
|
devices.push({
|
|
100
|
-
id
|
|
123
|
+
id,
|
|
101
124
|
name: attr(head, 'name'),
|
|
102
125
|
model,
|
|
103
126
|
comPort: Number(attr(head, 'com_port') ?? 0) || null,
|
|
@@ -128,8 +151,8 @@ export function parseTopology(xml) {
|
|
|
128
151
|
media: attr(pair, 'lineName') ?? 'Copper',
|
|
129
152
|
srcName: src?.name ?? null,
|
|
130
153
|
dstName: dst?.name ?? null,
|
|
131
|
-
srcPort: src ? interfaceName(src.model, srcIndex) : null,
|
|
132
|
-
dstPort: dst ? interfaceName(dst.model, dstIndex) : null,
|
|
154
|
+
srcPort: src ? (portsById.get(srcId)?.[srcIndex] ?? interfaceName(src.model, srcIndex)) : null,
|
|
155
|
+
dstPort: dst ? (portsById.get(dstId)?.[dstIndex] ?? interfaceName(dst.model, dstIndex)) : null,
|
|
133
156
|
})
|
|
134
157
|
}
|
|
135
158
|
|