@nordbyte/nordrelay 0.5.2 → 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.
Files changed (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -7,6 +7,11 @@
7
7
  { path: "/api/snapshot", methods: ["GET"] },
8
8
  { path: "/api/tasks", methods: ["GET"] },
9
9
  { path: "/api/progress", methods: ["GET"] },
10
+ { path: "/api/metrics", methods: ["GET"] },
11
+ { path: "/api/jobs", methods: ["GET"] },
12
+ { re: /^\/api\/jobs\/[^\/]+\/log$/, methods: ["GET"] },
13
+ { re: /^\/api\/jobs\/[^\/]+\/action$/, methods: ["POST"] },
14
+ { path: "/api/active-sessions", methods: ["GET"] },
10
15
  { path: "/api/version", methods: ["GET"] },
11
16
  { path: "/api/update", methods: ["POST"] },
12
17
  { path: "/api/agent-updates", methods: ["GET"] },
@@ -15,6 +20,14 @@
15
20
  { re: /^\/api\/agent-update\/[^\/]+\/input$/, methods: ["POST"] },
16
21
  { re: /^\/api\/agent-update\/[^\/]+\/cancel$/, methods: ["POST"] },
17
22
  { path: "/api/adapters/health", methods: ["GET"] },
23
+ { path: "/api/peers", methods: ["GET", "POST"] },
24
+ { path: "/api/peers/invite", methods: ["POST"] },
25
+ { path: "/api/peers/pair", methods: ["POST"] },
26
+ { path: "/api/peers/global-sessions", methods: ["GET"] },
27
+ { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
28
+ { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
29
+ { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
30
+ { re: /^\/api\/peers\/[^\/]+\/events$/, methods: ["GET"] },
18
31
  { path: "/api/permissions", methods: ["GET"] },
19
32
  { path: "/api/users", methods: ["GET", "POST"] },
20
33
  { re: /^\/api\/users\/[^\/]+$/, methods: ["PATCH"] },
@@ -23,10 +36,14 @@
23
36
  { re: /^\/api\/users\/[^\/]+\/sessions\/[^\/]+$/, methods: ["DELETE"] },
24
37
  { re: /^\/api\/users\/[^\/]+\/telegram$/, methods: ["POST"] },
25
38
  { re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
39
+ { re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
40
+ { re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
26
41
  { path: "/api/groups", methods: ["GET", "POST"] },
27
42
  { re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
28
43
  { path: "/api/telegram-chats", methods: ["GET", "POST"] },
29
44
  { re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
45
+ { path: "/api/discord-channels", methods: ["GET", "POST"] },
46
+ { re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
30
47
  { path: "/api/audit", methods: ["GET"] },
31
48
  { path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
32
49
  { path: "/api/auth/status", methods: ["GET"] },
@@ -75,6 +92,32 @@
75
92
  const method = normalizeMethod(options.method, options.body);
76
93
  const url = apiUrl(path, options.query);
77
94
  assertApiRoute(url.pathname, method);
95
+ if (!options.local && shouldProxyApi(url.pathname)) {
96
+ const peerId = selectedPeerTarget();
97
+ const proxyBody = JSON.stringify({
98
+ method,
99
+ path: url.pathname,
100
+ query: queryObject(url),
101
+ body: bodyObject(options.body),
102
+ contextKey: "web:dashboard"
103
+ });
104
+ const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: proxyBody
108
+ });
109
+ if (res2.status === 401) {
110
+ location.reload();
111
+ return (
112
+ /** @type {never} */
113
+ void 0
114
+ );
115
+ }
116
+ const text2 = await res2.text();
117
+ const data2 = text2 ? JSON.parse(text2) : {};
118
+ if (!res2.ok) throw new Error(data2.error || res2.statusText);
119
+ return data2;
120
+ }
78
121
  const body = normalizeBody(options.body);
79
122
  const headers = {
80
123
  ...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
@@ -93,6 +136,46 @@
93
136
  if (!res.ok) throw new Error(data.error || res.statusText);
94
137
  return data;
95
138
  }
139
+ function shouldProxyApi(path) {
140
+ const peerId = selectedPeerTarget();
141
+ if (!peerId || peerId === "local") return false;
142
+ if (!path.startsWith("/api/")) return false;
143
+ return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
144
+ }
145
+ function isLocalAdminApi(path) {
146
+ return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path);
147
+ }
148
+ function selectedPeerTarget() {
149
+ const runtimeState = (
150
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { selectedPeer?: string } }} */
151
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE
152
+ );
153
+ return runtimeState?.selectedPeer || "local";
154
+ }
155
+ function queryObject(url) {
156
+ const result = {};
157
+ for (const [key, value] of url.searchParams.entries()) {
158
+ if (result[key] === void 0) result[key] = value;
159
+ else if (Array.isArray(result[key])) result[key].push(value);
160
+ else result[key] = [result[key], value];
161
+ }
162
+ return result;
163
+ }
164
+ function bodyObject(body) {
165
+ if (body === void 0 || body === null) return {};
166
+ if (typeof body === "string") {
167
+ try {
168
+ return body ? JSON.parse(body) : {};
169
+ } catch {
170
+ return { value: body };
171
+ }
172
+ }
173
+ if (isNativeBody(body)) return {};
174
+ return (
175
+ /** @type {Record<string, unknown>} */
176
+ body
177
+ );
178
+ }
96
179
  function apiUrl(path, query) {
97
180
  const url = new URL(path, location.origin);
98
181
  if (query) {
@@ -139,7 +222,8 @@
139
222
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
140
223
  }
141
224
  }
142
- const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0 };
225
+ const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
226
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
143
227
  function toast(msg, options = {}) {
144
228
  const el = document.getElementById("toast");
145
229
  el.textContent = msg;
@@ -239,14 +323,15 @@
239
323
  ["#restartBtn", "system.restart"],
240
324
  ["#updateBtn", "updates.run"],
241
325
  ["#clearLogsBtn", "logs.clear"],
242
- ["#createUserBtn,#createGroupBtn,#createChatBtn", "users.write"],
326
+ ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
327
+ ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke]", "peers.write"],
243
328
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
244
329
  ["[data-switch]", "sessions.write"],
245
330
  ["[data-queue],[data-q]", "queue.write"],
246
331
  ["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
247
332
  ["[data-auth-login],[data-auth-logout]", "auth.manage"],
248
333
  ["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
249
- ["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle]", "users.write"]
334
+ ["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle]", "users.write"]
250
335
  ];
251
336
  disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
252
337
  el.disabled = !can(permission);
@@ -288,6 +373,7 @@
288
373
  }
289
374
  async function reloadCurrentPage(options = {}) {
290
375
  const name = state.currentPage;
376
+ if (name === "overview") await loadActiveSessions();
291
377
  if (name === "chat") {
292
378
  await loadChatHistory();
293
379
  scrollChatToBottom();
@@ -299,7 +385,9 @@
299
385
  if (name === "artifacts") await loadArtifacts();
300
386
  if (name === "activity") await loadActivity();
301
387
  if (name === "tasks") await loadTasks();
388
+ if (name === "metrics") await loadMetrics();
302
389
  if (name === "adapters") await loadAdapterHealth();
390
+ if (name === "peers") await loadPeers();
303
391
  if (name === "access") await loadAccess();
304
392
  if (name === "version") await loadVersion();
305
393
  }
@@ -343,20 +431,23 @@
343
431
  }
344
432
  const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
345
433
  async function loadBootstrap() {
346
- const data = await api("/api/bootstrap");
347
- state.auth = data.auth || null;
348
- state.permissions = data.auth?.permissions || [];
434
+ const local = await api("/api/bootstrap", { local: true });
435
+ state.auth = local.auth || null;
436
+ state.permissions = local.auth?.permissions || [];
437
+ await loadPeerSelector();
438
+ const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
349
439
  state.snapshot = data.status.snapshot;
350
440
  state.controls = data.controls;
351
441
  state.enabledAgents = data.enabledAgents || [];
352
442
  applyPermissions();
353
443
  renderSnapshot(state.snapshot);
444
+ safe(loadActiveSessions);
354
445
  renderSessionControls();
355
446
  populateNewSessionForm(data.enabledAgents);
356
447
  renderAdapters(data.channels, data.agentAdapters);
357
448
  document.getElementById("footerVersion").textContent = "NordRelay " + (data.status.health?.version || "");
358
449
  document.getElementById("footerHealth").textContent = "Health: " + (data.status.health?.state?.status || "unknown");
359
- document.getElementById("footerUser").textContent = "User: " + (data.auth?.user?.email || "-");
450
+ document.getElementById("footerUser").textContent = "User: " + (local.auth?.user?.email || "-") + (state.selectedPeer && state.selectedPeer !== "local" ? " / target peer" : "");
360
451
  const agentSelect = document.getElementById("agentSelect");
361
452
  agentSelect.innerHTML = data.enabledAgents.map((a) => '<option value="' + a + '">' + a + "</option>").join("");
362
453
  agentSelect.value = state.snapshot.session.agentId;
@@ -373,9 +464,37 @@
373
464
  });
374
465
  applyPermissions();
375
466
  }
467
+ async function loadPeerSelector() {
468
+ const peerSelect = document.getElementById("peerSelect");
469
+ if (!peerSelect) return;
470
+ if (!can("peers.read")) {
471
+ peerSelect.innerHTML = '<option value="local">Local</option>';
472
+ peerSelect.value = "local";
473
+ state.selectedPeer = "local";
474
+ return;
475
+ }
476
+ try {
477
+ const peers = await api("/api/peers", { local: true });
478
+ state.peers = peers;
479
+ const available = (peers.peers || []).filter((p) => p.enabled && p.url);
480
+ peerSelect.innerHTML = '<option value="local">Local node</option>' + available.map((p) => '<option value="' + attr(p.id) + '">' + esc(p.name) + "</option>").join("");
481
+ if (state.selectedPeer !== "local" && !available.some((p) => p.id === state.selectedPeer)) state.selectedPeer = "local";
482
+ peerSelect.value = state.selectedPeer;
483
+ peerSelect.onchange = () => safe(async () => {
484
+ state.selectedPeer = peerSelect.value || "local";
485
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
486
+ connectEvents();
487
+ toast(state.selectedPeer === "local" ? "Target: local" : "Target: " + peerSelect.options[peerSelect.selectedIndex].text);
488
+ await loadBootstrap();
489
+ await reloadCurrentPage();
490
+ });
491
+ } catch {
492
+ peerSelect.innerHTML = '<option value="local">Local node</option>';
493
+ peerSelect.value = "local";
494
+ }
495
+ }
376
496
  function renderSnapshot(s) {
377
497
  document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
378
- document.getElementById("sessionText").textContent = s.sessionText || "";
379
498
  document.getElementById("metrics").innerHTML = [
380
499
  ["Status", s.processing ? "working" : "idle"],
381
500
  ["Agent", s.session.agentLabel],
@@ -387,6 +506,68 @@
387
506
  ].map(([k, v]) => '<div class="metric"><div class="label">' + esc(k) + '</div><div class="value">' + esc(v) + "</div></div>").join("");
388
507
  renderQueue(s.queue, s.queuePaused);
389
508
  }
509
+ async function loadActiveSessions() {
510
+ const box = document.getElementById("activeSessions");
511
+ if (!box) return;
512
+ if (!can("sessions.read")) {
513
+ box.innerHTML = '<div class="item">Permission required: sessions.read</div>';
514
+ return;
515
+ }
516
+ const data = await api("/api/active-sessions");
517
+ renderActiveSessions(data.sessions || []);
518
+ }
519
+ function renderActiveSessions(items) {
520
+ state.activeSessions = { sessions: items || [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
521
+ const box = document.getElementById("activeSessions");
522
+ if (!box) return;
523
+ box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
524
+ document.querySelectorAll("[data-active-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.activeCopy || "", "Thread ID copied"));
525
+ document.querySelectorAll("[data-active-switch]").forEach((b) => b.onclick = () => safe(async () => {
526
+ if (!can("sessions.write")) {
527
+ toast("Permission required: sessions.write");
528
+ return;
529
+ }
530
+ const agentId = b.dataset.activeAgent;
531
+ const threadId = b.dataset.activeSwitch;
532
+ if (agentId && state.snapshot?.session?.agentId !== agentId) {
533
+ await api("/api/agent", { method: "POST", body: { agentId } });
534
+ }
535
+ if (threadId) {
536
+ await api("/api/sessions/switch", { method: "POST", body: { threadId } });
537
+ }
538
+ toast("Session switched");
539
+ await loadBootstrap();
540
+ page("chat");
541
+ }));
542
+ document.querySelectorAll("[data-active-detail]").forEach((b) => b.onclick = () => safe(async () => {
543
+ const agentId = b.dataset.activeAgent;
544
+ const threadId = b.dataset.activeDetail;
545
+ if (agentId && state.snapshot?.session?.agentId !== agentId) {
546
+ await api("/api/agent", { method: "POST", body: { agentId } });
547
+ await loadBootstrap();
548
+ }
549
+ if (threadId) await loadSessionDetail(threadId);
550
+ }));
551
+ applyPermissions();
552
+ }
553
+ function activeSessionCard(s) {
554
+ const thread = s.threadId || "not started";
555
+ const prompt = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
556
+ const tool2 = s.currentTool || s.lastTool || "-";
557
+ const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
558
+ const sourceLabel = activeSourceLabel(s.source);
559
+ const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
560
+ const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
561
+ const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
562
+ return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
563
+ }
564
+ function activeSourceLabel(source) {
565
+ if (source === "cli") return "CLI";
566
+ if (source === "telegram") return "Telegram";
567
+ if (source === "discord") return "Discord";
568
+ if (source === "web") return "WebUI";
569
+ return source || "-";
570
+ }
390
571
  function renderSessionControls() {
391
572
  const c = state.controls || {};
392
573
  const s = state.snapshot?.session || {};
@@ -428,7 +609,10 @@
428
609
  });
429
610
  }
430
611
  function renderAdapters(channels, agents) {
431
- const channelCards = (channels || []).map((c) => adapterCard(c.label, c.status, "", c.capabilities.join(", ")));
612
+ const channelCards = (channels || []).map((c) => {
613
+ const status = c.status === "available" ? c.enabled === false ? "disabled" : "enabled" : c.status || "planned";
614
+ return adapterCard(c.label, status, "", c.capabilities.join(", ") + (c.notes ? " - " + c.notes : ""));
615
+ });
432
616
  const agentCards = (agents || []).map((a) => {
433
617
  const available = a.status === "available";
434
618
  const status = available ? state.enabledAgents.includes(a.id) ? "enabled" : "disabled" : a.status || "planned";
@@ -522,7 +706,8 @@
522
706
  let currentAgentMessage = null;
523
707
  function connectEvents() {
524
708
  if (state.events) state.events.close();
525
- const events = new EventSource("/api/events");
709
+ const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
710
+ const events = new EventSource(eventsUrl);
526
711
  state.events = events;
527
712
  setConnection("Connecting", "warn");
528
713
  events.onopen = () => {
@@ -540,6 +725,11 @@
540
725
  });
541
726
  events.addEventListener("chat_history", (e) => renderChatMessages(JSON.parse(e.data).messages || []));
542
727
  events.addEventListener("activity_update", (e) => renderActivity(JSON.parse(e.data).events || []));
728
+ events.addEventListener("active_sessions_update", (e) => {
729
+ const d = JSON.parse(e.data);
730
+ state.activeSessions = d.active || null;
731
+ if (state.currentPage === "overview") renderActiveSessions(state.activeSessions?.sessions || []);
732
+ });
543
733
  events.addEventListener("session_update", (e) => {
544
734
  loadBootstrap();
545
735
  loadChatHistory();
@@ -1037,30 +1227,6 @@
1037
1227
  const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
1038
1228
  renderQueue(r.queue, r.paused);
1039
1229
  }));
1040
- async function loadTasks() {
1041
- setLoading("tasksList", "Loading tasks...");
1042
- const d = await api("/api/tasks");
1043
- renderTasks(d);
1044
- }
1045
- function taskCard(t, title) {
1046
- if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
1047
- const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
1048
- return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1049
- }
1050
- function renderTasks(d) {
1051
- document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1052
- document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
1053
- if (!can("queue.write")) {
1054
- toast("Permission required: queue.write");
1055
- return;
1056
- }
1057
- const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
1058
- renderQueue(r.queue, r.paused);
1059
- loadTasks();
1060
- }));
1061
- applyPermissions();
1062
- }
1063
- document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
1064
1230
  async function loadArtifacts() {
1065
1231
  setLoading("artifactList", "Loading artifacts...");
1066
1232
  document.getElementById("artifactPreview").innerHTML = "";
@@ -1158,9 +1324,230 @@
1158
1324
  throw err;
1159
1325
  }
1160
1326
  }
1327
+ function isRemotePeerTarget() {
1328
+ return Boolean(state.selectedPeer && state.selectedPeer !== "local");
1329
+ }
1330
+ function artifactFileHref(turnId, path) {
1331
+ return isRemotePeerTarget() ? "#" : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
1332
+ }
1333
+ function artifactZipHref(turnId) {
1334
+ return isRemotePeerTarget() ? "#" : "/api/artifacts/zip?turnId=" + encodeURIComponent(turnId);
1335
+ }
1336
+ async function downloadArtifactFile(turnId, path) {
1337
+ const file = await api("/api/artifacts/file", { query: { turnId, path } });
1338
+ downloadBase64(file.name || path, file.dataBase64 || "", file.mimeType || "application/octet-stream");
1339
+ }
1340
+ async function downloadArtifactZip(turnId) {
1341
+ const file = await api("/api/artifacts/zip", { query: { turnId } });
1342
+ downloadBase64(file.name || "nordrelay-artifacts-" + turnId + ".zip", file.dataBase64 || "", file.mimeType || "application/zip");
1343
+ }
1344
+ async function artifactDataUrl(turnId, path) {
1345
+ const file = await api("/api/artifacts/file", { query: { turnId, path } });
1346
+ return "data:" + (file.mimeType || "application/octet-stream") + ";base64," + (file.dataBase64 || "");
1347
+ }
1348
+ function renderArtifacts() {
1349
+ const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
1350
+ const kind = document.getElementById("artifactKind").value;
1351
+ const reports = state.artifactReports || [];
1352
+ document.getElementById("artifactList").innerHTML = reports.map((r) => {
1353
+ const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
1354
+ if (files.length === 0) return "";
1355
+ const gallery = files.map((a) => {
1356
+ const href = artifactFileHref(r.turnId, a.relativePath);
1357
+ const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(a.name);
1358
+ const img = isImage && !isRemotePeerTarget() ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
1359
+ return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '" data-open-artifact="' + attr(r.turnId) + '" data-open-path="' + attr(a.relativePath) + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
1360
+ }).join("");
1361
+ return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="' + artifactZipHref(r.turnId) + '" data-zip-artifact="' + attr(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
1362
+ }).join("") || '<div class="item">No artifacts.</div>';
1363
+ document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
1364
+ if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
1365
+ else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
1366
+ });
1367
+ document.querySelectorAll("[data-open-artifact]").forEach((a) => a.onclick = (e) => {
1368
+ if (!isRemotePeerTarget()) return;
1369
+ e.preventDefault();
1370
+ safe(() => downloadArtifactFile(a.dataset.openArtifact, a.dataset.openPath));
1371
+ });
1372
+ document.querySelectorAll("[data-zip-artifact]").forEach((a) => a.onclick = (e) => {
1373
+ if (!isRemotePeerTarget()) return;
1374
+ e.preventDefault();
1375
+ safe(() => downloadArtifactZip(a.dataset.zipArtifact));
1376
+ });
1377
+ document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
1378
+ if (!can("files.write")) {
1379
+ toast("Permission required: files.write");
1380
+ return;
1381
+ }
1382
+ if (confirm("Delete artifact turn " + b.dataset.delArt + "?")) {
1383
+ await api("/api/artifacts", { method: "DELETE", query: { turnId: b.dataset.delArt } });
1384
+ state.selectedArtifactTurns.delete(b.dataset.delArt);
1385
+ loadArtifacts();
1386
+ }
1387
+ }));
1388
+ document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
1389
+ applyPermissions();
1390
+ }
1391
+ document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
1392
+ const turnIds = [...state.selectedArtifactTurns];
1393
+ if (turnIds.length === 0) {
1394
+ toast("No artifact turns selected");
1395
+ return;
1396
+ }
1397
+ turnIds.forEach((turnId) => isRemotePeerTarget() ? safe(() => downloadArtifactZip(turnId)) : window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
1398
+ };
1399
+ async function previewArtifact(turnId, path) {
1400
+ const target = document.getElementById("artifactPreview");
1401
+ target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
1402
+ target.scrollIntoView({ block: "start", behavior: "smooth" });
1403
+ try {
1404
+ const data = await api("/api/artifacts/preview", { query: { turnId, path } });
1405
+ if (data.kind === "image") {
1406
+ const src = isRemotePeerTarget() ? await artifactDataUrl(turnId, path) : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
1407
+ target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="' + src + '"></div>';
1408
+ return;
1409
+ }
1410
+ if (data.kind === "text") {
1411
+ target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + " " + fmtBytes(data.sizeBytes) + "</h2><pre>" + highlightCode(data.text || "") + "</pre>" + (data.truncated ? "<small>Preview truncated.</small>" : "") + "</div>";
1412
+ return;
1413
+ }
1414
+ target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + "</h2><p>" + esc(data.detail || "Preview unavailable") + "</p></div>";
1415
+ } catch (err) {
1416
+ target.innerHTML = '<div class="panel"><h2>Preview failed</h2><p>' + esc(err.message || String(err)) + "</p></div>";
1417
+ throw err;
1418
+ }
1419
+ }
1420
+ async function loadTasks() {
1421
+ setLoading("tasksList", "Loading tasks...");
1422
+ const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
1423
+ renderTasks(d, jobs);
1424
+ }
1425
+ function taskCard(t, title) {
1426
+ if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
1427
+ const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
1428
+ return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
1429
+ }
1430
+ function renderTasks(d, jobs) {
1431
+ document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
1432
+ document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
1433
+ if (!can("queue.write")) {
1434
+ toast("Permission required: queue.write");
1435
+ return;
1436
+ }
1437
+ const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
1438
+ renderQueue(r.queue, r.paused);
1439
+ loadTasks();
1440
+ }));
1441
+ bindUnifiedJobButtons();
1442
+ applyPermissions();
1443
+ }
1444
+ function renderUnifiedJobs(jobs) {
1445
+ return jobs.map((job) => {
1446
+ const retryPermission = jobActionPermission(job, "retry");
1447
+ const cancelPermission = jobActionPermission(job, "cancel");
1448
+ return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
1449
+ }).join("") || '<div class="item">No jobs.</div>';
1450
+ }
1451
+ function jobActionPermission(job, action) {
1452
+ if (job.id === "web:current" && action === "cancel") return "prompt.abort";
1453
+ if (String(job.id || "").startsWith("queue:")) return "queue.write";
1454
+ if (String(job.id || "").startsWith("support-bundle:")) return "diagnostics.read";
1455
+ return "updates.run";
1456
+ }
1457
+ function bindUnifiedJobButtons() {
1458
+ document.querySelectorAll("[data-job-log]").forEach((b) => b.onclick = () => safe(async () => {
1459
+ const r = await api("/api/jobs/" + encodeURIComponent(b.dataset.jobLog) + "/log");
1460
+ toast((r.plain || "No log").slice(0, 3500), { duration: 12e3 });
1461
+ }));
1462
+ document.querySelectorAll("[data-job-action]").forEach((b) => b.onclick = () => safe(async () => {
1463
+ const permission = b.dataset.jobPermission || "updates.run";
1464
+ if (!can(permission)) {
1465
+ toast("Permission required: " + permission);
1466
+ return;
1467
+ }
1468
+ const action = b.dataset.jobAction;
1469
+ if (confirm((action === "cancel" ? "Cancel" : "Retry") + " job " + b.dataset.jobId + "?")) {
1470
+ await api("/api/jobs/" + encodeURIComponent(b.dataset.jobId) + "/action", { method: "POST", body: JSON.stringify({ action }) });
1471
+ toast("Job " + action + " requested");
1472
+ loadTasks();
1473
+ }
1474
+ }));
1475
+ }
1476
+ document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
1477
+ async function loadMetrics() {
1478
+ setLoading("metricsPanel", "Loading metrics...");
1479
+ const d = await api("/api/metrics");
1480
+ renderMetrics(d);
1481
+ }
1482
+ function metricStatusRows(d) {
1483
+ return [
1484
+ ["Generated", fmtDate(d.generatedAt)],
1485
+ ["Queue", String(d.queue?.length ?? 0) + (d.queue?.paused ? " paused" : " running")],
1486
+ ["Active turns", String(d.turns?.active ?? 0)],
1487
+ ["Completed turns", String(d.turns?.completed ?? 0)],
1488
+ ["Failed turns", String(d.turns?.failed ?? 0)],
1489
+ ["Aborted turns", String(d.turns?.aborted ?? 0)],
1490
+ ["Average turn duration", d.turns?.averageDurationMs === null ? "-" : fmtDuration(d.turns?.averageDurationMs)]
1491
+ ];
1492
+ }
1493
+ function metricJobRows(d) {
1494
+ const jobs = d.jobs || {};
1495
+ return [
1496
+ ["Total", jobs.total],
1497
+ ["Queued", jobs.queued],
1498
+ ["Running", jobs.running],
1499
+ ["Completed", jobs.completed],
1500
+ ["Failed", jobs.failed],
1501
+ ["Aborted", jobs.aborted]
1502
+ ];
1503
+ }
1504
+ function metricProcessRows(d) {
1505
+ const p = d.process || {};
1506
+ const memory = p.memory || {};
1507
+ const cpu = p.cpu || {};
1508
+ const loop = p.eventLoop || {};
1509
+ return [
1510
+ ["PID", p.pid],
1511
+ ["Node", p.nodeVersion],
1512
+ ["Platform", [p.platform, p.arch].filter(Boolean).join(" ")],
1513
+ ["Uptime", fmtDuration(p.uptimeMs)],
1514
+ ["Started", fmtDate(p.startedAt)],
1515
+ ["RSS", fmtBytes(memory.rssBytes || 0)],
1516
+ ["Heap used", fmtBytes(memory.heapUsedBytes || 0)],
1517
+ ["Heap total", fmtBytes(memory.heapTotalBytes || 0)],
1518
+ ["CPU total", fmtDuration(cpu.totalMs)],
1519
+ ["CPU avg", cpu.percentSinceStart === null || cpu.percentSinceStart === void 0 ? "-" : cpu.percentSinceStart + "%"],
1520
+ ["Event loop p95", formatMs(loop.delayP95Ms)],
1521
+ ["Event loop max", formatMs(loop.delayMaxMs)]
1522
+ ];
1523
+ }
1524
+ function formatMs(value) {
1525
+ return value === null || value === void 0 ? "-" : value + "ms";
1526
+ }
1527
+ function rateRows(name, rate) {
1528
+ return [
1529
+ ["Queued", rate?.queued ?? 0],
1530
+ ["Running", rate?.running ?? 0],
1531
+ ["Completed", rate?.completed ?? 0],
1532
+ ["Failed", rate?.failed ?? 0],
1533
+ ["Retries", rate?.retries ?? 0],
1534
+ ["Rate-limit hits", rate?.rateLimitHits ?? 0],
1535
+ ["Last rate limit", fmtDate(rate?.lastRateLimitAt)],
1536
+ ["Retry after", rate?.lastRetryAfterSeconds ? rate.lastRetryAfterSeconds + "s" : "-"],
1537
+ ["Buckets", (rate?.buckets || []).length]
1538
+ ].map(([k, v]) => [name + " " + k, v]);
1539
+ }
1540
+ function renderMetrics(d) {
1541
+ const adapters = d.adapters || {};
1542
+ document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + card("Telegram rate limits", rateRows("", adapters.telegram).map(([k, v]) => [String(k).trim(), v])) + card("Discord rate limits", rateRows("", adapters.discord).map(([k, v]) => [String(k).trim(), v])) + "</div>";
1543
+ }
1544
+ document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1545
+ function activityQuery() {
1546
+ return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
1547
+ }
1161
1548
  async function loadActivity() {
1162
1549
  setLoading("activityList", "Loading activity...");
1163
- const data = await api("/api/activity", { query: { source: val("activitySource"), status: val("activityStatus"), limit: val("activityLimit") || "100" } });
1550
+ const data = await api("/api/activity", { query: activityQuery() });
1164
1551
  state.activityEvents = data.events || [];
1165
1552
  renderActivity(state.activityEvents);
1166
1553
  }
@@ -1168,28 +1555,32 @@
1168
1555
  const active = state.snapshot?.session;
1169
1556
  return e.workspace || (active?.threadId && e.threadId === active.threadId ? active.workspace : "");
1170
1557
  }
1558
+ function activityActorText(e) {
1559
+ const a = e.actor || {};
1560
+ return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", cli: "CLI", system: "System" }[a.channel] || "System");
1561
+ }
1171
1562
  function activityMetaHtml(e) {
1172
1563
  const workspace = activityWorkspace(e);
1173
1564
  const duration = typeof e.durationMs === "number" ? fmtDuration(e.durationMs) : "";
1565
+ const actor = activityActorText(e);
1174
1566
  const parts = [];
1567
+ if (actor) parts.push("User: " + esc(actor));
1175
1568
  if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
1176
1569
  if (workspace) parts.push(esc(workspace));
1177
1570
  if (duration) parts.push(esc(duration));
1178
1571
  return parts.join(" | ");
1179
1572
  }
1180
1573
  function renderActivity(events) {
1181
- const since = val("activitySince") ? new Date(val("activitySince")).getTime() : 0;
1182
- const filtered = (events || []).filter((e) => !since || new Date(e.timestamp).getTime() >= since);
1183
- document.getElementById("activityList").innerHTML = filtered.map((e) => {
1574
+ document.getElementById("activityList").innerHTML = (events || []).map((e) => {
1184
1575
  const meta = activityMetaHtml(e);
1185
- return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
1576
+ return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.category, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
1186
1577
  }).join("") || '<div class="item">No activity.</div>';
1187
1578
  document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
1188
1579
  }
1189
1580
  document.getElementById("loadActivityBtn").onclick = () => loadActivity();
1190
- document.getElementById("activitySince").onchange = () => renderActivity(state.activityEvents || []);
1581
+ document.getElementById("activitySince").onchange = () => loadActivity();
1191
1582
  document.getElementById("exportActivityBtn").onclick = () => {
1192
- const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.status, e.type, e.threadId || "", e.prompt || e.detail || ""].join("\\t")).join("\\n");
1583
+ const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.category || "", e.status, e.type, activityActorText(e), e.agentId || "", e.threadId || "", activityWorkspace(e), e.prompt || e.detail || ""].join("\\t")).join("\\n");
1193
1584
  const blob = new Blob([rows], { type: "text/tab-separated-values" });
1194
1585
  const a = document.createElement("a");
1195
1586
  a.href = URL.createObjectURL(blob);
@@ -1203,7 +1594,7 @@
1203
1594
  state.settings = data.settings;
1204
1595
  renderSettings();
1205
1596
  }
1206
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
1597
+ const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1207
1598
  const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1208
1599
  function orderedSettingsGroups(groups) {
1209
1600
  const known = settingsGroupOrder.filter((name) => groups[name]);
@@ -1213,6 +1604,12 @@
1213
1604
  function agentSettingsNav(current) {
1214
1605
  return '<div class="agent-settings-nav"><strong>Agent settings</strong>' + agentSettingGroups.map((name) => '<button type="button" data-setting-tab="' + attr(name) + '" class="' + (name === current ? "active" : "") + '">' + esc(name) + "</button>").join("") + "</div>";
1215
1606
  }
1607
+ function settingHelp(s) {
1608
+ return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
1609
+ }
1610
+ function settingLabel(s) {
1611
+ return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
1612
+ }
1216
1613
  function renderSettings() {
1217
1614
  const groups = {};
1218
1615
  state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
@@ -1225,7 +1622,7 @@
1225
1622
  });
1226
1623
  const items = groups[state.settingsGroup] || [];
1227
1624
  const nav = state.settingsGroup === "Agents" || agentSettingGroups.includes(state.settingsGroup) ? agentSettingsNav(state.settingsGroup) : "";
1228
- document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '"><label>' + esc(s.label) + "</label>" + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
1625
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
1229
1626
  document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
1230
1627
  state.settingsGroup = b.dataset.settingTab;
1231
1628
  renderSettings();
@@ -1319,6 +1716,8 @@
1319
1716
  const d = await api("/api/users");
1320
1717
  state.userManagement = d;
1321
1718
  renderUserManagement(d);
1719
+ bindAccessTabs();
1720
+ switchAccessTab(state.accessTab || "users");
1322
1721
  if (can("sessions.read")) await loadLocks();
1323
1722
  else document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
1324
1723
  if (can("audit.read")) await loadAudit();
@@ -1334,7 +1733,7 @@
1334
1733
  return '<div class="permission-grid full-span">' + (state.userManagement?.permissions || []).map((p) => '<label class="checkbox"><input type="checkbox" data-permission-choice="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</div>";
1335
1734
  }
1336
1735
  function selectedValues(selector) {
1337
- return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.value).filter(Boolean);
1736
+ return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.value).filter(Boolean);
1338
1737
  }
1339
1738
  function csv(values = []) {
1340
1739
  return (values || []).join(", ");
@@ -1355,14 +1754,66 @@
1355
1754
  function userGroups(u) {
1356
1755
  return (u.groups || []).map((g) => g.id);
1357
1756
  }
1757
+ function accessCopyButton(value, label) {
1758
+ return value ? '<button type="button" class="copy-id" data-access-copy="' + attr(value) + '" data-access-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
1759
+ }
1760
+ function bindAccessCopyButtons() {
1761
+ document.querySelectorAll("[data-access-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.accessCopy || "", b.dataset.accessCopyLabel || "Copied"));
1762
+ }
1763
+ function bindAccessTabs() {
1764
+ document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
1765
+ const search = document.getElementById("discordChannelSearch");
1766
+ if (search && !search.dataset.bound) {
1767
+ search.dataset.bound = "true";
1768
+ search.oninput = () => renderDiscordChannels();
1769
+ }
1770
+ }
1771
+ function switchAccessTab(tab) {
1772
+ state.accessTab = tab || "users";
1773
+ document.querySelectorAll("[data-access-tab]").forEach((b) => b.classList.toggle("active", b.dataset.accessTab === state.accessTab));
1774
+ document.querySelectorAll("[data-access-tab-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.accessTabPanel === state.accessTab));
1775
+ }
1776
+ function groupNames(ids = []) {
1777
+ const groups = state.userManagement?.groups || [];
1778
+ return (ids || []).map((id) => groups.find((g) => g.id === id)?.name || id).join(", ");
1779
+ }
1780
+ function discordChannelLabel(channel) {
1781
+ return channel.title ? channel.title + " (" + channel.channelId + ")" : channel.channelId;
1782
+ }
1783
+ function discordScopeLabel(ids = []) {
1784
+ const channels = state.userManagement?.discordChannels || [];
1785
+ return (ids || []).map((id) => discordChannelLabel(channels.find((c) => c.channelId === id) || { channelId: id })).join(", ");
1786
+ }
1787
+ function discordChannelOptions(selected = []) {
1788
+ const selectedSet = new Set(selected || []);
1789
+ const channels = state.userManagement?.discordChannels || [];
1790
+ return '<div class="permission-grid full-span">' + (channels.map((c) => '<label class="checkbox"><input type="checkbox" data-discord-channel-choice="' + attr(c.channelId) + '" ' + (selectedSet.has(c.channelId) ? "checked" : "") + "> " + esc(discordChannelLabel(c) + " / " + (c.guildId || "DM")) + "</label>").join("") || "<small>No Discord channels registered.</small>") + "</div>";
1791
+ }
1792
+ function discordIdentityHtml(user, identity) {
1793
+ const id = String(identity.discordUserId || "");
1794
+ return '<span class="access-id-row">' + accessCopyButton(id, "Discord user ID copied") + (identity.username ? " <span>@" + esc(identity.username) + "</span>" : "") + ' <button class="secondary mini-button" data-discord-user="' + attr(user.id) + '" data-discord-unlink="' + attr(identity.id) + '"' + disabledAttr("users.write") + ">Unlink</button></span>";
1795
+ }
1796
+ function renderDiscordChannels(channels = state.userManagement?.discordChannels || []) {
1797
+ const target = document.getElementById("discordChannelsList");
1798
+ if (!target) return;
1799
+ const query = (document.getElementById("discordChannelSearch")?.value || "").toLowerCase();
1800
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.guildId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
1801
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Discord channel ID copied") + '</small><small class="access-id-row">Guild ID: ' + accessCopyButton(c.guildId || "", "Discord guild ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-discord-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-discord-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Discord channels registered.</div>';
1802
+ bindDiscordChannelButtons();
1803
+ bindAccessCopyButtons();
1804
+ applyPermissions();
1805
+ }
1358
1806
  function renderUserManagement(d) {
1359
1807
  document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
1360
1808
  const telegram = (u.telegramIdentities || []).map((t) => t.telegramUserId + (t.username ? " @" + t.username : "") + ' <button class="secondary mini-button" data-telegram-user="' + attr(u.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button>").join(" ");
1361
- return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
1809
+ const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
1810
+ return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Discord: " + (discord || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
1362
1811
  }).join("") || '<div class="item">No users.</div>';
1363
- document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
1364
- document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc((c.allowedGroupIds || []).join(", ") || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
1812
+ document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + "</small><small>Discord channel scope: " + esc(discordScopeLabel(g.discordChannelIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
1813
+ document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
1814
+ renderDiscordChannels(d.discordChannels || []);
1365
1815
  bindUserButtons();
1816
+ bindAccessCopyButtons();
1366
1817
  applyPermissions();
1367
1818
  }
1368
1819
  function bindUserButtons() {
@@ -1382,6 +1833,11 @@
1382
1833
  toast("Telegram link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
1383
1834
  }));
1384
1835
  document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
1836
+ document.querySelectorAll("[data-user-discord-code]").forEach((b) => b.onclick = () => safe(async () => {
1837
+ const r = await api("/api/users/" + encodeURIComponent(b.dataset.userDiscordCode) + "/discord", { method: "POST", body: JSON.stringify({ createCode: true }) });
1838
+ toast("Discord link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
1839
+ }));
1840
+ document.querySelectorAll("[data-user-discord-link]").forEach((b) => b.onclick = () => openDiscordLinkDialog(b.dataset.userDiscordLink));
1385
1841
  document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
1386
1842
  document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
1387
1843
  if (confirm("Revoke all web sessions for this user?")) {
@@ -1397,6 +1853,13 @@
1397
1853
  loadAccess();
1398
1854
  }
1399
1855
  }));
1856
+ document.querySelectorAll("[data-discord-unlink]").forEach((b) => b.onclick = () => safe(async () => {
1857
+ if (confirm("Unlink this Discord identity?")) {
1858
+ await api("/api/users/" + encodeURIComponent(b.dataset.discordUser) + "/discord/" + encodeURIComponent(b.dataset.discordUnlink), { method: "DELETE" });
1859
+ toast("Discord unlinked");
1860
+ loadAccess();
1861
+ }
1862
+ }));
1400
1863
  document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
1401
1864
  const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
1402
1865
  if (g) openGroupDialog(g);
@@ -1412,6 +1875,30 @@
1412
1875
  toast("Chat updated");
1413
1876
  loadAccess();
1414
1877
  }));
1878
+ document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
1879
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
1880
+ if (c) openDiscordChannelDialog(c);
1881
+ });
1882
+ document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
1883
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
1884
+ if (!c) return;
1885
+ await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
1886
+ toast("Discord channel updated");
1887
+ loadAccess();
1888
+ }));
1889
+ }
1890
+ function bindDiscordChannelButtons() {
1891
+ document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
1892
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
1893
+ if (c) openDiscordChannelDialog(c);
1894
+ });
1895
+ document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
1896
+ const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
1897
+ if (!c) return;
1898
+ await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
1899
+ toast("Discord channel updated");
1900
+ loadAccess();
1901
+ }));
1415
1902
  }
1416
1903
  function openUserDialog(u) {
1417
1904
  adminDialog(u ? "Edit user" : "Create user", '<label>Email<input id="dlgEmail" value="' + attr(u?.email || "") + '"></label><label>Display name<input id="dlgName" value="' + attr(u?.displayName || "") + '"></label>' + (!u ? '<label>Password<input id="dlgPassword" type="password" autocomplete="new-password"></label>' : "") + '<label class="checkbox"><input id="dlgActive" type="checkbox" ' + (!u || u.active ? "checked" : "") + '> Active</label><div class="full-span"><strong>Groups</strong><div class="row">' + groupOptions(u ? userGroups(u) : ["user"]) + "</div></div>", async () => {
@@ -1427,6 +1914,19 @@
1427
1914
  toast("Password updated");
1428
1915
  });
1429
1916
  }
1917
+ function assertDiscordId(value, label, required = true) {
1918
+ const text = String(value || "").trim();
1919
+ if (!text && !required) return "";
1920
+ if (!text) throw new Error(label + " is required");
1921
+ if (!/^\d{5,32}$/.test(text)) throw new Error(label + " must be a numeric Discord ID");
1922
+ return text;
1923
+ }
1924
+ function openDiscordLinkDialog(id) {
1925
+ adminDialog("Link Discord ID", '<label>Discord user ID<input id="dlgDiscordId" inputmode="numeric" pattern="[0-9]*"></label><label>Username<input id="dlgDiscordUsername"></label>', async () => {
1926
+ await api("/api/users/" + encodeURIComponent(id) + "/discord", { method: "POST", body: JSON.stringify({ discordUserId: assertDiscordId(val("dlgDiscordId"), "Discord user ID"), username: val("dlgDiscordUsername") || void 0 }) });
1927
+ toast("Discord linked");
1928
+ });
1929
+ }
1430
1930
  function openTelegramLinkDialog(id) {
1431
1931
  adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
1432
1932
  await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
@@ -1434,12 +1934,20 @@
1434
1934
  });
1435
1935
  }
1436
1936
  function openGroupDialog(g) {
1437
- adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
1438
- const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger) };
1937
+ adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><div class="full-span"><strong>Discord channel scope</strong>' + discordChannelOptions(g?.discordChannelIds || []) + '<small>Leave empty to allow every registered Discord channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
1938
+ const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger), discordChannelIds: selectedValues("[data-discord-channel-choice]") };
1439
1939
  await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
1440
1940
  toast(g ? "Group updated" : "Group created");
1441
1941
  });
1442
1942
  }
1943
+ function openDiscordChannelDialog(c) {
1944
+ adminDialog(c ? "Edit Discord channel" : "Add Discord channel", '<label>Guild ID<input id="dlgDiscordGuildId" inputmode="numeric" pattern="[0-9]*" value="' + attr(c?.guildId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Channel ID<input id="dlgDiscordChannelId" inputmode="numeric" pattern="[0-9]*" value="' + attr(c?.channelId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgDiscordChannelTitle" value="' + attr(c?.title || "") + '"></label><label>Type<select id="dlgDiscordChannelType"><option value="guild" ' + ((c?.type || "guild") === "guild" ? "selected" : "") + '>guild</option><option value="thread" ' + (c?.type === "thread" ? "selected" : "") + '>thread</option><option value="dm" ' + (c?.type === "dm" ? "selected" : "") + '>dm</option></select></label><label class="checkbox"><input id="dlgDiscordChannelEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
1945
+ const type = val("dlgDiscordChannelType") || "guild";
1946
+ const payload = { guildId: assertDiscordId(val("dlgDiscordGuildId"), "Guild ID", type === "dm" ? false : true) || void 0, channelId: assertDiscordId(val("dlgDiscordChannelId"), "Channel ID"), title: val("dlgDiscordChannelTitle") || void 0, type, enabled: document.getElementById("dlgDiscordChannelEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
1947
+ await api(c ? "/api/discord-channels/" + encodeURIComponent(c.id) : "/api/discord-channels", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
1948
+ toast(c ? "Discord channel updated" : "Discord channel added");
1949
+ });
1950
+ }
1443
1951
  function openChatDialog(c) {
1444
1952
  adminDialog(c ? "Edit Telegram chat" : "Add Telegram chat", '<label>Chat ID<input id="dlgChatId" type="number" value="' + attr(c?.chatId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgChatTitle" value="' + attr(c?.title || "") + '"></label><label>Type<input id="dlgChatType" value="' + attr(c?.type || "supergroup") + '"></label><label class="checkbox"><input id="dlgChatEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
1445
1953
  const payload = { chatId: Number(val("dlgChatId")), title: val("dlgChatTitle") || void 0, type: val("dlgChatType") || void 0, enabled: document.getElementById("dlgChatEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
@@ -1471,13 +1979,23 @@
1471
1979
  }
1472
1980
  openChatDialog(null);
1473
1981
  };
1982
+ document.getElementById("createDiscordChannelBtn").onclick = () => {
1983
+ if (!can("users.write")) {
1984
+ toast("Permission required: users.write");
1985
+ return;
1986
+ }
1987
+ openDiscordChannelDialog(null);
1988
+ };
1474
1989
  async function loadLocks() {
1475
1990
  if (!can("sessions.read")) {
1476
1991
  document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
1477
1992
  return;
1478
1993
  }
1479
1994
  const d = await api("/api/locks");
1480
- document.getElementById("locksList").innerHTML = (d.locks || []).map((l) => '<div class="item"><strong>' + esc(l.contextKey) + "</strong><small>" + esc((l.ownerName || "owner") + " / " + l.ownerId + " / expires " + fmtDate(l.expiresAt)) + "</small></div>").join("") || '<div class="item">No active locks.</div>';
1995
+ document.getElementById("locksList").innerHTML = (d.locks || []).map((l) => {
1996
+ const owner = [l.ownerLabel || l.ownerUserId, l.ownerUserId, l.ownerChannel ? "via " + l.ownerChannel : "", l.ownerChannelUserId ? "channel user " + l.ownerChannelUserId : "", l.expiresAt ? "expires " + fmtDate(l.expiresAt) : ""].filter(Boolean).join(" | ");
1997
+ return '<div class="item"><strong>' + esc(l.contextKey) + "</strong><small>" + esc(owner) + "</small></div>";
1998
+ }).join("") || '<div class="item">No active locks.</div>';
1481
1999
  }
1482
2000
  document.getElementById("lockSessionBtn").onclick = () => safe(async () => {
1483
2001
  if (!can("sessions.write")) {
@@ -1497,15 +2015,36 @@
1497
2015
  toast("Web session unlocked");
1498
2016
  loadLocks();
1499
2017
  });
2018
+ function auditQuery() {
2019
+ return { limit: val("auditLimit") || "50", channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
2020
+ }
1500
2021
  async function loadAudit() {
1501
2022
  if (!can("audit.read")) {
1502
2023
  document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
1503
2024
  return;
1504
2025
  }
1505
- const d = await api("/api/audit", { query: { limit: val("auditLimit") || "50" } });
1506
- document.getElementById("auditList").innerHTML = (d.events || []).map((e) => '<div class="item"><strong>' + esc(fmtDate(e.timestamp) + " / " + (e.channelId || "-") + " / " + e.status + " / " + e.action) + "</strong><small>" + esc((e.contextKey || "-") + " / " + (e.agentId || "-") + " / " + (e.threadId || "-")) + "</small><small>" + esc(e.description || e.detail || "") + "</small></div>").join("") || '<div class="item">No audit events.</div>';
2026
+ const d = await api("/api/audit", { query: auditQuery() });
2027
+ state.auditEvents = d.events || [];
2028
+ renderAudit(state.auditEvents);
2029
+ }
2030
+ function renderAudit(events) {
2031
+ document.getElementById("auditList").innerHTML = (events || []).map((e) => {
2032
+ const actor = e.actor ? [e.actor.label || e.actor.username || e.actor.id, e.actor.channel, e.actor.channelUserId].filter(Boolean).join(" | ") : e.actorId ? String(e.actorId) : "system";
2033
+ const meta = [e.contextKey, e.agentId, e.threadId, e.workspace].filter(Boolean).join(" | ");
2034
+ return '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action].filter(Boolean).join(" | ")) + "</strong><small>" + esc("Actor: " + actor) + "</small>" + (meta ? "<small>" + esc(meta) + "</small>" : "") + "<small>" + esc(e.description || e.detail || "") + "</small></div>";
2035
+ }).join("") || '<div class="item">No audit events.</div>';
1507
2036
  }
1508
2037
  document.getElementById("loadAuditBtn").onclick = () => loadAudit();
2038
+ document.getElementById("exportAuditBtn").onclick = () => {
2039
+ const events = state.auditEvents || [];
2040
+ const text = events.map((e) => [fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action, e.actor?.label || e.actor?.username || e.actor?.id || e.actorId || "system", e.contextKey, e.agentId || "", e.threadId || "", e.workspace || "", e.description || e.detail || ""].join("\\t")).join("\\n");
2041
+ const blob = new Blob([text], { type: "text/tab-separated-values" });
2042
+ const a = document.createElement("a");
2043
+ a.href = URL.createObjectURL(blob);
2044
+ a.download = "nordrelay-audit.tsv";
2045
+ a.click();
2046
+ URL.revokeObjectURL(a.href);
2047
+ };
1509
2048
  async function loadLogs() {
1510
2049
  if (!document.getElementById("logAutoRefresh").checked) setLoading("logs", "Loading logs...");
1511
2050
  const target = document.getElementById("logTarget").value;
@@ -1562,6 +2101,97 @@
1562
2101
  toast("Cleared " + target + " log");
1563
2102
  }
1564
2103
  });
2104
+ async function loadPeers() {
2105
+ if (!can("peers.read")) {
2106
+ document.getElementById("peersList").innerHTML = '<div class="item">Permission required: peers.read</div>';
2107
+ return;
2108
+ }
2109
+ setLoading("peersList", "Loading peers...");
2110
+ const d = await api("/api/peers", { local: true });
2111
+ state.peers = d;
2112
+ document.getElementById("peerStatus").innerHTML = card("Local peer identity", [["Peer server", d.enabled ? "enabled" : "disabled"], ["Listen URL", d.listenUrl], ["Require TLS", d.requireTls ? "yes" : "no"], ["Node ID", d.identity?.nodeId], ["Fingerprint", d.identity?.fingerprint]]);
2113
+ document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
2114
+ document.getElementById("peerInvites").innerHTML = (d.invitations || []).map((i) => '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt ? "open" : "closed") + "</span></strong><small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + "</div>").join("") || '<div class="item">No open invitations.</div>';
2115
+ bindPeerButtons();
2116
+ await loadPeerSelector();
2117
+ applyPermissions();
2118
+ }
2119
+ function peerCard(p) {
2120
+ const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2121
+ return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2122
+ }
2123
+ function bindPeerButtons() {
2124
+ document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
2125
+ state.selectedPeer = b.dataset.peerSelect || "local";
2126
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
2127
+ connectEvents();
2128
+ await loadBootstrap();
2129
+ toast("Peer target selected");
2130
+ }));
2131
+ document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
2132
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/proxy", { method: "POST", body: JSON.stringify({ method: "GET", path: "/api/health", query: {}, body: {} }), local: true });
2133
+ toast("Peer reachable: " + (r.health?.state?.status || r.state?.status || "ok"), { duration: 6e3 });
2134
+ loadPeers();
2135
+ }));
2136
+ document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
2137
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
2138
+ if (p) openPeerDialog(p);
2139
+ });
2140
+ document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
2141
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
2142
+ if (!p) return;
2143
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
2144
+ toast("Peer updated");
2145
+ loadPeers();
2146
+ }));
2147
+ document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
2148
+ if (confirm("Revoke this peer?")) {
2149
+ await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
2150
+ if (state.selectedPeer === b.dataset.peerRevoke) {
2151
+ state.selectedPeer = "local";
2152
+ localStorage.setItem("nordrelayPeerTarget", "local");
2153
+ }
2154
+ toast("Peer revoked");
2155
+ loadPeers();
2156
+ }
2157
+ }));
2158
+ }
2159
+ function openPeerDialog(p) {
2160
+ adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label>', async () => {
2161
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")) }), local: true });
2162
+ toast("Peer updated");
2163
+ await loadPeers();
2164
+ });
2165
+ }
2166
+ function openPeerInviteDialog() {
2167
+ adminDialog("Create peer invite", '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label>', async () => {
2168
+ const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")) }), local: true });
2169
+ toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
2170
+ await loadPeers();
2171
+ });
2172
+ }
2173
+ function openPeerAddDialog() {
2174
+ adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional"></label>', async () => {
2175
+ const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
2176
+ toast("Added peer " + (r.peer?.name || ""));
2177
+ await loadPeers();
2178
+ });
2179
+ }
2180
+ document.getElementById("loadPeersBtn").onclick = () => loadPeers();
2181
+ document.getElementById("createPeerInviteBtn").onclick = () => {
2182
+ if (!can("peers.write")) {
2183
+ toast("Permission required: peers.write");
2184
+ return;
2185
+ }
2186
+ openPeerInviteDialog();
2187
+ };
2188
+ document.getElementById("addPeerBtn").onclick = () => {
2189
+ if (!can("peers.write")) {
2190
+ toast("Permission required: peers.write");
2191
+ return;
2192
+ }
2193
+ openPeerAddDialog();
2194
+ };
1565
2195
  async function loadAdapterHealth() {
1566
2196
  setLoading("adapterHealth", "Loading adapters...");
1567
2197
  const d = await api("/api/adapters/health");
@@ -1724,13 +2354,28 @@
1724
2354
  document.getElementById("diagnostics").innerHTML = diagnosticsHtml(data);
1725
2355
  }
1726
2356
  const exportDiagnosticsBundleBtn = document.getElementById("exportDiagnosticsBundleBtn");
1727
- if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => {
2357
+ if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => safe(async () => {
1728
2358
  if (!can("diagnostics.read")) {
1729
2359
  toast("Permission required: diagnostics.read");
1730
2360
  return;
1731
2361
  }
1732
- window.open("/api/diagnostics/bundle", "_blank");
1733
- };
2362
+ if (!state.selectedPeer || state.selectedPeer === "local") {
2363
+ window.open("/api/diagnostics/bundle", "_blank");
2364
+ return;
2365
+ }
2366
+ const bundle = await api("/api/diagnostics/bundle");
2367
+ downloadBase64(bundle.name || "nordrelay-support-bundle.zip", bundle.dataBase64 || "", bundle.mimeType || "application/zip");
2368
+ toast("Remote diagnostics bundle downloaded");
2369
+ });
2370
+ function downloadBase64(name, dataBase64, mimeType) {
2371
+ const bytes = Uint8Array.from(atob(dataBase64), (c) => c.charCodeAt(0));
2372
+ const blob = new Blob([bytes], { type: mimeType });
2373
+ const a = document.createElement("a");
2374
+ a.href = URL.createObjectURL(blob);
2375
+ a.download = name;
2376
+ a.click();
2377
+ URL.revokeObjectURL(a.href);
2378
+ }
1734
2379
  function diagnosticsHtml(d) {
1735
2380
  const h = d.health || {};
1736
2381
  const s = d.snapshot?.session || {};
@@ -1747,4 +2392,83 @@
1747
2392
  Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
1748
2393
  }
1749
2394
  loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
2395
+ const loadPeersBase = loadPeers;
2396
+ loadPeers = async function() {
2397
+ await loadPeersBase();
2398
+ ensureGlobalPeerSessionsPanel();
2399
+ };
2400
+ function ensureGlobalPeerSessionsPanel() {
2401
+ if (document.getElementById("globalPeerSessionsPanel")) return;
2402
+ const anchor = document.getElementById("peersList");
2403
+ if (!anchor) return;
2404
+ anchor.insertAdjacentHTML("afterend", '<h2>Global sessions</h2><div id="globalPeerSessionsPanel" class="list"><div class="item"><div class="row"><input id="globalPeerSessionSearch" placeholder="Search sessions across peers"><button id="loadGlobalPeerSessionsBtn">Load global sessions</button></div><div id="globalPeerSessionsList"></div></div></div>');
2405
+ document.getElementById("loadGlobalPeerSessionsBtn").onclick = () => safe(loadGlobalPeerSessions);
2406
+ }
2407
+ async function loadGlobalPeerSessions() {
2408
+ const target = document.getElementById("globalPeerSessionsList");
2409
+ target.innerHTML = loadingHtml("Loading global sessions...");
2410
+ const q = document.getElementById("globalPeerSessionSearch").value || "";
2411
+ const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
2412
+ target.innerHTML = (d.targets || []).map((t) => '<div class="item"><strong>' + esc(t.peerName + " (" + t.peerId + ")") + ' <span class="adapter-status ' + (t.ok ? "enabled" : "disabled") + '">' + (t.ok ? "ok" : "error") + "</span></strong>" + (t.ok ? (t.data?.sessions || []).slice(0, 8).map((s) => "<small>" + esc(short(s.id, 48) + " | " + short(s.cwd || "", 80) + " | " + fmtDate(s.updatedAt)) + "</small>").join("") || "<small>No sessions.</small>" : '<small class="error">' + esc(t.error || "failed") + "</small>") + "</div>").join("") || '<div class="item">No peer sessions found.</div>';
2413
+ }
2414
+ function peerCard(p) {
2415
+ const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2416
+ const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
2417
+ const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2418
+ return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2419
+ }
2420
+ function openPeerDialog(p) {
2421
+ const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2422
+ adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
2423
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
2424
+ toast("Peer updated");
2425
+ await loadPeers();
2426
+ });
2427
+ }
2428
+ function openPeerInviteDialog() {
2429
+ adminDialog("Create peer invite", '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
2430
+ const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
2431
+ toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
2432
+ await loadPeers();
2433
+ });
2434
+ }
2435
+ function aliasMap(text) {
2436
+ return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
2437
+ }
2438
+ function bindPeerButtons() {
2439
+ document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
2440
+ state.selectedPeer = b.dataset.peerSelect || "local";
2441
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
2442
+ connectEvents();
2443
+ await loadBootstrap();
2444
+ toast("Peer target selected");
2445
+ }));
2446
+ document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
2447
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/health", { local: true });
2448
+ toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
2449
+ loadPeers();
2450
+ }));
2451
+ document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
2452
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
2453
+ if (p) openPeerDialog(p);
2454
+ });
2455
+ document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
2456
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
2457
+ if (!p) return;
2458
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
2459
+ toast("Peer updated");
2460
+ loadPeers();
2461
+ }));
2462
+ document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
2463
+ if (confirm("Revoke this peer?")) {
2464
+ await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
2465
+ if (state.selectedPeer === b.dataset.peerRevoke) {
2466
+ state.selectedPeer = "local";
2467
+ localStorage.setItem("nordrelayPeerTarget", "local");
2468
+ }
2469
+ toast("Peer revoked");
2470
+ loadPeers();
2471
+ }
2472
+ }));
2473
+ }
1750
2474
  })();