@nordbyte/nordrelay 0.6.0 → 0.8.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 +52 -0
- package/README.md +171 -50
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +95 -37
- package/dist/channel-adapter.js +44 -11
- package/dist/channel-command-catalog.js +94 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +230 -1
- package/dist/channel-mirror-registry.js +84 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +82 -8
- package/dist/config.js +79 -7
- package/dist/context-key.js +42 -0
- package/dist/discord-bot.js +173 -342
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +29 -0
- package/dist/metrics.js +48 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +288 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +658 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +307 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +210 -0
- package/dist/relay-runtime.js +79 -274
- package/dist/remote-prompt.js +98 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-general-commands.js +14 -0
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +16 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +26 -4
- package/dist/web-dashboard-peer-routes.js +225 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +46 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +870 -57
- package/package.json +5 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
{ re: /^\/api\/agent-update\/[^\/]+\/input$/, methods: ["POST"] },
|
|
21
21
|
{ re: /^\/api\/agent-update\/[^\/]+\/cancel$/, methods: ["POST"] },
|
|
22
22
|
{ path: "/api/adapters/health", methods: ["GET"] },
|
|
23
|
+
{ path: "/api/adapters/conformance", methods: ["GET"] },
|
|
24
|
+
{ path: "/api/peers", methods: ["GET", "POST"] },
|
|
25
|
+
{ path: "/api/peers/invite", methods: ["POST"] },
|
|
26
|
+
{ path: "/api/peers/pair", methods: ["POST"] },
|
|
27
|
+
{ path: "/api/peers/probe", methods: ["POST"] },
|
|
28
|
+
{ path: "/api/peers/global-sessions", methods: ["GET"] },
|
|
29
|
+
{ re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
|
|
30
|
+
{ re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
|
|
31
|
+
{ re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
|
|
32
|
+
{ re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
|
|
33
|
+
{ re: /^\/api\/peers\/[^\/]+\/events$/, methods: ["GET"] },
|
|
23
34
|
{ path: "/api/permissions", methods: ["GET"] },
|
|
24
35
|
{ path: "/api/users", methods: ["GET", "POST"] },
|
|
25
36
|
{ re: /^\/api\/users\/[^\/]+$/, methods: ["PATCH"] },
|
|
@@ -30,18 +41,23 @@
|
|
|
30
41
|
{ re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
|
|
31
42
|
{ re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
|
|
32
43
|
{ re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
|
|
44
|
+
{ re: /^\/api\/users\/[^\/]+\/slack$/, methods: ["POST"] },
|
|
45
|
+
{ re: /^\/api\/users\/[^\/]+\/slack\/[^\/]+$/, methods: ["DELETE"] },
|
|
33
46
|
{ path: "/api/groups", methods: ["GET", "POST"] },
|
|
34
47
|
{ re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
|
|
35
48
|
{ path: "/api/telegram-chats", methods: ["GET", "POST"] },
|
|
36
49
|
{ re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
|
|
37
50
|
{ path: "/api/discord-channels", methods: ["GET", "POST"] },
|
|
38
51
|
{ re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
|
|
52
|
+
{ path: "/api/slack-channels", methods: ["GET", "POST"] },
|
|
53
|
+
{ re: /^\/api\/slack-channels\/[^\/]+$/, methods: ["PATCH"] },
|
|
39
54
|
{ path: "/api/audit", methods: ["GET"] },
|
|
40
55
|
{ path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
|
|
41
56
|
{ path: "/api/auth/status", methods: ["GET"] },
|
|
42
57
|
{ path: "/api/auth/login", methods: ["POST"] },
|
|
43
58
|
{ path: "/api/auth/logout", methods: ["POST"] },
|
|
44
59
|
{ path: "/api/settings", methods: ["GET", "PATCH"] },
|
|
60
|
+
{ path: "/api/settings/wizard/test", methods: ["POST"] },
|
|
45
61
|
{ path: "/api/control-options", methods: ["GET"] },
|
|
46
62
|
{ path: "/api/sessions", methods: ["GET"] },
|
|
47
63
|
{ path: "/api/sessions/new", methods: ["POST"] },
|
|
@@ -84,6 +100,32 @@
|
|
|
84
100
|
const method = normalizeMethod(options.method, options.body);
|
|
85
101
|
const url = apiUrl(path, options.query);
|
|
86
102
|
assertApiRoute(url.pathname, method);
|
|
103
|
+
if (!options.local && shouldProxyApi(url.pathname)) {
|
|
104
|
+
const peerId = selectedPeerTarget();
|
|
105
|
+
const proxyBody = JSON.stringify({
|
|
106
|
+
method,
|
|
107
|
+
path: url.pathname,
|
|
108
|
+
query: queryObject(url),
|
|
109
|
+
body: bodyObject(options.body),
|
|
110
|
+
contextKey: "web:dashboard"
|
|
111
|
+
});
|
|
112
|
+
const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "content-type": "application/json" },
|
|
115
|
+
body: proxyBody
|
|
116
|
+
});
|
|
117
|
+
if (res2.status === 401) {
|
|
118
|
+
location.reload();
|
|
119
|
+
return (
|
|
120
|
+
/** @type {never} */
|
|
121
|
+
void 0
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const text2 = await res2.text();
|
|
125
|
+
const data2 = text2 ? JSON.parse(text2) : {};
|
|
126
|
+
if (!res2.ok) throw new Error(data2.error || res2.statusText);
|
|
127
|
+
return data2;
|
|
128
|
+
}
|
|
87
129
|
const body = normalizeBody(options.body);
|
|
88
130
|
const headers = {
|
|
89
131
|
...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
|
|
@@ -102,6 +144,46 @@
|
|
|
102
144
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
103
145
|
return data;
|
|
104
146
|
}
|
|
147
|
+
function shouldProxyApi(path) {
|
|
148
|
+
const peerId = selectedPeerTarget();
|
|
149
|
+
if (!peerId || peerId === "local") return false;
|
|
150
|
+
if (!path.startsWith("/api/")) return false;
|
|
151
|
+
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
|
|
152
|
+
}
|
|
153
|
+
function isLocalAdminApi(path) {
|
|
154
|
+
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" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
|
|
155
|
+
}
|
|
156
|
+
function selectedPeerTarget() {
|
|
157
|
+
const runtimeState = (
|
|
158
|
+
/** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { selectedPeer?: string } }} */
|
|
159
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE
|
|
160
|
+
);
|
|
161
|
+
return runtimeState?.selectedPeer || "local";
|
|
162
|
+
}
|
|
163
|
+
function queryObject(url) {
|
|
164
|
+
const result = {};
|
|
165
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
166
|
+
if (result[key] === void 0) result[key] = value;
|
|
167
|
+
else if (Array.isArray(result[key])) result[key].push(value);
|
|
168
|
+
else result[key] = [result[key], value];
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
function bodyObject(body) {
|
|
173
|
+
if (body === void 0 || body === null) return {};
|
|
174
|
+
if (typeof body === "string") {
|
|
175
|
+
try {
|
|
176
|
+
return body ? JSON.parse(body) : {};
|
|
177
|
+
} catch {
|
|
178
|
+
return { value: body };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (isNativeBody(body)) return {};
|
|
182
|
+
return (
|
|
183
|
+
/** @type {Record<string, unknown>} */
|
|
184
|
+
body
|
|
185
|
+
);
|
|
186
|
+
}
|
|
105
187
|
function apiUrl(path, query) {
|
|
106
188
|
const url = new URL(path, location.origin);
|
|
107
189
|
if (query) {
|
|
@@ -148,7 +230,8 @@
|
|
|
148
230
|
throw new Error("Unsupported WebUI API method: " + method + " " + path);
|
|
149
231
|
}
|
|
150
232
|
}
|
|
151
|
-
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,
|
|
233
|
+
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: 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, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
234
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
|
|
152
235
|
function toast(msg, options = {}) {
|
|
153
236
|
const el = document.getElementById("toast");
|
|
154
237
|
el.textContent = msg;
|
|
@@ -245,17 +328,20 @@
|
|
|
245
328
|
["#abortBtn", "prompt.abort"],
|
|
246
329
|
["#clearChatBtn", "sessions.write"],
|
|
247
330
|
["#saveSettingsBtn", "settings.write"],
|
|
331
|
+
["#settingsWizardBtn", "settings.write"],
|
|
248
332
|
["#restartBtn", "system.restart"],
|
|
249
333
|
["#updateBtn", "updates.run"],
|
|
250
334
|
["#clearLogsBtn", "logs.clear"],
|
|
251
|
-
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
|
|
335
|
+
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
|
|
336
|
+
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
|
|
337
|
+
["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
|
|
252
338
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
253
339
|
["[data-switch]", "sessions.write"],
|
|
254
340
|
["[data-queue],[data-q]", "queue.write"],
|
|
255
341
|
["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
|
|
256
342
|
["[data-auth-login],[data-auth-logout]", "auth.manage"],
|
|
257
343
|
["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
|
|
258
|
-
["[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"]
|
|
344
|
+
["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-slack-code],[data-user-slack-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-slack-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle],[data-slack-channel-edit],[data-slack-channel-toggle]", "users.write"]
|
|
259
345
|
];
|
|
260
346
|
disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
|
|
261
347
|
el.disabled = !can(permission);
|
|
@@ -311,6 +397,7 @@
|
|
|
311
397
|
if (name === "tasks") await loadTasks();
|
|
312
398
|
if (name === "metrics") await loadMetrics();
|
|
313
399
|
if (name === "adapters") await loadAdapterHealth();
|
|
400
|
+
if (name === "peers") await loadPeers();
|
|
314
401
|
if (name === "access") await loadAccess();
|
|
315
402
|
if (name === "version") await loadVersion();
|
|
316
403
|
}
|
|
@@ -354,9 +441,11 @@
|
|
|
354
441
|
}
|
|
355
442
|
const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
|
|
356
443
|
async function loadBootstrap() {
|
|
357
|
-
const
|
|
358
|
-
state.auth =
|
|
359
|
-
state.permissions =
|
|
444
|
+
const local = await api("/api/bootstrap", { local: true });
|
|
445
|
+
state.auth = local.auth || null;
|
|
446
|
+
state.permissions = local.auth?.permissions || [];
|
|
447
|
+
await loadPeerSelector();
|
|
448
|
+
const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
|
|
360
449
|
state.snapshot = data.status.snapshot;
|
|
361
450
|
state.controls = data.controls;
|
|
362
451
|
state.enabledAgents = data.enabledAgents || [];
|
|
@@ -368,7 +457,7 @@
|
|
|
368
457
|
renderAdapters(data.channels, data.agentAdapters);
|
|
369
458
|
document.getElementById("footerVersion").textContent = "NordRelay " + (data.status.health?.version || "");
|
|
370
459
|
document.getElementById("footerHealth").textContent = "Health: " + (data.status.health?.state?.status || "unknown");
|
|
371
|
-
document.getElementById("footerUser").textContent = "User: " + (
|
|
460
|
+
document.getElementById("footerUser").textContent = "User: " + (local.auth?.user?.email || "-") + (state.selectedPeer && state.selectedPeer !== "local" ? " / target peer" : "");
|
|
372
461
|
const agentSelect = document.getElementById("agentSelect");
|
|
373
462
|
agentSelect.innerHTML = data.enabledAgents.map((a) => '<option value="' + a + '">' + a + "</option>").join("");
|
|
374
463
|
agentSelect.value = state.snapshot.session.agentId;
|
|
@@ -385,6 +474,35 @@
|
|
|
385
474
|
});
|
|
386
475
|
applyPermissions();
|
|
387
476
|
}
|
|
477
|
+
async function loadPeerSelector() {
|
|
478
|
+
const peerSelect = document.getElementById("peerSelect");
|
|
479
|
+
if (!peerSelect) return;
|
|
480
|
+
if (!can("peers.read")) {
|
|
481
|
+
peerSelect.innerHTML = '<option value="local">Local</option>';
|
|
482
|
+
peerSelect.value = "local";
|
|
483
|
+
state.selectedPeer = "local";
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
const peers = await api("/api/peers", { local: true });
|
|
488
|
+
state.peers = peers;
|
|
489
|
+
const available = (peers.peers || []).filter((p) => p.enabled && p.url);
|
|
490
|
+
peerSelect.innerHTML = '<option value="local">Local node</option>' + available.map((p) => '<option value="' + attr(p.id) + '">' + esc(p.name) + "</option>").join("");
|
|
491
|
+
if (state.selectedPeer !== "local" && !available.some((p) => p.id === state.selectedPeer)) state.selectedPeer = "local";
|
|
492
|
+
peerSelect.value = state.selectedPeer;
|
|
493
|
+
peerSelect.onchange = () => safe(async () => {
|
|
494
|
+
state.selectedPeer = peerSelect.value || "local";
|
|
495
|
+
localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
|
|
496
|
+
connectEvents();
|
|
497
|
+
toast(state.selectedPeer === "local" ? "Target: local" : "Target: " + peerSelect.options[peerSelect.selectedIndex].text);
|
|
498
|
+
await loadBootstrap();
|
|
499
|
+
await reloadCurrentPage();
|
|
500
|
+
});
|
|
501
|
+
} catch {
|
|
502
|
+
peerSelect.innerHTML = '<option value="local">Local node</option>';
|
|
503
|
+
peerSelect.value = "local";
|
|
504
|
+
}
|
|
505
|
+
}
|
|
388
506
|
function renderSnapshot(s) {
|
|
389
507
|
document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
|
|
390
508
|
document.getElementById("metrics").innerHTML = [
|
|
@@ -409,6 +527,7 @@
|
|
|
409
527
|
renderActiveSessions(data.sessions || []);
|
|
410
528
|
}
|
|
411
529
|
function renderActiveSessions(items) {
|
|
530
|
+
state.activeSessions = { sessions: items || [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
412
531
|
const box = document.getElementById("activeSessions");
|
|
413
532
|
if (!box) return;
|
|
414
533
|
box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
|
|
@@ -456,6 +575,7 @@
|
|
|
456
575
|
if (source === "cli") return "CLI";
|
|
457
576
|
if (source === "telegram") return "Telegram";
|
|
458
577
|
if (source === "discord") return "Discord";
|
|
578
|
+
if (source === "slack") return "Slack";
|
|
459
579
|
if (source === "web") return "WebUI";
|
|
460
580
|
return source || "-";
|
|
461
581
|
}
|
|
@@ -552,9 +672,6 @@
|
|
|
552
672
|
if (status === "running") return "planned";
|
|
553
673
|
return "disabled";
|
|
554
674
|
}
|
|
555
|
-
state.activeSessionsTimer = setInterval(() => {
|
|
556
|
-
if (state.currentPage === "overview") safe(loadActiveSessions);
|
|
557
|
-
}, 5e3);
|
|
558
675
|
function scrollChatToBottom() {
|
|
559
676
|
const box = document.getElementById("messages");
|
|
560
677
|
if (!box) return;
|
|
@@ -600,7 +717,8 @@
|
|
|
600
717
|
let currentAgentMessage = null;
|
|
601
718
|
function connectEvents() {
|
|
602
719
|
if (state.events) state.events.close();
|
|
603
|
-
const
|
|
720
|
+
const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
|
|
721
|
+
const events = new EventSource(eventsUrl);
|
|
604
722
|
state.events = events;
|
|
605
723
|
setConnection("Connecting", "warn");
|
|
606
724
|
events.onopen = () => {
|
|
@@ -618,6 +736,11 @@
|
|
|
618
736
|
});
|
|
619
737
|
events.addEventListener("chat_history", (e) => renderChatMessages(JSON.parse(e.data).messages || []));
|
|
620
738
|
events.addEventListener("activity_update", (e) => renderActivity(JSON.parse(e.data).events || []));
|
|
739
|
+
events.addEventListener("active_sessions_update", (e) => {
|
|
740
|
+
const d = JSON.parse(e.data);
|
|
741
|
+
state.activeSessions = d.active || null;
|
|
742
|
+
if (state.currentPage === "overview") renderActiveSessions(state.activeSessions?.sessions || []);
|
|
743
|
+
});
|
|
621
744
|
events.addEventListener("session_update", (e) => {
|
|
622
745
|
loadBootstrap();
|
|
623
746
|
loadChatHistory();
|
|
@@ -1129,6 +1252,50 @@
|
|
|
1129
1252
|
if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
|
|
1130
1253
|
return true;
|
|
1131
1254
|
}
|
|
1255
|
+
document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
|
|
1256
|
+
document.getElementById("artifactSearch").oninput = renderArtifacts;
|
|
1257
|
+
document.getElementById("artifactKind").onchange = renderArtifacts;
|
|
1258
|
+
document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
|
|
1259
|
+
if (!can("files.write")) {
|
|
1260
|
+
toast("Permission required: files.write");
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const turnIds = [...state.selectedArtifactTurns];
|
|
1264
|
+
if (turnIds.length === 0) {
|
|
1265
|
+
toast("No artifact turns selected");
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
if (confirm("Delete " + turnIds.length + " selected artifact turn(s)?")) {
|
|
1269
|
+
const r = await api("/api/artifacts/bulk", { method: "POST", body: JSON.stringify({ action: "delete", turnIds }) });
|
|
1270
|
+
state.selectedArtifactTurns.clear();
|
|
1271
|
+
toast("Deleted " + (r.removed || []).length + " artifact turn(s)");
|
|
1272
|
+
loadArtifacts();
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
function highlightCode(text) {
|
|
1276
|
+
return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g, '<span class="chip">$1</span>');
|
|
1277
|
+
}
|
|
1278
|
+
function isRemotePeerTarget() {
|
|
1279
|
+
return Boolean(state.selectedPeer && state.selectedPeer !== "local");
|
|
1280
|
+
}
|
|
1281
|
+
function artifactFileHref(turnId, path) {
|
|
1282
|
+
return isRemotePeerTarget() ? "#" : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
|
|
1283
|
+
}
|
|
1284
|
+
function artifactZipHref(turnId) {
|
|
1285
|
+
return isRemotePeerTarget() ? "#" : "/api/artifacts/zip?turnId=" + encodeURIComponent(turnId);
|
|
1286
|
+
}
|
|
1287
|
+
async function downloadArtifactFile(turnId, path) {
|
|
1288
|
+
const file = await api("/api/artifacts/file", { query: { turnId, path } });
|
|
1289
|
+
downloadBase64(file.name || path, file.dataBase64 || "", file.mimeType || "application/octet-stream");
|
|
1290
|
+
}
|
|
1291
|
+
async function downloadArtifactZip(turnId) {
|
|
1292
|
+
const file = await api("/api/artifacts/zip", { query: { turnId } });
|
|
1293
|
+
downloadBase64(file.name || "nordrelay-artifacts-" + turnId + ".zip", file.dataBase64 || "", file.mimeType || "application/zip");
|
|
1294
|
+
}
|
|
1295
|
+
async function artifactDataUrl(turnId, path) {
|
|
1296
|
+
const file = await api("/api/artifacts/file", { query: { turnId, path } });
|
|
1297
|
+
return "data:" + (file.mimeType || "application/octet-stream") + ";base64," + (file.dataBase64 || "");
|
|
1298
|
+
}
|
|
1132
1299
|
function renderArtifacts() {
|
|
1133
1300
|
const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
|
|
1134
1301
|
const kind = document.getElementById("artifactKind").value;
|
|
@@ -1137,16 +1304,27 @@
|
|
|
1137
1304
|
const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
|
|
1138
1305
|
if (files.length === 0) return "";
|
|
1139
1306
|
const gallery = files.map((a) => {
|
|
1140
|
-
const href =
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1307
|
+
const href = artifactFileHref(r.turnId, a.relativePath);
|
|
1308
|
+
const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(a.name);
|
|
1309
|
+
const img = isImage && !isRemotePeerTarget() ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
|
|
1310
|
+
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>';
|
|
1143
1311
|
}).join("");
|
|
1144
|
-
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="
|
|
1312
|
+
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>";
|
|
1145
1313
|
}).join("") || '<div class="item">No artifacts.</div>';
|
|
1146
1314
|
document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
|
|
1147
1315
|
if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
|
|
1148
1316
|
else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
|
|
1149
1317
|
});
|
|
1318
|
+
document.querySelectorAll("[data-open-artifact]").forEach((a) => a.onclick = (e) => {
|
|
1319
|
+
if (!isRemotePeerTarget()) return;
|
|
1320
|
+
e.preventDefault();
|
|
1321
|
+
safe(() => downloadArtifactFile(a.dataset.openArtifact, a.dataset.openPath));
|
|
1322
|
+
});
|
|
1323
|
+
document.querySelectorAll("[data-zip-artifact]").forEach((a) => a.onclick = (e) => {
|
|
1324
|
+
if (!isRemotePeerTarget()) return;
|
|
1325
|
+
e.preventDefault();
|
|
1326
|
+
safe(() => downloadArtifactZip(a.dataset.zipArtifact));
|
|
1327
|
+
});
|
|
1150
1328
|
document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1151
1329
|
if (!can("files.write")) {
|
|
1152
1330
|
toast("Permission required: files.write");
|
|
@@ -1161,37 +1339,14 @@
|
|
|
1161
1339
|
document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
|
|
1162
1340
|
applyPermissions();
|
|
1163
1341
|
}
|
|
1164
|
-
document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
|
|
1165
|
-
document.getElementById("artifactSearch").oninput = renderArtifacts;
|
|
1166
|
-
document.getElementById("artifactKind").onchange = renderArtifacts;
|
|
1167
1342
|
document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
|
|
1168
1343
|
const turnIds = [...state.selectedArtifactTurns];
|
|
1169
1344
|
if (turnIds.length === 0) {
|
|
1170
1345
|
toast("No artifact turns selected");
|
|
1171
1346
|
return;
|
|
1172
1347
|
}
|
|
1173
|
-
turnIds.forEach((turnId) => window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
|
|
1348
|
+
turnIds.forEach((turnId) => isRemotePeerTarget() ? safe(() => downloadArtifactZip(turnId)) : window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
|
|
1174
1349
|
};
|
|
1175
|
-
document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
|
|
1176
|
-
if (!can("files.write")) {
|
|
1177
|
-
toast("Permission required: files.write");
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
const turnIds = [...state.selectedArtifactTurns];
|
|
1181
|
-
if (turnIds.length === 0) {
|
|
1182
|
-
toast("No artifact turns selected");
|
|
1183
|
-
return;
|
|
1184
|
-
}
|
|
1185
|
-
if (confirm("Delete " + turnIds.length + " selected artifact turn(s)?")) {
|
|
1186
|
-
const r = await api("/api/artifacts/bulk", { method: "POST", body: JSON.stringify({ action: "delete", turnIds }) });
|
|
1187
|
-
state.selectedArtifactTurns.clear();
|
|
1188
|
-
toast("Deleted " + (r.removed || []).length + " artifact turn(s)");
|
|
1189
|
-
loadArtifacts();
|
|
1190
|
-
}
|
|
1191
|
-
});
|
|
1192
|
-
function highlightCode(text) {
|
|
1193
|
-
return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g, '<span class="chip">$1</span>');
|
|
1194
|
-
}
|
|
1195
1350
|
async function previewArtifact(turnId, path) {
|
|
1196
1351
|
const target = document.getElementById("artifactPreview");
|
|
1197
1352
|
target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
|
|
@@ -1199,7 +1354,8 @@
|
|
|
1199
1354
|
try {
|
|
1200
1355
|
const data = await api("/api/artifacts/preview", { query: { turnId, path } });
|
|
1201
1356
|
if (data.kind === "image") {
|
|
1202
|
-
|
|
1357
|
+
const src = isRemotePeerTarget() ? await artifactDataUrl(turnId, path) : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
|
|
1358
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="' + src + '"></div>';
|
|
1203
1359
|
return;
|
|
1204
1360
|
}
|
|
1205
1361
|
if (data.kind === "text") {
|
|
@@ -1296,6 +1452,29 @@
|
|
|
1296
1452
|
["Aborted", jobs.aborted]
|
|
1297
1453
|
];
|
|
1298
1454
|
}
|
|
1455
|
+
function metricProcessRows(d) {
|
|
1456
|
+
const p = d.process || {};
|
|
1457
|
+
const memory = p.memory || {};
|
|
1458
|
+
const cpu = p.cpu || {};
|
|
1459
|
+
const loop = p.eventLoop || {};
|
|
1460
|
+
return [
|
|
1461
|
+
["PID", p.pid],
|
|
1462
|
+
["Node", p.nodeVersion],
|
|
1463
|
+
["Platform", [p.platform, p.arch].filter(Boolean).join(" ")],
|
|
1464
|
+
["Uptime", fmtDuration(p.uptimeMs)],
|
|
1465
|
+
["Started", fmtDate(p.startedAt)],
|
|
1466
|
+
["RSS", fmtBytes(memory.rssBytes || 0)],
|
|
1467
|
+
["Heap used", fmtBytes(memory.heapUsedBytes || 0)],
|
|
1468
|
+
["Heap total", fmtBytes(memory.heapTotalBytes || 0)],
|
|
1469
|
+
["CPU total", fmtDuration(cpu.totalMs)],
|
|
1470
|
+
["CPU avg", cpu.percentSinceStart === null || cpu.percentSinceStart === void 0 ? "-" : cpu.percentSinceStart + "%"],
|
|
1471
|
+
["Event loop p95", formatMs(loop.delayP95Ms)],
|
|
1472
|
+
["Event loop max", formatMs(loop.delayMaxMs)]
|
|
1473
|
+
];
|
|
1474
|
+
}
|
|
1475
|
+
function formatMs(value) {
|
|
1476
|
+
return value === null || value === void 0 ? "-" : value + "ms";
|
|
1477
|
+
}
|
|
1299
1478
|
function rateRows(name, rate) {
|
|
1300
1479
|
return [
|
|
1301
1480
|
["Queued", rate?.queued ?? 0],
|
|
@@ -1311,7 +1490,8 @@
|
|
|
1311
1490
|
}
|
|
1312
1491
|
function renderMetrics(d) {
|
|
1313
1492
|
const adapters = d.adapters || {};
|
|
1314
|
-
|
|
1493
|
+
const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
|
|
1494
|
+
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
|
|
1315
1495
|
}
|
|
1316
1496
|
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1317
1497
|
function activityQuery() {
|
|
@@ -1329,7 +1509,7 @@
|
|
|
1329
1509
|
}
|
|
1330
1510
|
function activityActorText(e) {
|
|
1331
1511
|
const a = e.actor || {};
|
|
1332
|
-
return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", cli: "CLI", system: "System" }[a.channel] || "System");
|
|
1512
|
+
return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", slack: "Slack user", cli: "CLI", system: "System" }[a.channel] || "System");
|
|
1333
1513
|
}
|
|
1334
1514
|
function activityMetaHtml(e) {
|
|
1335
1515
|
const workspace = activityWorkspace(e);
|
|
@@ -1361,12 +1541,14 @@
|
|
|
1361
1541
|
URL.revokeObjectURL(a.href);
|
|
1362
1542
|
};
|
|
1363
1543
|
async function loadSettings() {
|
|
1544
|
+
state.settingsWizard = null;
|
|
1545
|
+
document.getElementById("settingsTabs").style.display = "";
|
|
1364
1546
|
setLoading("settingsForm", "Loading settings...");
|
|
1365
1547
|
const data = await api("/api/settings");
|
|
1366
1548
|
state.settings = data.settings;
|
|
1367
1549
|
renderSettings();
|
|
1368
1550
|
}
|
|
1369
|
-
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
|
|
1551
|
+
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Slack", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
|
|
1370
1552
|
const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
|
|
1371
1553
|
function orderedSettingsGroups(groups) {
|
|
1372
1554
|
const known = settingsGroupOrder.filter((name) => groups[name]);
|
|
@@ -1505,7 +1687,7 @@
|
|
|
1505
1687
|
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>";
|
|
1506
1688
|
}
|
|
1507
1689
|
function selectedValues(selector) {
|
|
1508
|
-
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.value).filter(Boolean);
|
|
1690
|
+
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.dataset.slackChannelChoice || x.value).filter(Boolean);
|
|
1509
1691
|
}
|
|
1510
1692
|
function csv(values = []) {
|
|
1511
1693
|
return (values || []).join(", ");
|
|
@@ -1514,6 +1696,7 @@
|
|
|
1514
1696
|
const dialog = document.getElementById("adminDialog");
|
|
1515
1697
|
document.getElementById("adminDialogTitle").textContent = title;
|
|
1516
1698
|
document.getElementById("adminDialogBody").innerHTML = body;
|
|
1699
|
+
document.getElementById("adminDialogSubmit").textContent = "Save";
|
|
1517
1700
|
document.getElementById("adminDialogCancel").onclick = () => dialog.close();
|
|
1518
1701
|
document.getElementById("adminDialogForm").onsubmit = (e) => safe(async () => {
|
|
1519
1702
|
e.preventDefault();
|
|
@@ -1534,10 +1717,15 @@
|
|
|
1534
1717
|
}
|
|
1535
1718
|
function bindAccessTabs() {
|
|
1536
1719
|
document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
|
|
1537
|
-
const
|
|
1538
|
-
if (
|
|
1539
|
-
|
|
1540
|
-
|
|
1720
|
+
const discordSearch = document.getElementById("discordChannelSearch");
|
|
1721
|
+
if (discordSearch && !discordSearch.dataset.bound) {
|
|
1722
|
+
discordSearch.dataset.bound = "true";
|
|
1723
|
+
discordSearch.oninput = () => renderDiscordChannels();
|
|
1724
|
+
}
|
|
1725
|
+
const slackSearch = document.getElementById("slackChannelSearch");
|
|
1726
|
+
if (slackSearch && !slackSearch.dataset.bound) {
|
|
1727
|
+
slackSearch.dataset.bound = "true";
|
|
1728
|
+
slackSearch.oninput = () => renderSlackChannels();
|
|
1541
1729
|
}
|
|
1542
1730
|
}
|
|
1543
1731
|
function switchAccessTab(tab) {
|
|
@@ -1575,16 +1763,46 @@
|
|
|
1575
1763
|
bindAccessCopyButtons();
|
|
1576
1764
|
applyPermissions();
|
|
1577
1765
|
}
|
|
1766
|
+
function slackChannelLabel(channel) {
|
|
1767
|
+
return channel.title ? channel.title + " (" + channel.channelId + ")" : channel.channelId;
|
|
1768
|
+
}
|
|
1769
|
+
function slackScopeLabel(ids = []) {
|
|
1770
|
+
const channels = state.userManagement?.slackChannels || [];
|
|
1771
|
+
return (ids || []).map((id) => slackChannelLabel(channels.find((c) => c.channelId === id) || { channelId: id })).join(", ");
|
|
1772
|
+
}
|
|
1773
|
+
function slackChannelOptions(selected = []) {
|
|
1774
|
+
const selectedSet = new Set(selected || []);
|
|
1775
|
+
const channels = state.userManagement?.slackChannels || [];
|
|
1776
|
+
return '<div class="permission-grid full-span">' + (channels.map((c) => '<label class="checkbox"><input type="checkbox" data-slack-channel-choice="' + attr(c.channelId) + '" ' + (selectedSet.has(c.channelId) ? "checked" : "") + "> " + esc(slackChannelLabel(c) + " / " + (c.teamId || "team default")) + "</label>").join("") || "<small>No Slack channels registered.</small>") + "</div>";
|
|
1777
|
+
}
|
|
1778
|
+
function slackIdentityHtml(user, identity) {
|
|
1779
|
+
const id = String(identity.slackUserId || "");
|
|
1780
|
+
const team = identity.teamId ? " <span>" + esc(identity.teamId) + "</span>" : "";
|
|
1781
|
+
return '<span class="access-id-row">' + accessCopyButton(id, "Slack user ID copied") + team + (identity.username ? " <span>@" + esc(identity.username) + "</span>" : "") + ' <button class="secondary mini-button" data-slack-user="' + attr(user.id) + '" data-slack-unlink="' + attr(identity.id) + '"' + disabledAttr("users.write") + ">Unlink</button></span>";
|
|
1782
|
+
}
|
|
1783
|
+
function renderSlackChannels(channels = state.userManagement?.slackChannels || []) {
|
|
1784
|
+
const target = document.getElementById("slackChannelsList");
|
|
1785
|
+
if (!target) return;
|
|
1786
|
+
const query = (document.getElementById("slackChannelSearch")?.value || "").toLowerCase();
|
|
1787
|
+
const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.teamId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
|
1788
|
+
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, "Slack channel ID copied") + '</small><small class="access-id-row">Team ID: ' + accessCopyButton(c.teamId || "", "Slack team ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-slack-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-slack-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Slack channels registered.</div>';
|
|
1789
|
+
bindSlackChannelButtons();
|
|
1790
|
+
bindAccessCopyButtons();
|
|
1791
|
+
applyPermissions();
|
|
1792
|
+
}
|
|
1578
1793
|
function renderUserManagement(d) {
|
|
1579
1794
|
document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
|
|
1580
1795
|
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(" ");
|
|
1581
1796
|
const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
|
|
1582
|
-
|
|
1797
|
+
const slack = (u.slackIdentities || []).map((identity) => slackIdentityHtml(u, identity)).join(" ");
|
|
1798
|
+
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>Slack: " + (slack || "-") + "</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-slack-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Slack code</button><button class="secondary" data-user-slack-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Slack 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>";
|
|
1583
1799
|
}).join("") || '<div class="item">No users.</div>';
|
|
1584
|
-
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>';
|
|
1800
|
+
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><small>Slack channel scope: " + esc(slackScopeLabel(g.slackChannelIds) || "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>';
|
|
1585
1801
|
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>';
|
|
1586
1802
|
renderDiscordChannels(d.discordChannels || []);
|
|
1803
|
+
renderSlackChannels(d.slackChannels || []);
|
|
1587
1804
|
bindUserButtons();
|
|
1805
|
+
bindSlackUserButtons();
|
|
1588
1806
|
bindAccessCopyButtons();
|
|
1589
1807
|
applyPermissions();
|
|
1590
1808
|
}
|
|
@@ -1672,6 +1890,33 @@
|
|
|
1672
1890
|
loadAccess();
|
|
1673
1891
|
}));
|
|
1674
1892
|
}
|
|
1893
|
+
function bindSlackUserButtons() {
|
|
1894
|
+
document.querySelectorAll("[data-user-slack-code]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1895
|
+
const r = await api("/api/users/" + encodeURIComponent(b.dataset.userSlackCode) + "/slack", { method: "POST", body: JSON.stringify({ createCode: true }) });
|
|
1896
|
+
toast("Slack link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
|
|
1897
|
+
}));
|
|
1898
|
+
document.querySelectorAll("[data-user-slack-link]").forEach((b) => b.onclick = () => openSlackLinkDialog(b.dataset.userSlackLink));
|
|
1899
|
+
document.querySelectorAll("[data-slack-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1900
|
+
if (confirm("Unlink this Slack identity?")) {
|
|
1901
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.slackUser) + "/slack/" + encodeURIComponent(b.dataset.slackUnlink), { method: "DELETE" });
|
|
1902
|
+
toast("Slack unlinked");
|
|
1903
|
+
loadAccess();
|
|
1904
|
+
}
|
|
1905
|
+
}));
|
|
1906
|
+
}
|
|
1907
|
+
function bindSlackChannelButtons() {
|
|
1908
|
+
document.querySelectorAll("[data-slack-channel-edit]").forEach((b) => b.onclick = () => {
|
|
1909
|
+
const c = (state.userManagement?.slackChannels || []).find((x) => x.id === b.dataset.slackChannelEdit);
|
|
1910
|
+
if (c) openSlackChannelDialog(c);
|
|
1911
|
+
});
|
|
1912
|
+
document.querySelectorAll("[data-slack-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1913
|
+
const c = (state.userManagement?.slackChannels || []).find((x) => x.id === b.dataset.slackChannelToggle);
|
|
1914
|
+
if (!c) return;
|
|
1915
|
+
await api("/api/slack-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
1916
|
+
toast("Slack channel updated");
|
|
1917
|
+
loadAccess();
|
|
1918
|
+
}));
|
|
1919
|
+
}
|
|
1675
1920
|
function openUserDialog(u) {
|
|
1676
1921
|
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 () => {
|
|
1677
1922
|
const payload = { email: val("dlgEmail"), displayName: val("dlgName"), active: document.getElementById("dlgActive").checked, groupIds: selectedValues("[data-group-choice]") };
|
|
@@ -1699,6 +1944,19 @@
|
|
|
1699
1944
|
toast("Discord linked");
|
|
1700
1945
|
});
|
|
1701
1946
|
}
|
|
1947
|
+
function assertSlackId(value, label, required = true) {
|
|
1948
|
+
const text = String(value || "").trim();
|
|
1949
|
+
if (!text && !required) return "";
|
|
1950
|
+
if (!text) throw new Error(label + " is required");
|
|
1951
|
+
if (!/^[A-Z0-9]{2,64}$/.test(text)) throw new Error(label + " must look like a Slack ID");
|
|
1952
|
+
return text;
|
|
1953
|
+
}
|
|
1954
|
+
function openSlackLinkDialog(id) {
|
|
1955
|
+
adminDialog("Link Slack ID", '<label>Slack user ID<input id="dlgSlackId" placeholder="U..."></label><label>Team ID<input id="dlgSlackTeamId" placeholder="T..."></label><label>Username<input id="dlgSlackUsername"></label>', async () => {
|
|
1956
|
+
await api("/api/users/" + encodeURIComponent(id) + "/slack", { method: "POST", body: JSON.stringify({ slackUserId: assertSlackId(val("dlgSlackId"), "Slack user ID"), teamId: assertSlackId(val("dlgSlackTeamId"), "Slack team ID", false) || void 0, username: val("dlgSlackUsername") || void 0 }) });
|
|
1957
|
+
toast("Slack linked");
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1702
1960
|
function openTelegramLinkDialog(id) {
|
|
1703
1961
|
adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
|
|
1704
1962
|
await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
|
|
@@ -1706,8 +1964,8 @@
|
|
|
1706
1964
|
});
|
|
1707
1965
|
}
|
|
1708
1966
|
function openGroupDialog(g) {
|
|
1709
|
-
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 () => {
|
|
1710
|
-
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]") };
|
|
1967
|
+
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><div class="full-span"><strong>Slack channel scope</strong>' + slackChannelOptions(g?.slackChannelIds || []) + '<small>Leave empty to allow every registered Slack channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
|
|
1968
|
+
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]"), slackChannelIds: selectedValues("[data-slack-channel-choice]") };
|
|
1711
1969
|
await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1712
1970
|
toast(g ? "Group updated" : "Group created");
|
|
1713
1971
|
});
|
|
@@ -1720,6 +1978,13 @@
|
|
|
1720
1978
|
toast(c ? "Discord channel updated" : "Discord channel added");
|
|
1721
1979
|
});
|
|
1722
1980
|
}
|
|
1981
|
+
function openSlackChannelDialog(c) {
|
|
1982
|
+
adminDialog(c ? "Edit Slack channel" : "Add Slack channel", '<label>Team ID<input id="dlgSlackTeamId" value="' + attr(c?.teamId || "") + '" ' + (c ? "disabled" : "") + ' placeholder="T..."></label><label>Channel ID<input id="dlgSlackChannelId" value="' + attr(c?.channelId || "") + '" ' + (c ? "disabled" : "") + ' placeholder="C..."></label><label>Title<input id="dlgSlackChannelTitle" value="' + attr(c?.title || "") + '"></label><label>Type<select id="dlgSlackChannelType"><option value="channel" ' + ((c?.type || "channel") === "channel" ? "selected" : "") + '>channel</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="dlgSlackChannelEnabled" 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 () => {
|
|
1983
|
+
const payload = { teamId: assertSlackId(val("dlgSlackTeamId"), "Team ID", false) || void 0, channelId: assertSlackId(val("dlgSlackChannelId"), "Channel ID"), title: val("dlgSlackChannelTitle") || void 0, type: val("dlgSlackChannelType") || "channel", enabled: document.getElementById("dlgSlackChannelEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
|
|
1984
|
+
await api(c ? "/api/slack-channels/" + encodeURIComponent(c.id) : "/api/slack-channels", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1985
|
+
toast(c ? "Slack channel updated" : "Slack channel added");
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1723
1988
|
function openChatDialog(c) {
|
|
1724
1989
|
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 () => {
|
|
1725
1990
|
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]") };
|
|
@@ -1758,6 +2023,13 @@
|
|
|
1758
2023
|
}
|
|
1759
2024
|
openDiscordChannelDialog(null);
|
|
1760
2025
|
};
|
|
2026
|
+
document.getElementById("createSlackChannelBtn").onclick = () => {
|
|
2027
|
+
if (!can("users.write")) {
|
|
2028
|
+
toast("Permission required: users.write");
|
|
2029
|
+
return;
|
|
2030
|
+
}
|
|
2031
|
+
openSlackChannelDialog(null);
|
|
2032
|
+
};
|
|
1761
2033
|
async function loadLocks() {
|
|
1762
2034
|
if (!can("sessions.read")) {
|
|
1763
2035
|
document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
@@ -1873,10 +2145,55 @@
|
|
|
1873
2145
|
toast("Cleared " + target + " log");
|
|
1874
2146
|
}
|
|
1875
2147
|
});
|
|
2148
|
+
async function loadPeers() {
|
|
2149
|
+
if (!can("peers.read")) {
|
|
2150
|
+
document.getElementById("peersList").innerHTML = '<div class="item">Permission required: peers.read</div>';
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
setLoading("peersList", "Loading peers...");
|
|
2154
|
+
const d = await api("/api/peers", { local: true });
|
|
2155
|
+
state.peers = d;
|
|
2156
|
+
const inviteIds = new Set((d.invitations || []).map((i) => i.id));
|
|
2157
|
+
Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
|
|
2158
|
+
if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
|
|
2159
|
+
});
|
|
2160
|
+
document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
|
|
2161
|
+
document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
|
|
2162
|
+
document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
|
|
2163
|
+
ensureGlobalPeerSessionsPanel();
|
|
2164
|
+
bindPeerButtons();
|
|
2165
|
+
await loadPeerSelector();
|
|
2166
|
+
applyPermissions();
|
|
2167
|
+
}
|
|
2168
|
+
function openPeerAddDialog() {
|
|
2169
|
+
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 () => {
|
|
2170
|
+
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 });
|
|
2171
|
+
toast("Added peer " + (r.peer?.name || ""));
|
|
2172
|
+
await loadPeers();
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
document.getElementById("loadPeersBtn").onclick = () => loadPeers();
|
|
2176
|
+
document.getElementById("createPeerInviteBtn").onclick = () => {
|
|
2177
|
+
if (!can("peers.write")) {
|
|
2178
|
+
toast("Permission required: peers.write");
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
openPeerInviteDialog();
|
|
2182
|
+
};
|
|
2183
|
+
document.getElementById("addPeerBtn").onclick = () => {
|
|
2184
|
+
if (!can("peers.write")) {
|
|
2185
|
+
toast("Permission required: peers.write");
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
openPeerAddDialog();
|
|
2189
|
+
};
|
|
1876
2190
|
async function loadAdapterHealth() {
|
|
1877
2191
|
setLoading("adapterHealth", "Loading adapters...");
|
|
1878
|
-
|
|
2192
|
+
setLoading("adapterConformance", "Loading conformance...");
|
|
2193
|
+
const [d, conformance] = await Promise.all([api("/api/adapters/health"), api("/api/adapters/conformance")]);
|
|
2194
|
+
state.adapterConformance = conformance;
|
|
1879
2195
|
document.getElementById("adapterHealth").innerHTML = (d.adapters || []).map((a) => '<div class="item"><strong>' + esc(a.label) + ' <span class="adapter-status ' + esc(a.status) + '">' + esc(a.status) + "</span></strong><small>" + esc("CLI: " + (a.cli.label || "-") + " / path " + (a.cli.path || "-") + " / version " + (a.cli.version || "-")) + "</small><small>" + esc("Auth: " + (a.auth.supported ? a.auth.authenticated ? "authenticated" : "not authenticated" : "not managed") + " " + (a.auth.detail || "")) + "</small><small>" + esc("Version: " + a.version.installed + " / latest " + (a.version.latest || "-") + " / " + a.version.status) + "</small>" + featureMatrix(a.capabilities) + '<div class="row"><button data-auth-status="' + attr(a.id) + '">Auth status</button><button data-auth-login="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.login ? "disabled" : "") + disabledAttr("auth.manage") + '>Login</button><button data-auth-logout="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.logout ? "disabled" : "") + disabledAttr("auth.manage") + ">Logout</button></div></div>").join("") || '<div class="item">No adapters.</div>';
|
|
2196
|
+
renderAdapterConformance(conformance);
|
|
1880
2197
|
document.querySelectorAll("[data-auth-status]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1881
2198
|
const r = await api("/api/auth/status", { query: { agent: b.dataset.authStatus } });
|
|
1882
2199
|
toast(r.agentLabel + ": " + r.detail, { duration: 6e3 });
|
|
@@ -1901,6 +2218,22 @@
|
|
|
1901
2218
|
}));
|
|
1902
2219
|
applyPermissions();
|
|
1903
2220
|
}
|
|
2221
|
+
function renderAdapterConformance(matrix) {
|
|
2222
|
+
const target = document.getElementById("adapterConformance");
|
|
2223
|
+
if (!target) return;
|
|
2224
|
+
const agents = (matrix?.agents || []).map((a) => conformanceCard(a, "agent")).join("");
|
|
2225
|
+
const channels = (matrix?.channels || []).map((c) => conformanceCard(c, "channel")).join("");
|
|
2226
|
+
target.innerHTML = '<div class="conformance-grid"><div><h3>Agent capability contract</h3>' + (agents || '<div class="item">No agent conformance rows.</div>') + "</div><div><h3>Channel command contract</h3>" + (channels || '<div class="item">No channel conformance rows.</div>') + "</div></div>";
|
|
2227
|
+
}
|
|
2228
|
+
function conformanceCard(item, kind) {
|
|
2229
|
+
const missing = (item.unsupported || []).length;
|
|
2230
|
+
const commands = kind === "channel" && item.commands ? "<small>" + esc("Commands: " + item.commands.length + (item.commands.length ? " / " + short(item.commands.join(", "), 180) : "")) + "</small>" : "";
|
|
2231
|
+
const badge = missing === 0 ? "enabled" : item.status === "planned" ? "disabled" : "planned";
|
|
2232
|
+
return '<div class="item"><strong>' + esc(item.label) + ' <span class="adapter-status ' + badge + '">' + esc(missing === 0 ? "complete" : missing + " missing") + "</span></strong><small>" + esc("Status: " + item.status + (item.enabled === void 0 ? "" : " / " + (item.enabled ? "enabled" : "disabled"))) + "</small>" + commands + conformanceFeatureMatrix(item.features || []) + "</div>";
|
|
2233
|
+
}
|
|
2234
|
+
function conformanceFeatureMatrix(features) {
|
|
2235
|
+
return '<div class="feature-matrix">' + (features || []).map((f) => '<span class="feature-chip ' + (f.supported ? "supported" : "unsupported") + '" title="' + attr(f.description || f.key) + '"><span>' + esc(f.label || f.key) + "</span><b>" + (f.supported ? "\u2713" : "-") + "</b></span>").join("") + "</div>";
|
|
2236
|
+
}
|
|
1904
2237
|
document.getElementById("reloadAdaptersBtn").onclick = () => loadAdapterHealth();
|
|
1905
2238
|
const versionAgentIds = { codex: "codex", pi: "pi", hermes: "hermes", openclaw: "openclaw", claudeCode: "claude-code" };
|
|
1906
2239
|
async function loadVersion() {
|
|
@@ -2035,20 +2368,37 @@
|
|
|
2035
2368
|
document.getElementById("diagnostics").innerHTML = diagnosticsHtml(data);
|
|
2036
2369
|
}
|
|
2037
2370
|
const exportDiagnosticsBundleBtn = document.getElementById("exportDiagnosticsBundleBtn");
|
|
2038
|
-
if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => {
|
|
2371
|
+
if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => safe(async () => {
|
|
2039
2372
|
if (!can("diagnostics.read")) {
|
|
2040
2373
|
toast("Permission required: diagnostics.read");
|
|
2041
2374
|
return;
|
|
2042
2375
|
}
|
|
2043
|
-
|
|
2044
|
-
|
|
2376
|
+
if (!state.selectedPeer || state.selectedPeer === "local") {
|
|
2377
|
+
window.open("/api/diagnostics/bundle", "_blank");
|
|
2378
|
+
return;
|
|
2379
|
+
}
|
|
2380
|
+
const bundle = await api("/api/diagnostics/bundle");
|
|
2381
|
+
downloadBase64(bundle.name || "nordrelay-support-bundle.zip", bundle.dataBase64 || "", bundle.mimeType || "application/zip");
|
|
2382
|
+
toast("Remote diagnostics bundle downloaded");
|
|
2383
|
+
});
|
|
2384
|
+
function downloadBase64(name, dataBase64, mimeType) {
|
|
2385
|
+
const bytes = Uint8Array.from(atob(dataBase64), (c) => c.charCodeAt(0));
|
|
2386
|
+
const blob = new Blob([bytes], { type: mimeType });
|
|
2387
|
+
const a = document.createElement("a");
|
|
2388
|
+
a.href = URL.createObjectURL(blob);
|
|
2389
|
+
a.download = name;
|
|
2390
|
+
a.click();
|
|
2391
|
+
URL.revokeObjectURL(a.href);
|
|
2392
|
+
}
|
|
2045
2393
|
function diagnosticsHtml(d) {
|
|
2046
2394
|
const h = d.health || {};
|
|
2047
2395
|
const s = d.snapshot?.session || {};
|
|
2048
2396
|
const vc = d.versionChecks || {};
|
|
2049
2397
|
const caps = s.capabilities || {};
|
|
2050
2398
|
const agentDiag = d.runtime?.agentDiagnostics;
|
|
2051
|
-
|
|
2399
|
+
const slack = d.runtime?.slackDiagnostics;
|
|
2400
|
+
const slackRows = slack ? [["Enabled", slack.enabled ? "yes" : "no"], ["Mode", slack.mode], ["Registered channels", slack.registeredChannels], ...(slack.checks || []).map((x) => [x.status.toUpperCase() + " " + x.label, x.detail]), ...(slack.channelChecks || []).map((x) => [x.status.toUpperCase() + " channel " + x.channelId, x.detail])] : [["Status", "not collected"]];
|
|
2401
|
+
return '<div class="list">' + card("Runtime", [["Status", h.state?.status], ["PID", h.state?.pid], ["App PID", h.state?.appPid], ["State", h.stateFile], ["Log", h.logFile], ["State backend", d.runtime?.stateBackend], ["Uptime", h.uptimeSeconds + "s"]]) + card("Agent", [["Agent", s.agentLabel], ["Thread", s.threadId], ["Workspace", s.workspace], ["Model", s.model], ["Reasoning", s.reasoningEffort], ["Fast", caps.fastMode ? s.fastMode ? "on" : "off" : "n/a"]]) + card("Agent State", (agentDiag?.lines || []).map((x) => [x.label, x.value])) + card("CLI Versions", Object.values(vc).map((v) => [v.label, (v.status === "current" ? "OK " : "WARN ") + (v.installedLabel || "-") + " latest " + (v.latestVersion || "-")])) + card("External Mirror", d.runtime?.externalMirror ? Object.entries(d.runtime.externalMirror) : [["Status", "idle"]]) + card("Slack Readiness", slackRows) + "</div>";
|
|
2052
2402
|
}
|
|
2053
2403
|
function card(title, rows) {
|
|
2054
2404
|
return '<div class="item"><strong>' + esc(title) + "</strong>" + rows.map((r) => "<small>" + esc(r[0]) + ": " + esc(r[1] ?? "-") + "</small>").join("") + "</div>";
|
|
@@ -2058,4 +2408,467 @@
|
|
|
2058
2408
|
Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
|
|
2059
2409
|
}
|
|
2060
2410
|
loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
|
|
2411
|
+
function ensureGlobalPeerSessionsPanel() {
|
|
2412
|
+
if (document.getElementById("globalPeerSessionsPanel")) return;
|
|
2413
|
+
const anchor = document.getElementById("peersList");
|
|
2414
|
+
if (!anchor) return;
|
|
2415
|
+
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>');
|
|
2416
|
+
document.getElementById("loadGlobalPeerSessionsBtn").onclick = () => safe(loadGlobalPeerSessions);
|
|
2417
|
+
}
|
|
2418
|
+
async function loadGlobalPeerSessions() {
|
|
2419
|
+
const target = document.getElementById("globalPeerSessionsList");
|
|
2420
|
+
target.innerHTML = loadingHtml("Loading global sessions...");
|
|
2421
|
+
const q = document.getElementById("globalPeerSessionSearch").value || "";
|
|
2422
|
+
const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
|
|
2423
|
+
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>';
|
|
2424
|
+
}
|
|
2425
|
+
function peerStatusHtml(d) {
|
|
2426
|
+
const r = d.readiness || {};
|
|
2427
|
+
const rows = [["Peer server", d.enabled ? "enabled" : "disabled"], ["Listen URL", d.listenUrl], ["Bind", [r.bindHost || "-", r.port || ""].filter(Boolean).join(":")], ["Local port", r.localListening ? "listening" : "not listening"], ["TLS", r.tlsEnabled ? "enabled" : "disabled"], ["Require TLS", d.requireTls ? "yes" : "no"], ["Node ID", d.identity?.nodeId], ["Fingerprint", d.identity?.fingerprint]];
|
|
2428
|
+
const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
|
|
2429
|
+
return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
|
|
2430
|
+
}
|
|
2431
|
+
function peerWarningsHtml(warnings, title) {
|
|
2432
|
+
return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
|
|
2433
|
+
}
|
|
2434
|
+
function peerProbeResultHtml(result) {
|
|
2435
|
+
if (!result) return "";
|
|
2436
|
+
const probe = result.probe || {};
|
|
2437
|
+
const label = result.type === "remote" ? "Remote probe from " + (result.peerName || result.peerId || "peer") : "Local endpoint check";
|
|
2438
|
+
return '<div class="peer-probe-result ' + (probe.ok ? "ok" : "warn") + '"><strong>' + esc(label) + ' <span class="adapter-status ' + (probe.ok ? "enabled" : "planned") + '">' + esc(probe.status || "unknown") + "</span></strong><small>" + esc("URL: " + (probe.url || result.readiness?.listenUrl || "-")) + "</small>" + (probe.latencyMs !== void 0 ? "<small>" + esc("Latency: " + probe.latencyMs + "ms") + "</small>" : "") + (probe.statusCode !== void 0 ? "<small>" + esc("HTTP: " + probe.statusCode) + "</small>" : "") + (probe.tlsFingerprint ? "<small>" + esc("TLS fingerprint: " + probe.tlsFingerprint) + "</small>" : "") + "<small>" + esc(probe.detail || "") + "</small></div>";
|
|
2439
|
+
}
|
|
2440
|
+
function peerInviteDetails(i) {
|
|
2441
|
+
const details = state.peerInviteSecrets?.[i.id];
|
|
2442
|
+
if (!details) return "";
|
|
2443
|
+
return '<div class="peer-invite-details"><small>Pairing code</small><button type="button" class="copy-id peer-invite-copy" data-peer-invite-copy="' + attr(details.code || "") + '" data-peer-invite-copy-label="Pairing code copied">' + esc(details.code || "") + '</button><small>Peer add command</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(details.command || "") + '" data-peer-invite-copy-label="Peer add command copied">' + esc(details.command || "") + "</button></div>";
|
|
2444
|
+
}
|
|
2445
|
+
function peerInviteCard(i) {
|
|
2446
|
+
const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
|
|
2447
|
+
const readiness = state.peers?.readiness;
|
|
2448
|
+
return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "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>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
|
|
2449
|
+
}
|
|
2450
|
+
function peerCard(p) {
|
|
2451
|
+
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
2452
|
+
const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
|
|
2453
|
+
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2454
|
+
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-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</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>";
|
|
2455
|
+
}
|
|
2456
|
+
function openPeerDialog(p) {
|
|
2457
|
+
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2458
|
+
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 () => {
|
|
2459
|
+
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 });
|
|
2460
|
+
toast("Peer updated");
|
|
2461
|
+
await loadPeers();
|
|
2462
|
+
});
|
|
2463
|
+
}
|
|
2464
|
+
function openPeerInviteDialog() {
|
|
2465
|
+
const warnings = state.peers?.readiness?.warnings || [];
|
|
2466
|
+
const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
|
|
2467
|
+
adminDialog("Create peer invite", warningHtml + '<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 () => {
|
|
2468
|
+
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 });
|
|
2469
|
+
if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
|
|
2470
|
+
toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
|
|
2471
|
+
await loadPeers();
|
|
2472
|
+
});
|
|
2473
|
+
document.getElementById("adminDialogSubmit").textContent = warnings.length ? "Create invite anyway" : "Create invite";
|
|
2474
|
+
}
|
|
2475
|
+
function aliasMap(text) {
|
|
2476
|
+
return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
|
|
2477
|
+
}
|
|
2478
|
+
function bindPeerButtons() {
|
|
2479
|
+
document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2480
|
+
state.selectedPeer = b.dataset.peerSelect || "local";
|
|
2481
|
+
localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
|
|
2482
|
+
connectEvents();
|
|
2483
|
+
await loadBootstrap();
|
|
2484
|
+
toast("Peer target selected");
|
|
2485
|
+
}));
|
|
2486
|
+
document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2487
|
+
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/health", { local: true });
|
|
2488
|
+
toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
|
|
2489
|
+
loadPeers();
|
|
2490
|
+
}));
|
|
2491
|
+
const localProbe = document.getElementById("checkPeerReachabilityBtn");
|
|
2492
|
+
if (localProbe) localProbe.onclick = () => safe(async () => {
|
|
2493
|
+
if (!can("peers.connect")) {
|
|
2494
|
+
toast("Permission required: peers.connect");
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
state.peerProbeResult = await api("/api/peers/probe", { method: "POST", body: JSON.stringify({}), local: true });
|
|
2498
|
+
document.getElementById("peerProbeResult").innerHTML = peerProbeResultHtml(state.peerProbeResult);
|
|
2499
|
+
toast(state.peerProbeResult.probe?.ok ? "Peer endpoint reachable" : "Peer endpoint not reachable", { duration: 7e3 });
|
|
2500
|
+
});
|
|
2501
|
+
document.querySelectorAll("[data-peer-probe]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2502
|
+
if (!can("peers.connect")) {
|
|
2503
|
+
toast("Permission required: peers.connect");
|
|
2504
|
+
return;
|
|
2505
|
+
}
|
|
2506
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerProbe);
|
|
2507
|
+
state.peerProbeResult = await api("/api/peers/probe", { method: "POST", body: JSON.stringify({ peerId: b.dataset.peerProbe }), local: true });
|
|
2508
|
+
state.peerProbeResult.peerName = p?.name;
|
|
2509
|
+
document.getElementById("peerProbeResult").innerHTML = peerProbeResultHtml(state.peerProbeResult);
|
|
2510
|
+
toast(state.peerProbeResult.probe?.ok ? "Remote peer can reach this node" : "Remote peer cannot reach this node", { duration: 8e3 });
|
|
2511
|
+
}));
|
|
2512
|
+
document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
|
|
2513
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
|
|
2514
|
+
if (p) openPeerDialog(p);
|
|
2515
|
+
});
|
|
2516
|
+
document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2517
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
|
|
2518
|
+
if (!p) return;
|
|
2519
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
|
|
2520
|
+
toast("Peer updated");
|
|
2521
|
+
loadPeers();
|
|
2522
|
+
}));
|
|
2523
|
+
document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2524
|
+
if (confirm("Revoke this peer?")) {
|
|
2525
|
+
await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
|
|
2526
|
+
if (state.selectedPeer === b.dataset.peerRevoke) {
|
|
2527
|
+
state.selectedPeer = "local";
|
|
2528
|
+
localStorage.setItem("nordrelayPeerTarget", "local");
|
|
2529
|
+
}
|
|
2530
|
+
toast("Peer revoked");
|
|
2531
|
+
loadPeers();
|
|
2532
|
+
}
|
|
2533
|
+
}));
|
|
2534
|
+
document.querySelectorAll("[data-peer-invite-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.peerInviteCopy || "", b.dataset.peerInviteCopyLabel || "Copied"));
|
|
2535
|
+
document.querySelectorAll("[data-peer-invite-delete]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2536
|
+
if (confirm("Delete this peer invitation?")) {
|
|
2537
|
+
await api("/api/peers/invitations/" + encodeURIComponent(b.dataset.peerInviteDelete), { method: "DELETE", local: true });
|
|
2538
|
+
delete state.peerInviteSecrets[b.dataset.peerInviteDelete];
|
|
2539
|
+
toast("Peer invitation deleted");
|
|
2540
|
+
loadPeers();
|
|
2541
|
+
}
|
|
2542
|
+
}));
|
|
2543
|
+
}
|
|
2544
|
+
const SETUP_WIZARDS = {
|
|
2545
|
+
telegram: {
|
|
2546
|
+
id: "telegram",
|
|
2547
|
+
label: "Telegram",
|
|
2548
|
+
description: "Create a Telegram bot, choose polling or webhook delivery, and enable the Telegram adapter.",
|
|
2549
|
+
docs: [
|
|
2550
|
+
["BotFather", "https://t.me/BotFather"],
|
|
2551
|
+
["Telegram Bot API", "https://core.telegram.org/bots/api"],
|
|
2552
|
+
["Webhook setup", "https://core.telegram.org/bots/api#setwebhook"]
|
|
2553
|
+
],
|
|
2554
|
+
defaults: { TELEGRAM_ENABLED: "true", TELEGRAM_TRANSPORT: "polling", TELEGRAM_WEBHOOK_HOST: "127.0.0.1", TELEGRAM_WEBHOOK_PORT: "8080", TELEGRAM_WEBHOOK_PATH: "/telegram/webhook" },
|
|
2555
|
+
required: ["TELEGRAM_BOT_TOKEN"],
|
|
2556
|
+
steps: [
|
|
2557
|
+
{ title: "Create the bot", body: "Open BotFather, create a bot with /newbot, then paste only the token value into NordRelay. Do not paste the full BotFather message.", settings: ["TELEGRAM_BOT_TOKEN"], links: [["Open BotFather", "https://t.me/BotFather"]] },
|
|
2558
|
+
{ title: "Choose delivery mode", body: "Polling is the simplest setup and works without a public URL. Use webhook only when this NordRelay instance is reachable through HTTPS from Telegram.", settings: ["TELEGRAM_TRANSPORT", "TELEGRAM_WEBHOOK_URL", "TELEGRAM_WEBHOOK_HOST", "TELEGRAM_WEBHOOK_PORT", "TELEGRAM_WEBHOOK_PATH", "TELEGRAM_WEBHOOK_SECRET"], links: [["Webhook documentation", "https://core.telegram.org/bots/api#setwebhook"]] },
|
|
2559
|
+
{ title: "Enable and secure access", body: "Enable Telegram only after the token is configured. Access is still controlled by NordRelay users, groups, and linked Telegram identities or registered chats.", settings: ["TELEGRAM_ENABLED"], links: [["Bot API overview", "https://core.telegram.org/bots/api"]] }
|
|
2560
|
+
]
|
|
2561
|
+
},
|
|
2562
|
+
discord: {
|
|
2563
|
+
id: "discord",
|
|
2564
|
+
label: "Discord",
|
|
2565
|
+
description: "Create a Discord application, configure slash commands or message commands, invite the bot, and enable Discord.",
|
|
2566
|
+
docs: [
|
|
2567
|
+
["Discord Developer Portal", "https://discord.com/developers/applications"],
|
|
2568
|
+
["Privileged intents", "https://discord.com/developers/docs/topics/gateway#privileged-intents"],
|
|
2569
|
+
["OAuth2 bot invite", "https://discord.com/developers/docs/topics/oauth2"]
|
|
2570
|
+
],
|
|
2571
|
+
defaults: { DISCORD_ENABLED: "true", DISCORD_MESSAGE_CONTENT_ENABLED: "true", DISCORD_COMMAND_MODE: "both", DISCORD_AUTO_REGISTER_COMMANDS: "true" },
|
|
2572
|
+
required: ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"],
|
|
2573
|
+
steps: [
|
|
2574
|
+
{ title: "Create application and bot", body: "Create an application in the Discord Developer Portal, add a bot, copy the bot token, and copy the Application ID from General Information.", settings: ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"], links: [["Developer Portal", "https://discord.com/developers/applications"]] },
|
|
2575
|
+
{ title: "Commands, intents, and invite", body: "For regular text commands, enable Message Content Intent under Bot > Privileged Gateway Intents. Invite the bot with bot and applications.commands scopes.", settings: ["DISCORD_MESSAGE_CONTENT_ENABLED", "DISCORD_COMMAND_MODE", "DISCORD_AUTO_REGISTER_COMMANDS"], links: [["Privileged intents", "https://discord.com/developers/docs/topics/gateway#privileged-intents"], ["OAuth2 scopes", "https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes"]] },
|
|
2576
|
+
{ title: "Limit server and channel scope", body: "Enable Discord Developer Mode, then right-click servers and channels to copy IDs. These allow-lists run before the NordRelay user/group access checks.", settings: ["DISCORD_GUILD_IDS", "DISCORD_ALLOWED_GUILD_IDS", "DISCORD_ALLOWED_CHANNEL_IDS"], links: [["Where to find IDs", "https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID"]] },
|
|
2577
|
+
{ title: "Enable Discord", body: "Enable the adapter after the bot token, client ID, and invite are ready. Users still need NordRelay permissions and linked Discord identities or registered channels.", settings: ["DISCORD_ENABLED"], links: [["Developer Portal", "https://discord.com/developers/applications"]] }
|
|
2578
|
+
]
|
|
2579
|
+
},
|
|
2580
|
+
slack: {
|
|
2581
|
+
id: "slack",
|
|
2582
|
+
label: "Slack",
|
|
2583
|
+
description: "Create a Slack app, configure Socket Mode or HTTP Events, add slash commands, and enable Slack.",
|
|
2584
|
+
docs: [
|
|
2585
|
+
["Slack API Apps", "https://api.slack.com/apps"],
|
|
2586
|
+
["Socket Mode", "https://api.slack.com/apis/connections/socket"],
|
|
2587
|
+
["Slash commands", "https://api.slack.com/interactivity/slash-commands"]
|
|
2588
|
+
],
|
|
2589
|
+
defaults: { SLACK_ENABLED: "true", SLACK_SOCKET_MODE: "true", SLACK_PORT: "3000", SLACK_COMMAND: "/nordrelay" },
|
|
2590
|
+
required: ["SLACK_BOT_TOKEN"],
|
|
2591
|
+
steps: [
|
|
2592
|
+
{ title: "Create and install the Slack app", body: "Create a Slack app, add bot scopes, install it into the workspace, and copy the OAuth bot token that starts with xoxb-.", settings: ["SLACK_BOT_TOKEN"], links: [["Slack API Apps", "https://api.slack.com/apps"], ["OAuth scopes", "https://api.slack.com/authentication/oauth-v2"]] },
|
|
2593
|
+
{ title: "Choose transport mode", body: "Socket Mode is recommended because it avoids exposing a public HTTP endpoint. For Socket Mode, create an app-level token with connections:write. For HTTP Events, use the Signing Secret and configure an externally reachable URL.", settings: ["SLACK_SOCKET_MODE", "SLACK_APP_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_PORT"], links: [["Socket Mode", "https://api.slack.com/apis/connections/socket"], ["Events API", "https://api.slack.com/apis/events-api"]] },
|
|
2594
|
+
{ title: "Commands and scope", body: "Configure the slash command in Slack and optionally restrict workspaces or channels before NordRelay user/group permissions are applied.", settings: ["SLACK_COMMAND", "SLACK_ALLOWED_TEAM_IDS", "SLACK_ALLOWED_CHANNEL_IDS", "SLACK_MESSAGE_CONTENT_ENABLED"], links: [["Slash commands", "https://api.slack.com/interactivity/slash-commands"], ["Finding Slack IDs", "https://slack.com/help/articles/221769328-Locate-your-Slack-URL-or-ID"]] },
|
|
2595
|
+
{ title: "Enable Slack", body: "Enable the adapter after tokens and command settings are ready. Users still need NordRelay permissions and linked Slack identities or registered channels.", settings: ["SLACK_ENABLED"], links: [["Slack API Apps", "https://api.slack.com/apps"]] }
|
|
2596
|
+
]
|
|
2597
|
+
}
|
|
2598
|
+
};
|
|
2599
|
+
function settingByKey(key) {
|
|
2600
|
+
return (state.settings || []).find((s) => s.key === key) || null;
|
|
2601
|
+
}
|
|
2602
|
+
function wizardCurrentValue(key) {
|
|
2603
|
+
const values = state.settingsWizard?.values || {};
|
|
2604
|
+
if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
|
|
2605
|
+
const s = settingByKey(key);
|
|
2606
|
+
return s ? s.configured ? s.value || "" : s.effectiveValue || "" : "";
|
|
2607
|
+
}
|
|
2608
|
+
function wizardOriginalValue(key) {
|
|
2609
|
+
const s = settingByKey(key);
|
|
2610
|
+
return s && s.configured ? s.value : "";
|
|
2611
|
+
}
|
|
2612
|
+
function isMaskedSettingValue(value) {
|
|
2613
|
+
return /^\*+$/.test(String(value || "")) || String(value || "").includes("...");
|
|
2614
|
+
}
|
|
2615
|
+
function wizardKeys(wizard) {
|
|
2616
|
+
return Array.from(new Set(wizard.steps.flatMap((step) => step.settings)));
|
|
2617
|
+
}
|
|
2618
|
+
function safeDocLink(label, url) {
|
|
2619
|
+
return '<a href="' + attr(url) + '" target="_blank" rel="noopener noreferrer">' + esc(label) + "</a>";
|
|
2620
|
+
}
|
|
2621
|
+
function wizardLinkList(links = []) {
|
|
2622
|
+
return links.length ? '<div class="wizard-links">' + links.map(([label, url]) => safeDocLink(label, url)).join("") + "</div>" : "";
|
|
2623
|
+
}
|
|
2624
|
+
function startSettingsWizard(channel) {
|
|
2625
|
+
const wizard = SETUP_WIZARDS[channel];
|
|
2626
|
+
if (!wizard) return;
|
|
2627
|
+
const values = {};
|
|
2628
|
+
for (const [key, value] of Object.entries(wizard.defaults || {})) {
|
|
2629
|
+
const setting = settingByKey(key);
|
|
2630
|
+
if (!setting?.configured || key.endsWith("_ENABLED")) values[key] = value;
|
|
2631
|
+
}
|
|
2632
|
+
state.settingsWizard = { channel, step: 0, values, errors: [], testResult: null };
|
|
2633
|
+
renderSettingsWizardStep();
|
|
2634
|
+
}
|
|
2635
|
+
function openSettingsWizardHome() {
|
|
2636
|
+
if (!can("settings.write")) {
|
|
2637
|
+
toast("Permission required: settings.write");
|
|
2638
|
+
return;
|
|
2639
|
+
}
|
|
2640
|
+
state.settingsWizard = { home: true };
|
|
2641
|
+
renderSettingsWizardHome();
|
|
2642
|
+
}
|
|
2643
|
+
function closeSettingsWizard() {
|
|
2644
|
+
state.settingsWizard = null;
|
|
2645
|
+
document.getElementById("settingsTabs").style.display = "";
|
|
2646
|
+
renderSettings();
|
|
2647
|
+
}
|
|
2648
|
+
function wizardRequiredValuePresent(key) {
|
|
2649
|
+
const value = wizardCurrentValue(key);
|
|
2650
|
+
if (!value) return false;
|
|
2651
|
+
const setting = settingByKey(key);
|
|
2652
|
+
if (isMaskedSettingValue(value)) return setting?.kind === "secret" && (setting.configured || setting.masked);
|
|
2653
|
+
return true;
|
|
2654
|
+
}
|
|
2655
|
+
function wizardMissingRequired(wizard) {
|
|
2656
|
+
return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
|
|
2657
|
+
}
|
|
2658
|
+
function renderSettingsWizardHome() {
|
|
2659
|
+
document.getElementById("settingsTabs").style.display = "none";
|
|
2660
|
+
const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
|
|
2661
|
+
const missing = wizardMissingRequired(wizard);
|
|
2662
|
+
const docs = wizardLinkList(wizard.docs);
|
|
2663
|
+
return '<div class="wizard-card"><strong>' + esc(wizard.label) + ' <span class="adapter-status ' + (missing.length ? "planned" : "enabled") + '">' + esc(missing.length ? missing.length + " missing" : "ready") + "</span></strong><p>" + esc(wizard.description) + "</p><small>" + esc(missing.length ? "Missing: " + missing.join(", ") : "Required values are present or configured.") + "</small>" + docs + '<button type="button" data-start-wizard="' + attr(wizard.id) + '">Start wizard</button></div>';
|
|
2664
|
+
}).join("");
|
|
2665
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>Setup wizard</h2><p>Choose a chat adapter and follow the guided setup. Bot/app credentials only enable transport; NordRelay user and group permissions still control access.</p></div><button type="button" class="secondary" id="backToSettingsBtn">Back to settings</button></div><div class="wizard-choice-grid">' + cards + "</div></div>";
|
|
2666
|
+
document.getElementById("backToSettingsBtn").onclick = closeSettingsWizard;
|
|
2667
|
+
document.querySelectorAll("[data-start-wizard]").forEach((b) => b.onclick = () => startSettingsWizard(b.dataset.startWizard));
|
|
2668
|
+
applyPermissions();
|
|
2669
|
+
}
|
|
2670
|
+
function wizardSettingInput(s) {
|
|
2671
|
+
const value = wizardCurrentValue(s.key);
|
|
2672
|
+
const attrs = ' data-wizard-setting="' + attr(s.key) + '" data-original-value="' + attr(wizardOriginalValue(s.key)) + '"';
|
|
2673
|
+
if (s.options) {
|
|
2674
|
+
return "<select" + attrs + '><option value="">Unset / inherit active default</option>' + s.options.map((o) => '<option value="' + attr(o) + '" ' + (value === o ? "selected" : "") + ">" + esc(o) + "</option>").join("") + "</select>";
|
|
2675
|
+
}
|
|
2676
|
+
if (s.kind === "boolean") {
|
|
2677
|
+
return "<select" + attrs + '><option value="">Unset / inherit active default</option><option value="true" ' + (value === "true" ? "selected" : "") + '>true</option><option value="false" ' + (value === "false" ? "selected" : "") + ">false</option></select>";
|
|
2678
|
+
}
|
|
2679
|
+
if (s.kind === "json") return '<textarea rows="4"' + attrs + ">" + esc(value) + "</textarea>";
|
|
2680
|
+
return "<input" + attrs + ' value="' + attr(value) + '" ' + (s.kind === "secret" ? 'type="password" autocomplete="new-password"' : "") + ">";
|
|
2681
|
+
}
|
|
2682
|
+
function renderWizardSetting(key) {
|
|
2683
|
+
const s = settingByKey(key);
|
|
2684
|
+
if (!s) return '<div class="setting"><strong>' + esc(key) + "</strong><small>Unknown setting.</small></div>";
|
|
2685
|
+
return '<div class="setting" data-wizard-setting-box="' + attr(key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + wizardSettingInput(s) + "<small>" + esc(s.key + " - " + s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + '</small><div class="setting-error"></div></div>';
|
|
2686
|
+
}
|
|
2687
|
+
function renderWizardProgress(wizard) {
|
|
2688
|
+
return '<div class="wizard-progress">' + wizard.steps.map((step, index) => '<button type="button" data-wizard-step="' + index + '" class="' + (index === state.settingsWizard.step ? "active" : "") + '">' + esc(String(index + 1) + ". " + step.title) + "</button>").join("") + "</div>";
|
|
2689
|
+
}
|
|
2690
|
+
function renderSettingsWizardStep() {
|
|
2691
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard?.channel];
|
|
2692
|
+
if (!wizard) {
|
|
2693
|
+
renderSettingsWizardHome();
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
|
|
2697
|
+
document.getElementById("settingsTabs").style.display = "none";
|
|
2698
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + " setup wizard</h2><p>" + esc(wizard.description) + '</p></div><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div>' + renderWizardProgress(wizard) + '<div class="wizard-step"><h3>' + esc(step.title) + "</h3><p>" + esc(step.body) + "</p>" + wizardLinkList(step.links) + '<div id="wizardRestartBanner"></div><div class="settings-grid">' + step.settings.map(renderWizardSetting).join("") + '</div><div id="wizardErrors" class="wizard-errors"></div><div id="wizardTestResult" class="wizard-test-result"></div><div class="wizard-actions"><button type="button" id="wizardPrevBtn" class="secondary">Back</button><button type="button" id="wizardNextBtn">' + esc(state.settingsWizard.step === wizard.steps.length - 1 ? "Review" : "Next") + '</button><button type="button" id="wizardTestBtn" class="secondary">Test setup</button><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button></div><div class="setting-help">After transport is configured, link users and register allowed chats or channels in the Users page.</div></div></div>';
|
|
2699
|
+
bindWizardUx();
|
|
2700
|
+
renderWizardValidation();
|
|
2701
|
+
}
|
|
2702
|
+
function bindWizardUx() {
|
|
2703
|
+
document.getElementById("wizardHomeBtn").onclick = renderSettingsWizardHome;
|
|
2704
|
+
document.querySelectorAll("[data-wizard-step]").forEach((b) => b.onclick = () => {
|
|
2705
|
+
collectWizardValues();
|
|
2706
|
+
state.settingsWizard.step = Number(b.dataset.wizardStep) || 0;
|
|
2707
|
+
renderSettingsWizardStep();
|
|
2708
|
+
});
|
|
2709
|
+
document.querySelectorAll("[data-wizard-setting]").forEach((el) => {
|
|
2710
|
+
el.oninput = () => {
|
|
2711
|
+
state.settingsWizard.values[el.dataset.wizardSetting] = el.value;
|
|
2712
|
+
renderWizardValidation();
|
|
2713
|
+
};
|
|
2714
|
+
el.onchange = el.oninput;
|
|
2715
|
+
});
|
|
2716
|
+
document.getElementById("wizardPrevBtn").onclick = () => {
|
|
2717
|
+
collectWizardValues();
|
|
2718
|
+
if (state.settingsWizard.step > 0) {
|
|
2719
|
+
state.settingsWizard.step--;
|
|
2720
|
+
renderSettingsWizardStep();
|
|
2721
|
+
} else renderSettingsWizardHome();
|
|
2722
|
+
};
|
|
2723
|
+
document.getElementById("wizardNextBtn").onclick = () => {
|
|
2724
|
+
collectWizardValues();
|
|
2725
|
+
if (state.settingsWizard.step < SETUP_WIZARDS[state.settingsWizard.channel].steps.length - 1) {
|
|
2726
|
+
state.settingsWizard.step++;
|
|
2727
|
+
renderSettingsWizardStep();
|
|
2728
|
+
} else renderWizardReview();
|
|
2729
|
+
};
|
|
2730
|
+
document.getElementById("wizardSaveBtn").onclick = () => safe(() => saveWizard(false));
|
|
2731
|
+
document.getElementById("wizardSaveRestartBtn").onclick = () => safe(() => saveWizard(true));
|
|
2732
|
+
document.getElementById("wizardTestBtn").onclick = () => safe(testWizardSetup);
|
|
2733
|
+
applyPermissions();
|
|
2734
|
+
}
|
|
2735
|
+
function collectWizardValues() {
|
|
2736
|
+
document.querySelectorAll("[data-wizard-setting]").forEach((el) => {
|
|
2737
|
+
state.settingsWizard.values[el.dataset.wizardSetting] = el.value;
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
function collectWizardPatch(wizard) {
|
|
2741
|
+
collectWizardValues();
|
|
2742
|
+
const patch = {};
|
|
2743
|
+
for (const key of wizardKeys(wizard)) {
|
|
2744
|
+
if (!Object.prototype.hasOwnProperty.call(state.settingsWizard.values, key)) continue;
|
|
2745
|
+
const value = state.settingsWizard.values[key];
|
|
2746
|
+
if (value !== wizardOriginalValue(key)) patch[key] = value;
|
|
2747
|
+
}
|
|
2748
|
+
return patch;
|
|
2749
|
+
}
|
|
2750
|
+
function collectWizardSettings(wizard) {
|
|
2751
|
+
collectWizardValues();
|
|
2752
|
+
const settings = {};
|
|
2753
|
+
for (const key of wizardKeys(wizard)) settings[key] = wizardCurrentValue(key);
|
|
2754
|
+
return settings;
|
|
2755
|
+
}
|
|
2756
|
+
function parseList(text) {
|
|
2757
|
+
return String(text || "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
2758
|
+
}
|
|
2759
|
+
function wizardValueBool(value) {
|
|
2760
|
+
return ["true", "1", "yes", "on"].includes(String(value || "").toLowerCase());
|
|
2761
|
+
}
|
|
2762
|
+
function validateSnowflakeList(value, label, required = false) {
|
|
2763
|
+
const items = parseList(value);
|
|
2764
|
+
if (required && items.length === 0) return [label + " is required."];
|
|
2765
|
+
return items.filter((item) => !/^[0-9]{5,32}$/.test(item)).map((item) => label + " contains invalid Discord ID: " + item);
|
|
2766
|
+
}
|
|
2767
|
+
function validateSlackIdList(value, label) {
|
|
2768
|
+
return parseList(value).filter((item) => !/^[A-Z0-9]{2,64}$/.test(item)).map((item) => label + " contains invalid Slack ID: " + item);
|
|
2769
|
+
}
|
|
2770
|
+
function validateWizard(wizard) {
|
|
2771
|
+
const v = Object.fromEntries(wizardKeys(wizard).map((key) => [key, wizardCurrentValue(key)]));
|
|
2772
|
+
const errors = [];
|
|
2773
|
+
const warnings = [];
|
|
2774
|
+
if (wizard.id === "telegram") {
|
|
2775
|
+
if (!v.TELEGRAM_BOT_TOKEN) errors.push("Telegram bot token is required.");
|
|
2776
|
+
else if (!isMaskedSettingValue(v.TELEGRAM_BOT_TOKEN) && !/^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/.test(v.TELEGRAM_BOT_TOKEN)) errors.push("Telegram bot token does not look like a BotFather token.");
|
|
2777
|
+
if (!["polling", "webhook"].includes(v.TELEGRAM_TRANSPORT || "polling")) errors.push("Telegram transport must be polling or webhook.");
|
|
2778
|
+
if ((v.TELEGRAM_TRANSPORT || "polling") === "webhook") {
|
|
2779
|
+
if (!String(v.TELEGRAM_WEBHOOK_URL || "").startsWith("https://")) errors.push("Webhook public URL must be HTTPS.");
|
|
2780
|
+
if (!v.TELEGRAM_WEBHOOK_HOST) errors.push("Webhook bind host is required.");
|
|
2781
|
+
if (!Number.isFinite(Number(v.TELEGRAM_WEBHOOK_PORT))) errors.push("Webhook port must be numeric.");
|
|
2782
|
+
if (!String(v.TELEGRAM_WEBHOOK_PATH || "").startsWith("/")) errors.push("Webhook path must start with /.");
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (wizard.id === "discord") {
|
|
2786
|
+
if (!v.DISCORD_BOT_TOKEN) errors.push("Discord bot token is required.");
|
|
2787
|
+
else if (!isMaskedSettingValue(v.DISCORD_BOT_TOKEN) && String(v.DISCORD_BOT_TOKEN).length < 20) errors.push("Discord bot token is too short.");
|
|
2788
|
+
if (!v.DISCORD_CLIENT_ID) errors.push("Discord client ID is required.");
|
|
2789
|
+
else errors.push(...validateSnowflakeList(v.DISCORD_CLIENT_ID, "Discord client ID", true));
|
|
2790
|
+
errors.push(...validateSnowflakeList(v.DISCORD_GUILD_IDS, "Discord guild IDs"));
|
|
2791
|
+
errors.push(...validateSnowflakeList(v.DISCORD_ALLOWED_GUILD_IDS, "Allowed Discord guilds"));
|
|
2792
|
+
errors.push(...validateSnowflakeList(v.DISCORD_ALLOWED_CHANNEL_IDS, "Allowed Discord channels"));
|
|
2793
|
+
if (["message", "both"].includes(v.DISCORD_COMMAND_MODE || "both") && !wizardValueBool(v.DISCORD_MESSAGE_CONTENT_ENABLED)) warnings.push("Message command mode needs Message Content Intent enabled in the Discord Developer Portal.");
|
|
2794
|
+
}
|
|
2795
|
+
if (wizard.id === "slack") {
|
|
2796
|
+
if (!v.SLACK_BOT_TOKEN) errors.push("Slack bot token is required.");
|
|
2797
|
+
else if (!isMaskedSettingValue(v.SLACK_BOT_TOKEN) && !String(v.SLACK_BOT_TOKEN).startsWith("xoxb-")) errors.push("Slack bot token should start with xoxb-.");
|
|
2798
|
+
if (wizardValueBool(v.SLACK_SOCKET_MODE)) {
|
|
2799
|
+
if (!v.SLACK_APP_TOKEN) errors.push("Slack app token is required for Socket Mode.");
|
|
2800
|
+
else if (!isMaskedSettingValue(v.SLACK_APP_TOKEN) && !String(v.SLACK_APP_TOKEN).startsWith("xapp-")) errors.push("Slack app token should start with xapp-.");
|
|
2801
|
+
} else {
|
|
2802
|
+
if (!v.SLACK_SIGNING_SECRET) errors.push("Slack signing secret is required when Socket Mode is disabled.");
|
|
2803
|
+
if (!Number.isFinite(Number(v.SLACK_PORT))) errors.push("Slack HTTP port must be numeric.");
|
|
2804
|
+
}
|
|
2805
|
+
if (v.SLACK_COMMAND && !String(v.SLACK_COMMAND).startsWith("/")) errors.push("Slack slash command must start with /.");
|
|
2806
|
+
errors.push(...validateSlackIdList(v.SLACK_ALLOWED_TEAM_IDS, "Allowed Slack teams"));
|
|
2807
|
+
errors.push(...validateSlackIdList(v.SLACK_ALLOWED_CHANNEL_IDS, "Allowed Slack channels"));
|
|
2808
|
+
}
|
|
2809
|
+
return { errors, warnings };
|
|
2810
|
+
}
|
|
2811
|
+
function renderWizardValidation() {
|
|
2812
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard?.channel];
|
|
2813
|
+
if (!wizard) return;
|
|
2814
|
+
const result = validateWizard(wizard);
|
|
2815
|
+
const target = document.getElementById("wizardErrors");
|
|
2816
|
+
if (target) target.innerHTML = [...result.errors.map((e) => '<div class="wizard-error">' + esc(e) + "</div>"), ...result.warnings.map((w) => '<div class="wizard-warning">' + esc(w) + "</div>")].join("");
|
|
2817
|
+
const changed = Object.keys(collectWizardPatch(wizard)).length;
|
|
2818
|
+
const banner = document.getElementById("wizardRestartBanner");
|
|
2819
|
+
if (banner) banner.innerHTML = changed ? '<div class="restart-banner">' + changed + " wizard setting(s) will be saved. A NordRelay restart may be required.</div>" : "";
|
|
2820
|
+
document.getElementById("wizardSaveBtn").disabled = !can("settings.write") || result.errors.length > 0;
|
|
2821
|
+
document.getElementById("wizardSaveRestartBtn").disabled = !can("settings.write") || !can("system.restart") || result.errors.length > 0;
|
|
2822
|
+
}
|
|
2823
|
+
function renderWizardReview() {
|
|
2824
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
|
|
2825
|
+
collectWizardValues();
|
|
2826
|
+
const validation = validateWizard(wizard);
|
|
2827
|
+
const patch = collectWizardPatch(wizard);
|
|
2828
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + ' review</h2><p>Review changed settings before saving. Secret values stay masked when they are already configured.</p></div><button type="button" class="secondary" id="wizardEditBtn">Back to wizard</button></div><div class="list">' + (Object.keys(patch).map((key) => '<div class="item"><strong>' + esc(key) + "</strong><small>" + esc(isMaskedSettingValue(patch[key]) ? "configured secret / unchanged" : patch[key] || "(unset)") + "</small></div>").join("") || '<div class="item">No wizard changes.</div>') + '</div><div id="wizardErrors">' + [...validation.errors.map((e) => '<div class="wizard-error">' + esc(e) + "</div>"), ...validation.warnings.map((w) => '<div class="wizard-warning">' + esc(w) + "</div>")].join("") + '</div><div class="wizard-actions"><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div></div>';
|
|
2829
|
+
document.getElementById("wizardEditBtn").onclick = renderSettingsWizardStep;
|
|
2830
|
+
document.getElementById("wizardHomeBtn").onclick = renderSettingsWizardHome;
|
|
2831
|
+
document.getElementById("wizardSaveBtn").onclick = () => safe(() => saveWizard(false));
|
|
2832
|
+
document.getElementById("wizardSaveRestartBtn").onclick = () => safe(() => saveWizard(true));
|
|
2833
|
+
document.getElementById("wizardSaveBtn").disabled = validation.errors.length > 0;
|
|
2834
|
+
document.getElementById("wizardSaveRestartBtn").disabled = !can("system.restart") || validation.errors.length > 0;
|
|
2835
|
+
applyPermissions();
|
|
2836
|
+
}
|
|
2837
|
+
async function saveWizard(restart) {
|
|
2838
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
|
|
2839
|
+
const validation = validateWizard(wizard);
|
|
2840
|
+
if (validation.errors.length) {
|
|
2841
|
+
renderWizardValidation();
|
|
2842
|
+
toast("Wizard settings need attention");
|
|
2843
|
+
return;
|
|
2844
|
+
}
|
|
2845
|
+
const patch = collectWizardPatch(wizard);
|
|
2846
|
+
const r = await api("/api/settings", { method: "PATCH", body: JSON.stringify({ settings: patch }) });
|
|
2847
|
+
if (r.errors && r.errors.length) {
|
|
2848
|
+
document.getElementById("settingsStatus").textContent = "Fix " + r.errors.length + " setting error(s)";
|
|
2849
|
+
toast("Settings need attention");
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
toast("Wizard settings saved" + (r.restartRequired ? " - restart required" : ""));
|
|
2853
|
+
document.getElementById("settingsStatus").textContent = (r.changedKeys?.length ? "Saved " + r.changedKeys.length + " wizard setting(s)" : "No changes") + (r.restartRequired ? " - restart required" : "");
|
|
2854
|
+
if (restart) {
|
|
2855
|
+
await api("/api/runtime/restart", { method: "POST" });
|
|
2856
|
+
toast("Settings saved and restart requested", { duration: 6e3 });
|
|
2857
|
+
}
|
|
2858
|
+
await loadSettings();
|
|
2859
|
+
}
|
|
2860
|
+
async function testWizardSetup() {
|
|
2861
|
+
const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
|
|
2862
|
+
const validation = validateWizard(wizard);
|
|
2863
|
+
if (validation.errors.length) {
|
|
2864
|
+
renderWizardValidation();
|
|
2865
|
+
toast("Fix validation errors before testing");
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const target = document.getElementById("wizardTestResult");
|
|
2869
|
+
target.innerHTML = loadingHtml("Testing setup...");
|
|
2870
|
+
const result = await api("/api/settings/wizard/test", { method: "POST", body: JSON.stringify({ channel: wizard.id, settings: collectWizardSettings(wizard) }) });
|
|
2871
|
+
target.innerHTML = (result.checks || []).map((check) => '<div class="item"><strong>' + esc(check.label) + ' <span class="adapter-status ' + (check.status === "ok" ? "enabled" : check.status === "warn" ? "planned" : "disabled") + '">' + esc(check.status) + "</span></strong><small>" + esc(check.detail || "") + "</small></div>").join("") || '<div class="item">No checks returned.</div>';
|
|
2872
|
+
}
|
|
2873
|
+
document.getElementById("settingsWizardBtn").onclick = openSettingsWizardHome;
|
|
2061
2874
|
})();
|