@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/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
+ })