@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,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 批量配置同步:选择源/目标 → dry-run 预览 → 服务端重新计算并原子应用。
|
|
3
|
+
*
|
|
4
|
+
* UI 只消费 changedFields,绝不读取或展示配置值(尤其是环境变量与 secret)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CONFIG_SYNC_TARGET_LIMIT, HOST_WEB_RESTART_NOTICE } from '../actions.js';
|
|
8
|
+
import { button, clear, el } from '../utils.js';
|
|
9
|
+
|
|
10
|
+
const FIELD_LABEL = Object.freeze({
|
|
11
|
+
remoteWebPort: '远端 web 端口',
|
|
12
|
+
workdir: '工作目录',
|
|
13
|
+
'inject.env': '环境变量',
|
|
14
|
+
'inject.extraArgs': '附加参数',
|
|
15
|
+
'inject.patches': '补丁',
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const RESTART_PHASES = new Set(['running', 'degraded', 'starting']);
|
|
19
|
+
|
|
20
|
+
export function createConfigSyncDialog({ store, actions }) {
|
|
21
|
+
const sourceSelect = el('select.config-sync-source', { id: 'config-sync-source' });
|
|
22
|
+
const targetList = el('div.config-sync-targets');
|
|
23
|
+
const status = el('p.config-sync-status', {
|
|
24
|
+
id: 'config-sync-status',
|
|
25
|
+
role: 'status',
|
|
26
|
+
'aria-live': 'polite',
|
|
27
|
+
'aria-atomic': 'true',
|
|
28
|
+
});
|
|
29
|
+
const targetCount = el('p.config-sync-target-count', {
|
|
30
|
+
id: 'config-sync-target-count',
|
|
31
|
+
});
|
|
32
|
+
const errorSlot = el('div.config-sync-error', {
|
|
33
|
+
id: 'config-sync-error',
|
|
34
|
+
role: 'alert',
|
|
35
|
+
'aria-live': 'assertive',
|
|
36
|
+
'aria-atomic': 'true',
|
|
37
|
+
hidden: true,
|
|
38
|
+
});
|
|
39
|
+
const resultSlot = el('div.config-sync-result-slot');
|
|
40
|
+
|
|
41
|
+
const selectAllBtn = button('全选', { onClick: selectAll });
|
|
42
|
+
const clearBtn = button('清空', { onClick: clearTargets });
|
|
43
|
+
selectAllBtn.classList.add('config-sync-select-all');
|
|
44
|
+
clearBtn.classList.add('config-sync-clear');
|
|
45
|
+
|
|
46
|
+
const closeBtn = button('取消', { compact: false, onClick: close });
|
|
47
|
+
const previewBtn = button('预览变更', { compact: false, onClick: preview });
|
|
48
|
+
const applyBtn = button('应用同步', { variant: 'primary', compact: false, onClick: apply });
|
|
49
|
+
closeBtn.classList.add('config-sync-close');
|
|
50
|
+
previewBtn.classList.add('config-sync-preview');
|
|
51
|
+
applyBtn.classList.add('config-sync-apply');
|
|
52
|
+
|
|
53
|
+
const safety = el('p.config-sync-safety', {
|
|
54
|
+
id: 'config-sync-safety',
|
|
55
|
+
text: '只同步远端 web 端口、工作目录、环境变量、附加参数、补丁;不会修改主机身份、启用/自启或本机映射端口。',
|
|
56
|
+
});
|
|
57
|
+
const dialog = el('dialog.config-sync-dialog', {
|
|
58
|
+
'aria-labelledby': 'config-sync-title',
|
|
59
|
+
'aria-describedby': 'config-sync-safety config-sync-target-count config-sync-status config-sync-error',
|
|
60
|
+
}, [
|
|
61
|
+
el('div.config-sync-inner', {}, [
|
|
62
|
+
el('header.config-sync-head', {}, [
|
|
63
|
+
el('h2', { id: 'config-sync-title', text: '批量同步配置' }),
|
|
64
|
+
el('p', { text: '先预览差异,再把一台主机的启动配置同步到多台目标主机。' }),
|
|
65
|
+
]),
|
|
66
|
+
el('div.config-sync-body', {}, [
|
|
67
|
+
safety,
|
|
68
|
+
el('div.field', {}, [
|
|
69
|
+
el('label', { for: 'config-sync-source', text: '源主机' }),
|
|
70
|
+
sourceSelect,
|
|
71
|
+
]),
|
|
72
|
+
el('fieldset.config-sync-target-fieldset', {}, [
|
|
73
|
+
el('legend', { text: '目标主机(可多选)' }),
|
|
74
|
+
el('div.config-sync-shortcuts', {}, [selectAllBtn, clearBtn]),
|
|
75
|
+
targetCount,
|
|
76
|
+
targetList,
|
|
77
|
+
]),
|
|
78
|
+
status,
|
|
79
|
+
errorSlot,
|
|
80
|
+
resultSlot,
|
|
81
|
+
]),
|
|
82
|
+
el('footer.config-sync-actions', {}, [closeBtn, previewBtn, applyBtn]),
|
|
83
|
+
]),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
let source = '';
|
|
87
|
+
let targets = new Set();
|
|
88
|
+
let previewResult = null;
|
|
89
|
+
let previewSignature = null;
|
|
90
|
+
let previewToken = null;
|
|
91
|
+
let applied = false;
|
|
92
|
+
let generation = 0;
|
|
93
|
+
let requestMode = null;
|
|
94
|
+
let restoreFocus = null;
|
|
95
|
+
let hostNamesKey = '';
|
|
96
|
+
|
|
97
|
+
function hosts() {
|
|
98
|
+
return store.listHosts();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function selectionSignature() {
|
|
102
|
+
return JSON.stringify([source, [...targets]]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function initialStatus() {
|
|
106
|
+
return targets.size > 0
|
|
107
|
+
? '选择已就绪,请先预览变更。'
|
|
108
|
+
: '请选择至少一台目标主机,然后先预览变更。';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function setStatus(message) {
|
|
112
|
+
status.textContent = message;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function removeResults() {
|
|
116
|
+
clear(resultSlot);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function clearError() {
|
|
120
|
+
clear(errorSlot);
|
|
121
|
+
errorSlot.hidden = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function renderError(error, fallback) {
|
|
125
|
+
clear(errorSlot);
|
|
126
|
+
errorSlot.append(el('p.config-sync-error-summary', {
|
|
127
|
+
text: error?.summary || fallback,
|
|
128
|
+
}));
|
|
129
|
+
if (error?.detail) {
|
|
130
|
+
errorSlot.append(el('details.config-sync-error-detail', {}, [
|
|
131
|
+
el('summary', { text: '错误详情' }),
|
|
132
|
+
el('pre', { text: error.detail }),
|
|
133
|
+
]));
|
|
134
|
+
}
|
|
135
|
+
errorSlot.hidden = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function invalidate(message) {
|
|
139
|
+
const hadPreview = previewResult !== null || resultSlot.firstChild !== null || requestMode === 'preview';
|
|
140
|
+
generation += 1;
|
|
141
|
+
previewResult = null;
|
|
142
|
+
previewSignature = null;
|
|
143
|
+
previewToken = null;
|
|
144
|
+
applied = false;
|
|
145
|
+
removeResults();
|
|
146
|
+
clearError();
|
|
147
|
+
setStatus(hadPreview ? message : initialStatus());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function renderSourceOptions(hostList) {
|
|
151
|
+
clear(sourceSelect);
|
|
152
|
+
for (const host of hostList) {
|
|
153
|
+
sourceSelect.append(el('option', { value: host.name, text: host.name }));
|
|
154
|
+
}
|
|
155
|
+
sourceSelect.value = source;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderTargets(hostList) {
|
|
159
|
+
const active = document.activeElement;
|
|
160
|
+
const focusedHost = targetList.contains(active) ? active?.dataset?.host ?? null : null;
|
|
161
|
+
clear(targetList);
|
|
162
|
+
hostList.forEach((host, index) => {
|
|
163
|
+
const id = `config-sync-target-${index}`;
|
|
164
|
+
const input = el('input', {
|
|
165
|
+
id,
|
|
166
|
+
type: 'checkbox',
|
|
167
|
+
dataset: { host: host.name },
|
|
168
|
+
checked: targets.has(host.name),
|
|
169
|
+
disabled: host.name === source,
|
|
170
|
+
});
|
|
171
|
+
input.addEventListener('change', () => {
|
|
172
|
+
if (input.checked && targets.size >= CONFIG_SYNC_TARGET_LIMIT) {
|
|
173
|
+
input.checked = false;
|
|
174
|
+
setStatus(`一次最多选择 ${CONFIG_SYNC_TARGET_LIMIT} 台目标主机;请先取消一台再选择。`);
|
|
175
|
+
syncControls();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (input.checked) targets.add(host.name);
|
|
179
|
+
else targets.delete(host.name);
|
|
180
|
+
invalidate('选择已变化,请重新预览。');
|
|
181
|
+
syncControls();
|
|
182
|
+
});
|
|
183
|
+
targetList.append(el('div.config-sync-target-row', { dataset: { name: host.name } }, [
|
|
184
|
+
input,
|
|
185
|
+
el('label', { for: id, text: host.name }),
|
|
186
|
+
]));
|
|
187
|
+
});
|
|
188
|
+
if (focusedHost) {
|
|
189
|
+
const inputs = [...targetList.querySelectorAll('input')];
|
|
190
|
+
const replacement = inputs.find((input) => input.dataset.host === focusedHost);
|
|
191
|
+
const focusTarget = replacement && !replacement.disabled
|
|
192
|
+
? replacement
|
|
193
|
+
: inputs.find((input) => !input.disabled) ?? sourceSelect;
|
|
194
|
+
focusTarget.focus();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function reconcileHosts({ force = false } = {}) {
|
|
199
|
+
const hostList = hosts();
|
|
200
|
+
const names = hostList.map((host) => host.name);
|
|
201
|
+
const namesKey = JSON.stringify(names);
|
|
202
|
+
const namesChanged = namesKey !== hostNamesKey;
|
|
203
|
+
const previousSource = source;
|
|
204
|
+
const previousTargets = [...targets];
|
|
205
|
+
const valid = new Set(names);
|
|
206
|
+
|
|
207
|
+
if (!valid.has(source)) source = names[0] ?? '';
|
|
208
|
+
targets = new Set(
|
|
209
|
+
previousTargets
|
|
210
|
+
.filter((name) => valid.has(name) && name !== source)
|
|
211
|
+
.slice(0, CONFIG_SYNC_TARGET_LIMIT),
|
|
212
|
+
);
|
|
213
|
+
const selectionChanged = source !== previousSource
|
|
214
|
+
|| previousTargets.length !== targets.size
|
|
215
|
+
|| previousTargets.some((name) => !targets.has(name));
|
|
216
|
+
|
|
217
|
+
if (force || namesChanged) {
|
|
218
|
+
hostNamesKey = namesKey;
|
|
219
|
+
renderSourceOptions(hostList);
|
|
220
|
+
renderTargets(hostList);
|
|
221
|
+
}
|
|
222
|
+
return { namesChanged, selectionChanged };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function syncControls() {
|
|
226
|
+
const pending = store.isPending('config:sync');
|
|
227
|
+
const canRequest = store.canWrite() && hosts().length >= 2 && Boolean(source) && targets.size > 0;
|
|
228
|
+
targetCount.textContent = `已选 ${targets.size} / ${CONFIG_SYNC_TARGET_LIMIT} 台`;
|
|
229
|
+
sourceSelect.disabled = pending;
|
|
230
|
+
selectAllBtn.disabled = pending;
|
|
231
|
+
clearBtn.disabled = pending;
|
|
232
|
+
closeBtn.disabled = pending;
|
|
233
|
+
for (const input of targetList.querySelectorAll('input')) {
|
|
234
|
+
input.disabled = pending || input.dataset.host === source;
|
|
235
|
+
}
|
|
236
|
+
previewBtn.disabled = pending || !canRequest;
|
|
237
|
+
applyBtn.disabled = pending
|
|
238
|
+
|| !store.canWrite()
|
|
239
|
+
|| applied
|
|
240
|
+
|| previewResult === null
|
|
241
|
+
|| previewSignature !== selectionSignature()
|
|
242
|
+
|| previewToken === null
|
|
243
|
+
|| !previewResult.targets?.some((target) => target.changed);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function selectAll() {
|
|
247
|
+
const available = hosts().filter((host) => host.name !== source);
|
|
248
|
+
targets = new Set(available.slice(0, CONFIG_SYNC_TARGET_LIMIT).map((host) => host.name));
|
|
249
|
+
invalidate('选择已变化,请重新预览。');
|
|
250
|
+
if (available.length > CONFIG_SYNC_TARGET_LIMIT) {
|
|
251
|
+
setStatus(`已按主机顺序选择前 ${CONFIG_SYNC_TARGET_LIMIT} 台目标主机。`);
|
|
252
|
+
}
|
|
253
|
+
renderTargets(hosts());
|
|
254
|
+
syncControls();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function clearTargets() {
|
|
258
|
+
targets.clear();
|
|
259
|
+
invalidate('选择已变化,请重新预览。');
|
|
260
|
+
renderTargets(hosts());
|
|
261
|
+
syncControls();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
sourceSelect.addEventListener('change', () => {
|
|
265
|
+
source = sourceSelect.value;
|
|
266
|
+
targets.delete(source);
|
|
267
|
+
invalidate('选择已变化,请重新预览。');
|
|
268
|
+
renderTargets(hosts());
|
|
269
|
+
syncControls();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
function orderedPlans(result, requestedTargets) {
|
|
273
|
+
const byName = new Map((result?.targets ?? []).map((target) => [target.name, target]));
|
|
274
|
+
return requestedTargets.map((name) => byName.get(name)).filter(Boolean);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function renderResults(result, requestedTargets, { isApplied = false } = {}) {
|
|
278
|
+
removeResults();
|
|
279
|
+
const list = el('ul.config-sync-result-list');
|
|
280
|
+
for (const plan of orderedPlans(result, requestedTargets)) {
|
|
281
|
+
const detail = plan.changed
|
|
282
|
+
? `${isApplied ? '已变更' : '将变更'}:${plan.changedFields.map((field) => FIELD_LABEL[field]).filter(Boolean).join('、') || '受支持配置字段'}`
|
|
283
|
+
: '无需变更';
|
|
284
|
+
const item = el('li.config-sync-result-item', { dataset: { host: plan.name } }, [
|
|
285
|
+
el('strong', { text: plan.name }),
|
|
286
|
+
el('span.config-sync-change-summary', { text: detail }),
|
|
287
|
+
]);
|
|
288
|
+
const host = store.getHost(plan.name);
|
|
289
|
+
if (plan.changed && RESTART_PHASES.has(host?.phase)) {
|
|
290
|
+
item.append(el('span.config-sync-restart-note', {
|
|
291
|
+
text: `状态提示:${HOST_WEB_RESTART_NOTICE}`,
|
|
292
|
+
}));
|
|
293
|
+
}
|
|
294
|
+
list.append(item);
|
|
295
|
+
}
|
|
296
|
+
resultSlot.append(el('section.config-sync-results', { 'aria-labelledby': 'config-sync-results-title' }, [
|
|
297
|
+
el('h3', { id: 'config-sync-results-title', text: isApplied ? '同步结果' : '同步预览' }),
|
|
298
|
+
list,
|
|
299
|
+
el('p.config-sync-no-restart', {
|
|
300
|
+
text: `本动作不会重启或停止任何主机;运行中的配置${HOST_WEB_RESTART_NOTICE}。`,
|
|
301
|
+
}),
|
|
302
|
+
]));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function preview() {
|
|
306
|
+
if (previewBtn.disabled) return;
|
|
307
|
+
const requested = { source, targets: [...targets] };
|
|
308
|
+
const requestGeneration = generation;
|
|
309
|
+
const signature = selectionSignature();
|
|
310
|
+
requestMode = 'preview';
|
|
311
|
+
previewResult = null;
|
|
312
|
+
previewSignature = null;
|
|
313
|
+
previewToken = null;
|
|
314
|
+
applied = false;
|
|
315
|
+
removeResults();
|
|
316
|
+
clearError();
|
|
317
|
+
setStatus('正在预览配置差异…');
|
|
318
|
+
|
|
319
|
+
let requestError = null;
|
|
320
|
+
const result = await actions.syncConfig({
|
|
321
|
+
...requested,
|
|
322
|
+
dryRun: true,
|
|
323
|
+
onError: (error) => {
|
|
324
|
+
requestError = error;
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
requestMode = null;
|
|
328
|
+
if (requestGeneration !== generation || signature !== selectionSignature()) {
|
|
329
|
+
syncControls();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!result) {
|
|
333
|
+
setStatus('预览失败,请修正后重试。');
|
|
334
|
+
renderError(requestError, '预览配置差异失败');
|
|
335
|
+
syncControls();
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
clearError();
|
|
340
|
+
previewResult = result;
|
|
341
|
+
previewSignature = signature;
|
|
342
|
+
previewToken = typeof result.previewToken === 'string' ? result.previewToken : null;
|
|
343
|
+
const changed = result.targets?.filter((target) => target.changed).length ?? 0;
|
|
344
|
+
setStatus(changed > 0 ? `预览完成:${changed} 台主机有变更。` : '预览完成:目标配置已一致。');
|
|
345
|
+
renderResults(result, requested.targets);
|
|
346
|
+
syncControls();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function apply() {
|
|
350
|
+
if (applyBtn.disabled) return;
|
|
351
|
+
const requested = { source, targets: [...targets] };
|
|
352
|
+
const token = previewToken;
|
|
353
|
+
generation += 1;
|
|
354
|
+
const requestGeneration = generation;
|
|
355
|
+
requestMode = 'apply';
|
|
356
|
+
previewResult = null;
|
|
357
|
+
previewSignature = null;
|
|
358
|
+
previewToken = null;
|
|
359
|
+
applied = false;
|
|
360
|
+
removeResults();
|
|
361
|
+
clearError();
|
|
362
|
+
setStatus('正在由服务端重新核对并应用配置…');
|
|
363
|
+
|
|
364
|
+
let requestError = null;
|
|
365
|
+
const result = await actions.syncConfig({
|
|
366
|
+
...requested,
|
|
367
|
+
dryRun: false,
|
|
368
|
+
previewToken: token,
|
|
369
|
+
onError: (error) => {
|
|
370
|
+
requestError = error;
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
requestMode = null;
|
|
374
|
+
if (requestGeneration !== generation || !isOpen()) {
|
|
375
|
+
syncControls();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (!result) {
|
|
379
|
+
setStatus(requestError?.code === 'CONFIG_STALE'
|
|
380
|
+
? '预览已失效,请重新预览后再应用。'
|
|
381
|
+
: '应用结果未确认,请重新预览后再试。');
|
|
382
|
+
renderError(requestError, '应用配置同步失败');
|
|
383
|
+
syncControls();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
clearError();
|
|
388
|
+
previewResult = result;
|
|
389
|
+
applied = true;
|
|
390
|
+
closeBtn.textContent = '关闭';
|
|
391
|
+
const count = result.applied?.length ?? 0;
|
|
392
|
+
setStatus(count > 0 ? `同步完成:已更新 ${count} 台主机。` : '同步完成:目标配置已一致。');
|
|
393
|
+
renderResults(result, requested.targets, { isApplied: true });
|
|
394
|
+
syncControls();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function open(trigger = null) {
|
|
398
|
+
if (hosts().length < 2 || !store.canWrite() || store.isPending('config:sync')) return;
|
|
399
|
+
restoreFocus = trigger ?? (document.activeElement instanceof HTMLElement ? document.activeElement : null);
|
|
400
|
+
closeBtn.textContent = '取消';
|
|
401
|
+
reconcileHosts({ force: true });
|
|
402
|
+
invalidate('主机列表已变化,请重新预览。');
|
|
403
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
404
|
+
else dialog.setAttribute('open', '');
|
|
405
|
+
syncControls();
|
|
406
|
+
sourceSelect.focus();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isOpen() {
|
|
410
|
+
return dialog.open || dialog.hasAttribute('open');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function hide({ force = false, focusTarget = restoreFocus } = {}) {
|
|
414
|
+
if (!force && store.isPending('config:sync')) return;
|
|
415
|
+
generation += 1;
|
|
416
|
+
requestMode = null;
|
|
417
|
+
if (dialog.open) dialog.close();
|
|
418
|
+
else dialog.removeAttribute('open');
|
|
419
|
+
focusTarget?.focus?.();
|
|
420
|
+
restoreFocus = null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function close() {
|
|
424
|
+
hide();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
dialog.addEventListener('cancel', (event) => {
|
|
428
|
+
event.preventDefault();
|
|
429
|
+
close();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
const off = [
|
|
433
|
+
store.on('pending:changed', syncControls),
|
|
434
|
+
store.on('connection:changed', () => {
|
|
435
|
+
if (isOpen() && !store.canWrite()) {
|
|
436
|
+
hide({ force: true, focusTarget: document.querySelector('.manage-back') });
|
|
437
|
+
}
|
|
438
|
+
syncControls();
|
|
439
|
+
}),
|
|
440
|
+
store.on('hosts:changed', (name) => {
|
|
441
|
+
if (!isOpen()) return;
|
|
442
|
+
const related = name === source || targets.has(name);
|
|
443
|
+
const { namesChanged, selectionChanged } = reconcileHosts();
|
|
444
|
+
if (requestMode !== 'apply' && !applied && (related || namesChanged || selectionChanged)) {
|
|
445
|
+
invalidate(namesChanged ? '主机列表已变化,请重新预览。' : '主机状态已变化,请重新预览。');
|
|
446
|
+
}
|
|
447
|
+
syncControls();
|
|
448
|
+
}),
|
|
449
|
+
store.on('hosts:reset', () => {
|
|
450
|
+
if (!isOpen()) return;
|
|
451
|
+
reconcileHosts({ force: true });
|
|
452
|
+
if (requestMode !== 'apply' && !applied) invalidate('主机列表已变化,请重新预览。');
|
|
453
|
+
syncControls();
|
|
454
|
+
}),
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
setStatus(initialStatus());
|
|
458
|
+
syncControls();
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
root: dialog,
|
|
462
|
+
open,
|
|
463
|
+
close,
|
|
464
|
+
destroy() {
|
|
465
|
+
for (const unsubscribe of off) unsubscribe();
|
|
466
|
+
if (isOpen()) hide({ force: true, focusTarget: null });
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promise 化的原生 <dialog> 确认框(10 §3.10 / UI-08)。
|
|
3
|
+
* 串行处理:同一时刻只有一个确认在等,打开时聚焦「取消」,关闭后焦点归还触发元素。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { clear, el } from '../utils.js';
|
|
7
|
+
|
|
8
|
+
export function createConfirmDialog() {
|
|
9
|
+
const title = el('h2', { id: 'confirm-title' });
|
|
10
|
+
const body = el('div.confirm-body');
|
|
11
|
+
const cancelBtn = el('button.btn.btn-default', { type: 'button', value: 'cancel', text: '取消' });
|
|
12
|
+
const okBtn = el('button.btn.btn-danger', { type: 'button', value: 'confirm', text: '确认' });
|
|
13
|
+
|
|
14
|
+
const dialog = el('dialog.confirm-dialog', { 'aria-labelledby': 'confirm-title' }, [
|
|
15
|
+
el('div.confirm-inner', {}, [title, body, el('footer.confirm-actions', {}, [cancelBtn, okBtn])]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
let settle = null;
|
|
19
|
+
let restoreFocus = null;
|
|
20
|
+
|
|
21
|
+
const finish = (value) => {
|
|
22
|
+
if (!settle) return;
|
|
23
|
+
const done = settle;
|
|
24
|
+
settle = null;
|
|
25
|
+
if (dialog.open) dialog.close();
|
|
26
|
+
done(value);
|
|
27
|
+
restoreFocus?.focus?.();
|
|
28
|
+
restoreFocus = null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
cancelBtn.addEventListener('click', () => finish(false));
|
|
32
|
+
okBtn.addEventListener('click', () => finish(true));
|
|
33
|
+
dialog.addEventListener('cancel', (e) => {
|
|
34
|
+
e.preventDefault(); // Escape 走同一条收口路径
|
|
35
|
+
finish(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{title:string, lines?:string[], confirmLabel?:string, danger?:boolean}} opts
|
|
40
|
+
* @returns {Promise<boolean>}
|
|
41
|
+
*/
|
|
42
|
+
function confirm(opts) {
|
|
43
|
+
if (settle) finish(false); // 串行:新的请求让旧的按取消收场
|
|
44
|
+
title.textContent = opts.title;
|
|
45
|
+
clear(body);
|
|
46
|
+
for (const line of opts.lines ?? []) body.append(el('p', { text: line }));
|
|
47
|
+
okBtn.textContent = opts.confirmLabel ?? '确认';
|
|
48
|
+
okBtn.className = `btn ${opts.danger === false ? 'btn-primary' : 'btn-danger'}`;
|
|
49
|
+
restoreFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
settle = resolve;
|
|
53
|
+
if (typeof dialog.showModal === 'function') dialog.showModal();
|
|
54
|
+
else dialog.setAttribute('open', '');
|
|
55
|
+
cancelBtn.focus();
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
confirm.cancel = () => finish(false);
|
|
59
|
+
|
|
60
|
+
return { root: dialog, confirm };
|
|
61
|
+
}
|