dsh-plugin-snapshot 0.1.0 → 0.3.5
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 +73 -13
- package/README.md +99 -40
- 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 +23 -4
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
|
+
|
package/lib/apply-all.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 批量更新:一次 pnpm add 装多个包。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不是「循环调单包接口」:
|
|
5
|
+
* - 每个包一次 pnpm add 会把依赖树重解 N 次,慢且容易在中间态失败;
|
|
6
|
+
* - 一次装多个只有一次解析、一次写 lock,失败也是整体失败,配合快照更好收场。
|
|
7
|
+
*/
|
|
8
|
+
import { applyOne } from './auto-update.js';
|
|
9
|
+
import { validateInstallTargets } from './install-guard.js';
|
|
10
|
+
import { revertPackages } from './snapshot.js';
|
|
11
|
+
|
|
12
|
+
export function registerApplyAll(deps) {
|
|
13
|
+
const { reg, cfg, needProfile, send, readBody, createSnapshot, findPnpm } = deps;
|
|
14
|
+
|
|
15
|
+
reg('/dsh-plugin-snapshot/api/apply-all', async (request, response) => {
|
|
16
|
+
try {
|
|
17
|
+
const body = await readBody(request);
|
|
18
|
+
const list = Array.isArray(body && body.items) ? body.items.slice(0, 60) : [];
|
|
19
|
+
if (!list.length) return send(response, 200, { ok: false, error: '没有可更新的目标' });
|
|
20
|
+
|
|
21
|
+
const dir = needProfile(cfg);
|
|
22
|
+
const check = validateInstallTargets(dir, list.map((it) => ({ name: String((it && it.name) || ""), version: String((it && it.version) || "") })));
|
|
23
|
+
if (!check.approved.length) {
|
|
24
|
+
return send(response, 200, { ok: false, error: '目标全部未通过校验', rejected: check.rejected });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const pnpmCjs = findPnpm();
|
|
28
|
+
if (!pnpmCjs) return send(response, 200, { ok: false, error: '找不到 pnpm' });
|
|
29
|
+
|
|
30
|
+
const snapshot = createSnapshot(dir, cfg);
|
|
31
|
+
/* 逐包执行:单个包失败不影响其余,且能精确定位失败项 */
|
|
32
|
+
const results = [];
|
|
33
|
+
for (const it of check.approved) {
|
|
34
|
+
try {
|
|
35
|
+
const r = await applyOne(dir, pnpmCjs, { name: it.name, candidate: it.version, resolved: it.version }, cfg.logger);
|
|
36
|
+
results.push({ name: it.name, version: it.version, ok: r.ok, kind: r.kind });
|
|
37
|
+
} catch (e) {
|
|
38
|
+
results.push({ name: it.name, version: it.version, ok: false, kind: "threw", error: String((e && e.message) || e) });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const okList = results.filter((x) => x.ok);
|
|
43
|
+
const badList = results.filter((x) => !x.ok);
|
|
44
|
+
|
|
45
|
+
/* 部分失败:只把失败包的版本号改回快照值,成功的保留(与自动更新同一套分层回退) */
|
|
46
|
+
let recovery = 'none';
|
|
47
|
+
if (badList.length) {
|
|
48
|
+
try {
|
|
49
|
+
const reverted = revertPackages(dir, cfg, snapshot.name, badList.map((x) => x.name));
|
|
50
|
+
if (reverted.length === badList.length) {
|
|
51
|
+
recovery = 'package-level';
|
|
52
|
+
} else {
|
|
53
|
+
recovery = 'package-level-partial';
|
|
54
|
+
}
|
|
55
|
+
const inst = await runPnpm(pnpmCjs, ['install', '--no-frozen-lockfile', '--ignore-scripts'], dir, 600000);
|
|
56
|
+
if (inst.code !== 0) recovery += '-install-failed';
|
|
57
|
+
} catch (e) {
|
|
58
|
+
recovery = 'package-level-error';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
send(response, 200, {
|
|
62
|
+
ok: badList.length === 0,
|
|
63
|
+
count: okList.length,
|
|
64
|
+
failedCount: badList.length,
|
|
65
|
+
failed: badList,
|
|
66
|
+
rejected: check.rejected,
|
|
67
|
+
snapshot: snapshot.name,
|
|
68
|
+
recovery,
|
|
69
|
+
needRestart: okList.length > 0
|
|
70
|
+
});
|
|
71
|
+
} catch (e) { send(response, 200, { ok: false, error: String((e && e.message) || e) }); }
|
|
72
|
+
}, { write: true });
|
|
73
|
+
}
|