@nordbyte/nordrelay 0.8.2 → 0.8.3
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/README.md +4 -0
- package/dist/access/audit-log.js +30 -13
- package/dist/channels/discord/discord-bot.js +12 -27
- package/dist/channels/shared/channel-bridge-controller.js +1 -1
- package/dist/channels/shared/channel-prompt-queue.js +37 -0
- package/dist/channels/shared/channel-turn-service.js +23 -9
- package/dist/channels/slack/slack-bot.js +12 -15
- package/dist/channels/telegram/bot.js +18 -4
- package/dist/core/pagination.js +22 -0
- package/dist/peers/peer-store.js +16 -0
- package/dist/peers/peer-types.js +19 -0
- package/dist/peers/peer-web-proxy-contract.js +2 -0
- package/dist/runtime/relay-external-activity-monitor.js +15 -0
- package/dist/runtime/relay-queue-service.js +1 -0
- package/dist/runtime/relay-runtime-dashboard.js +3 -0
- package/dist/runtime/relay-runtime-helpers.js +3 -0
- package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +14 -10
- package/dist/runtime/relay-runtime-sessions.js +8 -0
- package/dist/runtime/relay-runtime-trace.js +92 -0
- package/dist/runtime/relay-runtime-updates-jobs.js +11 -5
- package/dist/runtime/relay-runtime.js +16 -6
- package/dist/state/prompt-store.js +13 -1
- package/dist/web/web-api-contract.js +2 -0
- package/dist/web/web-dashboard-access-routes.js +15 -12
- package/dist/web/web-dashboard-artifact-routes.js +6 -2
- package/dist/web/web-dashboard-assets.js +1 -0
- package/dist/web/web-dashboard-pages.js +58 -20
- package/dist/web/web-dashboard-peer-routes.js +19 -0
- package/dist/web/web-dashboard-runtime-routes.js +8 -1
- package/dist/web/web-dashboard-session-routes.js +17 -12
- package/dist/web/web-dashboard-ui.js +46 -10
- package/dist/web/web-performance.js +2 -0
- package/dist/web/web-state.js +33 -4
- package/dist/webui-assets/dashboard.css +227 -39
- package/dist/webui-assets/dashboard.js +728 -58
- package/package.json +4 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +333 -8
- package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
- package/scripts/postinstall.mjs +122 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
{ path: "/api/progress", methods: ["GET"] },
|
|
10
10
|
{ path: "/api/metrics", methods: ["GET"] },
|
|
11
11
|
{ path: "/api/jobs", methods: ["GET"] },
|
|
12
|
+
{ path: "/api/trace", methods: ["GET"] },
|
|
12
13
|
{ re: /^\/api\/jobs\/[^\/]+\/log$/, methods: ["GET"] },
|
|
13
14
|
{ re: /^\/api\/jobs\/[^\/]+\/action$/, methods: ["POST"] },
|
|
14
15
|
{ path: "/api/active-sessions", methods: ["GET"] },
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
{ path: "/api/peers/global-sessions", methods: ["GET"] },
|
|
36
37
|
{ re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
|
|
37
38
|
{ re: /^\/api\/peers\/[^\/]+\/repin$/, methods: ["POST"] },
|
|
39
|
+
{ re: /^\/api\/peers\/[^\/]+\/rotate$/, methods: ["POST"] },
|
|
38
40
|
{ re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
|
|
39
41
|
{ re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
|
|
40
42
|
{ re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
|
|
@@ -166,7 +168,7 @@
|
|
|
166
168
|
const peerId = selectedPeerTarget();
|
|
167
169
|
if (!peerId || peerId === "local") return false;
|
|
168
170
|
if (!path.startsWith("/api/")) return false;
|
|
169
|
-
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || isLocalAdminApi(path));
|
|
171
|
+
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || path === "/api/peers/discover" || path === "/api/peers/discovery-jobs" || path === "/api/peers/global-sessions" || path === "/api/peers/identity/backup" || path === "/api/peers/identity/restore" || path === "/api/settings/wizard/test" || /^\/api\/peers\/discovery-jobs\//.test(path) || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || /^\/api\/peers\/[^/]+\/repin$/.test(path) || /^\/api\/peers\/[^/]+\/rotate$/.test(path) || isLocalAdminApi(path));
|
|
170
172
|
}
|
|
171
173
|
function isLocalAdminApi(path) {
|
|
172
174
|
return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
|
|
@@ -250,6 +252,8 @@
|
|
|
250
252
|
}
|
|
251
253
|
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, csrfToken: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, stickyToastActive: false, stickyToastText: "", cliStatusActive: false, webMirror: null, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, toolsVisible: false, agentUpdateJobs: [], sessionsRequestId: 0, chatHistoryRequestId: 0, chatRenderVersion: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, peerDiscoveryJobs: [], selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
252
254
|
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
|
|
255
|
+
const PAGE_LABELS = { overview: "Overview", chat: "Chat", sessions: "Sessions", queue: "Queue", tasks: "Tasks", metrics: "Metrics", activity: "Activity", trace: "Trace", artifacts: "Artifacts", adapters: "Adapters", peers: "Peers", access: "Users", version: "Version", settings: "Settings", logs: "Logs", diagnostics: "Diagnostics" };
|
|
256
|
+
const NAV_OPEN_STORAGE_KEY = "nordrelayNavOpenSections";
|
|
253
257
|
function toast(msg, options = {}) {
|
|
254
258
|
const el = document.getElementById("toast");
|
|
255
259
|
const text = String(msg ?? "");
|
|
@@ -349,6 +353,7 @@
|
|
|
349
353
|
el.hidden = !allowed;
|
|
350
354
|
el.disabled = !allowed;
|
|
351
355
|
});
|
|
356
|
+
syncNavSections();
|
|
352
357
|
const currentButton = document.querySelector('nav button[data-page="' + cssEscape(state.currentPage) + '"]');
|
|
353
358
|
if (currentButton && currentButton.hidden) {
|
|
354
359
|
const first = [...document.querySelectorAll("nav button[data-page]")].find((b) => !b.hidden);
|
|
@@ -371,7 +376,7 @@
|
|
|
371
376
|
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
|
|
372
377
|
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
|
|
373
378
|
["#checkPeerReachabilityBtn,#discoverPeersBtn,#cancelPeerDiscoveryBtn,[data-peer-probe]", "peers.connect"],
|
|
374
|
-
["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin]", "peers.write"],
|
|
379
|
+
["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin],[data-peer-rotate]", "peers.write"],
|
|
375
380
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
376
381
|
["[data-switch]", "sessions.write"],
|
|
377
382
|
["[data-queue],[data-q]", "queue.write"],
|
|
@@ -385,6 +390,59 @@
|
|
|
385
390
|
if (!can(permission)) el.title = "Permission required: " + permission;
|
|
386
391
|
}));
|
|
387
392
|
}
|
|
393
|
+
function readOpenNavSections() {
|
|
394
|
+
try {
|
|
395
|
+
const raw = localStorage.getItem(NAV_OPEN_STORAGE_KEY);
|
|
396
|
+
if (!raw) return null;
|
|
397
|
+
const parsed = JSON.parse(raw);
|
|
398
|
+
return Array.isArray(parsed) ? new Set(parsed.filter(Boolean)) : null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function writeOpenNavSections() {
|
|
404
|
+
const open = [...document.querySelectorAll("[data-nav-section]")].filter((section) => section.dataset.navOpen === "true").map((section) => section.dataset.navSection).filter(Boolean);
|
|
405
|
+
localStorage.setItem(NAV_OPEN_STORAGE_KEY, JSON.stringify(open));
|
|
406
|
+
}
|
|
407
|
+
function setNavSectionOpen(sectionId, open, options = {}) {
|
|
408
|
+
const section = document.querySelector('[data-nav-section="' + cssEscape(sectionId) + '"]');
|
|
409
|
+
if (!section) return;
|
|
410
|
+
const items = section.querySelector(".nav-section-items");
|
|
411
|
+
const toggle = section.querySelector("[data-nav-toggle]");
|
|
412
|
+
section.dataset.navOpen = open ? "true" : "false";
|
|
413
|
+
if (items) items.hidden = !open;
|
|
414
|
+
if (toggle) toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
|
415
|
+
if (options.persist !== false) writeOpenNavSections();
|
|
416
|
+
}
|
|
417
|
+
function sectionForPage(name) {
|
|
418
|
+
const button = document.querySelector('nav button[data-page="' + cssEscape(name) + '"]');
|
|
419
|
+
return button?.closest("[data-nav-section]")?.dataset.navSection || "";
|
|
420
|
+
}
|
|
421
|
+
function openSectionForPage(name, options = {}) {
|
|
422
|
+
const sectionId = sectionForPage(name);
|
|
423
|
+
if (sectionId) setNavSectionOpen(sectionId, true, options);
|
|
424
|
+
}
|
|
425
|
+
function syncNavSections() {
|
|
426
|
+
document.querySelectorAll("[data-nav-section]").forEach((section) => {
|
|
427
|
+
const visiblePages = [...section.querySelectorAll("button[data-page]")].filter((button) => !button.hidden);
|
|
428
|
+
const hasVisiblePages = visiblePages.length > 0;
|
|
429
|
+
section.hidden = !hasVisiblePages;
|
|
430
|
+
const active = visiblePages.some((button) => button.dataset.page === state.currentPage);
|
|
431
|
+
section.classList.toggle("active", active);
|
|
432
|
+
section.querySelector("[data-nav-toggle]")?.classList.toggle("active", active);
|
|
433
|
+
if (active && section.dataset.navOpen !== "true") setNavSectionOpen(section.dataset.navSection, true, { persist: false });
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
function initNavSections() {
|
|
437
|
+
const saved = readOpenNavSections();
|
|
438
|
+
document.querySelectorAll("[data-nav-section]").forEach((section) => {
|
|
439
|
+
const sectionId = section.dataset.navSection;
|
|
440
|
+
const open = saved ? saved.has(sectionId) : section.dataset.navDefaultOpen === "true";
|
|
441
|
+
setNavSectionOpen(sectionId, open, { persist: false });
|
|
442
|
+
});
|
|
443
|
+
openSectionForPage(state.currentPage, { persist: false });
|
|
444
|
+
syncNavSections();
|
|
445
|
+
}
|
|
388
446
|
function modelLabel(m) {
|
|
389
447
|
const meta = [m.contextWindow ? compactNum(m.contextWindow) : "", m.supportsImages === true ? "img" : m.supportsImages === false ? "text" : "", m.supportsThinking === true ? "think" : ""].filter(Boolean).join(" ");
|
|
390
448
|
return (m.displayName || m.slug) + (meta ? " \xB7 " + meta : "");
|
|
@@ -427,9 +485,11 @@
|
|
|
427
485
|
}
|
|
428
486
|
function page(name) {
|
|
429
487
|
state.currentPage = name;
|
|
430
|
-
|
|
488
|
+
openSectionForPage(name);
|
|
489
|
+
document.querySelectorAll("nav button[data-page]").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
|
|
490
|
+
syncNavSections();
|
|
431
491
|
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.id === "page-" + name));
|
|
432
|
-
document.getElementById("pageTitle").textContent = name[0].toUpperCase() + name.slice(1);
|
|
492
|
+
document.getElementById("pageTitle").textContent = PAGE_LABELS[name] || name[0].toUpperCase() + name.slice(1);
|
|
433
493
|
document.getElementById("sidebar").classList.remove("open");
|
|
434
494
|
void reloadCurrentPage().catch((err) => toast(err.message || String(err)));
|
|
435
495
|
}
|
|
@@ -446,6 +506,7 @@
|
|
|
446
506
|
if (name === "diagnostics") await loadDiagnostics();
|
|
447
507
|
if (name === "artifacts") await loadArtifacts();
|
|
448
508
|
if (name === "activity") await loadActivity();
|
|
509
|
+
if (name === "trace") renderTracePlaceholder();
|
|
449
510
|
if (name === "tasks") await loadTasks();
|
|
450
511
|
if (name === "metrics") await loadMetrics();
|
|
451
512
|
if (name === "adapters") await loadAdapterHealth();
|
|
@@ -453,9 +514,15 @@
|
|
|
453
514
|
if (name === "access") await loadAccess();
|
|
454
515
|
if (name === "version") await loadVersion();
|
|
455
516
|
}
|
|
456
|
-
document.querySelectorAll("nav button").forEach((b) => b.onclick = () => page(b.dataset.page));
|
|
517
|
+
document.querySelectorAll("nav button[data-page]").forEach((b) => b.onclick = () => page(b.dataset.page));
|
|
518
|
+
document.querySelectorAll("[data-nav-toggle]").forEach((b) => b.onclick = () => {
|
|
519
|
+
const sectionId = b.dataset.navToggle;
|
|
520
|
+
const section = document.querySelector('[data-nav-section="' + cssEscape(sectionId) + '"]');
|
|
521
|
+
setNavSectionOpen(sectionId, section?.dataset.navOpen !== "true");
|
|
522
|
+
syncNavSections();
|
|
523
|
+
});
|
|
524
|
+
initNavSections();
|
|
457
525
|
document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
|
|
458
|
-
document.getElementById("refreshBtn").onclick = () => loadBootstrap();
|
|
459
526
|
document.getElementById("themeBtn").onclick = toggleTheme;
|
|
460
527
|
document.getElementById("toggleToolsBtn").onclick = toggleTools;
|
|
461
528
|
document.getElementById("logoutBtn").onclick = () => safe(async () => {
|
|
@@ -517,6 +584,49 @@
|
|
|
517
584
|
};
|
|
518
585
|
}
|
|
519
586
|
const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
|
|
587
|
+
function createCursorPager(containerId, onChange) {
|
|
588
|
+
const container = document.getElementById(containerId);
|
|
589
|
+
return {
|
|
590
|
+
stack: [],
|
|
591
|
+
cursor: null,
|
|
592
|
+
nextCursor: null,
|
|
593
|
+
hasNext: false,
|
|
594
|
+
total: 0,
|
|
595
|
+
reset() {
|
|
596
|
+
this.stack = [];
|
|
597
|
+
this.cursor = null;
|
|
598
|
+
this.nextCursor = null;
|
|
599
|
+
this.hasNext = false;
|
|
600
|
+
this.total = 0;
|
|
601
|
+
},
|
|
602
|
+
render(meta = {}) {
|
|
603
|
+
if (!container) return;
|
|
604
|
+
this.nextCursor = meta.nextCursor || null;
|
|
605
|
+
this.hasNext = Boolean(meta.hasNext);
|
|
606
|
+
this.total = Number(meta.total || 0);
|
|
607
|
+
container.innerHTML = "<span>" + esc(this.total ? this.total + " total" : "") + '</span><div class="pager-actions"><button data-cursor-action="prev" ' + (!this.stack.length ? "disabled" : "") + '>Previous</button><button data-cursor-action="next" ' + (!this.hasNext ? "disabled" : "") + ">Next</button></div>";
|
|
608
|
+
const prev = container.querySelector('[data-cursor-action="prev"]');
|
|
609
|
+
const next = container.querySelector('[data-cursor-action="next"]');
|
|
610
|
+
prev.onclick = () => {
|
|
611
|
+
if (this.stack.length) {
|
|
612
|
+
this.cursor = this.stack.pop() || null;
|
|
613
|
+
onChange();
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
next.onclick = () => {
|
|
617
|
+
if (this.hasNext && this.nextCursor) {
|
|
618
|
+
this.stack.push(this.cursor);
|
|
619
|
+
this.cursor = this.nextCursor;
|
|
620
|
+
onChange();
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const activityPager = createCursorPager("activityPager", () => loadActivity(false));
|
|
627
|
+
const auditPager = createCursorPager("auditPager", () => loadAudit(false));
|
|
628
|
+
const artifactPager = createCursorPager("artifactPager", () => loadArtifacts(false));
|
|
629
|
+
const jobsPager = createCursorPager("jobsPager", () => loadTasks(false));
|
|
520
630
|
async function loadBootstrap() {
|
|
521
631
|
const local = await api("/api/bootstrap", { local: true });
|
|
522
632
|
state.auth = local.auth || null;
|
|
@@ -1542,7 +1652,8 @@
|
|
|
1542
1652
|
};
|
|
1543
1653
|
function renderQueue(queue, paused) {
|
|
1544
1654
|
document.getElementById("queueStatus").textContent = paused ? "Paused" : "Running";
|
|
1545
|
-
document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
|
|
1655
|
+
document.getElementById("queueList").innerHTML = (queue || []).map((q, i) => '<div class="item queue-item" draggable="true" data-queue-id="' + attr(q.id) + '"><strong>' + esc(i + 1 + ". " + q.id + " - " + q.description) + "</strong><small>Created " + fmtDate(q.createdAt) + " / attempts " + q.attempts + (q.correlationId ? ' / CID: <button type="button" class="copy-id" data-copy-id="' + attr(q.correlationId) + '">' + esc(q.correlationId) + "</button>" : "") + (q.lastError ? " / " + esc(q.lastError) : "") + '</small><div class="row"><button data-q="run" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Run</button><button data-q="top" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Top</button><button data-q="up" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Up</button><button data-q="down" data-id="' + q.id + '"' + disabledAttr("queue.write") + '>Down</button><button data-q="cancel" data-id="' + q.id + '" class="danger"' + disabledAttr("queue.write") + ">Cancel</button></div></div>").join("") || '<div class="item">Queue is empty.</div>';
|
|
1656
|
+
document.querySelectorAll("#queueList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Correlation ID copied"));
|
|
1546
1657
|
document.querySelectorAll("[data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1547
1658
|
if (!can("queue.write")) {
|
|
1548
1659
|
toast("Permission required: queue.write");
|
|
@@ -1587,12 +1698,14 @@
|
|
|
1587
1698
|
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
|
|
1588
1699
|
renderQueue(r.queue, r.paused);
|
|
1589
1700
|
}));
|
|
1590
|
-
async function loadArtifacts() {
|
|
1701
|
+
async function loadArtifacts(reset = true) {
|
|
1702
|
+
if (reset) artifactPager.reset();
|
|
1591
1703
|
setLoading("artifactList", "Loading artifacts...");
|
|
1592
1704
|
document.getElementById("artifactPreview").innerHTML = "";
|
|
1593
|
-
const data = await api("/api/artifacts");
|
|
1705
|
+
const data = await api("/api/artifacts", { query: { limit: 50, cursor: artifactPager.cursor || void 0 } });
|
|
1594
1706
|
state.artifactReports = data.reports || [];
|
|
1595
1707
|
renderArtifacts();
|
|
1708
|
+
artifactPager.render(data.pagination || {});
|
|
1596
1709
|
}
|
|
1597
1710
|
function artifactMatches(a, kind, query) {
|
|
1598
1711
|
const name = (a.name || a.relativePath || "").toLowerCase();
|
|
@@ -1601,7 +1714,7 @@
|
|
|
1601
1714
|
if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
|
|
1602
1715
|
return true;
|
|
1603
1716
|
}
|
|
1604
|
-
document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
|
|
1717
|
+
document.getElementById("reloadArtifactsBtn").onclick = () => loadArtifacts(true);
|
|
1605
1718
|
document.getElementById("artifactSearch").oninput = renderArtifacts;
|
|
1606
1719
|
document.getElementById("artifactKind").onchange = renderArtifacts;
|
|
1607
1720
|
document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
|
|
@@ -1717,18 +1830,22 @@
|
|
|
1717
1830
|
throw err;
|
|
1718
1831
|
}
|
|
1719
1832
|
}
|
|
1720
|
-
async function loadTasks() {
|
|
1833
|
+
async function loadTasks(reset = true) {
|
|
1834
|
+
if (reset) jobsPager.reset();
|
|
1721
1835
|
setLoading("tasksList", "Loading tasks...");
|
|
1722
|
-
const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
|
|
1836
|
+
const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs", { query: { limit: 100, cursor: jobsPager.cursor || void 0 } })]);
|
|
1723
1837
|
renderTasks(d, jobs);
|
|
1838
|
+
jobsPager.render(jobs?.pagination || {});
|
|
1724
1839
|
}
|
|
1725
1840
|
function taskCard(t, title) {
|
|
1726
1841
|
if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
|
|
1727
1842
|
const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
|
|
1728
|
-
return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small
|
|
1843
|
+
return '<div class="item"><strong>' + esc(title + " \xB7 " + t.status) + "</strong><small>" + esc((t.agentLabel || t.agentId || t.source) + " / " + (t.threadId || "-")) + "</small>" + (t.correlationId ? '<small>CID: <button type="button" class="copy-id" data-copy-id="' + attr(t.correlationId) + '">' + esc(t.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(t.correlationId) + '">Trace</button></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>";
|
|
1729
1844
|
}
|
|
1730
1845
|
function renderTasks(d, jobs) {
|
|
1731
|
-
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>";
|
|
1846
|
+
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) + (q.correlationId ? " / CID: " : "") + (q.correlationId ? '<button type="button" class="copy-id" data-copy-id="' + attr(q.correlationId) + '">' + esc(q.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(q.correlationId) + '">Trace</button>' : "") + '</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 || "-")) + (e.correlationId ? " / CID: " : "") + (e.correlationId ? '<button type="button" class="copy-id" data-copy-id="' + attr(e.correlationId) + '">' + esc(e.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(e.correlationId) + '">Trace</button>' : "") + "</small><small>" + esc(short(e.prompt || e.detail || "", 300)) + "</small></div>").join("") || '<div class="item">No recent tasks.</div>') + "</div>";
|
|
1847
|
+
document.querySelectorAll("#tasksList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Correlation ID copied"));
|
|
1848
|
+
document.querySelectorAll("#tasksList [data-trace-id]").forEach((b) => b.onclick = () => openTrace(b.dataset.traceId || ""));
|
|
1732
1849
|
document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1733
1850
|
if (!can("queue.write")) {
|
|
1734
1851
|
toast("Permission required: queue.write");
|
|
@@ -1745,7 +1862,7 @@
|
|
|
1745
1862
|
return jobs.map((job) => {
|
|
1746
1863
|
const retryPermission = jobActionPermission(job, "retry");
|
|
1747
1864
|
const cancelPermission = jobActionPermission(job, "cancel");
|
|
1748
|
-
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>";
|
|
1865
|
+
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.correlationId ? '<small>CID: <button type="button" class="copy-id" data-copy-id="' + attr(job.correlationId) + '">' + esc(job.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(job.correlationId) + '">Trace</button></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>";
|
|
1749
1866
|
}).join("") || '<div class="item">No jobs.</div>';
|
|
1750
1867
|
}
|
|
1751
1868
|
function jobActionPermission(job, action) {
|
|
@@ -1773,7 +1890,7 @@
|
|
|
1773
1890
|
}
|
|
1774
1891
|
}));
|
|
1775
1892
|
}
|
|
1776
|
-
document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
|
|
1893
|
+
document.getElementById("reloadTasksBtn").onclick = () => loadTasks(true);
|
|
1777
1894
|
async function loadMetrics() {
|
|
1778
1895
|
setLoading("metricsPanel", "Loading metrics...");
|
|
1779
1896
|
const d = await api("/api/metrics");
|
|
@@ -1850,13 +1967,15 @@
|
|
|
1850
1967
|
}
|
|
1851
1968
|
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1852
1969
|
function activityQuery() {
|
|
1853
|
-
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 };
|
|
1970
|
+
return { source: val("activitySource"), category: val("activityCategory"), status: val("activityStatus"), limit: val("activityLimit") || "100", cursor: activityPager.cursor || void 0, 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 };
|
|
1854
1971
|
}
|
|
1855
|
-
async function loadActivity() {
|
|
1972
|
+
async function loadActivity(reset = true) {
|
|
1973
|
+
if (reset) activityPager.reset();
|
|
1856
1974
|
setLoading("activityList", "Loading activity...");
|
|
1857
1975
|
const data = await api("/api/activity", { query: activityQuery() });
|
|
1858
1976
|
state.activityEvents = data.events || [];
|
|
1859
1977
|
renderActivity(state.activityEvents);
|
|
1978
|
+
activityPager.render(data.pagination || {});
|
|
1860
1979
|
}
|
|
1861
1980
|
function activityWorkspace(e) {
|
|
1862
1981
|
const active = state.snapshot?.session;
|
|
@@ -1872,6 +1991,7 @@
|
|
|
1872
1991
|
const actor = activityActorText(e);
|
|
1873
1992
|
const parts = [];
|
|
1874
1993
|
if (actor) parts.push("User: " + esc(actor));
|
|
1994
|
+
if (e.correlationId) parts.push('CID: <button type="button" class="copy-id" data-copy-id="' + attr(e.correlationId) + '">' + esc(e.correlationId) + '</button> <button type="button" class="secondary mini-button" data-trace-id="' + attr(e.correlationId) + '">Trace</button>');
|
|
1875
1995
|
if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
|
|
1876
1996
|
if (workspace) parts.push(esc(workspace));
|
|
1877
1997
|
if (duration) parts.push(esc(duration));
|
|
@@ -1883,9 +2003,10 @@
|
|
|
1883
2003
|
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>";
|
|
1884
2004
|
}).join("") || '<div class="item">No activity.</div>';
|
|
1885
2005
|
document.querySelectorAll("#activityList [data-copy-id]").forEach((b) => b.onclick = () => copyText(b.dataset.copyId || "", "Thread ID copied"));
|
|
2006
|
+
document.querySelectorAll("#activityList [data-trace-id]").forEach((b) => b.onclick = () => openTrace(b.dataset.traceId || ""));
|
|
1886
2007
|
}
|
|
1887
|
-
document.getElementById("loadActivityBtn").onclick = () => loadActivity();
|
|
1888
|
-
document.getElementById("activitySince").onchange = () => loadActivity();
|
|
2008
|
+
document.getElementById("loadActivityBtn").onclick = () => loadActivity(true);
|
|
2009
|
+
document.getElementById("activitySince").onchange = () => loadActivity(true);
|
|
1889
2010
|
document.getElementById("exportActivityBtn").onclick = () => {
|
|
1890
2011
|
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");
|
|
1891
2012
|
const blob = new Blob([rows], { type: "text/tab-separated-values" });
|
|
@@ -1895,23 +2016,63 @@
|
|
|
1895
2016
|
a.click();
|
|
1896
2017
|
URL.revokeObjectURL(a.href);
|
|
1897
2018
|
};
|
|
2019
|
+
function renderTracePlaceholder() {
|
|
2020
|
+
const target = document.getElementById("traceDetail");
|
|
2021
|
+
if (target && !target.innerHTML) target.innerHTML = '<div class="item">Enter a correlation ID or open Trace from Activity/Tasks.</div>';
|
|
2022
|
+
}
|
|
2023
|
+
async function openTrace(correlationId) {
|
|
2024
|
+
document.getElementById("traceCorrelationId").value = correlationId;
|
|
2025
|
+
page("trace");
|
|
2026
|
+
await loadTrace(correlationId);
|
|
2027
|
+
}
|
|
2028
|
+
async function loadTrace(correlationId = val("traceCorrelationId")) {
|
|
2029
|
+
if (!correlationId) {
|
|
2030
|
+
renderTracePlaceholder();
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
setLoading("traceDetail", "Loading trace...");
|
|
2034
|
+
const data = await api("/api/trace", { query: { correlationId } });
|
|
2035
|
+
renderTrace(data);
|
|
2036
|
+
}
|
|
2037
|
+
function renderTrace(data) {
|
|
2038
|
+
const s = data.summary || {};
|
|
2039
|
+
const rows = [["Correlation ID", data.correlationId], ["Status", s.status], ["Started", fmtDate(s.startedAt)], ["Updated", fmtDate(s.updatedAt)], ["Agent", s.agentId], ["Thread", s.threadId], ["Workspace", s.workspace], ["Sources", (s.sources || []).join(", ")]];
|
|
2040
|
+
const timeline = (data.timeline || []).map((item) => '<div class="item"><strong>' + esc(fmtDate(item.at) + " | " + item.source + " | " + (item.status || item.type)) + "</strong><small>" + esc([item.title, item.agentId, item.threadId, item.workspace].filter(Boolean).join(" | ")) + "</small>" + (item.detail ? "<small>" + esc(short(item.detail, 500)) + "</small>" : "") + "</div>").join("") || '<div class="item">No events for this correlation ID.</div>';
|
|
2041
|
+
document.getElementById("traceDetail").innerHTML = card("Trace summary", rows) + '<h2 class="task-section-title">Timeline</h2>' + timeline;
|
|
2042
|
+
}
|
|
2043
|
+
document.getElementById("loadTraceBtn").onclick = () => loadTrace();
|
|
2044
|
+
document.getElementById("traceCorrelationId").addEventListener("keydown", (e) => {
|
|
2045
|
+
if (e.key === "Enter") loadTrace();
|
|
2046
|
+
});
|
|
1898
2047
|
async function loadSettings() {
|
|
1899
2048
|
state.settingsWizard = null;
|
|
1900
|
-
document.getElementById("
|
|
2049
|
+
document.getElementById("settingsTabHeader").style.display = "";
|
|
2050
|
+
document.getElementById("settingsSubnav").style.display = "";
|
|
2051
|
+
document.getElementById("settingsActions").style.display = "";
|
|
1901
2052
|
setLoading("settingsForm", "Loading settings...");
|
|
1902
2053
|
const data = await api("/api/settings");
|
|
1903
2054
|
state.settings = data.settings;
|
|
1904
2055
|
renderSettings();
|
|
1905
2056
|
}
|
|
1906
|
-
const
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
function
|
|
1914
|
-
|
|
2057
|
+
const settingsCategoryDefinitions = [
|
|
2058
|
+
{ id: "agents", label: "Agents", groups: ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"] },
|
|
2059
|
+
{ id: "chat", label: "Chat", groups: ["Telegram", "Discord", "Slack"] },
|
|
2060
|
+
{ id: "operations", label: "Operations", groups: ["Operations", "Artifacts", "Peers", "Voice"] },
|
|
2061
|
+
{ id: "workspace", label: "Workspace", groups: ["Workspace"] },
|
|
2062
|
+
{ id: "dashboard", label: "Dashboard", groups: ["Dashboard"] }
|
|
2063
|
+
];
|
|
2064
|
+
function settingsCategories(groups) {
|
|
2065
|
+
const used = /* @__PURE__ */ new Set();
|
|
2066
|
+
const categories = settingsCategoryDefinitions.map((def) => {
|
|
2067
|
+
const available = def.groups.filter((name) => groups[name]);
|
|
2068
|
+
available.forEach((name) => used.add(name));
|
|
2069
|
+
return available.length ? { id: def.id, label: def.label, groups: available, count: available.reduce((sum, name) => sum + groups[name].length, 0) } : null;
|
|
2070
|
+
}).filter(Boolean);
|
|
2071
|
+
Object.keys(groups).filter((name) => !used.has(name)).sort().forEach((name) => categories.push({ id: "extra:" + name, label: name, groups: [name], count: groups[name].length }));
|
|
2072
|
+
return categories;
|
|
2073
|
+
}
|
|
2074
|
+
function settingsCategoryForGroup(categories, group) {
|
|
2075
|
+
return categories.find((category) => category.groups.includes(group)) || categories[0];
|
|
1915
2076
|
}
|
|
1916
2077
|
function settingHelp(s) {
|
|
1917
2078
|
return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
|
|
@@ -1919,23 +2080,44 @@
|
|
|
1919
2080
|
function settingLabel(s) {
|
|
1920
2081
|
return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
|
|
1921
2082
|
}
|
|
2083
|
+
function settingCategoryButton(category, activeCategory) {
|
|
2084
|
+
const active = category.id === activeCategory?.id;
|
|
2085
|
+
return '<button type="button" role="tab" aria-selected="' + (active ? "true" : "false") + '" tabindex="' + (active ? "0" : "-1") + '" data-setting-category="' + attr(category.id) + '" class="' + (active ? "active" : "") + '">' + esc(category.label + " (" + category.count + ")") + "</button>";
|
|
2086
|
+
}
|
|
2087
|
+
function renderSettingsSubnav(category, groups) {
|
|
2088
|
+
const target = document.getElementById("settingsSubnav");
|
|
2089
|
+
if (!target) return;
|
|
2090
|
+
if (!category || category.groups.length < 2) {
|
|
2091
|
+
target.hidden = true;
|
|
2092
|
+
target.innerHTML = "";
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
2095
|
+
target.hidden = false;
|
|
2096
|
+
target.innerHTML = "<label><span>" + esc(category.label) + ' section</span><select id="settingsSubgroupSelect">' + category.groups.map((name) => '<option value="' + attr(name) + '" ' + (name === state.settingsGroup ? "selected" : "") + ">" + esc(name + " (" + groups[name].length + ")") + "</option>").join("") + "</select></label>";
|
|
2097
|
+
document.getElementById("settingsSubgroupSelect").onchange = (e) => {
|
|
2098
|
+
state.settingsGroup = e.target.value;
|
|
2099
|
+
renderSettings();
|
|
2100
|
+
};
|
|
2101
|
+
}
|
|
2102
|
+
function bindSettingsTabs(categories) {
|
|
2103
|
+
document.querySelectorAll("#settingsTabs [data-setting-category]").forEach((b) => b.onclick = () => {
|
|
2104
|
+
const category = categories.find((item) => item.id === b.dataset.settingCategory);
|
|
2105
|
+
if (!category) return;
|
|
2106
|
+
if (!category.groups.includes(state.settingsGroup)) state.settingsGroup = category.groups[0];
|
|
2107
|
+
renderSettings();
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
1922
2110
|
function renderSettings() {
|
|
1923
2111
|
const groups = {};
|
|
1924
2112
|
state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
|
|
1925
|
-
const
|
|
1926
|
-
if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" :
|
|
1927
|
-
|
|
1928
|
-
document.
|
|
1929
|
-
|
|
1930
|
-
renderSettings();
|
|
1931
|
-
});
|
|
2113
|
+
const categories = settingsCategories(groups);
|
|
2114
|
+
if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" : categories[0]?.groups[0];
|
|
2115
|
+
const activeCategory = settingsCategoryForGroup(categories, state.settingsGroup);
|
|
2116
|
+
document.getElementById("settingsTabs").innerHTML = categories.map((category) => settingCategoryButton(category, activeCategory)).join("");
|
|
2117
|
+
renderSettingsSubnav(activeCategory, groups);
|
|
1932
2118
|
const items = groups[state.settingsGroup] || [];
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
|
|
1936
|
-
state.settingsGroup = b.dataset.settingTab;
|
|
1937
|
-
renderSettings();
|
|
1938
|
-
});
|
|
2119
|
+
document.getElementById("settingsForm").innerHTML = '<div class="settings-section"><h2>' + esc(state.settingsGroup || "Settings") + '</h2><div id="settingsRestartBanner"></div>' + 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>";
|
|
2120
|
+
bindSettingsTabs(categories);
|
|
1939
2121
|
bindSettingsUx();
|
|
1940
2122
|
}
|
|
1941
2123
|
function settingAttrs(s, original) {
|
|
@@ -2415,9 +2597,10 @@
|
|
|
2415
2597
|
loadLocks();
|
|
2416
2598
|
});
|
|
2417
2599
|
function auditQuery() {
|
|
2418
|
-
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 };
|
|
2600
|
+
return { limit: val("auditLimit") || "50", cursor: auditPager.cursor || void 0, 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 };
|
|
2419
2601
|
}
|
|
2420
|
-
async function loadAudit() {
|
|
2602
|
+
async function loadAudit(reset = true) {
|
|
2603
|
+
if (reset) auditPager.reset();
|
|
2421
2604
|
if (!can("audit.read")) {
|
|
2422
2605
|
document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
|
|
2423
2606
|
return;
|
|
@@ -2425,6 +2608,7 @@
|
|
|
2425
2608
|
const d = await api("/api/audit", { query: auditQuery() });
|
|
2426
2609
|
state.auditEvents = d.events || [];
|
|
2427
2610
|
renderAudit(state.auditEvents);
|
|
2611
|
+
auditPager.render(d.pagination || {});
|
|
2428
2612
|
}
|
|
2429
2613
|
function renderAudit(events) {
|
|
2430
2614
|
document.getElementById("auditList").innerHTML = (events || []).map((e) => {
|
|
@@ -2433,7 +2617,7 @@
|
|
|
2433
2617
|
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>";
|
|
2434
2618
|
}).join("") || '<div class="item">No audit events.</div>';
|
|
2435
2619
|
}
|
|
2436
|
-
document.getElementById("loadAuditBtn").onclick = () => loadAudit();
|
|
2620
|
+
document.getElementById("loadAuditBtn").onclick = () => loadAudit(true);
|
|
2437
2621
|
document.getElementById("exportAuditBtn").onclick = () => {
|
|
2438
2622
|
const events = state.auditEvents || [];
|
|
2439
2623
|
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");
|
|
@@ -2454,22 +2638,32 @@
|
|
|
2454
2638
|
if (document.getElementById("logFollow").checked) document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
|
|
2455
2639
|
}
|
|
2456
2640
|
document.getElementById("loadLogsBtn").onclick = loadLogs;
|
|
2457
|
-
function
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
if (line.includes(" INFO ")) return "INFO";
|
|
2461
|
-
return "";
|
|
2641
|
+
function explicitLogLevelOf(line) {
|
|
2642
|
+
const m = String(line || "").match(/^\s*(?:\[[^\]]+\]|\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s+[+-]\d{2}:?\d{2})?)?\s*(ERROR|WARN|INFO)\b/);
|
|
2643
|
+
return m ? m[1] : "";
|
|
2462
2644
|
}
|
|
2463
2645
|
function logTimeOf(line) {
|
|
2464
|
-
const
|
|
2646
|
+
const text = String(line || "");
|
|
2647
|
+
const m = text.match(/^\s*\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})(?:\s+[+-]\d{2}:?\d{2})?\]?/);
|
|
2465
2648
|
return m ? new Date(m[1].replace(" ", "T")).getTime() : 0;
|
|
2466
2649
|
}
|
|
2650
|
+
function parsedLogLines() {
|
|
2651
|
+
let currentLevel = "INFO";
|
|
2652
|
+
let currentTime = 0;
|
|
2653
|
+
return state.logsPlain.split(/\n/).filter((line) => line.length > 0).map((line) => {
|
|
2654
|
+
const explicit = explicitLogLevelOf(line);
|
|
2655
|
+
if (explicit) currentLevel = explicit;
|
|
2656
|
+
const time = logTimeOf(line);
|
|
2657
|
+
if (time) currentTime = time;
|
|
2658
|
+
return { line, level: currentLevel, time: currentTime };
|
|
2659
|
+
});
|
|
2660
|
+
}
|
|
2467
2661
|
function renderLogs() {
|
|
2468
2662
|
const level = val("logLevel");
|
|
2469
2663
|
const query = val("logSearch").toLowerCase();
|
|
2470
2664
|
const since = val("logSince") ? new Date(val("logSince")).getTime() : 0;
|
|
2471
|
-
const lines =
|
|
2472
|
-
document.getElementById("logs").innerHTML = lines.map((
|
|
2665
|
+
const lines = parsedLogLines().filter((entry) => (level === "all" || entry.level === level) && (!query || entry.line.toLowerCase().includes(query)) && (!since || !entry.time || entry.time >= since));
|
|
2666
|
+
document.getElementById("logs").innerHTML = lines.map((entry) => '<span class="log-line ' + entry.level + '">' + esc(entry.line) + "</span>").join("") || "(empty)";
|
|
2473
2667
|
}
|
|
2474
2668
|
document.getElementById("logLevel").onchange = renderLogs;
|
|
2475
2669
|
document.getElementById("logSearch").oninput = renderLogs;
|
|
@@ -2728,6 +2922,22 @@
|
|
|
2728
2922
|
}
|
|
2729
2923
|
});
|
|
2730
2924
|
});
|
|
2925
|
+
document.addEventListener("click", (e) => {
|
|
2926
|
+
const b = e.target.closest?.("[data-peer-rotate]");
|
|
2927
|
+
if (!b) return;
|
|
2928
|
+
safe(async () => {
|
|
2929
|
+
if (!can("peers.write")) {
|
|
2930
|
+
toast("Permission required: peers.write");
|
|
2931
|
+
return;
|
|
2932
|
+
}
|
|
2933
|
+
if (confirm("Create a new pairing invite for this peer using the current scopes?")) {
|
|
2934
|
+
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRotate) + "/rotate", { method: "POST", body: JSON.stringify({ expiresMinutes: 10 }), local: true });
|
|
2935
|
+
if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
|
|
2936
|
+
toast("Rotation invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
|
|
2937
|
+
loadPeers();
|
|
2938
|
+
}
|
|
2939
|
+
});
|
|
2940
|
+
});
|
|
2731
2941
|
document.getElementById("updateBtn").onclick = () => safe(async () => {
|
|
2732
2942
|
if (!can("updates.run")) {
|
|
2733
2943
|
toast("Permission required: updates.run");
|
|
@@ -2914,11 +3124,13 @@
|
|
|
2914
3124
|
}
|
|
2915
3125
|
function peerCard(p) {
|
|
2916
3126
|
const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
|
|
3127
|
+
const trust = p.trustStatus || "trusted";
|
|
3128
|
+
const trustClass = trust === "trusted" ? "enabled" : trust === "tls-unpinned" ? "planned" : "disabled";
|
|
2917
3129
|
const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
|
|
2918
3130
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2919
3131
|
const effective = "Effective access: " + (p.scopes || []).length + " scope(s), agents " + ((p.allowedAgents || []).join(", ") || "all") + ", workspaces " + ((p.allowedWorkspaceRoots || []).join(", ") || "all");
|
|
2920
3132
|
const history = (p.healthHistory || []).slice(-5).reverse().map((h) => "<small>" + esc(fmtDate(h.checkedAt) + " | " + h.status + (h.latencyMs !== void 0 ? " | " + h.latencyMs + "ms" : "") + (h.error ? " | " + h.error : "")) + "</small>").join("");
|
|
2921
|
-
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<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(effective) + "</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>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>
|
|
3133
|
+
return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + '</span> <span class="adapter-status ' + trustClass + '">' + esc(trust) + "</span>" + selected + "</strong>" + (p.group ? "<small>" + esc("Group: " + p.group) + "</small>" : "") + "<small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + (p.trustWarnings && p.trustWarnings.length ? peerWarningsHtml(p.trustWarnings, "Trust warning") : "") + "<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(effective) + "</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>" : "") + (history ? '<details class="peer-health-history"><summary>Health history (' + (p.healthHistory || []).length + ")</summary>" + history + "</details>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-repin="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Trust TLS</button><button class="secondary" data-peer-rotate="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Rotate</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>";
|
|
2922
3134
|
}
|
|
2923
3135
|
function openPeerDialog(p) {
|
|
2924
3136
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
@@ -3008,6 +3220,459 @@
|
|
|
3008
3220
|
}
|
|
3009
3221
|
}));
|
|
3010
3222
|
}
|
|
3223
|
+
const ACCESS_USER_PAGE_SIZE = 20;
|
|
3224
|
+
const ACCESS_PERMISSION_GROUPS = [
|
|
3225
|
+
["Overview", ["inspect"]],
|
|
3226
|
+
["Sessions", ["sessions.read", "sessions.write", "prompt.send", "prompt.abort", "queue.read", "queue.write"]],
|
|
3227
|
+
["Files", ["files.read", "files.write"]],
|
|
3228
|
+
["Operations", ["settings.read", "settings.write", "auth.manage", "diagnostics.read", "logs.read", "logs.clear", "updates.run", "system.restart"]],
|
|
3229
|
+
["Users and audit", ["users.read", "users.write", "audit.read"]],
|
|
3230
|
+
["Peers", ["peers.read", "peers.write", "peers.connect"]]
|
|
3231
|
+
];
|
|
3232
|
+
function ensureAccessUiState() {
|
|
3233
|
+
if (!state.userFilters) state.userFilters = { query: "", status: "all", group: "all", identity: "all" };
|
|
3234
|
+
if (!state.userPage) state.userPage = 1;
|
|
3235
|
+
if (!state.userPageSize) state.userPageSize = ACCESS_USER_PAGE_SIZE;
|
|
3236
|
+
if (!state.userDetailAudit) state.userDetailAudit = {};
|
|
3237
|
+
}
|
|
3238
|
+
function groupIdsForUser(u) {
|
|
3239
|
+
return (u.groups || []).map((g) => g.id);
|
|
3240
|
+
}
|
|
3241
|
+
function groupNamesForUser(u) {
|
|
3242
|
+
return (u.groups || []).map((g) => g.name).join(", ") || "-";
|
|
3243
|
+
}
|
|
3244
|
+
function hasUserIdentity(u, kind) {
|
|
3245
|
+
if (kind === "telegram") return (u.telegramIdentities || []).length > 0;
|
|
3246
|
+
if (kind === "discord") return (u.discordIdentities || []).length > 0;
|
|
3247
|
+
if (kind === "slack") return (u.slackIdentities || []).length > 0;
|
|
3248
|
+
if (kind === "web") return (u.webSessions || []).length > 0;
|
|
3249
|
+
if (kind === "unlinked") return !hasUserIdentity(u, "telegram") && !hasUserIdentity(u, "discord") && !hasUserIdentity(u, "slack");
|
|
3250
|
+
return true;
|
|
3251
|
+
}
|
|
3252
|
+
function userSearchText(u) {
|
|
3253
|
+
return [u.displayName, u.email, u.id, groupNamesForUser(u), (u.telegramIdentities || []).map((i) => [i.telegramUserId, i.username].join(" ")).join(" "), (u.discordIdentities || []).map((i) => [i.discordUserId, i.username, i.globalName].join(" ")).join(" "), (u.slackIdentities || []).map((i) => [i.slackUserId, i.teamId, i.username, i.realName].join(" ")).join(" ")].join(" ").toLowerCase();
|
|
3254
|
+
}
|
|
3255
|
+
function filteredUsers() {
|
|
3256
|
+
ensureAccessUiState();
|
|
3257
|
+
const filters = state.userFilters;
|
|
3258
|
+
const query = (filters.query || "").toLowerCase().trim();
|
|
3259
|
+
return (state.userManagement?.users || []).filter((u) => {
|
|
3260
|
+
if (filters.status === "active" && !u.active) return false;
|
|
3261
|
+
if (filters.status === "disabled" && u.active) return false;
|
|
3262
|
+
if (filters.group && filters.group !== "all" && !groupIdsForUser(u).includes(filters.group)) return false;
|
|
3263
|
+
if (filters.identity && filters.identity !== "all" && !hasUserIdentity(u, filters.identity)) return false;
|
|
3264
|
+
if (query && !userSearchText(u).includes(query)) return false;
|
|
3265
|
+
return true;
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
function renderUserGroupFilter() {
|
|
3269
|
+
const select = document.getElementById("userGroupFilter");
|
|
3270
|
+
if (!select) return;
|
|
3271
|
+
const current = state.userFilters?.group || "all";
|
|
3272
|
+
select.innerHTML = '<option value="all">All groups</option>' + (state.userManagement?.groups || []).map((g) => '<option value="' + attr(g.id) + '">' + esc(g.name) + "</option>").join("");
|
|
3273
|
+
select.value = current;
|
|
3274
|
+
}
|
|
3275
|
+
function bindAccessFilters() {
|
|
3276
|
+
ensureAccessUiState();
|
|
3277
|
+
const search = document.getElementById("userSearch");
|
|
3278
|
+
if (search && !search.dataset.bound) {
|
|
3279
|
+
search.dataset.bound = "true";
|
|
3280
|
+
search.oninput = () => {
|
|
3281
|
+
state.userFilters.query = search.value;
|
|
3282
|
+
state.userPage = 1;
|
|
3283
|
+
renderUsersList();
|
|
3284
|
+
};
|
|
3285
|
+
}
|
|
3286
|
+
if (search) search.value = state.userFilters.query || "";
|
|
3287
|
+
const status = document.getElementById("userStatusFilter");
|
|
3288
|
+
if (status && !status.dataset.bound) {
|
|
3289
|
+
status.dataset.bound = "true";
|
|
3290
|
+
status.onchange = () => {
|
|
3291
|
+
state.userFilters.status = status.value;
|
|
3292
|
+
state.userPage = 1;
|
|
3293
|
+
renderUsersList();
|
|
3294
|
+
};
|
|
3295
|
+
}
|
|
3296
|
+
if (status) status.value = state.userFilters.status || "all";
|
|
3297
|
+
const group = document.getElementById("userGroupFilter");
|
|
3298
|
+
if (group && !group.dataset.bound) {
|
|
3299
|
+
group.dataset.bound = "true";
|
|
3300
|
+
group.onchange = () => {
|
|
3301
|
+
state.userFilters.group = group.value;
|
|
3302
|
+
state.userPage = 1;
|
|
3303
|
+
renderUsersList();
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
3306
|
+
if (group) group.value = state.userFilters.group || "all";
|
|
3307
|
+
const identity = document.getElementById("userIdentityFilter");
|
|
3308
|
+
if (identity && !identity.dataset.bound) {
|
|
3309
|
+
identity.dataset.bound = "true";
|
|
3310
|
+
identity.onchange = () => {
|
|
3311
|
+
state.userFilters.identity = identity.value;
|
|
3312
|
+
state.userPage = 1;
|
|
3313
|
+
renderUsersList();
|
|
3314
|
+
};
|
|
3315
|
+
}
|
|
3316
|
+
if (identity) identity.value = state.userFilters.identity || "all";
|
|
3317
|
+
const groupSearch = document.getElementById("groupSearch");
|
|
3318
|
+
if (groupSearch && !groupSearch.dataset.bound) {
|
|
3319
|
+
groupSearch.dataset.bound = "true";
|
|
3320
|
+
groupSearch.oninput = () => renderGroupsList();
|
|
3321
|
+
}
|
|
3322
|
+
const telegramSearch = document.getElementById("telegramChatSearch");
|
|
3323
|
+
if (telegramSearch && !telegramSearch.dataset.bound) {
|
|
3324
|
+
telegramSearch.dataset.bound = "true";
|
|
3325
|
+
telegramSearch.oninput = () => renderTelegramChats();
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
function switchAccessTabV2(tab) {
|
|
3329
|
+
state.accessTab = tab || "users";
|
|
3330
|
+
document.querySelectorAll("[data-access-tab]").forEach((b) => {
|
|
3331
|
+
const active = b.dataset.accessTab === state.accessTab;
|
|
3332
|
+
b.classList.toggle("active", active);
|
|
3333
|
+
b.setAttribute("aria-selected", active ? "true" : "false");
|
|
3334
|
+
b.tabIndex = active ? 0 : -1;
|
|
3335
|
+
});
|
|
3336
|
+
document.querySelectorAll("[data-access-tab-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.accessTabPanel === state.accessTab));
|
|
3337
|
+
bindAccessFilters();
|
|
3338
|
+
}
|
|
3339
|
+
function bindAccessTabsV2() {
|
|
3340
|
+
document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTabV2(b.dataset.accessTab));
|
|
3341
|
+
bindAccessFilters();
|
|
3342
|
+
const discordSearch = document.getElementById("discordChannelSearch");
|
|
3343
|
+
if (discordSearch && !discordSearch.dataset.bound) {
|
|
3344
|
+
discordSearch.dataset.bound = "true";
|
|
3345
|
+
discordSearch.oninput = () => renderDiscordChannelsV2();
|
|
3346
|
+
}
|
|
3347
|
+
const slackSearch = document.getElementById("slackChannelSearch");
|
|
3348
|
+
if (slackSearch && !slackSearch.dataset.bound) {
|
|
3349
|
+
slackSearch.dataset.bound = "true";
|
|
3350
|
+
slackSearch.oninput = () => renderSlackChannelsV2();
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
function renderUserManagementV2(d) {
|
|
3354
|
+
ensureAccessUiState();
|
|
3355
|
+
state.userManagement = d;
|
|
3356
|
+
renderUserGroupFilter();
|
|
3357
|
+
bindAccessFilters();
|
|
3358
|
+
renderUsersList();
|
|
3359
|
+
renderGroupsList();
|
|
3360
|
+
renderTelegramChats();
|
|
3361
|
+
renderDiscordChannelsV2(d.discordChannels || []);
|
|
3362
|
+
renderSlackChannelsV2(d.slackChannels || []);
|
|
3363
|
+
bindUserButtonsV2();
|
|
3364
|
+
bindSlackUserButtons();
|
|
3365
|
+
bindAccessCopyButtons();
|
|
3366
|
+
applyPermissions();
|
|
3367
|
+
}
|
|
3368
|
+
function userIdentityChips(u) {
|
|
3369
|
+
const chips = [];
|
|
3370
|
+
const telegram = (u.telegramIdentities || []).length;
|
|
3371
|
+
const discord = (u.discordIdentities || []).length;
|
|
3372
|
+
const slack = (u.slackIdentities || []).length;
|
|
3373
|
+
const web = (u.webSessions || []).length;
|
|
3374
|
+
if (telegram) chips.push('<span class="chip">Telegram ' + telegram + "</span>");
|
|
3375
|
+
if (discord) chips.push('<span class="chip">Discord ' + discord + "</span>");
|
|
3376
|
+
if (slack) chips.push('<span class="chip">Slack ' + slack + "</span>");
|
|
3377
|
+
if (web) chips.push('<span class="chip">Web ' + web + "</span>");
|
|
3378
|
+
return chips.join("") || '<span class="chip">No chat identity</span>';
|
|
3379
|
+
}
|
|
3380
|
+
function effectivePermissions(u) {
|
|
3381
|
+
return Array.from(new Set((u.groups || []).flatMap((g) => g.permissions || []))).sort();
|
|
3382
|
+
}
|
|
3383
|
+
function scopedUnion(u, key) {
|
|
3384
|
+
const groups = u.groups || [];
|
|
3385
|
+
if (groups.some((g) => (g[key] || []).length === 0)) return ["all"];
|
|
3386
|
+
return Array.from(new Set(groups.flatMap((g) => g[key] || []))).sort();
|
|
3387
|
+
}
|
|
3388
|
+
function userCard(u) {
|
|
3389
|
+
const groups = (u.groups || []).map((g) => '<span class="chip">' + esc(g.name) + "</span>").join("") || '<span class="chip">No groups</span>';
|
|
3390
|
+
const perms = effectivePermissions(u);
|
|
3391
|
+
return '<div class="item user-card"><div class="user-card-main"><div><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email) + '</small><small class="access-id-row">User ID: ' + accessCopyButton(u.id, "User ID copied") + '</small></div><div class="user-card-meta">' + groups + userIdentityChips(u) + "</div></div><small>" + esc(perms.length + " permissions \xB7 agents " + scopedUnion(u, "agentIds").join(", ") + " \xB7 workspaces " + scopedUnion(u, "workspaceRoots").join(", ")) + '</small><div class="row"><button data-user-detail="' + attr(u.id) + '">Details</button><button class="secondary" 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></div></div>";
|
|
3392
|
+
}
|
|
3393
|
+
function renderUsersList() {
|
|
3394
|
+
const target = document.getElementById("accessPanel");
|
|
3395
|
+
if (!target) return;
|
|
3396
|
+
const users = filteredUsers();
|
|
3397
|
+
const pages = Math.max(1, Math.ceil(users.length / (state.userPageSize || ACCESS_USER_PAGE_SIZE)));
|
|
3398
|
+
if (state.userPage > pages) state.userPage = pages;
|
|
3399
|
+
const start = (state.userPage - 1) * (state.userPageSize || ACCESS_USER_PAGE_SIZE);
|
|
3400
|
+
const pageUsers = users.slice(start, start + (state.userPageSize || ACCESS_USER_PAGE_SIZE));
|
|
3401
|
+
target.innerHTML = pageUsers.map(userCard).join("") || '<div class="item">No users match the current filters.</div>';
|
|
3402
|
+
renderUsersPager(users.length, pages);
|
|
3403
|
+
bindUserButtonsV2();
|
|
3404
|
+
bindAccessCopyButtons();
|
|
3405
|
+
applyPermissions();
|
|
3406
|
+
}
|
|
3407
|
+
function renderUsersPager(total, pages) {
|
|
3408
|
+
const pager = document.getElementById("usersPager");
|
|
3409
|
+
if (!pager) return;
|
|
3410
|
+
const start = total ? (state.userPage - 1) * (state.userPageSize || ACCESS_USER_PAGE_SIZE) + 1 : 0;
|
|
3411
|
+
const end = Math.min(total, state.userPage * (state.userPageSize || ACCESS_USER_PAGE_SIZE));
|
|
3412
|
+
pager.innerHTML = "<span>" + esc(start + "-" + end + " of " + total + " users \xB7 page " + state.userPage + " of " + pages) + '</span><div class="pager-actions"><button data-user-page="prev" ' + (state.userPage <= 1 ? "disabled" : "") + '>Previous</button><button data-user-page="next" ' + (state.userPage >= pages ? "disabled" : "") + ">Next</button></div>";
|
|
3413
|
+
pager.querySelector('[data-user-page="prev"]').onclick = () => {
|
|
3414
|
+
if (state.userPage > 1) {
|
|
3415
|
+
state.userPage -= 1;
|
|
3416
|
+
renderUsersList();
|
|
3417
|
+
}
|
|
3418
|
+
};
|
|
3419
|
+
pager.querySelector('[data-user-page="next"]').onclick = () => {
|
|
3420
|
+
if (state.userPage < pages) {
|
|
3421
|
+
state.userPage += 1;
|
|
3422
|
+
renderUsersList();
|
|
3423
|
+
}
|
|
3424
|
+
};
|
|
3425
|
+
}
|
|
3426
|
+
function renderGroupsList() {
|
|
3427
|
+
const target = document.getElementById("groupsList");
|
|
3428
|
+
if (!target) return;
|
|
3429
|
+
const query = (document.getElementById("groupSearch")?.value || "").toLowerCase();
|
|
3430
|
+
const groups = (state.userManagement?.groups || []).filter((g) => !query || [g.name, g.id, g.description, (g.permissions || []).join(" ")].join(" ").toLowerCase().includes(query));
|
|
3431
|
+
target.innerHTML = groups.map((g) => {
|
|
3432
|
+
const users = (state.userManagement?.users || []).filter((u) => groupIdsForUser(u).includes(g.id)).length;
|
|
3433
|
+
return '<div class="item group-card"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>" + esc(users + " user(s) \xB7 " + (g.permissions || []).length + " permission(s)") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Channels: " + esc(["Telegram " + (csv(g.telegramChatIds) || "all"), "Discord " + (discordScopeLabel(g.discordChannelIds) || "all"), "Slack " + (slackScopeLabel(g.slackChannelIds) || "all")].join(" \xB7 ")) + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>";
|
|
3434
|
+
}).join("") || '<div class="item">No groups match the current filters.</div>';
|
|
3435
|
+
bindUserButtonsV2();
|
|
3436
|
+
applyPermissions();
|
|
3437
|
+
}
|
|
3438
|
+
function renderTelegramChats(chats = state.userManagement?.telegramChats || []) {
|
|
3439
|
+
const target = document.getElementById("telegramChatsList");
|
|
3440
|
+
if (!target) return;
|
|
3441
|
+
const query = (document.getElementById("telegramChatSearch")?.value || "").toLowerCase();
|
|
3442
|
+
const filtered = (chats || []).filter((c) => !query || [c.title, c.chatId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
|
3443
|
+
target.innerHTML = filtered.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 class="access-id-row">Chat ID: ' + accessCopyButton(String(c.chatId), "Telegram chat ID copied") + "</small><small>" + esc("Type: " + (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>';
|
|
3444
|
+
bindUserButtonsV2();
|
|
3445
|
+
bindAccessCopyButtons();
|
|
3446
|
+
applyPermissions();
|
|
3447
|
+
}
|
|
3448
|
+
function renderDiscordChannelsV2(channels = state.userManagement?.discordChannels || []) {
|
|
3449
|
+
const target = document.getElementById("discordChannelsList");
|
|
3450
|
+
if (!target) return;
|
|
3451
|
+
const query = (document.getElementById("discordChannelSearch")?.value || "").toLowerCase();
|
|
3452
|
+
const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.guildId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
|
3453
|
+
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>';
|
|
3454
|
+
bindDiscordChannelButtons();
|
|
3455
|
+
bindAccessCopyButtons();
|
|
3456
|
+
applyPermissions();
|
|
3457
|
+
}
|
|
3458
|
+
function renderSlackChannelsV2(channels = state.userManagement?.slackChannels || []) {
|
|
3459
|
+
const target = document.getElementById("slackChannelsList");
|
|
3460
|
+
if (!target) return;
|
|
3461
|
+
const query = (document.getElementById("slackChannelSearch")?.value || "").toLowerCase();
|
|
3462
|
+
const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.teamId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
|
|
3463
|
+
target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Slack channel ID copied") + '</small><small class="access-id-row">Team ID: ' + accessCopyButton(c.teamId || "", "Slack team ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-slack-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-slack-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Slack channels registered.</div>';
|
|
3464
|
+
bindSlackChannelButtons();
|
|
3465
|
+
bindAccessCopyButtons();
|
|
3466
|
+
applyPermissions();
|
|
3467
|
+
}
|
|
3468
|
+
function bindUserButtonsV2() {
|
|
3469
|
+
document.querySelectorAll("[data-user-detail]").forEach((b) => b.onclick = () => safe(() => openUserDetail(b.dataset.userDetail)));
|
|
3470
|
+
document.querySelectorAll("[data-user-edit]").forEach((b) => b.onclick = () => {
|
|
3471
|
+
const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userEdit);
|
|
3472
|
+
if (u) openUserDialog(u);
|
|
3473
|
+
});
|
|
3474
|
+
document.querySelectorAll("[data-user-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3475
|
+
const u = (state.userManagement?.users || []).find((x) => x.id === b.dataset.userToggle);
|
|
3476
|
+
if (!u) return;
|
|
3477
|
+
await api("/api/users/" + encodeURIComponent(u.id), { method: "PATCH", body: JSON.stringify({ active: !u.active }) });
|
|
3478
|
+
toast("User updated");
|
|
3479
|
+
loadAccess();
|
|
3480
|
+
}));
|
|
3481
|
+
document.querySelectorAll("[data-user-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("telegram", b.dataset.userCode)));
|
|
3482
|
+
document.querySelectorAll("[data-user-link]").forEach((b) => b.onclick = () => openTelegramLinkDialog(b.dataset.userLink));
|
|
3483
|
+
document.querySelectorAll("[data-user-discord-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("discord", b.dataset.userDiscordCode)));
|
|
3484
|
+
document.querySelectorAll("[data-user-discord-link]").forEach((b) => b.onclick = () => openDiscordLinkDialog(b.dataset.userDiscordLink));
|
|
3485
|
+
document.querySelectorAll("[data-user-slack-code]").forEach((b) => b.onclick = () => safe(() => showLinkCodeDialog("slack", b.dataset.userSlackCode)));
|
|
3486
|
+
document.querySelectorAll("[data-user-slack-link]").forEach((b) => b.onclick = () => openSlackLinkDialog(b.dataset.userSlackLink));
|
|
3487
|
+
document.querySelectorAll("[data-user-password]").forEach((b) => b.onclick = () => openPasswordDialog(b.dataset.userPassword));
|
|
3488
|
+
document.querySelectorAll("[data-user-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3489
|
+
if (confirm("Revoke all web sessions for this user?")) {
|
|
3490
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.userRevoke) + "/sessions", { method: "DELETE" });
|
|
3491
|
+
toast("Sessions revoked");
|
|
3492
|
+
loadAccess();
|
|
3493
|
+
}
|
|
3494
|
+
}));
|
|
3495
|
+
document.querySelectorAll("[data-user-session-revoke]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3496
|
+
if (confirm("Revoke this web session?")) {
|
|
3497
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.user) + "/sessions/" + encodeURIComponent(b.dataset.userSessionRevoke), { method: "DELETE" });
|
|
3498
|
+
toast("Session revoked");
|
|
3499
|
+
await loadAccess();
|
|
3500
|
+
await openUserDetail(b.dataset.user);
|
|
3501
|
+
}
|
|
3502
|
+
}));
|
|
3503
|
+
document.querySelectorAll("[data-telegram-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3504
|
+
if (confirm("Unlink this Telegram identity?")) {
|
|
3505
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.telegramUser) + "/telegram/" + encodeURIComponent(b.dataset.telegramUnlink), { method: "DELETE" });
|
|
3506
|
+
toast("Telegram unlinked");
|
|
3507
|
+
loadAccess();
|
|
3508
|
+
}
|
|
3509
|
+
}));
|
|
3510
|
+
document.querySelectorAll("[data-discord-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3511
|
+
if (confirm("Unlink this Discord identity?")) {
|
|
3512
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.discordUser) + "/discord/" + encodeURIComponent(b.dataset.discordUnlink), { method: "DELETE" });
|
|
3513
|
+
toast("Discord unlinked");
|
|
3514
|
+
loadAccess();
|
|
3515
|
+
}
|
|
3516
|
+
}));
|
|
3517
|
+
document.querySelectorAll("[data-slack-unlink]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3518
|
+
if (confirm("Unlink this Slack identity?")) {
|
|
3519
|
+
await api("/api/users/" + encodeURIComponent(b.dataset.slackUser) + "/slack/" + encodeURIComponent(b.dataset.slackUnlink), { method: "DELETE" });
|
|
3520
|
+
toast("Slack unlinked");
|
|
3521
|
+
loadAccess();
|
|
3522
|
+
}
|
|
3523
|
+
}));
|
|
3524
|
+
document.querySelectorAll("[data-group-edit]").forEach((b) => b.onclick = () => {
|
|
3525
|
+
const g = (state.userManagement?.groups || []).find((x) => x.id === b.dataset.groupEdit);
|
|
3526
|
+
if (g) openGroupDialogV2(g);
|
|
3527
|
+
});
|
|
3528
|
+
document.querySelectorAll("[data-chat-edit]").forEach((b) => b.onclick = () => {
|
|
3529
|
+
const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatEdit);
|
|
3530
|
+
if (c) openChatDialog(c);
|
|
3531
|
+
});
|
|
3532
|
+
document.querySelectorAll("[data-chat-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3533
|
+
const c = (state.userManagement?.telegramChats || []).find((x) => x.id === b.dataset.chatToggle);
|
|
3534
|
+
if (!c) return;
|
|
3535
|
+
await api("/api/telegram-chats/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
3536
|
+
toast("Chat updated");
|
|
3537
|
+
loadAccess();
|
|
3538
|
+
}));
|
|
3539
|
+
document.querySelectorAll("[data-discord-channel-edit]").forEach((b) => b.onclick = () => {
|
|
3540
|
+
const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelEdit);
|
|
3541
|
+
if (c) openDiscordChannelDialog(c);
|
|
3542
|
+
});
|
|
3543
|
+
document.querySelectorAll("[data-discord-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
|
|
3544
|
+
const c = (state.userManagement?.discordChannels || []).find((x) => x.id === b.dataset.discordChannelToggle);
|
|
3545
|
+
if (!c) return;
|
|
3546
|
+
await api("/api/discord-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
|
|
3547
|
+
toast("Discord channel updated");
|
|
3548
|
+
loadAccess();
|
|
3549
|
+
}));
|
|
3550
|
+
bindAccessCopyButtons();
|
|
3551
|
+
applyPermissions();
|
|
3552
|
+
}
|
|
3553
|
+
function userDetailTab(label, id, active) {
|
|
3554
|
+
return '<button type="button" data-user-detail-tab="' + attr(id) + '" class="' + (active ? "active" : "") + '">' + esc(label) + "</button>";
|
|
3555
|
+
}
|
|
3556
|
+
function userDetailPanel(id, active, body) {
|
|
3557
|
+
return '<div class="user-detail-panel ' + (active ? "active" : "") + '" data-user-detail-panel="' + attr(id) + '">' + body + "</div>";
|
|
3558
|
+
}
|
|
3559
|
+
async function openUserDetail(userId) {
|
|
3560
|
+
const user = (state.userManagement?.users || []).find((u) => u.id === userId);
|
|
3561
|
+
if (!user) return;
|
|
3562
|
+
state.activeUserDetailId = userId;
|
|
3563
|
+
renderUserDetail(user);
|
|
3564
|
+
document.getElementById("userDetailDialog").showModal();
|
|
3565
|
+
if (can("audit.read")) {
|
|
3566
|
+
const data = await api("/api/audit", { query: { limit: 100 } });
|
|
3567
|
+
state.userDetailAudit[userId] = (data.events || []).filter((e) => userAuditMatches(user, e)).slice(0, 15);
|
|
3568
|
+
if (state.activeUserDetailId === userId) renderUserDetail(user);
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
function userAuditMatches(user, e) {
|
|
3572
|
+
const text = [e.actor?.id, e.actor?.label, e.actor?.username, e.actorId, e.actorRole, e.description, e.detail].join(" ").toLowerCase();
|
|
3573
|
+
const ids = [user.id, user.email, user.displayName].concat((user.telegramIdentities || []).map((i) => String(i.telegramUserId)), (user.discordIdentities || []).map((i) => i.discordUserId), (user.slackIdentities || []).map((i) => i.slackUserId)).filter(Boolean).map((x) => String(x).toLowerCase());
|
|
3574
|
+
return ids.some((id) => text.includes(id));
|
|
3575
|
+
}
|
|
3576
|
+
function renderUserDetail(user) {
|
|
3577
|
+
const audit = state.userDetailAudit?.[user.id];
|
|
3578
|
+
const profile = card("Profile", [["Email", user.email], ["User ID", user.id], ["Status", user.active ? "active" : "disabled"], ["Created", fmtDate(user.createdAt)], ["Updated", fmtDate(user.updatedAt)], ["Last login", fmtDate(user.lastLoginAt)], ["Web sessions", (user.webSessions || []).length]]);
|
|
3579
|
+
const groups = (user.groups || []).map((g) => uiItem(g.name, { badge: g.system ? { text: "system", status: "disabled" } : null, rows: [["Description", g.description], ["Permissions", (g.permissions || []).length], ["Agent scope", csv(g.agentIds) || "all"], ["Workspace scope", csv(g.workspaceRoots) || "all"]] })).join("") || uiEmpty("No groups.");
|
|
3580
|
+
const identities = identityDetailHtml(user);
|
|
3581
|
+
const sessions = (user.webSessions || []).map((s) => '<div class="item"><strong>' + esc("Session " + s.id) + "</strong><small>" + esc("Created: " + fmtDate(s.createdAt)) + "</small><small>" + esc("Last seen: " + fmtDate(s.lastSeenAt)) + "</small><small>" + esc("Expires: " + fmtDate(s.expiresAt)) + '</small><div class="row"><button class="danger" data-user="' + attr(user.id) + '" data-user-session-revoke="' + attr(s.id) + '"' + disabledAttr("users.write") + ">Revoke</button></div></div>").join("") || uiEmpty("No active web sessions.");
|
|
3582
|
+
const effective = effectiveAccessHtml(user);
|
|
3583
|
+
const auditHtml = !can("audit.read") ? uiEmpty("Permission required: audit.read") : audit ? audit.map((e) => '<div class="item"><strong>' + esc([fmtDate(e.timestamp), e.status, e.action].join(" | ")) + "</strong><small>" + esc(e.description || e.detail || "") + "</small></div>").join("") || uiEmpty("No user-related audit events found.") : loadingHtml("Loading user audit...");
|
|
3584
|
+
const tabs = userDetailTab("Profile", "profile", true) + userDetailTab("Groups", "groups", false) + userDetailTab("Identities", "identities", false) + userDetailTab("Web sessions", "sessions", false) + userDetailTab("Effective access", "effective", false) + userDetailTab("Audit", "audit", false);
|
|
3585
|
+
document.getElementById("userDetail").innerHTML = "<h2>" + esc(user.displayName) + "</h2><p>" + esc(user.email) + '</p><div class="tabs user-detail-tabs">' + tabs + "</div>" + userDetailPanel("profile", true, profile + '<div class="row"><button data-user-edit="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Edit user</button><button class="secondary" data-user-password="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="secondary" data-user-toggle="' + attr(user.id) + '"' + disabledAttr("users.write") + ">" + (user.active ? "Disable" : "Enable") + "</button></div>") + userDetailPanel("groups", false, '<div class="list">' + groups + "</div>") + userDetailPanel("identities", false, identities) + userDetailPanel("sessions", false, '<div class="list">' + sessions + '</div><div class="row"><button class="danger" data-user-revoke="' + attr(user.id) + '"' + disabledAttr("users.write") + ">Revoke all sessions</button></div>") + userDetailPanel("effective", false, effective) + userDetailPanel("audit", false, '<div class="list">' + auditHtml + "</div>");
|
|
3586
|
+
bindUserDetailTabs();
|
|
3587
|
+
bindUserButtonsV2();
|
|
3588
|
+
bindAccessCopyButtons();
|
|
3589
|
+
}
|
|
3590
|
+
function bindUserDetailTabs() {
|
|
3591
|
+
document.querySelectorAll("[data-user-detail-tab]").forEach((b) => b.onclick = () => {
|
|
3592
|
+
document.querySelectorAll("[data-user-detail-tab]").forEach((x) => x.classList.toggle("active", x === b));
|
|
3593
|
+
document.querySelectorAll("[data-user-detail-panel]").forEach((panel) => panel.classList.toggle("active", panel.dataset.userDetailPanel === b.dataset.userDetailTab));
|
|
3594
|
+
});
|
|
3595
|
+
}
|
|
3596
|
+
function identityDetailHtml(user) {
|
|
3597
|
+
const telegram = (user.telegramIdentities || []).map((t) => '<div class="item"><strong>Telegram <span class="adapter-status ' + (t.active ? "enabled" : "disabled") + '">' + (t.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(String(t.telegramUserId), "Telegram user ID copied") + "</small>" + (t.username ? "<small>" + esc("@" + t.username) + "</small>" : "") + '<div class="row"><button class="secondary" data-telegram-user="' + attr(user.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
|
|
3598
|
+
const discord = (user.discordIdentities || []).map((i) => '<div class="item"><strong>Discord <span class="adapter-status ' + (i.active ? "enabled" : "disabled") + '">' + (i.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(i.discordUserId, "Discord user ID copied") + "</small>" + (i.username ? "<small>" + esc("@" + i.username) + "</small>" : "") + (i.globalName ? "<small>" + esc(i.globalName) + "</small>" : "") + '<div class="row"><button class="secondary" data-discord-user="' + attr(user.id) + '" data-discord-unlink="' + attr(i.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
|
|
3599
|
+
const slack = (user.slackIdentities || []).map((i) => '<div class="item"><strong>Slack <span class="adapter-status ' + (i.active ? "enabled" : "disabled") + '">' + (i.active ? "active" : "disabled") + '</span></strong><small class="access-id-row">User ID: ' + accessCopyButton(i.slackUserId, "Slack user ID copied") + "</small>" + (i.teamId ? '<small class="access-id-row">Team ID: ' + accessCopyButton(i.teamId, "Slack team ID copied") + "</small>" : "") + (i.username ? "<small>" + esc("@" + i.username) + "</small>" : "") + (i.realName ? "<small>" + esc(i.realName) + "</small>" : "") + '<div class="row"><button class="secondary" data-slack-user="' + attr(user.id) + '" data-slack-unlink="' + attr(i.id) + '"' + disabledAttr("users.write") + ">Unlink</button></div></div>").join("");
|
|
3600
|
+
return '<div class="row identity-actions"><button class="secondary" data-user-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-slack-code="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Slack code</button><button class="secondary" data-user-slack-link="' + attr(user.id) + '"' + disabledAttr("users.write") + '>Link Slack ID</button></div><div class="list">' + (telegram + discord + slack || uiEmpty("No chat identities linked.")) + "</div>";
|
|
3601
|
+
}
|
|
3602
|
+
function effectiveAccessHtml(user) {
|
|
3603
|
+
const perms = effectivePermissions(user);
|
|
3604
|
+
const permissionRows = ACCESS_PERMISSION_GROUPS.map(([name, items]) => [name, items.filter((p) => perms.includes(p)).join(", ") || "-"]);
|
|
3605
|
+
const channelRows = [["Agents", scopedUnion(user, "agentIds").join(", ")], ["Workspaces", scopedUnion(user, "workspaceRoots").join(", ")], ["Telegram chat scope", scopedUnion(user, "telegramChatIds").join(", ")], ["Discord channel scope", scopedUnion(user, "discordChannelIds").join(", ")], ["Slack channel scope", scopedUnion(user, "slackChannelIds").join(", ")]];
|
|
3606
|
+
return '<div class="access-effective-grid">' + card("Permissions", permissionRows) + card("Scopes", channelRows) + "</div>";
|
|
3607
|
+
}
|
|
3608
|
+
async function showLinkCodeDialog(channel, userId) {
|
|
3609
|
+
const path = "/api/users/" + encodeURIComponent(userId) + "/" + channel;
|
|
3610
|
+
const data = await api(path, { method: "POST", body: JSON.stringify({ createCode: true }) });
|
|
3611
|
+
const linkCode = data.linkCode || {};
|
|
3612
|
+
const label = channel[0].toUpperCase() + channel.slice(1);
|
|
3613
|
+
showAccessInfoDialog(label + " link code", "<p>Send this code with the " + esc(label) + ' bot/app link command for this user. The code expires automatically.</p><div class="peer-invite-details"><small>Link code</small><button type="button" class="copy-id peer-invite-command" data-access-copy="' + attr(linkCode.code || "") + '" data-access-copy-label="' + attr(label + " link code copied") + '">' + esc(linkCode.code || "") + "</button><small>" + esc("Expires: " + fmtDate(linkCode.expiresAt)) + "</small></div>");
|
|
3614
|
+
}
|
|
3615
|
+
function showAccessInfoDialog(title, body) {
|
|
3616
|
+
const dialog = document.getElementById("adminDialog");
|
|
3617
|
+
document.getElementById("adminDialogTitle").textContent = title;
|
|
3618
|
+
document.getElementById("adminDialogBody").innerHTML = body;
|
|
3619
|
+
document.getElementById("adminDialogSubmit").textContent = "Close";
|
|
3620
|
+
document.getElementById("adminDialogCancel").onclick = () => dialog.close();
|
|
3621
|
+
document.getElementById("adminDialogForm").onsubmit = (e) => {
|
|
3622
|
+
e.preventDefault();
|
|
3623
|
+
dialog.close();
|
|
3624
|
+
};
|
|
3625
|
+
bindAccessCopyButtons();
|
|
3626
|
+
dialog.showModal();
|
|
3627
|
+
}
|
|
3628
|
+
function permissionCheckboxes(selected = []) {
|
|
3629
|
+
const selectedSet = new Set(selected);
|
|
3630
|
+
const used = new Set(ACCESS_PERMISSION_GROUPS.flatMap(([, items]) => items));
|
|
3631
|
+
const extra = (state.userManagement?.permissions || []).filter((p) => !used.has(p)).sort();
|
|
3632
|
+
const groups = ACCESS_PERMISSION_GROUPS.concat(extra.length ? [["Other", extra]] : []);
|
|
3633
|
+
return groups.map(([name, items]) => '<fieldset class="permission-section"><legend>' + esc(name) + "</legend>" + (items || []).map((p) => '<label class="checkbox"><input type="checkbox" data-group-permission="' + attr(p) + '" value="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</fieldset>").join("");
|
|
3634
|
+
}
|
|
3635
|
+
function checkboxScope(title, items, selected, attrName, emptyText) {
|
|
3636
|
+
const selectedSet = new Set((selected || []).map(String));
|
|
3637
|
+
return '<div class="scope-section full-span"><strong>' + esc(title) + '</strong><small>Leave every box unchecked to allow all.</small><div class="permission-grid">' + ((items || []).map((item) => '<label class="checkbox"><input type="checkbox" ' + attrName + '="' + attr(item.value) + '" value="' + attr(item.value) + '" ' + (selectedSet.has(String(item.value)) ? "checked" : "") + "> " + esc(item.label) + "</label>").join("") || "<small>" + esc(emptyText || "No options available.") + "</small>") + "</div></div>";
|
|
3638
|
+
}
|
|
3639
|
+
function availableAgentScopeItems() {
|
|
3640
|
+
const labels = { codex: "Codex", pi: "Pi", hermes: "Hermes", openclaw: "OpenClaw", "claude-code": "Claude Code" };
|
|
3641
|
+
const ids = Array.from(/* @__PURE__ */ new Set([...state.enabledAgents || [], "codex", "pi", "hermes", "openclaw", "claude-code"]));
|
|
3642
|
+
return ids.map((id) => ({ value: id, label: labels[id] || id }));
|
|
3643
|
+
}
|
|
3644
|
+
function telegramScopeItems() {
|
|
3645
|
+
return (state.userManagement?.telegramChats || []).map((c) => ({ value: String(c.chatId), label: (c.title || c.chatId) + " / " + (c.type || "-") }));
|
|
3646
|
+
}
|
|
3647
|
+
function discordScopeItems() {
|
|
3648
|
+
return (state.userManagement?.discordChannels || []).map((c) => ({ value: c.channelId, label: discordChannelLabel(c) + " / " + (c.guildId || "DM") }));
|
|
3649
|
+
}
|
|
3650
|
+
function slackScopeItems() {
|
|
3651
|
+
return (state.userManagement?.slackChannels || []).map((c) => ({ value: c.channelId, label: slackChannelLabel(c) + " / " + (c.teamId || "team default") }));
|
|
3652
|
+
}
|
|
3653
|
+
function checkedValues(selector) {
|
|
3654
|
+
return Array.from(document.querySelectorAll(selector + ":checked")).map((el) => el.value).filter(Boolean);
|
|
3655
|
+
}
|
|
3656
|
+
function linesToList(text) {
|
|
3657
|
+
return String(text || "").split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
|
|
3658
|
+
}
|
|
3659
|
+
function openGroupDialogV2(g) {
|
|
3660
|
+
const permissions = g?.permissions || ["inspect", "sessions.read"];
|
|
3661
|
+
const body = '<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">Workspace scope<textarea id="dlgWorkspaceRoots" rows="4" placeholder="One workspace root per line. Empty means all.">' + esc((g?.workspaceRoots || []).join("\n")) + "</textarea></label>" + checkboxScope("Agent scope", availableAgentScopeItems(), g?.agentIds || [], "data-scope-agent", "No agents available.") + checkboxScope("Telegram chat scope", telegramScopeItems(), (g?.telegramChatIds || []).map(String), "data-scope-telegram", "No Telegram chats registered.") + checkboxScope("Discord channel scope", discordScopeItems(), g?.discordChannelIds || [], "data-scope-discord", "No Discord channels registered.") + checkboxScope("Slack channel scope", slackScopeItems(), g?.slackChannelIds || [], "data-scope-slack", "No Slack channels registered.") + '<strong class="full-span">Permissions</strong><div class="permission-category-grid full-span">' + permissionCheckboxes(permissions) + "</div>";
|
|
3662
|
+
adminDialog(g ? "Edit group" : "Create group", body, async () => {
|
|
3663
|
+
const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: checkedValues("[data-group-permission]"), agentIds: checkedValues("[data-scope-agent]"), workspaceRoots: linesToList(val("dlgWorkspaceRoots")), telegramChatIds: checkedValues("[data-scope-telegram]").map(Number).filter(Number.isInteger), discordChannelIds: checkedValues("[data-scope-discord]"), slackChannelIds: checkedValues("[data-scope-slack]") };
|
|
3664
|
+
await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
|
|
3665
|
+
toast(g ? "Group updated" : "Group created");
|
|
3666
|
+
});
|
|
3667
|
+
}
|
|
3668
|
+
switchAccessTab = switchAccessTabV2;
|
|
3669
|
+
bindAccessTabs = bindAccessTabsV2;
|
|
3670
|
+
renderUserManagement = renderUserManagementV2;
|
|
3671
|
+
renderDiscordChannels = renderDiscordChannelsV2;
|
|
3672
|
+
renderSlackChannels = renderSlackChannelsV2;
|
|
3673
|
+
bindUserButtons = bindUserButtonsV2;
|
|
3674
|
+
openGroupDialog = openGroupDialogV2;
|
|
3675
|
+
document.getElementById("closeUserDetailBtn").onclick = () => document.getElementById("userDetailDialog").close();
|
|
3011
3676
|
const SETUP_WIZARDS = {
|
|
3012
3677
|
telegram: {
|
|
3013
3678
|
id: "telegram",
|
|
@@ -3107,9 +3772,14 @@
|
|
|
3107
3772
|
state.settingsWizard = { home: true };
|
|
3108
3773
|
renderSettingsWizardHome();
|
|
3109
3774
|
}
|
|
3775
|
+
function setSettingsChromeVisible(visible) {
|
|
3776
|
+
document.getElementById("settingsTabHeader").style.display = visible ? "" : "none";
|
|
3777
|
+
document.getElementById("settingsSubnav").style.display = visible ? "" : "none";
|
|
3778
|
+
document.getElementById("settingsActions").style.display = visible ? "" : "none";
|
|
3779
|
+
}
|
|
3110
3780
|
function closeSettingsWizard() {
|
|
3111
3781
|
state.settingsWizard = null;
|
|
3112
|
-
|
|
3782
|
+
setSettingsChromeVisible(true);
|
|
3113
3783
|
renderSettings();
|
|
3114
3784
|
}
|
|
3115
3785
|
function wizardRequiredValuePresent(key) {
|
|
@@ -3123,7 +3793,7 @@
|
|
|
3123
3793
|
return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
|
|
3124
3794
|
}
|
|
3125
3795
|
function renderSettingsWizardHome() {
|
|
3126
|
-
|
|
3796
|
+
setSettingsChromeVisible(false);
|
|
3127
3797
|
const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
|
|
3128
3798
|
const missing = wizardMissingRequired(wizard);
|
|
3129
3799
|
const docs = wizardLinkList(wizard.docs);
|
|
@@ -3161,7 +3831,7 @@
|
|
|
3161
3831
|
return;
|
|
3162
3832
|
}
|
|
3163
3833
|
const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
|
|
3164
|
-
|
|
3834
|
+
setSettingsChromeVisible(false);
|
|
3165
3835
|
document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + " setup wizard</h2><p>" + esc(wizard.description) + '</p></div><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div>' + renderWizardProgress(wizard) + '<div class="wizard-step"><h3>' + esc(step.title) + "</h3><p>" + esc(step.body) + "</p>" + wizardLinkList(step.links) + '<div id="wizardRestartBanner"></div><div class="settings-grid">' + step.settings.map(renderWizardSetting).join("") + '</div><div id="wizardErrors" class="wizard-errors"></div><div id="wizardTestResult" class="wizard-test-result"></div><div class="wizard-actions"><button type="button" id="wizardPrevBtn" class="secondary">Back</button><button type="button" id="wizardNextBtn">' + esc(state.settingsWizard.step === wizard.steps.length - 1 ? "Review" : "Next") + '</button><button type="button" id="wizardTestBtn" class="secondary">Test setup</button><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button></div><div class="setting-help">After transport is configured, link users and register allowed chats or channels in the Users page.</div></div></div>';
|
|
3166
3836
|
bindWizardUx();
|
|
3167
3837
|
renderWizardValidation();
|