dsh-server-connector 0.2.0 → 0.2.2

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/lib/client.js CHANGED
@@ -1,1716 +1,1720 @@
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
- '.srv-switch{position:relative;flex:0 0 auto;width:34px;height:20px;padding:0;border:none;border-radius:999px;',
282
- 'background:var(--dsw-alias-border-l4);cursor:pointer;transition:background .15s ease}',
283
- '.srv-switch[data-on="true"]{background:var(--dsw-alias-brand-primary)}',
284
- '.srv-switch:disabled{cursor:not-allowed;opacity:.5}',
285
- '.srv-switch:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
286
- '.srv-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
287
- 'background:var(--dsw-static-neutral-bluish-00);transition:transform .15s ease}',
288
- '.srv-switch[data-on="true"] .srv-switch-knob{transform:translateX(14px)}',
289
- '.srv-row-main{flex:1 1 240px;min-width:0;display:flex;flex-direction:column;gap:2px}',
290
- '.srv-row-title{display:flex;align-items:center;gap:8px;min-width:0;font-size:14px;line-height:20px;font-weight:500}',
291
- '.srv-open{padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;',
292
- 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}',
293
- '.srv-open:hover{text-decoration:underline}',
294
- '.srv-row-meta{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap;font-size:12px;line-height:18px;',
295
- 'color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
296
- '.srv-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
297
- '.srv-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
298
- '.srv-tag{display:inline-flex;align-items:center;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;',
299
- 'padding:0 8px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
300
- '.srv-tag[data-tone="success"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);',
301
- 'color:var(--dsw-alias-state-success-primary)}',
302
- '.srv-tag[data-tone="warn"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);',
303
- 'color:var(--dsw-alias-state-warn-primary)}',
304
- '.srv-row-actions{display:flex;align-items:center;gap:4px;flex:none;margin-left:auto}',
305
- '.srv-empty{margin:0;padding:32px 8px;text-align:center;color:var(--dsw-alias-label-tertiary)}',
306
- '.srv-empty p{margin:0 0 4px}',
307
- '.srv-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:.5px solid var(--dsw-alias-border-l3);',
308
- 'border-radius:16px;min-width:0}',
309
- '.srv-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
310
- '.srv-card-title{margin:0;flex:1;font-size:15px;line-height:22px;font-weight:600}',
311
- '.srv-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0;font-size:13px;line-height:20px}',
312
- '.srv-kv dt{color:var(--dsw-alias-label-tertiary)}',
313
- '.srv-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}',
314
- '.srv-mono{font-family:var(--ds-font-family-code,Menlo,monospace);font-size:12px}',
315
- '.srv-form{display:flex;flex-direction:column;gap:16px}',
316
- '.srv-field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:13px;line-height:20px}',
317
- '.srv-field-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px}',
318
- '.srv-input,.srv-select{box-sizing:border-box;width:100%;min-width:0;height:32px;padding:0 8px;font:inherit;',
319
- 'font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);',
320
- 'border:.5px solid var(--dsw-alias-border-l4);border-radius:8px}',
321
- '.srv-input:focus-visible,.srv-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
322
- '.srv-input[rows]{height:auto;padding:6px 8px;resize:vertical}',
323
- '.srv-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
324
- '.srv-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}',
325
- '.srv-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
326
- '.srv-note{margin:0;padding:8px 12px;border-radius:10px;font-size:13px;line-height:20px;',
327
- 'background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);overflow-wrap:anywhere}',
328
- '.srv-seg{display:inline-flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover)}',
329
- '.srv-seg button{height:26px;padding:0 12px;border:0;border-radius:7px;background:transparent;cursor:pointer;',
330
- 'font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary)}',
331
- '.srv-seg button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);',
332
- 'box-shadow:var(--dsw-elevation-soft)}',
333
- '.srv-rules{border-top:.5px solid var(--dsw-alias-border-l2);margin:0;padding:0;list-style:none}',
334
- '.srv-rule{display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
335
- '.srv-rule-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
336
- '.srv-rule-label{font-size:13px;line-height:20px;font-weight:500}',
337
- '.srv-rule-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
338
- '.srv-rule-select{flex:none;width:132px}',
339
- '.srv-audit{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;font-size:12px;line-height:18px}',
340
- '.srv-audit li{display:flex;gap:8px;align-items:baseline;min-width:0;padding:2px 0}',
341
- '.srv-audit-time{flex:none;color:var(--dsw-alias-label-caption);font-variant-numeric:tabular-nums}',
342
- '.srv-audit-decision{flex:none;width:56px;color:var(--dsw-alias-label-secondary)}',
343
- '.srv-audit-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,Menlo,monospace)}',
344
- '.srv-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;',
345
- 'border-radius:9px;background:var(--dsw-alias-state-error-primary);color:var(--dsw-static-neutral-bluish-00);',
346
- 'font-size:11px;line-height:18px;font-weight:600}',
347
- '.srv-pending{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}',
348
- '.srv-pending li{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 0;',
349
- 'border-bottom:.5px solid var(--dsw-alias-border-l2)}',
350
- '.srv-pending-main{flex:1 1 220px;min-width:0;display:flex;flex-direction:column;gap:2px}',
351
- '.srv-pending-title{font-size:13px;line-height:20px;font-weight:500;overflow-wrap:anywhere}',
352
- '.srv-pending-meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption)}',
353
- '.srv-danger-zone{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;',
354
- 'padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
355
- '@container (max-width:640px){.srv-field-row{grid-template-columns:1fr}.srv-rule{flex-wrap:wrap}',
356
- '.srv-rule-select{width:100%}}',
357
- ].join('')
358
-
359
- /** Inject the stylesheet once; the client module system tags it for HMR cleanup. */
360
- function installStyles() {
361
- if (typeof browserGlobal.document === 'undefined') return
362
- const document = browserGlobal.document
363
- if (document.querySelector('style[data-server-connector]') !== null) return
364
- const tag = document.createElement('style')
365
- tag.setAttribute('data-server-connector', '')
366
- tag.textContent = CSS
367
- document.head.appendChild(tag)
368
- }
369
-
370
- // ---------------------------------------------------------------- HTTP --
371
-
372
- /**
373
- * One route call. Same-origin, so the browser session cookie authenticates
374
- * it exactly like the rest of the GUI (§8.5 trust boundary).
375
- * @param {string} path - path below {@link API}.
376
- * @param {{method?: string, body?: unknown, query?: Record<string, string>}} [options]
377
- * @returns {Promise<any>}
378
- */
379
- async function api(path, options = {}) {
380
- const query = options.query === undefined ? '' : `?${new URLSearchParams(options.query).toString()}`
381
- /** @type {Record<string, string>} */
382
- const headers = {}
383
- /** @type {RequestInit} */
384
- const init = { method: options.method ?? 'GET', headers }
385
- if (options.body !== undefined) {
386
- headers['content-type'] = 'application/json'
387
- init.body = JSON.stringify(options.body)
388
- }
389
- const response = await browserGlobal.fetch(`${API}${path}${query}`, init)
390
- let payload
391
- const text = await response.text()
392
- try {
393
- payload = text === '' ? null : JSON.parse(text)
394
- } catch {
395
- payload = null
396
- }
397
- if (!response.ok) {
398
- throw Object.assign(
399
- new Error(typeof payload?.error?.message === 'string' ? payload.error.message : `HTTP ${String(response.status)}`),
400
- {
401
- code: payload?.error?.code ?? `HTTP_${String(response.status)}`,
402
- status: response.status,
403
- details: payload?.error?.details ?? {},
404
- },
405
- )
406
- }
407
- return payload
408
- }
409
-
410
- /**
411
- * The displayable message of a thrown value.
412
- * @param {unknown} error
413
- * @returns {string}
414
- */
415
- function messageOf(error) {
416
- if (error instanceof Error) return error.message
417
- if (typeof error === 'object' && error !== null && typeof (/** @type {any} */ (error).message) === 'string') {
418
- return /** @type {any} */ (error).message
419
- }
420
- return String(error)
421
- }
422
-
423
- /**
424
- * Format one timestamp for the panel; an unparsable value is shown as-is.
425
- * @param {unknown} value
426
- * @returns {string}
427
- */
428
- function formatTime(value) {
429
- if (typeof value !== 'string' || value === '') return '—'
430
- const date = new Date(value)
431
- if (Number.isNaN(date.getTime())) return value
432
- return date.toLocaleString(undefined, {
433
- month: '2-digit',
434
- day: '2-digit',
435
- hour: '2-digit',
436
- minute: '2-digit',
437
- })
438
- }
439
-
440
- // --------------------------------------------------------------- atoms --
441
-
442
- /** @param {{size?: number, children?: any}} props */
443
- function Icon({ size = 16, children }) {
444
- return h(
445
- 'svg',
446
- {
447
- width: size,
448
- height: size,
449
- viewBox: '0 0 24 24',
450
- fill: 'none',
451
- stroke: 'currentColor',
452
- strokeWidth: 1.8,
453
- strokeLinecap: 'round',
454
- strokeLinejoin: 'round',
455
- 'aria-hidden': true,
456
- },
457
- children,
458
- )
459
- }
460
-
461
- /** The §8.1 sidebar icon: a rack of two server units. */
462
- function ServerIcon({ size = 20 }) {
463
- return h(
464
- Icon,
465
- { size },
466
- h('rect', { key: 'a', x: 3, y: 5, width: 18, height: 6, rx: 1.6 }),
467
- h('rect', { key: 'b', x: 3, y: 13, width: 18, height: 6, rx: 1.6 }),
468
- h('path', { key: 'c', d: 'M6.5 8h.01M6.5 16h.01' }),
469
- )
470
- }
471
-
472
- /**
473
- * @param {{variant?: string, size?: string, danger?: boolean, children?: any} & Record<string, any>} props
474
- */
475
- function Button({ variant = 'ghost', size = 'md', danger = false, children, ...rest }) {
476
- const className = [
477
- 'srv-btn',
478
- size === 'sm' ? 'srv-btn-sm' : '',
479
- variant === 'primary' ? 'srv-btn-primary' : '',
480
- variant === 'outline' ? 'srv-btn-outline' : '',
481
- danger ? 'srv-btn-danger' : '',
482
- ]
483
- .filter(Boolean)
484
- .join(' ')
485
- return h('button', { type: 'button', className, ...rest }, children)
486
- }
487
-
488
- /** @param {{on: boolean}} props */
489
- function StatusDot({ on }) {
490
- return h('span', { className: 'srv-dot', 'data-on': on ? 'true' : 'false' })
491
- }
492
-
493
- // --------------------------------------------------------------- hooks --
494
-
495
- /**
496
- * Load `GET /servers` and keep a reload function.
497
- * @returns {{state: any, reload: () => Promise<void>}}
498
- */
499
- function useServers() {
500
- const [state, setState] = React.useState({ phase: 'loading' })
501
- const alive = React.useRef(true)
502
- React.useEffect(
503
- () => () => {
504
- alive.current = false
505
- },
506
- [],
507
- )
508
- const reload = React.useCallback(async () => {
509
- try {
510
- const data = await api('/servers')
511
- if (alive.current) setState({ phase: 'ready', data })
512
- } catch (error) {
513
- if (alive.current) setState({ phase: 'error', error })
514
- }
515
- }, [])
516
- React.useEffect(() => {
517
- void reload()
518
- }, [reload])
519
- return { state, reload }
520
- }
521
-
522
- // ------------------------------------------------------------- surfaces --
523
-
524
- /**
525
- * The §8.2 list row.
526
- * @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
527
- */
528
- function ServerRow({ server, t, onOpen, onEdit, onTest, onToggle, testing, toggling }) {
529
- const busy = testing === server.id
530
- const off = server.enabled === false
531
- const toggleBusy = toggling === server.id
532
- return h(
533
- 'li',
534
- { className: off ? 'srv-row srv-row-off' : 'srv-row' },
535
- h(StatusDot, { on: server.connected === true }),
536
- h(
537
- 'div',
538
- { className: 'srv-row-main' },
539
- h(
540
- 'div',
541
- { className: 'srv-row-title' },
542
- h('button', { type: 'button', className: 'srv-open', onClick: () => onOpen(server.id) }, server.name),
543
- ),
544
- h(
545
- 'div',
546
- { className: 'srv-row-meta' },
547
- h('span', { className: 'srv-mono' }, `${server.username}@${server.host}:${String(server.port)}`),
548
- server.defaultWorkDir === null
549
- ? null
550
- : h('span', { className: 'srv-mono' }, server.defaultWorkDir),
551
- h(
552
- 'span',
553
- { className: 'srv-tag', 'data-tone': server.connected === true ? 'success' : undefined },
554
- server.connected === true ? t('panel.connected') : t('panel.disconnected'),
555
- ),
556
- server.hostKeyMode === 'off' ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, 'host-key off') : null,
557
- off ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, t('panel.disabled')) : null,
558
- ),
559
- ),
560
- h(
561
- 'div',
562
- { className: 'srv-row-actions' },
563
- // The switch is the only control the panel needs for the enable state:
564
- // it applies immediately (no pending queue), so it reads as a switch.
565
- h(Switch, {
566
- checked: !off,
567
- disabled: toggleBusy || server.connected === true,
568
- label: off ? t('panel.enable') : t('panel.disable'),
569
- hint: server.connected === true ? t('panel.disconnectFirst') : null,
570
- onChange: () => void onToggle(server),
571
- }),
572
- h(Button, { size: 'sm', variant: 'outline', disabled: busy || off, onClick: () => void onTest(server.id) }, busy ? t('panel.testing') : t('panel.test')),
573
- h(Button, { size: 'sm', onClick: () => onEdit(server.id) }, t('panel.edit')),
574
- ),
575
- )
576
- }
577
-
578
- /**
579
- * A minimal on/off switch. The panel has no design-system switch in the
580
- * injected surface, so this is the smallest one that reads as a switch and
581
- * stays usable when disabled.
582
- * @param {{checked: boolean, disabled?: boolean, label: string, hint?: string|null, onChange: () => void}} props
583
- */
584
- function Switch({ checked, disabled = false, label, hint = null, onChange }) {
585
- return h(
586
- 'button',
587
- {
588
- type: 'button',
589
- role: 'switch',
590
- 'aria-checked': checked ? 'true' : 'false',
591
- 'aria-label': label,
592
- title: hint ?? label,
593
- className: 'srv-switch',
594
- 'data-on': checked ? 'true' : 'false',
595
- disabled,
596
- onClick: () => onChange(),
597
- },
598
- h('span', { className: 'srv-switch-knob', 'aria-hidden': 'true' }),
599
- )
600
- }
601
-
602
- /**
603
- * The list view (or the empty state). Owns no data: the panel passes state.
604
- * @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
605
- */
606
- function ServerList({ state, t, reload, onAdd, onOpen, onEdit, onTest, onToggle, testing, toggling, notice }) {
607
- const data = state.phase === 'ready' ? state.data : null
608
- const servers = data === null ? [] : data.servers
609
- const pendingCount = data === null ? 0 : data.pendingCount
610
- return h(
611
- F,
612
- null,
613
- h(
614
- 'div',
615
- { className: 'srv-head' },
616
- h('h2', null, t('panel.title')),
617
- pendingCount > 0
618
- ? h('span', { className: 'srv-badge', title: t('settings.pending') }, String(pendingCount))
619
- : null,
620
- h(
621
- 'div',
622
- { className: 'srv-head-actions' },
623
- h(Button, { variant: 'primary', onClick: onAdd }, t('panel.add')),
624
- ),
625
- ),
626
- notice === null ? null : h('p', { className: 'srv-note' }, notice),
627
- state.phase === 'loading' ? h('p', { className: 'srv-empty' }, t('panel.loading')) : null,
628
- state.phase === 'error'
629
- ? h(
630
- 'div',
631
- { className: 'srv-empty' },
632
- h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) })),
633
- h(Button, { variant: 'outline', onClick: () => void reload() }, t('panel.retry')),
634
- )
635
- : null,
636
- state.phase === 'ready' && servers.length === 0
637
- ? h(
638
- 'div',
639
- { className: 'srv-empty' },
640
- h('p', null, t('panel.empty')),
641
- h('p', null, t('panel.emptyHint')),
642
- )
643
- : null,
644
- servers.length > 0
645
- ? h(
646
- 'ul',
647
- { className: 'srv-list' },
648
- servers.map((/** @type {any} */ server) =>
649
- h(ServerRow, {
650
- key: server.id,
651
- server,
652
- t,
653
- onOpen,
654
- onEdit,
655
- onTest,
656
- onToggle,
657
- testing,
658
- toggling,
659
- }),
660
- ),
661
- )
662
- : null,
663
- )
664
- }
665
-
666
- /**
667
- * Custom rules for one server (doc §4.4).
668
- *
669
- * The section owns its own draft state: a rule is only sent to the Host after
670
- * the operator has seen a dry run of it against a sample command, which is
671
- * the one guard that catches "the regex never fires".
672
- * @param {{server: any, t: any, onRefresh: () => Promise<void>}} props
673
- */
674
- function CustomRulesSection({ server, t, onRefresh }) {
675
- const rules = Array.isArray(server.customRules) ? server.customRules : []
676
- const [draft, setDraft] = React.useState(null)
677
- const [busy, setBusy] = React.useState(false)
678
- const [error, setError] = React.useState(null)
679
- const [preview, setPreview] = React.useState(null)
680
-
681
- const startAdd = () =>
682
- setDraft({ label: '', pattern: '', defaultDecision: 'confirm', description: '', samples: '' })
683
- const cancel = () => {
684
- setDraft(null)
685
- setPreview(null)
686
- setError(null)
687
- }
688
-
689
- /** @param {any} event */
690
- const dryRun = async (event) => {
691
- event.preventDefault()
692
- if (draft === null) return
693
- setBusy(true)
694
- setError(null)
695
- try {
696
- const samples = String(draft.samples ?? '')
697
- .split('\n')
698
- .map((line) => line.trim())
699
- .filter((line) => line !== '')
700
- if (samples.length === 0) throw new Error(t('rules.needSample'))
701
- const data = await api(`/servers/${encodeURIComponent(server.id)}/rules/preview`, {
702
- method: 'POST',
703
- body: {
704
- label: draft.label === '' ? 'preview' : draft.label,
705
- pattern: draft.pattern,
706
- defaultDecision: draft.defaultDecision,
707
- samples,
708
- },
709
- })
710
- setPreview(data)
711
- } catch (failure) {
712
- setPreview(null)
713
- setError(messageOf(failure))
714
- } finally {
715
- setBusy(false)
716
- }
717
- }
718
-
719
- /** @param {any} event */
720
- const save = async (event) => {
721
- event.preventDefault()
722
- if (draft === null) return
723
- setBusy(true)
724
- setError(null)
725
- try {
726
- const samples = String(draft.samples ?? '')
727
- .split('\n')
728
- .map((line) => line.trim())
729
- .filter((line) => line !== '')
730
- await api(`/servers/${encodeURIComponent(server.id)}/rules`, {
731
- method: 'POST',
732
- body: {
733
- label: draft.label,
734
- pattern: draft.pattern,
735
- defaultDecision: draft.defaultDecision,
736
- description: draft.description,
737
- samples,
738
- },
739
- })
740
- setDraft(null)
741
- setPreview(null)
742
- await onRefresh()
743
- } catch (failure) {
744
- setError(messageOf(failure))
745
- } finally {
746
- setBusy(false)
747
- }
748
- }
749
-
750
- /** @param {string} ruleId */
751
- const remove = async (ruleId) => {
752
- setBusy(true)
753
- setError(null)
754
- try {
755
- await api(`/servers/${encodeURIComponent(server.id)}/rules/${encodeURIComponent(ruleId)}`, {
756
- method: 'DELETE',
757
- })
758
- await onRefresh()
759
- } catch (failure) {
760
- setError(messageOf(failure))
761
- } finally {
762
- setBusy(false)
763
- }
764
- }
765
-
766
- return h(
767
- 'section',
768
- { className: 'srv-card' },
769
- h('h3', { className: 'srv-card-title' }, t('rules.section')),
770
- h('p', { className: 'srv-hint' }, t('rules.hint')),
771
- rules.length === 0 ? h('p', { className: 'srv-hint' }, t('rules.empty')) : null,
772
- rules.length > 0
773
- ? h(
774
- 'ul',
775
- { className: 'srv-rules' },
776
- rules.map((/** @type {any} */ rule) =>
777
- h(
778
- 'li',
779
- { className: 'srv-rule', key: rule.id },
780
- h(
781
- 'div',
782
- { className: 'srv-rule-body' },
783
- h('span', { className: 'srv-rule-label' }, rule.label),
784
- h('span', { className: 'srv-rule-desc' }, rule.description || rule.pattern),
785
- ),
786
- h('span', { className: 'srv-tag', 'data-tone': rule.defaultDecision === 'block' ? undefined : 'success' }, rule.defaultDecision),
787
- h(
788
- Button,
789
- { size: 'sm', danger: true, disabled: busy, onClick: () => void remove(rule.id) },
790
- t('rules.delete'),
791
- ),
792
- ),
793
- ),
794
- )
795
- : null,
796
- error !== null ? h('p', { className: 'srv-error' }, error) : null,
797
- draft === null
798
- ? h(Button, { variant: 'outline', disabled: busy, onClick: startAdd }, t('rules.add'))
799
- : h(
800
- 'form',
801
- { className: 'srv-form', onSubmit: save },
802
- h(
803
- 'div',
804
- { className: 'srv-field' },
805
- h('label', { htmlFor: 'srv-rule-label' }, t('rules.label')),
806
- h('input', {
807
- id: 'srv-rule-label',
808
- className: 'srv-input',
809
- value: draft.label,
810
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, label: event.target.value }),
811
- }),
812
- ),
813
- h(
814
- 'div',
815
- { className: 'srv-field' },
816
- h('label', { htmlFor: 'srv-rule-pattern' }, t('rules.pattern')),
817
- h('input', {
818
- id: 'srv-rule-pattern',
819
- className: 'srv-input srv-mono',
820
- value: draft.pattern,
821
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, pattern: event.target.value }),
822
- }),
823
- ),
824
- h(
825
- 'div',
826
- { className: 'srv-field' },
827
- h('label', { htmlFor: 'srv-rule-decision' }, t('rules.decision')),
828
- h(
829
- 'select',
830
- {
831
- id: 'srv-rule-decision',
832
- className: 'srv-select',
833
- value: draft.defaultDecision,
834
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, defaultDecision: event.target.value }),
835
- },
836
- h('option', { value: 'confirm' }, 'confirm'),
837
- h('option', { value: 'block' }, 'block'),
838
- ),
839
- ),
840
- h(
841
- 'div',
842
- { className: 'srv-field' },
843
- h('label', { htmlFor: 'srv-rule-desc' }, t('rules.description')),
844
- h('input', {
845
- id: 'srv-rule-desc',
846
- className: 'srv-input',
847
- value: draft.description,
848
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, description: event.target.value }),
849
- }),
850
- ),
851
- h(
852
- 'div',
853
- { className: 'srv-field' },
854
- h('label', { htmlFor: 'srv-rule-samples' }, t('rules.samples')),
855
- h('textarea', {
856
- id: 'srv-rule-samples',
857
- className: 'srv-input srv-mono',
858
- rows: 3,
859
- value: draft.samples,
860
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, samples: event.target.value }),
861
- }),
862
- h('span', { className: 'srv-hint' }, t('rules.samplesHint')),
863
- ),
864
- preview !== null
865
- ? h(
866
- 'ul',
867
- { className: 'srv-rules' },
868
- (preview.results ?? []).map((/** @type {any} */ item, /** @type {number} */ index) =>
869
- h(
870
- 'li',
871
- { className: 'srv-rule', key: index },
872
- h(
873
- 'div',
874
- { className: 'srv-rule-body' },
875
- h('span', { className: 'srv-rule-label srv-mono' }, item.sample),
876
- h('span', { className: 'srv-rule-desc' }, `${t('rules.wouldMatch')}: ${item.matched ? 'yes' : 'no'} · ${String(item.decision)}`),
877
- ),
878
- ),
879
- ),
880
- )
881
- : null,
882
- h(
883
- 'div',
884
- { className: 'srv-actions' },
885
- h(Button, { type: 'button', disabled: busy, onClick: dryRun }, t('rules.preview')),
886
- h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
887
- h(Button, { type: 'button', disabled: busy, onClick: cancel }, t('form.cancel')),
888
- ),
889
- ),
890
- )
891
- }
892
-
893
- /**
894
- * The detail page: four connection fields read-only, the policy table, the
895
- * last 20 audit records and [删除] (§8.2).
896
- * @param {{server: any, rules: any[], t: any, onBack: () => void, onEdit: () => void, onDelete: () => Promise<void>, deleting: boolean, notice: string|null, onRefresh: () => Promise<void>}} props
897
- */
898
- function ServerDetail({ server, rules, t, onBack, onEdit, onDelete, deleting, notice, onRefresh }) {
899
- const [audit, setAudit] = React.useState({ phase: 'loading' })
900
- React.useEffect(() => {
901
- let alive = true
902
- api('/audit', { query: { limit: String(AUDIT_LIMIT), serverId: server.id } })
903
- .then((data) => {
904
- if (alive) setAudit({ phase: 'ready', records: data.records })
905
- })
906
- .catch((error) => {
907
- if (alive) setAudit({ phase: 'error', error })
908
- })
909
- return () => {
910
- alive = false
911
- }
912
- }, [server.id])
913
-
914
- const authText =
915
- server.auth.kind === 'key'
916
- ? `${t('auth.key')} · ${server.auth.privateKeyPath}`
917
- : server.auth.kind === 'password'
918
- ? server.passwordConfigured === true
919
- ? `${t('auth.password')} · ${t('auth.passwordConfigured')}`
920
- : `${t('auth.password')} · —`
921
- : t('auth.agent')
922
-
923
- return h(
924
- F,
925
- null,
926
- h(
927
- 'div',
928
- { className: 'srv-head' },
929
- h(Button, { size: 'sm', onClick: onBack }, `← ${t('panel.back')}`),
930
- h('h2', null, server.name),
931
- h(
932
- 'div',
933
- { className: 'srv-head-actions' },
934
- h(Button, { size: 'sm', variant: 'outline', onClick: onEdit }, t('panel.edit')),
935
- ),
936
- ),
937
- notice === null ? null : h('p', { className: 'srv-note' }, notice),
938
- h(
939
- 'section',
940
- { className: 'srv-card' },
941
- h('h3', { className: 'srv-card-title' }, t('panel.sectionConnection')),
942
- h(
943
- 'dl',
944
- { className: 'srv-kv' },
945
- h('dt', null, t('field.name')),
946
- h('dd', null, server.name),
947
- h('dt', null, t('field.host')),
948
- h('dd', { className: 'srv-mono' }, `${server.host}:${String(server.port)}`),
949
- h('dt', null, t('field.username')),
950
- h('dd', { className: 'srv-mono' }, server.username),
951
- h('dt', null, t('field.auth')),
952
- h('dd', { className: 'srv-mono' }, authText),
953
- h('dt', null, t('field.hostKeyMode')),
954
- h('dd', null, server.hostKeyMode),
955
- h('dt', null, t('field.defaultWorkDir')),
956
- h('dd', { className: 'srv-mono' }, server.defaultWorkDir === null ? '—' : server.defaultWorkDir),
957
- h('dt', null, t('field.lastConnectedAt')),
958
- h(
959
- 'dd',
960
- null,
961
- server.lastConnectedAt === null ? t('panel.neverConnected') : t('panel.lastConnected', { time: formatTime(server.lastConnectedAt) }),
962
- ),
963
- ),
964
- ),
965
- h(
966
- 'section',
967
- { className: 'srv-card' },
968
- h('h3', { className: 'srv-card-title' }, t('panel.sectionPolicy')),
969
- h(
970
- 'ul',
971
- { className: 'srv-rules' },
972
- rules.map((/** @type {any} */ rule) => {
973
- const state = server.policyState?.[rule.id] ?? { configured: null, decision: null, disabled: false }
974
- return h(
975
- 'li',
976
- { className: 'srv-rule', key: rule.id },
977
- h(
978
- 'div',
979
- { className: 'srv-rule-body' },
980
- h('span', { className: 'srv-rule-label' }, rule.label),
981
- h('span', { className: 'srv-rule-desc' }, rule.description),
982
- ),
983
- h(
984
- 'span',
985
- {
986
- className: 'srv-tag',
987
- 'data-tone': state.disabled ? 'warn' : state.decision === 'block' ? undefined : 'success',
988
- },
989
- state.disabled ? t('form.offOption') : String(state.decision ?? rule.defaultDecision),
990
- ),
991
- )
992
- }),
993
- ),
994
- ),
995
- h(CustomRulesSection, { server, t, onRefresh }),
996
- h(
997
- 'section',
998
- { className: 'srv-card' },
999
- h('h3', { className: 'srv-card-title' }, t('panel.sectionAudit')),
1000
- audit.phase === 'loading' ? h('p', { className: 'srv-hint' }, t('panel.auditLoading')) : null,
1001
- audit.phase === 'error'
1002
- ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(audit.error) }))
1003
- : null,
1004
- audit.phase === 'ready' && audit.records.length === 0
1005
- ? h('p', { className: 'srv-hint' }, t('panel.auditEmpty'))
1006
- : null,
1007
- audit.phase === 'ready' && audit.records.length > 0
1008
- ? h(
1009
- 'ul',
1010
- { className: 'srv-audit' },
1011
- audit.records.map((/** @type {any} */ record, /** @type {number} */ index) =>
1012
- h(
1013
- 'li',
1014
- { key: `${String(record.ts)}-${String(index)}` },
1015
- h('span', { className: 'srv-audit-time' }, formatTime(record.ts)),
1016
- h('span', { className: 'srv-audit-decision' }, String(record.event ?? record.decision ?? '')),
1017
- h(
1018
- 'span',
1019
- { className: 'srv-audit-text', title: String(record.command ?? record.detail ?? '') },
1020
- String(record.command ?? record.detail ?? ''),
1021
- ),
1022
- ),
1023
- ),
1024
- )
1025
- : null,
1026
- ),
1027
- h(
1028
- 'div',
1029
- { className: 'srv-danger-zone' },
1030
- h('span', { className: 'srv-hint' }, t('panel.deleteHint')),
1031
- h(Button, { variant: 'outline', danger: true, disabled: deleting, onClick: () => void onDelete() }, deleting ? t('pending.busy') : t('panel.delete')),
1032
- ),
1033
- )
1034
- }
1035
-
1036
- /**
1037
- * Whether a path is absolute on *this* machine.
1038
- *
1039
- * `auth.privateKeyPath` names a local file the Host reads, so on Windows a
1040
- * drive-letter (`C:\Users\me\.ssh\id_ed25519`) or UNC (`\\server\share\key`)
1041
- * path is absolute. The panel used to demand a leading `/` here, which made
1042
- * key auth impossible to submit on Windows.
1043
- * @param {string} path
1044
- * @returns {boolean}
1045
- */
1046
- function isLocalAbsolutePath(path) {
1047
- if (path.startsWith('/')) return true
1048
- if (/^[A-Za-z]:[\\/]/.test(path)) return true
1049
- return path.startsWith('\\\\')
1050
- }
1051
-
1052
- /**
1053
- * The create/edit form (§8.4). Block rules expose only confirm/block, and
1054
- * "off" is queued rather than applied (§8.3).
1055
- * @param {{server: any|null, rules: any[], hostKeyModes: string[], t: any, onCancel: () => void, onSaved: (message: string|null) => void}} props
1056
- */
1057
- function ServerForm({ server, rules, hostKeyModes, t, onCancel, onSaved }) {
1058
- const editing = server !== null
1059
- const [name, setName] = React.useState(editing ? server.name : '')
1060
- const [host, setHost] = React.useState(editing ? server.host : '')
1061
- const [port, setPort] = React.useState(editing ? String(server.port) : '22')
1062
- const [username, setUsername] = React.useState(editing ? server.username : '')
1063
- const [authKind, setAuthKind] = React.useState(editing ? server.auth.kind : 'key')
1064
- const [privateKeyPath, setPrivateKeyPath] = React.useState(editing && server.auth.kind === 'key' ? server.auth.privateKeyPath : '')
1065
- const [password, setPassword] = React.useState('')
1066
- const [hostKeyMode, setHostKeyMode] = React.useState(editing ? server.hostKeyMode : 'accept-new')
1067
- const [defaultWorkDir, setDefaultWorkDir] = React.useState(editing && server.defaultWorkDir !== null ? server.defaultWorkDir : '')
1068
- const [policy, setPolicy] = React.useState(() => {
1069
- /** @type {Record<string, string>} */
1070
- const initial = {}
1071
- for (const rule of rules) {
1072
- const state = editing ? server.policyState?.[rule.id] : null
1073
- initial[rule.id] = state === undefined || state === null ? rule.defaultDecision : state.disabled ? 'off' : String(state.decision)
1074
- }
1075
- return initial
1076
- })
1077
- const [busy, setBusy] = React.useState(false)
1078
- const [error, setError] = React.useState(/** @type {string|null} */ (null))
1079
- const [note, setNote] = React.useState(/** @type {string|null} */ (null))
1080
-
1081
- /** @param {string} ruleId @param {string} value */
1082
- const setRule = (ruleId, value) =>
1083
- setPolicy((/** @type {Record<string, string>} */ current) => ({ ...current, [ruleId]: value }))
1084
-
1085
- /** Validate one client-side rule before touching the network (§8.4 validations). */
1086
- const validate = () => {
1087
- if (name.trim() === '') return t('form.errorName')
1088
- if (host.trim() === '') return t('form.errorHost')
1089
- if (username.trim() === '') return t('form.errorUsername')
1090
- const parsed = Number(port)
1091
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return t('form.portRange')
1092
- if (authKind === 'key' && privateKeyPath.trim() === '') return t('form.errorKeyPath')
1093
- // The key file lives on *this* machine, so a Windows drive-letter or UNC
1094
- // path is as absolute as a POSIX one (the Host accepts both).
1095
- if (authKind === 'key' && !isLocalAbsolutePath(privateKeyPath.trim())) return t('form.absolutePath')
1096
- if (authKind === 'password' && !editing && password === '') return t('form.errorPassword')
1097
- // The working directory is on the *remote* host: POSIX only.
1098
- if (defaultWorkDir.trim() !== '' && !defaultWorkDir.trim().startsWith('/')) return t('form.absolutePath')
1099
- return null
1100
- }
1101
-
1102
- /** @param {any} event */
1103
- const submit = async (event) => {
1104
- event.preventDefault()
1105
- const problem = validate()
1106
- if (problem !== null) {
1107
- setError(problem)
1108
- return
1109
- }
1110
- setBusy(true)
1111
- setError(null)
1112
- setNote(null)
1113
- try {
1114
- /** @type {Record<string, unknown>} */
1115
- const body = {
1116
- name: name.trim(),
1117
- host: host.trim(),
1118
- port: Number(port),
1119
- username: username.trim(),
1120
- auth: authKind === 'key' ? { kind: 'key', privateKeyPath: privateKeyPath.trim() } : { kind: authKind },
1121
- hostKeyMode,
1122
- defaultWorkDir: defaultWorkDir.trim() === '' ? null : defaultWorkDir.trim(),
1123
- }
1124
- if (authKind === 'password' && password !== '') body.password = password
1125
-
1126
- // Only rules that leave their built-in default are stored, so the
1127
- // record stays readable; "off" and re-enabling have their own fields
1128
- // because both change the *existing* rule set.
1129
- /** @type {Record<string, string>} */
1130
- const storedPolicy = {}
1131
- /** @type {string[]} */
1132
- const disableRules = []
1133
- /** @type {string[]} */
1134
- const enableRules = []
1135
- for (const rule of rules) {
1136
- const value = policy[rule.id]
1137
- if (value === undefined) continue
1138
- if (value === 'off') {
1139
- disableRules.push(rule.id)
1140
- continue
1141
- }
1142
- if (value === rule.defaultDecision) continue
1143
- storedPolicy[rule.id] = value
1144
- }
1145
- body.policy = storedPolicy
1146
-
1147
- if (editing) {
1148
- for (const rule of rules) {
1149
- const value = policy[rule.id]
1150
- const wasDisabled = server.policyState?.[rule.id]?.disabled === true
1151
- if (wasDisabled && value !== undefined && value !== 'off') enableRules.push(rule.id)
1152
- }
1153
- if (disableRules.length > 0) body.disableRules = disableRules
1154
- if (enableRules.length > 0) body.enableRules = enableRules
1155
- const answer = await api(`/servers/${encodeURIComponent(server.id)}`, { method: 'PUT', body })
1156
- if (answer.pending !== undefined && answer.pending.length > 0) {
1157
- setNote(t('form.pendingNote'))
1158
- setBusy(false)
1159
- onSaved(t('form.pendingNote'))
1160
- return
1161
- }
1162
- } else {
1163
- if (disableRules.length > 0) body.disableRules = disableRules
1164
- const answer = await api('/servers', { method: 'POST', body })
1165
- if (answer.pending !== undefined && answer.pending.length > 0) {
1166
- onSaved(t('form.pendingNote'))
1167
- return
1168
- }
1169
- }
1170
- onSaved(null)
1171
- } catch (error) {
1172
- setError(t('error.generic', { message: messageOf(error) }))
1173
- } finally {
1174
- setBusy(false)
1175
- }
1176
- }
1177
-
1178
- return h(
1179
- 'form',
1180
- { className: 'srv-form', onSubmit: submit },
1181
- h('h2', { className: 'srv-card-title' }, editing ? t('form.edit') : t('form.new')),
1182
- h(
1183
- 'div',
1184
- { className: 'srv-field' },
1185
- h('label', { htmlFor: 'srv-name' }, t('field.name')),
1186
- h('input', {
1187
- id: 'srv-name',
1188
- className: 'srv-input',
1189
- value: name,
1190
- onChange: (/** @type {any} */ event) => setName(event.target.value),
1191
- required: true,
1192
- }),
1193
- ),
1194
- h(
1195
- 'div',
1196
- { className: 'srv-field-row' },
1197
- h(
1198
- 'div',
1199
- { className: 'srv-field' },
1200
- h('label', { htmlFor: 'srv-host' }, t('field.host')),
1201
- h('input', {
1202
- id: 'srv-host',
1203
- className: 'srv-input',
1204
- value: host,
1205
- onChange: (/** @type {any} */ event) => setHost(event.target.value),
1206
- required: true,
1207
- }),
1208
- ),
1209
- h(
1210
- 'div',
1211
- { className: 'srv-field' },
1212
- h('label', { htmlFor: 'srv-port' }, t('field.port')),
1213
- h('input', {
1214
- id: 'srv-port',
1215
- className: 'srv-input',
1216
- type: 'number',
1217
- min: 1,
1218
- max: 65535,
1219
- value: port,
1220
- onChange: (/** @type {any} */ event) => setPort(event.target.value),
1221
- }),
1222
- ),
1223
- ),
1224
- h(
1225
- 'div',
1226
- { className: 'srv-field' },
1227
- h('label', { htmlFor: 'srv-username' }, t('field.username')),
1228
- h('input', {
1229
- id: 'srv-username',
1230
- className: 'srv-input',
1231
- value: username,
1232
- onChange: (/** @type {any} */ event) => setUsername(event.target.value),
1233
- required: true,
1234
- }),
1235
- ),
1236
- h(
1237
- 'div',
1238
- { className: 'srv-field' },
1239
- h('span', null, t('field.auth')),
1240
- h(
1241
- 'div',
1242
- { className: 'srv-seg', role: 'group', 'aria-label': t('field.auth') },
1243
- ['key', 'password', 'agent'].map((kind) =>
1244
- h(
1245
- 'button',
1246
- {
1247
- key: kind,
1248
- type: 'button',
1249
- 'aria-pressed': authKind === kind ? 'true' : 'false',
1250
- onClick: () => setAuthKind(kind),
1251
- },
1252
- t(`auth.${kind}`),
1253
- ),
1254
- ),
1255
- ),
1256
- ),
1257
- authKind === 'key'
1258
- ? h(
1259
- 'div',
1260
- { className: 'srv-field' },
1261
- h('label', { htmlFor: 'srv-key' }, t('field.privateKeyPath')),
1262
- h('input', {
1263
- id: 'srv-key',
1264
- className: 'srv-input srv-mono',
1265
- value: privateKeyPath,
1266
- placeholder: '/root/.ssh/id_ed25519',
1267
- onChange: (/** @type {any} */ event) => setPrivateKeyPath(event.target.value),
1268
- }),
1269
- )
1270
- : null,
1271
- authKind === 'password'
1272
- ? h(
1273
- 'div',
1274
- { className: 'srv-field' },
1275
- h('label', { htmlFor: 'srv-password' }, t('field.password')),
1276
- h('input', {
1277
- id: 'srv-password',
1278
- className: 'srv-input',
1279
- type: 'password',
1280
- value: password,
1281
- autoComplete: 'new-password',
1282
- placeholder: editing && server.passwordConfigured === true ? t('auth.passwordConfigured') : '',
1283
- onChange: (/** @type {any} */ event) => setPassword(event.target.value),
1284
- }),
1285
- h('span', { className: 'srv-hint' }, editing ? t('form.passwordKeep') : t('form.passwordNew')),
1286
- )
1287
- : null,
1288
- h(
1289
- 'div',
1290
- { className: 'srv-field' },
1291
- h('label', { htmlFor: 'srv-hostkey' }, t('field.hostKeyMode')),
1292
- h(
1293
- 'select',
1294
- {
1295
- id: 'srv-hostkey',
1296
- className: 'srv-select',
1297
- value: hostKeyMode,
1298
- onChange: (/** @type {any} */ event) => setHostKeyMode(event.target.value),
1299
- },
1300
- hostKeyModes.map((mode) => h('option', { key: mode, value: mode }, t(`hostKey.${mode}`))),
1301
- ),
1302
- hostKeyMode === 'off' && (editing ? server.hostKeyMode !== 'off' : false)
1303
- ? h('span', { className: 'srv-hint' }, t('form.offQueued'))
1304
- : null,
1305
- ),
1306
- h(
1307
- 'div',
1308
- { className: 'srv-field' },
1309
- h('label', { htmlFor: 'srv-workdir' }, t('field.defaultWorkDir')),
1310
- h('input', {
1311
- id: 'srv-workdir',
1312
- className: 'srv-input srv-mono',
1313
- value: defaultWorkDir,
1314
- placeholder: '/srv/app',
1315
- onChange: (/** @type {any} */ event) => setDefaultWorkDir(event.target.value),
1316
- }),
1317
- ),
1318
- h(
1319
- 'div',
1320
- { className: 'srv-field' },
1321
- h('span', null, t('field.policy')),
1322
- h('p', { className: 'srv-hint' }, t('form.policyHint')),
1323
- h(
1324
- 'ul',
1325
- { className: 'srv-rules' },
1326
- rules.map((/** @type {any} */ rule) => {
1327
- const value = policy[rule.id] ?? rule.defaultDecision
1328
- // "停用" is always offered and always queued (§8.3): a new server
1329
- // is created with the rule enabled and a disable request filed.
1330
- const options = [...rule.allowedDecisions, 'off']
1331
- return h(
1332
- 'li',
1333
- { className: 'srv-rule', key: rule.id },
1334
- h(
1335
- 'div',
1336
- { className: 'srv-rule-body' },
1337
- h('span', { className: 'srv-rule-label' }, rule.label),
1338
- h('span', { className: 'srv-rule-desc' }, rule.description),
1339
- ),
1340
- h(
1341
- 'select',
1342
- {
1343
- className: 'srv-select srv-rule-select',
1344
- 'aria-label': rule.label,
1345
- value,
1346
- onChange: (/** @type {any} */ event) => setRule(rule.id, event.target.value),
1347
- },
1348
- options.map((decision) =>
1349
- h('option', { key: decision, value: decision }, decision === 'off' ? t('form.offOption') : decision),
1350
- ),
1351
- ),
1352
- )
1353
- }),
1354
- ),
1355
- ),
1356
- error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1357
- note === null ? null : h('p', { className: 'srv-note' }, note),
1358
- h(
1359
- 'div',
1360
- { className: 'srv-head-actions' },
1361
- h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
1362
- h(Button, { onClick: onCancel, disabled: busy }, t('form.cancel')),
1363
- ),
1364
- )
1365
- }
1366
-
1367
- /**
1368
- * The main panel: list ⇄ detail ⇄ form (§8.2). One server's data lives in
1369
- * the list payload, so a mutation is followed by a reload rather than by
1370
- * patching several copies.
1371
- * @param {{t: any}} props
1372
- */
1373
- function ServerPanel({ t }) {
1374
- const { state, reload } = useServers()
1375
- const [view, setView] = React.useState({ kind: 'list' })
1376
- const [testing, setTesting] = React.useState(/** @type {string|null} */ (null))
1377
- const [toggling, setToggling] = React.useState(/** @type {string|null} */ (null))
1378
- const [deleting, setDeleting] = React.useState(false)
1379
- const [notice, setNotice] = React.useState(/** @type {string|null} */ (null))
1380
- const [status, setStatus] = React.useState(/** @type {string|null} */ (null))
1381
-
1382
- const data = state.phase === 'ready' ? state.data : null
1383
- const servers = data === null ? [] : data.servers
1384
- const rules = data === null ? [] : data.rules
1385
- const hostKeyModes = data === null ? ['accept-new', 'verify', 'off'] : data.hostKeyModes
1386
-
1387
- /** @param {string} id */
1388
- const test = async (id) => {
1389
- setTesting(id)
1390
- setNotice(null)
1391
- setStatus(null)
1392
- try {
1393
- const answer = await api('/test', { method: 'POST', body: { id } })
1394
- setStatus(
1395
- t('status.connected', {
1396
- platform: answer.platform,
1397
- shell: answer.shell,
1398
- sudo: answer.sudo,
1399
- ms: answer.durationMs,
1400
- }),
1401
- )
1402
- await reload()
1403
- } catch (error) {
1404
- setStatus(t('error.generic', { message: messageOf(error) }))
1405
- } finally {
1406
- setTesting(null)
1407
- }
1408
- }
1409
-
1410
- /** @param {string} id */
1411
- const remove = async (id) => {
1412
- setDeleting(true)
1413
- setNotice(null)
1414
- try {
1415
- await api(`/servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
1416
- setNotice(t('panel.deleteQueued'))
1417
- setView({ kind: 'list' })
1418
- await reload()
1419
- } catch (error) {
1420
- setNotice(t('error.generic', { message: messageOf(error) }))
1421
- } finally {
1422
- setDeleting(false)
1423
- }
1424
- }
1425
-
1426
- /**
1427
- * Flip the enable switch. It applies immediately — no pending queue — so
1428
- * the only failure modes are "connected" (409) and a network error.
1429
- * @param {any} server
1430
- */
1431
- const toggle = async (server) => {
1432
- setToggling(server.id)
1433
- setNotice(null)
1434
- try {
1435
- await api(`/servers/${encodeURIComponent(server.id)}`, {
1436
- method: 'PUT',
1437
- body: { enabled: server.enabled === false },
1438
- })
1439
- await reload()
1440
- } catch (error) {
1441
- setNotice(t('error.generic', { message: messageOf(error) }))
1442
- } finally {
1443
- setToggling(null)
1444
- }
1445
- }
1446
-
1447
- /** @param {string|null} message */
1448
- const saved = async (message) => {
1449
- setNotice(message)
1450
- setView({ kind: 'list' })
1451
- await reload()
1452
- }
1453
-
1454
- const current = view.kind === 'detail' ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1455
- const edited = view.kind === 'form' && view.id !== undefined ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1456
-
1457
- return h(
1458
- 'div',
1459
- { className: 'srv-page' },
1460
- h(
1461
- 'div',
1462
- { className: 'srv-column' },
1463
- view.kind === 'list'
1464
- ? h(ServerList, {
1465
- state,
1466
- t,
1467
- reload,
1468
- onAdd: () => {
1469
- setNotice(null)
1470
- setView({ kind: 'form' })
1471
- },
1472
- onOpen: (/** @type {string} */ id) => {
1473
- setNotice(null)
1474
- setView({ kind: 'detail', id })
1475
- },
1476
- onEdit: (/** @type {string} */ id) => {
1477
- setNotice(null)
1478
- setView({ kind: 'form', id })
1479
- },
1480
- onTest: test,
1481
- onToggle: toggle,
1482
- testing,
1483
- toggling,
1484
- notice: notice ?? status,
1485
- })
1486
- : null,
1487
- view.kind === 'detail' && current !== null
1488
- ? h(ServerDetail, {
1489
- server: current,
1490
- rules,
1491
- t,
1492
- onBack: () => setView({ kind: 'list' }),
1493
- onEdit: () => setView({ kind: 'form', id: current.id }),
1494
- onDelete: () => remove(current.id),
1495
- deleting,
1496
- notice: status,
1497
- onRefresh: reload,
1498
- })
1499
- : null,
1500
- view.kind === 'detail' && current === null
1501
- ? h(
1502
- 'div',
1503
- { className: 'srv-empty' },
1504
- h('p', null, t('panel.notFound')),
1505
- h(Button, { variant: 'outline', onClick: () => setView({ kind: 'list' }) }, t('panel.back')),
1506
- )
1507
- : null,
1508
- view.kind === 'form'
1509
- ? h(ServerForm, {
1510
- server: edited,
1511
- rules,
1512
- hostKeyModes,
1513
- t,
1514
- onCancel: () => setView({ kind: 'list' }),
1515
- onSaved: saved,
1516
- })
1517
- : null,
1518
- ),
1519
- )
1520
- }
1521
-
1522
- /**
1523
- * One §8.3 pending row.
1524
- * @param {{record: any, t: any, onConfirm: (id: string) => Promise<void>, onCancel: (id: string) => Promise<void>, busy: string|null}} props
1525
- */
1526
- function PendingRow({ record, t, onConfirm, onCancel, busy }) {
1527
- const working = busy === record.id
1528
- const name = typeof record.serverName === 'string' ? record.serverName : record.serverId
1529
- const title =
1530
- record.kind === 'delete-server'
1531
- ? t('pending.deleteServer', { name })
1532
- : record.kind === 'disable-hostkey'
1533
- ? t('pending.disableHostkey', { name })
1534
- : t('pending.disableRule', { rule: String(record.ruleId ?? '?'), name })
1535
- return h(
1536
- 'li',
1537
- null,
1538
- h(
1539
- 'div',
1540
- { className: 'srv-pending-main' },
1541
- h('span', { className: 'srv-pending-title' }, title),
1542
- h(
1543
- 'span',
1544
- { className: 'srv-pending-meta' },
1545
- `${t('pending.requestedAt', { time: formatTime(record.requestedAt) })} · ${t('pending.expiresAt', {
1546
- time: formatTime(record.expiresAt),
1547
- })}${record.target?.present === false ? ` · ${t('pending.targetMissing')}` : ''}`,
1548
- ),
1549
- ),
1550
- h(
1551
- 'div',
1552
- { className: 'srv-row-actions' },
1553
- h(Button, { size: 'sm', variant: 'primary', disabled: working, onClick: () => void onConfirm(record.id) }, working ? t('pending.busy') : t('pending.confirm')),
1554
- h(Button, { size: 'sm', variant: 'outline', disabled: working, onClick: () => void onCancel(record.id) }, t('pending.cancel')),
1555
- ),
1556
- )
1557
- }
1558
-
1559
- /**
1560
- * The `settings.section` page: global settings (read-only, they come from the
1561
- * profile) and the pending queue with its red count badge (§8.3).
1562
- * @param {{t: any}} props
1563
- */
1564
- function SettingsPage({ t }) {
1565
- const { state, reload } = useServers()
1566
- const [pending, setPending] = React.useState({ phase: 'loading' })
1567
- const [busy, setBusy] = React.useState(/** @type {string|null} */ (null))
1568
- const [error, setError] = React.useState(/** @type {string|null} */ (null))
1569
-
1570
- const reloadPending = React.useCallback(async () => {
1571
- try {
1572
- const answer = await api('/pending')
1573
- setPending({ phase: 'ready', records: answer.pending })
1574
- } catch (failure) {
1575
- setPending({ phase: 'error', error: failure })
1576
- }
1577
- }, [])
1578
-
1579
- React.useEffect(() => {
1580
- void reloadPending()
1581
- }, [reloadPending])
1582
-
1583
- /** @param {'confirm'|'cancel'} action @param {string} id */
1584
- const act = async (action, id) => {
1585
- setBusy(id)
1586
- setError(null)
1587
- try {
1588
- await api(`/pending/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
1589
- await Promise.all([reloadPending(), reload()])
1590
- } catch (failure) {
1591
- setError(t('error.generic', { message: messageOf(failure) }))
1592
- } finally {
1593
- setBusy(null)
1594
- }
1595
- }
1596
-
1597
- const records = pending.phase === 'ready' ? pending.records : []
1598
- const config = state.phase === 'ready' ? state.data.config : null
1599
- return h(
1600
- 'div',
1601
- { className: 'srv-page' },
1602
- h(
1603
- 'div',
1604
- { className: 'srv-column' },
1605
- h('h2', null, t('settings.title')),
1606
- h('p', { className: 'srv-hint' }, t('settings.description')),
1607
- h(
1608
- 'section',
1609
- { className: 'srv-card' },
1610
- h(
1611
- 'div',
1612
- { className: 'srv-card-head' },
1613
- h('h3', { className: 'srv-card-title' }, t('settings.pending')),
1614
- h('span', { className: 'srv-badge', hidden: records.length === 0 }, String(records.length)),
1615
- ),
1616
- h('p', { className: 'srv-hint' }, t('settings.pendingHint')),
1617
- error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1618
- records.length === 0 ? h('p', { className: 'srv-hint' }, t('settings.pendingEmpty')) : null,
1619
- records.length > 0
1620
- ? h(
1621
- 'ul',
1622
- { className: 'srv-pending' },
1623
- records.map((/** @type {any} */ record) =>
1624
- h(PendingRow, {
1625
- key: record.id,
1626
- record,
1627
- t,
1628
- busy,
1629
- onConfirm: (/** @type {string} */ id) => act('confirm', id),
1630
- onCancel: (/** @type {string} */ id) => act('cancel', id),
1631
- }),
1632
- ),
1633
- )
1634
- : null,
1635
- ),
1636
- h(
1637
- 'section',
1638
- { className: 'srv-card' },
1639
- h('h3', { className: 'srv-card-title' }, t('settings.global')),
1640
- h('p', { className: 'srv-hint' }, t('settings.globalHint')),
1641
- config === null
1642
- ? state.phase === 'error'
1643
- ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) }))
1644
- : h('p', { className: 'srv-hint' }, t('panel.loading'))
1645
- : h(
1646
- 'dl',
1647
- { className: 'srv-kv' },
1648
- Object.entries(config).map(([key, value]) =>
1649
- h(F, { key }, h('dt', { className: 'srv-mono' }, key), h('dd', { className: 'srv-mono' }, String(value))),
1650
- ),
1651
- ),
1652
- ),
1653
- ),
1654
- )
1655
- }
1656
-
1657
- // ---------------------------------------------------------------- mount --
1658
-
1659
- /** Services this bundle needs; `slots` carries the three mount points, `locale` the copy. */
1660
- const inject = ['slots', 'locale']
1661
-
1662
- /**
1663
- * Register the dictionaries and the three §8.1 contributions.
1664
- * @param {any} ctx - the browser plugin context.
1665
- */
1666
- function apply(ctx) {
1667
- installStyles()
1668
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'server-connector: dictionaries')
1669
-
1670
- ctx.slots.inject('sidebar.panellist', () =>
1671
- ctx.slots.register(
1672
- {
1673
- name: 'sidebar.panellist',
1674
- id: PANEL_ID,
1675
- order: 10,
1676
- label: () => ctx.locale.bind(NS)('panel.label'),
1677
- locale: NS,
1678
- },
1679
- ServerIcon,
1680
- ),
1681
- )
1682
-
1683
- ctx.slots.inject('main', () =>
1684
- ctx.slots.register(
1685
- {
1686
- name: 'main',
1687
- key: PANEL_ID,
1688
- locale: NS,
1689
- inject: () => ({}),
1690
- },
1691
- ServerPanel,
1692
- ),
1693
- )
1694
-
1695
- ctx.slots.inject('settings.section', () =>
1696
- ctx.slots.register(
1697
- {
1698
- name: 'settings.section',
1699
- id: PANEL_ID,
1700
- order: 25,
1701
- label: () => ctx.locale.bind(NS)('settings.nav'),
1702
- locale: NS,
1703
- inject: () => ({}),
1704
- },
1705
- SettingsPage,
1706
- ),
1707
- )
1708
- }
1709
-
1710
- exports.NS = NS
1711
- exports.PANEL_ID = PANEL_ID
1712
- exports.apply = apply
1713
- exports.inject = inject
1714
- return module.exports
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
+ // The switch mirrors the Host's own switch (ui-primitives): 36×20, a 16px
282
+ // white thumb, `--dsw-alias-label-primary` when on and a 12% black wash
283
+ // when off. `--dsw-alias-brand-primary` does NOT exist in this theme, so
284
+ // the on-state colour must come from a token the Host actually defines.
285
+ '.srv-switch{position:relative;flex:0 0 auto;width:36px;height:20px;padding:0;border:none;border-radius:999px;',
286
+ 'background:rgba(0,0,0,.12);cursor:pointer;transition:background .15s ease}',
287
+ '.srv-switch[data-on="true"]{background:var(--dsw-alias-label-primary,#0f1115)}',
288
+ '.srv-switch:disabled{cursor:not-allowed;opacity:.5}',
289
+ '.srv-switch:focus-visible{outline:2px solid var(--dsw-alias-label-primary,#0f1115);outline-offset:2px}',
290
+ '.srv-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
291
+ 'background:#fff;transition:transform .15s ease}',
292
+ '.srv-switch[data-on="true"] .srv-switch-knob{transform:translateX(16px)}',
293
+ '.srv-row-main{flex:1 1 240px;min-width:0;display:flex;flex-direction:column;gap:2px}',
294
+ '.srv-row-title{display:flex;align-items:center;gap:8px;min-width:0;font-size:14px;line-height:20px;font-weight:500}',
295
+ '.srv-open{padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;',
296
+ 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}',
297
+ '.srv-open:hover{text-decoration:underline}',
298
+ '.srv-row-meta{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap;font-size:12px;line-height:18px;',
299
+ 'color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
300
+ '.srv-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
301
+ '.srv-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
302
+ '.srv-tag{display:inline-flex;align-items:center;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;',
303
+ 'padding:0 8px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
304
+ '.srv-tag[data-tone="success"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);',
305
+ 'color:var(--dsw-alias-state-success-primary)}',
306
+ '.srv-tag[data-tone="warn"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);',
307
+ 'color:var(--dsw-alias-state-warn-primary)}',
308
+ '.srv-row-actions{display:flex;align-items:center;gap:4px;flex:none;margin-left:auto}',
309
+ '.srv-empty{margin:0;padding:32px 8px;text-align:center;color:var(--dsw-alias-label-tertiary)}',
310
+ '.srv-empty p{margin:0 0 4px}',
311
+ '.srv-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:.5px solid var(--dsw-alias-border-l3);',
312
+ 'border-radius:16px;min-width:0}',
313
+ '.srv-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
314
+ '.srv-card-title{margin:0;flex:1;font-size:15px;line-height:22px;font-weight:600}',
315
+ '.srv-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0;font-size:13px;line-height:20px}',
316
+ '.srv-kv dt{color:var(--dsw-alias-label-tertiary)}',
317
+ '.srv-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}',
318
+ '.srv-mono{font-family:var(--ds-font-family-code,Menlo,monospace);font-size:12px}',
319
+ '.srv-form{display:flex;flex-direction:column;gap:16px}',
320
+ '.srv-field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:13px;line-height:20px}',
321
+ '.srv-field-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px}',
322
+ '.srv-input,.srv-select{box-sizing:border-box;width:100%;min-width:0;height:32px;padding:0 8px;font:inherit;',
323
+ 'font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);',
324
+ 'border:.5px solid var(--dsw-alias-border-l4);border-radius:8px}',
325
+ '.srv-input:focus-visible,.srv-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
326
+ '.srv-input[rows]{height:auto;padding:6px 8px;resize:vertical}',
327
+ '.srv-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
328
+ '.srv-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}',
329
+ '.srv-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
330
+ '.srv-note{margin:0;padding:8px 12px;border-radius:10px;font-size:13px;line-height:20px;',
331
+ 'background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);overflow-wrap:anywhere}',
332
+ '.srv-seg{display:inline-flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover)}',
333
+ '.srv-seg button{height:26px;padding:0 12px;border:0;border-radius:7px;background:transparent;cursor:pointer;',
334
+ 'font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary)}',
335
+ '.srv-seg button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);',
336
+ 'box-shadow:var(--dsw-elevation-soft)}',
337
+ '.srv-rules{border-top:.5px solid var(--dsw-alias-border-l2);margin:0;padding:0;list-style:none}',
338
+ '.srv-rule{display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
339
+ '.srv-rule-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
340
+ '.srv-rule-label{font-size:13px;line-height:20px;font-weight:500}',
341
+ '.srv-rule-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
342
+ '.srv-rule-select{flex:none;width:132px}',
343
+ '.srv-audit{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;font-size:12px;line-height:18px}',
344
+ '.srv-audit li{display:flex;gap:8px;align-items:baseline;min-width:0;padding:2px 0}',
345
+ '.srv-audit-time{flex:none;color:var(--dsw-alias-label-caption);font-variant-numeric:tabular-nums}',
346
+ '.srv-audit-decision{flex:none;width:56px;color:var(--dsw-alias-label-secondary)}',
347
+ '.srv-audit-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,Menlo,monospace)}',
348
+ '.srv-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;',
349
+ 'border-radius:9px;background:var(--dsw-alias-state-error-primary);color:var(--dsw-static-neutral-bluish-00);',
350
+ 'font-size:11px;line-height:18px;font-weight:600}',
351
+ '.srv-pending{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}',
352
+ '.srv-pending li{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 0;',
353
+ 'border-bottom:.5px solid var(--dsw-alias-border-l2)}',
354
+ '.srv-pending-main{flex:1 1 220px;min-width:0;display:flex;flex-direction:column;gap:2px}',
355
+ '.srv-pending-title{font-size:13px;line-height:20px;font-weight:500;overflow-wrap:anywhere}',
356
+ '.srv-pending-meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption)}',
357
+ '.srv-danger-zone{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;',
358
+ 'padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
359
+ '@container (max-width:640px){.srv-field-row{grid-template-columns:1fr}.srv-rule{flex-wrap:wrap}',
360
+ '.srv-rule-select{width:100%}}',
361
+ ].join('')
362
+
363
+ /** Inject the stylesheet once; the client module system tags it for HMR cleanup. */
364
+ function installStyles() {
365
+ if (typeof browserGlobal.document === 'undefined') return
366
+ const document = browserGlobal.document
367
+ if (document.querySelector('style[data-server-connector]') !== null) return
368
+ const tag = document.createElement('style')
369
+ tag.setAttribute('data-server-connector', '')
370
+ tag.textContent = CSS
371
+ document.head.appendChild(tag)
372
+ }
373
+
374
+ // ---------------------------------------------------------------- HTTP --
375
+
376
+ /**
377
+ * One route call. Same-origin, so the browser session cookie authenticates
378
+ * it exactly like the rest of the GUI (§8.5 trust boundary).
379
+ * @param {string} path - path below {@link API}.
380
+ * @param {{method?: string, body?: unknown, query?: Record<string, string>}} [options]
381
+ * @returns {Promise<any>}
382
+ */
383
+ async function api(path, options = {}) {
384
+ const query = options.query === undefined ? '' : `?${new URLSearchParams(options.query).toString()}`
385
+ /** @type {Record<string, string>} */
386
+ const headers = {}
387
+ /** @type {RequestInit} */
388
+ const init = { method: options.method ?? 'GET', headers }
389
+ if (options.body !== undefined) {
390
+ headers['content-type'] = 'application/json'
391
+ init.body = JSON.stringify(options.body)
392
+ }
393
+ const response = await browserGlobal.fetch(`${API}${path}${query}`, init)
394
+ let payload
395
+ const text = await response.text()
396
+ try {
397
+ payload = text === '' ? null : JSON.parse(text)
398
+ } catch {
399
+ payload = null
400
+ }
401
+ if (!response.ok) {
402
+ throw Object.assign(
403
+ new Error(typeof payload?.error?.message === 'string' ? payload.error.message : `HTTP ${String(response.status)}`),
404
+ {
405
+ code: payload?.error?.code ?? `HTTP_${String(response.status)}`,
406
+ status: response.status,
407
+ details: payload?.error?.details ?? {},
408
+ },
409
+ )
410
+ }
411
+ return payload
412
+ }
413
+
414
+ /**
415
+ * The displayable message of a thrown value.
416
+ * @param {unknown} error
417
+ * @returns {string}
418
+ */
419
+ function messageOf(error) {
420
+ if (error instanceof Error) return error.message
421
+ if (typeof error === 'object' && error !== null && typeof (/** @type {any} */ (error).message) === 'string') {
422
+ return /** @type {any} */ (error).message
423
+ }
424
+ return String(error)
425
+ }
426
+
427
+ /**
428
+ * Format one timestamp for the panel; an unparsable value is shown as-is.
429
+ * @param {unknown} value
430
+ * @returns {string}
431
+ */
432
+ function formatTime(value) {
433
+ if (typeof value !== 'string' || value === '') return '—'
434
+ const date = new Date(value)
435
+ if (Number.isNaN(date.getTime())) return value
436
+ return date.toLocaleString(undefined, {
437
+ month: '2-digit',
438
+ day: '2-digit',
439
+ hour: '2-digit',
440
+ minute: '2-digit',
441
+ })
442
+ }
443
+
444
+ // --------------------------------------------------------------- atoms --
445
+
446
+ /** @param {{size?: number, children?: any}} props */
447
+ function Icon({ size = 16, children }) {
448
+ return h(
449
+ 'svg',
450
+ {
451
+ width: size,
452
+ height: size,
453
+ viewBox: '0 0 24 24',
454
+ fill: 'none',
455
+ stroke: 'currentColor',
456
+ strokeWidth: 1.8,
457
+ strokeLinecap: 'round',
458
+ strokeLinejoin: 'round',
459
+ 'aria-hidden': true,
460
+ },
461
+ children,
462
+ )
463
+ }
464
+
465
+ /** The §8.1 sidebar icon: a rack of two server units. */
466
+ function ServerIcon({ size = 20 }) {
467
+ return h(
468
+ Icon,
469
+ { size },
470
+ h('rect', { key: 'a', x: 3, y: 5, width: 18, height: 6, rx: 1.6 }),
471
+ h('rect', { key: 'b', x: 3, y: 13, width: 18, height: 6, rx: 1.6 }),
472
+ h('path', { key: 'c', d: 'M6.5 8h.01M6.5 16h.01' }),
473
+ )
474
+ }
475
+
476
+ /**
477
+ * @param {{variant?: string, size?: string, danger?: boolean, children?: any} & Record<string, any>} props
478
+ */
479
+ function Button({ variant = 'ghost', size = 'md', danger = false, children, ...rest }) {
480
+ const className = [
481
+ 'srv-btn',
482
+ size === 'sm' ? 'srv-btn-sm' : '',
483
+ variant === 'primary' ? 'srv-btn-primary' : '',
484
+ variant === 'outline' ? 'srv-btn-outline' : '',
485
+ danger ? 'srv-btn-danger' : '',
486
+ ]
487
+ .filter(Boolean)
488
+ .join(' ')
489
+ return h('button', { type: 'button', className, ...rest }, children)
490
+ }
491
+
492
+ /** @param {{on: boolean}} props */
493
+ function StatusDot({ on }) {
494
+ return h('span', { className: 'srv-dot', 'data-on': on ? 'true' : 'false' })
495
+ }
496
+
497
+ // --------------------------------------------------------------- hooks --
498
+
499
+ /**
500
+ * Load `GET /servers` and keep a reload function.
501
+ * @returns {{state: any, reload: () => Promise<void>}}
502
+ */
503
+ function useServers() {
504
+ const [state, setState] = React.useState({ phase: 'loading' })
505
+ const alive = React.useRef(true)
506
+ React.useEffect(
507
+ () => () => {
508
+ alive.current = false
509
+ },
510
+ [],
511
+ )
512
+ const reload = React.useCallback(async () => {
513
+ try {
514
+ const data = await api('/servers')
515
+ if (alive.current) setState({ phase: 'ready', data })
516
+ } catch (error) {
517
+ if (alive.current) setState({ phase: 'error', error })
518
+ }
519
+ }, [])
520
+ React.useEffect(() => {
521
+ void reload()
522
+ }, [reload])
523
+ return { state, reload }
524
+ }
525
+
526
+ // ------------------------------------------------------------- surfaces --
527
+
528
+ /**
529
+ * The §8.2 list row.
530
+ * @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
531
+ */
532
+ function ServerRow({ server, t, onOpen, onEdit, onTest, onToggle, testing, toggling }) {
533
+ const busy = testing === server.id
534
+ const off = server.enabled === false
535
+ const toggleBusy = toggling === server.id
536
+ return h(
537
+ 'li',
538
+ { className: off ? 'srv-row srv-row-off' : 'srv-row' },
539
+ h(StatusDot, { on: server.connected === true }),
540
+ h(
541
+ 'div',
542
+ { className: 'srv-row-main' },
543
+ h(
544
+ 'div',
545
+ { className: 'srv-row-title' },
546
+ h('button', { type: 'button', className: 'srv-open', onClick: () => onOpen(server.id) }, server.name),
547
+ ),
548
+ h(
549
+ 'div',
550
+ { className: 'srv-row-meta' },
551
+ h('span', { className: 'srv-mono' }, `${server.username}@${server.host}:${String(server.port)}`),
552
+ server.defaultWorkDir === null
553
+ ? null
554
+ : h('span', { className: 'srv-mono' }, server.defaultWorkDir),
555
+ h(
556
+ 'span',
557
+ { className: 'srv-tag', 'data-tone': server.connected === true ? 'success' : undefined },
558
+ server.connected === true ? t('panel.connected') : t('panel.disconnected'),
559
+ ),
560
+ server.hostKeyMode === 'off' ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, 'host-key off') : null,
561
+ off ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, t('panel.disabled')) : null,
562
+ ),
563
+ ),
564
+ h(
565
+ 'div',
566
+ { className: 'srv-row-actions' },
567
+ // The switch is the only control the panel needs for the enable state:
568
+ // it applies immediately (no pending queue), so it reads as a switch.
569
+ h(Switch, {
570
+ checked: !off,
571
+ disabled: toggleBusy || server.connected === true,
572
+ label: off ? t('panel.enable') : t('panel.disable'),
573
+ hint: server.connected === true ? t('panel.disconnectFirst') : null,
574
+ onChange: () => void onToggle(server),
575
+ }),
576
+ h(Button, { size: 'sm', variant: 'outline', disabled: busy || off, onClick: () => void onTest(server.id) }, busy ? t('panel.testing') : t('panel.test')),
577
+ h(Button, { size: 'sm', onClick: () => onEdit(server.id) }, t('panel.edit')),
578
+ ),
579
+ )
580
+ }
581
+
582
+ /**
583
+ * A minimal on/off switch. The panel has no design-system switch in the
584
+ * injected surface, so this is the smallest one that reads as a switch and
585
+ * stays usable when disabled.
586
+ * @param {{checked: boolean, disabled?: boolean, label: string, hint?: string|null, onChange: () => void}} props
587
+ */
588
+ function Switch({ checked, disabled = false, label, hint = null, onChange }) {
589
+ return h(
590
+ 'button',
591
+ {
592
+ type: 'button',
593
+ role: 'switch',
594
+ 'aria-checked': checked ? 'true' : 'false',
595
+ 'aria-label': label,
596
+ title: hint ?? label,
597
+ className: 'srv-switch',
598
+ 'data-on': checked ? 'true' : 'false',
599
+ disabled,
600
+ onClick: () => onChange(),
601
+ },
602
+ h('span', { className: 'srv-switch-knob', 'aria-hidden': 'true' }),
603
+ )
604
+ }
605
+
606
+ /**
607
+ * The list view (or the empty state). Owns no data: the panel passes state.
608
+ * @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
609
+ */
610
+ function ServerList({ state, t, reload, onAdd, onOpen, onEdit, onTest, onToggle, testing, toggling, notice }) {
611
+ const data = state.phase === 'ready' ? state.data : null
612
+ const servers = data === null ? [] : data.servers
613
+ const pendingCount = data === null ? 0 : data.pendingCount
614
+ return h(
615
+ F,
616
+ null,
617
+ h(
618
+ 'div',
619
+ { className: 'srv-head' },
620
+ h('h2', null, t('panel.title')),
621
+ pendingCount > 0
622
+ ? h('span', { className: 'srv-badge', title: t('settings.pending') }, String(pendingCount))
623
+ : null,
624
+ h(
625
+ 'div',
626
+ { className: 'srv-head-actions' },
627
+ h(Button, { variant: 'primary', onClick: onAdd }, t('panel.add')),
628
+ ),
629
+ ),
630
+ notice === null ? null : h('p', { className: 'srv-note' }, notice),
631
+ state.phase === 'loading' ? h('p', { className: 'srv-empty' }, t('panel.loading')) : null,
632
+ state.phase === 'error'
633
+ ? h(
634
+ 'div',
635
+ { className: 'srv-empty' },
636
+ h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) })),
637
+ h(Button, { variant: 'outline', onClick: () => void reload() }, t('panel.retry')),
638
+ )
639
+ : null,
640
+ state.phase === 'ready' && servers.length === 0
641
+ ? h(
642
+ 'div',
643
+ { className: 'srv-empty' },
644
+ h('p', null, t('panel.empty')),
645
+ h('p', null, t('panel.emptyHint')),
646
+ )
647
+ : null,
648
+ servers.length > 0
649
+ ? h(
650
+ 'ul',
651
+ { className: 'srv-list' },
652
+ servers.map((/** @type {any} */ server) =>
653
+ h(ServerRow, {
654
+ key: server.id,
655
+ server,
656
+ t,
657
+ onOpen,
658
+ onEdit,
659
+ onTest,
660
+ onToggle,
661
+ testing,
662
+ toggling,
663
+ }),
664
+ ),
665
+ )
666
+ : null,
667
+ )
668
+ }
669
+
670
+ /**
671
+ * Custom rules for one server (doc §4.4).
672
+ *
673
+ * The section owns its own draft state: a rule is only sent to the Host after
674
+ * the operator has seen a dry run of it against a sample command, which is
675
+ * the one guard that catches "the regex never fires".
676
+ * @param {{server: any, t: any, onRefresh: () => Promise<void>}} props
677
+ */
678
+ function CustomRulesSection({ server, t, onRefresh }) {
679
+ const rules = Array.isArray(server.customRules) ? server.customRules : []
680
+ const [draft, setDraft] = React.useState(null)
681
+ const [busy, setBusy] = React.useState(false)
682
+ const [error, setError] = React.useState(null)
683
+ const [preview, setPreview] = React.useState(null)
684
+
685
+ const startAdd = () =>
686
+ setDraft({ label: '', pattern: '', defaultDecision: 'confirm', description: '', samples: '' })
687
+ const cancel = () => {
688
+ setDraft(null)
689
+ setPreview(null)
690
+ setError(null)
691
+ }
692
+
693
+ /** @param {any} event */
694
+ const dryRun = async (event) => {
695
+ event.preventDefault()
696
+ if (draft === null) return
697
+ setBusy(true)
698
+ setError(null)
699
+ try {
700
+ const samples = String(draft.samples ?? '')
701
+ .split('\n')
702
+ .map((line) => line.trim())
703
+ .filter((line) => line !== '')
704
+ if (samples.length === 0) throw new Error(t('rules.needSample'))
705
+ const data = await api(`/servers/${encodeURIComponent(server.id)}/rules/preview`, {
706
+ method: 'POST',
707
+ body: {
708
+ label: draft.label === '' ? 'preview' : draft.label,
709
+ pattern: draft.pattern,
710
+ defaultDecision: draft.defaultDecision,
711
+ samples,
712
+ },
713
+ })
714
+ setPreview(data)
715
+ } catch (failure) {
716
+ setPreview(null)
717
+ setError(messageOf(failure))
718
+ } finally {
719
+ setBusy(false)
720
+ }
721
+ }
722
+
723
+ /** @param {any} event */
724
+ const save = async (event) => {
725
+ event.preventDefault()
726
+ if (draft === null) return
727
+ setBusy(true)
728
+ setError(null)
729
+ try {
730
+ const samples = String(draft.samples ?? '')
731
+ .split('\n')
732
+ .map((line) => line.trim())
733
+ .filter((line) => line !== '')
734
+ await api(`/servers/${encodeURIComponent(server.id)}/rules`, {
735
+ method: 'POST',
736
+ body: {
737
+ label: draft.label,
738
+ pattern: draft.pattern,
739
+ defaultDecision: draft.defaultDecision,
740
+ description: draft.description,
741
+ samples,
742
+ },
743
+ })
744
+ setDraft(null)
745
+ setPreview(null)
746
+ await onRefresh()
747
+ } catch (failure) {
748
+ setError(messageOf(failure))
749
+ } finally {
750
+ setBusy(false)
751
+ }
752
+ }
753
+
754
+ /** @param {string} ruleId */
755
+ const remove = async (ruleId) => {
756
+ setBusy(true)
757
+ setError(null)
758
+ try {
759
+ await api(`/servers/${encodeURIComponent(server.id)}/rules/${encodeURIComponent(ruleId)}`, {
760
+ method: 'DELETE',
761
+ })
762
+ await onRefresh()
763
+ } catch (failure) {
764
+ setError(messageOf(failure))
765
+ } finally {
766
+ setBusy(false)
767
+ }
768
+ }
769
+
770
+ return h(
771
+ 'section',
772
+ { className: 'srv-card' },
773
+ h('h3', { className: 'srv-card-title' }, t('rules.section')),
774
+ h('p', { className: 'srv-hint' }, t('rules.hint')),
775
+ rules.length === 0 ? h('p', { className: 'srv-hint' }, t('rules.empty')) : null,
776
+ rules.length > 0
777
+ ? h(
778
+ 'ul',
779
+ { className: 'srv-rules' },
780
+ rules.map((/** @type {any} */ rule) =>
781
+ h(
782
+ 'li',
783
+ { className: 'srv-rule', key: rule.id },
784
+ h(
785
+ 'div',
786
+ { className: 'srv-rule-body' },
787
+ h('span', { className: 'srv-rule-label' }, rule.label),
788
+ h('span', { className: 'srv-rule-desc' }, rule.description || rule.pattern),
789
+ ),
790
+ h('span', { className: 'srv-tag', 'data-tone': rule.defaultDecision === 'block' ? undefined : 'success' }, rule.defaultDecision),
791
+ h(
792
+ Button,
793
+ { size: 'sm', danger: true, disabled: busy, onClick: () => void remove(rule.id) },
794
+ t('rules.delete'),
795
+ ),
796
+ ),
797
+ ),
798
+ )
799
+ : null,
800
+ error !== null ? h('p', { className: 'srv-error' }, error) : null,
801
+ draft === null
802
+ ? h(Button, { variant: 'outline', disabled: busy, onClick: startAdd }, t('rules.add'))
803
+ : h(
804
+ 'form',
805
+ { className: 'srv-form', onSubmit: save },
806
+ h(
807
+ 'div',
808
+ { className: 'srv-field' },
809
+ h('label', { htmlFor: 'srv-rule-label' }, t('rules.label')),
810
+ h('input', {
811
+ id: 'srv-rule-label',
812
+ className: 'srv-input',
813
+ value: draft.label,
814
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, label: event.target.value }),
815
+ }),
816
+ ),
817
+ h(
818
+ 'div',
819
+ { className: 'srv-field' },
820
+ h('label', { htmlFor: 'srv-rule-pattern' }, t('rules.pattern')),
821
+ h('input', {
822
+ id: 'srv-rule-pattern',
823
+ className: 'srv-input srv-mono',
824
+ value: draft.pattern,
825
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, pattern: event.target.value }),
826
+ }),
827
+ ),
828
+ h(
829
+ 'div',
830
+ { className: 'srv-field' },
831
+ h('label', { htmlFor: 'srv-rule-decision' }, t('rules.decision')),
832
+ h(
833
+ 'select',
834
+ {
835
+ id: 'srv-rule-decision',
836
+ className: 'srv-select',
837
+ value: draft.defaultDecision,
838
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, defaultDecision: event.target.value }),
839
+ },
840
+ h('option', { value: 'confirm' }, 'confirm'),
841
+ h('option', { value: 'block' }, 'block'),
842
+ ),
843
+ ),
844
+ h(
845
+ 'div',
846
+ { className: 'srv-field' },
847
+ h('label', { htmlFor: 'srv-rule-desc' }, t('rules.description')),
848
+ h('input', {
849
+ id: 'srv-rule-desc',
850
+ className: 'srv-input',
851
+ value: draft.description,
852
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, description: event.target.value }),
853
+ }),
854
+ ),
855
+ h(
856
+ 'div',
857
+ { className: 'srv-field' },
858
+ h('label', { htmlFor: 'srv-rule-samples' }, t('rules.samples')),
859
+ h('textarea', {
860
+ id: 'srv-rule-samples',
861
+ className: 'srv-input srv-mono',
862
+ rows: 3,
863
+ value: draft.samples,
864
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, samples: event.target.value }),
865
+ }),
866
+ h('span', { className: 'srv-hint' }, t('rules.samplesHint')),
867
+ ),
868
+ preview !== null
869
+ ? h(
870
+ 'ul',
871
+ { className: 'srv-rules' },
872
+ (preview.results ?? []).map((/** @type {any} */ item, /** @type {number} */ index) =>
873
+ h(
874
+ 'li',
875
+ { className: 'srv-rule', key: index },
876
+ h(
877
+ 'div',
878
+ { className: 'srv-rule-body' },
879
+ h('span', { className: 'srv-rule-label srv-mono' }, item.sample),
880
+ h('span', { className: 'srv-rule-desc' }, `${t('rules.wouldMatch')}: ${item.matched ? 'yes' : 'no'} · ${String(item.decision)}`),
881
+ ),
882
+ ),
883
+ ),
884
+ )
885
+ : null,
886
+ h(
887
+ 'div',
888
+ { className: 'srv-actions' },
889
+ h(Button, { type: 'button', disabled: busy, onClick: dryRun }, t('rules.preview')),
890
+ h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
891
+ h(Button, { type: 'button', disabled: busy, onClick: cancel }, t('form.cancel')),
892
+ ),
893
+ ),
894
+ )
895
+ }
896
+
897
+ /**
898
+ * The detail page: four connection fields read-only, the policy table, the
899
+ * last 20 audit records and [删除] (§8.2).
900
+ * @param {{server: any, rules: any[], t: any, onBack: () => void, onEdit: () => void, onDelete: () => Promise<void>, deleting: boolean, notice: string|null, onRefresh: () => Promise<void>}} props
901
+ */
902
+ function ServerDetail({ server, rules, t, onBack, onEdit, onDelete, deleting, notice, onRefresh }) {
903
+ const [audit, setAudit] = React.useState({ phase: 'loading' })
904
+ React.useEffect(() => {
905
+ let alive = true
906
+ api('/audit', { query: { limit: String(AUDIT_LIMIT), serverId: server.id } })
907
+ .then((data) => {
908
+ if (alive) setAudit({ phase: 'ready', records: data.records })
909
+ })
910
+ .catch((error) => {
911
+ if (alive) setAudit({ phase: 'error', error })
912
+ })
913
+ return () => {
914
+ alive = false
915
+ }
916
+ }, [server.id])
917
+
918
+ const authText =
919
+ server.auth.kind === 'key'
920
+ ? `${t('auth.key')} · ${server.auth.privateKeyPath}`
921
+ : server.auth.kind === 'password'
922
+ ? server.passwordConfigured === true
923
+ ? `${t('auth.password')} · ${t('auth.passwordConfigured')}`
924
+ : `${t('auth.password')} · —`
925
+ : t('auth.agent')
926
+
927
+ return h(
928
+ F,
929
+ null,
930
+ h(
931
+ 'div',
932
+ { className: 'srv-head' },
933
+ h(Button, { size: 'sm', onClick: onBack }, `← ${t('panel.back')}`),
934
+ h('h2', null, server.name),
935
+ h(
936
+ 'div',
937
+ { className: 'srv-head-actions' },
938
+ h(Button, { size: 'sm', variant: 'outline', onClick: onEdit }, t('panel.edit')),
939
+ ),
940
+ ),
941
+ notice === null ? null : h('p', { className: 'srv-note' }, notice),
942
+ h(
943
+ 'section',
944
+ { className: 'srv-card' },
945
+ h('h3', { className: 'srv-card-title' }, t('panel.sectionConnection')),
946
+ h(
947
+ 'dl',
948
+ { className: 'srv-kv' },
949
+ h('dt', null, t('field.name')),
950
+ h('dd', null, server.name),
951
+ h('dt', null, t('field.host')),
952
+ h('dd', { className: 'srv-mono' }, `${server.host}:${String(server.port)}`),
953
+ h('dt', null, t('field.username')),
954
+ h('dd', { className: 'srv-mono' }, server.username),
955
+ h('dt', null, t('field.auth')),
956
+ h('dd', { className: 'srv-mono' }, authText),
957
+ h('dt', null, t('field.hostKeyMode')),
958
+ h('dd', null, server.hostKeyMode),
959
+ h('dt', null, t('field.defaultWorkDir')),
960
+ h('dd', { className: 'srv-mono' }, server.defaultWorkDir === null ? '—' : server.defaultWorkDir),
961
+ h('dt', null, t('field.lastConnectedAt')),
962
+ h(
963
+ 'dd',
964
+ null,
965
+ server.lastConnectedAt === null ? t('panel.neverConnected') : t('panel.lastConnected', { time: formatTime(server.lastConnectedAt) }),
966
+ ),
967
+ ),
968
+ ),
969
+ h(
970
+ 'section',
971
+ { className: 'srv-card' },
972
+ h('h3', { className: 'srv-card-title' }, t('panel.sectionPolicy')),
973
+ h(
974
+ 'ul',
975
+ { className: 'srv-rules' },
976
+ rules.map((/** @type {any} */ rule) => {
977
+ const state = server.policyState?.[rule.id] ?? { configured: null, decision: null, disabled: false }
978
+ return h(
979
+ 'li',
980
+ { className: 'srv-rule', key: rule.id },
981
+ h(
982
+ 'div',
983
+ { className: 'srv-rule-body' },
984
+ h('span', { className: 'srv-rule-label' }, rule.label),
985
+ h('span', { className: 'srv-rule-desc' }, rule.description),
986
+ ),
987
+ h(
988
+ 'span',
989
+ {
990
+ className: 'srv-tag',
991
+ 'data-tone': state.disabled ? 'warn' : state.decision === 'block' ? undefined : 'success',
992
+ },
993
+ state.disabled ? t('form.offOption') : String(state.decision ?? rule.defaultDecision),
994
+ ),
995
+ )
996
+ }),
997
+ ),
998
+ ),
999
+ h(CustomRulesSection, { server, t, onRefresh }),
1000
+ h(
1001
+ 'section',
1002
+ { className: 'srv-card' },
1003
+ h('h3', { className: 'srv-card-title' }, t('panel.sectionAudit')),
1004
+ audit.phase === 'loading' ? h('p', { className: 'srv-hint' }, t('panel.auditLoading')) : null,
1005
+ audit.phase === 'error'
1006
+ ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(audit.error) }))
1007
+ : null,
1008
+ audit.phase === 'ready' && audit.records.length === 0
1009
+ ? h('p', { className: 'srv-hint' }, t('panel.auditEmpty'))
1010
+ : null,
1011
+ audit.phase === 'ready' && audit.records.length > 0
1012
+ ? h(
1013
+ 'ul',
1014
+ { className: 'srv-audit' },
1015
+ audit.records.map((/** @type {any} */ record, /** @type {number} */ index) =>
1016
+ h(
1017
+ 'li',
1018
+ { key: `${String(record.ts)}-${String(index)}` },
1019
+ h('span', { className: 'srv-audit-time' }, formatTime(record.ts)),
1020
+ h('span', { className: 'srv-audit-decision' }, String(record.event ?? record.decision ?? '')),
1021
+ h(
1022
+ 'span',
1023
+ { className: 'srv-audit-text', title: String(record.command ?? record.detail ?? '') },
1024
+ String(record.command ?? record.detail ?? ''),
1025
+ ),
1026
+ ),
1027
+ ),
1028
+ )
1029
+ : null,
1030
+ ),
1031
+ h(
1032
+ 'div',
1033
+ { className: 'srv-danger-zone' },
1034
+ h('span', { className: 'srv-hint' }, t('panel.deleteHint')),
1035
+ h(Button, { variant: 'outline', danger: true, disabled: deleting, onClick: () => void onDelete() }, deleting ? t('pending.busy') : t('panel.delete')),
1036
+ ),
1037
+ )
1038
+ }
1039
+
1040
+ /**
1041
+ * Whether a path is absolute on *this* machine.
1042
+ *
1043
+ * `auth.privateKeyPath` names a local file the Host reads, so on Windows a
1044
+ * drive-letter (`C:\Users\me\.ssh\id_ed25519`) or UNC (`\\server\share\key`)
1045
+ * path is absolute. The panel used to demand a leading `/` here, which made
1046
+ * key auth impossible to submit on Windows.
1047
+ * @param {string} path
1048
+ * @returns {boolean}
1049
+ */
1050
+ function isLocalAbsolutePath(path) {
1051
+ if (path.startsWith('/')) return true
1052
+ if (/^[A-Za-z]:[\\/]/.test(path)) return true
1053
+ return path.startsWith('\\\\')
1054
+ }
1055
+
1056
+ /**
1057
+ * The create/edit form (§8.4). Block rules expose only confirm/block, and
1058
+ * "off" is queued rather than applied (§8.3).
1059
+ * @param {{server: any|null, rules: any[], hostKeyModes: string[], t: any, onCancel: () => void, onSaved: (message: string|null) => void}} props
1060
+ */
1061
+ function ServerForm({ server, rules, hostKeyModes, t, onCancel, onSaved }) {
1062
+ const editing = server !== null
1063
+ const [name, setName] = React.useState(editing ? server.name : '')
1064
+ const [host, setHost] = React.useState(editing ? server.host : '')
1065
+ const [port, setPort] = React.useState(editing ? String(server.port) : '22')
1066
+ const [username, setUsername] = React.useState(editing ? server.username : '')
1067
+ const [authKind, setAuthKind] = React.useState(editing ? server.auth.kind : 'key')
1068
+ const [privateKeyPath, setPrivateKeyPath] = React.useState(editing && server.auth.kind === 'key' ? server.auth.privateKeyPath : '')
1069
+ const [password, setPassword] = React.useState('')
1070
+ const [hostKeyMode, setHostKeyMode] = React.useState(editing ? server.hostKeyMode : 'accept-new')
1071
+ const [defaultWorkDir, setDefaultWorkDir] = React.useState(editing && server.defaultWorkDir !== null ? server.defaultWorkDir : '')
1072
+ const [policy, setPolicy] = React.useState(() => {
1073
+ /** @type {Record<string, string>} */
1074
+ const initial = {}
1075
+ for (const rule of rules) {
1076
+ const state = editing ? server.policyState?.[rule.id] : null
1077
+ initial[rule.id] = state === undefined || state === null ? rule.defaultDecision : state.disabled ? 'off' : String(state.decision)
1078
+ }
1079
+ return initial
1080
+ })
1081
+ const [busy, setBusy] = React.useState(false)
1082
+ const [error, setError] = React.useState(/** @type {string|null} */ (null))
1083
+ const [note, setNote] = React.useState(/** @type {string|null} */ (null))
1084
+
1085
+ /** @param {string} ruleId @param {string} value */
1086
+ const setRule = (ruleId, value) =>
1087
+ setPolicy((/** @type {Record<string, string>} */ current) => ({ ...current, [ruleId]: value }))
1088
+
1089
+ /** Validate one client-side rule before touching the network (§8.4 validations). */
1090
+ const validate = () => {
1091
+ if (name.trim() === '') return t('form.errorName')
1092
+ if (host.trim() === '') return t('form.errorHost')
1093
+ if (username.trim() === '') return t('form.errorUsername')
1094
+ const parsed = Number(port)
1095
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return t('form.portRange')
1096
+ if (authKind === 'key' && privateKeyPath.trim() === '') return t('form.errorKeyPath')
1097
+ // The key file lives on *this* machine, so a Windows drive-letter or UNC
1098
+ // path is as absolute as a POSIX one (the Host accepts both).
1099
+ if (authKind === 'key' && !isLocalAbsolutePath(privateKeyPath.trim())) return t('form.absolutePath')
1100
+ if (authKind === 'password' && !editing && password === '') return t('form.errorPassword')
1101
+ // The working directory is on the *remote* host: POSIX only.
1102
+ if (defaultWorkDir.trim() !== '' && !defaultWorkDir.trim().startsWith('/')) return t('form.absolutePath')
1103
+ return null
1104
+ }
1105
+
1106
+ /** @param {any} event */
1107
+ const submit = async (event) => {
1108
+ event.preventDefault()
1109
+ const problem = validate()
1110
+ if (problem !== null) {
1111
+ setError(problem)
1112
+ return
1113
+ }
1114
+ setBusy(true)
1115
+ setError(null)
1116
+ setNote(null)
1117
+ try {
1118
+ /** @type {Record<string, unknown>} */
1119
+ const body = {
1120
+ name: name.trim(),
1121
+ host: host.trim(),
1122
+ port: Number(port),
1123
+ username: username.trim(),
1124
+ auth: authKind === 'key' ? { kind: 'key', privateKeyPath: privateKeyPath.trim() } : { kind: authKind },
1125
+ hostKeyMode,
1126
+ defaultWorkDir: defaultWorkDir.trim() === '' ? null : defaultWorkDir.trim(),
1127
+ }
1128
+ if (authKind === 'password' && password !== '') body.password = password
1129
+
1130
+ // Only rules that leave their built-in default are stored, so the
1131
+ // record stays readable; "off" and re-enabling have their own fields
1132
+ // because both change the *existing* rule set.
1133
+ /** @type {Record<string, string>} */
1134
+ const storedPolicy = {}
1135
+ /** @type {string[]} */
1136
+ const disableRules = []
1137
+ /** @type {string[]} */
1138
+ const enableRules = []
1139
+ for (const rule of rules) {
1140
+ const value = policy[rule.id]
1141
+ if (value === undefined) continue
1142
+ if (value === 'off') {
1143
+ disableRules.push(rule.id)
1144
+ continue
1145
+ }
1146
+ if (value === rule.defaultDecision) continue
1147
+ storedPolicy[rule.id] = value
1148
+ }
1149
+ body.policy = storedPolicy
1150
+
1151
+ if (editing) {
1152
+ for (const rule of rules) {
1153
+ const value = policy[rule.id]
1154
+ const wasDisabled = server.policyState?.[rule.id]?.disabled === true
1155
+ if (wasDisabled && value !== undefined && value !== 'off') enableRules.push(rule.id)
1156
+ }
1157
+ if (disableRules.length > 0) body.disableRules = disableRules
1158
+ if (enableRules.length > 0) body.enableRules = enableRules
1159
+ const answer = await api(`/servers/${encodeURIComponent(server.id)}`, { method: 'PUT', body })
1160
+ if (answer.pending !== undefined && answer.pending.length > 0) {
1161
+ setNote(t('form.pendingNote'))
1162
+ setBusy(false)
1163
+ onSaved(t('form.pendingNote'))
1164
+ return
1165
+ }
1166
+ } else {
1167
+ if (disableRules.length > 0) body.disableRules = disableRules
1168
+ const answer = await api('/servers', { method: 'POST', body })
1169
+ if (answer.pending !== undefined && answer.pending.length > 0) {
1170
+ onSaved(t('form.pendingNote'))
1171
+ return
1172
+ }
1173
+ }
1174
+ onSaved(null)
1175
+ } catch (error) {
1176
+ setError(t('error.generic', { message: messageOf(error) }))
1177
+ } finally {
1178
+ setBusy(false)
1179
+ }
1180
+ }
1181
+
1182
+ return h(
1183
+ 'form',
1184
+ { className: 'srv-form', onSubmit: submit },
1185
+ h('h2', { className: 'srv-card-title' }, editing ? t('form.edit') : t('form.new')),
1186
+ h(
1187
+ 'div',
1188
+ { className: 'srv-field' },
1189
+ h('label', { htmlFor: 'srv-name' }, t('field.name')),
1190
+ h('input', {
1191
+ id: 'srv-name',
1192
+ className: 'srv-input',
1193
+ value: name,
1194
+ onChange: (/** @type {any} */ event) => setName(event.target.value),
1195
+ required: true,
1196
+ }),
1197
+ ),
1198
+ h(
1199
+ 'div',
1200
+ { className: 'srv-field-row' },
1201
+ h(
1202
+ 'div',
1203
+ { className: 'srv-field' },
1204
+ h('label', { htmlFor: 'srv-host' }, t('field.host')),
1205
+ h('input', {
1206
+ id: 'srv-host',
1207
+ className: 'srv-input',
1208
+ value: host,
1209
+ onChange: (/** @type {any} */ event) => setHost(event.target.value),
1210
+ required: true,
1211
+ }),
1212
+ ),
1213
+ h(
1214
+ 'div',
1215
+ { className: 'srv-field' },
1216
+ h('label', { htmlFor: 'srv-port' }, t('field.port')),
1217
+ h('input', {
1218
+ id: 'srv-port',
1219
+ className: 'srv-input',
1220
+ type: 'number',
1221
+ min: 1,
1222
+ max: 65535,
1223
+ value: port,
1224
+ onChange: (/** @type {any} */ event) => setPort(event.target.value),
1225
+ }),
1226
+ ),
1227
+ ),
1228
+ h(
1229
+ 'div',
1230
+ { className: 'srv-field' },
1231
+ h('label', { htmlFor: 'srv-username' }, t('field.username')),
1232
+ h('input', {
1233
+ id: 'srv-username',
1234
+ className: 'srv-input',
1235
+ value: username,
1236
+ onChange: (/** @type {any} */ event) => setUsername(event.target.value),
1237
+ required: true,
1238
+ }),
1239
+ ),
1240
+ h(
1241
+ 'div',
1242
+ { className: 'srv-field' },
1243
+ h('span', null, t('field.auth')),
1244
+ h(
1245
+ 'div',
1246
+ { className: 'srv-seg', role: 'group', 'aria-label': t('field.auth') },
1247
+ ['key', 'password', 'agent'].map((kind) =>
1248
+ h(
1249
+ 'button',
1250
+ {
1251
+ key: kind,
1252
+ type: 'button',
1253
+ 'aria-pressed': authKind === kind ? 'true' : 'false',
1254
+ onClick: () => setAuthKind(kind),
1255
+ },
1256
+ t(`auth.${kind}`),
1257
+ ),
1258
+ ),
1259
+ ),
1260
+ ),
1261
+ authKind === 'key'
1262
+ ? h(
1263
+ 'div',
1264
+ { className: 'srv-field' },
1265
+ h('label', { htmlFor: 'srv-key' }, t('field.privateKeyPath')),
1266
+ h('input', {
1267
+ id: 'srv-key',
1268
+ className: 'srv-input srv-mono',
1269
+ value: privateKeyPath,
1270
+ placeholder: '/root/.ssh/id_ed25519',
1271
+ onChange: (/** @type {any} */ event) => setPrivateKeyPath(event.target.value),
1272
+ }),
1273
+ )
1274
+ : null,
1275
+ authKind === 'password'
1276
+ ? h(
1277
+ 'div',
1278
+ { className: 'srv-field' },
1279
+ h('label', { htmlFor: 'srv-password' }, t('field.password')),
1280
+ h('input', {
1281
+ id: 'srv-password',
1282
+ className: 'srv-input',
1283
+ type: 'password',
1284
+ value: password,
1285
+ autoComplete: 'new-password',
1286
+ placeholder: editing && server.passwordConfigured === true ? t('auth.passwordConfigured') : '',
1287
+ onChange: (/** @type {any} */ event) => setPassword(event.target.value),
1288
+ }),
1289
+ h('span', { className: 'srv-hint' }, editing ? t('form.passwordKeep') : t('form.passwordNew')),
1290
+ )
1291
+ : null,
1292
+ h(
1293
+ 'div',
1294
+ { className: 'srv-field' },
1295
+ h('label', { htmlFor: 'srv-hostkey' }, t('field.hostKeyMode')),
1296
+ h(
1297
+ 'select',
1298
+ {
1299
+ id: 'srv-hostkey',
1300
+ className: 'srv-select',
1301
+ value: hostKeyMode,
1302
+ onChange: (/** @type {any} */ event) => setHostKeyMode(event.target.value),
1303
+ },
1304
+ hostKeyModes.map((mode) => h('option', { key: mode, value: mode }, t(`hostKey.${mode}`))),
1305
+ ),
1306
+ hostKeyMode === 'off' && (editing ? server.hostKeyMode !== 'off' : false)
1307
+ ? h('span', { className: 'srv-hint' }, t('form.offQueued'))
1308
+ : null,
1309
+ ),
1310
+ h(
1311
+ 'div',
1312
+ { className: 'srv-field' },
1313
+ h('label', { htmlFor: 'srv-workdir' }, t('field.defaultWorkDir')),
1314
+ h('input', {
1315
+ id: 'srv-workdir',
1316
+ className: 'srv-input srv-mono',
1317
+ value: defaultWorkDir,
1318
+ placeholder: '/srv/app',
1319
+ onChange: (/** @type {any} */ event) => setDefaultWorkDir(event.target.value),
1320
+ }),
1321
+ ),
1322
+ h(
1323
+ 'div',
1324
+ { className: 'srv-field' },
1325
+ h('span', null, t('field.policy')),
1326
+ h('p', { className: 'srv-hint' }, t('form.policyHint')),
1327
+ h(
1328
+ 'ul',
1329
+ { className: 'srv-rules' },
1330
+ rules.map((/** @type {any} */ rule) => {
1331
+ const value = policy[rule.id] ?? rule.defaultDecision
1332
+ // "停用" is always offered and always queued (§8.3): a new server
1333
+ // is created with the rule enabled and a disable request filed.
1334
+ const options = [...rule.allowedDecisions, 'off']
1335
+ return h(
1336
+ 'li',
1337
+ { className: 'srv-rule', key: rule.id },
1338
+ h(
1339
+ 'div',
1340
+ { className: 'srv-rule-body' },
1341
+ h('span', { className: 'srv-rule-label' }, rule.label),
1342
+ h('span', { className: 'srv-rule-desc' }, rule.description),
1343
+ ),
1344
+ h(
1345
+ 'select',
1346
+ {
1347
+ className: 'srv-select srv-rule-select',
1348
+ 'aria-label': rule.label,
1349
+ value,
1350
+ onChange: (/** @type {any} */ event) => setRule(rule.id, event.target.value),
1351
+ },
1352
+ options.map((decision) =>
1353
+ h('option', { key: decision, value: decision }, decision === 'off' ? t('form.offOption') : decision),
1354
+ ),
1355
+ ),
1356
+ )
1357
+ }),
1358
+ ),
1359
+ ),
1360
+ error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1361
+ note === null ? null : h('p', { className: 'srv-note' }, note),
1362
+ h(
1363
+ 'div',
1364
+ { className: 'srv-head-actions' },
1365
+ h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
1366
+ h(Button, { onClick: onCancel, disabled: busy }, t('form.cancel')),
1367
+ ),
1368
+ )
1369
+ }
1370
+
1371
+ /**
1372
+ * The main panel: list ⇄ detail ⇄ form (§8.2). One server's data lives in
1373
+ * the list payload, so a mutation is followed by a reload rather than by
1374
+ * patching several copies.
1375
+ * @param {{t: any}} props
1376
+ */
1377
+ function ServerPanel({ t }) {
1378
+ const { state, reload } = useServers()
1379
+ const [view, setView] = React.useState({ kind: 'list' })
1380
+ const [testing, setTesting] = React.useState(/** @type {string|null} */ (null))
1381
+ const [toggling, setToggling] = React.useState(/** @type {string|null} */ (null))
1382
+ const [deleting, setDeleting] = React.useState(false)
1383
+ const [notice, setNotice] = React.useState(/** @type {string|null} */ (null))
1384
+ const [status, setStatus] = React.useState(/** @type {string|null} */ (null))
1385
+
1386
+ const data = state.phase === 'ready' ? state.data : null
1387
+ const servers = data === null ? [] : data.servers
1388
+ const rules = data === null ? [] : data.rules
1389
+ const hostKeyModes = data === null ? ['accept-new', 'verify', 'off'] : data.hostKeyModes
1390
+
1391
+ /** @param {string} id */
1392
+ const test = async (id) => {
1393
+ setTesting(id)
1394
+ setNotice(null)
1395
+ setStatus(null)
1396
+ try {
1397
+ const answer = await api('/test', { method: 'POST', body: { id } })
1398
+ setStatus(
1399
+ t('status.connected', {
1400
+ platform: answer.platform,
1401
+ shell: answer.shell,
1402
+ sudo: answer.sudo,
1403
+ ms: answer.durationMs,
1404
+ }),
1405
+ )
1406
+ await reload()
1407
+ } catch (error) {
1408
+ setStatus(t('error.generic', { message: messageOf(error) }))
1409
+ } finally {
1410
+ setTesting(null)
1411
+ }
1412
+ }
1413
+
1414
+ /** @param {string} id */
1415
+ const remove = async (id) => {
1416
+ setDeleting(true)
1417
+ setNotice(null)
1418
+ try {
1419
+ await api(`/servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
1420
+ setNotice(t('panel.deleteQueued'))
1421
+ setView({ kind: 'list' })
1422
+ await reload()
1423
+ } catch (error) {
1424
+ setNotice(t('error.generic', { message: messageOf(error) }))
1425
+ } finally {
1426
+ setDeleting(false)
1427
+ }
1428
+ }
1429
+
1430
+ /**
1431
+ * Flip the enable switch. It applies immediately — no pending queue — so
1432
+ * the only failure modes are "connected" (409) and a network error.
1433
+ * @param {any} server
1434
+ */
1435
+ const toggle = async (server) => {
1436
+ setToggling(server.id)
1437
+ setNotice(null)
1438
+ try {
1439
+ await api(`/servers/${encodeURIComponent(server.id)}`, {
1440
+ method: 'PUT',
1441
+ body: { enabled: server.enabled === false },
1442
+ })
1443
+ await reload()
1444
+ } catch (error) {
1445
+ setNotice(t('error.generic', { message: messageOf(error) }))
1446
+ } finally {
1447
+ setToggling(null)
1448
+ }
1449
+ }
1450
+
1451
+ /** @param {string|null} message */
1452
+ const saved = async (message) => {
1453
+ setNotice(message)
1454
+ setView({ kind: 'list' })
1455
+ await reload()
1456
+ }
1457
+
1458
+ const current = view.kind === 'detail' ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1459
+ const edited = view.kind === 'form' && view.id !== undefined ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1460
+
1461
+ return h(
1462
+ 'div',
1463
+ { className: 'srv-page' },
1464
+ h(
1465
+ 'div',
1466
+ { className: 'srv-column' },
1467
+ view.kind === 'list'
1468
+ ? h(ServerList, {
1469
+ state,
1470
+ t,
1471
+ reload,
1472
+ onAdd: () => {
1473
+ setNotice(null)
1474
+ setView({ kind: 'form' })
1475
+ },
1476
+ onOpen: (/** @type {string} */ id) => {
1477
+ setNotice(null)
1478
+ setView({ kind: 'detail', id })
1479
+ },
1480
+ onEdit: (/** @type {string} */ id) => {
1481
+ setNotice(null)
1482
+ setView({ kind: 'form', id })
1483
+ },
1484
+ onTest: test,
1485
+ onToggle: toggle,
1486
+ testing,
1487
+ toggling,
1488
+ notice: notice ?? status,
1489
+ })
1490
+ : null,
1491
+ view.kind === 'detail' && current !== null
1492
+ ? h(ServerDetail, {
1493
+ server: current,
1494
+ rules,
1495
+ t,
1496
+ onBack: () => setView({ kind: 'list' }),
1497
+ onEdit: () => setView({ kind: 'form', id: current.id }),
1498
+ onDelete: () => remove(current.id),
1499
+ deleting,
1500
+ notice: status,
1501
+ onRefresh: reload,
1502
+ })
1503
+ : null,
1504
+ view.kind === 'detail' && current === null
1505
+ ? h(
1506
+ 'div',
1507
+ { className: 'srv-empty' },
1508
+ h('p', null, t('panel.notFound')),
1509
+ h(Button, { variant: 'outline', onClick: () => setView({ kind: 'list' }) }, t('panel.back')),
1510
+ )
1511
+ : null,
1512
+ view.kind === 'form'
1513
+ ? h(ServerForm, {
1514
+ server: edited,
1515
+ rules,
1516
+ hostKeyModes,
1517
+ t,
1518
+ onCancel: () => setView({ kind: 'list' }),
1519
+ onSaved: saved,
1520
+ })
1521
+ : null,
1522
+ ),
1523
+ )
1524
+ }
1525
+
1526
+ /**
1527
+ * One §8.3 pending row.
1528
+ * @param {{record: any, t: any, onConfirm: (id: string) => Promise<void>, onCancel: (id: string) => Promise<void>, busy: string|null}} props
1529
+ */
1530
+ function PendingRow({ record, t, onConfirm, onCancel, busy }) {
1531
+ const working = busy === record.id
1532
+ const name = typeof record.serverName === 'string' ? record.serverName : record.serverId
1533
+ const title =
1534
+ record.kind === 'delete-server'
1535
+ ? t('pending.deleteServer', { name })
1536
+ : record.kind === 'disable-hostkey'
1537
+ ? t('pending.disableHostkey', { name })
1538
+ : t('pending.disableRule', { rule: String(record.ruleId ?? '?'), name })
1539
+ return h(
1540
+ 'li',
1541
+ null,
1542
+ h(
1543
+ 'div',
1544
+ { className: 'srv-pending-main' },
1545
+ h('span', { className: 'srv-pending-title' }, title),
1546
+ h(
1547
+ 'span',
1548
+ { className: 'srv-pending-meta' },
1549
+ `${t('pending.requestedAt', { time: formatTime(record.requestedAt) })} · ${t('pending.expiresAt', {
1550
+ time: formatTime(record.expiresAt),
1551
+ })}${record.target?.present === false ? ` · ${t('pending.targetMissing')}` : ''}`,
1552
+ ),
1553
+ ),
1554
+ h(
1555
+ 'div',
1556
+ { className: 'srv-row-actions' },
1557
+ h(Button, { size: 'sm', variant: 'primary', disabled: working, onClick: () => void onConfirm(record.id) }, working ? t('pending.busy') : t('pending.confirm')),
1558
+ h(Button, { size: 'sm', variant: 'outline', disabled: working, onClick: () => void onCancel(record.id) }, t('pending.cancel')),
1559
+ ),
1560
+ )
1561
+ }
1562
+
1563
+ /**
1564
+ * The `settings.section` page: global settings (read-only, they come from the
1565
+ * profile) and the pending queue with its red count badge (§8.3).
1566
+ * @param {{t: any}} props
1567
+ */
1568
+ function SettingsPage({ t }) {
1569
+ const { state, reload } = useServers()
1570
+ const [pending, setPending] = React.useState({ phase: 'loading' })
1571
+ const [busy, setBusy] = React.useState(/** @type {string|null} */ (null))
1572
+ const [error, setError] = React.useState(/** @type {string|null} */ (null))
1573
+
1574
+ const reloadPending = React.useCallback(async () => {
1575
+ try {
1576
+ const answer = await api('/pending')
1577
+ setPending({ phase: 'ready', records: answer.pending })
1578
+ } catch (failure) {
1579
+ setPending({ phase: 'error', error: failure })
1580
+ }
1581
+ }, [])
1582
+
1583
+ React.useEffect(() => {
1584
+ void reloadPending()
1585
+ }, [reloadPending])
1586
+
1587
+ /** @param {'confirm'|'cancel'} action @param {string} id */
1588
+ const act = async (action, id) => {
1589
+ setBusy(id)
1590
+ setError(null)
1591
+ try {
1592
+ await api(`/pending/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
1593
+ await Promise.all([reloadPending(), reload()])
1594
+ } catch (failure) {
1595
+ setError(t('error.generic', { message: messageOf(failure) }))
1596
+ } finally {
1597
+ setBusy(null)
1598
+ }
1599
+ }
1600
+
1601
+ const records = pending.phase === 'ready' ? pending.records : []
1602
+ const config = state.phase === 'ready' ? state.data.config : null
1603
+ return h(
1604
+ 'div',
1605
+ { className: 'srv-page' },
1606
+ h(
1607
+ 'div',
1608
+ { className: 'srv-column' },
1609
+ h('h2', null, t('settings.title')),
1610
+ h('p', { className: 'srv-hint' }, t('settings.description')),
1611
+ h(
1612
+ 'section',
1613
+ { className: 'srv-card' },
1614
+ h(
1615
+ 'div',
1616
+ { className: 'srv-card-head' },
1617
+ h('h3', { className: 'srv-card-title' }, t('settings.pending')),
1618
+ h('span', { className: 'srv-badge', hidden: records.length === 0 }, String(records.length)),
1619
+ ),
1620
+ h('p', { className: 'srv-hint' }, t('settings.pendingHint')),
1621
+ error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1622
+ records.length === 0 ? h('p', { className: 'srv-hint' }, t('settings.pendingEmpty')) : null,
1623
+ records.length > 0
1624
+ ? h(
1625
+ 'ul',
1626
+ { className: 'srv-pending' },
1627
+ records.map((/** @type {any} */ record) =>
1628
+ h(PendingRow, {
1629
+ key: record.id,
1630
+ record,
1631
+ t,
1632
+ busy,
1633
+ onConfirm: (/** @type {string} */ id) => act('confirm', id),
1634
+ onCancel: (/** @type {string} */ id) => act('cancel', id),
1635
+ }),
1636
+ ),
1637
+ )
1638
+ : null,
1639
+ ),
1640
+ h(
1641
+ 'section',
1642
+ { className: 'srv-card' },
1643
+ h('h3', { className: 'srv-card-title' }, t('settings.global')),
1644
+ h('p', { className: 'srv-hint' }, t('settings.globalHint')),
1645
+ config === null
1646
+ ? state.phase === 'error'
1647
+ ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) }))
1648
+ : h('p', { className: 'srv-hint' }, t('panel.loading'))
1649
+ : h(
1650
+ 'dl',
1651
+ { className: 'srv-kv' },
1652
+ Object.entries(config).map(([key, value]) =>
1653
+ h(F, { key }, h('dt', { className: 'srv-mono' }, key), h('dd', { className: 'srv-mono' }, String(value))),
1654
+ ),
1655
+ ),
1656
+ ),
1657
+ ),
1658
+ )
1659
+ }
1660
+
1661
+ // ---------------------------------------------------------------- mount --
1662
+
1663
+ /** Services this bundle needs; `slots` carries the three mount points, `locale` the copy. */
1664
+ const inject = ['slots', 'locale']
1665
+
1666
+ /**
1667
+ * Register the dictionaries and the three §8.1 contributions.
1668
+ * @param {any} ctx - the browser plugin context.
1669
+ */
1670
+ function apply(ctx) {
1671
+ installStyles()
1672
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'server-connector: dictionaries')
1673
+
1674
+ ctx.slots.inject('sidebar.panellist', () =>
1675
+ ctx.slots.register(
1676
+ {
1677
+ name: 'sidebar.panellist',
1678
+ id: PANEL_ID,
1679
+ order: 10,
1680
+ label: () => ctx.locale.bind(NS)('panel.label'),
1681
+ locale: NS,
1682
+ },
1683
+ ServerIcon,
1684
+ ),
1685
+ )
1686
+
1687
+ ctx.slots.inject('main', () =>
1688
+ ctx.slots.register(
1689
+ {
1690
+ name: 'main',
1691
+ key: PANEL_ID,
1692
+ locale: NS,
1693
+ inject: () => ({}),
1694
+ },
1695
+ ServerPanel,
1696
+ ),
1697
+ )
1698
+
1699
+ ctx.slots.inject('settings.section', () =>
1700
+ ctx.slots.register(
1701
+ {
1702
+ name: 'settings.section',
1703
+ id: PANEL_ID,
1704
+ order: 25,
1705
+ label: () => ctx.locale.bind(NS)('settings.nav'),
1706
+ locale: NS,
1707
+ inject: () => ({}),
1708
+ },
1709
+ SettingsPage,
1710
+ ),
1711
+ )
1712
+ }
1713
+
1714
+ exports.NS = NS
1715
+ exports.PANEL_ID = PANEL_ID
1716
+ exports.apply = apply
1717
+ exports.inject = inject
1718
+ return module.exports
1719
+ },
1720
+ })