@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.
- package/.env.example +80 -11
- package/README.md +154 -22
- package/dist/access-control.js +7 -1
- package/dist/activity-events.js +44 -0
- package/dist/audit-log.js +40 -2
- package/dist/bot-preferences.js +1 -0
- package/dist/bot-rendering.js +10 -7
- package/dist/bot.js +535 -11
- package/dist/channel-actions.js +7 -2
- package/dist/channel-adapter.js +40 -7
- package/dist/channel-command-catalog.js +88 -0
- package/dist/channel-command-service.js +369 -0
- package/dist/channel-mirror-registry.js +77 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-service.js +237 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +93 -13
- package/dist/config.js +103 -8
- package/dist/context-key.js +87 -5
- package/dist/discord-artifacts.js +165 -0
- package/dist/discord-bot.js +2073 -0
- package/dist/discord-channel-runtime.js +133 -0
- package/dist/discord-command-surface.js +57 -0
- package/dist/discord-rate-limit.js +141 -0
- package/dist/index.js +36 -5
- package/dist/job-store.js +127 -0
- package/dist/metrics.js +87 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +256 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-runtime-service.js +636 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +294 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-external-activity-monitor.js +47 -6
- package/dist/relay-runtime-helpers.js +208 -0
- package/dist/relay-runtime.js +897 -394
- package/dist/remote-prompt.js +98 -0
- package/dist/runtime-cache.js +57 -0
- package/dist/session-locks.js +10 -7
- package/dist/support-bundle.js +1 -0
- package/dist/telegram-access-commands.js +15 -2
- package/dist/telegram-access-middleware.js +16 -3
- package/dist/telegram-agent-commands.js +25 -0
- package/dist/telegram-artifact-commands.js +46 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-diagnostics-command.js +5 -50
- package/dist/telegram-general-commands.js +16 -6
- package/dist/telegram-operational-commands.js +14 -6
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/telegram-queue-commands.js +74 -4
- package/dist/telegram-support-command.js +7 -0
- package/dist/telegram-update-commands.js +27 -0
- package/dist/user-management.js +208 -0
- package/dist/web-api-contract.js +17 -0
- package/dist/web-dashboard-access-routes.js +74 -1
- package/dist/web-dashboard-artifact-routes.js +3 -3
- package/dist/web-dashboard-assets.js +2 -0
- package/dist/web-dashboard-pages.js +109 -13
- package/dist/web-dashboard-peer-routes.js +204 -0
- package/dist/web-dashboard-runtime-routes.js +53 -8
- package/dist/web-dashboard-session-routes.js +27 -20
- package/dist/web-dashboard-ui.js +2 -0
- package/dist/web-dashboard.js +160 -6
- package/dist/web-state.js +33 -2
- package/dist/webui-assets/dashboard.css +75 -1
- package/dist/webui-assets/dashboard.js +779 -55
- package/package.json +5 -2
- 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
|
|
347
|
-
state.auth =
|
|
348
|
-
state.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: " + (
|
|
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) =>
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 = () =>
|
|
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") + '"
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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:
|
|
1506
|
-
|
|
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
|
-
|
|
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
|
})();
|