agent-comm-hub 0.6.0 → 0.7.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.
@@ -0,0 +1,1234 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>agent-comm-hub admin console</title>
7
+ <link rel="icon" href="data:image/png;base64,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">
8
+ <style>
9
+ /* ── tokens ────────────────────────────────────────────────────────────
10
+ Notion-style console theme: warm neutral greys, one quiet blue accent
11
+ (#2383e2), hairline borders instead of glow — no gradients, no glass, no
12
+ neon. Feedback tones (ok / warn / danger) stay muted washes. Motion stays
13
+ on transform/opacity only, short and interruptible (apple-design: instant
14
+ material feedback, calm layer transitions). */
15
+ :root {
16
+ color-scheme: dark;
17
+ /* Notion-style dark surface: warm neutral greys, one quiet blue accent,
18
+ hairline borders instead of glow — no gradients, no glass, no neon. */
19
+ --bg: #191919;
20
+ --surface: #1f1f1f;
21
+ --surface-2: #252525;
22
+ --surface-3: #2b2b2b;
23
+ --line: #2e2e2e;
24
+ --line-strong: #3f3f3f;
25
+ --line-hover: #4a4a4a;
26
+ --text: #f1f1ef;
27
+ --text-dim: #a8a8a3;
28
+ --text-faint: #6f6f6a;
29
+ --accent: #2383e2;
30
+ --accent-strong: #2f93f2;
31
+ --accent-line: rgba(35, 131, 226, .35);
32
+ --accent-wash: rgba(35, 131, 226, .12);
33
+ --ok: #4dab5f;
34
+ --ok-wash: rgba(77, 171, 95, .12);
35
+ --ok-line: rgba(77, 171, 95, .38);
36
+ --danger: #d94442;
37
+ --danger-wash: rgba(217, 68, 66, .12);
38
+ --danger-line: rgba(217, 68, 66, .38);
39
+ --warn: #d58f3e;
40
+ --warn-wash: rgba(213, 143, 62, .12);
41
+ --warn-line: rgba(213, 143, 62, .38);
42
+ --radius: 6px;
43
+ --radius-sm: 4px;
44
+ --shadow-toast: 0 8px 24px rgba(0, 0, 0, .42);
45
+ --font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
46
+ --font-mono: ui-monospace, "Cascadia Mono", "SF Mono", Consolas, "Liberation Mono", monospace;
47
+ }
48
+ * { box-sizing: border-box; }
49
+ html { scroll-behavior: smooth; }
50
+ html, body { margin: 0; min-height: 100%; }
51
+ body {
52
+ background: var(--bg);
53
+ color: var(--text);
54
+ font: 13.5px/1.6 var(--font-ui);
55
+ -webkit-font-smoothing: antialiased;
56
+ }
57
+ ::selection { background: var(--accent-wash); }
58
+ button, input, select { font: inherit; color: inherit; }
59
+
60
+ /* ── form primitives: quiet Notion controls, blue focus ring ── */
61
+ select, input {
62
+ background: var(--bg);
63
+ border: 1px solid var(--line-strong);
64
+ border-radius: var(--radius-sm);
65
+ padding: 5px 9px;
66
+ min-height: 30px;
67
+ font: 12.5px/1.4 var(--font-mono);
68
+ color: var(--text);
69
+ transition: border-color .12s ease-out, background .12s ease-out;
70
+ }
71
+ select { font: 12.5px/1.4 var(--font-ui); }
72
+ select:focus, input:focus {
73
+ outline: none; border-color: var(--accent);
74
+ background: var(--surface-2); box-shadow: 0 0 0 2px var(--accent-wash);
75
+ }
76
+ input::placeholder { color: var(--text-faint); }
77
+ input:hover, select:hover { border-color: var(--line-hover); }
78
+ button {
79
+ background: var(--surface-2);
80
+ border: 1px solid var(--line-strong);
81
+ border-radius: var(--radius-sm);
82
+ padding: 4px 12px;
83
+ min-height: 30px;
84
+ cursor: pointer;
85
+ color: var(--text);
86
+ font: 500 12.5px/1 var(--font-ui);
87
+ transition: background .12s ease-out, border-color .12s ease-out, color .12s ease-out;
88
+ white-space: nowrap;
89
+ }
90
+ button:hover { background: var(--surface-3); }
91
+ button:active { transform: translateY(1px); }
92
+ button.primary {
93
+ background: var(--accent);
94
+ border-color: var(--accent);
95
+ color: #fff;
96
+ font-weight: 600;
97
+ }
98
+ button.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
99
+ button.danger { color: var(--danger); border-color: transparent; background: transparent; }
100
+ button.danger:hover { background: var(--danger-wash); border-color: var(--danger); color: var(--danger); }
101
+ button.ghost { background: transparent; border-color: transparent; color: var(--text-dim); min-height: 26px; padding: 2px 8px; }
102
+ button.ghost:hover { color: var(--text); background: var(--surface-2); }
103
+ :is(button, input, select):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
104
+ button:disabled { opacity: .45; cursor: default; transform: none; }
105
+
106
+ /* ── topbar: solid surface + hairline bottom edge (no glass) ── */
107
+ .topbar {
108
+ position: sticky; top: 0; z-index: 20;
109
+ display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
110
+ padding: 10px 22px;
111
+ background: var(--bg);
112
+ border-bottom: 1px solid var(--line);
113
+ transition: border-color .2s ease-out;
114
+ }
115
+ .topbar.scrolled { border-bottom-color: var(--line-strong); }
116
+ .brand { display: flex; align-items: center; gap: 10px; margin-right: 6px; }
117
+ .brand .mark {
118
+ width: 24px; height: 24px; border-radius: 5px;
119
+ background: var(--surface-2);
120
+ display: grid; place-items: center;
121
+ overflow: hidden;
122
+ }
123
+ .brand .mark img { width: 18px; height: 18px; object-fit: contain; display: block; }
124
+ .brand .name { font-size: 13px; font-weight: 600; color: var(--text); line-height: 1.3; }
125
+ .brand .name b { color: var(--text); font-weight: 600; }
126
+ .brand .name span { color: var(--text-dim); font-weight: 400; }
127
+ .brand .sub { font-size: 11px; color: var(--text-faint); }
128
+ .status {
129
+ display: inline-flex; align-items: center; gap: 7px;
130
+ font-size: 12px; color: var(--text-dim);
131
+ white-space: nowrap;
132
+ }
133
+ .status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-faint); flex: none; }
134
+ .status[data-state="ok"] .dot { background: var(--ok); animation: breathe 2.6s ease-in-out infinite; }
135
+ .status[data-state="err"] { color: var(--danger); }
136
+ .status[data-state="err"] .dot { background: var(--danger); }
137
+ @keyframes breathe { 50% { opacity: .45; } }
138
+ .topbar .field { display: flex; align-items: center; gap: 6px; }
139
+ .topbar .field label { font-size: 11px; color: var(--text-faint); }
140
+ .topbar .field input { min-height: 28px; padding: 4px 8px; font-size: 12px; }
141
+ #token { width: 240px; }
142
+ #mpath { width: 84px; }
143
+ .spacer { flex: 1; }
144
+
145
+ /* ── stats strip: plain numbers + labels, quiet hover (anchors to panels) ── */
146
+ .stats {
147
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
148
+ gap: 10px; max-width: 1360px; margin: 16px auto 0; padding: 0 22px;
149
+ }
150
+ .stat {
151
+ display: flex; align-items: baseline; gap: 8px;
152
+ border: 1px solid var(--line);
153
+ border-radius: var(--radius-sm);
154
+ padding: 10px 14px;
155
+ text-decoration: none; color: inherit;
156
+ transition: background .12s ease-out, border-color .12s ease-out;
157
+ }
158
+ .stat:hover { background: var(--surface-2); border-color: var(--line-strong); }
159
+ .stat .num { font: 600 17px/1 var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text); }
160
+ .stat .lbl { font-size: 11px; color: var(--text-faint); }
161
+ .stat[data-tone="ok"] .num { color: var(--ok); }
162
+ .stat[data-tone="warn"] .num { color: var(--warn); }
163
+
164
+ /* ── workspace ──────────────────────────────────────────────────────── */
165
+ .workspace {
166
+ max-width: 1360px; margin: 16px auto 32px;
167
+ padding: 0 22px;
168
+ display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(330px, 1fr);
169
+ gap: 14px; align-items: start;
170
+ }
171
+ .panel {
172
+ background: var(--surface);
173
+ border: 1px solid var(--line);
174
+ border-radius: var(--radius);
175
+ overflow: hidden;
176
+ scroll-margin-top: 62px; /* anchor lands below the sticky topbar */
177
+ }
178
+ .stack { display: grid; gap: 14px; }
179
+ .panel > header {
180
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
181
+ padding: 11px 16px;
182
+ border-bottom: 1px solid var(--line);
183
+ }
184
+ .panel > header h2 {
185
+ margin: 0; font-size: 13px; font-weight: 600; color: var(--text);
186
+ }
187
+ .panel > header .count {
188
+ font: 500 11px/1.4 var(--font-mono); color: var(--text-dim);
189
+ background: var(--surface-3); border-radius: 999px; padding: 2px 8px;
190
+ font-variant-numeric: tabular-nums;
191
+ }
192
+ .panel > header .hint { font-size: 11px; color: var(--text-faint); }
193
+ .panel > header .spacer { flex: 1; }
194
+ .toolbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
195
+
196
+ /* ── list rows ──────────────────────────────────────────────────────── */
197
+ .rows { display: flex; flex-direction: column; }
198
+ .row, .g-row {
199
+ display: flex; align-items: center; gap: 12px;
200
+ padding: 11px 16px;
201
+ border-bottom: 1px solid var(--line);
202
+ transition: background .12s ease-out;
203
+ animation: row-in .18s ease-out; /* gentle fade-in on enter only */
204
+ }
205
+ @keyframes row-in { from { opacity: 0; transform: translateY(2px); } }
206
+ .row:last-child, .g-row:last-child { border-bottom: none; }
207
+ .row:hover, .g-row:hover { background: var(--surface-2); }
208
+ .presence { width: 8px; height: 8px; border-radius: 50%; background: var(--text-faint); flex: none; }
209
+ .row[data-online="true"] .presence { background: var(--ok); }
210
+ .who { min-width: 0; flex: 1; }
211
+ .who .id { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
212
+ .who .alias { font-size: 10.5px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
213
+ .self-tag { color: var(--accent); margin-right: 5px; }
214
+ .chip {
215
+ font-size: 10px; color: var(--text-dim);
216
+ border: 1px solid var(--line-strong); border-radius: 4px;
217
+ padding: 1px 6px; white-space: nowrap;
218
+ }
219
+ .chip.ok { color: var(--ok); border-color: var(--ok-line); }
220
+ .chip.warn { color: var(--warn); border-color: var(--warn-line); }
221
+ .chip.dim { color: var(--text-faint); }
222
+ .chip.danger { color: var(--danger); border-color: var(--danger-line); }
223
+ .muted { color: var(--text-faint); }
224
+ .who .id .chip { margin-left: 4px; }
225
+ .badge {
226
+ min-width: 20px; text-align: center;
227
+ color: var(--danger);
228
+ background: var(--danger-wash);
229
+ border: 1px solid var(--danger-line);
230
+ border-radius: 999px; font-size: 10px; font-weight: 600;
231
+ padding: 0 6px; line-height: 16px;
232
+ }
233
+ .row .actions, .g-row .actions { display: flex; gap: 6px; flex: none; }
234
+ .empty { padding: 34px 16px; text-align: center; color: var(--text-faint); font-size: 12px; }
235
+ .empty b { display: block; color: var(--text-dim); font-weight: 400; margin-bottom: 4px; }
236
+ .loading { height: 46px; display: grid; place-items: center; }
237
+
238
+ /* Inline edit / two-step confirm: expands and collapses in place
239
+ (spatial consistency, symmetrical path) */
240
+ .editbox { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
241
+ .editbox input { flex: 1; min-width: 60px; min-height: 30px; padding: 4px 8px; font-size: 12px; }
242
+ .confirm-row { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
243
+ .confirm-note { color: var(--danger); font-size: 12px; flex: 1; min-width: 0; }
244
+ .confirm-note b { font-weight: 700; }
245
+
246
+ /* ── search & filter chips ──────────────────────────────────────────── */
247
+ .search {
248
+ display: flex; align-items: center; gap: 8px;
249
+ padding: 10px 16px; border-bottom: 1px solid var(--line); background: var(--surface-2);
250
+ }
251
+ .search input { flex: 1; min-width: 0; }
252
+ .filter { display: flex; gap: 6px; flex: none; }
253
+ .filter button { min-height: 26px; padding: 2px 10px; font-size: 11px; }
254
+ .filter button[aria-pressed="true"] {
255
+ background: var(--accent-wash); color: var(--accent);
256
+ border-color: var(--accent-line); font-weight: 700;
257
+ }
258
+
259
+ /* ── groups ─────────────────────────────────────────────────────────── */
260
+ .group-form { display: flex; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface-2); flex-wrap: wrap; }
261
+ .group-form input { flex: 1; min-width: 0; }
262
+ .group-form input[name="group"] { flex: 0 0 118px; }
263
+ .group-form button { flex: none; }
264
+ .g-row { flex-wrap: wrap; }
265
+ .g-row .g-name { min-width: 0; flex: 1; }
266
+ .g-row .g-name .id { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
267
+ .g-row .g-name .id .grp { color: var(--accent); }
268
+ .g-row .g-name .members { font-size: 10.5px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
269
+ .g-expand {
270
+ flex: 1 1 100%; display: grid; gap: 8px;
271
+ padding: 10px 12px; margin-top: 6px;
272
+ background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
273
+ animation: row-in .16s ease-out;
274
+ }
275
+ .g-members { display: flex; flex-wrap: wrap; gap: 6px; }
276
+ .member-chip {
277
+ display: inline-flex; align-items: center; gap: 5px;
278
+ font-size: 11px; color: var(--text);
279
+ background: var(--surface-3); border: 1px solid var(--line-strong);
280
+ border-radius: 999px; padding: 2px 4px 2px 8px;
281
+ }
282
+ .member-chip button {
283
+ min-height: 16px; height: 16px; width: 16px; padding: 0;
284
+ border: none; background: transparent; color: var(--text-faint);
285
+ font-size: 11px; line-height: 1; border-radius: 50%;
286
+ display: grid; place-items: center;
287
+ }
288
+ .member-chip button:hover { color: var(--danger); background: var(--danger-wash); }
289
+ .g-add { display: flex; gap: 6px; }
290
+ .g-add input { flex: 1; min-width: 0; }
291
+
292
+ /* ── message tail ───────────────────────────────────────────────────── */
293
+ .feed { list-style: none; margin: 0; padding: 6px 0; max-height: 430px; overflow: auto; }
294
+ .feed li { padding: 9px 16px; border-bottom: 1px solid var(--line); animation: row-in .16s ease-out; }
295
+ .feed li:last-child { border-bottom: none; }
296
+ .feed .meta { display: flex; gap: 8px; align-items: baseline; font-size: 10.5px; color: var(--text-faint); margin-bottom: 3px; }
297
+ .feed .meta .t { font-variant-numeric: tabular-nums; }
298
+ .feed .meta .route { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
299
+ .feed .meta .kind { border: 1px solid var(--line-strong); border-radius: 4px; padding: 0 5px; font-size: 9.5px; text-transform: uppercase; letter-spacing: .08em; flex: none; }
300
+ .feed .meta .kind.group { color: var(--accent); border-color: var(--accent-line); }
301
+ .feed .meta .kind.task { color: var(--warn); border-color: var(--warn-line); }
302
+ .feed .meta .kind.ack { color: var(--ok); border-color: var(--ok-line); }
303
+ .feed .meta .kind.notice { color: var(--text-faint); }
304
+ .feed .body { font-size: 12px; word-break: break-word; color: var(--text); }
305
+ .panel > footer {
306
+ display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
307
+ padding: 9px 16px; font-size: 11px; min-height: 36px;
308
+ border-top: 1px solid var(--line); color: var(--text-faint);
309
+ }
310
+ .panel > footer.err { color: var(--danger); }
311
+ .panel > footer .sep { color: var(--line-strong); }
312
+
313
+ /* ── toasts: aria-live broadcast + one-way slide-in (interruptible,
314
+ transform/opacity only) ── */
315
+ .toasts { position: fixed; right: 18px; bottom: 18px; display: grid; gap: 8px; z-index: 50; }
316
+ .toast {
317
+ background: var(--surface-3); border: 1px solid var(--line-strong);
318
+ border-left: 3px solid var(--accent);
319
+ border-radius: var(--radius-sm); padding: 9px 14px;
320
+ font-size: 12px; max-width: 380px;
321
+ box-shadow: var(--shadow-toast);
322
+ animation: slide-in .18s ease-out;
323
+ }
324
+ .toast.err { border-left-color: var(--danger); }
325
+ .toast.ok { border-left-color: var(--ok); }
326
+ .toast.warn { border-left-color: var(--warn); }
327
+ @keyframes slide-in { from { opacity: 0; transform: translateX(14px); } }
328
+
329
+ /* ── responsive / motion ────────────────────────────────────────────── */
330
+ @media (max-width: 980px) {
331
+ .workspace { grid-template-columns: 1fr; }
332
+ .topbar { flex-wrap: wrap; }
333
+ .spacer { display: none; }
334
+ }
335
+ @media (prefers-reduced-motion: reduce) {
336
+ html { scroll-behavior: auto; }
337
+ *, *::before, *::after { animation: none !important; transition: none !important; }
338
+ }
339
+ @media (prefers-contrast: more) {
340
+ .topbar { background: var(--bg); border-bottom-color: var(--line-strong); }
341
+ .panel { border-color: var(--line-strong); }
342
+ input, select { border-color: var(--line-strong); }
343
+ }
344
+ </style>
345
+ </head>
346
+ <body>
347
+
348
+ <header class="topbar">
349
+ <div class="brand">
350
+ <span class="mark"><img src="data:image/png;base64,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" alt="logo"></span>
351
+ <span>
352
+ <span class="name"><b>agent-comm-hub</b> <span data-i18n="brandTitle">管理台</span></span><br>
353
+ <span class="sub" data-i18n="brandSub">远程运维控制台 · manager</span>
354
+ </span>
355
+ </div>
356
+ <span class="status" id="conn" data-state="off" aria-live="polite"><span class="dot"></span><span id="conn-text" data-i18n="statusOff">未连接</span></span>
357
+ <span class="spacer"></span>
358
+ <div class="field">
359
+ <label for="token" data-i18n="fieldToken">token</label>
360
+ <input id="token" type="password" placeholder="manager token" autocomplete="off">
361
+ </div>
362
+ <div class="field">
363
+ <label for="mpath" data-i18n="fieldMcp">mcp</label>
364
+ <input id="mpath" value="/mcp" spellcheck="false" aria-label="MCP path">
365
+ </div>
366
+ <button id="lang" class="ghost" data-i18n-title="langTitle" title="切换语言">中 / EN</button>
367
+ <button id="reload" class="primary" data-i18n="btnReload">重新连接</button>
368
+ </header>
369
+
370
+ <nav class="stats" aria-label="overview">
371
+ <a class="stat" data-tone="ok" href="#panel-roster">
372
+ <span><span class="num" id="stat-online">–</span><div class="lbl" id="stat-online-lbl" data-i18n="statRoster">在线 / 花名册</div></span>
373
+ </a>
374
+ <a class="stat" href="#panel-tokens">
375
+ <span><span class="num" id="stat-tokens">–</span><div class="lbl" data-i18n="statTokens">已签发令牌</div></span>
376
+ </a>
377
+ <a class="stat" data-tone="warn" href="#panel-groups">
378
+ <span><span class="num" id="stat-groups">–</span><div class="lbl" data-i18n="statGroups">群组</div></span>
379
+ </a>
380
+ <a class="stat" href="#panel-log">
381
+ <span><span class="num" id="stat-msgs">–</span><div class="lbl" data-i18n="statLog">消息尾迹(30 条)</div></span>
382
+ </a>
383
+ </nav>
384
+
385
+ <main class="workspace">
386
+ <!-- main: members & tokens -->
387
+ <section class="panel" id="panel-tokens">
388
+ <header>
389
+ <h2 data-i18n="panelTokens">成员与令牌</h2>
390
+ <span class="hint" id="tok-hint"></span>
391
+ <span class="spacer"></span>
392
+ <div class="toolbar">
393
+ <button class="ghost" id="tok-reveal" data-i18n-title="btnRevealTitle" title="切换 明文/打码 显示" data-i18n="btnReveal">明/打码</button>
394
+ <button class="ghost" id="tok-refresh" data-i18n-title="btnRefreshTok" title="刷新 token 列表">↻ <span data-i18n="btnRefresh">刷新</span></button>
395
+ </div>
396
+ </header>
397
+ <form class="group-form" id="tok-form">
398
+ <input name="peer" data-i18n-ph="formPeer" placeholder="peer id(如 zhangsan)" required spellcheck="false" aria-label="peer id">
399
+ <select name="role" aria-label="role">
400
+ <option value="agent">agent</option>
401
+ <option value="manager">manager</option>
402
+ </select>
403
+ <input name="owner" data-i18n-ph="formOwner" placeholder="归属人(可选)" spellcheck="false" aria-label="owner">
404
+ <button class="primary" type="submit" data-i18n="formIssue">签发</button>
405
+ </form>
406
+ <div class="rows" id="tokens">
407
+ <div class="empty" id="tok-empty" data-i18n="emptyTokensRemote">远程模式(--auth-tokens)下才能在此签发 token</div>
408
+ </div>
409
+ </section>
410
+
411
+ <!-- main: roster -->
412
+ <section class="panel" id="panel-roster">
413
+ <header>
414
+ <h2 data-i18n="panelRoster">花名册</h2>
415
+ <span class="count" id="roster-count">0</span>
416
+ <span class="hint" id="roster-hint"></span>
417
+ <span class="spacer"></span>
418
+ <div class="toolbar">
419
+ <button class="ghost" id="refresh" data-i18n-title="btnRefreshTitle" title="立即刷新">↻ <span data-i18n="btnRefresh">刷新</span></button>
420
+ </div>
421
+ </header>
422
+ <div class="search">
423
+ <input id="roster-q" type="search" data-i18n-ph="searchRoster" placeholder="过滤 peer id / 别名 / 客户端…" spellcheck="false" aria-label="search roster">
424
+ <div class="filter" id="roster-filter" role="group" aria-label="presence filter">
425
+ <button data-f="all" aria-pressed="true" data-i18n="filterAll">全部</button>
426
+ <button data-f="online" aria-pressed="false" data-i18n="filterOnline">在线</button>
427
+ <button data-f="offline" aria-pressed="false" data-i18n="filterOffline">离线</button>
428
+ </div>
429
+ </div>
430
+ <div class="rows" id="roster">
431
+ <div class="loading"><span class="chip dim" data-i18n="stateLoading">连接中…</span></div>
432
+ </div>
433
+ </section>
434
+
435
+ <aside class="stack">
436
+ <!-- groups -->
437
+ <section class="panel" id="panel-groups">
438
+ <header>
439
+ <h2 data-i18n="panelGroups">群组</h2>
440
+ <span class="hint" data-i18n="groupsHint">频道内互发 · 离线成员从历史补读</span>
441
+ </header>
442
+ <form class="group-form" id="g-create">
443
+ <input name="group" data-i18n-ph="formGroup" placeholder="group id" required spellcheck="false" aria-label="group id">
444
+ <input name="name" data-i18n-ph="formGName" placeholder="显示名(可选)" spellcheck="false" aria-label="display name">
445
+ <input name="members" data-i18n-ph="formMembers" placeholder="成员 peer id,逗号分隔" required spellcheck="false" aria-label="members">
446
+ <button class="primary" type="submit" data-i18n="formCreate">创建</button>
447
+ </form>
448
+ <div class="rows" id="groups">
449
+ <div class="empty" data-i18n="emptyGroups">暂无群组</div>
450
+ </div>
451
+ </section>
452
+
453
+ <!-- message tail -->
454
+ <section class="panel" id="panel-log">
455
+ <header>
456
+ <h2 data-i18n="panelLog">消息尾迹</h2>
457
+ <span class="hint" id="log-hint" data-i18n="logHintAll">全量</span>
458
+ <span class="spacer"></span>
459
+ <div class="filter" id="log-filter" role="group" aria-label="kind filter">
460
+ <button data-k="all" aria-pressed="true" data-i18n="filterAll">全部</button>
461
+ <button data-k="chat" aria-pressed="false">chat</button>
462
+ <button data-k="task" aria-pressed="false">task</button>
463
+ <button data-k="ack" aria-pressed="false">ack</button>
464
+ <button data-k="group" aria-pressed="false" data-i18n="filterGroup">群组</button>
465
+ <button data-k="notice" aria-pressed="false">notice</button>
466
+ </div>
467
+ </header>
468
+ <ul class="feed" id="log"><li class="empty">—</li></ul>
469
+ <footer id="bar" data-i18n="stateWaitConn">等待连接…</footer>
470
+ </section>
471
+ </aside>
472
+ </main>
473
+
474
+ <div class="toasts" id="toasts" aria-live="polite"></div>
475
+
476
+ <script>
477
+ 'use strict'
478
+ /* ── i18n: tiny dictionary, zh/en; language persisted in localStorage ── */
479
+ const I18N = {
480
+ zh: {
481
+ title: 'agent-comm-hub 管理台',
482
+ brandTitle: '管理台',
483
+ brandSub: '远程运维控制台 · manager',
484
+ statusOff: '未连接',
485
+ statusOn: '已连接',
486
+ statusErr: '连接异常',
487
+ fieldToken: 'token',
488
+ fieldMcp: 'mcp',
489
+ btnReload: '重新连接',
490
+ btnReveal: '明/打码',
491
+ btnRevealTitle: '切换 明文/打码 显示',
492
+ btnRefresh: '刷新',
493
+ btnRefreshTitle: '立即刷新',
494
+ btnRefreshTok: '刷新 token 列表',
495
+ langTitle: '切换语言',
496
+ langToggle: '中 / EN',
497
+ statRoster: '在线 / 花名册',
498
+ statTokens: '已签发令牌',
499
+ statGroups: '群组',
500
+ statLog: '消息尾迹(30 条)',
501
+ panelTokens: '成员与令牌',
502
+ panelRoster: '花名册',
503
+ panelGroups: '群组',
504
+ panelLog: '消息尾迹',
505
+ groupsHint: '频道内互发 · 离线成员从历史补读',
506
+ formPeer: 'peer id(如 zhangsan)',
507
+ formOwner: '归属人(可选)',
508
+ formIssue: '签发',
509
+ formGroup: 'group id',
510
+ formGName: '显示名(可选)',
511
+ formMembers: '成员 peer id,逗号分隔',
512
+ formCreate: '创建',
513
+ emptyTokensRemote: '远程模式(--auth-tokens)下才能在此签发 token',
514
+ emptyTokensNone: '还没有 token · 用上方表单签发第一枚',
515
+ emptyGroups: '还没有群组 · 用上方表单创建一个',
516
+ emptyNoPeers: '还没有 peer 接入',
517
+ emptyNoPeersSub: 'agent 连上 MCP 端点后会出现在这里',
518
+ emptyNoMatch: '没有匹配项',
519
+ emptyNoMatchSub: '换个关键词或筛选条件',
520
+ searchRoster: '过滤 peer id / 别名 / 客户端…',
521
+ stateLoading: '连接中…',
522
+ stateWaitConn: '等待连接…',
523
+ stateRefreshEvery: '每 3 秒自动刷新 · {time}',
524
+ filterAll: '全部',
525
+ filterOnline: '在线',
526
+ filterOffline: '离线',
527
+ filterGroup: '群组',
528
+ rosterOnline: '在线',
529
+ rosterOffline: '离线',
530
+ rosterPending: '待认领',
531
+ rosterPendingTitle: '匿名加入,待认领',
532
+ rosterSelfTitle: '本管理台会话',
533
+ rosterQueued: '排队消息',
534
+ actionRename: '改名',
535
+ actionKick: '移除',
536
+ actionSave: '保存',
537
+ actionCancel: '取消',
538
+ actionCopy: '复制',
539
+ actionDelete: '删除',
540
+ actionMembers: '成员',
541
+ actionSend: '发消息',
542
+ renamePh: '新别名(清空并提交 = 清除别名)',
543
+ renameDone: '已改名 {from} → {to}',
544
+ kickNote: '移除 <b>{peer}</b>?排队消息将被清空',
545
+ kickYes: '确认移除',
546
+ kickDone: '已移除 {peer}',
547
+ kickAbsent: '{peer} 已不在 hub 上',
548
+ revokeNote: '吊销 <b>{peer}</b> 的 token?该成员将无法再连接。',
549
+ revokeYes: '确认吊销',
550
+ revokeDone: '已吊销 {peer}',
551
+ copyDone: '已复制 {peer} 的 token',
552
+ copyFail: '复制失败:{msg}',
553
+ tokensCount: '共 {n} 枚 · {reveal}',
554
+ tokensVisible: '明文',
555
+ tokensMasked: '打码',
556
+ tokenIssued: '已签发 {peer} [{role}] token:{token}',
557
+ groupCreated: '群 #{group} 已创建',
558
+ groupDeleted: '已删除 #{name}',
559
+ groupDelNote: '删除群 <b>{name}</b>?(消息历史保留)',
560
+ groupDelYes: '确认删除',
561
+ groupSend: '发到 #{name}:',
562
+ groupSent: '已发送到 #{name}',
563
+ groupRemoved: '已移除 {peer}',
564
+ groupRemoveTitle: '移除 {peer}',
565
+ groupAdded: '已添加 {peer} → #{group}',
566
+ groupAddPh: '添加成员 peer id',
567
+ groupAdd: '添加',
568
+ groupMemberCount: '{n} 人',
569
+ groupOnlyOwner: '仅创建者',
570
+ logHintAll: '全量',
571
+ logHint: '全量 · 显示 {shown}/{total}',
572
+ logNone: '还没有消息',
573
+ logNoneKind: '该类型暂无消息',
574
+ errUnauth: '未授权:token 缺失或无效',
575
+ errNeedManager: '未授权:需要 manager token'
576
+ },
577
+ en: {
578
+ title: 'agent-comm-hub admin console',
579
+ brandTitle: 'admin console',
580
+ brandSub: 'remote ops console · manager',
581
+ statusOff: 'disconnected',
582
+ statusOn: 'connected',
583
+ statusErr: 'connection error',
584
+ fieldToken: 'token',
585
+ fieldMcp: 'mcp',
586
+ btnReload: 'Reconnect',
587
+ btnReveal: 'show/hide',
588
+ btnRevealTitle: 'Toggle plain/masked tokens',
589
+ btnRefresh: 'refresh',
590
+ btnRefreshTitle: 'Refresh now',
591
+ btnRefreshTok: 'Refresh token list',
592
+ langTitle: 'Switch language',
593
+ langToggle: 'EN / 中',
594
+ statRoster: 'Online / Roster',
595
+ statTokens: 'Tokens issued',
596
+ statGroups: 'Groups',
597
+ statLog: 'Message tail (30)',
598
+ panelTokens: 'Members & Tokens',
599
+ panelRoster: 'Roster',
600
+ panelGroups: 'Groups',
601
+ panelLog: 'Message Tail',
602
+ groupsHint: 'Channel broadcast · offline members read back from history',
603
+ formPeer: 'peer id (e.g. zhangsan)',
604
+ formOwner: 'owner (optional)',
605
+ formIssue: 'Issue',
606
+ formGroup: 'group id',
607
+ formGName: 'display name (optional)',
608
+ formMembers: 'member peer ids, comma separated',
609
+ formCreate: 'Create',
610
+ emptyTokensRemote: 'Tokens can only be issued here in remote mode (--auth-tokens)',
611
+ emptyTokensNone: 'No tokens yet · issue the first one with the form above',
612
+ emptyGroups: 'No groups yet · create one with the form above',
613
+ emptyNoPeers: 'No peers connected yet',
614
+ emptyNoPeersSub: 'agents that join the MCP endpoint will show up here',
615
+ emptyNoMatch: 'No matches',
616
+ emptyNoMatchSub: 'try another keyword or filter',
617
+ searchRoster: 'Filter peer id / alias / client…',
618
+ stateLoading: 'connecting…',
619
+ stateWaitConn: 'waiting for connection…',
620
+ stateRefreshEvery: 'auto-refresh every 3 s · {time}',
621
+ filterAll: 'All',
622
+ filterOnline: 'Online',
623
+ filterOffline: 'Offline',
624
+ filterGroup: 'group',
625
+ rosterOnline: 'online',
626
+ rosterOffline: 'offline',
627
+ rosterPending: 'unclaimed',
628
+ rosterPendingTitle: 'anonymous join, pending claim',
629
+ rosterSelfTitle: 'this console session',
630
+ rosterQueued: 'queued messages',
631
+ actionRename: 'Rename',
632
+ actionKick: 'Remove',
633
+ actionSave: 'Save',
634
+ actionCancel: 'Cancel',
635
+ actionCopy: 'Copy',
636
+ actionDelete: 'Delete',
637
+ actionMembers: 'Members',
638
+ actionSend: 'Send',
639
+ renamePh: 'new alias (submit empty to clear)',
640
+ renameDone: 'renamed {from} → {to}',
641
+ kickNote: 'Remove <b>{peer}</b>? Queued messages will be dropped',
642
+ kickYes: 'Remove',
643
+ kickDone: 'removed {peer}',
644
+ kickAbsent: '{peer} is no longer on the hub',
645
+ revokeNote: 'Revoke <b>{peer}</b>\'s token? The member will not be able to connect.',
646
+ revokeYes: 'Revoke',
647
+ revokeDone: 'revoked {peer}',
648
+ copyDone: 'copied {peer}\'s token',
649
+ copyFail: 'copy failed: {msg}',
650
+ tokensCount: '{n} total · {reveal}',
651
+ tokensVisible: 'plain',
652
+ tokensMasked: 'masked',
653
+ tokenIssued: 'issued {peer} [{role}] token: {token}',
654
+ groupCreated: 'group #{group} created',
655
+ groupDeleted: 'deleted #{name}',
656
+ groupDelNote: 'Delete group <b>{name}</b>? (message history is kept)',
657
+ groupDelYes: 'Delete',
658
+ groupSend: 'Send to #{name}: ',
659
+ groupSent: 'sent to #{name}',
660
+ groupRemoved: 'removed {peer}',
661
+ groupRemoveTitle: 'Remove {peer}',
662
+ groupAdded: 'added {peer} → #{group}',
663
+ groupAddPh: 'member peer id',
664
+ groupAdd: 'Add',
665
+ groupMemberCount: '{n} members',
666
+ groupOnlyOwner: 'creator only',
667
+ logHintAll: 'all',
668
+ logHint: 'all · showing {shown}/{total}',
669
+ logNone: 'no messages yet',
670
+ logNoneKind: 'no messages of this kind',
671
+ errUnauth: 'unauthorized: token missing or invalid',
672
+ errNeedManager: 'unauthorized: manager token required'
673
+ }
674
+ }
675
+ function pickLang() {
676
+ const saved = localStorage.getItem('hub-admin-lang')
677
+ if (saved === 'zh' || saved === 'en') return saved
678
+ return /^zh/i.test(navigator.language || '') ? 'zh' : 'en'
679
+ }
680
+ const LANG = pickLang()
681
+ const t = (key, vars) => {
682
+ let s = I18N[LANG][key] ?? I18N.zh[key] ?? key
683
+ if (vars) for (const [k, v] of Object.entries(vars)) s = s.split('{' + k + '}').join(String(v))
684
+ return s
685
+ }
686
+ /* Apply keys to static DOM: [data-i18n] text, [data-i18n-ph] placeholder. */
687
+ function applyI18n() {
688
+ document.title = t('title')
689
+ document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n) })
690
+ document.querySelectorAll('[data-i18n-ph]').forEach((el) => { el.placeholder = t(el.dataset.i18nPh) })
691
+ document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = t(el.dataset.i18nTitle) })
692
+ }
693
+ document.querySelector('#lang').textContent = t('langToggle')
694
+
695
+ const $ = (id) => document.getElementById(id)
696
+ const token = () => $('token').value.trim()
697
+ const endpoint = () => location.origin + ($('mpath').value.trim() || '/mcp')
698
+
699
+ let sid = null
700
+ async function rpc(method, params) {
701
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }
702
+ if (token() !== '') headers.Authorization = 'Bearer ' + token()
703
+ if (sid) headers['Mcp-Session-Id'] = sid
704
+ // Notifications (no id) go out as 202; requests carry an id — mixing them
705
+ // up makes the hub report "method not found".
706
+ const isNotification = method.startsWith('notifications/')
707
+ const body = { jsonrpc: '2.0', method, params }
708
+ if (!isNotification) body.id = Date.now() % 1e6
709
+ const res = await fetch(endpoint(), { method: 'POST', headers, body: JSON.stringify(body) })
710
+ if (res.status === 401) throw new Error(t('errUnauth'))
711
+ const json = isNotification ? null : await res.json().catch(() => null)
712
+ const newSid = res.headers.get('mcp-session-id')
713
+ if (newSid) sid = newSid
714
+ if (isNotification) return {}
715
+ if (!json || json.error) throw new Error(json?.error?.message ?? ('HTTP ' + res.status))
716
+ const parsed = JSON.parse(json.result?.content?.[0]?.text ?? '{}')
717
+ if (json.result?.isError) throw new Error(parsed.error ?? 'tool error')
718
+ return parsed
719
+ }
720
+ async function ensureSession() {
721
+ if (sid) return
722
+ // MCP handshake: the returned Mcp-Session-Id binds this session for all
723
+ // subsequent tool calls (remote mode also binds the manager identity via
724
+ // the token).
725
+ await rpc('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'hub-admin', version: '1.0' } })
726
+ await rpc('notifications/initialized')
727
+ }
728
+ async function call(tool, args) { await ensureSession(); return rpc('tools/call', { name: tool, arguments: args ?? {} }) }
729
+
730
+ /* ── toast feedback: four states (info/ok/warn/err), aria-live broadcast ── */
731
+ function toast(msg, kind = '') {
732
+ const el = document.createElement('div')
733
+ el.className = 'toast ' + kind
734
+ el.textContent = msg
735
+ $('toasts').append(el)
736
+ setTimeout(() => el.remove(), 3600)
737
+ }
738
+
739
+ /* ── render: connection state ── */
740
+ function setConn(state, text) {
741
+ $('conn').dataset.state = state
742
+ $('conn-text').textContent = text
743
+ }
744
+
745
+ /* ── token management (members & tokens panel) ── */
746
+ let revealTokens = false
747
+ let pendingRevoke = null // inline two-step confirm
748
+ async function adminApi(method, path, body) {
749
+ const headers = {}
750
+ if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json'
751
+ if (token() !== '') headers.Authorization = 'Bearer ' + token()
752
+ const res = await fetch(endpoint().replace(/\/mcp$/, '') + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
753
+ if (res.status === 401 || res.status === 403) throw new Error(t('errNeedManager'))
754
+ const json = await res.json().catch(() => null)
755
+ if (!json || json.error) throw new Error(json?.error ?? ('HTTP ' + res.status))
756
+ return json
757
+ }
758
+
759
+ function tokRow(tk) {
760
+ const row = document.createElement('div')
761
+ row.className = 'row'
762
+ if (pendingRevoke === tk.peer) {
763
+ const box = document.createElement('div')
764
+ box.className = 'confirm-row'
765
+ const note = document.createElement('span')
766
+ note.className = 'confirm-note'
767
+ note.innerHTML = t('revokeNote', { peer: escapeHtml(tk.peer) })
768
+ const yes = document.createElement('button'); yes.className = 'danger'; yes.textContent = t('revokeYes')
769
+ const no = document.createElement('button'); no.textContent = t('actionCancel')
770
+ yes.onclick = async () => {
771
+ pendingRevoke = null
772
+ try {
773
+ await adminApi('DELETE', '/admin/api/tokens/remove/' + encodeURIComponent(tk.peer), undefined)
774
+ toast(t('revokeDone', { peer: tk.peer }), 'ok')
775
+ } catch (e) { toast(String(e.message ?? e), 'err') }
776
+ loadTokens()
777
+ }
778
+ no.onclick = () => { pendingRevoke = null; loadTokens() }
779
+ box.append(note, yes, no)
780
+ row.append(box)
781
+ return row
782
+ }
783
+ const who = document.createElement('div')
784
+ who.className = 'who'
785
+ who.innerHTML = `<div class="id">${escapeHtml(tk.peer)} <span class="chip ${tk.role === 'manager' ? 'warn' : ''}">${tk.role}</span></div>` +
786
+ `<div class="alias">${escapeHtml(tk.owner || '—')}</div>`
787
+ const tok = document.createElement('span')
788
+ tok.className = 'chip'
789
+ tok.textContent = tk.token
790
+ const actions = document.createElement('div')
791
+ actions.className = 'actions'
792
+ if (revealTokens) {
793
+ const copy = document.createElement('button'); copy.className = 'ghost'; copy.textContent = t('actionCopy')
794
+ copy.onclick = async () => {
795
+ try { await navigator.clipboard.writeText(tk.token); toast(t('copyDone', { peer: tk.peer }), 'ok') }
796
+ catch (e) { toast(t('copyFail', { msg: String(e.message ?? e) }), 'err') }
797
+ }
798
+ actions.append(copy)
799
+ }
800
+ const del = document.createElement('button'); del.className = 'ghost danger'; del.textContent = t('actionKick')
801
+ del.onclick = () => { pendingRevoke = tk.peer; renderTokens(lastTokens) }
802
+ actions.append(del)
803
+ row.append(who, tok, actions)
804
+ return row
805
+ }
806
+
807
+ let lastTokens = []
808
+ function renderTokens(list) {
809
+ lastTokens = list
810
+ const wrap = $('tokens')
811
+ wrap.innerHTML = ''
812
+ $('tok-hint').textContent = t('tokensCount', { n: list.length, reveal: revealTokens ? t('tokensVisible') : t('tokensMasked') })
813
+ if (list.length === 0) {
814
+ $('tok-empty').textContent = t('emptyTokensNone')
815
+ } else {
816
+ $('tok-empty').textContent = ''
817
+ for (const tk of list) wrap.append(tokRow(tk))
818
+ }
819
+ $('stat-tokens').textContent = String(list.length)
820
+ }
821
+
822
+ async function loadTokens() {
823
+ try {
824
+ const res = await adminApi('GET', '/admin/api/tokens?reveal=' + (revealTokens ? '1' : '0'), undefined)
825
+ renderTokens(res.tokens ?? [])
826
+ } catch (e) {
827
+ $('tokens').innerHTML = `<div class="empty">${escapeHtml(String(e.message ?? e))}</div>`
828
+ }
829
+ }
830
+
831
+ $('tok-form').onsubmit = async (ev) => {
832
+ ev.preventDefault()
833
+ const f = new FormData(ev.target)
834
+ try {
835
+ const res = await adminApi('POST', '/admin/api/tokens/add', {
836
+ peer: f.get('peer'), role: f.get('role'), owner: f.get('owner') || undefined,
837
+ })
838
+ revealTokens = true // reveal after issuing so the new token can be copied
839
+ loadTokens()
840
+ toast(t('tokenIssued', { peer: res.created.peer, role: res.created.role, token: res.created.token }), 'ok')
841
+ ev.target.reset()
842
+ } catch (e) { toast(String(e.message ?? e), 'err') }
843
+ }
844
+ $('tok-reveal').onclick = () => { revealTokens = !revealTokens; loadTokens() }
845
+ $('tok-refresh').onclick = () => { pendingRevoke = null; loadTokens() }
846
+
847
+ /* ── render: roster (inline rename / two-step confirm remove; search + presence filter) ── */
848
+ let editingId = null
849
+ let confirmingId = null
850
+ let rosterQuery = ''
851
+ let rosterFilter = 'all'
852
+ function displayName(p) { return p.alias ?? p.id }
853
+
854
+ function rosterRow(p) {
855
+ if (editingId === p.id) return rosterEditRow(p)
856
+ if (confirmingId === p.id) return rosterConfirmRow(p)
857
+ const row = document.createElement('div')
858
+ row.className = 'row'
859
+ row.dataset.online = String(p.connected)
860
+
861
+ const presence = document.createElement('span')
862
+ presence.className = 'presence'
863
+ presence.title = p.connected ? t('rosterOnline') : t('rosterOffline')
864
+
865
+ const who = document.createElement('div')
866
+ who.className = 'who'
867
+ const self = p.id === selfPeer() ? `<span class="self-tag" title="${t('rosterSelfTitle')}">●</span>` : ''
868
+ const anon = p.anonymous ? ` <span class="chip warn" title="${t('rosterPendingTitle')}">${t('rosterPending')}</span>` : ''
869
+ const ips = Array.isArray(p.clientIps) && p.clientIps.length ? ` · <span class="muted">${escapeHtml(p.clientIps.join(', '))}</span>` : ''
870
+ who.innerHTML = `<div class="id">${self}${escapeHtml(displayName(p))}${anon}</div>` +
871
+ `<div class="alias">${escapeHtml(p.alias ? p.id : (p.clientName ?? ''))}${ips}</div>`
872
+
873
+ const chip = document.createElement('span')
874
+ if (p.clientVersion) { chip.className = 'chip'; chip.textContent = 'v' + p.clientVersion }
875
+
876
+ const state = document.createElement('span')
877
+ state.className = 'chip ' + (p.connected ? 'ok' : 'dim')
878
+ state.textContent = p.connected ? t('rosterOnline') : t('rosterOffline')
879
+
880
+ const badge = document.createElement('span')
881
+ if ((p.queued ?? 0) > 0) { badge.className = 'badge'; badge.title = t('rosterQueued'); badge.textContent = String(p.queued) }
882
+
883
+ const actions = document.createElement('div')
884
+ actions.className = 'actions'
885
+ actions.append(mkBtn(t('actionRename'), 'ghost', () => { editingId = p.id; confirmingId = null; renderRoster(lastPeers) }))
886
+ if (p.connected) actions.append(mkBtn(t('actionKick'), 'ghost danger', () => { confirmingId = p.id; editingId = null; renderRoster(lastPeers) }))
887
+
888
+ row.append(presence, who, chip, state, badge, actions)
889
+ return row
890
+ }
891
+
892
+ function rosterEditRow(p) {
893
+ const row = document.createElement('div')
894
+ row.className = 'row'
895
+ const box = document.createElement('div')
896
+ box.className = 'editbox'
897
+ const input = document.createElement('input')
898
+ input.value = p.alias ?? ''
899
+ input.placeholder = t('renamePh')
900
+ input.spellcheck = false
901
+ const save = document.createElement('button'); save.className = 'primary'; save.textContent = t('actionSave')
902
+ const cancel = document.createElement('button'); cancel.textContent = t('actionCancel')
903
+ const submit = async () => {
904
+ const next = input.value.trim()
905
+ if (next === (p.alias ?? '')) { editingId = null; refresh(); return }
906
+ try {
907
+ await call('bridge_rename', { peer: p.id, alias: next })
908
+ toast(t('renameDone', { from: displayName(p), to: next || '—' }), 'ok')
909
+ } catch (e) { toast(String(e.message ?? e), 'err') }
910
+ editingId = null
911
+ refresh()
912
+ }
913
+ save.onclick = submit
914
+ cancel.onclick = () => { editingId = null; refresh() }
915
+ input.onkeydown = (e) => {
916
+ if (e.key === 'Enter') { e.stopPropagation(); void submit() }
917
+ if (e.key === 'Escape') { e.stopPropagation(); editingId = null; refresh() }
918
+ }
919
+ box.append(input, save, cancel)
920
+ row.append(box)
921
+ queueMicrotask(() => input.focus())
922
+ return row
923
+ }
924
+
925
+ function rosterConfirmRow(p) {
926
+ const row = document.createElement('div')
927
+ row.className = 'row'
928
+ const box = document.createElement('div')
929
+ box.className = 'confirm-row'
930
+ const note = document.createElement('span')
931
+ note.className = 'confirm-note'
932
+ note.innerHTML = t('kickNote', { peer: escapeHtml(displayName(p)) })
933
+ const yes = document.createElement('button'); yes.className = 'danger'; yes.textContent = t('kickYes')
934
+ const no = document.createElement('button'); no.textContent = t('actionCancel')
935
+ yes.onclick = async () => {
936
+ try {
937
+ const res = await call('bridge_unregister', { peer: p.id })
938
+ toast(res?.kicked === false ? t('kickAbsent', { peer: p.id }) : t('kickDone', { peer: displayName(p) }), 'ok')
939
+ } catch (e) { toast(String(e.message ?? e), 'err') }
940
+ confirmingId = null
941
+ refresh()
942
+ }
943
+ no.onclick = () => { confirmingId = null; refresh() }
944
+ box.append(note, yes, no)
945
+ row.append(box)
946
+ return row
947
+ }
948
+
949
+ let lastPeers = []
950
+ let lastRosterKey = ''
951
+ function rosterKey(peers) {
952
+ return rosterFilter + '|' + rosterQuery + '|' + peers.map(p =>
953
+ [p.id, p.connected ? 1 : 0, p.alias ?? '', p.queued ?? 0, p.clientName ?? '',
954
+ p.anonymous ? 1 : 0, Array.isArray(p.clientIps) ? p.clientIps.join(', ') : ''].join('\u0001')
955
+ ).join('\u0002')
956
+ }
957
+ function renderRoster(peers, force = false) {
958
+ lastPeers = peers
959
+ // Skip rebuilding when the snapshot is unchanged: the 3 s poll must not
960
+ // interrupt a hover or an ongoing read (apple-design: interruptibility).
961
+ const key = rosterKey(peers)
962
+ if (!force && key === lastRosterKey) return
963
+ lastRosterKey = key
964
+ const online = peers.filter(p => p.connected).length
965
+ $('roster-count').textContent = String(peers.length)
966
+ $('roster-hint').textContent = `${online} ${t('filterOnline')}`
967
+ $('stat-online').textContent = `${online}/${peers.length}`
968
+ const q = rosterQuery.toLowerCase()
969
+ const wrap = $('roster')
970
+ wrap.innerHTML = ''
971
+ const shown = peers.filter(p => {
972
+ if (rosterFilter === 'online' && !p.connected) return false
973
+ if (rosterFilter === 'offline' && p.connected) return false
974
+ if (q === '') return true
975
+ return [p.id, p.alias ?? '', p.clientName ?? ''].some(s => s.toLowerCase().includes(q))
976
+ })
977
+ if (shown.length === 0) {
978
+ wrap.innerHTML = peers.length === 0
979
+ ? `<div class="empty"><b>${escapeHtml(t('emptyNoPeers'))}</b>${escapeHtml(t('emptyNoPeersSub'))}</div>`
980
+ : `<div class="empty"><b>${escapeHtml(t('emptyNoMatch'))}</b>${escapeHtml(t('emptyNoMatchSub'))}</div>`
981
+ return
982
+ }
983
+ for (const p of shown) wrap.append(rosterRow(p))
984
+ }
985
+
986
+ $('roster-q').addEventListener('input', (e) => { rosterQuery = e.target.value; renderRoster(lastPeers, true) })
987
+ $('roster-filter').addEventListener('click', (e) => {
988
+ const b = e.target.closest('button[data-f]')
989
+ if (!b) return
990
+ rosterFilter = b.dataset.f
991
+ for (const x of $('roster-filter').children) x.setAttribute('aria-pressed', String(x === b))
992
+ renderRoster(lastPeers, true)
993
+ })
994
+
995
+ /* ── render: groups (member expansion management / inline confirm delete) ── */
996
+ let expandedGroup = null
997
+ let pendingGroupDelete = null
998
+ let lastGroups = []
999
+
1000
+ function groupRow(g) {
1001
+ const row = document.createElement('div')
1002
+ row.className = 'g-row'
1003
+ if (pendingGroupDelete === g.id) {
1004
+ const box = document.createElement('div')
1005
+ box.className = 'confirm-row'
1006
+ const note = document.createElement('span')
1007
+ note.className = 'confirm-note'
1008
+ note.innerHTML = t('groupDelNote', { name: escapeHtml(g.name ?? g.id) })
1009
+ const yes = document.createElement('button'); yes.className = 'danger'; yes.textContent = t('groupDelYes')
1010
+ const no = document.createElement('button'); no.textContent = t('actionCancel')
1011
+ yes.onclick = async () => {
1012
+ pendingGroupDelete = null
1013
+ try { await call('bridge_group_delete', { group: g.id }); toast(t('groupDeleted', { name: g.name ?? g.id }), 'ok') }
1014
+ catch (e) { toast(String(e.message ?? e), 'err') }
1015
+ refresh()
1016
+ }
1017
+ no.onclick = () => { pendingGroupDelete = null; renderGroups(lastGroups) }
1018
+ box.append(note, yes, no)
1019
+ row.append(box)
1020
+ return row
1021
+ }
1022
+ const memCount = (g.members ?? []).length
1023
+ const name = document.createElement('div')
1024
+ name.className = 'g-name'
1025
+ name.innerHTML = `<div class="id"><span class="grp">#</span>${escapeHtml(g.name ?? g.id)} <span class="chip dim">${escapeHtml(t('groupMemberCount', { n: memCount }))}</span></div>` +
1026
+ `<div class="members">${escapeHtml((g.members ?? []).join(' · ') || t('groupOnlyOwner'))}</div>`
1027
+ const actions = document.createElement('div')
1028
+ actions.className = 'actions'
1029
+ actions.append(mkBtn(t('actionMembers'), 'ghost' + (expandedGroup === g.id ? ' primary' : ''), () => {
1030
+ expandedGroup = expandedGroup === g.id ? null : g.id
1031
+ renderGroups(lastGroups)
1032
+ }))
1033
+ actions.append(mkBtn(t('actionSend'), 'ghost', async () => {
1034
+ const text = prompt(t('groupSend', { name: g.name ?? g.id }))
1035
+ if (!text) return
1036
+ try { await call('bridge_group_send', { group: g.id, message: text }); toast(t('groupSent', { name: g.name ?? g.id }), 'ok') }
1037
+ catch (e) { toast(String(e.message ?? e), 'err') }
1038
+ refresh()
1039
+ }))
1040
+ actions.append(mkBtn(t('actionDelete'), 'ghost danger', () => { pendingGroupDelete = g.id; renderGroups(lastGroups) }))
1041
+ row.append(name, actions)
1042
+ if (expandedGroup === g.id) row.append(groupExpand(g))
1043
+ return row
1044
+ }
1045
+
1046
+ function groupExpand(g) {
1047
+ const box = document.createElement('div')
1048
+ box.className = 'g-expand'
1049
+ const mem = document.createElement('div')
1050
+ mem.className = 'g-members'
1051
+ for (const m of g.members ?? []) {
1052
+ const chip = document.createElement('span')
1053
+ chip.className = 'member-chip'
1054
+ const remove = document.createElement('button')
1055
+ remove.textContent = '×'
1056
+ remove.title = t('groupRemoveTitle', { peer: m })
1057
+ remove.onclick = async () => {
1058
+ try { await call('bridge_group_remove_member', { group: g.id, member: m }); toast(t('groupRemoved', { peer: m }), 'ok') }
1059
+ catch (e) { toast(String(e.message ?? e), 'err') }
1060
+ refresh()
1061
+ }
1062
+ const span = document.createElement('span'); span.textContent = m
1063
+ chip.append(span, remove)
1064
+ mem.append(chip)
1065
+ }
1066
+ const add = document.createElement('form')
1067
+ add.className = 'g-add'
1068
+ const input = document.createElement('input')
1069
+ input.placeholder = t('groupAddPh')
1070
+ input.spellcheck = false
1071
+ const btn = document.createElement('button'); btn.className = 'primary'; btn.textContent = t('groupAdd')
1072
+ input.required = true
1073
+ add.append(input, btn)
1074
+ add.onsubmit = async (ev) => {
1075
+ ev.preventDefault()
1076
+ const m = input.value.trim()
1077
+ if (!m) return
1078
+ try { await call('bridge_group_add_member', { group: g.id, member: m }); toast(t('groupAdded', { peer: m, group: g.name ?? g.id }), 'ok') }
1079
+ catch (e) { toast(String(e.message ?? e), 'err') }
1080
+ refresh()
1081
+ }
1082
+ box.append(mem, add)
1083
+ queueMicrotask(() => input.focus())
1084
+ return box
1085
+ }
1086
+
1087
+ function renderGroups(groups) {
1088
+ lastGroups = groups
1089
+ $('stat-groups').textContent = String(groups.length)
1090
+ const wrap = $('groups')
1091
+ wrap.innerHTML = ''
1092
+ if (groups.length === 0) {
1093
+ wrap.innerHTML = '<div class="empty"></div>'
1094
+ $('groups .empty')[0]?.setAttribute('data-i18n', 'emptyGroups')
1095
+ wrap.querySelector('.empty').textContent = t('emptyGroups')
1096
+ return
1097
+ }
1098
+ for (const g of groups) wrap.append(groupRow(g))
1099
+ }
1100
+
1101
+ /* ── render: message tail (kind filter chips) ── */
1102
+ function kindOf(m) { return m.channel ? 'group' : m.kind }
1103
+ function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML }
1104
+ function selfPeer() { return lastStatus?.peerId ?? '' }
1105
+
1106
+ let logFilter = 'all'
1107
+ let lastTail = []
1108
+ let lastTailKey = ''
1109
+ function renderTail(messages, force = false) {
1110
+ lastTail = messages
1111
+ // Skip rebuilding when nothing new arrived: keep the scroll position and any
1112
+ // read in progress (apple-design: interruptibility).
1113
+ const key = messages.map(m => m.id + '@' + m.ts).join('|')
1114
+ if (!force && key === lastTailKey) return
1115
+ lastTailKey = key
1116
+ const feed = $('log')
1117
+ feed.innerHTML = ''
1118
+ const shown = logFilter === 'all' ? messages : messages.filter(m => kindOf(m) === logFilter)
1119
+ $('log-hint').textContent = t('logHint', { shown: shown.length, total: messages.length })
1120
+ $('stat-msgs').textContent = String(messages.length)
1121
+ if (shown.length === 0) {
1122
+ feed.innerHTML = '<li class="empty">' + (logFilter === 'all' ? t('logNone') : t('logNoneKind')) + '</li>'
1123
+ return
1124
+ }
1125
+ for (const m of shown) {
1126
+ const li = document.createElement('li')
1127
+ const time = new Date(m.ts).toLocaleTimeString(LANG === 'zh' ? 'zh-CN' : 'en-GB', { hour12: false })
1128
+ const body = typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
1129
+ const metaEl = document.createElement('div')
1130
+ metaEl.className = 'meta'
1131
+ const tEl = document.createElement('span'); tEl.className = 't'; tEl.textContent = time
1132
+ const route = document.createElement('span'); route.className = 'route'; route.textContent = m.from + ' → ' + m.to
1133
+ const kind = document.createElement('span')
1134
+ kind.className = 'kind ' + kindOf(m)
1135
+ kind.textContent = m.channel ? '#' + m.channel : m.kind
1136
+ metaEl.append(tEl, route, kind)
1137
+ const bodyEl = document.createElement('div')
1138
+ bodyEl.className = 'body'
1139
+ bodyEl.textContent = body
1140
+ li.append(metaEl, bodyEl)
1141
+ feed.append(li)
1142
+ }
1143
+ }
1144
+
1145
+ $('log-filter').addEventListener('click', (e) => {
1146
+ const b = e.target.closest('button[data-k]')
1147
+ if (!b) return
1148
+ logFilter = b.dataset.k
1149
+ for (const x of $('log-filter').children) x.setAttribute('aria-pressed', String(x === b))
1150
+ renderTail(lastTail, true)
1151
+ })
1152
+
1153
+ /* ── helpers ── */
1154
+ function mkBtn(label, cls, onclick) {
1155
+ const b = document.createElement('button')
1156
+ b.textContent = label
1157
+ b.className = cls
1158
+ b.onclick = onclick
1159
+ return b
1160
+ }
1161
+
1162
+ /* ── poll loop: skip re-render while a panel has an interaction in flight ── */
1163
+ let lastStatus = null
1164
+ async function refresh() {
1165
+ try {
1166
+ const status = await call('bridge_status')
1167
+ lastStatus = status
1168
+ setConn('ok', t('statusOn'))
1169
+ const bar = $('bar')
1170
+ bar.className = ''
1171
+ bar.textContent = t('stateRefreshEvery', { time: new Date().toLocaleTimeString(LANG === 'zh' ? 'zh-CN' : 'en-GB', { hour12: false }) })
1172
+ if (!editingId && !confirmingId) renderRoster(status.peers ?? [])
1173
+ const groups = await call('bridge_group_list')
1174
+ if (!pendingGroupDelete && expandedGroup === null) renderGroups(groups.groups ?? [])
1175
+ const tail = await call('bridge_history', { peer: 'all', limit: 30 })
1176
+ renderTail(tail.messages ?? [])
1177
+ loadTokens()
1178
+ } catch (e) {
1179
+ setConn('err', t('statusErr'))
1180
+ const bar = $('bar')
1181
+ bar.className = 'err'
1182
+ // A hub restart invalidates the old session: next tick re-runs initialize.
1183
+ sid = null
1184
+ bar.textContent = String(e.message ?? e)
1185
+ }
1186
+ }
1187
+
1188
+ $('g-create').onsubmit = async (ev) => {
1189
+ ev.preventDefault()
1190
+ const f = new FormData(ev.target)
1191
+ const members = String(f.get('members')).split(',').map(s => s.trim()).filter(Boolean)
1192
+ try {
1193
+ await call('bridge_group_create', { group: f.get('group'), name: f.get('name') || undefined, members })
1194
+ toast(t('groupCreated', { group: f.get('group') }), 'ok')
1195
+ ev.target.reset()
1196
+ } catch (e) { toast(String(e.message ?? e), 'err') }
1197
+ refresh()
1198
+ }
1199
+ $('refresh').onclick = () => { editingId = null; confirmingId = null; refresh() }
1200
+ $('reload').onclick = () => { sid = null; localStorage.setItem('hub-admin-token', token()); refresh() }
1201
+ $('lang').onclick = () => { localStorage.setItem('hub-admin-lang', LANG === 'zh' ? 'en' : 'zh'); location.reload() }
1202
+ $('token').addEventListener('change', () => localStorage.setItem('hub-admin-token', token()))
1203
+ $('token').value = localStorage.getItem('hub-admin-token') ?? ''
1204
+
1205
+ /* ── material feedback: topbar gets heavier past the content edge
1206
+ (rAF-throttled, interruptible transition) ── */
1207
+ const topbar = document.querySelector('.topbar')
1208
+ let scrollTick = false
1209
+ addEventListener('scroll', () => {
1210
+ if (scrollTick) return
1211
+ scrollTick = true
1212
+ requestAnimationFrame(() => {
1213
+ scrollTick = false
1214
+ topbar.classList.toggle('scrolled', scrollY > 8)
1215
+ })
1216
+ }, { passive: true })
1217
+
1218
+ /* ── exit path: Esc closes every inline confirm/edit/expansion along the
1219
+ same path it opened (spatial consistency) ── */
1220
+ document.addEventListener('keydown', (e) => {
1221
+ if (e.key !== 'Escape') return
1222
+ if (editingId === null && confirmingId === null && pendingRevoke === null &&
1223
+ pendingGroupDelete === null && expandedGroup === null) return
1224
+ editingId = null; confirmingId = null; pendingRevoke = null
1225
+ pendingGroupDelete = null; expandedGroup = null
1226
+ refresh()
1227
+ })
1228
+
1229
+ applyI18n()
1230
+ setInterval(refresh, 3000)
1231
+ refresh()
1232
+ </script>
1233
+ </body>
1234
+ </html>