@iasiv5/dsh-obmc-web 0.0.1
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/LICENSE +21 -0
- package/README.md +136 -0
- package/cordis.patch.yml +3 -0
- package/deploy/obmc-web-relay.service.example +22 -0
- package/deploy/relay.env.example +38 -0
- package/lib/client.js +497 -0
- package/lib/index.js +812 -0
- package/package.json +57 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
// dsh-obmc-web client half.
|
|
2
|
+
//
|
|
3
|
+
// Single surface: a first-level「BMC 控制台」entry in the Settings menu
|
|
4
|
+
// (the `settings.section` slot, the same mechanism dsh-surf uses for
|
|
5
|
+
// 「网络冲浪」).
|
|
6
|
+
//
|
|
7
|
+
// The auth edge (dsh-auth-caddy) stamps X-Frame-Options DENY on every
|
|
8
|
+
// response, so the BMC UI cannot render inside a sidebar iframe through the
|
|
9
|
+
// public entries. The entry is therefore a launcher panel: it polls the
|
|
10
|
+
// same-origin /bmc-status route (fetch is unaffected by XFO; ?probe=1 asks
|
|
11
|
+
// the host for a live tunnel verdict) and opens the console with
|
|
12
|
+
// window.open — a TOP-LEVEL navigation, where X-Frame-Options does not
|
|
13
|
+
// apply. When the DHCP-assigned BMC address changes, the host discovers it
|
|
14
|
+
// over SSH and retargets the tunnel; the panel shows the current target
|
|
15
|
+
// and the latest discovery outcome.
|
|
16
|
+
//
|
|
17
|
+
// The package registration id MUST equal package.json `name`.
|
|
18
|
+
window.__ModuleLoader__.load({
|
|
19
|
+
id: '@iasiv5/dsh-obmc-web',
|
|
20
|
+
factory: (require) => {
|
|
21
|
+
var module = { exports: {} }
|
|
22
|
+
var exports = module.exports
|
|
23
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
24
|
+
const React = require('react')
|
|
25
|
+
|
|
26
|
+
const SECTION_ID = 'dsh-obmc-web'
|
|
27
|
+
const ENTRY_URL = '/bmc'
|
|
28
|
+
const STATUS_URL = '/bmc-status'
|
|
29
|
+
const CONFIG_URL = '/bmc-config'
|
|
30
|
+
const RELAY_INSTALL_URL = '/bmc-relay-install'
|
|
31
|
+
|
|
32
|
+
function openBmcConsole() {
|
|
33
|
+
window.open(ENTRY_URL, '_blank', 'noopener,noreferrer')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatTime(epochMs) {
|
|
37
|
+
if (!epochMs) return ''
|
|
38
|
+
try {
|
|
39
|
+
return new Date(epochMs).toLocaleTimeString()
|
|
40
|
+
} catch {
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const CHIP = {
|
|
46
|
+
online: { color: '#34d399', label: '隧道在线 · BMC' },
|
|
47
|
+
discovering: { color: '#60a5fa', label: '正在 SSH 探测 BMC 新 IP…' },
|
|
48
|
+
auth: { color: '#fbbf24', label: 'DSH 会话已过期,请重新登录后重试' },
|
|
49
|
+
degraded: { color: '#fbbf24', label: '可达但响应异常' },
|
|
50
|
+
offline: { color: '#f87171', label: 'BMC 不可达(探测/切换中或 relay 未运行)' },
|
|
51
|
+
probing: { color: '#9ca3af', label: '探测中…' },
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Shared status store: the whole panel polls /bmc-status ONCE (not one
|
|
55
|
+
// loop per component), and every subscriber renders the same snapshot.
|
|
56
|
+
// ?probe=1 makes the host verify the tunnel live; the returned `ok`
|
|
57
|
+
// field is the authoritative tunnel verdict for the chip. Cadence is
|
|
58
|
+
// 15s, 2s while a discovery round is running. Tunnel-affecting actions
|
|
59
|
+
// (save/probe/relay install) call refreshStatus() so the chip follows
|
|
60
|
+
// the action's own verdict within a second or two instead of lagging
|
|
61
|
+
// behind it by up to a full interval.
|
|
62
|
+
const statusStore = { data: null, listeners: new Set(), timer: null, inFlight: false, pending: false }
|
|
63
|
+
|
|
64
|
+
async function fetchStatus() {
|
|
65
|
+
let next = { __chip: 'offline' }
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${STATUS_URL}?probe=1`, { cache: 'no-store' })
|
|
68
|
+
if (response.redirected && String(response.url).includes('/auth/login')) {
|
|
69
|
+
next = { __chip: 'auth' }
|
|
70
|
+
} else if (!response.ok) {
|
|
71
|
+
next = { __chip: 'degraded' }
|
|
72
|
+
} else {
|
|
73
|
+
const data = await response.json()
|
|
74
|
+
if (data.discovering) next = { ...data, __chip: 'discovering' }
|
|
75
|
+
else if (data.ok === true) next = { ...data, __chip: 'online' }
|
|
76
|
+
else if (data.ok === false) next = { ...data, __chip: 'offline' }
|
|
77
|
+
else next = { ...data, __chip: 'probing' }
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
next = { __chip: 'offline' }
|
|
81
|
+
}
|
|
82
|
+
return next
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function scheduleNext() {
|
|
86
|
+
clearTimeout(statusStore.timer)
|
|
87
|
+
if (statusStore.listeners.size === 0) return
|
|
88
|
+
const discovering = statusStore.data !== null && statusStore.data.discovering
|
|
89
|
+
statusStore.timer = setTimeout(pollStatus, discovering ? 2_000 : 15_000)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applyStatus(next) {
|
|
93
|
+
statusStore.data = next
|
|
94
|
+
for (const listener of statusStore.listeners) listener(next)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function pollStatus() {
|
|
98
|
+
if (statusStore.inFlight) {
|
|
99
|
+
statusStore.pending = true
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
statusStore.inFlight = true
|
|
103
|
+
try {
|
|
104
|
+
applyStatus(await fetchStatus())
|
|
105
|
+
} finally {
|
|
106
|
+
statusStore.inFlight = false
|
|
107
|
+
}
|
|
108
|
+
// A refresh requested mid-flight re-runs once the stale round ends,
|
|
109
|
+
// so an action's immediate probe is never swallowed by an older one.
|
|
110
|
+
if (statusStore.pending && statusStore.listeners.size > 0) {
|
|
111
|
+
statusStore.pending = false
|
|
112
|
+
pollStatus()
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
scheduleNext()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Immediate re-probe: cancels the pending interval tick. */
|
|
119
|
+
function refreshStatus() {
|
|
120
|
+
if (statusStore.listeners.size === 0) return
|
|
121
|
+
statusStore.pending = false
|
|
122
|
+
clearTimeout(statusStore.timer)
|
|
123
|
+
pollStatus()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function useBmcStatus() {
|
|
127
|
+
const [status, setStatus] = React.useState(() => statusStore.data)
|
|
128
|
+
React.useEffect(() => {
|
|
129
|
+
const listener = next => setStatus(next)
|
|
130
|
+
statusStore.listeners.add(listener)
|
|
131
|
+
if (statusStore.listeners.size === 1) pollStatus()
|
|
132
|
+
return () => {
|
|
133
|
+
statusStore.listeners.delete(listener)
|
|
134
|
+
if (statusStore.listeners.size === 0) clearTimeout(statusStore.timer)
|
|
135
|
+
}
|
|
136
|
+
}, [])
|
|
137
|
+
return status
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function StatusChip() {
|
|
141
|
+
const status = useBmcStatus()
|
|
142
|
+
const chip = CHIP[status === null ? 'probing' : status.__chip] || CHIP.probing
|
|
143
|
+
return React.createElement(
|
|
144
|
+
'span',
|
|
145
|
+
{
|
|
146
|
+
style: {
|
|
147
|
+
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
|
148
|
+
fontSize: '12px', padding: '3px 10px', borderRadius: '999px',
|
|
149
|
+
border: '1px solid rgba(128,128,128,.4)',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
React.createElement('span', {
|
|
153
|
+
style: {
|
|
154
|
+
width: '8px', height: '8px', borderRadius: '50%',
|
|
155
|
+
background: chip.color, boxShadow: `0 0 6px ${chip.color}`, flex: 'none',
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
chip.label,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function TargetLine() {
|
|
163
|
+
const status = useBmcStatus()
|
|
164
|
+
const discovery = status && status.lastDiscovery
|
|
165
|
+
return React.createElement(
|
|
166
|
+
'div',
|
|
167
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '4px' } },
|
|
168
|
+
React.createElement(
|
|
169
|
+
'div',
|
|
170
|
+
{ style: { fontSize: '12.5px', fontFamily: 'ui-monospace,Consolas,monospace' } },
|
|
171
|
+
'当前 BMC IP:',
|
|
172
|
+
React.createElement('strong', null, (status && status.target) || '未知'),
|
|
173
|
+
),
|
|
174
|
+
discovery && discovery.verified
|
|
175
|
+
? React.createElement(
|
|
176
|
+
'div',
|
|
177
|
+
{ style: { fontSize: '11.5px', opacity: 0.75 } },
|
|
178
|
+
`最近探测:${discovery.found}(${formatTime(discovery.at)},隧道已切换并验证)`,
|
|
179
|
+
)
|
|
180
|
+
: null,
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function OpenButton() {
|
|
185
|
+
const status = useBmcStatus()
|
|
186
|
+
const unconfigured = status !== null && !status.target
|
|
187
|
+
return React.createElement(
|
|
188
|
+
'button',
|
|
189
|
+
{
|
|
190
|
+
type: 'button',
|
|
191
|
+
onClick: openBmcConsole,
|
|
192
|
+
disabled: unconfigured,
|
|
193
|
+
title: unconfigured ? '尚未配置 BMC 地址:先在下方部署配置里探测或填写' : undefined,
|
|
194
|
+
style: {
|
|
195
|
+
fontSize: '14px', padding: '10px 22px', borderRadius: '10px',
|
|
196
|
+
cursor: unconfigured ? 'default' : 'pointer', border: '1px solid rgba(59,130,246,.55)',
|
|
197
|
+
background: 'rgba(59,130,246,.16)', color: 'inherit', opacity: unconfigured ? 0.5 : 1,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
'🖥 打开 BMC Web 控制台',
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const HINT = '新标签页顶层打开(同源代理 /bmc → SSH 隧道 → BMC)。若 BMC 的 IP 由 DHCP 动态分配,变化失联时会自动 SSH 到跳板机探测新地址并切换隧道,也可点「重新探测 IP」手动触发。登录凭据在 BMC 页面内输入,不会存储。'
|
|
205
|
+
|
|
206
|
+
const inputStyle = {
|
|
207
|
+
fontSize: '13px', padding: '6px 10px', borderRadius: '8px',
|
|
208
|
+
border: '1px solid rgba(128,128,128,.45)', background: 'transparent', color: 'inherit',
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Deployment form: reads/writes the shared env file through
|
|
212
|
+
// GET/POST /bmc-config; one-click relay unit install via
|
|
213
|
+
// POST /bmc-relay-install. Fresh machines need nothing but this form.
|
|
214
|
+
function DeployConfigForm() {
|
|
215
|
+
const [envFile, setEnvFile] = React.useState('')
|
|
216
|
+
const [relay, setRelay] = React.useState(null)
|
|
217
|
+
const [form, setForm] = React.useState(null)
|
|
218
|
+
const [busy, setBusy] = React.useState(false)
|
|
219
|
+
const [msg, setMsg] = React.useState(null)
|
|
220
|
+
|
|
221
|
+
const load = React.useCallback(async () => {
|
|
222
|
+
try {
|
|
223
|
+
const response = await fetch(CONFIG_URL, { cache: 'no-store' })
|
|
224
|
+
if (!response.ok) return
|
|
225
|
+
const data = await response.json()
|
|
226
|
+
setEnvFile(data.envFile || '')
|
|
227
|
+
setRelay(data.relay || null)
|
|
228
|
+
setForm({
|
|
229
|
+
bmcTarget: (data.config && data.config.bmcTarget) || '',
|
|
230
|
+
sshTarget: (data.config && data.config.sshTarget) || '',
|
|
231
|
+
sshPort: String((data.config && data.config.sshPort) ?? 22),
|
|
232
|
+
subnet: (data.config && data.config.subnet) || '',
|
|
233
|
+
relayPort: String((data.config && data.config.relayPort) ?? 18443),
|
|
234
|
+
})
|
|
235
|
+
} catch { /* panel still renders; the host may be older */ }
|
|
236
|
+
}, [])
|
|
237
|
+
React.useEffect(() => { load() }, [load])
|
|
238
|
+
|
|
239
|
+
if (form === null) {
|
|
240
|
+
return React.createElement('div', { style: { fontSize: '12px', opacity: 0.7 } }, '部署配置加载中…')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// One shared grid: every field gets the same track width, so all five
|
|
244
|
+
// inputs render the same length regardless of their label. auto-fit
|
|
245
|
+
// keeps two columns on the settings panel and collapses to one on
|
|
246
|
+
// narrow windows.
|
|
247
|
+
const field = (label, key, placeholder) => React.createElement(
|
|
248
|
+
'label',
|
|
249
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' } },
|
|
250
|
+
label,
|
|
251
|
+
React.createElement('input', {
|
|
252
|
+
value: form[key],
|
|
253
|
+
placeholder,
|
|
254
|
+
onChange: e => setForm({ ...form, [key]: e.target.value }),
|
|
255
|
+
style: inputStyle,
|
|
256
|
+
}),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
const saveForm = async () => {
|
|
260
|
+
const response = await fetch(CONFIG_URL, {
|
|
261
|
+
method: 'POST',
|
|
262
|
+
headers: { 'content-type': 'application/json' },
|
|
263
|
+
body: JSON.stringify(form),
|
|
264
|
+
})
|
|
265
|
+
const data = await response.json().catch(() => ({}))
|
|
266
|
+
if (!response.ok) return { ok: false, text: (data.errors || [data.error || '保存失败']).join(';') }
|
|
267
|
+
return { ok: true, data }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const save = async () => {
|
|
271
|
+
setBusy(true)
|
|
272
|
+
setMsg(null)
|
|
273
|
+
try {
|
|
274
|
+
const result = await saveForm()
|
|
275
|
+
if (!result.ok) {
|
|
276
|
+
setMsg({ ok: false, text: result.text })
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const { data } = result
|
|
280
|
+
setMsg({
|
|
281
|
+
ok: data.verified !== false,
|
|
282
|
+
text: data.verified === true
|
|
283
|
+
? '已保存,隧道验证通过'
|
|
284
|
+
: data.verified === false
|
|
285
|
+
? '已保存,但隧道验证未通过(检查跳板机免密登录与 BMC 地址)'
|
|
286
|
+
: '已保存',
|
|
287
|
+
})
|
|
288
|
+
await load()
|
|
289
|
+
} catch (error) {
|
|
290
|
+
setMsg({ ok: false, text: '保存失败:' + error.message })
|
|
291
|
+
} finally {
|
|
292
|
+
setBusy(false)
|
|
293
|
+
refreshStatus()
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Explicit discovery action: persist the form first (probe runs on the
|
|
298
|
+
// saved values), then start an SSH discovery round and wait for THAT
|
|
299
|
+
// round to end (roundEndedAt after our start), whatever it concludes.
|
|
300
|
+
const probe = async () => {
|
|
301
|
+
setBusy(true)
|
|
302
|
+
setMsg(null)
|
|
303
|
+
try {
|
|
304
|
+
const saved = await saveForm()
|
|
305
|
+
if (!saved.ok) {
|
|
306
|
+
setMsg({ ok: false, text: saved.text })
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
const startAt = Date.now()
|
|
310
|
+
const kick = await fetch(STATUS_URL, { method: 'POST', cache: 'no-store' })
|
|
311
|
+
.then(r => r.json()).catch(() => null)
|
|
312
|
+
if (kick && kick.started === false && kick.reason && !/already running/.test(kick.reason)) {
|
|
313
|
+
setMsg({ ok: false, text: kick.reason })
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
setMsg({ ok: true, text: '正在探测:SSH 登录跳板机查找 bmcweb(通常几秒到一分钟)…' })
|
|
317
|
+
let status = null
|
|
318
|
+
for (let i = 0; i < 45; i++) {
|
|
319
|
+
await new Promise(resolve => setTimeout(resolve, 2_000))
|
|
320
|
+
status = await fetch(STATUS_URL, { cache: 'no-store' }).then(r => r.json()).catch(() => null)
|
|
321
|
+
if (status && (status.roundEndedAt || 0) > startAt) break
|
|
322
|
+
}
|
|
323
|
+
const cfg = await fetch(CONFIG_URL, { cache: 'no-store' }).then(r => r.json()).catch(() => null)
|
|
324
|
+
const found = cfg && cfg.config && cfg.config.bmcTarget
|
|
325
|
+
const relayInstalled = cfg && cfg.relay && cfg.relay.installed
|
|
326
|
+
if (found) {
|
|
327
|
+
setMsg({
|
|
328
|
+
ok: true,
|
|
329
|
+
text: `探测成功:BMC 地址 ${found}${relayInstalled ? ',隧道已就绪' : ',接下来点「安装并启动 relay 服务」打通隧道'}`,
|
|
330
|
+
})
|
|
331
|
+
} else {
|
|
332
|
+
setMsg({ ok: false, text: (status && status.lastError) || '未发现 bmcweb:请检查跳板机免密登录、网段或 BMC 是否在线' })
|
|
333
|
+
}
|
|
334
|
+
await load()
|
|
335
|
+
} catch (error) {
|
|
336
|
+
setMsg({ ok: false, text: '探测失败:' + error.message })
|
|
337
|
+
} finally {
|
|
338
|
+
setBusy(false)
|
|
339
|
+
refreshStatus()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const installRelay = async () => {
|
|
344
|
+
setBusy(true)
|
|
345
|
+
setMsg(null)
|
|
346
|
+
try {
|
|
347
|
+
const response = await fetch(RELAY_INSTALL_URL, { method: 'POST' })
|
|
348
|
+
const data = await response.json().catch(() => ({}))
|
|
349
|
+
if (!response.ok) {
|
|
350
|
+
setMsg({ ok: false, text: (data.errors || [data.error || '安装失败']).join(';') })
|
|
351
|
+
} else {
|
|
352
|
+
const adjusted = data.portAdjusted ? `;原端口被占用,已自动改用 ${data.portAdjusted}` : ''
|
|
353
|
+
setMsg({
|
|
354
|
+
ok: data.verified !== false,
|
|
355
|
+
text: data.verified === true
|
|
356
|
+
? `relay 服务已安装并启动,隧道验证通过${adjusted}`
|
|
357
|
+
: `relay 服务已写入,但隧道验证未通过(查看 systemctl --user status obmc-web-relay)${adjusted}`,
|
|
358
|
+
})
|
|
359
|
+
await load()
|
|
360
|
+
}
|
|
361
|
+
} catch (error) {
|
|
362
|
+
setMsg({ ok: false, text: '安装失败:' + error.message })
|
|
363
|
+
} finally {
|
|
364
|
+
setBusy(false)
|
|
365
|
+
refreshStatus()
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const button = (label, onClick, disabled, primary, title, runningLabel) => React.createElement(
|
|
370
|
+
'button',
|
|
371
|
+
{
|
|
372
|
+
type: 'button',
|
|
373
|
+
onClick,
|
|
374
|
+
disabled: disabled || busy,
|
|
375
|
+
title,
|
|
376
|
+
style: {
|
|
377
|
+
fontSize: '12px', padding: '6px 14px', borderRadius: '8px',
|
|
378
|
+
cursor: disabled || busy ? 'default' : 'pointer',
|
|
379
|
+
border: primary ? '1px solid rgba(59,130,246,.55)' : '1px solid rgba(128,128,128,.45)',
|
|
380
|
+
background: primary ? 'rgba(59,130,246,.16)' : 'transparent',
|
|
381
|
+
color: 'inherit', opacity: disabled || busy ? 0.55 : 1,
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
busy ? (runningLabel || '处理中…') : label,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
const probeDisabled = !String(form.sshTarget || '').trim()
|
|
388
|
+
const probeTitle = probeDisabled
|
|
389
|
+
? '先填写 SSH 跳板机(探测需要 SSH 登录它)'
|
|
390
|
+
: form.bmcTarget
|
|
391
|
+
? 'BMC IP 变化后重新查找并自动切换隧道'
|
|
392
|
+
: '用跳板机自动发现 BMC 地址并回填'
|
|
393
|
+
const probeLabel = form.bmcTarget ? '重新探测 IP' : '探测 BMC IP'
|
|
394
|
+
|
|
395
|
+
return React.createElement(
|
|
396
|
+
'details',
|
|
397
|
+
{ style: { border: '1px solid rgba(128,128,128,.3)', borderRadius: '10px', padding: '10px 12px' } },
|
|
398
|
+
React.createElement('summary', { style: { cursor: 'pointer', fontSize: '13px' } }, '部署配置(BMC 地址 / SSH 跳板机 / 端口)'),
|
|
399
|
+
React.createElement(
|
|
400
|
+
'div',
|
|
401
|
+
{ style: { display: 'flex', flexDirection: 'column', gap: '10px', paddingTop: '10px' } },
|
|
402
|
+
React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '10px' } },
|
|
403
|
+
field('SSH 跳板机 user@host(探测必填)', 'sshTarget', 'user@你的跳板机地址'),
|
|
404
|
+
field('跳板机 SSH 端口', 'sshPort', '默认 22'),
|
|
405
|
+
field('BMC 地址(留空可自动探测)', 'bmcTarget', 'ip 或 ip:port,如 192.0.2.10'),
|
|
406
|
+
field('BMC 所在网段(可选)', 'subnet', 'a.b.c,默认自动判断'),
|
|
407
|
+
field('relay 本地端口', 'relayPort', '默认 18443'),
|
|
408
|
+
),
|
|
409
|
+
React.createElement(
|
|
410
|
+
'div',
|
|
411
|
+
{ style: { display: 'flex', gap: '10px', alignItems: 'center', flexWrap: 'wrap' } },
|
|
412
|
+
button('保存配置', save, false, true, undefined, '保存中…'),
|
|
413
|
+
button(probeLabel, probe, probeDisabled, false, probeTitle, '探测中…'),
|
|
414
|
+
button(relay && relay.installed ? '重装/更新 relay 服务' : '安装并启动 relay 服务', installRelay, false, false, undefined, '安装中…'),
|
|
415
|
+
relay && React.createElement('span', { style: { fontSize: '11.5px', opacity: 0.75 } },
|
|
416
|
+
`relay 服务:${relay.installed ? '已安装' : '未安装'} · 状态 ${relay.active}`),
|
|
417
|
+
),
|
|
418
|
+
msg && React.createElement(
|
|
419
|
+
'div',
|
|
420
|
+
{ style: { fontSize: '12px', color: msg.ok ? 'inherit' : '#f87171', lineHeight: 1.5 } },
|
|
421
|
+
msg.text,
|
|
422
|
+
),
|
|
423
|
+
React.createElement('div', { style: { fontSize: '11.5px', opacity: 0.65, lineHeight: 1.5 } },
|
|
424
|
+
'配置存储于 ', React.createElement('code', null, envFile || '(host 未上报)'),
|
|
425
|
+
',也可手动编辑(改完重启 dsh web 生效)。跳板机需已配置对本机的 SSH 免密登录。',
|
|
426
|
+
),
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Settings →「BMC 控制台」: mirrors dsh-surf's settings.section pattern.
|
|
432
|
+
function BmcSettingsSection() {
|
|
433
|
+
const status = useBmcStatus()
|
|
434
|
+
const unconfigured = status !== null && !status.target
|
|
435
|
+
return React.createElement(
|
|
436
|
+
'section',
|
|
437
|
+
{
|
|
438
|
+
'data-obmc-web': 'settings-section',
|
|
439
|
+
style: {
|
|
440
|
+
display: 'flex', flexDirection: 'column', gap: '12px', padding: '16px',
|
|
441
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
|
|
442
|
+
borderRadius: '12px',
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
React.createElement('strong', null, 'BMC 控制台'),
|
|
446
|
+
React.createElement(StatusChip),
|
|
447
|
+
React.createElement(TargetLine),
|
|
448
|
+
unconfigured
|
|
449
|
+
? React.createElement(
|
|
450
|
+
'div',
|
|
451
|
+
{
|
|
452
|
+
'data-obmc-web': 'unconfigured-notice',
|
|
453
|
+
style: {
|
|
454
|
+
fontSize: '12.5px', lineHeight: 1.6, padding: '10px 12px', borderRadius: '8px',
|
|
455
|
+
border: '1px solid rgba(251,191,36,.5)', color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
456
|
+
},
|
|
457
|
+
},
|
|
458
|
+
'尚未配置 BMC 地址:展开下方「部署配置」,填好 SSH 跳板机后点「探测 BMC IP」即可自动发现地址,然后安装 relay 服务。',
|
|
459
|
+
)
|
|
460
|
+
: null,
|
|
461
|
+
React.createElement(DeployConfigForm),
|
|
462
|
+
React.createElement(
|
|
463
|
+
'div',
|
|
464
|
+
{ style: { display: 'flex', gap: '10px', flexWrap: 'wrap', alignItems: 'center' } },
|
|
465
|
+
React.createElement(OpenButton),
|
|
466
|
+
),
|
|
467
|
+
React.createElement(
|
|
468
|
+
'p',
|
|
469
|
+
{ style: { margin: 0, color: 'var(--dsw-alias-label-secondary, inherit)', fontSize: '12px', lineHeight: 1.6 } },
|
|
470
|
+
HINT,
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function apply(ctx) {
|
|
476
|
+
const slots = ctx.slots
|
|
477
|
+
if (slots && typeof slots.inject === 'function') {
|
|
478
|
+
ctx.slots.inject('settings.section', () => slots.register(
|
|
479
|
+
{
|
|
480
|
+
name: 'settings.section',
|
|
481
|
+
id: SECTION_ID,
|
|
482
|
+
order: 131,
|
|
483
|
+
label: 'BMC 控制台',
|
|
484
|
+
},
|
|
485
|
+
BmcSettingsSection,
|
|
486
|
+
))
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
exports.name = 'dsh-obmc-web/client'
|
|
491
|
+
exports.inject = ['slots']
|
|
492
|
+
exports.apply = apply
|
|
493
|
+
exports.BmcSettingsSection = BmcSettingsSection
|
|
494
|
+
exports.openBmcConsole = openBmcConsole
|
|
495
|
+
return module.exports
|
|
496
|
+
},
|
|
497
|
+
})
|