dsh-plugin-snapshot 0.1.1 → 0.3.6
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 +55 -2
- package/README.md +64 -5
- package/client/client.js +476 -0
- package/lib/apply-all.js +73 -0
- package/lib/auto-update.js +286 -0
- package/lib/doctor.js +21 -6
- package/lib/exec.js +101 -0
- package/lib/index.js +46 -9
- package/lib/install-guard.js +51 -0
- package/lib/probe.js +118 -0
- package/lib/profile.js +6 -1
- package/lib/routes.js +244 -0
- package/lib/snapshot.js +78 -9
- package/lib/updates.js +24 -12
- package/lib/util.js +23 -2
- package/package.json +16 -4
package/README.en.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# dsh-plugin-snapshot
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**Checks and upgrades your installed DSH plugins automatically — a snapshot before every upgrade, automatic rollback on failure, plus one-click rollback and a static health check.**
|
|
4
4
|
|
|
5
|
-
One plugin
|
|
5
|
+
One plugin = automatic upgrades + three tools + a Settings panel.
|
|
6
6
|
|
|
7
7
|
- npm: https://www.npmjs.com/package/dsh-plugin-snapshot
|
|
8
8
|
- repo: https://github.com/Uesccda/dsh-plugin-snapshot
|
|
@@ -32,6 +32,17 @@ It deliberately does **not** install, remove or toggle plugins: that is what the
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
+
## Settings panel
|
|
36
|
+
|
|
37
|
+
After install, **Settings → Plugin Updates** shows a three-tab panel:
|
|
38
|
+
|
|
39
|
+
| Tab | Contents | Writes |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| **Updates** | plugins with changes (updatable / major / prerelease / below-local / incompatible), per-row update, update-all | yes |
|
|
42
|
+
| **Snapshots** | list, create, restore | yes |
|
|
43
|
+
| **Diagnostics** | installed / enabled / missing / incompatible / untested / native counts, conflict table, review priority | no |
|
|
44
|
+
|
|
45
|
+
All write operations go over same-origin HTTP with three guards: custom header, `application/json`, Origin check; plus a 1.5 s throttle per endpoint.
|
|
35
46
|
## Usage
|
|
36
47
|
|
|
37
48
|
Just talk to the model:
|
|
@@ -72,6 +83,48 @@ Nothing else is written except the three profile manifests on restore.
|
|
|
72
83
|
|
|
73
84
|
---
|
|
74
85
|
|
|
86
|
+
## Auto-update
|
|
87
|
+
|
|
88
|
+
**Disabled by default.** Set `"autoUpdate": true` explicitly to enable it — installing a snapshot tool should not silently rewrite your dependency tree.
|
|
89
|
+
|
|
90
|
+
When enabled, it runs 45 seconds after DSH starts:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
checkUpdates -> filter (exclude list + scope)
|
|
94
|
+
-> lock probe: drop packages currently held by DSH
|
|
95
|
+
-> snapshot
|
|
96
|
+
-> per-package install (pnpm add --save-exact --ignore-scripts)
|
|
97
|
+
-> four-state verdict: read the on-disk version, trust no exit code
|
|
98
|
+
-> layered rollback on failure (package-level / full)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Mechanism | What it does |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| **Lock probe** | directory rename + `.node` open probe, removes packages that cannot be replaced |
|
|
104
|
+
| **Four-state verdict** | on-disk version present means success; only NO_MATCHING_VERSION / UNSUPPORTED_ENGINE / FETCH_404 count as failure |
|
|
105
|
+
| **Layered rollback** | partial success reverts only failed items; full failure restores then reinstalls |
|
|
106
|
+
| **Transient retry** | network failures retried at 8s / 30s / 90s; deterministic failures are not retried |
|
|
107
|
+
|
|
108
|
+
Results are written to `~/.dsh-plugin-snapshot/auto-update.json` and shown at the top of the Updates tab.
|
|
109
|
+
## Configuration
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
"profileDir": "explicit profile directory (auto-detected when empty)",
|
|
113
|
+
"snapshotsKeep": 7,
|
|
114
|
+
"concurrency": 8,
|
|
115
|
+
"autoUpdate": false,
|
|
116
|
+
"autoUpdateScope": "patch",
|
|
117
|
+
"autoExclude": ["package-you-never-want-touched"]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
| Option | Default | Meaning |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| autoUpdate | false | Disabled by default. Set true to check and update 45 s after DSH starts |
|
|
123
|
+
| autoUpdateScope | patch | patch only / minor / major |
|
|
124
|
+
| autoExclude | [] | packages never touched by auto-update |
|
|
125
|
+
| snapshotsKeep | 7 | how many snapshots to retain |
|
|
126
|
+
| concurrency | 8 | registry query parallelism |
|
|
127
|
+
|
|
75
128
|
## Limitations
|
|
76
129
|
|
|
77
130
|
- Snapshots cover the three manifests only, not node_modules
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# dsh-plugin-snapshot
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**自动检查并升级你安装的 DSH 插件——升级前自动打快照,失败自动回滚;也能一键回滚与静态体检。**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
一个插件 = 自动升级 + 三个工具 + 一个设置页面板。
|
|
6
6
|
|
|
7
7
|
- npm: https://www.npmjs.com/package/dsh-plugin-snapshot
|
|
8
8
|
- 仓库: https://github.com/Uesccda/dsh-plugin-snapshot
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
| 装了几十个插件,不知道谁跟谁冲突、谁的兼容声明已经过期 | 一次扫描给出:已装/已启用/缺失/不兼容/未验证,以及检查优先级 |
|
|
19
19
|
| 不知道哪些插件有新版本,又怕自动升到测试版 | 列出可更新项,预发布版本单独标注、不混进"可升级"里 |
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
它**默认不做**安装、卸载、启停——那是 DSH 自带的 plugin_manager 的事。两套实现打架是插件市场里最常见的坑,这里直接避开。
|
|
22
22
|
|
|
23
23
|
---
|
|
24
24
|
|
|
@@ -32,6 +32,17 @@
|
|
|
32
32
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
|
+
## 设置页面板
|
|
36
|
+
|
|
37
|
+
装好后,**设置 → 插件更新** 会出现一个三页签面板:
|
|
38
|
+
|
|
39
|
+
| 页签 | 内容 | 写操作 |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| **插件更新** | 列出有变化的插件(可更新 / 主版本变更 / 预发布 / 低于本地 / 不兼容),每行可单独更新,右上角可全部更新 | 是 |
|
|
42
|
+
| **快照** | 快照列表、创建、恢复 | 是 |
|
|
43
|
+
| **诊断** | 已装 / 已启用 / 缺失 / 不兼容 / 未验证 / 原生 计数,冲突表与检查优先级 | 否 |
|
|
44
|
+
|
|
45
|
+
面板所有写操作都走同源 HTTP,并带三重守卫:自定义请求头、`application/json`、Origin 校验;同一接口 1.5 秒内只接受一次。
|
|
35
46
|
## 怎么用(对话示例)
|
|
36
47
|
|
|
37
48
|
你不用记命令,直接跟模型说:
|
|
@@ -96,15 +107,63 @@ profile 目录下的三份清单:
|
|
|
96
107
|
|
|
97
108
|
- plugin_doctor 与 plugin_updates 是**纯只读**:只读文件、只发 GET 请求
|
|
98
109
|
- plugin_snapshot 的 create 只写自己的数据目录;restore 必须显式传 name
|
|
99
|
-
-
|
|
110
|
+
- 两个只读工具(plugin_updates、plugin_doctor)只读文件、只发 GET 请求
|
|
111
|
+
- 写操作(快照恢复、更新)会执行 pnpm,但只作用于 profile 中**已安装**的包,
|
|
112
|
+
只允许升到更高版本,拒绝降级与 dist-tag,并一律带 --ignore-scripts
|
|
113
|
+
- 不结束任何进程
|
|
100
114
|
|
|
101
115
|
---
|
|
102
116
|
|
|
117
|
+
## 自动更新
|
|
118
|
+
|
|
119
|
+
**默认关闭。** 需要在插件配置里显式写 `"autoUpdate": true` 才会启用——装一个快照工具不应该静默改整个依赖树。
|
|
120
|
+
|
|
121
|
+
启用后,DSH 启动 45 秒执行一次:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
checkUpdates → 过滤(排除名单 + 档位)
|
|
125
|
+
→ 占用探针:把正被 DSH 锁住的包剔出本轮
|
|
126
|
+
→ 打快照
|
|
127
|
+
→ 逐包安装(pnpm add --save-exact --ignore-scripts)
|
|
128
|
+
→ 四态判定:回读磁盘版本,不信退出码
|
|
129
|
+
→ 失败时分层回退(包级 / 整体)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| 机制 | 作用 |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| **占用探针** | 目录 rename + `.node` 读写探测,提前剔掉装不上的包,避免白跑 |
|
|
135
|
+
| **四态判定** | 磁盘版本到位即判成功;只有 NO_MATCHING_VERSION / UNSUPPORTED_ENGINE / FETCH_404 三个致命码判失败 |
|
|
136
|
+
| **分层回退** | 部分成功只退失败项,全部失败才整体恢复,恢复后补 `pnpm install` |
|
|
137
|
+
| **瞬时重试** | 网络类失败按 8s / 30s / 90s 退避重试三次;确定性失败不重试 |
|
|
138
|
+
|
|
139
|
+
结果写入 `~/.dsh-plugin-snapshot/auto-update.json`,面板「插件更新」页顶部显示上次结果。
|
|
140
|
+
## 配置项
|
|
141
|
+
|
|
142
|
+
在插件配置里可写:
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
"profileDir": "指定 profile 目录(留空则自动定位)",
|
|
146
|
+
"snapshotsKeep": 7,
|
|
147
|
+
"concurrency": 8,
|
|
148
|
+
"autoUpdate": false,
|
|
149
|
+
"autoUpdateScope": "patch",
|
|
150
|
+
"autoExclude": ["某个不想被自动更新的包"]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
| 配置 | 默认 | 说明 |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| autoUpdate | false | 是否在 DSH 启动 45 秒后自动检查并更新 |
|
|
156
|
+
| autoUpdateScope | patch | 升级档位:patch 仅补丁版 / minor 含次版本 / major 含主版本 |
|
|
157
|
+
| autoExclude | [] | 永不参与自动更新的包名列表 |
|
|
158
|
+
| snapshotsKeep | 7 | 保留的快照份数 |
|
|
159
|
+
| concurrency | 8 | 查询 registry 的并发数 |
|
|
160
|
+
|
|
103
161
|
## 已知限制
|
|
104
162
|
|
|
105
163
|
- 快照只覆盖三份清单,不含 node_modules
|
|
106
164
|
- 诊断基于静态扫描与作者声明,不代表运行时验证
|
|
107
|
-
-
|
|
165
|
+
- 自动更新**默认关闭**:需在插件配置里显式写 autoUpdate: true 才会启用
|
|
166
|
+
- 插件只在 DSH 运行时存在,DSH 关闭期间不做任何事
|
|
108
167
|
|
|
109
168
|
---
|
|
110
169
|
|
package/client/client.js
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-snapshot - 设置页里的「插件更新 / 快照 / 诊断」面板。
|
|
3
|
+
*
|
|
4
|
+
* 契约(照 dsh-soul、dsh-session-manager 两个可用样本):
|
|
5
|
+
* __ModuleLoader__ + module.exports + Symbol.toStringTag='Module';
|
|
6
|
+
* ctx.slots.inject(slot, () => ctx.slots.register(options, Component));
|
|
7
|
+
* Component 是组件函数,不是工厂。
|
|
8
|
+
*
|
|
9
|
+
* 数据全部走同源 HTTP:前端拿不到 host 侧的工具。
|
|
10
|
+
*/
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-plugin-snapshot',
|
|
13
|
+
factory: (require) => {
|
|
14
|
+
const module = { exports: {} };
|
|
15
|
+
const exports = module.exports;
|
|
16
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
17
|
+
|
|
18
|
+
const React = require('react');
|
|
19
|
+
|
|
20
|
+
const S = {
|
|
21
|
+
wrap: { padding: '8px 4px' },
|
|
22
|
+
tabs: { display: 'flex', gap: '6px', marginBottom: '12px', borderBottom: '1px solid #e6eaef', paddingBottom: '8px' },
|
|
23
|
+
tab: { padding: '5px 12px', borderRadius: '6px', border: '1px solid transparent', background: 'transparent', cursor: 'pointer', fontSize: '13px', color: '#57606a' },
|
|
24
|
+
tabOn: { padding: '5px 12px', borderRadius: '6px', border: '1px solid #c3ccd8', background: '#fff', cursor: 'pointer', fontSize: '13px', color: '#1f2328', fontWeight: 600 },
|
|
25
|
+
head: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', marginBottom: '8px' },
|
|
26
|
+
title: { margin: '0', fontSize: '15px', fontWeight: 600 },
|
|
27
|
+
btn: { padding: '4px 10px', borderRadius: '6px', border: '1px solid #c3ccd8', background: '#fff', cursor: 'pointer', fontSize: '12.5px' },
|
|
28
|
+
btnOn: { padding: '4px 10px', borderRadius: '6px', border: '1px solid #2f6bff', background: '#2f6bff', color: '#fff', cursor: 'pointer', fontSize: '12.5px' },
|
|
29
|
+
meta: { fontSize: '12px', color: '#57606a', marginBottom: '10px', lineHeight: '1.7' },
|
|
30
|
+
err: { fontSize: '12px', color: '#cf222e', marginBottom: '10px' },
|
|
31
|
+
ok: { fontSize: '12px', color: '#1a7f37', marginBottom: '10px' },
|
|
32
|
+
table: { width: '100%', borderCollapse: 'collapse', fontSize: '12.5px' },
|
|
33
|
+
th: { textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid #d8dee6', color: '#57606a', fontWeight: 600, whiteSpace: 'nowrap' },
|
|
34
|
+
td: { padding: '6px 8px', borderBottom: '1px solid #eef1f4', verticalAlign: 'top' },
|
|
35
|
+
mono: { fontFamily: 'Consolas, monospace', fontSize: '12px' },
|
|
36
|
+
empty: { padding: '16px 8px', color: '#57606a', fontSize: '12.5px' }
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function stateColor(state) {
|
|
40
|
+
if (state === 'updatable') return '#1a7f37';
|
|
41
|
+
if (state === 'major') return '#9a6700';
|
|
42
|
+
if (state === 'prerelease') return '#8250df';
|
|
43
|
+
if (state === 'incompatible') return '#cf222e';
|
|
44
|
+
if (state === 'downgrade') return '#8b949e';
|
|
45
|
+
return '#57606a';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stateText(state) {
|
|
49
|
+
const m = { updatable: '可更新', major: '主版本变更', prerelease: '预发布', uptodate: '已最新', failed: '查询失败', manual: '需人工确认', unknown: '未知', downgrade: '低于本地', incompatible: '不兼容当前 DSH' };
|
|
50
|
+
return m[state] || state;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* 写操作必须带 X-DSH-Snapshot 头 + JSON 内容类型:
|
|
54
|
+
跨源请求带自定义头会触发 CORS preflight,而本服务不应答 preflight,
|
|
55
|
+
浏览器会直接拦下 —— 这是服务端写守卫的前端半边。 */
|
|
56
|
+
function post(url, body) {
|
|
57
|
+
return fetch(url, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'content-type': 'application/json', 'x-dsh-snapshot': '1' },
|
|
60
|
+
body: JSON.stringify(body || {})
|
|
61
|
+
}).then(function (r) { return r.json(); });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function get(url) {
|
|
65
|
+
return fetch(url, { headers: { accept: 'application/json' } }).then(function (r) { return r.json(); });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ---------------- 更新 ---------------- */
|
|
69
|
+
function UpdatesTab() {
|
|
70
|
+
const st = React.useState({ loading: true, items: [], summary: null, error: null, note: null, auto: null });
|
|
71
|
+
const d = st[0];
|
|
72
|
+
const setD = st[1];
|
|
73
|
+
const bulkSt = React.useState(false);
|
|
74
|
+
const bulk = bulkSt[0];
|
|
75
|
+
const setBulk = bulkSt[1];
|
|
76
|
+
|
|
77
|
+
const busySt = React.useState({});
|
|
78
|
+
const busy = busySt[0];
|
|
79
|
+
const busyMap = busy;
|
|
80
|
+
const setBusy = busySt[1];
|
|
81
|
+
|
|
82
|
+
const load = React.useCallback(function () {
|
|
83
|
+
setD(function (p) { return { loading: true, items: p.items, summary: p.summary, error: null, note: null, auto: p.auto }; });
|
|
84
|
+
Promise.all([
|
|
85
|
+
get('/dsh-plugin-snapshot/api/updates'),
|
|
86
|
+
get('/dsh-plugin-snapshot/api/auto').catch(function () { return { ok: false }; })
|
|
87
|
+
]).then(function (arr) {
|
|
88
|
+
const j = arr[0];
|
|
89
|
+
const au = arr[1];
|
|
90
|
+
if (!j || !j.ok) throw new Error((j && j.error) || '请求失败');
|
|
91
|
+
setD({ loading: false, items: j.items || [], summary: j.summary || null, error: null, note: null, auto: (au && au.ok) ? au.state : null });
|
|
92
|
+
}).catch(function (e) {
|
|
93
|
+
setD({ loading: false, items: [], summary: null, error: String((e && e.message) || e), note: null, auto: null });
|
|
94
|
+
});
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
React.useEffect(function () { load(); }, [load]);
|
|
98
|
+
|
|
99
|
+
function applyAll(list) {
|
|
100
|
+
setBulk(true);
|
|
101
|
+
setD(function (p) { return Object.assign({}, p, { note: null, error: null }); });
|
|
102
|
+
post('/dsh-plugin-snapshot/api/apply-all', {
|
|
103
|
+
items: list.map(function (x) { return { name: x.name, version: x.candidate }; })
|
|
104
|
+
}).then(function (j) {
|
|
105
|
+
setBulk(false);
|
|
106
|
+
if (j && j.ok) {
|
|
107
|
+
setD(function (p) { return Object.assign({}, p, { note: '已更新 ' + j.count + ' 个插件,快照 ' + j.snapshot + ',重启 DSH 后生效' }); });
|
|
108
|
+
load();
|
|
109
|
+
} else {
|
|
110
|
+
/* 后端在「部分失败」时返回 ok:false 但没有 error 字段,
|
|
111
|
+
直接把这种情况显示成「未知错误」会掩盖真实结果。 */
|
|
112
|
+
const fl = Array.isArray(j && j.failed) ? j.failed : [];
|
|
113
|
+
const detail = (j && j.error)
|
|
114
|
+
? j.error
|
|
115
|
+
: (function () {
|
|
116
|
+
const okN = (j && j.count) || 0;
|
|
117
|
+
const badN = (j && j.failedCount) || fl.length;
|
|
118
|
+
const names = fl.slice(0, 3).map(function (x) { return x.name; }).join("、");
|
|
119
|
+
return "成功 " + okN + " 个,失败 " + badN + " 个" + (names ? "(" + names + (fl.length > 3 ? " 等" : "") + ")" : "");
|
|
120
|
+
})();
|
|
121
|
+
setD(function (p) { return Object.assign({}, p, { error: "批量更新:" + detail }); });
|
|
122
|
+
}
|
|
123
|
+
}).catch(function (e) {
|
|
124
|
+
setBulk(false);
|
|
125
|
+
setD(function (p) { return Object.assign({}, p, { error: String((e && e.message) || e) }); });
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function apply(x) {
|
|
130
|
+
if (x.state === 'major') {
|
|
131
|
+
const from = x.resolved || x.declared || '?';
|
|
132
|
+
if (!window.confirm('这是主版本变更:' + from + ' → ' + x.candidate + ',可能有破坏性改动。仍然更新?')) return;
|
|
133
|
+
}
|
|
134
|
+
setBusy(function (p) { const n = Object.assign({}, p); n[x.name] = true; return n; });
|
|
135
|
+
setD(function (p) { return Object.assign({}, p, { note: null }); });
|
|
136
|
+
post('/dsh-plugin-snapshot/api/apply', { name: x.name, version: x.candidate }).then(function (j) {
|
|
137
|
+
setBusy(function (p) { const n = Object.assign({}, p); delete n[x.name]; return n; });
|
|
138
|
+
if (j && j.ok) {
|
|
139
|
+
setD(function (p) { return Object.assign({}, p, { note: '已更新 ' + x.name + ' 到 ' + x.candidate + ',快照 ' + j.snapshot + ',重启 DSH 后生效' }); });
|
|
140
|
+
load();
|
|
141
|
+
} else {
|
|
142
|
+
setD(function (p) { return Object.assign({}, p, { error: '更新失败:' + ((j && j.error) || '未知错误') }); });
|
|
143
|
+
}
|
|
144
|
+
}).catch(function (e) {
|
|
145
|
+
setBusy(function (p) { const n = Object.assign({}, p); delete n[x.name]; return n; });
|
|
146
|
+
setD(function (p) { return Object.assign({}, p, { error: String((e && e.message) || e) }); });
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const s = d.summary || {};
|
|
151
|
+
const rows = (d.items || []).filter(function (x) { return x.state !== 'uptodate'; });
|
|
152
|
+
|
|
153
|
+
const applyTargets = (d.items || []).filter(function (x) { return (x.state === 'updatable' || x.state === 'major') && x.candidate; });
|
|
154
|
+
const head = React.createElement('div', { style: S.head }, [
|
|
155
|
+
React.createElement('h3', { key: 't', style: S.title }, '插件更新'),
|
|
156
|
+
React.createElement('div', { key: 'acts', style: { display: 'flex', gap: '8px' } }, [
|
|
157
|
+
applyTargets.length > 0
|
|
158
|
+
? React.createElement('button', {
|
|
159
|
+
key: 'all',
|
|
160
|
+
style: S.btnOn,
|
|
161
|
+
disabled: bulk,
|
|
162
|
+
onClick: function () {
|
|
163
|
+
const majors = applyTargets.filter(function (x) { return x.state === 'major'; }).length;
|
|
164
|
+
const tip = '一次更新 ' + applyTargets.length + ' 个插件'
|
|
165
|
+
+ (majors > 0 ? '(其中 ' + majors + ' 个是主版本变更,可能有破坏性改动)' : '')
|
|
166
|
+
+ '?会先自动打快照。';
|
|
167
|
+
if (!window.confirm(tip)) return;
|
|
168
|
+
applyAll(applyTargets);
|
|
169
|
+
}
|
|
170
|
+
}, bulk ? '更新中…' : ('全部更新 (' + applyTargets.length + ')' + (applyTargets.filter(function (y) { return y.state === 'major'; }).length > 0 ? ' · 含大版本' : '')))
|
|
171
|
+
: null,
|
|
172
|
+
React.createElement('button', { key: 'b', style: S.btn, disabled: d.loading, onClick: function () { load(); } },
|
|
173
|
+
d.loading ? '检测中…' : '重新检测')
|
|
174
|
+
])
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const meta = React.createElement('div', { style: S.meta },
|
|
178
|
+
'可更新 ' + (s.updatable || 0) + ' 个 · 主版本 ' + (s.major || 0) + ' 个 · 共检测 ' + (s.total || 0) + ' 个插件');
|
|
179
|
+
|
|
180
|
+
let autoLine = null;
|
|
181
|
+
if (d.auto) {
|
|
182
|
+
const a = d.auto;
|
|
183
|
+
const map = {
|
|
184
|
+
'no-update': '没有需要更新的版本',
|
|
185
|
+
updated: '已自动更新 ' + (a.count || 0) + ' 个插件,重启 DSH 后生效',
|
|
186
|
+
partial: '部分成功:成功 ' + (a.count || 0) + ' 个,失败 ' + (a.failedCount || 0) + ' 个,失败项已回退',
|
|
187
|
+
'failed-rolled-back': '全部失败,已整体恢复到快照 ' + (a.snapshot || ''),
|
|
188
|
+
'failed-rollback-error': '全部失败,且回滚未成功,请手动检查',
|
|
189
|
+
'locked-abort': '全部目标都被 DSH 占用,本次未执行(关闭 DSH 后重试)',
|
|
190
|
+
'probe-damaged': '探针改名后无法还原,包目录可能已损坏,本次已中止',
|
|
191
|
+
error: '自动更新出错:' + (a.error || '')
|
|
192
|
+
};
|
|
193
|
+
const text = map[a.outcome] || a.outcome;
|
|
194
|
+
const color = a.outcome === 'updated' ? '#1a7f37' : (a.outcome === 'no-update' || a.outcome === 'locked-abort') ? '#57606a' : (a.outcome === 'partial' ? '#9a6700' : '#cf222e');
|
|
195
|
+
const failedList = Array.isArray(a.failed) ? a.failed : [];
|
|
196
|
+
const children = [
|
|
197
|
+
React.createElement('div', { key: 'm', style: { fontSize: '12px', color: color } },
|
|
198
|
+
'上次自动更新(' + (a.updatedAtText || '') + '):' + text)
|
|
199
|
+
];
|
|
200
|
+
const lockedList = Array.isArray(a.skippedLocked) ? a.skippedLocked : (Array.isArray(a.locked) ? a.locked : []);
|
|
201
|
+
const fatalList = Array.isArray(a.fatal) ? a.fatal : [];
|
|
202
|
+
if (fatalList.length > 0) {
|
|
203
|
+
children.push(React.createElement('div', { key: 'fd', style: { fontSize: '12px', color: '#cf222e', marginTop: '4px', fontWeight: 600 } },
|
|
204
|
+
'包目录损坏:' + fatalList.map(function (x) { return x.name + '(临时目录 ' + (x.tmpPath || '?') + ')'; }).join(';')
|
|
205
|
+
+ '——请手动把临时目录改名回原名,或从快照恢复'));
|
|
206
|
+
}
|
|
207
|
+
if (lockedList.length > 0) {
|
|
208
|
+
children.push(React.createElement('div', { key: 'l', style: { fontSize: '11.5px', color: '#9a6700', marginTop: '4px' } },
|
|
209
|
+
'跳过(文件被 DSH 占用):' + lockedList.map(function (x) { return x.name; }).join('、') + '——关闭 DSH 后可手动更新'));
|
|
210
|
+
}
|
|
211
|
+
if (failedList.length > 0) {
|
|
212
|
+
children.push(React.createElement('div', { key: 'f', style: { fontSize: '11.5px', color: '#cf222e', marginTop: '4px' } },
|
|
213
|
+
'失败项:' + failedList.map(function (x) {
|
|
214
|
+
const kindText = {
|
|
215
|
+
'cache-skip': '缓存跳过,磁盘版本未变',
|
|
216
|
+
unverified: '版本到位但安装过程报错',
|
|
217
|
+
failed: '安装失败',
|
|
218
|
+
threw: '执行异常',
|
|
219
|
+
warn: '成功(构建脚本告警)',
|
|
220
|
+
success: '成功'
|
|
221
|
+
}[x.kind] || x.kind;
|
|
222
|
+
const hint = /EPERM|EBUSY|EACCES/i.test(x.output || '') ? '(文件被 DSH 占用,需手动关闭 DSH 后重试)' : '';
|
|
223
|
+
return x.name + ':' + kindText + hint;
|
|
224
|
+
}).join(';')));
|
|
225
|
+
}
|
|
226
|
+
autoLine = React.createElement('div', { style: { marginBottom: '8px' } }, children);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const notice = d.error
|
|
230
|
+
? React.createElement('div', { style: S.err }, d.error)
|
|
231
|
+
: (d.note ? React.createElement('div', { style: S.ok }, d.note) : null);
|
|
232
|
+
|
|
233
|
+
let body;
|
|
234
|
+
if (d.loading && rows.length === 0 && !d.error) {
|
|
235
|
+
body = React.createElement('div', { style: S.empty }, '正在读取 registry…');
|
|
236
|
+
} else if (rows.length === 0) {
|
|
237
|
+
body = React.createElement('div', { style: S.empty }, d.error ? '暂无数据' : '全部插件都是最新版本');
|
|
238
|
+
} else {
|
|
239
|
+
body = React.createElement('table', { style: S.table }, [
|
|
240
|
+
React.createElement('thead', { key: 'h' }, React.createElement('tr', null, [
|
|
241
|
+
React.createElement('th', { key: 'a', style: S.th }, '插件'),
|
|
242
|
+
React.createElement('th', { key: 'b', style: S.th }, '当前'),
|
|
243
|
+
React.createElement('th', { key: 'c', style: S.th }, '最新'),
|
|
244
|
+
React.createElement('th', { key: 'd', style: S.th }, '状态'),
|
|
245
|
+
React.createElement('th', { key: 'e', style: S.th }, '操作')
|
|
246
|
+
])),
|
|
247
|
+
React.createElement('tbody', { key: 'b' }, rows.map(function (x) {
|
|
248
|
+
const canApply = (x.state === 'updatable' || x.state === 'major') && x.candidate;
|
|
249
|
+
const isBusy = !!busyMap[x.name];
|
|
250
|
+
return React.createElement('tr', { key: x.name }, [
|
|
251
|
+
React.createElement('td', { key: 'a', style: S.td }, x.name),
|
|
252
|
+
React.createElement('td', { key: 'b', style: Object.assign({}, S.td, S.mono) }, x.resolved || x.declared || '-'),
|
|
253
|
+
React.createElement('td', { key: 'c', style: Object.assign({}, S.td, S.mono) }, x.candidate || '-'),
|
|
254
|
+
React.createElement('td', { key: 'd', style: Object.assign({}, S.td, { color: stateColor(x.state), fontWeight: 600 }) }, stateText(x.state)),
|
|
255
|
+
React.createElement('td', { key: 'e', style: S.td }, canApply
|
|
256
|
+
? React.createElement('button', { style: isBusy ? S.btn : S.btnOn, disabled: isBusy, onClick: function () { apply(x); } }, isBusy ? '更新中…' : '更新')
|
|
257
|
+
: React.createElement('span', { style: { color: '#8b949e', fontSize: '12px' } }, '—'))
|
|
258
|
+
]);
|
|
259
|
+
}))
|
|
260
|
+
]);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return React.createElement('div', null, [head, meta, autoLine, notice, body]);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* ---------------- 快照 ---------------- */
|
|
267
|
+
function SnapshotsTab() {
|
|
268
|
+
const st = React.useState({ loading: true, list: [], error: null, note: null });
|
|
269
|
+
const d = st[0];
|
|
270
|
+
const setD = st[1];
|
|
271
|
+
|
|
272
|
+
const load = React.useCallback(function () {
|
|
273
|
+
setD(function (p) { return Object.assign({}, p, { loading: true, error: null }); });
|
|
274
|
+
get('/dsh-plugin-snapshot/api/snapshots').then(function (j) {
|
|
275
|
+
if (!j || !j.ok) throw new Error((j && j.error) || '请求失败');
|
|
276
|
+
setD({ loading: false, list: j.snapshots || [], error: null, note: null });
|
|
277
|
+
}).catch(function (e) {
|
|
278
|
+
setD({ loading: false, list: [], error: String((e && e.message) || e), note: null });
|
|
279
|
+
});
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
React.useEffect(function () { load(); }, [load]);
|
|
283
|
+
|
|
284
|
+
function create() {
|
|
285
|
+
post('/dsh-plugin-snapshot/api/snapshot/create', {}).then(function (j) {
|
|
286
|
+
if (j && j.ok) { setD(function (p) { return Object.assign({}, p, { note: '已创建快照 ' + j.created, error: null }); }); load(); }
|
|
287
|
+
else setD(function (p) { return Object.assign({}, p, { error: (j && j.error) || '创建失败' }); });
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function restore(name) {
|
|
292
|
+
if (!window.confirm('确定恢复到快照 ' + name + '?会覆盖当前 profile 的三份清单。')) return;
|
|
293
|
+
post('/dsh-plugin-snapshot/api/snapshot/restore', { name: name }).then(function (j) {
|
|
294
|
+
if (j && j.ok) { setD(function (p) { return Object.assign({}, p, { note: '已恢复 ' + j.restored.join(', ') + '(来自 ' + j.from + '),重启 DSH 后生效', error: null }); }); load(); }
|
|
295
|
+
else setD(function (p) { return Object.assign({}, p, { error: (j && j.error) || '恢复失败' }); });
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const head = React.createElement('div', { style: S.head }, [
|
|
300
|
+
React.createElement('h3', { key: 't', style: S.title }, '快照'),
|
|
301
|
+
React.createElement('button', { key: 'b', style: S.btnOn, onClick: create }, '创建快照')
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
const meta = React.createElement('div', { style: S.meta }, '共 ' + d.list.length + ' 份。每份含 profile 指纹与文件哈希,恢复前会校验身份。');
|
|
305
|
+
|
|
306
|
+
let autoLine = null;
|
|
307
|
+
if (d.auto) {
|
|
308
|
+
const a = d.auto;
|
|
309
|
+
const map = {
|
|
310
|
+
'no-update': '没有需要更新的版本',
|
|
311
|
+
updated: '已自动更新 ' + (a.count || 0) + ' 个插件,重启 DSH 后生效',
|
|
312
|
+
partial: '部分成功:成功 ' + (a.count || 0) + ' 个,失败 ' + (a.failedCount || 0) + ' 个,失败项已回退',
|
|
313
|
+
'failed-rolled-back': '全部失败,已整体恢复到快照 ' + (a.snapshot || ''),
|
|
314
|
+
'failed-rollback-error': '全部失败,且回滚未成功,请手动检查',
|
|
315
|
+
'locked-abort': '全部目标都被 DSH 占用,本次未执行(关闭 DSH 后重试)',
|
|
316
|
+
error: '自动更新出错:' + (a.error || '')
|
|
317
|
+
};
|
|
318
|
+
const text = map[a.outcome] || a.outcome;
|
|
319
|
+
const color = a.outcome === 'updated' ? '#1a7f37' : (a.outcome === 'no-update' || a.outcome === 'locked-abort') ? '#57606a' : (a.outcome === 'partial' ? '#9a6700' : '#cf222e');
|
|
320
|
+
const failedList = Array.isArray(a.failed) ? a.failed : [];
|
|
321
|
+
const children = [
|
|
322
|
+
React.createElement('div', { key: 'm', style: { fontSize: '12px', color: color } },
|
|
323
|
+
'上次自动更新(' + (a.updatedAtText || '') + '):' + text)
|
|
324
|
+
];
|
|
325
|
+
const lockedList = Array.isArray(a.skippedLocked) ? a.skippedLocked : (Array.isArray(a.locked) ? a.locked : []);
|
|
326
|
+
if (lockedList.length > 0) {
|
|
327
|
+
children.push(React.createElement('div', { key: 'l', style: { fontSize: '11.5px', color: '#9a6700', marginTop: '4px' } },
|
|
328
|
+
'跳过(文件被 DSH 占用):' + lockedList.map(function (x) { return x.name; }).join('、') + '——关闭 DSH 后可手动更新'));
|
|
329
|
+
}
|
|
330
|
+
if (failedList.length > 0) {
|
|
331
|
+
children.push(React.createElement('div', { key: 'f', style: { fontSize: '11.5px', color: '#cf222e', marginTop: '4px' } },
|
|
332
|
+
'失败项:' + failedList.map(function (x) {
|
|
333
|
+
const kindText = {
|
|
334
|
+
'cache-skip': '缓存跳过,磁盘版本未变',
|
|
335
|
+
unverified: '版本到位但安装过程报错',
|
|
336
|
+
failed: '安装失败',
|
|
337
|
+
threw: '执行异常'
|
|
338
|
+
}[x.kind] || x.kind;
|
|
339
|
+
const hint = /EPERM|EBUSY|EACCES/i.test(x.output || '') ? '(文件被 DSH 占用,需手动关闭 DSH 后重试)' : '';
|
|
340
|
+
return x.name + ':' + kindText + hint;
|
|
341
|
+
}).join(';')));
|
|
342
|
+
}
|
|
343
|
+
autoLine = React.createElement('div', { style: { marginBottom: '8px' } }, children);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const notice = d.error
|
|
347
|
+
? React.createElement('div', { style: S.err }, d.error)
|
|
348
|
+
: (d.note ? React.createElement('div', { style: S.ok }, d.note) : null);
|
|
349
|
+
|
|
350
|
+
let body;
|
|
351
|
+
if (d.loading && d.list.length === 0) body = React.createElement('div', { style: S.empty }, '读取中…');
|
|
352
|
+
else if (d.list.length === 0) body = React.createElement('div', { style: S.empty }, '还没有快照,点右上角创建一份');
|
|
353
|
+
else body = React.createElement('table', { style: S.table }, [
|
|
354
|
+
React.createElement('thead', { key: 'h' }, React.createElement('tr', null, [
|
|
355
|
+
React.createElement('th', { key: 'a', style: S.th }, '快照'),
|
|
356
|
+
React.createElement('th', { key: 'b', style: S.th }, '创建时间'),
|
|
357
|
+
React.createElement('th', { key: 'c', style: S.th }, '依赖数'),
|
|
358
|
+
React.createElement('th', { key: 'd', style: S.th }, '操作')
|
|
359
|
+
])),
|
|
360
|
+
React.createElement('tbody', { key: 'b' }, d.list.map(function (s) {
|
|
361
|
+
return React.createElement('tr', { key: s.name }, [
|
|
362
|
+
React.createElement('td', { key: 'a', style: Object.assign({}, S.td, S.mono) }, s.name),
|
|
363
|
+
React.createElement('td', { key: 'b', style: S.td }, new Date(s.createdAt).toLocaleString()),
|
|
364
|
+
React.createElement('td', { key: 'c', style: S.td }, String(s.deps)),
|
|
365
|
+
React.createElement('td', { key: 'd', style: S.td },
|
|
366
|
+
React.createElement('button', { style: S.btn, onClick: function () { restore(s.name); } }, '恢复'))
|
|
367
|
+
]);
|
|
368
|
+
}))
|
|
369
|
+
]);
|
|
370
|
+
|
|
371
|
+
return React.createElement('div', null, [head, meta, notice, body]);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* ---------------- 诊断 ---------------- */
|
|
375
|
+
function DoctorTab() {
|
|
376
|
+
const st = React.useState({ loading: true, data: null, error: null });
|
|
377
|
+
const d = st[0];
|
|
378
|
+
const setD = st[1];
|
|
379
|
+
|
|
380
|
+
const load = React.useCallback(function () {
|
|
381
|
+
setD(function (p) { return Object.assign({}, p, { loading: true, error: null }); });
|
|
382
|
+
get('/dsh-plugin-snapshot/api/doctor').then(function (j) {
|
|
383
|
+
if (!j || !j.ok) throw new Error((j && j.error) || '请求失败');
|
|
384
|
+
setD({ loading: false, data: j, error: null });
|
|
385
|
+
}).catch(function (e) {
|
|
386
|
+
setD({ loading: false, data: null, error: String((e && e.message) || e) });
|
|
387
|
+
});
|
|
388
|
+
}, []);
|
|
389
|
+
|
|
390
|
+
React.useEffect(function () { load(); }, [load]);
|
|
391
|
+
|
|
392
|
+
const head = React.createElement('div', { style: S.head }, [
|
|
393
|
+
React.createElement('h3', { key: 't', style: S.title }, '插件诊断'),
|
|
394
|
+
React.createElement('button', { key: 'b', style: S.btn, disabled: d.loading, onClick: function () { load(); } },
|
|
395
|
+
d.loading ? '扫描中…' : '重新扫描')
|
|
396
|
+
]);
|
|
397
|
+
|
|
398
|
+
if (d.error) return React.createElement('div', null, [head, React.createElement('div', { style: S.err }, d.error)]);
|
|
399
|
+
if (!d.data) return React.createElement('div', null, [head, React.createElement('div', { style: S.empty }, '扫描中…')]);
|
|
400
|
+
|
|
401
|
+
const s = d.data.summary || {};
|
|
402
|
+
const meta = React.createElement('div', { style: S.meta },
|
|
403
|
+
'已装 ' + s.installed + ' · 已启用 ' + (s.unknownEnabled > 0 && s.enabled === 0 ? '未知' : s.enabled) + ' · 缺失 ' + s.missing
|
|
404
|
+
+ ' · 不兼容 ' + s.incompatible + ' · 未验证 ' + s.untested + ' · 原生 ' + s.native);
|
|
405
|
+
|
|
406
|
+
const conflicts = d.data.conflicts || [];
|
|
407
|
+
const conflictBox = conflicts.length === 0
|
|
408
|
+
? React.createElement('div', { style: S.empty }, '未发现已知冲突')
|
|
409
|
+
: React.createElement('table', { style: S.table }, [
|
|
410
|
+
React.createElement('thead', { key: 'h' }, React.createElement('tr', null, [
|
|
411
|
+
React.createElement('th', { key: 'a', style: S.th }, '级别'),
|
|
412
|
+
React.createElement('th', { key: 'b', style: S.th }, '类型'),
|
|
413
|
+
React.createElement('th', { key: 'c', style: S.th }, '说明')
|
|
414
|
+
])),
|
|
415
|
+
React.createElement('tbody', { key: 'b' }, conflicts.map(function (c, i) {
|
|
416
|
+
const color = c.severity === 'critical' ? '#cf222e' : c.severity === 'high' ? '#9a6700' : '#57606a';
|
|
417
|
+
return React.createElement('tr', { key: i }, [
|
|
418
|
+
React.createElement('td', { key: 'a', style: Object.assign({}, S.td, { color: color, fontWeight: 600 }) }, c.severity),
|
|
419
|
+
React.createElement('td', { key: 'b', style: S.td }, c.kind),
|
|
420
|
+
React.createElement('td', { key: 'c', style: S.td }, c.message)
|
|
421
|
+
]);
|
|
422
|
+
}))
|
|
423
|
+
]);
|
|
424
|
+
|
|
425
|
+
return React.createElement('div', null, [head, meta, conflictBox]);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/* ---------------- 外壳 ---------------- */
|
|
429
|
+
function Panel() {
|
|
430
|
+
const st = React.useState('updates');
|
|
431
|
+
const tab = st[0];
|
|
432
|
+
const setTab = st[1];
|
|
433
|
+
|
|
434
|
+
function tabBtn(key, label) {
|
|
435
|
+
return React.createElement('button', {
|
|
436
|
+
key: key,
|
|
437
|
+
style: tab === key ? S.tabOn : S.tab,
|
|
438
|
+
onClick: function () { setTab(key); }
|
|
439
|
+
}, label);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
let body;
|
|
443
|
+
if (tab === 'updates') body = React.createElement(UpdatesTab);
|
|
444
|
+
else if (tab === 'snapshots') body = React.createElement(SnapshotsTab);
|
|
445
|
+
else body = React.createElement(DoctorTab);
|
|
446
|
+
|
|
447
|
+
return React.createElement('div', { style: S.wrap }, [
|
|
448
|
+
React.createElement('div', { key: 'tabs', style: S.tabs }, [
|
|
449
|
+
tabBtn('updates', '插件更新'),
|
|
450
|
+
tabBtn('snapshots', '快照'),
|
|
451
|
+
tabBtn('doctor', '诊断')
|
|
452
|
+
]),
|
|
453
|
+
React.createElement('div', { key: 'body' }, body)
|
|
454
|
+
]);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const inject = ['slots'];
|
|
458
|
+
|
|
459
|
+
function apply(ctx) {
|
|
460
|
+
ctx.slots.inject('settings.section', function () {
|
|
461
|
+
return ctx.slots.register({
|
|
462
|
+
name: 'settings.section',
|
|
463
|
+
id: 'plugin-snapshot',
|
|
464
|
+
order: 50,
|
|
465
|
+
label: function () { return '插件更新'; }
|
|
466
|
+
}, Panel);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
exports.name = 'dsh-plugin-snapshot';
|
|
471
|
+
exports.inject = inject;
|
|
472
|
+
exports.apply = apply;
|
|
473
|
+
return module.exports;
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|