@nordbyte/nordrelay 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/README.md +67 -6
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +77 -6
- package/dist/channel-adapter.js +11 -5
- package/dist/channel-command-catalog.js +88 -0
- package/dist/channel-command-service.js +214 -1
- package/dist/channel-mirror-registry.js +77 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +15 -0
- package/dist/config.js +31 -6
- package/dist/context-key.js +10 -0
- package/dist/discord-bot.js +85 -26
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +20 -0
- package/dist/metrics.js +46 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +256 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-runtime-service.js +636 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +294 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +208 -0
- package/dist/relay-runtime.js +72 -274
- package/dist/remote-prompt.js +98 -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/web-api-contract.js +8 -0
- package/dist/web-dashboard-pages.js +12 -0
- package/dist/web-dashboard-peer-routes.js +204 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +12 -0
- package/dist/webui-assets/dashboard.js +427 -14
- package/package.json +3 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +373 -7
|
@@ -20,6 +20,14 @@
|
|
|
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/peers", methods: ["GET", "POST"] },
|
|
24
|
+
{ path: "/api/peers/invite", methods: ["POST"] },
|
|
25
|
+
{ path: "/api/peers/pair", methods: ["POST"] },
|
|
26
|
+
{ path: "/api/peers/global-sessions", methods: ["GET"] },
|
|
27
|
+
{ re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
|
|
28
|
+
{ re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
|
|
29
|
+
{ re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
|
|
30
|
+
{ re: /^\/api\/peers\/[^\/]+\/events$/, methods: ["GET"] },
|
|
23
31
|
{ path: "/api/permissions", methods: ["GET"] },
|
|
24
32
|
{ path: "/api/users", methods: ["GET", "POST"] },
|
|
25
33
|
{ re: /^\/api\/users\/[^\/]+$/, methods: ["PATCH"] },
|
|
@@ -84,6 +92,32 @@
|
|
|
84
92
|
const method = normalizeMethod(options.method, options.body);
|
|
85
93
|
const url = apiUrl(path, options.query);
|
|
86
94
|
assertApiRoute(url.pathname, method);
|
|
95
|
+
if (!options.local && shouldProxyApi(url.pathname)) {
|
|
96
|
+
const peerId = selectedPeerTarget();
|
|
97
|
+
const proxyBody = JSON.stringify({
|
|
98
|
+
method,
|
|
99
|
+
path: url.pathname,
|
|
100
|
+
query: queryObject(url),
|
|
101
|
+
body: bodyObject(options.body),
|
|
102
|
+
contextKey: "web:dashboard"
|
|
103
|
+
});
|
|
104
|
+
const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "content-type": "application/json" },
|
|
107
|
+
body: proxyBody
|
|
108
|
+
});
|
|
109
|
+
if (res2.status === 401) {
|
|
110
|
+
location.reload();
|
|
111
|
+
return (
|
|
112
|
+
/** @type {never} */
|
|
113
|
+
void 0
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const text2 = await res2.text();
|
|
117
|
+
const data2 = text2 ? JSON.parse(text2) : {};
|
|
118
|
+
if (!res2.ok) throw new Error(data2.error || res2.statusText);
|
|
119
|
+
return data2;
|
|
120
|
+
}
|
|
87
121
|
const body = normalizeBody(options.body);
|
|
88
122
|
const headers = {
|
|
89
123
|
...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
|
|
@@ -102,6 +136,46 @@
|
|
|
102
136
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
103
137
|
return data;
|
|
104
138
|
}
|
|
139
|
+
function shouldProxyApi(path) {
|
|
140
|
+
const peerId = selectedPeerTarget();
|
|
141
|
+
if (!peerId || peerId === "local") return false;
|
|
142
|
+
if (!path.startsWith("/api/")) return false;
|
|
143
|
+
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
|
|
144
|
+
}
|
|
145
|
+
function isLocalAdminApi(path) {
|
|
146
|
+
return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path);
|
|
147
|
+
}
|
|
148
|
+
function selectedPeerTarget() {
|
|
149
|
+
const runtimeState = (
|
|
150
|
+
/** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { selectedPeer?: string } }} */
|
|
151
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE
|
|
152
|
+
);
|
|
153
|
+
return runtimeState?.selectedPeer || "local";
|
|
154
|
+
}
|
|
155
|
+
function queryObject(url) {
|
|
156
|
+
const result = {};
|
|
157
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
158
|
+
if (result[key] === void 0) result[key] = value;
|
|
159
|
+
else if (Array.isArray(result[key])) result[key].push(value);
|
|
160
|
+
else result[key] = [result[key], value];
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
function bodyObject(body) {
|
|
165
|
+
if (body === void 0 || body === null) return {};
|
|
166
|
+
if (typeof body === "string") {
|
|
167
|
+
try {
|
|
168
|
+
return body ? JSON.parse(body) : {};
|
|
169
|
+
} catch {
|
|
170
|
+
return { value: body };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (isNativeBody(body)) return {};
|
|
174
|
+
return (
|
|
175
|
+
/** @type {Record<string, unknown>} */
|
|
176
|
+
body
|
|
177
|
+
);
|
|
178
|
+
}
|
|
105
179
|
function apiUrl(path, query) {
|
|
106
180
|
const url = new URL(path, location.origin);
|
|
107
181
|
if (query) {
|
|
@@ -148,7 +222,8 @@
|
|
|
148
222
|
throw new Error("Unsupported WebUI API method: " + method + " " + path);
|
|
149
223
|
}
|
|
150
224
|
}
|
|
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,
|
|
225
|
+
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
226
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
|
|
152
227
|
function toast(msg, options = {}) {
|
|
153
228
|
const el = document.getElementById("toast");
|
|
154
229
|
el.textContent = msg;
|
|
@@ -249,6 +324,7 @@
|
|
|
249
324
|
["#updateBtn", "updates.run"],
|
|
250
325
|
["#clearLogsBtn", "logs.clear"],
|
|
251
326
|
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
|
|
327
|
+
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke]", "peers.write"],
|
|
252
328
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
253
329
|
["[data-switch]", "sessions.write"],
|
|
254
330
|
["[data-queue],[data-q]", "queue.write"],
|
|
@@ -311,6 +387,7 @@
|
|
|
311
387
|
if (name === "tasks") await loadTasks();
|
|
312
388
|
if (name === "metrics") await loadMetrics();
|
|
313
389
|
if (name === "adapters") await loadAdapterHealth();
|
|
390
|
+
if (name === "peers") await loadPeers();
|
|
314
391
|
if (name === "access") await loadAccess();
|
|
315
392
|
if (name === "version") await loadVersion();
|
|
316
393
|
}
|
|
@@ -354,9 +431,11 @@
|
|
|
354
431
|
}
|
|
355
432
|
const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
|
|
356
433
|
async function loadBootstrap() {
|
|
357
|
-
const
|
|
358
|
-
state.auth =
|
|
359
|
-
state.permissions =
|
|
434
|
+
const local = await api("/api/bootstrap", { local: true });
|
|
435
|
+
state.auth = local.auth || null;
|
|
436
|
+
state.permissions = local.auth?.permissions || [];
|
|
437
|
+
await loadPeerSelector();
|
|
438
|
+
const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
|
|
360
439
|
state.snapshot = data.status.snapshot;
|
|
361
440
|
state.controls = data.controls;
|
|
362
441
|
state.enabledAgents = data.enabledAgents || [];
|
|
@@ -368,7 +447,7 @@
|
|
|
368
447
|
renderAdapters(data.channels, data.agentAdapters);
|
|
369
448
|
document.getElementById("footerVersion").textContent = "NordRelay " + (data.status.health?.version || "");
|
|
370
449
|
document.getElementById("footerHealth").textContent = "Health: " + (data.status.health?.state?.status || "unknown");
|
|
371
|
-
document.getElementById("footerUser").textContent = "User: " + (
|
|
450
|
+
document.getElementById("footerUser").textContent = "User: " + (local.auth?.user?.email || "-") + (state.selectedPeer && state.selectedPeer !== "local" ? " / target peer" : "");
|
|
372
451
|
const agentSelect = document.getElementById("agentSelect");
|
|
373
452
|
agentSelect.innerHTML = data.enabledAgents.map((a) => '<option value="' + a + '">' + a + "</option>").join("");
|
|
374
453
|
agentSelect.value = state.snapshot.session.agentId;
|
|
@@ -385,6 +464,35 @@
|
|
|
385
464
|
});
|
|
386
465
|
applyPermissions();
|
|
387
466
|
}
|
|
467
|
+
async function loadPeerSelector() {
|
|
468
|
+
const peerSelect = document.getElementById("peerSelect");
|
|
469
|
+
if (!peerSelect) return;
|
|
470
|
+
if (!can("peers.read")) {
|
|
471
|
+
peerSelect.innerHTML = '<option value="local">Local</option>';
|
|
472
|
+
peerSelect.value = "local";
|
|
473
|
+
state.selectedPeer = "local";
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
const peers = await api("/api/peers", { local: true });
|
|
478
|
+
state.peers = peers;
|
|
479
|
+
const available = (peers.peers || []).filter((p) => p.enabled && p.url);
|
|
480
|
+
peerSelect.innerHTML = '<option value="local">Local node</option>' + available.map((p) => '<option value="' + attr(p.id) + '">' + esc(p.name) + "</option>").join("");
|
|
481
|
+
if (state.selectedPeer !== "local" && !available.some((p) => p.id === state.selectedPeer)) state.selectedPeer = "local";
|
|
482
|
+
peerSelect.value = state.selectedPeer;
|
|
483
|
+
peerSelect.onchange = () => safe(async () => {
|
|
484
|
+
state.selectedPeer = peerSelect.value || "local";
|
|
485
|
+
localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
|
|
486
|
+
connectEvents();
|
|
487
|
+
toast(state.selectedPeer === "local" ? "Target: local" : "Target: " + peerSelect.options[peerSelect.selectedIndex].text);
|
|
488
|
+
await loadBootstrap();
|
|
489
|
+
await reloadCurrentPage();
|
|
490
|
+
});
|
|
491
|
+
} catch {
|
|
492
|
+
peerSelect.innerHTML = '<option value="local">Local node</option>';
|
|
493
|
+
peerSelect.value = "local";
|
|
494
|
+
}
|
|
495
|
+
}
|
|
388
496
|
function renderSnapshot(s) {
|
|
389
497
|
document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
|
|
390
498
|
document.getElementById("metrics").innerHTML = [
|
|
@@ -409,6 +517,7 @@
|
|
|
409
517
|
renderActiveSessions(data.sessions || []);
|
|
410
518
|
}
|
|
411
519
|
function renderActiveSessions(items) {
|
|
520
|
+
state.activeSessions = { sessions: items || [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
412
521
|
const box = document.getElementById("activeSessions");
|
|
413
522
|
if (!box) return;
|
|
414
523
|
box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
|
|
@@ -552,9 +661,6 @@
|
|
|
552
661
|
if (status === "running") return "planned";
|
|
553
662
|
return "disabled";
|
|
554
663
|
}
|
|
555
|
-
state.activeSessionsTimer = setInterval(() => {
|
|
556
|
-
if (state.currentPage === "overview") safe(loadActiveSessions);
|
|
557
|
-
}, 5e3);
|
|
558
664
|
function scrollChatToBottom() {
|
|
559
665
|
const box = document.getElementById("messages");
|
|
560
666
|
if (!box) return;
|
|
@@ -600,7 +706,8 @@
|
|
|
600
706
|
let currentAgentMessage = null;
|
|
601
707
|
function connectEvents() {
|
|
602
708
|
if (state.events) state.events.close();
|
|
603
|
-
const
|
|
709
|
+
const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
|
|
710
|
+
const events = new EventSource(eventsUrl);
|
|
604
711
|
state.events = events;
|
|
605
712
|
setConnection("Connecting", "warn");
|
|
606
713
|
events.onopen = () => {
|
|
@@ -618,6 +725,11 @@
|
|
|
618
725
|
});
|
|
619
726
|
events.addEventListener("chat_history", (e) => renderChatMessages(JSON.parse(e.data).messages || []));
|
|
620
727
|
events.addEventListener("activity_update", (e) => renderActivity(JSON.parse(e.data).events || []));
|
|
728
|
+
events.addEventListener("active_sessions_update", (e) => {
|
|
729
|
+
const d = JSON.parse(e.data);
|
|
730
|
+
state.activeSessions = d.active || null;
|
|
731
|
+
if (state.currentPage === "overview") renderActiveSessions(state.activeSessions?.sessions || []);
|
|
732
|
+
});
|
|
621
733
|
events.addEventListener("session_update", (e) => {
|
|
622
734
|
loadBootstrap();
|
|
623
735
|
loadChatHistory();
|
|
@@ -1212,6 +1324,99 @@
|
|
|
1212
1324
|
throw err;
|
|
1213
1325
|
}
|
|
1214
1326
|
}
|
|
1327
|
+
function isRemotePeerTarget() {
|
|
1328
|
+
return Boolean(state.selectedPeer && state.selectedPeer !== "local");
|
|
1329
|
+
}
|
|
1330
|
+
function artifactFileHref(turnId, path) {
|
|
1331
|
+
return isRemotePeerTarget() ? "#" : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
|
|
1332
|
+
}
|
|
1333
|
+
function artifactZipHref(turnId) {
|
|
1334
|
+
return isRemotePeerTarget() ? "#" : "/api/artifacts/zip?turnId=" + encodeURIComponent(turnId);
|
|
1335
|
+
}
|
|
1336
|
+
async function downloadArtifactFile(turnId, path) {
|
|
1337
|
+
const file = await api("/api/artifacts/file", { query: { turnId, path } });
|
|
1338
|
+
downloadBase64(file.name || path, file.dataBase64 || "", file.mimeType || "application/octet-stream");
|
|
1339
|
+
}
|
|
1340
|
+
async function downloadArtifactZip(turnId) {
|
|
1341
|
+
const file = await api("/api/artifacts/zip", { query: { turnId } });
|
|
1342
|
+
downloadBase64(file.name || "nordrelay-artifacts-" + turnId + ".zip", file.dataBase64 || "", file.mimeType || "application/zip");
|
|
1343
|
+
}
|
|
1344
|
+
async function artifactDataUrl(turnId, path) {
|
|
1345
|
+
const file = await api("/api/artifacts/file", { query: { turnId, path } });
|
|
1346
|
+
return "data:" + (file.mimeType || "application/octet-stream") + ";base64," + (file.dataBase64 || "");
|
|
1347
|
+
}
|
|
1348
|
+
function renderArtifacts() {
|
|
1349
|
+
const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
|
|
1350
|
+
const kind = document.getElementById("artifactKind").value;
|
|
1351
|
+
const reports = state.artifactReports || [];
|
|
1352
|
+
document.getElementById("artifactList").innerHTML = reports.map((r) => {
|
|
1353
|
+
const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
|
|
1354
|
+
if (files.length === 0) return "";
|
|
1355
|
+
const gallery = files.map((a) => {
|
|
1356
|
+
const href = artifactFileHref(r.turnId, a.relativePath);
|
|
1357
|
+
const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(a.name);
|
|
1358
|
+
const img = isImage && !isRemotePeerTarget() ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
|
|
1359
|
+
return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '" data-open-artifact="' + attr(r.turnId) + '" data-open-path="' + attr(a.relativePath) + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
|
|
1360
|
+
}).join("");
|
|
1361
|
+
return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="' + artifactZipHref(r.turnId) + '" data-zip-artifact="' + attr(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
|
|
1362
|
+
}).join("") || '<div class="item">No artifacts.</div>';
|
|
1363
|
+
document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
|
|
1364
|
+
if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
|
|
1365
|
+
else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
|
|
1366
|
+
});
|
|
1367
|
+
document.querySelectorAll("[data-open-artifact]").forEach((a) => a.onclick = (e) => {
|
|
1368
|
+
if (!isRemotePeerTarget()) return;
|
|
1369
|
+
e.preventDefault();
|
|
1370
|
+
safe(() => downloadArtifactFile(a.dataset.openArtifact, a.dataset.openPath));
|
|
1371
|
+
});
|
|
1372
|
+
document.querySelectorAll("[data-zip-artifact]").forEach((a) => a.onclick = (e) => {
|
|
1373
|
+
if (!isRemotePeerTarget()) return;
|
|
1374
|
+
e.preventDefault();
|
|
1375
|
+
safe(() => downloadArtifactZip(a.dataset.zipArtifact));
|
|
1376
|
+
});
|
|
1377
|
+
document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1378
|
+
if (!can("files.write")) {
|
|
1379
|
+
toast("Permission required: files.write");
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (confirm("Delete artifact turn " + b.dataset.delArt + "?")) {
|
|
1383
|
+
await api("/api/artifacts", { method: "DELETE", query: { turnId: b.dataset.delArt } });
|
|
1384
|
+
state.selectedArtifactTurns.delete(b.dataset.delArt);
|
|
1385
|
+
loadArtifacts();
|
|
1386
|
+
}
|
|
1387
|
+
}));
|
|
1388
|
+
document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
|
|
1389
|
+
applyPermissions();
|
|
1390
|
+
}
|
|
1391
|
+
document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
|
|
1392
|
+
const turnIds = [...state.selectedArtifactTurns];
|
|
1393
|
+
if (turnIds.length === 0) {
|
|
1394
|
+
toast("No artifact turns selected");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
turnIds.forEach((turnId) => isRemotePeerTarget() ? safe(() => downloadArtifactZip(turnId)) : window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
|
|
1398
|
+
};
|
|
1399
|
+
async function previewArtifact(turnId, path) {
|
|
1400
|
+
const target = document.getElementById("artifactPreview");
|
|
1401
|
+
target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
|
|
1402
|
+
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
|
1403
|
+
try {
|
|
1404
|
+
const data = await api("/api/artifacts/preview", { query: { turnId, path } });
|
|
1405
|
+
if (data.kind === "image") {
|
|
1406
|
+
const src = isRemotePeerTarget() ? await artifactDataUrl(turnId, path) : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
|
|
1407
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="' + src + '"></div>';
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (data.kind === "text") {
|
|
1411
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + " " + fmtBytes(data.sizeBytes) + "</h2><pre>" + highlightCode(data.text || "") + "</pre>" + (data.truncated ? "<small>Preview truncated.</small>" : "") + "</div>";
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + "</h2><p>" + esc(data.detail || "Preview unavailable") + "</p></div>";
|
|
1415
|
+
} catch (err) {
|
|
1416
|
+
target.innerHTML = '<div class="panel"><h2>Preview failed</h2><p>' + esc(err.message || String(err)) + "</p></div>";
|
|
1417
|
+
throw err;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1215
1420
|
async function loadTasks() {
|
|
1216
1421
|
setLoading("tasksList", "Loading tasks...");
|
|
1217
1422
|
const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
|
|
@@ -1296,6 +1501,29 @@
|
|
|
1296
1501
|
["Aborted", jobs.aborted]
|
|
1297
1502
|
];
|
|
1298
1503
|
}
|
|
1504
|
+
function metricProcessRows(d) {
|
|
1505
|
+
const p = d.process || {};
|
|
1506
|
+
const memory = p.memory || {};
|
|
1507
|
+
const cpu = p.cpu || {};
|
|
1508
|
+
const loop = p.eventLoop || {};
|
|
1509
|
+
return [
|
|
1510
|
+
["PID", p.pid],
|
|
1511
|
+
["Node", p.nodeVersion],
|
|
1512
|
+
["Platform", [p.platform, p.arch].filter(Boolean).join(" ")],
|
|
1513
|
+
["Uptime", fmtDuration(p.uptimeMs)],
|
|
1514
|
+
["Started", fmtDate(p.startedAt)],
|
|
1515
|
+
["RSS", fmtBytes(memory.rssBytes || 0)],
|
|
1516
|
+
["Heap used", fmtBytes(memory.heapUsedBytes || 0)],
|
|
1517
|
+
["Heap total", fmtBytes(memory.heapTotalBytes || 0)],
|
|
1518
|
+
["CPU total", fmtDuration(cpu.totalMs)],
|
|
1519
|
+
["CPU avg", cpu.percentSinceStart === null || cpu.percentSinceStart === void 0 ? "-" : cpu.percentSinceStart + "%"],
|
|
1520
|
+
["Event loop p95", formatMs(loop.delayP95Ms)],
|
|
1521
|
+
["Event loop max", formatMs(loop.delayMaxMs)]
|
|
1522
|
+
];
|
|
1523
|
+
}
|
|
1524
|
+
function formatMs(value) {
|
|
1525
|
+
return value === null || value === void 0 ? "-" : value + "ms";
|
|
1526
|
+
}
|
|
1299
1527
|
function rateRows(name, rate) {
|
|
1300
1528
|
return [
|
|
1301
1529
|
["Queued", rate?.queued ?? 0],
|
|
@@ -1311,7 +1539,7 @@
|
|
|
1311
1539
|
}
|
|
1312
1540
|
function renderMetrics(d) {
|
|
1313
1541
|
const adapters = d.adapters || {};
|
|
1314
|
-
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Jobs", metricJobRows(d)) + card("Telegram rate limits", rateRows("", adapters.telegram).map(([k, v]) => [String(k).trim(), v])) + card("Discord rate limits", rateRows("", adapters.discord).map(([k, v]) => [String(k).trim(), v])) + "</div>";
|
|
1542
|
+
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + card("Telegram rate limits", rateRows("", adapters.telegram).map(([k, v]) => [String(k).trim(), v])) + card("Discord rate limits", rateRows("", adapters.discord).map(([k, v]) => [String(k).trim(), v])) + "</div>";
|
|
1315
1543
|
}
|
|
1316
1544
|
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1317
1545
|
function activityQuery() {
|
|
@@ -1366,7 +1594,7 @@
|
|
|
1366
1594
|
state.settings = data.settings;
|
|
1367
1595
|
renderSettings();
|
|
1368
1596
|
}
|
|
1369
|
-
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
|
|
1597
|
+
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
|
|
1370
1598
|
const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
|
|
1371
1599
|
function orderedSettingsGroups(groups) {
|
|
1372
1600
|
const known = settingsGroupOrder.filter((name) => groups[name]);
|
|
@@ -1873,6 +2101,97 @@
|
|
|
1873
2101
|
toast("Cleared " + target + " log");
|
|
1874
2102
|
}
|
|
1875
2103
|
});
|
|
2104
|
+
async function loadPeers() {
|
|
2105
|
+
if (!can("peers.read")) {
|
|
2106
|
+
document.getElementById("peersList").innerHTML = '<div class="item">Permission required: peers.read</div>';
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
2109
|
+
setLoading("peersList", "Loading peers...");
|
|
2110
|
+
const d = await api("/api/peers", { local: true });
|
|
2111
|
+
state.peers = d;
|
|
2112
|
+
document.getElementById("peerStatus").innerHTML = card("Local peer identity", [["Peer server", d.enabled ? "enabled" : "disabled"], ["Listen URL", d.listenUrl], ["Require TLS", d.requireTls ? "yes" : "no"], ["Node ID", d.identity?.nodeId], ["Fingerprint", d.identity?.fingerprint]]);
|
|
2113
|
+
document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
|
|
2114
|
+
document.getElementById("peerInvites").innerHTML = (d.invitations || []).map((i) => '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt ? "open" : "closed") + "</span></strong><small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + "</div>").join("") || '<div class="item">No open invitations.</div>';
|
|
2115
|
+
bindPeerButtons();
|
|
2116
|
+
await loadPeerSelector();
|
|
2117
|
+
applyPermissions();
|
|
2118
|
+
}
|
|
2119
|
+
function peerCard(p) {
|
|
2120
|
+
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
2121
|
+
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
|
|
2122
|
+
}
|
|
2123
|
+
function bindPeerButtons() {
|
|
2124
|
+
document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2125
|
+
state.selectedPeer = b.dataset.peerSelect || "local";
|
|
2126
|
+
localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
|
|
2127
|
+
connectEvents();
|
|
2128
|
+
await loadBootstrap();
|
|
2129
|
+
toast("Peer target selected");
|
|
2130
|
+
}));
|
|
2131
|
+
document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2132
|
+
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/proxy", { method: "POST", body: JSON.stringify({ method: "GET", path: "/api/health", query: {}, body: {} }), local: true });
|
|
2133
|
+
toast("Peer reachable: " + (r.health?.state?.status || r.state?.status || "ok"), { duration: 6e3 });
|
|
2134
|
+
loadPeers();
|
|
2135
|
+
}));
|
|
2136
|
+
document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
|
|
2137
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
|
|
2138
|
+
if (p) openPeerDialog(p);
|
|
2139
|
+
});
|
|
2140
|
+
document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2141
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
|
|
2142
|
+
if (!p) return;
|
|
2143
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
|
|
2144
|
+
toast("Peer updated");
|
|
2145
|
+
loadPeers();
|
|
2146
|
+
}));
|
|
2147
|
+
document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2148
|
+
if (confirm("Revoke this peer?")) {
|
|
2149
|
+
await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
|
|
2150
|
+
if (state.selectedPeer === b.dataset.peerRevoke) {
|
|
2151
|
+
state.selectedPeer = "local";
|
|
2152
|
+
localStorage.setItem("nordrelayPeerTarget", "local");
|
|
2153
|
+
}
|
|
2154
|
+
toast("Peer revoked");
|
|
2155
|
+
loadPeers();
|
|
2156
|
+
}
|
|
2157
|
+
}));
|
|
2158
|
+
}
|
|
2159
|
+
function openPeerDialog(p) {
|
|
2160
|
+
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label>', async () => {
|
|
2161
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")) }), local: true });
|
|
2162
|
+
toast("Peer updated");
|
|
2163
|
+
await loadPeers();
|
|
2164
|
+
});
|
|
2165
|
+
}
|
|
2166
|
+
function openPeerInviteDialog() {
|
|
2167
|
+
adminDialog("Create peer invite", '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label>', async () => {
|
|
2168
|
+
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")) }), local: true });
|
|
2169
|
+
toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
|
|
2170
|
+
await loadPeers();
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2173
|
+
function openPeerAddDialog() {
|
|
2174
|
+
adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional"></label>', async () => {
|
|
2175
|
+
const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
|
|
2176
|
+
toast("Added peer " + (r.peer?.name || ""));
|
|
2177
|
+
await loadPeers();
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
document.getElementById("loadPeersBtn").onclick = () => loadPeers();
|
|
2181
|
+
document.getElementById("createPeerInviteBtn").onclick = () => {
|
|
2182
|
+
if (!can("peers.write")) {
|
|
2183
|
+
toast("Permission required: peers.write");
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
openPeerInviteDialog();
|
|
2187
|
+
};
|
|
2188
|
+
document.getElementById("addPeerBtn").onclick = () => {
|
|
2189
|
+
if (!can("peers.write")) {
|
|
2190
|
+
toast("Permission required: peers.write");
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
openPeerAddDialog();
|
|
2194
|
+
};
|
|
1876
2195
|
async function loadAdapterHealth() {
|
|
1877
2196
|
setLoading("adapterHealth", "Loading adapters...");
|
|
1878
2197
|
const d = await api("/api/adapters/health");
|
|
@@ -2035,13 +2354,28 @@
|
|
|
2035
2354
|
document.getElementById("diagnostics").innerHTML = diagnosticsHtml(data);
|
|
2036
2355
|
}
|
|
2037
2356
|
const exportDiagnosticsBundleBtn = document.getElementById("exportDiagnosticsBundleBtn");
|
|
2038
|
-
if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => {
|
|
2357
|
+
if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => safe(async () => {
|
|
2039
2358
|
if (!can("diagnostics.read")) {
|
|
2040
2359
|
toast("Permission required: diagnostics.read");
|
|
2041
2360
|
return;
|
|
2042
2361
|
}
|
|
2043
|
-
|
|
2044
|
-
|
|
2362
|
+
if (!state.selectedPeer || state.selectedPeer === "local") {
|
|
2363
|
+
window.open("/api/diagnostics/bundle", "_blank");
|
|
2364
|
+
return;
|
|
2365
|
+
}
|
|
2366
|
+
const bundle = await api("/api/diagnostics/bundle");
|
|
2367
|
+
downloadBase64(bundle.name || "nordrelay-support-bundle.zip", bundle.dataBase64 || "", bundle.mimeType || "application/zip");
|
|
2368
|
+
toast("Remote diagnostics bundle downloaded");
|
|
2369
|
+
});
|
|
2370
|
+
function downloadBase64(name, dataBase64, mimeType) {
|
|
2371
|
+
const bytes = Uint8Array.from(atob(dataBase64), (c) => c.charCodeAt(0));
|
|
2372
|
+
const blob = new Blob([bytes], { type: mimeType });
|
|
2373
|
+
const a = document.createElement("a");
|
|
2374
|
+
a.href = URL.createObjectURL(blob);
|
|
2375
|
+
a.download = name;
|
|
2376
|
+
a.click();
|
|
2377
|
+
URL.revokeObjectURL(a.href);
|
|
2378
|
+
}
|
|
2045
2379
|
function diagnosticsHtml(d) {
|
|
2046
2380
|
const h = d.health || {};
|
|
2047
2381
|
const s = d.snapshot?.session || {};
|
|
@@ -2058,4 +2392,83 @@
|
|
|
2058
2392
|
Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
|
|
2059
2393
|
}
|
|
2060
2394
|
loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
|
|
2395
|
+
const loadPeersBase = loadPeers;
|
|
2396
|
+
loadPeers = async function() {
|
|
2397
|
+
await loadPeersBase();
|
|
2398
|
+
ensureGlobalPeerSessionsPanel();
|
|
2399
|
+
};
|
|
2400
|
+
function ensureGlobalPeerSessionsPanel() {
|
|
2401
|
+
if (document.getElementById("globalPeerSessionsPanel")) return;
|
|
2402
|
+
const anchor = document.getElementById("peersList");
|
|
2403
|
+
if (!anchor) return;
|
|
2404
|
+
anchor.insertAdjacentHTML("afterend", '<h2>Global sessions</h2><div id="globalPeerSessionsPanel" class="list"><div class="item"><div class="row"><input id="globalPeerSessionSearch" placeholder="Search sessions across peers"><button id="loadGlobalPeerSessionsBtn">Load global sessions</button></div><div id="globalPeerSessionsList"></div></div></div>');
|
|
2405
|
+
document.getElementById("loadGlobalPeerSessionsBtn").onclick = () => safe(loadGlobalPeerSessions);
|
|
2406
|
+
}
|
|
2407
|
+
async function loadGlobalPeerSessions() {
|
|
2408
|
+
const target = document.getElementById("globalPeerSessionsList");
|
|
2409
|
+
target.innerHTML = loadingHtml("Loading global sessions...");
|
|
2410
|
+
const q = document.getElementById("globalPeerSessionSearch").value || "";
|
|
2411
|
+
const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
|
|
2412
|
+
target.innerHTML = (d.targets || []).map((t) => '<div class="item"><strong>' + esc(t.peerName + " (" + t.peerId + ")") + ' <span class="adapter-status ' + (t.ok ? "enabled" : "disabled") + '">' + (t.ok ? "ok" : "error") + "</span></strong>" + (t.ok ? (t.data?.sessions || []).slice(0, 8).map((s) => "<small>" + esc(short(s.id, 48) + " | " + short(s.cwd || "", 80) + " | " + fmtDate(s.updatedAt)) + "</small>").join("") || "<small>No sessions.</small>" : '<small class="error">' + esc(t.error || "failed") + "</small>") + "</div>").join("") || '<div class="item">No peer sessions found.</div>';
|
|
2413
|
+
}
|
|
2414
|
+
function peerCard(p) {
|
|
2415
|
+
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
2416
|
+
const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
|
|
2417
|
+
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2418
|
+
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
|
|
2419
|
+
}
|
|
2420
|
+
function openPeerDialog(p) {
|
|
2421
|
+
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2422
|
+
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
|
|
2423
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
|
|
2424
|
+
toast("Peer updated");
|
|
2425
|
+
await loadPeers();
|
|
2426
|
+
});
|
|
2427
|
+
}
|
|
2428
|
+
function openPeerInviteDialog() {
|
|
2429
|
+
adminDialog("Create peer invite", '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
|
|
2430
|
+
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
|
|
2431
|
+
toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
|
|
2432
|
+
await loadPeers();
|
|
2433
|
+
});
|
|
2434
|
+
}
|
|
2435
|
+
function aliasMap(text) {
|
|
2436
|
+
return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
|
|
2437
|
+
}
|
|
2438
|
+
function bindPeerButtons() {
|
|
2439
|
+
document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2440
|
+
state.selectedPeer = b.dataset.peerSelect || "local";
|
|
2441
|
+
localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
|
|
2442
|
+
connectEvents();
|
|
2443
|
+
await loadBootstrap();
|
|
2444
|
+
toast("Peer target selected");
|
|
2445
|
+
}));
|
|
2446
|
+
document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2447
|
+
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/health", { local: true });
|
|
2448
|
+
toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
|
|
2449
|
+
loadPeers();
|
|
2450
|
+
}));
|
|
2451
|
+
document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
|
|
2452
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
|
|
2453
|
+
if (p) openPeerDialog(p);
|
|
2454
|
+
});
|
|
2455
|
+
document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2456
|
+
const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
|
|
2457
|
+
if (!p) return;
|
|
2458
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
|
|
2459
|
+
toast("Peer updated");
|
|
2460
|
+
loadPeers();
|
|
2461
|
+
}));
|
|
2462
|
+
document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
2463
|
+
if (confirm("Revoke this peer?")) {
|
|
2464
|
+
await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
|
|
2465
|
+
if (state.selectedPeer === b.dataset.peerRevoke) {
|
|
2466
|
+
state.selectedPeer = "local";
|
|
2467
|
+
localStorage.setItem("nordrelayPeerTarget", "local");
|
|
2468
|
+
}
|
|
2469
|
+
toast("Peer revoked");
|
|
2470
|
+
loadPeers();
|
|
2471
|
+
}
|
|
2472
|
+
}));
|
|
2473
|
+
}
|
|
2061
2474
|
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nordbyte/nordrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Remote control plane for coding agents across messaging channels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "Ricardo",
|
|
@@ -61,11 +61,12 @@
|
|
|
61
61
|
"webui:check": "tsc -p tsconfig.webui.json"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
64
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.141",
|
|
65
65
|
"@grammyjs/auto-retry": "^2.0.2",
|
|
66
66
|
"@openai/codex-sdk": "^0.130.0",
|
|
67
67
|
"discord.js": "^14.26.4",
|
|
68
68
|
"grammy": "^1.41.1",
|
|
69
|
+
"selfsigned": "^3.0.1",
|
|
69
70
|
"zod": "^4.4.3"
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|