@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,1039 @@
1
+ /**
2
+ * 主机详情抽屉(10 §3.3 / UI-19、UI-20、UI-21、UI-26)。
3
+ *
4
+ * 草稿纪律:打开时对 config 拍快照;SSE 更新运行态不覆盖用户未保存的草稿,
5
+ * 只在 config 真变了时给冲突提示(UI-26)。
6
+ */
7
+
8
+ import { DASH, button, clear, el, fmtAgo, phaseBadge, text } from '../utils.js';
9
+ import {
10
+ buildHostPatch,
11
+ deepEqual,
12
+ diffPatch,
13
+ field,
14
+ formatEnvLines,
15
+ formatLines,
16
+ input,
17
+ parseEnvLines,
18
+ parseLines,
19
+ parsePort,
20
+ parseWorkdir,
21
+ validatePatches,
22
+ } from '../form.js';
23
+ import { HOST_WEB_RESTART_NOTICE } from '../actions.js';
24
+ import { hostMappingSummary, hostPhaseMeta } from '../host-presentation.js';
25
+
26
+ const LOG_LINES = 200;
27
+ const REMOTE_CONFIG_NOTE = 'dsh Web 在目标主机运行;没有桌面环境也不影响内嵌页面。需要编辑 settings.yaml 时可直接使用下方“dsh 配置文件”编辑器,无需通过 SSH。';
28
+ const DRAFT_FIELDS = ['enabled', 'remoteWebPort', 'workdir', 'env', 'extraArgs', 'patches'];
29
+ const SETTINGS_UNKNOWN_CODES = new Set([
30
+ 'SSH_TIMEOUT',
31
+ 'SSH_UNREACHABLE',
32
+ 'LOCAL_TIMEOUT',
33
+ 'LOCAL_EXEC_FAILED',
34
+ 'LOCAL_COPY_FAILED',
35
+ 'PROTO_PARSE',
36
+ 'INTERNAL',
37
+ ]);
38
+ const DRAFT_FIELD_LABELS = {
39
+ enabled: '纳管状态',
40
+ remoteWebPort: 'web 端口',
41
+ workdir: '启动目录',
42
+ env: '环境变量',
43
+ extraArgs: '追加参数',
44
+ patches: 'Patch 文件',
45
+ };
46
+
47
+ /**
48
+ * config → 表单草稿(纯函数,便于单测草稿/冲突逻辑)。
49
+ *
50
+ * 端口一律存成字符串:草稿的另一半来自 `readForm()`,而 DOM 里的 value 永远是字符串。
51
+ * 存成数字会让「配了显式端口的主机」一打开就被判脏——保存键亮着、Esc 还要确认放弃。
52
+ */
53
+ export function draftOf(config) {
54
+ return {
55
+ enabled: Boolean(config?.enabled),
56
+ remoteWebPort: config?.remoteWebPort == null ? '' : String(config.remoteWebPort),
57
+ workdir: config?.workdir ?? '',
58
+ env: formatEnvLines(config?.inject?.env),
59
+ extraArgs: formatLines(config?.inject?.extraArgs),
60
+ patches: formatLines(config?.inject?.patches),
61
+ };
62
+ }
63
+
64
+ export function isDirty(draft, config) {
65
+ return !deepEqual(draft, draftOf(config));
66
+ }
67
+
68
+ const LIVE_PHASES = ['running', 'degraded'];
69
+
70
+ /**
71
+ * 「重启后生效」判定(纯函数):已保存的启动目录与正在跑的实例不一致。
72
+ *
73
+ * 只有 workdir 能做这种值比对——state.web 记了本次实例的实际生效值;
74
+ * env/extraArgs/patches 没有同款记录,故注入区只给静态提示,不假装能比对。
75
+ */
76
+ export function workdirPending(host) {
77
+ if (!LIVE_PHASES.includes(host?.phase) || !host?.web) return false;
78
+ return (host.web.workdir ?? null) !== (host.config?.workdir ?? null);
79
+ }
80
+
81
+ export function workspaceRegistrationState(host, {
82
+ canWrite = true,
83
+ pending = false,
84
+ } = {}) {
85
+ if (pending) {
86
+ return { enabled: false, message: '正在登记启动目录…', tone: null };
87
+ }
88
+ const savedWorkdir = host?.config?.workdir ?? null;
89
+ if (savedWorkdir === null) {
90
+ return { enabled: false, message: '请先配置并保存启动目录。', tone: 'warn' };
91
+ }
92
+ if (!LIVE_PHASES.includes(host?.phase) || !host.web || !host.mappedUrl) {
93
+ return {
94
+ enabled: false,
95
+ message: 'dsh web 实例未连接;连接后才能登记 Workspace。',
96
+ tone: 'warn',
97
+ };
98
+ }
99
+ if (host.tunnel?.connected !== true) {
100
+ return {
101
+ enabled: false,
102
+ message: 'dsh web 隧道未连接;连接后才能登记 Workspace。',
103
+ tone: 'warn',
104
+ };
105
+ }
106
+ if ((host.web.workdir ?? null) !== savedWorkdir) {
107
+ return {
108
+ enabled: false,
109
+ message: '请重启 dsh web,使当前实例应用已保存的启动目录。',
110
+ tone: 'warn',
111
+ };
112
+ }
113
+ if (
114
+ host.web.cwd != null
115
+ && (typeof host.web.cwd !== 'string' || !host.web.cwd.startsWith('/'))
116
+ ) {
117
+ return {
118
+ enabled: false,
119
+ message: 'dsh web 实际 CWD 不是绝对路径,无法登记 Workspace。',
120
+ tone: 'warn',
121
+ };
122
+ }
123
+ if (!canWrite) {
124
+ return { enabled: false, message: 'manager 失联,登记已暂停。', tone: 'warn' };
125
+ }
126
+ if (host.web.cwd == null) {
127
+ return {
128
+ enabled: true,
129
+ message: '实际 CWD 尚未上报;登记时将从 dsh Web 查询。',
130
+ tone: null,
131
+ };
132
+ }
133
+ return {
134
+ enabled: true,
135
+ message: '可登记已保存且当前实例已应用的启动目录。',
136
+ tone: null,
137
+ };
138
+ }
139
+
140
+ function comparableField(draft, key) {
141
+ if (key === 'enabled') return Boolean(draft.enabled);
142
+ if (key === 'remoteWebPort') {
143
+ const parsed = parsePort(draft.remoteWebPort, { allowEmpty: true });
144
+ return parsed.ok ? parsed.value : draft.remoteWebPort;
145
+ }
146
+ if (key === 'workdir') {
147
+ const parsed = parseWorkdir(draft.workdir);
148
+ return parsed.ok ? parsed.value : draft.workdir;
149
+ }
150
+ if (key === 'env') {
151
+ const parsed = parseEnvLines(draft.env);
152
+ return parsed.ok ? parsed.value : draft.env;
153
+ }
154
+ if (key === 'extraArgs') return parseLines(draft.extraArgs);
155
+ const parsed = validatePatches(parseLines(draft.patches));
156
+ return parsed.ok ? parsed.value : draft.patches;
157
+ }
158
+
159
+ function fieldEqual(left, right, key) {
160
+ return deepEqual(comparableField(left, key), comparableField(right, key));
161
+ }
162
+
163
+ /**
164
+ * 抽屉字段级三方合并(base = prevConfig,local = draft,remote = nextConfig)。
165
+ *
166
+ * inject 在 API 上是一个原子 patch,但在抽屉里 env / extraArgs / patches 各自拥有字段:
167
+ * 未改字段跟随 remote,本地独改字段保留;双方同字段改成不同有效值才算冲突。
168
+ */
169
+ export function reconcile(draft, prevConfig, nextConfig, priorConflicts = []) {
170
+ const base = draftOf(prevConfig);
171
+ const remote = draftOf(nextConfig);
172
+ const merged = { ...draft };
173
+ const unresolved = new Set(priorConflicts);
174
+ const conflicts = [];
175
+ let remoteChanged = false;
176
+
177
+ for (const key of DRAFT_FIELDS) {
178
+ const localChanged = !fieldEqual(draft, base, key);
179
+ const fieldRemoteChanged = !fieldEqual(remote, base, key);
180
+ const sameResult = fieldEqual(draft, remote, key);
181
+ remoteChanged ||= fieldRemoteChanged;
182
+
183
+ if (unresolved.has(key) && !sameResult) {
184
+ conflicts.push(key);
185
+ } else if (!localChanged || sameResult) {
186
+ merged[key] = remote[key];
187
+ } else if (fieldRemoteChanged) {
188
+ conflicts.push(key);
189
+ }
190
+ }
191
+
192
+ return { draft: merged, conflicts, remoteChanged };
193
+ }
194
+
195
+ /** 详情抽屉里随运输类型变化的标题与只读字段名。 */
196
+ export function drawerCopy(host) {
197
+ if (host?.local === true) {
198
+ return {
199
+ portLabel: '本机 web 端口',
200
+ probeTitle: '本机探测详情',
201
+ logTitle: '本机日志',
202
+ effectivePortLabel: '生效本机端口',
203
+ processLabel: '本机进程',
204
+ workdirLabel: '本机实际工作目录',
205
+ configChanged: '本机配置已变化',
206
+ };
207
+ }
208
+ return {
209
+ portLabel: '远端 web 端口',
210
+ probeTitle: '探测详情',
211
+ logTitle: '远端日志',
212
+ effectivePortLabel: '生效远端端口',
213
+ processLabel: '进程',
214
+ workdirLabel: '实际工作目录',
215
+ configChanged: '远端配置已变化',
216
+ };
217
+ }
218
+
219
+ export function createHostDrawer({ store, actions, confirm, setBackgroundInert = () => {} }) {
220
+ const title = el('h2', { id: 'drawer-title' });
221
+ const badge = el('div.drawer-badge');
222
+ const conflict = el('p.card-notice', { hidden: true });
223
+
224
+ const enabled = field('纳管此主机', input('checkbox', false));
225
+ const remotePort = field('远端 web 端口', input('number', '', { min: '1', max: '65535', placeholder: '留空 = 用全局默认' }));
226
+ const workdir = field(
227
+ '启动目录(进程 CWD)',
228
+ input('text', '', { placeholder: '~(家目录,默认)', spellcheck: 'false' }),
229
+ { hint: '这是 dsh web 进程的 CWD;保存后需在下次拉起或重启时生效。新会话使用或恢复哪个 Workspace 由 dsh Web 自身决定。' },
230
+ );
231
+ const workdirBadge = el('span.pending-badge', { hidden: true, text: HOST_WEB_RESTART_NOTICE });
232
+ workdir.root.querySelector('label').append(workdirBadge);
233
+ const workspaceExplanation = el('p.section-note', {
234
+ text: '已保存的启动目录可通过 dsh Web 官方 API 显式登记为 Workspace;此操作不修改 dsh CLI,也不改变 HOME,dsh Web 的目录选择器仍从 HOME 开始。',
235
+ });
236
+ const workspaceHint = el('p.field-hint');
237
+ const workspaceStatus = el('p.dsh-settings-status', {
238
+ role: 'status',
239
+ 'aria-live': 'polite',
240
+ });
241
+ const workspaceRegisterBtn = button('登记启动目录为 Workspace', {
242
+ variant: 'primary',
243
+ compact: false,
244
+ disabled: true,
245
+ onClick: () => registerWorkspace(),
246
+ });
247
+ const workspaceSection = el('section.config-section', {}, [
248
+ el('h4', { text: 'dsh Workspace' }),
249
+ workspaceExplanation,
250
+ workspaceHint,
251
+ workspaceStatus,
252
+ el('div.dsh-settings-actions', {}, [workspaceRegisterBtn]),
253
+ ]);
254
+ const remoteConfigNote = el('p.section-note.remote-config-note', { hidden: true });
255
+ const env = field('环境变量(每行 KEY=VALUE)', input('textarea', '', { rows: '4', spellcheck: 'false' }));
256
+ const extraArgs = field('追加参数(每行一项,不做 shell 拆词)', input('textarea', '', { rows: '3', spellcheck: 'false' }));
257
+ const patches = field('Patch 文件(每行一个本机绝对路径)', input('textarea', '', { rows: '3', spellcheck: 'false' }));
258
+ const fields = [enabled, remotePort, workdir, env, extraArgs, patches];
259
+
260
+ const saveBtn = button('保存', { variant: 'primary', compact: false, onClick: submit });
261
+ const cancelBtn = button('放弃修改', { compact: false, onClick: () => resetDraft() });
262
+
263
+ const probeDl = el('dl.kv');
264
+ const probeTitle = el('h3', { text: '探测详情' });
265
+
266
+ const settingsTextareaId = 'drawer-dsh-settings-content';
267
+ const settingsTextarea = el('textarea.dsh-settings-content', {
268
+ id: settingsTextareaId,
269
+ disabled: true,
270
+ rows: '12',
271
+ spellcheck: 'false',
272
+ autocomplete: 'off',
273
+ autocapitalize: 'off',
274
+ });
275
+ const settingsPreservedTextarea = el('textarea.dsh-settings-preserved-content', {
276
+ rows: '8',
277
+ spellcheck: 'false',
278
+ autocomplete: 'off',
279
+ autocapitalize: 'off',
280
+ 'aria-label': '重新加载前的草稿(只读)',
281
+ });
282
+ settingsPreservedTextarea.readOnly = true;
283
+ const settingsPreserved = el('details.dsh-settings-preserved', { hidden: true }, [
284
+ el('summary', { text: '重新加载前的草稿(只读)' }),
285
+ settingsPreservedTextarea,
286
+ ]);
287
+ const settingsPath = el('code.dsh-settings-path');
288
+ const settingsStatus = el('p.dsh-settings-status', {
289
+ role: 'status',
290
+ 'aria-live': 'polite',
291
+ });
292
+ const settingsLoadBtn = button('加载文件', {
293
+ compact: false,
294
+ onClick: () => loadSettings(),
295
+ });
296
+ const settingsSaveBtn = button('保存文件', {
297
+ variant: 'primary',
298
+ compact: false,
299
+ disabled: true,
300
+ onClick: () => saveSettings(),
301
+ });
302
+ const settingsDiscardBtn = button('放弃文件修改', {
303
+ compact: false,
304
+ disabled: true,
305
+ onClick: () => resetSettingsDraft(),
306
+ });
307
+ const settingsSection = el('section.dsh-settings-editor', {}, [
308
+ el('h3', { text: 'dsh 配置文件' }),
309
+ el('p.dsh-settings-safety', {
310
+ text: 'settings.yaml 可能包含凭据;内容仅在本页面内存中显示,不写入 manager 配置、日志或 SSE。',
311
+ }),
312
+ el('p.section-note', {
313
+ text: '零侵入:只原子替换 settings.yaml,不修改 dsh CLI;当前 dsh 自行监视并加载,Center 不自动重启。',
314
+ }),
315
+ el('div.dsh-settings-path-row', {}, [
316
+ el('span', { text: '解析路径(只读)' }),
317
+ settingsPath,
318
+ ]),
319
+ el('label', { for: settingsTextareaId, text: '文件内容' }),
320
+ settingsTextarea,
321
+ settingsPreserved,
322
+ settingsStatus,
323
+ el('div.dsh-settings-actions', {}, [
324
+ settingsLoadBtn,
325
+ settingsSaveBtn,
326
+ settingsDiscardBtn,
327
+ ]),
328
+ ]);
329
+
330
+ const logPre = el('pre.remote-log-body', { text: '(未加载)' });
331
+ const logBtn = button('拉取最近 200 行', { compact: false, onClick: () => loadLog() });
332
+ const logTitle = el('h3', { text: '远端日志' });
333
+
334
+ // 有校验器的字段(键名对齐 buildHostPatch 的 errors)
335
+ const validated = {
336
+ remoteWebPort: remotePort, workdir, env, patches,
337
+ };
338
+
339
+ // 离开字段就把它自己的校验结果说出来,不必等到点保存(issue #30)。
340
+ // blur 不冒泡,所以只能挂在字段本身——挂到 form 上收不到。
341
+ for (const [key, f] of Object.entries(validated)) {
342
+ f.input.addEventListener('blur', () => {
343
+ touched.add(key);
344
+ revalidate();
345
+ });
346
+ }
347
+
348
+ const form = el('form.drawer-form', {
349
+ on: {
350
+ submit: (e) => {
351
+ e.preventDefault();
352
+ submit();
353
+ },
354
+ input: () => syncDirty(),
355
+ change: () => syncDirty(),
356
+ // 离开字段就把它自己的校验结果说出来,不必等到点保存(issue #30)。
357
+ // focusout 会冒泡,blur 不会——但垫片直接往 input 上派发 blur,两个都收。
358
+ },
359
+ }, [
360
+ el('section.config-section', {}, [
361
+ el('h3', { text: '基本配置' }),
362
+ el('div.field-grid', {}, [enabled.root, remotePort.root]),
363
+ ]),
364
+ el('section.config-section.env-editor', {}, [
365
+ el('h3', { text: '注入配置' }),
366
+ el('p.section-note', { text: '本区改动保存后在下次拉起时生效,不影响正在跑的实例。' }),
367
+ remoteConfigNote,
368
+ workdir.root,
369
+ workspaceSection,
370
+ env.root,
371
+ extraArgs.root,
372
+ patches.root,
373
+ ]),
374
+ el('footer.drawer-actions', {}, [saveBtn, cancelBtn]),
375
+ ]);
376
+
377
+ const closeBtn = el('button.drawer-close', {
378
+ type: 'button', 'aria-label': '关闭', text: '×', on: { click: () => requestClose() },
379
+ });
380
+
381
+ const root = el('aside.host-drawer', {
382
+ 'aria-labelledby': 'drawer-title',
383
+ hidden: true,
384
+ role: 'dialog',
385
+ // 遮罩吞掉鼠标事件,那就是模态——说 false 会让读屏用户拿到相反的信息(issue #28)
386
+ 'aria-modal': 'true',
387
+ on: {
388
+ keydown: (e) => {
389
+ if (e.key !== 'Escape') return;
390
+ e.stopPropagation();
391
+ onEsc(e);
392
+ },
393
+ },
394
+ }, [
395
+ el('header.drawer-header', {}, [el('div', {}, [title, badge]), closeBtn]),
396
+ conflict,
397
+ form,
398
+ el('section.probe-detail', {}, [probeTitle, probeDl]),
399
+ settingsSection,
400
+ el('section.remote-log', {}, [
401
+ logTitle,
402
+ logPre,
403
+ el('div.log-actions', {}, [logBtn]),
404
+ ]),
405
+ ]);
406
+
407
+ const scrim = el('div.drawer-scrim', { hidden: true, on: { click: () => requestClose() } });
408
+
409
+ let current = null; // { name, draft, config, conflicts }
410
+ let restoreFocus = null;
411
+ let closing = false;
412
+ const touched = new Set(); // 碰过的字段才实时报错,见 revalidate
413
+
414
+ settingsTextarea.addEventListener('input', () => {
415
+ if (current) current.settings.editEpoch += 1;
416
+ syncSettingsControls();
417
+ });
418
+
419
+ // ── 草稿 ─────────────────────────────────────────────────────────────
420
+
421
+ function readForm() {
422
+ return {
423
+ enabled: enabled.input.checked,
424
+ remoteWebPort: remotePort.input.value,
425
+ workdir: workdir.input.value,
426
+ env: env.input.value,
427
+ extraArgs: extraArgs.input.value,
428
+ patches: patches.input.value,
429
+ };
430
+ }
431
+
432
+ function writeForm(draft) {
433
+ enabled.input.checked = draft.enabled;
434
+ remotePort.input.value = draft.remoteWebPort === null ? '' : String(draft.remoteWebPort);
435
+ workdir.input.value = draft.workdir ?? '';
436
+ env.input.value = draft.env;
437
+ extraArgs.input.value = draft.extraArgs;
438
+ patches.input.value = draft.patches;
439
+ for (const f of fields) f.setError(null);
440
+ touched.clear();
441
+ }
442
+
443
+ /**
444
+ * 校验结果的展示纪律(issue #30)。
445
+ *
446
+ * 一个字段「被碰过」(离开过它,或点过保存)之后,它的错误提示就一直跟着值走:
447
+ * 改对了立刻灭,改坏了立刻换成当前那条。没碰过的字段一声不吭——边打字边报太吵,
448
+ * 刚敲下 `4` 就红一次没有意义。
449
+ *
450
+ * 原来的毛病是错误只在点保存时算一次、之后再没人碰:改回合法值,红字和
451
+ * `aria-invalid` 还挂在那儿,读屏用户听到的仍是「无效」。
452
+ */
453
+ function revalidate() {
454
+ if (!current) return;
455
+ const built = buildHostPatch(current.draft);
456
+ for (const [key, f] of Object.entries(validated)) {
457
+ if (touched.has(key)) f.setError(built.errors?.[key] ?? null);
458
+ }
459
+ extraArgs.setError(null);
460
+ return built;
461
+ }
462
+
463
+ function refreshConflict() {
464
+ if (!current) return;
465
+ const latest = draftOf(current.config);
466
+ current.conflicts = current.conflicts.filter((key) => !fieldEqual(current.draft, latest, key));
467
+ conflict.hidden = current.conflicts.length === 0;
468
+ if (!conflict.hidden) {
469
+ const host = store.getHost(current.name);
470
+ const labels = current.conflicts.map((key) => DRAFT_FIELD_LABELS[key]).join('、');
471
+ conflict.textContent = `${drawerCopy(host).configChanged}(可能来自另一个标签页);同一字段(${labels})也被修改。你的草稿已保留,保存已暂停;「放弃修改」可载入最新值。`;
472
+ }
473
+ }
474
+
475
+ function syncDirty() {
476
+ if (!current) return;
477
+ current.draft = readForm();
478
+ refreshConflict();
479
+ revalidate();
480
+ const dirty = isDirty(current.draft, current.config);
481
+ const saving = store.isPending('config:save', current.name);
482
+ for (const f of fields) f.input.disabled = saving;
483
+ saveBtn.disabled = !dirty || current.conflicts.length > 0
484
+ || !store.canWrite() || saving;
485
+ cancelBtn.disabled = !dirty || saving;
486
+ syncWorkspaceControls();
487
+ syncSettingsControls();
488
+ }
489
+
490
+ function resetDraft() {
491
+ if (!current) return;
492
+ current.draft = draftOf(current.config);
493
+ current.conflicts = [];
494
+ writeForm(current.draft);
495
+ syncDirty();
496
+ }
497
+
498
+ // ── 打开 / 关闭 ──────────────────────────────────────────────────────
499
+
500
+ function open(name) {
501
+ const host = store.getHost(name);
502
+ if (!host) {
503
+ store.addToast({ level: 'warn', summary: `主机 ${name} 不存在或尚未同步` });
504
+ return;
505
+ }
506
+ restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
507
+ clearSettingsSession(current);
508
+ clearWorkspaceSession();
509
+ current = {
510
+ name, config: host.config, draft: draftOf(host.config), conflicts: [],
511
+ settings: {
512
+ loaded: false,
513
+ baselineContent: null,
514
+ baselineChecksum: null,
515
+ path: null,
516
+ size: null,
517
+ saveContent: null,
518
+ editEpoch: 0,
519
+ preserveOnReload: false,
520
+ },
521
+ };
522
+ writeForm(current.draft);
523
+ logPre.textContent = '(未加载)';
524
+ renderReadonly(host);
525
+ root.hidden = false;
526
+ scrim.hidden = false;
527
+ setBackgroundInert(true);
528
+ syncDirty();
529
+ closeBtn.focus();
530
+ loadLog();
531
+ }
532
+
533
+ /**
534
+ * Esc 的唯一入口。
535
+ *
536
+ * 「放弃未保存的修改?」是原生 `<dialog>`,`showModal()` 就在这一记 Esc 的处理器里调;
537
+ * 浏览器随后处理同一记 Esc 的默认动作(CloseWatcher),一眼看到刚开的模态框就把它
538
+ * 关掉——用户按 Esc 的体感是「毫无反应」,只能改用鼠标(issue #71)。所以要开框的
539
+ * 那一记必须把默认动作摘掉。
540
+ *
541
+ * 反过来,确认框已经开着时这里一律不插手:那记 Esc 就该由框自己的原生 cancel 收场,
542
+ * 再 preventDefault 就等于把 Esc 彻底焊死。
543
+ */
544
+ function onEsc(e) {
545
+ if (closing) return;
546
+ e.preventDefault?.();
547
+ requestClose();
548
+ }
549
+
550
+ async function requestClose() {
551
+ if (current && store.isPending('config:save', current.name)) {
552
+ store.addToast({
553
+ level: 'warn',
554
+ summary: `${current.name} 配置正在保存,完成后再关闭;关闭不会取消写入`,
555
+ });
556
+ return;
557
+ }
558
+ if (current && store.isPending('settings:save', current.name)) {
559
+ setSettingsStatus('保存正在进行,完成后再关闭;关闭不会取消写入。', 'warn');
560
+ return;
561
+ }
562
+ if (current && store.isPending('workspace:register', current.name)) {
563
+ setWorkspaceStatus('登记正在进行,完成后再关闭;关闭不会取消请求。', 'warn');
564
+ return;
565
+ }
566
+ // 连按两下 Esc 不该弹出两个「放弃未保存的修改?」
567
+ if (closing) return;
568
+ closing = true;
569
+ try {
570
+ await confirmThenClose();
571
+ } finally {
572
+ closing = false;
573
+ }
574
+ }
575
+
576
+ async function confirmThenClose() {
577
+ const hostDirty = current && isDirty(current.draft, current.config);
578
+ const fileDirty = settingsDirty(current);
579
+ if (current && (hostDirty || fileDirty)) {
580
+ const lines = [];
581
+ if (hostDirty) lines.push(`${current.name} 的主机配置有改动尚未保存。`);
582
+ if (fileDirty) lines.push(`${current.name} 的 dsh 配置文件有改动尚未保存。`);
583
+ const ok = await confirm({
584
+ title: '放弃未保存的修改?',
585
+ lines,
586
+ confirmLabel: '放弃',
587
+ });
588
+ if (!ok) return;
589
+ }
590
+ close();
591
+ }
592
+
593
+ function close() {
594
+ root.hidden = true;
595
+ scrim.hidden = true;
596
+ setBackgroundInert(false);
597
+ clearSettingsSession(current);
598
+ clearWorkspaceSession();
599
+ current = null;
600
+ store.setDrawer({ open: false, host: null, dirty: false });
601
+ const focusTarget = isUsableFocusTarget(restoreFocus)
602
+ ? restoreFocus
603
+ : findStableFocusTarget();
604
+ focusTarget?.focus?.();
605
+ restoreFocus = null;
606
+ }
607
+
608
+ function isUsableFocusTarget(node) {
609
+ if (!node || node.disabled) return false;
610
+ if (typeof node.isConnected === 'boolean') return node.isConnected;
611
+ return typeof document.contains === 'function' ? document.contains(node) : false;
612
+ }
613
+
614
+ function findStableFocusTarget() {
615
+ const manageBack = document.querySelector('.manage-back');
616
+ if (isUsableFocusTarget(manageBack)) return manageBack;
617
+ const brand = document.querySelector('.brand');
618
+ if (isUsableFocusTarget(brand)) {
619
+ brand.tabIndex = -1;
620
+ return brand;
621
+ }
622
+ return isUsableFocusTarget(document.body) ? document.body : null;
623
+ }
624
+
625
+ // ── dsh Workspace ───────────────────────────────────────────────────
626
+
627
+ function setWorkspaceStatus(message, tone = null) {
628
+ workspaceStatus.textContent = message ?? '';
629
+ if (tone) workspaceStatus.dataset.tone = tone;
630
+ else delete workspaceStatus.dataset.tone;
631
+ }
632
+
633
+ function clearWorkspaceSession() {
634
+ workspaceHint.textContent = '';
635
+ setWorkspaceStatus('');
636
+ workspaceRegisterBtn.disabled = true;
637
+ }
638
+
639
+ function syncWorkspaceControls() {
640
+ if (!current) return;
641
+ const host = store.getHost(current.name);
642
+ if (!host) {
643
+ clearWorkspaceSession();
644
+ return;
645
+ }
646
+ const pending = store.isPending('workspace:register', current.name);
647
+ const availability = workspaceRegistrationState(host, {
648
+ canWrite: store.canWrite(),
649
+ pending,
650
+ });
651
+ workspaceRegisterBtn.disabled = !availability.enabled;
652
+ setWorkspaceStatus(availability.message, availability.tone);
653
+
654
+ if (!isDirty(current.draft, current.config)) {
655
+ workspaceHint.textContent = '';
656
+ return;
657
+ }
658
+ const savedDraft = draftOf(current.config);
659
+ const workdirDraftChanged = !fieldEqual(current.draft, savedDraft, 'workdir');
660
+ workspaceHint.textContent = workdirDraftChanged
661
+ ? '当前启动目录草稿尚未保存;登记仍使用已保存且当前实例已应用的目录。如要登记草稿值,请先保存并重启 dsh web。'
662
+ : '主机配置有未保存修改;登记仍只使用已保存且当前实例已应用的启动目录。若修改启动目录,请先保存并重启 dsh web。';
663
+ }
664
+
665
+ async function registerWorkspace() {
666
+ const session = current;
667
+ if (!session) return;
668
+ const host = store.getHost(session.name);
669
+ const availability = workspaceRegistrationState(host, {
670
+ canWrite: store.canWrite(),
671
+ pending: store.isPending('workspace:register', session.name),
672
+ });
673
+ if (!availability.enabled) {
674
+ syncWorkspaceControls();
675
+ return;
676
+ }
677
+
678
+ setWorkspaceStatus('正在登记启动目录…');
679
+ const body = await actions.registerDshWorkspace(session.name, {
680
+ onError: (error) => {
681
+ if (current === session) {
682
+ setWorkspaceStatus(error?.summary || 'dsh Workspace 登记失败,请稍后重试。', 'error');
683
+ }
684
+ },
685
+ });
686
+ if (current !== session || !body) return;
687
+
688
+ const path = typeof body.path === 'string' ? `:${body.path}` : '';
689
+ setWorkspaceStatus(
690
+ body.created
691
+ ? `已登记启动目录为 Workspace${path}`
692
+ : `该启动目录已经登记为 Workspace${path}`,
693
+ );
694
+ }
695
+
696
+ // ── dsh settings 文件 ────────────────────────────────────────────────
697
+
698
+ function settingsDirty(session = current) {
699
+ return Boolean(
700
+ session?.settings.loaded
701
+ && settingsTextarea.value !== session.settings.baselineContent,
702
+ );
703
+ }
704
+
705
+ function setSettingsStatus(message, tone = null) {
706
+ settingsStatus.textContent = message ?? '';
707
+ if (tone) settingsStatus.dataset.tone = tone;
708
+ else delete settingsStatus.dataset.tone;
709
+ }
710
+
711
+ function updateDrawerDirty() {
712
+ if (!current) return;
713
+ store.setDrawer({
714
+ dirty: isDirty(current.draft, current.config) || settingsDirty(current),
715
+ });
716
+ }
717
+
718
+ function syncSettingsControls() {
719
+ if (!current) return;
720
+ const loaded = current.settings.loaded;
721
+ const dirty = settingsDirty(current);
722
+ const loading = store.isPending('settings:load', current.name);
723
+ const saving = store.isPending('settings:save', current.name);
724
+
725
+ settingsTextarea.disabled = !loaded || loading;
726
+ settingsLoadBtn.disabled = loading || saving;
727
+ settingsSaveBtn.disabled = !loaded || !dirty || !store.canWrite() || loading || saving;
728
+ settingsDiscardBtn.disabled = !loaded || !dirty || loading || saving;
729
+ updateDrawerDirty();
730
+ }
731
+
732
+ function clearPreservedSettingsDraft() {
733
+ settingsPreservedTextarea.value = '';
734
+ settingsPreserved.hidden = true;
735
+ settingsPreserved.open = false;
736
+ }
737
+
738
+ function clearSettingsSession(session) {
739
+ settingsTextarea.value = '';
740
+ settingsTextarea.disabled = true;
741
+ settingsPath.textContent = '';
742
+ setSettingsStatus('');
743
+ settingsLoadBtn.disabled = false;
744
+ settingsSaveBtn.disabled = true;
745
+ settingsDiscardBtn.disabled = true;
746
+ clearPreservedSettingsDraft();
747
+ if (!session?.settings) return;
748
+ session.settings.loaded = false;
749
+ session.settings.baselineContent = null;
750
+ session.settings.baselineChecksum = null;
751
+ session.settings.path = null;
752
+ session.settings.size = null;
753
+ session.settings.saveContent = null;
754
+ session.settings.editEpoch = 0;
755
+ session.settings.preserveOnReload = false;
756
+ }
757
+
758
+ function showSettingsLoadError(error) {
759
+ setSettingsStatus(error?.summary || '读取失败,请稍后重试。', 'error');
760
+ }
761
+
762
+ function showSettingsSaveError(error) {
763
+ if (error?.code === 'SETTINGS_STALE') {
764
+ if (current) current.settings.preserveOnReload = true;
765
+ setSettingsStatus('目标文件已变化;请重新加载后手工合并。你的草稿已保留。', 'warn');
766
+ return;
767
+ }
768
+ if (error?.code === 'SETTINGS_BUSY') {
769
+ setSettingsStatus('另一项文件操作进行中,请稍后重试。你的草稿已保留。', 'warn');
770
+ return;
771
+ }
772
+ if (error?.status === 0 || SETTINGS_UNKNOWN_CODES.has(error?.code)) {
773
+ if (current) current.settings.preserveOnReload = true;
774
+ setSettingsStatus('保存结果未知;请重新加载后确认。你的草稿已保留。', 'warn');
775
+ return;
776
+ }
777
+ setSettingsStatus(error?.summary || '保存失败,请稍后重试。', 'error');
778
+ }
779
+
780
+ async function loadSettings() {
781
+ const session = current;
782
+ if (!session
783
+ || store.isPending('settings:load', session.name)
784
+ || store.isPending('settings:save', session.name)) return;
785
+ if (settingsDirty(session)) {
786
+ const ok = await confirm({
787
+ title: '重新加载 dsh 配置文件?',
788
+ lines: [`${session.name} 有未保存的文件修改;重新加载会用当前文件覆盖草稿。`],
789
+ confirmLabel: '重新加载',
790
+ });
791
+ if (!ok || current !== session) return;
792
+ }
793
+
794
+ const dirtyBeforeLoad = settingsDirty(session);
795
+ const draftBeforeLoad = dirtyBeforeLoad ? settingsTextarea.value : null;
796
+ const loadEpoch = session.settings.editEpoch;
797
+ setSettingsStatus('正在读取文件…');
798
+ const body = await actions.loadDshSettings(session.name, {
799
+ onError: (error) => {
800
+ if (current === session) showSettingsLoadError(error);
801
+ },
802
+ });
803
+ if (current !== session) return;
804
+ if (!body) {
805
+ syncSettingsControls();
806
+ return;
807
+ }
808
+ if (session.settings.editEpoch !== loadEpoch) {
809
+ setSettingsStatus('加载期间草稿已变化;本次响应未覆盖内容,请确认草稿后再次加载。', 'warn');
810
+ syncSettingsControls();
811
+ return;
812
+ }
813
+
814
+ session.settings.loaded = true;
815
+ session.settings.baselineContent = body.content;
816
+ session.settings.baselineChecksum = body.checksum;
817
+ session.settings.path = body.path;
818
+ session.settings.size = body.size;
819
+ settingsTextarea.value = body.content;
820
+ settingsPath.textContent = body.path;
821
+ if (dirtyBeforeLoad && session.settings.preserveOnReload) {
822
+ settingsPreservedTextarea.value = draftBeforeLoad;
823
+ settingsPreserved.hidden = false;
824
+ settingsPreserved.open = false;
825
+ setSettingsStatus('已重新加载目标文件;重新加载前的草稿已保留在下方,可手工对照合并。', 'warn');
826
+ } else {
827
+ clearPreservedSettingsDraft();
828
+ setSettingsStatus(
829
+ body.exists
830
+ ? `已加载(${body.size} 字节)。`
831
+ : '文件不存在;当前为空草稿,保存将创建 settings.yaml。',
832
+ );
833
+ }
834
+ session.settings.preserveOnReload = false;
835
+ syncSettingsControls();
836
+ }
837
+
838
+ async function saveSettings() {
839
+ const session = current;
840
+ if (!session
841
+ || !session.settings.loaded
842
+ || !settingsDirty(session)
843
+ || !store.canWrite()
844
+ || store.isPending('settings:load', session.name)
845
+ || store.isPending('settings:save', session.name)) return;
846
+
847
+ session.settings.saveContent = settingsTextarea.value;
848
+ setSettingsStatus('正在保存文件…');
849
+ const body = await actions.saveDshSettings(session.name, {
850
+ content: session.settings.saveContent,
851
+ baseChecksum: session.settings.baselineChecksum,
852
+ }, {
853
+ onError: (error) => {
854
+ if (current === session) showSettingsSaveError(error);
855
+ },
856
+ });
857
+ if (current !== session) {
858
+ if (body && session.forcedHostRemoval) {
859
+ store.addToast({
860
+ level: 'success',
861
+ summary: `${session.name} 的 dsh 配置文件已保存(主机详情已关闭)`,
862
+ });
863
+ }
864
+ return;
865
+ }
866
+
867
+ const savedContent = session.settings.saveContent;
868
+ session.settings.saveContent = null;
869
+ if (!body) {
870
+ syncSettingsControls();
871
+ return;
872
+ }
873
+
874
+ session.settings.loaded = true;
875
+ session.settings.baselineContent = savedContent;
876
+ session.settings.baselineChecksum = body.checksum;
877
+ session.settings.path = body.path;
878
+ session.settings.size = body.size;
879
+ settingsPath.textContent = body.path;
880
+ session.settings.preserveOnReload = false;
881
+ clearPreservedSettingsDraft();
882
+ if (settingsTextarea.value !== savedContent) {
883
+ setSettingsStatus('已保存提交时版本;当前仍有未保存修改。', 'warn');
884
+ } else {
885
+ setSettingsStatus(`已保存(${body.size} 字节)。`);
886
+ }
887
+ syncSettingsControls();
888
+ }
889
+
890
+ function resetSettingsDraft() {
891
+ if (!current?.settings.loaded) return;
892
+ settingsTextarea.value = current.settings.baselineContent;
893
+ setSettingsStatus('已放弃文件修改,恢复到最近一次加载或保存的内容。');
894
+ syncSettingsControls();
895
+ }
896
+
897
+ // ── 只读区 ───────────────────────────────────────────────────────────
898
+
899
+ function renderReadonly(host) {
900
+ const copy = drawerCopy(host);
901
+ title.textContent = host.name;
902
+ clear(badge).append(phaseBadge(hostPhaseMeta(host)));
903
+ workdirBadge.hidden = !workdirPending(host);
904
+ remotePort.root.querySelector('label').textContent = copy.portLabel;
905
+ probeTitle.textContent = copy.probeTitle;
906
+ logTitle.textContent = copy.logTitle;
907
+ remoteConfigNote.hidden = host.local === true;
908
+ remoteConfigNote.textContent = host.local === true ? '' : REMOTE_CONFIG_NOTE;
909
+
910
+ const probe = host.probe;
911
+ clear(probeDl);
912
+ const rows = [
913
+ ['dsh 路径', text(probe?.dshPath)],
914
+ ['版本', text(probe?.version)],
915
+ ['web profile', probe ? String(probe.profileWeb) : DASH],
916
+ ['DSH_HOME', text(probe?.dshHome)],
917
+ ['最近探测', probe?.at ? fmtAgo(probe.at) : DASH],
918
+ [copy.effectivePortLabel, text(host.effectiveRemotePort)],
919
+ ['本机映射', hostMappingSummary(host).url ?? DASH],
920
+ [copy.processLabel, host.web ? `PID ${host.web.pid}(${host.web.startedByUs ? '本工具拉起' : '手动'})` : DASH],
921
+ // 实测工作目录:远端 /proc 不可读时为 null,此处退回「—」而不是编一个值
922
+ [copy.workdirLabel, host.web ? (host.web.cwd ?? DASH) : DASH],
923
+ ];
924
+ if (probe?.errorSummary) rows.push(['探测错误', probe.errorSummary]);
925
+ for (const [k, v] of rows) probeDl.append(el('dt', { text: k }), el('dd', { text: v }));
926
+ }
927
+
928
+ // ── 日志 ─────────────────────────────────────────────────────────────
929
+
930
+ let logBusy = false;
931
+
932
+ async function loadLog() {
933
+ if (!current || logBusy) return;
934
+ logBusy = true;
935
+ logBtn.disabled = true;
936
+ logBtn.textContent = '拉取中…';
937
+ const name = current.name;
938
+ const body = await actions.loadHostLog(name, LOG_LINES);
939
+ logBusy = false;
940
+ logBtn.disabled = false;
941
+ logBtn.textContent = '刷新日志';
942
+ if (!current || current.name !== name) return;
943
+ // 失败不清空上一次成功内容(UI-21)
944
+ if (body !== null) logPre.textContent = body === '' ? '(日志为空)' : body;
945
+ }
946
+
947
+ // ── 保存 ─────────────────────────────────────────────────────────────
948
+
949
+ async function submit() {
950
+ const session = current;
951
+ if (!session || store.isPending('config:save', session.name)) return;
952
+ // submit 以此刻 DOM 为准;input/change 事件与点击之间可能还有尚未同步进 draft 的值。
953
+ session.draft = readForm();
954
+ refreshConflict();
955
+ if (session.conflicts.length > 0) return;
956
+ // 保存是最终权威:该说的全说,之后这些字段也就都算碰过了
957
+ for (const key of Object.keys(validated)) touched.add(key);
958
+ const built = revalidate();
959
+ if (!built.ok) return;
960
+
961
+ // 只提交真正改动的键:避免把没碰过的字段“全量替换”回当前显示值
962
+ const patch = diffPatch(built.value, session.config);
963
+ if (Object.keys(patch).length === 0) {
964
+ // raw 文本可能看似有变化,但 trim / 注释过滤后与服务端配置完全一致。
965
+ // 回填 canonical draft,避免按钮与冲突提示继续暗示仍有内容可保存。
966
+ resetDraft();
967
+ store.addToast({ level: 'info', summary: '没有需要保存的有效变更' });
968
+ return;
969
+ }
970
+ await actions.saveHostConfig(session.name, patch);
971
+ if (current !== session) return;
972
+ syncDirty();
973
+ }
974
+
975
+ // ── store 订阅 ───────────────────────────────────────────────────────
976
+
977
+ function reconcileCurrentHost() {
978
+ if (!current) return;
979
+ const name = current.name;
980
+ const host = store.getHost(name);
981
+ if (!host) {
982
+ const removedSession = current;
983
+ removedSession.forcedHostRemoval = true;
984
+ store.addToast({ level: 'warn', summary: `${name} 已从清单移除,详情已关闭` });
985
+ confirm.cancel?.();
986
+ close();
987
+ return;
988
+ }
989
+ renderReadonly(host);
990
+ const merged = reconcile(
991
+ current.draft, current.config, host.config, current.conflicts,
992
+ );
993
+ const draftChanged = !deepEqual(current.draft, merged.draft);
994
+ current.config = host.config;
995
+ current.draft = merged.draft;
996
+ current.conflicts = merged.conflicts;
997
+ if (draftChanged) writeForm(current.draft);
998
+ syncDirty();
999
+ }
1000
+
1001
+ const offs = [
1002
+ store.on('drawer:changed', (drawer) => {
1003
+ if (drawer.open && drawer.host && (!current || current.name !== drawer.host)) open(drawer.host);
1004
+ }),
1005
+ store.on('hosts:changed', (name) => {
1006
+ if (!current || current.name !== name) return;
1007
+ reconcileCurrentHost();
1008
+ }),
1009
+ store.on('hosts:reset', reconcileCurrentHost),
1010
+ store.on('pending:changed', syncDirty),
1011
+ store.on('connection:changed', syncDirty),
1012
+ ];
1013
+
1014
+ // Esc 得在任何焦点位置都管用。抽屉元素上那个处理器 stopPropagation,
1015
+ // 所以焦点在抽屉里时这条不会重复触发。
1016
+ const onDocKeyDown = (e) => {
1017
+ if (e.key !== 'Escape' || root.hidden) return;
1018
+ onEsc(e);
1019
+ };
1020
+ document.addEventListener('keydown', onDocKeyDown);
1021
+
1022
+ return {
1023
+ root,
1024
+ scrim,
1025
+ open,
1026
+ close,
1027
+ requestClose,
1028
+ get current() {
1029
+ return current;
1030
+ },
1031
+ destroy() {
1032
+ for (const off of offs) off();
1033
+ document.removeEventListener('keydown', onDocKeyDown);
1034
+ clearSettingsSession(current);
1035
+ clearWorkspaceSession();
1036
+ current = null;
1037
+ },
1038
+ };
1039
+ }