@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,210 @@
1
+ /**
2
+ * 展示层纯函数 + 极简 DOM 助手(10 §1.2 状态语义表在此落地)。
3
+ * 纯函数部分不触 DOM,可被 node:test 直接 import(14 §4)。
4
+ */
5
+
6
+ import { allowedHostActions, isManagedHost } from './host-rules.js';
7
+
8
+ /** phase → 文案与样式(颜色之外必须有文本,10 §1.2)。 */
9
+ export const PHASE_META = Object.freeze({
10
+ running: { label: '运行中', tone: 'running', dot: 'solid' },
11
+ degraded: { label: '隧道重连中', tone: 'degraded', dot: 'pulse' },
12
+ crashed: { label: '进程异常退出', tone: 'crashed', dot: 'solid' },
13
+ ready: { label: '可拉起', tone: 'ready', dot: 'hollow' },
14
+ starting: { label: '启动中', tone: 'starting', dot: 'blink' },
15
+ no_dsh: { label: '未安装/未配置', tone: 'neutral', dot: 'none' },
16
+ unreachable: { label: 'SSH 不可达', tone: 'neutral', dot: 'none' },
17
+ unknown: { label: '等待探测', tone: 'neutral', dot: 'none' },
18
+ });
19
+
20
+ export function phaseMeta(phase) {
21
+ return PHASE_META[phase] ?? { label: phase ?? '—', tone: 'neutral', dot: 'none' };
22
+ }
23
+
24
+ const NO_DSH_REASON = { 'missing-bin': '远端未安装 dsh', 'no-web-profile': 'dsh 缺 web profile' };
25
+
26
+ /** 状态徽章下方的一行补充说明(缺失原因、挂起原因、orphaned…)。 */
27
+ export function phaseHint(host) {
28
+ if (!host) return '';
29
+ if (host.phase === 'no_dsh') return NO_DSH_REASON[host.probe?.noDshReason] ?? '';
30
+ if (host.phase === 'unreachable') return host.probe?.errorSummary ?? '';
31
+ if (host.tunnel?.suspendedReason === 'forward-disabled') return '远端禁止端口转发,已暂停重连';
32
+ if (host.tunnel?.suspendedReason === 'local-port-busy') return '本机端口被占,已暂停重连';
33
+ if (host.phase === 'degraded' && host.tunnel) return `重连尝试 ${host.tunnel.reconnectAttempt}`;
34
+ return '';
35
+ }
36
+
37
+ export const DASH = '—';
38
+
39
+ export function text(value) {
40
+ if (value === null || value === undefined || value === '') return DASH;
41
+ return String(value);
42
+ }
43
+
44
+ export function fmtDuration(ms) {
45
+ if (!Number.isFinite(ms) || ms < 0) return DASH;
46
+ const s = Math.floor(ms / 1000);
47
+ const d = Math.floor(s / 86_400);
48
+ const h = Math.floor((s % 86_400) / 3600);
49
+ const m = Math.floor((s % 3600) / 60);
50
+ if (d > 0) return `${d}天 ${h}小时`;
51
+ if (h > 0) return `${h}小时 ${m}分`;
52
+ if (m > 0) return `${m}分 ${s % 60}秒`;
53
+ return `${s}秒`;
54
+ }
55
+
56
+ /** 事件面板与探测时间用;只显示时钟部分,日期靠 title 补齐。 */
57
+ export function fmtClock(iso) {
58
+ if (!iso) return DASH;
59
+ const d = new Date(iso);
60
+ if (Number.isNaN(d.getTime())) return DASH;
61
+ return d.toLocaleTimeString('zh-CN', { hour12: false });
62
+ }
63
+
64
+ export function fmtAgo(iso, now = Date.now()) {
65
+ if (!iso) return DASH;
66
+ const t = Date.parse(iso);
67
+ if (Number.isNaN(t)) return DASH;
68
+ return `${fmtDuration(now - t)}前`;
69
+ }
70
+
71
+ /** dsh 列文案:路径与版本都可能缺,不从主机名推断。 */
72
+ export function dshSummary(host) {
73
+ const probe = host?.probe;
74
+ if (!probe) return { line1: DASH, line2: '' };
75
+ return {
76
+ line1: text(probe.version),
77
+ line2: probe.dshPath ? probe.dshPath : (NO_DSH_REASON[probe.noDshReason] ?? ''),
78
+ };
79
+ }
80
+
81
+ /** 映射列:只用后端下发的 mappedUrl / tunnel.localPort,不猜端口。 */
82
+ export function mappingSummary(host) {
83
+ if (host?.mappedUrl) return { line1: `127.0.0.1:${host.tunnel.localPort}`, line2: `→ 远端 ${host.web?.port ?? host.effectiveRemotePort}`, url: host.mappedUrl };
84
+ const reserved = host?.config?.localPort;
85
+ return { line1: reserved ? `127.0.0.1:${reserved}(未连接)` : DASH, line2: '', url: null };
86
+ }
87
+
88
+ /** 手动实例(非受管)必须带锁:禁 stop/restart(README 不误杀契约)。 */
89
+ export function isManaged(host) {
90
+ return isManagedHost(host);
91
+ }
92
+
93
+ /**
94
+ * 行内可用动作(10 §3.2)。connection/pending 的禁用在组件里叠加。
95
+ * @returns {string[]}
96
+ */
97
+ export function rowActions(host) {
98
+ return [...allowedHostActions(host)];
99
+ }
100
+
101
+ export const ACTION_LABEL = Object.freeze({
102
+ start: '拉起',
103
+ stop: '关停',
104
+ restart: '重启',
105
+ reconnect: '重连',
106
+ probe: '探测',
107
+ open: '打开',
108
+ });
109
+
110
+ // ── DOM 助手(调用时才碰 document,import 本身在 node 下无副作用) ────────
111
+
112
+ /**
113
+ * @param {string} tag 形如 'div.card.is-open' 或 'span'
114
+ * @param {object} [props] className/textContent/dataset/attrs/on
115
+ * @param {(Node|string|null)[]} [children]
116
+ */
117
+ export function el(tag, props = {}, children = []) {
118
+ const [name, ...classes] = tag.split('.');
119
+ const node = document.createElement(name || 'div');
120
+ if (classes.length > 0) node.className = classes.join(' ');
121
+ for (const [key, value] of Object.entries(props)) {
122
+ if (value === null || value === undefined) continue;
123
+ if (key === 'class') node.className = node.className ? `${node.className} ${value}` : value;
124
+ else if (key === 'text') node.textContent = String(value);
125
+ else if (key === 'html') throw new Error('禁止 innerHTML:一切动态文本走 textContent');
126
+ else if (key === 'dataset') Object.assign(node.dataset, value);
127
+ else if (key === 'on') for (const [evt, fn] of Object.entries(value)) node.addEventListener(evt, fn);
128
+ else if (key === 'disabled' || key === 'hidden' || key === 'checked') node[key] = Boolean(value);
129
+ else node.setAttribute(key, String(value));
130
+ }
131
+ for (const child of children) {
132
+ if (child === null || child === undefined) continue;
133
+ node.append(child instanceof Node ? child : document.createTextNode(String(child)));
134
+ }
135
+ return node;
136
+ }
137
+
138
+ export function clear(node) {
139
+ while (node.firstChild) node.firstChild.remove();
140
+ return node;
141
+ }
142
+
143
+ /**
144
+ * 把同一拍内的多次重绘合成一次(issue #106)。
145
+ *
146
+ * 事件面板原先直挂 `events:changed`,每来一条就 clear + 整表重建。实测后端 15ms
147
+ * 发出的 1500 条事件在页面上是**一个 2278ms 的长任务**——那 2.3 秒里点不动、
148
+ * 打不进字。一拍里来 100 条,用户能看到的只有最后那一版,中间 99 次纯属白烧。
149
+ *
150
+ * @param {() => void} fn
151
+ * @param {(cb: () => void) => void} [schedule] 注入点:单测要确定的时机
152
+ */
153
+ export function coalesce(fn, schedule = nextFrame) {
154
+ let scheduled = false;
155
+ return () => {
156
+ if (scheduled) return;
157
+ scheduled = true;
158
+ schedule(() => {
159
+ scheduled = false;
160
+ fn();
161
+ });
162
+ };
163
+ }
164
+
165
+ /** 帧边界;没有 rAF 的环境(如 DOM 垫片)退化成一个短定时器。 */
166
+ function nextFrame(cb) {
167
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(cb);
168
+ else setTimeout(cb, 16);
169
+ }
170
+
171
+ /** 状态圆点 + 文本(颜色不单独承载语义);兼容 phase 字符串与已解析的展示 meta。 */
172
+ export function phaseBadge(phaseOrMeta) {
173
+ const meta = typeof phaseOrMeta === 'object' && phaseOrMeta !== null
174
+ ? phaseOrMeta
175
+ : phaseMeta(phaseOrMeta);
176
+ return el('span.phase-badge', { dataset: { tone: meta.tone } }, [
177
+ el('span.status-dot', { dataset: { dot: meta.dot } }),
178
+ el('span', { text: meta.label }),
179
+ ]);
180
+ }
181
+
182
+ export function button(label, { onClick, variant = 'default', disabled = false, title = null, compact = true } = {}) {
183
+ return el(`button.btn${compact ? '.btn-compact' : ''}`, {
184
+ type: 'button',
185
+ class: `btn-${variant}`,
186
+ text: label,
187
+ title,
188
+ disabled,
189
+ on: onClick ? { click: onClick } : undefined,
190
+ });
191
+ }
192
+
193
+ /** 复制到剪贴板(无 clipboard 权限时退回临时 textarea)。 */
194
+ export async function copyText(value) {
195
+ try {
196
+ await navigator.clipboard.writeText(value);
197
+ return true;
198
+ } catch {
199
+ const ta = document.createElement('textarea');
200
+ ta.value = value;
201
+ ta.setAttribute('readonly', '');
202
+ ta.style.position = 'fixed';
203
+ ta.style.opacity = '0';
204
+ document.body.append(ta);
205
+ ta.select();
206
+ const ok = document.execCommand?.('copy') ?? false;
207
+ ta.remove();
208
+ return ok;
209
+ }
210
+ }