@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,726 @@
1
+ /**
2
+ * 首启引导四步向导(10 §3.5 / UI-22…25)。
3
+ *
4
+ * 题目、校验、config 组装全部来自 `setup-schema.js`——与 `dshc init` 同源,
5
+ * 页面这边只负责渲染、渐进探测展示、JSON 预览与端口迁移。
6
+ *
7
+ * 纪律:判定逻辑一律做成本文件顶部的纯函数(DOM-free,可单测),
8
+ * `createSetupWizard` 只做装配与事件绑定。
9
+ */
10
+
11
+ import { api as defaultApi } from '../api.js';
12
+ import {
13
+ SETUP_STEPS,
14
+ buildConfigFromAnswers,
15
+ canAutoStart,
16
+ defaultAnswers,
17
+ getByPath,
18
+ previewJson,
19
+ setByPath,
20
+ vPort,
21
+ vRange,
22
+ } from '../setup-schema.js';
23
+ import { field, input } from '../form.js';
24
+ import { hostPhaseMeta } from '../host-presentation.js';
25
+ import { button, clear, copyText, el } from '../utils.js';
26
+
27
+ /** 迁移探测的递增间隔(10 §3.5:60 秒兜底后停手)。 */
28
+ export const MIGRATION_DELAYS = Object.freeze([300, 500, 800, 1_200, 2_000, 3_000, 5_000]);
29
+ export const MIGRATION_BUDGET_MS = 60_000;
30
+
31
+ /** 探测未完成也允许进入第 4 步,但它们的 autoStart 只能是 false。 */
32
+ const PROBED = new Set(['ready', 'no_dsh', 'unreachable', 'running', 'degraded', 'crashed']);
33
+
34
+ /** 单步字段校验:只判本步,避免第 1 步就报第 2 步的错。 */
35
+ export function stepErrors(answers, step) {
36
+ const errors = {};
37
+ for (const f of step?.fields ?? []) {
38
+ const bad = f.validate(getByPath(answers, f.key));
39
+ if (bad) errors[f.key] = bad;
40
+ }
41
+ return errors;
42
+ }
43
+
44
+ /**
45
+ * 勾选联动(01 §2.5 第 3 步):取消纳管必然取消开启链接;
46
+ * 只有探测为 ready 的主机才允许开启链接。
47
+ */
48
+ export function nextSelection(selection, name, patch, host) {
49
+ const prev = selection[name] ?? { enabled: true, autoStart: false };
50
+ const enabled = patch.enabled ?? prev.enabled;
51
+ let autoStart = patch.autoStart ?? prev.autoStart;
52
+ if (!enabled) autoStart = false;
53
+ if (autoStart && !canAutoStart(host)) autoStart = false;
54
+ return { ...selection, [name]: { enabled, autoStart, touchedAutoStart: prev.touchedAutoStart || patch.autoStart !== undefined } };
55
+ }
56
+
57
+ /**
58
+ * 探测结果到达后刷新勾选:新主机默认纳管;**首次**变为 ready 默认开启链接;
59
+ * 离开 ready 的行强制收回 autoStart。用户手动动过的行不再被默认值覆盖。
60
+ */
61
+ export function syncSelectionWithHosts(selection, hosts) {
62
+ const next = {};
63
+ for (const host of hosts) {
64
+ const prev = selection[host.name];
65
+ if (!prev) {
66
+ next[host.name] = { enabled: true, autoStart: canAutoStart(host), touchedAutoStart: false };
67
+ continue;
68
+ }
69
+ let autoStart = prev.autoStart;
70
+ if (!prev.touchedAutoStart && canAutoStart(host)) autoStart = prev.enabled;
71
+ if (!canAutoStart(host)) autoStart = false;
72
+ next[host.name] = { ...prev, autoStart: prev.enabled ? autoStart : false };
73
+ }
74
+ return next;
75
+ }
76
+
77
+ /** 顶部「已完成 x / 总数 y」。 */
78
+ export function probeProgress(hosts) {
79
+ return { done: hosts.filter((h) => PROBED.has(h.phase)).length, total: hosts.length };
80
+ }
81
+
82
+ export function pendingHosts(hosts, selection) {
83
+ return hosts.filter((h) => !PROBED.has(h.phase) && (selection[h.name]?.enabled ?? true)).map((h) => h.name);
84
+ }
85
+
86
+ /** 保留给既有消费者的兼容导出,展示语义统一由 host-presentation 提供。 */
87
+ export function setupPhaseLabel(host) {
88
+ return hostPhaseMeta(host).label;
89
+ }
90
+
91
+ /**
92
+ * 第 4 步 JSON 预览的解析 + 前端基础结构校验(后端 schema 才是终裁)。
93
+ * @returns {{ok:true, config:object}|{ok:false, error:string, line:number|null}}
94
+ */
95
+ export function parsePreview(text) {
96
+ let parsed;
97
+ try {
98
+ parsed = JSON.parse(text);
99
+ } catch (err) {
100
+ return { ok: false, error: `JSON 语法错误:${err.message}`, line: lineOfJsonError(text, err) };
101
+ }
102
+ const problems = structureProblems(parsed);
103
+ if (problems.length > 0) return { ok: false, error: problems.join(';'), line: null };
104
+ return { ok: true, config: parsed };
105
+ }
106
+
107
+ function structureProblems(cfg) {
108
+ const bad = [];
109
+ if (cfg === null || typeof cfg !== 'object' || Array.isArray(cfg)) return ['顶层必须是 JSON 对象'];
110
+ if (!Number.isInteger(cfg.configVersion)) bad.push('缺少 configVersion');
111
+ const portBad = vPort(cfg.manager?.port);
112
+ if (portBad) bad.push(`manager.port ${portBad}`);
113
+ const remoteBad = vPort(cfg.defaults?.remoteWebPort);
114
+ if (remoteBad) bad.push(`defaults.remoteWebPort ${remoteBad}`);
115
+ const rangeBad = vRange(cfg.defaults?.localPortRange);
116
+ if (rangeBad) bad.push(`defaults.localPortRange ${rangeBad}`);
117
+ if (cfg.hosts === null || typeof cfg.hosts !== 'object' || Array.isArray(cfg.hosts)) bad.push('hosts 必须是对象');
118
+ return bad;
119
+ }
120
+
121
+ /** JSON.parse 只给字符偏移,人要看的是行号。 */
122
+ export function lineOfJsonError(text, err) {
123
+ const m = /position (\d+)/.exec(err.message ?? '');
124
+ if (!m) return null;
125
+ const pos = Math.min(Number(m[1]), text.length);
126
+ return text.slice(0, pos).split('\n').length;
127
+ }
128
+
129
+ /**
130
+ * 端口迁移目标(10 §3.5):端口没改就不迁移;改了就用**用户提交的端口**
131
+ * 拼目标 origin——绝不硬编码。
132
+ */
133
+ export function migrationTarget(submittedPort, currentOrigin) {
134
+ const url = new URL(currentOrigin);
135
+ if (String(submittedPort) === (url.port || (url.protocol === 'https:' ? '443' : '80'))) return null;
136
+ return `${url.protocol}//${url.hostname}:${submittedPort}`;
137
+ }
138
+
139
+ // ── 组件 ─────────────────────────────────────────────────────────────────
140
+
141
+ const sleepDefault = (ms) => new Promise((r) => {
142
+ const t = setTimeout(r, ms);
143
+ t?.unref?.();
144
+ });
145
+
146
+ /**
147
+ * @param {{
148
+ * store: object, actions: object, confirm: Function,
149
+ * api?: object, win?: Window, sleep?: (ms:number)=>Promise<void>,
150
+ * }} deps
151
+ */
152
+ export function createSetupWizard({
153
+ store, actions, confirm, api = defaultApi, win = globalThis.window, sleep = sleepDefault,
154
+ }) {
155
+ const ui = {
156
+ step: 0,
157
+ answers: null,
158
+ answersFrom: null,
159
+ serverAnswers: null,
160
+ /** 一旦用户编辑或聚焦字段,该字段的原始字符串就由用户草稿接管。 */
161
+ fieldDrafts: {},
162
+ ownedFields: new Set(),
163
+ conflicts: new Set(),
164
+ selection: {},
165
+ previewText: '',
166
+ previewDirty: false,
167
+ previewError: null,
168
+ /** 输入框里「还没解析成合法值」的原始错误:它们同样要挡住下一步。 */
169
+ rawErrors: {},
170
+ submitting: false,
171
+ discovered: false,
172
+ migration: null, // { target, elapsedMs, stopped }
173
+ frozen: null, // 提交快照:确认后迟到的探测结果不得改它
174
+ };
175
+
176
+ // 焦点已经安放在第几步(初值等于起始步,免得首屏就把焦点从别处抢过来)
177
+ let focusedStep = ui.step;
178
+ // 真实浏览器的 element.focus() 会同步触发 focus 事件;重渲染恢复焦点不等于用户接管字段。
179
+ let restoringFocus = false;
180
+
181
+ function restoreFocus(node) {
182
+ restoringFocus = true;
183
+ try {
184
+ node.focus();
185
+ } finally {
186
+ restoringFocus = false;
187
+ }
188
+ }
189
+
190
+ const stepper = el('ol.stepper', { 'aria-label': '初始化步骤' });
191
+ const panel = el('form.step-panel', { novalidate: 'novalidate' });
192
+ const foot = el('footer.wizard-foot');
193
+ const notice = el('p.wizard-notice', { role: 'status', hidden: true });
194
+ const root = el('main.view.setup-wizard', { hidden: true }, [
195
+ el('header.wizard-head', {}, [
196
+ el('h1', { text: '初始化 DSH Center' }),
197
+ el('p.wizard-sub', { text: '四步完成本机与远端约定,随后即可纳管主机' }),
198
+ ]),
199
+ stepper,
200
+ notice,
201
+ panel,
202
+ foot,
203
+ ]);
204
+
205
+ panel.addEventListener('submit', (ev) => ev.preventDefault());
206
+
207
+ /**
208
+ * 现值来源:`GET /api/config` 下发的已配置 manager 端口与 defaults;配置端口还没到时
209
+ * 才退回 manager info 的实际监听端口。首启时后端那份就是出厂默认表。前端不留第二份
210
+ * 常量:拿不到就留空让用户填,抄一份端口常量在这里迟早和 `src/defaults.js` 对不上。
211
+ */
212
+ function currentConfig() {
213
+ const info = store.state.manager.info;
214
+ const configuredPort = store.state.manager.configuredPort;
215
+ const defaults = store.state.defaults;
216
+ return {
217
+ manager: { port: configuredPort ?? info?.port ?? null },
218
+ defaults: {
219
+ remoteWebPort: defaults?.remoteWebPort ?? null,
220
+ localPortRange: defaults?.localPortRange ?? null,
221
+ },
222
+ };
223
+ }
224
+
225
+ const answerFields = SETUP_STEPS.flatMap((step) => step.fields ?? []);
226
+
227
+ function shownFieldValue(f, value) {
228
+ if (value === null || value === undefined) return '';
229
+ return f.format ? f.format(value) : String(value);
230
+ }
231
+
232
+ function parseFieldDraft(f, raw) {
233
+ const parsed = f.parse(raw);
234
+ if (!parsed.ok) return parsed;
235
+ const error = f.validate(parsed.value);
236
+ return error ? { ok: false, error } : parsed;
237
+ }
238
+
239
+ function draftMatchesServer(f, raw, serverValue) {
240
+ const local = parseFieldDraft(f, raw);
241
+ const remote = parseFieldDraft(f, shownFieldValue(f, serverValue));
242
+ return local.ok && remote.ok && JSON.stringify(local.value) === JSON.stringify(remote.value);
243
+ }
244
+
245
+ /**
246
+ * 后端现值按字段合并进草稿:尚未被用户接管的字段跟随配置更新;已经编辑或聚焦的
247
+ * 字段保留原始字符串(包括暂时非法的空值),双方同时改同一字段时只提示不覆盖。
248
+ */
249
+ function ensureAnswers() {
250
+ const current = currentConfig();
251
+ const from = JSON.stringify(current);
252
+ const incoming = defaultAnswers(current);
253
+ if (!ui.answers) {
254
+ ui.answers = incoming;
255
+ ui.serverAnswers = incoming;
256
+ ui.answersFrom = from;
257
+ return ui.answers;
258
+ }
259
+ if (from !== ui.answersFrom) {
260
+ for (const f of answerFields) {
261
+ const previous = getByPath(ui.serverAnswers, f.key);
262
+ const next = getByPath(incoming, f.key);
263
+ if (!ui.ownedFields.has(f.key)) {
264
+ setByPath(ui.answers, f.key, next);
265
+ ui.conflicts.delete(f.key);
266
+ } else if (JSON.stringify(previous) !== JSON.stringify(next)) {
267
+ const localRaw = ui.fieldDrafts[f.key] ?? shownFieldValue(f, getByPath(ui.answers, f.key));
268
+ if (draftMatchesServer(f, localRaw, next)) {
269
+ // 只有格式不同(如 07799、"18001 18099")时收敛到 schema 的展示格式,
270
+ // 同时释放字段所有权;后续服务端更新便可继续正常回灌。
271
+ setByPath(ui.answers, f.key, next);
272
+ delete ui.fieldDrafts[f.key];
273
+ delete ui.rawErrors[f.key];
274
+ ui.ownedFields.delete(f.key);
275
+ ui.conflicts.delete(f.key);
276
+ } else {
277
+ ui.conflicts.add(f.key);
278
+ }
279
+ }
280
+ }
281
+ ui.serverAnswers = incoming;
282
+ ui.answersFrom = from;
283
+ }
284
+ return ui.answers;
285
+ }
286
+
287
+ function renderDraftNotice() {
288
+ if (ui.conflicts.size === 0) {
289
+ notice.hidden = true;
290
+ notice.textContent = '';
291
+ return;
292
+ }
293
+ notice.hidden = false;
294
+ notice.textContent = '外部配置已更新;正在编辑的同字段草稿已保留,不会自动覆盖。';
295
+ }
296
+
297
+ // ── 第 3 步:候选发现与并行探测 ──────────────────────────────────────
298
+ async function discover() {
299
+ if (ui.discovered) return;
300
+ ui.discovered = true;
301
+ try {
302
+ const res = await api.hosts();
303
+ store.mergeFetchedHosts(res.hosts, res.revision, performance.now());
304
+ ui.selection = syncSelectionWithHosts(ui.selection, store.listHosts());
305
+ render();
306
+ } catch (err) {
307
+ actions.reportError(err, '读取候选主机失败');
308
+ ui.discovered = false;
309
+ return;
310
+ }
311
+ await actions.probeAll({ failureMessage: '发起探测失败(可稍后在管理台重试)' });
312
+ }
313
+
314
+ function onHostsChanged() {
315
+ if (ui.frozen) return; // 提交快照已冻结,迟到结果只影响显示
316
+ ui.selection = syncSelectionWithHosts(ui.selection, store.listHosts());
317
+ if (ui.step === 2) render();
318
+ }
319
+
320
+ // ── 渲染 ────────────────────────────────────────────────────────────
321
+ function renderStepper() {
322
+ clear(stepper);
323
+ SETUP_STEPS.forEach((step, i) => {
324
+ stepper.append(el('li.step', {
325
+ dataset: { state: i === ui.step ? 'current' : (i < ui.step ? 'done' : 'todo') },
326
+ 'aria-current': i === ui.step ? 'step' : null,
327
+ text: `${i + 1}. ${step.title}`,
328
+ }));
329
+ });
330
+ }
331
+
332
+ function renderFields(step) {
333
+ const answers = ensureAnswers();
334
+ const rows = [];
335
+ for (const f of step.fields) {
336
+ const value = getByPath(answers, f.key);
337
+ const shown = ui.ownedFields.has(f.key)
338
+ ? (ui.fieldDrafts[f.key] ?? shownFieldValue(f, value))
339
+ : shownFieldValue(f, value);
340
+ const box = field(f.label, input('text', shown, {
341
+ inputmode: 'numeric',
342
+ 'data-key': f.key,
343
+ on: {
344
+ focus: (ev) => {
345
+ if (restoringFocus) return;
346
+ ui.ownedFields.add(f.key);
347
+ ui.fieldDrafts[f.key] = ev.target.value;
348
+ },
349
+ input: (ev) => {
350
+ ui.ownedFields.add(f.key);
351
+ ui.fieldDrafts[f.key] = ev.target.value;
352
+ const parsed = parseFieldDraft(f, ev.target.value);
353
+ const bad = parsed.ok ? null : parsed.error;
354
+ box.setError(bad);
355
+ if (bad) {
356
+ // 非法输入不写回答案(避免污染),但必须挡住「下一步」
357
+ ui.rawErrors[f.key] = bad;
358
+ } else {
359
+ delete ui.rawErrors[f.key];
360
+ setByPath(answers, f.key, parsed.value);
361
+ ui.previewDirty = false; // 结构化改动后预览需重生成
362
+ }
363
+ const serverValue = getByPath(ui.serverAnswers, f.key);
364
+ if (draftMatchesServer(f, ev.target.value, serverValue)) ui.conflicts.delete(f.key);
365
+ renderDraftNotice();
366
+ syncFoot();
367
+ },
368
+ },
369
+ }), { hint: f.hint });
370
+ if (ui.rawErrors[f.key]) box.setError(ui.rawErrors[f.key]);
371
+ rows.push(box);
372
+ panel.append(box.root);
373
+ }
374
+ return rows;
375
+ }
376
+
377
+ function renderHostStep() {
378
+ const hosts = store.listHosts();
379
+ const { done, total } = probeProgress(hosts);
380
+ panel.append(el('p.wizard-progress', {
381
+ role: 'status',
382
+ text: total === 0 ? '~/.ssh/config 里没有候选主机;可先完成配置,之后补充。' : `已完成探测 ${done} / 共 ${total}`,
383
+ }));
384
+ if (total === 0) return;
385
+
386
+ const table = el('table.setup-hosts', {}, [
387
+ el('thead', {}, [el('tr', {}, [
388
+ el('th', { text: '主机' }),
389
+ el('th', { text: '探测状态' }),
390
+ el('th', { text: '纳管' }),
391
+ el('th', { text: '开启链接' }),
392
+ ])]),
393
+ ]);
394
+ const body = el('tbody');
395
+ for (const host of hosts) {
396
+ const pick = ui.selection[host.name] ?? { enabled: true, autoStart: false };
397
+ const meta = hostPhaseMeta(host);
398
+ const phaseLabel = setupPhaseLabel(host);
399
+ const probing = !PROBED.has(host.phase);
400
+ const enabledBox = input('checkbox', pick.enabled, {
401
+ 'aria-label': `纳管 ${host.name}`,
402
+ on: {
403
+ change: (ev) => {
404
+ ui.selection = nextSelection(ui.selection, host.name, { enabled: ev.target.checked }, host);
405
+ render();
406
+ },
407
+ },
408
+ });
409
+ const autoBox = input('checkbox', pick.autoStart, {
410
+ 'aria-label': `开启链接 ${host.name}`,
411
+ on: {
412
+ change: (ev) => {
413
+ ui.selection = nextSelection(ui.selection, host.name, { autoStart: ev.target.checked }, host);
414
+ render();
415
+ },
416
+ },
417
+ });
418
+ // 只有 ready 能开启链接;探测中/不可用的行禁用该勾选框但仍可改纳管
419
+ autoBox.disabled = !pick.enabled || !canAutoStart(host);
420
+ body.append(el('tr', { dataset: { host: host.name, probing: probing ? 'yes' : 'no' } }, [
421
+ el('td', {}, [
422
+ el('span', { text: host.name }),
423
+ host.local ? el('span.tag.tag-lock', { text: '本机' }) : null,
424
+ ]),
425
+ el('td.probe-cell', {}, [el('span.phase-badge', { dataset: { tone: meta.tone }, text: probing ? '探测中' : phaseLabel })]),
426
+ el('td', {}, [enabledBox]),
427
+ el('td', {}, [autoBox]),
428
+ ]));
429
+ }
430
+ table.append(body);
431
+ panel.append(table);
432
+ // 不给向导加第 5 步(四步承诺,01 §2.5):启动目录是 per-host 的事后配置项
433
+ panel.append(el('p.wizard-note', {
434
+ text: '各主机的启动目录(远端工作区根)可稍后在主机详情中配置,默认为远端家目录。',
435
+ }));
436
+ }
437
+
438
+ function buildConfigNow() {
439
+ const hosts = store.listHosts();
440
+ const probeResults = Object.fromEntries(hosts.map((h) => [h.name, { phase: h.phase }]));
441
+ return buildConfigFromAnswers(
442
+ ensureAnswers(),
443
+ hosts.map((host) => ({ name: host.name, local: host.local === true })),
444
+ probeResults,
445
+ { hostDefaults: { localPort: null, remoteWebPort: null, workdir: null } },
446
+ { selection: ui.selection },
447
+ );
448
+ }
449
+
450
+ function renderPreview() {
451
+ if (!ui.previewDirty) ui.previewText = previewJson(buildConfigNow());
452
+ const box = field('完整配置(可直接编辑)', input('textarea', ui.previewText, {
453
+ rows: '18',
454
+ spellcheck: 'false',
455
+ 'aria-label': '配置 JSON 预览',
456
+ on: {
457
+ input: (ev) => {
458
+ ui.previewDirty = true;
459
+ ui.previewText = ev.target.value;
460
+ const res = parsePreview(ui.previewText);
461
+ ui.previewError = res.ok ? null : res;
462
+ box.setError(res.ok ? null : `${res.error}${res.line ? `(第 ${res.line} 行)` : ''}`);
463
+ syncFoot();
464
+ },
465
+ },
466
+ }), { hint: '内容即提交体;手改后以这里为准' });
467
+ if (ui.previewError) box.setError(`${ui.previewError.error}${ui.previewError.line ? `(第 ${ui.previewError.line} 行)` : ''}`);
468
+ panel.append(box.root);
469
+
470
+ const waiting = pendingHosts(store.listHosts(), ui.selection);
471
+ if (waiting.length > 0) {
472
+ panel.append(el('p.wizard-warn', {
473
+ text: `仍有 ${waiting.length} 台探测未完成:将按当前纳管选择保存,开启链接一律关闭。`,
474
+ }));
475
+ }
476
+ }
477
+
478
+ function renderMigration() {
479
+ const { target, stopped } = ui.migration;
480
+ notice.hidden = true;
481
+ clear(panel).append(
482
+ el('p.wizard-progress', {
483
+ role: 'status',
484
+ text: stopped
485
+ ? '等待新端口超时。manager 可能仍在重启,可重试或手动打开新地址。'
486
+ : `配置已保存。manager 正在切到新端口,正在等待 ${target} 就绪…`,
487
+ }),
488
+ el('p.mono.migration-target', { text: `${target}/` }),
489
+ );
490
+ clear(foot).append(
491
+ button('重试', { compact: false, variant: 'primary', disabled: !stopped, onClick: () => pollMigration(target) }),
492
+ button('复制新地址', { compact: false, onClick: () => copyText(`${target}/`) }),
493
+ );
494
+ clear(stepper);
495
+ }
496
+
497
+ function syncFoot() {
498
+ clear(foot);
499
+ const step = SETUP_STEPS[ui.step];
500
+ const errors = stepErrors(ensureAnswers(), step);
501
+ const rawBad = (step.fields ?? []).some((f) => ui.rawErrors[f.key]);
502
+ const blocked = Object.keys(errors).length > 0 || rawBad;
503
+ const last = ui.step === SETUP_STEPS.length - 1;
504
+
505
+ if (ui.step > 0) {
506
+ foot.append(button('上一步', {
507
+ compact: false,
508
+ disabled: ui.submitting,
509
+ onClick: () => goBack(),
510
+ }));
511
+ }
512
+ if (!last) {
513
+ foot.append(button('下一步', {
514
+ compact: false,
515
+ variant: 'primary',
516
+ disabled: blocked,
517
+ onClick: () => goNext(),
518
+ }));
519
+ } else {
520
+ foot.append(button(ui.submitting ? '提交中…' : '完成并保存', {
521
+ compact: false,
522
+ variant: 'primary',
523
+ disabled: ui.submitting || Boolean(ui.previewError),
524
+ onClick: () => submit(),
525
+ }));
526
+ }
527
+ // 首次 setup 没有「退出到管理台」的口子(10 §5.2);重新配置才给取消
528
+ if (store.state.manager.setupCompleted === true) {
529
+ foot.append(el('a.link', { href: '#/manage', text: '取消,返回管理台' }));
530
+ }
531
+ }
532
+
533
+ function render() {
534
+ if (ui.migration) {
535
+ renderMigration();
536
+ return;
537
+ }
538
+ renderStepper();
539
+ const had = panel.contains(document.activeElement) ? document.activeElement : null;
540
+ const hadLabel = had?.getAttribute?.('aria-label') ?? null;
541
+ const hadKey = had?.getAttribute?.('data-key') ?? null;
542
+ const hadTitle = had?.classList?.contains('step-title') === true;
543
+ clear(panel);
544
+ const step = SETUP_STEPS[ui.step];
545
+ const title = el('h2.step-title', { text: `${ui.step + 1}. ${step.title}`, tabindex: '-1' });
546
+ panel.append(title);
547
+ if (step.fields) renderFields(step);
548
+ else if (step.kind === 'host-select') renderHostStep();
549
+ else renderPreview();
550
+ renderDraftNotice();
551
+ syncFoot();
552
+
553
+ // 换步要把焦点带过去。整块面板是重建的,按下「下一步」的那个按钮随即被移除,
554
+ // 焦点于是落回 body——键盘用户每一步都得从文档顶部重新 Tab 起。焦点给标题
555
+ // (而不是第一个输入框):读屏先念出「第几步·做什么」,再 Tab 就是第一个字段。
556
+ // 只在真的换步时动,别在字段校验、主机探测这些重渲染里抢焦点。
557
+ if (focusedStep !== ui.step) {
558
+ focusedStep = ui.step;
559
+ restoreFocus(title);
560
+ } else if (had && document.activeElement !== had) {
561
+ // 同一步里的重渲染也会吃掉焦点:进第 3 步后候选异步到达要再渲一次,
562
+ // 每台主机探测完也各渲一次。能认出原来那个控件(勾选框都有 aria-label)
563
+ // 就还给它,认不出就退回标题——总之别让焦点掉回文档顶端。
564
+ let same = hadTitle ? title : null;
565
+ if (!same && hadLabel) {
566
+ same = [...panel.querySelectorAll('[aria-label]')]
567
+ .find((n) => n.getAttribute('aria-label') === hadLabel);
568
+ }
569
+ if (!same && hadKey) {
570
+ same = [...panel.querySelectorAll('input')]
571
+ .find((n) => n.getAttribute('data-key') === hadKey);
572
+ }
573
+ restoreFocus(same && !same.disabled ? same : title);
574
+ }
575
+ }
576
+
577
+ function goNext() {
578
+ const step = SETUP_STEPS[ui.step];
579
+ if (Object.keys(stepErrors(ensureAnswers(), step)).length > 0) return;
580
+ if ((step.fields ?? []).some((f) => ui.rawErrors[f.key])) return;
581
+ ui.step = Math.min(ui.step + 1, SETUP_STEPS.length - 1);
582
+ if (SETUP_STEPS[ui.step].kind === 'host-select') discover();
583
+ render();
584
+ }
585
+
586
+ /** 从第 4 步回退:先把手改的 JSON 回灌结构化草稿,解析失败则不许退。 */
587
+ function goBack() {
588
+ if (SETUP_STEPS[ui.step].kind === 'preview' && ui.previewDirty) {
589
+ const res = parsePreview(ui.previewText);
590
+ if (!res.ok) {
591
+ ui.previewError = res;
592
+ render();
593
+ actions.reportError(new Error(res.error), 'JSON 无法解析,先修好再返回');
594
+ return;
595
+ }
596
+ const answers = ensureAnswers();
597
+ setByPath(answers, 'manager.port', res.config.manager.port);
598
+ setByPath(answers, 'defaults.remoteWebPort', res.config.defaults.remoteWebPort);
599
+ setByPath(answers, 'defaults.localPortRange', [...res.config.defaults.localPortRange]);
600
+ for (const f of answerFields) {
601
+ ui.ownedFields.add(f.key);
602
+ ui.fieldDrafts[f.key] = shownFieldValue(f, getByPath(answers, f.key));
603
+ ui.conflicts.delete(f.key);
604
+ delete ui.rawErrors[f.key];
605
+ }
606
+ for (const [name, hostCfg] of Object.entries(res.config.hosts ?? {})) {
607
+ ui.selection[name] = {
608
+ enabled: Boolean(hostCfg.enabled),
609
+ autoStart: Boolean(hostCfg.autoStart),
610
+ touchedAutoStart: true,
611
+ };
612
+ }
613
+ ui.previewDirty = false;
614
+ ui.previewError = null;
615
+ }
616
+ ui.step = Math.max(ui.step - 1, 0);
617
+ render();
618
+ }
619
+
620
+ async function submit() {
621
+ const parsed = ui.previewDirty ? parsePreview(ui.previewText) : { ok: true, config: buildConfigNow() };
622
+ if (!parsed.ok) {
623
+ ui.previewError = parsed;
624
+ render();
625
+ return;
626
+ }
627
+
628
+ const waiting = pendingHosts(store.listHosts(), ui.selection);
629
+ if (waiting.length > 0) {
630
+ const ok = await confirm({
631
+ title: '仍有主机未完成探测',
632
+ lines: [
633
+ `仍有 ${waiting.length} 台探测未完成:${waiting.join('、')}。`,
634
+ '它们将按当前纳管选择保存,开启链接保持关闭。',
635
+ ],
636
+ confirmLabel: '仍然保存',
637
+ danger: false,
638
+ });
639
+ if (!ok) return;
640
+ }
641
+
642
+ ui.frozen = parsed.config; // 冻结快照:之后到达的探测结果不得改提交内容
643
+ ui.submitting = true;
644
+ store.emit('setup:changed', { submitting: true });
645
+ render();
646
+
647
+ try {
648
+ const res = await api.setup(ui.frozen);
649
+ const target = res.portChanged ? migrationTarget(res.port, win.location.origin) : null;
650
+ if (target) {
651
+ ui.migration = { target, stopped: false };
652
+ store.emit('setup:changed', { migrating: true, target });
653
+ render();
654
+ pollMigration(target);
655
+ return;
656
+ }
657
+ store.addToast({ level: 'success', summary: '配置已保存', timeoutMs: 4_000 });
658
+ if (res.restartRequired) {
659
+ store.addToast({ level: 'warning', summary: '前台模式下需在终端重启 manager 才能生效' });
660
+ }
661
+ // 端口没变:拿最新 info 撤掉 setup 守卫,再回管理台
662
+ try {
663
+ store.setManagerInfo(await api.managerInfo());
664
+ } catch { /* 守卫会在下次刷新时纠正 */ }
665
+ actions.navigate('#/hub');
666
+ } catch (err) {
667
+ actions.reportError(err, '保存配置失败');
668
+ ui.frozen = null;
669
+ } finally {
670
+ ui.submitting = false;
671
+ ui.previewDirty = false;
672
+ store.emit('setup:changed', { submitting: false });
673
+ if (!ui.migration) render();
674
+ }
675
+ }
676
+
677
+ /** 只探测目标 origin,绝不重复 POST /api/setup(10 §7 场景 19)。 */
678
+ async function pollMigration(target) {
679
+ ui.migration = { target, stopped: false };
680
+ render();
681
+ const startedAt = performance.now(); // 单调钟:预算是「过了多久」(#104)
682
+ for (let i = 0; performance.now() - startedAt < MIGRATION_BUDGET_MS; i += 1) {
683
+ // eslint-disable-next-line no-await-in-loop -- 递增间隔的顺序探测
684
+ await sleep(MIGRATION_DELAYS[Math.min(i, MIGRATION_DELAYS.length - 1)]);
685
+ // eslint-disable-next-line no-await-in-loop -- 同上
686
+ const info = await api.probeOrigin(target);
687
+ if (info?.setupCompleted === true) {
688
+ win.location.replace(`${target}/#/hub`);
689
+ return;
690
+ }
691
+ }
692
+ ui.migration = { target, stopped: true };
693
+ render();
694
+ }
695
+
696
+ const offHosts = store.on('hosts:changed', onHostsChanged);
697
+ const offReset = store.on('hosts:reset', onHostsChanged);
698
+ const offManager = store.on('manager:changed', () => {
699
+ if (!root.hidden && !ui.migration) syncFoot();
700
+ });
701
+ const offManagerConfig = store.on('manager-config:changed', () => {
702
+ if (!root.hidden && !ui.migration) render();
703
+ });
704
+
705
+ render();
706
+
707
+ return {
708
+ root,
709
+ /** 路由切到 #/setup 时调用:进入即渲染,重新配置时预填现值。 */
710
+ open() {
711
+ root.hidden = false;
712
+ render();
713
+ },
714
+ close() {
715
+ root.hidden = true;
716
+ },
717
+ destroy() {
718
+ offHosts();
719
+ offReset();
720
+ offManager();
721
+ offManagerConfig();
722
+ },
723
+ // 测试用视图
724
+ _ui: ui,
725
+ };
726
+ }