dsh-ensp 0.2.0 → 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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ensp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Automate Huawei eNSP labs: generate .topo files, start and stop devices, and push configuration one command at a time with per-command error reporting.",
5
5
  "keywords": [
6
6
  "dsh",
@@ -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]
@@ -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
- if (PROMPT_RE.test(text)) {
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
- * Failures are non-fatal: some older VRP builds reject the command.
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.exec('screen-length 0 temporary', { timeoutMs: 8000 })
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
 
@@ -109,20 +109,32 @@ export function probeInputInjection({ timeoutMs = 30000 } = {}) {
109
109
  }
110
110
  }
111
111
 
112
- /** Free physical memory in MB, or null when it cannot be read. */
112
+ /**
113
+ * Free physical memory in MB, or null when it cannot be read.
114
+ *
115
+ * On Windows Win32_OperatingSystem is the accurate number, but it needs
116
+ * PowerShell. Everything else (and any Windows box where PowerShell is not on
117
+ * PATH) falls back to os.freemem(), so the check still says something useful
118
+ * instead of giving up on the platform it happens to run on.
119
+ */
113
120
  export function freeMemoryMb() {
114
- const r = spawnSync(
115
- 'powershell',
116
- [
117
- '-NoProfile',
118
- '-Command',
119
- "Get-CimInstance Win32_OperatingSystem | ForEach-Object { [int]($_.FreePhysicalMemory / 1024) }",
120
- ],
121
- { encoding: 'utf8', windowsHide: true, timeout: 20000 },
122
- )
123
- if (r.status !== 0) return null
124
- const n = Number((r.stdout || '').trim())
125
- return Number.isFinite(n) && n > 0 ? n : null
121
+ if (process.platform === 'win32') {
122
+ const r = spawnSync(
123
+ 'powershell',
124
+ [
125
+ '-NoProfile',
126
+ '-Command',
127
+ "Get-CimInstance Win32_OperatingSystem | ForEach-Object { [int]($_.FreePhysicalMemory / 1024) }",
128
+ ],
129
+ { encoding: 'utf8', windowsHide: true, timeout: 20000 },
130
+ )
131
+ if (r.status === 0) {
132
+ const n = Number((r.stdout || '').trim())
133
+ if (Number.isFinite(n) && n > 0) return n
134
+ }
135
+ }
136
+ const fallback = Math.floor(os.freemem() / (1024 * 1024))
137
+ return Number.isFinite(fallback) && fallback > 0 ? fallback : null
126
138
  }
127
139
 
128
140
  /** Ports in eNSP's console range that are already listening. */
@@ -244,7 +256,14 @@ export function doctor(options = {}) {
244
256
  // 6. Memory headroom: each AR_Base needs 256 MB, WLAN images 530-650 MB
245
257
  const free = freeMemoryMb()
246
258
  if (free === null) {
247
- checks.push(check('memory', 'warn', 'could not read free physical memory'))
259
+ checks.push(
260
+ check(
261
+ 'memory',
262
+ 'warn',
263
+ 'could not read free physical memory',
264
+ 'Check free memory yourself (Task Manager on Windows) and keep at least 1 GB spare; a device needs ~256 MB to boot.',
265
+ ),
266
+ )
248
267
  } else {
249
268
  const okMem = free >= MIN_FREE_MEMORY_MB
250
269
  checks.push(
@@ -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,
@@ -814,8 +814,15 @@ export async function startAllDevices({
814
814
  }
815
815
 
816
816
  report({ phase: 'calibrate', hwnd })
817
- // Calibration photographs the screen, so an occluded window yields anchors
818
- // measured on the wrong pixels. Check first, and say who is in the way.
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
- report({ phase: 'focus', hwnd })
847
- const focused = focusEnsp(hwnd)
848
- if (!focused) report({ phase: 'focus-failed', hwnd })
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 })
@@ -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]
@@ -23,7 +23,14 @@
23
23
 
24
24
  import { readFile, writeFile } from 'node:fs/promises'
25
25
 
26
- import { MODEL_SPECS, GEOMETRY, portCount, flatIndexToVrp, vrpToFlatIndex } from './models.mjs'
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
- const declared = [...body.matchAll(/<interface\b[^>]*count="(\d+)"/g)]
98
- .reduce((sum, x) => sum + Number(x[1]), 0)
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: attr(head, '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