dsh-server-connector 0.2.1 → 0.2.3
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/CHANGELOG.md +31 -0
- package/lib/client.js +1728 -1716
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -1,1716 +1,1728 @@
|
|
|
1
|
-
// dsh-server-connector — Client half (design doc §8).
|
|
2
|
-
//
|
|
3
|
-
// Three mount points, all taken from the confirmed live Slot tree (§8.1):
|
|
4
|
-
//
|
|
5
|
-
// sidebar.panellist → the "服务器" icon in the left rail
|
|
6
|
-
// main (key: "server-connector") → server list / detail / form
|
|
7
|
-
// settings.section → global settings + the pending two-step queue
|
|
8
|
-
//
|
|
9
|
-
// The file is a browser module-loader bundle, not a Node module: the DSH client
|
|
10
|
-
// module system loads it through `window.__ModuleLoader__.load`, and `require`
|
|
11
|
-
// is the page's static table (react, react/jsx-runtime, …). It is hand-authored
|
|
12
|
-
// — this repository has no client build step — so it uses `React.createElement`
|
|
13
|
-
// rather than JSX and keeps every browser global behind `browserGlobal` so the
|
|
14
|
-
// Node-side `tsc --noEmit` pass (tsconfig lib is ES2023, no DOM) stays clean.
|
|
15
|
-
//
|
|
16
|
-
// Two product constraints shape the UI:
|
|
17
|
-
// - the panel talks to the Host only through the §8.5 JSON routes, and it
|
|
18
|
-
// never receives a secret (a password is write-only, §6.3);
|
|
19
|
-
// - the panel has no turn, so `ctx.approval` is unavailable (§11): deleting a
|
|
20
|
-
// server, switching host-key verification off and disabling a rule all write
|
|
21
|
-
// to the pending queue and say so. Nothing dangerous happens on one click.
|
|
22
|
-
|
|
23
|
-
/** The page global, typed loosely: this bundle runs in the browser, not in Node. */
|
|
24
|
-
const browserGlobal = /** @type {any} */ (globalThis)
|
|
25
|
-
|
|
26
|
-
browserGlobal.__ModuleLoader__.load({
|
|
27
|
-
id: 'dsh-server-connector',
|
|
28
|
-
factory: (/** @type {(id: string) => any} */ load) => {
|
|
29
|
-
const module = { exports: /** @type {Record<string, unknown>} */ ({}) }
|
|
30
|
-
const exports = module.exports
|
|
31
|
-
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
32
|
-
|
|
33
|
-
const React = load('react')
|
|
34
|
-
const h = React.createElement
|
|
35
|
-
const F = React.Fragment
|
|
36
|
-
|
|
37
|
-
/** Dictionary namespace owned by this plugin. */
|
|
38
|
-
const NS = 'serverConnector'
|
|
39
|
-
/** The id shared by the sidebar entry, the main panel key and the settings page. */
|
|
40
|
-
const PANEL_ID = 'server-connector'
|
|
41
|
-
/** Mount path of the Host routes (§8.5). */
|
|
42
|
-
const API = '/server-connector'
|
|
43
|
-
/** A displayed audit tail matches the detail page (§8.2). */
|
|
44
|
-
const AUDIT_LIMIT = 20
|
|
45
|
-
|
|
46
|
-
// ---------------------------------------------------------------- copy --
|
|
47
|
-
|
|
48
|
-
/** Chinese dictionary, the source of truth. */
|
|
49
|
-
const zh = {
|
|
50
|
-
'panel.label': '服务器',
|
|
51
|
-
'panel.title': '服务器',
|
|
52
|
-
'panel.add': '新增',
|
|
53
|
-
'panel.loading': '读取服务器列表…',
|
|
54
|
-
'panel.retry': '重试',
|
|
55
|
-
'panel.empty': '还没有配置服务器。',
|
|
56
|
-
'panel.emptyHint': '新增一台后,模型即可通过 srv_* 工具在这台机器上执行命令。',
|
|
57
|
-
'panel.connected': '已连接',
|
|
58
|
-
'panel.disconnected': '未连接',
|
|
59
|
-
'panel.test': '测试',
|
|
60
|
-
'panel.testing': '测试中…',
|
|
61
|
-
'panel.edit': '编辑',
|
|
62
|
-
'panel.enable': '启用',
|
|
63
|
-
'panel.disable': '禁用',
|
|
64
|
-
'panel.disabled': '已禁用',
|
|
65
|
-
'panel.disconnectFirst': '已连接的服务器不能禁用,请先断开',
|
|
66
|
-
'panel.disabledNote': '已禁用:srv_list 不再返回它,任何 srv_* 调用都会被拒绝。',
|
|
67
|
-
'panel.back': '返回列表',
|
|
68
|
-
'panel.delete': '删除',
|
|
69
|
-
'panel.deleteHint': '删除会先写入待确认区,确认后才真正移除。',
|
|
70
|
-
'panel.deleteQueued': '删除请求已加入待确认区,服务器仍然可用;请到设置页确认。',
|
|
71
|
-
'panel.sectionConnection': '连接',
|
|
72
|
-
'panel.sectionPolicy': '高危策略',
|
|
73
|
-
'panel.sectionAudit': `最近 ${AUDIT_LIMIT} 条审计`,
|
|
74
|
-
'panel.auditEmpty': '没有审计记录。',
|
|
75
|
-
'panel.auditLoading': '读取审计…',
|
|
76
|
-
'panel.notFound': '这台服务器已不存在。',
|
|
77
|
-
'panel.lastConnected': '上次连接 {time}',
|
|
78
|
-
'panel.neverConnected': '从未连接',
|
|
79
|
-
'status.connected': '连接成功:{platform}({shell},sudo {sudo}),耗时 {ms} ms',
|
|
80
|
-
'field.name': '名称',
|
|
81
|
-
'field.host': '主机',
|
|
82
|
-
'field.port': '端口',
|
|
83
|
-
'field.username': '用户名',
|
|
84
|
-
'field.auth': '认证方式',
|
|
85
|
-
'field.privateKeyPath': '私钥路径',
|
|
86
|
-
'field.password': '密码',
|
|
87
|
-
'field.hostKeyMode': '主机密钥策略',
|
|
88
|
-
'field.defaultWorkDir': '默认工作目录',
|
|
89
|
-
'field.lastConnectedAt': '最近连接',
|
|
90
|
-
'field.policy': '高危策略',
|
|
91
|
-
'auth.key': '私钥',
|
|
92
|
-
'auth.password': '密码',
|
|
93
|
-
'auth.agent': 'SSH agent',
|
|
94
|
-
'auth.passwordConfigured': '已设置',
|
|
95
|
-
'hostKey.accept-new': 'accept-new(首次接受并记住)',
|
|
96
|
-
'hostKey.verify': 'verify(严格校验)',
|
|
97
|
-
'hostKey.off': 'off(关闭校验)',
|
|
98
|
-
'form.new': '新增服务器',
|
|
99
|
-
'form.edit': '编辑服务器',
|
|
100
|
-
'form.save': '保存',
|
|
101
|
-
'form.saving': '保存中…',
|
|
102
|
-
'form.cancel': '取消',
|
|
103
|
-
'form.portRange': '端口必须是 1–65535 的整数',
|
|
104
|
-
'form.absolutePath': '必须是绝对路径',
|
|
105
|
-
'form.passwordKeep': '留空则保持当前密码',
|
|
106
|
-
'form.passwordNew': '密码只写不回显',
|
|
107
|
-
'form.policyHint': '内置 block 规则只能降为 confirm,不能直接改为 allow;选择「停用」会先写入待确认区。',
|
|
108
|
-
'form.offOption': '停用',
|
|
109
|
-
'form.offQueued': '「停用」会写入待确认区,确认后该规则才失效。',
|
|
110
|
-
'form.pendingNote': '危险改动已写入待确认区,确认后才生效。',
|
|
111
|
-
'form.errorName': '名称不能为空',
|
|
112
|
-
'form.errorHost': '主机不能为空',
|
|
113
|
-
'form.errorUsername': '用户名不能为空',
|
|
114
|
-
'form.errorKeyPath': '私钥路径不能为空',
|
|
115
|
-
'form.errorPassword': '密码不能为空',
|
|
116
|
-
'settings.nav': '服务器连接器',
|
|
117
|
-
'settings.title': '服务器连接器',
|
|
118
|
-
'settings.description': '面板、连接池与危险操作的两段式确认。',
|
|
119
|
-
'settings.global': '全局设置',
|
|
120
|
-
'settings.globalHint': '这些值来自 profile 配置,修改后重启插件生效。',
|
|
121
|
-
'settings.pending': '待确认操作',
|
|
122
|
-
'settings.pendingEmpty': '没有待确认的操作。',
|
|
123
|
-
'settings.pendingHint': '删除服务器、关闭主机密钥校验、停用高危规则都会先进入这里;确认前目标保持原样。',
|
|
124
|
-
'rules.section': '自定义规则',
|
|
125
|
-
'rules.hint': '只作用于这台服务器;默认档只能是 confirm 或 block。保存前请先试跑,确认正则能命中真实命令。',
|
|
126
|
-
'rules.empty': '还没有自定义规则。',
|
|
127
|
-
'rules.add': '新增规则',
|
|
128
|
-
'rules.delete': '删除',
|
|
129
|
-
'rules.label': '标签',
|
|
130
|
-
'rules.pattern': '正则(区分大小写的部分用 (?i) 关闭)',
|
|
131
|
-
'rules.decision': '默认档',
|
|
132
|
-
'rules.description': '说明',
|
|
133
|
-
'rules.samples': '样例命令(每行一条)',
|
|
134
|
-
'rules.samplesHint': '至少一条样例必须能命中,否则规则会因「永不触发」被拒绝。',
|
|
135
|
-
'rules.preview': '试跑',
|
|
136
|
-
'rules.wouldMatch': '是否命中',
|
|
137
|
-
'rules.needSample': '试跑前请至少填一条样例命令。',
|
|
138
|
-
'pending.deleteServer': '删除服务器「{name}」',
|
|
139
|
-
'pending.disableHostkey': '关闭主机密钥校验「{name}」',
|
|
140
|
-
'pending.disableRule': '停用高危规则「{rule}」({name})',
|
|
141
|
-
'pending.requestedAt': '申请于 {time}',
|
|
142
|
-
'pending.expiresAt': '{time} 过期',
|
|
143
|
-
'pending.confirm': '确认执行',
|
|
144
|
-
'pending.cancel': '取消',
|
|
145
|
-
'pending.busy': '执行中…',
|
|
146
|
-
'pending.targetMissing': '目标已不存在',
|
|
147
|
-
'error.generic': '操作失败:{message}',
|
|
148
|
-
'error.load': '读取失败:{message}',
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** English dictionary, key-identical to the Chinese source of truth. */
|
|
152
|
-
const en = {
|
|
153
|
-
'panel.label': 'Servers',
|
|
154
|
-
'panel.title': 'Servers',
|
|
155
|
-
'panel.add': 'Add',
|
|
156
|
-
'panel.loading': 'Reading servers…',
|
|
157
|
-
'panel.retry': 'Retry',
|
|
158
|
-
'panel.empty': 'No server configured yet.',
|
|
159
|
-
'panel.emptyHint': 'Add one and the model can run commands on this machine through the srv_* tools.',
|
|
160
|
-
'panel.connected': 'Connected',
|
|
161
|
-
'panel.disconnected': 'Not connected',
|
|
162
|
-
'panel.test': 'Test',
|
|
163
|
-
'panel.testing': 'Testing…',
|
|
164
|
-
'panel.edit': 'Edit',
|
|
165
|
-
'panel.enable': 'Enable',
|
|
166
|
-
'panel.disable': 'Disable',
|
|
167
|
-
'panel.disabled': 'Disabled',
|
|
168
|
-
'panel.disconnectFirst': 'Disconnect this server before disabling it',
|
|
169
|
-
'panel.disabledNote': 'Disabled: srv_list no longer returns it and every srv_* call is rejected.',
|
|
170
|
-
'panel.back': 'Back to list',
|
|
171
|
-
'panel.delete': 'Delete',
|
|
172
|
-
'panel.deleteHint': 'Deleting first queues a request; the server is removed only after you confirm it.',
|
|
173
|
-
'panel.deleteQueued': 'The delete request is queued and the server still works; confirm it in Settings.',
|
|
174
|
-
'panel.sectionConnection': 'Connection',
|
|
175
|
-
'panel.sectionPolicy': 'High-risk policy',
|
|
176
|
-
'panel.sectionAudit': `Last ${AUDIT_LIMIT} audit records`,
|
|
177
|
-
'panel.auditEmpty': 'No audit records.',
|
|
178
|
-
'panel.auditLoading': 'Reading the audit log…',
|
|
179
|
-
'panel.notFound': 'This server no longer exists.',
|
|
180
|
-
'panel.lastConnected': 'Last connected {time}',
|
|
181
|
-
'panel.neverConnected': 'Never connected',
|
|
182
|
-
'status.connected': 'Connected: {platform} ({shell}, sudo {sudo}) in {ms} ms',
|
|
183
|
-
'field.name': 'Name',
|
|
184
|
-
'field.host': 'Host',
|
|
185
|
-
'field.port': 'Port',
|
|
186
|
-
'field.username': 'Username',
|
|
187
|
-
'field.auth': 'Authentication',
|
|
188
|
-
'field.privateKeyPath': 'Private key path',
|
|
189
|
-
'field.password': 'Password',
|
|
190
|
-
'field.hostKeyMode': 'Host-key policy',
|
|
191
|
-
'field.defaultWorkDir': 'Default working directory',
|
|
192
|
-
'field.lastConnectedAt': 'Last connected',
|
|
193
|
-
'field.policy': 'High-risk policy',
|
|
194
|
-
'auth.key': 'Private key',
|
|
195
|
-
'auth.password': 'Password',
|
|
196
|
-
'auth.agent': 'SSH agent',
|
|
197
|
-
'auth.passwordConfigured': 'configured',
|
|
198
|
-
'hostKey.accept-new': 'accept-new (accept and remember first)',
|
|
199
|
-
'hostKey.verify': 'verify (strict)',
|
|
200
|
-
'hostKey.off': 'off (verification off)',
|
|
201
|
-
'form.new': 'Add server',
|
|
202
|
-
'form.edit': 'Edit server',
|
|
203
|
-
'form.save': 'Save',
|
|
204
|
-
'form.saving': 'Saving…',
|
|
205
|
-
'form.cancel': 'Cancel',
|
|
206
|
-
'form.portRange': 'The port must be an integer from 1 to 65535',
|
|
207
|
-
'form.absolutePath': 'Must be an absolute path',
|
|
208
|
-
'form.passwordKeep': 'Leave blank to keep the current password',
|
|
209
|
-
'form.passwordNew': 'The password is write-only',
|
|
210
|
-
'form.policyHint': 'A built-in block rule can be lowered to confirm, never straight to allow; choosing “off” queues a pending request.',
|
|
211
|
-
'form.offOption': 'Off',
|
|
212
|
-
'form.offQueued': '“Off” is queued; the rule keeps working until you confirm.',
|
|
213
|
-
'form.pendingNote': 'The dangerous change is queued and takes effect only after you confirm it.',
|
|
214
|
-
'form.errorName': 'Name is required',
|
|
215
|
-
'form.errorHost': 'Host is required',
|
|
216
|
-
'form.errorUsername': 'Username is required',
|
|
217
|
-
'form.errorKeyPath': 'Private key path is required',
|
|
218
|
-
'form.errorPassword': 'Password is required',
|
|
219
|
-
'settings.nav': 'Server Connector',
|
|
220
|
-
'settings.title': 'Server Connector',
|
|
221
|
-
'settings.description': 'The panel, the connection pool and two-step confirmation for dangerous operations.',
|
|
222
|
-
'settings.global': 'Global settings',
|
|
223
|
-
'settings.globalHint': 'These values come from the profile configuration; restart the plugin to change them.',
|
|
224
|
-
'settings.pending': 'Pending operations',
|
|
225
|
-
'settings.pendingEmpty': 'Nothing is pending.',
|
|
226
|
-
'settings.pendingHint': 'Deleting a server, switching host-key verification off and disabling a high-risk rule all wait here; the target stays as it is until you confirm.',
|
|
227
|
-
'rules.section': 'Custom rules',
|
|
228
|
-
'rules.hint': 'These apply to this server only; the default decision may be confirm or block. Run a preview before saving so you know the pattern matches a real command.',
|
|
229
|
-
'rules.empty': 'No custom rules yet.',
|
|
230
|
-
'rules.add': 'Add rule',
|
|
231
|
-
'rules.delete': 'Delete',
|
|
232
|
-
'rules.label': 'Label',
|
|
233
|
-
'rules.pattern': 'Pattern (regular expression)',
|
|
234
|
-
'rules.decision': 'Default decision',
|
|
235
|
-
'rules.description': 'Description',
|
|
236
|
-
'rules.samples': 'Sample commands (one per line)',
|
|
237
|
-
'rules.samplesHint': 'At least one sample must match, otherwise the rule is rejected as one that never fires.',
|
|
238
|
-
'rules.preview': 'Preview',
|
|
239
|
-
'rules.wouldMatch': 'Matches',
|
|
240
|
-
'rules.needSample': 'Add at least one sample command before previewing.',
|
|
241
|
-
'pending.deleteServer': 'Delete server “{name}”',
|
|
242
|
-
'pending.disableHostkey': 'Turn off host-key verification for “{name}”',
|
|
243
|
-
'pending.disableRule': 'Disable high-risk rule “{rule}” on “{name}”',
|
|
244
|
-
'pending.requestedAt': 'Requested {time}',
|
|
245
|
-
'pending.expiresAt': 'expires {time}',
|
|
246
|
-
'pending.confirm': 'Confirm',
|
|
247
|
-
'pending.cancel': 'Cancel',
|
|
248
|
-
'pending.busy': 'Working…',
|
|
249
|
-
'pending.targetMissing': 'the target is already gone',
|
|
250
|
-
'error.generic': 'The operation failed: {message}',
|
|
251
|
-
'error.load': 'Could not read: {message}',
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// --------------------------------------------------------------- style --
|
|
255
|
-
|
|
256
|
-
/** One stylesheet, injected once by the module system (§ client styles claim the tag). */
|
|
257
|
-
const CSS = [
|
|
258
|
-
'.srv-page{box-sizing:border-box;height:100%;overflow:auto;padding:24px clamp(16px,3vw,40px) 48px;color:var(--dsw-alias-label-primary);',
|
|
259
|
-
'font-size:14px;line-height:22px;container-type:inline-size}',
|
|
260
|
-
'.srv-column{max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:16px}',
|
|
261
|
-
'.srv-head{display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap}',
|
|
262
|
-
'.srv-head h2{margin:0;flex:1 1 auto;min-width:0;font-size:20px;line-height:28px;font-weight:500;',
|
|
263
|
-
'overflow-wrap:anywhere}',
|
|
264
|
-
'.srv-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
265
|
-
'.srv-btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 14px;',
|
|
266
|
-
'border:0;border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;',
|
|
267
|
-
'font-size:13px;cursor:pointer;white-space:nowrap}',
|
|
268
|
-
'.srv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
269
|
-
'.srv-btn:disabled{cursor:not-allowed;opacity:.4}',
|
|
270
|
-
'.srv-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
|
|
271
|
-
'.srv-btn-sm{height:26px;padding:0 10px;font-size:12px;border-radius:13px}',
|
|
272
|
-
'.srv-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}',
|
|
273
|
-
'.srv-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
|
|
274
|
-
'.srv-btn-outline{border:.5px solid var(--dsw-alias-border-l3)}',
|
|
275
|
-
'.srv-btn-danger{color:var(--dsw-alias-state-error-primary)}',
|
|
276
|
-
'.srv-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
|
|
277
|
-
'.srv-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}',
|
|
278
|
-
'.srv-row{display:flex;align-items:flex-start;gap:4px 12px;flex-wrap:wrap;padding:10px 8px;border-radius:12px;min-width:0}',
|
|
279
|
-
'.srv-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
280
|
-
'.srv-row-off{opacity:.55}',
|
|
281
|
-
'
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
'
|
|
293
|
-
'
|
|
294
|
-
'.srv-
|
|
295
|
-
'
|
|
296
|
-
'.srv-
|
|
297
|
-
'.srv-
|
|
298
|
-
'
|
|
299
|
-
'
|
|
300
|
-
'
|
|
301
|
-
'
|
|
302
|
-
'.srv-
|
|
303
|
-
'color:
|
|
304
|
-
'
|
|
305
|
-
'.srv-
|
|
306
|
-
'.srv-
|
|
307
|
-
'
|
|
308
|
-
'border-radius:
|
|
309
|
-
'.srv-
|
|
310
|
-
'.srv-
|
|
311
|
-
'
|
|
312
|
-
'.srv-
|
|
313
|
-
'
|
|
314
|
-
'.srv-
|
|
315
|
-
'
|
|
316
|
-
'.srv-
|
|
317
|
-
'.srv-
|
|
318
|
-
'.srv-
|
|
319
|
-
'
|
|
320
|
-
'border
|
|
321
|
-
'.srv-
|
|
322
|
-
'.srv-
|
|
323
|
-
'.srv-
|
|
324
|
-
'.srv-
|
|
325
|
-
'.srv-
|
|
326
|
-
'.srv-
|
|
327
|
-
'
|
|
328
|
-
'.srv-
|
|
329
|
-
'.srv-
|
|
330
|
-
'
|
|
331
|
-
'
|
|
332
|
-
'
|
|
333
|
-
'.srv-
|
|
334
|
-
'.srv-
|
|
335
|
-
'.srv-
|
|
336
|
-
'.srv-
|
|
337
|
-
'.srv-
|
|
338
|
-
'.srv-
|
|
339
|
-
'
|
|
340
|
-
'.srv-
|
|
341
|
-
'.srv-
|
|
342
|
-
'
|
|
343
|
-
'.srv-
|
|
344
|
-
'
|
|
345
|
-
'border-
|
|
346
|
-
'
|
|
347
|
-
'.srv-
|
|
348
|
-
'.srv-
|
|
349
|
-
'
|
|
350
|
-
'.srv-
|
|
351
|
-
'.srv-
|
|
352
|
-
'.srv-
|
|
353
|
-
'.srv-
|
|
354
|
-
'
|
|
355
|
-
'
|
|
356
|
-
'.srv-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
if (
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
)
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
*
|
|
425
|
-
* @
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
if (typeof
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
return
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
return
|
|
445
|
-
'
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
)
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
await
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
h('
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
h(
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
h('
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
value: draft.
|
|
860
|
-
onChange: (/** @type {any} */ event) => setDraft({ ...draft,
|
|
861
|
-
}),
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
{
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
h(
|
|
939
|
-
'
|
|
940
|
-
{ className: 'srv-
|
|
941
|
-
h(
|
|
942
|
-
h(
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
h('
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
h('dt', null, t('field.
|
|
958
|
-
h(
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
),
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
)
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
* The
|
|
1054
|
-
*
|
|
1055
|
-
* @param {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
const [
|
|
1078
|
-
const [
|
|
1079
|
-
const [
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
return
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
if (
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
if (
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
{
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
),
|
|
1194
|
-
h(
|
|
1195
|
-
'div',
|
|
1196
|
-
{ className: 'srv-field
|
|
1197
|
-
h(
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
h(
|
|
1210
|
-
'div',
|
|
1211
|
-
{ className: 'srv-field' },
|
|
1212
|
-
h('label', { htmlFor: 'srv-
|
|
1213
|
-
h('input', {
|
|
1214
|
-
id: 'srv-
|
|
1215
|
-
className: 'srv-input',
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
),
|
|
1236
|
-
h(
|
|
1237
|
-
'div',
|
|
1238
|
-
{ className: 'srv-field' },
|
|
1239
|
-
h('
|
|
1240
|
-
h(
|
|
1241
|
-
'
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
),
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
'
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
),
|
|
1318
|
-
h(
|
|
1319
|
-
'div',
|
|
1320
|
-
{ className: 'srv-field' },
|
|
1321
|
-
h('
|
|
1322
|
-
h('
|
|
1323
|
-
|
|
1324
|
-
'
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
),
|
|
1351
|
-
),
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
notice: status,
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
h(
|
|
1551
|
-
'div',
|
|
1552
|
-
{ className: 'srv-
|
|
1553
|
-
h(
|
|
1554
|
-
h(
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
React.
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
'
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
},
|
|
1691
|
-
|
|
1692
|
-
),
|
|
1693
|
-
)
|
|
1694
|
-
|
|
1695
|
-
ctx.slots.inject('
|
|
1696
|
-
ctx.slots.register(
|
|
1697
|
-
{
|
|
1698
|
-
name: '
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
)
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
}
|
|
1
|
+
// dsh-server-connector — Client half (design doc §8).
|
|
2
|
+
//
|
|
3
|
+
// Three mount points, all taken from the confirmed live Slot tree (§8.1):
|
|
4
|
+
//
|
|
5
|
+
// sidebar.panellist → the "服务器" icon in the left rail
|
|
6
|
+
// main (key: "server-connector") → server list / detail / form
|
|
7
|
+
// settings.section → global settings + the pending two-step queue
|
|
8
|
+
//
|
|
9
|
+
// The file is a browser module-loader bundle, not a Node module: the DSH client
|
|
10
|
+
// module system loads it through `window.__ModuleLoader__.load`, and `require`
|
|
11
|
+
// is the page's static table (react, react/jsx-runtime, …). It is hand-authored
|
|
12
|
+
// — this repository has no client build step — so it uses `React.createElement`
|
|
13
|
+
// rather than JSX and keeps every browser global behind `browserGlobal` so the
|
|
14
|
+
// Node-side `tsc --noEmit` pass (tsconfig lib is ES2023, no DOM) stays clean.
|
|
15
|
+
//
|
|
16
|
+
// Two product constraints shape the UI:
|
|
17
|
+
// - the panel talks to the Host only through the §8.5 JSON routes, and it
|
|
18
|
+
// never receives a secret (a password is write-only, §6.3);
|
|
19
|
+
// - the panel has no turn, so `ctx.approval` is unavailable (§11): deleting a
|
|
20
|
+
// server, switching host-key verification off and disabling a rule all write
|
|
21
|
+
// to the pending queue and say so. Nothing dangerous happens on one click.
|
|
22
|
+
|
|
23
|
+
/** The page global, typed loosely: this bundle runs in the browser, not in Node. */
|
|
24
|
+
const browserGlobal = /** @type {any} */ (globalThis)
|
|
25
|
+
|
|
26
|
+
browserGlobal.__ModuleLoader__.load({
|
|
27
|
+
id: 'dsh-server-connector',
|
|
28
|
+
factory: (/** @type {(id: string) => any} */ load) => {
|
|
29
|
+
const module = { exports: /** @type {Record<string, unknown>} */ ({}) }
|
|
30
|
+
const exports = module.exports
|
|
31
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
32
|
+
|
|
33
|
+
const React = load('react')
|
|
34
|
+
const h = React.createElement
|
|
35
|
+
const F = React.Fragment
|
|
36
|
+
|
|
37
|
+
/** Dictionary namespace owned by this plugin. */
|
|
38
|
+
const NS = 'serverConnector'
|
|
39
|
+
/** The id shared by the sidebar entry, the main panel key and the settings page. */
|
|
40
|
+
const PANEL_ID = 'server-connector'
|
|
41
|
+
/** Mount path of the Host routes (§8.5). */
|
|
42
|
+
const API = '/server-connector'
|
|
43
|
+
/** A displayed audit tail matches the detail page (§8.2). */
|
|
44
|
+
const AUDIT_LIMIT = 20
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------- copy --
|
|
47
|
+
|
|
48
|
+
/** Chinese dictionary, the source of truth. */
|
|
49
|
+
const zh = {
|
|
50
|
+
'panel.label': '服务器',
|
|
51
|
+
'panel.title': '服务器',
|
|
52
|
+
'panel.add': '新增',
|
|
53
|
+
'panel.loading': '读取服务器列表…',
|
|
54
|
+
'panel.retry': '重试',
|
|
55
|
+
'panel.empty': '还没有配置服务器。',
|
|
56
|
+
'panel.emptyHint': '新增一台后,模型即可通过 srv_* 工具在这台机器上执行命令。',
|
|
57
|
+
'panel.connected': '已连接',
|
|
58
|
+
'panel.disconnected': '未连接',
|
|
59
|
+
'panel.test': '测试',
|
|
60
|
+
'panel.testing': '测试中…',
|
|
61
|
+
'panel.edit': '编辑',
|
|
62
|
+
'panel.enable': '启用',
|
|
63
|
+
'panel.disable': '禁用',
|
|
64
|
+
'panel.disabled': '已禁用',
|
|
65
|
+
'panel.disconnectFirst': '已连接的服务器不能禁用,请先断开',
|
|
66
|
+
'panel.disabledNote': '已禁用:srv_list 不再返回它,任何 srv_* 调用都会被拒绝。',
|
|
67
|
+
'panel.back': '返回列表',
|
|
68
|
+
'panel.delete': '删除',
|
|
69
|
+
'panel.deleteHint': '删除会先写入待确认区,确认后才真正移除。',
|
|
70
|
+
'panel.deleteQueued': '删除请求已加入待确认区,服务器仍然可用;请到设置页确认。',
|
|
71
|
+
'panel.sectionConnection': '连接',
|
|
72
|
+
'panel.sectionPolicy': '高危策略',
|
|
73
|
+
'panel.sectionAudit': `最近 ${AUDIT_LIMIT} 条审计`,
|
|
74
|
+
'panel.auditEmpty': '没有审计记录。',
|
|
75
|
+
'panel.auditLoading': '读取审计…',
|
|
76
|
+
'panel.notFound': '这台服务器已不存在。',
|
|
77
|
+
'panel.lastConnected': '上次连接 {time}',
|
|
78
|
+
'panel.neverConnected': '从未连接',
|
|
79
|
+
'status.connected': '连接成功:{platform}({shell},sudo {sudo}),耗时 {ms} ms',
|
|
80
|
+
'field.name': '名称',
|
|
81
|
+
'field.host': '主机',
|
|
82
|
+
'field.port': '端口',
|
|
83
|
+
'field.username': '用户名',
|
|
84
|
+
'field.auth': '认证方式',
|
|
85
|
+
'field.privateKeyPath': '私钥路径',
|
|
86
|
+
'field.password': '密码',
|
|
87
|
+
'field.hostKeyMode': '主机密钥策略',
|
|
88
|
+
'field.defaultWorkDir': '默认工作目录',
|
|
89
|
+
'field.lastConnectedAt': '最近连接',
|
|
90
|
+
'field.policy': '高危策略',
|
|
91
|
+
'auth.key': '私钥',
|
|
92
|
+
'auth.password': '密码',
|
|
93
|
+
'auth.agent': 'SSH agent',
|
|
94
|
+
'auth.passwordConfigured': '已设置',
|
|
95
|
+
'hostKey.accept-new': 'accept-new(首次接受并记住)',
|
|
96
|
+
'hostKey.verify': 'verify(严格校验)',
|
|
97
|
+
'hostKey.off': 'off(关闭校验)',
|
|
98
|
+
'form.new': '新增服务器',
|
|
99
|
+
'form.edit': '编辑服务器',
|
|
100
|
+
'form.save': '保存',
|
|
101
|
+
'form.saving': '保存中…',
|
|
102
|
+
'form.cancel': '取消',
|
|
103
|
+
'form.portRange': '端口必须是 1–65535 的整数',
|
|
104
|
+
'form.absolutePath': '必须是绝对路径',
|
|
105
|
+
'form.passwordKeep': '留空则保持当前密码',
|
|
106
|
+
'form.passwordNew': '密码只写不回显',
|
|
107
|
+
'form.policyHint': '内置 block 规则只能降为 confirm,不能直接改为 allow;选择「停用」会先写入待确认区。',
|
|
108
|
+
'form.offOption': '停用',
|
|
109
|
+
'form.offQueued': '「停用」会写入待确认区,确认后该规则才失效。',
|
|
110
|
+
'form.pendingNote': '危险改动已写入待确认区,确认后才生效。',
|
|
111
|
+
'form.errorName': '名称不能为空',
|
|
112
|
+
'form.errorHost': '主机不能为空',
|
|
113
|
+
'form.errorUsername': '用户名不能为空',
|
|
114
|
+
'form.errorKeyPath': '私钥路径不能为空',
|
|
115
|
+
'form.errorPassword': '密码不能为空',
|
|
116
|
+
'settings.nav': '服务器连接器',
|
|
117
|
+
'settings.title': '服务器连接器',
|
|
118
|
+
'settings.description': '面板、连接池与危险操作的两段式确认。',
|
|
119
|
+
'settings.global': '全局设置',
|
|
120
|
+
'settings.globalHint': '这些值来自 profile 配置,修改后重启插件生效。',
|
|
121
|
+
'settings.pending': '待确认操作',
|
|
122
|
+
'settings.pendingEmpty': '没有待确认的操作。',
|
|
123
|
+
'settings.pendingHint': '删除服务器、关闭主机密钥校验、停用高危规则都会先进入这里;确认前目标保持原样。',
|
|
124
|
+
'rules.section': '自定义规则',
|
|
125
|
+
'rules.hint': '只作用于这台服务器;默认档只能是 confirm 或 block。保存前请先试跑,确认正则能命中真实命令。',
|
|
126
|
+
'rules.empty': '还没有自定义规则。',
|
|
127
|
+
'rules.add': '新增规则',
|
|
128
|
+
'rules.delete': '删除',
|
|
129
|
+
'rules.label': '标签',
|
|
130
|
+
'rules.pattern': '正则(区分大小写的部分用 (?i) 关闭)',
|
|
131
|
+
'rules.decision': '默认档',
|
|
132
|
+
'rules.description': '说明',
|
|
133
|
+
'rules.samples': '样例命令(每行一条)',
|
|
134
|
+
'rules.samplesHint': '至少一条样例必须能命中,否则规则会因「永不触发」被拒绝。',
|
|
135
|
+
'rules.preview': '试跑',
|
|
136
|
+
'rules.wouldMatch': '是否命中',
|
|
137
|
+
'rules.needSample': '试跑前请至少填一条样例命令。',
|
|
138
|
+
'pending.deleteServer': '删除服务器「{name}」',
|
|
139
|
+
'pending.disableHostkey': '关闭主机密钥校验「{name}」',
|
|
140
|
+
'pending.disableRule': '停用高危规则「{rule}」({name})',
|
|
141
|
+
'pending.requestedAt': '申请于 {time}',
|
|
142
|
+
'pending.expiresAt': '{time} 过期',
|
|
143
|
+
'pending.confirm': '确认执行',
|
|
144
|
+
'pending.cancel': '取消',
|
|
145
|
+
'pending.busy': '执行中…',
|
|
146
|
+
'pending.targetMissing': '目标已不存在',
|
|
147
|
+
'error.generic': '操作失败:{message}',
|
|
148
|
+
'error.load': '读取失败:{message}',
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** English dictionary, key-identical to the Chinese source of truth. */
|
|
152
|
+
const en = {
|
|
153
|
+
'panel.label': 'Servers',
|
|
154
|
+
'panel.title': 'Servers',
|
|
155
|
+
'panel.add': 'Add',
|
|
156
|
+
'panel.loading': 'Reading servers…',
|
|
157
|
+
'panel.retry': 'Retry',
|
|
158
|
+
'panel.empty': 'No server configured yet.',
|
|
159
|
+
'panel.emptyHint': 'Add one and the model can run commands on this machine through the srv_* tools.',
|
|
160
|
+
'panel.connected': 'Connected',
|
|
161
|
+
'panel.disconnected': 'Not connected',
|
|
162
|
+
'panel.test': 'Test',
|
|
163
|
+
'panel.testing': 'Testing…',
|
|
164
|
+
'panel.edit': 'Edit',
|
|
165
|
+
'panel.enable': 'Enable',
|
|
166
|
+
'panel.disable': 'Disable',
|
|
167
|
+
'panel.disabled': 'Disabled',
|
|
168
|
+
'panel.disconnectFirst': 'Disconnect this server before disabling it',
|
|
169
|
+
'panel.disabledNote': 'Disabled: srv_list no longer returns it and every srv_* call is rejected.',
|
|
170
|
+
'panel.back': 'Back to list',
|
|
171
|
+
'panel.delete': 'Delete',
|
|
172
|
+
'panel.deleteHint': 'Deleting first queues a request; the server is removed only after you confirm it.',
|
|
173
|
+
'panel.deleteQueued': 'The delete request is queued and the server still works; confirm it in Settings.',
|
|
174
|
+
'panel.sectionConnection': 'Connection',
|
|
175
|
+
'panel.sectionPolicy': 'High-risk policy',
|
|
176
|
+
'panel.sectionAudit': `Last ${AUDIT_LIMIT} audit records`,
|
|
177
|
+
'panel.auditEmpty': 'No audit records.',
|
|
178
|
+
'panel.auditLoading': 'Reading the audit log…',
|
|
179
|
+
'panel.notFound': 'This server no longer exists.',
|
|
180
|
+
'panel.lastConnected': 'Last connected {time}',
|
|
181
|
+
'panel.neverConnected': 'Never connected',
|
|
182
|
+
'status.connected': 'Connected: {platform} ({shell}, sudo {sudo}) in {ms} ms',
|
|
183
|
+
'field.name': 'Name',
|
|
184
|
+
'field.host': 'Host',
|
|
185
|
+
'field.port': 'Port',
|
|
186
|
+
'field.username': 'Username',
|
|
187
|
+
'field.auth': 'Authentication',
|
|
188
|
+
'field.privateKeyPath': 'Private key path',
|
|
189
|
+
'field.password': 'Password',
|
|
190
|
+
'field.hostKeyMode': 'Host-key policy',
|
|
191
|
+
'field.defaultWorkDir': 'Default working directory',
|
|
192
|
+
'field.lastConnectedAt': 'Last connected',
|
|
193
|
+
'field.policy': 'High-risk policy',
|
|
194
|
+
'auth.key': 'Private key',
|
|
195
|
+
'auth.password': 'Password',
|
|
196
|
+
'auth.agent': 'SSH agent',
|
|
197
|
+
'auth.passwordConfigured': 'configured',
|
|
198
|
+
'hostKey.accept-new': 'accept-new (accept and remember first)',
|
|
199
|
+
'hostKey.verify': 'verify (strict)',
|
|
200
|
+
'hostKey.off': 'off (verification off)',
|
|
201
|
+
'form.new': 'Add server',
|
|
202
|
+
'form.edit': 'Edit server',
|
|
203
|
+
'form.save': 'Save',
|
|
204
|
+
'form.saving': 'Saving…',
|
|
205
|
+
'form.cancel': 'Cancel',
|
|
206
|
+
'form.portRange': 'The port must be an integer from 1 to 65535',
|
|
207
|
+
'form.absolutePath': 'Must be an absolute path',
|
|
208
|
+
'form.passwordKeep': 'Leave blank to keep the current password',
|
|
209
|
+
'form.passwordNew': 'The password is write-only',
|
|
210
|
+
'form.policyHint': 'A built-in block rule can be lowered to confirm, never straight to allow; choosing “off” queues a pending request.',
|
|
211
|
+
'form.offOption': 'Off',
|
|
212
|
+
'form.offQueued': '“Off” is queued; the rule keeps working until you confirm.',
|
|
213
|
+
'form.pendingNote': 'The dangerous change is queued and takes effect only after you confirm it.',
|
|
214
|
+
'form.errorName': 'Name is required',
|
|
215
|
+
'form.errorHost': 'Host is required',
|
|
216
|
+
'form.errorUsername': 'Username is required',
|
|
217
|
+
'form.errorKeyPath': 'Private key path is required',
|
|
218
|
+
'form.errorPassword': 'Password is required',
|
|
219
|
+
'settings.nav': 'Server Connector',
|
|
220
|
+
'settings.title': 'Server Connector',
|
|
221
|
+
'settings.description': 'The panel, the connection pool and two-step confirmation for dangerous operations.',
|
|
222
|
+
'settings.global': 'Global settings',
|
|
223
|
+
'settings.globalHint': 'These values come from the profile configuration; restart the plugin to change them.',
|
|
224
|
+
'settings.pending': 'Pending operations',
|
|
225
|
+
'settings.pendingEmpty': 'Nothing is pending.',
|
|
226
|
+
'settings.pendingHint': 'Deleting a server, switching host-key verification off and disabling a high-risk rule all wait here; the target stays as it is until you confirm.',
|
|
227
|
+
'rules.section': 'Custom rules',
|
|
228
|
+
'rules.hint': 'These apply to this server only; the default decision may be confirm or block. Run a preview before saving so you know the pattern matches a real command.',
|
|
229
|
+
'rules.empty': 'No custom rules yet.',
|
|
230
|
+
'rules.add': 'Add rule',
|
|
231
|
+
'rules.delete': 'Delete',
|
|
232
|
+
'rules.label': 'Label',
|
|
233
|
+
'rules.pattern': 'Pattern (regular expression)',
|
|
234
|
+
'rules.decision': 'Default decision',
|
|
235
|
+
'rules.description': 'Description',
|
|
236
|
+
'rules.samples': 'Sample commands (one per line)',
|
|
237
|
+
'rules.samplesHint': 'At least one sample must match, otherwise the rule is rejected as one that never fires.',
|
|
238
|
+
'rules.preview': 'Preview',
|
|
239
|
+
'rules.wouldMatch': 'Matches',
|
|
240
|
+
'rules.needSample': 'Add at least one sample command before previewing.',
|
|
241
|
+
'pending.deleteServer': 'Delete server “{name}”',
|
|
242
|
+
'pending.disableHostkey': 'Turn off host-key verification for “{name}”',
|
|
243
|
+
'pending.disableRule': 'Disable high-risk rule “{rule}” on “{name}”',
|
|
244
|
+
'pending.requestedAt': 'Requested {time}',
|
|
245
|
+
'pending.expiresAt': 'expires {time}',
|
|
246
|
+
'pending.confirm': 'Confirm',
|
|
247
|
+
'pending.cancel': 'Cancel',
|
|
248
|
+
'pending.busy': 'Working…',
|
|
249
|
+
'pending.targetMissing': 'the target is already gone',
|
|
250
|
+
'error.generic': 'The operation failed: {message}',
|
|
251
|
+
'error.load': 'Could not read: {message}',
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --------------------------------------------------------------- style --
|
|
255
|
+
|
|
256
|
+
/** One stylesheet, injected once by the module system (§ client styles claim the tag). */
|
|
257
|
+
const CSS = [
|
|
258
|
+
'.srv-page{box-sizing:border-box;height:100%;overflow:auto;padding:24px clamp(16px,3vw,40px) 48px;color:var(--dsw-alias-label-primary);',
|
|
259
|
+
'font-size:14px;line-height:22px;container-type:inline-size}',
|
|
260
|
+
'.srv-column{max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:16px}',
|
|
261
|
+
'.srv-head{display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap}',
|
|
262
|
+
'.srv-head h2{margin:0;flex:1 1 auto;min-width:0;font-size:20px;line-height:28px;font-weight:500;',
|
|
263
|
+
'overflow-wrap:anywhere}',
|
|
264
|
+
'.srv-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
265
|
+
'.srv-btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 14px;',
|
|
266
|
+
'border:0;border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;',
|
|
267
|
+
'font-size:13px;cursor:pointer;white-space:nowrap}',
|
|
268
|
+
'.srv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
|
|
269
|
+
'.srv-btn:disabled{cursor:not-allowed;opacity:.4}',
|
|
270
|
+
'.srv-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
|
|
271
|
+
'.srv-btn-sm{height:26px;padding:0 10px;font-size:12px;border-radius:13px}',
|
|
272
|
+
'.srv-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}',
|
|
273
|
+
'.srv-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
|
|
274
|
+
'.srv-btn-outline{border:.5px solid var(--dsw-alias-border-l3)}',
|
|
275
|
+
'.srv-btn-danger{color:var(--dsw-alias-state-error-primary)}',
|
|
276
|
+
'.srv-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
|
|
277
|
+
'.srv-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}',
|
|
278
|
+
'.srv-row{display:flex;align-items:flex-start;gap:4px 12px;flex-wrap:wrap;padding:10px 8px;border-radius:12px;min-width:0}',
|
|
279
|
+
'.srv-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
|
|
280
|
+
'.srv-row-off{opacity:.55}',
|
|
281
|
+
// Mirrors the Host's own switch (ui-primitives), which inverts with the
|
|
282
|
+
// theme rather than staying monochrome:
|
|
283
|
+
//
|
|
284
|
+
// track thumb
|
|
285
|
+
// light on --dsw-alias-label-primary (#0f1115) --dsw-alias-label-primary-inverted (#fff)
|
|
286
|
+
// dark on --dsw-alias-label-primary (#f9fafb) --dsw-alias-label-primary-inverted (#353638)
|
|
287
|
+
//
|
|
288
|
+
// The thumb MUST use the inverted pair, not a fixed white: a hard-coded
|
|
289
|
+
// `#fff` disappears against the near-white track in dark mode, which reads
|
|
290
|
+
// as one blank bar. `label-primary` / `label-primary-inverted` are the only
|
|
291
|
+
// pair guaranteed to contrast in both themes.
|
|
292
|
+
'.srv-switch{position:relative;flex:0 0 auto;width:36px;height:20px;padding:0;border:none;border-radius:999px;',
|
|
293
|
+
'background:var(--dsw-alias-border-l4,rgba(0,0,0,.12));cursor:pointer;transition:background .15s ease}',
|
|
294
|
+
'.srv-switch[data-on="true"]{background:var(--dsw-alias-label-primary,#0f1115)}',
|
|
295
|
+
'.srv-switch:disabled{cursor:not-allowed;opacity:.5}',
|
|
296
|
+
'.srv-switch:focus-visible{outline:2px solid var(--dsw-alias-label-primary,#0f1115);outline-offset:2px}',
|
|
297
|
+
'.srv-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
|
|
298
|
+
'background:var(--dsw-alias-label-tertiary,#adb2b8);transition:transform .15s ease,background .15s ease}',
|
|
299
|
+
'.srv-switch[data-on="true"] .srv-switch-knob{transform:translateX(16px);',
|
|
300
|
+
'background:var(--dsw-alias-label-primary-inverted,#fff)}',
|
|
301
|
+
'.srv-row-main{flex:1 1 240px;min-width:0;display:flex;flex-direction:column;gap:2px}',
|
|
302
|
+
'.srv-row-title{display:flex;align-items:center;gap:8px;min-width:0;font-size:14px;line-height:20px;font-weight:500}',
|
|
303
|
+
'.srv-open{padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;',
|
|
304
|
+
'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}',
|
|
305
|
+
'.srv-open:hover{text-decoration:underline}',
|
|
306
|
+
'.srv-row-meta{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap;font-size:12px;line-height:18px;',
|
|
307
|
+
'color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
|
|
308
|
+
'.srv-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
|
|
309
|
+
'.srv-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
|
|
310
|
+
'.srv-tag{display:inline-flex;align-items:center;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;',
|
|
311
|
+
'padding:0 8px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
|
|
312
|
+
'.srv-tag[data-tone="success"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);',
|
|
313
|
+
'color:var(--dsw-alias-state-success-primary)}',
|
|
314
|
+
'.srv-tag[data-tone="warn"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);',
|
|
315
|
+
'color:var(--dsw-alias-state-warn-primary)}',
|
|
316
|
+
'.srv-row-actions{display:flex;align-items:center;gap:4px;flex:none;margin-left:auto}',
|
|
317
|
+
'.srv-empty{margin:0;padding:32px 8px;text-align:center;color:var(--dsw-alias-label-tertiary)}',
|
|
318
|
+
'.srv-empty p{margin:0 0 4px}',
|
|
319
|
+
'.srv-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:.5px solid var(--dsw-alias-border-l3);',
|
|
320
|
+
'border-radius:16px;min-width:0}',
|
|
321
|
+
'.srv-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
|
|
322
|
+
'.srv-card-title{margin:0;flex:1;font-size:15px;line-height:22px;font-weight:600}',
|
|
323
|
+
'.srv-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0;font-size:13px;line-height:20px}',
|
|
324
|
+
'.srv-kv dt{color:var(--dsw-alias-label-tertiary)}',
|
|
325
|
+
'.srv-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}',
|
|
326
|
+
'.srv-mono{font-family:var(--ds-font-family-code,Menlo,monospace);font-size:12px}',
|
|
327
|
+
'.srv-form{display:flex;flex-direction:column;gap:16px}',
|
|
328
|
+
'.srv-field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:13px;line-height:20px}',
|
|
329
|
+
'.srv-field-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px}',
|
|
330
|
+
'.srv-input,.srv-select{box-sizing:border-box;width:100%;min-width:0;height:32px;padding:0 8px;font:inherit;',
|
|
331
|
+
'font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);',
|
|
332
|
+
'border:.5px solid var(--dsw-alias-border-l4);border-radius:8px}',
|
|
333
|
+
'.srv-input:focus-visible,.srv-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
|
|
334
|
+
'.srv-input[rows]{height:auto;padding:6px 8px;resize:vertical}',
|
|
335
|
+
'.srv-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
|
|
336
|
+
'.srv-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}',
|
|
337
|
+
'.srv-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
|
|
338
|
+
'.srv-note{margin:0;padding:8px 12px;border-radius:10px;font-size:13px;line-height:20px;',
|
|
339
|
+
'background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);overflow-wrap:anywhere}',
|
|
340
|
+
'.srv-seg{display:inline-flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover)}',
|
|
341
|
+
'.srv-seg button{height:26px;padding:0 12px;border:0;border-radius:7px;background:transparent;cursor:pointer;',
|
|
342
|
+
'font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary)}',
|
|
343
|
+
'.srv-seg button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);',
|
|
344
|
+
'box-shadow:var(--dsw-elevation-soft)}',
|
|
345
|
+
'.srv-rules{border-top:.5px solid var(--dsw-alias-border-l2);margin:0;padding:0;list-style:none}',
|
|
346
|
+
'.srv-rule{display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
|
|
347
|
+
'.srv-rule-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
|
|
348
|
+
'.srv-rule-label{font-size:13px;line-height:20px;font-weight:500}',
|
|
349
|
+
'.srv-rule-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
|
|
350
|
+
'.srv-rule-select{flex:none;width:132px}',
|
|
351
|
+
'.srv-audit{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;font-size:12px;line-height:18px}',
|
|
352
|
+
'.srv-audit li{display:flex;gap:8px;align-items:baseline;min-width:0;padding:2px 0}',
|
|
353
|
+
'.srv-audit-time{flex:none;color:var(--dsw-alias-label-caption);font-variant-numeric:tabular-nums}',
|
|
354
|
+
'.srv-audit-decision{flex:none;width:56px;color:var(--dsw-alias-label-secondary)}',
|
|
355
|
+
'.srv-audit-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,Menlo,monospace)}',
|
|
356
|
+
'.srv-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;',
|
|
357
|
+
'border-radius:9px;background:var(--dsw-alias-state-error-primary);color:var(--dsw-static-neutral-bluish-00);',
|
|
358
|
+
'font-size:11px;line-height:18px;font-weight:600}',
|
|
359
|
+
'.srv-pending{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}',
|
|
360
|
+
'.srv-pending li{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 0;',
|
|
361
|
+
'border-bottom:.5px solid var(--dsw-alias-border-l2)}',
|
|
362
|
+
'.srv-pending-main{flex:1 1 220px;min-width:0;display:flex;flex-direction:column;gap:2px}',
|
|
363
|
+
'.srv-pending-title{font-size:13px;line-height:20px;font-weight:500;overflow-wrap:anywhere}',
|
|
364
|
+
'.srv-pending-meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption)}',
|
|
365
|
+
'.srv-danger-zone{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;',
|
|
366
|
+
'padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
|
|
367
|
+
'@container (max-width:640px){.srv-field-row{grid-template-columns:1fr}.srv-rule{flex-wrap:wrap}',
|
|
368
|
+
'.srv-rule-select{width:100%}}',
|
|
369
|
+
].join('')
|
|
370
|
+
|
|
371
|
+
/** Inject the stylesheet once; the client module system tags it for HMR cleanup. */
|
|
372
|
+
function installStyles() {
|
|
373
|
+
if (typeof browserGlobal.document === 'undefined') return
|
|
374
|
+
const document = browserGlobal.document
|
|
375
|
+
if (document.querySelector('style[data-server-connector]') !== null) return
|
|
376
|
+
const tag = document.createElement('style')
|
|
377
|
+
tag.setAttribute('data-server-connector', '')
|
|
378
|
+
tag.textContent = CSS
|
|
379
|
+
document.head.appendChild(tag)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ---------------------------------------------------------------- HTTP --
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* One route call. Same-origin, so the browser session cookie authenticates
|
|
386
|
+
* it exactly like the rest of the GUI (§8.5 trust boundary).
|
|
387
|
+
* @param {string} path - path below {@link API}.
|
|
388
|
+
* @param {{method?: string, body?: unknown, query?: Record<string, string>}} [options]
|
|
389
|
+
* @returns {Promise<any>}
|
|
390
|
+
*/
|
|
391
|
+
async function api(path, options = {}) {
|
|
392
|
+
const query = options.query === undefined ? '' : `?${new URLSearchParams(options.query).toString()}`
|
|
393
|
+
/** @type {Record<string, string>} */
|
|
394
|
+
const headers = {}
|
|
395
|
+
/** @type {RequestInit} */
|
|
396
|
+
const init = { method: options.method ?? 'GET', headers }
|
|
397
|
+
if (options.body !== undefined) {
|
|
398
|
+
headers['content-type'] = 'application/json'
|
|
399
|
+
init.body = JSON.stringify(options.body)
|
|
400
|
+
}
|
|
401
|
+
const response = await browserGlobal.fetch(`${API}${path}${query}`, init)
|
|
402
|
+
let payload
|
|
403
|
+
const text = await response.text()
|
|
404
|
+
try {
|
|
405
|
+
payload = text === '' ? null : JSON.parse(text)
|
|
406
|
+
} catch {
|
|
407
|
+
payload = null
|
|
408
|
+
}
|
|
409
|
+
if (!response.ok) {
|
|
410
|
+
throw Object.assign(
|
|
411
|
+
new Error(typeof payload?.error?.message === 'string' ? payload.error.message : `HTTP ${String(response.status)}`),
|
|
412
|
+
{
|
|
413
|
+
code: payload?.error?.code ?? `HTTP_${String(response.status)}`,
|
|
414
|
+
status: response.status,
|
|
415
|
+
details: payload?.error?.details ?? {},
|
|
416
|
+
},
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
return payload
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The displayable message of a thrown value.
|
|
424
|
+
* @param {unknown} error
|
|
425
|
+
* @returns {string}
|
|
426
|
+
*/
|
|
427
|
+
function messageOf(error) {
|
|
428
|
+
if (error instanceof Error) return error.message
|
|
429
|
+
if (typeof error === 'object' && error !== null && typeof (/** @type {any} */ (error).message) === 'string') {
|
|
430
|
+
return /** @type {any} */ (error).message
|
|
431
|
+
}
|
|
432
|
+
return String(error)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Format one timestamp for the panel; an unparsable value is shown as-is.
|
|
437
|
+
* @param {unknown} value
|
|
438
|
+
* @returns {string}
|
|
439
|
+
*/
|
|
440
|
+
function formatTime(value) {
|
|
441
|
+
if (typeof value !== 'string' || value === '') return '—'
|
|
442
|
+
const date = new Date(value)
|
|
443
|
+
if (Number.isNaN(date.getTime())) return value
|
|
444
|
+
return date.toLocaleString(undefined, {
|
|
445
|
+
month: '2-digit',
|
|
446
|
+
day: '2-digit',
|
|
447
|
+
hour: '2-digit',
|
|
448
|
+
minute: '2-digit',
|
|
449
|
+
})
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --------------------------------------------------------------- atoms --
|
|
453
|
+
|
|
454
|
+
/** @param {{size?: number, children?: any}} props */
|
|
455
|
+
function Icon({ size = 16, children }) {
|
|
456
|
+
return h(
|
|
457
|
+
'svg',
|
|
458
|
+
{
|
|
459
|
+
width: size,
|
|
460
|
+
height: size,
|
|
461
|
+
viewBox: '0 0 24 24',
|
|
462
|
+
fill: 'none',
|
|
463
|
+
stroke: 'currentColor',
|
|
464
|
+
strokeWidth: 1.8,
|
|
465
|
+
strokeLinecap: 'round',
|
|
466
|
+
strokeLinejoin: 'round',
|
|
467
|
+
'aria-hidden': true,
|
|
468
|
+
},
|
|
469
|
+
children,
|
|
470
|
+
)
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** The §8.1 sidebar icon: a rack of two server units. */
|
|
474
|
+
function ServerIcon({ size = 20 }) {
|
|
475
|
+
return h(
|
|
476
|
+
Icon,
|
|
477
|
+
{ size },
|
|
478
|
+
h('rect', { key: 'a', x: 3, y: 5, width: 18, height: 6, rx: 1.6 }),
|
|
479
|
+
h('rect', { key: 'b', x: 3, y: 13, width: 18, height: 6, rx: 1.6 }),
|
|
480
|
+
h('path', { key: 'c', d: 'M6.5 8h.01M6.5 16h.01' }),
|
|
481
|
+
)
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* @param {{variant?: string, size?: string, danger?: boolean, children?: any} & Record<string, any>} props
|
|
486
|
+
*/
|
|
487
|
+
function Button({ variant = 'ghost', size = 'md', danger = false, children, ...rest }) {
|
|
488
|
+
const className = [
|
|
489
|
+
'srv-btn',
|
|
490
|
+
size === 'sm' ? 'srv-btn-sm' : '',
|
|
491
|
+
variant === 'primary' ? 'srv-btn-primary' : '',
|
|
492
|
+
variant === 'outline' ? 'srv-btn-outline' : '',
|
|
493
|
+
danger ? 'srv-btn-danger' : '',
|
|
494
|
+
]
|
|
495
|
+
.filter(Boolean)
|
|
496
|
+
.join(' ')
|
|
497
|
+
return h('button', { type: 'button', className, ...rest }, children)
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** @param {{on: boolean}} props */
|
|
501
|
+
function StatusDot({ on }) {
|
|
502
|
+
return h('span', { className: 'srv-dot', 'data-on': on ? 'true' : 'false' })
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// --------------------------------------------------------------- hooks --
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Load `GET /servers` and keep a reload function.
|
|
509
|
+
* @returns {{state: any, reload: () => Promise<void>}}
|
|
510
|
+
*/
|
|
511
|
+
function useServers() {
|
|
512
|
+
const [state, setState] = React.useState({ phase: 'loading' })
|
|
513
|
+
const alive = React.useRef(true)
|
|
514
|
+
React.useEffect(
|
|
515
|
+
() => () => {
|
|
516
|
+
alive.current = false
|
|
517
|
+
},
|
|
518
|
+
[],
|
|
519
|
+
)
|
|
520
|
+
const reload = React.useCallback(async () => {
|
|
521
|
+
try {
|
|
522
|
+
const data = await api('/servers')
|
|
523
|
+
if (alive.current) setState({ phase: 'ready', data })
|
|
524
|
+
} catch (error) {
|
|
525
|
+
if (alive.current) setState({ phase: 'error', error })
|
|
526
|
+
}
|
|
527
|
+
}, [])
|
|
528
|
+
React.useEffect(() => {
|
|
529
|
+
void reload()
|
|
530
|
+
}, [reload])
|
|
531
|
+
return { state, reload }
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ------------------------------------------------------------- surfaces --
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The §8.2 list row.
|
|
538
|
+
* @param {{server: any, t: any, onOpen: (id: string) => void, onEdit: (id: string) => void, onTest: (id: string) => Promise<void>, onToggle: (server: any) => Promise<void>, testing: string|null, toggling: string|null}} props
|
|
539
|
+
*/
|
|
540
|
+
function ServerRow({ server, t, onOpen, onEdit, onTest, onToggle, testing, toggling }) {
|
|
541
|
+
const busy = testing === server.id
|
|
542
|
+
const off = server.enabled === false
|
|
543
|
+
const toggleBusy = toggling === server.id
|
|
544
|
+
return h(
|
|
545
|
+
'li',
|
|
546
|
+
{ className: off ? 'srv-row srv-row-off' : 'srv-row' },
|
|
547
|
+
h(StatusDot, { on: server.connected === true }),
|
|
548
|
+
h(
|
|
549
|
+
'div',
|
|
550
|
+
{ className: 'srv-row-main' },
|
|
551
|
+
h(
|
|
552
|
+
'div',
|
|
553
|
+
{ className: 'srv-row-title' },
|
|
554
|
+
h('button', { type: 'button', className: 'srv-open', onClick: () => onOpen(server.id) }, server.name),
|
|
555
|
+
),
|
|
556
|
+
h(
|
|
557
|
+
'div',
|
|
558
|
+
{ className: 'srv-row-meta' },
|
|
559
|
+
h('span', { className: 'srv-mono' }, `${server.username}@${server.host}:${String(server.port)}`),
|
|
560
|
+
server.defaultWorkDir === null
|
|
561
|
+
? null
|
|
562
|
+
: h('span', { className: 'srv-mono' }, server.defaultWorkDir),
|
|
563
|
+
h(
|
|
564
|
+
'span',
|
|
565
|
+
{ className: 'srv-tag', 'data-tone': server.connected === true ? 'success' : undefined },
|
|
566
|
+
server.connected === true ? t('panel.connected') : t('panel.disconnected'),
|
|
567
|
+
),
|
|
568
|
+
server.hostKeyMode === 'off' ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, 'host-key off') : null,
|
|
569
|
+
off ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, t('panel.disabled')) : null,
|
|
570
|
+
),
|
|
571
|
+
),
|
|
572
|
+
h(
|
|
573
|
+
'div',
|
|
574
|
+
{ className: 'srv-row-actions' },
|
|
575
|
+
// The switch is the only control the panel needs for the enable state:
|
|
576
|
+
// it applies immediately (no pending queue), so it reads as a switch.
|
|
577
|
+
h(Switch, {
|
|
578
|
+
checked: !off,
|
|
579
|
+
disabled: toggleBusy || server.connected === true,
|
|
580
|
+
label: off ? t('panel.enable') : t('panel.disable'),
|
|
581
|
+
hint: server.connected === true ? t('panel.disconnectFirst') : null,
|
|
582
|
+
onChange: () => void onToggle(server),
|
|
583
|
+
}),
|
|
584
|
+
h(Button, { size: 'sm', variant: 'outline', disabled: busy || off, onClick: () => void onTest(server.id) }, busy ? t('panel.testing') : t('panel.test')),
|
|
585
|
+
h(Button, { size: 'sm', onClick: () => onEdit(server.id) }, t('panel.edit')),
|
|
586
|
+
),
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A minimal on/off switch. The panel has no design-system switch in the
|
|
592
|
+
* injected surface, so this is the smallest one that reads as a switch and
|
|
593
|
+
* stays usable when disabled.
|
|
594
|
+
* @param {{checked: boolean, disabled?: boolean, label: string, hint?: string|null, onChange: () => void}} props
|
|
595
|
+
*/
|
|
596
|
+
function Switch({ checked, disabled = false, label, hint = null, onChange }) {
|
|
597
|
+
return h(
|
|
598
|
+
'button',
|
|
599
|
+
{
|
|
600
|
+
type: 'button',
|
|
601
|
+
role: 'switch',
|
|
602
|
+
'aria-checked': checked ? 'true' : 'false',
|
|
603
|
+
'aria-label': label,
|
|
604
|
+
title: hint ?? label,
|
|
605
|
+
className: 'srv-switch',
|
|
606
|
+
'data-on': checked ? 'true' : 'false',
|
|
607
|
+
disabled,
|
|
608
|
+
onClick: () => onChange(),
|
|
609
|
+
},
|
|
610
|
+
h('span', { className: 'srv-switch-knob', 'aria-hidden': 'true' }),
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* The list view (or the empty state). Owns no data: the panel passes state.
|
|
616
|
+
* @param {{state: any, t: any, reload: () => Promise<void>, onAdd: () => void, onOpen: (id: string) => void, onEdit: (id: string) => void, onTest: (id: string) => Promise<void>, onToggle: (server: any) => Promise<void>, testing: string|null, toggling: string|null, notice: string|null, onOpenSettings?: () => void}} props
|
|
617
|
+
*/
|
|
618
|
+
function ServerList({ state, t, reload, onAdd, onOpen, onEdit, onTest, onToggle, testing, toggling, notice }) {
|
|
619
|
+
const data = state.phase === 'ready' ? state.data : null
|
|
620
|
+
const servers = data === null ? [] : data.servers
|
|
621
|
+
const pendingCount = data === null ? 0 : data.pendingCount
|
|
622
|
+
return h(
|
|
623
|
+
F,
|
|
624
|
+
null,
|
|
625
|
+
h(
|
|
626
|
+
'div',
|
|
627
|
+
{ className: 'srv-head' },
|
|
628
|
+
h('h2', null, t('panel.title')),
|
|
629
|
+
pendingCount > 0
|
|
630
|
+
? h('span', { className: 'srv-badge', title: t('settings.pending') }, String(pendingCount))
|
|
631
|
+
: null,
|
|
632
|
+
h(
|
|
633
|
+
'div',
|
|
634
|
+
{ className: 'srv-head-actions' },
|
|
635
|
+
h(Button, { variant: 'primary', onClick: onAdd }, t('panel.add')),
|
|
636
|
+
),
|
|
637
|
+
),
|
|
638
|
+
notice === null ? null : h('p', { className: 'srv-note' }, notice),
|
|
639
|
+
state.phase === 'loading' ? h('p', { className: 'srv-empty' }, t('panel.loading')) : null,
|
|
640
|
+
state.phase === 'error'
|
|
641
|
+
? h(
|
|
642
|
+
'div',
|
|
643
|
+
{ className: 'srv-empty' },
|
|
644
|
+
h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) })),
|
|
645
|
+
h(Button, { variant: 'outline', onClick: () => void reload() }, t('panel.retry')),
|
|
646
|
+
)
|
|
647
|
+
: null,
|
|
648
|
+
state.phase === 'ready' && servers.length === 0
|
|
649
|
+
? h(
|
|
650
|
+
'div',
|
|
651
|
+
{ className: 'srv-empty' },
|
|
652
|
+
h('p', null, t('panel.empty')),
|
|
653
|
+
h('p', null, t('panel.emptyHint')),
|
|
654
|
+
)
|
|
655
|
+
: null,
|
|
656
|
+
servers.length > 0
|
|
657
|
+
? h(
|
|
658
|
+
'ul',
|
|
659
|
+
{ className: 'srv-list' },
|
|
660
|
+
servers.map((/** @type {any} */ server) =>
|
|
661
|
+
h(ServerRow, {
|
|
662
|
+
key: server.id,
|
|
663
|
+
server,
|
|
664
|
+
t,
|
|
665
|
+
onOpen,
|
|
666
|
+
onEdit,
|
|
667
|
+
onTest,
|
|
668
|
+
onToggle,
|
|
669
|
+
testing,
|
|
670
|
+
toggling,
|
|
671
|
+
}),
|
|
672
|
+
),
|
|
673
|
+
)
|
|
674
|
+
: null,
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Custom rules for one server (doc §4.4).
|
|
680
|
+
*
|
|
681
|
+
* The section owns its own draft state: a rule is only sent to the Host after
|
|
682
|
+
* the operator has seen a dry run of it against a sample command, which is
|
|
683
|
+
* the one guard that catches "the regex never fires".
|
|
684
|
+
* @param {{server: any, t: any, onRefresh: () => Promise<void>}} props
|
|
685
|
+
*/
|
|
686
|
+
function CustomRulesSection({ server, t, onRefresh }) {
|
|
687
|
+
const rules = Array.isArray(server.customRules) ? server.customRules : []
|
|
688
|
+
const [draft, setDraft] = React.useState(null)
|
|
689
|
+
const [busy, setBusy] = React.useState(false)
|
|
690
|
+
const [error, setError] = React.useState(null)
|
|
691
|
+
const [preview, setPreview] = React.useState(null)
|
|
692
|
+
|
|
693
|
+
const startAdd = () =>
|
|
694
|
+
setDraft({ label: '', pattern: '', defaultDecision: 'confirm', description: '', samples: '' })
|
|
695
|
+
const cancel = () => {
|
|
696
|
+
setDraft(null)
|
|
697
|
+
setPreview(null)
|
|
698
|
+
setError(null)
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** @param {any} event */
|
|
702
|
+
const dryRun = async (event) => {
|
|
703
|
+
event.preventDefault()
|
|
704
|
+
if (draft === null) return
|
|
705
|
+
setBusy(true)
|
|
706
|
+
setError(null)
|
|
707
|
+
try {
|
|
708
|
+
const samples = String(draft.samples ?? '')
|
|
709
|
+
.split('\n')
|
|
710
|
+
.map((line) => line.trim())
|
|
711
|
+
.filter((line) => line !== '')
|
|
712
|
+
if (samples.length === 0) throw new Error(t('rules.needSample'))
|
|
713
|
+
const data = await api(`/servers/${encodeURIComponent(server.id)}/rules/preview`, {
|
|
714
|
+
method: 'POST',
|
|
715
|
+
body: {
|
|
716
|
+
label: draft.label === '' ? 'preview' : draft.label,
|
|
717
|
+
pattern: draft.pattern,
|
|
718
|
+
defaultDecision: draft.defaultDecision,
|
|
719
|
+
samples,
|
|
720
|
+
},
|
|
721
|
+
})
|
|
722
|
+
setPreview(data)
|
|
723
|
+
} catch (failure) {
|
|
724
|
+
setPreview(null)
|
|
725
|
+
setError(messageOf(failure))
|
|
726
|
+
} finally {
|
|
727
|
+
setBusy(false)
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** @param {any} event */
|
|
732
|
+
const save = async (event) => {
|
|
733
|
+
event.preventDefault()
|
|
734
|
+
if (draft === null) return
|
|
735
|
+
setBusy(true)
|
|
736
|
+
setError(null)
|
|
737
|
+
try {
|
|
738
|
+
const samples = String(draft.samples ?? '')
|
|
739
|
+
.split('\n')
|
|
740
|
+
.map((line) => line.trim())
|
|
741
|
+
.filter((line) => line !== '')
|
|
742
|
+
await api(`/servers/${encodeURIComponent(server.id)}/rules`, {
|
|
743
|
+
method: 'POST',
|
|
744
|
+
body: {
|
|
745
|
+
label: draft.label,
|
|
746
|
+
pattern: draft.pattern,
|
|
747
|
+
defaultDecision: draft.defaultDecision,
|
|
748
|
+
description: draft.description,
|
|
749
|
+
samples,
|
|
750
|
+
},
|
|
751
|
+
})
|
|
752
|
+
setDraft(null)
|
|
753
|
+
setPreview(null)
|
|
754
|
+
await onRefresh()
|
|
755
|
+
} catch (failure) {
|
|
756
|
+
setError(messageOf(failure))
|
|
757
|
+
} finally {
|
|
758
|
+
setBusy(false)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** @param {string} ruleId */
|
|
763
|
+
const remove = async (ruleId) => {
|
|
764
|
+
setBusy(true)
|
|
765
|
+
setError(null)
|
|
766
|
+
try {
|
|
767
|
+
await api(`/servers/${encodeURIComponent(server.id)}/rules/${encodeURIComponent(ruleId)}`, {
|
|
768
|
+
method: 'DELETE',
|
|
769
|
+
})
|
|
770
|
+
await onRefresh()
|
|
771
|
+
} catch (failure) {
|
|
772
|
+
setError(messageOf(failure))
|
|
773
|
+
} finally {
|
|
774
|
+
setBusy(false)
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return h(
|
|
779
|
+
'section',
|
|
780
|
+
{ className: 'srv-card' },
|
|
781
|
+
h('h3', { className: 'srv-card-title' }, t('rules.section')),
|
|
782
|
+
h('p', { className: 'srv-hint' }, t('rules.hint')),
|
|
783
|
+
rules.length === 0 ? h('p', { className: 'srv-hint' }, t('rules.empty')) : null,
|
|
784
|
+
rules.length > 0
|
|
785
|
+
? h(
|
|
786
|
+
'ul',
|
|
787
|
+
{ className: 'srv-rules' },
|
|
788
|
+
rules.map((/** @type {any} */ rule) =>
|
|
789
|
+
h(
|
|
790
|
+
'li',
|
|
791
|
+
{ className: 'srv-rule', key: rule.id },
|
|
792
|
+
h(
|
|
793
|
+
'div',
|
|
794
|
+
{ className: 'srv-rule-body' },
|
|
795
|
+
h('span', { className: 'srv-rule-label' }, rule.label),
|
|
796
|
+
h('span', { className: 'srv-rule-desc' }, rule.description || rule.pattern),
|
|
797
|
+
),
|
|
798
|
+
h('span', { className: 'srv-tag', 'data-tone': rule.defaultDecision === 'block' ? undefined : 'success' }, rule.defaultDecision),
|
|
799
|
+
h(
|
|
800
|
+
Button,
|
|
801
|
+
{ size: 'sm', danger: true, disabled: busy, onClick: () => void remove(rule.id) },
|
|
802
|
+
t('rules.delete'),
|
|
803
|
+
),
|
|
804
|
+
),
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
: null,
|
|
808
|
+
error !== null ? h('p', { className: 'srv-error' }, error) : null,
|
|
809
|
+
draft === null
|
|
810
|
+
? h(Button, { variant: 'outline', disabled: busy, onClick: startAdd }, t('rules.add'))
|
|
811
|
+
: h(
|
|
812
|
+
'form',
|
|
813
|
+
{ className: 'srv-form', onSubmit: save },
|
|
814
|
+
h(
|
|
815
|
+
'div',
|
|
816
|
+
{ className: 'srv-field' },
|
|
817
|
+
h('label', { htmlFor: 'srv-rule-label' }, t('rules.label')),
|
|
818
|
+
h('input', {
|
|
819
|
+
id: 'srv-rule-label',
|
|
820
|
+
className: 'srv-input',
|
|
821
|
+
value: draft.label,
|
|
822
|
+
onChange: (/** @type {any} */ event) => setDraft({ ...draft, label: event.target.value }),
|
|
823
|
+
}),
|
|
824
|
+
),
|
|
825
|
+
h(
|
|
826
|
+
'div',
|
|
827
|
+
{ className: 'srv-field' },
|
|
828
|
+
h('label', { htmlFor: 'srv-rule-pattern' }, t('rules.pattern')),
|
|
829
|
+
h('input', {
|
|
830
|
+
id: 'srv-rule-pattern',
|
|
831
|
+
className: 'srv-input srv-mono',
|
|
832
|
+
value: draft.pattern,
|
|
833
|
+
onChange: (/** @type {any} */ event) => setDraft({ ...draft, pattern: event.target.value }),
|
|
834
|
+
}),
|
|
835
|
+
),
|
|
836
|
+
h(
|
|
837
|
+
'div',
|
|
838
|
+
{ className: 'srv-field' },
|
|
839
|
+
h('label', { htmlFor: 'srv-rule-decision' }, t('rules.decision')),
|
|
840
|
+
h(
|
|
841
|
+
'select',
|
|
842
|
+
{
|
|
843
|
+
id: 'srv-rule-decision',
|
|
844
|
+
className: 'srv-select',
|
|
845
|
+
value: draft.defaultDecision,
|
|
846
|
+
onChange: (/** @type {any} */ event) => setDraft({ ...draft, defaultDecision: event.target.value }),
|
|
847
|
+
},
|
|
848
|
+
h('option', { value: 'confirm' }, 'confirm'),
|
|
849
|
+
h('option', { value: 'block' }, 'block'),
|
|
850
|
+
),
|
|
851
|
+
),
|
|
852
|
+
h(
|
|
853
|
+
'div',
|
|
854
|
+
{ className: 'srv-field' },
|
|
855
|
+
h('label', { htmlFor: 'srv-rule-desc' }, t('rules.description')),
|
|
856
|
+
h('input', {
|
|
857
|
+
id: 'srv-rule-desc',
|
|
858
|
+
className: 'srv-input',
|
|
859
|
+
value: draft.description,
|
|
860
|
+
onChange: (/** @type {any} */ event) => setDraft({ ...draft, description: event.target.value }),
|
|
861
|
+
}),
|
|
862
|
+
),
|
|
863
|
+
h(
|
|
864
|
+
'div',
|
|
865
|
+
{ className: 'srv-field' },
|
|
866
|
+
h('label', { htmlFor: 'srv-rule-samples' }, t('rules.samples')),
|
|
867
|
+
h('textarea', {
|
|
868
|
+
id: 'srv-rule-samples',
|
|
869
|
+
className: 'srv-input srv-mono',
|
|
870
|
+
rows: 3,
|
|
871
|
+
value: draft.samples,
|
|
872
|
+
onChange: (/** @type {any} */ event) => setDraft({ ...draft, samples: event.target.value }),
|
|
873
|
+
}),
|
|
874
|
+
h('span', { className: 'srv-hint' }, t('rules.samplesHint')),
|
|
875
|
+
),
|
|
876
|
+
preview !== null
|
|
877
|
+
? h(
|
|
878
|
+
'ul',
|
|
879
|
+
{ className: 'srv-rules' },
|
|
880
|
+
(preview.results ?? []).map((/** @type {any} */ item, /** @type {number} */ index) =>
|
|
881
|
+
h(
|
|
882
|
+
'li',
|
|
883
|
+
{ className: 'srv-rule', key: index },
|
|
884
|
+
h(
|
|
885
|
+
'div',
|
|
886
|
+
{ className: 'srv-rule-body' },
|
|
887
|
+
h('span', { className: 'srv-rule-label srv-mono' }, item.sample),
|
|
888
|
+
h('span', { className: 'srv-rule-desc' }, `${t('rules.wouldMatch')}: ${item.matched ? 'yes' : 'no'} · ${String(item.decision)}`),
|
|
889
|
+
),
|
|
890
|
+
),
|
|
891
|
+
),
|
|
892
|
+
)
|
|
893
|
+
: null,
|
|
894
|
+
h(
|
|
895
|
+
'div',
|
|
896
|
+
{ className: 'srv-actions' },
|
|
897
|
+
h(Button, { type: 'button', disabled: busy, onClick: dryRun }, t('rules.preview')),
|
|
898
|
+
h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
|
|
899
|
+
h(Button, { type: 'button', disabled: busy, onClick: cancel }, t('form.cancel')),
|
|
900
|
+
),
|
|
901
|
+
),
|
|
902
|
+
)
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* The detail page: four connection fields read-only, the policy table, the
|
|
907
|
+
* last 20 audit records and [删除] (§8.2).
|
|
908
|
+
* @param {{server: any, rules: any[], t: any, onBack: () => void, onEdit: () => void, onDelete: () => Promise<void>, deleting: boolean, notice: string|null, onRefresh: () => Promise<void>}} props
|
|
909
|
+
*/
|
|
910
|
+
function ServerDetail({ server, rules, t, onBack, onEdit, onDelete, deleting, notice, onRefresh }) {
|
|
911
|
+
const [audit, setAudit] = React.useState({ phase: 'loading' })
|
|
912
|
+
React.useEffect(() => {
|
|
913
|
+
let alive = true
|
|
914
|
+
api('/audit', { query: { limit: String(AUDIT_LIMIT), serverId: server.id } })
|
|
915
|
+
.then((data) => {
|
|
916
|
+
if (alive) setAudit({ phase: 'ready', records: data.records })
|
|
917
|
+
})
|
|
918
|
+
.catch((error) => {
|
|
919
|
+
if (alive) setAudit({ phase: 'error', error })
|
|
920
|
+
})
|
|
921
|
+
return () => {
|
|
922
|
+
alive = false
|
|
923
|
+
}
|
|
924
|
+
}, [server.id])
|
|
925
|
+
|
|
926
|
+
const authText =
|
|
927
|
+
server.auth.kind === 'key'
|
|
928
|
+
? `${t('auth.key')} · ${server.auth.privateKeyPath}`
|
|
929
|
+
: server.auth.kind === 'password'
|
|
930
|
+
? server.passwordConfigured === true
|
|
931
|
+
? `${t('auth.password')} · ${t('auth.passwordConfigured')}`
|
|
932
|
+
: `${t('auth.password')} · —`
|
|
933
|
+
: t('auth.agent')
|
|
934
|
+
|
|
935
|
+
return h(
|
|
936
|
+
F,
|
|
937
|
+
null,
|
|
938
|
+
h(
|
|
939
|
+
'div',
|
|
940
|
+
{ className: 'srv-head' },
|
|
941
|
+
h(Button, { size: 'sm', onClick: onBack }, `← ${t('panel.back')}`),
|
|
942
|
+
h('h2', null, server.name),
|
|
943
|
+
h(
|
|
944
|
+
'div',
|
|
945
|
+
{ className: 'srv-head-actions' },
|
|
946
|
+
h(Button, { size: 'sm', variant: 'outline', onClick: onEdit }, t('panel.edit')),
|
|
947
|
+
),
|
|
948
|
+
),
|
|
949
|
+
notice === null ? null : h('p', { className: 'srv-note' }, notice),
|
|
950
|
+
h(
|
|
951
|
+
'section',
|
|
952
|
+
{ className: 'srv-card' },
|
|
953
|
+
h('h3', { className: 'srv-card-title' }, t('panel.sectionConnection')),
|
|
954
|
+
h(
|
|
955
|
+
'dl',
|
|
956
|
+
{ className: 'srv-kv' },
|
|
957
|
+
h('dt', null, t('field.name')),
|
|
958
|
+
h('dd', null, server.name),
|
|
959
|
+
h('dt', null, t('field.host')),
|
|
960
|
+
h('dd', { className: 'srv-mono' }, `${server.host}:${String(server.port)}`),
|
|
961
|
+
h('dt', null, t('field.username')),
|
|
962
|
+
h('dd', { className: 'srv-mono' }, server.username),
|
|
963
|
+
h('dt', null, t('field.auth')),
|
|
964
|
+
h('dd', { className: 'srv-mono' }, authText),
|
|
965
|
+
h('dt', null, t('field.hostKeyMode')),
|
|
966
|
+
h('dd', null, server.hostKeyMode),
|
|
967
|
+
h('dt', null, t('field.defaultWorkDir')),
|
|
968
|
+
h('dd', { className: 'srv-mono' }, server.defaultWorkDir === null ? '—' : server.defaultWorkDir),
|
|
969
|
+
h('dt', null, t('field.lastConnectedAt')),
|
|
970
|
+
h(
|
|
971
|
+
'dd',
|
|
972
|
+
null,
|
|
973
|
+
server.lastConnectedAt === null ? t('panel.neverConnected') : t('panel.lastConnected', { time: formatTime(server.lastConnectedAt) }),
|
|
974
|
+
),
|
|
975
|
+
),
|
|
976
|
+
),
|
|
977
|
+
h(
|
|
978
|
+
'section',
|
|
979
|
+
{ className: 'srv-card' },
|
|
980
|
+
h('h3', { className: 'srv-card-title' }, t('panel.sectionPolicy')),
|
|
981
|
+
h(
|
|
982
|
+
'ul',
|
|
983
|
+
{ className: 'srv-rules' },
|
|
984
|
+
rules.map((/** @type {any} */ rule) => {
|
|
985
|
+
const state = server.policyState?.[rule.id] ?? { configured: null, decision: null, disabled: false }
|
|
986
|
+
return h(
|
|
987
|
+
'li',
|
|
988
|
+
{ className: 'srv-rule', key: rule.id },
|
|
989
|
+
h(
|
|
990
|
+
'div',
|
|
991
|
+
{ className: 'srv-rule-body' },
|
|
992
|
+
h('span', { className: 'srv-rule-label' }, rule.label),
|
|
993
|
+
h('span', { className: 'srv-rule-desc' }, rule.description),
|
|
994
|
+
),
|
|
995
|
+
h(
|
|
996
|
+
'span',
|
|
997
|
+
{
|
|
998
|
+
className: 'srv-tag',
|
|
999
|
+
'data-tone': state.disabled ? 'warn' : state.decision === 'block' ? undefined : 'success',
|
|
1000
|
+
},
|
|
1001
|
+
state.disabled ? t('form.offOption') : String(state.decision ?? rule.defaultDecision),
|
|
1002
|
+
),
|
|
1003
|
+
)
|
|
1004
|
+
}),
|
|
1005
|
+
),
|
|
1006
|
+
),
|
|
1007
|
+
h(CustomRulesSection, { server, t, onRefresh }),
|
|
1008
|
+
h(
|
|
1009
|
+
'section',
|
|
1010
|
+
{ className: 'srv-card' },
|
|
1011
|
+
h('h3', { className: 'srv-card-title' }, t('panel.sectionAudit')),
|
|
1012
|
+
audit.phase === 'loading' ? h('p', { className: 'srv-hint' }, t('panel.auditLoading')) : null,
|
|
1013
|
+
audit.phase === 'error'
|
|
1014
|
+
? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(audit.error) }))
|
|
1015
|
+
: null,
|
|
1016
|
+
audit.phase === 'ready' && audit.records.length === 0
|
|
1017
|
+
? h('p', { className: 'srv-hint' }, t('panel.auditEmpty'))
|
|
1018
|
+
: null,
|
|
1019
|
+
audit.phase === 'ready' && audit.records.length > 0
|
|
1020
|
+
? h(
|
|
1021
|
+
'ul',
|
|
1022
|
+
{ className: 'srv-audit' },
|
|
1023
|
+
audit.records.map((/** @type {any} */ record, /** @type {number} */ index) =>
|
|
1024
|
+
h(
|
|
1025
|
+
'li',
|
|
1026
|
+
{ key: `${String(record.ts)}-${String(index)}` },
|
|
1027
|
+
h('span', { className: 'srv-audit-time' }, formatTime(record.ts)),
|
|
1028
|
+
h('span', { className: 'srv-audit-decision' }, String(record.event ?? record.decision ?? '')),
|
|
1029
|
+
h(
|
|
1030
|
+
'span',
|
|
1031
|
+
{ className: 'srv-audit-text', title: String(record.command ?? record.detail ?? '') },
|
|
1032
|
+
String(record.command ?? record.detail ?? ''),
|
|
1033
|
+
),
|
|
1034
|
+
),
|
|
1035
|
+
),
|
|
1036
|
+
)
|
|
1037
|
+
: null,
|
|
1038
|
+
),
|
|
1039
|
+
h(
|
|
1040
|
+
'div',
|
|
1041
|
+
{ className: 'srv-danger-zone' },
|
|
1042
|
+
h('span', { className: 'srv-hint' }, t('panel.deleteHint')),
|
|
1043
|
+
h(Button, { variant: 'outline', danger: true, disabled: deleting, onClick: () => void onDelete() }, deleting ? t('pending.busy') : t('panel.delete')),
|
|
1044
|
+
),
|
|
1045
|
+
)
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Whether a path is absolute on *this* machine.
|
|
1050
|
+
*
|
|
1051
|
+
* `auth.privateKeyPath` names a local file the Host reads, so on Windows a
|
|
1052
|
+
* drive-letter (`C:\Users\me\.ssh\id_ed25519`) or UNC (`\\server\share\key`)
|
|
1053
|
+
* path is absolute. The panel used to demand a leading `/` here, which made
|
|
1054
|
+
* key auth impossible to submit on Windows.
|
|
1055
|
+
* @param {string} path
|
|
1056
|
+
* @returns {boolean}
|
|
1057
|
+
*/
|
|
1058
|
+
function isLocalAbsolutePath(path) {
|
|
1059
|
+
if (path.startsWith('/')) return true
|
|
1060
|
+
if (/^[A-Za-z]:[\\/]/.test(path)) return true
|
|
1061
|
+
return path.startsWith('\\\\')
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The create/edit form (§8.4). Block rules expose only confirm/block, and
|
|
1066
|
+
* "off" is queued rather than applied (§8.3).
|
|
1067
|
+
* @param {{server: any|null, rules: any[], hostKeyModes: string[], t: any, onCancel: () => void, onSaved: (message: string|null) => void}} props
|
|
1068
|
+
*/
|
|
1069
|
+
function ServerForm({ server, rules, hostKeyModes, t, onCancel, onSaved }) {
|
|
1070
|
+
const editing = server !== null
|
|
1071
|
+
const [name, setName] = React.useState(editing ? server.name : '')
|
|
1072
|
+
const [host, setHost] = React.useState(editing ? server.host : '')
|
|
1073
|
+
const [port, setPort] = React.useState(editing ? String(server.port) : '22')
|
|
1074
|
+
const [username, setUsername] = React.useState(editing ? server.username : '')
|
|
1075
|
+
const [authKind, setAuthKind] = React.useState(editing ? server.auth.kind : 'key')
|
|
1076
|
+
const [privateKeyPath, setPrivateKeyPath] = React.useState(editing && server.auth.kind === 'key' ? server.auth.privateKeyPath : '')
|
|
1077
|
+
const [password, setPassword] = React.useState('')
|
|
1078
|
+
const [hostKeyMode, setHostKeyMode] = React.useState(editing ? server.hostKeyMode : 'accept-new')
|
|
1079
|
+
const [defaultWorkDir, setDefaultWorkDir] = React.useState(editing && server.defaultWorkDir !== null ? server.defaultWorkDir : '')
|
|
1080
|
+
const [policy, setPolicy] = React.useState(() => {
|
|
1081
|
+
/** @type {Record<string, string>} */
|
|
1082
|
+
const initial = {}
|
|
1083
|
+
for (const rule of rules) {
|
|
1084
|
+
const state = editing ? server.policyState?.[rule.id] : null
|
|
1085
|
+
initial[rule.id] = state === undefined || state === null ? rule.defaultDecision : state.disabled ? 'off' : String(state.decision)
|
|
1086
|
+
}
|
|
1087
|
+
return initial
|
|
1088
|
+
})
|
|
1089
|
+
const [busy, setBusy] = React.useState(false)
|
|
1090
|
+
const [error, setError] = React.useState(/** @type {string|null} */ (null))
|
|
1091
|
+
const [note, setNote] = React.useState(/** @type {string|null} */ (null))
|
|
1092
|
+
|
|
1093
|
+
/** @param {string} ruleId @param {string} value */
|
|
1094
|
+
const setRule = (ruleId, value) =>
|
|
1095
|
+
setPolicy((/** @type {Record<string, string>} */ current) => ({ ...current, [ruleId]: value }))
|
|
1096
|
+
|
|
1097
|
+
/** Validate one client-side rule before touching the network (§8.4 validations). */
|
|
1098
|
+
const validate = () => {
|
|
1099
|
+
if (name.trim() === '') return t('form.errorName')
|
|
1100
|
+
if (host.trim() === '') return t('form.errorHost')
|
|
1101
|
+
if (username.trim() === '') return t('form.errorUsername')
|
|
1102
|
+
const parsed = Number(port)
|
|
1103
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return t('form.portRange')
|
|
1104
|
+
if (authKind === 'key' && privateKeyPath.trim() === '') return t('form.errorKeyPath')
|
|
1105
|
+
// The key file lives on *this* machine, so a Windows drive-letter or UNC
|
|
1106
|
+
// path is as absolute as a POSIX one (the Host accepts both).
|
|
1107
|
+
if (authKind === 'key' && !isLocalAbsolutePath(privateKeyPath.trim())) return t('form.absolutePath')
|
|
1108
|
+
if (authKind === 'password' && !editing && password === '') return t('form.errorPassword')
|
|
1109
|
+
// The working directory is on the *remote* host: POSIX only.
|
|
1110
|
+
if (defaultWorkDir.trim() !== '' && !defaultWorkDir.trim().startsWith('/')) return t('form.absolutePath')
|
|
1111
|
+
return null
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** @param {any} event */
|
|
1115
|
+
const submit = async (event) => {
|
|
1116
|
+
event.preventDefault()
|
|
1117
|
+
const problem = validate()
|
|
1118
|
+
if (problem !== null) {
|
|
1119
|
+
setError(problem)
|
|
1120
|
+
return
|
|
1121
|
+
}
|
|
1122
|
+
setBusy(true)
|
|
1123
|
+
setError(null)
|
|
1124
|
+
setNote(null)
|
|
1125
|
+
try {
|
|
1126
|
+
/** @type {Record<string, unknown>} */
|
|
1127
|
+
const body = {
|
|
1128
|
+
name: name.trim(),
|
|
1129
|
+
host: host.trim(),
|
|
1130
|
+
port: Number(port),
|
|
1131
|
+
username: username.trim(),
|
|
1132
|
+
auth: authKind === 'key' ? { kind: 'key', privateKeyPath: privateKeyPath.trim() } : { kind: authKind },
|
|
1133
|
+
hostKeyMode,
|
|
1134
|
+
defaultWorkDir: defaultWorkDir.trim() === '' ? null : defaultWorkDir.trim(),
|
|
1135
|
+
}
|
|
1136
|
+
if (authKind === 'password' && password !== '') body.password = password
|
|
1137
|
+
|
|
1138
|
+
// Only rules that leave their built-in default are stored, so the
|
|
1139
|
+
// record stays readable; "off" and re-enabling have their own fields
|
|
1140
|
+
// because both change the *existing* rule set.
|
|
1141
|
+
/** @type {Record<string, string>} */
|
|
1142
|
+
const storedPolicy = {}
|
|
1143
|
+
/** @type {string[]} */
|
|
1144
|
+
const disableRules = []
|
|
1145
|
+
/** @type {string[]} */
|
|
1146
|
+
const enableRules = []
|
|
1147
|
+
for (const rule of rules) {
|
|
1148
|
+
const value = policy[rule.id]
|
|
1149
|
+
if (value === undefined) continue
|
|
1150
|
+
if (value === 'off') {
|
|
1151
|
+
disableRules.push(rule.id)
|
|
1152
|
+
continue
|
|
1153
|
+
}
|
|
1154
|
+
if (value === rule.defaultDecision) continue
|
|
1155
|
+
storedPolicy[rule.id] = value
|
|
1156
|
+
}
|
|
1157
|
+
body.policy = storedPolicy
|
|
1158
|
+
|
|
1159
|
+
if (editing) {
|
|
1160
|
+
for (const rule of rules) {
|
|
1161
|
+
const value = policy[rule.id]
|
|
1162
|
+
const wasDisabled = server.policyState?.[rule.id]?.disabled === true
|
|
1163
|
+
if (wasDisabled && value !== undefined && value !== 'off') enableRules.push(rule.id)
|
|
1164
|
+
}
|
|
1165
|
+
if (disableRules.length > 0) body.disableRules = disableRules
|
|
1166
|
+
if (enableRules.length > 0) body.enableRules = enableRules
|
|
1167
|
+
const answer = await api(`/servers/${encodeURIComponent(server.id)}`, { method: 'PUT', body })
|
|
1168
|
+
if (answer.pending !== undefined && answer.pending.length > 0) {
|
|
1169
|
+
setNote(t('form.pendingNote'))
|
|
1170
|
+
setBusy(false)
|
|
1171
|
+
onSaved(t('form.pendingNote'))
|
|
1172
|
+
return
|
|
1173
|
+
}
|
|
1174
|
+
} else {
|
|
1175
|
+
if (disableRules.length > 0) body.disableRules = disableRules
|
|
1176
|
+
const answer = await api('/servers', { method: 'POST', body })
|
|
1177
|
+
if (answer.pending !== undefined && answer.pending.length > 0) {
|
|
1178
|
+
onSaved(t('form.pendingNote'))
|
|
1179
|
+
return
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
onSaved(null)
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
setError(t('error.generic', { message: messageOf(error) }))
|
|
1185
|
+
} finally {
|
|
1186
|
+
setBusy(false)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
return h(
|
|
1191
|
+
'form',
|
|
1192
|
+
{ className: 'srv-form', onSubmit: submit },
|
|
1193
|
+
h('h2', { className: 'srv-card-title' }, editing ? t('form.edit') : t('form.new')),
|
|
1194
|
+
h(
|
|
1195
|
+
'div',
|
|
1196
|
+
{ className: 'srv-field' },
|
|
1197
|
+
h('label', { htmlFor: 'srv-name' }, t('field.name')),
|
|
1198
|
+
h('input', {
|
|
1199
|
+
id: 'srv-name',
|
|
1200
|
+
className: 'srv-input',
|
|
1201
|
+
value: name,
|
|
1202
|
+
onChange: (/** @type {any} */ event) => setName(event.target.value),
|
|
1203
|
+
required: true,
|
|
1204
|
+
}),
|
|
1205
|
+
),
|
|
1206
|
+
h(
|
|
1207
|
+
'div',
|
|
1208
|
+
{ className: 'srv-field-row' },
|
|
1209
|
+
h(
|
|
1210
|
+
'div',
|
|
1211
|
+
{ className: 'srv-field' },
|
|
1212
|
+
h('label', { htmlFor: 'srv-host' }, t('field.host')),
|
|
1213
|
+
h('input', {
|
|
1214
|
+
id: 'srv-host',
|
|
1215
|
+
className: 'srv-input',
|
|
1216
|
+
value: host,
|
|
1217
|
+
onChange: (/** @type {any} */ event) => setHost(event.target.value),
|
|
1218
|
+
required: true,
|
|
1219
|
+
}),
|
|
1220
|
+
),
|
|
1221
|
+
h(
|
|
1222
|
+
'div',
|
|
1223
|
+
{ className: 'srv-field' },
|
|
1224
|
+
h('label', { htmlFor: 'srv-port' }, t('field.port')),
|
|
1225
|
+
h('input', {
|
|
1226
|
+
id: 'srv-port',
|
|
1227
|
+
className: 'srv-input',
|
|
1228
|
+
type: 'number',
|
|
1229
|
+
min: 1,
|
|
1230
|
+
max: 65535,
|
|
1231
|
+
value: port,
|
|
1232
|
+
onChange: (/** @type {any} */ event) => setPort(event.target.value),
|
|
1233
|
+
}),
|
|
1234
|
+
),
|
|
1235
|
+
),
|
|
1236
|
+
h(
|
|
1237
|
+
'div',
|
|
1238
|
+
{ className: 'srv-field' },
|
|
1239
|
+
h('label', { htmlFor: 'srv-username' }, t('field.username')),
|
|
1240
|
+
h('input', {
|
|
1241
|
+
id: 'srv-username',
|
|
1242
|
+
className: 'srv-input',
|
|
1243
|
+
value: username,
|
|
1244
|
+
onChange: (/** @type {any} */ event) => setUsername(event.target.value),
|
|
1245
|
+
required: true,
|
|
1246
|
+
}),
|
|
1247
|
+
),
|
|
1248
|
+
h(
|
|
1249
|
+
'div',
|
|
1250
|
+
{ className: 'srv-field' },
|
|
1251
|
+
h('span', null, t('field.auth')),
|
|
1252
|
+
h(
|
|
1253
|
+
'div',
|
|
1254
|
+
{ className: 'srv-seg', role: 'group', 'aria-label': t('field.auth') },
|
|
1255
|
+
['key', 'password', 'agent'].map((kind) =>
|
|
1256
|
+
h(
|
|
1257
|
+
'button',
|
|
1258
|
+
{
|
|
1259
|
+
key: kind,
|
|
1260
|
+
type: 'button',
|
|
1261
|
+
'aria-pressed': authKind === kind ? 'true' : 'false',
|
|
1262
|
+
onClick: () => setAuthKind(kind),
|
|
1263
|
+
},
|
|
1264
|
+
t(`auth.${kind}`),
|
|
1265
|
+
),
|
|
1266
|
+
),
|
|
1267
|
+
),
|
|
1268
|
+
),
|
|
1269
|
+
authKind === 'key'
|
|
1270
|
+
? h(
|
|
1271
|
+
'div',
|
|
1272
|
+
{ className: 'srv-field' },
|
|
1273
|
+
h('label', { htmlFor: 'srv-key' }, t('field.privateKeyPath')),
|
|
1274
|
+
h('input', {
|
|
1275
|
+
id: 'srv-key',
|
|
1276
|
+
className: 'srv-input srv-mono',
|
|
1277
|
+
value: privateKeyPath,
|
|
1278
|
+
placeholder: '/root/.ssh/id_ed25519',
|
|
1279
|
+
onChange: (/** @type {any} */ event) => setPrivateKeyPath(event.target.value),
|
|
1280
|
+
}),
|
|
1281
|
+
)
|
|
1282
|
+
: null,
|
|
1283
|
+
authKind === 'password'
|
|
1284
|
+
? h(
|
|
1285
|
+
'div',
|
|
1286
|
+
{ className: 'srv-field' },
|
|
1287
|
+
h('label', { htmlFor: 'srv-password' }, t('field.password')),
|
|
1288
|
+
h('input', {
|
|
1289
|
+
id: 'srv-password',
|
|
1290
|
+
className: 'srv-input',
|
|
1291
|
+
type: 'password',
|
|
1292
|
+
value: password,
|
|
1293
|
+
autoComplete: 'new-password',
|
|
1294
|
+
placeholder: editing && server.passwordConfigured === true ? t('auth.passwordConfigured') : '',
|
|
1295
|
+
onChange: (/** @type {any} */ event) => setPassword(event.target.value),
|
|
1296
|
+
}),
|
|
1297
|
+
h('span', { className: 'srv-hint' }, editing ? t('form.passwordKeep') : t('form.passwordNew')),
|
|
1298
|
+
)
|
|
1299
|
+
: null,
|
|
1300
|
+
h(
|
|
1301
|
+
'div',
|
|
1302
|
+
{ className: 'srv-field' },
|
|
1303
|
+
h('label', { htmlFor: 'srv-hostkey' }, t('field.hostKeyMode')),
|
|
1304
|
+
h(
|
|
1305
|
+
'select',
|
|
1306
|
+
{
|
|
1307
|
+
id: 'srv-hostkey',
|
|
1308
|
+
className: 'srv-select',
|
|
1309
|
+
value: hostKeyMode,
|
|
1310
|
+
onChange: (/** @type {any} */ event) => setHostKeyMode(event.target.value),
|
|
1311
|
+
},
|
|
1312
|
+
hostKeyModes.map((mode) => h('option', { key: mode, value: mode }, t(`hostKey.${mode}`))),
|
|
1313
|
+
),
|
|
1314
|
+
hostKeyMode === 'off' && (editing ? server.hostKeyMode !== 'off' : false)
|
|
1315
|
+
? h('span', { className: 'srv-hint' }, t('form.offQueued'))
|
|
1316
|
+
: null,
|
|
1317
|
+
),
|
|
1318
|
+
h(
|
|
1319
|
+
'div',
|
|
1320
|
+
{ className: 'srv-field' },
|
|
1321
|
+
h('label', { htmlFor: 'srv-workdir' }, t('field.defaultWorkDir')),
|
|
1322
|
+
h('input', {
|
|
1323
|
+
id: 'srv-workdir',
|
|
1324
|
+
className: 'srv-input srv-mono',
|
|
1325
|
+
value: defaultWorkDir,
|
|
1326
|
+
placeholder: '/srv/app',
|
|
1327
|
+
onChange: (/** @type {any} */ event) => setDefaultWorkDir(event.target.value),
|
|
1328
|
+
}),
|
|
1329
|
+
),
|
|
1330
|
+
h(
|
|
1331
|
+
'div',
|
|
1332
|
+
{ className: 'srv-field' },
|
|
1333
|
+
h('span', null, t('field.policy')),
|
|
1334
|
+
h('p', { className: 'srv-hint' }, t('form.policyHint')),
|
|
1335
|
+
h(
|
|
1336
|
+
'ul',
|
|
1337
|
+
{ className: 'srv-rules' },
|
|
1338
|
+
rules.map((/** @type {any} */ rule) => {
|
|
1339
|
+
const value = policy[rule.id] ?? rule.defaultDecision
|
|
1340
|
+
// "停用" is always offered and always queued (§8.3): a new server
|
|
1341
|
+
// is created with the rule enabled and a disable request filed.
|
|
1342
|
+
const options = [...rule.allowedDecisions, 'off']
|
|
1343
|
+
return h(
|
|
1344
|
+
'li',
|
|
1345
|
+
{ className: 'srv-rule', key: rule.id },
|
|
1346
|
+
h(
|
|
1347
|
+
'div',
|
|
1348
|
+
{ className: 'srv-rule-body' },
|
|
1349
|
+
h('span', { className: 'srv-rule-label' }, rule.label),
|
|
1350
|
+
h('span', { className: 'srv-rule-desc' }, rule.description),
|
|
1351
|
+
),
|
|
1352
|
+
h(
|
|
1353
|
+
'select',
|
|
1354
|
+
{
|
|
1355
|
+
className: 'srv-select srv-rule-select',
|
|
1356
|
+
'aria-label': rule.label,
|
|
1357
|
+
value,
|
|
1358
|
+
onChange: (/** @type {any} */ event) => setRule(rule.id, event.target.value),
|
|
1359
|
+
},
|
|
1360
|
+
options.map((decision) =>
|
|
1361
|
+
h('option', { key: decision, value: decision }, decision === 'off' ? t('form.offOption') : decision),
|
|
1362
|
+
),
|
|
1363
|
+
),
|
|
1364
|
+
)
|
|
1365
|
+
}),
|
|
1366
|
+
),
|
|
1367
|
+
),
|
|
1368
|
+
error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
|
|
1369
|
+
note === null ? null : h('p', { className: 'srv-note' }, note),
|
|
1370
|
+
h(
|
|
1371
|
+
'div',
|
|
1372
|
+
{ className: 'srv-head-actions' },
|
|
1373
|
+
h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
|
|
1374
|
+
h(Button, { onClick: onCancel, disabled: busy }, t('form.cancel')),
|
|
1375
|
+
),
|
|
1376
|
+
)
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* The main panel: list ⇄ detail ⇄ form (§8.2). One server's data lives in
|
|
1381
|
+
* the list payload, so a mutation is followed by a reload rather than by
|
|
1382
|
+
* patching several copies.
|
|
1383
|
+
* @param {{t: any}} props
|
|
1384
|
+
*/
|
|
1385
|
+
function ServerPanel({ t }) {
|
|
1386
|
+
const { state, reload } = useServers()
|
|
1387
|
+
const [view, setView] = React.useState({ kind: 'list' })
|
|
1388
|
+
const [testing, setTesting] = React.useState(/** @type {string|null} */ (null))
|
|
1389
|
+
const [toggling, setToggling] = React.useState(/** @type {string|null} */ (null))
|
|
1390
|
+
const [deleting, setDeleting] = React.useState(false)
|
|
1391
|
+
const [notice, setNotice] = React.useState(/** @type {string|null} */ (null))
|
|
1392
|
+
const [status, setStatus] = React.useState(/** @type {string|null} */ (null))
|
|
1393
|
+
|
|
1394
|
+
const data = state.phase === 'ready' ? state.data : null
|
|
1395
|
+
const servers = data === null ? [] : data.servers
|
|
1396
|
+
const rules = data === null ? [] : data.rules
|
|
1397
|
+
const hostKeyModes = data === null ? ['accept-new', 'verify', 'off'] : data.hostKeyModes
|
|
1398
|
+
|
|
1399
|
+
/** @param {string} id */
|
|
1400
|
+
const test = async (id) => {
|
|
1401
|
+
setTesting(id)
|
|
1402
|
+
setNotice(null)
|
|
1403
|
+
setStatus(null)
|
|
1404
|
+
try {
|
|
1405
|
+
const answer = await api('/test', { method: 'POST', body: { id } })
|
|
1406
|
+
setStatus(
|
|
1407
|
+
t('status.connected', {
|
|
1408
|
+
platform: answer.platform,
|
|
1409
|
+
shell: answer.shell,
|
|
1410
|
+
sudo: answer.sudo,
|
|
1411
|
+
ms: answer.durationMs,
|
|
1412
|
+
}),
|
|
1413
|
+
)
|
|
1414
|
+
await reload()
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
setStatus(t('error.generic', { message: messageOf(error) }))
|
|
1417
|
+
} finally {
|
|
1418
|
+
setTesting(null)
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** @param {string} id */
|
|
1423
|
+
const remove = async (id) => {
|
|
1424
|
+
setDeleting(true)
|
|
1425
|
+
setNotice(null)
|
|
1426
|
+
try {
|
|
1427
|
+
await api(`/servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
|
|
1428
|
+
setNotice(t('panel.deleteQueued'))
|
|
1429
|
+
setView({ kind: 'list' })
|
|
1430
|
+
await reload()
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
setNotice(t('error.generic', { message: messageOf(error) }))
|
|
1433
|
+
} finally {
|
|
1434
|
+
setDeleting(false)
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Flip the enable switch. It applies immediately — no pending queue — so
|
|
1440
|
+
* the only failure modes are "connected" (409) and a network error.
|
|
1441
|
+
* @param {any} server
|
|
1442
|
+
*/
|
|
1443
|
+
const toggle = async (server) => {
|
|
1444
|
+
setToggling(server.id)
|
|
1445
|
+
setNotice(null)
|
|
1446
|
+
try {
|
|
1447
|
+
await api(`/servers/${encodeURIComponent(server.id)}`, {
|
|
1448
|
+
method: 'PUT',
|
|
1449
|
+
body: { enabled: server.enabled === false },
|
|
1450
|
+
})
|
|
1451
|
+
await reload()
|
|
1452
|
+
} catch (error) {
|
|
1453
|
+
setNotice(t('error.generic', { message: messageOf(error) }))
|
|
1454
|
+
} finally {
|
|
1455
|
+
setToggling(null)
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
/** @param {string|null} message */
|
|
1460
|
+
const saved = async (message) => {
|
|
1461
|
+
setNotice(message)
|
|
1462
|
+
setView({ kind: 'list' })
|
|
1463
|
+
await reload()
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const current = view.kind === 'detail' ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
|
|
1467
|
+
const edited = view.kind === 'form' && view.id !== undefined ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
|
|
1468
|
+
|
|
1469
|
+
return h(
|
|
1470
|
+
'div',
|
|
1471
|
+
{ className: 'srv-page' },
|
|
1472
|
+
h(
|
|
1473
|
+
'div',
|
|
1474
|
+
{ className: 'srv-column' },
|
|
1475
|
+
view.kind === 'list'
|
|
1476
|
+
? h(ServerList, {
|
|
1477
|
+
state,
|
|
1478
|
+
t,
|
|
1479
|
+
reload,
|
|
1480
|
+
onAdd: () => {
|
|
1481
|
+
setNotice(null)
|
|
1482
|
+
setView({ kind: 'form' })
|
|
1483
|
+
},
|
|
1484
|
+
onOpen: (/** @type {string} */ id) => {
|
|
1485
|
+
setNotice(null)
|
|
1486
|
+
setView({ kind: 'detail', id })
|
|
1487
|
+
},
|
|
1488
|
+
onEdit: (/** @type {string} */ id) => {
|
|
1489
|
+
setNotice(null)
|
|
1490
|
+
setView({ kind: 'form', id })
|
|
1491
|
+
},
|
|
1492
|
+
onTest: test,
|
|
1493
|
+
onToggle: toggle,
|
|
1494
|
+
testing,
|
|
1495
|
+
toggling,
|
|
1496
|
+
notice: notice ?? status,
|
|
1497
|
+
})
|
|
1498
|
+
: null,
|
|
1499
|
+
view.kind === 'detail' && current !== null
|
|
1500
|
+
? h(ServerDetail, {
|
|
1501
|
+
server: current,
|
|
1502
|
+
rules,
|
|
1503
|
+
t,
|
|
1504
|
+
onBack: () => setView({ kind: 'list' }),
|
|
1505
|
+
onEdit: () => setView({ kind: 'form', id: current.id }),
|
|
1506
|
+
onDelete: () => remove(current.id),
|
|
1507
|
+
deleting,
|
|
1508
|
+
notice: status,
|
|
1509
|
+
onRefresh: reload,
|
|
1510
|
+
})
|
|
1511
|
+
: null,
|
|
1512
|
+
view.kind === 'detail' && current === null
|
|
1513
|
+
? h(
|
|
1514
|
+
'div',
|
|
1515
|
+
{ className: 'srv-empty' },
|
|
1516
|
+
h('p', null, t('panel.notFound')),
|
|
1517
|
+
h(Button, { variant: 'outline', onClick: () => setView({ kind: 'list' }) }, t('panel.back')),
|
|
1518
|
+
)
|
|
1519
|
+
: null,
|
|
1520
|
+
view.kind === 'form'
|
|
1521
|
+
? h(ServerForm, {
|
|
1522
|
+
server: edited,
|
|
1523
|
+
rules,
|
|
1524
|
+
hostKeyModes,
|
|
1525
|
+
t,
|
|
1526
|
+
onCancel: () => setView({ kind: 'list' }),
|
|
1527
|
+
onSaved: saved,
|
|
1528
|
+
})
|
|
1529
|
+
: null,
|
|
1530
|
+
),
|
|
1531
|
+
)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* One §8.3 pending row.
|
|
1536
|
+
* @param {{record: any, t: any, onConfirm: (id: string) => Promise<void>, onCancel: (id: string) => Promise<void>, busy: string|null}} props
|
|
1537
|
+
*/
|
|
1538
|
+
function PendingRow({ record, t, onConfirm, onCancel, busy }) {
|
|
1539
|
+
const working = busy === record.id
|
|
1540
|
+
const name = typeof record.serverName === 'string' ? record.serverName : record.serverId
|
|
1541
|
+
const title =
|
|
1542
|
+
record.kind === 'delete-server'
|
|
1543
|
+
? t('pending.deleteServer', { name })
|
|
1544
|
+
: record.kind === 'disable-hostkey'
|
|
1545
|
+
? t('pending.disableHostkey', { name })
|
|
1546
|
+
: t('pending.disableRule', { rule: String(record.ruleId ?? '?'), name })
|
|
1547
|
+
return h(
|
|
1548
|
+
'li',
|
|
1549
|
+
null,
|
|
1550
|
+
h(
|
|
1551
|
+
'div',
|
|
1552
|
+
{ className: 'srv-pending-main' },
|
|
1553
|
+
h('span', { className: 'srv-pending-title' }, title),
|
|
1554
|
+
h(
|
|
1555
|
+
'span',
|
|
1556
|
+
{ className: 'srv-pending-meta' },
|
|
1557
|
+
`${t('pending.requestedAt', { time: formatTime(record.requestedAt) })} · ${t('pending.expiresAt', {
|
|
1558
|
+
time: formatTime(record.expiresAt),
|
|
1559
|
+
})}${record.target?.present === false ? ` · ${t('pending.targetMissing')}` : ''}`,
|
|
1560
|
+
),
|
|
1561
|
+
),
|
|
1562
|
+
h(
|
|
1563
|
+
'div',
|
|
1564
|
+
{ className: 'srv-row-actions' },
|
|
1565
|
+
h(Button, { size: 'sm', variant: 'primary', disabled: working, onClick: () => void onConfirm(record.id) }, working ? t('pending.busy') : t('pending.confirm')),
|
|
1566
|
+
h(Button, { size: 'sm', variant: 'outline', disabled: working, onClick: () => void onCancel(record.id) }, t('pending.cancel')),
|
|
1567
|
+
),
|
|
1568
|
+
)
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* The `settings.section` page: global settings (read-only, they come from the
|
|
1573
|
+
* profile) and the pending queue with its red count badge (§8.3).
|
|
1574
|
+
* @param {{t: any}} props
|
|
1575
|
+
*/
|
|
1576
|
+
function SettingsPage({ t }) {
|
|
1577
|
+
const { state, reload } = useServers()
|
|
1578
|
+
const [pending, setPending] = React.useState({ phase: 'loading' })
|
|
1579
|
+
const [busy, setBusy] = React.useState(/** @type {string|null} */ (null))
|
|
1580
|
+
const [error, setError] = React.useState(/** @type {string|null} */ (null))
|
|
1581
|
+
|
|
1582
|
+
const reloadPending = React.useCallback(async () => {
|
|
1583
|
+
try {
|
|
1584
|
+
const answer = await api('/pending')
|
|
1585
|
+
setPending({ phase: 'ready', records: answer.pending })
|
|
1586
|
+
} catch (failure) {
|
|
1587
|
+
setPending({ phase: 'error', error: failure })
|
|
1588
|
+
}
|
|
1589
|
+
}, [])
|
|
1590
|
+
|
|
1591
|
+
React.useEffect(() => {
|
|
1592
|
+
void reloadPending()
|
|
1593
|
+
}, [reloadPending])
|
|
1594
|
+
|
|
1595
|
+
/** @param {'confirm'|'cancel'} action @param {string} id */
|
|
1596
|
+
const act = async (action, id) => {
|
|
1597
|
+
setBusy(id)
|
|
1598
|
+
setError(null)
|
|
1599
|
+
try {
|
|
1600
|
+
await api(`/pending/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
|
|
1601
|
+
await Promise.all([reloadPending(), reload()])
|
|
1602
|
+
} catch (failure) {
|
|
1603
|
+
setError(t('error.generic', { message: messageOf(failure) }))
|
|
1604
|
+
} finally {
|
|
1605
|
+
setBusy(null)
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
const records = pending.phase === 'ready' ? pending.records : []
|
|
1610
|
+
const config = state.phase === 'ready' ? state.data.config : null
|
|
1611
|
+
return h(
|
|
1612
|
+
'div',
|
|
1613
|
+
{ className: 'srv-page' },
|
|
1614
|
+
h(
|
|
1615
|
+
'div',
|
|
1616
|
+
{ className: 'srv-column' },
|
|
1617
|
+
h('h2', null, t('settings.title')),
|
|
1618
|
+
h('p', { className: 'srv-hint' }, t('settings.description')),
|
|
1619
|
+
h(
|
|
1620
|
+
'section',
|
|
1621
|
+
{ className: 'srv-card' },
|
|
1622
|
+
h(
|
|
1623
|
+
'div',
|
|
1624
|
+
{ className: 'srv-card-head' },
|
|
1625
|
+
h('h3', { className: 'srv-card-title' }, t('settings.pending')),
|
|
1626
|
+
h('span', { className: 'srv-badge', hidden: records.length === 0 }, String(records.length)),
|
|
1627
|
+
),
|
|
1628
|
+
h('p', { className: 'srv-hint' }, t('settings.pendingHint')),
|
|
1629
|
+
error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
|
|
1630
|
+
records.length === 0 ? h('p', { className: 'srv-hint' }, t('settings.pendingEmpty')) : null,
|
|
1631
|
+
records.length > 0
|
|
1632
|
+
? h(
|
|
1633
|
+
'ul',
|
|
1634
|
+
{ className: 'srv-pending' },
|
|
1635
|
+
records.map((/** @type {any} */ record) =>
|
|
1636
|
+
h(PendingRow, {
|
|
1637
|
+
key: record.id,
|
|
1638
|
+
record,
|
|
1639
|
+
t,
|
|
1640
|
+
busy,
|
|
1641
|
+
onConfirm: (/** @type {string} */ id) => act('confirm', id),
|
|
1642
|
+
onCancel: (/** @type {string} */ id) => act('cancel', id),
|
|
1643
|
+
}),
|
|
1644
|
+
),
|
|
1645
|
+
)
|
|
1646
|
+
: null,
|
|
1647
|
+
),
|
|
1648
|
+
h(
|
|
1649
|
+
'section',
|
|
1650
|
+
{ className: 'srv-card' },
|
|
1651
|
+
h('h3', { className: 'srv-card-title' }, t('settings.global')),
|
|
1652
|
+
h('p', { className: 'srv-hint' }, t('settings.globalHint')),
|
|
1653
|
+
config === null
|
|
1654
|
+
? state.phase === 'error'
|
|
1655
|
+
? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) }))
|
|
1656
|
+
: h('p', { className: 'srv-hint' }, t('panel.loading'))
|
|
1657
|
+
: h(
|
|
1658
|
+
'dl',
|
|
1659
|
+
{ className: 'srv-kv' },
|
|
1660
|
+
Object.entries(config).map(([key, value]) =>
|
|
1661
|
+
h(F, { key }, h('dt', { className: 'srv-mono' }, key), h('dd', { className: 'srv-mono' }, String(value))),
|
|
1662
|
+
),
|
|
1663
|
+
),
|
|
1664
|
+
),
|
|
1665
|
+
),
|
|
1666
|
+
)
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// ---------------------------------------------------------------- mount --
|
|
1670
|
+
|
|
1671
|
+
/** Services this bundle needs; `slots` carries the three mount points, `locale` the copy. */
|
|
1672
|
+
const inject = ['slots', 'locale']
|
|
1673
|
+
|
|
1674
|
+
/**
|
|
1675
|
+
* Register the dictionaries and the three §8.1 contributions.
|
|
1676
|
+
* @param {any} ctx - the browser plugin context.
|
|
1677
|
+
*/
|
|
1678
|
+
function apply(ctx) {
|
|
1679
|
+
installStyles()
|
|
1680
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'server-connector: dictionaries')
|
|
1681
|
+
|
|
1682
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
1683
|
+
ctx.slots.register(
|
|
1684
|
+
{
|
|
1685
|
+
name: 'sidebar.panellist',
|
|
1686
|
+
id: PANEL_ID,
|
|
1687
|
+
order: 10,
|
|
1688
|
+
label: () => ctx.locale.bind(NS)('panel.label'),
|
|
1689
|
+
locale: NS,
|
|
1690
|
+
},
|
|
1691
|
+
ServerIcon,
|
|
1692
|
+
),
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
ctx.slots.inject('main', () =>
|
|
1696
|
+
ctx.slots.register(
|
|
1697
|
+
{
|
|
1698
|
+
name: 'main',
|
|
1699
|
+
key: PANEL_ID,
|
|
1700
|
+
locale: NS,
|
|
1701
|
+
inject: () => ({}),
|
|
1702
|
+
},
|
|
1703
|
+
ServerPanel,
|
|
1704
|
+
),
|
|
1705
|
+
)
|
|
1706
|
+
|
|
1707
|
+
ctx.slots.inject('settings.section', () =>
|
|
1708
|
+
ctx.slots.register(
|
|
1709
|
+
{
|
|
1710
|
+
name: 'settings.section',
|
|
1711
|
+
id: PANEL_ID,
|
|
1712
|
+
order: 25,
|
|
1713
|
+
label: () => ctx.locale.bind(NS)('settings.nav'),
|
|
1714
|
+
locale: NS,
|
|
1715
|
+
inject: () => ({}),
|
|
1716
|
+
},
|
|
1717
|
+
SettingsPage,
|
|
1718
|
+
),
|
|
1719
|
+
)
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
exports.NS = NS
|
|
1723
|
+
exports.PANEL_ID = PANEL_ID
|
|
1724
|
+
exports.apply = apply
|
|
1725
|
+
exports.inject = inject
|
|
1726
|
+
return module.exports
|
|
1727
|
+
},
|
|
1728
|
+
})
|