@mzzsfy/dsh-maintain 0.5.2

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/src/client.js ADDED
@@ -0,0 +1,781 @@
1
+ // dsh-maintain Client 半区:settings.section 设置页面板,版本监测 + 一键升级 + 安全重启。
2
+ // 以 DSH client-modules 自注册格式发布:__ModuleLoader__.load({id, factory}),
3
+ // factory(require) 中 require('react') 由 DSH client runtime 模块表解析;
4
+ // 浏览器半区经 webServer 路由('/api/maintain/*')访问 Host,样式随组件内联渲染。
5
+
6
+ window.__ModuleLoader__.load({
7
+ id: '@mzzsfy/dsh-maintain',
8
+ factory(require) {
9
+ const React = require('react')
10
+ const { useState, useEffect, useRef } = React
11
+
12
+ // 面板反馈出口:公共依赖 @mzzsfy/dsh-toast,可选消费——占位条目由
13
+ // session-manager 唯一代挂,权威方未安装时降级 console,不挂死不报错
14
+ let toast = null
15
+ try {
16
+ toast = require('@mzzsfy/dsh-toast/client').show
17
+ } catch {
18
+ // 模块表无 toast → 反馈降级 console.warn
19
+ }
20
+
21
+ const notify = (text, kind) => {
22
+ if (toast) toast(text, { kind: kind === 'ok' ? 'ok' : 'error' })
23
+ else console.warn('[dsh-maintain] ' + text)
24
+ }
25
+
26
+ // 导航图标声明:交给 dsh-settings-nav-icons 统一渲染(本插件分区 → wrench);
27
+ // 该插件未就绪时入队,由其启动时排空
28
+ const NAV_ICON = { '版本与运维': 'wrench' }
29
+ if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
30
+ else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
31
+ else window.__navicIconQueue = [NAV_ICON]
32
+
33
+ const CSS = [
34
+ '.dm-panel { display:flex; flex-direction:column; gap:12px; color:inherit; font-size:13px; --dm-warn:#d97706; }',
35
+ '.dm-head { display:flex; align-items:center; gap:8px; }',
36
+ '.dm-head__title { font-weight:600; font-size:14px; }',
37
+ '.dm-head__hint { color:var(--dsw-alias-label-secondary); font-size:12px; }',
38
+ '.dm-btn { cursor:pointer; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); background:transparent;',
39
+ ' color:inherit; border-radius:6px; padding:3px 10px; font-size:12px; }',
40
+ '.dm-btn:hover { opacity:0.8; }',
41
+ '.dm-btn:disabled { opacity:0.45; cursor:default; }',
42
+ '.dm-btn--danger { color:var(--dsw-alias-state-error-primary, #d43a3a); border-color:var(--dsw-alias-state-error-primary, #d43a3a); }',
43
+ '.dm-card { border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); border-radius:10px; padding:10px 12px;',
44
+ ' display:flex; flex-direction:column; gap:8px; }',
45
+ '.dm-card__title { font-weight:600; font-size:12px; color:var(--dsw-alias-label-secondary); }',
46
+ '.dm-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }',
47
+ '.dm-row__label { width:72px; flex:none; font-size:12px; color:var(--dsw-alias-label-secondary); white-space:nowrap; }',
48
+ '.dm-spacer { flex:1; }',
49
+ '.dm-meta { color:var(--dsw-alias-label-secondary); font-size:12px; }',
50
+ '.dm-error { color:var(--dsw-alias-state-error-primary, #d43a3a); font-size:12px; }',
51
+ '.dm-ok { color:var(--dsw-alias-state-success-primary, #1a9e55); }',
52
+ '.dm-warn { color:var(--dm-warn); }',
53
+ '.dm-badge { font-size:11px; padding:1px 8px; border-radius:999px; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35));',
54
+ ' color:var(--dsw-alias-label-secondary); }',
55
+ '.dm-badge--new { color:var(--dsw-alias-state-error-primary, #d43a3a); border-color:var(--dsw-alias-state-error-primary, #d43a3a); }',
56
+ '.dm-badge--ok { color:var(--dsw-alias-state-success-primary, #1a9e55); border-color:var(--dsw-alias-state-success-primary, #1a9e55); }',
57
+ '.dm-select { background:transparent; color:inherit; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35));',
58
+ ' border-radius:6px; padding:2px 6px; font-size:12px; font-family:inherit; }',
59
+ '.dm-input { background:transparent; color:inherit; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35));',
60
+ ' border-radius:6px; padding:2px 6px; font-size:12px; font-family:ui-monospace, Consolas, monospace; flex:1; min-width:160px; }',
61
+ '.dm-input:focus { outline:none; border-color:var(--dsw-alias-label-secondary, rgba(128,128,128,0.6)); }',
62
+ '.dm-input:disabled { opacity:0.45; }',
63
+ '.dm-link { color:var(--dsw-alias-label-secondary); font-size:12px; }',
64
+ '.dm-notice { font-size:12px; padding:6px 10px; border-radius:6px; border:1px solid var(--dsw-alias-separator-primary, rgba(128,128,128,0.35)); }',
65
+ '.dm-notice--error { color:var(--dsw-alias-state-error-primary, #d43a3a); }',
66
+ '.dm-notice--warn { color:var(--dm-warn); border-color:var(--dm-warn); }',
67
+ '.dm-notice--ok { color:var(--dsw-alias-state-success-primary, #1a9e55); }',
68
+ '.dm-pre { margin:0; font-family:ui-monospace, Consolas, monospace; font-size:11px; line-height:1.6; white-space:pre-wrap;',
69
+ ' word-break:break-all; color:var(--dsw-alias-label-secondary); max-height:120px; overflow:auto; }',
70
+ ].join('\n')
71
+
72
+ const STATUS_URL = '/api/maintain/status'
73
+ const REFRESH_URL = '/api/maintain/refresh'
74
+ const CHANNEL_URL = '/api/maintain/channel'
75
+ const TEMPLATE_URL = '/api/maintain/upgrade-template'
76
+ const POLL_INTERVAL_URL = '/api/maintain/poll-interval'
77
+ const REGISTRY_BASE_URL = '/api/maintain/registry-base'
78
+ const UPGRADE_URL = '/api/maintain/upgrade'
79
+ const RESTART_URL = '/api/maintain/restart'
80
+ const UPGRADE_POLL_MS = 2 * 1000
81
+ // 升级提示浮条挂 body,脱离 React 组件树,SPA 切页不消失
82
+ const UPGRADE_FLOAT_ID = 'dsh-maintain-upgrade-float'
83
+ const UPGRADE_FLOAT_STYLE_ID = UPGRADE_FLOAT_ID + '__style'
84
+ // 与 host 侧轮询 tick(TICK_MS)同源:间隔设置的生效粒度受固定 tick 调度限制
85
+ const POLL_MIN_TICK_SECONDS = 60
86
+ // 与 host 侧默认值同源(DEFAULT_POLL_INTERVAL_SEC / DEFAULT_REGISTRY_BASE / DEFAULT_UPGRADE_TEMPLATE):
87
+ // 等于默认值时输入框留空以 placeholder 展示,清空保存即恢复默认
88
+ const DEFAULT_UPGRADE_TEMPLATE = 'npm install -g @deepseek-ai/dsh@{tag}'
89
+ const DEFAULT_POLL_INTERVAL_SEC = 6 * 60 * 60
90
+ const DEFAULT_REGISTRY_BASE = 'https://registry.npmjs.org'
91
+ const RESTART_POLL_MS = 1 * 1000
92
+ const RESTART_POLL_TIMEOUT_MS = 5 * 1000
93
+ // 重启等待总时长:超时说明宿主未被进程管理器拉起或退出失败,退出等待态转人工处理
94
+ const RESTART_TIMEOUT_MS = 30 * 1000
95
+ // 主文档连续就绪拍数门槛:单次 200 可能落在 fallback 刚注册的瞬间,连续多次确认启动链稳定
96
+ const RESTART_READY_REQUIRED = 2
97
+ // 刷新放行后的最后缓冲:给宿主启动链一段无请求干扰的稳定期再整页刷新
98
+ const RESTART_SETTLE_DELAY_MS = 1 * 1000
99
+ // 页面就绪探测目标:与整页刷新后浏览器实际请求的主文档同资源
100
+ const INDEX_URL = '/'
101
+ const NPM_VERSIONS_URL = 'https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions'
102
+
103
+ // abort-aware 睡眠:轮询顺序循环的拍间间隔,中止即提前唤醒
104
+ function restartSleep(ms, controller) {
105
+ return new Promise((resolve) => {
106
+ const done = () => {
107
+ controller.signal.removeEventListener('abort', done)
108
+ clearTimeout(timer)
109
+ resolve()
110
+ }
111
+ const timer = setTimeout(done, ms)
112
+ controller.signal.addEventListener('abort', done)
113
+ })
114
+ }
115
+
116
+ // 错误分类:重启 POST 失败后是否应视为"宿主失联"继续等待。
117
+ // 无应答(TypeError/abort)属失联;502-504 网关错误发生在宿主退出窗口内,同样属失联——
118
+ // 只有网关可达、宿主应答的状态(409 互斥/500 能力缺失等)才是活宿主的明确回绝。
119
+ // LOGIC-BEGIN restartPostLost
120
+ function restartPostLost(error) {
121
+ const hasStatus = error !== null && typeof error === 'object' && typeof error.status === 'number'
122
+ if (!hasStatus) return true
123
+ return error.status >= 502 && error.status <= 504
124
+ }
125
+ // LOGIC-END restartPostLost
126
+
127
+ // 判定常量与 host 侧 core.mjs 保持一致:client 半区无法 import ESM,
128
+ // test/parity.test.mjs 按 const 名正则提取对拍,修改任一侧必须同步。
129
+ const VERDICT_OUTDATED = 'outdated'
130
+ const VERDICT_UP_TO_DATE = 'up-to-date'
131
+ const VERDICT_UNKNOWN = 'unknown'
132
+
133
+ // 非 2xx 应答抛带 status 的错误对象:调用方据此区分"活宿主明确回绝"(有 status)
134
+ // 与"网络失联"(fetch reject TypeError/abort DOMException,无 status)
135
+ // LOGIC-BEGIN apiError
136
+ function apiError(response, payload) {
137
+ const error = new Error(payload && payload.error ? payload.error : 'HTTP ' + response.status)
138
+ error.status = response.status
139
+ return error
140
+ }
141
+ // LOGIC-END apiError
142
+
143
+ async function api(url, options) {
144
+ const response = await fetch(url, {
145
+ headers: { 'content-type': 'application/json' },
146
+ ...options,
147
+ })
148
+ const payload = await response.json().catch(() => ({}))
149
+ if (!response.ok) throw apiError(response, payload)
150
+ return payload
151
+ }
152
+
153
+ function post(url, body) {
154
+ return api(url, { method: 'POST', body: body === undefined ? '{}' : JSON.stringify(body) })
155
+ }
156
+
157
+ // 升级观察器:模块级单例,升级进行中每拍拉取状态并广播,组件挂载与否不影响;
158
+ // 发现任意一拍不在进行中即升级落定,展示结果浮条后停止轮询;
159
+ // 总时长上限与宿主升级命令超时同语义(见 parity 对拍):超限说明宿主或进程管理器异常,浮条转状态未知。
160
+ // 代际令牌防重叠拍:顺序自调度(settle 后排下一拍),单请求带超时,stop 后迟到拍经验代际丢弃
161
+ const UPGRADE_WATCH_MAX_MS = 10 * 60 * 1000
162
+ const UPGRADE_POLL_TIMEOUT_MS = 5 * 1000
163
+ const upgradeWatch = { generation: null, startedAt: 0, listeners: new Set() }
164
+
165
+ function broadcastUpgradeStatus(status) {
166
+ for (const listener of upgradeWatch.listeners) listener(status)
167
+ }
168
+
169
+ function stopUpgradeWatch() {
170
+ upgradeWatch.generation = null
171
+ }
172
+
173
+ function subscribeUpgradeStatus(listener) {
174
+ upgradeWatch.listeners.add(listener)
175
+ return () => upgradeWatch.listeners.delete(listener)
176
+ }
177
+
178
+ function ensureUpgradeWatch() {
179
+ if (upgradeWatch.generation !== null) return
180
+ const generation = {}
181
+ upgradeWatch.generation = generation
182
+ upgradeWatch.startedAt = Date.now()
183
+ showUpgradeFloat(null)
184
+ let pollFailures = 0
185
+ const tick = async () => {
186
+ if (upgradeWatch.generation !== generation) return
187
+ if (Date.now() - upgradeWatch.startedAt >= UPGRADE_WATCH_MAX_MS) {
188
+ upgradeWatch.generation = null
189
+ showUpgradeFloat('unknown')
190
+ return
191
+ }
192
+ try {
193
+ const next = await api(STATUS_URL, { signal: AbortSignal.timeout(UPGRADE_POLL_TIMEOUT_MS) })
194
+ if (upgradeWatch.generation !== generation) return
195
+ pollFailures = 0
196
+ broadcastUpgradeStatus(next)
197
+ const upgrade = next ? next.upgrade : null
198
+ if (!upgrade || upgrade.running !== true) {
199
+ upgradeWatch.generation = null
200
+ // 快照归零(last 为空)不做失败渲染,保持进行中文案,由重启流程接管
201
+ if (upgrade && upgrade.last) showUpgradeFloat(upgrade.last)
202
+ return
203
+ }
204
+ } catch (pollError) {
205
+ if (upgradeWatch.generation !== generation) return
206
+ pollFailures += 1
207
+ // 失败限频:首条与之后每 15 条(约 30 秒)一条,防宿主重启窗口刷屏
208
+ if (pollFailures === 1 || pollFailures % 15 === 0) {
209
+ console.warn('[dsh-maintain] 升级状态轮询失败: ' + (pollError && pollError.message ? pollError.message : String(pollError)))
210
+ }
211
+ }
212
+ if (upgradeWatch.generation === generation) setTimeout(tick, UPGRADE_POLL_MS)
213
+ }
214
+ tick()
215
+ }
216
+
217
+ function ensureUpgradeFloatStyle() {
218
+ if (document.getElementById(UPGRADE_FLOAT_STYLE_ID) !== null) return
219
+ const style = document.createElement('style')
220
+ style.id = UPGRADE_FLOAT_STYLE_ID
221
+ style.textContent = [
222
+ '#' + UPGRADE_FLOAT_ID + ' { position:fixed; right:20px; bottom:20px; z-index:9999; display:flex; align-items:center; gap:10px;',
223
+ ' max-width:340px; padding:10px 14px; border-radius:10px; border:1px solid rgba(128,128,128,0.35);',
224
+ ' background:var(--dsw-alias-bg-layer-3, #fff); box-shadow:0 4px 16px rgba(0,0,0,0.15); font-size:13px; }',
225
+ '#' + UPGRADE_FLOAT_ID + '__close { cursor:pointer; border:0; background:transparent; color:inherit; font-size:14px; padding:0 2px; opacity:0.6; }',
226
+ '#' + UPGRADE_FLOAT_ID + '__close:hover { opacity:1; }',
227
+ ].join('\n')
228
+ document.head.appendChild(style)
229
+ }
230
+
231
+ // state:null=进行中;对象=升级结果(last,ok 区分成败);'unknown'=观察超限状态未知
232
+ function showUpgradeFloat(state) {
233
+ ensureUpgradeFloatStyle()
234
+ removeUpgradeFloat()
235
+ const text = document.createElement('span')
236
+ const close = document.createElement('button')
237
+ close.id = UPGRADE_FLOAT_ID + '__close'
238
+ close.textContent = '×'
239
+ close.addEventListener('click', removeUpgradeFloat)
240
+ const box = document.createElement('div')
241
+ box.id = UPGRADE_FLOAT_ID
242
+ box.appendChild(text)
243
+ box.appendChild(close)
244
+ if (state === null) {
245
+ text.textContent = '升级进行中,可离开本页,完成后此处提示'
246
+ } else if (state === 'unknown') {
247
+ text.textContent = '升级状态长时间未更新,请刷新页面查看'
248
+ } else if (state.ok) {
249
+ text.textContent = '升级完成,重启宿主后生效(版本与运维页可重启)'
250
+ } else if (state.stillRunning === true) {
251
+ // 强杀后进程树疑似仍存活:升级入口已由锁锁定直至过期,不做"可重跑"的误导指引
252
+ text.textContent = '升级超时已终止;旧进程可能仍在运行,升级入口已锁定直至其退出或锁过期'
253
+ } else if (state.timedOut === true) {
254
+ // 超时强杀即包管理器被中途杀死,全局目录可能半写,提示先验证再重启
255
+ text.textContent = '升级超时已终止;安装可能只完成一半,重启宿主前请先确认命令需否重跑'
256
+ } else {
257
+ // 浮条只做通知,stderr 摘要在版本与运维页完整展示
258
+ text.textContent = '升级失败:' + (state.error || '详情见版本与运维页')
259
+ }
260
+ document.body.appendChild(box)
261
+ }
262
+
263
+ function removeUpgradeFloat() {
264
+ const box = document.getElementById(UPGRADE_FLOAT_ID)
265
+ if (box !== null) box.remove()
266
+ }
267
+
268
+ function fmtTime(ms) {
269
+ const t = Number(ms)
270
+ if (t !== t || !t) return null
271
+ const d = new Date(t)
272
+ const pad = (n) => (n < 10 ? '0' : '') + n
273
+ return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
274
+ }
275
+
276
+ function fmtElapsed(startedAt) {
277
+ const seconds = Math.max(0, Math.round((Date.now() - startedAt) / 1000))
278
+ if (seconds < 60) return seconds + ' 秒'
279
+ if (seconds < 60 * 60) return Math.floor(seconds / 60) + ' 分 ' + (seconds % 60) + ' 秒'
280
+ return Math.floor(seconds / (60 * 60)) + ' 小时 ' + Math.floor((seconds % (60 * 60)) / 60) + ' 分'
281
+ }
282
+
283
+ function h(type, props) {
284
+ const children = Array.prototype.slice.call(arguments, 2)
285
+ return React.createElement.apply(React, [type, props || null].concat(children))
286
+ }
287
+
288
+ // 结论徽章:落后显示目标版本,未知给原因,最新为绿色。
289
+ function VerdictBadge(props) {
290
+ const status = props.status
291
+ if (status.verdict === VERDICT_OUTDATED) {
292
+ return h('span', { className: 'dm-badge dm-badge--new' }, '有新版本 ' + status.channelLatest)
293
+ }
294
+ if (status.verdict === VERDICT_UP_TO_DATE) {
295
+ return h('span', { className: 'dm-badge dm-badge--ok' }, '已是最新')
296
+ }
297
+ return h('span', { className: 'dm-badge' }, '未知:' + (status.reason || '等待检查'))
298
+ }
299
+
300
+ // 可编辑设置行:label + 文本框 + 保存按钮;value 为服务端当前值,key 变化即外部更新时重置输入。
301
+ function EditRow(props) {
302
+ const inputRef = useRef(null)
303
+ const read = () => (inputRef.current ? inputRef.current.value : '')
304
+ return h('div', { className: 'dm-row' },
305
+ h('span', { className: 'dm-row__label' }, props.label),
306
+ h('input', {
307
+ className: 'dm-input',
308
+ ref: inputRef,
309
+ key: props.value == null ? '' : props.value,
310
+ defaultValue: props.value == null ? '' : props.value,
311
+ placeholder: props.placeholder || '',
312
+ disabled: props.busy || props.restarting,
313
+ onKeyDown: (e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) props.onSave(read()) },
314
+ }),
315
+ h('button', {
316
+ className: 'dm-btn',
317
+ disabled: props.busy || props.restarting,
318
+ onClick: () => props.onSave(read()),
319
+ }, props.busy ? '保存中…' : '保存'),
320
+ props.hint ? h('span', { className: 'dm-meta' }, props.hint) : null,
321
+ )
322
+ }
323
+
324
+ // 版本区:当前版本、通道切换、通道最新版、结论、检查时间与错误、刷新与升级、升级命令编辑。
325
+ function VersionCard(props) {
326
+ const status = props.status
327
+ const tags = status.tags || {}
328
+ const tagNames = Object.keys(tags)
329
+ const checked = fmtTime(status.checkedAt)
330
+ return h('div', { className: 'dm-card' },
331
+ h('div', { className: 'dm-row' },
332
+ h('span', { className: 'dm-row__label' }, '当前版本'),
333
+ h('span', null, status.currentVersion || '未知'),
334
+ h(VerdictBadge, { status }),
335
+ h('span', { className: 'dm-spacer' }),
336
+ h('button', {
337
+ className: 'dm-btn',
338
+ disabled: props.busy.refresh || props.restarting,
339
+ onClick: props.onRefresh,
340
+ }, props.busy.refresh ? '检查中…' : '刷新'),
341
+ h('button', {
342
+ className: 'dm-btn',
343
+ // upgradeLockHeld 为 host 权威信号(锁文件时效化):升级进行中或残留锁未过期时禁用
344
+ disabled: props.busy.upgrade || props.restarting || (status.upgrade && status.upgrade.running)
345
+ || status.upgradeLockHeld === true,
346
+ onClick: props.onUpgrade,
347
+ }, props.upgradeArmed ? '确认升级' : '升级'),
348
+ ),
349
+ h('div', { className: 'dm-row' },
350
+ h('span', { className: 'dm-row__label' }, '追踪通道'),
351
+ tagNames.length > 0
352
+ // 持久化通道不在通道表中(registry 侧删 tag/换镜像):作为标注项保留在 select 内,
353
+ // 保留切换出口(否则通道切换能力不可达,只能手改 settings.yaml)
354
+ ? h('select', {
355
+ className: 'dm-select',
356
+ value: tagNames.indexOf(status.channel) >= 0 ? status.channel : tagNames[0],
357
+ onChange: (e) => props.onChannel(e.target.value),
358
+ disabled: props.busy.channel || props.restarting,
359
+ }, (tagNames.indexOf(status.channel) >= 0 ? [] : [status.channel]).concat(tagNames)
360
+ .map((name) => h('option', { key: name, value: name }, name
361
+ + (name === status.channel && tagNames.indexOf(name) < 0 ? ' (不在通道表中,请重新选择)' : '')
362
+ + (tags[name] ? ' (' + tags[name] + ')' : ''))))
363
+ : h('span', { className: 'dm-meta' }, status.channel + '(通道表未就绪)'),
364
+ checked ? h('span', { className: 'dm-meta' }, '上次检查 ' + checked) : null,
365
+ status.checkError ? h('span', { className: 'dm-error' }, status.checkError) : null,
366
+ h('a', { className: 'dm-link', href: NPM_VERSIONS_URL, target: '_blank', rel: 'noreferrer' }, 'npm 版本页'),
367
+ ),
368
+ h(EditRow, {
369
+ label: '升级命令',
370
+ value: status.upgradeTemplate === DEFAULT_UPGRADE_TEMPLATE ? '' : status.upgradeTemplate,
371
+ placeholder: DEFAULT_UPGRADE_TEMPLATE,
372
+ busy: props.busy.template,
373
+ restarting: props.restarting,
374
+ onSave: props.onTemplateSave,
375
+ hint: '默认以灰字提示,清空保存即恢复默认;{tag} 执行时替换为追踪通道;升级完成后需重启生效',
376
+ }),
377
+ )
378
+ }
379
+
380
+ // 设置区:轮询间隔与 registry 基地址,保存即时生效。
381
+ function SettingsCard(props) {
382
+ const status = props.status
383
+ return h('div', { className: 'dm-card' },
384
+ h('div', { className: 'dm-card__title' }, '设置'),
385
+ h(EditRow, {
386
+ label: '轮询间隔',
387
+ value: status.pollIntervalSec === DEFAULT_POLL_INTERVAL_SEC ? '' : status.pollIntervalSec,
388
+ placeholder: DEFAULT_POLL_INTERVAL_SEC,
389
+ busy: props.busy.pollInterval,
390
+ restarting: props.restarting,
391
+ onSave: props.onPollInterval,
392
+ hint: '默认以灰字提示,清空保存即恢复默认;0 表示仅手动检查;最小生效粒度 ' + POLL_MIN_TICK_SECONDS + ' 秒',
393
+ }),
394
+ h(EditRow, {
395
+ label: '镜像地址',
396
+ value: status.registryBase === DEFAULT_REGISTRY_BASE ? '' : status.registryBase,
397
+ placeholder: DEFAULT_REGISTRY_BASE,
398
+ busy: props.busy.registryBase,
399
+ restarting: props.restarting,
400
+ onSave: props.onRegistryBase,
401
+ hint: '默认以灰字提示,清空保存即恢复默认;官方源不可达时改为镜像',
402
+ }),
403
+ )
404
+ }
405
+
406
+ // 升级状态区:进行中显示命令与已用时;结束后显示结果,成功附重启入口,失败附 stderr 摘要。
407
+ function UpgradeCard(props) {
408
+ const upgrade = props.status.upgrade
409
+ if (!upgrade || (!upgrade.running && !upgrade.last)) return null
410
+ const last = upgrade.last
411
+ return h('div', { className: 'dm-card' },
412
+ h('div', { className: 'dm-card__title' }, '升级'),
413
+ upgrade.running
414
+ ? h('div', { className: 'dm-row' },
415
+ h('span', { className: 'dm-warn' }, '升级进行中'),
416
+ h('span', { className: 'dm-meta' }, last ? last.command : ''),
417
+ h('span', { className: 'dm-meta' }, '已用时 ' + fmtElapsed(last ? last.startedAt : Date.now())),
418
+ )
419
+ : null,
420
+ !upgrade.running && last
421
+ ? h('div', { className: 'dm-row' },
422
+ last.ok ? h('span', { className: 'dm-ok' }, '升级完成,重启宿主后生效')
423
+ : h('span', { className: 'dm-error' }, '升级失败' + (last.code !== null && last.code !== undefined ? '(退出码 ' + last.code + ')' : '') + (last.timedOut ? ',已超时终止;安装可能只完成一半,重启前先确认命令需否重跑' : '')),
424
+ h('span', { className: 'dm-spacer' }),
425
+ last.ok ? h('button', {
426
+ className: 'dm-btn',
427
+ // 与 OpsCard 入口行为归一:appExit 缺失时必然 500,不得开放重启入口
428
+ disabled: props.restarting || !props.status.canRestart,
429
+ onClick: props.onRestart,
430
+ }, props.restartArmed ? '确认重启' : '重启宿主') : null,
431
+ )
432
+ : null,
433
+ !upgrade.running && last && !last.ok && last.stderrTail
434
+ ? h('pre', { className: 'dm-pre' }, last.stderrTail)
435
+ : null,
436
+ )
437
+ }
438
+
439
+ // 运维区:两段式重启 + 托管环境说明。
440
+ function OpsCard(props) {
441
+ const status = props.status
442
+ return h('div', { className: 'dm-card' },
443
+ h('div', { className: 'dm-card__title' }, '重启'),
444
+ h('div', { className: 'dm-row' },
445
+ h('button', {
446
+ className: 'dm-btn dm-btn--danger',
447
+ disabled: !status.canRestart || props.restarting,
448
+ onClick: props.onRestart,
449
+ }, props.armed ? '确认重启' : '重启宿主'),
450
+ !status.canRestart ? h('span', { className: 'dm-meta' }, '当前启动方式不支持就地重启') : null,
451
+ ),
452
+ h('div', { className: 'dm-meta' },
453
+ '重启依赖进程管理器(pm2 / systemd / nssm / Docker 等)自动拉起;手动终端启动的进程不会自动恢复。',
454
+ '重启后本页自动检测宿主恢复并刷新;若长时间未恢复请手动刷新。运行中的 agent 将被中断,会话已持久化,重开后可 resume。'),
455
+ )
456
+ }
457
+
458
+ function MaintainApp() {
459
+ const [status, setStatus] = useState(null)
460
+ const [error, setError] = useState(null)
461
+ const [busy, setBusy] = useState({})
462
+ const [upgradeArmed, setUpgradeArmed] = useState(false)
463
+ const [restartArmed, setRestartArmed] = useState(false)
464
+ const [restarting, setRestarting] = useState(false)
465
+ // 重启探测的跨拍状态快照 {lost,pid,bootAt,readyStreak}:lost 与 readyStreak 随拍更新,
466
+ // pid/bootAt 保持确认时刻基线不可被拍结果覆盖,否则重启判定恒 false;null=未在等待态
467
+ const restartPrevRef = useRef(null)
468
+ const restartPendingRef = useRef(false)
469
+
470
+ function markBusy(key, value) {
471
+ setBusy((prev) => Object.assign({}, prev, { [key]: value }))
472
+ }
473
+
474
+ function load() {
475
+ return api(STATUS_URL)
476
+ .then((next) => { setStatus(next); setError(null); return next })
477
+ .catch((loadError) => { setError('读取状态失败:' + (loadError && loadError.message ? loadError.message : String(loadError))); return null })
478
+ }
479
+
480
+ // 挂载即订阅观察器快照,页面刷新落在升级进行中时恢复浮条与观察
481
+ useEffect(() => {
482
+ const unsubscribe = subscribeUpgradeStatus((next) => {
483
+ if (next !== null) setStatus(next)
484
+ })
485
+ void load().then((next) => {
486
+ if (next !== null && next.upgrade !== null && next.upgrade.running === true) ensureUpgradeWatch()
487
+ })
488
+ return unsubscribe
489
+ }, [])
490
+
491
+ // 重启判定与 core.mjs shouldReloadAfterRestart 同源:client 半区无法 import ESM,修改需两处同步。
492
+ // prev/next 均为 {lost,pid,bootAt} 快照:lost=经历失联(强信号);bootAt=宿主进程启动时刻,
493
+ // 变化即重启(容器 pid 恒 1 场景的唯一可靠信号);pid 比对为 bootAt 缺失时的退化路径
494
+ // LOGIC-BEGIN shouldReloadAfterRestart
495
+ function shouldReloadAfterRestart(prev, next) {
496
+ if (prev.lost) return true
497
+ if (typeof prev.bootAt === 'number' && typeof next.bootAt === 'number') return prev.bootAt !== next.bootAt
498
+ return typeof prev.pid === 'number' && typeof next.pid === 'number' && prev.pid !== next.pid
499
+ }
500
+ // LOGIC-END shouldReloadAfterRestart
501
+
502
+ // LOGIC-BEGIN pageReady
503
+ function pageReady(pageFetch) {
504
+ return pageFetch.then(
505
+ (response) => Boolean(response && response.ok)
506
+ && /^text\/html\b/i.test(String(response.headers.get('content-type') || '')),
507
+ () => false,
508
+ )
509
+ }
510
+ // LOGIC-END pageReady
511
+
512
+ // LOGIC-BEGIN restartTick
513
+ // 重启等待的单拍决策:超时收尾;status 失败记失联;宿主重启判定通过后还须主文档可加载才刷新——
514
+ // status 可达只证明 API 路由已注册,宿主页面服务的 fallback 注册晚于插件路由,
515
+ // 该窗口期内整页刷新会拿到宿主裸 404;就绪须连续多拍确认(readyStreak 跨拍由调用方持有,
516
+ // 任何失联/未确认/未就绪拍都清零),达标返回 reload,刷新动作由调用方延迟执行
517
+ async function restartTick({ now, deadlineAt, prev, readyStreak, statusFetch, pageFetch }) {
518
+ if (now >= deadlineAt) return { action: 'timeout', lost: prev.lost, readyStreak }
519
+ let next
520
+ try {
521
+ next = await statusFetch()
522
+ } catch {
523
+ return { action: 'wait', lost: true, readyStreak: 0 }
524
+ }
525
+ const snapshot = { lost: false, pid: next ? next.pid : null, bootAt: next ? next.bootAt : null }
526
+ if (!shouldReloadAfterRestart(prev, snapshot)) {
527
+ return { action: 'wait', lost: prev.lost, readyStreak: 0 }
528
+ }
529
+ const ready = await pageReady(pageFetch())
530
+ const streak = ready ? readyStreak + 1 : 0
531
+ return { action: streak >= RESTART_READY_REQUIRED ? 'reload' : 'wait', lost: prev.lost, readyStreak: streak }
532
+ }
533
+ // LOGIC-END restartTick
534
+
535
+ // 重启确认后顺序轮询(restartTick 拍决策):任意时刻至多一拍在途(settle 后排下一拍),
536
+ // 消除 setInterval 重叠拍的失联标记回退与超时后迟到拍 reload 竞态;卸载经 AbortController 中止
537
+ useEffect(() => {
538
+ if (!restarting) {
539
+ restartPrevRef.current = null
540
+ return
541
+ }
542
+ const deadlineAt = Date.now() + RESTART_TIMEOUT_MS
543
+ const controller = new AbortController()
544
+ const probeSignal = () => AbortSignal.any([controller.signal, AbortSignal.timeout(RESTART_POLL_TIMEOUT_MS)])
545
+ ;(async () => {
546
+ while (!controller.signal.aborted) {
547
+ const prev = restartPrevRef.current
548
+ if (prev === null) return
549
+ const tick = await restartTick({
550
+ now: Date.now(),
551
+ deadlineAt,
552
+ prev,
553
+ statusFetch: () => api(STATUS_URL, { signal: probeSignal() }),
554
+ pageFetch: () => fetch(INDEX_URL, { cache: 'no-store', signal: probeSignal() }),
555
+ })
556
+ if (controller.signal.aborted) return
557
+ // 基线不可变契约:仅回写 lost 与 readyStreak,pid/bootAt 保持确认时刻值(见 ref 注释)
558
+ restartPrevRef.current = { ...prev, lost: tick.lost, readyStreak: tick.readyStreak }
559
+ if (tick.action === 'reload') {
560
+ // 刷新放行后的最后缓冲:中止即作废本次刷新(等待态已退出,页面归用户控制)
561
+ await restartSleep(RESTART_SETTLE_DELAY_MS, controller)
562
+ if (controller.signal.aborted) return
563
+ window.location.reload()
564
+ return
565
+ }
566
+ if (tick.action === 'timeout') {
567
+ setRestarting(false)
568
+ setError('重启未在 ' + RESTART_TIMEOUT_MS / 1000 + ' 秒内完成,宿主可能未被拉起,请检查进程管理器后手动刷新')
569
+ return
570
+ }
571
+ await restartSleep(RESTART_POLL_MS, controller)
572
+ }
573
+ })()
574
+ return () => controller.abort()
575
+ }, [restarting])
576
+
577
+ function onRefresh() {
578
+ markBusy('refresh', true)
579
+ post(REFRESH_URL)
580
+ .then((next) => { setStatus(next); setError(null) })
581
+ .catch((refreshError) => notify('检查失败:' + (refreshError && refreshError.message ? refreshError.message : String(refreshError)), 'error'))
582
+ .then(() => markBusy('refresh', false))
583
+ }
584
+
585
+ function onChannel(channel) {
586
+ markBusy('channel', true)
587
+ post(CHANNEL_URL, { channel })
588
+ .then((next) => { setStatus(next); setError(null) })
589
+ .catch((channelError) => {
590
+ notify('切换失败:' + (channelError && channelError.message ? channelError.message : String(channelError)), 'error')
591
+ return load()
592
+ })
593
+ .then(() => markBusy('channel', false))
594
+ }
595
+
596
+ // 设置编辑公共提交链:校验通过后 POST 持久化,成功以响应快照刷新,失败回读兜底
597
+ function submitEdit(url, body, busyKey, onSaveError) {
598
+ markBusy(busyKey, true)
599
+ post(url, body)
600
+ .then((next) => { setStatus(next); setError(null) })
601
+ .catch((saveError) => {
602
+ notify(onSaveError(saveError), 'error')
603
+ return load()
604
+ })
605
+ .then(() => markBusy(busyKey, false))
606
+ }
607
+
608
+ function onTemplateSave(template) {
609
+ const text = typeof template === 'string' ? template.trim() : ''
610
+ // 空输入即恢复默认:输入框留空时以 placeholder 展示默认命令,保存空 = 回到默认模板
611
+ submitEdit(TEMPLATE_URL, { template: text.length > 0 ? text : DEFAULT_UPGRADE_TEMPLATE }, 'template', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
612
+ }
613
+
614
+ function onPollInterval(raw) {
615
+ const text = typeof raw === 'string' ? raw.trim() : ''
616
+ // 空输入即恢复默认:输入框留空时以 placeholder 展示默认间隔
617
+ if (text.length === 0) {
618
+ submitEdit(POLL_INTERVAL_URL, { seconds: DEFAULT_POLL_INTERVAL_SEC }, 'pollInterval', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
619
+ return
620
+ }
621
+ const seconds = Number(text)
622
+ if (!Number.isFinite(seconds) || seconds < 0) {
623
+ notify('轮询间隔必须是不小于 0 的秒数', 'error')
624
+ return
625
+ }
626
+ submitEdit(POLL_INTERVAL_URL, { seconds }, 'pollInterval', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
627
+ }
628
+
629
+ // 与 host 的 isValidRegistryBase 同源:client 半区无法 import ESM,修改需两处同步。
630
+ // 拒绝带 query/hash 的输入:拼接 dist-tags API 路径时 search 会吞掉路径导致检查恒败
631
+ // LOGIC-BEGIN isValidRegistryBase
632
+ function isValidRegistryBase(value) {
633
+ if (typeof value !== 'string') return false
634
+ try {
635
+ const parsed = new URL(value.trim())
636
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.search === '' && parsed.hash === ''
637
+ } catch {
638
+ return false
639
+ }
640
+ }
641
+ // LOGIC-END isValidRegistryBase
642
+
643
+ function onRegistryBase(raw) {
644
+ const base = typeof raw === 'string' ? raw.trim() : ''
645
+ // 空输入即恢复默认:输入框留空时以 placeholder 展示官方源
646
+ if (base.length === 0) {
647
+ submitEdit(REGISTRY_BASE_URL, { base: DEFAULT_REGISTRY_BASE }, 'registryBase', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
648
+ return
649
+ }
650
+ if (!isValidRegistryBase(base)) {
651
+ notify('registry 基地址须为 http(s) 地址且不带查询串或锚点', 'error')
652
+ return
653
+ }
654
+ submitEdit(REGISTRY_BASE_URL, { base }, 'registryBase', (error) => '保存失败:' + (error && error.message ? error.message : String(error)))
655
+ }
656
+
657
+ function onUpgrade() {
658
+ if (!upgradeArmed) {
659
+ setUpgradeArmed(true)
660
+ setRestartArmed(false)
661
+ return
662
+ }
663
+ setUpgradeArmed(false)
664
+ markBusy('upgrade', true)
665
+ post(UPGRADE_URL)
666
+ .then((next) => {
667
+ setStatus(next); setError(null)
668
+ // 升级转后台执行:观察器接管状态跟踪与完成提示,页面可离开
669
+ ensureUpgradeWatch()
670
+ })
671
+ .catch((upgradeError) => notify('升级触发失败:' + (upgradeError && upgradeError.message ? upgradeError.message : String(upgradeError)), 'error'))
672
+ .then(() => markBusy('upgrade', false))
673
+ }
674
+
675
+ function onRestart() {
676
+ if (restartPendingRef.current) return
677
+ if (!restartArmed) {
678
+ setRestartArmed(true)
679
+ setUpgradeArmed(false)
680
+ return
681
+ }
682
+ setRestartArmed(false)
683
+ setError(null)
684
+ restartPrevRef.current = {
685
+ lost: false,
686
+ pid: status && typeof status.pid === 'number' ? status.pid : null,
687
+ bootAt: status && typeof status.bootAt === 'number' ? status.bootAt : null,
688
+ readyStreak: 0,
689
+ }
690
+ // 先取实时 status 修正基线(页面可能经历过一次未经面板感知的宿主重启),失败退回已有快照
691
+ restartPendingRef.current = true
692
+ api(STATUS_URL, { signal: AbortSignal.timeout(RESTART_POLL_TIMEOUT_MS) })
693
+ .then((fresh) => {
694
+ if (fresh) {
695
+ restartPrevRef.current = {
696
+ lost: false,
697
+ pid: typeof fresh.pid === 'number' ? fresh.pid : restartPrevRef.current.pid,
698
+ bootAt: typeof fresh.bootAt === 'number' ? fresh.bootAt : restartPrevRef.current.bootAt,
699
+ readyStreak: 0,
700
+ }
701
+ }
702
+ })
703
+ .catch(() => {})
704
+ .then(() => {
705
+ setRestarting(true)
706
+ // 宿主重启后内存快照归零,升级观察与浮条随重启作废;
707
+ // 放在受理成功之后:明确回绝(409 互斥)时升级仍在跑,观察器必须存活
708
+ stopUpgradeWatch()
709
+ removeUpgradeFloat()
710
+ return post(RESTART_URL)
711
+ })
712
+ .catch((restartError) => {
713
+ if (restartPostLost(restartError)) {
714
+ // 无应答(连接失败/网关失联/中止)不构成宿主状态证据:视为失联继续等待,
715
+ // 由恢复探测或总时长超时收尾
716
+ if (restartPrevRef.current !== null) restartPrevRef.current = { ...restartPrevRef.current, lost: true }
717
+ return
718
+ }
719
+ // 有应答的明确回绝(409 升级互斥/500 能力缺失等):宿主未退出,不得进入等待轮询,
720
+ // 否则会凭"活宿主 + pageReady 恒真"误判已重启而整页刷新
721
+ setRestarting(false)
722
+ notify('重启失败:' + (restartError && restartError.message ? restartError.message : String(restartError)), 'error')
723
+ })
724
+ .then(() => { restartPendingRef.current = false })
725
+ }
726
+
727
+ if (status === null) {
728
+ return h('div', { className: 'dm-panel' },
729
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
730
+ h('span', { className: 'dm-meta' }, error !== null ? '读取失败' : '加载中…'),
731
+ error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null)
732
+ }
733
+
734
+ return h('div', { className: 'dm-panel' },
735
+ h('style', { dangerouslySetInnerHTML: { __html: CSS } }),
736
+ h('div', { className: 'dm-head' },
737
+ h('span', { className: 'dm-head__title' }, '版本与运维'),
738
+ h('span', { className: 'dm-head__hint' }, '追踪 npm 新版本,一键升级,安全重启'),
739
+ ),
740
+ restarting ? h('div', { className: 'dm-notice dm-notice--warn' },
741
+ '重启指令已发送,宿主正在退出;恢复后本页自动刷新,请勿关闭页面。') : null,
742
+ status !== null && status.pollRunning === false ? h('div', { className: 'dm-notice dm-notice--warn' },
743
+ '自动轮询未运行(宿主定时服务不可用);可手动点「检查更新」,其余能力不受影响。') : null,
744
+ error !== null ? h('div', { className: 'dm-notice dm-notice--error' }, error) : null,
745
+ h(VersionCard, {
746
+ status,
747
+ busy,
748
+ upgradeArmed,
749
+ restartArmed,
750
+ restarting,
751
+ onRefresh,
752
+ onChannel,
753
+ onTemplateSave,
754
+ onUpgrade,
755
+ onRestart,
756
+ }),
757
+ h(SettingsCard, {
758
+ status,
759
+ busy,
760
+ restarting,
761
+ onPollInterval,
762
+ onRegistryBase,
763
+ }),
764
+ h(UpgradeCard, { status, restartArmed, restarting, onRestart }),
765
+ h(OpsCard, { status, armed: restartArmed, restarting, onRestart }),
766
+ )
767
+ }
768
+
769
+ return {
770
+ inject: ['slots'],
771
+ apply(ctx) {
772
+ ctx.slots.inject('settings.section', () =>
773
+ ctx.slots.register(
774
+ { name: 'settings.section', id: 'maintain', order: 45, label: '版本与运维' },
775
+ () => React.createElement(MaintainApp),
776
+ ))
777
+ },
778
+ }
779
+ },
780
+ })
781
+