@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,377 @@
1
+ /**
2
+ * iframe keep-alive registry 与断联遮罩(10 §3.4 / UI-14、UI-15)。
3
+ *
4
+ * 决策逻辑(paneDecision)是纯函数,单测直接覆盖 reload 语义:
5
+ * degraded 往返不 reload、crashed 恢复只 reload 一次、localPort 变化必须重建。
6
+ */
7
+
8
+ import { isHostActionAllowed } from '../host-rules.js';
9
+ import { ACTION_LABEL, button, el, phaseMeta } from '../utils.js';
10
+
11
+ /** 有 iframe 意义的三态之外都不该留着 pane。 */
12
+ const KEEP_PHASES = new Set(['running', 'degraded', 'crashed', 'starting']);
13
+
14
+ /** tab 与懒建 panel 共用的稳定 ID;编码后不含空白,任意合法主机名都可直接落进 id。 */
15
+ export function hostTabId(name) {
16
+ return `host-tab-${encodeURIComponent(String(name))}`;
17
+ }
18
+
19
+ export function hostPanelId(name) {
20
+ return `host-panel-${encodeURIComponent(String(name))}`;
21
+ }
22
+
23
+ /**
24
+ * @param {{mappedUrl:string|null, localPort:number|null, phase:string, sawCrash:boolean}|null} prev
25
+ * @param {{mappedUrl:string|null, localPort:number|null, phase:string}} next
26
+ * @returns {{kind:'none'|'create'|'recreate'|'reload'|'destroy', reason:string}}
27
+ */
28
+ export function paneDecision(prev, next) {
29
+ if (!KEEP_PHASES.has(next.phase)) {
30
+ // 主动关停到 ready,或主机被禁用/移除:释放会话资源(10 §3.4)
31
+ return prev ? { kind: 'destroy', reason: `phase=${next.phase}` } : { kind: 'none', reason: 'no-pane' };
32
+ }
33
+ if (!next.mappedUrl) {
34
+ // starting / crashed 时隧道可能还没起来:留住已有文档,只挂遮罩
35
+ return prev ? { kind: 'none', reason: 'no-url-keep' } : { kind: 'none', reason: 'no-url-yet' };
36
+ }
37
+ if (!prev) return { kind: 'create', reason: 'first-url' };
38
+
39
+ if (prev.localPort !== null && next.localPort !== prev.localPort) {
40
+ // 旧文档与 WebSocket 都绑在旧 origin 上,只能整只换掉
41
+ return { kind: 'recreate', reason: 'local-port-changed' };
42
+ }
43
+ if (prev.mappedUrl !== next.mappedUrl) return { kind: 'recreate', reason: 'mapped-url-changed' };
44
+
45
+ // crashed → running:远端进程换了新的,页面里的会话已失效,reload 一次
46
+ if (next.phase === 'running' && prev.sawCrash) return { kind: 'reload', reason: 'crash-recovered' };
47
+ // degraded → running:隧道自己回来了,交给 dsh web 的 WebSocket 自愈
48
+ return { kind: 'none', reason: 'keep-alive' };
49
+ }
50
+
51
+ function startingOverlay(host) {
52
+ return {
53
+ title: '正在启动…',
54
+ body: host?.local === true ? '正在本机拉起 dsh web。' : '拉起远端 dsh web 并建立隧道。',
55
+ action: null,
56
+ };
57
+ }
58
+
59
+ /** 遮罩内容(纯函数):null = 不显示遮罩;startPending 只是一层视图投影。 */
60
+ export function overlayFor(host, { startPending = false } = {}) {
61
+ if (!host) return { title: '主机已消失', body: '该主机已不在 manager 的清单中。', action: null };
62
+ if (startPending && host.phase === 'ready') return startingOverlay(host);
63
+ const local = host.local === true;
64
+ switch (host.phase) {
65
+ case 'degraded': {
66
+ const canReconnect = isHostActionAllowed(host, 'reconnect');
67
+ return {
68
+ title: local ? '本机页面连接中断,manager 正在恢复' : '隧道断开,manager 正在重连',
69
+ body: host.tunnel?.suspendedReason
70
+ ? '重连已暂停,需人工处理。'
71
+ : canReconnect
72
+ ? '页面内容仍是上次加载的快照。'
73
+ : '页面内容仍是上次加载的快照;请回管理台手动处理。',
74
+ action: canReconnect ? 'reconnect' : null,
75
+ };
76
+ }
77
+ case 'crashed': {
78
+ const canRestart = isHostActionAllowed(host, 'restart');
79
+ return {
80
+ title: local ? '本机 dsh web 已退出' : '远端 dsh web 已退出',
81
+ body: canRestart ? '重启后页面会自动重新载入。' : '该实例不是本工具拉起的,请回管理台手动处理。',
82
+ action: canRestart ? 'restart' : null,
83
+ };
84
+ }
85
+ case 'starting':
86
+ return startingOverlay(host);
87
+ case 'running':
88
+ return host.mappedUrl
89
+ ? null
90
+ : {
91
+ title: local ? '等待本机页面就绪' : '等待隧道就绪',
92
+ body: local ? '访问地址尚未下发。' : '映射地址尚未下发。',
93
+ action: null,
94
+ };
95
+ default:
96
+ return { title: `当前状态:${phaseMeta(host.phase).label}`, body: '此状态没有可用的页面。', action: null };
97
+ }
98
+ }
99
+
100
+ export function loadingText(host) {
101
+ return host?.local === true ? '正在加载本机页面…' : '正在加载远端页面…';
102
+ }
103
+
104
+ export function createIframePanes({ store, actions }) {
105
+ const root = el('div.iframe-stack');
106
+ // 还没有可用映射地址(starting / crashed 且隧道已关)时,页面区只放遮罩
107
+ const placeholderOverlay = el('div.iframe-overlay', {
108
+ role: 'status',
109
+ 'aria-live': 'polite',
110
+ 'aria-busy': 'false',
111
+ tabindex: '-1',
112
+ });
113
+ const placeholder = el('section.iframe-pane.is-placeholder', {
114
+ hidden: true,
115
+ role: 'tabpanel',
116
+ 'aria-hidden': 'true',
117
+ }, [placeholderOverlay]);
118
+ root.append(placeholder);
119
+ /** @type {Map<string, {section:HTMLElement, frame:HTMLIFrameElement, loading:HTMLElement, overlay:HTMLElement, loaded:boolean, snap:object}>} */
120
+ const panes = new Map();
121
+ let pendingStatusFocus = null;
122
+
123
+ function snapshotOf(host, sawCrash) {
124
+ return {
125
+ mappedUrl: host.mappedUrl ?? null,
126
+ localPort: host.tunnel?.localPort ?? null,
127
+ phase: host.phase,
128
+ sawCrash,
129
+ };
130
+ }
131
+
132
+ function build(host) {
133
+ let pane = null;
134
+ const frame = el('iframe', {
135
+ title: `DSH Web — ${host.name}`,
136
+ referrerpolicy: 'no-referrer',
137
+ src: host.mappedUrl,
138
+ on: {
139
+ load: () => {
140
+ if (!pane) return;
141
+ pane.loaded = true;
142
+ pane.loading.hidden = true;
143
+ pane.loading.setAttribute('aria-busy', 'false');
144
+ },
145
+ },
146
+ });
147
+ const loading = el('div.iframe-loading', {
148
+ role: 'status',
149
+ 'aria-live': 'polite',
150
+ 'aria-busy': 'true',
151
+ tabindex: '-1',
152
+ }, [
153
+ el('p', { text: loadingText(host) }),
154
+ ]);
155
+ const overlay = el('div.iframe-overlay', {
156
+ hidden: true,
157
+ role: 'status',
158
+ 'aria-live': 'polite',
159
+ 'aria-busy': 'false',
160
+ tabindex: '-1',
161
+ });
162
+ const section = el('section.iframe-pane', {
163
+ id: hostPanelId(host.name),
164
+ dataset: { host: host.name },
165
+ hidden: true,
166
+ role: 'tabpanel',
167
+ 'aria-labelledby': hostTabId(host.name),
168
+ 'aria-hidden': 'true',
169
+ }, [frame, loading, overlay]);
170
+ root.append(section);
171
+ // 崩溃态下打开的 pane 要记住「见过 crashed」,恢复时才会 reload 一次
172
+ pane = {
173
+ section,
174
+ frame,
175
+ loading,
176
+ overlay,
177
+ loaded: false,
178
+ snap: snapshotOf(host, host.phase === 'crashed'),
179
+ };
180
+ panes.set(host.name, pane);
181
+ return pane;
182
+ }
183
+
184
+ function destroy(name) {
185
+ panes.get(name)?.section.remove();
186
+ panes.delete(name);
187
+ }
188
+
189
+ function renderOverlay(pane, host) {
190
+ const startPending = host?.phase === 'ready' && store.isPending('start', host.name);
191
+ const spec = overlayFor(host, { startPending });
192
+ if (!spec) {
193
+ pane.overlay.hidden = true;
194
+ pane.overlay.setAttribute('aria-busy', 'false');
195
+ if (pane.loading) {
196
+ pane.loading.hidden = pane.loaded;
197
+ pane.loading.setAttribute('aria-busy', String(!pane.loaded));
198
+ }
199
+ return;
200
+ }
201
+ // 后端 phase 是唯一失败事实源;它的遮罩永远优先于仅表示首载进度的 loading。
202
+ if (pane.loading) pane.loading.hidden = true;
203
+ pane.overlay.hidden = false;
204
+ pane.overlay.setAttribute('aria-busy', String(host?.phase === 'starting' || startPending));
205
+ pane.overlay.replaceChildren(
206
+ el('h2', { text: spec.title }),
207
+ el('p', { text: spec.body }),
208
+ el('div.overlay-actions', {}, [
209
+ spec.action
210
+ ? button(spec.action === 'reconnect' ? '立即重连' : ACTION_LABEL[spec.action], {
211
+ variant: 'primary',
212
+ compact: false,
213
+ disabled: store.hostBusy(host.name) || !store.canWrite(),
214
+ onClick: () => actions.hostAction(spec.action, host.name),
215
+ })
216
+ : null,
217
+ el('a.link', { href: '#/hub', text: '回到起始页' }),
218
+ ]),
219
+ );
220
+ }
221
+
222
+ /**
223
+ * 按当前 host 视图同步某台主机的 pane。
224
+ * @param {string} name
225
+ * @param {{force?:boolean}} [opts] force=true 表示即使没 pane 也要按需创建(用户主动打开)
226
+ */
227
+ function sync(name, { force = false } = {}) {
228
+ const host = store.getHost(name);
229
+ const pane = panes.get(name) ?? null;
230
+ if (!host) {
231
+ destroy(name);
232
+ return null;
233
+ }
234
+
235
+ const next = snapshotOf(host, false);
236
+ const decision = paneDecision(pane?.snap ?? null, next);
237
+
238
+ if (decision.kind === 'destroy') {
239
+ destroy(name);
240
+ return null;
241
+ }
242
+ if (decision.kind === 'create') {
243
+ if (!force && !panes.has(name)) return null; // 后台主机延迟到首次打开才建 iframe(10 §5.3)
244
+ const created = build(host);
245
+ renderOverlay(created, host);
246
+ return created;
247
+ }
248
+ if (!pane) return null;
249
+
250
+ if (decision.kind === 'recreate') {
251
+ destroy(name);
252
+ const rebuilt = build(host);
253
+ renderOverlay(rebuilt, host);
254
+ if (decision.reason === 'local-port-changed') {
255
+ store.addToast({ level: 'warn', summary: `${name} 映射端口已变更,页面已重新载入` });
256
+ }
257
+ return rebuilt;
258
+ }
259
+ if (decision.kind === 'reload') {
260
+ reloadFrame(pane);
261
+ }
262
+
263
+ // sawCrash 只在「见过 crashed 且还没 reload」期间为真,保证只 reload 一次
264
+ pane.snap = {
265
+ ...next,
266
+ sawCrash: host.phase === 'crashed' ? true : (decision.kind === 'reload' ? false : pane.snap.sawCrash),
267
+ };
268
+ renderOverlay(pane, host);
269
+ return pane;
270
+ }
271
+
272
+ function reloadFrame(pane) {
273
+ pane.loaded = false;
274
+ pane.loading.hidden = false;
275
+ pane.loading.setAttribute('aria-busy', 'true');
276
+ try {
277
+ pane.frame.contentWindow.location.reload();
278
+ } catch {
279
+ pane.frame.src = pane.frame.src; // eslint-disable-line no-self-assign -- 跨源时唯一可用的重载手段
280
+ }
281
+ }
282
+
283
+ /** 路由切换只改显隐,绝不动 src(keep-alive 的关键)。 */
284
+ function show(name) {
285
+ const carryPlaceholderFocus = name !== null
286
+ && placeholder.dataset.host === name
287
+ && document.activeElement === placeholderOverlay;
288
+ const routeHost = store.state.route.kind === 'host' ? store.state.route.host : null;
289
+ if (pendingStatusFocus !== null && pendingStatusFocus !== name && pendingStatusFocus !== routeHost) {
290
+ pendingStatusFocus = null;
291
+ }
292
+ if (name && !panes.has(name)) sync(name, { force: true });
293
+ for (const [host, pane] of panes) {
294
+ const active = host === name;
295
+ pane.section.hidden = !active;
296
+ pane.section.setAttribute('aria-hidden', String(!active));
297
+ }
298
+
299
+ // 目标主机还没 iframe(隧道未就绪):用占位遮罩顶上,别把用户丢在空白页
300
+ const bare = name !== null && !panes.has(name);
301
+ placeholder.hidden = !bare;
302
+ placeholder.setAttribute('aria-hidden', String(!bare));
303
+ if (bare) {
304
+ placeholder.dataset.host = name;
305
+ placeholder.setAttribute('id', hostPanelId(name));
306
+ placeholder.setAttribute('aria-labelledby', hostTabId(name));
307
+ renderOverlay({ overlay: placeholderOverlay }, store.getHost(name));
308
+ } else {
309
+ delete placeholder.dataset.host;
310
+ placeholder.removeAttribute('id');
311
+ placeholder.removeAttribute('aria-labelledby');
312
+ }
313
+ root.hidden = name === null;
314
+
315
+ if (name !== null && (pendingStatusFocus === name || carryPlaceholderFocus)) {
316
+ const pane = panes.get(name);
317
+ const status = pane && !pane.section.hidden
318
+ ? (!pane.overlay.hidden ? pane.overlay : (!pane.loading.hidden ? pane.loading : null))
319
+ // ready + start pending 的占位虽已可见,但键盘焦点仍等 SSE starting 真相到达
320
+ // 再转交;这样请求失败时不会把焦点送进随即消失的临时状态区。
321
+ : (bare && store.getHost(name)?.phase !== 'ready' ? placeholderOverlay : null);
322
+ if (status) {
323
+ pendingStatusFocus = null;
324
+ status.focus();
325
+ }
326
+ }
327
+ }
328
+
329
+ const offs = [
330
+ store.on('hosts:changed', (name) => {
331
+ sync(name);
332
+ if (store.state.route.kind === 'host' && store.state.route.host === name) show(name);
333
+ }),
334
+ store.on('hosts:reset', () => {
335
+ for (const name of [...panes.keys()]) sync(name);
336
+ }),
337
+ store.on('pending:changed', () => {
338
+ for (const [name, pane] of panes) {
339
+ const host = store.getHost(name);
340
+ if (host) renderOverlay(pane, host);
341
+ }
342
+ const placeholderHost = placeholder.dataset.host;
343
+ if (placeholderHost) renderOverlay({ overlay: placeholderOverlay }, store.getHost(placeholderHost));
344
+ }),
345
+ store.on('connection:changed', () => {
346
+ for (const [name, pane] of panes) {
347
+ const host = store.getHost(name);
348
+ if (host) renderOverlay(pane, host);
349
+ }
350
+ }),
351
+ ];
352
+
353
+ return {
354
+ root,
355
+ show,
356
+ sync,
357
+ focusStatusWhenAvailable(name) {
358
+ pendingStatusFocus = name;
359
+ const pane = panes.get(name);
360
+ const status = pane && !pane.section.hidden
361
+ ? (!pane.overlay.hidden ? pane.overlay : (!pane.loading.hidden ? pane.loading : null))
362
+ : (!placeholder.hidden && placeholder.dataset.host === name && store.getHost(name)?.phase !== 'ready'
363
+ ? placeholderOverlay
364
+ : null);
365
+ if (status) {
366
+ pendingStatusFocus = null;
367
+ status.focus();
368
+ }
369
+ },
370
+ has: (name) => panes.has(name),
371
+ openedHosts: () => [...panes.keys()],
372
+ destroy() {
373
+ for (const off of offs) off();
374
+ for (const name of [...panes.keys()]) destroy(name);
375
+ },
376
+ };
377
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * manager 状态卡(10 §3.6 / UI-16)。前台模式的「不支持自我重启」不在前端预判,
3
+ * 由后端错误 detail 呈现。
4
+ */
5
+
6
+ import { button, clear, el, fmtDuration, text } from '../utils.js';
7
+
8
+ const MODE_LABEL = { foreground: '前台', background: '后台', launchd: 'launchd 托管' };
9
+
10
+ export function createManagerCard({ store, actions }) {
11
+ const dl = el('dl.kv');
12
+ const modeBadge = el('span.mode-badge', { text: '—' });
13
+ const restartBtn = button('重启 manager', {
14
+ variant: 'danger',
15
+ compact: false,
16
+ onClick: () => actions.restartManager(),
17
+ });
18
+
19
+ const root = el('article.card.manager-card', {}, [
20
+ el('header.card-header', {}, [el('h2', { text: 'Manager' }), modeBadge]),
21
+ dl,
22
+ el('footer.card-footer', {}, [
23
+ restartBtn,
24
+ el('a.link', { href: '#/setup', text: '重新配置' }),
25
+ ]),
26
+ ]);
27
+
28
+ function render() {
29
+ const info = store.state.manager.info;
30
+ clear(dl);
31
+ if (!info) {
32
+ dl.append(el('p.empty-hint', { text: '正在获取 manager 信息…' }));
33
+ return;
34
+ }
35
+ modeBadge.textContent = MODE_LABEL[info.mode] ?? info.mode;
36
+ const counts = info.hostCounts ?? {};
37
+ for (const [k, v] of [
38
+ ['版本', text(info.version)],
39
+ ['PID', text(info.pid)],
40
+ ['监听端口', text(info.port)],
41
+ ['已运行', fmtDuration(info.uptimeMs)],
42
+ ['主机', `${counts.total ?? 0} 台(运行 ${counts.running ?? 0} / 重连 ${counts.degraded ?? 0} / 异常 ${counts.crashed ?? 0})`],
43
+ ]) {
44
+ dl.append(el('dt', { text: k }), el('dd', { text: v }));
45
+ }
46
+
47
+ const pending = store.isPending('manager:restart');
48
+ restartBtn.disabled = pending || !store.canWrite();
49
+ restartBtn.textContent = pending ? '正在重启…' : '重启 manager';
50
+ }
51
+
52
+ const offs = [
53
+ store.on('manager:changed', render),
54
+ store.on('pending:changed', render),
55
+ store.on('connection:changed', render),
56
+ ];
57
+ render();
58
+
59
+ return {
60
+ root,
61
+ destroy() {
62
+ for (const off of offs) off();
63
+ },
64
+ };
65
+ }