@gehennawu/dsh-service 0.11.1 → 0.12.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 +62 -66
- package/README.md +63 -67
- package/client.js +240 -20
- package/index.js +106 -0
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -2,57 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](./README.md)
|
|
4
4
|
|
|
5
|
-
A service-control and operations plugin for self-hosted DSH Web.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
5
|
+
A service-control and operations plugin for self-hosted DSH Web. Provides safe restart, version management and one-click upgrade, health diagnostics, model-usage statistics, backup management, task notifications, and Linux file-permission maintenance.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
### Version and updates
|
|
12
|
+
|
|
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
|
|
15
|
+
- One-click plugin upgrade with automatic restart after completion
|
|
16
|
+
|
|
17
|
+
### Safe restart
|
|
18
|
+
|
|
19
|
+
- Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
|
|
20
|
+
- `/restart` command also available in conversations; automatically refuses when active work is detected
|
|
21
|
+
- Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
|
|
22
|
+
|
|
23
|
+
### Health diagnostics
|
|
24
|
+
|
|
25
|
+
- Shows uptime, memory, session count, active agents, and background jobs
|
|
26
|
+
- Full diagnostics check session storage, workspace registry, backup storage, tar availability, and file permissions
|
|
27
|
+
- File-permission deep scan and repair: checks whether the Agent can read/write DSH_HOME and workspaces; repair requires two-step confirmation
|
|
28
|
+
|
|
29
|
+
### Model statistics
|
|
30
|
+
|
|
31
|
+
- 7-day stacked bar chart of input/output/cache tokens with blue/orange/teal legend
|
|
32
|
+
- Filter by project; hover for exact values
|
|
33
|
+
- Model breakdown sorted by steps: `x times · Cache hit x% · Input xM token · Output xM token`
|
|
34
|
+
- Last-24-hour model/tool error statistics, collapsed by default
|
|
35
|
+
|
|
36
|
+
### Backup management
|
|
37
|
+
|
|
38
|
+
- Creates `.tar.gz` archives of sessions, configuration, and plugin profile manifests
|
|
39
|
+
- Export: download backup to browser
|
|
40
|
+
- Restore: extract and overwrite to corresponding paths, two-step confirmation followed by automatic restart
|
|
41
|
+
- Import: upload a `.tar.gz` file to the backup directory
|
|
42
|
+
- Delete requires two-step confirmation; backups are unlimited and never auto-pruned
|
|
43
|
+
|
|
44
|
+
### Task notifications
|
|
45
|
+
|
|
46
|
+
- Browser notification when an Agent completes a task
|
|
47
|
+
- Bell icon toggle in the conversation input bar
|
|
48
|
+
- Configurable polling interval (5–300 seconds)
|
|
49
|
+
- Toggle and interval persist across page reloads
|
|
50
|
+
|
|
51
|
+
### External liveness probe
|
|
52
|
+
|
|
53
|
+
- `GET` / `HEAD /healthz` returns empty HTTP 200; other methods return 405
|
|
54
|
+
- Suitable for Uptime Kuma, Docker, Kubernetes, or other external monitors
|
|
46
55
|
|
|
47
56
|
## Installation
|
|
48
57
|
|
|
58
|
+
### Install from npm (recommended)
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
dsh plugin --profile web add @gehennawu/dsh-service
|
|
62
|
+
```
|
|
63
|
+
|
|
49
64
|
### Install from GitHub
|
|
50
65
|
|
|
51
66
|
```sh
|
|
52
67
|
dsh plugin --profile web add github:gehennawu/dsh-service
|
|
53
68
|
```
|
|
54
69
|
|
|
55
|
-
Restart DSH Web after installation or updates
|
|
70
|
+
Restart DSH Web after installation or updates:
|
|
56
71
|
|
|
57
72
|
```sh
|
|
58
73
|
dsh web
|
|
@@ -104,32 +119,13 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
|
|
|
104
119
|
|
|
105
120
|
Requirements: Node.js `>=22`, and a DSH Web installation capable of loading both Host and Client plugin halves. Update checks require access to `registry.npmjs.org`; network failures do not affect other features.
|
|
106
121
|
|
|
107
|
-
> Automated coverage plus Host and real Chromium checks on the current Linux + Docker deployment are complete. The standard preset does not mount a Terminal backend, and the container exposes no root, CAP_CHOWN, Docker socket, or user namespace; those unproducible branches are covered by scoped-service regression tests and real-subprocess temporary-directory tests.
|
|
108
|
-
|
|
109
122
|
## Security design
|
|
110
123
|
|
|
111
|
-
- The browser cannot supply URLs, package names, commands, or file paths
|
|
112
|
-
- Update checks only access
|
|
113
|
-
-
|
|
114
|
-
- The
|
|
115
|
-
-
|
|
116
|
-
- Restarting interrupts active work; the plugin lists active work first and only proceeds after an explicit **Force restart** action.
|
|
117
|
-
|
|
118
|
-
## Project structure
|
|
119
|
-
|
|
120
|
-
- `index.js`: Host half; version/update checks, activity guard, health metrics and diagnostics, incremental usage index, liveness, backups, and permission-maintenance RPC.
|
|
121
|
-
- `client.js`: Browser half; Settings layout, switchable usage charts, update badge, and global status overlays.
|
|
122
|
-
- `cordis.patch.yml`: inserts the Host and Client plugin into the DSH Web profile.
|
|
123
|
-
- `README.md`: Chinese documentation.
|
|
124
|
-
|
|
125
|
-
Basic checks:
|
|
126
|
-
|
|
127
|
-
```sh
|
|
128
|
-
npm test
|
|
129
|
-
node --check index.js
|
|
130
|
-
node --check client.js
|
|
131
|
-
npm pack --dry-run
|
|
132
|
-
```
|
|
124
|
+
- The browser cannot supply URLs, package names, commands, or file paths
|
|
125
|
+
- Update checks only access fixed npm registry endpoints
|
|
126
|
+
- The RPC channel is loopback-only
|
|
127
|
+
- The model-usage index stores no messages, prompts, tool arguments, or credentials
|
|
128
|
+
- Destructive operations (restart, delete, permission repair) all require two-step confirmation
|
|
133
129
|
|
|
134
130
|
## License
|
|
135
131
|
|
package/README.md
CHANGED
|
@@ -2,57 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
[English](./README.en.md)
|
|
4
4
|
|
|
5
|
-
面向自托管 DSH Web
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
##
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
5
|
+
面向自托管 DSH Web 的服务控制与运维插件。提供安全重启、版本管理与一键升级、健康诊断、模型用量统计、备份管理、任务通知和 Linux 文件权限维护。
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
## 功能
|
|
10
|
+
|
|
11
|
+
### 版本与更新
|
|
12
|
+
|
|
13
|
+
- 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
|
|
14
|
+
- 自动检查 npm registry 的正式版和预览版,显示更新状态
|
|
15
|
+
- 一键升级插件,升级后自动重启
|
|
16
|
+
|
|
17
|
+
### 安全重启
|
|
18
|
+
|
|
19
|
+
- 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
|
|
20
|
+
- 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
|
|
21
|
+
- 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
|
|
22
|
+
|
|
23
|
+
### 健康诊断
|
|
24
|
+
|
|
25
|
+
- 显示运行时间、内存、会话数、活跃 Agent 和后台任务
|
|
26
|
+
- 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性和文件权限
|
|
27
|
+
- 文件权限深检与修复:检查 Agent 是否能读写 DSH_HOME 和工作区,修复需两段式确认
|
|
28
|
+
|
|
29
|
+
### 模型统计
|
|
30
|
+
|
|
31
|
+
- 近 7 天输入/输出/缓存 token 堆叠柱图,蓝/橙/青图例
|
|
32
|
+
- 按项目筛选,鼠标悬停显示精确数值
|
|
33
|
+
- 模型明细按步骤数降序,格式为「x次 · 缓存命中 x% · 输入 xM token · 输出 xM token」
|
|
34
|
+
- 最近 24 小时模型/工具报错统计,默认折叠
|
|
35
|
+
|
|
36
|
+
### 备份管理
|
|
37
|
+
|
|
38
|
+
- 创建会话、配置和插件 profile 清单的 `.tar.gz` 归档
|
|
39
|
+
- 导出:下载备份到浏览器
|
|
40
|
+
- 恢复:解压覆盖到对应路径,两段式确认后自动重启
|
|
41
|
+
- 导入:选择 `.tar.gz` 文件上传到备份目录
|
|
42
|
+
- 删除需两段式确认,备份不限份数,不自动清理
|
|
43
|
+
|
|
44
|
+
### 任务通知
|
|
45
|
+
|
|
46
|
+
- Agent 完成任务时发送浏览器通知
|
|
47
|
+
- 对话栏内铃铛图标切换开关
|
|
48
|
+
- 可配置轮询间隔(5-300 秒)
|
|
49
|
+
- 开关和间隔在页面刷新后保持
|
|
50
|
+
|
|
51
|
+
### 外部探活
|
|
52
|
+
|
|
53
|
+
- `GET` / `HEAD /healthz` 返回空的 HTTP 200,其他方法返回 405
|
|
54
|
+
- 适合 Uptime Kuma、Docker、Kubernetes 等外部监控
|
|
46
55
|
|
|
47
56
|
## 安装
|
|
48
57
|
|
|
58
|
+
### 从 npm 安装(推荐)
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
dsh plugin --profile web add @gehennawu/dsh-service
|
|
62
|
+
```
|
|
63
|
+
|
|
49
64
|
### 从 GitHub 安装
|
|
50
65
|
|
|
51
66
|
```sh
|
|
52
67
|
dsh plugin --profile web add github:gehennawu/dsh-service
|
|
53
68
|
```
|
|
54
69
|
|
|
55
|
-
安装或更新后重启 DSH Web
|
|
70
|
+
安装或更新后重启 DSH Web:
|
|
56
71
|
|
|
57
72
|
```sh
|
|
58
73
|
dsh web
|
|
@@ -102,34 +117,15 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
|
|
|
102
117
|
| macOS / Windows + pm2 等 | 代码未限制 | 由进程管理器负责 | 未验证 |
|
|
103
118
|
| 直接运行 `dsh web` | 支持 | 不支持 | 预期行为 |
|
|
104
119
|
|
|
105
|
-
运行要求:Node.js `>=22
|
|
106
|
-
|
|
107
|
-
> 自动化测试以及当前 Linux + Docker 的 Host/真实 Chromium 验证已完成。当前 standard preset 未挂载 Terminal backend,容器也不提供 root、CAP_CHOWN、Docker socket 或 user namespace,因此这两种不可制造的环境分支通过隔离服务回归测试和真实 subprocess 临时目录测试覆盖。
|
|
120
|
+
运行要求:Node.js `>=22`,DSH Web 能加载 Host 和 Client 两半插件。检查更新需要访问 `registry.npmjs.org`;网络失败不影响其他功能。
|
|
108
121
|
|
|
109
122
|
## 安全设计
|
|
110
123
|
|
|
111
|
-
- 浏览器端不能传入 URL
|
|
112
|
-
- 更新检查只访问固定的
|
|
113
|
-
-
|
|
114
|
-
-
|
|
115
|
-
-
|
|
116
|
-
- 重启会中断正在运行的任务;插件会先展示活动清单,只有显式确认「仍要重启」才会强制执行。
|
|
117
|
-
|
|
118
|
-
## 项目结构
|
|
119
|
-
|
|
120
|
-
- `index.js`:Host 半;版本/更新、活动保护、健康指标与诊断、增量用量索引、探活、备份和权限维护 RPC。
|
|
121
|
-
- `client.js`:Browser 半;设置页布局、可切换用量图表、更新脚标和全局状态浮层。
|
|
122
|
-
- `cordis.patch.yml`:将 Host 与 Client 插件插入 DSH Web profile。
|
|
123
|
-
- `README.en.md`:英文文档。
|
|
124
|
-
|
|
125
|
-
基础检查:
|
|
126
|
-
|
|
127
|
-
```sh
|
|
128
|
-
npm test
|
|
129
|
-
node --check index.js
|
|
130
|
-
node --check client.js
|
|
131
|
-
npm pack --dry-run
|
|
132
|
-
```
|
|
124
|
+
- 浏览器端不能传入 URL、包名、命令或文件路径
|
|
125
|
+
- 更新检查只访问固定的 npm registry 地址
|
|
126
|
+
- RPC channel 仅接受 loopback 调用
|
|
127
|
+
- 模型用量索引不保存消息、Prompt、Tool 参数或凭据
|
|
128
|
+
- 破坏性操作(重启、删除、修复权限)均需两段式确认
|
|
133
129
|
|
|
134
130
|
## 许可证
|
|
135
131
|
|
package/client.js
CHANGED
|
@@ -87,6 +87,13 @@ window.__ModuleLoader__.load({
|
|
|
87
87
|
'backup.confirmHint': '确认删除这个备份?此操作无法撤销。',
|
|
88
88
|
'backup.cancel': '取消',
|
|
89
89
|
'backup.error': '备份操作失败',
|
|
90
|
+
'backup.export': '导出',
|
|
91
|
+
'backup.exporting': '导出中…',
|
|
92
|
+
'backup.exportError': '备份导出失败',
|
|
93
|
+
'backup.restore': '恢复',
|
|
94
|
+
'backup.restoreConfirm': '确认恢复',
|
|
95
|
+
'backup.restoreHint': '确认恢复此备份?当前会话和配置将被覆盖,恢复后服务将自动重启。',
|
|
96
|
+
'backup.restoreError': '备份恢复失败',
|
|
90
97
|
'backup.import': '导入备份',
|
|
91
98
|
'backup.importing': '导入中…',
|
|
92
99
|
'backup.showRecords': '展开备份记录',
|
|
@@ -106,8 +113,12 @@ window.__ModuleLoader__.load({
|
|
|
106
113
|
'update.details.title': 'DSH 更新可用',
|
|
107
114
|
'update.details.current': '当前版本:{version}',
|
|
108
115
|
'update.details.latest': '最新版本:{version}',
|
|
109
|
-
'update.channels': '
|
|
116
|
+
'update.channels': '正式版 {latest} · 预览版 {next}',
|
|
110
117
|
'update.details.close': '关闭',
|
|
118
|
+
'update.upgrade': '升级插件',
|
|
119
|
+
'update.upgrading': '升级中…',
|
|
120
|
+
'update.upgradeError': '插件升级失败',
|
|
121
|
+
'update.upgradeSuccess': '升级成功,服务重启中…',
|
|
111
122
|
'restart.title': '服务重启',
|
|
112
123
|
'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
|
|
113
124
|
'restart.button': '重启 dsh web',
|
|
@@ -160,6 +171,18 @@ window.__ModuleLoader__.load({
|
|
|
160
171
|
'usage.toolErrors.toggle': '工具报错({count} 类)',
|
|
161
172
|
'usage.toolErrors.empty': '最近 24 小时没有记录到工具报错。',
|
|
162
173
|
'usage.errors.count': '{count} 次',
|
|
174
|
+
'notification.title': '任务通知',
|
|
175
|
+
'notification.description': '当运行中的 Agent 完成一轮任务时,发送浏览器通知提醒。需要授权浏览器通知权限;开关和轮询间隔在页面刷新后保持。',
|
|
176
|
+
'notification.enable': '开启通知',
|
|
177
|
+
'notification.enabled': '通知已开启',
|
|
178
|
+
'notification.disable': '关闭通知',
|
|
179
|
+
'notification.denied': '通知权限被拒绝',
|
|
180
|
+
'notification.agentDone': '任务完成',
|
|
181
|
+
'notification.agentDoneBody': 'Agent {id} 已完成本轮任务',
|
|
182
|
+
'notification.bellOn': '通知开启',
|
|
183
|
+
'notification.bellOff': '通知关闭',
|
|
184
|
+
'notification.interval': '轮询间隔',
|
|
185
|
+
'notification.intervalUnit': '秒(5-300)',
|
|
163
186
|
}
|
|
164
187
|
const en = {
|
|
165
188
|
'nav.label': 'Service Control',
|
|
@@ -205,11 +228,11 @@ window.__ModuleLoader__.load({
|
|
|
205
228
|
'health.detail.permissions.warning': 'Found {count} file or directory permission anomalies',
|
|
206
229
|
'health.detail.generic': '{status}',
|
|
207
230
|
'tabs.overview': 'Overview',
|
|
208
|
-
'tabs.health': 'Health
|
|
209
|
-
'tabs.usage': '
|
|
210
|
-
'overview.container': 'Container
|
|
211
|
-
'overview.errors': '
|
|
212
|
-
'tabs.backup': 'Backup
|
|
231
|
+
'tabs.health': 'Health',
|
|
232
|
+
'tabs.usage': 'Models',
|
|
233
|
+
'overview.container': 'Container info',
|
|
234
|
+
'overview.errors': 'Errors',
|
|
235
|
+
'tabs.backup': 'Backup',
|
|
213
236
|
'tabs.restart': 'Restart',
|
|
214
237
|
'tabs.alert.title': 'Service control alert',
|
|
215
238
|
'tabs.alert.body': 'These areas need attention: {tabs}',
|
|
@@ -240,6 +263,13 @@ window.__ModuleLoader__.load({
|
|
|
240
263
|
'backup.confirmHint': 'Delete this backup? This cannot be undone.',
|
|
241
264
|
'backup.cancel': 'Cancel',
|
|
242
265
|
'backup.error': 'Backup operation failed',
|
|
266
|
+
'backup.export': 'Export',
|
|
267
|
+
'backup.exporting': 'Exporting…',
|
|
268
|
+
'backup.exportError': 'Backup export failed',
|
|
269
|
+
'backup.restore': 'Restore',
|
|
270
|
+
'backup.restoreConfirm': 'Confirm restore',
|
|
271
|
+
'backup.restoreHint': 'Restore this backup? Current sessions and configuration will be overwritten. The service will restart automatically after restoration.',
|
|
272
|
+
'backup.restoreError': 'Backup restore failed',
|
|
243
273
|
'backup.import': 'Import backup',
|
|
244
274
|
'backup.importing': 'Importing…',
|
|
245
275
|
'backup.showRecords': 'Show backup records',
|
|
@@ -259,8 +289,12 @@ window.__ModuleLoader__.load({
|
|
|
259
289
|
'update.details.title': 'DSH update available',
|
|
260
290
|
'update.details.current': 'Current version: {version}',
|
|
261
291
|
'update.details.latest': 'Latest version: {version}',
|
|
262
|
-
'update.channels': '
|
|
292
|
+
'update.channels': 'Stable {latest} · Preview {next}',
|
|
263
293
|
'update.details.close': 'Close',
|
|
294
|
+
'update.upgrade': 'Upgrade plugin',
|
|
295
|
+
'update.upgrading': 'Upgrading…',
|
|
296
|
+
'update.upgradeError': 'Plugin upgrade failed',
|
|
297
|
+
'update.upgradeSuccess': 'Upgrade successful, restarting…',
|
|
264
298
|
'restart.title': 'Service restart',
|
|
265
299
|
'restart.description': 'Restart the dsh web process. Active work will be interrupted; persisted sessions can be resumed. You can also type /restart in a conversation.',
|
|
266
300
|
'restart.button': 'Restart dsh web',
|
|
@@ -313,16 +347,31 @@ window.__ModuleLoader__.load({
|
|
|
313
347
|
'usage.toolErrors.toggle': 'Tool errors ({count} types)',
|
|
314
348
|
'usage.toolErrors.empty': 'No tool errors were recorded in the last 24 hours.',
|
|
315
349
|
'usage.errors.count': '{count} occurrence(s)',
|
|
350
|
+
'notification.title': 'Task notifications',
|
|
351
|
+
'notification.description': 'Receive a browser notification when a running Agent finishes its turn. Requires browser notification permission; the toggle and polling interval persist across page reloads.',
|
|
352
|
+
'notification.enable': 'Enable notifications',
|
|
353
|
+
'notification.enabled': 'Notifications enabled',
|
|
354
|
+
'notification.disable': 'Disable notifications',
|
|
355
|
+
'notification.denied': 'Notification permission denied',
|
|
356
|
+
'notification.agentDone': 'Task complete',
|
|
357
|
+
'notification.agentDoneBody': 'Agent {id} has finished its turn',
|
|
358
|
+
'notification.bellOn': 'Notifications on',
|
|
359
|
+
'notification.bellOff': 'Notifications off',
|
|
360
|
+
'notification.interval': 'Polling interval',
|
|
361
|
+
'notification.intervalUnit': 'seconds (5–300)',
|
|
316
362
|
}
|
|
317
363
|
|
|
318
364
|
const inject = ['slots', 'connection', 'timer', 'locale']
|
|
319
365
|
|
|
320
366
|
function apply(ctx) {
|
|
321
367
|
const { useState, useEffect } = React
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
368
|
+
let svcStyle
|
|
369
|
+
if (typeof document !== 'undefined' && document.head) {
|
|
370
|
+
svcStyle = document.createElement('style')
|
|
371
|
+
svcStyle.textContent = ':root{--dsh-svc-surface-bg:#f3f4f6}body[data-ds-dark-theme]{--dsh-svc-surface-bg:#1e1e20}'
|
|
372
|
+
document.head.appendChild(svcStyle)
|
|
373
|
+
}
|
|
374
|
+
ctx.effect(() => () => { if (svcStyle) svcStyle.remove() }, 'dsh-service theme styles')
|
|
326
375
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-service dictionaries')
|
|
327
376
|
const t = ctx.locale.bind(NS)
|
|
328
377
|
const useTranslation = () => {
|
|
@@ -330,6 +379,60 @@ window.__ModuleLoader__.load({
|
|
|
330
379
|
useEffect(() => ctx.locale.subscribe(() => setSnapshot(ctx.locale.getSnapshot())), [])
|
|
331
380
|
return t
|
|
332
381
|
}
|
|
382
|
+
// 全局 agent 完成通知轮询
|
|
383
|
+
let notifyEnabled = false
|
|
384
|
+
let notifyInterval = 30
|
|
385
|
+
try { notifyEnabled = localStorage.getItem('dsh-service-notify') !== 'false' } catch (_) {}
|
|
386
|
+
try { const v = parseInt(localStorage.getItem('dsh-service-notify-interval'), 10); if (v >= 5 && v <= 300) notifyInterval = v } catch (_) {}
|
|
387
|
+
const notifyListeners = new Set()
|
|
388
|
+
const setNotifyEnabled = (value) => {
|
|
389
|
+
notifyEnabled = value
|
|
390
|
+
try { localStorage.setItem('dsh-service-notify', value ? 'true' : 'false') } catch (_) {}
|
|
391
|
+
for (const listener of notifyListeners) listener()
|
|
392
|
+
}
|
|
393
|
+
const setNotifyInterval = (value) => {
|
|
394
|
+
const v = Math.max(5, Math.min(300, Math.round(Number(value) || 30)))
|
|
395
|
+
notifyInterval = v
|
|
396
|
+
try { localStorage.setItem('dsh-service-notify-interval', String(v)) } catch (_) {}
|
|
397
|
+
for (const listener of notifyListeners) listener()
|
|
398
|
+
}
|
|
399
|
+
const useNotifyState = () => {
|
|
400
|
+
const [, setTick] = useState(0)
|
|
401
|
+
const [enabled, setEnabled] = useState(notifyEnabled)
|
|
402
|
+
const [interval, setInterval_] = useState(notifyInterval)
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
const update = () => { setEnabled(notifyEnabled); setInterval_(notifyInterval); setTick((t) => t + 1) }
|
|
405
|
+
notifyListeners.add(update)
|
|
406
|
+
return () => notifyListeners.delete(update)
|
|
407
|
+
}, [])
|
|
408
|
+
return { enabled, interval, setEnabled: (v) => setNotifyEnabled(v), setInterval: (v) => setNotifyInterval(v) }
|
|
409
|
+
}
|
|
410
|
+
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
|
411
|
+
const previousAgentIds = new Set()
|
|
412
|
+
let initialized = false
|
|
413
|
+
const pollActivity = async () => {
|
|
414
|
+
if (!notifyEnabled) { ctx.timer.timeout(pollActivity, notifyInterval * 1000); return }
|
|
415
|
+
try {
|
|
416
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'activity', {})
|
|
417
|
+
if (res && res.ok) {
|
|
418
|
+
const currentIds = new Set(res.value.items.filter((item) => item.type === 'agent').map((item) => item.id))
|
|
419
|
+
if (initialized) {
|
|
420
|
+
for (const id of previousAgentIds) {
|
|
421
|
+
if (!currentIds.has(id)) {
|
|
422
|
+
try { new Notification(t('notification.agentDone'), { body: t('notification.agentDoneBody', { id }) }) } catch (_) {}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
previousAgentIds.clear()
|
|
427
|
+
for (const id of currentIds) previousAgentIds.add(id)
|
|
428
|
+
initialized = true
|
|
429
|
+
}
|
|
430
|
+
} catch (_) {}
|
|
431
|
+
ctx.timer.timeout(pollActivity, notifyInterval * 1000)
|
|
432
|
+
}
|
|
433
|
+
ctx.timer.timeout(pollActivity, notifyInterval * 1000)
|
|
434
|
+
}
|
|
435
|
+
|
|
333
436
|
const recoveryListeners = new Set()
|
|
334
437
|
const updateListeners = new Set()
|
|
335
438
|
let recoveryState = { status: 'idle', elapsedMs: 0 }
|
|
@@ -509,6 +612,8 @@ window.__ModuleLoader__.load({
|
|
|
509
612
|
const [backupBusy, setBackupBusy] = useState(false)
|
|
510
613
|
const [backupError, setBackupError] = useState(null)
|
|
511
614
|
const [backupDeleteId, setBackupDeleteId] = useState(null)
|
|
615
|
+
const [backupRestoreId, setBackupRestoreId] = useState(null)
|
|
616
|
+
const [backupExportBusy, setBackupExportBusy] = useState(false)
|
|
512
617
|
const [backupImportBusy, setBackupImportBusy] = useState(false)
|
|
513
618
|
const [backupDetails, setBackupDetails] = useState(false)
|
|
514
619
|
const [version, setVersion] = useState(null)
|
|
@@ -518,6 +623,8 @@ window.__ModuleLoader__.load({
|
|
|
518
623
|
const [usage, setUsage] = useState(null)
|
|
519
624
|
const [usageBusy, setUsageBusy] = useState(false)
|
|
520
625
|
const [usageError, setUsageError] = useState(null)
|
|
626
|
+
const [upgradeBusy, setUpgradeBusy] = useState(false)
|
|
627
|
+
const [upgradeError, setUpgradeError] = useState(null)
|
|
521
628
|
const [hoveredUsageSegment, setHoveredUsageSegment] = useState(null)
|
|
522
629
|
const [usageProject, setUsageProject] = useState('all')
|
|
523
630
|
const [modelErrorsOpen, setModelErrorsOpen] = useState(false)
|
|
@@ -700,6 +807,44 @@ window.__ModuleLoader__.load({
|
|
|
700
807
|
}
|
|
701
808
|
}
|
|
702
809
|
|
|
810
|
+
const exportBackup = async (id) => {
|
|
811
|
+
setBackupExportBusy(true)
|
|
812
|
+
setBackupError(null)
|
|
813
|
+
try {
|
|
814
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'backup-export', { id })
|
|
815
|
+
if (!res || res.ok === false) throw new Error('export failed')
|
|
816
|
+
const a = document.createElement('a')
|
|
817
|
+
a.href = res.value.url
|
|
818
|
+
a.download = res.value.name
|
|
819
|
+
document.body.appendChild(a)
|
|
820
|
+
a.click()
|
|
821
|
+
document.body.removeChild(a)
|
|
822
|
+
} catch (_) {
|
|
823
|
+
setBackupError(translate('backup.exportError'))
|
|
824
|
+
} finally {
|
|
825
|
+
setBackupExportBusy(false)
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const restoreBackup = async (id) => {
|
|
830
|
+
setBackupBusy(true)
|
|
831
|
+
setBackupError(null)
|
|
832
|
+
try {
|
|
833
|
+
const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
|
|
834
|
+
const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
|
|
835
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'backup-restore', { id })
|
|
836
|
+
if (!res || res.ok === false) throw new Error('backup restore failed')
|
|
837
|
+
setBackupRestoreId(null)
|
|
838
|
+
if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
|
|
839
|
+
startRecovery(previousInstanceId).catch(() => {})
|
|
840
|
+
}
|
|
841
|
+
} catch (_) {
|
|
842
|
+
setBackupError(translate('backup.restoreError'))
|
|
843
|
+
} finally {
|
|
844
|
+
setBackupBusy(false)
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
703
848
|
const importBackup = (event) => {
|
|
704
849
|
const file = event.target.files && event.target.files[0]
|
|
705
850
|
event.target.value = ''
|
|
@@ -728,6 +873,24 @@ window.__ModuleLoader__.load({
|
|
|
728
873
|
reader.readAsArrayBuffer(file)
|
|
729
874
|
}
|
|
730
875
|
|
|
876
|
+
const upgradePlugin = async () => {
|
|
877
|
+
setUpgradeBusy(true)
|
|
878
|
+
setUpgradeError(null)
|
|
879
|
+
try {
|
|
880
|
+
const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
|
|
881
|
+
const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
|
|
882
|
+
const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
|
|
883
|
+
if (!res || res.ok === false) throw new Error('upgrade failed')
|
|
884
|
+
if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
|
|
885
|
+
startRecovery(previousInstanceId).catch(() => {})
|
|
886
|
+
}
|
|
887
|
+
} catch (_) {
|
|
888
|
+
setUpgradeError(translate('update.upgradeError'))
|
|
889
|
+
} finally {
|
|
890
|
+
setUpgradeBusy(false)
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
731
894
|
const checkRestart = async () => {
|
|
732
895
|
setBusy(true)
|
|
733
896
|
setError(null)
|
|
@@ -790,7 +953,7 @@ window.__ModuleLoader__.load({
|
|
|
790
953
|
const card = { padding: '4px 0 14px', marginBottom: '12px', color: 'var(--dsw-alias-label-primary)' }
|
|
791
954
|
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' }
|
|
792
955
|
const tabPanel = { padding: '14px 2px 2px', color: 'var(--dsw-alias-label-primary)' }
|
|
793
|
-
const inlineTab = { background: 'transparent', color: 'var(--dsw-alias-label-secondary)', border: 0, borderBottom: '2px solid transparent', padding: '8px
|
|
956
|
+
const inlineTab = { background: 'transparent', color: 'var(--dsw-alias-label-secondary)', border: 0, borderBottom: '2px solid transparent', padding: '8px 10px', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'color 120ms, border-color 120ms' }
|
|
794
957
|
const inlineTabActive = { color: 'var(--dsw-alias-brand-primary)', borderBottom: '2px solid var(--dsw-alias-brand-primary)', fontWeight: 700 }
|
|
795
958
|
const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
|
|
796
959
|
|
|
@@ -1103,7 +1266,13 @@ window.__ModuleLoader__.load({
|
|
|
1103
1266
|
React.createElement('div', { style: { fontFamily: 'monospace', fontSize: '12px', overflowWrap: 'anywhere' } }, item.name),
|
|
1104
1267
|
React.createElement('div', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', marginTop: '3px' } }, `${formatSize(item.sizeBytes)} · ${new Date(item.createdAt).toLocaleString()}`)),
|
|
1105
1268
|
React.createElement('div', { style: { display: 'flex', gap: '6px', flexShrink: 0 } },
|
|
1106
|
-
backupDeleteId === item.id
|
|
1269
|
+
backupDeleteId === item.id || backupRestoreId === item.id
|
|
1270
|
+
? null
|
|
1271
|
+
: React.createElement('button', { style: Object.assign({}, ghost, { minHeight: '28px', padding: '4px 9px' }), disabled: backupExportBusy || backupBusy, onClick: () => exportBackup(item.id) }, translate(backupExportBusy ? 'backup.exporting' : 'backup.export')),
|
|
1272
|
+
backupDeleteId === item.id || backupRestoreId === item.id
|
|
1273
|
+
? null
|
|
1274
|
+
: React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '28px', padding: '4px 9px' }), disabled: backupBusy, onClick: () => setBackupRestoreId(item.id) }, translate('backup.restore')),
|
|
1275
|
+
backupDeleteId === item.id || backupRestoreId === item.id
|
|
1107
1276
|
? null
|
|
1108
1277
|
: React.createElement('button', { style: Object.assign({}, dangerGhost, { minHeight: '28px', padding: '4px 9px' }), 'data-variant': 'danger-filled', disabled: backupBusy, onClick: () => setBackupDeleteId(item.id) }, translate('backup.delete')),
|
|
1109
1278
|
)),
|
|
@@ -1113,16 +1282,22 @@ window.__ModuleLoader__.load({
|
|
|
1113
1282
|
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
1114
1283
|
React.createElement('button', { style: danger, disabled: backupBusy, onClick: () => deleteBackup(item.id) }, translate('backup.confirm')),
|
|
1115
1284
|
React.createElement('button', { style: ghost, disabled: backupBusy, onClick: () => setBackupDeleteId(null) }, translate('backup.cancel'))))
|
|
1116
|
-
:
|
|
1285
|
+
: backupRestoreId === item.id
|
|
1286
|
+
? React.createElement('div', { style: { marginTop: '8px' } },
|
|
1287
|
+
React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-warn-primary)', margin: '0 0 6px' }) }, translate('backup.restoreHint')),
|
|
1288
|
+
React.createElement('div', { style: { display: 'flex', gap: '8px' } },
|
|
1289
|
+
React.createElement('button', { style: primary, disabled: backupBusy, onClick: () => restoreBackup(item.id) }, translate('backup.restoreConfirm')),
|
|
1290
|
+
React.createElement('button', { style: ghost, disabled: backupBusy, onClick: () => setBackupRestoreId(null) }, translate('backup.cancel'))))
|
|
1291
|
+
: null)))
|
|
1117
1292
|
: null))
|
|
1118
1293
|
|
|
1119
1294
|
const versionRow = (id, label, fallbackVersion, state) => 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)' } },
|
|
1120
1295
|
React.createElement('div', { style: { whiteSpace: 'nowrap' } },
|
|
1121
1296
|
React.createElement('span', { style: { fontSize: '13px', fontWeight: 650 } }, `${label} `),
|
|
1122
1297
|
state?.url
|
|
1123
|
-
? 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' } }, state.current || fallbackVersion || translate('version.loading'))
|
|
1124
|
-
: React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)' } }, state?.current || fallbackVersion || translate('version.loading'))),
|
|
1125
|
-
state?.tags ? 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,
|
|
1298
|
+
? 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'))
|
|
1299
|
+
: React.createElement('code', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', marginLeft: '16px' } }, state?.current || fallbackVersion || translate('version.loading'))),
|
|
1300
|
+
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,
|
|
1126
1301
|
React.createElement('div', { style: { textAlign: 'right', fontSize: '12px' } },
|
|
1127
1302
|
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
|
|
1128
1303
|
? (updateError || translate('update.checking'))
|
|
@@ -1130,11 +1305,17 @@ window.__ModuleLoader__.load({
|
|
|
1130
1305
|
: state.status === 'unavailable' ? translate('update.unavailable')
|
|
1131
1306
|
: state.upToDate ? translate('update.current') : translate('update.available', { version: state.latest }))))
|
|
1132
1307
|
// 版本信息区块
|
|
1308
|
+
const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
|
|
1133
1309
|
const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
|
|
1134
1310
|
React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
|
|
1135
1311
|
React.createElement('div', { style: displaySurface },
|
|
1136
1312
|
versionRow('dsh', 'DSH', version, updateInfo?.dsh),
|
|
1137
|
-
versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin)
|
|
1313
|
+
versionRow('plugin', 'dsh-service', pluginVersion, updateInfo?.plugin),
|
|
1314
|
+
pluginUpdate || upgradeError
|
|
1315
|
+
? React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1316
|
+
pluginUpdate ? React.createElement('button', { style: primary, disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade')) : null,
|
|
1317
|
+
upgradeError ? React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-state-error-primary)' } }, upgradeError) : null)
|
|
1318
|
+
: null))
|
|
1138
1319
|
|
|
1139
1320
|
// 重启后提示
|
|
1140
1321
|
if (stage === 2) {
|
|
@@ -1194,7 +1375,25 @@ window.__ModuleLoader__.load({
|
|
|
1194
1375
|
error ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)' }) }, String(error)) : null)
|
|
1195
1376
|
)
|
|
1196
1377
|
|
|
1197
|
-
const
|
|
1378
|
+
const { enabled: notifyOn, interval: notifyIntv, setEnabled: setNotifyOn, setInterval: setNotifyIntv } = useNotifyState()
|
|
1379
|
+
const notifSupported = typeof Notification !== 'undefined'
|
|
1380
|
+
const notifPermission = notifSupported ? Notification.permission : 'denied'
|
|
1381
|
+
const notificationBlock = !notifSupported ? null
|
|
1382
|
+
: React.createElement('div', { style: { marginTop: '18px' } },
|
|
1383
|
+
React.createElement('div', { style: sectionTitle }, translate('notification.title')),
|
|
1384
|
+
React.createElement('div', { style: Object.assign({}, displaySurface, { marginTop: '4px' }) },
|
|
1385
|
+
React.createElement('p', { style: hint }, translate('notification.description')),
|
|
1386
|
+
notifPermission !== 'granted'
|
|
1387
|
+
? React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1388
|
+
React.createElement('button', { style: neutral, onClick: () => { Notification.requestPermission().then((p) => { if (p === 'granted') setNotifyOn(true) }) } }, translate('notification.enable')),
|
|
1389
|
+
React.createElement('span', { style: hint }, notifPermission === 'denied' ? translate('notification.denied') : ''))
|
|
1390
|
+
: React.createElement('div', { style: { marginTop: '8px', display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'wrap' } },
|
|
1391
|
+
React.createElement('span', { style: { fontSize: '12px', color: notifyOn ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)' } }, notifyOn ? `✓ ${translate('notification.enabled')}` : translate('notification.disable')),
|
|
1392
|
+
React.createElement('button', { style: ghost, onClick: () => setNotifyOn(!notifyOn) }, translate(notifyOn ? 'notification.disable' : 'notification.enable')),
|
|
1393
|
+
React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)', marginLeft: '4px' } }, translate('notification.interval')),
|
|
1394
|
+
React.createElement('input', { type: 'number', min: 5, max: 300, step: 5, value: notifyIntv, onChange: (e) => setNotifyIntv(e.target.value), style: { width: '60px', padding: '4px 6px', borderRadius: '4px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)', fontSize: '12px', textAlign: 'center' } }),
|
|
1395
|
+
React.createElement('span', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary)' } }, translate('notification.intervalUnit')))))
|
|
1396
|
+
const overviewBlock = React.createElement('div', null, versionBlock, notificationBlock, containerInfoBlock, overviewErrorsBlock)
|
|
1198
1397
|
const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
|
|
1199
1398
|
const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
|
|
1200
1399
|
const tabWarnings = {
|
|
@@ -1225,11 +1424,32 @@ window.__ModuleLoader__.load({
|
|
|
1225
1424
|
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)' } },
|
|
1226
1425
|
React.createElement('div', { style: { fontSize: '13px', fontWeight: 700 } }, translate('tabs.alert.title')),
|
|
1227
1426
|
React.createElement('div', { style: Object.assign({}, hint, { marginTop: '3px' }) }, translate('tabs.alert.body', { tabs: warningTabs.join('、') }))) : null,
|
|
1228
|
-
React.createElement('div', { 'data-testid': 'tab-list', style: { display: 'flex', gap: '
|
|
1427
|
+
React.createElement('div', { 'data-testid': 'tab-list', style: { display: 'flex', gap: '10px', flexWrap: 'wrap', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
|
|
1229
1428
|
tabs.map(([id, label]) => React.createElement('button', { key: id, style: Object.assign({}, inlineTab, activeTab === id ? inlineTabActive : { color: tabWarnings[id] ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)', borderBottom: '2px solid transparent' }), onClick: () => { setActiveTab(id); if (id === 'health') runDiagnostics(false) } }, `${tabWarnings[id] ? '⚠ ' : ''}${translate(label)}`))),
|
|
1230
1429
|
React.createElement('div', { 'data-testid': 'tab-panel', style: tabPanel }, tabContent))
|
|
1231
1430
|
}
|
|
1232
1431
|
|
|
1432
|
+
function InlineNotifyBell() {
|
|
1433
|
+
const { enabled, setEnabled } = useNotifyState()
|
|
1434
|
+
const translate = useTranslation()
|
|
1435
|
+
return React.createElement('button', {
|
|
1436
|
+
type: 'button',
|
|
1437
|
+
title: translate(enabled ? 'notification.bellOn' : 'notification.bellOff'),
|
|
1438
|
+
style: { background: 'transparent', border: 0, cursor: 'pointer', fontSize: '15px', padding: '2px 4px', color: 'inherit', opacity: enabled ? 1 : 0.45 },
|
|
1439
|
+
onClick: () => {
|
|
1440
|
+
if (typeof Notification === 'undefined') return
|
|
1441
|
+
if (Notification.permission !== 'granted') {
|
|
1442
|
+
Notification.requestPermission().then((p) => { if (p === 'granted') setEnabled(true) })
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
setEnabled(!enabled)
|
|
1446
|
+
},
|
|
1447
|
+
}, enabled ? '🔔' : '🔕')
|
|
1448
|
+
}
|
|
1449
|
+
ctx.slots.inject('conversation.input.left', () => ctx.slots.register(
|
|
1450
|
+
{ name: 'conversation.input.left', id: 'dsh-service-notify', order: 90, label: () => t('notification.bellOn') },
|
|
1451
|
+
() => React.createElement(InlineNotifyBell, null),
|
|
1452
|
+
))
|
|
1233
1453
|
ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register(
|
|
1234
1454
|
{ name: 'sidebar.footer.action', id: 'dsh-service-update', order: 90, label: () => t('update.badge') },
|
|
1235
1455
|
() => React.createElement(UpdateBadge, null),
|
package/index.js
CHANGED
|
@@ -279,6 +279,46 @@ async function createBackup(ctx, dshHome) {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
async function restoreBackup(ctx, dshHome, id) {
|
|
283
|
+
if (typeof id !== 'string' || id.length === 0) return undefined
|
|
284
|
+
const snapshot = await listBackups(dshHome)
|
|
285
|
+
const item = snapshot.items.find((candidate) => candidate.id === id)
|
|
286
|
+
if (item === undefined) return undefined
|
|
287
|
+
const staging = join(dshHome, 'backups', `.restore-${randomUUID()}`)
|
|
288
|
+
await mkdir(staging, { recursive: true, mode: 0o700 })
|
|
289
|
+
try {
|
|
290
|
+
await runTar(ctx, join(dshHome, 'backups'), ['-xzf', basename(item.name), '-C', staging])
|
|
291
|
+
const extractedSessions = join(staging, 'sessions')
|
|
292
|
+
if (await pathExists(extractedSessions)) {
|
|
293
|
+
const targetSessions = join(dshHome, 'sessions')
|
|
294
|
+
await rm(targetSessions, { recursive: true, force: true })
|
|
295
|
+
await cp(extractedSessions, targetSessions, { recursive: true })
|
|
296
|
+
}
|
|
297
|
+
const extractedConfig = join(staging, 'config')
|
|
298
|
+
if (await pathExists(extractedConfig)) {
|
|
299
|
+
for (const file of ['settings.yaml', 'cordis.patch.yml', 'AGENTS.md']) {
|
|
300
|
+
const source = join(extractedConfig, file)
|
|
301
|
+
if (await pathExists(source)) await cp(source, join(dshHome, file), { recursive: true })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const extractedProfiles = join(staging, 'profiles')
|
|
305
|
+
if (await pathExists(extractedProfiles)) {
|
|
306
|
+
const targetProfiles = join(dshHome, 'profiles')
|
|
307
|
+
for (const entry of await readdir(extractedProfiles, { withFileTypes: true })) {
|
|
308
|
+
if (!entry.isDirectory()) continue
|
|
309
|
+
const manifest = join(extractedProfiles, entry.name, 'package.json')
|
|
310
|
+
if (!(await pathExists(manifest))) continue
|
|
311
|
+
const target = join(targetProfiles, entry.name)
|
|
312
|
+
await mkdir(target, { recursive: true })
|
|
313
|
+
await cp(manifest, join(target, 'package.json'))
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { restoredFrom: item.name }
|
|
317
|
+
} finally {
|
|
318
|
+
await rm(staging, { recursive: true, force: true })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
282
322
|
async function deleteBackup(dshHome, id) {
|
|
283
323
|
if (typeof id !== 'string' || id.length === 0) return undefined
|
|
284
324
|
const snapshot = await listBackups(dshHome)
|
|
@@ -288,6 +328,16 @@ async function deleteBackup(dshHome, id) {
|
|
|
288
328
|
return listBackups(dshHome)
|
|
289
329
|
}
|
|
290
330
|
|
|
331
|
+
async function exportBackup(dshHome, downloadTokens, id) {
|
|
332
|
+
if (typeof id !== 'string' || id.length === 0) return undefined
|
|
333
|
+
const snapshot = await listBackups(dshHome)
|
|
334
|
+
const item = snapshot.items.find((candidate) => candidate.id === id)
|
|
335
|
+
if (item === undefined) return undefined
|
|
336
|
+
const token = randomUUID()
|
|
337
|
+
downloadTokens.set(token, { name: item.name, path: join(dshHome, 'backups', basename(item.name)), expires: Date.now() + 60000 })
|
|
338
|
+
return { name: item.name, url: `/dsh-backup-download?token=${token}` }
|
|
339
|
+
}
|
|
340
|
+
|
|
291
341
|
async function importBackup(dshHome, name, encoded) {
|
|
292
342
|
if (typeof name !== 'string' || !BACKUP_NAME.test(name) || typeof encoded !== 'string' || encoded.length === 0) return undefined
|
|
293
343
|
const data = Buffer.from(encoded, 'base64')
|
|
@@ -633,6 +683,12 @@ async function runFixedCommand(ctx, argv) {
|
|
|
633
683
|
}
|
|
634
684
|
}
|
|
635
685
|
|
|
686
|
+
async function upgradePlugin(ctx) {
|
|
687
|
+
await runFixedCommand(ctx, ['npm', 'install', '-g', `${PLUGIN_PACKAGE}@latest`])
|
|
688
|
+
scheduleRestart(ctx)
|
|
689
|
+
return { ok: true }
|
|
690
|
+
}
|
|
691
|
+
|
|
636
692
|
async function permissionSnapshot(ctx, dshHome, plans) {
|
|
637
693
|
if (process.platform !== 'linux' || typeof process.getuid !== 'function' || typeof process.getgid !== 'function') {
|
|
638
694
|
return { supported: false }
|
|
@@ -925,6 +981,7 @@ function scheduleRestart(ctx) {
|
|
|
925
981
|
function apply(ctx) {
|
|
926
982
|
const dshHome = resolveDshHome()
|
|
927
983
|
const permissionPlans = new Map()
|
|
984
|
+
const downloadTokens = new Map()
|
|
928
985
|
let usageIndexPromise = loadUsageIndex(dshHome)
|
|
929
986
|
let usageRefreshPromise
|
|
930
987
|
let updateCache
|
|
@@ -959,6 +1016,26 @@ function apply(ctx) {
|
|
|
959
1016
|
res.end()
|
|
960
1017
|
},
|
|
961
1018
|
}), 'dsh-service healthz route')
|
|
1019
|
+
ctx.effect(() => webServer.register({
|
|
1020
|
+
kind: 'exact',
|
|
1021
|
+
path: '/dsh-backup-download',
|
|
1022
|
+
handler: async (req, res) => {
|
|
1023
|
+
const url = new URL(req.url, 'http://localhost')
|
|
1024
|
+
const token = url.searchParams.get('token')
|
|
1025
|
+
if (!token) { res.writeHead(400); res.end(); return }
|
|
1026
|
+
const entry = downloadTokens.get(token)
|
|
1027
|
+
if (!entry || Date.now() > entry.expires) { downloadTokens.delete(token); res.writeHead(404); res.end(); return }
|
|
1028
|
+
downloadTokens.delete(token)
|
|
1029
|
+
try {
|
|
1030
|
+
const data = await readFile(entry.path)
|
|
1031
|
+
res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Disposition': `attachment; filename="${entry.name}"`, 'Content-Length': data.length })
|
|
1032
|
+
res.end(data)
|
|
1033
|
+
} catch (_) {
|
|
1034
|
+
res.writeHead(500)
|
|
1035
|
+
res.end()
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
}), 'dsh-service backup download route')
|
|
962
1039
|
}
|
|
963
1040
|
|
|
964
1041
|
// DSH 的 Connection RPC channel 只能是单层绝对路径;子功能放在 endpoint 中。
|
|
@@ -1001,6 +1078,14 @@ function apply(ctx) {
|
|
|
1001
1078
|
}
|
|
1002
1079
|
}
|
|
1003
1080
|
|
|
1081
|
+
if (endpoint === 'upgrade') {
|
|
1082
|
+
try {
|
|
1083
|
+
return { ok: true, value: await upgradePlugin(ctx) }
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1004
1089
|
if (endpoint === 'activity') {
|
|
1005
1090
|
return { ok: true, value: collectActiveWork(ctx) }
|
|
1006
1091
|
}
|
|
@@ -1084,6 +1169,16 @@ function apply(ctx) {
|
|
|
1084
1169
|
}
|
|
1085
1170
|
}
|
|
1086
1171
|
|
|
1172
|
+
if (endpoint === 'backup-export') {
|
|
1173
|
+
try {
|
|
1174
|
+
const value = await exportBackup(dshHome, downloadTokens, payload?.id)
|
|
1175
|
+
if (value === undefined) return { ok: false, error: 'unknown-backup' }
|
|
1176
|
+
return { ok: true, value }
|
|
1177
|
+
} catch (error) {
|
|
1178
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1087
1182
|
if (endpoint === 'backup-delete') {
|
|
1088
1183
|
try {
|
|
1089
1184
|
const value = await deleteBackup(dshHome, payload?.id)
|
|
@@ -1094,6 +1189,17 @@ function apply(ctx) {
|
|
|
1094
1189
|
}
|
|
1095
1190
|
}
|
|
1096
1191
|
|
|
1192
|
+
if (endpoint === 'backup-restore') {
|
|
1193
|
+
try {
|
|
1194
|
+
const value = await restoreBackup(ctx, dshHome, payload?.id)
|
|
1195
|
+
if (value === undefined) return { ok: false, error: 'unknown-backup' }
|
|
1196
|
+
scheduleRestart(ctx)
|
|
1197
|
+
return { ok: true, value }
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
return { ok: false, error: error?.message || String(error) }
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1097
1203
|
if (endpoint === 'backup-import') {
|
|
1098
1204
|
try {
|
|
1099
1205
|
const value = await importBackup(dshHome, payload?.name, payload?.data)
|