@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 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. The current release provides safe restart and recovery, update notifications, deep health diagnostics, exact incremental model-usage charts, container liveness, backup management, and Linux file-permission maintenance.
6
-
7
- > This project is still in early development and is currently verified mainly with **Linux + Docker**. Docker, systemd, pm2, or another external process manager must restart DSH Web after the plugin exits the process.
8
-
9
- ## Development status
10
-
11
- | Stage | Status | Scope |
12
- | --- | --- | --- |
13
- | Current `0.11.0` | ✅ Feature complete | Version links, latest/next update checks, model-tok breakdowns, backup import, the `/restart` command, and editability-based permission repair are implemented and tested |
14
- | v0.3 Safety and UX | Implemented | Restart guard, automatic post-restart recovery, update badge/details overlay, dynamic zh/en UI |
15
- | v0.4 Observability | Implemented | Health panel and status-code-only `/healthz` endpoint |
16
- | v0.5 Data and maintenance | ✅ Implemented | Session/config/plugin-manifest backups, backup listing/deletion, and controlled Linux permission inspection/repair |
17
- | v0.6 Panel and usage | ✅ Implemented | Version-first layout, deep health check, exact incremental model usage, provider/model and project filters, switchable charts, and collapsed maintenance details |
18
- | v0.7 Error statistics and UI | ✅ Implemented | Last-24-hour model/tool errors, path normalization, collapsed error details, card sections, and semantic button colors |
19
- | v0.8 Tabbed service control | ✅ Implemented | Overview, model statistics, backup maintenance, and restart tabs with functional-failure badges, a top summary alert, and high-contrast display surfaces |
20
- | v0.9 Native Settings style | Implemented | Overview, Health diagnostics, Model statistics, Backup maintenance, and Restart tabs with line navigation, a single content layer, and simplified statistics |
21
- | v0.10 Automatic status checks | Implemented | Separate DSH/plugin version states and release links; successful update checks cached for 10 minutes, failures for 1 minute; Health-tab diagnostics cached for 30 seconds |
22
-
23
- The plugin is currently developed and verified for **DSH `0.1.0-rc.7`** and is ready for use on that release. When newer DSH versions are published, this project will run corresponding compatibility tests and update the verified status.
24
-
25
- ## Current features
26
-
27
- - **Host version**: displays the installed `@deepseek-ai/dsh` version.
28
- - **Automatic update status**: when Settings opens, checks the `latest` and `next` dist-tags for DSH and the plugin from fixed npm registry URLs, takes the semver maximum as the effective newest version, and displays both tags beside the version number. One package failure does not contaminate the other; each version number links to its corresponding GitHub Releases page. The Host caches successful/partial results for 10 minutes and complete failures for 1 minute.
29
- - **Safe restart**: checks active agents, background jobs, and terminals, reading Terminal services from each Agent-scoped realm with a shared-service fallback; it lists active work, requires an explicit force action, and then exits DSH Web with code `42`. Conversations can also use `/restart`; the command rechecks active work and refuses while work is running instead of bypassing the panel's two-step confirmation.
30
- - **Loopback RPC**: uses the single-level `/dsh-service` channel with `version`, `check-update`, and `web` endpoints, available only to loopback callers.
31
- - **Automatic recovery**: shows a global `shell.overlay` after restart, probes for a new process instance with backoff, reloads automatically, and offers manual reload after 60 seconds.
32
- - **Bilingual UI**: the Settings page, active-work warning, and recovery overlay switch dynamically with DSH's Chinese/English locale preference.
33
- - **Update badge**: silently checks for DSH updates when the Settings page mounts; shows a sidebar-footer badge when a newer version exists, and opens a global overlay with current/latest versions. Registry failures stay silent.
34
- - **Health panel and deep diagnostics**: shows uptime, memory RSS, live/persisted sessions, active agents, and background jobs, with lightweight metrics refreshed every 5 seconds. The first entry into Health diagnostics automatically runs the full check; returning within 30 seconds reuses the result, while a later entry refreshes it. “Run again” remains available for forced rechecking. Full diagnostics use a dedicated solid display region, check rows stay expanded, and file-permission inspection/deep scan/repair live in the same tab. Results translate internal counts into readable explanations rather than exposing formats such as `0:0`.
35
- - **Exact model-usage charts**: stores exact aggregates in UTC-hour buckets and lets the RPC regroup them into the browser's local calendar day, so “Today” does not incorrectly show zero after local midnight when the Host runs in UTC. It reports successful model steps, input tok, output tok, cache tok, and cache hit rate. The UI can switch between all projects and individual workspaces and displays models as `provider/model`. The first build reads historical sessions sequentially; later refreshes fold only new events using each session revision and `lastSeq`. The index stores no messages, prompts, or tool content.
36
- - **Resume and fork semantics**: reopening an old conversation keeps the same session ID, so newly produced tok are appended to their actual day without double counting. Forks skip the inherited prefix below `seedLength`; subagent usage is included in its actual project by default.
37
- - **Model error statistics**: extracts provider failures from `llm/retry` and final failed turns, groups them by `provider/model + code + HTTP status`, and retains only the rolling last 24 hours in descending count order. Cancellations, max-token endings, and tool errors are excluded. Details are collapsed by default, follow the project filter, and are grouped in Overview.
38
- - **Tool error statistics**: counts failures from direct Tool calls and nested calls inside `run_code`, including command, read, write, edit, and search failures. Stable `error.code` values are preferred; otherwise failures are normalized into stable categories such as `FS_NOT_OBSERVED`, `OLD_STRING_NOT_FOUND`, `PATH_NOT_FOUND`, and command exit codes. Different file paths are grouped together, while the index and UI store/display only a redacted `<path>` representative. Only the last 24 hours are retained, sorted by descending count, collapsed by default, filtered by project, and shown alongside model errors in Overview.
39
- - **Five-tab service control**: Settings is split into Overview, Health diagnostics, Model statistics, Backup maintenance, and Restart line-style tabs. It uses a DSH-native-like underline selection and one content layer instead of nested background cards. Surfaces, text, borders, tabs, and state colors use DSH's native light/dark theme variables. Ordinary actions use a neutral fill, primary actions such as creating a backup use the brand fill, and repair/delete/restart actions use a white-on-danger fill. Overview contains versions, update states, existing container runtime metrics, and a titled “Error information” section for last-24-hour model/tool errors; deep diagnostics and file-permission inspection/repair live in Health diagnostics.
40
- - **Simplified model-statistics layout**: project selection uses compact line tabs. The main chart is a fixed seven-day stacked view of input, output, and cache tok with blue/orange/teal legend colors. The chart includes a K/M-abbreviated vertical axis, a dated horizontal baseline anchored at the bottom of the chart, and low-contrast grid lines. Hovering any colored segment highlights it and shows the date, input tok, output tok, and cache-hit tok on four separate lines. Provider/model rows use the format `x times · Cache hit x% · Input xM tok · Output xM tok`, are sorted by seven-day step count, and show only the top three by default; remaining models expand with `▸` and collapse with `▾`.
41
- - **Functional-failure alerts**: health/permission failures, statistics RPC failures, backup-operation failures, or restart-flow failures add `⚠` to the corresponding tab and appear in a top-level summary alert. Overview itself does not inherit health failures. Having no backups is not a failure. Model and tool error records from the last 24 hours are statistics and do not trigger tab or top alerts.
42
- - **External liveness probe**: `GET` / `HEAD /healthz` returns an empty HTTP 200; other methods return 405, with no DSH version, counts, or other information exposed. It is intended for Uptime Kuma on another server, Docker, Kubernetes, or another monitor to verify that DSH Web and its HTTP server remain reachable. It does nothing unless an external consumer is configured and does not replace the panel's deep diagnostics. When DSH listens only on `127.0.0.1`, expose this path through the existing Nginx/Caddy/Traefik reverse proxy rather than opening port 3080 directly.
43
- - **Linux file editability**: Health diagnostics checks whether the current DSH/Agent process can read, write, and enter DSH_HOME and every workspace root. Manual deep scans recursively verify the same real access instead of requiring fixed 755/644 modes, so stricter 700/600 modes do not become false positives when the Agent can still edit them. Internal `.git` metadata is not a direct Agent editing target and is skipped by both scan and repair. Nested workspaces are traversed once under the outermost root. Repair still requires two-step confirmation: it restores ownership to the current process, adds only owner `rwx` to directories and owner `rw` to ordinary files, preserves execute and group/other bits, and finally fixes `$DSH_HOME/.credentials.yaml` at 600 for the DSH startup contract. The section is hidden outside Linux.
44
- - **Backup management**: creates archives under `$DSH_HOME/backups/` containing sessions, configuration, and plugin profile manifests; sorts records newest-first, shows up to 10 directly, and collapses only after that. Each record has a right-aligned delete button, and the panel can import a selected `.tar.gz` file; deletion still requires two-step confirmation. Backups are unlimited and never auto-pruned, so disk usage is the user's responsibility. Credentials and `node_modules` are excluded.
45
- - **Lifecycle cleanup**: uses the DSH `timer` service for delayed exit and recovery probes so pending work can be disposed with the plugin Fiber.
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
+ ![Overview](./screenshots/overview_en.png)
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 so both the Host and Client plugin halves are reloaded:
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 the fixed `https://registry.npmjs.org/@deepseek-ai%2Fdsh` and `https://registry.npmjs.org/@gehennawu%2Fdsh-service` endpoints.
113
- - npm responses are limited to 256 KiB with a 10-second timeout.
114
- - The RPC channel is loopback-only and does not expose control operations to external network callers.
115
- - The model-usage index stores session revisions/watermarks, numeric aggregates by day/project/provider/model, and stable codes, counts, timestamps, and `<path>` representatives for redacted errors from the last 24 hours. It stores no messages, prompts, tool arguments, original file paths, passwords, private keys, or other credentials; it is fixed at `$DSH_HOME/dsh-service-usage-index.json` with mode 0600.
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 的服务控制与运维插件。当前版本提供安全重启与自动恢复、更新提示、健康诊断、精确增量模型用量图表、容器探活、备份管理和 Linux 文件权限维护。
6
-
7
- > 项目仍处于早期开发阶段。目前主要在 **Linux + Docker** 环境验证;重启后的自动拉起必须由 Docker、systemd、pm2 等外部进程管理器负责。
8
-
9
- ## 开发进度
10
-
11
- | 阶段 | 状态 | 内容 |
12
- | --- | --- | --- |
13
- | 当前版本 `0.11.0` | 功能完成 | 版本链接、latest/next 更新检查、模型 tok 展示、备份导入、/restart 命令和权限修复交互已实现并通过自动化测试 |
14
- | v0.3 安全与体验 | ✅ 已实现 | 重启安全网、重启后自动恢复、更新脚标与详情浮层、zh+en 动态切换 |
15
- | v0.4 可观测性 | ✅ 已实现 | 健康面板、仅返回状态码的 `/healthz` 探活端点 |
16
- | v0.5 数据与维护 | ✅ 已实现 | 会话/配置/插件清单备份、备份列表与删除、Linux 文件权限查看与受控修复 |
17
- | v0.6 面板与用量 | ✅ 已实现 | 版本置顶、完整健康检查、精确增量模型统计、供应商/模型与项目筛选、可切换图表、折叠维护详情 |
18
- | v0.7 错误统计与界面 | ✅ 已实现 | 最近 24 小时模型/工具错误、路径归一化、默认折叠错误详情、卡片分区与语义按钮颜色 |
19
- | v0.8 标签式服务控制 | ✅ 已实现 | 概览、模型统计、备份维护、重启四标签;功能故障标签警告、顶部汇总提醒和高对比度展示面 |
20
- | v0.9 原生设置页风格 | ✅ 已实现 | 概览、健康诊断、模型统计、备份维护、重启五标签;线型导航、单层内容区和简化统计布局 |
21
- | v0.10 自动状态检查 | ✅ 已实现 | DSH/插件独立版本状态与发布链接;更新检查成功缓存 10 分钟、失败缓存 1 分钟;健康标签自动诊断并缓存 30 秒 |
22
-
23
- 目前插件已在 **DSH `0.1.0-rc.7`** 中完成开发与验证,可以正常使用。后续 DSH 发布新版本后,本项目会同步进行兼容性测试并更新验证结果。
24
-
25
- ## 当前功能
26
-
27
- - **宿主版本**:显示当前安装的 `@deepseek-ai/dsh` 版本。
28
- - **自动更新状态**:设置页打开时从固定 npm registry 地址独立读取 DSH 与插件的 `latest` 和 `next` dist-tag,按 semver 取两者最大值作为最新版,并在版本号旁同时展示两个标签;单项失败不会污染另一项,版本号本身链接到对应 GitHub Releases。Host 对成功/部分成功结果缓存 10 分钟,对完全失败结果缓存 1 分钟。
29
- - **安全重启**:重启前检测活跃 agent、后台 job 和终端;Terminal 服务按 Agent scoped realm 读取并兼容共享 fallback。发现运行中工作时展示清单并要求显式强制,随后以退出码 `42` 结束当前 DSH Web 进程。对话中也可输入 `/restart`;该命令复用活动检查,检测到运行中工作时直接拒绝,不绕过面板的两段式确认。
30
- - **回环 RPC**:使用单层 `/dsh-service` channel,提供 `version`、`check-update`、`web` endpoint,仅接受 loopback 调用。
31
- - **自动恢复**:重启后通过 `shell.overlay` 显示全局状态,退避探测新的进程实例并自动刷新;60 秒未恢复时提供手动刷新。
32
- - **双语界面**:设置页、活动警告和恢复浮层跟随 DSH 的中文/英文语言设置动态切换。
33
- - **更新脚标**:设置页打开时静默检查 DSH 更新;有新版本时在侧边栏底部显示提示,点击后通过全局浮层查看当前与最新版本。registry 不可达时不打扰用户。
34
- - **健康面板与完整诊断**:显示 uptime、内存 RSS、存活/持久化会话、活跃 Agent 和后台任务;轻量指标每 5 秒刷新。首次进入健康诊断标签会自动运行完整诊断,30 秒内切换回来复用结果,过期后再次进入自动刷新;保留「重新诊断」用于强制复查。完整诊断结果位于独立实底展示区,检查项始终展开;文件权限查看/深检/修复也位于此标签。结果会把内部计数转换为可读说明,例如「备份目录可用,当前暂无备份」或「发现 N 个文件或目录权限异常」。
35
- - **精确模型用量图表**:索引按 UTC 小时保存精确聚合,RPC 根据浏览器时区重新汇总为用户本地自然日,因此 Host 运行在 UTC 时,午夜后的「今天」也不会错误显示为 0。统计包含成功模型步骤、输入 tok、输出 tok、缓存 tok 和缓存命中率,可切换全部项目或单个工作区,并以 `provider/model` 展示供应商前缀。首次索引顺序读取历史会话,以后用会话 revision 和 `lastSeq` 只折叠新增事件;索引不保存消息、Prompt 或 Tool 内容。
36
- - **续聊与 Fork 口径**:恢复旧会话沿用同一 session ID,因此后续产生的 tok 会按实际日期增量补入且不会重复;Fork 会跳过 `seedLength` 以内继承的父会话历史,Subagent 默认计入其实际项目。
37
- - **模型报错统计**:从 `llm/retry` 与最终失败回合中提取 provider 错误,按 `provider/model + code + HTTP status` 归类,只保留滚动最近 24 小时并按次数从高到低排列;取消、最大 tok 和 Tool 错误不计入。详情默认折叠、跟随项目筛选,并集中放在概览区域。
38
- - **工具报错统计**:统计直接 Tool 调用和 `run_code` 内子调用产生的失败,包括命令、读取、写入、编辑与搜索错误。优先使用稳定 `error.code`,缺失时归一化为 `FS_NOT_OBSERVED`、`OLD_STRING_NOT_FOUND`、`PATH_NOT_FOUND`、命令退出码等稳定口径;不同文件路径合并计数,索引和界面只保存/展示 `<path>` 级脱敏代表信息。只保留最近 24 小时、按次数降序、默认折叠、跟随项目筛选,并与模型报错一起显示在概览区域。
39
- - **五标签服务控制**:设置页拆分为「概览」「健康诊断」「模型统计」「备份维护」「重启」五个线型标签,采用接近 DSH 原生设置页的下划线选中态和单层内容区,减少嵌套背景框。展示面、文字、边框、标签和状态色全部使用 DSH 原生明暗主题令牌;普通操作使用中性填充,创建备份等主要操作使用品牌色填充,修复/删除/重启使用白字危险色填充。概览包含版本、更新、容器运行指标,以及带有「报错信息」标题的最近 24 小时模型/工具报错;完整诊断及文件权限检查/修复独立放入健康诊断。
40
- - **简化模型统计布局**:项目切换使用线型小标签,主图固定为近 7 天输入/输出/缓存 tok 堆叠柱图,并用蓝/橙/青图例区分结构。图表带有 K/M 缩写的纵轴刻度、贴近图表底部的横轴日期基线和低对比度网格线;鼠标移入任一颜色区段时,统一显示日期、输入 tok、输出 tok 和缓存命中 tok,并按四行换行。模型明细按近 7 天步骤数降序,格式为「x次· 缓存命中 x%· 输入 xM tok· 输出 xM tok」,默认只显示前三,其他模型通过 `▸` 展开并以 `▾` 收起。
41
- - **功能故障提醒**:健康/权限、统计 RPC、备份操作或重启流程发生功能故障时,对应标签显示 `⚠`,并在服务控制顶部汇总受影响标签。概览本身不承载健康故障标记;没有备份不算故障;最近 24 小时的模型报错和工具报错属于统计数据,也不会触发标签或顶部提醒。
42
- - **外部存活探针**:`GET` / `HEAD /healthz` 返回空的 HTTP 200;其他方法返回 405,不暴露版本、计数或其他 DSH 信息。它适合供另一台服务器上的 Uptime Kuma、Docker、Kubernetes 或其他监控程序检查 DSH Web 进程和 HTTP 服务是否仍可达;未配置外部消费者时不会自行产生作用,也不替代面板中的完整健康诊断。若 DSH 仅监听 `127.0.0.1`,应通过现有 Nginx/Caddy/Traefik 反向代理暴露该路径,不建议直接开放 3080 端口。
43
- - **备份管理**:在 `$DSH_HOME/backups/` 创建会话、配置和插件 profile 清单归档;按日期从新到旧列出,10 条以内直接展示,超过 10 条才折叠。每条记录都有右侧删除按钮,支持选择 `.tar.gz` 文件导入;删除仍需两段式确认。备份不限份数且不会自动清理,磁盘占用由用户自行管理;归档不包含凭据或 `node_modules`。
44
- - **Linux 文件可编辑性**:打开面板时在健康诊断中检查当前 DSH/Agent 进程能否读取、写入并进入 DSH_HOME 与全部工作区根目录;手动深检递归验证同一实际访问能力,而不是要求固定的 755/644,因此 700/600 等只要 Agent 可编辑就不会误报。`.git` 内部元数据不属于 Agent 直接编辑目标,深检与修复均跳过。嵌套工作区只随最外层根扫描一次。修复仍需两段式确认:递归恢复当前进程属主,目录只增加 owner `rwx`,普通文件只增加 owner `rw`,不会删除执行位或放宽 group/other 权限;`$DSH_HOME/.credentials.yaml` 最后固定为 600,以满足 DSH 启动契约。非 Linux 不显示此功能。
45
- - **生命周期清理**:优先使用 DSH `timer` 服务延迟退出与调度恢复探测,使未完成的定时动作可随插件 Fiber 清理。
5
+ 面向自托管 DSH Web 的服务控制与运维插件。提供安全重启、版本管理与一键升级、健康诊断、模型用量统计、备份管理、任务通知和 Linux 文件权限维护。
6
+
7
+ ![概览](./screenshots/overview.png)
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,使 Host 与 Client 两半插件重新加载:
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`,且 DSH Web 能加载 Host 和 Client 两半插件。检查更新需要访问 `registry.npmjs.org`;网络失败不会影响其他功能。
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
- - 更新检查只访问固定的 `https://registry.npmjs.org/@deepseek-ai%2Fdsh` 与 `https://registry.npmjs.org/@gehennawu%2Fdsh-service`。
113
- - npm 响应限制为 256 KiB,请求超时为 10 秒。
114
- - RPC channel 仅注册为 loopback,不对外部网络开放控制接口。
115
- - 模型用量索引仅保存 session revision/watermark、按日期/项目/provider/model 聚合的数字,以及最近 24 小时脱敏错误的稳定 code、计数、时间戳和 `<path>` 代表信息;不保存消息、Prompt、Tool 参数、原始文件路径、密码、私钥或其他凭据。索引固定写入 `$DSH_HOME/dsh-service-usage-index.json` 并使用 0600 权限。
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': 'latest:{latest} · next:{next}',
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 diagnostics',
209
- 'tabs.usage': 'Model statistics',
210
- 'overview.container': 'Container information',
211
- 'overview.errors': 'Error information',
212
- 'tabs.backup': 'Backup maintenance',
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': 'latest: {latest} · next: {next}',
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
- const svcStyle = document.createElement('style')
323
- svcStyle.textContent = ':root{--dsh-svc-surface-bg:#f3f4f6}body[data-ds-dark-theme]{--dsh-svc-surface-bg:#1e1e20}'
324
- document.head.appendChild(svcStyle)
325
- ctx.effect(() => () => svcStyle.remove(), 'dsh-service theme styles')
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 14px', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'color 120ms, border-color 120ms' }
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
- : null)))
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 overviewBlock = React.createElement('div', null, versionBlock, containerInfoBlock, overviewErrorsBlock)
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: '18px', flexWrap: 'wrap', borderBottom: '1px solid var(--dsw-alias-border-l1)' } },
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {