@gehennawu/dsh-service 0.13.2 → 0.14.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 CHANGED
@@ -11,7 +11,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
11
11
  ### Version and updates
12
12
 
13
13
  - Displays current DSH and plugin versions with links to GitHub Releases
14
- - Automatically checks npm registry for stable and preview releases, shows update status
14
+ - Automatically checks npm registry for stable and preview releases; when a new version exists, the right-side status text (chevron + `New version`) is clickable to expand/collapse, showing the current/latest versions and both dist-tag versions inline, each with npmjs and npmmirror links (npmjs.com is blocked on some networks; npmmirror serves as the mirror entry)
15
15
  - One-click plugin upgrade with automatic restart after completion
16
16
 
17
17
  ### Safe restart
@@ -19,6 +19,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
19
19
  - Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
20
20
  - `/restart` command also available in conversations; automatically refuses when active work is detected
21
21
  - Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
22
+ - Optional `Restart` entry at the bottom of the settings left navigation, enabled by a switch in the Restart tab (off by default), sharing the exact same confirmation flow as the Restart tab
22
23
 
23
24
  ### Health diagnostics
24
25
 
@@ -44,6 +45,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
44
45
  ### Task notifications
45
46
 
46
47
  - Browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
48
+ - Clicking a notification focuses the DSH page and closes the popup
47
49
  - Three toggle switches: master switch, task completion, and approvals & questions, each controlled independently
48
50
  - Bell icon in the conversation input bar toggles the master switch quickly
49
51
  - All toggles persist across page reloads
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  ### 版本与更新
12
12
 
13
13
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
14
- - 自动检查 npm registry 的正式版和预览版,显示更新状态
14
+ - 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
15
15
  - 一键升级插件,升级后自动重启
16
16
 
17
17
  ### 安全重启
@@ -19,6 +19,7 @@
19
19
  - 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
20
20
  - 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
21
21
  - 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
22
+ - 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
22
23
 
23
24
  ### 健康诊断
24
25
 
@@ -44,6 +45,7 @@
44
45
  ### 任务通知
45
46
 
46
47
  - 会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
48
+ - 点击系统通知弹窗会聚焦到 DSH 页面并关闭该通知
47
49
  - 三档开关条:总开关、任务结束通知、授权与提问通知,各自独立控制
48
50
  - 对话栏内铃铛图标快速切换总开关
49
51
  - 各开关在页面刷新后保持
package/client.js CHANGED
@@ -10,6 +10,7 @@ window.__ModuleLoader__.load({
10
10
  const NS = 'dsh-service'
11
11
  const zh = {
12
12
  'nav.label': '服务控制',
13
+ 'nav.restart': '重启',
13
14
  'overlay.label': '服务重启状态',
14
15
  'recovery.waiting.title': '服务重启中…',
15
16
  'recovery.waiting.body': '正在等待新的 DSH Web 进程启动,已等待 {seconds} 秒。',
@@ -52,6 +53,7 @@ window.__ModuleLoader__.load({
52
53
  'health.detail.permissions.warning': '发现 {count} 个文件或目录权限异常',
53
54
  'health.detail.generic': '{status}',
54
55
  'tabs.overview': '概览',
56
+ 'tabs.notify': '通知',
55
57
  'tabs.health': '健康诊断',
56
58
  'tabs.usage': '模型统计',
57
59
  'overview.container': '容器信息',
@@ -106,6 +108,8 @@ window.__ModuleLoader__.load({
106
108
  'update.checking': '检查中…',
107
109
  'update.current': '已是最新版本',
108
110
  'update.available': '有新版本:{version}',
111
+ 'update.detailsButton': '查看详情',
112
+ 'update.detailsHide': '收起',
109
113
  'update.unavailable': '暂时无法检查最新版本',
110
114
  'update.unpublished': '尚未发布可检查版本',
111
115
  'health.recheck': '重新诊断',
@@ -113,7 +117,8 @@ window.__ModuleLoader__.load({
113
117
  'update.details.title': 'DSH 更新可用',
114
118
  'update.details.current': '当前版本:{version}',
115
119
  'update.details.latest': '最新版本:{version}',
116
- 'update.channels': '正式版 {latest} · 预览版 {next}',
120
+ 'update.channelStable': '正式版',
121
+ 'update.channelPreview': '预览版',
117
122
  'update.details.close': '关闭',
118
123
  'update.upgrade': '升级插件',
119
124
  'update.upgrading': '升级中…',
@@ -146,6 +151,8 @@ window.__ModuleLoader__.load({
146
151
  'restart.sent': '重启指令已发出。',
147
152
  'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
148
153
  'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
154
+ 'restart.navToggle': '设置页左列显示「重启」入口',
155
+ 'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
149
156
  'activity.agent': 'Agent',
150
157
  'activity.job': '后台任务',
151
158
  'activity.terminal': '终端',
@@ -212,6 +219,7 @@ window.__ModuleLoader__.load({
212
219
  }
213
220
  const en = {
214
221
  'nav.label': 'Service Control',
222
+ 'nav.restart': 'Restart',
215
223
  'overlay.label': 'Service restart status',
216
224
  'recovery.waiting.title': 'Restarting service…',
217
225
  'recovery.waiting.body': 'Waiting for a new DSH Web process. Elapsed: {seconds} seconds.',
@@ -254,6 +262,7 @@ window.__ModuleLoader__.load({
254
262
  'health.detail.permissions.warning': 'Found {count} file or directory permission anomalies',
255
263
  'health.detail.generic': '{status}',
256
264
  'tabs.overview': 'Overview',
265
+ 'tabs.notify': 'Notifications',
257
266
  'tabs.health': 'Health',
258
267
  'tabs.usage': 'Models',
259
268
  'overview.container': 'Container info',
@@ -308,6 +317,8 @@ window.__ModuleLoader__.load({
308
317
  'update.checking': 'Checking…',
309
318
  'update.current': 'Up to date',
310
319
  'update.available': 'New version: {version}',
320
+ 'update.detailsButton': 'View details',
321
+ 'update.detailsHide': 'Collapse',
311
322
  'update.unavailable': 'Latest version is temporarily unavailable',
312
323
  'update.unpublished': 'No published version is available to check',
313
324
  'health.recheck': 'Run again',
@@ -315,7 +326,8 @@ window.__ModuleLoader__.load({
315
326
  'update.details.title': 'DSH update available',
316
327
  'update.details.current': 'Current version: {version}',
317
328
  'update.details.latest': 'Latest version: {version}',
318
- 'update.channels': 'Stable {latest} · Preview {next}',
329
+ 'update.channelStable': 'Stable',
330
+ 'update.channelPreview': 'Preview',
319
331
  'update.details.close': 'Close',
320
332
  'update.upgrade': 'Upgrade plugin',
321
333
  'update.upgrading': 'Upgrading…',
@@ -348,6 +360,8 @@ window.__ModuleLoader__.load({
348
360
  'restart.sent': 'Restart request sent.',
349
361
  'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
350
362
  'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
363
+ 'restart.navToggle': 'Show "Restart" entry in settings left nav',
364
+ 'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
351
365
  'activity.agent': 'Agent',
352
366
  'activity.job': 'Background job',
353
367
  'activity.terminal': 'Terminal',
@@ -456,6 +470,36 @@ window.__ModuleLoader__.load({
456
470
  }, [])
457
471
  return { enabled, done, input, setEnabled: (v) => setNotifyEnabled(v), setDone: (v) => setNotifyDone(v), setInput: (v) => setNotifyInput(v) }
458
472
  }
473
+ // 设置页左列「重启」入口显示开关:默认关闭,localStorage 持久化;
474
+ // 开启时才注册 settings.section 条目(导航列单元格由外壳渲染,开关不生效就不能用 null 内容占位)。
475
+ let restartNavEnabled = false
476
+ try { restartNavEnabled = localStorage.getItem('dsh-service-restart-nav') === 'true' } catch (_) {}
477
+ let restartNavDispose = null
478
+ const syncRestartNavEntry = () => {
479
+ if (restartNavDispose) { restartNavDispose(); restartNavDispose = null }
480
+ if (!restartNavEnabled) return
481
+ restartNavDispose = ctx.slots.register(
482
+ { name: 'settings.section', id: 'dsh-service-restart', order: 499, label: () => t('nav.restart') },
483
+ () => React.createElement(RestartSection, null),
484
+ )
485
+ }
486
+ const restartNavListeners = new Set()
487
+ const setRestartNavEnabled = (value) => {
488
+ restartNavEnabled = value === true
489
+ try { localStorage.setItem('dsh-service-restart-nav', restartNavEnabled ? 'true' : 'false') } catch (_) {}
490
+ syncRestartNavEntry()
491
+ for (const listener of restartNavListeners) listener()
492
+ }
493
+ const useRestartNavEnabled = () => {
494
+ const [enabled, setEnabled] = useState(restartNavEnabled)
495
+ useEffect(() => {
496
+ const update = () => setEnabled(restartNavEnabled)
497
+ restartNavListeners.add(update)
498
+ setEnabled(restartNavEnabled)
499
+ return () => restartNavListeners.delete(update)
500
+ }, [])
501
+ return [enabled, setRestartNavEnabled]
502
+ }
459
503
  // 会话边沿通知:running→idle 记一次任务结束;pendingInteraction 出现记一次需要确认。
460
504
  // 数据源是客户端运行时的会话列表快照(订阅推送);首个快照只建立基线,重连后重建基线,二者都不响铃。
461
505
  const NOTIFY_KIND_KEYS = {
@@ -466,7 +510,15 @@ window.__ModuleLoader__.load({
466
510
  const notifyPermissionGranted = () => typeof Notification !== 'undefined' && Notification.permission === 'granted'
467
511
  const fireNotification = (title, body) => {
468
512
  if (!notifyPermissionGranted()) return
469
- try { new Notification(title, { body }) } catch (_) {}
513
+ try {
514
+ const notification = new Notification(title, { body })
515
+ // 点击系统通知弹窗:聚焦 DSH 页面并关闭该通知(chrome/ff 从通知点击回调
516
+ // 视为用户手势,window.focus() 可把标签页带到前台)
517
+ notification.onclick = () => {
518
+ try { window.focus() } catch (_) {}
519
+ try { notification.close() } catch (_) {}
520
+ }
521
+ } catch (_) {}
470
522
  }
471
523
  if (ctx.sessions && typeof ctx.sessions.list?.subscribe === 'function') {
472
524
  const observed = new Map()
@@ -540,6 +592,60 @@ window.__ModuleLoader__.load({
540
592
  return snapshot
541
593
  }
542
594
 
595
+ // 重启流程共享状态:设置面板「重启」标签与专属设置页共用同一份 stage/activity/busy/error,
596
+ // 触发路径(两段式确认、强制重启、恢复浮层)完全一致。
597
+ const restartFlowListeners = new Set()
598
+ let restartFlow = { stage: 0, activity: null, busy: false, error: null }
599
+ const setRestartFlow = (next) => {
600
+ restartFlow = next
601
+ for (const listener of restartFlowListeners) listener(next)
602
+ }
603
+ const useRestartFlow = () => {
604
+ const [snapshot, setSnapshot] = useState(restartFlow)
605
+ useEffect(() => {
606
+ restartFlowListeners.add(setSnapshot)
607
+ setSnapshot(restartFlow)
608
+ return () => restartFlowListeners.delete(setSnapshot)
609
+ }, [])
610
+ return snapshot
611
+ }
612
+ const checkRestart = async () => {
613
+ setRestartFlow({ ...restartFlow, busy: true, error: null })
614
+ try {
615
+ const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
616
+ if (res && res.ok === false) {
617
+ console.error('dsh-service: activity check failed', res.error)
618
+ throw new Error(t('error.activity'))
619
+ }
620
+ const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
621
+ setRestartFlow({ ...restartFlow, activity: nextActivity, stage: nextActivity.hasActive ? 3 : 1, busy: false, error: null })
622
+ } catch (err) {
623
+ setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
624
+ }
625
+ }
626
+ const restartWeb = async (force) => {
627
+ setRestartFlow({ ...restartFlow, busy: true, error: null })
628
+ try {
629
+ const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
630
+ if (res && res.ok === false) {
631
+ if (res.error === 'active-work' && res.value) {
632
+ setRestartFlow({ ...restartFlow, activity: res.value, stage: 3, busy: false, error: null })
633
+ return
634
+ }
635
+ console.error('dsh-service: restart failed', res.error)
636
+ throw new Error(t('error.restart'))
637
+ }
638
+ const previousInstanceId = res && res.value ? res.value.instanceId : undefined
639
+ if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
640
+ throw new Error(t('error.instance'))
641
+ }
642
+ setRestartFlow({ ...restartFlow, stage: 2, busy: false, error: null })
643
+ startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
644
+ } catch (err) {
645
+ setRestartFlow({ ...restartFlow, error: err && err.message ? String(err.message) : String(err), stage: 0, busy: false })
646
+ }
647
+ }
648
+
543
649
  const useRecoveryState = () => {
544
650
  const [snapshot, setSnapshot] = useState(recoveryState)
545
651
  useEffect(() => {
@@ -587,6 +693,7 @@ window.__ModuleLoader__.load({
587
693
  ctx.effect(() => () => {
588
694
  recoveryGeneration += 1
589
695
  recoveryListeners.clear()
696
+ restartFlowListeners.clear()
590
697
  updateListeners.clear()
591
698
  }, 'dsh-service recovery')
592
699
 
@@ -601,6 +708,27 @@ window.__ModuleLoader__.load({
601
708
  }, translate('update.badge'))
602
709
  }
603
710
 
711
+ // 正式/预览通道行:版本号后跟 npmjs(版本页)与 npmmirror(镜像版本页)两个文字链接。
712
+ // 版本串嵌进 URL 前过安全字符集校验,不过校验的标签降级为纯文本。供更新详情浮层使用。
713
+ const NPM_DSH_PACKAGE = '@deepseek-ai/dsh'
714
+ const packageVersionHref = (base, version) => {
715
+ if (typeof version !== 'string' || version.length === 0 || !/^[0-9A-Za-z.+_-]+$/.test(version)) return null
716
+ return `${base}${version}`
717
+ }
718
+ const siteLabelLink = (kind, label, href) => {
719
+ const testid = `version-dsh-channel-${kind}-${label}`
720
+ if (!href) return React.createElement('span', { 'data-testid': testid, style: { marginLeft: '10px' } }, label)
721
+ return React.createElement('a', { 'data-testid': testid, href, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-brand-primary)', textDecoration: 'underline', marginLeft: '10px' } }, label)
722
+ }
723
+ const channelLine = (translate, kind, version) => React.createElement('div', { style: { whiteSpace: 'nowrap', lineHeight: 1.7 } },
724
+ kind === 'latest' ? translate('update.channelStable') : translate('update.channelPreview'),
725
+ ' ', React.createElement('span', { 'data-testid': `version-dsh-channel-${kind}`, style: { marginLeft: '4px' } }, version || '—'),
726
+ siteLabelLink(kind, 'npmjs', packageVersionHref(`https://www.npmjs.com/package/${NPM_DSH_PACKAGE}/v/`, version)),
727
+ siteLabelLink(kind, 'npmmirror', packageVersionHref(`https://www.npmmirror.com/package/${NPM_DSH_PACKAGE}/home?version=`, version)))
728
+ const channelLines = (translate, tags) => React.createElement('div', { style: { margin: '4px 0', fontSize: '12px', lineHeight: 1.7, color: 'var(--dsw-alias-label-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' } },
729
+ channelLine(translate, 'latest', tags && tags.latest),
730
+ channelLine(translate, 'next', tags && tags.next))
731
+
604
732
  function ServiceOverlay() {
605
733
  const recovery = useRecoveryState()
606
734
  const updateState = useUpdateState()
@@ -617,7 +745,7 @@ window.__ModuleLoader__.load({
617
745
  React.createElement('div', { style: { fontSize: '18px', fontWeight: 700, marginBottom: '10px' } }, translate('update.details.title')),
618
746
  React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.current', { version: update.current })),
619
747
  React.createElement('p', { style: { margin: '4px 0', fontSize: '13px' } }, translate('update.details.latest', { version: update.latest })),
620
- React.createElement('p', { style: { margin: '4px 0', fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } }, translate('update.channels', { latest: update.tags?.latest || '—', next: update.tags?.next || '—' })),
748
+ channelLines(translate, update.tags),
621
749
  React.createElement('button', { style: { marginTop: '16px', padding: '7px 16px', borderRadius: '6px', border: 0, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', cursor: 'pointer' }, onClick: () => setUpdateDetailsOpen(false) }, translate('update.details.close'))))
622
750
  }
623
751
 
@@ -666,6 +794,89 @@ window.__ModuleLoader__.load({
666
794
  return React.createElement(ServiceOverlay, null)
667
795
  }
668
796
 
797
+ // 重启区块:设置面板「重启」标签与设置页左列底部的专属入口共用,状态取自共享重启流。
798
+ // showNavToggle 仅「重启」标签传入:控制设置页左列入口是否显示(默认关闭)。
799
+ function RestartSection({ showNavToggle }) {
800
+ const translate = useTranslation()
801
+ const flow = useRestartFlow()
802
+ const [navEnabled, setNavEnabled] = useRestartNavEnabled()
803
+ const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
804
+ const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
805
+ const ghost = { ...btn, background: 'transparent', color: 'var(--dsw-alias-label-primary)', borderColor: 'var(--dsw-alias-border-l2)' }
806
+ const row = { display: 'flex', alignItems: 'center', gap: '8px', marginTop: '12px', flexWrap: 'wrap' }
807
+ const hint = { color: 'var(--dsw-alias-label-secondary)', fontSize: '12px', marginTop: '8px', lineHeight: 1.5 }
808
+ const card = { padding: '4px 0 14px', marginBottom: '12px', color: 'var(--dsw-alias-label-primary)' }
809
+ const displaySurface = { background: 'var(--dsh-svc-surface-bg)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', padding: '10px' }
810
+ const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
811
+
812
+ // 重启后提示
813
+ if (flow.stage === 2) {
814
+ return React.createElement('div', { 'data-testid': 'restart-card', style: card },
815
+ React.createElement('div', { style: sectionTitle }, translate('restart.title')),
816
+ React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
817
+ React.createElement('p', { style: hint }, translate('restart.sentHint')))
818
+ }
819
+
820
+ const activityLabels = {
821
+ agent: translate('activity.agent'),
822
+ job: translate('activity.job'),
823
+ terminal: translate('activity.terminal'),
824
+ }
825
+ const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
826
+ const activityWarning = flow.stage === 3
827
+ ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
828
+ React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
829
+ translate('activity.warning', { count: activityItems.length })),
830
+ React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
831
+ activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
832
+ translate('activity.item', {
833
+ type: activityLabels[item.type] || item.type,
834
+ label: item.label,
835
+ status: item.status,
836
+ }))))
837
+ )
838
+ : null
839
+
840
+ // 重启按钮区块
841
+ return React.createElement('div', { 'data-testid': 'restart-card', style: card },
842
+ React.createElement('div', { style: sectionTitle }, translate('restart.title')),
843
+ React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
844
+ React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
845
+ activityWarning,
846
+ React.createElement('div', { style: row },
847
+ flow.stage === 0
848
+ ? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: flow.busy }, translate(flow.busy ? 'update.checking' : 'restart.button'))
849
+ : flow.stage === 1
850
+ ? [
851
+ React.createElement('button', { key: 'confirm', style: danger, onClick: () => restartWeb(false), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.confirm')),
852
+ React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
853
+ ]
854
+ : flow.stage === 3
855
+ ? [
856
+ React.createElement('button', { key: 'force', style: danger, onClick: () => restartWeb(true), disabled: flow.busy }, translate(flow.busy ? 'restart.sending' : 'restart.force')),
857
+ React.createElement('button', { key: 'cancel', style: ghost, onClick: () => setRestartFlow({ ...restartFlow, activity: null, stage: 0, busy: false, error: null }), disabled: flow.busy }, translate('restart.cancel')),
858
+ ]
859
+ : null
860
+ ),
861
+ flow.stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
862
+ flow.error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(flow.error)) : null,
863
+ showNavToggle
864
+ ? React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l1)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px' } },
865
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
866
+ React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, translate('restart.navToggle')),
867
+ React.createElement('span', { style: hint }, translate('restart.navToggleHint'))),
868
+ React.createElement('button', {
869
+ type: 'button',
870
+ role: 'switch',
871
+ 'data-testid': 'restart-nav-switch',
872
+ 'aria-checked': String(navEnabled),
873
+ onClick: () => setNavEnabled(!navEnabled),
874
+ style: { width: '34px', height: '20px', borderRadius: '10px', padding: 0, flexShrink: 0, position: 'relative', border: `1px solid ${navEnabled ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-border-l2)'}`, background: navEnabled ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-bg-layer-2)', cursor: 'pointer', lineHeight: 0 },
875
+ }, React.createElement('span', { style: { position: 'absolute', top: '1px', left: navEnabled ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: navEnabled ? '#fff' : 'var(--dsw-alias-label-tertiary)' } })))
876
+ : null)
877
+ )
878
+ }
879
+
669
880
  function ServicePanel() {
670
881
  const translate = useTranslation()
671
882
  const [health, setHealth] = useState(null)
@@ -703,11 +914,10 @@ window.__ModuleLoader__.load({
703
914
  const [toolErrorsOpen, setToolErrorsOpen] = useState(false)
704
915
  const [modelsOpen, setModelsOpen] = useState(false)
705
916
  const [activeTab, setActiveTab] = useState('overview')
706
- // 重启状态:0=初始,1=普通确认,2=已发出,3=检测到活动工作
707
- const [stage, setStage] = useState(0)
708
- const [activity, setActivity] = useState(null)
709
- const [busy, setBusy] = useState(false)
710
- const [error, setError] = useState(null)
917
+ // 版本详情行内展开(不用浮层:弹层会被设置模态盖住)
918
+ const [channelOpen, setChannelOpen] = useState(false)
919
+ // 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
920
+ const restartFlowState = useRestartFlow()
711
921
  const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
712
922
 
713
923
  // 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
@@ -994,54 +1204,6 @@ window.__ModuleLoader__.load({
994
1204
  }
995
1205
  }
996
1206
 
997
- const checkRestart = async () => {
998
- setBusy(true)
999
- setError(null)
1000
- try {
1001
- const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
1002
- if (res && res.ok === false) {
1003
- console.error('dsh-service: activity check failed', res.error)
1004
- throw new Error(translate('error.activity'))
1005
- }
1006
- const nextActivity = res && res.value ? res.value : { hasActive: false, items: [] }
1007
- setActivity(nextActivity)
1008
- setStage(nextActivity.hasActive ? 3 : 1)
1009
- } catch (err) {
1010
- setError(err && err.message ? String(err.message) : String(err))
1011
- setStage(0)
1012
- } finally {
1013
- setBusy(false)
1014
- }
1015
- }
1016
-
1017
- const restart = async (force) => {
1018
- setBusy(true)
1019
- setError(null)
1020
- try {
1021
- const res = await ctx.connection.rpc.call('/dsh-service', 'web', { force: force === true })
1022
- if (res && res.ok === false) {
1023
- if (res.error === 'active-work' && res.value) {
1024
- setActivity(res.value)
1025
- setStage(3)
1026
- return
1027
- }
1028
- console.error('dsh-service: restart failed', res.error)
1029
- throw new Error(translate('error.restart'))
1030
- }
1031
- setStage(2)
1032
- const previousInstanceId = res && res.value ? res.value.instanceId : undefined
1033
- if (typeof previousInstanceId !== 'string' || previousInstanceId.length === 0) {
1034
- throw new Error(translate('error.instance'))
1035
- }
1036
- startRecovery(previousInstanceId).catch((err) => console.error('dsh-service: recovery failed', err))
1037
- } catch (err) {
1038
- setError(err && err.message ? String(err.message) : String(err))
1039
- setStage(0)
1040
- } finally {
1041
- setBusy(false)
1042
- }
1043
- }
1044
-
1045
1207
  // 样式
1046
1208
  const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
1047
1209
  const primary = { ...btn, background: 'var(--dsw-alias-brand-primary)', color: 'var(--dsw-alias-button-contrast-fill)', borderColor: 'var(--dsw-alias-brand-primary)' }
@@ -1394,21 +1556,36 @@ window.__ModuleLoader__.load({
1394
1556
  : null)))
1395
1557
  : null))
1396
1558
 
1397
- const versionRow = (id, label, fallbackVersion, state, action) => React.createElement('div', { key: id, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', padding: '10px 2px', borderTop: id === 'dsh' ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1398
- React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1399
- React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1400
- state?.url
1401
- ? React.createElement('a', { 'data-testid': `version-${id}-link`, href: state.url, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-label-primary)', textDecoration: 'underline', fontSize: '12px', whiteSpace: 'nowrap', marginLeft: '16px' } }, state.current || fallbackVersion || translate('version.loading'))
1402
- : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1403
- state?.tags && id === 'dsh' ? React.createElement('span', { style: { marginLeft: '8px', fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', whiteSpace: 'nowrap' } }, translate('update.channels', { latest: state.tags.latest || '—', next: state.tags.next || '—' })) : null,
1559
+ // 正式/预览通道两行信息在版本卡内下拉展开(不弹浮层:弹层会被设置模态盖住)。
1560
+ // 有更新时状态文本本身可点击:小三角 + 「有新版本:…」整体切换展开/收起。
1561
+ const chevronIcon = (open) => React.createElement('svg', { xmlns: 'http://www.w3.org/2000/svg', width: 12, height: 12, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 3, strokeLinecap: 'round', strokeLinejoin: 'round', style: { display: 'block', transition: 'transform 150ms ease', transform: open ? 'rotate(90deg)' : 'none' } },
1562
+ React.createElement('path', { d: 'M9 6l6 6-6 6' }))
1563
+ const versionRow = (id, label, fallbackVersion, state, action, expandable, topBorder) => {
1564
+ const statusText = !state
1565
+ ? (updateError || translate('update.checking'))
1566
+ : state.status === 'unpublished' ? translate('update.unpublished')
1567
+ : state.status === 'unavailable' ? translate('update.unavailable')
1568
+ : state.upToDate ? translate('update.current')
1569
+ : translate('update.available', { version: state.latest })
1570
+ const statusColor = !state ? 'var(--dsw-alias-label-secondary)' : state.upToDate ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)'
1571
+ const clickable = expandable && state && state.status !== 'unpublished' && state.status !== 'unavailable' && !state.upToDate
1572
+ const rightSide = clickable
1573
+ ? React.createElement('button', { type: 'button', title: translate(channelOpen ? 'update.detailsHide' : 'update.detailsButton'), style: { background: 'transparent', border: 0, padding: 0, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: '5px', fontSize: '12px', fontWeight: 600, color: statusColor }, onClick: () => setChannelOpen((value) => !value) },
1574
+ chevronIcon(channelOpen),
1575
+ React.createElement('span', null, statusText))
1576
+ : React.createElement('div', { style: { color: statusColor, fontWeight: 600 } }, statusText)
1577
+ return React.createElement('div', { key: id, style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '16px', padding: '10px 2px', borderTop: topBorder ? '1px solid var(--dsw-alias-border-l1)' : 0 } },
1578
+ React.createElement('div', { style: { whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: '8px' } },
1579
+ React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
1580
+ state?.url
1581
+ ? React.createElement('a', { 'data-testid': `version-${id}-link`, href: state.url, target: '_blank', rel: 'noreferrer', style: { color: 'var(--dsw-alias-label-primary)', textDecoration: 'underline', fontSize: '12px', whiteSpace: 'nowrap', marginLeft: '16px' } }, state.current || fallbackVersion || translate('version.loading'))
1582
+ : React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
1404
1583
  action || null,
1405
- React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } },
1406
- React.createElement('div', { style: { color: !state ? 'var(--dsw-alias-label-secondary)' : state.upToDate ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-warn-primary)', fontWeight: 600 } }, !state
1407
- ? (updateError || translate('update.checking'))
1408
- : state.status === 'unpublished' ? translate('update.unpublished')
1409
- : state.status === 'unavailable' ? translate('update.unavailable')
1410
- : state.upToDate ? translate('update.current') : translate('update.available', { version: state.latest }))))
1411
- // 版本信息区块
1584
+ React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } }, rightSide))
1585
+ }
1586
+ // 版本信息区块:DSH 行在有更新时状态文本(小三角 + 有新版本)整体可点击,行内下拉展开
1587
+ const dshUpdate = updateInfo?.dsh
1588
+ const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
1412
1589
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1413
1590
  const pluginAction = pluginUpdate
1414
1591
  ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
@@ -1416,67 +1593,18 @@ window.__ModuleLoader__.load({
1416
1593
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1417
1594
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1418
1595
  React.createElement('div', { style: displaySurface },
1419
- versionRow('dsh', 'DSH', version, updateInfo?.dsh),
1420
- versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction),
1596
+ versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin, pluginAction, false, false),
1597
+ versionRow('dsh', 'DSH', version, dshUpdate, null, dshExpandable === true, true),
1598
+ channelOpen
1599
+ ? React.createElement('div', { 'data-testid': 'version-channel-details', style: { marginTop: '6px', paddingTop: '8px', borderTop: '1px solid var(--dsw-alias-border-l1)', fontSize: '12px', lineHeight: 1.7, color: 'var(--dsw-alias-label-secondary)', display: 'flex', flexDirection: 'column', gap: '6px' } },
1600
+ React.createElement('div', null, translate('update.details.current', { version: dshUpdate?.current || version || '—' })),
1601
+ React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
1602
+ channelLines(translate, dshUpdate?.tags))
1603
+ : null,
1421
1604
  upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1422
1605
 
1423
- // 重启后提示
1424
- if (stage === 2) {
1425
- return React.createElement('div', null,
1426
- healthBlock,
1427
- backupBlock,
1428
- versionBlock,
1429
- React.createElement('div', { key: 'restart-section' },
1430
- React.createElement('div', { style: sectionTitle }, translate('restart.title')),
1431
- React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
1432
- React.createElement('p', { style: hint }, translate('restart.sentHint')))
1433
- )
1434
- }
1435
-
1436
- const activityLabels = {
1437
- agent: translate('activity.agent'),
1438
- job: translate('activity.job'),
1439
- terminal: translate('activity.terminal'),
1440
- }
1441
- const activityItems = activity && Array.isArray(activity.items) ? activity.items : []
1442
- const activityWarning = stage === 3
1443
- ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
1444
- React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
1445
- translate('activity.warning', { count: activityItems.length })),
1446
- React.createElement('ul', { style: { margin: 0, paddingLeft: '20px', fontSize: '12px', lineHeight: 1.7 } },
1447
- activityItems.map((item) => React.createElement('li', { key: item.type + ':' + item.id },
1448
- translate('activity.item', {
1449
- type: activityLabels[item.type] || item.type,
1450
- label: item.label,
1451
- status: item.status,
1452
- }))))
1453
- )
1454
- : null
1455
-
1456
- // 重启按钮区块
1457
- const restartBlock = React.createElement('div', { key: 'restart-section', 'data-testid': 'restart-card', style: card },
1458
- React.createElement('div', { style: sectionTitle }, translate('restart.title')),
1459
- React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
1460
- React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
1461
- activityWarning,
1462
- React.createElement('div', { style: row },
1463
- stage === 0
1464
- ? React.createElement('button', { style: danger, 'data-variant': 'danger', onClick: checkRestart, disabled: busy }, translate(busy ? 'update.checking' : 'restart.button'))
1465
- : stage === 1
1466
- ? [
1467
- React.createElement('button', { key: 'confirm', style: danger, onClick: () => restart(false), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.confirm')),
1468
- React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
1469
- ]
1470
- : stage === 3
1471
- ? [
1472
- React.createElement('button', { key: 'force', style: danger, onClick: () => restart(true), disabled: busy }, translate(busy ? 'restart.sending' : 'restart.force')),
1473
- React.createElement('button', { key: 'cancel', style: ghost, onClick: () => { setActivity(null); setStage(0) }, disabled: busy }, translate('restart.cancel')),
1474
- ]
1475
- : null
1476
- ),
1477
- stage === 1 ? React.createElement('p', { style: hint }, translate('restart.idleHint')) : null,
1478
- error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
1479
- )
1606
+ // 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
1607
+ const restartBlock = React.createElement(RestartSection, { showNavToggle: true })
1480
1608
 
1481
1609
  const { enabled: notifyOn, done: notifyDoneOn, input: notifyInputOn, setEnabled: setNotifyOn, setDone: setNotifyDoneOn, setInput: setNotifyInputOn } = useNotifyState()
1482
1610
  const notifSupported = typeof Notification !== 'undefined'
@@ -1512,18 +1640,22 @@ window.__ModuleLoader__.load({
1512
1640
  notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
1513
1641
  notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
1514
1642
  notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
1515
- const overviewBlock = React.createElement('div', null, versionBlock, notificationBlock, containerInfoBlock, overviewErrorsBlock)
1643
+ const overviewBlock = React.createElement('div', null, versionBlock, containerInfoBlock, overviewErrorsBlock)
1644
+ // 任务通知独立成顶部标签(v0.14 起不再混在概览里)
1645
+ const notifyBlock = React.createElement('div', null, notificationBlock)
1516
1646
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1517
1647
  const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
1518
1648
  const tabWarnings = {
1519
1649
  overview: false,
1650
+ notify: false,
1520
1651
  health: Boolean(healthError || permissionError || diagnosticFailure || permissionAbnormal > 0),
1521
1652
  usage: Boolean(usageError),
1522
1653
  backup: Boolean(backupError),
1523
- restart: Boolean(error),
1654
+ restart: Boolean(restartFlowState.error),
1524
1655
  }
1525
1656
  const tabs = [
1526
1657
  ['overview', 'tabs.overview'],
1658
+ ['notify', 'tabs.notify'],
1527
1659
  ['health', 'tabs.health'],
1528
1660
  ['usage', 'tabs.usage'],
1529
1661
  ['backup', 'tabs.backup'],
@@ -1532,13 +1664,15 @@ window.__ModuleLoader__.load({
1532
1664
  const warningTabs = tabs.filter(([id]) => tabWarnings[id]).map(([, label]) => translate(label))
1533
1665
  const tabContent = activeTab === 'overview'
1534
1666
  ? overviewBlock
1535
- : activeTab === 'health'
1536
- ? healthBlock
1537
- : activeTab === 'usage'
1538
- ? usageBlock
1539
- : activeTab === 'backup'
1540
- ? maintenanceBlock
1541
- : restartBlock
1667
+ : activeTab === 'notify'
1668
+ ? notifyBlock
1669
+ : activeTab === 'health'
1670
+ ? healthBlock
1671
+ : activeTab === 'usage'
1672
+ ? usageBlock
1673
+ : activeTab === 'backup'
1674
+ ? maintenanceBlock
1675
+ : restartBlock
1542
1676
  return React.createElement('div', null,
1543
1677
  warningTabs.length > 0 ? React.createElement('div', { style: { marginBottom: '12px', padding: '11px 13px', borderRadius: '8px', background: 'rgba(198,128,0,0.16)', border: '1px solid rgba(198,128,0,0.48)', boxShadow: '0 2px 8px rgba(0,0,0,0.08)' } },
1544
1678
  React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
@@ -1581,10 +1715,21 @@ window.__ModuleLoader__.load({
1581
1715
  { name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
1582
1716
  () => React.createElement(UpdateBadge, null),
1583
1717
  ))
1584
- ctx.slots.inject('settings.section', () => ctx.slots.register(
1585
- { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
1586
- () => React.createElement(ServicePanel, null),
1587
- ))
1718
+ ctx.slots.inject('settings.section', () => {
1719
+ const disposePanel = ctx.slots.register(
1720
+ { name: 'settings.section', id: 'dsh-service', order: 99, label: () => t('nav.label') },
1721
+ () => React.createElement(ServicePanel, null),
1722
+ )
1723
+ // 左列「重启」入口由「重启」标签内的开关控制,默认不注册
1724
+ syncRestartNavEntry()
1725
+ return () => {
1726
+ disposePanel()
1727
+ if (restartNavDispose) {
1728
+ restartNavDispose()
1729
+ restartNavDispose = null
1730
+ }
1731
+ }
1732
+ })
1588
1733
  ctx.slots.inject('shell.overlay', () => ctx.slots.register(
1589
1734
  { name: 'shell.overlay', id: 'dsh-service-restart', order: 100, label: () => t('overlay.label') },
1590
1735
  () => React.createElement(RestartOverlay, null),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.13.2",
3
+ "version": "0.14.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {