@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,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主机表格(10 §3.2 / UI-12、UI-13)。
|
|
3
|
+
*
|
|
4
|
+
* 单台 SSE 更新只重绘对应 <tr>,不整表重排;缺字段一律显示「—」,
|
|
5
|
+
* 不从主机名或默认值推断。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
ACTION_LABEL, DASH, button, clear, el, fmtAgo, isManaged, phaseBadge, rowActions, text,
|
|
10
|
+
} from '../utils.js';
|
|
11
|
+
import {
|
|
12
|
+
hostDshSummary, hostMappingSummary, hostPhaseHint, hostPhaseMeta,
|
|
13
|
+
} from '../host-presentation.js';
|
|
14
|
+
import { field, input } from '../form.js';
|
|
15
|
+
|
|
16
|
+
const COLUMNS = ['主机', '状态', 'dsh', '本机映射', 'PID', '自启', '操作'];
|
|
17
|
+
|
|
18
|
+
export function createHostTable({ store, actions }) {
|
|
19
|
+
const tbody = el('tbody');
|
|
20
|
+
const empty = el('p.empty-hint', { text: '尚无主机:确认 ~/.ssh/config 中有可用 Host 条目,然后重新探测。' });
|
|
21
|
+
const countLabel = el('span.card-sub.host-count');
|
|
22
|
+
const localName = field('本机名称(可选)', input('text', '', {
|
|
23
|
+
placeholder: '留空使用系统主机名',
|
|
24
|
+
autocomplete: 'off',
|
|
25
|
+
'aria-label': '本机名称(可选)',
|
|
26
|
+
}));
|
|
27
|
+
const addLocalButton = button('添加本机', {
|
|
28
|
+
onClick: () => actions.addLocalHost(localName.input.value),
|
|
29
|
+
});
|
|
30
|
+
addLocalButton.dataset.act = 'add-local';
|
|
31
|
+
addLocalButton.setAttribute('aria-label', '添加本机');
|
|
32
|
+
|
|
33
|
+
const root = el('section.card.host-table-card', {}, [
|
|
34
|
+
el('header.card-header', {}, [
|
|
35
|
+
el('h2', { text: '主机' }),
|
|
36
|
+
el('div.row-actions', {}, [countLabel, localName.root, addLocalButton]),
|
|
37
|
+
]),
|
|
38
|
+
el('div.table-scroll', {}, [
|
|
39
|
+
el('table.host-table', {}, [
|
|
40
|
+
el('thead', {}, [el('tr', {}, COLUMNS.map((c) => el('th', { text: c })))]),
|
|
41
|
+
tbody,
|
|
42
|
+
]),
|
|
43
|
+
]),
|
|
44
|
+
empty,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** @type {Map<string, HTMLTableRowElement>} */
|
|
48
|
+
const rows = new Map();
|
|
49
|
+
|
|
50
|
+
function syncHeader() {
|
|
51
|
+
const hosts = store.listHosts();
|
|
52
|
+
const loaded = store.state.hostsLoaded;
|
|
53
|
+
const hasLocal = hosts.some((host) => host.local === true);
|
|
54
|
+
const showAddLocal = loaded && !hasLocal;
|
|
55
|
+
const addLocalDisabled = !showAddLocal || !store.canWrite() || store.isPending('local:create');
|
|
56
|
+
countLabel.textContent = hosts.length > 0 ? `${hosts.length} 台` : '';
|
|
57
|
+
localName.root.hidden = !showAddLocal;
|
|
58
|
+
localName.input.disabled = addLocalDisabled;
|
|
59
|
+
addLocalButton.hidden = !showAddLocal;
|
|
60
|
+
addLocalButton.disabled = addLocalDisabled;
|
|
61
|
+
addLocalButton.title = !store.canWrite() ? '与 manager 失联,写操作已暂停' : '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function renderAll() {
|
|
65
|
+
clear(tbody);
|
|
66
|
+
rows.clear();
|
|
67
|
+
const hosts = store.listHosts();
|
|
68
|
+
for (const host of hosts) {
|
|
69
|
+
const tr = renderRow(host);
|
|
70
|
+
rows.set(host.name, tr);
|
|
71
|
+
tbody.append(tr);
|
|
72
|
+
}
|
|
73
|
+
empty.hidden = hosts.length > 0;
|
|
74
|
+
syncHeader();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderOne(name) {
|
|
78
|
+
const host = store.getHost(name);
|
|
79
|
+
const existing = rows.get(name);
|
|
80
|
+
if (!host) {
|
|
81
|
+
existing?.remove();
|
|
82
|
+
rows.delete(name);
|
|
83
|
+
empty.hidden = rows.size > 0;
|
|
84
|
+
syncHeader();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const fresh = renderRow(host);
|
|
88
|
+
if (existing) existing.replaceWith(fresh);
|
|
89
|
+
else tbody.append(fresh);
|
|
90
|
+
rows.set(name, fresh);
|
|
91
|
+
empty.hidden = rows.size > 0;
|
|
92
|
+
syncHeader();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderRow(host) {
|
|
96
|
+
const tr = el('tr', {
|
|
97
|
+
dataset: { host: host.name },
|
|
98
|
+
tabindex: '0',
|
|
99
|
+
on: {
|
|
100
|
+
click: () => actions.openHostDrawer(host.name),
|
|
101
|
+
keydown: (e) => {
|
|
102
|
+
// 只认落在行本身的按键。行内控件的 Enter/Space 是它们自己的:这个
|
|
103
|
+
// preventDefault 会连带取消按钮的原生激活,于是键盘用户按「探测」
|
|
104
|
+
// 只会开抽屉、请求一个都发不出去——鼠标点却一切正常(那条路上控件
|
|
105
|
+
// 各自 stopPropagation,压根到不了这里)。
|
|
106
|
+
if (e.target !== e.currentTarget) return;
|
|
107
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
actions.openHostDrawer(host.name);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const ssh = host.sshInfo;
|
|
116
|
+
tr.append(el('td.host-cell', {}, [
|
|
117
|
+
el('strong', { text: host.name }),
|
|
118
|
+
host.local ? el('span.tag.tag-lock', { text: '本机' }) : null,
|
|
119
|
+
ssh
|
|
120
|
+
? el('small', { text: `${text(ssh.user)}@${text(ssh.hostName)}:${text(ssh.port)}` })
|
|
121
|
+
: (host.local ? null : el('small', { text: 'ssh config 中已消失' })),
|
|
122
|
+
!host.local && host.orphaned
|
|
123
|
+
? el('span.tag.tag-warn', { text: 'orphaned', title: 'config 里还有,ssh config 里已不见' })
|
|
124
|
+
: null,
|
|
125
|
+
]));
|
|
126
|
+
|
|
127
|
+
const hint = hostPhaseHint(host);
|
|
128
|
+
tr.append(el('td', {}, [
|
|
129
|
+
phaseBadge(hostPhaseMeta(host)),
|
|
130
|
+
hint ? el('small.phase-hint', { text: hint }) : null,
|
|
131
|
+
]));
|
|
132
|
+
|
|
133
|
+
const dsh = hostDshSummary(host);
|
|
134
|
+
tr.append(el('td.dsh-cell', {}, [
|
|
135
|
+
el('span', { text: dsh.line1 }),
|
|
136
|
+
dsh.line2 ? el('small', { text: dsh.line2, title: dsh.line2 }) : null,
|
|
137
|
+
host.probe?.at ? el('small.probe-at', { text: `探测 ${fmtAgo(host.probe.at)}` }) : null,
|
|
138
|
+
]));
|
|
139
|
+
|
|
140
|
+
const mapping = hostMappingSummary(host);
|
|
141
|
+
tr.append(el('td.mapping-cell', {}, [
|
|
142
|
+
mapping.url
|
|
143
|
+
? el('a.mono-link', {
|
|
144
|
+
href: mapping.url,
|
|
145
|
+
target: '_blank',
|
|
146
|
+
rel: 'noreferrer',
|
|
147
|
+
text: mapping.line1,
|
|
148
|
+
on: { click: (e) => e.stopPropagation() },
|
|
149
|
+
})
|
|
150
|
+
: el('span', { text: mapping.line1 }),
|
|
151
|
+
mapping.line2 ? el('small', { text: mapping.line2 }) : null,
|
|
152
|
+
]));
|
|
153
|
+
|
|
154
|
+
tr.append(el('td.pid-cell', {}, [
|
|
155
|
+
el('span', { text: host.web ? String(host.web.pid) : DASH }),
|
|
156
|
+
host.web && !isManaged(host) ? el('span.tag.tag-lock', { text: '🔒 手动', title: '非本工具拉起,禁用关停/重启' }) : null,
|
|
157
|
+
host.manualInstances.length > 0
|
|
158
|
+
? el('small', { text: `另有 ${host.manualInstances.length} 个手动实例`, title: host.manualInstances.map((i) => `${i.pid} ${i.args}`).join('\n') })
|
|
159
|
+
: null,
|
|
160
|
+
]));
|
|
161
|
+
|
|
162
|
+
tr.append(el('td.autostart-cell', {}, [renderAutoStart(host)]));
|
|
163
|
+
tr.append(el('td.row-actions', {}, renderActions(host)));
|
|
164
|
+
return tr;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 即改即存;服务端确认前保持 pending,失败由 actions 层回滚并 toast。 */
|
|
168
|
+
function renderAutoStart(host) {
|
|
169
|
+
const busy = store.isPending('config:save', host.name);
|
|
170
|
+
const input = el('input', {
|
|
171
|
+
type: 'checkbox',
|
|
172
|
+
checked: host.config.autoStart,
|
|
173
|
+
disabled: busy || !store.canWrite(),
|
|
174
|
+
'aria-label': `${host.name} 开机自启`,
|
|
175
|
+
dataset: { act: 'autostart' },
|
|
176
|
+
on: {
|
|
177
|
+
click: (e) => e.stopPropagation(),
|
|
178
|
+
change: (e) => actions.setAutoStart(host.name, e.target.checked),
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
return el('label.switch', { on: { click: (e) => e.stopPropagation() } }, [input, el('span.switch-track')]);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function renderActions(host) {
|
|
185
|
+
const busy = store.hostBusy(host.name);
|
|
186
|
+
const writable = store.canWrite();
|
|
187
|
+
return rowActions(host).map((action) => {
|
|
188
|
+
if (action === 'open') {
|
|
189
|
+
return markAct('open', button(ACTION_LABEL.open, {
|
|
190
|
+
variant: 'primary',
|
|
191
|
+
// 「打开」是读操作:断线与 pending 都不禁用(10 §3.2)
|
|
192
|
+
onClick: (e) => {
|
|
193
|
+
e.stopPropagation();
|
|
194
|
+
actions.openHost(host.name);
|
|
195
|
+
},
|
|
196
|
+
}));
|
|
197
|
+
}
|
|
198
|
+
return markAct(action, button(ACTION_LABEL[action], {
|
|
199
|
+
variant: action === 'stop' ? 'danger' : 'default',
|
|
200
|
+
disabled: busy || !writable,
|
|
201
|
+
title: !writable ? '与 manager 失联,写操作已暂停' : null,
|
|
202
|
+
onClick: (e) => {
|
|
203
|
+
e.stopPropagation();
|
|
204
|
+
actions.hostAction(action, host.name);
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function markAct(act, node) {
|
|
211
|
+
node.dataset.act = act;
|
|
212
|
+
return node;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 重渲染前后把焦点留住(issue #32)。
|
|
217
|
+
*
|
|
218
|
+
* 这张表在任何写操作(`pending:changed`)和任何主机变更(`hosts:changed`)时都会
|
|
219
|
+
* 整片/整行重建,旧节点带着焦点被移除,浏览器只能把焦点交回 body。键盘用户按下
|
|
220
|
+
* 「拉起」的那一刻就失去了位置:启动要走 5 拍,每拍都重建,他既跟不到变化也回不去。
|
|
221
|
+
*
|
|
222
|
+
* 认「同一个控件」靠两个稳定标记:行的 `data-host` + 控件的 `data-act`
|
|
223
|
+
* (自启开关用 `data-act="autostart"`)。控件因状态变化消失了(关停成功后
|
|
224
|
+
* 「关停」换成「拉起」)就退到那一行——行本身 tabindex=0,是天然落点。
|
|
225
|
+
*/
|
|
226
|
+
function keepFocus(render) {
|
|
227
|
+
const active = document.activeElement;
|
|
228
|
+
const inTable = active instanceof HTMLElement && root.contains(active) && active !== root;
|
|
229
|
+
const mark = inTable
|
|
230
|
+
? { host: active.closest('tr')?.dataset.host ?? null, act: active.dataset.act ?? null }
|
|
231
|
+
: null;
|
|
232
|
+
|
|
233
|
+
render();
|
|
234
|
+
if (!mark?.host || document.activeElement === active) return;
|
|
235
|
+
const tr = rows.get(mark.host);
|
|
236
|
+
if (!tr) return;
|
|
237
|
+
// 同名控件还在也未必接得住焦点:忙碌态下它是 disabled,focus() 会静默失效
|
|
238
|
+
// (真机上「拉起」按下的那一拍正是如此,焦点于是照样掉回 body)。
|
|
239
|
+
const same = mark.act ? tr.querySelector(`[data-act="${mark.act}"]`) : null;
|
|
240
|
+
const target = same && !same.disabled ? same : tr;
|
|
241
|
+
target.focus();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 手指底下不换节点(issue #61)。
|
|
246
|
+
*
|
|
247
|
+
* 鼠标与 Space 的原生激活都在「抬起」那一刻,且要求按下与抬起落在同一个 DOM 节点上。
|
|
248
|
+
* 这张表在每条 `host-changed` 上重建对应行,一次重建插进按下与抬起之间,这次操作就
|
|
249
|
+
* 无声无息地没了——用户只知道「按了没反应」。焦点保护(#32)保不住这个:焦点是重建后
|
|
250
|
+
* 补回去的,对激活已经太晚;macOS 上点按钮更是压根不给焦点。
|
|
251
|
+
*
|
|
252
|
+
* 所以按压期间把重绘攒起来,松手再刷。攒的窗口是亚秒级,比丢掉一次点击划算得多。
|
|
253
|
+
*/
|
|
254
|
+
let holding = false;
|
|
255
|
+
let queued = null; // null=没攒 | 'all'=整表 | Set<主机名>
|
|
256
|
+
/** @param {string|null} name 只影响一台就给名字,整表重绘给 null */
|
|
257
|
+
function gated(name, render) {
|
|
258
|
+
if (!holding) {
|
|
259
|
+
render();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (queued === 'all') return;
|
|
263
|
+
if (name === null) { queued = 'all'; return; }
|
|
264
|
+
queued ??= new Set();
|
|
265
|
+
queued.add(name);
|
|
266
|
+
}
|
|
267
|
+
let flushTimer = null;
|
|
268
|
+
/**
|
|
269
|
+
* 松手不能当场刷:click 是浏览器在 pointerup / keyup **之后**才派发的,
|
|
270
|
+
* 当场重建会把节点从 click 的派发路径上抽走——刷新自己反倒把这一次点击掐了
|
|
271
|
+
* (真页面上实测到过:闸门拦住了重建,请求却还是没发出)。所以让到下一拍。
|
|
272
|
+
*/
|
|
273
|
+
function release() {
|
|
274
|
+
if (!holding || flushTimer !== null) return;
|
|
275
|
+
flushTimer = setTimeout(() => {
|
|
276
|
+
flushTimer = null;
|
|
277
|
+
holding = false;
|
|
278
|
+
const pending = queued;
|
|
279
|
+
queued = null;
|
|
280
|
+
if (pending === 'all') keepFocus(renderAll);
|
|
281
|
+
else if (pending) keepFocus(() => { for (const n of pending) renderOne(n); });
|
|
282
|
+
}, 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const onPressStart = (e) => {
|
|
286
|
+
// 只认落在表内控件上的按压;行本身与空白处重建了也无所谓
|
|
287
|
+
const node = e.target;
|
|
288
|
+
if (!(node instanceof HTMLElement) || !root.contains(node)) return;
|
|
289
|
+
if (e.type === 'keydown' && e.key !== ' ' && e.key !== 'Enter') return;
|
|
290
|
+
holding = true;
|
|
291
|
+
};
|
|
292
|
+
root.addEventListener('pointerdown', onPressStart);
|
|
293
|
+
root.addEventListener('keydown', onPressStart);
|
|
294
|
+
// 抬起可能落在表外(按住拖出去再松手),所以听在 document 上;
|
|
295
|
+
// 窗口整个失焦时 pointerup/keyup 永远不会来,靠 window 的 blur 兜住(blur 不冒泡)
|
|
296
|
+
const RELEASE_EVENTS = ['pointerup', 'pointercancel', 'keyup'];
|
|
297
|
+
for (const type of RELEASE_EVENTS) document.addEventListener(type, release);
|
|
298
|
+
window.addEventListener('blur', release);
|
|
299
|
+
|
|
300
|
+
const offs = [
|
|
301
|
+
store.on('hosts:reset', () => gated(null, () => keepFocus(renderAll))),
|
|
302
|
+
store.on('hosts:changed', (name) => gated(name, () => keepFocus(() => renderOne(name)))),
|
|
303
|
+
store.on('pending:changed', () => gated(null, () => keepFocus(renderAll))),
|
|
304
|
+
store.on('connection:changed', () => gated(null, () => keepFocus(renderAll))),
|
|
305
|
+
];
|
|
306
|
+
renderAll();
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
root,
|
|
310
|
+
destroy() {
|
|
311
|
+
for (const off of offs) off();
|
|
312
|
+
for (const type of RELEASE_EVENTS) document.removeEventListener(type, release);
|
|
313
|
+
window.removeEventListener('blur', release);
|
|
314
|
+
if (flushTimer !== null) clearTimeout(flushTimer);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主机起始页:把日常入口压成可打开主机卡片,不可用主机只留一行摘要。
|
|
3
|
+
*
|
|
4
|
+
* 组件只消费 store 视图并调用既有 actions;ready 的「拉起 + 进入」仍由
|
|
5
|
+
* actions.openHost 统一实现,这里不复制 phase 或请求推进逻辑。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
DASH, clear, el, phaseBadge, phaseMeta,
|
|
10
|
+
} from '../utils.js';
|
|
11
|
+
import { isHostEnabled, primaryHosts } from '../host-rules.js';
|
|
12
|
+
import {
|
|
13
|
+
hostMappingSummary, hostPhaseMeta, hostStatusText,
|
|
14
|
+
} from '../host-presentation.js';
|
|
15
|
+
|
|
16
|
+
/** @param {Iterable<object>} hosts */
|
|
17
|
+
export function hubHosts(hosts) {
|
|
18
|
+
const all = [...hosts];
|
|
19
|
+
const primary = primaryHosts(all);
|
|
20
|
+
const primarySet = new Set(primary);
|
|
21
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
22
|
+
const unavailable = all.filter((host) => !primarySet.has(host)).sort(byName);
|
|
23
|
+
return { primary, unavailable };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function mappingLine(host) {
|
|
27
|
+
const mapping = hostMappingSummary(host);
|
|
28
|
+
return mapping.line1 === DASH ? null : mapping.line1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cardSummary(host, store) {
|
|
32
|
+
const mapping = mappingLine(host);
|
|
33
|
+
if (store.hostBusy(host.name)) return '操作处理中…';
|
|
34
|
+
switch (host.phase) {
|
|
35
|
+
case 'ready':
|
|
36
|
+
if (!store.canWrite()) return 'manager 已失联,暂时无法拉起';
|
|
37
|
+
return mapping ? `${mapping} · 点击拉起并进入` : '点击拉起并进入';
|
|
38
|
+
case 'starting':
|
|
39
|
+
return host.local === true ? '正在拉起本机页面…' : '正在拉起并建立隧道…';
|
|
40
|
+
case 'running':
|
|
41
|
+
return mapping ? `${mapping} · 点击进入` : '页面已就绪 · 点击进入';
|
|
42
|
+
case 'degraded':
|
|
43
|
+
return mapping ? `${mapping} · 页面可打开,等待恢复` : '页面可打开,等待恢复';
|
|
44
|
+
case 'crashed':
|
|
45
|
+
return mapping ? `${mapping} · 保留上次页面` : '保留上次页面 · 点击查看';
|
|
46
|
+
default:
|
|
47
|
+
return phaseMeta(host.phase).label;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function unavailableSummary(hosts) {
|
|
52
|
+
const counts = new Map();
|
|
53
|
+
for (const host of hosts) {
|
|
54
|
+
const label = hostStatusText(host, { disabled: !isHostEnabled(host) });
|
|
55
|
+
counts.set(label, (counts.get(label) ?? 0) + 1);
|
|
56
|
+
}
|
|
57
|
+
const detail = [...counts].map(([label, count]) => `${label} ${count}`).join(' · ');
|
|
58
|
+
return `${hosts.length} 台主机不可用或已禁用${detail ? `(${detail})` : ''}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function createHub({ store, actions }) {
|
|
62
|
+
const content = el('div.hub-content');
|
|
63
|
+
const root = el('main.view.view-hub', { hidden: true }, [content]);
|
|
64
|
+
|
|
65
|
+
function hostCard(host) {
|
|
66
|
+
const active = store.state.route.kind === 'host' && store.state.route.host === host.name;
|
|
67
|
+
return el('button.hub-host-card', {
|
|
68
|
+
type: 'button',
|
|
69
|
+
dataset: { host: host.name, phase: host.phase },
|
|
70
|
+
class: active ? 'is-active' : '',
|
|
71
|
+
'aria-label': `${host.name},${hostPhaseMeta(host).label},${cardSummary(host, store)}`,
|
|
72
|
+
'aria-current': active ? 'page' : null,
|
|
73
|
+
on: { click: () => actions.openHost(host.name) },
|
|
74
|
+
}, [
|
|
75
|
+
el('span.hub-host-head', {}, [
|
|
76
|
+
el('strong', { text: host.name }),
|
|
77
|
+
host.local === true ? el('span.tag.tag-lock', { text: '本机' }) : null,
|
|
78
|
+
]),
|
|
79
|
+
phaseBadge(host.phase),
|
|
80
|
+
el('span.hub-host-summary', { text: cardSummary(host, store) }),
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function render() {
|
|
85
|
+
const hosts = store.listHosts();
|
|
86
|
+
const { primary, unavailable } = hubHosts(hosts);
|
|
87
|
+
const body = [];
|
|
88
|
+
|
|
89
|
+
if (!store.state.hostsLoaded) {
|
|
90
|
+
body.push(el('p.empty-hint.hub-syncing', { text: '正在同步主机…', role: 'status' }));
|
|
91
|
+
} else if (hosts.length === 0) {
|
|
92
|
+
body.push(el('section.hub-empty', {}, [
|
|
93
|
+
el('p', { text: '还没有主机。可以先添加本机,或前往管理页配置远端主机。' }),
|
|
94
|
+
el('div.hub-empty-actions', {}, [
|
|
95
|
+
el('button.btn.btn-primary', {
|
|
96
|
+
type: 'button',
|
|
97
|
+
text: store.isPending('local:create') ? '正在添加本机…' : '添加本机',
|
|
98
|
+
disabled: !store.canWrite() || store.isPending('local:create'),
|
|
99
|
+
on: { click: () => actions.addLocalHost() },
|
|
100
|
+
}),
|
|
101
|
+
el('a.link', { href: '#/manage', text: '去管理' }),
|
|
102
|
+
]),
|
|
103
|
+
]));
|
|
104
|
+
} else {
|
|
105
|
+
if (primary.length > 0) {
|
|
106
|
+
body.push(el('div.hub-host-grid', { 'aria-label': '可打开的主机' }, primary.map(hostCard)));
|
|
107
|
+
} else {
|
|
108
|
+
body.push(el('p.empty-hint', { text: '当前没有可打开的主机。' }));
|
|
109
|
+
}
|
|
110
|
+
if (unavailable.length > 0) {
|
|
111
|
+
body.push(el('div.hub-unavailable', {}, [
|
|
112
|
+
el('span', { text: unavailableSummary(unavailable) }),
|
|
113
|
+
el('a.link', { href: '#/manage', text: '去管理' }),
|
|
114
|
+
]));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
clear(content).append(
|
|
119
|
+
el('header.hub-hero', {}, [
|
|
120
|
+
el('p.hub-brand', { text: '◆ DSH Center' }),
|
|
121
|
+
el('h2', { text: '选择一台主机开始工作', tabindex: '-1' }),
|
|
122
|
+
]),
|
|
123
|
+
...body,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const offs = [
|
|
128
|
+
store.on('hosts:reset', render),
|
|
129
|
+
store.on('hosts:changed', render),
|
|
130
|
+
store.on('pending:changed', render),
|
|
131
|
+
store.on('connection:changed', render),
|
|
132
|
+
store.on('route:changed', render),
|
|
133
|
+
];
|
|
134
|
+
render();
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
root,
|
|
138
|
+
render,
|
|
139
|
+
destroy() {
|
|
140
|
+
for (const off of offs) off();
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|