@nordbyte/nordrelay 0.8.1 → 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/.env.example +9 -0
- package/README.md +84 -1205
- package/dist/{access-control.js → access/access-control.js} +1 -1
- package/dist/{audit-log.js → access/audit-log.js} +32 -15
- package/dist/{session-locks.js → access/session-locks.js} +1 -1
- package/dist/{user-management.js → access/user-management.js} +1 -1
- package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
- package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
- package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
- package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
- package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
- package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
- package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
- package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
- package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
- package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
- package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
- package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
- package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
- package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
- package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
- package/dist/agents/shared/agent-auth-commands.js +30 -0
- package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
- package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
- package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
- package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
- package/dist/{discord-bot.js → channels/discord/discord-bot.js} +176 -451
- package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
- package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
- package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
- package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
- package/dist/channels/shared/channel-bridge-controller.js +69 -0
- package/dist/channels/shared/channel-cli-artifacts.js +51 -0
- package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
- package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
- package/dist/channels/shared/channel-external-monitor.js +52 -0
- package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
- package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
- package/dist/channels/shared/channel-prompt-queue.js +37 -0
- package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +25 -11
- package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
- package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
- package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
- package/dist/{slack-bot.js → channels/slack/slack-bot.js} +171 -309
- package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
- package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
- package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
- package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
- package/dist/{bot.js → channels/telegram/bot.js} +195 -430
- package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
- package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
- package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
- package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
- package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
- package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
- package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
- package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
- package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
- package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
- package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
- package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
- package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
- package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
- package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
- package/dist/{config.js → core/config.js} +11 -3
- package/dist/core/pagination.js +22 -0
- package/dist/index.js +27 -23
- package/dist/peers/peer-discovery-jobs.js +206 -0
- package/dist/peers/peer-discovery.js +223 -0
- package/dist/peers/peer-health-monitor.js +49 -0
- package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
- package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
- package/dist/{peer-server.js → peers/peer-server.js} +3 -2
- package/dist/{peer-store.js → peers/peer-store.js} +96 -9
- package/dist/{peer-types.js → peers/peer-types.js} +28 -0
- package/dist/peers/peer-web-proxy-contract.js +129 -0
- package/dist/{metrics.js → runtime/metrics.js} +5 -3
- package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
- package/dist/runtime/relay-auth-service.js +63 -0
- package/dist/runtime/relay-dashboard-service.js +139 -0
- package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +155 -53
- package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +1 -0
- package/dist/runtime/relay-runtime-active-sessions.js +387 -0
- package/dist/runtime/relay-runtime-dashboard.js +204 -0
- package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +3 -0
- package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +311 -0
- package/dist/runtime/relay-runtime-sessions.js +631 -0
- package/dist/runtime/relay-runtime-trace.js +92 -0
- package/dist/runtime/relay-runtime-types.js +1 -0
- package/dist/runtime/relay-runtime-updates-jobs.js +366 -0
- package/dist/runtime/relay-runtime.js +461 -0
- package/dist/runtime/runtime-cache.js +117 -0
- package/dist/{prompt-store.js → state/prompt-store.js} +13 -1
- package/dist/{session-registry.js → state/session-registry.js} +3 -3
- package/dist/{operations.js → support/operations.js} +7 -7
- package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
- package/dist/{web-api-contract.js → web/web-api-contract.js} +19 -3
- package/dist/web/web-api-types.js +1 -0
- package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +17 -14
- package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +6 -2
- package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +25 -2
- package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
- package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +95 -30
- package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +121 -7
- package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +8 -1
- package/dist/web/web-dashboard-security.js +14 -0
- package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +29 -13
- package/dist/web/web-dashboard-ui.js +56 -0
- package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
- package/dist/web/web-performance.js +62 -0
- package/dist/web/web-rate-limit.js +19 -0
- package/dist/{web-state.js → web/web-state.js} +107 -9
- package/dist/webui-assets/dashboard.css +398 -49
- package/dist/webui-assets/dashboard.js +1239 -103
- package/dist/webui-assets/favicon.ico +0 -0
- package/dist/webui-assets/favicon.png +0 -0
- package/dist/webui-assets/logo.png +0 -0
- package/package.json +6 -3
- package/plugins/nordrelay/scripts/nordrelay.mjs +346 -12
- package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
- package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
- package/scripts/postinstall.mjs +122 -0
- package/dist/relay-runtime.js +0 -1916
- package/dist/runtime-cache.js +0 -57
- package/dist/web-dashboard-ui.js +0 -20
- /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
- /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
- /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
- /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
- /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
- /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
- /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
- /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
- /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
- /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
- /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
- /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
- /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
- /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
- /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
- /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
- /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
- /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
- /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
- /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
- /package/dist/{agent.js → agents/shared/agent.js} +0 -0
- /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
- /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
- /package/dist/{voice.js → artifacts/voice.js} +0 -0
- /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
- /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
- /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
- /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
- /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
- /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
- /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
- /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
- /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
- /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
- /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
- /package/dist/{activity-events.js → core/activity-events.js} +0 -0
- /package/dist/{error-messages.js → core/error-messages.js} +0 -0
- /package/dist/{format.js → core/format.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{redaction.js → core/redaction.js} +0 -0
- /package/dist/{settings-service.js → core/settings-service.js} +0 -0
- /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
- /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
- /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
- /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
- /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
- /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
- /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
- /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
- /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
- /package/dist/{job-store.js → state/job-store.js} +0 -0
- /package/dist/{persistence.js → state/persistence.js} +0 -0
- /package/dist/{state-backend.js → state/state-backend.js} +0 -0
- /package/dist/{zip-writer.js → support/zip-writer.js} +0 -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"] },
|
|
@@ -25,8 +26,17 @@
|
|
|
25
26
|
{ path: "/api/peers/invite", methods: ["POST"] },
|
|
26
27
|
{ path: "/api/peers/pair", methods: ["POST"] },
|
|
27
28
|
{ path: "/api/peers/probe", methods: ["POST"] },
|
|
29
|
+
{ path: "/api/peers/discover", methods: ["GET"] },
|
|
30
|
+
{ path: "/api/peers/discovery-jobs", methods: ["GET", "POST"] },
|
|
31
|
+
{ re: /^\/api\/peers\/discovery-jobs\/[^\/]+$/, methods: ["GET"] },
|
|
32
|
+
{ re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/cancel$/, methods: ["POST"] },
|
|
33
|
+
{ re: /^\/api\/peers\/discovery-jobs\/[^\/]+\/log$/, methods: ["GET"] },
|
|
34
|
+
{ path: "/api/peers/identity/backup", methods: ["GET"] },
|
|
35
|
+
{ path: "/api/peers/identity/restore", methods: ["POST"] },
|
|
28
36
|
{ path: "/api/peers/global-sessions", methods: ["GET"] },
|
|
29
37
|
{ re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
|
|
38
|
+
{ re: /^\/api\/peers\/[^\/]+\/repin$/, methods: ["POST"] },
|
|
39
|
+
{ re: /^\/api\/peers\/[^\/]+\/rotate$/, methods: ["POST"] },
|
|
30
40
|
{ re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
|
|
31
41
|
{ re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
|
|
32
42
|
{ re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
|
|
@@ -79,6 +89,7 @@
|
|
|
79
89
|
{ path: "/api/sync", methods: ["POST"] },
|
|
80
90
|
{ path: "/api/queue", methods: ["GET", "POST"] },
|
|
81
91
|
{ path: "/api/chat/history", methods: ["GET", "DELETE"] },
|
|
92
|
+
{ path: "/api/chat/mirror", methods: ["GET", "POST"] },
|
|
82
93
|
{ path: "/api/activity", methods: ["GET"] },
|
|
83
94
|
{ path: "/api/artifacts", methods: ["GET", "DELETE"] },
|
|
84
95
|
{ path: "/api/artifacts/bulk", methods: ["POST"] },
|
|
@@ -102,6 +113,10 @@
|
|
|
102
113
|
assertApiRoute(url.pathname, method);
|
|
103
114
|
if (!options.local && shouldProxyApi(url.pathname)) {
|
|
104
115
|
const peerId = selectedPeerTarget();
|
|
116
|
+
const csrfToken2 = (
|
|
117
|
+
/** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
|
|
118
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
|
|
119
|
+
);
|
|
105
120
|
const proxyBody = JSON.stringify({
|
|
106
121
|
method,
|
|
107
122
|
path: url.pathname,
|
|
@@ -111,7 +126,7 @@
|
|
|
111
126
|
});
|
|
112
127
|
const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
|
|
113
128
|
method: "POST",
|
|
114
|
-
headers: { "content-type": "application/json" },
|
|
129
|
+
headers: { "content-type": "application/json", ...csrfToken2 ? { "x-nordrelay-csrf": csrfToken2 } : {} },
|
|
115
130
|
body: proxyBody
|
|
116
131
|
});
|
|
117
132
|
if (res2.status === 401) {
|
|
@@ -127,8 +142,13 @@
|
|
|
127
142
|
return data2;
|
|
128
143
|
}
|
|
129
144
|
const body = normalizeBody(options.body);
|
|
145
|
+
const csrfToken = (
|
|
146
|
+
/** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { csrfToken?: string | null } }} */
|
|
147
|
+
globalThis.NORDRELAY_WEBUI_RUNTIME_STATE?.csrfToken
|
|
148
|
+
);
|
|
130
149
|
const headers = {
|
|
131
150
|
...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
|
|
151
|
+
...method !== "GET" && csrfToken ? { "x-nordrelay-csrf": csrfToken } : {},
|
|
132
152
|
...options.headers || {}
|
|
133
153
|
};
|
|
134
154
|
const res = await fetch(url.pathname + url.search, { method, headers, body });
|
|
@@ -148,7 +168,7 @@
|
|
|
148
168
|
const peerId = selectedPeerTarget();
|
|
149
169
|
if (!peerId || peerId === "local") return false;
|
|
150
170
|
if (!path.startsWith("/api/")) return false;
|
|
151
|
-
return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
|
|
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));
|
|
152
172
|
}
|
|
153
173
|
function isLocalAdminApi(path) {
|
|
154
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);
|
|
@@ -230,20 +250,39 @@
|
|
|
230
250
|
throw new Error("Unsupported WebUI API method: " + method + " " + path);
|
|
231
251
|
}
|
|
232
252
|
}
|
|
233
|
-
const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
|
|
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" };
|
|
234
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";
|
|
235
257
|
function toast(msg, options = {}) {
|
|
236
258
|
const el = document.getElementById("toast");
|
|
237
|
-
|
|
238
|
-
el.style.display = "block";
|
|
259
|
+
const text = String(msg ?? "");
|
|
239
260
|
if (state.toastTimer) clearTimeout(state.toastTimer);
|
|
240
261
|
state.toastTimer = null;
|
|
241
|
-
if (
|
|
242
|
-
state.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
262
|
+
if (options.sticky) {
|
|
263
|
+
state.stickyToastActive = true;
|
|
264
|
+
state.stickyToastText = text;
|
|
265
|
+
if (el.textContent !== text) el.textContent = text;
|
|
266
|
+
if (el.style.display !== "block") el.style.display = "block";
|
|
267
|
+
return;
|
|
246
268
|
}
|
|
269
|
+
el.textContent = text;
|
|
270
|
+
el.style.display = "block";
|
|
271
|
+
state.toastTimer = setTimeout(() => {
|
|
272
|
+
state.toastTimer = null;
|
|
273
|
+
if (state.stickyToastActive) {
|
|
274
|
+
el.textContent = state.stickyToastText;
|
|
275
|
+
el.style.display = "block";
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
el.style.display = "none";
|
|
279
|
+
}, options.duration || 3500);
|
|
280
|
+
}
|
|
281
|
+
function clearStickyToast() {
|
|
282
|
+
state.stickyToastActive = false;
|
|
283
|
+
state.stickyToastText = "";
|
|
284
|
+
if (state.toastTimer) clearTimeout(state.toastTimer);
|
|
285
|
+
state.toastTimer = null;
|
|
247
286
|
}
|
|
248
287
|
function esc(s) {
|
|
249
288
|
return String(s ?? "").replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
@@ -314,6 +353,7 @@
|
|
|
314
353
|
el.hidden = !allowed;
|
|
315
354
|
el.disabled = !allowed;
|
|
316
355
|
});
|
|
356
|
+
syncNavSections();
|
|
317
357
|
const currentButton = document.querySelector('nav button[data-page="' + cssEscape(state.currentPage) + '"]');
|
|
318
358
|
if (currentButton && currentButton.hidden) {
|
|
319
359
|
const first = [...document.querySelectorAll("nav button[data-page]")].find((b) => !b.hidden);
|
|
@@ -325,6 +365,7 @@
|
|
|
325
365
|
["#newSessionBtn,#attachBtn,#createSessionBtn", "sessions.write"],
|
|
326
366
|
["#retryBtn", "prompt.send"],
|
|
327
367
|
["#syncBtn,#handbackBtn", "sessions.write"],
|
|
368
|
+
["#mirrorModeSelect", "settings.write"],
|
|
328
369
|
["#abortBtn", "prompt.abort"],
|
|
329
370
|
["#clearChatBtn", "sessions.write"],
|
|
330
371
|
["#saveSettingsBtn", "settings.write"],
|
|
@@ -334,7 +375,8 @@
|
|
|
334
375
|
["#clearLogsBtn", "logs.clear"],
|
|
335
376
|
["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
|
|
336
377
|
["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
|
|
337
|
-
["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
|
|
378
|
+
["#checkPeerReachabilityBtn,#discoverPeersBtn,#cancelPeerDiscoveryBtn,[data-peer-probe]", "peers.connect"],
|
|
379
|
+
["#exportPeerIdentityBtn,#restorePeerIdentityBtn,[data-peer-repin],[data-peer-rotate]", "peers.write"],
|
|
338
380
|
["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
|
|
339
381
|
["[data-switch]", "sessions.write"],
|
|
340
382
|
["[data-queue],[data-q]", "queue.write"],
|
|
@@ -348,6 +390,59 @@
|
|
|
348
390
|
if (!can(permission)) el.title = "Permission required: " + permission;
|
|
349
391
|
}));
|
|
350
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
|
+
}
|
|
351
446
|
function modelLabel(m) {
|
|
352
447
|
const meta = [m.contextWindow ? compactNum(m.contextWindow) : "", m.supportsImages === true ? "img" : m.supportsImages === false ? "text" : "", m.supportsThinking === true ? "think" : ""].filter(Boolean).join(" ");
|
|
353
448
|
return (m.displayName || m.slug) + (meta ? " \xB7 " + meta : "");
|
|
@@ -360,10 +455,10 @@
|
|
|
360
455
|
return Math.floor(min / 60) + "h ago";
|
|
361
456
|
}
|
|
362
457
|
function isCliRunningStatus(msg) {
|
|
363
|
-
return / CLI running
|
|
458
|
+
return / CLI running\b/.test(String(msg || ""));
|
|
364
459
|
}
|
|
365
460
|
function isCliDoneStatus(msg) {
|
|
366
|
-
return / CLI task
|
|
461
|
+
return / CLI task (?:finished|completed|failed|aborted)\b/i.test(String(msg || ""));
|
|
367
462
|
}
|
|
368
463
|
function applyTheme(theme) {
|
|
369
464
|
document.documentElement.dataset.theme = theme;
|
|
@@ -373,11 +468,28 @@
|
|
|
373
468
|
function toggleTheme() {
|
|
374
469
|
applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark");
|
|
375
470
|
}
|
|
471
|
+
function setToolsVisible(visible) {
|
|
472
|
+
state.toolsVisible = Boolean(visible);
|
|
473
|
+
const layout = document.getElementById("chatLayout");
|
|
474
|
+
const panel = document.getElementById("toolPanel");
|
|
475
|
+
const button = document.getElementById("toggleToolsBtn");
|
|
476
|
+
layout?.classList.toggle("tools-hidden", !state.toolsVisible);
|
|
477
|
+
if (panel) panel.hidden = !state.toolsVisible;
|
|
478
|
+
if (button) {
|
|
479
|
+
button.textContent = state.toolsVisible ? "Hide Tools" : "Show Tools";
|
|
480
|
+
button.setAttribute("aria-expanded", state.toolsVisible ? "true" : "false");
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function toggleTools() {
|
|
484
|
+
setToolsVisible(!state.toolsVisible);
|
|
485
|
+
}
|
|
376
486
|
function page(name) {
|
|
377
487
|
state.currentPage = name;
|
|
378
|
-
|
|
488
|
+
openSectionForPage(name);
|
|
489
|
+
document.querySelectorAll("nav button[data-page]").forEach((b) => b.classList.toggle("active", b.dataset.page === name));
|
|
490
|
+
syncNavSections();
|
|
379
491
|
document.querySelectorAll(".page").forEach((p) => p.classList.toggle("active", p.id === "page-" + name));
|
|
380
|
-
document.getElementById("pageTitle").textContent = name[0].toUpperCase() + name.slice(1);
|
|
492
|
+
document.getElementById("pageTitle").textContent = PAGE_LABELS[name] || name[0].toUpperCase() + name.slice(1);
|
|
381
493
|
document.getElementById("sidebar").classList.remove("open");
|
|
382
494
|
void reloadCurrentPage().catch((err) => toast(err.message || String(err)));
|
|
383
495
|
}
|
|
@@ -385,8 +497,8 @@
|
|
|
385
497
|
const name = state.currentPage;
|
|
386
498
|
if (name === "overview") await loadActiveSessions();
|
|
387
499
|
if (name === "chat") {
|
|
388
|
-
await loadChatHistory();
|
|
389
|
-
scrollChatToBottom();
|
|
500
|
+
const [historyRendered] = await Promise.all([loadChatHistory({ forceScroll: true }), loadMirrorPreference()]);
|
|
501
|
+
if (historyRendered) scrollChatToBottom({ force: true });
|
|
390
502
|
}
|
|
391
503
|
if (name === "sessions") await loadSessions(true, options.agentId);
|
|
392
504
|
if (name === "settings") await loadSettings();
|
|
@@ -394,6 +506,7 @@
|
|
|
394
506
|
if (name === "diagnostics") await loadDiagnostics();
|
|
395
507
|
if (name === "artifacts") await loadArtifacts();
|
|
396
508
|
if (name === "activity") await loadActivity();
|
|
509
|
+
if (name === "trace") renderTracePlaceholder();
|
|
397
510
|
if (name === "tasks") await loadTasks();
|
|
398
511
|
if (name === "metrics") await loadMetrics();
|
|
399
512
|
if (name === "adapters") await loadAdapterHealth();
|
|
@@ -401,15 +514,46 @@
|
|
|
401
514
|
if (name === "access") await loadAccess();
|
|
402
515
|
if (name === "version") await loadVersion();
|
|
403
516
|
}
|
|
404
|
-
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();
|
|
405
525
|
document.getElementById("menuBtn").onclick = () => document.getElementById("sidebar").classList.toggle("open");
|
|
406
|
-
document.getElementById("refreshBtn").onclick = () => loadBootstrap();
|
|
407
526
|
document.getElementById("themeBtn").onclick = toggleTheme;
|
|
527
|
+
document.getElementById("toggleToolsBtn").onclick = toggleTools;
|
|
408
528
|
document.getElementById("logoutBtn").onclick = () => safe(async () => {
|
|
409
529
|
await api("/api/dashboard/logout", { method: "POST" });
|
|
410
530
|
location.href = "/";
|
|
411
531
|
});
|
|
412
532
|
applyTheme(localStorage.getItem("nordrelayTheme") || "light");
|
|
533
|
+
setToolsVisible(false);
|
|
534
|
+
function uiBadge(text, status = "enabled") {
|
|
535
|
+
return '<span class="adapter-status ' + esc(status) + '">' + esc(text) + "</span>";
|
|
536
|
+
}
|
|
537
|
+
function uiRows(rows = []) {
|
|
538
|
+
return rows.filter(Boolean).map((row) => Array.isArray(row) ? "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>" : "<small>" + esc(row) + "</small>").join("");
|
|
539
|
+
}
|
|
540
|
+
function uiItem(title, options = {}) {
|
|
541
|
+
const badge = options.badge ? uiBadge(options.badge.text, options.badge.status) : "";
|
|
542
|
+
const rows = uiRows(options.rows || []);
|
|
543
|
+
const body = options.body || "";
|
|
544
|
+
const actions = options.actions ? '<div class="row">' + options.actions + "</div>" : "";
|
|
545
|
+
const titleAttr = options.title ? ' title="' + attr(options.title) + '"' : "";
|
|
546
|
+
return '<div class="item ' + (options.className ? attr(options.className) : "") + '"><strong' + titleAttr + ">" + esc(title) + " " + badge + "</strong>" + rows + body + actions + "</div>";
|
|
547
|
+
}
|
|
548
|
+
function uiEmpty(text) {
|
|
549
|
+
return '<div class="item">' + esc(text) + "</div>";
|
|
550
|
+
}
|
|
551
|
+
function uiCopyButton(value, label = "Copied", className = "copy-id") {
|
|
552
|
+
return value ? '<button type="button" class="' + attr(className) + '" data-copy-value="' + attr(value) + '" data-copy-label="' + attr(label) + '">' + esc(value) + "</button>" : "-";
|
|
553
|
+
}
|
|
554
|
+
function bindUiCopyButtons(root = document) {
|
|
555
|
+
root.querySelectorAll?.("[data-copy-value]").forEach((b) => b.onclick = () => copyText(b.dataset.copyValue || "", b.dataset.copyLabel || "Copied"));
|
|
556
|
+
}
|
|
413
557
|
function createPaginator(containerId, onChange, pageSize = 50) {
|
|
414
558
|
const container = document.getElementById(containerId);
|
|
415
559
|
return {
|
|
@@ -440,9 +584,53 @@
|
|
|
440
584
|
};
|
|
441
585
|
}
|
|
442
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));
|
|
443
630
|
async function loadBootstrap() {
|
|
444
631
|
const local = await api("/api/bootstrap", { local: true });
|
|
445
632
|
state.auth = local.auth || null;
|
|
633
|
+
state.csrfToken = local.auth?.csrfToken || state.csrfToken || null;
|
|
446
634
|
state.permissions = local.auth?.permissions || [];
|
|
447
635
|
await loadPeerSelector();
|
|
448
636
|
const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
|
|
@@ -562,14 +750,14 @@
|
|
|
562
750
|
}
|
|
563
751
|
function activeSessionCard(s) {
|
|
564
752
|
const thread = s.threadId || "not started";
|
|
565
|
-
const
|
|
753
|
+
const prompt2 = s.prompt ? "<small>" + esc(short(s.prompt, 250)) + "</small>" : "";
|
|
566
754
|
const tool2 = s.currentTool || s.lastTool || "-";
|
|
567
755
|
const queue = s.queueLength ? " \xB7 " + s.queueLength + " queued" + (s.queuePaused ? " paused" : "") : "";
|
|
568
756
|
const sourceLabel = activeSourceLabel(s.source);
|
|
569
757
|
const mirrors = (s.mirrorChannels || []).map((m) => activeSourceLabel(m.source) + " " + m.mode + (m.queueLength ? " \xB7 " + m.queueLength + " queued" + (m.queuePaused ? " paused" : "") : "")).join(", ");
|
|
570
758
|
const meta = ["Source " + sourceLabel, s.workspace, fmtDuration(s.durationMs), tool2 && tool2 !== "-" ? "tool " + tool2 : ""].filter(Boolean).join(" | ");
|
|
571
759
|
const mirrorLine = mirrors ? "<small>Mirroring: " + esc(mirrors) + "</small>" : s.source === "cli" ? "<small>Mirroring: none</small>" : "";
|
|
572
|
-
return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine +
|
|
760
|
+
return '<div class="item active-session-item"><strong>' + esc(s.agentLabel || s.agentId || "Agent") + ' <span class="adapter-status enabled">' + esc(s.status) + '</span></strong><small><button type="button" class="copy-id" data-active-copy="' + attr(thread) + '" title="Copy thread ID">' + esc(short(thread, 64)) + "</button>" + esc(queue) + "</small><small>" + esc(meta) + "</small>" + mirrorLine + prompt2 + '<div class="row"><button data-active-switch="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + disabledAttr("sessions.write") + '>Switch</button><button class="secondary" data-active-detail="' + attr(thread) + '" data-active-agent="' + attr(s.agentId || "") + '" ' + (!s.threadId ? "disabled " : "") + ">Details</button></div></div>";
|
|
573
761
|
}
|
|
574
762
|
function activeSourceLabel(source) {
|
|
575
763
|
if (source === "cli") return "CLI";
|
|
@@ -672,9 +860,19 @@
|
|
|
672
860
|
if (status === "running") return "planned";
|
|
673
861
|
return "disabled";
|
|
674
862
|
}
|
|
675
|
-
|
|
863
|
+
const CHAT_CODE_BLOCK_PREFIX = "\uE010C";
|
|
864
|
+
const CHAT_CODE_BLOCK_SUFFIX = "\uE010";
|
|
865
|
+
const CHAT_INLINE_CODE_PREFIX = "\uE011I";
|
|
866
|
+
const CHAT_INLINE_CODE_SUFFIX = "\uE011";
|
|
867
|
+
function isChatNearBottom() {
|
|
868
|
+
const box = document.getElementById("messages");
|
|
869
|
+
if (!box) return true;
|
|
870
|
+
return box.scrollHeight - box.scrollTop - box.clientHeight < 80;
|
|
871
|
+
}
|
|
872
|
+
function scrollChatToBottom(options = {}) {
|
|
676
873
|
const box = document.getElementById("messages");
|
|
677
874
|
if (!box) return;
|
|
875
|
+
if (!options.force && !isChatNearBottom()) return;
|
|
678
876
|
requestAnimationFrame(() => {
|
|
679
877
|
box.scrollTop = box.scrollHeight;
|
|
680
878
|
requestAnimationFrame(() => {
|
|
@@ -682,39 +880,251 @@
|
|
|
682
880
|
});
|
|
683
881
|
});
|
|
684
882
|
}
|
|
685
|
-
function
|
|
883
|
+
function markChatRendered() {
|
|
884
|
+
state.chatRenderVersion = (state.chatRenderVersion || 0) + 1;
|
|
885
|
+
}
|
|
886
|
+
function appendMessage(cls, text, options = {}) {
|
|
686
887
|
const box = document.getElementById("messages");
|
|
888
|
+
const stick = options.forceScroll || isChatNearBottom();
|
|
889
|
+
const previousTop = box?.scrollTop ?? 0;
|
|
687
890
|
const div = document.createElement("div");
|
|
688
891
|
div.className = "message " + cls;
|
|
689
|
-
|
|
892
|
+
const body = document.createElement("div");
|
|
893
|
+
body.className = "message-body";
|
|
894
|
+
div.appendChild(body);
|
|
895
|
+
setMessageText(div, text);
|
|
690
896
|
box.appendChild(div);
|
|
691
|
-
scrollChatToBottom();
|
|
897
|
+
if (stick) scrollChatToBottom({ force: true });
|
|
898
|
+
else box.scrollTop = previousTop;
|
|
899
|
+
return div;
|
|
900
|
+
}
|
|
901
|
+
function messageBody(div) {
|
|
902
|
+
let body = div.querySelector?.(".message-body");
|
|
903
|
+
if (!body) {
|
|
904
|
+
body = document.createElement("div");
|
|
905
|
+
body.className = "message-body";
|
|
906
|
+
div.textContent = "";
|
|
907
|
+
div.appendChild(body);
|
|
908
|
+
}
|
|
909
|
+
return body;
|
|
910
|
+
}
|
|
911
|
+
function setMessageText(div, text) {
|
|
912
|
+
div.__rawText = String(text ?? "");
|
|
913
|
+
const body = messageBody(div);
|
|
914
|
+
body.innerHTML = renderChatMarkdown(div.__rawText);
|
|
915
|
+
bindChatCopyButtons(body);
|
|
916
|
+
markChatRendered();
|
|
692
917
|
return div;
|
|
693
918
|
}
|
|
694
919
|
function appendQueuedMessage(id) {
|
|
695
920
|
const div = appendMessage("system", "Queued prompt " + id);
|
|
921
|
+
const body = messageBody(div);
|
|
696
922
|
const btn = document.createElement("button");
|
|
697
923
|
btn.textContent = "Cancel queued message";
|
|
698
924
|
btn.className = "danger";
|
|
699
925
|
btn.onclick = () => safe(async () => {
|
|
700
926
|
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: "cancel", id }) });
|
|
701
927
|
renderQueue(r.queue, r.paused);
|
|
702
|
-
div
|
|
928
|
+
setMessageText(div, "Cancelled queued prompt " + id);
|
|
703
929
|
});
|
|
704
|
-
|
|
705
|
-
|
|
930
|
+
body.appendChild(document.createElement("br"));
|
|
931
|
+
body.appendChild(btn);
|
|
706
932
|
}
|
|
707
|
-
function renderChatMessages(messages) {
|
|
933
|
+
function renderChatMessages(messages, options = {}) {
|
|
708
934
|
state.chatMessages = messages || [];
|
|
709
935
|
const box = document.getElementById("messages");
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
936
|
+
const stick = options.forceScroll || isChatNearBottom();
|
|
937
|
+
const previousTop = box?.scrollTop ?? 0;
|
|
938
|
+
box.innerHTML = (messages || []).map((m) => '<div class="message ' + esc(m.role) + '"><small>' + esc((m.source || "web") + " / " + fmtDate(m.timestamp)) + '</small><br><div class="message-body">' + renderChatMarkdown(m.text) + "</div></div>").join("");
|
|
939
|
+
bindChatCopyButtons(box);
|
|
940
|
+
markChatRendered();
|
|
941
|
+
if (stick) scrollChatToBottom({ force: true });
|
|
942
|
+
else box.scrollTop = previousTop;
|
|
943
|
+
}
|
|
944
|
+
async function loadChatHistory(options = {}) {
|
|
945
|
+
const requestId = (state.chatHistoryRequestId || 0) + 1;
|
|
946
|
+
state.chatHistoryRequestId = requestId;
|
|
947
|
+
const renderVersion = state.chatRenderVersion || 0;
|
|
714
948
|
const data = await api("/api/chat/history");
|
|
715
|
-
|
|
949
|
+
if (requestId !== state.chatHistoryRequestId) return false;
|
|
950
|
+
if (options.skipIfRendered !== false && (state.chatRenderVersion || 0) !== renderVersion) return false;
|
|
951
|
+
renderChatMessages(data.messages || [], options);
|
|
952
|
+
return true;
|
|
716
953
|
}
|
|
717
954
|
let currentAgentMessage = null;
|
|
955
|
+
function renderChatMarkdown(text) {
|
|
956
|
+
let output = esc(String(text ?? ""));
|
|
957
|
+
const codeBlocks = [];
|
|
958
|
+
const inlineCode = [];
|
|
959
|
+
output = extractChatCodeBlocks(output, codeBlocks);
|
|
960
|
+
output = extractChatInlineCode(output, inlineCode);
|
|
961
|
+
output = formatChatBold(output);
|
|
962
|
+
output = formatChatItalic(output);
|
|
963
|
+
output = formatChatLinks(output);
|
|
964
|
+
output = formatChatBlockquotes(output);
|
|
965
|
+
output = formatChatLists(output);
|
|
966
|
+
output = formatChatHeadings(output);
|
|
967
|
+
output = restoreChatMarkdown(output, CHAT_INLINE_CODE_PREFIX, CHAT_INLINE_CODE_SUFFIX, inlineCode);
|
|
968
|
+
output = restoreChatMarkdown(output, CHAT_CODE_BLOCK_PREFIX, CHAT_CODE_BLOCK_SUFFIX, codeBlocks);
|
|
969
|
+
return output;
|
|
970
|
+
}
|
|
971
|
+
function extractChatCodeBlocks(text, blocks) {
|
|
972
|
+
return text.replace(/```([^\n`]*)\n?([\s\S]*?)```/g, (_match, rawLanguage, rawCode) => {
|
|
973
|
+
const language = String(rawLanguage || "").trim().replace(/[^a-zA-Z0-9_+-]/g, "");
|
|
974
|
+
const className = language ? ' class="language-' + attr(language) + '"' : "";
|
|
975
|
+
const label = language ? ' data-code-language="' + attr(language) + '"' : "";
|
|
976
|
+
const block = '<pre class="chat-code-block" tabindex="0" title="Copy code" data-chat-copy="code-block"' + label + "><code" + className + ">" + rawCode + "</code></pre>";
|
|
977
|
+
const index = blocks.push(block) - 1;
|
|
978
|
+
return CHAT_CODE_BLOCK_PREFIX + index + CHAT_CODE_BLOCK_SUFFIX;
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
function extractChatInlineCode(text, inline) {
|
|
982
|
+
let result = "";
|
|
983
|
+
let index = 0;
|
|
984
|
+
while (index < text.length) {
|
|
985
|
+
if (text[index] !== "`") {
|
|
986
|
+
result += text[index];
|
|
987
|
+
index += 1;
|
|
988
|
+
continue;
|
|
989
|
+
}
|
|
990
|
+
let tickCount = 1;
|
|
991
|
+
while (text[index + tickCount] === "`") tickCount += 1;
|
|
992
|
+
const fence = "`".repeat(tickCount);
|
|
993
|
+
const start = index + tickCount;
|
|
994
|
+
const end = text.indexOf(fence, start);
|
|
995
|
+
if (end === -1) {
|
|
996
|
+
result += fence;
|
|
997
|
+
index += tickCount;
|
|
998
|
+
continue;
|
|
999
|
+
}
|
|
1000
|
+
const content = text.slice(start, end);
|
|
1001
|
+
if (content.includes("\n")) {
|
|
1002
|
+
result += fence;
|
|
1003
|
+
index += tickCount;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
const button = '<button type="button" class="chat-inline-code copy-id" title="Copy code" data-chat-copy="inline-code">' + content + "</button>";
|
|
1007
|
+
result += CHAT_INLINE_CODE_PREFIX + (inline.push(button) - 1) + CHAT_INLINE_CODE_SUFFIX;
|
|
1008
|
+
index = end + tickCount;
|
|
1009
|
+
}
|
|
1010
|
+
return result;
|
|
1011
|
+
}
|
|
1012
|
+
function formatChatBold(text) {
|
|
1013
|
+
return text.replace(/(?<!\*)\*\*(?!\s)([^\n]*?\S)\*\*(?!\*)/g, "<strong>$1</strong>");
|
|
1014
|
+
}
|
|
1015
|
+
function formatChatItalic(text) {
|
|
1016
|
+
return text.replace(/(?<![\w_])_(?!\s)([^_\n]*?\S)_(?![\w_])/g, "<em>$1</em>").replace(/(?<![\w*])\*(?!\s)([^*\n]*?\S)\*(?![\w*])/g, "<em>$1</em>");
|
|
1017
|
+
}
|
|
1018
|
+
function formatChatLinks(text) {
|
|
1019
|
+
return text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => {
|
|
1020
|
+
const safeUrl = sanitizeChatUrl(String(url).replace(/&/g, "&"));
|
|
1021
|
+
return '<a class="chat-link" href="' + attr(safeUrl) + '" target="_blank" rel="noreferrer noopener">' + label + "</a>";
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
function sanitizeChatUrl(url) {
|
|
1025
|
+
const trimmed = String(url || "").trim().replace(/"/g, "%22");
|
|
1026
|
+
return /^(https?|mailto):/i.test(trimmed) ? trimmed : "#";
|
|
1027
|
+
}
|
|
1028
|
+
function formatChatBlockquotes(text) {
|
|
1029
|
+
const lines = text.split("\n");
|
|
1030
|
+
const out = [];
|
|
1031
|
+
let quote = [];
|
|
1032
|
+
const flush = () => {
|
|
1033
|
+
if (quote.length) {
|
|
1034
|
+
out.push('<blockquote class="chat-blockquote">' + quote.join("\n") + "</blockquote>");
|
|
1035
|
+
quote = [];
|
|
1036
|
+
}
|
|
1037
|
+
};
|
|
1038
|
+
for (const line of lines) {
|
|
1039
|
+
const match = line.match(/^> ?(.*)$/);
|
|
1040
|
+
if (match) {
|
|
1041
|
+
quote.push(match[1]);
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
flush();
|
|
1045
|
+
out.push(line);
|
|
1046
|
+
}
|
|
1047
|
+
flush();
|
|
1048
|
+
return out.join("\n");
|
|
1049
|
+
}
|
|
1050
|
+
function formatChatLists(text) {
|
|
1051
|
+
const lines = text.split("\n");
|
|
1052
|
+
const out = [];
|
|
1053
|
+
let list = null;
|
|
1054
|
+
const flush = () => {
|
|
1055
|
+
if (list) {
|
|
1056
|
+
out.push("</" + list + ">");
|
|
1057
|
+
list = null;
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
for (const line of lines) {
|
|
1061
|
+
const task = line.match(/^\s*[-*]\s+\[([ xX])\]\s+(.+)$/);
|
|
1062
|
+
const bullet = line.match(/^\s*[-*]\s+(.+)$/);
|
|
1063
|
+
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
1064
|
+
if (task) {
|
|
1065
|
+
if (list !== "ul") {
|
|
1066
|
+
flush();
|
|
1067
|
+
out.push('<ul class="chat-list chat-task-list">');
|
|
1068
|
+
list = "ul";
|
|
1069
|
+
}
|
|
1070
|
+
const checked = task[1].toLowerCase() === "x" ? "[x]" : "[ ]";
|
|
1071
|
+
out.push('<li><span class="chat-task-mark">' + checked + "</span> " + task[2] + "</li>");
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
if (bullet) {
|
|
1075
|
+
if (list !== "ul") {
|
|
1076
|
+
flush();
|
|
1077
|
+
out.push('<ul class="chat-list">');
|
|
1078
|
+
list = "ul";
|
|
1079
|
+
}
|
|
1080
|
+
out.push("<li>" + bullet[1] + "</li>");
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
if (ordered) {
|
|
1084
|
+
if (list !== "ol") {
|
|
1085
|
+
flush();
|
|
1086
|
+
out.push('<ol class="chat-list">');
|
|
1087
|
+
list = "ol";
|
|
1088
|
+
}
|
|
1089
|
+
out.push("<li>" + ordered[1] + "</li>");
|
|
1090
|
+
continue;
|
|
1091
|
+
}
|
|
1092
|
+
flush();
|
|
1093
|
+
out.push(line);
|
|
1094
|
+
}
|
|
1095
|
+
flush();
|
|
1096
|
+
return out.join("\n");
|
|
1097
|
+
}
|
|
1098
|
+
function formatChatHeadings(text) {
|
|
1099
|
+
return text.split("\n").map((line) => {
|
|
1100
|
+
const match = line.match(/^(#{1,4})\s+(.+)$/);
|
|
1101
|
+
return match ? '<strong class="chat-heading chat-heading-' + match[1].length + '">' + match[2] + "</strong>" : line;
|
|
1102
|
+
}).join("\n");
|
|
1103
|
+
}
|
|
1104
|
+
function restoreChatMarkdown(text, prefix, suffix, values) {
|
|
1105
|
+
const pattern = new RegExp(escapeChatRegExp(prefix) + "(\\d+)" + escapeChatRegExp(suffix), "g");
|
|
1106
|
+
return text.replace(pattern, (_match, rawIndex) => values[Number.parseInt(rawIndex, 10)] ?? "");
|
|
1107
|
+
}
|
|
1108
|
+
function escapeChatRegExp(text) {
|
|
1109
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1110
|
+
}
|
|
1111
|
+
function bindChatCopyButtons(root) {
|
|
1112
|
+
root.querySelectorAll?.("[data-chat-copy]").forEach((el) => {
|
|
1113
|
+
el.onclick = (event) => {
|
|
1114
|
+
event.preventDefault();
|
|
1115
|
+
event.stopPropagation();
|
|
1116
|
+
const code = el.dataset.chatCopy === "code-block" ? el.querySelector("code")?.textContent : el.textContent;
|
|
1117
|
+
copyText(code || "", "Copied code");
|
|
1118
|
+
};
|
|
1119
|
+
el.onkeydown = (event) => {
|
|
1120
|
+
if (el.tagName === "BUTTON") return;
|
|
1121
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
1122
|
+
event.preventDefault();
|
|
1123
|
+
el.click();
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
718
1128
|
function connectEvents() {
|
|
719
1129
|
if (state.events) state.events.close();
|
|
720
1130
|
const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
|
|
@@ -765,9 +1175,10 @@
|
|
|
765
1175
|
});
|
|
766
1176
|
events.addEventListener("text_delta", (e) => {
|
|
767
1177
|
const d = JSON.parse(e.data);
|
|
1178
|
+
const stick = isChatNearBottom();
|
|
768
1179
|
if (!currentAgentMessage) currentAgentMessage = appendMessage("agent", "");
|
|
769
|
-
currentAgentMessage.
|
|
770
|
-
scrollChatToBottom();
|
|
1180
|
+
setMessageText(currentAgentMessage, (currentAgentMessage.__rawText || "") + d.delta);
|
|
1181
|
+
if (stick) scrollChatToBottom({ force: true });
|
|
771
1182
|
if (state.currentPage === "tasks") loadTasks();
|
|
772
1183
|
});
|
|
773
1184
|
events.addEventListener("tool_start", (e) => {
|
|
@@ -785,7 +1196,7 @@
|
|
|
785
1196
|
});
|
|
786
1197
|
events.addEventListener("todo_update", (e) => {
|
|
787
1198
|
const d = JSON.parse(e.data);
|
|
788
|
-
tool("tool", "Plan
|
|
1199
|
+
tool("tool", "Plan:\n" + d.items.map((i) => (i.completed ? "[x] " : "[ ] ") + i.text).join("\n"));
|
|
789
1200
|
});
|
|
790
1201
|
events.addEventListener("turn_error", (e) => {
|
|
791
1202
|
const d = JSON.parse(e.data);
|
|
@@ -806,7 +1217,10 @@
|
|
|
806
1217
|
toast(msg, { sticky: true });
|
|
807
1218
|
return;
|
|
808
1219
|
}
|
|
809
|
-
if (isCliDoneStatus(msg))
|
|
1220
|
+
if (isCliDoneStatus(msg)) {
|
|
1221
|
+
state.cliStatusActive = false;
|
|
1222
|
+
clearStickyToast();
|
|
1223
|
+
}
|
|
810
1224
|
toast(msg);
|
|
811
1225
|
});
|
|
812
1226
|
events.onerror = () => {
|
|
@@ -971,6 +1385,27 @@
|
|
|
971
1385
|
});
|
|
972
1386
|
document.getElementById("promptForm").onsubmit = (e) => safe(async () => {
|
|
973
1387
|
e.preventDefault();
|
|
1388
|
+
const input = document.getElementById("promptInput");
|
|
1389
|
+
const text = input.value.trim();
|
|
1390
|
+
if (/^\/mirror\b/i.test(text)) {
|
|
1391
|
+
if (selectedFiles.length) {
|
|
1392
|
+
toast("/mirror cannot be sent with attachments");
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
const argument = text.replace(/^\/mirror\b/i, "").trim();
|
|
1396
|
+
if (argument && !can("settings.write")) {
|
|
1397
|
+
toast("Permission required: settings.write");
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (!argument && !can("sessions.read")) {
|
|
1401
|
+
toast("Permission required: sessions.read");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
input.value = "";
|
|
1405
|
+
const data = argument ? await setMirrorPreference(argument) : await loadMirrorPreference();
|
|
1406
|
+
appendMessage("system", data?.response?.plain || "CLI mirroring: " + (data?.mode || "-"));
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
974
1409
|
if (!can("prompt.send")) {
|
|
975
1410
|
toast("Permission required: prompt.send");
|
|
976
1411
|
return;
|
|
@@ -979,8 +1414,6 @@
|
|
|
979
1414
|
toast("Permission required: files.write");
|
|
980
1415
|
return;
|
|
981
1416
|
}
|
|
982
|
-
const input = document.getElementById("promptInput");
|
|
983
|
-
const text = input.value.trim();
|
|
984
1417
|
if (!text && selectedFiles.length === 0) return;
|
|
985
1418
|
const files = selectedFiles;
|
|
986
1419
|
input.value = "";
|
|
@@ -989,7 +1422,7 @@
|
|
|
989
1422
|
renderSelectedFiles();
|
|
990
1423
|
const payloadFiles = files.length ? await Promise.all(files.map(filePayload)) : [];
|
|
991
1424
|
const r = files.length ? await api("/api/prompt/upload", { method: "POST", body: JSON.stringify({ text, files: payloadFiles }) }) : await api("/api/prompt", { method: "POST", body: JSON.stringify({ text }) });
|
|
992
|
-
if (r.transcribeOnly) appendMessage("system", "Transcribed audio
|
|
1425
|
+
if (r.transcribeOnly) appendMessage("system", "Transcribed audio:\n" + (r.transcript || "(empty)"));
|
|
993
1426
|
else if (r.queued) appendQueuedMessage(r.queueId);
|
|
994
1427
|
}, e);
|
|
995
1428
|
document.getElementById("newSessionBtn").onclick = () => {
|
|
@@ -1024,6 +1457,14 @@
|
|
|
1024
1457
|
loadBootstrap();
|
|
1025
1458
|
});
|
|
1026
1459
|
document.getElementById("notifyBtn").onclick = () => enableNotifications();
|
|
1460
|
+
document.getElementById("mirrorModeSelect").onchange = () => safe(async () => {
|
|
1461
|
+
if (!can("settings.write")) {
|
|
1462
|
+
toast("Permission required: settings.write");
|
|
1463
|
+
renderMirrorPreference(state.webMirror);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
await setMirrorPreference(document.getElementById("mirrorModeSelect").value);
|
|
1467
|
+
});
|
|
1027
1468
|
document.getElementById("clearChatBtn").onclick = () => safe(async () => {
|
|
1028
1469
|
if (!can("sessions.write")) {
|
|
1029
1470
|
toast("Permission required: sessions.write");
|
|
@@ -1049,7 +1490,7 @@
|
|
|
1049
1490
|
return;
|
|
1050
1491
|
}
|
|
1051
1492
|
const r = await api("/api/handback", { method: "POST" });
|
|
1052
|
-
appendMessage("system", "Handback command
|
|
1493
|
+
appendMessage("system", "Handback command:\n" + (r.command || "No command available"));
|
|
1053
1494
|
});
|
|
1054
1495
|
document.getElementById("recordBtn").onclick = () => safe(async () => {
|
|
1055
1496
|
if (!can("files.write")) {
|
|
@@ -1077,6 +1518,24 @@
|
|
|
1077
1518
|
state.mediaRecorder.start();
|
|
1078
1519
|
btn.textContent = "Stop recording";
|
|
1079
1520
|
});
|
|
1521
|
+
function renderMirrorPreference(data) {
|
|
1522
|
+
if (!data) return;
|
|
1523
|
+
state.webMirror = data;
|
|
1524
|
+
const select = document.getElementById("mirrorModeSelect");
|
|
1525
|
+
if (select && data.mode) select.value = data.mode;
|
|
1526
|
+
}
|
|
1527
|
+
async function loadMirrorPreference() {
|
|
1528
|
+
if (!can("sessions.read")) return null;
|
|
1529
|
+
const data = await api("/api/chat/mirror");
|
|
1530
|
+
renderMirrorPreference(data);
|
|
1531
|
+
return data;
|
|
1532
|
+
}
|
|
1533
|
+
async function setMirrorPreference(argument) {
|
|
1534
|
+
const data = await api("/api/chat/mirror", { method: "POST", body: JSON.stringify({ argument }) });
|
|
1535
|
+
renderMirrorPreference(data);
|
|
1536
|
+
toast("Mirror " + data.mode);
|
|
1537
|
+
return data;
|
|
1538
|
+
}
|
|
1080
1539
|
function renderNewSessionControls(c) {
|
|
1081
1540
|
const s = state.snapshot?.session || {};
|
|
1082
1541
|
const caps = c.capabilities || {};
|
|
@@ -1120,7 +1579,7 @@
|
|
|
1120
1579
|
document.getElementById("newSessionDialog").close();
|
|
1121
1580
|
toast("New session started");
|
|
1122
1581
|
await loadBootstrap();
|
|
1123
|
-
await loadChatHistory();
|
|
1582
|
+
await loadChatHistory({ forceScroll: true });
|
|
1124
1583
|
}, e);
|
|
1125
1584
|
document.getElementById("cancelSessionBtn").onclick = () => document.getElementById("newSessionDialog").close();
|
|
1126
1585
|
function val(id) {
|
|
@@ -1193,7 +1652,8 @@
|
|
|
1193
1652
|
};
|
|
1194
1653
|
function renderQueue(queue, paused) {
|
|
1195
1654
|
document.getElementById("queueStatus").textContent = paused ? "Paused" : "Running";
|
|
1196
|
-
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"));
|
|
1197
1657
|
document.querySelectorAll("[data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1198
1658
|
if (!can("queue.write")) {
|
|
1199
1659
|
toast("Permission required: queue.write");
|
|
@@ -1238,12 +1698,14 @@
|
|
|
1238
1698
|
const r = await api("/api/queue", { method: "POST", body: JSON.stringify({ action: b.dataset.queue }) });
|
|
1239
1699
|
renderQueue(r.queue, r.paused);
|
|
1240
1700
|
}));
|
|
1241
|
-
async function loadArtifacts() {
|
|
1701
|
+
async function loadArtifacts(reset = true) {
|
|
1702
|
+
if (reset) artifactPager.reset();
|
|
1242
1703
|
setLoading("artifactList", "Loading artifacts...");
|
|
1243
1704
|
document.getElementById("artifactPreview").innerHTML = "";
|
|
1244
|
-
const data = await api("/api/artifacts");
|
|
1705
|
+
const data = await api("/api/artifacts", { query: { limit: 50, cursor: artifactPager.cursor || void 0 } });
|
|
1245
1706
|
state.artifactReports = data.reports || [];
|
|
1246
1707
|
renderArtifacts();
|
|
1708
|
+
artifactPager.render(data.pagination || {});
|
|
1247
1709
|
}
|
|
1248
1710
|
function artifactMatches(a, kind, query) {
|
|
1249
1711
|
const name = (a.name || a.relativePath || "").toLowerCase();
|
|
@@ -1252,7 +1714,7 @@
|
|
|
1252
1714
|
if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
|
|
1253
1715
|
return true;
|
|
1254
1716
|
}
|
|
1255
|
-
document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
|
|
1717
|
+
document.getElementById("reloadArtifactsBtn").onclick = () => loadArtifacts(true);
|
|
1256
1718
|
document.getElementById("artifactSearch").oninput = renderArtifacts;
|
|
1257
1719
|
document.getElementById("artifactKind").onchange = renderArtifacts;
|
|
1258
1720
|
document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
|
|
@@ -1368,18 +1830,22 @@
|
|
|
1368
1830
|
throw err;
|
|
1369
1831
|
}
|
|
1370
1832
|
}
|
|
1371
|
-
async function loadTasks() {
|
|
1833
|
+
async function loadTasks(reset = true) {
|
|
1834
|
+
if (reset) jobsPager.reset();
|
|
1372
1835
|
setLoading("tasksList", "Loading tasks...");
|
|
1373
|
-
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 } })]);
|
|
1374
1837
|
renderTasks(d, jobs);
|
|
1838
|
+
jobsPager.render(jobs?.pagination || {});
|
|
1375
1839
|
}
|
|
1376
1840
|
function taskCard(t, title) {
|
|
1377
1841
|
if (!t) return '<div class="item"><strong>' + esc(title) + "</strong><small>Idle</small></div>";
|
|
1378
1842
|
const tools = (t.tools || []).map((x) => x.name + " x" + x.count).join(", ") || "-";
|
|
1379
|
-
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>";
|
|
1380
1844
|
}
|
|
1381
1845
|
function renderTasks(d, jobs) {
|
|
1382
|
-
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 || ""));
|
|
1383
1849
|
document.querySelectorAll("#tasksList [data-q]").forEach((b) => b.onclick = () => safe(async () => {
|
|
1384
1850
|
if (!can("queue.write")) {
|
|
1385
1851
|
toast("Permission required: queue.write");
|
|
@@ -1396,7 +1862,7 @@
|
|
|
1396
1862
|
return jobs.map((job) => {
|
|
1397
1863
|
const retryPermission = jobActionPermission(job, "retry");
|
|
1398
1864
|
const cancelPermission = jobActionPermission(job, "cancel");
|
|
1399
|
-
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>";
|
|
1400
1866
|
}).join("") || '<div class="item">No jobs.</div>';
|
|
1401
1867
|
}
|
|
1402
1868
|
function jobActionPermission(job, action) {
|
|
@@ -1424,7 +1890,7 @@
|
|
|
1424
1890
|
}
|
|
1425
1891
|
}));
|
|
1426
1892
|
}
|
|
1427
|
-
document.getElementById("reloadTasksBtn").onclick = () => loadTasks();
|
|
1893
|
+
document.getElementById("reloadTasksBtn").onclick = () => loadTasks(true);
|
|
1428
1894
|
async function loadMetrics() {
|
|
1429
1895
|
setLoading("metricsPanel", "Loading metrics...");
|
|
1430
1896
|
const d = await api("/api/metrics");
|
|
@@ -1488,20 +1954,28 @@
|
|
|
1488
1954
|
["Buckets", (rate?.buckets || []).length]
|
|
1489
1955
|
].map(([k, v]) => [name + " " + k, v]);
|
|
1490
1956
|
}
|
|
1957
|
+
function webRouteRows(d) {
|
|
1958
|
+
return (d.web?.routes || []).slice(0, 8).map((route) => [route.method + " " + route.path, route.averageMs + "ms avg / " + route.maxMs + "ms max / " + route.count + " hit(s)"]);
|
|
1959
|
+
}
|
|
1960
|
+
function webSlowRows(d) {
|
|
1961
|
+
return (d.web?.slowest || []).slice(0, 8).map((sample) => [sample.method + " " + sample.path, sample.durationMs + "ms / " + sample.statusCode + " / " + fmtDate(sample.at)]);
|
|
1962
|
+
}
|
|
1491
1963
|
function renderMetrics(d) {
|
|
1492
1964
|
const adapters = d.adapters || {};
|
|
1493
1965
|
const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
|
|
1494
|
-
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
|
|
1966
|
+
document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + card("Web API latency", webRouteRows(d)) + card("Slow Web API calls", webSlowRows(d)) + adapterCards + "</div>";
|
|
1495
1967
|
}
|
|
1496
1968
|
document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
|
|
1497
1969
|
function activityQuery() {
|
|
1498
|
-
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 };
|
|
1499
1971
|
}
|
|
1500
|
-
async function loadActivity() {
|
|
1972
|
+
async function loadActivity(reset = true) {
|
|
1973
|
+
if (reset) activityPager.reset();
|
|
1501
1974
|
setLoading("activityList", "Loading activity...");
|
|
1502
1975
|
const data = await api("/api/activity", { query: activityQuery() });
|
|
1503
1976
|
state.activityEvents = data.events || [];
|
|
1504
1977
|
renderActivity(state.activityEvents);
|
|
1978
|
+
activityPager.render(data.pagination || {});
|
|
1505
1979
|
}
|
|
1506
1980
|
function activityWorkspace(e) {
|
|
1507
1981
|
const active = state.snapshot?.session;
|
|
@@ -1517,6 +1991,7 @@
|
|
|
1517
1991
|
const actor = activityActorText(e);
|
|
1518
1992
|
const parts = [];
|
|
1519
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>');
|
|
1520
1995
|
if (e.threadId) parts.push('<button type="button" class="copy-id" data-copy-id="' + attr(e.threadId) + '">' + esc(e.threadId) + "</button>");
|
|
1521
1996
|
if (workspace) parts.push(esc(workspace));
|
|
1522
1997
|
if (duration) parts.push(esc(duration));
|
|
@@ -1528,9 +2003,10 @@
|
|
|
1528
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>";
|
|
1529
2004
|
}).join("") || '<div class="item">No activity.</div>';
|
|
1530
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 || ""));
|
|
1531
2007
|
}
|
|
1532
|
-
document.getElementById("loadActivityBtn").onclick = () => loadActivity();
|
|
1533
|
-
document.getElementById("activitySince").onchange = () => loadActivity();
|
|
2008
|
+
document.getElementById("loadActivityBtn").onclick = () => loadActivity(true);
|
|
2009
|
+
document.getElementById("activitySince").onchange = () => loadActivity(true);
|
|
1534
2010
|
document.getElementById("exportActivityBtn").onclick = () => {
|
|
1535
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");
|
|
1536
2012
|
const blob = new Blob([rows], { type: "text/tab-separated-values" });
|
|
@@ -1540,23 +2016,63 @@
|
|
|
1540
2016
|
a.click();
|
|
1541
2017
|
URL.revokeObjectURL(a.href);
|
|
1542
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
|
+
});
|
|
1543
2047
|
async function loadSettings() {
|
|
1544
2048
|
state.settingsWizard = null;
|
|
1545
|
-
document.getElementById("
|
|
2049
|
+
document.getElementById("settingsTabHeader").style.display = "";
|
|
2050
|
+
document.getElementById("settingsSubnav").style.display = "";
|
|
2051
|
+
document.getElementById("settingsActions").style.display = "";
|
|
1546
2052
|
setLoading("settingsForm", "Loading settings...");
|
|
1547
2053
|
const data = await api("/api/settings");
|
|
1548
2054
|
state.settings = data.settings;
|
|
1549
2055
|
renderSettings();
|
|
1550
2056
|
}
|
|
1551
|
-
const
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
function
|
|
1559
|
-
|
|
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];
|
|
1560
2076
|
}
|
|
1561
2077
|
function settingHelp(s) {
|
|
1562
2078
|
return s.help ? '<span class="setting-info" tabindex="0" role="img" aria-label="' + attr(s.help) + '" title="' + attr(s.help) + '">i</span>' : "";
|
|
@@ -1564,23 +2080,44 @@
|
|
|
1564
2080
|
function settingLabel(s) {
|
|
1565
2081
|
return '<label class="setting-label"><span>' + esc(s.label) + "</span>" + settingHelp(s) + "</label>";
|
|
1566
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
|
+
}
|
|
1567
2110
|
function renderSettings() {
|
|
1568
2111
|
const groups = {};
|
|
1569
2112
|
state.settings.forEach((s) => (groups[s.group] ??= []).push(s));
|
|
1570
|
-
const
|
|
1571
|
-
if (!state.settingsGroup || !groups[state.settingsGroup]) state.settingsGroup = groups.Agents ? "Agents" :
|
|
1572
|
-
|
|
1573
|
-
document.
|
|
1574
|
-
|
|
1575
|
-
renderSettings();
|
|
1576
|
-
});
|
|
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);
|
|
1577
2118
|
const items = groups[state.settingsGroup] || [];
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
document.querySelectorAll("[data-setting-tab]").forEach((b) => b.onclick = () => {
|
|
1581
|
-
state.settingsGroup = b.dataset.settingTab;
|
|
1582
|
-
renderSettings();
|
|
1583
|
-
});
|
|
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);
|
|
1584
2121
|
bindSettingsUx();
|
|
1585
2122
|
}
|
|
1586
2123
|
function settingAttrs(s, original) {
|
|
@@ -2060,9 +2597,10 @@
|
|
|
2060
2597
|
loadLocks();
|
|
2061
2598
|
});
|
|
2062
2599
|
function auditQuery() {
|
|
2063
|
-
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 };
|
|
2064
2601
|
}
|
|
2065
|
-
async function loadAudit() {
|
|
2602
|
+
async function loadAudit(reset = true) {
|
|
2603
|
+
if (reset) auditPager.reset();
|
|
2066
2604
|
if (!can("audit.read")) {
|
|
2067
2605
|
document.getElementById("auditList").innerHTML = '<div class="item">Permission required: audit.read</div>';
|
|
2068
2606
|
return;
|
|
@@ -2070,6 +2608,7 @@
|
|
|
2070
2608
|
const d = await api("/api/audit", { query: auditQuery() });
|
|
2071
2609
|
state.auditEvents = d.events || [];
|
|
2072
2610
|
renderAudit(state.auditEvents);
|
|
2611
|
+
auditPager.render(d.pagination || {});
|
|
2073
2612
|
}
|
|
2074
2613
|
function renderAudit(events) {
|
|
2075
2614
|
document.getElementById("auditList").innerHTML = (events || []).map((e) => {
|
|
@@ -2078,7 +2617,7 @@
|
|
|
2078
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>";
|
|
2079
2618
|
}).join("") || '<div class="item">No audit events.</div>';
|
|
2080
2619
|
}
|
|
2081
|
-
document.getElementById("loadAuditBtn").onclick = () => loadAudit();
|
|
2620
|
+
document.getElementById("loadAuditBtn").onclick = () => loadAudit(true);
|
|
2082
2621
|
document.getElementById("exportAuditBtn").onclick = () => {
|
|
2083
2622
|
const events = state.auditEvents || [];
|
|
2084
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");
|
|
@@ -2099,22 +2638,32 @@
|
|
|
2099
2638
|
if (document.getElementById("logFollow").checked) document.getElementById("logs").scrollTop = document.getElementById("logs").scrollHeight;
|
|
2100
2639
|
}
|
|
2101
2640
|
document.getElementById("loadLogsBtn").onclick = loadLogs;
|
|
2102
|
-
function
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
if (line.includes(" INFO ")) return "INFO";
|
|
2106
|
-
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] : "";
|
|
2107
2644
|
}
|
|
2108
2645
|
function logTimeOf(line) {
|
|
2109
|
-
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})?\]?/);
|
|
2110
2648
|
return m ? new Date(m[1].replace(" ", "T")).getTime() : 0;
|
|
2111
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
|
+
}
|
|
2112
2661
|
function renderLogs() {
|
|
2113
2662
|
const level = val("logLevel");
|
|
2114
2663
|
const query = val("logSearch").toLowerCase();
|
|
2115
2664
|
const since = val("logSince") ? new Date(val("logSince")).getTime() : 0;
|
|
2116
|
-
const lines =
|
|
2117
|
-
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)";
|
|
2118
2667
|
}
|
|
2119
2668
|
document.getElementById("logLevel").onchange = renderLogs;
|
|
2120
2669
|
document.getElementById("logSearch").oninput = renderLogs;
|
|
@@ -2151,14 +2700,17 @@
|
|
|
2151
2700
|
return;
|
|
2152
2701
|
}
|
|
2153
2702
|
setLoading("peersList", "Loading peers...");
|
|
2154
|
-
const d = await api("/api/peers", { local: true });
|
|
2703
|
+
const [d, jobsData] = await Promise.all([api("/api/peers", { local: true }), can("peers.connect") ? api("/api/peers/discovery-jobs", { local: true }).catch(() => ({ jobs: [] })) : Promise.resolve({ jobs: [] })]);
|
|
2155
2704
|
state.peers = d;
|
|
2705
|
+
state.peerDiscoveryJobs = jobsData.jobs || [];
|
|
2156
2706
|
const inviteIds = new Set((d.invitations || []).map((i) => i.id));
|
|
2157
2707
|
Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
|
|
2158
2708
|
if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
|
|
2159
2709
|
});
|
|
2160
2710
|
document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
|
|
2161
2711
|
document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
|
|
2712
|
+
document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobsHtml(state.peerDiscoveryJobs);
|
|
2713
|
+
bindUiCopyButtons(document.getElementById("peerDiscovery"));
|
|
2162
2714
|
document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
|
|
2163
2715
|
ensureGlobalPeerSessionsPanel();
|
|
2164
2716
|
bindPeerButtons();
|
|
@@ -2167,8 +2719,9 @@
|
|
|
2167
2719
|
}
|
|
2168
2720
|
function openPeerAddDialog() {
|
|
2169
2721
|
const publicUrl = state.peers?.enabled ? state.peers?.listenUrl || "" : "";
|
|
2170
|
-
adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
|
|
2722
|
+
adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Group<input id="dlgPeerAddGroup" placeholder="LAN, Servers, Workstations"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional" value="' + attr(publicUrl) + '"></label>', async () => {
|
|
2171
2723
|
const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
|
|
2724
|
+
if (val("dlgPeerAddGroup") && r.peer?.id) await api("/api/peers/" + encodeURIComponent(r.peer.id), { method: "PATCH", body: JSON.stringify({ group: val("dlgPeerAddGroup") }), local: true });
|
|
2172
2725
|
toast("Added peer " + (r.peer?.name || ""));
|
|
2173
2726
|
await loadPeers();
|
|
2174
2727
|
});
|
|
@@ -2188,6 +2741,10 @@
|
|
|
2188
2741
|
}
|
|
2189
2742
|
openPeerAddDialog();
|
|
2190
2743
|
};
|
|
2744
|
+
document.getElementById("discoverPeersBtn").onclick = () => safe(discoverPeers);
|
|
2745
|
+
document.getElementById("cancelPeerDiscoveryBtn").onclick = () => safe(cancelPeerDiscovery);
|
|
2746
|
+
document.getElementById("exportPeerIdentityBtn").onclick = () => safe(exportPeerIdentity);
|
|
2747
|
+
document.getElementById("restorePeerIdentityBtn").onclick = () => safe(restorePeerIdentity);
|
|
2191
2748
|
async function loadAdapterHealth() {
|
|
2192
2749
|
setLoading("adapterHealth", "Loading adapters...");
|
|
2193
2750
|
setLoading("adapterConformance", "Loading conformance...");
|
|
@@ -2350,6 +2907,37 @@
|
|
|
2350
2907
|
}));
|
|
2351
2908
|
}
|
|
2352
2909
|
document.getElementById("loadVersionBtn").onclick = () => loadVersion();
|
|
2910
|
+
document.addEventListener("click", (e) => {
|
|
2911
|
+
const b = e.target.closest?.("[data-peer-repin]");
|
|
2912
|
+
if (!b) return;
|
|
2913
|
+
safe(async () => {
|
|
2914
|
+
if (!can("peers.write")) {
|
|
2915
|
+
toast("Permission required: peers.write");
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
if (confirm("Re-pin TLS fingerprint for this peer?")) {
|
|
2919
|
+
const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerRepin) + "/repin", { method: "POST", local: true });
|
|
2920
|
+
toast("TLS fingerprint updated: " + (r.peer?.tlsFingerprint || "-"), { duration: 8e3 });
|
|
2921
|
+
loadPeers();
|
|
2922
|
+
}
|
|
2923
|
+
});
|
|
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
|
+
});
|
|
2353
2941
|
document.getElementById("updateBtn").onclick = () => safe(async () => {
|
|
2354
2942
|
if (!can("updates.run")) {
|
|
2355
2943
|
toast("Permission required: updates.run");
|
|
@@ -2429,6 +3017,92 @@
|
|
|
2429
3017
|
const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
|
|
2430
3018
|
return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
|
|
2431
3019
|
}
|
|
3020
|
+
async function discoverPeers() {
|
|
3021
|
+
if (!can("peers.connect")) {
|
|
3022
|
+
toast("Permission required: peers.connect");
|
|
3023
|
+
return;
|
|
3024
|
+
}
|
|
3025
|
+
const target = document.getElementById("peerDiscovery");
|
|
3026
|
+
target.innerHTML = loadingHtml("Starting LAN peer discovery...");
|
|
3027
|
+
const body = { targets: csvToList(val("peerDiscoveryTargets")), maxHosts: Number(val("peerDiscoveryMaxHosts") || 512), concurrency: Number(val("peerDiscoveryConcurrency") || 32) };
|
|
3028
|
+
const data = await api("/api/peers/discovery-jobs", { method: "POST", body: JSON.stringify(body), local: true });
|
|
3029
|
+
state.activePeerDiscoveryJobId = data.job?.id;
|
|
3030
|
+
await pollPeerDiscoveryJob(data.job?.id);
|
|
3031
|
+
}
|
|
3032
|
+
async function pollPeerDiscoveryJob(id) {
|
|
3033
|
+
if (!id) return;
|
|
3034
|
+
const target = document.getElementById("peerDiscovery");
|
|
3035
|
+
for (; ; ) {
|
|
3036
|
+
const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id), { local: true });
|
|
3037
|
+
const job = data.job;
|
|
3038
|
+
if (!job) {
|
|
3039
|
+
target.innerHTML = uiEmpty("Discovery job not found.");
|
|
3040
|
+
return;
|
|
3041
|
+
}
|
|
3042
|
+
target.innerHTML = peerDiscoveryJobHtml(job);
|
|
3043
|
+
bindUiCopyButtons(target);
|
|
3044
|
+
applyPermissions();
|
|
3045
|
+
if (!["queued", "running"].includes(job.status)) break;
|
|
3046
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
async function cancelPeerDiscovery() {
|
|
3050
|
+
const id = state.activePeerDiscoveryJobId;
|
|
3051
|
+
if (!id) {
|
|
3052
|
+
toast("No active discovery job");
|
|
3053
|
+
return;
|
|
3054
|
+
}
|
|
3055
|
+
const data = await api("/api/peers/discovery-jobs/" + encodeURIComponent(id) + "/cancel", { method: "POST", local: true });
|
|
3056
|
+
if (data.job) {
|
|
3057
|
+
document.getElementById("peerDiscovery").innerHTML = peerDiscoveryJobHtml(data.job);
|
|
3058
|
+
}
|
|
3059
|
+
toast("Discovery cancelled");
|
|
3060
|
+
}
|
|
3061
|
+
async function exportPeerIdentity() {
|
|
3062
|
+
if (!can("peers.write")) {
|
|
3063
|
+
toast("Permission required: peers.write");
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
const data = await api("/api/peers/identity/backup", { local: true });
|
|
3067
|
+
downloadJson("nordrelay-peer-identity-backup.json", data.backup);
|
|
3068
|
+
toast("Peer identity backup exported");
|
|
3069
|
+
}
|
|
3070
|
+
async function restorePeerIdentity() {
|
|
3071
|
+
if (!can("peers.write")) {
|
|
3072
|
+
toast("Permission required: peers.write");
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
const text = prompt("Paste peer identity backup JSON");
|
|
3076
|
+
if (!text) return;
|
|
3077
|
+
const backup = JSON.parse(text);
|
|
3078
|
+
await api("/api/peers/identity/restore", { method: "POST", body: JSON.stringify({ backup }), local: true });
|
|
3079
|
+
toast("Peer identity restored. Restart peer server to use it.");
|
|
3080
|
+
await loadPeers();
|
|
3081
|
+
}
|
|
3082
|
+
function downloadJson(name, value) {
|
|
3083
|
+
const blob = new Blob([JSON.stringify(value, null, 2) + "\n"], { type: "application/json" });
|
|
3084
|
+
const a = document.createElement("a");
|
|
3085
|
+
a.href = URL.createObjectURL(blob);
|
|
3086
|
+
a.download = name;
|
|
3087
|
+
a.click();
|
|
3088
|
+
URL.revokeObjectURL(a.href);
|
|
3089
|
+
}
|
|
3090
|
+
function peerDiscoveryJobsHtml(jobs) {
|
|
3091
|
+
const list = (jobs || []).slice(0, 5);
|
|
3092
|
+
return list.map(peerDiscoveryJobHtml).join("") || uiEmpty("No LAN discovery jobs yet.");
|
|
3093
|
+
}
|
|
3094
|
+
function peerDiscoveryJobHtml(job) {
|
|
3095
|
+
const progress = job.total ? Math.round(job.scanned / job.total * 100) : 0;
|
|
3096
|
+
return uiItem("Discovery job " + job.id, { badge: { text: job.status, status: job.status === "completed" ? "enabled" : job.status === "failed" ? "disabled" : "planned" }, rows: [["Progress", job.scanned + " / " + job.total + " (" + progress + "%)"], ["Targets", (job.options?.targets || []).join(", ") || "local LAN + mDNS"], ["Started", fmtDate(job.startedAt)], ["Completed", fmtDate(job.completedAt)]], body: peerDiscoveryHtml(job) + '<details class="peer-health-history"><summary>Discovery log (' + (job.log || []).length + ")</summary>" + (job.log || []).slice(-30).map((line) => "<small>" + esc(line) + "</small>").join("") + "</details>" });
|
|
3097
|
+
}
|
|
3098
|
+
function peerDiscoveryHtml(data) {
|
|
3099
|
+
const warnings = (data.warnings || []).length ? peerWarningsHtml(data.warnings, "Discovery warning") : "";
|
|
3100
|
+
const cards = (data.candidates || []).map((c) => {
|
|
3101
|
+
const command = "nordrelay peer add " + c.url + " --code <pairing-code>";
|
|
3102
|
+
return uiItem(c.name || c.host, { badge: { text: "found", status: "enabled" }, rows: [["URL", c.url], ["Node", c.nodeId], ["Fingerprint", c.fingerprint], ["TLS", c.tlsFingerprint || "-"], ["Latency", c.latencyMs !== void 0 ? c.latencyMs + "ms" : "-"]], body: '<div class="peer-invite-details"><small>Pairing command template</small>' + uiCopyButton(command, "Peer add command copied", "copy-id peer-invite-command") + "</div>" });
|
|
3103
|
+
}).join("");
|
|
3104
|
+
return warnings + (cards || uiEmpty("No LAN peers found. Scanned " + (data.scanned || 0) + " endpoint candidates."));
|
|
3105
|
+
}
|
|
2432
3106
|
function peerWarningsHtml(warnings, title) {
|
|
2433
3107
|
return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
|
|
2434
3108
|
}
|
|
@@ -2446,18 +3120,22 @@
|
|
|
2446
3120
|
function peerInviteCard(i) {
|
|
2447
3121
|
const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
|
|
2448
3122
|
const readiness = state.peers?.readiness;
|
|
2449
|
-
return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong
|
|
3123
|
+
return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong>" + (i.group ? "<small>" + esc("Group: " + i.group) + "</small>" : "") + "<small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
|
|
2450
3124
|
}
|
|
2451
3125
|
function peerCard(p) {
|
|
2452
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";
|
|
2453
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";
|
|
2454
3130
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2455
|
-
|
|
3131
|
+
const effective = "Effective access: " + (p.scopes || []).length + " scope(s), agents " + ((p.allowedAgents || []).join(", ") || "all") + ", workspaces " + ((p.allowedWorkspaceRoots || []).join(", ") || "all");
|
|
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("");
|
|
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>";
|
|
2456
3134
|
}
|
|
2457
3135
|
function openPeerDialog(p) {
|
|
2458
3136
|
const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
|
|
2459
|
-
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
|
|
2460
|
-
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
|
|
3137
|
+
adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>Group<input id="dlgPeerGroup" value="' + attr(p.group || "") + '" placeholder="LAN, Servers, Workstations"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
|
|
3138
|
+
await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), group: val("dlgPeerGroup"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
|
|
2461
3139
|
toast("Peer updated");
|
|
2462
3140
|
await loadPeers();
|
|
2463
3141
|
});
|
|
@@ -2465,8 +3143,8 @@
|
|
|
2465
3143
|
function openPeerInviteDialog() {
|
|
2466
3144
|
const warnings = state.peers?.readiness?.warnings || [];
|
|
2467
3145
|
const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
|
|
2468
|
-
adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
|
|
2469
|
-
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
|
|
3146
|
+
adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Group<input id="dlgPeerInviteGroup" placeholder="LAN, Servers, Workstations"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
|
|
3147
|
+
const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), group: val("dlgPeerInviteGroup") || void 0, expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
|
|
2470
3148
|
if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
|
|
2471
3149
|
toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
|
|
2472
3150
|
await loadPeers();
|
|
@@ -2542,6 +3220,459 @@
|
|
|
2542
3220
|
}
|
|
2543
3221
|
}));
|
|
2544
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();
|
|
2545
3676
|
const SETUP_WIZARDS = {
|
|
2546
3677
|
telegram: {
|
|
2547
3678
|
id: "telegram",
|
|
@@ -2641,9 +3772,14 @@
|
|
|
2641
3772
|
state.settingsWizard = { home: true };
|
|
2642
3773
|
renderSettingsWizardHome();
|
|
2643
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
|
+
}
|
|
2644
3780
|
function closeSettingsWizard() {
|
|
2645
3781
|
state.settingsWizard = null;
|
|
2646
|
-
|
|
3782
|
+
setSettingsChromeVisible(true);
|
|
2647
3783
|
renderSettings();
|
|
2648
3784
|
}
|
|
2649
3785
|
function wizardRequiredValuePresent(key) {
|
|
@@ -2657,7 +3793,7 @@
|
|
|
2657
3793
|
return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
|
|
2658
3794
|
}
|
|
2659
3795
|
function renderSettingsWizardHome() {
|
|
2660
|
-
|
|
3796
|
+
setSettingsChromeVisible(false);
|
|
2661
3797
|
const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
|
|
2662
3798
|
const missing = wizardMissingRequired(wizard);
|
|
2663
3799
|
const docs = wizardLinkList(wizard.docs);
|
|
@@ -2695,7 +3831,7 @@
|
|
|
2695
3831
|
return;
|
|
2696
3832
|
}
|
|
2697
3833
|
const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
|
|
2698
|
-
|
|
3834
|
+
setSettingsChromeVisible(false);
|
|
2699
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>';
|
|
2700
3836
|
bindWizardUx();
|
|
2701
3837
|
renderWizardValidation();
|