@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.
- package/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +174 -0
- package/package.json +48 -0
- package/scripts/install.mjs +208 -0
- package/src/api.js +725 -0
- package/src/cli.js +1445 -0
- package/src/config-sync.js +157 -0
- package/src/daemon.js +362 -0
- package/src/defaults.js +89 -0
- package/src/dsh-workspace.js +467 -0
- package/src/launcher.js +627 -0
- package/src/lib/bundle.js +82 -0
- package/src/lib/bus.js +109 -0
- package/src/lib/capture.js +53 -0
- package/src/lib/clock.js +18 -0
- package/src/lib/entry.js +27 -0
- package/src/lib/errors.js +88 -0
- package/src/lib/logfile.js +65 -0
- package/src/lib/machine.js +63 -0
- package/src/lib/origin-guard.js +64 -0
- package/src/lib/pool.js +88 -0
- package/src/lib/proto.js +457 -0
- package/src/lib/semver.js +103 -0
- package/src/lib/shq.js +112 -0
- package/src/lib/ssh.js +647 -0
- package/src/lib/validate.js +363 -0
- package/src/monitor.js +145 -0
- package/src/patchsync.js +310 -0
- package/src/ports.js +93 -0
- package/src/prober.js +185 -0
- package/src/server.js +449 -0
- package/src/settings-file.js +550 -0
- package/src/ssh-config.js +152 -0
- package/src/store.js +772 -0
- package/src/tunnel.js +589 -0
- package/src/updater.js +450 -0
- package/src/web/actions.js +409 -0
- package/src/web/api.js +262 -0
- package/src/web/app.js +347 -0
- package/src/web/components/config-sync-dialog.js +469 -0
- package/src/web/components/confirm-dialog.js +61 -0
- package/src/web/components/defaults-card.js +216 -0
- package/src/web/components/event-panel.js +98 -0
- package/src/web/components/host-drawer.js +1039 -0
- package/src/web/components/host-table.js +317 -0
- package/src/web/components/hub.js +143 -0
- package/src/web/components/iframe-pane.js +377 -0
- package/src/web/components/manager-card.js +65 -0
- package/src/web/components/setup-wizard.js +726 -0
- package/src/web/components/tabbar.js +577 -0
- package/src/web/components/toast-region.js +107 -0
- package/src/web/favicon.svg +7 -0
- package/src/web/form.js +220 -0
- package/src/web/host-presentation.js +73 -0
- package/src/web/host-rules.js +76 -0
- package/src/web/index.html +17 -0
- package/src/web/router.js +118 -0
- package/src/web/setup-schema.js +203 -0
- package/src/web/sse.js +118 -0
- package/src/web/store.js +405 -0
- package/src/web/style.css +813 -0
- package/src/web/utils.js +210 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首启引导的「问题定义」——CLI 向导与页面向导共用的唯一源(11 §6.3 / ENG-17)。
|
|
3
|
+
*
|
|
4
|
+
* 硬约束:纯数据 + 纯函数,不引 DOM、不引 node API,也不 import 项目里任何带副作用的模块。
|
|
5
|
+
* 页面用 <script type=module> 直接 import;cli.js 以文件路径 import。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const PORT_MIN = 1;
|
|
9
|
+
export const BINDABLE_PORT_MIN = 1024;
|
|
10
|
+
export const PORT_MAX = 65_535;
|
|
11
|
+
|
|
12
|
+
export function parseIntStrict(raw) {
|
|
13
|
+
const s = String(raw ?? '').trim();
|
|
14
|
+
if (!/^\d+$/.test(s)) return { ok: false, error: '请输入整数' };
|
|
15
|
+
return { ok: true, value: Number(s) };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function vPort(n) {
|
|
19
|
+
if (!Number.isInteger(n) || n < PORT_MIN || n > PORT_MAX) return `端口须为 ${PORT_MIN}–${PORT_MAX} 的整数`;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `17701-17799`、`17701 17799`、`17701,17799` 都收。 */
|
|
24
|
+
export function parseRange(raw) {
|
|
25
|
+
const parts = String(raw ?? '').split(/[\s,\-–~]+/).map((s) => s.trim()).filter(Boolean);
|
|
26
|
+
if (parts.length !== 2) return { ok: false, error: '请输入两个端口,如 17701-17799' };
|
|
27
|
+
const from = parseIntStrict(parts[0]);
|
|
28
|
+
const to = parseIntStrict(parts[1]);
|
|
29
|
+
if (!from.ok) return from;
|
|
30
|
+
if (!to.ok) return to;
|
|
31
|
+
return { ok: true, value: [from.value, to.value] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function vBindableRange(range) {
|
|
35
|
+
if (!Array.isArray(range) || range.length !== 2) return '区间需要起点与终点两个值';
|
|
36
|
+
for (const p of range) {
|
|
37
|
+
if (!Number.isInteger(p) || p < BINDABLE_PORT_MIN || p > PORT_MAX) {
|
|
38
|
+
return `端口须为 ${BINDABLE_PORT_MIN}–${PORT_MAX} 的整数`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (range[1] < range[0]) return '区间终点必须 ≥ 起点';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 兼容既有页面向导消费者;区间只用于 defaults.localPortRange,语义即本机可绑定范围。
|
|
46
|
+
export const vRange = vBindableRange;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 四步定义,与 01 §2.5 一一对应。
|
|
50
|
+
* `def(current)` 取预填值:current 是「现有 config 或出厂默认」的同形对象。
|
|
51
|
+
*/
|
|
52
|
+
export const SETUP_STEPS = Object.freeze([
|
|
53
|
+
{
|
|
54
|
+
id: 'manager',
|
|
55
|
+
title: '本机服务',
|
|
56
|
+
fields: [
|
|
57
|
+
{
|
|
58
|
+
key: 'manager.port',
|
|
59
|
+
label: 'manager 端口',
|
|
60
|
+
hint: '管理台与 API 的本机监听端口',
|
|
61
|
+
def: (c) => c.manager.port,
|
|
62
|
+
parse: parseIntStrict,
|
|
63
|
+
validate: vPort,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'defaults.localPortRange',
|
|
67
|
+
label: '本机映射端口区间',
|
|
68
|
+
hint: '每台远端主机从这个区间里分一个本机端口,如 17701-17799',
|
|
69
|
+
def: (c) => c.defaults.localPortRange,
|
|
70
|
+
parse: parseRange,
|
|
71
|
+
validate: vBindableRange,
|
|
72
|
+
format: (v) => `${v[0]}-${v[1]}`,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'remote',
|
|
78
|
+
title: '远端约定',
|
|
79
|
+
fields: [
|
|
80
|
+
{
|
|
81
|
+
key: 'defaults.remoteWebPort',
|
|
82
|
+
label: '远端 dsh web 端口',
|
|
83
|
+
hint: '统一约定值;个别主机可事后单独覆写',
|
|
84
|
+
def: (c) => c.defaults.remoteWebPort,
|
|
85
|
+
parse: parseIntStrict,
|
|
86
|
+
validate: vPort,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
{ id: 'hosts', title: '主机纳管与开启', kind: 'host-select' },
|
|
91
|
+
{ id: 'confirm', title: '确认', kind: 'preview' },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
/** 按 `a.b` 点路径取值,两侧向导共用同一套 key 字符串。 */
|
|
95
|
+
export function getByPath(obj, keyPath) {
|
|
96
|
+
return keyPath.split('.').reduce((acc, k) => (acc === null || acc === undefined ? acc : acc[k]), obj);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setByPath(obj, keyPath, value) {
|
|
100
|
+
const keys = keyPath.split('.');
|
|
101
|
+
const last = keys.pop();
|
|
102
|
+
let cur = obj;
|
|
103
|
+
for (const k of keys) {
|
|
104
|
+
cur[k] ??= {};
|
|
105
|
+
cur = cur[k];
|
|
106
|
+
}
|
|
107
|
+
cur[last] = value;
|
|
108
|
+
return obj;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 收集所有字段的预填答案(一路回车即得此结果)。 */
|
|
112
|
+
export function defaultAnswers(current) {
|
|
113
|
+
const answers = {};
|
|
114
|
+
for (const step of SETUP_STEPS) {
|
|
115
|
+
for (const f of step.fields ?? []) setByPath(answers, f.key, f.def(current));
|
|
116
|
+
}
|
|
117
|
+
return answers;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 逐字段校验答案;返回按 key 的错误表(空表 = 全通过)。
|
|
122
|
+
* 两侧向导共用,保证「同源同题同判定」。
|
|
123
|
+
*/
|
|
124
|
+
export function validateAnswers(answers) {
|
|
125
|
+
const errors = {};
|
|
126
|
+
for (const step of SETUP_STEPS) {
|
|
127
|
+
for (const f of step.fields ?? []) {
|
|
128
|
+
const bad = f.validate(getByPath(answers, f.key));
|
|
129
|
+
if (bad) errors[f.key] = bad;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return errors;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 主机勾选规则(01 §2.5 第 3 步 / UI-23):只有探测为 ready 才能开启链接;
|
|
137
|
+
* 探测未完成的行可以纳管,但 autoStart 一律 false。
|
|
138
|
+
* @param {{phase?:string}|null} probe
|
|
139
|
+
*/
|
|
140
|
+
export function canAutoStart(probe) {
|
|
141
|
+
return probe?.phase === 'ready';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 候选入口兼容旧 string[];Node/页面只需注入名字与运输类型,纯模块不猜本机身份。
|
|
146
|
+
* @param {Array<string|{name:string,local?:boolean}>} candidates
|
|
147
|
+
* @returns {{name:string,local:boolean}[]}
|
|
148
|
+
*/
|
|
149
|
+
export function normalizeHostCandidates(candidates = []) {
|
|
150
|
+
return candidates.map((candidate) => (
|
|
151
|
+
typeof candidate === 'string'
|
|
152
|
+
? { name: candidate, local: false }
|
|
153
|
+
: { name: candidate.name, local: candidate.local === true }
|
|
154
|
+
));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* answers + 主机候选 + 探测结果 → 完整 config(setupCompleted 由落盘侧强制置 true)。
|
|
159
|
+
*
|
|
160
|
+
* @param {object} answers 形如 { manager:{port}, defaults:{remoteWebPort, localPortRange} }
|
|
161
|
+
* @param {Array<string|{name:string,local?:boolean}>} candidates 主机候选;string 视为远端
|
|
162
|
+
* @param {Record<string, {phase?:string}>} probeResults 主机名 → 探测结果(可缺)
|
|
163
|
+
* @param {object} factoryDefaults 出厂默认(提供 hostDefaults 形状)
|
|
164
|
+
* @param {{selection?:Record<string,{enabled?:boolean, autoStart?:boolean}>}} [opts]
|
|
165
|
+
*/
|
|
166
|
+
export function buildConfigFromAnswers(answers, candidates, probeResults, factoryDefaults, opts = {}) {
|
|
167
|
+
const selection = opts.selection ?? {};
|
|
168
|
+
const hostDefaults = factoryDefaults.hostDefaults;
|
|
169
|
+
|
|
170
|
+
const hosts = {};
|
|
171
|
+
for (const candidate of normalizeHostCandidates(candidates)) {
|
|
172
|
+
const { name, local } = candidate;
|
|
173
|
+
const pick = selection[name] ?? {};
|
|
174
|
+
const enabled = pick.enabled ?? true;
|
|
175
|
+
// 未探测/非 ready 的主机永远不自启:避免开机就撞一串失败
|
|
176
|
+
const autoStart = Boolean(enabled && pick.autoStart && canAutoStart(probeResults?.[name]));
|
|
177
|
+
hosts[name] = {
|
|
178
|
+
local,
|
|
179
|
+
enabled,
|
|
180
|
+
autoStart,
|
|
181
|
+
localPort: local ? null : hostDefaults.localPort,
|
|
182
|
+
remoteWebPort: hostDefaults.remoteWebPort,
|
|
183
|
+
workdir: hostDefaults.workdir ?? null,
|
|
184
|
+
inject: { env: {}, extraArgs: [], patches: [] },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
configVersion: 1,
|
|
190
|
+
setupCompleted: true,
|
|
191
|
+
manager: { port: getByPath(answers, 'manager.port') },
|
|
192
|
+
defaults: {
|
|
193
|
+
remoteWebPort: getByPath(answers, 'defaults.remoteWebPort'),
|
|
194
|
+
localPortRange: [...getByPath(answers, 'defaults.localPortRange')],
|
|
195
|
+
},
|
|
196
|
+
hosts,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** 第 4 步预览:2 空格缩进的完整 config JSON。 */
|
|
201
|
+
export function previewJson(config) {
|
|
202
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
203
|
+
}
|
package/src/web/sse.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 唯一 EventSource 客户端(10 §6 / UI-05、UI-06)。
|
|
3
|
+
*
|
|
4
|
+
* 后端首帧就是 snapshot(13 §3.2),所以不需要 300ms 静默窗口去抖:
|
|
5
|
+
* 收到 snapshot 即视为全量同步完成,resyncing 清零。
|
|
6
|
+
* 不自建重连循环——浏览器原生重连已足够;只在 pageshow / bfcache 恢复时重建。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const FRAME_TYPES = ['snapshot', 'host-changed', 'log-line', 'operation-done', 'config-changed'];
|
|
10
|
+
|
|
11
|
+
export function createSseClient({ store, url = '/api/events', onSnapshot = null, factory = null }) {
|
|
12
|
+
const make = factory ?? ((u) => new EventSource(u));
|
|
13
|
+
let es = null;
|
|
14
|
+
let closed = false;
|
|
15
|
+
|
|
16
|
+
const parse = (raw) => {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(raw);
|
|
19
|
+
} catch {
|
|
20
|
+
store.addToast({ level: 'warn', summary: 'SSE 帧解析失败(已忽略该帧)', detail: String(raw).slice(0, 400) });
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const handlers = {
|
|
26
|
+
snapshot(frame) {
|
|
27
|
+
store.applySnapshot(frame);
|
|
28
|
+
onSnapshot?.(frame);
|
|
29
|
+
},
|
|
30
|
+
'host-changed': (frame) => store.applyHostChanged(frame),
|
|
31
|
+
'log-line': (frame) => store.appendEvent(frame),
|
|
32
|
+
'operation-done': (frame) => {
|
|
33
|
+
const settled = store.settleByOperation(frame.operationId);
|
|
34
|
+
if (frame.status === 'failed') {
|
|
35
|
+
store.addToast({
|
|
36
|
+
level: 'error',
|
|
37
|
+
summary: `${frame.host ?? 'manager'} ${frame.action} 失败:${frame.error ?? '未知原因'}`,
|
|
38
|
+
detail: frame.detail ?? null,
|
|
39
|
+
});
|
|
40
|
+
} else if (settled) {
|
|
41
|
+
store.addToast({ level: 'success', summary: `${frame.host ?? 'manager'} ${frame.action} 完成` });
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
'config-changed': (frame) => store.applyConfigChanged(frame),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function connect() {
|
|
48
|
+
if (closed || es) return;
|
|
49
|
+
store.setConnection({ sse: store.state.connection.everOpened ? 'reconnecting' : 'connecting' });
|
|
50
|
+
es = make(url);
|
|
51
|
+
|
|
52
|
+
es.addEventListener('open', () => {
|
|
53
|
+
// 断线恢复也走 snapshot:横幅在 snapshot 到达后才撤,避免露出旧数据
|
|
54
|
+
store.setConnection({ sse: 'open', resyncing: store.state.connection.everOpened });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
es.addEventListener('error', () => {
|
|
58
|
+
// readyState CLOSED 时浏览器已放弃;否则它会自己退避重连
|
|
59
|
+
const dead = es?.readyState === 2;
|
|
60
|
+
store.setConnection({ sse: dead ? 'offline' : 'reconnecting' });
|
|
61
|
+
if (dead) {
|
|
62
|
+
es?.close();
|
|
63
|
+
es = null;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
for (const type of FRAME_TYPES) {
|
|
68
|
+
es.addEventListener(type, (ev) => {
|
|
69
|
+
const frame = parse(ev.data);
|
|
70
|
+
if (!frame) return;
|
|
71
|
+
store.setConnection({ lastEventAt: Date.now() });
|
|
72
|
+
handlers[type](frame);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function close() {
|
|
78
|
+
closed = true;
|
|
79
|
+
es?.close();
|
|
80
|
+
es = null;
|
|
81
|
+
store.setConnection({ sse: 'offline' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** bfcache 返回时 EventSource 可能已死:只在确实断了才重建,避免多连接。 */
|
|
85
|
+
function revive() {
|
|
86
|
+
closed = false;
|
|
87
|
+
if (es && es.readyState !== 2) return;
|
|
88
|
+
es?.close();
|
|
89
|
+
es = null;
|
|
90
|
+
connect();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function attachLifecycle(win = window) {
|
|
94
|
+
const onHide = () => {
|
|
95
|
+
es?.close();
|
|
96
|
+
es = null;
|
|
97
|
+
store.setConnection({ sse: 'offline' });
|
|
98
|
+
};
|
|
99
|
+
win.addEventListener('pagehide', onHide);
|
|
100
|
+
win.addEventListener('pageshow', revive);
|
|
101
|
+
return () => {
|
|
102
|
+
win.removeEventListener('pagehide', onHide);
|
|
103
|
+
win.removeEventListener('pageshow', revive);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { connect, close, revive, attachLifecycle, get raw() { return es; } };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 横幅文案(纯函数,便于单测覆盖 10 §3.11 的四种情形)。 */
|
|
111
|
+
export function bannerText(connection, { managerRestarting = false } = {}) {
|
|
112
|
+
if (managerRestarting) return 'manager 正在重启,稍后自动重连…';
|
|
113
|
+
const { sse, everOpened, resyncing } = connection;
|
|
114
|
+
if (sse === 'open') return resyncing ? '已重新连上 manager,正在同步状态…' : null;
|
|
115
|
+
if (!everOpened) return sse === 'offline' ? '无法连接 manager,请确认服务已启动' : '正在连接 manager…';
|
|
116
|
+
if (sse === 'offline') return '与 manager 失联且已停止重连;请检查 manager 进程后刷新页面';
|
|
117
|
+
return '与 manager 失联,正在重连;写操作已暂停。';
|
|
118
|
+
}
|