dsh-server-connector 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,1728 +1,2389 @@
1
- // dsh-server-connector — Client half (design doc §8).
2
- //
3
- // Three mount points, all taken from the confirmed live Slot tree (§8.1):
4
- //
5
- // sidebar.panellist → the "服务器" icon in the left rail
6
- // main (key: "server-connector") → server list / detail / form
7
- // settings.section → global settings + the pending two-step queue
8
- //
9
- // The file is a browser module-loader bundle, not a Node module: the DSH client
10
- // module system loads it through `window.__ModuleLoader__.load`, and `require`
11
- // is the page's static table (react, react/jsx-runtime, …). It is hand-authored
12
- // — this repository has no client build step — so it uses `React.createElement`
13
- // rather than JSX and keeps every browser global behind `browserGlobal` so the
14
- // Node-side `tsc --noEmit` pass (tsconfig lib is ES2023, no DOM) stays clean.
15
- //
16
- // Two product constraints shape the UI:
17
- // - the panel talks to the Host only through the §8.5 JSON routes, and it
18
- // never receives a secret (a password is write-only, §6.3);
19
- // - the panel has no turn, so `ctx.approval` is unavailable (§11): deleting a
20
- // server, switching host-key verification off and disabling a rule all write
21
- // to the pending queue and say so. Nothing dangerous happens on one click.
22
-
23
- /** The page global, typed loosely: this bundle runs in the browser, not in Node. */
24
- const browserGlobal = /** @type {any} */ (globalThis)
25
-
26
- browserGlobal.__ModuleLoader__.load({
27
- id: 'dsh-server-connector',
28
- factory: (/** @type {(id: string) => any} */ load) => {
29
- const module = { exports: /** @type {Record<string, unknown>} */ ({}) }
30
- const exports = module.exports
31
- Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
32
-
33
- const React = load('react')
34
- const h = React.createElement
35
- const F = React.Fragment
36
-
37
- /** Dictionary namespace owned by this plugin. */
38
- const NS = 'serverConnector'
39
- /** The id shared by the sidebar entry, the main panel key and the settings page. */
40
- const PANEL_ID = 'server-connector'
41
- /** Mount path of the Host routes (§8.5). */
42
- const API = '/server-connector'
43
- /** A displayed audit tail matches the detail page (§8.2). */
44
- const AUDIT_LIMIT = 20
45
-
46
- // ---------------------------------------------------------------- copy --
47
-
48
- /** Chinese dictionary, the source of truth. */
49
- const zh = {
50
- 'panel.label': '服务器',
51
- 'panel.title': '服务器',
52
- 'panel.add': '新增',
53
- 'panel.loading': '读取服务器列表…',
54
- 'panel.retry': '重试',
55
- 'panel.empty': '还没有配置服务器。',
56
- 'panel.emptyHint': '新增一台后,模型即可通过 srv_* 工具在这台机器上执行命令。',
57
- 'panel.connected': '已连接',
58
- 'panel.disconnected': '未连接',
59
- 'panel.test': '测试',
60
- 'panel.testing': '测试中…',
61
- 'panel.edit': '编辑',
62
- 'panel.enable': '启用',
63
- 'panel.disable': '禁用',
64
- 'panel.disabled': '已禁用',
65
- 'panel.disconnectFirst': '已连接的服务器不能禁用,请先断开',
66
- 'panel.disabledNote': '已禁用:srv_list 不再返回它,任何 srv_* 调用都会被拒绝。',
67
- 'panel.back': '返回列表',
68
- 'panel.delete': '删除',
69
- 'panel.deleteHint': '删除会先写入待确认区,确认后才真正移除。',
70
- 'panel.deleteQueued': '删除请求已加入待确认区,服务器仍然可用;请到设置页确认。',
71
- 'panel.sectionConnection': '连接',
72
- 'panel.sectionPolicy': '高危策略',
73
- 'panel.sectionAudit': `最近 ${AUDIT_LIMIT} 条审计`,
74
- 'panel.auditEmpty': '没有审计记录。',
75
- 'panel.auditLoading': '读取审计…',
76
- 'panel.notFound': '这台服务器已不存在。',
77
- 'panel.lastConnected': '上次连接 {time}',
78
- 'panel.neverConnected': '从未连接',
79
- 'status.connected': '连接成功:{platform}({shell},sudo {sudo}),耗时 {ms} ms',
80
- 'field.name': '名称',
81
- 'field.host': '主机',
82
- 'field.port': '端口',
83
- 'field.username': '用户名',
84
- 'field.auth': '认证方式',
85
- 'field.privateKeyPath': '私钥路径',
86
- 'field.password': '密码',
87
- 'field.hostKeyMode': '主机密钥策略',
88
- 'field.defaultWorkDir': '默认工作目录',
89
- 'field.lastConnectedAt': '最近连接',
90
- 'field.policy': '高危策略',
91
- 'auth.key': '私钥',
92
- 'auth.password': '密码',
93
- 'auth.agent': 'SSH agent',
94
- 'auth.passwordConfigured': '已设置',
95
- 'hostKey.accept-new': 'accept-new(首次接受并记住)',
96
- 'hostKey.verify': 'verify(严格校验)',
97
- 'hostKey.off': 'off(关闭校验)',
98
- 'form.new': '新增服务器',
99
- 'form.edit': '编辑服务器',
100
- 'form.save': '保存',
101
- 'form.saving': '保存中…',
102
- 'form.cancel': '取消',
103
- 'form.portRange': '端口必须是 1–65535 的整数',
104
- 'form.absolutePath': '必须是绝对路径',
105
- 'form.passwordKeep': '留空则保持当前密码',
106
- 'form.passwordNew': '密码只写不回显',
107
- 'form.policyHint': '内置 block 规则只能降为 confirm,不能直接改为 allow;选择「停用」会先写入待确认区。',
108
- 'form.offOption': '停用',
109
- 'form.offQueued': '「停用」会写入待确认区,确认后该规则才失效。',
110
- 'form.pendingNote': '危险改动已写入待确认区,确认后才生效。',
111
- 'form.errorName': '名称不能为空',
112
- 'form.errorHost': '主机不能为空',
113
- 'form.errorUsername': '用户名不能为空',
114
- 'form.errorKeyPath': '私钥路径不能为空',
115
- 'form.errorPassword': '密码不能为空',
116
- 'settings.nav': '服务器连接器',
117
- 'settings.title': '服务器连接器',
118
- 'settings.description': '面板、连接池与危险操作的两段式确认。',
119
- 'settings.global': '全局设置',
120
- 'settings.globalHint': '这些值来自 profile 配置,修改后重启插件生效。',
121
- 'settings.pending': '待确认操作',
122
- 'settings.pendingEmpty': '没有待确认的操作。',
123
- 'settings.pendingHint': '删除服务器、关闭主机密钥校验、停用高危规则都会先进入这里;确认前目标保持原样。',
124
- 'rules.section': '自定义规则',
125
- 'rules.hint': '只作用于这台服务器;默认档只能是 confirm 或 block。保存前请先试跑,确认正则能命中真实命令。',
126
- 'rules.empty': '还没有自定义规则。',
127
- 'rules.add': '新增规则',
128
- 'rules.delete': '删除',
129
- 'rules.label': '标签',
130
- 'rules.pattern': '正则(区分大小写的部分用 (?i) 关闭)',
131
- 'rules.decision': '默认档',
132
- 'rules.description': '说明',
133
- 'rules.samples': '样例命令(每行一条)',
134
- 'rules.samplesHint': '至少一条样例必须能命中,否则规则会因「永不触发」被拒绝。',
135
- 'rules.preview': '试跑',
136
- 'rules.wouldMatch': '是否命中',
137
- 'rules.needSample': '试跑前请至少填一条样例命令。',
138
- 'pending.deleteServer': '删除服务器「{name}」',
139
- 'pending.disableHostkey': '关闭主机密钥校验「{name}」',
140
- 'pending.disableRule': '停用高危规则「{rule}」({name})',
141
- 'pending.requestedAt': '申请于 {time}',
142
- 'pending.expiresAt': '{time} 过期',
143
- 'pending.confirm': '确认执行',
144
- 'pending.cancel': '取消',
145
- 'pending.busy': '执行中…',
146
- 'pending.targetMissing': '目标已不存在',
147
- 'error.generic': '操作失败:{message}',
148
- 'error.load': '读取失败:{message}',
149
- }
150
-
151
- /** English dictionary, key-identical to the Chinese source of truth. */
152
- const en = {
153
- 'panel.label': 'Servers',
154
- 'panel.title': 'Servers',
155
- 'panel.add': 'Add',
156
- 'panel.loading': 'Reading servers…',
157
- 'panel.retry': 'Retry',
158
- 'panel.empty': 'No server configured yet.',
159
- 'panel.emptyHint': 'Add one and the model can run commands on this machine through the srv_* tools.',
160
- 'panel.connected': 'Connected',
161
- 'panel.disconnected': 'Not connected',
162
- 'panel.test': 'Test',
163
- 'panel.testing': 'Testing…',
164
- 'panel.edit': 'Edit',
165
- 'panel.enable': 'Enable',
166
- 'panel.disable': 'Disable',
167
- 'panel.disabled': 'Disabled',
168
- 'panel.disconnectFirst': 'Disconnect this server before disabling it',
169
- 'panel.disabledNote': 'Disabled: srv_list no longer returns it and every srv_* call is rejected.',
170
- 'panel.back': 'Back to list',
171
- 'panel.delete': 'Delete',
172
- 'panel.deleteHint': 'Deleting first queues a request; the server is removed only after you confirm it.',
173
- 'panel.deleteQueued': 'The delete request is queued and the server still works; confirm it in Settings.',
174
- 'panel.sectionConnection': 'Connection',
175
- 'panel.sectionPolicy': 'High-risk policy',
176
- 'panel.sectionAudit': `Last ${AUDIT_LIMIT} audit records`,
177
- 'panel.auditEmpty': 'No audit records.',
178
- 'panel.auditLoading': 'Reading the audit log…',
179
- 'panel.notFound': 'This server no longer exists.',
180
- 'panel.lastConnected': 'Last connected {time}',
181
- 'panel.neverConnected': 'Never connected',
182
- 'status.connected': 'Connected: {platform} ({shell}, sudo {sudo}) in {ms} ms',
183
- 'field.name': 'Name',
184
- 'field.host': 'Host',
185
- 'field.port': 'Port',
186
- 'field.username': 'Username',
187
- 'field.auth': 'Authentication',
188
- 'field.privateKeyPath': 'Private key path',
189
- 'field.password': 'Password',
190
- 'field.hostKeyMode': 'Host-key policy',
191
- 'field.defaultWorkDir': 'Default working directory',
192
- 'field.lastConnectedAt': 'Last connected',
193
- 'field.policy': 'High-risk policy',
194
- 'auth.key': 'Private key',
195
- 'auth.password': 'Password',
196
- 'auth.agent': 'SSH agent',
197
- 'auth.passwordConfigured': 'configured',
198
- 'hostKey.accept-new': 'accept-new (accept and remember first)',
199
- 'hostKey.verify': 'verify (strict)',
200
- 'hostKey.off': 'off (verification off)',
201
- 'form.new': 'Add server',
202
- 'form.edit': 'Edit server',
203
- 'form.save': 'Save',
204
- 'form.saving': 'Saving…',
205
- 'form.cancel': 'Cancel',
206
- 'form.portRange': 'The port must be an integer from 1 to 65535',
207
- 'form.absolutePath': 'Must be an absolute path',
208
- 'form.passwordKeep': 'Leave blank to keep the current password',
209
- 'form.passwordNew': 'The password is write-only',
210
- 'form.policyHint': 'A built-in block rule can be lowered to confirm, never straight to allow; choosing “off” queues a pending request.',
211
- 'form.offOption': 'Off',
212
- 'form.offQueued': '“Off” is queued; the rule keeps working until you confirm.',
213
- 'form.pendingNote': 'The dangerous change is queued and takes effect only after you confirm it.',
214
- 'form.errorName': 'Name is required',
215
- 'form.errorHost': 'Host is required',
216
- 'form.errorUsername': 'Username is required',
217
- 'form.errorKeyPath': 'Private key path is required',
218
- 'form.errorPassword': 'Password is required',
219
- 'settings.nav': 'Server Connector',
220
- 'settings.title': 'Server Connector',
221
- 'settings.description': 'The panel, the connection pool and two-step confirmation for dangerous operations.',
222
- 'settings.global': 'Global settings',
223
- 'settings.globalHint': 'These values come from the profile configuration; restart the plugin to change them.',
224
- 'settings.pending': 'Pending operations',
225
- 'settings.pendingEmpty': 'Nothing is pending.',
226
- 'settings.pendingHint': 'Deleting a server, switching host-key verification off and disabling a high-risk rule all wait here; the target stays as it is until you confirm.',
227
- 'rules.section': 'Custom rules',
228
- 'rules.hint': 'These apply to this server only; the default decision may be confirm or block. Run a preview before saving so you know the pattern matches a real command.',
229
- 'rules.empty': 'No custom rules yet.',
230
- 'rules.add': 'Add rule',
231
- 'rules.delete': 'Delete',
232
- 'rules.label': 'Label',
233
- 'rules.pattern': 'Pattern (regular expression)',
234
- 'rules.decision': 'Default decision',
235
- 'rules.description': 'Description',
236
- 'rules.samples': 'Sample commands (one per line)',
237
- 'rules.samplesHint': 'At least one sample must match, otherwise the rule is rejected as one that never fires.',
238
- 'rules.preview': 'Preview',
239
- 'rules.wouldMatch': 'Matches',
240
- 'rules.needSample': 'Add at least one sample command before previewing.',
241
- 'pending.deleteServer': 'Delete server “{name}”',
242
- 'pending.disableHostkey': 'Turn off host-key verification for “{name}”',
243
- 'pending.disableRule': 'Disable high-risk rule “{rule}” on “{name}”',
244
- 'pending.requestedAt': 'Requested {time}',
245
- 'pending.expiresAt': 'expires {time}',
246
- 'pending.confirm': 'Confirm',
247
- 'pending.cancel': 'Cancel',
248
- 'pending.busy': 'Working…',
249
- 'pending.targetMissing': 'the target is already gone',
250
- 'error.generic': 'The operation failed: {message}',
251
- 'error.load': 'Could not read: {message}',
252
- }
253
-
254
- // --------------------------------------------------------------- style --
255
-
256
- /** One stylesheet, injected once by the module system (§ client styles claim the tag). */
257
- const CSS = [
258
- '.srv-page{box-sizing:border-box;height:100%;overflow:auto;padding:24px clamp(16px,3vw,40px) 48px;color:var(--dsw-alias-label-primary);',
259
- 'font-size:14px;line-height:22px;container-type:inline-size}',
260
- '.srv-column{max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:16px}',
261
- '.srv-head{display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap}',
262
- '.srv-head h2{margin:0;flex:1 1 auto;min-width:0;font-size:20px;line-height:28px;font-weight:500;',
263
- 'overflow-wrap:anywhere}',
264
- '.srv-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
265
- '.srv-btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 14px;',
266
- 'border:0;border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;',
267
- 'font-size:13px;cursor:pointer;white-space:nowrap}',
268
- '.srv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
269
- '.srv-btn:disabled{cursor:not-allowed;opacity:.4}',
270
- '.srv-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
271
- '.srv-btn-sm{height:26px;padding:0 10px;font-size:12px;border-radius:13px}',
272
- '.srv-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}',
273
- '.srv-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
274
- '.srv-btn-outline{border:.5px solid var(--dsw-alias-border-l3)}',
275
- '.srv-btn-danger{color:var(--dsw-alias-state-error-primary)}',
276
- '.srv-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
277
- '.srv-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}',
278
- '.srv-row{display:flex;align-items:flex-start;gap:4px 12px;flex-wrap:wrap;padding:10px 8px;border-radius:12px;min-width:0}',
279
- '.srv-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
280
- '.srv-row-off{opacity:.55}',
281
- // Mirrors the Host's own switch (ui-primitives), which inverts with the
282
- // theme rather than staying monochrome:
283
- //
284
- // track thumb
285
- // light on --dsw-alias-label-primary (#0f1115) --dsw-alias-label-primary-inverted (#fff)
286
- // dark on --dsw-alias-label-primary (#f9fafb) --dsw-alias-label-primary-inverted (#353638)
287
- //
288
- // The thumb MUST use the inverted pair, not a fixed white: a hard-coded
289
- // `#fff` disappears against the near-white track in dark mode, which reads
290
- // as one blank bar. `label-primary` / `label-primary-inverted` are the only
291
- // pair guaranteed to contrast in both themes.
292
- '.srv-switch{position:relative;flex:0 0 auto;width:36px;height:20px;padding:0;border:none;border-radius:999px;',
293
- 'background:var(--dsw-alias-border-l4,rgba(0,0,0,.12));cursor:pointer;transition:background .15s ease}',
294
- '.srv-switch[data-on="true"]{background:var(--dsw-alias-label-primary,#0f1115)}',
295
- '.srv-switch:disabled{cursor:not-allowed;opacity:.5}',
296
- '.srv-switch:focus-visible{outline:2px solid var(--dsw-alias-label-primary,#0f1115);outline-offset:2px}',
297
- '.srv-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
298
- 'background:var(--dsw-alias-label-tertiary,#adb2b8);transition:transform .15s ease,background .15s ease}',
299
- '.srv-switch[data-on="true"] .srv-switch-knob{transform:translateX(16px);',
300
- 'background:var(--dsw-alias-label-primary-inverted,#fff)}',
301
- '.srv-row-main{flex:1 1 240px;min-width:0;display:flex;flex-direction:column;gap:2px}',
302
- '.srv-row-title{display:flex;align-items:center;gap:8px;min-width:0;font-size:14px;line-height:20px;font-weight:500}',
303
- '.srv-open{padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;',
304
- 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}',
305
- '.srv-open:hover{text-decoration:underline}',
306
- '.srv-row-meta{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap;font-size:12px;line-height:18px;',
307
- 'color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
308
- '.srv-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
309
- '.srv-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
310
- '.srv-tag{display:inline-flex;align-items:center;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;',
311
- 'padding:0 8px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
312
- '.srv-tag[data-tone="success"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);',
313
- 'color:var(--dsw-alias-state-success-primary)}',
314
- '.srv-tag[data-tone="warn"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);',
315
- 'color:var(--dsw-alias-state-warn-primary)}',
316
- '.srv-row-actions{display:flex;align-items:center;gap:4px;flex:none;margin-left:auto}',
317
- '.srv-empty{margin:0;padding:32px 8px;text-align:center;color:var(--dsw-alias-label-tertiary)}',
318
- '.srv-empty p{margin:0 0 4px}',
319
- '.srv-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:.5px solid var(--dsw-alias-border-l3);',
320
- 'border-radius:16px;min-width:0}',
321
- '.srv-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
322
- '.srv-card-title{margin:0;flex:1;font-size:15px;line-height:22px;font-weight:600}',
323
- '.srv-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0;font-size:13px;line-height:20px}',
324
- '.srv-kv dt{color:var(--dsw-alias-label-tertiary)}',
325
- '.srv-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}',
326
- '.srv-mono{font-family:var(--ds-font-family-code,Menlo,monospace);font-size:12px}',
327
- '.srv-form{display:flex;flex-direction:column;gap:16px}',
328
- '.srv-field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:13px;line-height:20px}',
329
- '.srv-field-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px}',
330
- '.srv-input,.srv-select{box-sizing:border-box;width:100%;min-width:0;height:32px;padding:0 8px;font:inherit;',
331
- 'font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);',
332
- 'border:.5px solid var(--dsw-alias-border-l4);border-radius:8px}',
333
- '.srv-input:focus-visible,.srv-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
334
- '.srv-input[rows]{height:auto;padding:6px 8px;resize:vertical}',
335
- '.srv-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
336
- '.srv-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}',
337
- '.srv-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
338
- '.srv-note{margin:0;padding:8px 12px;border-radius:10px;font-size:13px;line-height:20px;',
339
- 'background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);overflow-wrap:anywhere}',
340
- '.srv-seg{display:inline-flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover)}',
341
- '.srv-seg button{height:26px;padding:0 12px;border:0;border-radius:7px;background:transparent;cursor:pointer;',
342
- 'font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary)}',
343
- '.srv-seg button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);',
344
- 'box-shadow:var(--dsw-elevation-soft)}',
345
- '.srv-rules{border-top:.5px solid var(--dsw-alias-border-l2);margin:0;padding:0;list-style:none}',
346
- '.srv-rule{display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
347
- '.srv-rule-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
348
- '.srv-rule-label{font-size:13px;line-height:20px;font-weight:500}',
349
- '.srv-rule-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
350
- '.srv-rule-select{flex:none;width:132px}',
351
- '.srv-audit{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;font-size:12px;line-height:18px}',
352
- '.srv-audit li{display:flex;gap:8px;align-items:baseline;min-width:0;padding:2px 0}',
353
- '.srv-audit-time{flex:none;color:var(--dsw-alias-label-caption);font-variant-numeric:tabular-nums}',
354
- '.srv-audit-decision{flex:none;width:56px;color:var(--dsw-alias-label-secondary)}',
355
- '.srv-audit-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,Menlo,monospace)}',
356
- '.srv-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;',
357
- 'border-radius:9px;background:var(--dsw-alias-state-error-primary);color:var(--dsw-static-neutral-bluish-00);',
358
- 'font-size:11px;line-height:18px;font-weight:600}',
359
- '.srv-pending{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}',
360
- '.srv-pending li{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 0;',
361
- 'border-bottom:.5px solid var(--dsw-alias-border-l2)}',
362
- '.srv-pending-main{flex:1 1 220px;min-width:0;display:flex;flex-direction:column;gap:2px}',
363
- '.srv-pending-title{font-size:13px;line-height:20px;font-weight:500;overflow-wrap:anywhere}',
364
- '.srv-pending-meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption)}',
365
- '.srv-danger-zone{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;',
366
- 'padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
367
- '@container (max-width:640px){.srv-field-row{grid-template-columns:1fr}.srv-rule{flex-wrap:wrap}',
368
- '.srv-rule-select{width:100%}}',
369
- ].join('')
370
-
371
- /** Inject the stylesheet once; the client module system tags it for HMR cleanup. */
372
- function installStyles() {
373
- if (typeof browserGlobal.document === 'undefined') return
374
- const document = browserGlobal.document
375
- if (document.querySelector('style[data-server-connector]') !== null) return
376
- const tag = document.createElement('style')
377
- tag.setAttribute('data-server-connector', '')
378
- tag.textContent = CSS
379
- document.head.appendChild(tag)
380
- }
381
-
382
- // ---------------------------------------------------------------- HTTP --
383
-
384
- /**
385
- * One route call. Same-origin, so the browser session cookie authenticates
386
- * it exactly like the rest of the GUI (§8.5 trust boundary).
387
- * @param {string} path - path below {@link API}.
388
- * @param {{method?: string, body?: unknown, query?: Record<string, string>}} [options]
389
- * @returns {Promise<any>}
390
- */
391
- async function api(path, options = {}) {
392
- const query = options.query === undefined ? '' : `?${new URLSearchParams(options.query).toString()}`
393
- /** @type {Record<string, string>} */
394
- const headers = {}
395
- /** @type {RequestInit} */
396
- const init = { method: options.method ?? 'GET', headers }
397
- if (options.body !== undefined) {
398
- headers['content-type'] = 'application/json'
399
- init.body = JSON.stringify(options.body)
400
- }
401
- const response = await browserGlobal.fetch(`${API}${path}${query}`, init)
402
- let payload
403
- const text = await response.text()
404
- try {
405
- payload = text === '' ? null : JSON.parse(text)
406
- } catch {
407
- payload = null
408
- }
409
- if (!response.ok) {
410
- throw Object.assign(
411
- new Error(typeof payload?.error?.message === 'string' ? payload.error.message : `HTTP ${String(response.status)}`),
412
- {
413
- code: payload?.error?.code ?? `HTTP_${String(response.status)}`,
414
- status: response.status,
415
- details: payload?.error?.details ?? {},
416
- },
417
- )
418
- }
419
- return payload
420
- }
421
-
422
- /**
423
- * The displayable message of a thrown value.
424
- * @param {unknown} error
425
- * @returns {string}
426
- */
427
- function messageOf(error) {
428
- if (error instanceof Error) return error.message
429
- if (typeof error === 'object' && error !== null && typeof (/** @type {any} */ (error).message) === 'string') {
430
- return /** @type {any} */ (error).message
431
- }
432
- return String(error)
433
- }
434
-
435
- /**
436
- * Format one timestamp for the panel; an unparsable value is shown as-is.
437
- * @param {unknown} value
438
- * @returns {string}
439
- */
440
- function formatTime(value) {
441
- if (typeof value !== 'string' || value === '') return '—'
442
- const date = new Date(value)
443
- if (Number.isNaN(date.getTime())) return value
444
- return date.toLocaleString(undefined, {
445
- month: '2-digit',
446
- day: '2-digit',
447
- hour: '2-digit',
448
- minute: '2-digit',
449
- })
450
- }
451
-
452
- // --------------------------------------------------------------- atoms --
453
-
454
- /** @param {{size?: number, children?: any}} props */
455
- function Icon({ size = 16, children }) {
456
- return h(
457
- 'svg',
458
- {
459
- width: size,
460
- height: size,
461
- viewBox: '0 0 24 24',
462
- fill: 'none',
463
- stroke: 'currentColor',
464
- strokeWidth: 1.8,
465
- strokeLinecap: 'round',
466
- strokeLinejoin: 'round',
467
- 'aria-hidden': true,
468
- },
469
- children,
470
- )
471
- }
472
-
473
- /** The §8.1 sidebar icon: a rack of two server units. */
474
- function ServerIcon({ size = 20 }) {
475
- return h(
476
- Icon,
477
- { size },
478
- h('rect', { key: 'a', x: 3, y: 5, width: 18, height: 6, rx: 1.6 }),
479
- h('rect', { key: 'b', x: 3, y: 13, width: 18, height: 6, rx: 1.6 }),
480
- h('path', { key: 'c', d: 'M6.5 8h.01M6.5 16h.01' }),
481
- )
482
- }
483
-
484
- /**
485
- * @param {{variant?: string, size?: string, danger?: boolean, children?: any} & Record<string, any>} props
486
- */
487
- function Button({ variant = 'ghost', size = 'md', danger = false, children, ...rest }) {
488
- const className = [
489
- 'srv-btn',
490
- size === 'sm' ? 'srv-btn-sm' : '',
491
- variant === 'primary' ? 'srv-btn-primary' : '',
492
- variant === 'outline' ? 'srv-btn-outline' : '',
493
- danger ? 'srv-btn-danger' : '',
494
- ]
495
- .filter(Boolean)
496
- .join(' ')
497
- return h('button', { type: 'button', className, ...rest }, children)
498
- }
499
-
500
- /** @param {{on: boolean}} props */
501
- function StatusDot({ on }) {
502
- return h('span', { className: 'srv-dot', 'data-on': on ? 'true' : 'false' })
503
- }
504
-
505
- // --------------------------------------------------------------- hooks --
506
-
507
- /**
508
- * Load `GET /servers` and keep a reload function.
509
- * @returns {{state: any, reload: () => Promise<void>}}
510
- */
511
- function useServers() {
512
- const [state, setState] = React.useState({ phase: 'loading' })
513
- const alive = React.useRef(true)
514
- React.useEffect(
515
- () => () => {
516
- alive.current = false
517
- },
518
- [],
519
- )
520
- const reload = React.useCallback(async () => {
521
- try {
522
- const data = await api('/servers')
523
- if (alive.current) setState({ phase: 'ready', data })
524
- } catch (error) {
525
- if (alive.current) setState({ phase: 'error', error })
526
- }
527
- }, [])
528
- React.useEffect(() => {
529
- void reload()
530
- }, [reload])
531
- return { state, reload }
532
- }
533
-
534
- // ------------------------------------------------------------- surfaces --
535
-
536
- /**
537
- * The §8.2 list row.
538
- * @param {{server: any, t: any, onOpen: (id: string) => void, onEdit: (id: string) => void, onTest: (id: string) => Promise<void>, onToggle: (server: any) => Promise<void>, testing: string|null, toggling: string|null}} props
539
- */
540
- function ServerRow({ server, t, onOpen, onEdit, onTest, onToggle, testing, toggling }) {
541
- const busy = testing === server.id
542
- const off = server.enabled === false
543
- const toggleBusy = toggling === server.id
544
- return h(
545
- 'li',
546
- { className: off ? 'srv-row srv-row-off' : 'srv-row' },
547
- h(StatusDot, { on: server.connected === true }),
548
- h(
549
- 'div',
550
- { className: 'srv-row-main' },
551
- h(
552
- 'div',
553
- { className: 'srv-row-title' },
554
- h('button', { type: 'button', className: 'srv-open', onClick: () => onOpen(server.id) }, server.name),
555
- ),
556
- h(
557
- 'div',
558
- { className: 'srv-row-meta' },
559
- h('span', { className: 'srv-mono' }, `${server.username}@${server.host}:${String(server.port)}`),
560
- server.defaultWorkDir === null
561
- ? null
562
- : h('span', { className: 'srv-mono' }, server.defaultWorkDir),
563
- h(
564
- 'span',
565
- { className: 'srv-tag', 'data-tone': server.connected === true ? 'success' : undefined },
566
- server.connected === true ? t('panel.connected') : t('panel.disconnected'),
567
- ),
568
- server.hostKeyMode === 'off' ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, 'host-key off') : null,
569
- off ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, t('panel.disabled')) : null,
570
- ),
571
- ),
572
- h(
573
- 'div',
574
- { className: 'srv-row-actions' },
575
- // The switch is the only control the panel needs for the enable state:
576
- // it applies immediately (no pending queue), so it reads as a switch.
577
- h(Switch, {
578
- checked: !off,
579
- disabled: toggleBusy || server.connected === true,
580
- label: off ? t('panel.enable') : t('panel.disable'),
581
- hint: server.connected === true ? t('panel.disconnectFirst') : null,
582
- onChange: () => void onToggle(server),
583
- }),
584
- h(Button, { size: 'sm', variant: 'outline', disabled: busy || off, onClick: () => void onTest(server.id) }, busy ? t('panel.testing') : t('panel.test')),
585
- h(Button, { size: 'sm', onClick: () => onEdit(server.id) }, t('panel.edit')),
586
- ),
587
- )
588
- }
589
-
590
- /**
591
- * A minimal on/off switch. The panel has no design-system switch in the
592
- * injected surface, so this is the smallest one that reads as a switch and
593
- * stays usable when disabled.
594
- * @param {{checked: boolean, disabled?: boolean, label: string, hint?: string|null, onChange: () => void}} props
595
- */
596
- function Switch({ checked, disabled = false, label, hint = null, onChange }) {
597
- return h(
598
- 'button',
599
- {
600
- type: 'button',
601
- role: 'switch',
602
- 'aria-checked': checked ? 'true' : 'false',
603
- 'aria-label': label,
604
- title: hint ?? label,
605
- className: 'srv-switch',
606
- 'data-on': checked ? 'true' : 'false',
607
- disabled,
608
- onClick: () => onChange(),
609
- },
610
- h('span', { className: 'srv-switch-knob', 'aria-hidden': 'true' }),
611
- )
612
- }
613
-
614
- /**
615
- * The list view (or the empty state). Owns no data: the panel passes state.
616
- * @param {{state: any, t: any, reload: () => Promise<void>, onAdd: () => void, onOpen: (id: string) => void, onEdit: (id: string) => void, onTest: (id: string) => Promise<void>, onToggle: (server: any) => Promise<void>, testing: string|null, toggling: string|null, notice: string|null, onOpenSettings?: () => void}} props
617
- */
618
- function ServerList({ state, t, reload, onAdd, onOpen, onEdit, onTest, onToggle, testing, toggling, notice }) {
619
- const data = state.phase === 'ready' ? state.data : null
620
- const servers = data === null ? [] : data.servers
621
- const pendingCount = data === null ? 0 : data.pendingCount
622
- return h(
623
- F,
624
- null,
625
- h(
626
- 'div',
627
- { className: 'srv-head' },
628
- h('h2', null, t('panel.title')),
629
- pendingCount > 0
630
- ? h('span', { className: 'srv-badge', title: t('settings.pending') }, String(pendingCount))
631
- : null,
632
- h(
633
- 'div',
634
- { className: 'srv-head-actions' },
635
- h(Button, { variant: 'primary', onClick: onAdd }, t('panel.add')),
636
- ),
637
- ),
638
- notice === null ? null : h('p', { className: 'srv-note' }, notice),
639
- state.phase === 'loading' ? h('p', { className: 'srv-empty' }, t('panel.loading')) : null,
640
- state.phase === 'error'
641
- ? h(
642
- 'div',
643
- { className: 'srv-empty' },
644
- h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) })),
645
- h(Button, { variant: 'outline', onClick: () => void reload() }, t('panel.retry')),
646
- )
647
- : null,
648
- state.phase === 'ready' && servers.length === 0
649
- ? h(
650
- 'div',
651
- { className: 'srv-empty' },
652
- h('p', null, t('panel.empty')),
653
- h('p', null, t('panel.emptyHint')),
654
- )
655
- : null,
656
- servers.length > 0
657
- ? h(
658
- 'ul',
659
- { className: 'srv-list' },
660
- servers.map((/** @type {any} */ server) =>
661
- h(ServerRow, {
662
- key: server.id,
663
- server,
664
- t,
665
- onOpen,
666
- onEdit,
667
- onTest,
668
- onToggle,
669
- testing,
670
- toggling,
671
- }),
672
- ),
673
- )
674
- : null,
675
- )
676
- }
677
-
678
- /**
679
- * Custom rules for one server (doc §4.4).
680
- *
681
- * The section owns its own draft state: a rule is only sent to the Host after
682
- * the operator has seen a dry run of it against a sample command, which is
683
- * the one guard that catches "the regex never fires".
684
- * @param {{server: any, t: any, onRefresh: () => Promise<void>}} props
685
- */
686
- function CustomRulesSection({ server, t, onRefresh }) {
687
- const rules = Array.isArray(server.customRules) ? server.customRules : []
688
- const [draft, setDraft] = React.useState(null)
689
- const [busy, setBusy] = React.useState(false)
690
- const [error, setError] = React.useState(null)
691
- const [preview, setPreview] = React.useState(null)
692
-
693
- const startAdd = () =>
694
- setDraft({ label: '', pattern: '', defaultDecision: 'confirm', description: '', samples: '' })
695
- const cancel = () => {
696
- setDraft(null)
697
- setPreview(null)
698
- setError(null)
699
- }
700
-
701
- /** @param {any} event */
702
- const dryRun = async (event) => {
703
- event.preventDefault()
704
- if (draft === null) return
705
- setBusy(true)
706
- setError(null)
707
- try {
708
- const samples = String(draft.samples ?? '')
709
- .split('\n')
710
- .map((line) => line.trim())
711
- .filter((line) => line !== '')
712
- if (samples.length === 0) throw new Error(t('rules.needSample'))
713
- const data = await api(`/servers/${encodeURIComponent(server.id)}/rules/preview`, {
714
- method: 'POST',
715
- body: {
716
- label: draft.label === '' ? 'preview' : draft.label,
717
- pattern: draft.pattern,
718
- defaultDecision: draft.defaultDecision,
719
- samples,
720
- },
721
- })
722
- setPreview(data)
723
- } catch (failure) {
724
- setPreview(null)
725
- setError(messageOf(failure))
726
- } finally {
727
- setBusy(false)
728
- }
729
- }
730
-
731
- /** @param {any} event */
732
- const save = async (event) => {
733
- event.preventDefault()
734
- if (draft === null) return
735
- setBusy(true)
736
- setError(null)
737
- try {
738
- const samples = String(draft.samples ?? '')
739
- .split('\n')
740
- .map((line) => line.trim())
741
- .filter((line) => line !== '')
742
- await api(`/servers/${encodeURIComponent(server.id)}/rules`, {
743
- method: 'POST',
744
- body: {
745
- label: draft.label,
746
- pattern: draft.pattern,
747
- defaultDecision: draft.defaultDecision,
748
- description: draft.description,
749
- samples,
750
- },
751
- })
752
- setDraft(null)
753
- setPreview(null)
754
- await onRefresh()
755
- } catch (failure) {
756
- setError(messageOf(failure))
757
- } finally {
758
- setBusy(false)
759
- }
760
- }
761
-
762
- /** @param {string} ruleId */
763
- const remove = async (ruleId) => {
764
- setBusy(true)
765
- setError(null)
766
- try {
767
- await api(`/servers/${encodeURIComponent(server.id)}/rules/${encodeURIComponent(ruleId)}`, {
768
- method: 'DELETE',
769
- })
770
- await onRefresh()
771
- } catch (failure) {
772
- setError(messageOf(failure))
773
- } finally {
774
- setBusy(false)
775
- }
776
- }
777
-
778
- return h(
779
- 'section',
780
- { className: 'srv-card' },
781
- h('h3', { className: 'srv-card-title' }, t('rules.section')),
782
- h('p', { className: 'srv-hint' }, t('rules.hint')),
783
- rules.length === 0 ? h('p', { className: 'srv-hint' }, t('rules.empty')) : null,
784
- rules.length > 0
785
- ? h(
786
- 'ul',
787
- { className: 'srv-rules' },
788
- rules.map((/** @type {any} */ rule) =>
789
- h(
790
- 'li',
791
- { className: 'srv-rule', key: rule.id },
792
- h(
793
- 'div',
794
- { className: 'srv-rule-body' },
795
- h('span', { className: 'srv-rule-label' }, rule.label),
796
- h('span', { className: 'srv-rule-desc' }, rule.description || rule.pattern),
797
- ),
798
- h('span', { className: 'srv-tag', 'data-tone': rule.defaultDecision === 'block' ? undefined : 'success' }, rule.defaultDecision),
799
- h(
800
- Button,
801
- { size: 'sm', danger: true, disabled: busy, onClick: () => void remove(rule.id) },
802
- t('rules.delete'),
803
- ),
804
- ),
805
- ),
806
- )
807
- : null,
808
- error !== null ? h('p', { className: 'srv-error' }, error) : null,
809
- draft === null
810
- ? h(Button, { variant: 'outline', disabled: busy, onClick: startAdd }, t('rules.add'))
811
- : h(
812
- 'form',
813
- { className: 'srv-form', onSubmit: save },
814
- h(
815
- 'div',
816
- { className: 'srv-field' },
817
- h('label', { htmlFor: 'srv-rule-label' }, t('rules.label')),
818
- h('input', {
819
- id: 'srv-rule-label',
820
- className: 'srv-input',
821
- value: draft.label,
822
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, label: event.target.value }),
823
- }),
824
- ),
825
- h(
826
- 'div',
827
- { className: 'srv-field' },
828
- h('label', { htmlFor: 'srv-rule-pattern' }, t('rules.pattern')),
829
- h('input', {
830
- id: 'srv-rule-pattern',
831
- className: 'srv-input srv-mono',
832
- value: draft.pattern,
833
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, pattern: event.target.value }),
834
- }),
835
- ),
836
- h(
837
- 'div',
838
- { className: 'srv-field' },
839
- h('label', { htmlFor: 'srv-rule-decision' }, t('rules.decision')),
840
- h(
841
- 'select',
842
- {
843
- id: 'srv-rule-decision',
844
- className: 'srv-select',
845
- value: draft.defaultDecision,
846
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, defaultDecision: event.target.value }),
847
- },
848
- h('option', { value: 'confirm' }, 'confirm'),
849
- h('option', { value: 'block' }, 'block'),
850
- ),
851
- ),
852
- h(
853
- 'div',
854
- { className: 'srv-field' },
855
- h('label', { htmlFor: 'srv-rule-desc' }, t('rules.description')),
856
- h('input', {
857
- id: 'srv-rule-desc',
858
- className: 'srv-input',
859
- value: draft.description,
860
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, description: event.target.value }),
861
- }),
862
- ),
863
- h(
864
- 'div',
865
- { className: 'srv-field' },
866
- h('label', { htmlFor: 'srv-rule-samples' }, t('rules.samples')),
867
- h('textarea', {
868
- id: 'srv-rule-samples',
869
- className: 'srv-input srv-mono',
870
- rows: 3,
871
- value: draft.samples,
872
- onChange: (/** @type {any} */ event) => setDraft({ ...draft, samples: event.target.value }),
873
- }),
874
- h('span', { className: 'srv-hint' }, t('rules.samplesHint')),
875
- ),
876
- preview !== null
877
- ? h(
878
- 'ul',
879
- { className: 'srv-rules' },
880
- (preview.results ?? []).map((/** @type {any} */ item, /** @type {number} */ index) =>
881
- h(
882
- 'li',
883
- { className: 'srv-rule', key: index },
884
- h(
885
- 'div',
886
- { className: 'srv-rule-body' },
887
- h('span', { className: 'srv-rule-label srv-mono' }, item.sample),
888
- h('span', { className: 'srv-rule-desc' }, `${t('rules.wouldMatch')}: ${item.matched ? 'yes' : 'no'} · ${String(item.decision)}`),
889
- ),
890
- ),
891
- ),
892
- )
893
- : null,
894
- h(
895
- 'div',
896
- { className: 'srv-actions' },
897
- h(Button, { type: 'button', disabled: busy, onClick: dryRun }, t('rules.preview')),
898
- h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
899
- h(Button, { type: 'button', disabled: busy, onClick: cancel }, t('form.cancel')),
900
- ),
901
- ),
902
- )
903
- }
904
-
905
- /**
906
- * The detail page: four connection fields read-only, the policy table, the
907
- * last 20 audit records and [删除] (§8.2).
908
- * @param {{server: any, rules: any[], t: any, onBack: () => void, onEdit: () => void, onDelete: () => Promise<void>, deleting: boolean, notice: string|null, onRefresh: () => Promise<void>}} props
909
- */
910
- function ServerDetail({ server, rules, t, onBack, onEdit, onDelete, deleting, notice, onRefresh }) {
911
- const [audit, setAudit] = React.useState({ phase: 'loading' })
912
- React.useEffect(() => {
913
- let alive = true
914
- api('/audit', { query: { limit: String(AUDIT_LIMIT), serverId: server.id } })
915
- .then((data) => {
916
- if (alive) setAudit({ phase: 'ready', records: data.records })
917
- })
918
- .catch((error) => {
919
- if (alive) setAudit({ phase: 'error', error })
920
- })
921
- return () => {
922
- alive = false
923
- }
924
- }, [server.id])
925
-
926
- const authText =
927
- server.auth.kind === 'key'
928
- ? `${t('auth.key')} · ${server.auth.privateKeyPath}`
929
- : server.auth.kind === 'password'
930
- ? server.passwordConfigured === true
931
- ? `${t('auth.password')} · ${t('auth.passwordConfigured')}`
932
- : `${t('auth.password')} · —`
933
- : t('auth.agent')
934
-
935
- return h(
936
- F,
937
- null,
938
- h(
939
- 'div',
940
- { className: 'srv-head' },
941
- h(Button, { size: 'sm', onClick: onBack }, `← ${t('panel.back')}`),
942
- h('h2', null, server.name),
943
- h(
944
- 'div',
945
- { className: 'srv-head-actions' },
946
- h(Button, { size: 'sm', variant: 'outline', onClick: onEdit }, t('panel.edit')),
947
- ),
948
- ),
949
- notice === null ? null : h('p', { className: 'srv-note' }, notice),
950
- h(
951
- 'section',
952
- { className: 'srv-card' },
953
- h('h3', { className: 'srv-card-title' }, t('panel.sectionConnection')),
954
- h(
955
- 'dl',
956
- { className: 'srv-kv' },
957
- h('dt', null, t('field.name')),
958
- h('dd', null, server.name),
959
- h('dt', null, t('field.host')),
960
- h('dd', { className: 'srv-mono' }, `${server.host}:${String(server.port)}`),
961
- h('dt', null, t('field.username')),
962
- h('dd', { className: 'srv-mono' }, server.username),
963
- h('dt', null, t('field.auth')),
964
- h('dd', { className: 'srv-mono' }, authText),
965
- h('dt', null, t('field.hostKeyMode')),
966
- h('dd', null, server.hostKeyMode),
967
- h('dt', null, t('field.defaultWorkDir')),
968
- h('dd', { className: 'srv-mono' }, server.defaultWorkDir === null ? '—' : server.defaultWorkDir),
969
- h('dt', null, t('field.lastConnectedAt')),
970
- h(
971
- 'dd',
972
- null,
973
- server.lastConnectedAt === null ? t('panel.neverConnected') : t('panel.lastConnected', { time: formatTime(server.lastConnectedAt) }),
974
- ),
975
- ),
976
- ),
977
- h(
978
- 'section',
979
- { className: 'srv-card' },
980
- h('h3', { className: 'srv-card-title' }, t('panel.sectionPolicy')),
981
- h(
982
- 'ul',
983
- { className: 'srv-rules' },
984
- rules.map((/** @type {any} */ rule) => {
985
- const state = server.policyState?.[rule.id] ?? { configured: null, decision: null, disabled: false }
986
- return h(
987
- 'li',
988
- { className: 'srv-rule', key: rule.id },
989
- h(
990
- 'div',
991
- { className: 'srv-rule-body' },
992
- h('span', { className: 'srv-rule-label' }, rule.label),
993
- h('span', { className: 'srv-rule-desc' }, rule.description),
994
- ),
995
- h(
996
- 'span',
997
- {
998
- className: 'srv-tag',
999
- 'data-tone': state.disabled ? 'warn' : state.decision === 'block' ? undefined : 'success',
1000
- },
1001
- state.disabled ? t('form.offOption') : String(state.decision ?? rule.defaultDecision),
1002
- ),
1003
- )
1004
- }),
1005
- ),
1006
- ),
1007
- h(CustomRulesSection, { server, t, onRefresh }),
1008
- h(
1009
- 'section',
1010
- { className: 'srv-card' },
1011
- h('h3', { className: 'srv-card-title' }, t('panel.sectionAudit')),
1012
- audit.phase === 'loading' ? h('p', { className: 'srv-hint' }, t('panel.auditLoading')) : null,
1013
- audit.phase === 'error'
1014
- ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(audit.error) }))
1015
- : null,
1016
- audit.phase === 'ready' && audit.records.length === 0
1017
- ? h('p', { className: 'srv-hint' }, t('panel.auditEmpty'))
1018
- : null,
1019
- audit.phase === 'ready' && audit.records.length > 0
1020
- ? h(
1021
- 'ul',
1022
- { className: 'srv-audit' },
1023
- audit.records.map((/** @type {any} */ record, /** @type {number} */ index) =>
1024
- h(
1025
- 'li',
1026
- { key: `${String(record.ts)}-${String(index)}` },
1027
- h('span', { className: 'srv-audit-time' }, formatTime(record.ts)),
1028
- h('span', { className: 'srv-audit-decision' }, String(record.event ?? record.decision ?? '')),
1029
- h(
1030
- 'span',
1031
- { className: 'srv-audit-text', title: String(record.command ?? record.detail ?? '') },
1032
- String(record.command ?? record.detail ?? ''),
1033
- ),
1034
- ),
1035
- ),
1036
- )
1037
- : null,
1038
- ),
1039
- h(
1040
- 'div',
1041
- { className: 'srv-danger-zone' },
1042
- h('span', { className: 'srv-hint' }, t('panel.deleteHint')),
1043
- h(Button, { variant: 'outline', danger: true, disabled: deleting, onClick: () => void onDelete() }, deleting ? t('pending.busy') : t('panel.delete')),
1044
- ),
1045
- )
1046
- }
1047
-
1048
- /**
1049
- * Whether a path is absolute on *this* machine.
1050
- *
1051
- * `auth.privateKeyPath` names a local file the Host reads, so on Windows a
1052
- * drive-letter (`C:\Users\me\.ssh\id_ed25519`) or UNC (`\\server\share\key`)
1053
- * path is absolute. The panel used to demand a leading `/` here, which made
1054
- * key auth impossible to submit on Windows.
1055
- * @param {string} path
1056
- * @returns {boolean}
1057
- */
1058
- function isLocalAbsolutePath(path) {
1059
- if (path.startsWith('/')) return true
1060
- if (/^[A-Za-z]:[\\/]/.test(path)) return true
1061
- return path.startsWith('\\\\')
1062
- }
1063
-
1064
- /**
1065
- * The create/edit form (§8.4). Block rules expose only confirm/block, and
1066
- * "off" is queued rather than applied (§8.3).
1067
- * @param {{server: any|null, rules: any[], hostKeyModes: string[], t: any, onCancel: () => void, onSaved: (message: string|null) => void}} props
1068
- */
1069
- function ServerForm({ server, rules, hostKeyModes, t, onCancel, onSaved }) {
1070
- const editing = server !== null
1071
- const [name, setName] = React.useState(editing ? server.name : '')
1072
- const [host, setHost] = React.useState(editing ? server.host : '')
1073
- const [port, setPort] = React.useState(editing ? String(server.port) : '22')
1074
- const [username, setUsername] = React.useState(editing ? server.username : '')
1075
- const [authKind, setAuthKind] = React.useState(editing ? server.auth.kind : 'key')
1076
- const [privateKeyPath, setPrivateKeyPath] = React.useState(editing && server.auth.kind === 'key' ? server.auth.privateKeyPath : '')
1077
- const [password, setPassword] = React.useState('')
1078
- const [hostKeyMode, setHostKeyMode] = React.useState(editing ? server.hostKeyMode : 'accept-new')
1079
- const [defaultWorkDir, setDefaultWorkDir] = React.useState(editing && server.defaultWorkDir !== null ? server.defaultWorkDir : '')
1080
- const [policy, setPolicy] = React.useState(() => {
1081
- /** @type {Record<string, string>} */
1082
- const initial = {}
1083
- for (const rule of rules) {
1084
- const state = editing ? server.policyState?.[rule.id] : null
1085
- initial[rule.id] = state === undefined || state === null ? rule.defaultDecision : state.disabled ? 'off' : String(state.decision)
1086
- }
1087
- return initial
1088
- })
1089
- const [busy, setBusy] = React.useState(false)
1090
- const [error, setError] = React.useState(/** @type {string|null} */ (null))
1091
- const [note, setNote] = React.useState(/** @type {string|null} */ (null))
1092
-
1093
- /** @param {string} ruleId @param {string} value */
1094
- const setRule = (ruleId, value) =>
1095
- setPolicy((/** @type {Record<string, string>} */ current) => ({ ...current, [ruleId]: value }))
1096
-
1097
- /** Validate one client-side rule before touching the network (§8.4 validations). */
1098
- const validate = () => {
1099
- if (name.trim() === '') return t('form.errorName')
1100
- if (host.trim() === '') return t('form.errorHost')
1101
- if (username.trim() === '') return t('form.errorUsername')
1102
- const parsed = Number(port)
1103
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return t('form.portRange')
1104
- if (authKind === 'key' && privateKeyPath.trim() === '') return t('form.errorKeyPath')
1105
- // The key file lives on *this* machine, so a Windows drive-letter or UNC
1106
- // path is as absolute as a POSIX one (the Host accepts both).
1107
- if (authKind === 'key' && !isLocalAbsolutePath(privateKeyPath.trim())) return t('form.absolutePath')
1108
- if (authKind === 'password' && !editing && password === '') return t('form.errorPassword')
1109
- // The working directory is on the *remote* host: POSIX only.
1110
- if (defaultWorkDir.trim() !== '' && !defaultWorkDir.trim().startsWith('/')) return t('form.absolutePath')
1111
- return null
1112
- }
1113
-
1114
- /** @param {any} event */
1115
- const submit = async (event) => {
1116
- event.preventDefault()
1117
- const problem = validate()
1118
- if (problem !== null) {
1119
- setError(problem)
1120
- return
1121
- }
1122
- setBusy(true)
1123
- setError(null)
1124
- setNote(null)
1125
- try {
1126
- /** @type {Record<string, unknown>} */
1127
- const body = {
1128
- name: name.trim(),
1129
- host: host.trim(),
1130
- port: Number(port),
1131
- username: username.trim(),
1132
- auth: authKind === 'key' ? { kind: 'key', privateKeyPath: privateKeyPath.trim() } : { kind: authKind },
1133
- hostKeyMode,
1134
- defaultWorkDir: defaultWorkDir.trim() === '' ? null : defaultWorkDir.trim(),
1135
- }
1136
- if (authKind === 'password' && password !== '') body.password = password
1137
-
1138
- // Only rules that leave their built-in default are stored, so the
1139
- // record stays readable; "off" and re-enabling have their own fields
1140
- // because both change the *existing* rule set.
1141
- /** @type {Record<string, string>} */
1142
- const storedPolicy = {}
1143
- /** @type {string[]} */
1144
- const disableRules = []
1145
- /** @type {string[]} */
1146
- const enableRules = []
1147
- for (const rule of rules) {
1148
- const value = policy[rule.id]
1149
- if (value === undefined) continue
1150
- if (value === 'off') {
1151
- disableRules.push(rule.id)
1152
- continue
1153
- }
1154
- if (value === rule.defaultDecision) continue
1155
- storedPolicy[rule.id] = value
1156
- }
1157
- body.policy = storedPolicy
1158
-
1159
- if (editing) {
1160
- for (const rule of rules) {
1161
- const value = policy[rule.id]
1162
- const wasDisabled = server.policyState?.[rule.id]?.disabled === true
1163
- if (wasDisabled && value !== undefined && value !== 'off') enableRules.push(rule.id)
1164
- }
1165
- if (disableRules.length > 0) body.disableRules = disableRules
1166
- if (enableRules.length > 0) body.enableRules = enableRules
1167
- const answer = await api(`/servers/${encodeURIComponent(server.id)}`, { method: 'PUT', body })
1168
- if (answer.pending !== undefined && answer.pending.length > 0) {
1169
- setNote(t('form.pendingNote'))
1170
- setBusy(false)
1171
- onSaved(t('form.pendingNote'))
1172
- return
1173
- }
1174
- } else {
1175
- if (disableRules.length > 0) body.disableRules = disableRules
1176
- const answer = await api('/servers', { method: 'POST', body })
1177
- if (answer.pending !== undefined && answer.pending.length > 0) {
1178
- onSaved(t('form.pendingNote'))
1179
- return
1180
- }
1181
- }
1182
- onSaved(null)
1183
- } catch (error) {
1184
- setError(t('error.generic', { message: messageOf(error) }))
1185
- } finally {
1186
- setBusy(false)
1187
- }
1188
- }
1189
-
1190
- return h(
1191
- 'form',
1192
- { className: 'srv-form', onSubmit: submit },
1193
- h('h2', { className: 'srv-card-title' }, editing ? t('form.edit') : t('form.new')),
1194
- h(
1195
- 'div',
1196
- { className: 'srv-field' },
1197
- h('label', { htmlFor: 'srv-name' }, t('field.name')),
1198
- h('input', {
1199
- id: 'srv-name',
1200
- className: 'srv-input',
1201
- value: name,
1202
- onChange: (/** @type {any} */ event) => setName(event.target.value),
1203
- required: true,
1204
- }),
1205
- ),
1206
- h(
1207
- 'div',
1208
- { className: 'srv-field-row' },
1209
- h(
1210
- 'div',
1211
- { className: 'srv-field' },
1212
- h('label', { htmlFor: 'srv-host' }, t('field.host')),
1213
- h('input', {
1214
- id: 'srv-host',
1215
- className: 'srv-input',
1216
- value: host,
1217
- onChange: (/** @type {any} */ event) => setHost(event.target.value),
1218
- required: true,
1219
- }),
1220
- ),
1221
- h(
1222
- 'div',
1223
- { className: 'srv-field' },
1224
- h('label', { htmlFor: 'srv-port' }, t('field.port')),
1225
- h('input', {
1226
- id: 'srv-port',
1227
- className: 'srv-input',
1228
- type: 'number',
1229
- min: 1,
1230
- max: 65535,
1231
- value: port,
1232
- onChange: (/** @type {any} */ event) => setPort(event.target.value),
1233
- }),
1234
- ),
1235
- ),
1236
- h(
1237
- 'div',
1238
- { className: 'srv-field' },
1239
- h('label', { htmlFor: 'srv-username' }, t('field.username')),
1240
- h('input', {
1241
- id: 'srv-username',
1242
- className: 'srv-input',
1243
- value: username,
1244
- onChange: (/** @type {any} */ event) => setUsername(event.target.value),
1245
- required: true,
1246
- }),
1247
- ),
1248
- h(
1249
- 'div',
1250
- { className: 'srv-field' },
1251
- h('span', null, t('field.auth')),
1252
- h(
1253
- 'div',
1254
- { className: 'srv-seg', role: 'group', 'aria-label': t('field.auth') },
1255
- ['key', 'password', 'agent'].map((kind) =>
1256
- h(
1257
- 'button',
1258
- {
1259
- key: kind,
1260
- type: 'button',
1261
- 'aria-pressed': authKind === kind ? 'true' : 'false',
1262
- onClick: () => setAuthKind(kind),
1263
- },
1264
- t(`auth.${kind}`),
1265
- ),
1266
- ),
1267
- ),
1268
- ),
1269
- authKind === 'key'
1270
- ? h(
1271
- 'div',
1272
- { className: 'srv-field' },
1273
- h('label', { htmlFor: 'srv-key' }, t('field.privateKeyPath')),
1274
- h('input', {
1275
- id: 'srv-key',
1276
- className: 'srv-input srv-mono',
1277
- value: privateKeyPath,
1278
- placeholder: '/root/.ssh/id_ed25519',
1279
- onChange: (/** @type {any} */ event) => setPrivateKeyPath(event.target.value),
1280
- }),
1281
- )
1282
- : null,
1283
- authKind === 'password'
1284
- ? h(
1285
- 'div',
1286
- { className: 'srv-field' },
1287
- h('label', { htmlFor: 'srv-password' }, t('field.password')),
1288
- h('input', {
1289
- id: 'srv-password',
1290
- className: 'srv-input',
1291
- type: 'password',
1292
- value: password,
1293
- autoComplete: 'new-password',
1294
- placeholder: editing && server.passwordConfigured === true ? t('auth.passwordConfigured') : '',
1295
- onChange: (/** @type {any} */ event) => setPassword(event.target.value),
1296
- }),
1297
- h('span', { className: 'srv-hint' }, editing ? t('form.passwordKeep') : t('form.passwordNew')),
1298
- )
1299
- : null,
1300
- h(
1301
- 'div',
1302
- { className: 'srv-field' },
1303
- h('label', { htmlFor: 'srv-hostkey' }, t('field.hostKeyMode')),
1304
- h(
1305
- 'select',
1306
- {
1307
- id: 'srv-hostkey',
1308
- className: 'srv-select',
1309
- value: hostKeyMode,
1310
- onChange: (/** @type {any} */ event) => setHostKeyMode(event.target.value),
1311
- },
1312
- hostKeyModes.map((mode) => h('option', { key: mode, value: mode }, t(`hostKey.${mode}`))),
1313
- ),
1314
- hostKeyMode === 'off' && (editing ? server.hostKeyMode !== 'off' : false)
1315
- ? h('span', { className: 'srv-hint' }, t('form.offQueued'))
1316
- : null,
1317
- ),
1318
- h(
1319
- 'div',
1320
- { className: 'srv-field' },
1321
- h('label', { htmlFor: 'srv-workdir' }, t('field.defaultWorkDir')),
1322
- h('input', {
1323
- id: 'srv-workdir',
1324
- className: 'srv-input srv-mono',
1325
- value: defaultWorkDir,
1326
- placeholder: '/srv/app',
1327
- onChange: (/** @type {any} */ event) => setDefaultWorkDir(event.target.value),
1328
- }),
1329
- ),
1330
- h(
1331
- 'div',
1332
- { className: 'srv-field' },
1333
- h('span', null, t('field.policy')),
1334
- h('p', { className: 'srv-hint' }, t('form.policyHint')),
1335
- h(
1336
- 'ul',
1337
- { className: 'srv-rules' },
1338
- rules.map((/** @type {any} */ rule) => {
1339
- const value = policy[rule.id] ?? rule.defaultDecision
1340
- // "停用" is always offered and always queued (§8.3): a new server
1341
- // is created with the rule enabled and a disable request filed.
1342
- const options = [...rule.allowedDecisions, 'off']
1343
- return h(
1344
- 'li',
1345
- { className: 'srv-rule', key: rule.id },
1346
- h(
1347
- 'div',
1348
- { className: 'srv-rule-body' },
1349
- h('span', { className: 'srv-rule-label' }, rule.label),
1350
- h('span', { className: 'srv-rule-desc' }, rule.description),
1351
- ),
1352
- h(
1353
- 'select',
1354
- {
1355
- className: 'srv-select srv-rule-select',
1356
- 'aria-label': rule.label,
1357
- value,
1358
- onChange: (/** @type {any} */ event) => setRule(rule.id, event.target.value),
1359
- },
1360
- options.map((decision) =>
1361
- h('option', { key: decision, value: decision }, decision === 'off' ? t('form.offOption') : decision),
1362
- ),
1363
- ),
1364
- )
1365
- }),
1366
- ),
1367
- ),
1368
- error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1369
- note === null ? null : h('p', { className: 'srv-note' }, note),
1370
- h(
1371
- 'div',
1372
- { className: 'srv-head-actions' },
1373
- h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
1374
- h(Button, { onClick: onCancel, disabled: busy }, t('form.cancel')),
1375
- ),
1376
- )
1377
- }
1378
-
1379
- /**
1380
- * The main panel: list ⇄ detail ⇄ form (§8.2). One server's data lives in
1381
- * the list payload, so a mutation is followed by a reload rather than by
1382
- * patching several copies.
1383
- * @param {{t: any}} props
1384
- */
1385
- function ServerPanel({ t }) {
1386
- const { state, reload } = useServers()
1387
- const [view, setView] = React.useState({ kind: 'list' })
1388
- const [testing, setTesting] = React.useState(/** @type {string|null} */ (null))
1389
- const [toggling, setToggling] = React.useState(/** @type {string|null} */ (null))
1390
- const [deleting, setDeleting] = React.useState(false)
1391
- const [notice, setNotice] = React.useState(/** @type {string|null} */ (null))
1392
- const [status, setStatus] = React.useState(/** @type {string|null} */ (null))
1393
-
1394
- const data = state.phase === 'ready' ? state.data : null
1395
- const servers = data === null ? [] : data.servers
1396
- const rules = data === null ? [] : data.rules
1397
- const hostKeyModes = data === null ? ['accept-new', 'verify', 'off'] : data.hostKeyModes
1398
-
1399
- /** @param {string} id */
1400
- const test = async (id) => {
1401
- setTesting(id)
1402
- setNotice(null)
1403
- setStatus(null)
1404
- try {
1405
- const answer = await api('/test', { method: 'POST', body: { id } })
1406
- setStatus(
1407
- t('status.connected', {
1408
- platform: answer.platform,
1409
- shell: answer.shell,
1410
- sudo: answer.sudo,
1411
- ms: answer.durationMs,
1412
- }),
1413
- )
1414
- await reload()
1415
- } catch (error) {
1416
- setStatus(t('error.generic', { message: messageOf(error) }))
1417
- } finally {
1418
- setTesting(null)
1419
- }
1420
- }
1421
-
1422
- /** @param {string} id */
1423
- const remove = async (id) => {
1424
- setDeleting(true)
1425
- setNotice(null)
1426
- try {
1427
- await api(`/servers/${encodeURIComponent(id)}`, { method: 'DELETE' })
1428
- setNotice(t('panel.deleteQueued'))
1429
- setView({ kind: 'list' })
1430
- await reload()
1431
- } catch (error) {
1432
- setNotice(t('error.generic', { message: messageOf(error) }))
1433
- } finally {
1434
- setDeleting(false)
1435
- }
1436
- }
1437
-
1438
- /**
1439
- * Flip the enable switch. It applies immediately — no pending queue — so
1440
- * the only failure modes are "connected" (409) and a network error.
1441
- * @param {any} server
1442
- */
1443
- const toggle = async (server) => {
1444
- setToggling(server.id)
1445
- setNotice(null)
1446
- try {
1447
- await api(`/servers/${encodeURIComponent(server.id)}`, {
1448
- method: 'PUT',
1449
- body: { enabled: server.enabled === false },
1450
- })
1451
- await reload()
1452
- } catch (error) {
1453
- setNotice(t('error.generic', { message: messageOf(error) }))
1454
- } finally {
1455
- setToggling(null)
1456
- }
1457
- }
1458
-
1459
- /** @param {string|null} message */
1460
- const saved = async (message) => {
1461
- setNotice(message)
1462
- setView({ kind: 'list' })
1463
- await reload()
1464
- }
1465
-
1466
- const current = view.kind === 'detail' ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1467
- const edited = view.kind === 'form' && view.id !== undefined ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1468
-
1469
- return h(
1470
- 'div',
1471
- { className: 'srv-page' },
1472
- h(
1473
- 'div',
1474
- { className: 'srv-column' },
1475
- view.kind === 'list'
1476
- ? h(ServerList, {
1477
- state,
1478
- t,
1479
- reload,
1480
- onAdd: () => {
1481
- setNotice(null)
1482
- setView({ kind: 'form' })
1483
- },
1484
- onOpen: (/** @type {string} */ id) => {
1485
- setNotice(null)
1486
- setView({ kind: 'detail', id })
1487
- },
1488
- onEdit: (/** @type {string} */ id) => {
1489
- setNotice(null)
1490
- setView({ kind: 'form', id })
1491
- },
1492
- onTest: test,
1493
- onToggle: toggle,
1494
- testing,
1495
- toggling,
1496
- notice: notice ?? status,
1497
- })
1498
- : null,
1499
- view.kind === 'detail' && current !== null
1500
- ? h(ServerDetail, {
1501
- server: current,
1502
- rules,
1503
- t,
1504
- onBack: () => setView({ kind: 'list' }),
1505
- onEdit: () => setView({ kind: 'form', id: current.id }),
1506
- onDelete: () => remove(current.id),
1507
- deleting,
1508
- notice: status,
1509
- onRefresh: reload,
1510
- })
1511
- : null,
1512
- view.kind === 'detail' && current === null
1513
- ? h(
1514
- 'div',
1515
- { className: 'srv-empty' },
1516
- h('p', null, t('panel.notFound')),
1517
- h(Button, { variant: 'outline', onClick: () => setView({ kind: 'list' }) }, t('panel.back')),
1518
- )
1519
- : null,
1520
- view.kind === 'form'
1521
- ? h(ServerForm, {
1522
- server: edited,
1523
- rules,
1524
- hostKeyModes,
1525
- t,
1526
- onCancel: () => setView({ kind: 'list' }),
1527
- onSaved: saved,
1528
- })
1529
- : null,
1530
- ),
1531
- )
1532
- }
1533
-
1534
- /**
1535
- * One §8.3 pending row.
1536
- * @param {{record: any, t: any, onConfirm: (id: string) => Promise<void>, onCancel: (id: string) => Promise<void>, busy: string|null}} props
1537
- */
1538
- function PendingRow({ record, t, onConfirm, onCancel, busy }) {
1539
- const working = busy === record.id
1540
- const name = typeof record.serverName === 'string' ? record.serverName : record.serverId
1541
- const title =
1542
- record.kind === 'delete-server'
1543
- ? t('pending.deleteServer', { name })
1544
- : record.kind === 'disable-hostkey'
1545
- ? t('pending.disableHostkey', { name })
1546
- : t('pending.disableRule', { rule: String(record.ruleId ?? '?'), name })
1547
- return h(
1548
- 'li',
1549
- null,
1550
- h(
1551
- 'div',
1552
- { className: 'srv-pending-main' },
1553
- h('span', { className: 'srv-pending-title' }, title),
1554
- h(
1555
- 'span',
1556
- { className: 'srv-pending-meta' },
1557
- `${t('pending.requestedAt', { time: formatTime(record.requestedAt) })} · ${t('pending.expiresAt', {
1558
- time: formatTime(record.expiresAt),
1559
- })}${record.target?.present === false ? ` · ${t('pending.targetMissing')}` : ''}`,
1560
- ),
1561
- ),
1562
- h(
1563
- 'div',
1564
- { className: 'srv-row-actions' },
1565
- h(Button, { size: 'sm', variant: 'primary', disabled: working, onClick: () => void onConfirm(record.id) }, working ? t('pending.busy') : t('pending.confirm')),
1566
- h(Button, { size: 'sm', variant: 'outline', disabled: working, onClick: () => void onCancel(record.id) }, t('pending.cancel')),
1567
- ),
1568
- )
1569
- }
1570
-
1571
- /**
1572
- * The `settings.section` page: global settings (read-only, they come from the
1573
- * profile) and the pending queue with its red count badge (§8.3).
1574
- * @param {{t: any}} props
1575
- */
1576
- function SettingsPage({ t }) {
1577
- const { state, reload } = useServers()
1578
- const [pending, setPending] = React.useState({ phase: 'loading' })
1579
- const [busy, setBusy] = React.useState(/** @type {string|null} */ (null))
1580
- const [error, setError] = React.useState(/** @type {string|null} */ (null))
1581
-
1582
- const reloadPending = React.useCallback(async () => {
1583
- try {
1584
- const answer = await api('/pending')
1585
- setPending({ phase: 'ready', records: answer.pending })
1586
- } catch (failure) {
1587
- setPending({ phase: 'error', error: failure })
1588
- }
1589
- }, [])
1590
-
1591
- React.useEffect(() => {
1592
- void reloadPending()
1593
- }, [reloadPending])
1594
-
1595
- /** @param {'confirm'|'cancel'} action @param {string} id */
1596
- const act = async (action, id) => {
1597
- setBusy(id)
1598
- setError(null)
1599
- try {
1600
- await api(`/pending/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
1601
- await Promise.all([reloadPending(), reload()])
1602
- } catch (failure) {
1603
- setError(t('error.generic', { message: messageOf(failure) }))
1604
- } finally {
1605
- setBusy(null)
1606
- }
1607
- }
1608
-
1609
- const records = pending.phase === 'ready' ? pending.records : []
1610
- const config = state.phase === 'ready' ? state.data.config : null
1611
- return h(
1612
- 'div',
1613
- { className: 'srv-page' },
1614
- h(
1615
- 'div',
1616
- { className: 'srv-column' },
1617
- h('h2', null, t('settings.title')),
1618
- h('p', { className: 'srv-hint' }, t('settings.description')),
1619
- h(
1620
- 'section',
1621
- { className: 'srv-card' },
1622
- h(
1623
- 'div',
1624
- { className: 'srv-card-head' },
1625
- h('h3', { className: 'srv-card-title' }, t('settings.pending')),
1626
- h('span', { className: 'srv-badge', hidden: records.length === 0 }, String(records.length)),
1627
- ),
1628
- h('p', { className: 'srv-hint' }, t('settings.pendingHint')),
1629
- error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1630
- records.length === 0 ? h('p', { className: 'srv-hint' }, t('settings.pendingEmpty')) : null,
1631
- records.length > 0
1632
- ? h(
1633
- 'ul',
1634
- { className: 'srv-pending' },
1635
- records.map((/** @type {any} */ record) =>
1636
- h(PendingRow, {
1637
- key: record.id,
1638
- record,
1639
- t,
1640
- busy,
1641
- onConfirm: (/** @type {string} */ id) => act('confirm', id),
1642
- onCancel: (/** @type {string} */ id) => act('cancel', id),
1643
- }),
1644
- ),
1645
- )
1646
- : null,
1647
- ),
1648
- h(
1649
- 'section',
1650
- { className: 'srv-card' },
1651
- h('h3', { className: 'srv-card-title' }, t('settings.global')),
1652
- h('p', { className: 'srv-hint' }, t('settings.globalHint')),
1653
- config === null
1654
- ? state.phase === 'error'
1655
- ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) }))
1656
- : h('p', { className: 'srv-hint' }, t('panel.loading'))
1657
- : h(
1658
- 'dl',
1659
- { className: 'srv-kv' },
1660
- Object.entries(config).map(([key, value]) =>
1661
- h(F, { key }, h('dt', { className: 'srv-mono' }, key), h('dd', { className: 'srv-mono' }, String(value))),
1662
- ),
1663
- ),
1664
- ),
1665
- ),
1666
- )
1667
- }
1668
-
1669
- // ---------------------------------------------------------------- mount --
1670
-
1671
- /** Services this bundle needs; `slots` carries the three mount points, `locale` the copy. */
1672
- const inject = ['slots', 'locale']
1673
-
1674
- /**
1675
- * Register the dictionaries and the three §8.1 contributions.
1676
- * @param {any} ctx - the browser plugin context.
1677
- */
1678
- function apply(ctx) {
1679
- installStyles()
1680
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'server-connector: dictionaries')
1681
-
1682
- ctx.slots.inject('sidebar.panellist', () =>
1683
- ctx.slots.register(
1684
- {
1685
- name: 'sidebar.panellist',
1686
- id: PANEL_ID,
1687
- order: 10,
1688
- label: () => ctx.locale.bind(NS)('panel.label'),
1689
- locale: NS,
1690
- },
1691
- ServerIcon,
1692
- ),
1693
- )
1694
-
1695
- ctx.slots.inject('main', () =>
1696
- ctx.slots.register(
1697
- {
1698
- name: 'main',
1699
- key: PANEL_ID,
1700
- locale: NS,
1701
- inject: () => ({}),
1702
- },
1703
- ServerPanel,
1704
- ),
1705
- )
1706
-
1707
- ctx.slots.inject('settings.section', () =>
1708
- ctx.slots.register(
1709
- {
1710
- name: 'settings.section',
1711
- id: PANEL_ID,
1712
- order: 25,
1713
- label: () => ctx.locale.bind(NS)('settings.nav'),
1714
- locale: NS,
1715
- inject: () => ({}),
1716
- },
1717
- SettingsPage,
1718
- ),
1719
- )
1720
- }
1721
-
1722
- exports.NS = NS
1723
- exports.PANEL_ID = PANEL_ID
1724
- exports.apply = apply
1725
- exports.inject = inject
1726
- return module.exports
1727
- },
1728
- })
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.deleteConfirmTitle': '确认删除这台服务器?',
72
+ 'panel.deleteConfirmBody': '删除后它的密码、主机指纹与审计记录一并移除,且无法撤销。服务器在你点击「确认删除」之前仍然可用。',
73
+ 'panel.deleteConfirmCheck': '我已确认这台服务器可以删除',
74
+ 'panel.deleteConfirm': '确认删除',
75
+ 'panel.deleteCancel': '保留服务器',
76
+ 'panel.deleteDone': '「{name}」已删除。',
77
+ 'panel.sectionConnection': '连接',
78
+ 'panel.sectionPolicy': '高危策略',
79
+ 'panel.sectionAudit': `最近 ${AUDIT_LIMIT} 条审计`,
80
+ 'panel.auditEmpty': '没有审计记录。',
81
+ 'panel.auditLoading': '读取审计…',
82
+ 'panel.notFound': '这台服务器已不存在。',
83
+ 'panel.lastConnected': '上次连接 {time}',
84
+ 'panel.neverConnected': '从未连接',
85
+ 'status.connected': '连接成功:{platform}({shell},sudo {sudo}),耗时 {ms} ms',
86
+ 'field.name': '名称',
87
+ 'field.host': '主机',
88
+ 'field.port': '端口',
89
+ 'field.username': '用户名',
90
+ 'field.auth': '认证方式',
91
+ 'field.privateKeyPath': '私钥路径',
92
+ 'field.password': '密码',
93
+ 'field.hostKeyMode': '主机密钥策略',
94
+ 'field.defaultWorkDir': '默认工作目录',
95
+ 'field.lastConnectedAt': '最近连接',
96
+ 'field.policy': '高危策略',
97
+ 'auth.key': '私钥',
98
+ 'auth.password': '密码',
99
+ 'auth.agent': 'SSH agent',
100
+ 'auth.passwordConfigured': '已设置',
101
+ 'hostKey.accept-new': 'accept-new(首次接受并记住)',
102
+ 'hostKey.verify': 'verify(严格校验)',
103
+ 'hostKey.off': 'off(关闭校验)',
104
+ 'form.new': '新增服务器',
105
+ 'form.edit': '编辑服务器',
106
+ 'form.save': '保存',
107
+ 'form.saving': '保存中…',
108
+ 'form.cancel': '取消',
109
+ 'form.portRange': '端口必须是 1–65535 的整数',
110
+ 'form.absolutePath': '必须是绝对路径',
111
+ 'form.passwordKeep': '留空则保持当前密码',
112
+ 'form.passwordNew': '密码只写不回显',
113
+ 'form.policyHint': '内置 block 规则只能降为 confirm,不能直接改为 allow;选择「停用」会先写入待确认区。',
114
+ 'form.offOption': '停用',
115
+ 'form.offQueued': '「停用」会写入待确认区,确认后该规则才失效。',
116
+ 'form.pendingNote': '危险改动已写入待确认区,确认后才生效。',
117
+ 'form.errorName': '名称不能为空',
118
+ 'form.errorHost': '主机不能为空',
119
+ 'form.errorUsername': '用户名不能为空',
120
+ 'form.errorKeyPath': '私钥路径不能为空',
121
+ 'form.errorPassword': '密码不能为空',
122
+ 'settings.nav': '服务器连接器',
123
+ 'settings.title': '服务器连接器',
124
+ 'settings.description': '面板、连接池与危险操作的两段式确认。',
125
+ 'settings.global': '全局设置',
126
+ 'settings.globalHint': '保存后立即生效,不需要重启插件;正在使用的连接不会被断开。',
127
+ 'settings.globalReadonly': '此处显示的是当前生效值,但本会话没能拿到可编辑的配置表单,因此只能查看。',
128
+ 'settings.globalLoading': '读取可编辑配置…',
129
+ 'settings.save': '保存',
130
+ 'settings.saving': '保存中…',
131
+ 'settings.discard': '还原',
132
+ 'settings.saved': '已保存,新值立即生效。',
133
+ 'settings.conflict': '配置已被别处修改,页面已重新读取;请确认后再次保存。',
134
+ 'settings.rejected': '保存被拒绝,配置未改变。',
135
+ 'settings.dirty': '有未保存的修改。',
136
+ 'settings.invalid': '「{field}」的值不合法:必须是不小于下限的整数。',
137
+ 'settings.pending': '待确认操作',
138
+ 'settings.pendingEmpty': '没有待确认的操作。',
139
+ 'settings.pendingHint': '删除服务器、关闭主机密钥校验、停用高危规则都会先进入这里;确认前目标保持原样。',
140
+ 'rules.section': '自定义规则',
141
+ 'rules.hint': '只作用于这台服务器;默认档只能是 confirm 或 block。保存前请先试跑,确认正则能命中真实命令。',
142
+ 'rules.empty': '还没有自定义规则。',
143
+ 'rules.add': '新增规则',
144
+ 'rules.delete': '删除',
145
+ 'rules.label': '标签',
146
+ 'rules.pattern': '正则(区分大小写的部分用 (?i) 关闭)',
147
+ 'rules.decision': '默认档',
148
+ 'rules.description': '说明',
149
+ 'rules.samples': '样例命令(每行一条)',
150
+ 'rules.samplesHint': '至少一条样例必须能命中,否则规则会因「永不触发」被拒绝。',
151
+ 'rules.preview': '试跑',
152
+ 'rules.wouldMatch': '是否命中',
153
+ 'rules.needSample': '试跑前请至少填一条样例命令。',
154
+ 'pending.deleteServer': '删除服务器「{name}」',
155
+ 'pending.disableHostkey': '关闭主机密钥校验「{name}」',
156
+ 'pending.disableRule': '停用高危规则「{rule}」({name})',
157
+ 'pending.requestedAt': '申请于 {time}',
158
+ 'pending.expiresAt': '{time} 过期',
159
+ 'pending.confirm': '确认执行',
160
+ 'pending.cancel': '取消',
161
+ 'pending.busy': '执行中…',
162
+ 'pending.targetMissing': '目标已不存在',
163
+ 'error.generic': '操作失败:{message}',
164
+ 'error.load': '读取失败:{message}',
165
+ }
166
+
167
+ /** English dictionary, key-identical to the Chinese source of truth. */
168
+ const en = {
169
+ 'panel.label': 'Servers',
170
+ 'panel.title': 'Servers',
171
+ 'panel.add': 'Add',
172
+ 'panel.loading': 'Reading servers…',
173
+ 'panel.retry': 'Retry',
174
+ 'panel.empty': 'No server configured yet.',
175
+ 'panel.emptyHint': 'Add one and the model can run commands on this machine through the srv_* tools.',
176
+ 'panel.connected': 'Connected',
177
+ 'panel.disconnected': 'Not connected',
178
+ 'panel.test': 'Test',
179
+ 'panel.testing': 'Testing…',
180
+ 'panel.edit': 'Edit',
181
+ 'panel.enable': 'Enable',
182
+ 'panel.disable': 'Disable',
183
+ 'panel.disabled': 'Disabled',
184
+ 'panel.disconnectFirst': 'Disconnect this server before disabling it',
185
+ 'panel.disabledNote': 'Disabled: srv_list no longer returns it and every srv_* call is rejected.',
186
+ 'panel.back': 'Back to list',
187
+ 'panel.delete': 'Delete',
188
+ 'panel.deleteHint': 'Deleting first queues a request; the server is removed only after you confirm it.',
189
+ 'panel.deleteQueued': 'The delete request is queued and the server still works; confirm it in Settings.',
190
+ 'panel.deleteConfirmTitle': 'Delete this server?',
191
+ 'panel.deleteConfirmBody': 'Its password, host fingerprint and audit trail go with it, and this cannot be undone. The server stays usable until you press “Delete permanently”.',
192
+ 'panel.deleteConfirmCheck': 'I understand this server can go',
193
+ 'panel.deleteConfirm': 'Delete permanently',
194
+ 'panel.deleteCancel': 'Keep the server',
195
+ 'panel.deleteDone': '“{name}” was deleted.',
196
+ 'panel.sectionConnection': 'Connection',
197
+ 'panel.sectionPolicy': 'High-risk policy',
198
+ 'panel.sectionAudit': `Last ${AUDIT_LIMIT} audit records`,
199
+ 'panel.auditEmpty': 'No audit records.',
200
+ 'panel.auditLoading': 'Reading the audit log…',
201
+ 'panel.notFound': 'This server no longer exists.',
202
+ 'panel.lastConnected': 'Last connected {time}',
203
+ 'panel.neverConnected': 'Never connected',
204
+ 'status.connected': 'Connected: {platform} ({shell}, sudo {sudo}) in {ms} ms',
205
+ 'field.name': 'Name',
206
+ 'field.host': 'Host',
207
+ 'field.port': 'Port',
208
+ 'field.username': 'Username',
209
+ 'field.auth': 'Authentication',
210
+ 'field.privateKeyPath': 'Private key path',
211
+ 'field.password': 'Password',
212
+ 'field.hostKeyMode': 'Host-key policy',
213
+ 'field.defaultWorkDir': 'Default working directory',
214
+ 'field.lastConnectedAt': 'Last connected',
215
+ 'field.policy': 'High-risk policy',
216
+ 'auth.key': 'Private key',
217
+ 'auth.password': 'Password',
218
+ 'auth.agent': 'SSH agent',
219
+ 'auth.passwordConfigured': 'configured',
220
+ 'hostKey.accept-new': 'accept-new (accept and remember first)',
221
+ 'hostKey.verify': 'verify (strict)',
222
+ 'hostKey.off': 'off (verification off)',
223
+ 'form.new': 'Add server',
224
+ 'form.edit': 'Edit server',
225
+ 'form.save': 'Save',
226
+ 'form.saving': 'Saving…',
227
+ 'form.cancel': 'Cancel',
228
+ 'form.portRange': 'The port must be an integer from 1 to 65535',
229
+ 'form.absolutePath': 'Must be an absolute path',
230
+ 'form.passwordKeep': 'Leave blank to keep the current password',
231
+ 'form.passwordNew': 'The password is write-only',
232
+ 'form.policyHint': 'A built-in block rule can be lowered to confirm, never straight to allow; choosing “off” queues a pending request.',
233
+ 'form.offOption': 'Off',
234
+ 'form.offQueued': '“Off” is queued; the rule keeps working until you confirm.',
235
+ 'form.pendingNote': 'The dangerous change is queued and takes effect only after you confirm it.',
236
+ 'form.errorName': 'Name is required',
237
+ 'form.errorHost': 'Host is required',
238
+ 'form.errorUsername': 'Username is required',
239
+ 'form.errorKeyPath': 'Private key path is required',
240
+ 'form.errorPassword': 'Password is required',
241
+ 'settings.nav': 'Server Connector',
242
+ 'settings.title': 'Server Connector',
243
+ 'settings.description': 'The panel, the connection pool and two-step confirmation for dangerous operations.',
244
+ 'settings.global': 'Global settings',
245
+ 'settings.globalHint': 'Saved values apply immediately; the plugin is not restarted and live connections stay up.',
246
+ 'settings.globalReadonly': 'These are the values in effect, but this session could not get an editable settings form, so they are read-only.',
247
+ 'settings.globalLoading': 'Reading the editable settings…',
248
+ 'settings.save': 'Save',
249
+ 'settings.saving': 'Saving…',
250
+ 'settings.discard': 'Discard',
251
+ 'settings.saved': 'Saved; the new values apply immediately.',
252
+ 'settings.conflict': 'The configuration changed elsewhere, so the page re-read it. Check the values and save again.',
253
+ 'settings.rejected': 'The save was refused and nothing changed.',
254
+ 'settings.dirty': 'Unsaved changes.',
255
+ 'settings.invalid': '“{field}” is not valid: it must be an integer no smaller than its minimum.',
256
+ 'settings.pending': 'Pending operations',
257
+ 'settings.pendingEmpty': 'Nothing is pending.',
258
+ '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.',
259
+ 'rules.section': 'Custom rules',
260
+ '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.',
261
+ 'rules.empty': 'No custom rules yet.',
262
+ 'rules.add': 'Add rule',
263
+ 'rules.delete': 'Delete',
264
+ 'rules.label': 'Label',
265
+ 'rules.pattern': 'Pattern (regular expression)',
266
+ 'rules.decision': 'Default decision',
267
+ 'rules.description': 'Description',
268
+ 'rules.samples': 'Sample commands (one per line)',
269
+ 'rules.samplesHint': 'At least one sample must match, otherwise the rule is rejected as one that never fires.',
270
+ 'rules.preview': 'Preview',
271
+ 'rules.wouldMatch': 'Matches',
272
+ 'rules.needSample': 'Add at least one sample command before previewing.',
273
+ 'pending.deleteServer': 'Delete server “{name}”',
274
+ 'pending.disableHostkey': 'Turn off host-key verification for “{name}”',
275
+ 'pending.disableRule': 'Disable high-risk rule “{rule}” on “{name}”',
276
+ 'pending.requestedAt': 'Requested {time}',
277
+ 'pending.expiresAt': 'expires {time}',
278
+ 'pending.confirm': 'Confirm',
279
+ 'pending.cancel': 'Cancel',
280
+ 'pending.busy': 'Working…',
281
+ 'pending.targetMissing': 'the target is already gone',
282
+ 'error.generic': 'The operation failed: {message}',
283
+ 'error.load': 'Could not read: {message}',
284
+ }
285
+
286
+ // --------------------------------------------------------------- style --
287
+
288
+ /** One stylesheet, injected once by the module system (§ client styles claim the tag). */
289
+ const CSS = [
290
+ '.srv-page{box-sizing:border-box;height:100%;overflow:auto;padding:24px clamp(16px,3vw,40px) 48px;color:var(--dsw-alias-label-primary);',
291
+ 'font-size:14px;line-height:22px;container-type:inline-size}',
292
+ '.srv-column{max-width:960px;margin:0 auto;display:flex;flex-direction:column;gap:16px}',
293
+ '.srv-head{display:flex;align-items:center;gap:8px 16px;flex-wrap:wrap}',
294
+ '.srv-head h2{margin:0;flex:1 1 auto;min-width:0;font-size:20px;line-height:28px;font-weight:500;',
295
+ 'overflow-wrap:anywhere}',
296
+ '.srv-head-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
297
+ '.srv-btn{display:inline-flex;align-items:center;justify-content:center;gap:4px;height:32px;padding:0 14px;',
298
+ 'border:0;border-radius:16px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;',
299
+ 'font-size:13px;cursor:pointer;white-space:nowrap}',
300
+ '.srv-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
301
+ '.srv-btn:disabled{cursor:not-allowed;opacity:.4}',
302
+ '.srv-btn:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}',
303
+ '.srv-btn-sm{height:26px;padding:0 10px;font-size:12px;border-radius:13px}',
304
+ '.srv-btn-primary{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}',
305
+ '.srv-btn-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}',
306
+ '.srv-btn-outline{border:.5px solid var(--dsw-alias-border-l3)}',
307
+ '.srv-btn-danger{color:var(--dsw-alias-state-error-primary)}',
308
+ '.srv-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}',
309
+ // A destructive *primary* button: the plain danger variant only recolours
310
+ // the text, which would be red-on-black once the primary fill applies.
311
+ '.srv-btn-primary.srv-btn-danger{background:var(--dsw-alias-state-error-primary);',
312
+ 'color:var(--dsw-static-neutral-bluish-00)}',
313
+ '.srv-btn-primary.srv-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 86%,var(--dsw-alias-label-primary))}',
314
+ '.srv-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}',
315
+ '.srv-row{display:flex;align-items:flex-start;gap:4px 12px;flex-wrap:wrap;padding:10px 8px;border-radius:12px;min-width:0}',
316
+ '.srv-row:hover{background:var(--dsw-alias-interactive-bg-hover)}',
317
+ '.srv-row-off{opacity:.55}',
318
+ // Mirrors the Host's own switch (ui-primitives), which inverts with the
319
+ // theme rather than staying monochrome:
320
+ //
321
+ // track thumb
322
+ // light on --dsw-alias-label-primary (#0f1115) --dsw-alias-label-primary-inverted (#fff)
323
+ // dark on --dsw-alias-label-primary (#f9fafb) --dsw-alias-label-primary-inverted (#353638)
324
+ //
325
+ // The thumb MUST use the inverted pair, not a fixed white: a hard-coded
326
+ // `#fff` disappears against the near-white track in dark mode, which reads
327
+ // as one blank bar. `label-primary` / `label-primary-inverted` are the only
328
+ // pair guaranteed to contrast in both themes.
329
+ '.srv-switch{position:relative;flex:0 0 auto;width:36px;height:20px;padding:0;border:none;border-radius:999px;',
330
+ 'background:var(--dsw-alias-border-l4,rgba(0,0,0,.12));cursor:pointer;transition:background .15s ease}',
331
+ '.srv-switch[data-on="true"]{background:var(--dsw-alias-label-primary,#0f1115)}',
332
+ '.srv-switch:disabled{cursor:not-allowed;opacity:.5}',
333
+ '.srv-switch:focus-visible{outline:2px solid var(--dsw-alias-label-primary,#0f1115);outline-offset:2px}',
334
+ '.srv-switch-knob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;',
335
+ 'background:var(--dsw-alias-label-tertiary,#adb2b8);transition:transform .15s ease,background .15s ease}',
336
+ '.srv-switch[data-on="true"] .srv-switch-knob{transform:translateX(16px);',
337
+ 'background:var(--dsw-alias-label-primary-inverted,#fff)}',
338
+ '.srv-row-main{flex:1 1 240px;min-width:0;display:flex;flex-direction:column;gap:2px}',
339
+ '.srv-row-title{display:flex;align-items:center;gap:8px;min-width:0;font-size:14px;line-height:20px;font-weight:500}',
340
+ '.srv-open{padding:0;border:0;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;',
341
+ 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%}',
342
+ '.srv-open:hover{text-decoration:underline}',
343
+ '.srv-row-meta{display:flex;align-items:center;gap:4px 10px;flex-wrap:wrap;font-size:12px;line-height:18px;',
344
+ 'color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
345
+ '.srv-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary)}',
346
+ '.srv-dot[data-on="true"]{background:var(--dsw-alias-state-success-primary)}',
347
+ '.srv-tag{display:inline-flex;align-items:center;border:.5px solid var(--dsw-alias-border-l4);border-radius:999px;',
348
+ 'padding:0 8px;font-size:11px;line-height:17px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}',
349
+ '.srv-tag[data-tone="success"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);',
350
+ 'color:var(--dsw-alias-state-success-primary)}',
351
+ '.srv-tag[data-tone="warn"]{border-color:transparent;background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 12%,transparent);',
352
+ 'color:var(--dsw-alias-state-warn-primary)}',
353
+ '.srv-row-actions{display:flex;align-items:center;gap:4px;flex:none;margin-left:auto}',
354
+ '.srv-empty{margin:0;padding:32px 8px;text-align:center;color:var(--dsw-alias-label-tertiary)}',
355
+ '.srv-empty p{margin:0 0 4px}',
356
+ '.srv-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:.5px solid var(--dsw-alias-border-l3);',
357
+ 'border-radius:16px;min-width:0}',
358
+ '.srv-card-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}',
359
+ '.srv-card-title{margin:0;flex:1;font-size:15px;line-height:22px;font-weight:600}',
360
+ '.srv-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0;font-size:13px;line-height:20px}',
361
+ '.srv-kv dt{color:var(--dsw-alias-label-tertiary)}',
362
+ '.srv-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}',
363
+ '.srv-mono{font-family:var(--ds-font-family-code,Menlo,monospace);font-size:12px}',
364
+ '.srv-form{display:flex;flex-direction:column;gap:16px}',
365
+ '.srv-field{display:flex;flex-direction:column;gap:6px;min-width:0;font-size:13px;line-height:20px}',
366
+ '.srv-field-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,1fr);gap:12px}',
367
+ '.srv-input,.srv-select{box-sizing:border-box;width:100%;min-width:0;height:32px;padding:0 8px;font:inherit;',
368
+ 'font-size:13px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1,transparent);',
369
+ 'border:.5px solid var(--dsw-alias-border-l4);border-radius:8px}',
370
+ '.srv-input:focus-visible,.srv-select:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
371
+ '.srv-input[rows]{height:auto;padding:6px 8px;resize:vertical}',
372
+ '.srv-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}',
373
+ '.srv-error{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-state-error-primary)}',
374
+ '.srv-hint{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}',
375
+ '.srv-note{margin:0;padding:8px 12px;border-radius:10px;font-size:13px;line-height:20px;',
376
+ 'background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent);overflow-wrap:anywhere}',
377
+ '.srv-seg{display:inline-flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-interactive-bg-hover)}',
378
+ '.srv-seg button{height:26px;padding:0 12px;border:0;border-radius:7px;background:transparent;cursor:pointer;',
379
+ 'font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary)}',
380
+ '.srv-seg button[aria-pressed="true"]{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);',
381
+ 'box-shadow:var(--dsw-elevation-soft)}',
382
+ '.srv-rules{border-top:.5px solid var(--dsw-alias-border-l2);margin:0;padding:0;list-style:none}',
383
+ '.srv-rule{display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}',
384
+ '.srv-rule-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}',
385
+ '.srv-rule-label{font-size:13px;line-height:20px;font-weight:500}',
386
+ '.srv-rule-desc{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption);overflow-wrap:anywhere}',
387
+ '.srv-rule-select{flex:none;width:132px}',
388
+ '.srv-audit{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px;font-size:12px;line-height:18px}',
389
+ '.srv-audit li{display:flex;gap:8px;align-items:baseline;min-width:0;padding:2px 0}',
390
+ '.srv-audit-time{flex:none;color:var(--dsw-alias-label-caption);font-variant-numeric:tabular-nums}',
391
+ '.srv-audit-decision{flex:none;width:56px;color:var(--dsw-alias-label-secondary)}',
392
+ '.srv-audit-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--ds-font-family-code,Menlo,monospace)}',
393
+ '.srv-badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;',
394
+ 'border-radius:9px;background:var(--dsw-alias-state-error-primary);color:var(--dsw-static-neutral-bluish-00);',
395
+ 'font-size:11px;line-height:18px;font-weight:600}',
396
+ '.srv-pending{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}',
397
+ '.srv-pending li{display:flex;align-items:center;gap:8px 12px;flex-wrap:wrap;padding:8px 0;',
398
+ 'border-bottom:.5px solid var(--dsw-alias-border-l2)}',
399
+ '.srv-pending-main{flex:1 1 220px;min-width:0;display:flex;flex-direction:column;gap:2px}',
400
+ '.srv-pending-title{font-size:13px;line-height:20px;font-weight:500;overflow-wrap:anywhere}',
401
+ '.srv-pending-meta{font-size:12px;line-height:18px;color:var(--dsw-alias-label-caption)}',
402
+ '.srv-danger-zone{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;',
403
+ 'padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2)}',
404
+ '.srv-cfg-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 16px}',
405
+ '.srv-check{display:flex;align-items:center;gap:8px;font-size:13px;line-height:20px;cursor:pointer}',
406
+ '.srv-check input{width:15px;height:15px;margin:0;accent-color:var(--dsw-alias-brand-primary);cursor:pointer}',
407
+ '.srv-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 45%,var(--dsw-alias-border-l3))}',
408
+ '.srv-danger-title{margin:0;font-size:14px;line-height:22px;font-weight:600;',
409
+ 'color:var(--dsw-alias-state-error-primary)}',
410
+ '.srv-danger-kv{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:4px 16px;margin:0;font-size:13px;line-height:20px}',
411
+ '.srv-danger-kv dt{color:var(--dsw-alias-label-tertiary)}',
412
+ '.srv-danger-kv dd{margin:0;min-width:0;overflow-wrap:anywhere}',
413
+ '@container (max-width:640px){.srv-field-row{grid-template-columns:1fr}.srv-rule{flex-wrap:wrap}',
414
+ '.srv-rule-select{width:100%}}',
415
+ ].join('')
416
+
417
+ /** Inject the stylesheet once; the client module system tags it for HMR cleanup. */
418
+ function installStyles() {
419
+ if (typeof browserGlobal.document === 'undefined') return
420
+ const document = browserGlobal.document
421
+ if (document.querySelector('style[data-server-connector]') !== null) return
422
+ const tag = document.createElement('style')
423
+ tag.setAttribute('data-server-connector', '')
424
+ tag.textContent = CSS
425
+ document.head.appendChild(tag)
426
+ }
427
+
428
+ // ---------------------------------------------------------------- HTTP --
429
+
430
+ /**
431
+ * One route call. Same-origin, so the browser session cookie authenticates
432
+ * it exactly like the rest of the GUI (§8.5 trust boundary).
433
+ * @param {string} path - path below {@link API}.
434
+ * @param {{method?: string, body?: unknown, query?: Record<string, string>}} [options]
435
+ * @returns {Promise<any>}
436
+ */
437
+ async function api(path, options = {}) {
438
+ const query = options.query === undefined ? '' : `?${new URLSearchParams(options.query).toString()}`
439
+ /** @type {Record<string, string>} */
440
+ const headers = {}
441
+ /** @type {RequestInit} */
442
+ const init = { method: options.method ?? 'GET', headers }
443
+ if (options.body !== undefined) {
444
+ headers['content-type'] = 'application/json'
445
+ init.body = JSON.stringify(options.body)
446
+ }
447
+ const response = await browserGlobal.fetch(`${API}${path}${query}`, init)
448
+ let payload
449
+ const text = await response.text()
450
+ try {
451
+ payload = text === '' ? null : JSON.parse(text)
452
+ } catch {
453
+ payload = null
454
+ }
455
+ if (!response.ok) {
456
+ throw Object.assign(
457
+ new Error(typeof payload?.error?.message === 'string' ? payload.error.message : `HTTP ${String(response.status)}`),
458
+ {
459
+ code: payload?.error?.code ?? `HTTP_${String(response.status)}`,
460
+ status: response.status,
461
+ details: payload?.error?.details ?? {},
462
+ },
463
+ )
464
+ }
465
+ return payload
466
+ }
467
+
468
+ /**
469
+ * The displayable message of a thrown value.
470
+ * @param {unknown} error
471
+ * @returns {string}
472
+ */
473
+ function messageOf(error) {
474
+ if (error instanceof Error) return error.message
475
+ if (typeof error === 'object' && error !== null && typeof (/** @type {any} */ (error).message) === 'string') {
476
+ return /** @type {any} */ (error).message
477
+ }
478
+ return String(error)
479
+ }
480
+
481
+ /**
482
+ * Format one timestamp for the panel; an unparsable value is shown as-is.
483
+ * @param {unknown} value
484
+ * @returns {string}
485
+ */
486
+ function formatTime(value) {
487
+ if (typeof value !== 'string' || value === '') return '—'
488
+ const date = new Date(value)
489
+ if (Number.isNaN(date.getTime())) return value
490
+ return date.toLocaleString(undefined, {
491
+ month: '2-digit',
492
+ day: '2-digit',
493
+ hour: '2-digit',
494
+ minute: '2-digit',
495
+ })
496
+ }
497
+
498
+ // --------------------------------------------------------------- atoms --
499
+
500
+ /** @param {{size?: number, children?: any}} props */
501
+ function Icon({ size = 16, children }) {
502
+ return h(
503
+ 'svg',
504
+ {
505
+ width: size,
506
+ height: size,
507
+ viewBox: '0 0 24 24',
508
+ fill: 'none',
509
+ stroke: 'currentColor',
510
+ strokeWidth: 1.8,
511
+ strokeLinecap: 'round',
512
+ strokeLinejoin: 'round',
513
+ 'aria-hidden': true,
514
+ },
515
+ children,
516
+ )
517
+ }
518
+
519
+ /** The §8.1 sidebar icon: a rack of two server units. */
520
+ function ServerIcon({ size = 20 }) {
521
+ return h(
522
+ Icon,
523
+ { size },
524
+ h('rect', { key: 'a', x: 3, y: 5, width: 18, height: 6, rx: 1.6 }),
525
+ h('rect', { key: 'b', x: 3, y: 13, width: 18, height: 6, rx: 1.6 }),
526
+ h('path', { key: 'c', d: 'M6.5 8h.01M6.5 16h.01' }),
527
+ )
528
+ }
529
+
530
+ /**
531
+ * @param {{variant?: string, size?: string, danger?: boolean, children?: any} & Record<string, any>} props
532
+ */
533
+ function Button({ variant = 'ghost', size = 'md', danger = false, children, ...rest }) {
534
+ const className = [
535
+ 'srv-btn',
536
+ size === 'sm' ? 'srv-btn-sm' : '',
537
+ variant === 'primary' ? 'srv-btn-primary' : '',
538
+ variant === 'outline' ? 'srv-btn-outline' : '',
539
+ danger ? 'srv-btn-danger' : '',
540
+ ]
541
+ .filter(Boolean)
542
+ .join(' ')
543
+ return h('button', { type: 'button', className, ...rest }, children)
544
+ }
545
+
546
+ /** @param {{on: boolean}} props */
547
+ function StatusDot({ on }) {
548
+ return h('span', { className: 'srv-dot', 'data-on': on ? 'true' : 'false' })
549
+ }
550
+
551
+ // ----------------------------------------------------- global settings --
552
+
553
+ /**
554
+ * The row id this bundle's Host half is mounted as (see `cordis.patch.yml`).
555
+ * `configForms.get()` addresses a Host configuration entry by that row id —
556
+ * not by the package name — and `describe()` reports the namespace as
557
+ * `entry.options.id`.
558
+ */
559
+ const CONFIG_ROW_ID = 'dsh-server-connector'
560
+
561
+ /**
562
+ * One field of the §10 global configuration, in the order the page shows it.
563
+ *
564
+ * `kind` decides the control and the client-side validation; the Host
565
+ * validates against the real schema as well, so this table is about the UI,
566
+ * never about authority. It has to stay in step with `Config` in
567
+ * `lib/index.js` — `tests/client.test.ts` compares the two.
568
+ */
569
+ const CONFIG_FIELDS = [
570
+ { key: 'connectTimeoutMs', kind: 'number', min: 1000 },
571
+ { key: 'commandTimeoutMs', kind: 'number', min: 1000 },
572
+ { key: 'syncTimeoutMs', kind: 'number', min: 1000 },
573
+ { key: 'maxOutputChars', kind: 'number', min: 1024 },
574
+ { key: 'idleTimeoutMs', kind: 'number', min: 1000 },
575
+ { key: 'auditEnabled', kind: 'boolean' },
576
+ { key: 'auditMaxBytes', kind: 'number', min: 0 },
577
+ { key: 'pendingTtlHours', kind: 'number', min: 1 },
578
+ { key: 'maxListedServers', kind: 'number', min: 1 },
579
+ ]
580
+
581
+ /**
582
+ * Bind the Host's editable configuration form for this plugin's row.
583
+ *
584
+ * `configForms` is an optional client service, and the row only appears in it
585
+ * when the Host projects the schema — which happens because every key of
586
+ * `Config` is `.volatile()`. Both facts are runtime conditions, so every read
587
+ * here is tolerant: a missing service, an unserved row and a scope that
588
+ * throws all degrade to "read-only", never to an exception inside a render.
589
+ *
590
+ * The controller is created once per mount and handed to the page through
591
+ * the slot's `inject` callback, exactly as the shipped panels do it.
592
+ */
593
+ function createConfigModel() {
594
+ let service = false
595
+ /** @type {any} */
596
+ let scope = null
597
+ /** @type {(() => void)|null} */
598
+ let unsubscribe = null
599
+ /** @type {Set<() => void>} */
600
+ const listeners = new Set()
601
+
602
+ const publish = () => {
603
+ // Copy first: a listener may unsubscribe itself while being notified.
604
+ for (const listener of Array.from(listeners)) {
605
+ try {
606
+ listener()
607
+ } catch (error) {
608
+ console.error('dsh-server-connector: a settings listener failed', error)
609
+ }
610
+ }
611
+ }
612
+
613
+ /**
614
+ * The current snapshot, or a shape that reads as "loading" / "unavailable".
615
+ * @returns {any}
616
+ */
617
+ const read = () => {
618
+ if (scope === null || typeof scope.getSnapshot !== 'function') return { status: 'loading' }
619
+ try {
620
+ const snapshot = scope.getSnapshot()
621
+ return snapshot !== null && typeof snapshot === 'object' ? snapshot : { status: 'unavailable' }
622
+ } catch (error) {
623
+ console.error('dsh-server-connector: reading the settings scope failed', error)
624
+ return { status: 'unavailable' }
625
+ }
626
+ }
627
+
628
+ return {
629
+ /** Whether the page can save at all (the service exists and the row is served). */
630
+ canWrite: () => read().writable === true && scope !== null && typeof scope.mutate === 'function',
631
+ /** True once the client settings service was seen, even before the row is served. */
632
+ hasService: () => service,
633
+ noteService: () => {
634
+ service = true
635
+ publish()
636
+ },
637
+ /** @param {any} next */
638
+ attach: (next) => {
639
+ if (next === null || next === undefined) return
640
+ service = true
641
+ scope = next
642
+ if (typeof scope.subscribe === 'function') {
643
+ try {
644
+ unsubscribe = scope.subscribe(publish)
645
+ } catch (error) {
646
+ console.error('dsh-server-connector: subscribing to the settings scope failed', error)
647
+ }
648
+ }
649
+ publish()
650
+ },
651
+ detach: () => {
652
+ if (typeof unsubscribe === 'function') {
653
+ try {
654
+ unsubscribe()
655
+ } catch (error) {
656
+ console.error('dsh-server-connector: releasing the settings scope failed', error)
657
+ }
658
+ }
659
+ unsubscribe = null
660
+ },
661
+ read,
662
+ /** @param {() => void} listener */
663
+ subscribe: (listener) => {
664
+ listeners.add(listener)
665
+ return () => {
666
+ listeners.delete(listener)
667
+ }
668
+ },
669
+ /**
670
+ * Submit one revision-fenced batch of field writes.
671
+ *
672
+ * The revision is what keeps two windows from silently overwriting each
673
+ * other: the Host refuses a batch composed against a value somebody else
674
+ * already replaced, and this reports that refusal instead of pretending
675
+ * the save landed.
676
+ * @param {Array<{op: string, path: string[], value?: unknown}>} ops
677
+ * @returns {Promise<boolean>}
678
+ */
679
+ write: async (ops) => {
680
+ const snapshot = read()
681
+ if (scope === null || typeof scope.mutate !== 'function') return false
682
+ if (snapshot.writable !== true) return false
683
+ if (!Array.isArray(ops) || ops.length === 0) return true
684
+ try {
685
+ return Boolean(await scope.mutate(ops, snapshot.revision))
686
+ } catch (error) {
687
+ console.error('dsh-server-connector: writing the configuration failed', error)
688
+ return false
689
+ }
690
+ },
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Ask `configForms` for our row and keep the scope for the fiber's lifetime.
696
+ *
697
+ * `whileServed` is the platform's own gate: a form fetched before the
698
+ * `describe()` round trip settles is born with its persistence fixed to "not
699
+ * host" and never upgrades, so the row is awaited instead of requested
700
+ * eagerly.
701
+ * @param {any} ctx - the client plugin context.
702
+ * @param {ReturnType<typeof createConfigModel>} model
703
+ * @returns {void}
704
+ */
705
+ function bindConfigModel(ctx, model) {
706
+ try {
707
+ if (typeof ctx.inject !== 'function') return
708
+ ctx.inject(['configForms'], (/** @type {any} */ inner) => {
709
+ const configForms = inner.configForms
710
+ if (configForms === null || configForms === undefined) return
711
+ model.noteService()
712
+ const attach = () => {
713
+ try {
714
+ model.attach(configForms.get(CONFIG_ROW_ID))
715
+ } catch (error) {
716
+ console.error('dsh-server-connector: the settings row could not be opened', error)
717
+ }
718
+ }
719
+ let stop = () => model.detach()
720
+ try {
721
+ if (typeof configForms.whileServed === 'function') {
722
+ stop = configForms.whileServed([CONFIG_ROW_ID], () => {
723
+ attach()
724
+ return () => model.detach()
725
+ })
726
+ } else {
727
+ attach()
728
+ stop = () => model.detach()
729
+ }
730
+ } catch (error) {
731
+ console.error('dsh-server-connector: this DSH serves no settings row for this plugin', error)
732
+ return
733
+ }
734
+ if (typeof inner.effect === 'function') {
735
+ inner.effect(() => stop, 'server-connector: settings scope')
736
+ }
737
+ })
738
+ } catch (error) {
739
+ console.error('dsh-server-connector: asking for the settings service failed', error)
740
+ }
741
+ }
742
+
743
+ /**
744
+ * A model that is never bound to a Host scope. Rendering the settings page
745
+ * outside the `inject` path (a test, or a Host that mounts a section without
746
+ * running the inject callback) then reads as "no editable form" and shows the
747
+ * read-only card instead of throwing.
748
+ */
749
+ const DETACHED_CONFIG_MODEL = createConfigModel()
750
+
751
+ // --------------------------------------------------------------- hooks --
752
+
753
+ /**
754
+ * Load `GET /servers` and keep a reload function.
755
+ * @returns {{state: any, reload: () => Promise<void>}}
756
+ */
757
+ function useServers() {
758
+ const [state, setState] = React.useState({ phase: 'loading' })
759
+ const alive = React.useRef(true)
760
+ React.useEffect(
761
+ () => () => {
762
+ alive.current = false
763
+ },
764
+ [],
765
+ )
766
+ const reload = React.useCallback(async () => {
767
+ try {
768
+ const data = await api('/servers')
769
+ if (alive.current) setState({ phase: 'ready', data })
770
+ } catch (error) {
771
+ if (alive.current) setState({ phase: 'error', error })
772
+ }
773
+ }, [])
774
+ React.useEffect(() => {
775
+ void reload()
776
+ }, [reload])
777
+ return { state, reload }
778
+ }
779
+
780
+ // ------------------------------------------------------------- surfaces --
781
+
782
+ /**
783
+ * The §8.2 list row.
784
+ * @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
785
+ */
786
+ function ServerRow({ server, t, onOpen, onEdit, onTest, onToggle, testing, toggling }) {
787
+ const busy = testing === server.id
788
+ const off = server.enabled === false
789
+ const toggleBusy = toggling === server.id
790
+ return h(
791
+ 'li',
792
+ { className: off ? 'srv-row srv-row-off' : 'srv-row' },
793
+ h(StatusDot, { on: server.connected === true }),
794
+ h(
795
+ 'div',
796
+ { className: 'srv-row-main' },
797
+ h(
798
+ 'div',
799
+ { className: 'srv-row-title' },
800
+ h('button', { type: 'button', className: 'srv-open', onClick: () => onOpen(server.id) }, server.name),
801
+ ),
802
+ h(
803
+ 'div',
804
+ { className: 'srv-row-meta' },
805
+ h('span', { className: 'srv-mono' }, `${server.username}@${server.host}:${String(server.port)}`),
806
+ server.defaultWorkDir === null
807
+ ? null
808
+ : h('span', { className: 'srv-mono' }, server.defaultWorkDir),
809
+ h(
810
+ 'span',
811
+ { className: 'srv-tag', 'data-tone': server.connected === true ? 'success' : undefined },
812
+ server.connected === true ? t('panel.connected') : t('panel.disconnected'),
813
+ ),
814
+ server.hostKeyMode === 'off' ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, 'host-key off') : null,
815
+ off ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, t('panel.disabled')) : null,
816
+ ),
817
+ ),
818
+ h(
819
+ 'div',
820
+ { className: 'srv-row-actions' },
821
+ // The switch is the only control the panel needs for the enable state:
822
+ // it applies immediately (no pending queue), so it reads as a switch.
823
+ h(Switch, {
824
+ checked: !off,
825
+ disabled: toggleBusy || server.connected === true,
826
+ label: off ? t('panel.enable') : t('panel.disable'),
827
+ hint: server.connected === true ? t('panel.disconnectFirst') : null,
828
+ onChange: () => void onToggle(server),
829
+ }),
830
+ h(Button, { size: 'sm', variant: 'outline', disabled: busy || off, onClick: () => void onTest(server.id) }, busy ? t('panel.testing') : t('panel.test')),
831
+ h(Button, { size: 'sm', onClick: () => onEdit(server.id) }, t('panel.edit')),
832
+ ),
833
+ )
834
+ }
835
+
836
+ /**
837
+ * A minimal on/off switch. The panel has no design-system switch in the
838
+ * injected surface, so this is the smallest one that reads as a switch and
839
+ * stays usable when disabled.
840
+ * @param {{checked: boolean, disabled?: boolean, label: string, hint?: string|null, onChange: () => void}} props
841
+ */
842
+ function Switch({ checked, disabled = false, label, hint = null, onChange }) {
843
+ return h(
844
+ 'button',
845
+ {
846
+ type: 'button',
847
+ role: 'switch',
848
+ 'aria-checked': checked ? 'true' : 'false',
849
+ 'aria-label': label,
850
+ title: hint ?? label,
851
+ className: 'srv-switch',
852
+ 'data-on': checked ? 'true' : 'false',
853
+ disabled,
854
+ onClick: () => onChange(),
855
+ },
856
+ h('span', { className: 'srv-switch-knob', 'aria-hidden': 'true' }),
857
+ )
858
+ }
859
+
860
+ /**
861
+ * The list view (or the empty state). Owns no data: the panel passes state.
862
+ * @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
863
+ */
864
+ function ServerList({ state, t, reload, onAdd, onOpen, onEdit, onTest, onToggle, testing, toggling, notice }) {
865
+ const data = state.phase === 'ready' ? state.data : null
866
+ const servers = data === null ? [] : data.servers
867
+ const pendingCount = data === null ? 0 : data.pendingCount
868
+ return h(
869
+ F,
870
+ null,
871
+ h(
872
+ 'div',
873
+ { className: 'srv-head' },
874
+ h('h2', null, t('panel.title')),
875
+ pendingCount > 0
876
+ ? h('span', { className: 'srv-badge', title: t('settings.pending') }, String(pendingCount))
877
+ : null,
878
+ h(
879
+ 'div',
880
+ { className: 'srv-head-actions' },
881
+ h(Button, { variant: 'primary', onClick: onAdd }, t('panel.add')),
882
+ ),
883
+ ),
884
+ notice === null ? null : h('p', { className: 'srv-note' }, notice),
885
+ state.phase === 'loading' ? h('p', { className: 'srv-empty' }, t('panel.loading')) : null,
886
+ state.phase === 'error'
887
+ ? h(
888
+ 'div',
889
+ { className: 'srv-empty' },
890
+ h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(state.error) })),
891
+ h(Button, { variant: 'outline', onClick: () => void reload() }, t('panel.retry')),
892
+ )
893
+ : null,
894
+ state.phase === 'ready' && servers.length === 0
895
+ ? h(
896
+ 'div',
897
+ { className: 'srv-empty' },
898
+ h('p', null, t('panel.empty')),
899
+ h('p', null, t('panel.emptyHint')),
900
+ )
901
+ : null,
902
+ servers.length > 0
903
+ ? h(
904
+ 'ul',
905
+ { className: 'srv-list' },
906
+ servers.map((/** @type {any} */ server) =>
907
+ h(ServerRow, {
908
+ key: server.id,
909
+ server,
910
+ t,
911
+ onOpen,
912
+ onEdit,
913
+ onTest,
914
+ onToggle,
915
+ testing,
916
+ toggling,
917
+ }),
918
+ ),
919
+ )
920
+ : null,
921
+ )
922
+ }
923
+
924
+ /**
925
+ * Custom rules for one server (doc §4.4).
926
+ *
927
+ * The section owns its own draft state: a rule is only sent to the Host after
928
+ * the operator has seen a dry run of it against a sample command, which is
929
+ * the one guard that catches "the regex never fires".
930
+ * @param {{server: any, t: any, onRefresh: () => Promise<void>}} props
931
+ */
932
+ function CustomRulesSection({ server, t, onRefresh }) {
933
+ const rules = Array.isArray(server.customRules) ? server.customRules : []
934
+ const [draft, setDraft] = React.useState(null)
935
+ const [busy, setBusy] = React.useState(false)
936
+ const [error, setError] = React.useState(null)
937
+ const [preview, setPreview] = React.useState(null)
938
+
939
+ const startAdd = () =>
940
+ setDraft({ label: '', pattern: '', defaultDecision: 'confirm', description: '', samples: '' })
941
+ const cancel = () => {
942
+ setDraft(null)
943
+ setPreview(null)
944
+ setError(null)
945
+ }
946
+
947
+ /** @param {any} event */
948
+ const dryRun = async (event) => {
949
+ event.preventDefault()
950
+ if (draft === null) return
951
+ setBusy(true)
952
+ setError(null)
953
+ try {
954
+ const samples = String(draft.samples ?? '')
955
+ .split('\n')
956
+ .map((line) => line.trim())
957
+ .filter((line) => line !== '')
958
+ if (samples.length === 0) throw new Error(t('rules.needSample'))
959
+ const data = await api(`/servers/${encodeURIComponent(server.id)}/rules/preview`, {
960
+ method: 'POST',
961
+ body: {
962
+ label: draft.label === '' ? 'preview' : draft.label,
963
+ pattern: draft.pattern,
964
+ defaultDecision: draft.defaultDecision,
965
+ samples,
966
+ },
967
+ })
968
+ setPreview(data)
969
+ } catch (failure) {
970
+ setPreview(null)
971
+ setError(messageOf(failure))
972
+ } finally {
973
+ setBusy(false)
974
+ }
975
+ }
976
+
977
+ /** @param {any} event */
978
+ const save = async (event) => {
979
+ event.preventDefault()
980
+ if (draft === null) return
981
+ setBusy(true)
982
+ setError(null)
983
+ try {
984
+ const samples = String(draft.samples ?? '')
985
+ .split('\n')
986
+ .map((line) => line.trim())
987
+ .filter((line) => line !== '')
988
+ await api(`/servers/${encodeURIComponent(server.id)}/rules`, {
989
+ method: 'POST',
990
+ body: {
991
+ label: draft.label,
992
+ pattern: draft.pattern,
993
+ defaultDecision: draft.defaultDecision,
994
+ description: draft.description,
995
+ samples,
996
+ },
997
+ })
998
+ setDraft(null)
999
+ setPreview(null)
1000
+ await onRefresh()
1001
+ } catch (failure) {
1002
+ setError(messageOf(failure))
1003
+ } finally {
1004
+ setBusy(false)
1005
+ }
1006
+ }
1007
+
1008
+ /** @param {string} ruleId */
1009
+ const remove = async (ruleId) => {
1010
+ setBusy(true)
1011
+ setError(null)
1012
+ try {
1013
+ await api(`/servers/${encodeURIComponent(server.id)}/rules/${encodeURIComponent(ruleId)}`, {
1014
+ method: 'DELETE',
1015
+ })
1016
+ await onRefresh()
1017
+ } catch (failure) {
1018
+ setError(messageOf(failure))
1019
+ } finally {
1020
+ setBusy(false)
1021
+ }
1022
+ }
1023
+
1024
+ return h(
1025
+ 'section',
1026
+ { className: 'srv-card' },
1027
+ h('h3', { className: 'srv-card-title' }, t('rules.section')),
1028
+ h('p', { className: 'srv-hint' }, t('rules.hint')),
1029
+ rules.length === 0 ? h('p', { className: 'srv-hint' }, t('rules.empty')) : null,
1030
+ rules.length > 0
1031
+ ? h(
1032
+ 'ul',
1033
+ { className: 'srv-rules' },
1034
+ rules.map((/** @type {any} */ rule) =>
1035
+ h(
1036
+ 'li',
1037
+ { className: 'srv-rule', key: rule.id },
1038
+ h(
1039
+ 'div',
1040
+ { className: 'srv-rule-body' },
1041
+ h('span', { className: 'srv-rule-label' }, rule.label),
1042
+ h('span', { className: 'srv-rule-desc' }, rule.description || rule.pattern),
1043
+ ),
1044
+ h('span', { className: 'srv-tag', 'data-tone': rule.defaultDecision === 'block' ? undefined : 'success' }, rule.defaultDecision),
1045
+ h(
1046
+ Button,
1047
+ { size: 'sm', danger: true, disabled: busy, onClick: () => void remove(rule.id) },
1048
+ t('rules.delete'),
1049
+ ),
1050
+ ),
1051
+ ),
1052
+ )
1053
+ : null,
1054
+ error !== null ? h('p', { className: 'srv-error' }, error) : null,
1055
+ draft === null
1056
+ ? h(Button, { variant: 'outline', disabled: busy, onClick: startAdd }, t('rules.add'))
1057
+ : h(
1058
+ 'form',
1059
+ { className: 'srv-form', onSubmit: save },
1060
+ h(
1061
+ 'div',
1062
+ { className: 'srv-field' },
1063
+ h('label', { htmlFor: 'srv-rule-label' }, t('rules.label')),
1064
+ h('input', {
1065
+ id: 'srv-rule-label',
1066
+ className: 'srv-input',
1067
+ value: draft.label,
1068
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, label: event.target.value }),
1069
+ }),
1070
+ ),
1071
+ h(
1072
+ 'div',
1073
+ { className: 'srv-field' },
1074
+ h('label', { htmlFor: 'srv-rule-pattern' }, t('rules.pattern')),
1075
+ h('input', {
1076
+ id: 'srv-rule-pattern',
1077
+ className: 'srv-input srv-mono',
1078
+ value: draft.pattern,
1079
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, pattern: event.target.value }),
1080
+ }),
1081
+ ),
1082
+ h(
1083
+ 'div',
1084
+ { className: 'srv-field' },
1085
+ h('label', { htmlFor: 'srv-rule-decision' }, t('rules.decision')),
1086
+ h(
1087
+ 'select',
1088
+ {
1089
+ id: 'srv-rule-decision',
1090
+ className: 'srv-select',
1091
+ value: draft.defaultDecision,
1092
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, defaultDecision: event.target.value }),
1093
+ },
1094
+ h('option', { value: 'confirm' }, 'confirm'),
1095
+ h('option', { value: 'block' }, 'block'),
1096
+ ),
1097
+ ),
1098
+ h(
1099
+ 'div',
1100
+ { className: 'srv-field' },
1101
+ h('label', { htmlFor: 'srv-rule-desc' }, t('rules.description')),
1102
+ h('input', {
1103
+ id: 'srv-rule-desc',
1104
+ className: 'srv-input',
1105
+ value: draft.description,
1106
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, description: event.target.value }),
1107
+ }),
1108
+ ),
1109
+ h(
1110
+ 'div',
1111
+ { className: 'srv-field' },
1112
+ h('label', { htmlFor: 'srv-rule-samples' }, t('rules.samples')),
1113
+ h('textarea', {
1114
+ id: 'srv-rule-samples',
1115
+ className: 'srv-input srv-mono',
1116
+ rows: 3,
1117
+ value: draft.samples,
1118
+ onChange: (/** @type {any} */ event) => setDraft({ ...draft, samples: event.target.value }),
1119
+ }),
1120
+ h('span', { className: 'srv-hint' }, t('rules.samplesHint')),
1121
+ ),
1122
+ preview !== null
1123
+ ? h(
1124
+ 'ul',
1125
+ { className: 'srv-rules' },
1126
+ (preview.results ?? []).map((/** @type {any} */ item, /** @type {number} */ index) =>
1127
+ h(
1128
+ 'li',
1129
+ { className: 'srv-rule', key: index },
1130
+ h(
1131
+ 'div',
1132
+ { className: 'srv-rule-body' },
1133
+ h('span', { className: 'srv-rule-label srv-mono' }, item.sample),
1134
+ h('span', { className: 'srv-rule-desc' }, `${t('rules.wouldMatch')}: ${item.matched ? 'yes' : 'no'} · ${String(item.decision)}`),
1135
+ ),
1136
+ ),
1137
+ ),
1138
+ )
1139
+ : null,
1140
+ h(
1141
+ 'div',
1142
+ { className: 'srv-actions' },
1143
+ h(Button, { type: 'button', disabled: busy, onClick: dryRun }, t('rules.preview')),
1144
+ h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
1145
+ h(Button, { type: 'button', disabled: busy, onClick: cancel }, t('form.cancel')),
1146
+ ),
1147
+ ),
1148
+ )
1149
+ }
1150
+
1151
+ /**
1152
+ * The detail page: four connection fields read-only, the policy table, the
1153
+ * last 20 audit records and the delete flow (§8.2).
1154
+ *
1155
+ * Deleting is a two-step decision, but both steps happen here: the button
1156
+ * opens an in-page confirmation that names the target and its consequences
1157
+ * and stays disabled until a checkbox is ticked, and only that confirmation
1158
+ * touches the network (queue, then confirm). Nothing dangerous happens on one
1159
+ * click, and the operator no longer has to change panels to finish the job.
1160
+ * @param {{server: any, rules: any[], t: any, onBack: () => void, onEdit: () => void, onDelete: () => Promise<void>, deleting: boolean, notice: string|null, onRefresh: () => Promise<void>}} props
1161
+ */
1162
+ function ServerDetail({ server, rules, t, onBack, onEdit, onDelete, deleting, notice, onRefresh }) {
1163
+ const [audit, setAudit] = React.useState({ phase: 'loading' })
1164
+ const [confirming, setConfirming] = React.useState(false)
1165
+ const [acknowledged, setAcknowledged] = React.useState(false)
1166
+ // Leaving this server (or returning to it) must not keep a half-confirmed
1167
+ // deletion alive: the gate belongs to the server that was on screen.
1168
+ React.useEffect(() => {
1169
+ setConfirming(false)
1170
+ setAcknowledged(false)
1171
+ }, [server.id])
1172
+ React.useEffect(() => {
1173
+ let alive = true
1174
+ api('/audit', { query: { limit: String(AUDIT_LIMIT), serverId: server.id } })
1175
+ .then((data) => {
1176
+ if (alive) setAudit({ phase: 'ready', records: data.records })
1177
+ })
1178
+ .catch((error) => {
1179
+ if (alive) setAudit({ phase: 'error', error })
1180
+ })
1181
+ return () => {
1182
+ alive = false
1183
+ }
1184
+ }, [server.id])
1185
+
1186
+ const authText =
1187
+ server.auth.kind === 'key'
1188
+ ? `${t('auth.key')} · ${server.auth.privateKeyPath}`
1189
+ : server.auth.kind === 'password'
1190
+ ? server.passwordConfigured === true
1191
+ ? `${t('auth.password')} · ${t('auth.passwordConfigured')}`
1192
+ : `${t('auth.password')} · —`
1193
+ : t('auth.agent')
1194
+
1195
+ return h(
1196
+ F,
1197
+ null,
1198
+ h(
1199
+ 'div',
1200
+ { className: 'srv-head' },
1201
+ h(Button, { size: 'sm', onClick: onBack }, `← ${t('panel.back')}`),
1202
+ h('h2', null, server.name),
1203
+ h(
1204
+ 'div',
1205
+ { className: 'srv-head-actions' },
1206
+ h(Button, { size: 'sm', variant: 'outline', onClick: onEdit }, t('panel.edit')),
1207
+ ),
1208
+ ),
1209
+ notice === null ? null : h('p', { className: 'srv-note' }, notice),
1210
+ h(
1211
+ 'section',
1212
+ { className: 'srv-card' },
1213
+ h('h3', { className: 'srv-card-title' }, t('panel.sectionConnection')),
1214
+ h(
1215
+ 'dl',
1216
+ { className: 'srv-kv' },
1217
+ h('dt', null, t('field.name')),
1218
+ h('dd', null, server.name),
1219
+ h('dt', null, t('field.host')),
1220
+ h('dd', { className: 'srv-mono' }, `${server.host}:${String(server.port)}`),
1221
+ h('dt', null, t('field.username')),
1222
+ h('dd', { className: 'srv-mono' }, server.username),
1223
+ h('dt', null, t('field.auth')),
1224
+ h('dd', { className: 'srv-mono' }, authText),
1225
+ h('dt', null, t('field.hostKeyMode')),
1226
+ h('dd', null, server.hostKeyMode),
1227
+ h('dt', null, t('field.defaultWorkDir')),
1228
+ h('dd', { className: 'srv-mono' }, server.defaultWorkDir === null ? '—' : server.defaultWorkDir),
1229
+ h('dt', null, t('field.lastConnectedAt')),
1230
+ h(
1231
+ 'dd',
1232
+ null,
1233
+ server.lastConnectedAt === null ? t('panel.neverConnected') : t('panel.lastConnected', { time: formatTime(server.lastConnectedAt) }),
1234
+ ),
1235
+ ),
1236
+ ),
1237
+ h(
1238
+ 'section',
1239
+ { className: 'srv-card' },
1240
+ h('h3', { className: 'srv-card-title' }, t('panel.sectionPolicy')),
1241
+ h(
1242
+ 'ul',
1243
+ { className: 'srv-rules' },
1244
+ rules.map((/** @type {any} */ rule) => {
1245
+ const state = server.policyState?.[rule.id] ?? { configured: null, decision: null, disabled: false }
1246
+ return h(
1247
+ 'li',
1248
+ { className: 'srv-rule', key: rule.id },
1249
+ h(
1250
+ 'div',
1251
+ { className: 'srv-rule-body' },
1252
+ h('span', { className: 'srv-rule-label' }, rule.label),
1253
+ h('span', { className: 'srv-rule-desc' }, rule.description),
1254
+ ),
1255
+ h(
1256
+ 'span',
1257
+ {
1258
+ className: 'srv-tag',
1259
+ 'data-tone': state.disabled ? 'warn' : state.decision === 'block' ? undefined : 'success',
1260
+ },
1261
+ state.disabled ? t('form.offOption') : String(state.decision ?? rule.defaultDecision),
1262
+ ),
1263
+ )
1264
+ }),
1265
+ ),
1266
+ ),
1267
+ h(CustomRulesSection, { server, t, onRefresh }),
1268
+ h(
1269
+ 'section',
1270
+ { className: 'srv-card' },
1271
+ h('h3', { className: 'srv-card-title' }, t('panel.sectionAudit')),
1272
+ audit.phase === 'loading' ? h('p', { className: 'srv-hint' }, t('panel.auditLoading')) : null,
1273
+ audit.phase === 'error'
1274
+ ? h('p', { className: 'srv-error' }, t('error.load', { message: messageOf(audit.error) }))
1275
+ : null,
1276
+ audit.phase === 'ready' && audit.records.length === 0
1277
+ ? h('p', { className: 'srv-hint' }, t('panel.auditEmpty'))
1278
+ : null,
1279
+ audit.phase === 'ready' && audit.records.length > 0
1280
+ ? h(
1281
+ 'ul',
1282
+ { className: 'srv-audit' },
1283
+ audit.records.map((/** @type {any} */ record, /** @type {number} */ index) =>
1284
+ h(
1285
+ 'li',
1286
+ { key: `${String(record.ts)}-${String(index)}` },
1287
+ h('span', { className: 'srv-audit-time' }, formatTime(record.ts)),
1288
+ h('span', { className: 'srv-audit-decision' }, String(record.event ?? record.decision ?? '')),
1289
+ h(
1290
+ 'span',
1291
+ { className: 'srv-audit-text', title: String(record.command ?? record.detail ?? '') },
1292
+ String(record.command ?? record.detail ?? ''),
1293
+ ),
1294
+ ),
1295
+ ),
1296
+ )
1297
+ : null,
1298
+ ),
1299
+ h(
1300
+ 'div',
1301
+ { className: confirming ? 'srv-danger-zone srv-danger' : 'srv-danger-zone' },
1302
+ h('span', { className: 'srv-hint' }, t('panel.deleteHint')),
1303
+ confirming
1304
+ ? null
1305
+ : h(
1306
+ Button,
1307
+ {
1308
+ variant: 'outline',
1309
+ danger: true,
1310
+ disabled: deleting,
1311
+ onClick: () => {
1312
+ setAcknowledged(false)
1313
+ setConfirming(true)
1314
+ },
1315
+ },
1316
+ deleting ? t('pending.busy') : t('panel.delete'),
1317
+ ),
1318
+ ),
1319
+ confirming
1320
+ ? h(
1321
+ 'section',
1322
+ { className: 'srv-card srv-danger', role: 'alertdialog', 'aria-label': t('panel.deleteConfirmTitle') },
1323
+ h('h3', { className: 'srv-danger-title' }, t('panel.deleteConfirmTitle')),
1324
+ h(
1325
+ 'dl',
1326
+ { className: 'srv-danger-kv' },
1327
+ h('dt', null, t('field.name')),
1328
+ h('dd', null, server.name),
1329
+ h('dt', null, t('field.host')),
1330
+ h('dd', { className: 'srv-mono' }, `${server.username}@${server.host}:${String(server.port)}`),
1331
+ ),
1332
+ h('p', { className: 'srv-hint' }, t('panel.deleteConfirmBody')),
1333
+ h(
1334
+ 'label',
1335
+ { className: 'srv-check' },
1336
+ h('input', {
1337
+ type: 'checkbox',
1338
+ checked: acknowledged,
1339
+ disabled: deleting,
1340
+ onChange: (/** @type {any} */ event) => setAcknowledged(event.target.checked),
1341
+ }),
1342
+ h('span', { className: 'srv-hint' }, t('panel.deleteConfirmCheck')),
1343
+ ),
1344
+ h(
1345
+ 'div',
1346
+ { className: 'srv-actions' },
1347
+ h(
1348
+ Button,
1349
+ {
1350
+ variant: 'primary',
1351
+ danger: true,
1352
+ disabled: deleting || !acknowledged,
1353
+ onClick: () => void onDelete(),
1354
+ },
1355
+ deleting ? t('pending.busy') : t('panel.deleteConfirm'),
1356
+ ),
1357
+ h(
1358
+ Button,
1359
+ {
1360
+ variant: 'outline',
1361
+ disabled: deleting,
1362
+ onClick: () => {
1363
+ setConfirming(false)
1364
+ setAcknowledged(false)
1365
+ },
1366
+ },
1367
+ t('panel.deleteCancel'),
1368
+ ),
1369
+ ),
1370
+ )
1371
+ : null,
1372
+ )
1373
+ }
1374
+
1375
+ /**
1376
+ * Whether a path is absolute on *this* machine.
1377
+ *
1378
+ * `auth.privateKeyPath` names a local file the Host reads, so on Windows a
1379
+ * drive-letter (`C:\Users\me\.ssh\id_ed25519`) or UNC (`\\server\share\key`)
1380
+ * path is absolute. The panel used to demand a leading `/` here, which made
1381
+ * key auth impossible to submit on Windows.
1382
+ * @param {string} path
1383
+ * @returns {boolean}
1384
+ */
1385
+ function isLocalAbsolutePath(path) {
1386
+ if (path.startsWith('/')) return true
1387
+ if (/^[A-Za-z]:[\\/]/.test(path)) return true
1388
+ return path.startsWith('\\\\')
1389
+ }
1390
+
1391
+ /**
1392
+ * The create/edit form (§8.4). Block rules expose only confirm/block, and
1393
+ * "off" is queued rather than applied (§8.3).
1394
+ * @param {{server: any|null, rules: any[], hostKeyModes: string[], t: any, onCancel: () => void, onSaved: (message: string|null) => void}} props
1395
+ */
1396
+ function ServerForm({ server, rules, hostKeyModes, t, onCancel, onSaved }) {
1397
+ const editing = server !== null
1398
+ const [name, setName] = React.useState(editing ? server.name : '')
1399
+ const [host, setHost] = React.useState(editing ? server.host : '')
1400
+ const [port, setPort] = React.useState(editing ? String(server.port) : '22')
1401
+ const [username, setUsername] = React.useState(editing ? server.username : '')
1402
+ const [authKind, setAuthKind] = React.useState(editing ? server.auth.kind : 'key')
1403
+ const [privateKeyPath, setPrivateKeyPath] = React.useState(editing && server.auth.kind === 'key' ? server.auth.privateKeyPath : '')
1404
+ const [password, setPassword] = React.useState('')
1405
+ const [hostKeyMode, setHostKeyMode] = React.useState(editing ? server.hostKeyMode : 'accept-new')
1406
+ const [defaultWorkDir, setDefaultWorkDir] = React.useState(editing && server.defaultWorkDir !== null ? server.defaultWorkDir : '')
1407
+ const [policy, setPolicy] = React.useState(() => {
1408
+ /** @type {Record<string, string>} */
1409
+ const initial = {}
1410
+ for (const rule of rules) {
1411
+ const state = editing ? server.policyState?.[rule.id] : null
1412
+ initial[rule.id] = state === undefined || state === null ? rule.defaultDecision : state.disabled ? 'off' : String(state.decision)
1413
+ }
1414
+ return initial
1415
+ })
1416
+ const [busy, setBusy] = React.useState(false)
1417
+ const [error, setError] = React.useState(/** @type {string|null} */ (null))
1418
+ const [note, setNote] = React.useState(/** @type {string|null} */ (null))
1419
+
1420
+ /** @param {string} ruleId @param {string} value */
1421
+ const setRule = (ruleId, value) =>
1422
+ setPolicy((/** @type {Record<string, string>} */ current) => ({ ...current, [ruleId]: value }))
1423
+
1424
+ /** Validate one client-side rule before touching the network (§8.4 validations). */
1425
+ const validate = () => {
1426
+ if (name.trim() === '') return t('form.errorName')
1427
+ if (host.trim() === '') return t('form.errorHost')
1428
+ if (username.trim() === '') return t('form.errorUsername')
1429
+ const parsed = Number(port)
1430
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) return t('form.portRange')
1431
+ if (authKind === 'key' && privateKeyPath.trim() === '') return t('form.errorKeyPath')
1432
+ // The key file lives on *this* machine, so a Windows drive-letter or UNC
1433
+ // path is as absolute as a POSIX one (the Host accepts both).
1434
+ if (authKind === 'key' && !isLocalAbsolutePath(privateKeyPath.trim())) return t('form.absolutePath')
1435
+ if (authKind === 'password' && !editing && password === '') return t('form.errorPassword')
1436
+ // The working directory is on the *remote* host: POSIX only.
1437
+ if (defaultWorkDir.trim() !== '' && !defaultWorkDir.trim().startsWith('/')) return t('form.absolutePath')
1438
+ return null
1439
+ }
1440
+
1441
+ /** @param {any} event */
1442
+ const submit = async (event) => {
1443
+ event.preventDefault()
1444
+ const problem = validate()
1445
+ if (problem !== null) {
1446
+ setError(problem)
1447
+ return
1448
+ }
1449
+ setBusy(true)
1450
+ setError(null)
1451
+ setNote(null)
1452
+ try {
1453
+ /** @type {Record<string, unknown>} */
1454
+ const body = {
1455
+ name: name.trim(),
1456
+ host: host.trim(),
1457
+ port: Number(port),
1458
+ username: username.trim(),
1459
+ auth: authKind === 'key' ? { kind: 'key', privateKeyPath: privateKeyPath.trim() } : { kind: authKind },
1460
+ hostKeyMode,
1461
+ defaultWorkDir: defaultWorkDir.trim() === '' ? null : defaultWorkDir.trim(),
1462
+ }
1463
+ if (authKind === 'password' && password !== '') body.password = password
1464
+
1465
+ // Only rules that leave their built-in default are stored, so the
1466
+ // record stays readable; "off" and re-enabling have their own fields
1467
+ // because both change the *existing* rule set.
1468
+ /** @type {Record<string, string>} */
1469
+ const storedPolicy = {}
1470
+ /** @type {string[]} */
1471
+ const disableRules = []
1472
+ /** @type {string[]} */
1473
+ const enableRules = []
1474
+ for (const rule of rules) {
1475
+ const value = policy[rule.id]
1476
+ if (value === undefined) continue
1477
+ if (value === 'off') {
1478
+ disableRules.push(rule.id)
1479
+ continue
1480
+ }
1481
+ if (value === rule.defaultDecision) continue
1482
+ storedPolicy[rule.id] = value
1483
+ }
1484
+ body.policy = storedPolicy
1485
+
1486
+ if (editing) {
1487
+ for (const rule of rules) {
1488
+ const value = policy[rule.id]
1489
+ const wasDisabled = server.policyState?.[rule.id]?.disabled === true
1490
+ if (wasDisabled && value !== undefined && value !== 'off') enableRules.push(rule.id)
1491
+ }
1492
+ if (disableRules.length > 0) body.disableRules = disableRules
1493
+ if (enableRules.length > 0) body.enableRules = enableRules
1494
+ const answer = await api(`/servers/${encodeURIComponent(server.id)}`, { method: 'PUT', body })
1495
+ if (answer.pending !== undefined && answer.pending.length > 0) {
1496
+ setNote(t('form.pendingNote'))
1497
+ setBusy(false)
1498
+ onSaved(t('form.pendingNote'))
1499
+ return
1500
+ }
1501
+ } else {
1502
+ if (disableRules.length > 0) body.disableRules = disableRules
1503
+ const answer = await api('/servers', { method: 'POST', body })
1504
+ if (answer.pending !== undefined && answer.pending.length > 0) {
1505
+ onSaved(t('form.pendingNote'))
1506
+ return
1507
+ }
1508
+ }
1509
+ onSaved(null)
1510
+ } catch (error) {
1511
+ setError(t('error.generic', { message: messageOf(error) }))
1512
+ } finally {
1513
+ setBusy(false)
1514
+ }
1515
+ }
1516
+
1517
+ return h(
1518
+ 'form',
1519
+ { className: 'srv-form', onSubmit: submit },
1520
+ h('h2', { className: 'srv-card-title' }, editing ? t('form.edit') : t('form.new')),
1521
+ h(
1522
+ 'div',
1523
+ { className: 'srv-field' },
1524
+ h('label', { htmlFor: 'srv-name' }, t('field.name')),
1525
+ h('input', {
1526
+ id: 'srv-name',
1527
+ className: 'srv-input',
1528
+ value: name,
1529
+ onChange: (/** @type {any} */ event) => setName(event.target.value),
1530
+ required: true,
1531
+ }),
1532
+ ),
1533
+ h(
1534
+ 'div',
1535
+ { className: 'srv-field-row' },
1536
+ h(
1537
+ 'div',
1538
+ { className: 'srv-field' },
1539
+ h('label', { htmlFor: 'srv-host' }, t('field.host')),
1540
+ h('input', {
1541
+ id: 'srv-host',
1542
+ className: 'srv-input',
1543
+ value: host,
1544
+ onChange: (/** @type {any} */ event) => setHost(event.target.value),
1545
+ required: true,
1546
+ }),
1547
+ ),
1548
+ h(
1549
+ 'div',
1550
+ { className: 'srv-field' },
1551
+ h('label', { htmlFor: 'srv-port' }, t('field.port')),
1552
+ h('input', {
1553
+ id: 'srv-port',
1554
+ className: 'srv-input',
1555
+ type: 'number',
1556
+ min: 1,
1557
+ max: 65535,
1558
+ value: port,
1559
+ onChange: (/** @type {any} */ event) => setPort(event.target.value),
1560
+ }),
1561
+ ),
1562
+ ),
1563
+ h(
1564
+ 'div',
1565
+ { className: 'srv-field' },
1566
+ h('label', { htmlFor: 'srv-username' }, t('field.username')),
1567
+ h('input', {
1568
+ id: 'srv-username',
1569
+ className: 'srv-input',
1570
+ value: username,
1571
+ onChange: (/** @type {any} */ event) => setUsername(event.target.value),
1572
+ required: true,
1573
+ }),
1574
+ ),
1575
+ h(
1576
+ 'div',
1577
+ { className: 'srv-field' },
1578
+ h('span', null, t('field.auth')),
1579
+ h(
1580
+ 'div',
1581
+ { className: 'srv-seg', role: 'group', 'aria-label': t('field.auth') },
1582
+ ['key', 'password', 'agent'].map((kind) =>
1583
+ h(
1584
+ 'button',
1585
+ {
1586
+ key: kind,
1587
+ type: 'button',
1588
+ 'aria-pressed': authKind === kind ? 'true' : 'false',
1589
+ onClick: () => setAuthKind(kind),
1590
+ },
1591
+ t(`auth.${kind}`),
1592
+ ),
1593
+ ),
1594
+ ),
1595
+ ),
1596
+ authKind === 'key'
1597
+ ? h(
1598
+ 'div',
1599
+ { className: 'srv-field' },
1600
+ h('label', { htmlFor: 'srv-key' }, t('field.privateKeyPath')),
1601
+ h('input', {
1602
+ id: 'srv-key',
1603
+ className: 'srv-input srv-mono',
1604
+ value: privateKeyPath,
1605
+ placeholder: '/root/.ssh/id_ed25519',
1606
+ onChange: (/** @type {any} */ event) => setPrivateKeyPath(event.target.value),
1607
+ }),
1608
+ )
1609
+ : null,
1610
+ authKind === 'password'
1611
+ ? h(
1612
+ 'div',
1613
+ { className: 'srv-field' },
1614
+ h('label', { htmlFor: 'srv-password' }, t('field.password')),
1615
+ h('input', {
1616
+ id: 'srv-password',
1617
+ className: 'srv-input',
1618
+ type: 'password',
1619
+ value: password,
1620
+ autoComplete: 'new-password',
1621
+ placeholder: editing && server.passwordConfigured === true ? t('auth.passwordConfigured') : '',
1622
+ onChange: (/** @type {any} */ event) => setPassword(event.target.value),
1623
+ }),
1624
+ h('span', { className: 'srv-hint' }, editing ? t('form.passwordKeep') : t('form.passwordNew')),
1625
+ )
1626
+ : null,
1627
+ h(
1628
+ 'div',
1629
+ { className: 'srv-field' },
1630
+ h('label', { htmlFor: 'srv-hostkey' }, t('field.hostKeyMode')),
1631
+ h(
1632
+ 'select',
1633
+ {
1634
+ id: 'srv-hostkey',
1635
+ className: 'srv-select',
1636
+ value: hostKeyMode,
1637
+ onChange: (/** @type {any} */ event) => setHostKeyMode(event.target.value),
1638
+ },
1639
+ hostKeyModes.map((mode) => h('option', { key: mode, value: mode }, t(`hostKey.${mode}`))),
1640
+ ),
1641
+ hostKeyMode === 'off' && (editing ? server.hostKeyMode !== 'off' : false)
1642
+ ? h('span', { className: 'srv-hint' }, t('form.offQueued'))
1643
+ : null,
1644
+ ),
1645
+ h(
1646
+ 'div',
1647
+ { className: 'srv-field' },
1648
+ h('label', { htmlFor: 'srv-workdir' }, t('field.defaultWorkDir')),
1649
+ h('input', {
1650
+ id: 'srv-workdir',
1651
+ className: 'srv-input srv-mono',
1652
+ value: defaultWorkDir,
1653
+ placeholder: '/srv/app',
1654
+ onChange: (/** @type {any} */ event) => setDefaultWorkDir(event.target.value),
1655
+ }),
1656
+ ),
1657
+ h(
1658
+ 'div',
1659
+ { className: 'srv-field' },
1660
+ h('span', null, t('field.policy')),
1661
+ h('p', { className: 'srv-hint' }, t('form.policyHint')),
1662
+ h(
1663
+ 'ul',
1664
+ { className: 'srv-rules' },
1665
+ rules.map((/** @type {any} */ rule) => {
1666
+ const value = policy[rule.id] ?? rule.defaultDecision
1667
+ // "停用" is always offered and always queued (§8.3): a new server
1668
+ // is created with the rule enabled and a disable request filed.
1669
+ const options = [...rule.allowedDecisions, 'off']
1670
+ return h(
1671
+ 'li',
1672
+ { className: 'srv-rule', key: rule.id },
1673
+ h(
1674
+ 'div',
1675
+ { className: 'srv-rule-body' },
1676
+ h('span', { className: 'srv-rule-label' }, rule.label),
1677
+ h('span', { className: 'srv-rule-desc' }, rule.description),
1678
+ ),
1679
+ h(
1680
+ 'select',
1681
+ {
1682
+ className: 'srv-select srv-rule-select',
1683
+ 'aria-label': rule.label,
1684
+ value,
1685
+ onChange: (/** @type {any} */ event) => setRule(rule.id, event.target.value),
1686
+ },
1687
+ options.map((decision) =>
1688
+ h('option', { key: decision, value: decision }, decision === 'off' ? t('form.offOption') : decision),
1689
+ ),
1690
+ ),
1691
+ )
1692
+ }),
1693
+ ),
1694
+ ),
1695
+ error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
1696
+ note === null ? null : h('p', { className: 'srv-note' }, note),
1697
+ h(
1698
+ 'div',
1699
+ { className: 'srv-head-actions' },
1700
+ h(Button, { variant: 'primary', type: 'submit', disabled: busy }, busy ? t('form.saving') : t('form.save')),
1701
+ h(Button, { onClick: onCancel, disabled: busy }, t('form.cancel')),
1702
+ ),
1703
+ )
1704
+ }
1705
+
1706
+ /**
1707
+ * The main panel: list ⇄ detail ⇄ form (§8.2). One server's data lives in
1708
+ * the list payload, so a mutation is followed by a reload rather than by
1709
+ * patching several copies.
1710
+ * @param {{t: any}} props
1711
+ */
1712
+ function ServerPanel({ t }) {
1713
+ const { state, reload } = useServers()
1714
+ const [view, setView] = React.useState({ kind: 'list' })
1715
+ const [testing, setTesting] = React.useState(/** @type {string|null} */ (null))
1716
+ const [toggling, setToggling] = React.useState(/** @type {string|null} */ (null))
1717
+ const [deleting, setDeleting] = React.useState(false)
1718
+ const [notice, setNotice] = React.useState(/** @type {string|null} */ (null))
1719
+ const [status, setStatus] = React.useState(/** @type {string|null} */ (null))
1720
+
1721
+ const data = state.phase === 'ready' ? state.data : null
1722
+ const servers = data === null ? [] : data.servers
1723
+ const rules = data === null ? [] : data.rules
1724
+ const hostKeyModes = data === null ? ['accept-new', 'verify', 'off'] : data.hostKeyModes
1725
+
1726
+ /** @param {string} id */
1727
+ const test = async (id) => {
1728
+ setTesting(id)
1729
+ setNotice(null)
1730
+ setStatus(null)
1731
+ try {
1732
+ const answer = await api('/test', { method: 'POST', body: { id } })
1733
+ setStatus(
1734
+ t('status.connected', {
1735
+ platform: answer.platform,
1736
+ shell: answer.shell,
1737
+ sudo: answer.sudo,
1738
+ ms: answer.durationMs,
1739
+ }),
1740
+ )
1741
+ await reload()
1742
+ } catch (error) {
1743
+ setStatus(t('error.generic', { message: messageOf(error) }))
1744
+ } finally {
1745
+ setTesting(null)
1746
+ }
1747
+ }
1748
+
1749
+ /**
1750
+ * Delete one server from the detail page, in place and in one flow.
1751
+ *
1752
+ * The safety model is unchanged: `DELETE` only ever *queues*, and the
1753
+ * removal happens on `POST /pending/:id/confirm`. What changed is where the
1754
+ * operator confirms — the detail page now shows the queued request with its
1755
+ * target and consequences, so confirming no longer means switching to the
1756
+ * settings page and finding the row again. The settings queue stays as the
1757
+ * second door, which is what makes a forgotten request visible later.
1758
+ * @param {any} server
1759
+ */
1760
+ const remove = async (server) => {
1761
+ setDeleting(true)
1762
+ setNotice(null)
1763
+ try {
1764
+ const queued = await api(`/servers/${encodeURIComponent(server.id)}`, { method: 'DELETE' })
1765
+ const id = queued?.pending?.id
1766
+ if (typeof id !== 'string' || id === '') {
1767
+ setNotice(t('panel.deleteQueued'))
1768
+ setView({ kind: 'list' })
1769
+ await reload()
1770
+ return
1771
+ }
1772
+ await api(`/pending/${encodeURIComponent(id)}/confirm`, { method: 'POST', body: {} })
1773
+ setNotice(t('panel.deleteDone', { name: server.name }))
1774
+ setView({ kind: 'list' })
1775
+ await reload()
1776
+ } catch (error) {
1777
+ // The queue step and the confirm step fail differently, and the server
1778
+ // may be gone already if the confirm half applied before the read
1779
+ // failed — so reload rather than guess which state we are in.
1780
+ setNotice(t('error.generic', { message: messageOf(error) }))
1781
+ await reload()
1782
+ } finally {
1783
+ setDeleting(false)
1784
+ }
1785
+ }
1786
+
1787
+ /**
1788
+ * Flip the enable switch. It applies immediately — no pending queue — so
1789
+ * the only failure modes are "connected" (409) and a network error.
1790
+ * @param {any} server
1791
+ */
1792
+ const toggle = async (server) => {
1793
+ setToggling(server.id)
1794
+ setNotice(null)
1795
+ try {
1796
+ await api(`/servers/${encodeURIComponent(server.id)}`, {
1797
+ method: 'PUT',
1798
+ body: { enabled: server.enabled === false },
1799
+ })
1800
+ await reload()
1801
+ } catch (error) {
1802
+ setNotice(t('error.generic', { message: messageOf(error) }))
1803
+ } finally {
1804
+ setToggling(null)
1805
+ }
1806
+ }
1807
+
1808
+ /** @param {string|null} message */
1809
+ const saved = async (message) => {
1810
+ setNotice(message)
1811
+ setView({ kind: 'list' })
1812
+ await reload()
1813
+ }
1814
+
1815
+ const current = view.kind === 'detail' ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1816
+ const edited = view.kind === 'form' && view.id !== undefined ? servers.find((/** @type {any} */ server) => server.id === view.id) ?? null : null
1817
+
1818
+ return h(
1819
+ 'div',
1820
+ { className: 'srv-page' },
1821
+ h(
1822
+ 'div',
1823
+ { className: 'srv-column' },
1824
+ view.kind === 'list'
1825
+ ? h(ServerList, {
1826
+ state,
1827
+ t,
1828
+ reload,
1829
+ onAdd: () => {
1830
+ setNotice(null)
1831
+ setView({ kind: 'form' })
1832
+ },
1833
+ onOpen: (/** @type {string} */ id) => {
1834
+ setNotice(null)
1835
+ setView({ kind: 'detail', id })
1836
+ },
1837
+ onEdit: (/** @type {string} */ id) => {
1838
+ setNotice(null)
1839
+ setView({ kind: 'form', id })
1840
+ },
1841
+ onTest: test,
1842
+ onToggle: toggle,
1843
+ testing,
1844
+ toggling,
1845
+ notice: notice ?? status,
1846
+ })
1847
+ : null,
1848
+ view.kind === 'detail' && current !== null
1849
+ ? h(ServerDetail, {
1850
+ server: current,
1851
+ rules,
1852
+ t,
1853
+ onBack: () => setView({ kind: 'list' }),
1854
+ onEdit: () => setView({ kind: 'form', id: current.id }),
1855
+ onDelete: () => remove(current),
1856
+ deleting,
1857
+ notice: status,
1858
+ onRefresh: reload,
1859
+ })
1860
+ : null,
1861
+ view.kind === 'detail' && current === null
1862
+ ? h(
1863
+ 'div',
1864
+ { className: 'srv-empty' },
1865
+ h('p', null, t('panel.notFound')),
1866
+ h(Button, { variant: 'outline', onClick: () => setView({ kind: 'list' }) }, t('panel.back')),
1867
+ )
1868
+ : null,
1869
+ view.kind === 'form'
1870
+ ? h(ServerForm, {
1871
+ server: edited,
1872
+ rules,
1873
+ hostKeyModes,
1874
+ t,
1875
+ onCancel: () => setView({ kind: 'list' }),
1876
+ onSaved: saved,
1877
+ })
1878
+ : null,
1879
+ ),
1880
+ )
1881
+ }
1882
+
1883
+ /**
1884
+ * One §8.3 pending row.
1885
+ * @param {{record: any, t: any, onConfirm: (id: string) => Promise<void>, onCancel: (id: string) => Promise<void>, busy: string|null}} props
1886
+ */
1887
+ function PendingRow({ record, t, onConfirm, onCancel, busy }) {
1888
+ const working = busy === record.id
1889
+ const name = typeof record.serverName === 'string' ? record.serverName : record.serverId
1890
+ const title =
1891
+ record.kind === 'delete-server'
1892
+ ? t('pending.deleteServer', { name })
1893
+ : record.kind === 'disable-hostkey'
1894
+ ? t('pending.disableHostkey', { name })
1895
+ : t('pending.disableRule', { rule: String(record.ruleId ?? '?'), name })
1896
+ return h(
1897
+ 'li',
1898
+ null,
1899
+ h(
1900
+ 'div',
1901
+ { className: 'srv-pending-main' },
1902
+ h('span', { className: 'srv-pending-title' }, title),
1903
+ h(
1904
+ 'span',
1905
+ { className: 'srv-pending-meta' },
1906
+ `${t('pending.requestedAt', { time: formatTime(record.requestedAt) })} · ${t('pending.expiresAt', {
1907
+ time: formatTime(record.expiresAt),
1908
+ })}${record.target?.present === false ? ` · ${t('pending.targetMissing')}` : ''}`,
1909
+ ),
1910
+ ),
1911
+ h(
1912
+ 'div',
1913
+ { className: 'srv-row-actions' },
1914
+ h(Button, { size: 'sm', variant: 'primary', disabled: working, onClick: () => void onConfirm(record.id) }, working ? t('pending.busy') : t('pending.confirm')),
1915
+ h(Button, { size: 'sm', variant: 'outline', disabled: working, onClick: () => void onCancel(record.id) }, t('pending.cancel')),
1916
+ ),
1917
+ )
1918
+ }
1919
+
1920
+ /**
1921
+ * One §10 field, rendered from {@link CONFIG_FIELDS}.
1922
+ *
1923
+ * A number field keeps its raw text in the draft so the operator can clear it
1924
+ * and retype without the value snapping back mid-edit; validation happens on
1925
+ * save, not on every keystroke.
1926
+ * @param {{field: any, value: unknown, disabled: boolean, onChange: (value: unknown) => void}} props
1927
+ */
1928
+ function ConfigField({ field, value, disabled, onChange }) {
1929
+ const id = `srv-cfg-${field.key}`
1930
+ if (field.kind === 'boolean') {
1931
+ return h(
1932
+ 'div',
1933
+ { className: 'srv-field' },
1934
+ h(
1935
+ 'label',
1936
+ { className: 'srv-check' },
1937
+ h('input', {
1938
+ id,
1939
+ type: 'checkbox',
1940
+ checked: value === true,
1941
+ disabled,
1942
+ onChange: (/** @type {any} */ event) => onChange(event.target.checked),
1943
+ }),
1944
+ h('span', { className: 'srv-mono' }, field.key),
1945
+ ),
1946
+ )
1947
+ }
1948
+ return h(
1949
+ 'div',
1950
+ { className: 'srv-field' },
1951
+ h('label', { htmlFor: id, className: 'srv-mono' }, field.key),
1952
+ h('input', {
1953
+ id,
1954
+ className: 'srv-input',
1955
+ type: 'number',
1956
+ min: field.min,
1957
+ step: 1,
1958
+ value: value === undefined || value === null ? '' : String(value),
1959
+ disabled,
1960
+ onChange: (/** @type {any} */ event) => onChange(event.target.value),
1961
+ }),
1962
+ )
1963
+ }
1964
+
1965
+ /**
1966
+ * Validate one staged value against the field's own rule.
1967
+ * @param {any} field
1968
+ * @param {unknown} value
1969
+ * @returns {number|boolean|null} the value to save, or null when invalid
1970
+ */
1971
+ function coerceConfigValue(field, value) {
1972
+ if (field.kind === 'boolean') return value === true
1973
+ const text = String(value ?? '').trim()
1974
+ if (text === '') return null
1975
+ const parsed = Number(text)
1976
+ if (!Number.isInteger(parsed) || parsed < field.min) return null
1977
+ return parsed
1978
+ }
1979
+
1980
+ /**
1981
+ * The global-settings card: the nine §10 values, editable and saved live.
1982
+ *
1983
+ * Two shapes, chosen at runtime rather than at build time:
1984
+ *
1985
+ * - `ready` — the Host projected this row into a settings form, so the
1986
+ * values are staged locally and written back as one revision-fenced
1987
+ * batch. The save applies immediately (the Host writes the volatile
1988
+ * cells in place), which is why the copy says so.
1989
+ * - anything else — the form could not be obtained (an older Host without
1990
+ * `configForms`, or a row this deployment does not serve). The card then
1991
+ * renders the values the Host reports through `GET /servers` and says
1992
+ * plainly that they are read-only, instead of showing a dead input.
1993
+ * @param {{t: any, model: any, fallback: Record<string, unknown>|null}} props
1994
+ */
1995
+ function GlobalSettings({ t, model, fallback }) {
1996
+ const [snapshot, setSnapshot] = React.useState(() => model.read())
1997
+ const [draft, setDraft] = React.useState(/** @type {Record<string, unknown>} */ ({}))
1998
+ const [busy, setBusy] = React.useState(false)
1999
+ const [error, setError] = React.useState(/** @type {string|null} */ (null))
2000
+ const [note, setNote] = React.useState(/** @type {string|null} */ (null))
2001
+
2002
+ React.useEffect(() => {
2003
+ const off = model.subscribe(() => setSnapshot(model.read()))
2004
+ setSnapshot(model.read())
2005
+ return off
2006
+ }, [model])
2007
+
2008
+ // A new Host revision discards staged edits: keeping them would let a stale
2009
+ // draft overwrite a value somebody else just wrote.
2010
+ const revision = snapshot.revision
2011
+ const ready = snapshot.status === 'ready' && typeof snapshot.value === 'object' && snapshot.value !== null
2012
+ const values = ready ? /** @type {Record<string, unknown>} */ (snapshot.value) : {}
2013
+ React.useEffect(() => {
2014
+ setDraft({})
2015
+ setError(null)
2016
+ }, [revision])
2017
+
2018
+ const dirty = Object.keys(draft).length > 0
2019
+ const current = (/** @type {any} */ field) => (Object.hasOwn(draft, field.key) ? draft[field.key] : values[field.key])
2020
+
2021
+ /** @param {string} key @param {unknown} value */
2022
+ const stage = (key, value) => {
2023
+ setNote(null)
2024
+ setError(null)
2025
+ setDraft((/** @type {Record<string, unknown>} */ now) => ({ ...now, [key]: value }))
2026
+ }
2027
+
2028
+ const save = async () => {
2029
+ /** @type {Array<{op: string, path: string[], value?: unknown}>} */
2030
+ const ops = []
2031
+ for (const field of CONFIG_FIELDS) {
2032
+ if (!Object.hasOwn(draft, field.key)) continue
2033
+ const value = coerceConfigValue(field, draft[field.key])
2034
+ if (value === null) {
2035
+ setError(t('settings.invalid', { field: field.key }))
2036
+ return
2037
+ }
2038
+ if (value === values[field.key]) continue
2039
+ ops.push({ op: 'set', path: [field.key], value })
2040
+ }
2041
+ if (ops.length === 0) {
2042
+ setDraft({})
2043
+ return
2044
+ }
2045
+ setBusy(true)
2046
+ setError(null)
2047
+ setNote(null)
2048
+ try {
2049
+ const landed = await model.write(ops)
2050
+ if (landed) {
2051
+ setDraft({})
2052
+ setNote(t('settings.saved'))
2053
+ } else {
2054
+ // The Host refused: either the row is not writable or the revision
2055
+ // moved under us. The scope re-reads on its own, so the page follows.
2056
+ setError(t(snapshot.writable === true ? 'settings.conflict' : 'settings.rejected'))
2057
+ }
2058
+ } finally {
2059
+ setBusy(false)
2060
+ setSnapshot(model.read())
2061
+ }
2062
+ }
2063
+
2064
+ const header = h(
2065
+ 'div',
2066
+ { className: 'srv-card-head' },
2067
+ h('h3', { className: 'srv-card-title' }, t('settings.global')),
2068
+ dirty ? h('span', { className: 'srv-tag', 'data-tone': 'warn' }, t('settings.dirty')) : null,
2069
+ )
2070
+
2071
+ if (!ready) {
2072
+ const readOnly = snapshot.status === 'unavailable' || model.hasService() === false
2073
+ return h(
2074
+ 'section',
2075
+ { className: 'srv-card' },
2076
+ header,
2077
+ h('p', { className: 'srv-hint' }, readOnly ? t('settings.globalReadonly') : t('settings.globalLoading')),
2078
+ readOnly && fallback !== null
2079
+ ? h(
2080
+ 'dl',
2081
+ { className: 'srv-kv' },
2082
+ CONFIG_FIELDS.map((/** @type {any} */ field) =>
2083
+ h(
2084
+ F,
2085
+ { key: field.key },
2086
+ h('dt', { className: 'srv-mono' }, field.key),
2087
+ h('dd', { className: 'srv-mono' }, String(fallback[field.key] ?? '—')),
2088
+ ),
2089
+ ),
2090
+ )
2091
+ : null,
2092
+ )
2093
+ }
2094
+
2095
+ const locked = snapshot.writable !== true || busy
2096
+ return h(
2097
+ 'section',
2098
+ { className: 'srv-card' },
2099
+ header,
2100
+ h('p', { className: 'srv-hint' }, t('settings.globalHint')),
2101
+ h(
2102
+ 'div',
2103
+ { className: 'srv-cfg-grid' },
2104
+ CONFIG_FIELDS.map((/** @type {any} */ field) =>
2105
+ h(ConfigField, {
2106
+ key: field.key,
2107
+ field,
2108
+ value: current(field),
2109
+ disabled: locked,
2110
+ onChange: (/** @type {unknown} */ value) => stage(field.key, value),
2111
+ }),
2112
+ ),
2113
+ ),
2114
+ error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
2115
+ note === null ? null : h('p', { className: 'srv-note' }, note),
2116
+ h(
2117
+ 'div',
2118
+ { className: 'srv-actions' },
2119
+ h(
2120
+ Button,
2121
+ { variant: 'primary', disabled: locked || !dirty, onClick: () => void save() },
2122
+ busy ? t('settings.saving') : t('settings.save'),
2123
+ ),
2124
+ h(
2125
+ Button,
2126
+ {
2127
+ variant: 'outline',
2128
+ disabled: locked || !dirty,
2129
+ onClick: () => {
2130
+ setDraft({})
2131
+ setError(null)
2132
+ setNote(null)
2133
+ },
2134
+ },
2135
+ t('settings.discard'),
2136
+ ),
2137
+ ),
2138
+ )
2139
+ }
2140
+
2141
+ /**
2142
+ * The `settings.section` page: the global settings (now editable) and the
2143
+ * pending queue with its red count badge (§8.3).
2144
+ *
2145
+ * `model` arrives from the slot's `inject` callback. A render outside that
2146
+ * path (a test, or a Host that mounts the section without running `inject`)
2147
+ * falls back to a detached model, which reads as "unavailable" and shows the
2148
+ * read-only view rather than throwing inside the page.
2149
+ * @param {{t: any, model?: any}} props
2150
+ */
2151
+ function SettingsPage({ t, model }) {
2152
+ const settingsModel = model === undefined || model === null ? DETACHED_CONFIG_MODEL : model
2153
+ const { state, reload } = useServers()
2154
+ const [pending, setPending] = React.useState({ phase: 'loading' })
2155
+ const [busy, setBusy] = React.useState(/** @type {string|null} */ (null))
2156
+ const [error, setError] = React.useState(/** @type {string|null} */ (null))
2157
+
2158
+ const reloadPending = React.useCallback(async () => {
2159
+ try {
2160
+ const answer = await api('/pending')
2161
+ setPending({ phase: 'ready', records: answer.pending })
2162
+ } catch (failure) {
2163
+ setPending({ phase: 'error', error: failure })
2164
+ }
2165
+ }, [])
2166
+
2167
+ React.useEffect(() => {
2168
+ void reloadPending()
2169
+ }, [reloadPending])
2170
+
2171
+ /** @param {'confirm'|'cancel'} action @param {string} id */
2172
+ const act = async (action, id) => {
2173
+ setBusy(id)
2174
+ setError(null)
2175
+ try {
2176
+ await api(`/pending/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: {} })
2177
+ await Promise.all([reloadPending(), reload()])
2178
+ } catch (failure) {
2179
+ setError(t('error.generic', { message: messageOf(failure) }))
2180
+ } finally {
2181
+ setBusy(null)
2182
+ }
2183
+ }
2184
+
2185
+ const records = pending.phase === 'ready' ? pending.records : []
2186
+ const config = state.phase === 'ready' ? state.data.config : null
2187
+ return h(
2188
+ 'div',
2189
+ { className: 'srv-page' },
2190
+ h(
2191
+ 'div',
2192
+ { className: 'srv-column' },
2193
+ h('h2', null, t('settings.title')),
2194
+ h('p', { className: 'srv-hint' }, t('settings.description')),
2195
+ h(
2196
+ 'section',
2197
+ { className: 'srv-card' },
2198
+ h(
2199
+ 'div',
2200
+ { className: 'srv-card-head' },
2201
+ h('h3', { className: 'srv-card-title' }, t('settings.pending')),
2202
+ h('span', { className: 'srv-badge', hidden: records.length === 0 }, String(records.length)),
2203
+ ),
2204
+ h('p', { className: 'srv-hint' }, t('settings.pendingHint')),
2205
+ error === null ? null : h('p', { className: 'srv-error', role: 'alert' }, error),
2206
+ records.length === 0 ? h('p', { className: 'srv-hint' }, t('settings.pendingEmpty')) : null,
2207
+ records.length > 0
2208
+ ? h(
2209
+ 'ul',
2210
+ { className: 'srv-pending' },
2211
+ records.map((/** @type {any} */ record) =>
2212
+ h(PendingRow, {
2213
+ key: record.id,
2214
+ record,
2215
+ t,
2216
+ busy,
2217
+ onConfirm: (/** @type {string} */ id) => act('confirm', id),
2218
+ onCancel: (/** @type {string} */ id) => act('cancel', id),
2219
+ }),
2220
+ ),
2221
+ )
2222
+ : null,
2223
+ ),
2224
+ h(GlobalSettings, { t, model: settingsModel, fallback: config }),
2225
+ ),
2226
+ )
2227
+ }
2228
+
2229
+ // ---------------------------------------------------------------- mount --
2230
+
2231
+ /** Services this bundle needs; `slots` carries the three mount points, `locale` the copy. */
2232
+ const inject = ['slots', 'locale']
2233
+
2234
+ /**
2235
+ * Register one mount point, tolerating three separate failure modes.
2236
+ *
2237
+ * 1. **The seat is already taken by us.** The shipped slot registry refuses a
2238
+ * second registration of the same id at the same priority (`list slot "…"
2239
+ * already has an entry with id "…"`). An update can leave exactly that
2240
+ * state: HMR replaces the bundle while the previous registration is still
2241
+ * in the table. `dsh-context` guards it with an `alreadyMounted()` check
2242
+ * before every `register()`; so does this helper. Without the guard the
2243
+ * throw below would fire, and see (2).
2244
+ * 2. **A throw inside `slots.inject`'s callback retires that injection for the
2245
+ * fiber's remaining lifetime.** Measured behaviour, not a guess: it is
2246
+ * never retried, so the contribution stays missing until the process
2247
+ * restarts. Reusing an existing seat instead of racing it is what keeps a
2248
+ * re-apply from reaching this state.
2249
+ * 3. **A throw out of `apply()` takes the rest of the batch down.** The client
2250
+ * bundle batch is one concatenated classic script, so an uncaught exception
2251
+ * here also skips every later bundle's registration.
2252
+ *
2253
+ * Each seat is therefore registered through here: an occupied seat is left
2254
+ * alone, and any remaining failure degrades to "this one entry is absent"
2255
+ * rather than "no panel at all".
2256
+ * @param {any} ctx - the client plugin context.
2257
+ * @param {string} name - the slot name.
2258
+ * @param {{id?: string, key?: string}} identity - how the registry tells our entry from others.
2259
+ * @param {() => any} register - registers the contribution, returns its disposer.
2260
+ * @returns {void}
2261
+ */
2262
+ function mount(ctx, name, identity, register) {
2263
+ try {
2264
+ if (alreadyMounted(ctx, name, identity)) return
2265
+ ctx.slots.inject(name, () => {
2266
+ // Re-check inside the callback: the injected contribution runs later, so
2267
+ // the seat may have been filled in the meantime.
2268
+ if (alreadyMounted(ctx, name, identity)) return undefined
2269
+ try {
2270
+ return register()
2271
+ } catch (error) {
2272
+ console.error(`dsh-server-connector: registering ${name} failed; that entry stays hidden`, error)
2273
+ return undefined
2274
+ }
2275
+ })
2276
+ } catch (error) {
2277
+ console.error(`dsh-server-connector: this DSH does not accept a ${name} entry`, error)
2278
+ }
2279
+ }
2280
+
2281
+ /**
2282
+ * Whether this bundle already occupies a seat.
2283
+ *
2284
+ * A `list` slot is identified by `id` and a `keyed` slot by `key`; that split
2285
+ * is the registry's own (`lib/client/ui-slots`: the duplicate checks read
2286
+ * `options.id` for list and `options.key` for keyed entries). `entries()` is
2287
+ * the registry's read face; a Host that does not expose it reports "not
2288
+ * mounted", which keeps the plain `register()` path working there.
2289
+ * @param {any} ctx - the client plugin context.
2290
+ * @param {string} name - the slot name.
2291
+ * @param {{id?: string, key?: string}} identity
2292
+ * @returns {boolean}
2293
+ */
2294
+ function alreadyMounted(ctx, name, identity) {
2295
+ const entriesOf = ctx.slots.entriesOfSlot ?? ctx.slots.entries
2296
+ if (typeof entriesOf !== 'function') return false
2297
+ try {
2298
+ const entries = entriesOf.call(ctx.slots, name)
2299
+ if (!Array.isArray(entries)) return false
2300
+ return entries.some((/** @type {any} */ entry) => {
2301
+ const options = entry?.options
2302
+ if (options === null || options === undefined) return false
2303
+ if (identity.id !== undefined) return options.id === identity.id
2304
+ if (identity.key !== undefined) return options.key === identity.key
2305
+ return false
2306
+ })
2307
+ } catch {
2308
+ return false
2309
+ }
2310
+ }
2311
+
2312
+ /**
2313
+ * Register the dictionaries and the three §8.1 contributions.
2314
+ *
2315
+ * This function must never throw: `apply()` runs inside the client bundle batch
2316
+ * (see {@link mount}), and an escaping exception would silently unship every
2317
+ * later bundle in that batch as well.
2318
+ * @param {any} ctx - the browser plugin context.
2319
+ */
2320
+ function apply(ctx) {
2321
+ try {
2322
+ installStyles()
2323
+ } catch (error) {
2324
+ // A page without a DOM (a server renderer, a test) simply gets no styles;
2325
+ // the panel still mounts, just unstyled.
2326
+ console.error('dsh-server-connector: installing the stylesheet failed', error)
2327
+ }
2328
+ try {
2329
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'server-connector: dictionaries')
2330
+ } catch (error) {
2331
+ // Without the dictionaries every label renders as its raw key — ugly, but
2332
+ // far better than unshipping the panel over a copy table.
2333
+ console.error('dsh-server-connector: registering the dictionaries failed', error)
2334
+ }
2335
+
2336
+ // The editable global settings. `configForms` is an optional service and
2337
+ // is therefore reached through `ctx.inject` rather than the bundle's own
2338
+ // `inject` array: a Host without it still gets the panel and the tools,
2339
+ // and the settings card degrades to the read-only view.
2340
+ const configModel = createConfigModel()
2341
+ bindConfigModel(ctx, configModel)
2342
+
2343
+ mount(ctx, 'sidebar.panellist', { id: PANEL_ID }, () =>
2344
+ ctx.slots.register(
2345
+ {
2346
+ name: 'sidebar.panellist',
2347
+ id: PANEL_ID,
2348
+ order: 10,
2349
+ label: () => ctx.locale.bind(NS)('panel.label'),
2350
+ locale: NS,
2351
+ },
2352
+ ServerIcon,
2353
+ ),
2354
+ )
2355
+
2356
+ mount(ctx, 'main', { key: PANEL_ID }, () =>
2357
+ ctx.slots.register(
2358
+ {
2359
+ name: 'main',
2360
+ key: PANEL_ID,
2361
+ locale: NS,
2362
+ inject: () => ({}),
2363
+ },
2364
+ ServerPanel,
2365
+ ),
2366
+ )
2367
+
2368
+ mount(ctx, 'settings.section', { id: PANEL_ID }, () =>
2369
+ ctx.slots.register(
2370
+ {
2371
+ name: 'settings.section',
2372
+ id: PANEL_ID,
2373
+ order: 25,
2374
+ label: () => ctx.locale.bind(NS)('settings.nav'),
2375
+ locale: NS,
2376
+ inject: () => ({ model: configModel }),
2377
+ },
2378
+ SettingsPage,
2379
+ ),
2380
+ )
2381
+ }
2382
+
2383
+ exports.NS = NS
2384
+ exports.PANEL_ID = PANEL_ID
2385
+ exports.apply = apply
2386
+ exports.inject = inject
2387
+ return module.exports
2388
+ },
2389
+ })