@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,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 全局默认卡(10 §3.7 / UI-17)。manager.port 改动只落盘;重启提示始终由
|
|
3
|
+
* configuredPort 与实际监听端口的差异派生(13 §2.6)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { button, el } from '../utils.js';
|
|
7
|
+
import {
|
|
8
|
+
buildDefaultsPatch,
|
|
9
|
+
deepEqual,
|
|
10
|
+
diffPatch,
|
|
11
|
+
field,
|
|
12
|
+
input,
|
|
13
|
+
} from '../form.js';
|
|
14
|
+
import { BINDABLE_PORT_MIN, PORT_MAX, PORT_MIN } from '../setup-schema.js';
|
|
15
|
+
|
|
16
|
+
export function createDefaultsCard({ store, actions }) {
|
|
17
|
+
const remote = field('远端默认 web 端口', input('number', '', { min: PORT_MIN, max: PORT_MAX }));
|
|
18
|
+
const rangeFrom = field('本机端口区间起点', input('number', '', { min: BINDABLE_PORT_MIN, max: PORT_MAX }));
|
|
19
|
+
const rangeTo = field('本机端口区间终点', input('number', '', { min: BINDABLE_PORT_MIN, max: PORT_MAX }));
|
|
20
|
+
const managerPort = field('manager 监听端口', input('number', '', { min: PORT_MIN, max: PORT_MAX }), {
|
|
21
|
+
hint: '改动仅落盘,需重启 manager 生效',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const saveBtn = button('保存', { variant: 'primary', compact: false, onClick: submit });
|
|
25
|
+
const resetBtn = button('还原', { compact: false, onClick: resetDraft });
|
|
26
|
+
const notice = el('p.card-notice', { hidden: true });
|
|
27
|
+
|
|
28
|
+
const root = el('article.card.defaults-card', {}, [
|
|
29
|
+
el('header.card-header', {}, [el('h2', { text: '全局默认' })]),
|
|
30
|
+
el('div.field-grid', {}, [remote.root, managerPort.root, rangeFrom.root, rangeTo.root]),
|
|
31
|
+
notice,
|
|
32
|
+
el('footer.card-footer', {}, [saveBtn, resetBtn]),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const controls = {
|
|
36
|
+
remoteWebPort: remote,
|
|
37
|
+
managerPort,
|
|
38
|
+
rangeFrom,
|
|
39
|
+
rangeTo,
|
|
40
|
+
};
|
|
41
|
+
const draftKeys = Object.keys(controls);
|
|
42
|
+
let baseline = configFromStore();
|
|
43
|
+
const conflicts = new Set();
|
|
44
|
+
|
|
45
|
+
function configFromStore() {
|
|
46
|
+
const defaults = store.state.defaults;
|
|
47
|
+
const info = store.state.manager.info;
|
|
48
|
+
const configuredPort = store.state.manager.configuredPort ?? info?.port ?? null;
|
|
49
|
+
return {
|
|
50
|
+
remoteWebPort: defaults?.remoteWebPort ?? null,
|
|
51
|
+
localPortRange: Array.isArray(defaults?.localPortRange) ? [...defaults.localPortRange] : null,
|
|
52
|
+
manager: { port: configuredPort },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function draftOf(config) {
|
|
57
|
+
return {
|
|
58
|
+
remoteWebPort: config?.remoteWebPort == null ? '' : String(config.remoteWebPort),
|
|
59
|
+
managerPort: config?.manager?.port == null ? '' : String(config.manager.port),
|
|
60
|
+
rangeFrom: config?.localPortRange?.[0] == null ? '' : String(config.localPortRange[0]),
|
|
61
|
+
rangeTo: config?.localPortRange?.[1] == null ? '' : String(config.localPortRange[1]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readDraft() {
|
|
66
|
+
return Object.fromEntries(draftKeys.map((key) => [key, controls[key].input.value]));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function writeDraft(draft, keys = draftKeys) {
|
|
70
|
+
for (const key of keys) controls[key].input.value = draft[key];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clearErrors(keys = draftKeys) {
|
|
74
|
+
for (const key of keys) controls[key].setError(null);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderNotice() {
|
|
78
|
+
const messages = [];
|
|
79
|
+
if (conflicts.size > 0) {
|
|
80
|
+
messages.push('服务端配置已变化;同字段的本地草稿已保留。「还原」可载入最新服务端值。');
|
|
81
|
+
}
|
|
82
|
+
const runtimePort = store.state.manager.info?.port ?? null;
|
|
83
|
+
const configuredPort = store.state.manager.configuredPort;
|
|
84
|
+
if (configuredPort != null && runtimePort != null && configuredPort !== runtimePort) {
|
|
85
|
+
messages.push(`manager 端口已配置为 ${configuredPort},重启 manager 后生效。`);
|
|
86
|
+
}
|
|
87
|
+
notice.textContent = messages.join('\n');
|
|
88
|
+
notice.hidden = messages.length === 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isDirty() {
|
|
92
|
+
return !deepEqual(readDraft(), draftOf(baseline));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function syncDisabled() {
|
|
96
|
+
const pending = store.isPending('defaults:save');
|
|
97
|
+
const dirty = isDirty();
|
|
98
|
+
saveBtn.disabled = !dirty || !store.canWrite() || pending;
|
|
99
|
+
resetBtn.disabled = !dirty || pending;
|
|
100
|
+
for (const f of Object.values(controls)) f.input.disabled = pending;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function syncDraftState() {
|
|
104
|
+
const draft = readDraft();
|
|
105
|
+
const canonical = draftOf(baseline);
|
|
106
|
+
for (const key of conflicts) {
|
|
107
|
+
if (draft[key] === canonical[key]) conflicts.delete(key);
|
|
108
|
+
}
|
|
109
|
+
renderNotice();
|
|
110
|
+
syncDisabled();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resetDraft() {
|
|
114
|
+
if (store.isPending('defaults:save')) return;
|
|
115
|
+
baseline = configFromStore();
|
|
116
|
+
writeDraft(draftOf(baseline));
|
|
117
|
+
clearErrors();
|
|
118
|
+
conflicts.clear();
|
|
119
|
+
syncDraftState();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 服务端更新按字段三方合并:用户没动的字段跟随;双方都动了同一字段时,
|
|
124
|
+
* baseline 仍推进到最新服务端值,但保留 DOM 草稿并提示冲突。
|
|
125
|
+
*/
|
|
126
|
+
function reconcileFromStore() {
|
|
127
|
+
const next = configFromStore();
|
|
128
|
+
const previousDraft = draftOf(baseline);
|
|
129
|
+
const nextDraft = draftOf(next);
|
|
130
|
+
const draft = readDraft();
|
|
131
|
+
for (const key of draftKeys) {
|
|
132
|
+
const localChanged = draft[key] !== previousDraft[key];
|
|
133
|
+
const serverChanged = nextDraft[key] !== previousDraft[key];
|
|
134
|
+
if (!localChanged) {
|
|
135
|
+
controls[key].input.value = nextDraft[key];
|
|
136
|
+
controls[key].setError(null);
|
|
137
|
+
conflicts.delete(key);
|
|
138
|
+
} else if (serverChanged) {
|
|
139
|
+
if (draft[key] === nextDraft[key]) {
|
|
140
|
+
controls[key].input.value = nextDraft[key];
|
|
141
|
+
controls[key].setError(null);
|
|
142
|
+
conflicts.delete(key);
|
|
143
|
+
} else {
|
|
144
|
+
conflicts.add(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
baseline = next;
|
|
149
|
+
syncDraftState();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function onDraftChanged(event) {
|
|
153
|
+
const key = draftKeys.find((candidate) => controls[candidate].input === event.target);
|
|
154
|
+
if (key) {
|
|
155
|
+
if (key === 'rangeFrom' || key === 'rangeTo') rangeFrom.setError(null);
|
|
156
|
+
else controls[key].setError(null);
|
|
157
|
+
}
|
|
158
|
+
syncDraftState();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function submit() {
|
|
162
|
+
if (store.isPending('defaults:save') || !store.canWrite()) return;
|
|
163
|
+
const built = buildDefaultsPatch({
|
|
164
|
+
remoteWebPort: remote.input.value,
|
|
165
|
+
rangeFrom: rangeFrom.input.value,
|
|
166
|
+
rangeTo: rangeTo.input.value,
|
|
167
|
+
managerPort: managerPort.input.value,
|
|
168
|
+
}, { minWidth: Math.max(1, store.state.hosts.size) });
|
|
169
|
+
|
|
170
|
+
remote.setError(built.errors?.remoteWebPort ?? null);
|
|
171
|
+
rangeFrom.setError(built.errors?.localPortRange ?? null);
|
|
172
|
+
rangeTo.setError(null);
|
|
173
|
+
managerPort.setError(built.errors?.managerPort ?? null);
|
|
174
|
+
if (!built.ok) return;
|
|
175
|
+
|
|
176
|
+
const patch = diffPatch(built.value, baseline);
|
|
177
|
+
if (Object.keys(patch).length === 0) {
|
|
178
|
+
writeDraft(draftOf(baseline));
|
|
179
|
+
clearErrors();
|
|
180
|
+
conflicts.clear();
|
|
181
|
+
syncDraftState();
|
|
182
|
+
store.addToast({ level: 'info', summary: '没有需要保存的有效变更' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const res = await actions.saveDefaults(patch);
|
|
187
|
+
if (!res) return;
|
|
188
|
+
baseline = configFromStore();
|
|
189
|
+
writeDraft(draftOf(baseline));
|
|
190
|
+
clearErrors();
|
|
191
|
+
conflicts.clear();
|
|
192
|
+
syncDraftState();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const f of Object.values(controls)) {
|
|
196
|
+
f.input.addEventListener('input', onDraftChanged);
|
|
197
|
+
f.input.addEventListener('change', onDraftChanged);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const offs = [
|
|
201
|
+
store.on('defaults:changed', reconcileFromStore),
|
|
202
|
+
store.on('manager:changed', reconcileFromStore),
|
|
203
|
+
store.on('manager-config:changed', reconcileFromStore),
|
|
204
|
+
store.on('pending:changed', syncDraftState),
|
|
205
|
+
store.on('connection:changed', syncDraftState),
|
|
206
|
+
store.on('hosts:reset', syncDraftState),
|
|
207
|
+
];
|
|
208
|
+
resetDraft();
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
root,
|
|
212
|
+
destroy() {
|
|
213
|
+
for (const off of offs) off();
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件面板(10 §3.8 / UI-18)。环形缓冲 50 条由 store 保证;
|
|
3
|
+
* 面板只做渲染 + 主机过滤 + 折叠。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { clear, coalesce, el, fmtClock } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
const LEVEL_LABEL = { info: '信息', warn: '警告', error: '错误' };
|
|
9
|
+
|
|
10
|
+
export function createEventPanel({ store }) {
|
|
11
|
+
const list = el('ul.event-list', { 'aria-live': 'polite' });
|
|
12
|
+
const filter = el('select.event-filter', {
|
|
13
|
+
'aria-label': '按主机过滤事件',
|
|
14
|
+
on: { change: () => render() },
|
|
15
|
+
});
|
|
16
|
+
const clearBtn = el('button.btn.btn-compact.btn-default', {
|
|
17
|
+
type: 'button', text: '清空', on: { click: () => store.clearEvents() },
|
|
18
|
+
});
|
|
19
|
+
const body = el('div.event-body', {}, [
|
|
20
|
+
el('div.event-toolbar', {}, [filter, clearBtn]),
|
|
21
|
+
list,
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
const root = el('section.card.event-panel', {}, [
|
|
25
|
+
el('header.card-header', {}, [
|
|
26
|
+
el('h2', { text: '事件' }),
|
|
27
|
+
el('button.btn.btn-compact.btn-default.collapse-toggle', {
|
|
28
|
+
type: 'button',
|
|
29
|
+
text: '折叠',
|
|
30
|
+
'aria-expanded': 'true',
|
|
31
|
+
on: {
|
|
32
|
+
click: (e) => {
|
|
33
|
+
const open = body.hidden;
|
|
34
|
+
body.hidden = !open;
|
|
35
|
+
e.target.textContent = open ? '折叠' : '展开';
|
|
36
|
+
e.target.setAttribute('aria-expanded', String(open));
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
]),
|
|
41
|
+
body,
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function renderFilter() {
|
|
45
|
+
const selected = filter.value;
|
|
46
|
+
clear(filter);
|
|
47
|
+
filter.append(el('option', { value: '', text: '全部主机' }));
|
|
48
|
+
for (const name of [...store.state.hosts.keys()].sort()) {
|
|
49
|
+
filter.append(el('option', { value: name, text: name }));
|
|
50
|
+
}
|
|
51
|
+
filter.value = [...filter.options].some((o) => o.value === selected) ? selected : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function render() {
|
|
55
|
+
const want = filter.value;
|
|
56
|
+
clear(list);
|
|
57
|
+
const events = store.state.events.filter((e) => want === '' || e.host === want);
|
|
58
|
+
if (events.length === 0) {
|
|
59
|
+
list.append(el('li.empty-hint', { text: '暂无事件' }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const ev of events.slice().reverse()) {
|
|
63
|
+
list.append(el(`li.event-item.event-${ev.level}`, {}, [
|
|
64
|
+
el('time', { datetime: ev.ts, text: fmtClock(ev.ts), title: ev.ts }),
|
|
65
|
+
el('span.event-level', { text: LEVEL_LABEL[ev.level] ?? ev.level }),
|
|
66
|
+
el('span.event-host', { text: ev.host ?? 'manager' }),
|
|
67
|
+
el('span.event-msg', { text: ev.msg, title: ev.detail ?? ev.msg }),
|
|
68
|
+
]));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rerenderAll = () => {
|
|
73
|
+
renderFilter();
|
|
74
|
+
render();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// 事件是会成串来的(批量拉起、一批隧道同时重连、某台远端在刷警告),故订阅侧一律
|
|
78
|
+
// 合到帧边界再画一次;用户看到的最终画面不变(issue #106)。
|
|
79
|
+
const renderSoon = coalesce(render);
|
|
80
|
+
const rerenderAllSoon = coalesce(rerenderAll);
|
|
81
|
+
|
|
82
|
+
const offs = [
|
|
83
|
+
store.on('events:changed', renderSoon),
|
|
84
|
+
store.on('hosts:reset', rerenderAllSoon),
|
|
85
|
+
// 单条 host-changed 也可能带来一台没见过的主机(ssh config 多出一台、向导收尾
|
|
86
|
+
// 解门禁都走这条)。只认整份快照的话,下拉里就一直缺这台。
|
|
87
|
+
store.on('hosts:changed', rerenderAllSoon),
|
|
88
|
+
];
|
|
89
|
+
renderFilter();
|
|
90
|
+
render();
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
root,
|
|
94
|
+
destroy() {
|
|
95
|
+
for (const off of offs) off();
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|