@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,405 @@
1
+ /**
2
+ * 单一 store 与事件总线(10 §4)。
3
+ *
4
+ * 纪律:本文件不触 DOM、不发请求——只有纯数据变更 + 订阅通知,因此可被 node:test
5
+ * 直接 import(14 §4 的 DOM-free 约定)。组件只能经这里导出的 mutation 改状态。
6
+ */
7
+
8
+ export const EVENT_BUFFER_LIMIT = 50;
9
+ export const TOAST_LIMIT = 4;
10
+
11
+ /** 各动作的默认超时(10 §4.5):只解除 loading,绝不擅改 phase。 */
12
+ export const ACTION_TIMEOUT_MS = Object.freeze({
13
+ start: 30_000,
14
+ restart: 30_000,
15
+ stop: 20_000,
16
+ reconnect: 20_000,
17
+ probe: 20_000,
18
+ 'probe-all': 45_000,
19
+ 'manager:restart': 20_000,
20
+ 'defaults:save': 20_000,
21
+ 'config:save': 20_000,
22
+ });
23
+
24
+ export function pendingKey(action, host = null) {
25
+ return host ? `host:${host}:${action}` : action;
26
+ }
27
+
28
+ function initialState() {
29
+ return {
30
+ manager: { info: null, configuredPort: null, setupCompleted: null },
31
+ defaults: null,
32
+ hosts: new Map(),
33
+ // 「主机集合已经从后端到过一次」——用来区分「这台不存在」与「还没同步」。
34
+ // 空 Map 本身区分不了这两者,而它们该有的反应完全相反(见 tabbar 的回管理台判据)。
35
+ hostsLoaded: false,
36
+ revision: -1,
37
+ events: [],
38
+ connection: { sse: 'idle', everOpened: false, openedAt: null, lastEventAt: null, resyncing: false },
39
+ route: { kind: 'dashboard', host: null, raw: '#/' },
40
+ pending: new Map(),
41
+ drawer: { open: false, host: null, dirty: false },
42
+ toasts: [],
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @param {object} [preset] 测试用初始片段
48
+ */
49
+ export function createStore(preset = {}) {
50
+ const state = { ...initialState(), ...preset };
51
+ /** @type {Map<string, Set<Function>>} */
52
+ const listeners = new Map();
53
+ // REST / SSE 跨连接会乱序:hosts/config 各自判旧;state.revision 只保留对外最大值。
54
+ const domainRevisions = { hosts: -1, config: -1 };
55
+ // 初始 config REST 还需按字段补缺,旧 snapshot 可能没有 configuredPort。
56
+ const configRevisions = { defaults: -1, manager: -1 };
57
+ const configEpochs = { defaults: 0, manager: 0 };
58
+ let hostsResetEpoch = 0;
59
+ let seq = 0;
60
+
61
+ const on = (type, fn) => {
62
+ const set = listeners.get(type) ?? new Set();
63
+ set.add(fn);
64
+ listeners.set(type, set);
65
+ return () => set.delete(fn);
66
+ };
67
+
68
+ const emit = (type, detail) => {
69
+ for (const fn of [...(listeners.get(type) ?? [])]) fn(detail, state);
70
+ for (const fn of [...(listeners.get('*') ?? [])]) fn(type, detail, state);
71
+ };
72
+
73
+ // ── manager / defaults ────────────────────────────────────────────────
74
+
75
+ const setManagerInfo = (info) => {
76
+ state.manager.info = info;
77
+ state.manager.setupCompleted = info?.setupCompleted ?? null;
78
+ emit('manager:changed', info);
79
+ };
80
+
81
+ const setManagerConfig = (manager, revision = null) => {
82
+ state.manager.configuredPort = manager?.port ?? null;
83
+ configEpochs.manager += 1;
84
+ if (Number.isInteger(revision)) configRevisions.manager = revision;
85
+ emit('manager-config:changed', state.manager.configuredPort);
86
+ };
87
+
88
+ const setDefaults = (defaults, revision = null) => {
89
+ state.defaults = defaults ?? null;
90
+ configEpochs.defaults += 1;
91
+ if (Number.isInteger(revision)) configRevisions.defaults = revision;
92
+ emit('defaults:changed', state.defaults);
93
+ };
94
+
95
+ /**
96
+ * 初始 GET /api/config 的兜底路径:只合并请求发出后未变化的配置字段。
97
+ * revision 保留 SSE 来源真相;epoch 同时覆盖本地 REST 写入,不能拿本地写入伪造 revision。
98
+ */
99
+ const captureConfigRevisions = () => ({
100
+ defaults: { revision: configRevisions.defaults, epoch: configEpochs.defaults },
101
+ manager: { revision: configRevisions.manager, epoch: configEpochs.manager },
102
+ });
103
+
104
+ const mergeFetchedConfig = (config, requestRevisions) => {
105
+ if (config?.defaults !== undefined
106
+ && configRevisions.defaults <= requestRevisions.defaults.revision
107
+ && configEpochs.defaults === requestRevisions.defaults.epoch) {
108
+ setDefaults(config.defaults);
109
+ }
110
+ if (config?.manager !== undefined
111
+ && configRevisions.manager <= requestRevisions.manager.revision
112
+ && configEpochs.manager === requestRevisions.manager.epoch) {
113
+ setManagerConfig(config.manager);
114
+ }
115
+ };
116
+
117
+ // ── hosts ─────────────────────────────────────────────────────────────
118
+
119
+ const captureHostMergeGuard = () => ({
120
+ startedAt: performance.now(),
121
+ resetEpoch: hostsResetEpoch,
122
+ });
123
+
124
+ /**
125
+ * GET /api/hosts 的兜底路径:不覆盖发出请求后到达的更新(10 §4.4 第 3 点)。
126
+ * @param {number} requestStartedAt 必须是 `performance.now()` 的值——这里要跟
127
+ * `__receivedAt` 比先后,两边得是同一把尺,且不能被墙钟跳变搅乱(issue #104)
128
+ */
129
+ const mergeFetchedHosts = (hosts, revision, requestStartedAt) => {
130
+ for (const host of hosts) {
131
+ const known = state.hosts.get(host.name);
132
+ if (known && known.__receivedAt > requestStartedAt) continue;
133
+ state.hosts.set(host.name, stamp(host));
134
+ }
135
+ if (Number.isInteger(revision)) {
136
+ domainRevisions.hosts = Math.max(domainRevisions.hosts, revision);
137
+ state.revision = Math.max(state.revision, revision);
138
+ }
139
+ state.hostsLoaded = true;
140
+ emit('hosts:reset', [...state.hosts.keys()]);
141
+ };
142
+
143
+ /**
144
+ * 写操作响应里的 HostView 是 SSE 丢失时的兜底:请求后若到过完整 snapshot,则整批
145
+ * 响应作废;否则逐主机保护更晚 SSE。响应没有 revision,故绝不推进 domain revision。
146
+ */
147
+ const mergeActionHosts = (hosts, requestGuard) => {
148
+ const resetAfterRequest = hostsResetEpoch !== requestGuard.resetEpoch;
149
+ if (resetAfterRequest) return;
150
+ for (const host of hosts) {
151
+ const known = state.hosts.get(host.name);
152
+ if (known && known.__receivedAt > requestGuard.startedAt) continue;
153
+ state.hosts.set(host.name, stamp(host));
154
+ emit('hosts:changed', host.name);
155
+ settleByPhase(host);
156
+ }
157
+ };
158
+
159
+ /** snapshot 帧:整体替换(可安全删除已消失的主机,13 §3.1)。 */
160
+ const applySnapshot = (frame) => {
161
+ state.revision = frame.revision;
162
+ domainRevisions.hosts = frame.revision;
163
+ domainRevisions.config = frame.revision;
164
+ hostsResetEpoch += 1;
165
+ state.hosts = new Map(frame.hosts.map((h) => [h.name, stamp(h)]));
166
+ const hasManager = frame.manager != null;
167
+ const hasConfiguredPort = Object.hasOwn(frame, 'configuredPort');
168
+ // 先同时落状态再通知:订阅任一 manager 事件的组件都不能看到 runtime 已新、
169
+ // configuredPort 仍旧的半帧状态。旧后端没有 configuredPort 时保留已知值。
170
+ if (hasManager) {
171
+ state.manager.info = frame.manager;
172
+ state.manager.setupCompleted = frame.manager.setupCompleted ?? null;
173
+ }
174
+ if (hasConfiguredPort) {
175
+ state.manager.configuredPort = frame.configuredPort ?? null;
176
+ configEpochs.manager += 1;
177
+ configRevisions.manager = frame.revision;
178
+ }
179
+ if (hasManager) emit('manager:changed', frame.manager);
180
+ if (hasConfiguredPort) emit('manager-config:changed', state.manager.configuredPort);
181
+ if (frame.defaults !== undefined) setDefaults(frame.defaults, frame.revision);
182
+ if (Array.isArray(frame.logs)) {
183
+ state.events = frame.logs.slice(-EVENT_BUFFER_LIMIT).map(toEvent);
184
+ emit('events:changed', state.events);
185
+ }
186
+ state.connection.resyncing = false;
187
+ state.hostsLoaded = true;
188
+ // 重连后按快照结算在飞的写操作:它们的 operation-done 帧是在页面失联期间发出的,
189
+ // 永远不会再来。不结算的话,那一行的按钮要一直禁到动作超时,末了还多弹一条假的
190
+ // 超时提示——而快照里那台主机明明已经在运行了。
191
+ for (const host of state.hosts.values()) settleByPhase(host);
192
+ emit('hosts:reset', [...state.hosts.keys()]);
193
+ emit('connection:changed', state.connection);
194
+ };
195
+
196
+ /** REST 响应回传的 HostView(PUT config 等):不参与 revision 排序,直接落地。 */
197
+ const upsertHost = (host) => {
198
+ state.hosts.set(host.name, stamp(host));
199
+ emit('hosts:changed', host.name);
200
+ settleByPhase(host);
201
+ };
202
+
203
+ /** host-changed 帧:旧 revision 丢弃(13 §3.1 的前端规则)。 */
204
+ const applyHostChanged = (frame) => {
205
+ if (frame.revision <= domainRevisions.hosts) return false;
206
+ domainRevisions.hosts = frame.revision;
207
+ state.revision = Math.max(state.revision, frame.revision);
208
+ state.hosts.set(frame.host.name, stamp(frame.host));
209
+ emit('hosts:changed', frame.host.name);
210
+ settleByPhase(frame.host);
211
+ return true;
212
+ };
213
+
214
+ const applyConfigChanged = (frame) => {
215
+ if (frame.revision <= domainRevisions.config) return false;
216
+ domainRevisions.config = frame.revision;
217
+ state.revision = Math.max(state.revision, frame.revision);
218
+ if (frame.defaults !== undefined) setDefaults(frame.defaults, frame.revision);
219
+ if (frame.manager !== undefined) setManagerConfig(frame.manager, frame.revision);
220
+ return true;
221
+ };
222
+
223
+ // ── 事件流(环形缓冲 50) ─────────────────────────────────────────────
224
+
225
+ const appendEvent = (entry) => {
226
+ state.events.push(toEvent(entry, (seq += 1)));
227
+ if (state.events.length > EVENT_BUFFER_LIMIT) state.events.shift();
228
+ emit('events:changed', state.events);
229
+ };
230
+
231
+ const clearEvents = () => {
232
+ state.events = [];
233
+ emit('events:changed', state.events);
234
+ };
235
+
236
+ // ── 连接态 ───────────────────────────────────────────────────────────
237
+
238
+ const setConnection = (patch) => {
239
+ Object.assign(state.connection, patch);
240
+ if (patch.sse === 'open') {
241
+ state.connection.everOpened = true;
242
+ state.connection.openedAt = Date.now();
243
+ }
244
+ emit('connection:changed', state.connection);
245
+ };
246
+
247
+ /**
248
+ * 写操作是否可用(10 §3.2)。禁写只针对「曾连上又断了」——首屏还在建连时不该
249
+ * 把按钮全灰,否则页面刚打开的一瞬什么都点不了。重连后必须等全量 snapshot
250
+ * 清掉 resyncing,旧页面状态尚未校准时不能提前放开写操作。
251
+ */
252
+ const canWrite = () => {
253
+ const { sse, everOpened, resyncing } = state.connection;
254
+ if (sse === 'open') return !resyncing;
255
+ return !everOpened && sse !== 'offline';
256
+ };
257
+
258
+ // ── 路由 / 抽屉 ──────────────────────────────────────────────────────
259
+
260
+ const setRoute = (route) => {
261
+ state.route = route;
262
+ emit('route:changed', route);
263
+ };
264
+
265
+ const setDrawer = (patch) => {
266
+ Object.assign(state.drawer, patch);
267
+ emit('drawer:changed', state.drawer);
268
+ };
269
+
270
+ // ── pending(202 + operationId 结算,13 §3.4) ────────────────────────
271
+
272
+ const beginPending = ({ action, host = null }) => {
273
+ const key = pendingKey(action, host);
274
+ const entry = {
275
+ key, host, action, operationId: null, status: 'requesting', acceptedAt: Date.now(), timeoutId: null,
276
+ };
277
+ state.pending.set(key, entry);
278
+ emit('pending:changed', key);
279
+ return entry;
280
+ };
281
+
282
+ const acceptPending = (key, operationId, onTimeout = null) => {
283
+ const entry = state.pending.get(key);
284
+ if (!entry) return null;
285
+ entry.status = 'accepted';
286
+ entry.operationId = operationId ?? null;
287
+ if (onTimeout) {
288
+ const ms = ACTION_TIMEOUT_MS[entry.action] ?? 30_000;
289
+ entry.timeoutId = setTimeout(() => {
290
+ entry.status = 'timed-out';
291
+ emit('pending:changed', key);
292
+ onTimeout(entry);
293
+ state.pending.delete(key);
294
+ emit('pending:changed', key);
295
+ }, ms);
296
+ // 浏览器里 setTimeout 返回 number(无 unref);node 单测里 unref 防止 30s 空等
297
+ entry.timeoutId?.unref?.();
298
+ }
299
+ emit('pending:changed', key);
300
+ return entry;
301
+ };
302
+
303
+ const settlePending = (key) => {
304
+ const entry = state.pending.get(key);
305
+ if (!entry) return null;
306
+ if (entry.timeoutId) clearTimeout(entry.timeoutId);
307
+ state.pending.delete(key);
308
+ emit('pending:changed', key);
309
+ return entry;
310
+ };
311
+
312
+ /** operation-done 帧的精确结算(替代 phase 启发式)。 */
313
+ const settleByOperation = (operationId) => {
314
+ for (const [key, entry] of state.pending) {
315
+ if (entry.operationId === operationId) return settlePending(key);
316
+ }
317
+ return null;
318
+ };
319
+
320
+ /** 兜底:动作已到终态而 operation-done 迟到/丢失时也让按钮解锁。 */
321
+ function settleByPhase(host) {
322
+ const terminal = {
323
+ start: ['running'], restart: ['running'], stop: ['ready'], reconnect: ['running'],
324
+ };
325
+ for (const [key, entry] of state.pending) {
326
+ if (entry.host !== host.name) continue;
327
+ if ((terminal[entry.action] ?? []).includes(host.phase)) settlePending(key);
328
+ }
329
+ }
330
+
331
+ const isPending = (action, host = null) => state.pending.has(pendingKey(action, host));
332
+
333
+ const hostBusy = (name) => {
334
+ for (const entry of state.pending.values()) if (entry.host === name) return true;
335
+ return false;
336
+ };
337
+
338
+ // ── toast ────────────────────────────────────────────────────────────
339
+
340
+ const addToast = ({ level = 'info', summary, detail = null, timeoutMs = null }) => {
341
+ const last = state.toasts.at(-1);
342
+ if (last && last.summary === summary && last.level === level) {
343
+ last.count += 1;
344
+ emit('toasts:changed', state.toasts);
345
+ return last;
346
+ }
347
+ const toast = { id: (seq += 1), level, summary, detail, count: 1, at: Date.now(), timeoutMs };
348
+ state.toasts.push(toast);
349
+ while (state.toasts.length > TOAST_LIMIT) state.toasts.shift();
350
+ emit('toasts:changed', state.toasts);
351
+ return toast;
352
+ };
353
+
354
+ const dismissToast = (id) => {
355
+ state.toasts = state.toasts.filter((t) => t.id !== id);
356
+ emit('toasts:changed', state.toasts);
357
+ };
358
+
359
+ return {
360
+ state,
361
+ on,
362
+ emit,
363
+ setManagerInfo,
364
+ setManagerConfig,
365
+ setDefaults,
366
+ captureConfigRevisions,
367
+ mergeFetchedConfig,
368
+ captureHostMergeGuard,
369
+ mergeFetchedHosts,
370
+ mergeActionHosts,
371
+ applySnapshot,
372
+ upsertHost,
373
+ applyHostChanged,
374
+ applyConfigChanged,
375
+ appendEvent,
376
+ clearEvents,
377
+ setConnection,
378
+ canWrite,
379
+ setRoute,
380
+ setDrawer,
381
+ beginPending,
382
+ acceptPending,
383
+ settlePending,
384
+ settleByOperation,
385
+ isPending,
386
+ hostBusy,
387
+ addToast,
388
+ dismissToast,
389
+ getHost: (name) => state.hosts.get(name) ?? null,
390
+ listHosts: () => [...state.hosts.values()].sort((a, b) => a.name.localeCompare(b.name)),
391
+ };
392
+ }
393
+
394
+ /** 记录本地接收时刻:GET 兜底与 SSE 竞速时用它判新旧(10 §4.4)。 */
395
+ function stamp(host) {
396
+ return { ...host, __receivedAt: performance.now() }; // 单调钟:只与 requestStartedAt 比先后(#104)
397
+ }
398
+
399
+ let eventSeq = 0;
400
+
401
+ function toEvent(entry, id = (eventSeq += 1)) {
402
+ return {
403
+ id, host: entry.host ?? null, level: entry.level ?? 'info', msg: entry.msg ?? '', ts: entry.ts ?? new Date().toISOString(), detail: entry.detail ?? null,
404
+ };
405
+ }