dsh-ensp 0.1.2 → 0.1.6
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 +212 -274
- package/docs/plugin-detail.en.md +14 -0
- package/docs/plugin-detail.zh.md +14 -0
- package/package.json +5 -15
- package/screenshots.json +3 -0
- package/src/core/index.mjs +9 -1
- package/src/core/launcher.mjs +284 -24
- package/src/core/provision.mjs +81 -0
- package/src/core/topology.mjs +23 -5
- package/src/dsh/index.mjs +595 -387
package/src/core/launcher.mjs
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
import { spawn, spawnSync } from 'node:child_process'
|
|
32
32
|
import net from 'node:net'
|
|
33
|
+
import os from 'node:os'
|
|
34
|
+
import path from 'node:path'
|
|
33
35
|
|
|
34
36
|
import { calibrateAnchors, DEFAULT_ANCHORS } from './calibrate.mjs'
|
|
35
37
|
|
|
@@ -180,17 +182,61 @@ ${body}
|
|
|
180
182
|
return { ok: r.status === 0, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim() }
|
|
181
183
|
}
|
|
182
184
|
|
|
183
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* PID of a *live* eNSP client, or null.
|
|
187
|
+
*
|
|
188
|
+
* eNSP leaves zombie processes behind: the process object survives with
|
|
189
|
+
* `Threads.Count === 0` and holds the client's single-instance lock, so a new
|
|
190
|
+
* launch inherits an empty canvas. Such a PID must never be reused.
|
|
191
|
+
*
|
|
192
|
+
* @returns {number|null}
|
|
193
|
+
*/
|
|
184
194
|
export function enspPid() {
|
|
185
195
|
const r = spawnSync(
|
|
186
196
|
'powershell',
|
|
187
|
-
[
|
|
197
|
+
[
|
|
198
|
+
'-NoProfile',
|
|
199
|
+
'-Command',
|
|
200
|
+
'Get-Process eNSP_Client -ErrorAction SilentlyContinue | ' +
|
|
201
|
+
'Where-Object { $_.Threads.Count -gt 0 } | ' +
|
|
202
|
+
'Select-Object -First 1 -ExpandProperty Id',
|
|
203
|
+
],
|
|
188
204
|
{ encoding: 'utf8', windowsHide: true },
|
|
189
205
|
)
|
|
190
206
|
const pid = Number((r.stdout || '').trim())
|
|
191
207
|
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Is this window handle still valid and owned by a live process?
|
|
212
|
+
*
|
|
213
|
+
* @param {string} hwnd
|
|
214
|
+
* @returns {boolean}
|
|
215
|
+
*/
|
|
216
|
+
export function windowAlive(hwnd) {
|
|
217
|
+
const r = runPowerShell(
|
|
218
|
+
`$h = [IntPtr]::new([int64]${hwnd})
|
|
219
|
+
if (-not [EnspWin]::IsWindow($h)) { 'no'; exit 0 }
|
|
220
|
+
$owned = Get-Process eNSP_Client -ErrorAction SilentlyContinue | Where-Object { $_.Threads.Count -gt 0 }
|
|
221
|
+
if ($owned) { 'yes' } else { 'no' }`,
|
|
222
|
+
{ timeoutMs: 20000 },
|
|
223
|
+
)
|
|
224
|
+
return r.ok && r.stdout.trim().endsWith('yes')
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Every eNSP client PID, including zero-thread zombies. */
|
|
228
|
+
export function enspPids() {
|
|
229
|
+
const r = spawnSync(
|
|
230
|
+
'powershell',
|
|
231
|
+
['-NoProfile', '-Command', 'Get-Process eNSP_Client -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id'],
|
|
232
|
+
{ encoding: 'utf8', windowsHide: true },
|
|
233
|
+
)
|
|
234
|
+
return (r.stdout || '')
|
|
235
|
+
.split(/\s+/)
|
|
236
|
+
.map(Number)
|
|
237
|
+
.filter((n) => Number.isInteger(n) && n > 0)
|
|
238
|
+
}
|
|
239
|
+
|
|
194
240
|
/**
|
|
195
241
|
* Launch the eNSP client, optionally opening a topology.
|
|
196
242
|
*
|
|
@@ -200,38 +246,236 @@ export function enspPid() {
|
|
|
200
246
|
* @param {number} [options.readyTimeoutMs=90000] - how long to wait for the window to exist
|
|
201
247
|
* @returns {Promise<{pid: number, hwnd: string}>}
|
|
202
248
|
*/
|
|
203
|
-
export async function launchEnsp({ topology, exspPath, readyTimeoutMs =
|
|
249
|
+
export async function launchEnsp({ topology, exspPath, readyTimeoutMs = 120000 } = {}) {
|
|
204
250
|
const exe = exspPath ?? process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
251
|
+
const target = topology ? path.resolve(topology) : null
|
|
252
|
+
|
|
253
|
+
// Reuse an existing window only when it renders something; a blank window is a
|
|
254
|
+
// dead instance that would otherwise be reported as success.
|
|
255
|
+
const existing = enspPid()
|
|
256
|
+
if (existing) {
|
|
257
|
+
const hwnd = locateWindow(existing)
|
|
258
|
+
if (hwnd && (!target || topologyRendered(hwnd))) {
|
|
259
|
+
return { pid: existing, hwnd, reused: true, rendered: Boolean(hwnd) }
|
|
260
|
+
}
|
|
261
|
+
if (hwnd && target) {
|
|
262
|
+
// A live window showing nothing: reopen the file in it first.
|
|
263
|
+
reopenTopology(target)
|
|
264
|
+
if (await waitForRender(hwnd, { attempts: 6, intervalMs: 2500 })) {
|
|
265
|
+
return { pid: existing, hwnd, reused: true, rendered: true }
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// Give up on it: stale clients hold eNSP's single-instance lock and make every
|
|
269
|
+
// new window open blank.
|
|
270
|
+
killAllClients()
|
|
271
|
+
await sleep(2500)
|
|
209
272
|
}
|
|
210
273
|
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
274
|
+
/** Launch once and wait for a frame window. */
|
|
275
|
+
const boot = async () => {
|
|
276
|
+
startDetached(exe, target)
|
|
277
|
+
const started = Date.now()
|
|
278
|
+
const deadline = started + readyTimeoutMs
|
|
279
|
+
while (Date.now() < deadline) {
|
|
280
|
+
const pid = enspPid()
|
|
281
|
+
if (pid) {
|
|
282
|
+
const hwnd = locateWindow(pid)
|
|
283
|
+
if (hwnd) return { pid, hwnd, waitMs: Date.now() - started }
|
|
284
|
+
}
|
|
285
|
+
await sleep(250)
|
|
286
|
+
}
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
214
289
|
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
290
|
+
// Up to three attempts: the client occasionally opens with an empty canvas on a
|
|
291
|
+
// cold start, and a clean relaunch always renders. Verified empirically.
|
|
292
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
293
|
+
const booted = await boot()
|
|
294
|
+
if (!booted) {
|
|
295
|
+
killAllClients()
|
|
296
|
+
await sleep(1500)
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
if (!target) return { ...booted, reused: false, rendered: true }
|
|
300
|
+
if (await waitForRender(booted.hwnd, { attempts: 6, intervalMs: 2000 })) {
|
|
301
|
+
return { ...booted, reused: false, rendered: true }
|
|
226
302
|
}
|
|
227
|
-
|
|
303
|
+
// Blank canvas: tear the instance down and try again from a clean state.
|
|
304
|
+
killAllClients()
|
|
305
|
+
await sleep(2500)
|
|
228
306
|
}
|
|
307
|
+
|
|
229
308
|
throw new Error(
|
|
230
|
-
|
|
231
|
-
|
|
309
|
+
'eNSP opened but never rendered the topology after 3 attempts. ' +
|
|
310
|
+
'Close every eNSP window and retry; a leftover client holds the single-instance lock.',
|
|
232
311
|
)
|
|
233
312
|
}
|
|
234
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Force-terminate every eNSP client process.
|
|
316
|
+
*
|
|
317
|
+
* Zombie processes (zero threads) cannot be killed by any API; they linger until
|
|
318
|
+
* the OS reaps them. This reports what survived so the caller can decide whether
|
|
319
|
+
* retrying is worthwhile.
|
|
320
|
+
*
|
|
321
|
+
* @returns {{killed: boolean, survivors: number[]}}
|
|
322
|
+
*/
|
|
323
|
+
function killAllClients() {
|
|
324
|
+
spawnSync('taskkill', ['/F', '/IM', 'eNSP_Client.exe'], { windowsHide: true, encoding: 'utf8' })
|
|
325
|
+
return { killed: true, survivors: enspPids() }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Poll until the canvas actually shows the topology.
|
|
330
|
+
*
|
|
331
|
+
* @param {string} hwnd
|
|
332
|
+
* @param {{attempts?: number, intervalMs?: number}} [options]
|
|
333
|
+
* @returns {Promise<boolean>}
|
|
334
|
+
*/
|
|
335
|
+
async function waitForRender(hwnd, { attempts = 6, intervalMs = 2000 } = {}) {
|
|
336
|
+
for (let i = 0; i < attempts; i++) {
|
|
337
|
+
await sleep(intervalMs)
|
|
338
|
+
if (topologyRendered(hwnd)) return true
|
|
339
|
+
}
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Start the client so that it outlives this Node process.
|
|
345
|
+
*
|
|
346
|
+
* `spawn(..., {detached:true})` is not enough on Windows: eNSP is a GUI application
|
|
347
|
+
* that terminates when the console session that launched it goes away. Going
|
|
348
|
+
* through `cmd /c start` hands ownership to the shell, which is what a double-click
|
|
349
|
+
* in Explorer does, and the client then survives its launcher.
|
|
350
|
+
*
|
|
351
|
+
* @param {string} exe
|
|
352
|
+
* @param {string|null} [topology]
|
|
353
|
+
*/
|
|
354
|
+
function startDetached(exe, topology) {
|
|
355
|
+
// Win32_Process.Create via WMI is the only launch path measured to leave the
|
|
356
|
+
// client fully independent of this Node process:
|
|
357
|
+
//
|
|
358
|
+
// spawn(..., {detached:true}) -> client dies with its launcher
|
|
359
|
+
// cmd /c start -> client dies with its launcher
|
|
360
|
+
// Start-Process -WindowStyle Hidden -> client exits before creating a window
|
|
361
|
+
// Start-Process (visible) -> unreliable when called from a hidden shell
|
|
362
|
+
// WMI Win32_Process.Create -> survives; returns the real PID
|
|
363
|
+
//
|
|
364
|
+
// The client must outlive the tool call, otherwise every follow-up (start
|
|
365
|
+
// devices, push config) finds a dead console.
|
|
366
|
+
const cmdline = topology ? `"${exe}" "${topology}"` : `"${exe}"`
|
|
367
|
+
const r = runPowerShell(
|
|
368
|
+
`
|
|
369
|
+
$r = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
|
370
|
+
CommandLine = '${cmdline.replace(/'/g, "''")}'
|
|
371
|
+
}
|
|
372
|
+
if ($r.ReturnValue -ne 0) { 'ERR ' + $r.ReturnValue; exit 0 }
|
|
373
|
+
'pid=' + $r.ProcessId
|
|
374
|
+
`,
|
|
375
|
+
{ timeoutMs: 30000 },
|
|
376
|
+
)
|
|
377
|
+
if (!r.ok || !/pid=\d+/.test(r.stdout)) {
|
|
378
|
+
throw new Error(`could not launch eNSP: ${r.stdout || r.stderr || 'WMI refused the request'}`)
|
|
379
|
+
}
|
|
380
|
+
return Number(/pid=(\d+)/.exec(r.stdout)[1])
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Ask the running client to open a topology again.
|
|
385
|
+
*
|
|
386
|
+
* A second instance is normally refused, so this relaunches the client with the
|
|
387
|
+
* same file; the window handle changes and the caller re-locates it.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} topology
|
|
390
|
+
*/
|
|
391
|
+
function reopenTopology(topology) {
|
|
392
|
+
const exe = process.env.DSH_ENSP_EXE ?? 'E:\\Huawei\\eNSP\\eNSP_Client.exe'
|
|
393
|
+
startDetached(exe, topology)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Take a screenshot of the client's canvas, cropped to the drawing area.
|
|
398
|
+
*
|
|
399
|
+
* Used by `topologyRendered` and by the probe diagnostics. The canvas excludes the
|
|
400
|
+
* left device palette and the toolbar, so anything found inside it came from the
|
|
401
|
+
* open topology.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} hwnd
|
|
404
|
+
* @returns {string} absolute path to the PNG, or '' on failure
|
|
405
|
+
*/
|
|
406
|
+
export function captureCanvas(hwnd) {
|
|
407
|
+
const out = path.join(os.tmpdir(), `dsh-ensp-canvas-${Date.now()}.png`)
|
|
408
|
+
const r = runPowerShell(
|
|
409
|
+
`
|
|
410
|
+
$h = [IntPtr]::new([int64]${hwnd})
|
|
411
|
+
[void][EnspWin]::FocusWindow($h)
|
|
412
|
+
Start-Sleep -Milliseconds 900
|
|
413
|
+
$wr = New-Object EnspWin+RECT
|
|
414
|
+
[void][EnspWin]::GetWindowRect($h, [ref]$wr)
|
|
415
|
+
$w = $wr.R - $wr.L; $ht = $wr.B - $wr.T
|
|
416
|
+
if ($w -lt 400 -or $ht -lt 300) { 'ERR small'; exit 0 }
|
|
417
|
+
Add-Type -AssemblyName System.Drawing
|
|
418
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $ht
|
|
419
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
420
|
+
$g.CopyFromScreen($wr.L, $wr.T, 0, 0, $bmp.Size)
|
|
421
|
+
$bmp.Save('${out.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
422
|
+
$bmp.Dispose(); $g.Dispose()
|
|
423
|
+
'ok'
|
|
424
|
+
`,
|
|
425
|
+
{ timeoutMs: 40000 },
|
|
426
|
+
)
|
|
427
|
+
return r.ok ? out : ''
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Does the open topology show devices on the canvas?
|
|
432
|
+
*
|
|
433
|
+
* eNSP paints with DirectX, so there is no window tree to query. Two independent
|
|
434
|
+
* signals are combined, because either alone is weak:
|
|
435
|
+
*
|
|
436
|
+
* 1. **Icon colour.** Device icons are a saturated blue on a pale canvas; the
|
|
437
|
+
* empty canvas has no such pixels.
|
|
438
|
+
* 2. **Link lines.** Rendered links are near-black thin strokes across the canvas.
|
|
439
|
+
*
|
|
440
|
+
* This is the check that gates "the topology actually opened". Callers retry on a
|
|
441
|
+
* negative result rather than reporting success for a blank window.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} hwnd
|
|
444
|
+
* @returns {boolean}
|
|
445
|
+
*/
|
|
446
|
+
export function topologyRendered(hwnd) {
|
|
447
|
+
const png = captureCanvas(hwnd)
|
|
448
|
+
if (!png) return false
|
|
449
|
+
const r = runPowerShell(
|
|
450
|
+
`
|
|
451
|
+
Add-Type -AssemblyName System.Drawing
|
|
452
|
+
$bmp = [System.Drawing.Bitmap]::FromFile('${png.replace(/\\/g, '\\\\')}')
|
|
453
|
+
$w = $bmp.Width; $ht = $bmp.Height
|
|
454
|
+
# Canvas only: skip the palette column and the toolbar band.
|
|
455
|
+
$x0 = [int]($w * 0.22); $x1 = [int]($w * 0.97)
|
|
456
|
+
$y0 = [int]($ht * 0.10); $y1 = [int]($ht * 0.92)
|
|
457
|
+
$icon = 0; $dark = 0
|
|
458
|
+
for ($y = $y0; $y -lt $y1; $y += 2) {
|
|
459
|
+
for ($x = $x0; $x -lt $x1; $x += 2) {
|
|
460
|
+
$c = $bmp.GetPixel($x, $y)
|
|
461
|
+
if ($c.B -gt 140 -and ($c.B - $c.R) -gt 55 -and ($c.B - $c.G) -gt 25) { $icon++ }
|
|
462
|
+
elseif ($c.R -lt 90 -and $c.G -lt 90 -and $c.B -lt 90) { $dark++ }
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
$bmp.Dispose()
|
|
466
|
+
'icon=' + $icon + ' dark=' + $dark
|
|
467
|
+
`,
|
|
468
|
+
{ timeoutMs: 40000 },
|
|
469
|
+
)
|
|
470
|
+
if (!r.ok) return false
|
|
471
|
+
const m = /icon=(\d+) dark=(\d+)/.exec(r.stdout)
|
|
472
|
+
if (!m) return false
|
|
473
|
+
const icon = Number(m[1])
|
|
474
|
+
const dark = Number(m[2])
|
|
475
|
+
// A rendered lab shows dozens of icon pixels and a few hundred link pixels.
|
|
476
|
+
return icon >= 15 && dark >= 40
|
|
477
|
+
}
|
|
478
|
+
|
|
235
479
|
/**
|
|
236
480
|
* Locate the eNSP main frame window for a PID.
|
|
237
481
|
* @param {number} pid
|
|
@@ -399,6 +643,12 @@ export async function startAllDevices({
|
|
|
399
643
|
|
|
400
644
|
// Locate the toolbar buttons by colour instead of trusting hard-coded pixels;
|
|
401
645
|
// this survives eNSP upgrades, DPI changes and different display sizes.
|
|
646
|
+
// The window must still exist: a stale handle means the client already exited,
|
|
647
|
+
// and clicking a dead handle does nothing while reporting success below.
|
|
648
|
+
if (!hwnd || !windowAlive(hwnd)) {
|
|
649
|
+
throw new Error('eNSP window is gone; reopen the topology before starting devices')
|
|
650
|
+
}
|
|
651
|
+
|
|
402
652
|
report({ phase: 'calibrate', hwnd })
|
|
403
653
|
let anchors
|
|
404
654
|
try {
|
|
@@ -406,6 +656,16 @@ export async function startAllDevices({
|
|
|
406
656
|
} catch (error) {
|
|
407
657
|
anchors = { ...DEFAULT_ANCHORS, method: 'default', detail: { reason: error.message } }
|
|
408
658
|
}
|
|
659
|
+
|
|
660
|
+
// A default anchor means calibration failed, and an unmeasured click can land on
|
|
661
|
+
// any toolbar control (including the one that closes the document). Refuse
|
|
662
|
+
// instead of guessing.
|
|
663
|
+
if (anchors.method !== 'measured') {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`toolbar calibration failed (${anchors.detail?.reason ?? 'unknown'}). ` +
|
|
666
|
+
'Make sure the eNSP window is visible and run ensp_probe to re-measure.',
|
|
667
|
+
)
|
|
668
|
+
}
|
|
409
669
|
report({ phase: 'calibrated', method: anchors.method, detail: anchors.detail })
|
|
410
670
|
|
|
411
671
|
report({ phase: 'focus', hwnd })
|
package/src/core/provision.mjs
CHANGED
|
@@ -280,3 +280,84 @@ export async function queryDevice({ port, command, timeoutMs = 25000 }) {
|
|
|
280
280
|
session?.close()
|
|
281
281
|
}
|
|
282
282
|
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Poll a device until a predicate accepts its command output, or time runs out.
|
|
286
|
+
*
|
|
287
|
+
* Routing protocols converge asynchronously: right after `ospf 1` is committed a
|
|
288
|
+
* peering sits in ExStart/Exchange/Loading for a while, and a single query taken
|
|
289
|
+
* too early reads as "failed" when it is merely "not finished". Waiting a fixed
|
|
290
|
+
* number of seconds either wastes time or is still too short on a slow machine,
|
|
291
|
+
* so every convergence assertion in this package goes through this helper.
|
|
292
|
+
*
|
|
293
|
+
* @param {{
|
|
294
|
+
* port: number,
|
|
295
|
+
* command: string,
|
|
296
|
+
* until: (output: string) => boolean,
|
|
297
|
+
* timeoutMs?: number,
|
|
298
|
+
* intervalMs?: number,
|
|
299
|
+
* probeTimeoutMs?: number,
|
|
300
|
+
* }} options
|
|
301
|
+
* @returns {Promise<{ok: boolean, converged: boolean, output: string, attempts: number, waitedMs: number}>}
|
|
302
|
+
*/
|
|
303
|
+
export async function waitForOutput({
|
|
304
|
+
port,
|
|
305
|
+
command,
|
|
306
|
+
until,
|
|
307
|
+
timeoutMs = 120000,
|
|
308
|
+
intervalMs = 3000,
|
|
309
|
+
probeTimeoutMs = 20000,
|
|
310
|
+
}) {
|
|
311
|
+
const started = Date.now()
|
|
312
|
+
let attempts = 0
|
|
313
|
+
let output = ''
|
|
314
|
+
while (Date.now() - started < timeoutMs) {
|
|
315
|
+
attempts += 1
|
|
316
|
+
const r = await queryDevice({ port, command, timeoutMs: probeTimeoutMs })
|
|
317
|
+
if (r.ok) output = r.output
|
|
318
|
+
if (r.ok && until(r.output)) {
|
|
319
|
+
return { ok: true, converged: true, output, attempts, waitedMs: Date.now() - started }
|
|
320
|
+
}
|
|
321
|
+
await sleep(intervalMs)
|
|
322
|
+
}
|
|
323
|
+
return {
|
|
324
|
+
ok: false,
|
|
325
|
+
converged: false,
|
|
326
|
+
output,
|
|
327
|
+
attempts,
|
|
328
|
+
waitedMs: Date.now() - started,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Wait until an OSPF adjacency reaches Full on `port`, optionally naming the peer.
|
|
334
|
+
*
|
|
335
|
+
* @param {{port: number, neighbourId?: string, timeoutMs?: number}} options
|
|
336
|
+
*/
|
|
337
|
+
export async function waitForOspfFull({ port, neighbourId, timeoutMs = 120000 }) {
|
|
338
|
+
const r = await waitForOutput({
|
|
339
|
+
port,
|
|
340
|
+
command: 'display ospf peer brief',
|
|
341
|
+
until: (out) =>
|
|
342
|
+
out
|
|
343
|
+
.split('\n')
|
|
344
|
+
.filter((l) => /\bFull\b/.test(l))
|
|
345
|
+
.some((l) => !neighbourId || l.includes(neighbourId)),
|
|
346
|
+
timeoutMs,
|
|
347
|
+
})
|
|
348
|
+
return { ...r, neighbourId: neighbourId || null }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Wait until a prefix shows up in the OSPF routing table on `port`.
|
|
353
|
+
*
|
|
354
|
+
* @param {{port: number, prefix: string, timeoutMs?: number}} options
|
|
355
|
+
*/
|
|
356
|
+
export async function waitForOspfRoute({ port, prefix, timeoutMs = 120000 }) {
|
|
357
|
+
return waitForOutput({
|
|
358
|
+
port,
|
|
359
|
+
command: 'display ip routing-table protocol ospf',
|
|
360
|
+
until: (out) => out.includes(prefix),
|
|
361
|
+
timeoutMs,
|
|
362
|
+
})
|
|
363
|
+
}
|
package/src/core/topology.mjs
CHANGED
|
@@ -43,8 +43,25 @@ const MODEL_SPECS = {
|
|
|
43
43
|
Cloud: { groups: [['Ethernet', 'Ethernet', 3, 0]] },
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Icon bounding box, measured from every cable anchor in eNSP's own samples.
|
|
48
|
+
*
|
|
49
|
+
* The box is **not** centred on (cx, cy): it runs from `cx - 16.2` to
|
|
50
|
+
* `cx + 70.2` horizontally, so its centre is `cx + 27`, not `cx + 43`. Using
|
|
51
|
+
* `height / 2` puts the anchor outside the icon and eNSP then rejects the whole
|
|
52
|
+
* `<lines>` block, which shows up as an empty canvas with no error message.
|
|
53
|
+
*
|
|
54
|
+
* Frequencies across the bundled samples: (+70.2,+27.0) x35, (-16.2,+27.0) x35,
|
|
55
|
+
* (+27.0,+70.2) x29, (+27.0,-16.2) x27.
|
|
56
|
+
*/
|
|
57
|
+
const ICON = {
|
|
58
|
+
left: -16.2,
|
|
59
|
+
right: 70.2,
|
|
60
|
+
top: -16.2,
|
|
61
|
+
bottom: 70.2,
|
|
62
|
+
centerX: 27.0,
|
|
63
|
+
centerY: 27.0,
|
|
64
|
+
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* Interfaces exposed by each model eNSP can run, in declaration order.
|
|
@@ -255,11 +272,12 @@ export function serialiseTopology(topo) {
|
|
|
255
272
|
const dst = byId.get(l.dstId)
|
|
256
273
|
if (!src || !dst) throw new Error(`link references unknown device: ${l.srcId} -> ${l.dstId}`)
|
|
257
274
|
|
|
258
|
-
// Anchor on the facing sides: right edge of the source, left edge of the target
|
|
275
|
+
// Anchor on the facing sides: right edge of the source, left edge of the target,
|
|
276
|
+
// both at the icon's true vertical centre.
|
|
259
277
|
const sx = src.x + ICON.right
|
|
260
|
-
const sy = src.y + ICON.
|
|
278
|
+
const sy = src.y + ICON.centerY
|
|
261
279
|
const tx = dst.x + ICON.left
|
|
262
|
-
const ty = dst.y + ICON.
|
|
280
|
+
const ty = dst.y + ICON.centerY
|
|
263
281
|
|
|
264
282
|
return (
|
|
265
283
|
` <line srcDeviceID="${src.id}" destDeviceID="${dst.id}">\n` +
|