dsh-server-connector 0.2.2 → 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/CHANGELOG.md +88 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1720
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/client.js
CHANGED
|
@@ -1,1720 +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.
|
|
72
|
-
'panel.
|
|
73
|
-
'panel.
|
|
74
|
-
'panel.
|
|
75
|
-
'panel.
|
|
76
|
-
'panel.
|
|
77
|
-
'panel.
|
|
78
|
-
'panel.
|
|
79
|
-
'
|
|
80
|
-
'
|
|
81
|
-
'
|
|
82
|
-
'
|
|
83
|
-
'
|
|
84
|
-
'
|
|
85
|
-
'
|
|
86
|
-
'field.
|
|
87
|
-
'field.
|
|
88
|
-
'field.
|
|
89
|
-
'field.
|
|
90
|
-
'field.
|
|
91
|
-
'
|
|
92
|
-
'
|
|
93
|
-
'
|
|
94
|
-
'
|
|
95
|
-
'
|
|
96
|
-
'
|
|
97
|
-
'
|
|
98
|
-
'
|
|
99
|
-
'
|
|
100
|
-
'
|
|
101
|
-
'
|
|
102
|
-
'
|
|
103
|
-
'
|
|
104
|
-
'form.
|
|
105
|
-
'form.
|
|
106
|
-
'form.
|
|
107
|
-
'form.
|
|
108
|
-
'form.
|
|
109
|
-
'form.
|
|
110
|
-
'form.
|
|
111
|
-
'form.
|
|
112
|
-
'form.
|
|
113
|
-
'form.
|
|
114
|
-
'form.
|
|
115
|
-
'form.
|
|
116
|
-
'
|
|
117
|
-
'
|
|
118
|
-
'
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'settings.
|
|
123
|
-
'settings.
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
'
|
|
130
|
-
'
|
|
131
|
-
'
|
|
132
|
-
'
|
|
133
|
-
'
|
|
134
|
-
'
|
|
135
|
-
'
|
|
136
|
-
'
|
|
137
|
-
'
|
|
138
|
-
'
|
|
139
|
-
'
|
|
140
|
-
'
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
146
|
-
'
|
|
147
|
-
'
|
|
148
|
-
'
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
'
|
|
154
|
-
'
|
|
155
|
-
'
|
|
156
|
-
'
|
|
157
|
-
'
|
|
158
|
-
'
|
|
159
|
-
'
|
|
160
|
-
'
|
|
161
|
-
'
|
|
162
|
-
'
|
|
163
|
-
'
|
|
164
|
-
'
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
'panel.
|
|
170
|
-
'panel.
|
|
171
|
-
'panel.
|
|
172
|
-
'panel.
|
|
173
|
-
'panel.
|
|
174
|
-
'panel.
|
|
175
|
-
'panel.
|
|
176
|
-
'panel.
|
|
177
|
-
'panel.
|
|
178
|
-
'panel.
|
|
179
|
-
'panel.
|
|
180
|
-
'panel.
|
|
181
|
-
'panel.
|
|
182
|
-
'
|
|
183
|
-
'
|
|
184
|
-
'
|
|
185
|
-
'
|
|
186
|
-
'
|
|
187
|
-
'
|
|
188
|
-
'
|
|
189
|
-
'
|
|
190
|
-
'
|
|
191
|
-
'
|
|
192
|
-
'
|
|
193
|
-
'
|
|
194
|
-
'
|
|
195
|
-
'
|
|
196
|
-
'
|
|
197
|
-
'
|
|
198
|
-
'
|
|
199
|
-
'
|
|
200
|
-
'
|
|
201
|
-
'
|
|
202
|
-
'
|
|
203
|
-
'
|
|
204
|
-
'
|
|
205
|
-
'
|
|
206
|
-
'
|
|
207
|
-
'
|
|
208
|
-
'
|
|
209
|
-
'
|
|
210
|
-
'
|
|
211
|
-
'
|
|
212
|
-
'
|
|
213
|
-
'
|
|
214
|
-
'
|
|
215
|
-
'
|
|
216
|
-
'
|
|
217
|
-
'
|
|
218
|
-
'
|
|
219
|
-
'
|
|
220
|
-
'
|
|
221
|
-
'
|
|
222
|
-
'
|
|
223
|
-
'
|
|
224
|
-
'
|
|
225
|
-
'
|
|
226
|
-
'
|
|
227
|
-
'
|
|
228
|
-
'
|
|
229
|
-
'
|
|
230
|
-
'
|
|
231
|
-
'
|
|
232
|
-
'
|
|
233
|
-
'
|
|
234
|
-
'
|
|
235
|
-
'
|
|
236
|
-
'
|
|
237
|
-
'
|
|
238
|
-
'
|
|
239
|
-
'
|
|
240
|
-
'
|
|
241
|
-
'
|
|
242
|
-
'
|
|
243
|
-
'
|
|
244
|
-
'
|
|
245
|
-
'
|
|
246
|
-
'
|
|
247
|
-
'
|
|
248
|
-
'
|
|
249
|
-
'
|
|
250
|
-
'
|
|
251
|
-
'
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
'.
|
|
259
|
-
'
|
|
260
|
-
'.
|
|
261
|
-
'.
|
|
262
|
-
'.
|
|
263
|
-
'
|
|
264
|
-
'.
|
|
265
|
-
'.
|
|
266
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'.
|
|
269
|
-
'.
|
|
270
|
-
'.
|
|
271
|
-
'.
|
|
272
|
-
'.
|
|
273
|
-
'.
|
|
274
|
-
'.
|
|
275
|
-
'.
|
|
276
|
-
'.
|
|
277
|
-
'.
|
|
278
|
-
'.
|
|
279
|
-
'.
|
|
280
|
-
'.
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
'.srv-
|
|
291
|
-
'
|
|
292
|
-
'.srv-
|
|
293
|
-
'.srv-
|
|
294
|
-
'.srv-
|
|
295
|
-
'
|
|
296
|
-
'
|
|
297
|
-
'.srv-
|
|
298
|
-
'
|
|
299
|
-
'
|
|
300
|
-
'.srv-
|
|
301
|
-
'.srv-
|
|
302
|
-
'.srv-
|
|
303
|
-
'padding:0
|
|
304
|
-
'.srv-
|
|
305
|
-
'
|
|
306
|
-
'.srv-
|
|
307
|
-
'color:var(--dsw-alias-state-
|
|
308
|
-
'.srv-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
'.srv-
|
|
312
|
-
'
|
|
313
|
-
'.srv-
|
|
314
|
-
'.srv-
|
|
315
|
-
'.srv-
|
|
316
|
-
'.srv-
|
|
317
|
-
'.srv-
|
|
318
|
-
'
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
'.srv-
|
|
330
|
-
'
|
|
331
|
-
'background:
|
|
332
|
-
'.srv-
|
|
333
|
-
'.srv-
|
|
334
|
-
'
|
|
335
|
-
'
|
|
336
|
-
'
|
|
337
|
-
'
|
|
338
|
-
'.srv-
|
|
339
|
-
'.srv-
|
|
340
|
-
'.srv-
|
|
341
|
-
'
|
|
342
|
-
'.srv-
|
|
343
|
-
'.srv-
|
|
344
|
-
'
|
|
345
|
-
'.srv-
|
|
346
|
-
'.srv-
|
|
347
|
-
'.srv-
|
|
348
|
-
'
|
|
349
|
-
'border-
|
|
350
|
-
'
|
|
351
|
-
'.srv-
|
|
352
|
-
'
|
|
353
|
-
'
|
|
354
|
-
'.srv-
|
|
355
|
-
'.srv-
|
|
356
|
-
'.srv-
|
|
357
|
-
'
|
|
358
|
-
'
|
|
359
|
-
'
|
|
360
|
-
'.srv-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
'
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
)
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
.
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
)
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
}
|
|
997
|
-
)
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
{
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
h(
|
|
1199
|
-
'div',
|
|
1200
|
-
{ className: 'srv-
|
|
1201
|
-
h(
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
h(
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
h('
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
'
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
className: 'srv-
|
|
1283
|
-
type
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
'
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
{
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
:
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
h(
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
'
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
?
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
{ className: 'srv-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
),
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
{
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1
|
+
// dsh-server-connector — Client half (design doc §8).
|
|
2
|
+
//
|
|
3
|
+
// Three mount points, all taken from the confirmed live Slot tree (§8.1):
|
|
4
|
+
//
|
|
5
|
+
// sidebar.panellist → the "服务器" icon in the left rail
|
|
6
|
+
// main (key: "server-connector") → server list / detail / form
|
|
7
|
+
// settings.section → global settings + the pending two-step queue
|
|
8
|
+
//
|
|
9
|
+
// The file is a browser module-loader bundle, not a Node module: the DSH client
|
|
10
|
+
// module system loads it through `window.__ModuleLoader__.load`, and `require`
|
|
11
|
+
// is the page's static table (react, react/jsx-runtime, …). It is hand-authored
|
|
12
|
+
// — this repository has no client build step — so it uses `React.createElement`
|
|
13
|
+
// rather than JSX and keeps every browser global behind `browserGlobal` so the
|
|
14
|
+
// Node-side `tsc --noEmit` pass (tsconfig lib is ES2023, no DOM) stays clean.
|
|
15
|
+
//
|
|
16
|
+
// Two product constraints shape the UI:
|
|
17
|
+
// - the panel talks to the Host only through the §8.5 JSON routes, and it
|
|
18
|
+
// never receives a secret (a password is write-only, §6.3);
|
|
19
|
+
// - the panel has no turn, so `ctx.approval` is unavailable (§11): deleting a
|
|
20
|
+
// server, switching host-key verification off and disabling a rule all write
|
|
21
|
+
// to the pending queue and say so. Nothing dangerous happens on one click.
|
|
22
|
+
|
|
23
|
+
/** The page global, typed loosely: this bundle runs in the browser, not in Node. */
|
|
24
|
+
const browserGlobal = /** @type {any} */ (globalThis)
|
|
25
|
+
|
|
26
|
+
browserGlobal.__ModuleLoader__.load({
|
|
27
|
+
id: 'dsh-server-connector',
|
|
28
|
+
factory: (/** @type {(id: string) => any} */ load) => {
|
|
29
|
+
const module = { exports: /** @type {Record<string, unknown>} */ ({}) }
|
|
30
|
+
const exports = module.exports
|
|
31
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
32
|
+
|
|
33
|
+
const React = load('react')
|
|
34
|
+
const h = React.createElement
|
|
35
|
+
const F = React.Fragment
|
|
36
|
+
|
|
37
|
+
/** Dictionary namespace owned by this plugin. */
|
|
38
|
+
const NS = 'serverConnector'
|
|
39
|
+
/** The id shared by the sidebar entry, the main panel key and the settings page. */
|
|
40
|
+
const PANEL_ID = 'server-connector'
|
|
41
|
+
/** Mount path of the Host routes (§8.5). */
|
|
42
|
+
const API = '/server-connector'
|
|
43
|
+
/** A displayed audit tail matches the detail page (§8.2). */
|
|
44
|
+
const AUDIT_LIMIT = 20
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------- copy --
|
|
47
|
+
|
|
48
|
+
/** Chinese dictionary, the source of truth. */
|
|
49
|
+
const zh = {
|
|
50
|
+
'panel.label': '服务器',
|
|
51
|
+
'panel.title': '服务器',
|
|
52
|
+
'panel.add': '新增',
|
|
53
|
+
'panel.loading': '读取服务器列表…',
|
|
54
|
+
'panel.retry': '重试',
|
|
55
|
+
'panel.empty': '还没有配置服务器。',
|
|
56
|
+
'panel.emptyHint': '新增一台后,模型即可通过 srv_* 工具在这台机器上执行命令。',
|
|
57
|
+
'panel.connected': '已连接',
|
|
58
|
+
'panel.disconnected': '未连接',
|
|
59
|
+
'panel.test': '测试',
|
|
60
|
+
'panel.testing': '测试中…',
|
|
61
|
+
'panel.edit': '编辑',
|
|
62
|
+
'panel.enable': '启用',
|
|
63
|
+
'panel.disable': '禁用',
|
|
64
|
+
'panel.disabled': '已禁用',
|
|
65
|
+
'panel.disconnectFirst': '已连接的服务器不能禁用,请先断开',
|
|
66
|
+
'panel.disabledNote': '已禁用:srv_list 不再返回它,任何 srv_* 调用都会被拒绝。',
|
|
67
|
+
'panel.back': '返回列表',
|
|
68
|
+
'panel.delete': '删除',
|
|
69
|
+
'panel.deleteHint': '删除会先写入待确认区,确认后才真正移除。',
|
|
70
|
+
'panel.deleteQueued': '删除请求已加入待确认区,服务器仍然可用;请到设置页确认。',
|
|
71
|
+
'panel.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
|
+
})
|