dsh-ensp 0.7.0 → 0.8.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.en.md +86 -16
- package/README.md +85 -15
- package/docs/ENSP-FORMAT.md +8 -8
- package/docs/SCENARIOS.md +332 -286
- package/docs/UI-ANCHORS.md +21 -0
- package/docs/plugin-detail.en.md +2 -0
- package/docs/plugin-detail.zh.md +2 -0
- package/package.json +2 -4
- package/scripts/gen-scenarios-doc.mjs +7 -3
- package/scripts/survey-topologies.mjs +15 -6
- package/src/cli/ensp.mjs +252 -28
- package/src/core/acceptance.mjs +258 -53
- package/src/core/build.mjs +15 -1
- package/src/core/compat.mjs +249 -17
- package/src/core/console.mjs +4 -0
- package/src/core/doctor.mjs +9 -12
- package/src/core/docx.mjs +359 -0
- package/src/core/index.mjs +28 -9
- package/src/core/labconfig.mjs +98 -0
- package/src/core/labs.mjs +12 -12
- package/src/core/launcher.mjs +96 -37
- package/src/core/models.mjs +27 -18
- package/src/core/ooxml.mjs +430 -0
- package/src/core/paths.mjs +59 -10
- package/src/core/presets.mjs +9 -5
- package/src/core/report.mjs +411 -0
- package/src/dsh/index.mjs +626 -652
- package/src/mcp/server.mjs +213 -356
- package/src/tools.mjs +271 -0
- package/screenshots.json +0 -3
- package/scripts/gen-knowledge.mjs +0 -196
- package/src/core/knowledge.json +0 -959
- package/src/core/knowledge.mjs +0 -103
package/src/core/paths.mjs
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* 1. an explicit override (CLI flag, plugin config, or function argument)
|
|
8
8
|
* 2. `DSH_ENSP_EXE` / `ENSP_EXE` / `DSH_VBOX`
|
|
9
|
-
* 3. the Windows install registry
|
|
9
|
+
* 3. the Windows install registry, including the Inno Setup uninstall keys
|
|
10
10
|
* 4. the usual install roots
|
|
11
|
-
* 5. `where` on PATH
|
|
11
|
+
* 5. `where` on PATH
|
|
12
12
|
*
|
|
13
13
|
* Every candidate that was tried is reported on failure, so a wrong override
|
|
14
14
|
* produces a message that says what was looked at instead of a bare ENOENT.
|
|
@@ -17,11 +17,28 @@ import fs from 'node:fs'
|
|
|
17
17
|
import path from 'node:path'
|
|
18
18
|
import { spawnSync } from 'node:child_process'
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* Registry keys an installer may have written the install root to.
|
|
22
|
+
*
|
|
23
|
+
* The Huawei keys are what an eNSP build writes for itself. The Uninstall keys
|
|
24
|
+
* are what Inno Setup writes, and they are the ones that exist on a normal
|
|
25
|
+
* installation: `eNSP` carries `InstallPath`, and the GUID key carries
|
|
26
|
+
* `InstallLocation`. Reading both is what makes an installation outside the
|
|
27
|
+
* usual four roots findable at all.
|
|
28
|
+
*/
|
|
21
29
|
const ENSP_REGISTRY = [
|
|
22
30
|
['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallPath'],
|
|
23
31
|
['HKLM\\SOFTWARE\\WOW6432Node\\Huawei\\eNSP', 'InstallPath'],
|
|
24
32
|
['HKLM\\SOFTWARE\\Huawei\\eNSP', 'InstallDir'],
|
|
33
|
+
['HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\eNSP', 'InstallPath'],
|
|
34
|
+
['HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\eNSP', 'InstallFullPath'],
|
|
35
|
+
['HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\eNSP', 'InstallPath'],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/** VirtualBox writes its own directory here, whatever drive it landed on. */
|
|
39
|
+
const VBOX_REGISTRY = [
|
|
40
|
+
['HKLM\\SOFTWARE\\Oracle\\VirtualBox', 'InstallDir'],
|
|
41
|
+
['HKLM\\SOFTWARE\\WOW6432Node\\Oracle\\VirtualBox', 'InstallDir'],
|
|
25
42
|
]
|
|
26
43
|
|
|
27
44
|
/** Install roots seen on real machines, most common first. */
|
|
@@ -52,13 +69,28 @@ function isFile(candidate) {
|
|
|
52
69
|
}
|
|
53
70
|
}
|
|
54
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Registry reads are cached for the life of the process.
|
|
74
|
+
*
|
|
75
|
+
* Each `reg query` is a process spawn at roughly 100 ms, and the install paths
|
|
76
|
+
* cannot change while the tool is running, so repeating them made every path
|
|
77
|
+
* lookup cost a second for nothing.
|
|
78
|
+
*/
|
|
79
|
+
const registryCache = new Map()
|
|
80
|
+
|
|
55
81
|
/** Read one string value out of the registry, or null. */
|
|
56
82
|
function registryValue(key, name) {
|
|
57
83
|
if (!isWindows) return null
|
|
84
|
+
const cacheKey = `${key}\u0000${name}`
|
|
85
|
+
if (registryCache.has(cacheKey)) return registryCache.get(cacheKey)
|
|
58
86
|
const r = spawnSync('reg', ['query', key, '/v', name], { encoding: 'utf8', windowsHide: true })
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
87
|
+
let value = null
|
|
88
|
+
if (r.status === 0 && r.stdout) {
|
|
89
|
+
const m = new RegExp(`${name}\\s+REG_\\w+\\s+(.+)`).exec(r.stdout)
|
|
90
|
+
value = m ? m[1].trim() : null
|
|
91
|
+
}
|
|
92
|
+
registryCache.set(cacheKey, value)
|
|
93
|
+
return value
|
|
62
94
|
}
|
|
63
95
|
|
|
64
96
|
/** Expand `%VAR%` and strip surrounding quotes from a registry value. */
|
|
@@ -83,8 +115,10 @@ export function enspCandidates(override) {
|
|
|
83
115
|
if (v) out.push({ path: v, source: name })
|
|
84
116
|
}
|
|
85
117
|
for (const [key, name] of ENSP_REGISTRY) {
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
118
|
+
const value = cleanRoot(registryValue(key, name))
|
|
119
|
+
if (!value) continue
|
|
120
|
+
const exe = /\.exe$/i.test(value) ? value : path.join(value, 'eNSP_Client.exe')
|
|
121
|
+
out.push({ path: exe, source: `${key}\\${name}` })
|
|
88
122
|
}
|
|
89
123
|
for (const root of ENSP_ROOTS) out.push({ path: path.join(root, 'eNSP_Client.exe'), source: 'install root' })
|
|
90
124
|
return out
|
|
@@ -95,15 +129,30 @@ export function vboxCandidates(override) {
|
|
|
95
129
|
const out = []
|
|
96
130
|
if (override) out.push({ path: override, source: 'override' })
|
|
97
131
|
if (process.env.DSH_VBOX) out.push({ path: process.env.DSH_VBOX, source: 'DSH_VBOX' })
|
|
132
|
+
// Registry before PATH: VirtualBox always writes InstallDir, and a PATH entry
|
|
133
|
+
// can belong to a second or stale copy.
|
|
134
|
+
for (const [key, name] of VBOX_REGISTRY) {
|
|
135
|
+
const dir = cleanRoot(registryValue(key, name))
|
|
136
|
+
if (dir) out.push({ path: path.join(dir, 'VBoxManage.exe'), source: `${key}\\${name}` })
|
|
137
|
+
}
|
|
98
138
|
if (isWindows) {
|
|
99
|
-
const
|
|
100
|
-
const first = (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0]
|
|
139
|
+
const first = firstOnPath('VBoxManage.exe')
|
|
101
140
|
if (first) out.push({ path: first, source: 'PATH' })
|
|
102
141
|
}
|
|
103
142
|
for (const p of VBOX_PATHS) out.push({ path: p, source: 'install root' })
|
|
104
143
|
return out
|
|
105
144
|
}
|
|
106
145
|
|
|
146
|
+
/** First hit from `where`, cached: it is a process spawn and PATH does not move. */
|
|
147
|
+
const pathCache = new Map()
|
|
148
|
+
function firstOnPath(program) {
|
|
149
|
+
if (pathCache.has(program)) return pathCache.get(program)
|
|
150
|
+
const r = spawnSync('where', [program], { encoding: 'utf8', windowsHide: true })
|
|
151
|
+
const first = (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] ?? null
|
|
152
|
+
pathCache.set(program, first)
|
|
153
|
+
return first
|
|
154
|
+
}
|
|
155
|
+
|
|
107
156
|
/** First existing candidate, or null. */
|
|
108
157
|
function firstExisting(candidates) {
|
|
109
158
|
for (const c of candidates) if (isFile(c.path)) return c
|
package/src/core/presets.mjs
CHANGED
|
@@ -582,11 +582,15 @@ export const SCENARIOS = {
|
|
|
582
582
|
devices: '2 台路由器(串口)',
|
|
583
583
|
goal: '串口封装、PPP 认证、PPPoE 拨号',
|
|
584
584
|
steps: [
|
|
585
|
-
{ do: '设封装类型', detail: 'HDLC
|
|
586
|
-
{ do: '配置认证', detail: '一端 authenticator,另一端 pap/chap 用户名密码', verify: 'display
|
|
587
|
-
{ do: 'PPPoE', detail: '服务端建 VT 与地址池,客户端 dialer 拨号', verify: 'display
|
|
585
|
+
{ do: '设封装类型', detail: '串口默认是 PPP,HDLC 要显式切换;PPP 支持认证与多链路', verify: 'display interface Serial0/0/1' },
|
|
586
|
+
{ do: '配置认证', detail: '一端 authenticator,另一端 pap/chap 用户名密码', verify: 'display current-configuration interface Serial0/0/0' },
|
|
587
|
+
{ do: 'PPPoE', detail: '服务端建 VT 与地址池,客户端 dialer 拨号', verify: 'display ip pool' },
|
|
588
|
+
],
|
|
589
|
+
pitfalls: [
|
|
590
|
+
'串口上默认封装是 PPP,不是 HDLC,`display interface` 里的 Link layer protocol 会直接说明当前是哪种',
|
|
591
|
+
'PPP 认证两端 pap/chap 不一致会反复协商失败',
|
|
592
|
+
'PPPoE 服务端要在物理口上 `pppoe-server bind virtual-template`,地址池挂在 VT 上',
|
|
588
593
|
],
|
|
589
|
-
pitfalls: ['PPP 认证两端 pap/chap 不一致会反复协商失败', '串口需先确认物理 up 且时钟配置正确'],
|
|
590
594
|
},
|
|
591
595
|
|
|
592
596
|
'ipv6': {
|
|
@@ -1038,7 +1042,7 @@ export function renderScenario(id) {
|
|
|
1038
1042
|
'',
|
|
1039
1043
|
'## 配置顺序(只给方向,具体命令按设备补全)',
|
|
1040
1044
|
...s.steps.map((step, i) => {
|
|
1041
|
-
const detail = step.detail ?
|
|
1045
|
+
const detail = step.detail ? `:${step.detail}` : ''
|
|
1042
1046
|
const verify = step.verify ? `\n 验证: ${step.verify}` : ''
|
|
1043
1047
|
return `${i + 1}. ${step.do}${detail}${verify}`
|
|
1044
1048
|
}),
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a finished lab into a Word delivery report.
|
|
3
|
+
*
|
|
4
|
+
* The report is evidence, not narration. Every command in it was applied to a
|
|
5
|
+
* live device in this run, every verification output is the text the device
|
|
6
|
+
* printed, and the verdict at the top is the same three-state verdict
|
|
7
|
+
* `ensp verify` gives. A lab whose checks had no assertion is reported as
|
|
8
|
+
* unverified here too, so the document cannot claim more than the run proved.
|
|
9
|
+
*
|
|
10
|
+
* Chapter order follows what a course deliverable is expected to contain:
|
|
11
|
+
* purpose, topology and addressing, configuration, verification, problems and
|
|
12
|
+
* conclusion, appendix.
|
|
13
|
+
*
|
|
14
|
+
* @module core/report
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFileSync } from 'node:fs'
|
|
18
|
+
import { basename } from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { buildDocx } from './docx.mjs'
|
|
21
|
+
import { consolePorts, runAcceptance } from './acceptance.mjs'
|
|
22
|
+
import { buildScenarioTopology, hasLab, labAddressing } from './labs.mjs'
|
|
23
|
+
import { SCENARIOS } from './presets.mjs'
|
|
24
|
+
import { queryDevice } from './provision.mjs'
|
|
25
|
+
import { readTopology } from './topology.mjs'
|
|
26
|
+
|
|
27
|
+
/** Kept short so one device's config cannot push a chapter onto its own page. */
|
|
28
|
+
const MAX_CONFIG_LINES = 400
|
|
29
|
+
|
|
30
|
+
/** How many verification commands to list in the appendix table. */
|
|
31
|
+
const APPENDIX_ROWS = 60
|
|
32
|
+
|
|
33
|
+
function today(now = new Date()) {
|
|
34
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
35
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ask every device for its running configuration.
|
|
40
|
+
*
|
|
41
|
+
* `display current-configuration` is used rather than the .cfg files that were
|
|
42
|
+
* applied: the files say what was meant, the device says what took effect, and
|
|
43
|
+
* a report is worth more when those differ.
|
|
44
|
+
*/
|
|
45
|
+
async function collectConfigs(devices, query, timeoutMs) {
|
|
46
|
+
const configs = []
|
|
47
|
+
for (const device of devices) {
|
|
48
|
+
const port = device.comPort
|
|
49
|
+
if (!port) {
|
|
50
|
+
configs.push({ device: device.name, text: '', note: '没有控制台端口,未读取配置' })
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
const res = await query({ port, command: 'display current-configuration', timeoutMs })
|
|
54
|
+
const output = (res?.output || '').trim()
|
|
55
|
+
if (!res?.ok || !output) {
|
|
56
|
+
configs.push({
|
|
57
|
+
device: device.name,
|
|
58
|
+
text: '',
|
|
59
|
+
note: res?.ok === false ? `读取失败:${res.error || '控制台无响应'}` : '设备无配置',
|
|
60
|
+
})
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
const lines = output.split('\n')
|
|
64
|
+
configs.push({
|
|
65
|
+
device: device.name,
|
|
66
|
+
text: lines.slice(0, MAX_CONFIG_LINES).join('\n'),
|
|
67
|
+
note: lines.length > MAX_CONFIG_LINES ? `配置超过 ${MAX_CONFIG_LINES} 行,已截断` : '',
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return configs
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Run the lab's acceptance checks and keep the full output of each command.
|
|
75
|
+
*
|
|
76
|
+
* `runAcceptance` keeps a 400 character snippet because it is formatted for a
|
|
77
|
+
* terminal. A document wants the whole table, so this wraps its `query` and
|
|
78
|
+
* records the last answer per command, which after the convergence retry is the
|
|
79
|
+
* converged one.
|
|
80
|
+
*/
|
|
81
|
+
async function collectChecks(id, { topology, ports, query, timeoutMs, waitMs, intervalMs, onProgress }) {
|
|
82
|
+
// Key captured output by device name, not by port: the report prints device
|
|
83
|
+
// names, and two devices can be asked the same question.
|
|
84
|
+
const byPort = new Map()
|
|
85
|
+
if (ports) {
|
|
86
|
+
for (const [name, port] of ports) byPort.set(port, name)
|
|
87
|
+
} else if (topology) {
|
|
88
|
+
const parsed = await readTopology(topology)
|
|
89
|
+
for (const [name, port] of consolePorts(parsed)) byPort.set(port, name)
|
|
90
|
+
}
|
|
91
|
+
const captured = new Map()
|
|
92
|
+
const recording = async (args) => {
|
|
93
|
+
const res = await query(args)
|
|
94
|
+
const name = byPort.get(args.port) ?? String(args.port)
|
|
95
|
+
captured.set(`${name}|${args.command}`, res?.output || '')
|
|
96
|
+
return res
|
|
97
|
+
}
|
|
98
|
+
const report = await runAcceptance({
|
|
99
|
+
id,
|
|
100
|
+
...(topology ? { topology } : { ports }),
|
|
101
|
+
query: recording,
|
|
102
|
+
...(timeoutMs ? { timeoutMs } : {}),
|
|
103
|
+
...(waitMs ? { waitMs } : {}),
|
|
104
|
+
...(intervalMs ? { intervalMs } : {}),
|
|
105
|
+
...(onProgress ? { onProgress } : {}),
|
|
106
|
+
})
|
|
107
|
+
const checks = report.results.map((r) => ({
|
|
108
|
+
id: r.id,
|
|
109
|
+
on: r.device,
|
|
110
|
+
command: r.command,
|
|
111
|
+
pass: r.pass,
|
|
112
|
+
asserted: r.asserted,
|
|
113
|
+
why: r.why,
|
|
114
|
+
waitedMs: r.waitedMs || 0,
|
|
115
|
+
output: captured.get(`${r.device}|${r.command}`) ?? '',
|
|
116
|
+
}))
|
|
117
|
+
return { ...report, checks }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Gather everything the document needs from the running lab.
|
|
122
|
+
*
|
|
123
|
+
* @param {{
|
|
124
|
+
* id: string,
|
|
125
|
+
* topology?: string,
|
|
126
|
+
* query?: Function,
|
|
127
|
+
* timeoutMs?: number,
|
|
128
|
+
* waitMs?: number,
|
|
129
|
+
* intervalMs?: number,
|
|
130
|
+
* onProgress?: Function,
|
|
131
|
+
* }} options
|
|
132
|
+
*/
|
|
133
|
+
export async function collectEvidence(options = {}) {
|
|
134
|
+
const { id, topology, query = queryDevice, timeoutMs, waitMs, intervalMs, onProgress } = options
|
|
135
|
+
const scenario = SCENARIOS[id]
|
|
136
|
+
if (!scenario) {
|
|
137
|
+
throw new Error(`unknown scenario "${id}"; available: ${Object.keys(SCENARIOS).join(', ')}`)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let devices = []
|
|
141
|
+
let links = []
|
|
142
|
+
let parsedPath = null
|
|
143
|
+
if (topology) {
|
|
144
|
+
const parsed = await readTopology(topology)
|
|
145
|
+
parsedPath = topology
|
|
146
|
+
devices = parsed.devices.map((d) => ({ name: d.name, model: d.model, comPort: d.comPort }))
|
|
147
|
+
links = parsed.links.map((l) => ({
|
|
148
|
+
a: l.srcName,
|
|
149
|
+
aPort: l.srcPort,
|
|
150
|
+
b: l.dstName,
|
|
151
|
+
bPort: l.dstPort,
|
|
152
|
+
media: l.media,
|
|
153
|
+
}))
|
|
154
|
+
} else if (hasLab(id)) {
|
|
155
|
+
// No .topo on disk: describe the blueprint so the topology chapter is still
|
|
156
|
+
// accurate, and use the ports the blueprint would hand out.
|
|
157
|
+
const blueprint = buildScenarioTopology(id)
|
|
158
|
+
devices = blueprint.devices.map((d) => ({ name: d.name, model: d.model, comPort: d.comPort }))
|
|
159
|
+
links = blueprint.links.map((l) => ({
|
|
160
|
+
a: l.srcName,
|
|
161
|
+
aPort: l.srcPort,
|
|
162
|
+
b: l.dstName,
|
|
163
|
+
bPort: l.dstPort,
|
|
164
|
+
media: l.media,
|
|
165
|
+
}))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const portMap = new Map()
|
|
169
|
+
for (const device of devices) {
|
|
170
|
+
if (device.comPort) portMap.set(device.name, device.comPort)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const addressing = hasLab(id) ? labAddressing(id) : []
|
|
174
|
+
const configs = await collectConfigs(devices, query, timeoutMs)
|
|
175
|
+
const checks = await collectChecks(id, {
|
|
176
|
+
topology: parsedPath,
|
|
177
|
+
ports: parsedPath ? undefined : portMap,
|
|
178
|
+
query,
|
|
179
|
+
timeoutMs,
|
|
180
|
+
waitMs,
|
|
181
|
+
intervalMs,
|
|
182
|
+
onProgress,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
id,
|
|
187
|
+
title: scenario.title,
|
|
188
|
+
titleEn: scenario.titleEn,
|
|
189
|
+
level: scenario.level,
|
|
190
|
+
source: scenario.source,
|
|
191
|
+
goal: scenario.goal,
|
|
192
|
+
steps: scenario.steps || [],
|
|
193
|
+
pitfalls: scenario.pitfalls || [],
|
|
194
|
+
devices,
|
|
195
|
+
links,
|
|
196
|
+
addressing,
|
|
197
|
+
configs,
|
|
198
|
+
verdict: { ok: checks.ok, passed: checks.passed, failed: checks.failed, unverified: checks.unverified, total: checks.total },
|
|
199
|
+
checks: checks.checks,
|
|
200
|
+
sourceKind: checks.source,
|
|
201
|
+
topologyPath: parsedPath,
|
|
202
|
+
generatedAt: today(),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function verdictSentence(evidence) {
|
|
207
|
+
const { ok, passed, failed, unverified, total } = evidence.verdict
|
|
208
|
+
if (!total) return '本次运行没有可执行的验证项。'
|
|
209
|
+
if (ok === true) return `验证通过:${passed}/${total} 项检查全部满足,配置状态与预期一致。`
|
|
210
|
+
if (ok === false) return `验证未通过:${failed}/${total} 项检查失败,详见第四章的失败原因。`
|
|
211
|
+
return (
|
|
212
|
+
`本次运行未验证:${unverified}/${total} 项检查没有断言,${passed} 项命令被设备接受。` +
|
|
213
|
+
'命令被接受不等于状态正确,这一章不能作为达标证明。'
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function verdictNote(evidence) {
|
|
218
|
+
const { ok } = evidence.verdict
|
|
219
|
+
if (ok === true) return '验证结论:通过。'
|
|
220
|
+
if (ok === false) return '验证结论:未通过。'
|
|
221
|
+
return '验证结论:未验证。这些检查没有断言,只能证明命令被设备接受。'
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Build the document blocks for a collected evidence set. */
|
|
225
|
+
export function buildReportBlocks(evidence) {
|
|
226
|
+
const blocks = []
|
|
227
|
+
const push = (...items) => blocks.push(...items)
|
|
228
|
+
|
|
229
|
+
push(
|
|
230
|
+
{ type: 'title', text: `${evidence.title} 实验报告` },
|
|
231
|
+
{ type: 'subtitle', text: `${evidence.id} · dsh-ensp · ${evidence.generatedAt}` },
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
const deviceCount = evidence.devices.length
|
|
235
|
+
push(
|
|
236
|
+
{ type: 'caption', text: '文档信息' },
|
|
237
|
+
{
|
|
238
|
+
type: 'table',
|
|
239
|
+
header: ['项目', '内容'],
|
|
240
|
+
rows: [
|
|
241
|
+
['实验场景', `${evidence.title}(${evidence.id})`],
|
|
242
|
+
['实验级别', evidence.level],
|
|
243
|
+
['设备数量', String(deviceCount)],
|
|
244
|
+
['拓扑文件', evidence.topologyPath ? basename(evidence.topologyPath) : '未指定,按蓝图记录'],
|
|
245
|
+
['验证来源', evidence.sourceKind === 'curated' ? '针对性断言' : '仅命令执行'],
|
|
246
|
+
['验证结论', verdictSentence(evidence)],
|
|
247
|
+
['生成时间', evidence.generatedAt],
|
|
248
|
+
],
|
|
249
|
+
widths: [2000, 7000],
|
|
250
|
+
},
|
|
251
|
+
{ type: 'note', text: verdictSentence(evidence) },
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
// ---- 一、实验目的
|
|
255
|
+
push({ type: 'heading', level: 1, text: '一、实验目的' })
|
|
256
|
+
push({ type: 'para', text: `本次实验的目标是${evidence.goal}。` })
|
|
257
|
+
if (evidence.steps.length) {
|
|
258
|
+
push({ type: 'para', text: '需要完成的工作:' })
|
|
259
|
+
push({ type: 'bullets', items: evidence.steps.map((s) => s.do) })
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ---- 二、拓扑与地址规划
|
|
263
|
+
push({ type: 'heading', level: 1, text: '二、拓扑与地址规划' })
|
|
264
|
+
if (deviceCount) {
|
|
265
|
+
push({ type: 'para', text: `实验拓扑包含 ${deviceCount} 台设备、${evidence.links.length} 条链路。` })
|
|
266
|
+
push(
|
|
267
|
+
{ type: 'caption', text: '表 2-1 设备清单' },
|
|
268
|
+
{
|
|
269
|
+
type: 'table',
|
|
270
|
+
header: ['设备', '型号', '控制台端口'],
|
|
271
|
+
rows: evidence.devices.map((d) => [d.name, d.model, d.comPort ? String(d.comPort) : '无']),
|
|
272
|
+
widths: [3000, 3000, 3000],
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
} else {
|
|
276
|
+
push({ type: 'para', text: '本次运行未指定拓扑文件,拓扑部分按蓝图记录,实际连接以运行的实验为准。' })
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (evidence.links.length) {
|
|
280
|
+
push(
|
|
281
|
+
{ type: 'caption', text: '表 2-2 链路连接' },
|
|
282
|
+
{
|
|
283
|
+
type: 'table',
|
|
284
|
+
header: ['本端设备', '本端接口', '对端设备', '对端接口', '介质'],
|
|
285
|
+
rows: evidence.links.map((l) => [l.a, l.aPort, l.b, l.bPort, l.media]),
|
|
286
|
+
widths: [1800, 2400, 1800, 2400, 1400],
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (evidence.addressing.length) {
|
|
292
|
+
push({ type: 'para', text: '地址规划如下表。接口名称与设备上的实际名称一致。' })
|
|
293
|
+
push(
|
|
294
|
+
{ type: 'caption', text: '表 2-3 地址规划' },
|
|
295
|
+
{
|
|
296
|
+
type: 'table',
|
|
297
|
+
header: ['设备', '接口', '地址', '说明'],
|
|
298
|
+
rows: evidence.addressing.map((row) => [row.device, row.port, row.ip, row.note || '']),
|
|
299
|
+
widths: [1600, 3000, 2600, 1800],
|
|
300
|
+
},
|
|
301
|
+
)
|
|
302
|
+
} else {
|
|
303
|
+
push({ type: 'note', text: '本场景的蓝图没有地址规划表,地址配置见第三章各设备的配置原文。' })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- 三、配置思路与命令
|
|
307
|
+
push({ type: 'heading', level: 1, text: '三、配置思路与命令' })
|
|
308
|
+
if (evidence.steps.length) {
|
|
309
|
+
push({ type: 'para', text: '配置顺序按下面的步骤推进,每一步的验证命令列在括号里。' })
|
|
310
|
+
push({
|
|
311
|
+
type: 'steps',
|
|
312
|
+
items: evidence.steps.map((s) => (s.verify ? `${s.do}(验证:${s.verify})` : s.do)),
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
push({
|
|
316
|
+
type: 'para',
|
|
317
|
+
text: '下面的配置原文取自各设备执行 `display current-configuration` 的结果,是本次运行实际生效的内容。',
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
for (const config of evidence.configs) {
|
|
321
|
+
push({ type: 'heading', level: 2, text: config.device })
|
|
322
|
+
if (config.note && !config.text) {
|
|
323
|
+
push({ type: 'note', text: config.note })
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
push({ type: 'code', text: config.text })
|
|
327
|
+
if (config.note) push({ type: 'note', text: config.note })
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- 四、验证结果
|
|
331
|
+
push({ type: 'heading', level: 1, text: '四、验证结果' })
|
|
332
|
+
push({ type: 'para', text: verdictSentence(evidence) })
|
|
333
|
+
|
|
334
|
+
let index = 0
|
|
335
|
+
for (const check of evidence.checks) {
|
|
336
|
+
index += 1
|
|
337
|
+
const mark = check.pass === true ? '通过' : check.pass === null ? '未验证' : '未通过'
|
|
338
|
+
push({ type: 'heading', level: 2, text: `4.${index} ${check.id}(${check.on})` })
|
|
339
|
+
push({ type: 'para', text: `检查命令:\`${check.command}\`。判定结果:${mark}。` })
|
|
340
|
+
if (check.output) {
|
|
341
|
+
push({ type: 'code', text: check.output })
|
|
342
|
+
} else {
|
|
343
|
+
push({ type: 'note', text: '该命令没有返回内容。' })
|
|
344
|
+
}
|
|
345
|
+
if (check.pass !== true) {
|
|
346
|
+
const waited = check.waitedMs ? `(已等待 ${Math.round(check.waitedMs / 1000)} 秒)` : ''
|
|
347
|
+
push({ type: 'note', text: `判定说明:${check.why}${waited}` })
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
push({ type: 'note', text: verdictNote(evidence) })
|
|
352
|
+
|
|
353
|
+
// ---- 五、问题与结论
|
|
354
|
+
push({ type: 'heading', level: 1, text: '五、问题与结论' })
|
|
355
|
+
if (evidence.pitfalls.length) {
|
|
356
|
+
push({ type: 'para', text: '这个实验容易出错的地方:' })
|
|
357
|
+
push({ type: 'bullets', items: evidence.pitfalls })
|
|
358
|
+
}
|
|
359
|
+
push({ type: 'heading', level: 2, text: '结论' })
|
|
360
|
+
push({ type: 'para', text: verdictSentence(evidence) })
|
|
361
|
+
|
|
362
|
+
const failed = evidence.checks.filter((c) => c.pass === false)
|
|
363
|
+
if (failed.length) {
|
|
364
|
+
push({ type: 'para', text: '需要处理的问题:' })
|
|
365
|
+
push({ type: 'bullets', items: failed.map((c) => `${c.id}(${c.on}):${c.why}`) })
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- 六、附录
|
|
369
|
+
push({ type: 'heading', level: 1, text: '六、附录' })
|
|
370
|
+
push({ type: 'heading', level: 2, text: '复现步骤' })
|
|
371
|
+
push({
|
|
372
|
+
type: 'steps',
|
|
373
|
+
items: [
|
|
374
|
+
`生成拓扑:\`ensp preset ${evidence.id} --topo ${evidence.id}.topo\``,
|
|
375
|
+
`启动设备:\`ensp up --topology ${evidence.id}.topo\``,
|
|
376
|
+
'按第三章的配置原文在各设备上执行配置',
|
|
377
|
+
`运行验证:\`ensp verify ${evidence.id} --topology ${evidence.id}.topo\``,
|
|
378
|
+
`生成文档:\`ensp report ${evidence.id} --topology ${evidence.id}.topo\``,
|
|
379
|
+
],
|
|
380
|
+
})
|
|
381
|
+
push({ type: 'heading', level: 2, text: '验证命令清单' })
|
|
382
|
+
push({ type: 'caption', text: '表 6-1 验证命令与结果' })
|
|
383
|
+
push({
|
|
384
|
+
type: 'table',
|
|
385
|
+
header: ['序号', '设备', '命令', '结果'],
|
|
386
|
+
rows: evidence.checks.slice(0, APPENDIX_ROWS).map((c, i) => [
|
|
387
|
+
String(i + 1),
|
|
388
|
+
String(c.on),
|
|
389
|
+
c.command,
|
|
390
|
+
c.pass === true ? '通过' : c.pass === null ? '未验证' : '未通过',
|
|
391
|
+
]),
|
|
392
|
+
widths: [900, 1600, 5100, 1400],
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
return blocks
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Collect evidence, render the document and write it to disk.
|
|
400
|
+
*
|
|
401
|
+
* @returns {Promise<{path: string, bytes: number, evidence: object}>}
|
|
402
|
+
*/
|
|
403
|
+
export async function writeReport(options = {}) {
|
|
404
|
+
const { out, ...rest } = options
|
|
405
|
+
const evidence = await collectEvidence(rest)
|
|
406
|
+
const blocks = buildReportBlocks(evidence)
|
|
407
|
+
const buffer = buildDocx(blocks)
|
|
408
|
+
const path = out || `${evidence.id}-report.docx`
|
|
409
|
+
writeFileSync(path, buffer)
|
|
410
|
+
return { path, bytes: buffer.length, evidence }
|
|
411
|
+
}
|