@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
package/src/web/app.js ADDED
@@ -0,0 +1,347 @@
1
+ /**
2
+ * 应用外壳与启动装配(10 §3.1 / UI-01、UI-04、UI-06)。
3
+ *
4
+ * 首屏顺序:GET /api/manager/info 决定 setup 守卫 → 正常模式并行 GET /api/hosts
5
+ * 与建 SSE;GET 结果不覆盖其请求发出后已收到的 SSE(10 §4.4)。
6
+ */
7
+
8
+ import { api } from './api.js';
9
+ import { createActions } from './actions.js';
10
+ import { createStore } from './store.js';
11
+ import { bannerText, createSseClient } from './sse.js';
12
+ import {
13
+ applyGuard, canOpenHost, navigate, parseRoute, rememberLastHost, rootRouteTarget,
14
+ } from './router.js';
15
+ import { button, clear, el } from './utils.js';
16
+ import { hostPhaseMeta } from './host-presentation.js';
17
+ import { createConfirmDialog } from './components/confirm-dialog.js';
18
+ import { createConfigSyncDialog } from './components/config-sync-dialog.js';
19
+ import { createDefaultsCard } from './components/defaults-card.js';
20
+ import { createEventPanel } from './components/event-panel.js';
21
+ import { createHostDrawer } from './components/host-drawer.js';
22
+ import { createHostTable } from './components/host-table.js';
23
+ import { createHub } from './components/hub.js';
24
+ import { createIframePanes, hostTabId } from './components/iframe-pane.js';
25
+ import { createManagerCard } from './components/manager-card.js';
26
+ import { createSetupWizard } from './components/setup-wizard.js';
27
+ import { createTabbar, hostFallbackPanelId } from './components/tabbar.js';
28
+ import { createToastRegion } from './components/toast-region.js';
29
+
30
+ const CONNECTION_LABEL = Object.freeze({
31
+ idle: 'manager 连接中',
32
+ connecting: 'manager 连接中',
33
+ open: 'manager 已连接',
34
+ reconnecting: 'manager 重连中',
35
+ offline: 'manager 离线',
36
+ });
37
+
38
+ export function bootApp({ root = document.getElementById('app') } = {}) {
39
+ const store = createStore();
40
+ const dialog = createConfirmDialog();
41
+ const toasts = createToastRegion({ store });
42
+
43
+ const actions = createActions({
44
+ store,
45
+ confirm: dialog.confirm,
46
+ navigate,
47
+ });
48
+
49
+ const banner = el('div.disconnect-banner', { role: 'status', hidden: true });
50
+ const header = el('header.app-header', {}, [
51
+ el('div.brand', {}, [
52
+ el('a.brand-link', { href: '#/hub', text: 'DSH Center' }),
53
+ el('span.brand-sub', { text: '远端 dsh web 统一入口' }),
54
+ ]),
55
+ ]);
56
+
57
+ const dashboard = el('main.view.view-dashboard', { hidden: true });
58
+ const hub = createHub({ store, actions });
59
+ const skeleton = el('main.view.view-skeleton', {}, [el('p.empty-hint', { text: '正在连接 manager…' })]);
60
+ const fallback = el('main.view.view-fallback', {
61
+ hidden: true,
62
+ role: 'tabpanel',
63
+ 'aria-hidden': 'true',
64
+ });
65
+
66
+ // ── header 操作区 ────────────────────────────────────────────────────
67
+ const manageBackBtn = button('返回主页面', {
68
+ compact: false,
69
+ onClick: () => actions.navigate('#/hub'),
70
+ });
71
+ const probeAllBtn = button('全部探测', { variant: 'primary', compact: false, onClick: () => actions.probeAll() });
72
+ const reloadBtn = button('重载配置', { compact: false, onClick: () => actions.reload() });
73
+ let configSyncDialog;
74
+ const configSyncBtn = button('批量同步配置', {
75
+ compact: false,
76
+ onClick: () => configSyncDialog.open(configSyncBtn),
77
+ });
78
+ manageBackBtn.classList.add('manage-back');
79
+ probeAllBtn.classList.add('probe-all');
80
+ reloadBtn.classList.add('reload-config');
81
+ configSyncBtn.classList.add('config-sync-open');
82
+ const connDot = el('span.conn-indicator', {
83
+ role: 'status',
84
+ 'aria-live': 'polite',
85
+ 'aria-label': CONNECTION_LABEL.idle,
86
+ title: CONNECTION_LABEL.idle,
87
+ });
88
+
89
+ // ── 管理台 ───────────────────────────────────────────────────────────
90
+ const hostTable = createHostTable({ store, actions });
91
+ const managerCard = createManagerCard({ store, actions });
92
+ const defaultsCard = createDefaultsCard({ store, actions });
93
+ const eventPanel = createEventPanel({ store });
94
+ configSyncDialog = createConfigSyncDialog({ store, actions });
95
+ dashboard.append(
96
+ el('div.card-header.manage-header', {}, [
97
+ el('h2', { text: '管理' }),
98
+ el('div.row-actions', {}, [manageBackBtn, probeAllBtn, reloadBtn, configSyncBtn]),
99
+ ]),
100
+ hostTable.root,
101
+ el('div.side-by-side', {}, [managerCard.root, defaultsCard.root]),
102
+ eventPanel.root,
103
+ );
104
+
105
+ // ── 标签栏 / iframe / 抽屉 ───────────────────────────────────────────
106
+ const panes = createIframePanes({ store, actions });
107
+ const tabbar = createTabbar({ store, actions, panes, trailing: connDot });
108
+ const shell = el('div.app-shell', {}, [header, tabbar.root]);
109
+ // 抽屉是「有遮罩即模态」(issue #28):它开着时后景整片 inert,键盘就不会 Tab 到
110
+ // 被遮罩压住的按钮上——那是鼠标碰不到、键盘却能碰到的两套规矩。层次归属放在
111
+ // app 这层:只有它知道页面由哪几片组成,抽屉组件不该反过来伸手改兄弟节点。
112
+ const backgroundLayers = [];
113
+ const drawer = createHostDrawer({
114
+ store,
115
+ actions,
116
+ confirm: dialog.confirm,
117
+ setBackgroundInert: (on) => {
118
+ for (const node of backgroundLayers) node.inert = on;
119
+ // toast 仍须留在 aria-live 树里播报抽屉保存错误,只单独封住交互控件。
120
+ toasts.setModalBlocked(on);
121
+ },
122
+ });
123
+
124
+ // ── 首启向导 ─────────────────────────────────────────────────────────
125
+ const wizard = createSetupWizard({ store, actions, confirm: dialog.confirm });
126
+
127
+ // 抽屉开着时要 inert 的就是这一串(遮罩、抽屉本体、确认框、toast 不在其中——
128
+ // 确认框正是抽屉自己弹的「放弃未保存的修改?」,inert 了就点不动;toast 由上面
129
+ // 保留 live 语义、只封交互)。菜单虽通常会先关闭,仍按后景处理,避免竞态漏焦点。
130
+ backgroundLayers.push(
131
+ header,
132
+ tabbar.root,
133
+ banner,
134
+ skeleton,
135
+ hub.root,
136
+ dashboard,
137
+ panes.root,
138
+ wizard.root,
139
+ fallback,
140
+ tabbar.menu,
141
+ tabbar.overflowMenu,
142
+ );
143
+
144
+ clear(root).append(
145
+ shell,
146
+ banner,
147
+ skeleton,
148
+ hub.root,
149
+ dashboard,
150
+ panes.root,
151
+ wizard.root,
152
+ fallback,
153
+ drawer.scrim,
154
+ drawer.root,
155
+ tabbar.menu,
156
+ tabbar.overflowMenu,
157
+ toasts.root,
158
+ configSyncDialog.root,
159
+ dialog.root,
160
+ );
161
+
162
+ // ── 连接横幅 / 写操作禁用 ────────────────────────────────────────────
163
+ const syncConnection = () => {
164
+ const text = bannerText(store.state.connection, {
165
+ managerRestarting: store.isPending('manager:restart'),
166
+ });
167
+ banner.hidden = text === null;
168
+ banner.textContent = text ?? '';
169
+ banner.dataset.tone = store.state.connection.sse === 'open' ? 'resync' : 'offline';
170
+ const sseState = store.state.connection.sse;
171
+ const connectionLabel = CONNECTION_LABEL[sseState] ?? CONNECTION_LABEL.connecting;
172
+ connDot.dataset.state = sseState;
173
+ connDot.setAttribute('aria-label', connectionLabel);
174
+ connDot.setAttribute('title', connectionLabel);
175
+ const writable = store.canWrite();
176
+ probeAllBtn.disabled = !writable || store.isPending('probe-all');
177
+ reloadBtn.disabled = !writable || store.isPending('config:reload');
178
+ configSyncBtn.disabled = !writable || store.listHosts().length < 2 || store.isPending('config:sync');
179
+ };
180
+ store.on('connection:changed', syncConnection);
181
+ store.on('pending:changed', syncConnection);
182
+ store.on('hosts:changed', syncConnection);
183
+ store.on('hosts:reset', syncConnection);
184
+ syncConnection();
185
+
186
+ // ── 路由 ─────────────────────────────────────────────────────────────
187
+ let currentRoute = { kind: 'root', host: null, raw: '#/' };
188
+ let wizardWasOpen = false; // 用来认出「刚从向导出来」这一跳
189
+
190
+ function renderRoute() {
191
+ const guarded = applyGuard(currentRoute, { setupCompleted: store.state.manager.setupCompleted });
192
+ if (guarded.redirectTo && window.location.hash !== guarded.redirectTo) {
193
+ window.location.replace(guarded.redirectTo);
194
+ return;
195
+ }
196
+
197
+ const route = guarded.route;
198
+ const rootWaiting = !guarded.blocked && route.kind === 'root' && !store.state.hostsLoaded;
199
+ if (!guarded.blocked && route.kind === 'root' && !rootWaiting) {
200
+ window.location.replace(rootRouteTarget(store.listHosts()));
201
+ return;
202
+ }
203
+
204
+ const blocked = guarded.blocked || rootWaiting;
205
+ store.setRoute(route);
206
+
207
+ const wantHost = !blocked && route.kind === 'host';
208
+ // 深链到不可开的主机:解析仍成立,但不造无效 iframe(10 §5.2)
209
+ const host = wantHost ? store.getHost(route.host) : null;
210
+ const openable = wantHost && canOpenHost(host);
211
+ // ready 的启动请求已经发出、SSE 还没推进到 starting 时,也要让目标路由先落在
212
+ // 页面占位区。这里只投影视图,不碰 host.phase;pending 一结算便重新按 SSE 真相判定。
213
+ const startPending = wantHost && host?.phase === 'ready' && store.isPending('start', route.host);
214
+ const showHostPane = openable || startPending;
215
+ const showFallback = !blocked && wantHost && !showHostPane;
216
+ if (openable) rememberLastHost(route.host);
217
+
218
+ skeleton.hidden = !blocked;
219
+ hub.root.hidden = blocked || route.kind !== 'hub';
220
+ dashboard.hidden = blocked || route.kind !== 'manage';
221
+ // fallback 与 iframe/启动占位使用不同 ID;切入正常 pane 前先摘掉 fallback 身份,
222
+ // DOM 中任何时刻都不会有两个同名 panel。
223
+ fallback.hidden = !showFallback;
224
+ fallback.setAttribute('aria-hidden', String(!showFallback));
225
+ if (showFallback) {
226
+ fallback.setAttribute('id', hostFallbackPanelId(route.host));
227
+ fallback.setAttribute('aria-labelledby', hostTabId(route.host));
228
+ } else {
229
+ fallback.removeAttribute('id');
230
+ fallback.removeAttribute('aria-labelledby');
231
+ }
232
+ panes.show(showHostPane ? route.host : null);
233
+
234
+ const showWizard = !blocked && route.kind === 'setup';
235
+ const leavingWizard = wizardWasOpen && !showWizard;
236
+ wizardWasOpen = showWizard;
237
+ if (showWizard) wizard.open();
238
+ else wizard.close();
239
+ // 向导期间不显示管理台专属的写操作入口,避免误触未生效的配置
240
+ shell.hidden = blocked || showWizard;
241
+ header.hidden = shell.hidden;
242
+ tabbar.root.hidden = shell.hidden;
243
+
244
+ if (showFallback) {
245
+ let msg;
246
+ if (!host) msg = `主机 ${route.host} 不存在或尚未同步。`;
247
+ else msg = `${route.host} 当前状态「${hostPhaseMeta(host).label}」,还没有可打开的页面。`;
248
+ clear(fallback).append(
249
+ el('p.empty-hint', { text: msg }),
250
+ el('a.link', { href: '#/hub', text: '回到起始页' }),
251
+ );
252
+ }
253
+
254
+ // 向导收尾:整块向导被藏起来,焦点跟着它一起没了(掉回 body),键盘用户刚走完
255
+ // 四步、落到一个陌生页面还得从文档顶端重新 Tab。把焦点交给 hub 标题。
256
+ if (leavingWizard && !hub.root.hidden) {
257
+ const heading = hub.root.querySelector('h2');
258
+ if (heading) {
259
+ heading.focus();
260
+ }
261
+ }
262
+ }
263
+
264
+ const onHashChange = () => {
265
+ currentRoute = parseRoute(window.location.hash);
266
+ renderRoute();
267
+ };
268
+ window.addEventListener('hashchange', onHashChange);
269
+ store.on('manager:changed', renderRoute);
270
+ // 主机状态变化会影响「当前深链能否落地」:ready→running 时把 iframe 显出来
271
+ store.on('hosts:changed', (name) => {
272
+ if (store.state.route.kind === 'host' && store.state.route.host === name) renderRoute();
273
+ });
274
+ store.on('hosts:reset', () => {
275
+ if (store.state.route.kind === 'host' || store.state.route.kind === 'root') renderRoute();
276
+ });
277
+ // ready + start pending 是 host 路由的短暂视图态:开始时显示占位,失败/超时结算后
278
+ // 若 SSE 仍是 ready,就立刻回到准确 fallback。
279
+ store.on('pending:changed', () => {
280
+ if (store.state.route.kind === 'host') renderRoute();
281
+ });
282
+
283
+ // ── SSE ──────────────────────────────────────────────────────────────
284
+ const sse = createSseClient({ store });
285
+ const detachLifecycle = sse.attachLifecycle();
286
+
287
+ // ── 首屏 ─────────────────────────────────────────────────────────────
288
+ async function boot() {
289
+ currentRoute = parseRoute(window.location.hash);
290
+ renderRoute();
291
+ try {
292
+ const info = await api.managerInfo();
293
+ store.setManagerInfo(info);
294
+ } catch (err) {
295
+ actions.reportError(err, '无法获取 manager 信息');
296
+ clear(skeleton).append(
297
+ el('p.empty-hint', { text: '无法连接 manager。确认它已启动(dshc up)后重试。' }),
298
+ button('重试', { variant: 'primary', compact: false, onClick: () => boot() }),
299
+ );
300
+ return;
301
+ }
302
+ renderRoute();
303
+
304
+ // setup 模式(13 §4 白名单):SSE 与 GET /api/config 可用——向导要靠 SSE 收
305
+ // 逐台探测结果、靠 config 预填现值;主机清单等向导走到第 3 步再拉。
306
+ sse.connect();
307
+ if (store.state.manager.setupCompleted === false) {
308
+ const configRequestRevisions = store.captureConfigRevisions();
309
+ try {
310
+ const config = await api.config();
311
+ store.mergeFetchedConfig(config, configRequestRevisions);
312
+ } catch (err) {
313
+ actions.reportError(err, '读取当前配置失败(向导将使用出厂默认)');
314
+ }
315
+ renderRoute();
316
+ return;
317
+ }
318
+
319
+ const startedAt = performance.now(); // 单调钟:与 __receivedAt 比先后,墙钟会跳(#104)
320
+ const configRequestRevisions = store.captureConfigRevisions();
321
+ try {
322
+ const [hosts, config] = await Promise.all([api.hosts(), api.config()]);
323
+ store.mergeFetchedHosts(hosts.hosts, hosts.revision, startedAt);
324
+ store.mergeFetchedConfig(config, configRequestRevisions);
325
+ } catch (err) {
326
+ // SSE 的 snapshot 是主路径,GET 只是兜底:失败降级为提示
327
+ actions.reportError(err, '主机列表首屏加载失败(等待实时同步)');
328
+ }
329
+ }
330
+
331
+ boot();
332
+
333
+ return {
334
+ store,
335
+ actions,
336
+ sse,
337
+ destroy() {
338
+ window.removeEventListener('hashchange', onHashChange);
339
+ detachLifecycle();
340
+ sse.close();
341
+ for (const c of [
342
+ hub, hostTable, managerCard, defaultsCard, eventPanel, tabbar, panes, drawer, wizard, toasts,
343
+ configSyncDialog,
344
+ ]) c.destroy();
345
+ },
346
+ };
347
+ }