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.
- package/README.md +2 -1
- package/README.zh.md +1 -1
- package/assets/admin.html +1234 -0
- package/lib/cli.js +1021 -75
- package/lib/index.js +898 -42
- package/package.json +1 -1
|
@@ -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>
|