@shendeguize/remote-dsh-center 0.4.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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * 动作层(10 §4.5 / UI-09):请求 → pending → 202 结算 → 超时兜底 → 错误 toast。
3
+ *
4
+ * 铁律:这里绝不擅改 host.phase,phase 只由 SSE 推进。
5
+ */
6
+
7
+ import { ApiError, api } from './api.js';
8
+ import { isHostEnabled, isManagedHost } from './host-rules.js';
9
+ import { ACTION_TIMEOUT_MS, pendingKey } from './store.js';
10
+ import { phaseMeta } from './utils.js';
11
+
12
+ const CALL = {
13
+ start: api.startHost,
14
+ stop: api.stopHost,
15
+ restart: api.restartHost,
16
+ reconnect: api.reconnectHost,
17
+ probe: api.probeHost,
18
+ };
19
+
20
+ const NEEDS_CONFIRM = new Set(['stop', 'restart']);
21
+ export const CONFIG_SYNC_TARGET_LIMIT = 200;
22
+ export const HOST_WEB_RESTART_NOTICE = '需重启此主机的 dsh web(重启 manager 无效)';
23
+ const HOST_RESTART_PHASES = new Set(['running', 'degraded', 'starting']);
24
+
25
+ function workdirChangePending(patch, hostBeforeSave, savedHost) {
26
+ if (!Object.hasOwn(patch, 'workdir') || !HOST_RESTART_PHASES.has(savedHost?.phase)) {
27
+ return false;
28
+ }
29
+
30
+ const savedWorkdir = savedHost.config?.workdir ?? null;
31
+ if (savedHost.web) return (savedHost.web.workdir ?? null) !== savedWorkdir;
32
+
33
+ return savedHost.phase === 'starting'
34
+ && hostBeforeSave?.phase === 'starting'
35
+ && (hostBeforeSave.config?.workdir ?? null) !== savedWorkdir;
36
+ }
37
+
38
+ export function createActions({ store, confirm, navigate }) {
39
+ /** 统一错误呈现:ApiError 的 detail 落可展开区域(10 §3.9)。 */
40
+ function reportError(err, fallback, { preferFallback = false } = {}) {
41
+ let presentation;
42
+ if (err instanceof ApiError) {
43
+ const apiDetail = err.detail ? String(err.detail) : null;
44
+ const detail = preferFallback
45
+ ? [err.message && err.message !== fallback ? err.message : null, apiDetail].filter(Boolean).join('\n') || null
46
+ : apiDetail;
47
+ presentation = {
48
+ level: 'error',
49
+ summary: preferFallback ? fallback : (err.message || fallback),
50
+ detail,
51
+ };
52
+ } else {
53
+ presentation = {
54
+ level: 'error',
55
+ summary: `${fallback}:${err.message}`,
56
+ detail: err.stack ?? null,
57
+ };
58
+ }
59
+ const toast = store.addToast(presentation);
60
+ return {
61
+ ...presentation,
62
+ id: toast.id,
63
+ code: err instanceof ApiError ? err.code : null,
64
+ status: err instanceof ApiError ? err.status : null,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * 包住一次受保护操作:pending 生命周期 + 202 accepted + 超时只解 loading。
70
+ * @param {{
71
+ * action:string, host?:string|null, run:()=>Promise<any>,
72
+ * settleOnResolve?:boolean, failureMessage?:string, requireWritable?:boolean,
73
+ * onError?:(toast:{
74
+ * id:number,level:string,summary:string,detail:string|null,
75
+ * code:string|null,status:number|null,
76
+ * })=>void,
77
+ * }} opts
78
+ */
79
+ async function guarded({
80
+ action,
81
+ host = null,
82
+ run,
83
+ settleOnResolve = false,
84
+ failureMessage,
85
+ onError,
86
+ requireWritable = true,
87
+ }) {
88
+ if (requireWritable && !store.canWrite()) {
89
+ const presentation = {
90
+ level: 'warn',
91
+ summary: '与 manager 失联,写操作已暂停',
92
+ detail: null,
93
+ };
94
+ const toast = store.addToast(presentation);
95
+ onError?.({
96
+ ...presentation,
97
+ id: toast.id,
98
+ code: null,
99
+ status: null,
100
+ });
101
+ return null;
102
+ }
103
+ if (store.isPending(action, host)) return null;
104
+
105
+ const key = pendingKey(action, host);
106
+ store.beginPending({ action, host });
107
+ try {
108
+ const res = await run();
109
+ if (settleOnResolve) {
110
+ store.settlePending(key);
111
+ return res;
112
+ }
113
+ store.acceptPending(key, res?.operationId ?? null, (entry) => {
114
+ store.addToast({
115
+ level: 'warn',
116
+ summary: `${entry.host ?? 'manager'} ${entry.action} 结果未确认(${ACTION_TIMEOUT_MS[entry.action] / 1000}s 未回执)`,
117
+ detail: '请以当前状态为准,或重新探测。manager 侧可能仍在执行。',
118
+ });
119
+ });
120
+ return res;
121
+ } catch (err) {
122
+ store.settlePending(key);
123
+ const errorToast = reportError(
124
+ err,
125
+ failureMessage ?? `${host ?? 'manager'} ${action} 失败`,
126
+ { preferFallback: failureMessage !== undefined },
127
+ );
128
+ onError?.(errorToast);
129
+ return null;
130
+ }
131
+ }
132
+
133
+ async function hostAction(action, name) {
134
+ const host = store.getHost(name);
135
+ if (!host) {
136
+ store.addToast({ level: 'warn', summary: `主机 ${name} 不存在或尚未同步` });
137
+ return null;
138
+ }
139
+ // 点击前重新读 store:自动恢复可能已经跑在手动重连之前(10 §7 第 12 条)
140
+ if (action === 'reconnect' && host.phase === 'running') {
141
+ store.addToast({ level: 'info', summary: `${name} 隧道已自行恢复,无需重连` });
142
+ return null;
143
+ }
144
+ if (NEEDS_CONFIRM.has(action) && !isManagedHost(host)) {
145
+ store.addToast({ level: 'warn', summary: `${name} 上的 dsh web 不是本工具拉起的,禁止 ${action}` });
146
+ return null;
147
+ }
148
+ if (NEEDS_CONFIRM.has(action)) {
149
+ const ok = await confirm({
150
+ title: action === 'stop' ? `关停 ${name} 的 dsh web?` : `重启 ${name} 的 dsh web?`,
151
+ lines: [
152
+ `进程 PID ${host.web?.pid ?? '—'},当前状态「${phaseMeta(host.phase).label}」。`,
153
+ '仅关停由本工具拉起且指纹校验通过的进程;页面内未保存的会话会中断。',
154
+ ],
155
+ confirmLabel: action === 'stop' ? '关停' : '重启',
156
+ });
157
+ if (!ok) return null;
158
+ }
159
+
160
+ return guarded({ action, host: name, run: () => CALL[action](name) });
161
+ }
162
+
163
+ async function probeAll({ failureMessage } = {}) {
164
+ return guarded({ action: 'probe-all', failureMessage, run: () => api.probeAll() });
165
+ }
166
+
167
+ /** 创建响应先落服务端 HostView,再复用既有 probe pending;phase 仍只由响应/SSE 决定。 */
168
+ async function addLocalHost(name) {
169
+ const requestedName = typeof name === 'string' ? name.trim() : '';
170
+ const created = await guarded({
171
+ action: 'local:create',
172
+ settleOnResolve: true,
173
+ run: () => api.createLocalHost(requestedName || undefined),
174
+ });
175
+ if (!created?.host) return created;
176
+
177
+ const hostName = created.host.name;
178
+ store.upsertHost(created.host);
179
+ store.addToast({ level: 'success', summary: `已添加本机 ${hostName},正在探测`, timeoutMs: 4_000 });
180
+ await guarded({
181
+ action: 'probe',
182
+ host: hostName,
183
+ run: () => api.probeHost(hostName),
184
+ });
185
+ return created;
186
+ }
187
+
188
+ /** toggle 走 config:save 通道;失败时用响应/回滚保持与服务端一致(10 §7 第 4 条)。 */
189
+ async function setAutoStart(name, value) {
190
+ const host = store.getHost(name);
191
+ if (!host) return null;
192
+ const res = await guarded({
193
+ action: 'config:save',
194
+ host: name,
195
+ settleOnResolve: true,
196
+ run: () => api.saveHostConfig(name, { autoStart: value }),
197
+ });
198
+ if (res?.host) store.upsertHost(res.host);
199
+ else store.emit('hosts:changed', name); // 失败:用本地已有视图复位复选框
200
+ return res;
201
+ }
202
+
203
+ async function saveHostConfig(name, patch) {
204
+ const hostBeforeSave = store.getHost(name);
205
+ let hostMergeGuard = null;
206
+ const res = await guarded({
207
+ action: 'config:save',
208
+ host: name,
209
+ settleOnResolve: true,
210
+ run: () => {
211
+ hostMergeGuard = store.captureHostMergeGuard();
212
+ return api.saveHostConfig(name, patch);
213
+ },
214
+ });
215
+ if (res?.host && hostMergeGuard !== null) {
216
+ store.mergeActionHosts([res.host], hostMergeGuard);
217
+ }
218
+ if (res?.host && store.getHost(name)) {
219
+ const restartNotice = workdirChangePending(patch, hostBeforeSave, res.host)
220
+ ? `;${HOST_WEB_RESTART_NOTICE}`
221
+ : '';
222
+ store.addToast({ level: 'success', summary: `${name} 配置已保存${restartNotice}` });
223
+ }
224
+ return res;
225
+ }
226
+
227
+ async function syncConfig({
228
+ source, targets, dryRun, previewToken, onError,
229
+ }) {
230
+ if (targets.length > CONFIG_SYNC_TARGET_LIMIT) {
231
+ store.addToast({
232
+ level: 'warn',
233
+ summary: `一次最多同步 ${CONFIG_SYNC_TARGET_LIMIT} 台目标主机`,
234
+ detail: '请减少目标后重试;配置同步按整单原子执行,不会自动拆分。',
235
+ });
236
+ return null;
237
+ }
238
+ let hostMergeGuard = null;
239
+ const res = await guarded({
240
+ action: 'config:sync',
241
+ settleOnResolve: true,
242
+ onError,
243
+ run: () => {
244
+ hostMergeGuard = store.captureHostMergeGuard();
245
+ return api.syncHostConfig({
246
+ source, targets, dryRun, previewToken,
247
+ });
248
+ },
249
+ });
250
+ if (!res || dryRun) return res;
251
+
252
+ if (Array.isArray(res.hosts) && hostMergeGuard !== null) {
253
+ store.mergeActionHosts(res.hosts, hostMergeGuard);
254
+ }
255
+ const count = Array.isArray(res.applied) ? res.applied.length : 0;
256
+ const restartTargets = (res.targets ?? [])
257
+ .filter((target) => target.changed && HOST_RESTART_PHASES.has(store.getHost(target.name)?.phase))
258
+ .map((target) => target.name);
259
+ const restartSummary = restartTargets.length > 0
260
+ ? ';运行中目标需重启 dsh web(重启 manager 无效)'
261
+ : '';
262
+ store.addToast({
263
+ level: 'success',
264
+ summary: count > 0 ? `已同步 ${count} 台主机配置${restartSummary}` : '目标配置已一致',
265
+ detail: restartTargets.length > 0
266
+ ? restartTargets.map((name) => `${name}:${HOST_WEB_RESTART_NOTICE}`).join('\n')
267
+ : null,
268
+ });
269
+ return res;
270
+ }
271
+
272
+ async function loadDshSettings(name, { onError } = {}) {
273
+ return guarded({
274
+ action: 'settings:load',
275
+ host: name,
276
+ settleOnResolve: true,
277
+ onError,
278
+ requireWritable: false,
279
+ run: () => api.getDshSettings(name),
280
+ });
281
+ }
282
+
283
+ async function saveDshSettings(name, payload, { onError } = {}) {
284
+ return guarded({
285
+ action: 'settings:save',
286
+ host: name,
287
+ settleOnResolve: true,
288
+ onError,
289
+ run: () => api.saveDshSettings(name, payload),
290
+ });
291
+ }
292
+
293
+ async function registerDshWorkspace(name, { onError } = {}) {
294
+ const res = await guarded({
295
+ action: 'workspace:register',
296
+ host: name,
297
+ settleOnResolve: true,
298
+ onError,
299
+ run: () => api.registerDshWorkspace(name),
300
+ });
301
+ if (res) {
302
+ store.addToast({
303
+ level: 'success',
304
+ summary: res.created
305
+ ? `${name} 已登记启动目录为 dsh Workspace`
306
+ : `${name} 的启动目录已是 dsh Workspace`,
307
+ });
308
+ }
309
+ return res;
310
+ }
311
+
312
+ async function saveDefaults(patch) {
313
+ const res = await guarded({
314
+ action: 'defaults:save',
315
+ settleOnResolve: true,
316
+ run: () => api.saveDefaults(patch),
317
+ });
318
+ if (res) {
319
+ store.setDefaults(res.defaults);
320
+ if (res.manager) store.setManagerConfig(res.manager);
321
+ store.addToast({
322
+ level: 'success',
323
+ summary: res.restartRequired ? '已保存;manager 端口需重启后生效' : '全局默认已保存',
324
+ });
325
+ }
326
+ return res;
327
+ }
328
+
329
+ async function reload() {
330
+ const res = await guarded({ action: 'config:reload', settleOnResolve: true, run: () => api.reload() });
331
+ if (res) {
332
+ store.addToast({
333
+ level: 'success',
334
+ summary: res.changed.length > 0 ? `已重载配置(${res.changed.length} 项变化)` : '配置无变化',
335
+ detail: res.changed.length > 0 ? res.changed.join('\n') : null,
336
+ });
337
+ }
338
+ return res;
339
+ }
340
+
341
+ async function restartManager() {
342
+ const ok = await confirm({
343
+ title: '重启 manager?',
344
+ lines: [
345
+ '存活的受管实例会按原 PID 与命令指纹复核接管,只重建隧道或直连登记:远端重建隧道、本机重新登记直连;不会重启这些 dsh web。',
346
+ '只有恢复时探测为 ready 且已启用 autoStart 的主机才会拉起 dsh web;已打开的页面标签会短暂失联。',
347
+ '前台模式不支持自我重启,manager 会直接拒绝。',
348
+ ],
349
+ confirmLabel: '重启',
350
+ });
351
+ if (!ok) return null;
352
+ return guarded({ action: 'manager:restart', run: () => api.restartManager() });
353
+ }
354
+
355
+ function openHost(name) {
356
+ const host = store.getHost(name);
357
+ if (!host) {
358
+ store.addToast({ level: 'warn', summary: `主机 ${name} 不存在或尚未同步` });
359
+ return;
360
+ }
361
+ // ready 标签就是「一步拉起」入口:先提交启动,再立即切路由;phase 仍只等
362
+ // 响应/SSE 推进,绝不在动作层乐观改成 running。
363
+ if (host.phase === 'ready' && isHostEnabled(host)) void hostAction('start', name);
364
+ navigate(`#/host/${encodeURIComponent(name)}`);
365
+ }
366
+
367
+ function openHostDrawer(name) {
368
+ store.setDrawer({ open: true, host: name, dirty: false });
369
+ }
370
+
371
+ function viewHostInManage(name) {
372
+ if (!store.getHost(name)) {
373
+ store.addToast({ level: 'warn', summary: `主机 ${name} 不存在或尚未同步` });
374
+ return;
375
+ }
376
+ navigate('#/manage');
377
+ openHostDrawer(name);
378
+ }
379
+
380
+ async function loadHostLog(name, lines = 200) {
381
+ try {
382
+ return await api.hostLog(name, lines);
383
+ } catch (err) {
384
+ reportError(err, `${name} 日志拉取失败`);
385
+ return null;
386
+ }
387
+ }
388
+
389
+ return {
390
+ navigate,
391
+ hostAction,
392
+ probeAll,
393
+ addLocalHost,
394
+ setAutoStart,
395
+ saveHostConfig,
396
+ syncConfig,
397
+ loadDshSettings,
398
+ saveDshSettings,
399
+ registerDshWorkspace,
400
+ saveDefaults,
401
+ reload,
402
+ restartManager,
403
+ openHost,
404
+ openHostDrawer,
405
+ viewHostInManage,
406
+ loadHostLog,
407
+ reportError,
408
+ };
409
+ }
package/src/web/api.js ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * REST 客户端(10 §2 / UI-02)。同源相对 URL——页面绝不硬编码运行期端口。
3
+ * 错误统一抛 ApiError(携带后端 13 §1.1 的 error/code/detail)。
4
+ */
5
+
6
+ const JSON_HEADERS = { 'content-type': 'application/json' };
7
+ const DEFAULT_TIMEOUT_MS = 15_000;
8
+ const SETTINGS_TIMEOUT_MS = 30_000;
9
+ const SETTINGS_MUTATION_UNKNOWN_MESSAGE = '保存结果未知,请重新加载后确认';
10
+ const SETTINGS_ERROR_MESSAGES = Object.freeze({
11
+ SETTINGS_STALE: 'dsh 配置文件已变化,请重新加载后再保存',
12
+ SETTINGS_TOO_LARGE: 'dsh 配置文件超过大小限制,无法处理',
13
+ SETTINGS_UNSUPPORTED: '该主机不支持安全编辑 dsh 配置文件',
14
+ SETTINGS_INVALID_UTF8: 'dsh 配置文件不是有效的 UTF-8 文本',
15
+ SETTINGS_READ_FAILED: '读取 dsh 配置文件失败,请稍后重试',
16
+ SETTINGS_WRITE_FAILED: '保存 dsh 配置文件失败,请重新加载确认结果',
17
+ SETTINGS_BUSY: '该主机已有 dsh 配置操作正在进行,请稍后重试',
18
+ SSH_TIMEOUT: 'dsh 配置请求超时,请稍后重试',
19
+ SSH_UNREACHABLE: '无法连接目标主机,请稍后重试',
20
+ LOCAL_TIMEOUT: '本机 dsh 配置请求超时,请稍后重试',
21
+ LOCAL_EXEC_FAILED: '本机 dsh 配置操作失败,请稍后重试',
22
+ LOCAL_COPY_FAILED: '本机 dsh 配置传输失败,请稍后重试',
23
+ NOT_FOUND: '目标主机不存在或已被删除',
24
+ VALIDATION: 'dsh 配置请求无效,请检查后重试',
25
+ PROTO_PARSE: 'dsh 配置响应无法解析,请重试',
26
+ INTERNAL: 'dsh 配置请求失败,请稍后重试',
27
+ });
28
+ const SETTINGS_MUTATION_UNCERTAIN_CODES = new Set([
29
+ 'SSH_TIMEOUT',
30
+ 'SSH_UNREACHABLE',
31
+ 'LOCAL_TIMEOUT',
32
+ 'LOCAL_EXEC_FAILED',
33
+ 'LOCAL_COPY_FAILED',
34
+ 'PROTO_PARSE',
35
+ 'INTERNAL',
36
+ ]);
37
+ const WORKSPACE_ERROR_MESSAGES = Object.freeze({
38
+ WORKSPACE_BUSY: '该主机已有 dsh Workspace 登记正在进行,请稍后重试',
39
+ WORKSPACE_WORKDIR_REQUIRED: '请先配置启动目录并重启 dsh web 后再登记',
40
+ WORKSPACE_CWD_UNAVAILABLE: '当前 dsh web 的实际工作目录不可用,请重启后重试',
41
+ WORKSPACE_INVALID_PATH: '当前 dsh web 的实际工作目录不是绝对路径,无法登记',
42
+ WORKSPACE_REGISTER_FAILED: 'dsh Workspace 登记失败,请稍后重试',
43
+ WORKSPACE_REGISTER_TIMEOUT: 'dsh Workspace 登记超时;可安全重试',
44
+ NOT_FOUND: '目标主机不存在或已被删除',
45
+ PHASE_CONFLICT: '主机状态已变化,请确认 dsh web 已连接且启动目录已应用',
46
+ VALIDATION: 'dsh Workspace 请求无效,请检查启动目录后重试',
47
+ PROTO_PARSE: 'dsh Workspace 响应无法解析,请重试',
48
+ INTERNAL: 'dsh Workspace 登记失败,请稍后重试',
49
+ });
50
+ const SETTINGS_REDACTION = Object.freeze({
51
+ messages: SETTINGS_ERROR_MESSAGES,
52
+ mutationUnknownMessage: SETTINGS_MUTATION_UNKNOWN_MESSAGE,
53
+ mutationUncertainCodes: SETTINGS_MUTATION_UNCERTAIN_CODES,
54
+ timeoutCode: 'SSH_TIMEOUT',
55
+ timeoutMessage: SETTINGS_ERROR_MESSAGES.SSH_TIMEOUT,
56
+ internalMessage: SETTINGS_ERROR_MESSAGES.INTERNAL,
57
+ parseMessage: '响应不是合法 JSON',
58
+ httpFallback: (status) => `dsh 配置请求失败(HTTP ${status})`,
59
+ });
60
+ const WORKSPACE_REDACTION = Object.freeze({
61
+ messages: WORKSPACE_ERROR_MESSAGES,
62
+ timeoutCode: 'WORKSPACE_REGISTER_TIMEOUT',
63
+ timeoutMessage: WORKSPACE_ERROR_MESSAGES.WORKSPACE_REGISTER_TIMEOUT,
64
+ internalMessage: WORKSPACE_ERROR_MESSAGES.INTERNAL,
65
+ parseMessage: WORKSPACE_ERROR_MESSAGES.PROTO_PARSE,
66
+ httpFallback: (status) => `dsh Workspace 请求失败(HTTP ${status})`,
67
+ });
68
+
69
+ function safeRedactedHttpError(rawCode, status, redaction, mutationResultUnknown) {
70
+ const known = typeof rawCode === 'string'
71
+ && Object.hasOwn(redaction.messages, rawCode);
72
+ const code = known ? rawCode : 'INTERNAL';
73
+ const message = mutationResultUnknown
74
+ && (!known || redaction.mutationUncertainCodes?.has(code))
75
+ ? redaction.mutationUnknownMessage
76
+ : (known
77
+ ? redaction.messages[code]
78
+ : redaction.httpFallback(status));
79
+ return { code, message };
80
+ }
81
+
82
+ export class ApiError extends Error {
83
+ constructor({ status, code, message, detail }) {
84
+ super(message);
85
+ this.name = 'ApiError';
86
+ this.status = status;
87
+ this.code = code ?? 'INTERNAL';
88
+ this.detail = detail ?? null;
89
+ }
90
+ }
91
+
92
+ async function call(method, path, {
93
+ body,
94
+ timeoutMs = DEFAULT_TIMEOUT_MS,
95
+ as = 'json',
96
+ redactParseError = false,
97
+ redactErrorResponse = false,
98
+ redaction = null,
99
+ mutationResultUnknown = false,
100
+ } = {}) {
101
+ const controller = new AbortController();
102
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
103
+ let res;
104
+ let text;
105
+ try {
106
+ res = await fetch(path, {
107
+ method,
108
+ headers: body === undefined ? undefined : JSON_HEADERS,
109
+ body: body === undefined ? undefined : JSON.stringify(body),
110
+ signal: controller.signal,
111
+ cache: 'no-store',
112
+ });
113
+ text = await res.text();
114
+ } catch (err) {
115
+ const aborted = err?.name === 'AbortError' || controller.signal.aborted;
116
+ if (!aborted && res && !redactErrorResponse) throw err;
117
+ throw new ApiError({
118
+ status: 0,
119
+ code: aborted ? (redaction?.timeoutCode ?? 'SSH_TIMEOUT') : 'INTERNAL',
120
+ message: mutationResultUnknown
121
+ ? redaction?.mutationUnknownMessage ?? SETTINGS_MUTATION_UNKNOWN_MESSAGE
122
+ : redactErrorResponse
123
+ ? (aborted
124
+ ? redaction?.timeoutMessage ?? SETTINGS_ERROR_MESSAGES.SSH_TIMEOUT
125
+ : redaction?.internalMessage ?? SETTINGS_ERROR_MESSAGES.INTERNAL)
126
+ : (aborted ? `请求超时(${method} ${path})` : `无法连接 manager:${err.message}`),
127
+ detail: null,
128
+ });
129
+ } finally {
130
+ clearTimeout(timer);
131
+ }
132
+
133
+ if (!res.ok) {
134
+ let payload = null;
135
+ try {
136
+ payload = JSON.parse(text);
137
+ } catch {
138
+ payload = null;
139
+ }
140
+ const rawCode = typeof payload?.code === 'string' ? payload.code : undefined;
141
+ const safeError = redactErrorResponse
142
+ ? safeRedactedHttpError(
143
+ rawCode,
144
+ res.status,
145
+ redaction ?? SETTINGS_REDACTION,
146
+ mutationResultUnknown,
147
+ )
148
+ : null;
149
+ throw new ApiError({
150
+ status: res.status,
151
+ code: safeError?.code ?? rawCode,
152
+ message: redactErrorResponse
153
+ ? safeError.message
154
+ : (payload?.error ?? `${method} ${path} 失败(HTTP ${res.status})`),
155
+ detail: redactErrorResponse ? null : (payload?.detail ?? (text || null)),
156
+ });
157
+ }
158
+
159
+ if (as === 'text') return text;
160
+ if (text === '') {
161
+ if (!redactParseError) return null;
162
+ throw new ApiError({
163
+ status: res.status,
164
+ code: 'PROTO_PARSE',
165
+ message: mutationResultUnknown
166
+ ? redaction?.mutationUnknownMessage ?? SETTINGS_MUTATION_UNKNOWN_MESSAGE
167
+ : redaction?.parseMessage ?? '响应不是合法 JSON',
168
+ detail: null,
169
+ });
170
+ }
171
+ try {
172
+ return JSON.parse(text);
173
+ } catch {
174
+ throw new ApiError({
175
+ status: res.status,
176
+ code: 'PROTO_PARSE',
177
+ message: mutationResultUnknown
178
+ ? redaction?.mutationUnknownMessage ?? SETTINGS_MUTATION_UNKNOWN_MESSAGE
179
+ : redaction?.parseMessage ?? '响应不是合法 JSON',
180
+ detail: redactParseError ? null : text,
181
+ });
182
+ }
183
+ }
184
+
185
+ const enc = encodeURIComponent;
186
+
187
+ export const api = {
188
+ managerInfo: () => call('GET', '/api/manager/info'),
189
+ hosts: () => call('GET', '/api/hosts'),
190
+ config: () => call('GET', '/api/config'),
191
+
192
+ probeAll: () => call('POST', '/api/hosts/probe'),
193
+ createLocalHost: (name) => call('POST', '/api/hosts/local', {
194
+ body: name === undefined ? {} : { name },
195
+ }),
196
+ probeHost: (name) => call('POST', `/api/hosts/${enc(name)}/probe`),
197
+ startHost: (name) => call('POST', `/api/hosts/${enc(name)}/start`),
198
+ stopHost: (name) => call('POST', `/api/hosts/${enc(name)}/stop`),
199
+ restartHost: (name) => call('POST', `/api/hosts/${enc(name)}/restart`),
200
+ reconnectHost: (name) => call('POST', `/api/hosts/${enc(name)}/reconnect`),
201
+ syncHostConfig: ({
202
+ source, targets, dryRun, previewToken,
203
+ }) => call('POST', '/api/hosts/sync-config', {
204
+ body: {
205
+ source,
206
+ targets,
207
+ dryRun,
208
+ ...(previewToken === undefined ? {} : { previewToken }),
209
+ },
210
+ }),
211
+
212
+ hostLog: (name, lines = 200) => call('GET', `/api/hosts/${enc(name)}/log?lines=${lines}`, { as: 'text', timeoutMs: 30_000 }),
213
+ getDshSettings: (name) => call('GET', `/api/hosts/${enc(name)}/dsh-settings`, {
214
+ timeoutMs: SETTINGS_TIMEOUT_MS,
215
+ redactParseError: true,
216
+ redactErrorResponse: true,
217
+ redaction: SETTINGS_REDACTION,
218
+ }),
219
+ saveDshSettings: (name, { content, baseChecksum }) => call(
220
+ 'PUT',
221
+ `/api/hosts/${enc(name)}/dsh-settings`,
222
+ {
223
+ body: { content, baseChecksum },
224
+ timeoutMs: SETTINGS_TIMEOUT_MS,
225
+ redactParseError: true,
226
+ redactErrorResponse: true,
227
+ redaction: SETTINGS_REDACTION,
228
+ mutationResultUnknown: true,
229
+ },
230
+ ),
231
+ registerDshWorkspace: (name) => call('POST', `/api/hosts/${enc(name)}/dsh-workspace`, {
232
+ body: {},
233
+ timeoutMs: 20_000,
234
+ redactParseError: true,
235
+ redactErrorResponse: true,
236
+ redaction: WORKSPACE_REDACTION,
237
+ }),
238
+ saveHostConfig: (name, patch) => call('PUT', `/api/hosts/${enc(name)}/config`, { body: patch }),
239
+ saveDefaults: (patch) => call('PUT', '/api/config/defaults', { body: patch }),
240
+ reload: () => call('POST', '/api/reload'),
241
+
242
+ setup: (config) => call('POST', '/api/setup', { body: config, timeoutMs: 30_000 }),
243
+ restartManager: () => call('POST', '/api/manager/restart'),
244
+ shutdownManager: () => call('POST', '/api/manager/shutdown'),
245
+
246
+ /** 迁移页用:探测目标 origin 是否已就绪(10 §3.5,端口来自用户提交值)。 */
247
+ probeOrigin: async (origin, { timeoutMs = 2_000 } = {}) => {
248
+ const controller = new AbortController();
249
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
250
+ try {
251
+ const res = await fetch(`${origin}/api/manager/info`, { signal: controller.signal, cache: 'no-store' });
252
+ if (!res.ok) return null;
253
+ return await res.json();
254
+ } catch {
255
+ return null;
256
+ } finally {
257
+ clearTimeout(timer);
258
+ }
259
+ },
260
+ };
261
+
262
+ export { call as rawCall };