@nordbyte/nordrelay 0.5.1 → 0.6.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 +65 -11
- package/README.md +97 -23
- package/dist/access-control.js +1 -0
- package/dist/activity-events.js +44 -0
- package/dist/agent-updates.js +18 -2
- package/dist/audit-log.js +40 -2
- package/dist/bot-rendering.js +10 -7
- package/dist/bot.js +492 -7
- package/dist/channel-actions.js +7 -2
- package/dist/channel-adapter.js +34 -7
- package/dist/channel-command-service.js +156 -0
- package/dist/channel-turn-service.js +237 -0
- package/dist/codex-cli.js +1 -1
- package/dist/config-metadata.js +80 -13
- package/dist/config.js +77 -7
- package/dist/context-key.js +77 -5
- package/dist/discord-artifacts.js +165 -0
- package/dist/discord-bot.js +2014 -0
- package/dist/discord-channel-runtime.js +133 -0
- package/dist/discord-command-surface.js +119 -0
- package/dist/discord-rate-limit.js +141 -0
- package/dist/index.js +16 -5
- package/dist/job-store.js +127 -0
- package/dist/metrics.js +41 -0
- package/dist/operations.js +176 -119
- package/dist/relay-external-activity-monitor.js +47 -6
- package/dist/relay-runtime.js +1003 -268
- package/dist/runtime-cache.js +57 -0
- package/dist/session-locks.js +10 -7
- package/dist/state-backend.js +3 -0
- package/dist/support-bundle.js +18 -1
- package/dist/telegram-access-commands.js +15 -2
- package/dist/telegram-access-middleware.js +16 -3
- package/dist/telegram-agent-commands.js +25 -0
- package/dist/telegram-artifact-commands.js +46 -0
- package/dist/telegram-diagnostics-command.js +5 -50
- package/dist/telegram-general-commands.js +2 -6
- package/dist/telegram-operational-commands.js +14 -6
- package/dist/telegram-queue-commands.js +74 -4
- package/dist/telegram-support-command.js +7 -0
- package/dist/telegram-update-commands.js +27 -0
- package/dist/user-management.js +208 -0
- package/dist/web-api-contract.js +9 -0
- package/dist/web-dashboard-access-routes.js +74 -1
- package/dist/web-dashboard-artifact-routes.js +3 -3
- package/dist/web-dashboard-assets.js +2 -0
- package/dist/web-dashboard-pages.js +97 -13
- package/dist/web-dashboard-runtime-routes.js +53 -8
- package/dist/web-dashboard-session-routes.js +27 -20
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +149 -6
- package/dist/web-state.js +33 -2
- package/dist/webui-assets/dashboard.css +75 -1
- package/dist/webui-assets/dashboard.js +358 -47
- package/package.json +3 -1
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -22
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
{ path: "/api/snapshot", methods: ["GET"] },
|
|
8
8
|
{ path: "/api/tasks", methods: ["GET"] },
|
|
9
9
|
{ path: "/api/progress", methods: ["GET"] },
|
|
10
|
+
{ path: "/api/metrics", methods: ["GET"] },
|
|
11
|
+
{ path: "/api/jobs", methods: ["GET"] },
|
|
12
|
+
{ re: /^\/api\/jobs\/[^\/]+\/log$/, methods: ["GET"] },
|
|
13
|
+
{ re: /^\/api\/jobs\/[^\/]+\/action$/, methods: ["POST"] },
|
|
14
|
+
{ path: "/api/active-sessions", methods: ["GET"] },
|
|
10
15
|
{ path: "/api/version", methods: ["GET"] },
|
|
11
16
|
{ path: "/api/update", methods: ["POST"] },
|
|
12
17
|
{ path: "/api/agent-updates", methods: ["GET"] },
|
|
@@ -23,10 +28,14 @@
|
|
|
23
28
|
{ re: /^\/api\/users\/[^\/]+\/sessions\/[^\/]+$/, methods: ["DELETE"] },
|
|
24
29
|
{ re: /^\/api\/users\/[^\/]+\/telegram$/, methods: ["POST"] },
|
|
25
30
|
{ re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
|
|
31
|
+
{ re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
|
|
32
|
+
{ re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
|
|
26
33
|
{ path: "/api/groups", methods: ["GET", "POST"] },
|
|
27
34
|
{ re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
|
|
28
35
|
{ path: "/api/telegram-chats", methods: ["GET", "POST"] },
|
|
29
36
|
{ re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
|
|
37
|
+
{ path: "/api/discord-channels", methods: ["GET", "POST"] },
|
|
38
|
+
{ re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
|
|
30
39
|
{ path: "/api/audit", methods: ["GET"] },
|
|
31
40
|
{ path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
|
|
32
41
|
{ path: "/api/auth/status", methods: ["GET"] },
|
|
@@ -139,7 +148,7 @@
|
|
|
139
148
|
throw new Error("Unsupported WebUI API method: " + method + " " + path);
|
|
140
149
|
}
|
|
141
150
|
}
|
|
142
|
-
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0 };
|
|
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, activeSessionsTimer: null };
|
|
143
152
|
function toast(msg, options = {}) {
|
|
144
153
|
const el = document.getElementById("toast");
|
|
145
154
|
el.textContent = msg;
|
|
@@ -239,14 +248,14 @@
|
|
|
239
248
|
["#restartBtn", "system.restart"],
|
|
240
249
|
["#updateBtn", "updates.run"],
|
|
241
250
|
["#clearLogsBtn", "logs.clear"],
|
|
242
|
-
["#createUserBtn,#createGroupBtn,#createChatBtn", "users.write"],
|
|
251
|
+
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
|
|
243
252
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
244
253
|
["[data-switch]", "sessions.write"],
|
|
245
254
|
["[data-queue],[data-q]", "queue.write"],
|
|
246
255
|
["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
|
|
247
256
|
["[data-auth-login],[data-auth-logout]", "auth.manage"],
|
|
248
257
|
["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
|
|
249
|
-
["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle]", "users.write"]
|
|
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"]
|
|
250
259
|
];
|
|
251
260
|
disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
|
|
252
261
|
el.disabled = !can(permission);
|
|
@@ -288,6 +297,7 @@
|
|
|
288
297
|
}
|
|
289
298
|
async function reloadCurrentPage(options = {}) {
|
|
290
299
|
const name = state.currentPage;
|
|
300
|
+
if (name === "overview") await loadActiveSessions();
|
|
291
301
|
if (name === "chat") {
|
|
292
302
|
await loadChatHistory();
|
|
293
303
|
scrollChatToBottom();
|
|
@@ -299,6 +309,7 @@
|
|
|
299
309
|
if (name === "artifacts") await loadArtifacts();
|
|
300
310
|
if (name === "activity") await loadActivity();
|
|
301
311
|
if (name === "tasks") await loadTasks();
|
|
312
|
+
if (name === "metrics") await loadMetrics();
|
|
302
313
|
if (name === "adapters") await loadAdapterHealth();
|
|
303
314
|
if (name === "access") await loadAccess();
|
|
304
315
|
if (name === "version") await loadVersion();
|
|
@@ -351,6 +362,7 @@
|
|
|
351
362
|
state.enabledAgents = data.enabledAgents || [];
|
|
352
363
|
applyPermissions();
|
|
353
364
|
renderSnapshot(state.snapshot);
|
|
365
|
+
safe(loadActiveSessions);
|
|
354
366
|
renderSessionControls();
|
|
355
367
|
populateNewSessionForm(data.enabledAgents);
|
|
356
368
|
renderAdapters(data.channels, data.agentAdapters);
|
|
@@ -375,7 +387,6 @@
|
|
|
375
387
|
}
|
|
376
388
|
function renderSnapshot(s) {
|
|
377
389
|
document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
|
|
378
|
-
document.getElementById("sessionText").textContent = s.sessionText || "";
|
|
379
390
|
document.getElementById("metrics").innerHTML = [
|
|
380
391
|
["Status", s.processing ? "working" : "idle"],
|
|
381
392
|
["Agent", s.session.agentLabel],
|
|
@@ -387,6 +398,67 @@
|
|
|
387
398
|
].map(([k, v]) => '<div class="metric"><div class="label">' + esc(k) + '</div><div class="value">' + esc(v) + "</div></div>").join("");
|
|
388
399
|
renderQueue(s.queue, s.queuePaused);
|
|
389
400
|
}
|
|
401
|
+
async function loadActiveSessions() {
|
|
402
|
+
const box = document.getElementById("activeSessions");
|
|
403
|
+
if (!box) return;
|
|
404
|
+
if (!can("sessions.read")) {
|
|
405
|
+
box.innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const data = await api("/api/active-sessions");
|
|
409
|
+
renderActiveSessions(data.sessions || []);
|
|
410
|
+
}
|
|
411
|
+
function renderActiveSessions(items) {
|
|
412
|
+
const box = document.getElementById("activeSessions");
|
|
413
|
+
if (!box) return;
|
|
414
|
+
box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
|
|
415
|
+
document.querySelectorAll("[data-active-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.activeCopy || "", "Thread ID copied"));
|
|
416
|
+
document.querySelectorAll("[data-active-switch]").forEach((b) => b.onclick = () => safe(async () => {
|
|
417
|
+
if (!can("sessions.write")) {
|
|
418
|
+
toast("Permission required: sessions.write");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const agentId = b.dataset.activeAgent;
|
|
422
|
+
const threadId = b.dataset.activeSwitch;
|
|
423
|
+
if (agentId && state.snapshot?.session?.agentId !== agentId) {
|
|
424
|
+
await api("/api/agent", { method: "POST", body: { agentId } });
|
|
425
|
+
}
|
|
426
|
+
if (threadId) {
|
|
427
|
+
await api("/api/sessions/switch", { method: "POST", body: { threadId } });
|
|
428
|
+
}
|
|
429
|
+
toast("Session switched");
|
|
430
|
+
await loadBootstrap();
|
|
431
|
+
page("chat");
|
|
432
|
+
}));
|
|
433
|
+
document.querySelectorAll("[data-active-detail]").forEach((b) => b.onclick = () => safe(async () => {
|
|
434
|
+
const agentId = b.dataset.activeAgent;
|
|
435
|
+
const threadId = b.dataset.activeDetail;
|
|
436
|
+
if (agentId && state.snapshot?.session?.agentId !== agentId) {
|
|
437
|
+
await api("/api/agent", { method: "POST", body: { agentId } });
|
|
438
|
+
await loadBootstrap();
|
|
439
|
+
}
|
|
440
|
+
if (threadId) await loadSessionDetail(threadId);
|
|
441
|
+
}));
|
|
442
|
+
applyPermissions();
|
|
443
|
+
}
|
|
444
|
+
function activeSessionCard(s) {
|
|
445
|
+
const thread = s.threadId || "not started";
|
|
446
|
+
const prompt = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
|
|
447
|
+
const tool2 = s.currentTool || s.lastTool || "-";
|
|
448
|
+
const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
|
|
449
|
+
const sourceLabel = activeSourceLabel(s.source);
|
|
450
|
+
const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
|
|
451
|
+
const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
|
|
452
|
+
const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
|
|
453
|
+
return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
|
|
454
|
+
}
|
|
455
|
+
function activeSourceLabel(source) {
|
|
456
|
+
if (source === "cli") return "CLI";
|
|
457
|
+
if (source === "telegram") return "Telegram";
|
|
458
|
+
if (source === "discord") return "Discord";
|
|
459
|
+
if (source === "web") return "WebUI";
|
|
460
|
+
return source || "-";
|
|
461
|
+
}
|
|
390
462
|
function renderSessionControls() {
|
|
391
463
|
const c = state.controls || {};
|
|
392
464
|
const s = state.snapshot?.session || {};
|
|
@@ -428,7 +500,10 @@
|
|
|
428
500
|
});
|
|
429
501
|
}
|
|
430
502
|
function renderAdapters(channels, agents) {
|
|
431
|
-
const channelCards = (channels || []).map((c) =>
|
|
503
|
+
const channelCards = (channels || []).map((c) => {
|
|
504
|
+
const status = c.status === "available" ? c.enabled === false ? "disabled" : "enabled" : c.status || "planned";
|
|
505
|
+
return adapterCard(c.label, status, "", c.capabilities.join(", ") + (c.notes ? " - " + c.notes : ""));
|
|
506
|
+
});
|
|
432
507
|
const agentCards = (agents || []).map((a) => {
|
|
433
508
|
const available = a.status === "available";
|
|
434
509
|
const status = available ? state.enabledAgents.includes(a.id) ? "enabled" : "disabled" : a.status || "planned";
|
|
@@ -477,6 +552,9 @@
|
|
|
477
552
|
if (status === "running") return "planned";
|
|
478
553
|
return "disabled";
|
|
479
554
|
}
|
|
555
|
+
state.activeSessionsTimer = setInterval(() => {
|
|
556
|
+
if (state.currentPage === "overview") safe(loadActiveSessions);
|
|
557
|
+
}, 5e3);
|
|
480
558
|
function scrollChatToBottom() {
|
|
481
559
|
const box = document.getElementById("messages");
|
|
482
560
|
if (!box) return;
|
|
@@ -1037,30 +1115,6 @@
|
|
|
1037
1115
|
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
|
|
1038
1116
|
renderQueue(r.queue, r.paused);
|
|
1039
1117
|
}));
|
|
1040
|
-
async function loadTasks() {
|
|
1041
|
-
setLoading("tasksList", "Loading tasks...");
|
|
1042
|
-
const d = await api("/api/tasks");
|
|
1043
|
-
renderTasks(d);
|
|
1044
|
-
}
|
|
1045
|
-
function taskCard(t, title) {
|
|
1046
|
-
if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
|
|
1047
|
-
const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
|
|
1048
|
-
return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
|
|
1049
|
-
}
|
|
1050
|
-
function renderTasks(d) {
|
|
1051
|
-
document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
|
|
1052
|
-
document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1053
|
-
if (!can("queue.write")) {
|
|
1054
|
-
toast("Permission required: queue.write");
|
|
1055
|
-
return;
|
|
1056
|
-
}
|
|
1057
|
-
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
|
|
1058
|
-
renderQueue(r.queue, r.paused);
|
|
1059
|
-
loadTasks();
|
|
1060
|
-
}));
|
|
1061
|
-
applyPermissions();
|
|
1062
|
-
}
|
|
1063
|
-
document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
|
|
1064
1118
|
async function loadArtifacts() {
|
|
1065
1119
|
setLoading("artifactList", "Loading artifacts...");
|
|
1066
1120
|
document.getElementById("artifactPreview").innerHTML = "";
|
|
@@ -1158,9 +1212,114 @@
|
|
|
1158
1212
|
throw err;
|
|
1159
1213
|
}
|
|
1160
1214
|
}
|
|
1215
|
+
async function loadTasks() {
|
|
1216
|
+
setLoading("tasksList", "Loading tasks...");
|
|
1217
|
+
const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
|
|
1218
|
+
renderTasks(d, jobs);
|
|
1219
|
+
}
|
|
1220
|
+
function taskCard(t, title) {
|
|
1221
|
+
if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
|
|
1222
|
+
const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
|
|
1223
|
+
return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small><small>" + esc("Elapsed " + fmtDuration(t.durationMs) + " / current " + (t.currentTool || "-") + " / last " + (t.lastTool || "-")) + "</small><small>" + esc("Tools: " + tools + " / output chars " + (t.outputChars || 0)) + "</small><small>" + esc(t.prompt || t.detail || "") + "</small></div>";
|
|
1224
|
+
}
|
|
1225
|
+
function renderTasks(d, jobs) {
|
|
1226
|
+
document.getElementById("tasksList").innerHTML = '<div class="task-grid">' + taskCard(d.current, "Current web turn") + taskCard(d.external, "External CLI turn") + '</div><h2 class="task-section-title">Unified jobs</h2><div class="list">' + renderUnifiedJobs(jobs?.jobs || []) + '</div><h2 class="task-section-title">Queue</h2><div class="list">' + ((d.queue || []).map((q) => '<div class="item"><strong>' + esc(q.id + " \xB7 " + q.description) + "</strong><small>" + esc(fmtDate(q.createdAt) + " / attempts " + q.attempts) + '</small><div class="row"><button data-q="run" data-id="' + attr(q.id) + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="cancel" data-id="' + attr(q.id) + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>') + '</div><h2 class="task-section-title">Recent turns</h2><div class="list">' + ((d.recent || []).map((e) => '<div class="item"><strong>' + esc(e.status + " / " + e.source + " / " + e.type) + "</strong><small>" + esc(fmtDate(e.timestamp) + " / " + (e.threadId || "-")) + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
|
|
1227
|
+
document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1228
|
+
if (!can("queue.write")) {
|
|
1229
|
+
toast("Permission required: queue.write");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.q, id: b.dataset.id }) });
|
|
1233
|
+
renderQueue(r.queue, r.paused);
|
|
1234
|
+
loadTasks();
|
|
1235
|
+
}));
|
|
1236
|
+
bindUnifiedJobButtons();
|
|
1237
|
+
applyPermissions();
|
|
1238
|
+
}
|
|
1239
|
+
function renderUnifiedJobs(jobs) {
|
|
1240
|
+
return jobs.map((job) => {
|
|
1241
|
+
const retryPermission = jobActionPermission(job, "retry");
|
|
1242
|
+
const cancelPermission = jobActionPermission(job, "cancel");
|
|
1243
|
+
return '<div class="item"><strong>' + esc(job.title) + ' <span class="adapter-status ' + esc(jobStatusClass(job.status)) + '">' + esc(job.status) + "</span></strong><small>" + esc([job.kind, job.source, job.agentLabel || job.agentId, fmtDate(job.startedAt)].filter(Boolean).join(" / ")) + "</small>" + (job.owner ? "<small>" + esc("Owner: " + (job.owner.label || job.owner.username || job.owner.id || "-")) + "</small>" : "") + (job.threadId ? "<small>" + esc("Thread: " + job.threadId) + "</small>" : "") + (job.summary ? "<small>" + esc(short(job.summary, 300)) + "</small>" : "") + (job.logTail ? '<pre class="update-log">' + esc(short(job.logTail, 1200)) + "</pre>" : "") + '<div class="row">' + (job.canReadLog ? '<button class="secondary" data-job-log="' + attr(job.id) + '">Log</button>' : "") + (job.canRetry ? '<button class="secondary" data-job-action="retry" data-job-permission="' + attr(retryPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(retryPermission) + ">Retry</button>" : "") + (job.canCancel ? '<button class="danger" data-job-action="cancel" data-job-permission="' + attr(cancelPermission) + '" data-job-id="' + attr(job.id) + '"' + disabledAttr(cancelPermission) + ">Cancel</button>" : "") + "</div></div>";
|
|
1244
|
+
}).join("") || '<div class="item">No jobs.</div>';
|
|
1245
|
+
}
|
|
1246
|
+
function jobActionPermission(job, action) {
|
|
1247
|
+
if (job.id === "web:current" && action === "cancel") return "prompt.abort";
|
|
1248
|
+
if (String(job.id || "").startsWith("queue:")) return "queue.write";
|
|
1249
|
+
if (String(job.id || "").startsWith("support-bundle:")) return "diagnostics.read";
|
|
1250
|
+
return "updates.run";
|
|
1251
|
+
}
|
|
1252
|
+
function bindUnifiedJobButtons() {
|
|
1253
|
+
document.querySelectorAll("[data-job-log]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1254
|
+
const r = await api("/api/jobs/" + encodeURIComponent(b.dataset.jobLog) + "/log");
|
|
1255
|
+
toast((r.plain || "No log").slice(0, 3500), { duration: 12e3 });
|
|
1256
|
+
}));
|
|
1257
|
+
document.querySelectorAll("[data-job-action]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1258
|
+
const permission = b.dataset.jobPermission || "updates.run";
|
|
1259
|
+
if (!can(permission)) {
|
|
1260
|
+
toast("Permission required: " + permission);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
const action = b.dataset.jobAction;
|
|
1264
|
+
if (confirm((action === "cancel" ? "Cancel" : "Retry") + " job " + b.dataset.jobId + "?")) {
|
|
1265
|
+
await api("/api/jobs/" + encodeURIComponent(b.dataset.jobId) + "/action", { method: "POST", body: JSON.stringify({ action }) });
|
|
1266
|
+
toast("Job " + action + " requested");
|
|
1267
|
+
loadTasks();
|
|
1268
|
+
}
|
|
1269
|
+
}));
|
|
1270
|
+
}
|
|
1271
|
+
document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
|
|
1272
|
+
async function loadMetrics() {
|
|
1273
|
+
setLoading("metricsPanel", "Loading metrics...");
|
|
1274
|
+
const d = await api("/api/metrics");
|
|
1275
|
+
renderMetrics(d);
|
|
1276
|
+
}
|
|
1277
|
+
function metricStatusRows(d) {
|
|
1278
|
+
return [
|
|
1279
|
+
["Generated", fmtDate(d.generatedAt)],
|
|
1280
|
+
["Queue", String(d.queue?.length ?? 0) + (d.queue?.paused ? " paused" : " running")],
|
|
1281
|
+
["Active turns", String(d.turns?.active ?? 0)],
|
|
1282
|
+
["Completed turns", String(d.turns?.completed ?? 0)],
|
|
1283
|
+
["Failed turns", String(d.turns?.failed ?? 0)],
|
|
1284
|
+
["Aborted turns", String(d.turns?.aborted ?? 0)],
|
|
1285
|
+
["Average turn duration", d.turns?.averageDurationMs === null ? "-" : fmtDuration(d.turns?.averageDurationMs)]
|
|
1286
|
+
];
|
|
1287
|
+
}
|
|
1288
|
+
function metricJobRows(d) {
|
|
1289
|
+
const jobs = d.jobs || {};
|
|
1290
|
+
return [
|
|
1291
|
+
["Total", jobs.total],
|
|
1292
|
+
["Queued", jobs.queued],
|
|
1293
|
+
["Running", jobs.running],
|
|
1294
|
+
["Completed", jobs.completed],
|
|
1295
|
+
["Failed", jobs.failed],
|
|
1296
|
+
["Aborted", jobs.aborted]
|
|
1297
|
+
];
|
|
1298
|
+
}
|
|
1299
|
+
function rateRows(name, rate) {
|
|
1300
|
+
return [
|
|
1301
|
+
["Queued", rate?.queued ?? 0],
|
|
1302
|
+
["Running", rate?.running ?? 0],
|
|
1303
|
+
["Completed", rate?.completed ?? 0],
|
|
1304
|
+
["Failed", rate?.failed ?? 0],
|
|
1305
|
+
["Retries", rate?.retries ?? 0],
|
|
1306
|
+
["Rate-limit hits", rate?.rateLimitHits ?? 0],
|
|
1307
|
+
["Last rate limit", fmtDate(rate?.lastRateLimitAt)],
|
|
1308
|
+
["Retry after", rate?.lastRetryAfterSeconds ? rate.lastRetryAfterSeconds + "s" : "-"],
|
|
1309
|
+
["Buckets", (rate?.buckets || []).length]
|
|
1310
|
+
].map(([k, v]) => [name + " " + k, v]);
|
|
1311
|
+
}
|
|
1312
|
+
function renderMetrics(d) {
|
|
1313
|
+
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>";
|
|
1315
|
+
}
|
|
1316
|
+
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1317
|
+
function activityQuery() {
|
|
1318
|
+
return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", actor: val("activityActor") || void 0, agent: val("activityAgent") || "all", thread: val("activityThread") || void 0, workspace: val("activityWorkspace") || void 0, type: val("activityType") || void 0, since: val("activitySince") || void 0 };
|
|
1319
|
+
}
|
|
1161
1320
|
async function loadActivity() {
|
|
1162
1321
|
setLoading("activityList", "Loading activity...");
|
|
1163
|
-
const data = await api("/api/activity", { query:
|
|
1322
|
+
const data = await api("/api/activity", { query: activityQuery() });
|
|
1164
1323
|
state.activityEvents = data.events || [];
|
|
1165
1324
|
renderActivity(state.activityEvents);
|
|
1166
1325
|
}
|
|
@@ -1168,28 +1327,32 @@
|
|
|
1168
1327
|
const active = state.snapshot?.session;
|
|
1169
1328
|
return e.workspace || (active?.threadId && e.threadId === active.threadId ? active.workspace : "");
|
|
1170
1329
|
}
|
|
1330
|
+
function activityActorText(e) {
|
|
1331
|
+
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");
|
|
1333
|
+
}
|
|
1171
1334
|
function activityMetaHtml(e) {
|
|
1172
1335
|
const workspace = activityWorkspace(e);
|
|
1173
1336
|
const duration = typeof e.durationMs === "number" ? fmtDuration(e.durationMs) : "";
|
|
1337
|
+
const actor = activityActorText(e);
|
|
1174
1338
|
const parts = [];
|
|
1339
|
+
if (actor) parts.push("User: " + esc(actor));
|
|
1175
1340
|
if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
|
|
1176
1341
|
if (workspace) parts.push(esc(workspace));
|
|
1177
1342
|
if (duration) parts.push(esc(duration));
|
|
1178
1343
|
return parts.join(" | ");
|
|
1179
1344
|
}
|
|
1180
1345
|
function renderActivity(events) {
|
|
1181
|
-
|
|
1182
|
-
const filtered = (events || []).filter((e) => !since || new Date(e.timestamp).getTime() >= since);
|
|
1183
|
-
document.getElementById("activityList").innerHTML = filtered.map((e) => {
|
|
1346
|
+
document.getElementById("activityList").innerHTML = (events || []).map((e) => {
|
|
1184
1347
|
const meta = activityMetaHtml(e);
|
|
1185
|
-
return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
|
|
1348
|
+
return '<div class="item"><strong><span class="chip ' + (e.status === "failed" ? "error" : e.status === "queued" ? "warn" : "") + '">' + esc(e.status) + "</span>" + esc([fmtDate(e.timestamp), e.source, e.category, e.type].filter(Boolean).join(" | ")) + "</strong><small>" + esc(short(e.prompt || e.detail || "", 220)) + "</small>" + (meta ? "<small>" + meta + "</small>" : "") + "</div>";
|
|
1186
1349
|
}).join("") || '<div class="item">No activity.</div>';
|
|
1187
1350
|
document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
|
|
1188
1351
|
}
|
|
1189
1352
|
document.getElementById("loadActivityBtn").onclick = () => loadActivity();
|
|
1190
|
-
document.getElementById("activitySince").onchange = () =>
|
|
1353
|
+
document.getElementById("activitySince").onchange = () => loadActivity();
|
|
1191
1354
|
document.getElementById("exportActivityBtn").onclick = () => {
|
|
1192
|
-
const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.status, e.type, e.threadId || "", e.prompt || e.detail || ""].join("\\t")).join("\\n");
|
|
1355
|
+
const rows = (state.activityEvents || []).map((e) => [e.timestamp, e.source, e.category || "", e.status, e.type, activityActorText(e), e.agentId || "", e.threadId || "", activityWorkspace(e), e.prompt || e.detail || ""].join("\\t")).join("\\n");
|
|
1193
1356
|
const blob = new Blob([rows], { type: "text/tab-separated-values" });
|
|
1194
1357
|
const a = document.createElement("a");
|
|
1195
1358
|
a.href = URL.createObjectURL(blob);
|
|
@@ -1203,7 +1366,7 @@
|
|
|
1203
1366
|
state.settings = data.settings;
|
|
1204
1367
|
renderSettings();
|
|
1205
1368
|
}
|
|
1206
|
-
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
|
|
1369
|
+
const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
|
|
1207
1370
|
const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
|
|
1208
1371
|
function orderedSettingsGroups(groups) {
|
|
1209
1372
|
const known = settingsGroupOrder.filter((name) => groups[name]);
|
|
@@ -1213,6 +1376,12 @@
|
|
|
1213
1376
|
function agentSettingsNav(current) {
|
|
1214
1377
|
return '<div class="agent-settings-nav"><strong>Agent settings</strong>' + agentSettingGroups.map((name) => '<button type="button" data-setting-tab="' + attr(name) + '" class="' + (name === current ? "active" : "") + '">' + esc(name) + "</button>").join("") + "</div>";
|
|
1215
1378
|
}
|
|
1379
|
+
function settingHelp(s) {
|
|
1380
|
+
return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
|
|
1381
|
+
}
|
|
1382
|
+
function settingLabel(s) {
|
|
1383
|
+
return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
|
|
1384
|
+
}
|
|
1216
1385
|
function renderSettings() {
|
|
1217
1386
|
const groups = {};
|
|
1218
1387
|
state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
|
|
@@ -1225,7 +1394,7 @@
|
|
|
1225
1394
|
});
|
|
1226
1395
|
const items = groups[state.settingsGroup] || [];
|
|
1227
1396
|
const nav = state.settingsGroup === "Agents" || agentSettingGroups.includes(state.settingsGroup) ? agentSettingsNav(state.settingsGroup) : "";
|
|
1228
|
-
document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '"
|
|
1397
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + nav + items.map((s) => '<div class="setting" data-setting-box="' + attr(s.key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + settingInput(s) + "<small>" + esc(s.key) + " - " + esc(s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + (s.configured ? " Saved in env file." : " Using default.") + '</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="' + attr(s.key) + '">Use default</button>' + (s.kind === "secret" ? '<button type="button" class="secondary" data-reveal-setting="' + attr(s.key) + '">Reveal/replace</button>' : "") + '</div><div class="setting-error"></div></div>').join("") + "</div>";
|
|
1229
1398
|
document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
|
|
1230
1399
|
state.settingsGroup = b.dataset.settingTab;
|
|
1231
1400
|
renderSettings();
|
|
@@ -1319,6 +1488,8 @@
|
|
|
1319
1488
|
const d = await api("/api/users");
|
|
1320
1489
|
state.userManagement = d;
|
|
1321
1490
|
renderUserManagement(d);
|
|
1491
|
+
bindAccessTabs();
|
|
1492
|
+
switchAccessTab(state.accessTab || "users");
|
|
1322
1493
|
if (can("sessions.read")) await loadLocks();
|
|
1323
1494
|
else document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
1324
1495
|
if (can("audit.read")) await loadAudit();
|
|
@@ -1334,7 +1505,7 @@
|
|
|
1334
1505
|
return '<div class="permission-grid full-span">' + (state.userManagement?.permissions || []).map((p) => '<label class="checkbox"><input type="checkbox" data-permission-choice="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</div>";
|
|
1335
1506
|
}
|
|
1336
1507
|
function selectedValues(selector) {
|
|
1337
|
-
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.value).filter(Boolean);
|
|
1508
|
+
return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.value).filter(Boolean);
|
|
1338
1509
|
}
|
|
1339
1510
|
function csv(values = []) {
|
|
1340
1511
|
return (values || []).join(", ");
|
|
@@ -1355,14 +1526,66 @@
|
|
|
1355
1526
|
function userGroups(u) {
|
|
1356
1527
|
return (u.groups || []).map((g) => g.id);
|
|
1357
1528
|
}
|
|
1529
|
+
function accessCopyButton(value, label) {
|
|
1530
|
+
return value ? '<button type="button" class="copy-id" data-access-copy="' + attr(value) + '" data-access-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
|
|
1531
|
+
}
|
|
1532
|
+
function bindAccessCopyButtons() {
|
|
1533
|
+
document.querySelectorAll("[data-access-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.accessCopy || "", b.dataset.accessCopyLabel || "Copied"));
|
|
1534
|
+
}
|
|
1535
|
+
function bindAccessTabs() {
|
|
1536
|
+
document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
|
|
1537
|
+
const search = document.getElementById("discordChannelSearch");
|
|
1538
|
+
if (search && !search.dataset.bound) {
|
|
1539
|
+
search.dataset.bound = "true";
|
|
1540
|
+
search.oninput = () => renderDiscordChannels();
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
function switchAccessTab(tab) {
|
|
1544
|
+
state.accessTab = tab || "users";
|
|
1545
|
+
document.querySelectorAll("[data-access-tab]").forEach((b) => b.classList.toggle("active", b.dataset.accessTab === state.accessTab));
|
|
1546
|
+
document.querySelectorAll("[data-access-tab-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.accessTabPanel === state.accessTab));
|
|
1547
|
+
}
|
|
1548
|
+
function groupNames(ids = []) {
|
|
1549
|
+
const groups = state.userManagement?.groups || [];
|
|
1550
|
+
return (ids || []).map((id) => groups.find((g) => g.id === id)?.name || id).join(", ");
|
|
1551
|
+
}
|
|
1552
|
+
function discordChannelLabel(channel) {
|
|
1553
|
+
return channel.title ? channel.title + " (" + channel.channelId + ")" : channel.channelId;
|
|
1554
|
+
}
|
|
1555
|
+
function discordScopeLabel(ids = []) {
|
|
1556
|
+
const channels = state.userManagement?.discordChannels || [];
|
|
1557
|
+
return (ids || []).map((id) => discordChannelLabel(channels.find((c) => c.channelId === id) || { channelId: id })).join(", ");
|
|
1558
|
+
}
|
|
1559
|
+
function discordChannelOptions(selected = []) {
|
|
1560
|
+
const selectedSet = new Set(selected || []);
|
|
1561
|
+
const channels = state.userManagement?.discordChannels || [];
|
|
1562
|
+
return '<div class="permission-grid full-span">' + (channels.map((c) => '<label class="checkbox"><input type="checkbox" data-discord-channel-choice="' + attr(c.channelId) + '" ' + (selectedSet.has(c.channelId) ? "checked" : "") + "> " + esc(discordChannelLabel(c) + " / " + (c.guildId || "DM")) + "</label>").join("") || "<small>No Discord channels registered.</small>") + "</div>";
|
|
1563
|
+
}
|
|
1564
|
+
function discordIdentityHtml(user, identity) {
|
|
1565
|
+
const id = String(identity.discordUserId || "");
|
|
1566
|
+
return '<span class="access-id-row">' + accessCopyButton(id, "Discord user ID copied") + (identity.username ? " <span>@" + esc(identity.username) + "</span>" : "") + ' <button class="secondary mini-button" data-discord-user="' + attr(user.id) + '" data-discord-unlink="' + attr(identity.id) + '"' + disabledAttr("users.write") + ">Unlink</button></span>";
|
|
1567
|
+
}
|
|
1568
|
+
function renderDiscordChannels(channels = state.userManagement?.discordChannels || []) {
|
|
1569
|
+
const target = document.getElementById("discordChannelsList");
|
|
1570
|
+
if (!target) return;
|
|
1571
|
+
const query = (document.getElementById("discordChannelSearch")?.value || "").toLowerCase();
|
|
1572
|
+
const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.guildId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
|
1573
|
+
target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Discord channel ID copied") + '</small><small class="access-id-row">Guild ID: ' + accessCopyButton(c.guildId || "", "Discord guild ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-discord-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-discord-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Discord channels registered.</div>';
|
|
1574
|
+
bindDiscordChannelButtons();
|
|
1575
|
+
bindAccessCopyButtons();
|
|
1576
|
+
applyPermissions();
|
|
1577
|
+
}
|
|
1358
1578
|
function renderUserManagement(d) {
|
|
1359
1579
|
document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
|
|
1360
1580
|
const telegram = (u.telegramIdentities || []).map((t) => t.telegramUserId + (t.username ? " @" + t.username : "") + ' <button class="secondary mini-button" data-telegram-user="' + attr(u.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button>").join(" ");
|
|
1361
|
-
|
|
1581
|
+
const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
|
|
1582
|
+
return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Discord: " + (discord || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
|
|
1362
1583
|
}).join("") || '<div class="item">No users.</div>';
|
|
1363
|
-
document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
|
|
1364
|
-
document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc((c.allowedGroupIds
|
|
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>';
|
|
1585
|
+
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
|
+
renderDiscordChannels(d.discordChannels || []);
|
|
1365
1587
|
bindUserButtons();
|
|
1588
|
+
bindAccessCopyButtons();
|
|
1366
1589
|
applyPermissions();
|
|
1367
1590
|
}
|
|
1368
1591
|
function bindUserButtons() {
|
|
@@ -1382,6 +1605,11 @@
|
|
|
1382
1605
|
toast("Telegram link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
|
|
1383
1606
|
}));
|
|
1384
1607
|
document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
|
|
1608
|
+
document.querySelectorAll("[data-user-discord-code]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1609
|
+
const r = await api("/api/users/" + encodeURIComponent(b.dataset.userDiscordCode) + "/discord", { method: "POST", body: JSON.stringify({ createCode: true }) });
|
|
1610
|
+
toast("Discord link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
|
|
1611
|
+
}));
|
|
1612
|
+
document.querySelectorAll("[data-user-discord-link]").forEach((b) => b.onclick = () => openDiscordLinkDialog(b.dataset.userDiscordLink));
|
|
1385
1613
|
document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
|
|
1386
1614
|
document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1387
1615
|
if (confirm("Revoke all web sessions for this user?")) {
|
|
@@ -1397,6 +1625,13 @@
|
|
|
1397
1625
|
loadAccess();
|
|
1398
1626
|
}
|
|
1399
1627
|
}));
|
|
1628
|
+
document.querySelectorAll("[data-discord-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1629
|
+
if (confirm("Unlink this Discord identity?")) {
|
|
1630
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.discordUser) + "/discord/" + encodeURIComponent(b.dataset.discordUnlink), { method: "DELETE" });
|
|
1631
|
+
toast("Discord unlinked");
|
|
1632
|
+
loadAccess();
|
|
1633
|
+
}
|
|
1634
|
+
}));
|
|
1400
1635
|
document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
|
|
1401
1636
|
const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
|
|
1402
1637
|
if (g) openGroupDialog(g);
|
|
@@ -1412,6 +1647,30 @@
|
|
|
1412
1647
|
toast("Chat updated");
|
|
1413
1648
|
loadAccess();
|
|
1414
1649
|
}));
|
|
1650
|
+
document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
|
|
1651
|
+
const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
|
|
1652
|
+
if (c) openDiscordChannelDialog(c);
|
|
1653
|
+
});
|
|
1654
|
+
document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1655
|
+
const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
|
|
1656
|
+
if (!c) return;
|
|
1657
|
+
await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
1658
|
+
toast("Discord channel updated");
|
|
1659
|
+
loadAccess();
|
|
1660
|
+
}));
|
|
1661
|
+
}
|
|
1662
|
+
function bindDiscordChannelButtons() {
|
|
1663
|
+
document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
|
|
1664
|
+
const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
|
|
1665
|
+
if (c) openDiscordChannelDialog(c);
|
|
1666
|
+
});
|
|
1667
|
+
document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1668
|
+
const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
|
|
1669
|
+
if (!c) return;
|
|
1670
|
+
await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
1671
|
+
toast("Discord channel updated");
|
|
1672
|
+
loadAccess();
|
|
1673
|
+
}));
|
|
1415
1674
|
}
|
|
1416
1675
|
function openUserDialog(u) {
|
|
1417
1676
|
adminDialog(u ? "Edit user" : "Create user", '<label>Email<input id="dlgEmail" value="' + attr(u?.email || "") + '"></label><label>Display name<input id="dlgName" value="' + attr(u?.displayName || "") + '"></label>' + (!u ? '<label>Password<input id="dlgPassword" type="password" autocomplete="new-password"></label>' : "") + '<label class="checkbox"><input id="dlgActive" type="checkbox" ' + (!u || u.active ? "checked" : "") + '> Active</label><div class="full-span"><strong>Groups</strong><div class="row">' + groupOptions(u ? userGroups(u) : ["user"]) + "</div></div>", async () => {
|
|
@@ -1427,6 +1686,19 @@
|
|
|
1427
1686
|
toast("Password updated");
|
|
1428
1687
|
});
|
|
1429
1688
|
}
|
|
1689
|
+
function assertDiscordId(value, label, required = true) {
|
|
1690
|
+
const text = String(value || "").trim();
|
|
1691
|
+
if (!text && !required) return "";
|
|
1692
|
+
if (!text) throw new Error(label + " is required");
|
|
1693
|
+
if (!/^\d{5,32}$/.test(text)) throw new Error(label + " must be a numeric Discord ID");
|
|
1694
|
+
return text;
|
|
1695
|
+
}
|
|
1696
|
+
function openDiscordLinkDialog(id) {
|
|
1697
|
+
adminDialog("Link Discord ID", '<label>Discord user ID<input id="dlgDiscordId" inputmode="numeric" pattern="[0-9]*"></label><label>Username<input id="dlgDiscordUsername"></label>', async () => {
|
|
1698
|
+
await api("/api/users/" + encodeURIComponent(id) + "/discord", { method: "POST", body: JSON.stringify({ discordUserId: assertDiscordId(val("dlgDiscordId"), "Discord user ID"), username: val("dlgDiscordUsername") || void 0 }) });
|
|
1699
|
+
toast("Discord linked");
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1430
1702
|
function openTelegramLinkDialog(id) {
|
|
1431
1703
|
adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
|
|
1432
1704
|
await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
|
|
@@ -1434,12 +1706,20 @@
|
|
|
1434
1706
|
});
|
|
1435
1707
|
}
|
|
1436
1708
|
function openGroupDialog(g) {
|
|
1437
|
-
adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
|
|
1438
|
-
const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger) };
|
|
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]") };
|
|
1439
1711
|
await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1440
1712
|
toast(g ? "Group updated" : "Group created");
|
|
1441
1713
|
});
|
|
1442
1714
|
}
|
|
1715
|
+
function openDiscordChannelDialog(c) {
|
|
1716
|
+
adminDialog(c ? "Edit Discord channel" : "Add Discord channel", '<label>Guild ID<input id="dlgDiscordGuildId" inputmode="numeric" pattern="[0-9]*" value="' + attr(c?.guildId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Channel ID<input id="dlgDiscordChannelId" inputmode="numeric" pattern="[0-9]*" value="' + attr(c?.channelId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgDiscordChannelTitle" value="' + attr(c?.title || "") + '"></label><label>Type<select id="dlgDiscordChannelType"><option value="guild" ' + ((c?.type || "guild") === "guild" ? "selected" : "") + '>guild</option><option value="thread" ' + (c?.type === "thread" ? "selected" : "") + '>thread</option><option value="dm" ' + (c?.type === "dm" ? "selected" : "") + '>dm</option></select></label><label class="checkbox"><input id="dlgDiscordChannelEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
|
|
1717
|
+
const type = val("dlgDiscordChannelType") || "guild";
|
|
1718
|
+
const payload = { guildId: assertDiscordId(val("dlgDiscordGuildId"), "Guild ID", type === "dm" ? false : true) || void 0, channelId: assertDiscordId(val("dlgDiscordChannelId"), "Channel ID"), title: val("dlgDiscordChannelTitle") || void 0, type, enabled: document.getElementById("dlgDiscordChannelEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
|
|
1719
|
+
await api(c ? "/api/discord-channels/" + encodeURIComponent(c.id) : "/api/discord-channels", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
1720
|
+
toast(c ? "Discord channel updated" : "Discord channel added");
|
|
1721
|
+
});
|
|
1722
|
+
}
|
|
1443
1723
|
function openChatDialog(c) {
|
|
1444
1724
|
adminDialog(c ? "Edit Telegram chat" : "Add Telegram chat", '<label>Chat ID<input id="dlgChatId" type="number" value="' + attr(c?.chatId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgChatTitle" value="' + attr(c?.title || "") + '"></label><label>Type<input id="dlgChatType" value="' + attr(c?.type || "supergroup") + '"></label><label class="checkbox"><input id="dlgChatEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
|
|
1445
1725
|
const payload = { chatId: Number(val("dlgChatId")), title: val("dlgChatTitle") || void 0, type: val("dlgChatType") || void 0, enabled: document.getElementById("dlgChatEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
|
|
@@ -1471,13 +1751,23 @@
|
|
|
1471
1751
|
}
|
|
1472
1752
|
openChatDialog(null);
|
|
1473
1753
|
};
|
|
1754
|
+
document.getElementById("createDiscordChannelBtn").onclick = () => {
|
|
1755
|
+
if (!can("users.write")) {
|
|
1756
|
+
toast("Permission required: users.write");
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
openDiscordChannelDialog(null);
|
|
1760
|
+
};
|
|
1474
1761
|
async function loadLocks() {
|
|
1475
1762
|
if (!can("sessions.read")) {
|
|
1476
1763
|
document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
|
|
1477
1764
|
return;
|
|
1478
1765
|
}
|
|
1479
1766
|
const d = await api("/api/locks");
|
|
1480
|
-
document.getElementById("locksList").innerHTML = (d.locks || []).map((l) =>
|
|
1767
|
+
document.getElementById("locksList").innerHTML = (d.locks || []).map((l) => {
|
|
1768
|
+
const owner = [l.ownerLabel || l.ownerUserId, l.ownerUserId, l.ownerChannel ? "via " + l.ownerChannel : "", l.ownerChannelUserId ? "channel user " + l.ownerChannelUserId : "", l.expiresAt ? "expires " + fmtDate(l.expiresAt) : ""].filter(Boolean).join(" | ");
|
|
1769
|
+
return '<div class="item"><strong>' + esc(l.contextKey) + "</strong><small>" + esc(owner) + "</small></div>";
|
|
1770
|
+
}).join("") || '<div class="item">No active locks.</div>';
|
|
1481
1771
|
}
|
|
1482
1772
|
document.getElementById("lockSessionBtn").onclick = () => safe(async () => {
|
|
1483
1773
|
if (!can("sessions.write")) {
|
|
@@ -1497,15 +1787,36 @@
|
|
|
1497
1787
|
toast("Web session unlocked");
|
|
1498
1788
|
loadLocks();
|
|
1499
1789
|
});
|
|
1790
|
+
function auditQuery() {
|
|
1791
|
+
return { limit: val("auditLimit") || "50", channel: val("auditChannel") || "all", category: val("auditCategory") || "all", status: val("auditStatus") || "all", actor: val("auditActor") || void 0, agent: val("auditAgent") || "all", thread: val("auditThread") || void 0, workspace: val("auditWorkspace") || void 0, since: val("auditSince") || void 0 };
|
|
1792
|
+
}
|
|
1500
1793
|
async function loadAudit() {
|
|
1501
1794
|
if (!can("audit.read")) {
|
|
1502
1795
|
document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
|
|
1503
1796
|
return;
|
|
1504
1797
|
}
|
|
1505
|
-
const d = await api("/api/audit", { query:
|
|
1506
|
-
|
|
1798
|
+
const d = await api("/api/audit", { query: auditQuery() });
|
|
1799
|
+
state.auditEvents = d.events || [];
|
|
1800
|
+
renderAudit(state.auditEvents);
|
|
1801
|
+
}
|
|
1802
|
+
function renderAudit(events) {
|
|
1803
|
+
document.getElementById("auditList").innerHTML = (events || []).map((e) => {
|
|
1804
|
+
const actor = e.actor ? [e.actor.label || e.actor.username || e.actor.id, e.actor.channel, e.actor.channelUserId].filter(Boolean).join(" | ") : e.actorId ? String(e.actorId) : "system";
|
|
1805
|
+
const meta = [e.contextKey, e.agentId, e.threadId, e.workspace].filter(Boolean).join(" | ");
|
|
1806
|
+
return '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action].filter(Boolean).join(" | ")) + "</strong><small>" + esc("Actor: " + actor) + "</small>" + (meta ? "<small>" + esc(meta) + "</small>" : "") + "<small>" + esc(e.description || e.detail || "") + "</small></div>";
|
|
1807
|
+
}).join("") || '<div class="item">No audit events.</div>';
|
|
1507
1808
|
}
|
|
1508
1809
|
document.getElementById("loadAuditBtn").onclick = () => loadAudit();
|
|
1810
|
+
document.getElementById("exportAuditBtn").onclick = () => {
|
|
1811
|
+
const events = state.auditEvents || [];
|
|
1812
|
+
const text = events.map((e) => [fmtDate(e.timestamp), e.channelId, e.status, e.category, e.action, e.actor?.label || e.actor?.username || e.actor?.id || e.actorId || "system", e.contextKey, e.agentId || "", e.threadId || "", e.workspace || "", e.description || e.detail || ""].join("\\t")).join("\\n");
|
|
1813
|
+
const blob = new Blob([text], { type: "text/tab-separated-values" });
|
|
1814
|
+
const a = document.createElement("a");
|
|
1815
|
+
a.href = URL.createObjectURL(blob);
|
|
1816
|
+
a.download = "nordrelay-audit.tsv";
|
|
1817
|
+
a.click();
|
|
1818
|
+
URL.revokeObjectURL(a.href);
|
|
1819
|
+
};
|
|
1509
1820
|
async function loadLogs() {
|
|
1510
1821
|
if (!document.getElementById("logAutoRefresh").checked) setLoading("logs", "Loading logs...");
|
|
1511
1822
|
const target = document.getElementById("logTarget").value;
|