@nordbyte/nordrelay 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +155 -64
- package/README.md +80 -58
- package/dist/access-control.js +126 -114
- package/dist/agent-feature-matrix.js +42 -0
- package/dist/agent-updates.js +312 -0
- package/dist/bot-rendering.js +838 -0
- package/dist/bot.js +130 -1371
- package/dist/channel-actions.js +372 -0
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +33 -8
- package/dist/relay-runtime.js +159 -31
- package/dist/session-format.js +72 -3
- package/dist/settings-service.js +2 -117
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +54 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-update-commands.js +88 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +56 -0
- package/dist/web-dashboard-assets.js +33 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +649 -369
- package/dist/webui-assets/dashboard.css +919 -0
- package/dist/webui-assets/dashboard.js +1611 -0
- package/package.json +6 -3
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +283 -87
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
package/dist/web-dashboard.js
CHANGED
|
@@ -6,26 +6,31 @@ import { URL } from "node:url";
|
|
|
6
6
|
import { enabledAgents } from "./agent-factory.js";
|
|
7
7
|
import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
8
8
|
import { isAgentId } from "./agent.js";
|
|
9
|
+
import { AuditLogStore } from "./audit-log.js";
|
|
9
10
|
import { listChannelDescriptors } from "./channel-adapter.js";
|
|
11
|
+
import { ALL_PERMISSIONS, permissionForWebRequest } from "./access-control.js";
|
|
10
12
|
import { loadConfig } from "./config.js";
|
|
11
13
|
import { friendlyErrorText } from "./error-messages.js";
|
|
12
14
|
import { escapeHTML } from "./format.js";
|
|
13
15
|
import { RelayRuntime } from "./relay-runtime.js";
|
|
14
16
|
import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
|
|
17
|
+
import { UserStore, publicUser, publicUserSnapshot } from "./user-management.js";
|
|
18
|
+
import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
|
|
15
19
|
import { renderDashboardNav } from "./web-dashboard-ui.js";
|
|
16
20
|
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
17
21
|
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
18
22
|
const options = parseOptions(process.argv.slice(2));
|
|
19
|
-
const auth = resolveDashboardAuth(options.host);
|
|
20
|
-
if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
|
|
21
|
-
throw new Error("Dashboard bound to 0.0.0.0 requires NORDRELAY_DASHBOARD_TOKEN or NORDRELAY_DASHBOARD_USER/PASSWORD.");
|
|
22
|
-
}
|
|
23
23
|
const config = loadConfig();
|
|
24
24
|
const runtime = new RelayRuntime(config);
|
|
25
25
|
const settings = new SettingsService(resolveDashboardEnvPath(options.home));
|
|
26
|
+
const users = new UserStore(options.home);
|
|
27
|
+
const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
28
|
+
const loginAttempts = new Map();
|
|
29
|
+
class AccessDeniedError extends Error {
|
|
30
|
+
}
|
|
26
31
|
const server = createServer((req, res) => {
|
|
27
32
|
void handleRequest(req, res).catch((error) => {
|
|
28
|
-
sendJson(res, 500, { error: friendlyErrorText(error) });
|
|
33
|
+
sendJson(res, error instanceof AccessDeniedError ? 403 : 500, { error: friendlyErrorText(error) });
|
|
29
34
|
});
|
|
30
35
|
});
|
|
31
36
|
await new Promise((resolve) => server.listen(options.port, options.host, resolve));
|
|
@@ -34,64 +39,109 @@ process.once("SIGINT", () => shutdown());
|
|
|
34
39
|
process.once("SIGTERM", () => shutdown());
|
|
35
40
|
async function handleRequest(req, res) {
|
|
36
41
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
37
|
-
const queryToken = url.searchParams.get("token");
|
|
38
|
-
if (queryToken && isAuthorizedToken(queryToken) && !url.pathname.startsWith("/api/")) {
|
|
39
|
-
setAuthCookie(res, queryToken);
|
|
40
|
-
res.writeHead(302, { location: url.pathname || "/" });
|
|
41
|
-
res.end();
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
42
|
if (url.pathname === "/api/auth" && req.method === "POST") {
|
|
45
43
|
await handleLogin(req, res);
|
|
46
44
|
return;
|
|
47
45
|
}
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
if (url.pathname === "/api/dashboard/logout" && req.method === "POST") {
|
|
47
|
+
handleLogout(req, res);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const authenticated = authenticateRequest(req);
|
|
51
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
52
|
+
if (!authenticated) {
|
|
53
|
+
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
51
54
|
return;
|
|
52
55
|
}
|
|
53
|
-
|
|
54
|
-
|
|
56
|
+
sendJson(res, 200, currentUserDto(authenticated));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!authenticated) {
|
|
60
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
61
|
+
sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
|
|
55
62
|
return;
|
|
56
63
|
}
|
|
57
|
-
|
|
64
|
+
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
58
65
|
return;
|
|
59
66
|
}
|
|
60
67
|
if (url.pathname === "/healthz") {
|
|
68
|
+
if (!users.hasPermission(authenticated, "inspect")) {
|
|
69
|
+
sendText(res, 403, "access denied\n", "text/plain; charset=utf-8");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
61
72
|
sendText(res, 200, "ok\n", "text/plain; charset=utf-8");
|
|
62
73
|
return;
|
|
63
74
|
}
|
|
64
75
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
65
|
-
sendText(res, 200, renderDashboardApp(
|
|
76
|
+
sendText(res, 200, renderDashboardApp(), "text/html; charset=utf-8");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (url.pathname === "/assets/dashboard.css") {
|
|
80
|
+
sendText(res, 200, dashboardCss(), "text/css; charset=utf-8");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (url.pathname === "/assets/dashboard.js") {
|
|
84
|
+
sendText(res, 200, dashboardJs(), "application/javascript; charset=utf-8");
|
|
66
85
|
return;
|
|
67
86
|
}
|
|
68
87
|
if (url.pathname === "/api/events" && req.method === "GET") {
|
|
69
|
-
handleEvents(req, res);
|
|
88
|
+
await handleEvents(req, res);
|
|
70
89
|
return;
|
|
71
90
|
}
|
|
72
91
|
if (!url.pathname.startsWith("/api/")) {
|
|
73
92
|
sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
|
|
74
93
|
return;
|
|
75
94
|
}
|
|
76
|
-
await handleApi(req, res, url);
|
|
95
|
+
await handleApi(req, res, url, authenticated);
|
|
77
96
|
}
|
|
78
|
-
async function handleApi(req, res, url) {
|
|
97
|
+
async function handleApi(req, res, url, authUser) {
|
|
98
|
+
const permission = permissionForWebRequest(req.method, url.pathname);
|
|
99
|
+
if (!permission) {
|
|
100
|
+
audit({
|
|
101
|
+
action: "permission_denied",
|
|
102
|
+
status: "denied",
|
|
103
|
+
channelId: "web",
|
|
104
|
+
contextKey: "web",
|
|
105
|
+
actorId: authUser.user.id,
|
|
106
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
107
|
+
description: `Denied unknown endpoint ${req.method ?? "GET"} ${url.pathname}`,
|
|
108
|
+
});
|
|
109
|
+
sendJson(res, 403, { error: "Access denied." });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!users.hasPermission(authUser, permission)) {
|
|
113
|
+
audit({
|
|
114
|
+
action: "permission_denied",
|
|
115
|
+
status: "denied",
|
|
116
|
+
channelId: "web",
|
|
117
|
+
contextKey: "web",
|
|
118
|
+
actorId: authUser.user.id,
|
|
119
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
120
|
+
description: `${permission} required for ${req.method ?? "GET"} ${url.pathname}`,
|
|
121
|
+
});
|
|
122
|
+
sendJson(res, 403, { error: `Access denied: ${permission} permission required.` });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
79
125
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
126
|
+
await assertCurrentSessionScope(authUser);
|
|
80
127
|
sendJson(res, 200, {
|
|
81
|
-
auth:
|
|
128
|
+
auth: currentUserDto(authUser),
|
|
82
129
|
channels: listChannelDescriptors(),
|
|
83
|
-
agentAdapters: listAgentAdapterDescriptors(),
|
|
84
|
-
enabledAgents: enabledAgents(config),
|
|
85
|
-
controls: await runtime.controlOptions(),
|
|
86
|
-
status: await runtime.
|
|
130
|
+
agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
|
|
131
|
+
enabledAgents: enabledAgents(config).filter((agentId) => users.canUseAgent(authUser, agentId)),
|
|
132
|
+
controls: scopedControlOptions(authUser, await runtime.controlOptions()),
|
|
133
|
+
status: await runtime.bootstrapStatus(),
|
|
87
134
|
});
|
|
88
135
|
return;
|
|
89
136
|
}
|
|
90
137
|
if (req.method === "GET" && url.pathname === "/api/control-options") {
|
|
91
|
-
|
|
138
|
+
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
139
|
+
assertScopedAgent(authUser, agentId);
|
|
140
|
+
sendJson(res, 200, scopedControlOptions(authUser, await runtime.controlOptions(agentId)));
|
|
92
141
|
return;
|
|
93
142
|
}
|
|
94
143
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
144
|
+
await assertCurrentSessionScope(authUser);
|
|
95
145
|
sendJson(res, 200, await runtime.status());
|
|
96
146
|
return;
|
|
97
147
|
}
|
|
@@ -103,16 +153,203 @@ async function handleApi(req, res, url) {
|
|
|
103
153
|
sendJson(res, 202, runtime.updateConnector());
|
|
104
154
|
return;
|
|
105
155
|
}
|
|
156
|
+
if (req.method === "GET" && url.pathname === "/api/agent-updates") {
|
|
157
|
+
sendJson(res, 200, { jobs: runtime.agentUpdateJobs().filter((job) => users.canUseAgent(authUser, job.agentId)) });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (req.method === "POST" && url.pathname === "/api/agent-update") {
|
|
161
|
+
const body = await readJsonBody(req);
|
|
162
|
+
const agentId = parseAgentIdRequired(stringField(body, "agentId"));
|
|
163
|
+
assertScopedAgent(authUser, agentId);
|
|
164
|
+
sendJson(res, 202, { job: runtime.startAgentUpdate(agentId) });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
|
|
168
|
+
if (req.method === "GET" && agentUpdateLogMatch?.[1]) {
|
|
169
|
+
const id = decodeURIComponent(agentUpdateLogMatch[1]);
|
|
170
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
171
|
+
sendJson(res, 200, runtime.agentUpdateLog(id));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (req.method === "DELETE" && agentUpdateLogMatch?.[1]) {
|
|
175
|
+
const id = decodeURIComponent(agentUpdateLogMatch[1]);
|
|
176
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
177
|
+
sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id) });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
|
|
181
|
+
if (req.method === "POST" && agentUpdateInputMatch?.[1]) {
|
|
182
|
+
const body = await readJsonBody(req);
|
|
183
|
+
const id = decodeURIComponent(agentUpdateInputMatch[1]);
|
|
184
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
185
|
+
sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input")) });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
|
|
189
|
+
if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
|
|
190
|
+
const id = decodeURIComponent(agentUpdateCancelMatch[1]);
|
|
191
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
192
|
+
sendJson(res, 200, { job: runtime.cancelAgentUpdate(id) });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
106
195
|
if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
|
|
107
|
-
sendJson(res, 200, runtime.tasks());
|
|
196
|
+
sendJson(res, 200, await scopedTasks(authUser, runtime.tasks()));
|
|
108
197
|
return;
|
|
109
198
|
}
|
|
110
199
|
if (req.method === "GET" && url.pathname === "/api/adapters/health") {
|
|
111
|
-
sendJson(res, 200, { adapters: await runtime.adapterHealth() });
|
|
200
|
+
sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
|
|
112
201
|
return;
|
|
113
202
|
}
|
|
114
203
|
if (req.method === "GET" && url.pathname === "/api/permissions") {
|
|
115
|
-
sendJson(res, 200,
|
|
204
|
+
sendJson(res, 200, { ...publicUserSnapshot(users.snapshot()), permissions: ALL_PERMISSIONS });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (req.method === "GET" && url.pathname === "/api/users") {
|
|
208
|
+
sendJson(res, 200, { ...publicUserSnapshot(users.snapshot()), permissions: ALL_PERMISSIONS });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (req.method === "POST" && url.pathname === "/api/users") {
|
|
212
|
+
const body = await readJsonBody(req);
|
|
213
|
+
const user = users.createUser({
|
|
214
|
+
email: stringField(body, "email"),
|
|
215
|
+
displayName: optionalStringField(body, "displayName") ?? stringField(body, "email"),
|
|
216
|
+
password: stringField(body, "password"),
|
|
217
|
+
groupIds: arrayStringField(body, "groupIds"),
|
|
218
|
+
active: optionalBooleanField(body, "active") ?? true,
|
|
219
|
+
telegramUserId: optionalNumberField(body, "telegramUserId"),
|
|
220
|
+
});
|
|
221
|
+
auditUserAction(authUser, "user_created", user.user.email);
|
|
222
|
+
sendJson(res, 201, { user: publicUser(user.user), groups: user.groups });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const userMatch = url.pathname.match(/^\/api\/users\/([^/]+)$/);
|
|
226
|
+
if (userMatch?.[1] && req.method === "PATCH") {
|
|
227
|
+
const body = await readJsonBody(req);
|
|
228
|
+
const user = users.updateUser(decodeURIComponent(userMatch[1]), {
|
|
229
|
+
email: optionalStringField(body, "email"),
|
|
230
|
+
displayName: optionalStringField(body, "displayName"),
|
|
231
|
+
active: optionalBooleanField(body, "active"),
|
|
232
|
+
groupIds: body.groupIds === undefined ? undefined : arrayStringField(body, "groupIds"),
|
|
233
|
+
});
|
|
234
|
+
auditUserAction(authUser, "user_updated", user.user.email);
|
|
235
|
+
sendJson(res, 200, { user: publicUser(user.user), groups: user.groups });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const passwordMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/password$/);
|
|
239
|
+
if (passwordMatch?.[1] && req.method === "POST") {
|
|
240
|
+
const body = await readJsonBody(req);
|
|
241
|
+
const userId = decodeURIComponent(passwordMatch[1]);
|
|
242
|
+
users.setPassword(userId, stringField(body, "password"));
|
|
243
|
+
auditUserAction(authUser, "user_password_changed", userId);
|
|
244
|
+
sendJson(res, 200, { ok: true });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const userSessionsMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/sessions$/);
|
|
248
|
+
if (userSessionsMatch?.[1] && req.method === "GET") {
|
|
249
|
+
sendJson(res, 200, { sessions: users.listWebSessions(decodeURIComponent(userSessionsMatch[1])) });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (userSessionsMatch?.[1] && req.method === "DELETE") {
|
|
253
|
+
const userId = decodeURIComponent(userSessionsMatch[1]);
|
|
254
|
+
const revoked = users.revokeUserSessions(userId);
|
|
255
|
+
auditUserAction(authUser, "user_session_revoked", `${userId}: ${revoked} sessions`);
|
|
256
|
+
sendJson(res, 200, { revoked });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const userSessionMatch = url.pathname.match(/^\/api\/users\/[^/]+\/sessions\/([^/]+)$/);
|
|
260
|
+
if (userSessionMatch?.[1] && req.method === "DELETE") {
|
|
261
|
+
const sessionId = decodeURIComponent(userSessionMatch[1]);
|
|
262
|
+
const revoked = users.revokeWebSession(sessionId);
|
|
263
|
+
auditUserAction(authUser, "user_session_revoked", sessionId);
|
|
264
|
+
sendJson(res, 200, { revoked });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const telegramLinkMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/telegram$/);
|
|
268
|
+
if (telegramLinkMatch?.[1] && req.method === "POST") {
|
|
269
|
+
const body = await readJsonBody(req);
|
|
270
|
+
if (body.createCode === true) {
|
|
271
|
+
const userId = decodeURIComponent(telegramLinkMatch[1]);
|
|
272
|
+
const linkCode = users.createTelegramLinkCode(userId);
|
|
273
|
+
auditUserAction(authUser, "telegram_link_created", userId);
|
|
274
|
+
sendJson(res, 201, { linkCode });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const identity = users.linkTelegramUser(decodeURIComponent(telegramLinkMatch[1]), {
|
|
278
|
+
telegramUserId: numberField(body, "telegramUserId"),
|
|
279
|
+
username: optionalStringField(body, "username"),
|
|
280
|
+
});
|
|
281
|
+
auditUserAction(authUser, "telegram_linked", String(identity.telegramUserId));
|
|
282
|
+
sendJson(res, 201, { identity });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const telegramUnlinkMatch = url.pathname.match(/^\/api\/users\/[^/]+\/telegram\/([^/]+)$/);
|
|
286
|
+
if (telegramUnlinkMatch?.[1] && req.method === "DELETE") {
|
|
287
|
+
const identityId = decodeURIComponent(telegramUnlinkMatch[1]);
|
|
288
|
+
const removed = users.unlinkTelegramIdentity(identityId);
|
|
289
|
+
auditUserAction(authUser, "telegram_unlinked", identityId);
|
|
290
|
+
sendJson(res, 200, { removed });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (req.method === "GET" && url.pathname === "/api/groups") {
|
|
294
|
+
sendJson(res, 200, { groups: users.listGroups() });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (req.method === "POST" && url.pathname === "/api/groups") {
|
|
298
|
+
const body = await readJsonBody(req);
|
|
299
|
+
const group = users.createGroup({
|
|
300
|
+
name: stringField(body, "name"),
|
|
301
|
+
description: optionalStringField(body, "description"),
|
|
302
|
+
permissions: arrayStringField(body, "permissions"),
|
|
303
|
+
agentIds: arrayStringField(body, "agentIds"),
|
|
304
|
+
workspaceRoots: arrayStringField(body, "workspaceRoots"),
|
|
305
|
+
telegramChatIds: arrayNumberField(body, "telegramChatIds"),
|
|
306
|
+
});
|
|
307
|
+
auditUserAction(authUser, "group_created", group.id);
|
|
308
|
+
sendJson(res, 201, { group });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const groupMatch = url.pathname.match(/^\/api\/groups\/([^/]+)$/);
|
|
312
|
+
if (groupMatch?.[1] && req.method === "PATCH") {
|
|
313
|
+
const body = await readJsonBody(req);
|
|
314
|
+
const group = users.updateGroup(decodeURIComponent(groupMatch[1]), {
|
|
315
|
+
name: optionalStringField(body, "name"),
|
|
316
|
+
description: optionalStringField(body, "description"),
|
|
317
|
+
permissions: body.permissions === undefined ? undefined : arrayStringField(body, "permissions"),
|
|
318
|
+
agentIds: body.agentIds === undefined ? undefined : arrayStringField(body, "agentIds"),
|
|
319
|
+
workspaceRoots: body.workspaceRoots === undefined ? undefined : arrayStringField(body, "workspaceRoots"),
|
|
320
|
+
telegramChatIds: body.telegramChatIds === undefined ? undefined : arrayNumberField(body, "telegramChatIds"),
|
|
321
|
+
});
|
|
322
|
+
auditUserAction(authUser, "group_updated", group.id);
|
|
323
|
+
sendJson(res, 200, { group });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (req.method === "GET" && url.pathname === "/api/telegram-chats") {
|
|
327
|
+
sendJson(res, 200, { chats: users.snapshot().telegramChats });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (req.method === "POST" && url.pathname === "/api/telegram-chats") {
|
|
331
|
+
const body = await readJsonBody(req);
|
|
332
|
+
const chat = users.registerTelegramChat({
|
|
333
|
+
chatId: numberField(body, "chatId"),
|
|
334
|
+
title: optionalStringField(body, "title"),
|
|
335
|
+
type: optionalStringField(body, "type"),
|
|
336
|
+
enabled: optionalBooleanField(body, "enabled") ?? true,
|
|
337
|
+
allowedGroupIds: arrayStringField(body, "allowedGroupIds"),
|
|
338
|
+
});
|
|
339
|
+
auditUserAction(authUser, "telegram_chat_updated", String(chat.chatId));
|
|
340
|
+
sendJson(res, 201, { chat });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const chatMatch = url.pathname.match(/^\/api\/telegram-chats\/([^/]+)$/);
|
|
344
|
+
if (chatMatch?.[1] && req.method === "PATCH") {
|
|
345
|
+
const body = await readJsonBody(req);
|
|
346
|
+
const chat = users.updateTelegramChat(decodeURIComponent(chatMatch[1]), {
|
|
347
|
+
enabled: optionalBooleanField(body, "enabled"),
|
|
348
|
+
title: optionalStringField(body, "title"),
|
|
349
|
+
allowedGroupIds: body.allowedGroupIds === undefined ? undefined : arrayStringField(body, "allowedGroupIds"),
|
|
350
|
+
});
|
|
351
|
+
auditUserAction(authUser, "telegram_chat_updated", String(chat.chatId));
|
|
352
|
+
sendJson(res, 200, { chat });
|
|
116
353
|
return;
|
|
117
354
|
}
|
|
118
355
|
if (req.method === "GET" && url.pathname === "/api/audit") {
|
|
@@ -120,30 +357,39 @@ async function handleApi(req, res, url) {
|
|
|
120
357
|
return;
|
|
121
358
|
}
|
|
122
359
|
if (req.method === "GET" && url.pathname === "/api/locks") {
|
|
360
|
+
await assertCurrentSessionScope(authUser);
|
|
123
361
|
sendJson(res, 200, { locks: runtime.locks() });
|
|
124
362
|
return;
|
|
125
363
|
}
|
|
126
364
|
if (req.method === "POST" && url.pathname === "/api/locks") {
|
|
127
365
|
const body = await readJsonBody(req);
|
|
366
|
+
await assertCurrentSessionScope(authUser);
|
|
128
367
|
sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
|
|
129
368
|
return;
|
|
130
369
|
}
|
|
131
370
|
if (req.method === "DELETE" && url.pathname === "/api/locks") {
|
|
371
|
+
await assertCurrentSessionScope(authUser);
|
|
132
372
|
sendJson(res, 200, runtime.unlockWebSession());
|
|
133
373
|
return;
|
|
134
374
|
}
|
|
135
375
|
if (req.method === "GET" && url.pathname === "/api/auth/status") {
|
|
136
|
-
|
|
376
|
+
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
377
|
+
assertScopedAgent(authUser, agentId);
|
|
378
|
+
sendJson(res, 200, await runtime.authStatus(agentId));
|
|
137
379
|
return;
|
|
138
380
|
}
|
|
139
381
|
if (req.method === "POST" && url.pathname === "/api/auth/login") {
|
|
140
382
|
const body = await readJsonBody(req);
|
|
141
|
-
|
|
383
|
+
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
384
|
+
assertScopedAgent(authUser, agentId);
|
|
385
|
+
sendJson(res, 200, await runtime.login(agentId));
|
|
142
386
|
return;
|
|
143
387
|
}
|
|
144
388
|
if (req.method === "POST" && url.pathname === "/api/auth/logout") {
|
|
145
389
|
const body = await readJsonBody(req);
|
|
146
|
-
|
|
390
|
+
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
391
|
+
assertScopedAgent(authUser, agentId);
|
|
392
|
+
sendJson(res, 200, await runtime.logout(agentId));
|
|
147
393
|
return;
|
|
148
394
|
}
|
|
149
395
|
if (req.method === "GET" && url.pathname === "/api/settings") {
|
|
@@ -156,11 +402,20 @@ async function handleApi(req, res, url) {
|
|
|
156
402
|
return;
|
|
157
403
|
}
|
|
158
404
|
if (req.method === "GET" && url.pathname === "/api/snapshot") {
|
|
405
|
+
await assertCurrentSessionScope(authUser);
|
|
159
406
|
sendJson(res, 200, await runtime.snapshot());
|
|
160
407
|
return;
|
|
161
408
|
}
|
|
162
409
|
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
163
|
-
|
|
410
|
+
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
411
|
+
if (agentId) {
|
|
412
|
+
assertScopedAgent(authUser, agentId);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
await assertCurrentSessionScope(authUser);
|
|
416
|
+
}
|
|
417
|
+
const page = await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? "", agentId);
|
|
418
|
+
sendJson(res, 200, scopedSessionPage(authUser, page));
|
|
164
419
|
return;
|
|
165
420
|
}
|
|
166
421
|
if (req.method === "POST" && url.pathname === "/api/agent") {
|
|
@@ -169,15 +424,20 @@ async function handleApi(req, res, url) {
|
|
|
169
424
|
if (!isAgentId(agentId)) {
|
|
170
425
|
throw new Error(`Invalid agent: ${agentId}`);
|
|
171
426
|
}
|
|
427
|
+
assertScopedAgent(authUser, agentId);
|
|
172
428
|
sendJson(res, 200, { session: await runtime.setAgent(agentId) });
|
|
173
429
|
return;
|
|
174
430
|
}
|
|
175
431
|
if (req.method === "POST" && url.pathname === "/api/sessions/new") {
|
|
176
432
|
const body = await readJsonBody(req);
|
|
433
|
+
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
434
|
+
const workspace = optionalStringField(body, "workspace");
|
|
435
|
+
assertScopedAgent(authUser, agentId);
|
|
436
|
+
assertScopedWorkspace(authUser, workspace);
|
|
177
437
|
sendJson(res, 200, {
|
|
178
438
|
session: await runtime.newSession({
|
|
179
|
-
agentId
|
|
180
|
-
workspace
|
|
439
|
+
agentId,
|
|
440
|
+
workspace,
|
|
181
441
|
model: optionalStringField(body, "model"),
|
|
182
442
|
reasoningEffort: optionalStringField(body, "reasoningEffort"),
|
|
183
443
|
launchProfileId: optionalStringField(body, "launchProfileId"),
|
|
@@ -188,49 +448,68 @@ async function handleApi(req, res, url) {
|
|
|
188
448
|
}
|
|
189
449
|
if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
|
|
190
450
|
const body = await readJsonBody(req);
|
|
191
|
-
|
|
451
|
+
const threadId = stringField(body, "threadId");
|
|
452
|
+
const detail = await runtime.sessionDetail(threadId);
|
|
453
|
+
if (detail.record && typeof detail.record === "object") {
|
|
454
|
+
assertSessionScope(authUser, detail.record);
|
|
455
|
+
}
|
|
456
|
+
const session = await runtime.switchSession(threadId);
|
|
457
|
+
assertSessionScope(authUser, session);
|
|
458
|
+
sendJson(res, 200, { session });
|
|
192
459
|
return;
|
|
193
460
|
}
|
|
194
461
|
if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
|
|
195
462
|
const body = await readJsonBody(req);
|
|
196
|
-
|
|
463
|
+
const session = await runtime.attachSession(stringField(body, "threadId"));
|
|
464
|
+
assertSessionScope(authUser, session);
|
|
465
|
+
sendJson(res, 200, { session });
|
|
197
466
|
return;
|
|
198
467
|
}
|
|
199
468
|
if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
|
|
200
|
-
|
|
469
|
+
const threadId = requiredSearch(url, "threadId");
|
|
470
|
+
const detail = await runtime.sessionDetail(threadId);
|
|
471
|
+
assertSessionDetailScope(authUser, threadId, detail);
|
|
472
|
+
sendJson(res, 200, detail);
|
|
201
473
|
return;
|
|
202
474
|
}
|
|
203
475
|
if (req.method === "GET" && url.pathname === "/api/models") {
|
|
476
|
+
await assertCurrentSessionScope(authUser);
|
|
204
477
|
sendJson(res, 200, { models: await runtime.listModels() });
|
|
205
478
|
return;
|
|
206
479
|
}
|
|
207
480
|
if (req.method === "POST" && url.pathname === "/api/session/model") {
|
|
208
481
|
const body = await readJsonBody(req);
|
|
482
|
+
await assertCurrentSessionScope(authUser);
|
|
209
483
|
sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
|
|
210
484
|
return;
|
|
211
485
|
}
|
|
212
486
|
if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
|
|
213
487
|
const body = await readJsonBody(req);
|
|
488
|
+
await assertCurrentSessionScope(authUser);
|
|
214
489
|
sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
|
|
215
490
|
return;
|
|
216
491
|
}
|
|
217
492
|
if (req.method === "POST" && url.pathname === "/api/session/fast") {
|
|
218
493
|
const body = await readJsonBody(req);
|
|
494
|
+
await assertCurrentSessionScope(authUser);
|
|
219
495
|
sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
|
|
220
496
|
return;
|
|
221
497
|
}
|
|
222
498
|
if (req.method === "POST" && url.pathname === "/api/session/launch") {
|
|
223
499
|
const body = await readJsonBody(req);
|
|
500
|
+
await assertCurrentSessionScope(authUser);
|
|
224
501
|
sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
|
|
225
502
|
return;
|
|
226
503
|
}
|
|
227
504
|
if (req.method === "POST" && url.pathname === "/api/prompt") {
|
|
228
505
|
const body = await readJsonBody(req);
|
|
506
|
+
await assertCurrentSessionScope(authUser);
|
|
229
507
|
sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
|
|
230
508
|
return;
|
|
231
509
|
}
|
|
232
510
|
if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
|
|
233
511
|
const body = await readJsonBody(req);
|
|
512
|
+
await assertCurrentSessionScope(authUser);
|
|
234
513
|
sendJson(res, 202, await runtime.sendUploadPrompt({
|
|
235
514
|
text: optionalStringField(body, "text"),
|
|
236
515
|
files: parseUploadFiles(body.files),
|
|
@@ -238,59 +517,70 @@ async function handleApi(req, res, url) {
|
|
|
238
517
|
return;
|
|
239
518
|
}
|
|
240
519
|
if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
|
|
520
|
+
await assertCurrentSessionScope(authUser);
|
|
241
521
|
await runtime.abort();
|
|
242
522
|
sendJson(res, 200, { ok: true });
|
|
243
523
|
return;
|
|
244
524
|
}
|
|
245
525
|
if (req.method === "POST" && url.pathname === "/api/handback") {
|
|
526
|
+
await assertCurrentSessionScope(authUser);
|
|
246
527
|
sendJson(res, 200, await runtime.handback());
|
|
247
528
|
return;
|
|
248
529
|
}
|
|
249
530
|
if (req.method === "POST" && url.pathname === "/api/retry") {
|
|
531
|
+
await assertCurrentSessionScope(authUser);
|
|
250
532
|
sendJson(res, 202, await runtime.retry());
|
|
251
533
|
return;
|
|
252
534
|
}
|
|
253
535
|
if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
536
|
+
await assertCurrentSessionScope(authUser);
|
|
254
537
|
sendJson(res, 200, await runtime.sync());
|
|
255
538
|
return;
|
|
256
539
|
}
|
|
257
540
|
if (req.method === "GET" && url.pathname === "/api/queue") {
|
|
541
|
+
await assertCurrentSessionScope(authUser);
|
|
258
542
|
sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
|
|
259
543
|
return;
|
|
260
544
|
}
|
|
261
545
|
if (req.method === "POST" && url.pathname === "/api/queue") {
|
|
262
546
|
const body = await readJsonBody(req);
|
|
547
|
+
await assertCurrentSessionScope(authUser);
|
|
263
548
|
sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
|
|
264
549
|
return;
|
|
265
550
|
}
|
|
266
551
|
if (req.method === "GET" && url.pathname === "/api/chat/history") {
|
|
552
|
+
await assertCurrentSessionScope(authUser);
|
|
267
553
|
sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
|
|
268
554
|
return;
|
|
269
555
|
}
|
|
270
556
|
if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
|
|
557
|
+
await assertCurrentSessionScope(authUser);
|
|
271
558
|
sendJson(res, 200, await runtime.clearChatHistory());
|
|
272
559
|
return;
|
|
273
560
|
}
|
|
274
561
|
if (req.method === "GET" && url.pathname === "/api/activity") {
|
|
275
562
|
sendJson(res, 200, {
|
|
276
|
-
events: runtime.activity({
|
|
563
|
+
events: filterActivityByScope(authUser, runtime.activity({
|
|
277
564
|
limit: numberParam(url, "limit", 100),
|
|
278
565
|
source: (url.searchParams.get("source") || "all"),
|
|
279
566
|
status: (url.searchParams.get("status") || "all"),
|
|
280
|
-
}),
|
|
567
|
+
})),
|
|
281
568
|
});
|
|
282
569
|
return;
|
|
283
570
|
}
|
|
284
571
|
if (req.method === "GET" && url.pathname === "/api/artifacts") {
|
|
572
|
+
await assertCurrentSessionScope(authUser);
|
|
285
573
|
sendJson(res, 200, { reports: await runtime.artifacts() });
|
|
286
574
|
return;
|
|
287
575
|
}
|
|
288
576
|
if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
|
|
577
|
+
await assertCurrentSessionScope(authUser);
|
|
289
578
|
sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
|
|
290
579
|
return;
|
|
291
580
|
}
|
|
292
581
|
if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
|
|
293
582
|
const body = await readJsonBody(req);
|
|
583
|
+
await assertCurrentSessionScope(authUser);
|
|
294
584
|
const action = stringField(body, "action");
|
|
295
585
|
const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
|
|
296
586
|
if (action !== "delete") {
|
|
@@ -306,6 +596,7 @@ async function handleApi(req, res, url) {
|
|
|
306
596
|
return;
|
|
307
597
|
}
|
|
308
598
|
if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
|
|
599
|
+
await assertCurrentSessionScope(authUser);
|
|
309
600
|
const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
|
|
310
601
|
if (!bundle) {
|
|
311
602
|
sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
|
|
@@ -315,6 +606,7 @@ async function handleApi(req, res, url) {
|
|
|
315
606
|
return;
|
|
316
607
|
}
|
|
317
608
|
if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
|
|
609
|
+
await assertCurrentSessionScope(authUser);
|
|
318
610
|
const turnId = requiredSearch(url, "turnId");
|
|
319
611
|
const relativePath = requiredSearch(url, "path");
|
|
320
612
|
const report = await runtime.artifact(turnId);
|
|
@@ -327,6 +619,7 @@ async function handleApi(req, res, url) {
|
|
|
327
619
|
return;
|
|
328
620
|
}
|
|
329
621
|
if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
|
|
622
|
+
await assertCurrentSessionScope(authUser);
|
|
330
623
|
const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
|
|
331
624
|
if (!preview) {
|
|
332
625
|
sendJson(res, 404, { error: "Artifact not found" });
|
|
@@ -336,10 +629,16 @@ async function handleApi(req, res, url) {
|
|
|
336
629
|
return;
|
|
337
630
|
}
|
|
338
631
|
if (req.method === "GET" && url.pathname === "/api/logs") {
|
|
339
|
-
sendJson(res, 200, await runtime.logs(url.searchParams.get("target")
|
|
632
|
+
sendJson(res, 200, await runtime.logs(parseLogTarget(url.searchParams.get("target") ?? undefined), numberParam(url, "lines", 120)));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (req.method === "POST" && url.pathname === "/api/logs/clear") {
|
|
636
|
+
const body = await readJsonBody(req);
|
|
637
|
+
sendJson(res, 200, runtime.clearLogs(parseLogTarget(optionalStringField(body, "target"))));
|
|
340
638
|
return;
|
|
341
639
|
}
|
|
342
640
|
if (req.method === "GET" && url.pathname === "/api/diagnostics") {
|
|
641
|
+
await assertCurrentSessionScope(authUser);
|
|
343
642
|
sendJson(res, 200, await runtime.diagnostics());
|
|
344
643
|
return;
|
|
345
644
|
}
|
|
@@ -349,21 +648,40 @@ async function handleApi(req, res, url) {
|
|
|
349
648
|
}
|
|
350
649
|
sendJson(res, 404, { error: "Unknown endpoint" });
|
|
351
650
|
}
|
|
352
|
-
function handleEvents(req, res) {
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
if (auth.required && !(isAuthorizedRequest(req) || (token && isAuthorizedToken(token)))) {
|
|
651
|
+
async function handleEvents(req, res) {
|
|
652
|
+
const authUser = authenticateRequest(req);
|
|
653
|
+
if (!authUser) {
|
|
356
654
|
sendJson(res, 401, { error: "Authentication required" });
|
|
357
655
|
return;
|
|
358
656
|
}
|
|
657
|
+
if (!users.hasPermission(authUser, "sessions.read")) {
|
|
658
|
+
sendJson(res, 403, { error: "Access denied: sessions.read permission required." });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
await assertCurrentSessionScope(authUser);
|
|
359
662
|
res.writeHead(200, {
|
|
360
663
|
"content-type": "text/event-stream; charset=utf-8",
|
|
361
664
|
"cache-control": "no-cache, no-transform",
|
|
362
665
|
connection: "keep-alive",
|
|
363
666
|
});
|
|
364
667
|
const send = (event) => {
|
|
365
|
-
|
|
366
|
-
|
|
668
|
+
void scopeRelayEvent(authUser, event, canUseCurrentSession).then((scopedEvent) => {
|
|
669
|
+
if (!scopedEvent || res.destroyed || res.writableEnded) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
res.write(`event: ${scopedEvent.type}\n`);
|
|
673
|
+
res.write(`data: ${JSON.stringify(scopedEvent)}\n\n`);
|
|
674
|
+
}).catch(() => { });
|
|
675
|
+
};
|
|
676
|
+
let currentScopeCache = null;
|
|
677
|
+
const canUseCurrentSession = async () => {
|
|
678
|
+
const now = Date.now();
|
|
679
|
+
if (currentScopeCache && currentScopeCache.expiresAt > now) {
|
|
680
|
+
return currentScopeCache.allowed;
|
|
681
|
+
}
|
|
682
|
+
const allowed = await canUseCurrentSessionScope(authUser);
|
|
683
|
+
currentScopeCache = { allowed, expiresAt: now + 1_000 };
|
|
684
|
+
return allowed;
|
|
367
685
|
};
|
|
368
686
|
const unsubscribe = runtime.subscribe(send);
|
|
369
687
|
const heartbeat = setInterval(() => {
|
|
@@ -377,20 +695,60 @@ function handleEvents(req, res) {
|
|
|
377
695
|
}
|
|
378
696
|
async function handleLogin(req, res) {
|
|
379
697
|
const body = await readJsonBody(req);
|
|
380
|
-
const
|
|
381
|
-
const user = optionalStringField(body, "user");
|
|
698
|
+
const email = optionalStringField(body, "email");
|
|
382
699
|
const password = optionalStringField(body, "password");
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
700
|
+
const rateLimitKey = `${req.socket.remoteAddress ?? "unknown"}:${email ?? "-"}`;
|
|
701
|
+
const limited = consumeRateLimit(loginAttempts, rateLimitKey, 5, 15 * 60 * 1000, 15 * 60 * 1000);
|
|
702
|
+
if (limited.limited) {
|
|
703
|
+
audit({
|
|
704
|
+
action: "auth_login_failed",
|
|
705
|
+
status: "denied",
|
|
706
|
+
channelId: "web",
|
|
707
|
+
contextKey: "web",
|
|
708
|
+
description: `Rate limited login attempt for ${email ?? "unknown"}`,
|
|
709
|
+
detail: `${Math.ceil((limited.retryAfterMs ?? 0) / 1000)}s retry-after`,
|
|
710
|
+
});
|
|
711
|
+
sendJson(res, 429, { error: "Too many login attempts. Try again later.", retryAfterMs: limited.retryAfterMs });
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (!users.hasAdminUser()) {
|
|
715
|
+
sendJson(res, 503, { error: "No admin user exists. Run nordrelay user create-admin first." });
|
|
386
716
|
return;
|
|
387
717
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
718
|
+
const authUser = email && password ? users.verifyPassword(email, password) : null;
|
|
719
|
+
if (!authUser) {
|
|
720
|
+
audit({
|
|
721
|
+
action: "auth_login_failed",
|
|
722
|
+
status: "failed",
|
|
723
|
+
channelId: "web",
|
|
724
|
+
contextKey: "web",
|
|
725
|
+
description: `Failed login for ${email ?? "unknown"}`,
|
|
726
|
+
});
|
|
727
|
+
sendJson(res, 401, { error: "Invalid credentials" });
|
|
391
728
|
return;
|
|
392
729
|
}
|
|
393
|
-
|
|
730
|
+
resetRateLimit(loginAttempts, rateLimitKey);
|
|
731
|
+
const session = users.createWebSession(authUser.user.id);
|
|
732
|
+
audit({
|
|
733
|
+
action: "auth_login",
|
|
734
|
+
status: "ok",
|
|
735
|
+
channelId: "web",
|
|
736
|
+
contextKey: "web",
|
|
737
|
+
actorId: authUser.user.id,
|
|
738
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
739
|
+
description: `Login ${authUser.user.email}`,
|
|
740
|
+
});
|
|
741
|
+
setSessionCookie(res, session.token);
|
|
742
|
+
sendJson(res, 200, currentUserDto(authUser));
|
|
743
|
+
}
|
|
744
|
+
function handleLogout(req, res) {
|
|
745
|
+
const authUser = authenticateRequest(req);
|
|
746
|
+
users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
|
|
747
|
+
if (authUser) {
|
|
748
|
+
auditUserAction(authUser, "auth_logout", authUser.user.email);
|
|
749
|
+
}
|
|
750
|
+
clearSessionCookie(res);
|
|
751
|
+
sendJson(res, 200, { ok: true });
|
|
394
752
|
}
|
|
395
753
|
function parseOptions(argv) {
|
|
396
754
|
let host = process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
|
|
@@ -410,77 +768,177 @@ function parseOptions(argv) {
|
|
|
410
768
|
}
|
|
411
769
|
return { host, port, home };
|
|
412
770
|
}
|
|
413
|
-
function
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
771
|
+
function authenticateRequest(req) {
|
|
772
|
+
const cookies = parseCookies(req.headers.cookie ?? "");
|
|
773
|
+
return users.resolveWebSession(cookies.nr_session);
|
|
774
|
+
}
|
|
775
|
+
function setSessionCookie(res, token) {
|
|
776
|
+
res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
|
|
777
|
+
}
|
|
778
|
+
function clearSessionCookie(res) {
|
|
779
|
+
res.setHeader("set-cookie", "nr_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
|
|
780
|
+
}
|
|
781
|
+
function currentUserDto(authUser) {
|
|
418
782
|
return {
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
user,
|
|
423
|
-
password,
|
|
783
|
+
user: publicUser(authUser.user),
|
|
784
|
+
groups: authUser.groups,
|
|
785
|
+
permissions: authUser.permissions,
|
|
424
786
|
};
|
|
425
787
|
}
|
|
426
|
-
function
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
function isAuthorizedRequest(req) {
|
|
430
|
-
if (!auth.required) {
|
|
431
|
-
return true;
|
|
788
|
+
function audit(event) {
|
|
789
|
+
try {
|
|
790
|
+
auditLog.append(event);
|
|
432
791
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
return true;
|
|
792
|
+
catch (error) {
|
|
793
|
+
console.warn("Failed to write audit event:", error instanceof Error ? error.message : String(error));
|
|
436
794
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
795
|
+
}
|
|
796
|
+
function auditUserAction(authUser, action, description) {
|
|
797
|
+
audit({
|
|
798
|
+
action,
|
|
799
|
+
status: "ok",
|
|
800
|
+
channelId: "web",
|
|
801
|
+
contextKey: "web",
|
|
802
|
+
actorId: authUser.user.id,
|
|
803
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
804
|
+
description,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
function scopedControlOptions(authUser, options) {
|
|
808
|
+
return {
|
|
809
|
+
...options,
|
|
810
|
+
workspaces: options.workspaces.filter((workspace) => users.canUseWorkspace(authUser, workspace)),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function scopedSessionPage(authUser, page) {
|
|
814
|
+
return {
|
|
815
|
+
...page,
|
|
816
|
+
sessions: page.sessions.filter((session) => canUseSession(authUser, session)),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
async function scopedTasks(authUser, tasks) {
|
|
820
|
+
const currentAllowed = await canUseCurrentSessionScope(authUser);
|
|
821
|
+
return {
|
|
822
|
+
...tasks,
|
|
823
|
+
current: tasks.current && canUseSession(authUser, tasks.current) ? tasks.current : null,
|
|
824
|
+
external: tasks.external && canUseSession(authUser, tasks.external) ? tasks.external : null,
|
|
825
|
+
queue: currentAllowed ? tasks.queue : [],
|
|
826
|
+
recent: filterActivityByScope(authUser, tasks.recent),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => canUseCurrentSessionScope(authUser)) {
|
|
830
|
+
switch (event.type) {
|
|
831
|
+
case "snapshot":
|
|
832
|
+
return canUseSession(authUser, event.data.session) ? event : null;
|
|
833
|
+
case "session_update":
|
|
834
|
+
return canUseSession(authUser, event.session) ? event : null;
|
|
835
|
+
case "activity_update":
|
|
836
|
+
return { ...event, events: filterActivityByScope(authUser, event.events) };
|
|
837
|
+
case "agent_update":
|
|
838
|
+
return users.canUseAgent(authUser, event.job.agentId) ? event : null;
|
|
839
|
+
case "status":
|
|
840
|
+
return event;
|
|
841
|
+
case "chat_history":
|
|
842
|
+
case "queue_update":
|
|
843
|
+
case "turn_start":
|
|
844
|
+
case "text_delta":
|
|
845
|
+
case "tool_start":
|
|
846
|
+
case "tool_update":
|
|
847
|
+
case "tool_end":
|
|
848
|
+
case "todo_update":
|
|
849
|
+
case "turn_complete":
|
|
850
|
+
case "turn_error":
|
|
851
|
+
return await canUseCurrentSession() ? event : null;
|
|
443
852
|
}
|
|
444
|
-
|
|
445
|
-
|
|
853
|
+
}
|
|
854
|
+
function filterActivityByScope(authUser, events) {
|
|
855
|
+
return events.filter((event) => canUseSession(authUser, event));
|
|
856
|
+
}
|
|
857
|
+
async function canUseCurrentSessionScope(authUser) {
|
|
858
|
+
try {
|
|
859
|
+
await assertCurrentSessionScope(authUser);
|
|
446
860
|
return true;
|
|
447
861
|
}
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
|
|
452
|
-
return true;
|
|
862
|
+
catch (error) {
|
|
863
|
+
if (error instanceof AccessDeniedError) {
|
|
864
|
+
return false;
|
|
453
865
|
}
|
|
866
|
+
throw error;
|
|
454
867
|
}
|
|
455
|
-
return false;
|
|
456
868
|
}
|
|
457
|
-
function
|
|
458
|
-
|
|
869
|
+
function canUseSession(authUser, session) {
|
|
870
|
+
const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
|
|
871
|
+
const workspace = typeof session.workspace === "string"
|
|
872
|
+
? session.workspace
|
|
873
|
+
: typeof session.cwd === "string"
|
|
874
|
+
? session.cwd
|
|
875
|
+
: undefined;
|
|
876
|
+
return users.canUseAgent(authUser, agentId) && users.canUseWorkspace(authUser, workspace);
|
|
877
|
+
}
|
|
878
|
+
function assertAgentUpdateJobScope(authUser, id) {
|
|
879
|
+
const job = runtime.agentUpdateJobs().find((candidate) => candidate.id === id);
|
|
880
|
+
if (job) {
|
|
881
|
+
assertScopedAgent(authUser, job.agentId);
|
|
882
|
+
}
|
|
459
883
|
}
|
|
460
|
-
function
|
|
461
|
-
|
|
884
|
+
function assertSessionDetailScope(authUser, threadId, detail) {
|
|
885
|
+
const record = objectValue(detail.record);
|
|
886
|
+
if (record) {
|
|
887
|
+
assertSessionScope(authUser, record);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const active = objectValue(detail.active);
|
|
891
|
+
if (active && active.threadId === threadId) {
|
|
892
|
+
assertSessionScope(authUser, active);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
throw new AccessDeniedError("Access denied: session is outside your group scope.");
|
|
462
896
|
}
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
if (leftBuffer.length !== rightBuffer.length) {
|
|
467
|
-
return false;
|
|
897
|
+
function assertScopedAgent(authUser, agentId) {
|
|
898
|
+
if (!users.canUseAgent(authUser, agentId)) {
|
|
899
|
+
throw new AccessDeniedError(`Access denied: agent ${agentId} is outside your group scope.`);
|
|
468
900
|
}
|
|
469
|
-
return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
|
|
470
901
|
}
|
|
471
|
-
function
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
diff |= left[index] ^ right[index];
|
|
902
|
+
function assertScopedWorkspace(authUser, workspace) {
|
|
903
|
+
if (!users.canUseWorkspace(authUser, workspace)) {
|
|
904
|
+
throw new AccessDeniedError(`Access denied: workspace ${workspace} is outside your group scope.`);
|
|
475
905
|
}
|
|
476
|
-
return diff === 0;
|
|
477
906
|
}
|
|
478
|
-
function
|
|
479
|
-
|
|
907
|
+
function assertSessionScope(authUser, session) {
|
|
908
|
+
const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
|
|
909
|
+
const workspace = typeof session.workspace === "string"
|
|
910
|
+
? session.workspace
|
|
911
|
+
: typeof session.cwd === "string"
|
|
912
|
+
? session.cwd
|
|
913
|
+
: undefined;
|
|
914
|
+
assertScopedAgent(authUser, agentId);
|
|
915
|
+
assertScopedWorkspace(authUser, workspace);
|
|
916
|
+
}
|
|
917
|
+
async function assertCurrentSessionScope(authUser) {
|
|
918
|
+
const snapshot = await runtime.snapshot();
|
|
919
|
+
assertSessionScope(authUser, snapshot.session);
|
|
920
|
+
}
|
|
921
|
+
function objectValue(value) {
|
|
922
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
480
923
|
}
|
|
481
|
-
function
|
|
482
|
-
const
|
|
483
|
-
|
|
924
|
+
function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
|
|
925
|
+
const now = Date.now();
|
|
926
|
+
const existing = buckets.get(key);
|
|
927
|
+
if (existing?.blockedUntil && existing.blockedUntil > now) {
|
|
928
|
+
return { limited: true, retryAfterMs: existing.blockedUntil - now };
|
|
929
|
+
}
|
|
930
|
+
const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
|
|
931
|
+
bucket.count += 1;
|
|
932
|
+
if (bucket.count > limit) {
|
|
933
|
+
bucket.blockedUntil = now + blockMs;
|
|
934
|
+
buckets.set(key, bucket);
|
|
935
|
+
return { limited: true, retryAfterMs: blockMs };
|
|
936
|
+
}
|
|
937
|
+
buckets.set(key, bucket);
|
|
938
|
+
return { limited: false };
|
|
939
|
+
}
|
|
940
|
+
function resetRateLimit(buckets, key) {
|
|
941
|
+
buckets.delete(key);
|
|
484
942
|
}
|
|
485
943
|
function parseCookies(cookieHeader) {
|
|
486
944
|
const cookies = {};
|
|
@@ -532,15 +990,61 @@ function optionalBooleanField(value, key) {
|
|
|
532
990
|
const field = value[key];
|
|
533
991
|
return typeof field === "boolean" ? field : undefined;
|
|
534
992
|
}
|
|
993
|
+
function numberField(value, key) {
|
|
994
|
+
const field = value[key];
|
|
995
|
+
const parsed = typeof field === "number" ? field : typeof field === "string" ? Number(field) : Number.NaN;
|
|
996
|
+
if (!Number.isInteger(parsed)) {
|
|
997
|
+
throw new Error(`${key} must be an integer`);
|
|
998
|
+
}
|
|
999
|
+
return parsed;
|
|
1000
|
+
}
|
|
1001
|
+
function optionalNumberField(value, key) {
|
|
1002
|
+
if (value[key] === undefined || value[key] === "") {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
return numberField(value, key);
|
|
1006
|
+
}
|
|
1007
|
+
function arrayStringField(value, key) {
|
|
1008
|
+
const field = value[key];
|
|
1009
|
+
if (field === undefined || field === null || field === "") {
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
if (Array.isArray(field)) {
|
|
1013
|
+
return field.filter((item) => typeof item === "string");
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof field === "string") {
|
|
1016
|
+
return field.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1017
|
+
}
|
|
1018
|
+
throw new Error(`${key} must be a string list`);
|
|
1019
|
+
}
|
|
1020
|
+
function arrayNumberField(value, key) {
|
|
1021
|
+
const field = value[key];
|
|
1022
|
+
if (field === undefined || field === null || field === "") {
|
|
1023
|
+
return [];
|
|
1024
|
+
}
|
|
1025
|
+
if (Array.isArray(field)) {
|
|
1026
|
+
return field.map((item) => typeof item === "number" ? item : Number(item)).filter((item) => Number.isInteger(item));
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof field === "string") {
|
|
1029
|
+
return field.split(",").map((item) => Number(item.trim())).filter((item) => Number.isInteger(item));
|
|
1030
|
+
}
|
|
1031
|
+
throw new Error(`${key} must be a number list`);
|
|
1032
|
+
}
|
|
535
1033
|
function parseAgentId(value) {
|
|
536
1034
|
if (!value) {
|
|
537
1035
|
return undefined;
|
|
538
1036
|
}
|
|
1037
|
+
return parseAgentIdRequired(value);
|
|
1038
|
+
}
|
|
1039
|
+
function parseAgentIdRequired(value) {
|
|
539
1040
|
if (!isAgentId(value)) {
|
|
540
1041
|
throw new Error(`Invalid agent: ${value}`);
|
|
541
1042
|
}
|
|
542
1043
|
return value;
|
|
543
1044
|
}
|
|
1045
|
+
function parseLogTarget(value) {
|
|
1046
|
+
return value === "update" || value === "agent-updates" ? value : "connector";
|
|
1047
|
+
}
|
|
544
1048
|
function objectRecord(value) {
|
|
545
1049
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
546
1050
|
return {};
|
|
@@ -586,13 +1090,7 @@ function optionalEnv(key) {
|
|
|
586
1090
|
}
|
|
587
1091
|
function activeSettingsValues(current) {
|
|
588
1092
|
return {
|
|
589
|
-
TELEGRAM_ALLOW_ANY_CHAT: boolValue(current.telegramAllowAnyChat),
|
|
590
1093
|
TELEGRAM_BOT_TOKEN: current.telegramBotToken,
|
|
591
|
-
TELEGRAM_ADMIN_USER_IDS: current.telegramAdminUserIds.join(","),
|
|
592
|
-
TELEGRAM_ALLOWED_USER_IDS: current.telegramAllowedUserIds.join(","),
|
|
593
|
-
TELEGRAM_READONLY_USER_IDS: current.telegramReadOnlyUserIds.join(","),
|
|
594
|
-
TELEGRAM_ALLOWED_CHAT_IDS: current.telegramAllowedChatIds.join(","),
|
|
595
|
-
TELEGRAM_ROLE_POLICIES_JSON: optionalEnv("TELEGRAM_ROLE_POLICIES_JSON"),
|
|
596
1094
|
TELEGRAM_TRANSPORT: current.telegramTransport,
|
|
597
1095
|
TELEGRAM_WEBHOOK_URL: current.telegramWebhookUrl,
|
|
598
1096
|
TELEGRAM_WEBHOOK_HOST: current.telegramWebhookHost,
|
|
@@ -699,7 +1197,7 @@ function shutdown() {
|
|
|
699
1197
|
runtime.dispose();
|
|
700
1198
|
server.close(() => process.exit(0));
|
|
701
1199
|
}
|
|
702
|
-
function renderLoginPage(
|
|
1200
|
+
function renderLoginPage(options) {
|
|
703
1201
|
return `<!doctype html>
|
|
704
1202
|
<html lang="en">
|
|
705
1203
|
<head>
|
|
@@ -720,18 +1218,17 @@ function renderLoginPage(currentAuth) {
|
|
|
720
1218
|
<body>
|
|
721
1219
|
<form id="login">
|
|
722
1220
|
<h1>NordRelay Dashboard</h1>
|
|
723
|
-
<p>${
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
<button>Sign in</button>
|
|
1221
|
+
<p>${options.adminConfigured ? "Sign in with your NordRelay user account." : "No admin user exists. Run nordrelay user create-admin on this host first."}</p>
|
|
1222
|
+
<label>Email</label><input id="email" name="email" type="email" autocomplete="username" ${options.adminConfigured ? "" : "disabled"}>
|
|
1223
|
+
<label>Password</label><input id="password" name="password" type="password" autocomplete="current-password" ${options.adminConfigured ? "" : "disabled"}>
|
|
1224
|
+
<button ${options.adminConfigured ? "" : "disabled"}>Sign in</button>
|
|
727
1225
|
<div class="error" id="error"></div>
|
|
728
1226
|
</form>
|
|
729
1227
|
<script>
|
|
730
1228
|
document.getElementById('login').addEventListener('submit', async (event) => {
|
|
731
1229
|
event.preventDefault();
|
|
732
1230
|
const payload = {
|
|
733
|
-
|
|
734
|
-
user: document.getElementById('user')?.value || undefined,
|
|
1231
|
+
email: document.getElementById('email')?.value || undefined,
|
|
735
1232
|
password: document.getElementById('password')?.value || undefined,
|
|
736
1233
|
};
|
|
737
1234
|
const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
|
|
@@ -739,14 +1236,13 @@ function renderLoginPage(currentAuth) {
|
|
|
739
1236
|
document.getElementById('error').textContent = 'Invalid credentials';
|
|
740
1237
|
return;
|
|
741
1238
|
}
|
|
742
|
-
if (payload.token) localStorage.setItem('nordrelayDashboardToken', payload.token);
|
|
743
1239
|
location.href = '/';
|
|
744
1240
|
});
|
|
745
1241
|
</script>
|
|
746
1242
|
</body>
|
|
747
1243
|
</html>`;
|
|
748
1244
|
}
|
|
749
|
-
function renderDashboardApp(
|
|
1245
|
+
function renderDashboardApp() {
|
|
750
1246
|
return `<!doctype html>
|
|
751
1247
|
<html lang="en">
|
|
752
1248
|
<head>
|
|
@@ -754,7 +1250,7 @@ function renderDashboardApp(options) {
|
|
|
754
1250
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
755
1251
|
<title>NordRelay Dashboard</title>
|
|
756
1252
|
<script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
|
|
757
|
-
<
|
|
1253
|
+
<link rel="stylesheet" href="/assets/dashboard.css">
|
|
758
1254
|
</head>
|
|
759
1255
|
<body>
|
|
760
1256
|
<div class="app">
|
|
@@ -776,6 +1272,7 @@ function renderDashboardApp(options) {
|
|
|
776
1272
|
<select id="agentSelect"></select>
|
|
777
1273
|
<button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
|
|
778
1274
|
<button id="refreshBtn">Refresh</button>
|
|
1275
|
+
<button id="logoutBtn" class="secondary">Logout</button>
|
|
779
1276
|
</div>
|
|
780
1277
|
</header>
|
|
781
1278
|
|
|
@@ -783,7 +1280,10 @@ function renderDashboardApp(options) {
|
|
|
783
1280
|
<div class="metrics" id="metrics"></div>
|
|
784
1281
|
<div class="stack">
|
|
785
1282
|
<div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
|
|
786
|
-
<div class="
|
|
1283
|
+
<div class="overview-adapter-grid">
|
|
1284
|
+
<div class="panel"><h2>Agent Adapters</h2><div id="agentAdapters"></div></div>
|
|
1285
|
+
<div class="panel"><h2>Chat Adapters</h2><div id="chatAdapters"></div></div>
|
|
1286
|
+
</div>
|
|
787
1287
|
</div>
|
|
788
1288
|
</section>
|
|
789
1289
|
|
|
@@ -855,8 +1355,8 @@ function renderDashboardApp(options) {
|
|
|
855
1355
|
<section class="page" id="page-artifacts">
|
|
856
1356
|
<div class="panel">
|
|
857
1357
|
<div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button><input id="artifactSearch" placeholder="Search artifacts"><select id="artifactKind"><option value="all">All files</option><option value="images">Images</option><option value="docs">Docs/code</option></select><button id="zipSelectedArtifactsBtn" class="secondary">ZIP selected</button><button id="deleteSelectedArtifactsBtn" class="danger">Delete selected</button></div>
|
|
858
|
-
<div id="artifactList" class="list"></div>
|
|
859
1358
|
<div id="artifactPreview" class="preview"></div>
|
|
1359
|
+
<div id="artifactList" class="list"></div>
|
|
860
1360
|
</div>
|
|
861
1361
|
</section>
|
|
862
1362
|
|
|
@@ -869,8 +1369,12 @@ function renderDashboardApp(options) {
|
|
|
869
1369
|
|
|
870
1370
|
<section class="page" id="page-access">
|
|
871
1371
|
<div class="panel">
|
|
872
|
-
<div class="row"><button id="loadAccessBtn">Reload
|
|
1372
|
+
<div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
|
|
873
1373
|
<div id="accessPanel" class="settings-grid"></div>
|
|
1374
|
+
<h2>Groups</h2>
|
|
1375
|
+
<div id="groupsList" class="list"></div>
|
|
1376
|
+
<h2>Telegram chats</h2>
|
|
1377
|
+
<div id="telegramChatsList" class="list"></div>
|
|
874
1378
|
<h2>Locks</h2>
|
|
875
1379
|
<div id="locksList" class="list"></div>
|
|
876
1380
|
<h2>Audit</h2>
|
|
@@ -881,8 +1385,10 @@ function renderDashboardApp(options) {
|
|
|
881
1385
|
|
|
882
1386
|
<section class="page" id="page-version">
|
|
883
1387
|
<div class="panel">
|
|
884
|
-
<div class="row"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
|
|
1388
|
+
<div class="row version-actions"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
|
|
885
1389
|
<div id="versionPanel" class="list"></div>
|
|
1390
|
+
<h2 class="version-update-title">Agent update jobs</h2>
|
|
1391
|
+
<div id="agentUpdateJobs" class="list"></div>
|
|
886
1392
|
</div>
|
|
887
1393
|
</section>
|
|
888
1394
|
|
|
@@ -896,8 +1402,8 @@ function renderDashboardApp(options) {
|
|
|
896
1402
|
|
|
897
1403
|
<section class="page" id="page-logs">
|
|
898
1404
|
<div class="panel">
|
|
899
|
-
<div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">Update</option></select><select id="logLevel"><option value="all">All levels</option><option value="ERROR">Error</option><option value="WARN">Warn</option><option value="INFO">Info</option></select><input id="logSearch" placeholder="Search logs"><input id="logSince" type="datetime-local" title="Show entries after this time"><input id="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><label class="checkbox"><input id="logFollow" type="checkbox"> Follow</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</button></div>
|
|
900
|
-
<pre id="logs"></pre>
|
|
1405
|
+
<div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">NordRelay Update</option><option value="agent-updates">Agent Updates</option></select><select id="logLevel"><option value="all">All levels</option><option value="ERROR">Error</option><option value="WARN">Warn</option><option value="INFO">Info</option></select><input id="logSearch" placeholder="Search logs"><input id="logSince" type="datetime-local" title="Show entries after this time"><input id="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><label class="checkbox"><input id="logFollow" type="checkbox"> Follow</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</button><button id="clearLogsBtn" class="danger">Clear</button></div>
|
|
1406
|
+
<pre id="logs" class="log-view"></pre>
|
|
901
1407
|
</div>
|
|
902
1408
|
</section>
|
|
903
1409
|
|
|
@@ -908,7 +1414,7 @@ function renderDashboardApp(options) {
|
|
|
908
1414
|
<footer>
|
|
909
1415
|
<span id="footerVersion">NordRelay</span>
|
|
910
1416
|
<span id="footerHealth">Health: loading</span>
|
|
911
|
-
<span
|
|
1417
|
+
<span id="footerUser">User: loading</span>
|
|
912
1418
|
</footer>
|
|
913
1419
|
</main>
|
|
914
1420
|
</div>
|
|
@@ -931,242 +1437,16 @@ function renderDashboardApp(options) {
|
|
|
931
1437
|
<div id="sessionDetail"></div>
|
|
932
1438
|
<div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
|
|
933
1439
|
</dialog>
|
|
1440
|
+
<dialog id="adminDialog">
|
|
1441
|
+
<form method="dialog" id="adminDialogForm">
|
|
1442
|
+
<h2 id="adminDialogTitle">Edit</h2>
|
|
1443
|
+
<div id="adminDialogBody" class="form-grid"></div>
|
|
1444
|
+
<div class="row dialog-actions"><button type="button" id="adminDialogCancel" class="secondary">Cancel</button><button id="adminDialogSubmit" value="default">Save</button></div>
|
|
1445
|
+
</form>
|
|
1446
|
+
</dialog>
|
|
1447
|
+
<div id="toolTooltip" class="tool-tooltip"></div>
|
|
934
1448
|
<div id="toast"></div>
|
|
935
|
-
<script
|
|
1449
|
+
<script src="/assets/dashboard.js"></script>
|
|
936
1450
|
</body>
|
|
937
1451
|
</html>`;
|
|
938
1452
|
}
|
|
939
|
-
function dashboardCss() {
|
|
940
|
-
return `
|
|
941
|
-
:root{color-scheme:light;--bg:#f4f6f2;--surface:#ffffff;--surface-soft:#fbfcf8;--text:#18201b;--muted:#5d675f;--border:#dce3d9;--border-soft:#e7ede4;--sidebar:#17251d;--sidebar-text:#f4f8f2;--sidebar-muted:#aebcaf;--accent:#235c42;--accent-strong:#17452f;--accent-soft:#dff5e8;--warn:#fff7da;--danger:#9b1c1c;--pre:#111812;--pre-text:#f3f7ef;--shadow:0 8px 24px rgba(24,32,27,.04);--link:#1d6a4c}
|
|
942
|
-
:root[data-theme="dark"]{color-scheme:dark;--bg:#101411;--surface:#171d19;--surface-soft:#1d251f;--text:#edf4ee;--muted:#a7b3aa;--border:#2d3830;--border-soft:#263128;--sidebar:#0c120f;--sidebar-text:#edf7ef;--sidebar-muted:#8da091;--accent:#4fa876;--accent-strong:#64bd89;--accent-soft:#173d2a;--warn:#3b3216;--danger:#cc4b4b;--pre:#070a08;--pre-text:#e8f1ea;--shadow:0 10px 28px rgba(0,0,0,.22);--link:#75c99a}
|
|
943
|
-
.agent-settings-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;padding:10px;border:1px solid var(--border-soft);border-radius:8px;background:var(--surface)}.agent-settings-nav strong{font-size:13px;color:var(--muted);margin-right:4px}.agent-settings-nav button{background:var(--surface);color:var(--text);border-color:var(--border);height:32px}.agent-settings-nav button.active{background:var(--accent);color:white;border-color:var(--accent)}@media(max-width:560px){.agent-settings-nav{align-items:stretch}.agent-settings-nav button{width:100%}}
|
|
944
|
-
.drop-active{outline:2px dashed var(--accent);outline-offset:-8px}.chip{display:inline-flex;align-items:center;border-radius:999px;border:1px solid var(--border);padding:2px 8px;font-size:12px;color:var(--muted);margin-right:6px}.chip.error{color:var(--danger);border-color:var(--danger)}.chip.warn{color:#8a6a12;border-color:#d9c27a;background:var(--warn)}.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px}.artifact-card{border:1px solid var(--border-soft);border-radius:8px;padding:8px;background:var(--surface-soft);min-width:0}.artifact-card img{width:100%;aspect-ratio:1.4;object-fit:cover;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.artifact-card small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.setting.dirty{border-color:var(--accent)}.setting-actions{display:flex;gap:8px;align-items:center;margin-top:8px}.setting-help{font-size:12px;color:var(--muted)}.restart-banner{border:1px solid #d9c27a;background:var(--warn);border-radius:8px;padding:10px;margin:0 0 12px}.task-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}.log-line{display:block}.log-line.ERROR{color:var(--danger);font-weight:700}.log-line.WARN{color:#8a6a12;font-weight:700}.connection-ok{color:#1e754e;border-color:#8ed0aa}.connection-warn{color:#8a6a12;border-color:#d9c27a}.connection-error{color:var(--danger);border-color:var(--danger)}
|
|
945
|
-
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}.app{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.sidebar{background:var(--sidebar);color:var(--sidebar-text);padding:18px;display:flex;flex-direction:column;gap:22px}.brand{display:flex;align-items:center;gap:12px}.mark{display:grid;place-items:center;width:38px;height:38px;border-radius:8px;background:#d7ffe5;color:#173d29;font-weight:800}.brand small{display:block;color:var(--sidebar-muted)}nav{display:flex;flex-direction:column;gap:6px}nav button,.menu{border:0;border-radius:6px;padding:10px 12px;background:transparent;color:inherit;text-align:left;font:inherit;cursor:pointer}nav button.active,nav button:hover{background:color-mix(in srgb,var(--accent) 35%,transparent)}main{min-width:0;display:flex;flex-direction:column}header{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;gap:16px;align-items:center;padding:16px 22px;background:color-mix(in srgb,var(--surface) 92%,transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}h1{font-size:24px;margin:0}h2{font-size:16px;margin:0 0 12px}p{margin:4px 0 0;color:var(--muted)}a{color:var(--link)}.header-actions,.row,.chat-toolbar,.attachment-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.menu{display:none;background:var(--surface-soft);color:var(--text)}.page{display:none;padding:22px}.page.active{display:block}.stack{display:flex;flex-direction:column;gap:16px}.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:16px}.metric,.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;box-shadow:var(--shadow)}.metric .label{font-size:12px;text-transform:uppercase;color:var(--muted)}.metric .value{font-size:22px;font-weight:750;margin-top:4px;overflow:hidden;text-overflow:ellipsis}button,select,input,textarea{border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit}button{height:36px;padding:0 12px;background:var(--accent);color:white;border-color:var(--accent);cursor:pointer}button:hover{background:var(--accent-strong)}button.secondary{background:var(--surface);color:var(--text)}input,select{height:36px;padding:0 10px}textarea{width:100%;padding:10px;resize:vertical}.chat-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;align-items:start}.chat-panel{height:calc(100vh - 170px);min-height:520px;display:flex;flex-direction:column}.control-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:12px 0}.control-grid label,.form-grid label{display:grid;gap:5px;font-size:12px;color:var(--muted)}.messages{flex:1;min-height:0;overflow:auto;border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.message{margin:0 0 12px;padding:10px 12px;border-radius:8px;max-width:92%;white-space:pre-wrap;word-break:break-word}.message.user{margin-left:auto;background:var(--accent-soft)}.message.agent{background:color-mix(in srgb,var(--surface-soft) 80%,var(--border))}.message.system{background:var(--warn)}.composer{display:grid;grid-template-columns:1fr auto;gap:10px;margin-top:12px}.composer-fields{min-width:0}.composer button{height:auto;min-width:90px}.attachment-row{margin-top:8px;color:var(--muted);font-size:13px}.file-button{display:inline-flex;align-items:center;height:34px;padding:0 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);cursor:pointer}input[type=file]{display:none}.sessions-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.sessions-toolbar .search-row{flex:1 1 320px}.sessions-toolbar .attach-row{flex:1 1 360px;justify-content:flex-end;margin-left:auto}.sessions-toolbar input{min-width:220px}.copy-id{height:auto;padding:0;border:0;background:transparent;color:var(--link);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}.copy-id:hover{background:transparent;text-decoration:underline}.side-panel{max-height:calc(100vh - 126px);display:flex;flex-direction:column}.tool-stream{display:flex;flex-direction:column;gap:8px;overflow:auto;max-height:calc(100vh - 190px);padding-right:4px}.tool{border:1px solid var(--border-soft);border-radius:6px;padding:8px;background:var(--surface-soft);white-space:pre-wrap;word-break:break-word}.list{display:flex;flex-direction:column;gap:8px;margin-top:12px}.item{border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.item strong{display:block;overflow-wrap:anywhere}.item small{display:block;color:var(--muted);overflow-wrap:anywhere}.queue-item{cursor:grab}.queue-item.dragging{opacity:.55}.badge,.adapter-status{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:2px 8px;color:var(--muted);font-size:12px}.adapter-status{margin-left:6px;text-transform:capitalize}.adapter-status.enabled,.adapter-status.available{color:#1e754e;border-color:#8ed0aa;background:color-mix(in srgb,var(--accent-soft) 55%,transparent)}.adapter-status.disabled{color:var(--muted)}.adapter-status.planned{color:#8a6a12;border-color:#d9c27a;background:var(--warn)}.preview{margin-top:12px}.preview img{max-width:100%;border:1px solid var(--border);border-radius:8px;background:var(--surface)}.settings-grid{display:block}.setting{border:1px solid var(--border-soft);border-radius:8px;padding:12px;margin-bottom:10px;background:var(--surface-soft)}.setting label{display:block;font-size:13px;font-weight:700;margin-bottom:6px}.setting small{display:block;color:var(--muted);margin-top:6px}.setting input,.setting textarea,.setting select{width:100%}.setting-error{color:var(--danger);font-size:12px;margin-top:6px}.checkbox{display:inline-flex!important;grid-template-columns:auto 1fr!important;align-items:center;gap:8px}.checkbox input{height:auto;width:auto}.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.tabs button{background:var(--surface);color:var(--text);border-color:var(--border);height:34px}.tabs button.active{background:var(--accent);color:white;border-color:var(--accent)}.pager{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;color:var(--muted)}.pager-actions{display:flex;gap:8px}.pager button:disabled{opacity:.45;cursor:not-allowed}pre{white-space:pre-wrap;word-break:break-word;background:var(--pre);color:var(--pre-text);border-radius:8px;padding:14px;overflow:auto}footer{margin-top:auto;display:flex;gap:18px;flex-wrap:wrap;padding:14px 22px;border-top:1px solid var(--border);color:var(--muted);background:var(--surface)}dialog{border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);width:min(720px,calc(100vw - 28px));padding:18px;box-shadow:0 18px 70px rgba(0,0,0,.22)}dialog::backdrop{background:rgba(0,0,0,.35)}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.dialog-actions{justify-content:flex-end;margin-top:16px}#toast{position:fixed;right:18px;bottom:18px;display:none;background:var(--accent);color:white;border-radius:8px;padding:12px 14px;max-width:360px}.danger{background:var(--danger);border-color:var(--danger);color:white}@media(max-width:860px){.app{display:block}.sidebar{position:fixed;inset:0 auto 0 0;width:270px;transform:translateX(-100%);transition:.18s transform;z-index:20}.sidebar.open{transform:translateX(0)}.menu{display:inline-block}.header-actions{justify-content:flex-end}.page{padding:14px}.chat-layout{grid-template-columns:1fr}.chat-panel{height:auto;min-height:0}.messages{max-height:55vh;min-height:300px}.composer{grid-template-columns:1fr}.composer button{height:40px}.side-panel{order:-1;max-height:360px}.tool-stream{max-height:300px}header{align-items:flex-start}.metrics{grid-template-columns:1fr 1fr}}@media(max-width:560px){.metrics{grid-template-columns:1fr}.row{align-items:stretch}.row>*{width:100%}header{display:grid;grid-template-columns:auto 1fr}.header-actions{grid-column:1/3}.message{max-width:100%}.pager{align-items:stretch}.pager-actions,.pager button{width:100%}.attachment-row>*,.sessions-toolbar,.sessions-toolbar .row,.sessions-toolbar input,.sessions-toolbar button{width:100%}.sessions-toolbar .attach-row{margin-left:0;justify-content:stretch}}
|
|
946
|
-
`;
|
|
947
|
-
}
|
|
948
|
-
function dashboardJs() {
|
|
949
|
-
return `
|
|
950
|
-
const token = localStorage.getItem('nordrelayDashboardToken') || '';
|
|
951
|
-
const state = { snapshot:null, controls:null, newSessionControls:null, enabledAgents:[], settings:[], currentPage:'overview', settingsGroup:null, logsPlain:'', logTimer:null, toastTimer:null, cliStatusActive:false, selectedArtifactTurns:new Set(), mediaRecorder:null, recordedChunks:[], events:null, reconnectTimer:null, notifications:false };
|
|
952
|
-
const authHeaders = () => token ? { authorization: 'Bearer ' + token } : {};
|
|
953
|
-
async function api(path, options={}) {
|
|
954
|
-
const headers = { ...(options.body ? {'content-type':'application/json'} : {}), ...authHeaders(), ...(options.headers||{}) };
|
|
955
|
-
const res = await fetch(path, { ...options, headers });
|
|
956
|
-
if (res.status === 401) { location.reload(); return; }
|
|
957
|
-
const text = await res.text();
|
|
958
|
-
const data = text ? JSON.parse(text) : {};
|
|
959
|
-
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
960
|
-
return data;
|
|
961
|
-
}
|
|
962
|
-
function toast(msg,options={}){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';if(state.toastTimer)clearTimeout(state.toastTimer);state.toastTimer=null;if(!options.sticky){state.toastTimer=setTimeout(()=>{el.style.display='none';state.toastTimer=null},options.duration||3500)}}
|
|
963
|
-
function esc(s){return String(s??'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]))}
|
|
964
|
-
function attr(s){return esc(s).replace(/"/g,'"')}
|
|
965
|
-
function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/[^a-zA-Z0-9_-]/g,'\\\\$&')}
|
|
966
|
-
function short(s,max=250){const text=String(s??'');return text.length>max?text.slice(0,max-1)+'...':text}
|
|
967
|
-
async function copyText(text,label='Copied'){if(!text)return;try{await navigator.clipboard.writeText(text)}catch{const area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();document.execCommand('copy');area.remove()}toast(label)}
|
|
968
|
-
function fmtDate(s){return s?new Date(s).toLocaleString(): '-'}
|
|
969
|
-
function fmtDuration(ms){if(!ms&&ms!==0)return '-';const sec=Math.round(ms/1000);if(sec<60)return sec+'s';return Math.floor(sec/60)+'m '+(sec%60)+'s'}
|
|
970
|
-
function fmtBytes(n){if(n<1024)return n+' B';if(n<1048576)return (n/1024).toFixed(1).replace(/\\.0$/,'')+' KB';return (n/1048576).toFixed(1).replace(/\\.0$/,'')+' MB'}
|
|
971
|
-
function compactNum(n){if(!n)return'';if(n>=1000000000)return Math.round(n/100000000)/10+'B';if(n>=1000000)return Math.round(n/100000)/10+'M';if(n>=1000)return Math.round(n/100)/10+'K';return String(n)}
|
|
972
|
-
function modelLabel(m){const meta=[m.contextWindow?compactNum(m.contextWindow):'',m.supportsImages===true?'img':m.supportsImages===false?'text':'',m.supportsThinking===true?'think':''].filter(Boolean).join(' ');return (m.displayName||m.slug)+(meta?' · '+meta:'')}
|
|
973
|
-
function fmtAge(ms){const sec=Math.max(0,Math.floor(ms/1000));if(sec<60)return sec+'s ago';const min=Math.floor(sec/60);if(min<60)return min+'m ago';return Math.floor(min/60)+'h ago'}
|
|
974
|
-
function isCliRunningStatus(msg){return / CLI running\\b/.test(String(msg||''))}
|
|
975
|
-
function isCliDoneStatus(msg){return / CLI task\\b/.test(String(msg||''))}
|
|
976
|
-
function applyTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('nordrelayTheme',theme);document.getElementById('themeBtn').textContent=theme==='dark'?'Light':'Dark'}
|
|
977
|
-
function toggleTheme(){applyTheme(document.documentElement.dataset.theme==='dark'?'light':'dark')}
|
|
978
|
-
function page(name){state.currentPage=name;document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.page===name));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+name));document.getElementById('pageTitle').textContent=name[0].toUpperCase()+name.slice(1);document.getElementById('sidebar').classList.remove('open'); if(name==='sessions') loadSessions(); if(name==='settings') loadSettings(); if(name==='logs') loadLogs(); if(name==='diagnostics') loadDiagnostics(); if(name==='artifacts') loadArtifacts(); if(name==='activity') loadActivity(); if(name==='tasks') loadTasks(); if(name==='adapters') loadAdapterHealth(); if(name==='access') loadAccess(); if(name==='version') loadVersion();}
|
|
979
|
-
document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>page(b.dataset.page));
|
|
980
|
-
document.getElementById('menuBtn').onclick=()=>document.getElementById('sidebar').classList.toggle('open');
|
|
981
|
-
document.getElementById('refreshBtn').onclick=()=>loadBootstrap();
|
|
982
|
-
document.getElementById('themeBtn').onclick=toggleTheme;
|
|
983
|
-
applyTheme(localStorage.getItem('nordrelayTheme') || 'light');
|
|
984
|
-
|
|
985
|
-
function createPaginator(containerId, onChange, pageSize=50){
|
|
986
|
-
const container=document.getElementById(containerId);
|
|
987
|
-
return {
|
|
988
|
-
page:1,
|
|
989
|
-
pageSize,
|
|
990
|
-
reset(){this.page=1},
|
|
991
|
-
render(meta={}){
|
|
992
|
-
const hasPrevious=Boolean(meta.hasPrevious);
|
|
993
|
-
const hasNext=Boolean(meta.hasNext);
|
|
994
|
-
container.innerHTML='<span>Page '+this.page+' / '+this.pageSize+' per page</span><div class="pager-actions"><button data-page-action="prev" '+(!hasPrevious?'disabled':'')+'>Previous</button><button data-page-action="next" '+(!hasNext?'disabled':'')+'>Next</button></div>';
|
|
995
|
-
const prev=container.querySelector('[data-page-action="prev"]');
|
|
996
|
-
const next=container.querySelector('[data-page-action="next"]');
|
|
997
|
-
prev.onclick=()=>{if(hasPrevious){this.page-=1;onChange()}};
|
|
998
|
-
next.onclick=()=>{if(hasNext){this.page+=1;onChange()}};
|
|
999
|
-
}
|
|
1000
|
-
};
|
|
1001
|
-
}
|
|
1002
|
-
const sessionsPager=createPaginator('sessionsPager',()=>loadSessions(false),50);
|
|
1003
|
-
|
|
1004
|
-
async function loadBootstrap(){
|
|
1005
|
-
const data = await api('/api/bootstrap');
|
|
1006
|
-
state.snapshot = data.status.snapshot;
|
|
1007
|
-
state.controls = data.controls;
|
|
1008
|
-
state.enabledAgents = data.enabledAgents || [];
|
|
1009
|
-
renderSnapshot(state.snapshot);
|
|
1010
|
-
renderSessionControls();
|
|
1011
|
-
populateNewSessionForm(data.enabledAgents);
|
|
1012
|
-
renderAdapters(data.channels, data.agentAdapters);
|
|
1013
|
-
document.getElementById('footerVersion').textContent='NordRelay '+(data.status.health?.version || '');
|
|
1014
|
-
document.getElementById('footerHealth').textContent='Health: '+(data.status.health?.state?.status || 'unknown');
|
|
1015
|
-
const agentSelect=document.getElementById('agentSelect');
|
|
1016
|
-
agentSelect.innerHTML=data.enabledAgents.map(a=>'<option value="'+a+'">'+a+'</option>').join('');
|
|
1017
|
-
agentSelect.value=state.snapshot.session.agentId;
|
|
1018
|
-
agentSelect.onchange=()=>safe(async()=>{await api('/api/agent',{method:'POST',body:JSON.stringify({agentId:agentSelect.value})});toast('Agent switched');await loadBootstrap();await loadChatHistory()});
|
|
1019
|
-
}
|
|
1020
|
-
function renderSnapshot(s){
|
|
1021
|
-
document.getElementById('sessionLine').textContent=(s.session.agentLabel||'Agent')+' / '+(s.session.model||'default')+' / '+(s.session.threadId||'not started');
|
|
1022
|
-
document.getElementById('sessionText').textContent=s.sessionText||'';
|
|
1023
|
-
document.getElementById('metrics').innerHTML=[
|
|
1024
|
-
['Status',s.processing?'working':'idle'],['Agent',s.session.agentLabel],['Queue',s.queue.length],['Workspace',s.session.workspace],['Thread',s.session.threadId||'not started'],['Reasoning',s.session.reasoningEffort||'default'],['Fast',s.session.capabilities&&s.session.capabilities.fastMode?(s.session.fastMode?'on':'off'):'n/a']
|
|
1025
|
-
].map(([k,v])=>'<div class="metric"><div class="label">'+esc(k)+'</div><div class="value">'+esc(v)+'</div></div>').join('');
|
|
1026
|
-
renderQueue(s.queue,s.queuePaused);
|
|
1027
|
-
}
|
|
1028
|
-
function renderSessionControls(){
|
|
1029
|
-
const c=state.controls||{};const s=state.snapshot?.session||{};const caps=c.capabilities||{};
|
|
1030
|
-
const modelOptions=['<option value="">Default</option>'].concat((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'" '+(m.slug===s.model?'selected':'')+'>'+esc(modelLabel(m))+'</option>')).join('');
|
|
1031
|
-
const reasoningOptions=(c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'" '+(v===s.reasoningEffort?'selected':'')+'>'+esc(v)+'</option>').join('');
|
|
1032
|
-
const launchOptions=(c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'" '+(p.id===(s.nextLaunchProfileId||s.launchProfileId)?'selected':'')+'>'+esc(p.label+' - '+p.behavior+(p.unsafe?' - unsafe':''))+'</option>').join('');
|
|
1033
|
-
document.getElementById('sessionControls').innerHTML=[
|
|
1034
|
-
caps.modelSelection?'<label>Model<select id="controlModel">'+modelOptions+'</select></label>':'',
|
|
1035
|
-
caps.reasoningSelection?'<label>'+esc(c.reasoningLabel||'Reasoning')+'<select id="controlReasoning">'+reasoningOptions+'</select></label>':'',
|
|
1036
|
-
caps.launchProfiles?'<label>Launch<select id="controlLaunch">'+launchOptions+'</select></label>':'',
|
|
1037
|
-
caps.fastMode?'<label class="checkbox"><input id="controlFast" type="checkbox" '+(s.fastMode?'checked':'')+'> Fast mode</label>':''
|
|
1038
|
-
].join('');
|
|
1039
|
-
const model=document.getElementById('controlModel'); if(model) model.onchange=()=>safe(async()=>{if(model.value){await api('/api/session/model',{method:'POST',body:JSON.stringify({model:model.value})});toast('Model updated');loadBootstrap()}});
|
|
1040
|
-
const reasoning=document.getElementById('controlReasoning'); if(reasoning) reasoning.onchange=()=>safe(async()=>{await api('/api/session/reasoning',{method:'POST',body:JSON.stringify({reasoning:reasoning.value})});toast((c.reasoningLabel||'Reasoning')+' updated');loadBootstrap()});
|
|
1041
|
-
const launch=document.getElementById('controlLaunch'); if(launch) launch.onchange=()=>safe(async()=>{await api('/api/session/launch',{method:'POST',body:JSON.stringify({profileId:launch.value})});toast('Launch profile updated');loadBootstrap()});
|
|
1042
|
-
const fast=document.getElementById('controlFast'); if(fast) fast.onchange=()=>safe(async()=>{await api('/api/session/fast',{method:'POST',body:JSON.stringify({enabled:fast.checked})});toast('Fast mode updated');loadBootstrap()});
|
|
1043
|
-
}
|
|
1044
|
-
function renderAdapters(channels, agents){
|
|
1045
|
-
const channelCards=(channels||[]).map(c=>adapterCard(c.label,c.status,c.capabilities.join(', ')));
|
|
1046
|
-
const agentCards=(agents||[]).map(a=>{const available=a.status==='available';const status=available?(state.enabledAgents.includes(a.id)?'enabled':'disabled'):(a.status||'planned');const detail=[available?'integrated':(a.status||'planned'),a.envFlag,a.notes].filter(Boolean).join(' / ');return adapterCard(a.label,status,detail)});
|
|
1047
|
-
document.getElementById('adapters').innerHTML='<div class="list">'+channelCards.concat(agentCards).join('')+'</div>';
|
|
1048
|
-
}
|
|
1049
|
-
function adapterCard(label,status,detail){return '<div class="item"><strong>'+esc(label)+' <span class="adapter-status '+esc(status)+'">'+esc(status)+'</span></strong><small>'+esc(detail||'')+'</small></div>'}
|
|
1050
|
-
function appendMessage(cls,text){const box=document.getElementById('messages');const div=document.createElement('div');div.className='message '+cls;div.textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div}
|
|
1051
|
-
function appendQueuedMessage(id){const div=appendMessage('system','Queued prompt '+id);const btn=document.createElement('button');btn.textContent='Cancel queued message';btn.className='danger';btn.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:'cancel',id})});renderQueue(r.queue,r.paused);div.textContent='Cancelled queued prompt '+id});div.appendChild(document.createElement('br'));div.appendChild(btn)}
|
|
1052
|
-
function renderChatMessages(messages){state.chatMessages=messages||[];const box=document.getElementById('messages');box.innerHTML=(messages||[]).map(m=>'<div class="message '+esc(m.role)+'"><small>'+esc((m.source||'web')+' / '+fmtDate(m.timestamp))+'</small>\\n'+esc(m.text)+'</div>').join('');box.scrollTop=box.scrollHeight}
|
|
1053
|
-
async function loadChatHistory(){const data=await api('/api/chat/history');renderChatMessages(data.messages||[])}
|
|
1054
|
-
let currentAgentMessage=null;
|
|
1055
|
-
function connectEvents(){
|
|
1056
|
-
if(state.events) state.events.close();
|
|
1057
|
-
const qs = token ? '?token='+encodeURIComponent(token) : '';
|
|
1058
|
-
const events = new EventSource('/api/events'+qs);
|
|
1059
|
-
state.events=events;
|
|
1060
|
-
setConnection('Connecting','warn');
|
|
1061
|
-
events.onopen=()=>{if(state.reconnectTimer){clearTimeout(state.reconnectTimer);state.reconnectTimer=null}setConnection('Live','ok')};
|
|
1062
|
-
events.addEventListener('snapshot', e=>{const d=JSON.parse(e.data).data;state.snapshot=d;renderSnapshot(d);renderSessionControls()});
|
|
1063
|
-
events.addEventListener('chat_history', e=>renderChatMessages(JSON.parse(e.data).messages||[]));
|
|
1064
|
-
events.addEventListener('activity_update', e=>renderActivity(JSON.parse(e.data).events||[]));
|
|
1065
|
-
events.addEventListener('session_update', e=>{loadBootstrap();loadChatHistory()});
|
|
1066
|
-
events.addEventListener('queue_update', e=>{const d=JSON.parse(e.data);renderQueue(d.queue,d.paused)});
|
|
1067
|
-
events.addEventListener('turn_start', e=>{const d=JSON.parse(e.data);appendMessage('user',d.prompt);currentAgentMessage=appendMessage('agent','');if(state.currentPage==='tasks')loadTasks()});
|
|
1068
|
-
events.addEventListener('text_delta', e=>{const d=JSON.parse(e.data);if(!currentAgentMessage)currentAgentMessage=appendMessage('agent','');currentAgentMessage.textContent+=d.delta;currentAgentMessage.scrollIntoView({block:'end'});if(state.currentPage==='tasks')loadTasks()});
|
|
1069
|
-
events.addEventListener('tool_start', e=>{const d=JSON.parse(e.data);tool('tool','Started '+d.toolName);if(state.currentPage==='tasks')loadTasks()});
|
|
1070
|
-
events.addEventListener('tool_update', e=>{const d=JSON.parse(e.data);if(d.partialResult)tool('tool',d.partialResult.slice(-600))});
|
|
1071
|
-
events.addEventListener('tool_end', e=>{const d=JSON.parse(e.data);tool(d.isError?'danger':'tool','Finished '+d.toolCallId+(d.isError?' with error':''))});
|
|
1072
|
-
events.addEventListener('todo_update', e=>{const d=JSON.parse(e.data);tool('tool','Plan:\\n'+d.items.map(i=>(i.completed?'[x] ':'[ ] ')+i.text).join('\\n'))});
|
|
1073
|
-
events.addEventListener('turn_error', e=>{const d=JSON.parse(e.data);appendMessage('system','Error: '+d.error);currentAgentMessage=null});
|
|
1074
|
-
events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;notify('NordRelay turn finished','The active task completed.');loadBootstrap();if(state.currentPage==='tasks')loadTasks()});
|
|
1075
|
-
events.addEventListener('status', e=>{const d=JSON.parse(e.data);const msg=d.message||'';if(isCliRunningStatus(msg)){state.cliStatusActive=true;toast(msg,{sticky:true});return}if(isCliDoneStatus(msg))state.cliStatusActive=false;toast(msg)});
|
|
1076
|
-
events.onerror=()=>{setConnection('Reconnecting','error');if(!state.reconnectTimer)state.reconnectTimer=setTimeout(()=>{state.reconnectTimer=null;connectEvents()},5000)};
|
|
1077
|
-
}
|
|
1078
|
-
function setConnection(text,kind){const el=document.getElementById('connectionStatus');el.textContent=text;el.className='badge connection-'+kind}
|
|
1079
|
-
async function enableNotifications(){if(!('Notification' in window)){toast('Browser notifications are not supported');return}const permission=Notification.permission==='granted'?'granted':await Notification.requestPermission();state.notifications=permission==='granted';toast(state.notifications?'Browser notifications enabled':'Browser notifications denied')}
|
|
1080
|
-
function notify(title,body){if(state.notifications&&'Notification' in window&&Notification.permission==='granted')new Notification(title,{body})}
|
|
1081
|
-
function updateToolAgeTitles(){document.querySelectorAll('.tool[data-created-at]').forEach(el=>{const created=Number(el.dataset.createdAt||Date.now());el.title='Updated '+fmtAge(Date.now()-created)})}
|
|
1082
|
-
function tool(cls,text){const div=document.createElement('div');div.className='tool '+(cls==='danger'?'danger':'');div.dataset.createdAt=String(Date.now());div.textContent=text;document.getElementById('toolStream').prepend(div);updateToolAgeTitles()}
|
|
1083
|
-
setInterval(updateToolAgeTitles,30000);
|
|
1084
|
-
let selectedFiles=[];
|
|
1085
|
-
function renderSelectedFiles(){const summary=document.getElementById('fileSummary');if(selectedFiles.length===0){summary.textContent='No files selected';return}const names=selectedFiles.slice(0,3).map(f=>f.name || 'file').join(', ');const more=selectedFiles.length>3?' +'+(selectedFiles.length-3)+' more':'';const bytes=selectedFiles.reduce((sum,file)=>sum+file.size,0);summary.textContent=names+more+' ('+fmtBytes(bytes)+')'}
|
|
1086
|
-
function addFiles(files){selectedFiles=selectedFiles.concat(Array.from(files||[]));renderSelectedFiles()}
|
|
1087
|
-
async function filePayload(file){return {name:file.name || 'upload',mimeType:file.type || 'application/octet-stream',dataBase64:await fileToBase64(file)}}
|
|
1088
|
-
async function fileToBase64(file){const buffer=await file.arrayBuffer();const bytes=new Uint8Array(buffer);let binary='';const chunk=0x8000;for(let i=0;i<bytes.length;i+=chunk){binary+=String.fromCharCode(...bytes.subarray(i,i+chunk))}return btoa(binary)}
|
|
1089
|
-
document.getElementById('fileInput').onchange=e=>{addFiles(e.target.files)};
|
|
1090
|
-
document.getElementById('clearFilesBtn').onclick=()=>{selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles()};
|
|
1091
|
-
document.addEventListener('paste',e=>{const files=Array.from(e.clipboardData?.files||[]);if(files.length){addFiles(files);toast('Pasted '+files.length+' file(s)')}});
|
|
1092
|
-
document.addEventListener('dragover',e=>{e.preventDefault();document.body.classList.add('drop-active')});
|
|
1093
|
-
document.addEventListener('dragleave',()=>document.body.classList.remove('drop-active'));
|
|
1094
|
-
document.addEventListener('drop',e=>{e.preventDefault();document.body.classList.remove('drop-active');const files=Array.from(e.dataTransfer?.files||[]);if(files.length){addFiles(files);toast('Added '+files.length+' dropped file(s)')}});
|
|
1095
|
-
document.getElementById('promptForm').onsubmit=e=>safe(async()=>{e.preventDefault();const input=document.getElementById('promptInput');const text=input.value.trim();if(!text&&selectedFiles.length===0)return;const files=selectedFiles;input.value='';selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles();const payloadFiles=files.length?await Promise.all(files.map(filePayload)):[];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})});if(r.transcribeOnly)appendMessage('system','Transcribed audio:\\n'+(r.transcript||'(empty)'));else if(r.queued)appendQueuedMessage(r.queueId)},e);
|
|
1096
|
-
document.getElementById('newSessionBtn').onclick=()=>openNewSessionDialog();
|
|
1097
|
-
document.getElementById('retryBtn').onclick=()=>safe(async()=>{const r=await api('/api/retry',{method:'POST'});toast(r.queued?'Retry queued '+r.queueId:'Retry started')});
|
|
1098
|
-
document.getElementById('editLastBtn').onclick=()=>{const last=[...(state.chatMessages||[])].reverse().find(m=>m.role==='user');if(last){document.getElementById('promptInput').value=last.text;document.getElementById('promptInput').focus()}else toast('No user prompt found')};
|
|
1099
|
-
document.getElementById('syncBtn').onclick=()=>safe(async()=>{const r=await api('/api/sync',{method:'POST'});toast(r.changed?'Synced: '+(r.changedFields||[]).join(', '):'Already in sync');loadBootstrap()});
|
|
1100
|
-
document.getElementById('notifyBtn').onclick=()=>enableNotifications();
|
|
1101
|
-
document.getElementById('clearChatBtn').onclick=()=>safe(async()=>{if(confirm('Clear chat history for the current thread?')){const r=await api('/api/chat/history',{method:'DELETE'});renderChatMessages(r.messages||[]);toast('Removed '+r.removed+' messages')}});
|
|
1102
|
-
document.getElementById('abortBtn').onclick=()=>safe(async()=>{await api('/api/abort',{method:'POST'});toast('Abort sent')});
|
|
1103
|
-
document.getElementById('handbackBtn').onclick=()=>safe(async()=>{const r=await api('/api/handback',{method:'POST'});appendMessage('system','Handback command:\\n'+(r.command||'No command available'))});
|
|
1104
|
-
document.getElementById('recordBtn').onclick=()=>safe(async()=>{const btn=document.getElementById('recordBtn');if(state.mediaRecorder&&state.mediaRecorder.state==='recording'){state.mediaRecorder.stop();btn.textContent='Record voice';return}const stream=await navigator.mediaDevices.getUserMedia({audio:true});state.recordedChunks=[];state.mediaRecorder=new MediaRecorder(stream);state.mediaRecorder.ondataavailable=e=>{if(e.data.size>0)state.recordedChunks.push(e.data)};state.mediaRecorder.onstop=()=>{stream.getTracks().forEach(t=>t.stop());const blob=new Blob(state.recordedChunks,{type:'audio/webm'});addFiles([new File([blob],'voice-note.webm',{type:'audio/webm'})]);toast('Voice note attached')};state.mediaRecorder.start();btn.textContent='Stop recording'});
|
|
1105
|
-
function renderNewSessionControls(c){const s=state.snapshot?.session||{};const caps=c.capabilities||{};document.getElementById('workspaceOptions').innerHTML=(c.workspaces||[]).map(w=>'<option value="'+attr(w)+'"></option>').join('');document.getElementById('newModel').innerHTML='<option value="">Default</option>'+((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'">'+esc(modelLabel(m))+'</option>').join(''));document.getElementById('newModel').parentElement.style.display=caps.modelSelection?'grid':'none';const reasoningWrap=document.getElementById('newReasoningWrap');reasoningWrap.firstChild.nodeValue=(c.reasoningLabel||'Reasoning');reasoningWrap.style.display=caps.reasoningSelection?'grid':'none';document.getElementById('newReasoning').innerHTML='<option value="">Default</option>'+((c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'">'+esc(v)+'</option>').join(''));document.getElementById('newLaunch').innerHTML='<option value="">Default</option>'+((c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'">'+esc(p.label+' - '+p.behavior)+'</option>').join(''));document.getElementById('newFast').checked=Boolean(s.fastMode&&caps.fastMode);document.getElementById('newLaunchWrap').style.display=caps.launchProfiles?'grid':'none';document.getElementById('newFastWrap').style.display=caps.fastMode?'inline-flex':'none'}
|
|
1106
|
-
function populateNewSessionForm(agents){const s=state.snapshot?.session||{};const agentSelect=document.getElementById('newAgent');agentSelect.innerHTML=(agents||[]).map(a=>'<option value="'+attr(a)+'" '+(a===s.agentId?'selected':'')+'>'+esc(a)+'</option>').join('');agentSelect.value=s.agentId||agentSelect.value;document.getElementById('newWorkspace').value=s.workspace||'';state.newSessionControls=state.controls||{};renderNewSessionControls(state.newSessionControls);agentSelect.onchange=()=>safe(async()=>{state.newSessionControls=await api('/api/control-options?agent='+encodeURIComponent(agentSelect.value));renderNewSessionControls(state.newSessionControls)})}
|
|
1107
|
-
function openNewSessionDialog(){populateNewSessionForm(state.enabledAgents);document.getElementById('newSessionDialog').showModal()}
|
|
1108
|
-
document.getElementById('newSessionForm').onsubmit=e=>safe(async()=>{e.preventDefault();const payload={agentId:val('newAgent'),workspace:val('newWorkspace')||undefined,model:val('newModel')||undefined,reasoningEffort:val('newReasoning')||undefined,launchProfileId:val('newLaunch')||undefined,fastMode:document.getElementById('newFast').checked};await api('/api/sessions/new',{method:'POST',body:JSON.stringify(payload)});document.getElementById('newSessionDialog').close();toast('New session started');await loadBootstrap();await loadChatHistory()},e);
|
|
1109
|
-
document.getElementById('cancelSessionBtn').onclick=()=>document.getElementById('newSessionDialog').close();
|
|
1110
|
-
function val(id){return document.getElementById(id).value.trim()}
|
|
1111
|
-
async function loadSessions(reset=true){if(reset)sessionsPager.reset();const q=document.getElementById('sessionSearch').value||'';const data=await api('/api/sessions?query='+encodeURIComponent(q)+'&page='+sessionsPager.page+'&limit='+sessionsPager.pageSize);document.getElementById('sessionsList').innerHTML=data.sessions.map(s=>'<div class="item"><strong title="'+attr(s.title||s.firstUserMessage||s.id)+'">'+esc(short(s.title||s.firstUserMessage||s.id))+'</strong><small><button type="button" class="copy-id" data-copy-id="'+attr(s.id)+'" title="Copy thread ID">'+esc(short(s.id,64))+'</button> / '+esc(short((s.cwd||'')+' / '+fmtDate(s.updatedAt)))+'</small><div class="row"><button data-switch="'+attr(s.id)+'">Switch</button><button class="secondary" data-session-detail="'+attr(s.id)+'">Details</button></div></div>').join('')||'<div class="item">No sessions found.</div>';sessionsPager.render(data.pagination||{});document.querySelectorAll('[data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||'','Thread ID copied'));document.querySelectorAll('[data-switch]').forEach(b=>b.onclick=()=>safe(async()=>{await api('/api/sessions/switch',{method:'POST',body:JSON.stringify({threadId:b.dataset.switch})});toast('Session switched');loadBootstrap()}));document.querySelectorAll('[data-session-detail]').forEach(b=>b.onclick=()=>safe(()=>loadSessionDetail(b.dataset.sessionDetail)))}
|
|
1112
|
-
async function loadSessionDetail(threadId){const d=await api('/api/sessions/detail?threadId='+encodeURIComponent(threadId));const r=d.record||{};document.getElementById('sessionDetail').innerHTML='<h2>Session detail</h2>'+card('Metadata',[['Thread',threadId],['Agent',r.agentId],['Title',r.title],['Workspace',r.cwd],['Model',r.model],['Reasoning',r.reasoningEffort],['Updated',fmtDate(r.updatedAt)],['Path',r.sessionPath]])+'<h2>Recent messages</h2><div class="list">'+(d.messages||[]).slice(-20).map(m=>'<div class="item"><strong>'+esc(m.role+' / '+fmtDate(m.timestamp))+'</strong><small>'+esc(short(m.text,500))+'</small></div>').join('')+'</div><h2>Activity</h2><div class="list">'+(d.activity||[]).map(e=>'<div class="item"><strong>'+esc(e.status+' / '+e.type+' / '+fmtDate(e.timestamp))+'</strong><small>'+esc(short(e.prompt||e.detail||'',300))+'</small></div>').join('')+'</div>';document.getElementById('sessionDetailDialog').showModal()}
|
|
1113
|
-
document.getElementById('closeSessionDetailBtn').onclick=()=>document.getElementById('sessionDetailDialog').close();
|
|
1114
|
-
document.getElementById('sessionSearchBtn').onclick=()=>loadSessions(true);document.getElementById('sessionSearch').addEventListener('keydown',e=>{if(e.key==='Enter')loadSessions(true)});document.getElementById('attachBtn').onclick=async()=>{const threadId=document.getElementById('attachInput').value.trim();if(threadId){await api('/api/sessions/attach',{method:'POST',body:JSON.stringify({threadId})});toast('Session attached');loadBootstrap()}};
|
|
1115
|
-
function renderQueue(queue,paused){document.getElementById('queueStatus').textContent=paused?'Paused':'Running';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+'">Run</button><button data-q="top" data-id="'+q.id+'">Top</button><button data-q="up" data-id="'+q.id+'">Up</button><button data-q="down" data-id="'+q.id+'">Down</button><button data-q="cancel" data-id="'+q.id+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>';document.querySelectorAll('[data-q]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused)}));let dragged=null;document.querySelectorAll('.queue-item').forEach(item=>{item.ondragstart=()=>{dragged=item.dataset.queueId;item.classList.add('dragging')};item.ondragend=()=>item.classList.remove('dragging');item.ondragover=e=>e.preventDefault();item.ondrop=()=>safe(async()=>{if(dragged&&dragged!==item.dataset.queueId){const ids=Array.from(document.querySelectorAll('.queue-item')).map(el=>el.dataset.queueId);const targetIndex=Math.max(0,ids.indexOf(item.dataset.queueId));await api('/api/queue',{method:'POST',body:JSON.stringify({action:'top',id:dragged})});for(let i=0;i<targetIndex;i++)await api('/api/queue',{method:'POST',body:JSON.stringify({action:'down',id:dragged})});const r=await api('/api/queue');renderQueue(r.queue,r.paused)}})})}
|
|
1116
|
-
document.querySelectorAll('[data-queue]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.queue})});renderQueue(r.queue,r.paused)}));
|
|
1117
|
-
async function loadTasks(){const d=await api('/api/tasks');renderTasks(d)}
|
|
1118
|
-
function taskCard(t,title){if(!t)return '<div class="item"><strong>'+esc(title)+'</strong><small>Idle</small></div>';const tools=(t.tools||[]).map(x=>x.name+' x'+x.count).join(', ')||'-';return '<div class="item"><strong>'+esc(title+' · '+t.status)+'</strong><small>'+esc((t.agentLabel||t.agentId||t.source)+' / '+(t.threadId||'-'))+'</small><small>'+esc('Elapsed '+fmtDuration(t.durationMs)+' / current '+(t.currentTool||'-')+' / last '+(t.lastTool||'-'))+'</small><small>'+esc('Tools: '+tools+' / output chars '+(t.outputChars||0))+'</small><small>'+esc(t.prompt||t.detail||'')+'</small></div>'}
|
|
1119
|
-
function renderTasks(d){document.getElementById('tasksList').innerHTML='<div class="task-grid">'+taskCard(d.current,'Current web turn')+taskCard(d.external,'External CLI turn')+'</div><h2>Queue</h2><div class="list">'+((d.queue||[]).map(q=>'<div class="item"><strong>'+esc(q.id+' · '+q.description)+'</strong><small>'+esc(fmtDate(q.createdAt)+' / attempts '+q.attempts)+'</small><div class="row"><button data-q="run" data-id="'+attr(q.id)+'">Run</button><button data-q="cancel" data-id="'+attr(q.id)+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>')+'</div><h2>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>';document.querySelectorAll('#tasksList [data-q]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused);loadTasks()}))}
|
|
1120
|
-
document.getElementById('reloadTasksBtn').onclick=()=>loadTasks();
|
|
1121
|
-
async function loadArtifacts(){const data=await api('/api/artifacts');state.artifactReports=data.reports||[];renderArtifacts()}
|
|
1122
|
-
function artifactMatches(a,kind,query){const name=(a.name||a.relativePath||'').toLowerCase();if(query&&!name.includes(query))return false;if(kind==='images')return /\\.(png|jpe?g|gif|webp|svg)$/i.test(name);if(kind==='docs')return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);return true}
|
|
1123
|
-
function renderArtifacts(){const query=(document.getElementById('artifactSearch').value||'').toLowerCase();const kind=document.getElementById('artifactKind').value;const reports=state.artifactReports||[];document.getElementById('artifactList').innerHTML=reports.map(r=>{const files=(r.artifacts||[]).filter(a=>artifactMatches(a,kind,query));if(files.length===0)return'';const gallery=files.map(a=>{const href='/api/artifacts/file?turnId='+encodeURIComponent(r.turnId)+'&path='+encodeURIComponent(a.relativePath)+(token?'&token='+encodeURIComponent(token):'');const img=/\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name)?'<img src="'+href+'">':'<pre>'+esc(a.name.split('.').pop()||'file')+'</pre>';return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="'+attr(r.turnId)+'" '+(state.selectedArtifactTurns.has(r.turnId)?'checked':'')+'> '+esc(short(a.name,32))+'</label>'+img+'<small>'+esc(fmtBytes(a.sizeBytes))+'</small><div class="row"><a href="'+href+'">Open</a><button class="secondary" data-preview-turn="'+attr(r.turnId)+'" data-preview-path="'+attr(a.relativePath)+'">Preview</button></div></div>'}).join('');return '<div class="item"><strong>'+esc(r.turnId)+' - '+files.length+'/'+r.fileCount+' files - '+fmtBytes(r.totalSizeBytes)+'</strong><small>'+fmtDate(r.updatedAt)+' / '+esc(r.source||'turn')+'</small><div class="row"><a href="/api/artifacts/zip?turnId='+encodeURIComponent(r.turnId)+(token?'&token='+encodeURIComponent(token):'')+'">Download ZIP</a><button data-del-art="'+esc(r.turnId)+'" class="danger">Delete</button></div><div class="gallery">'+gallery+'</div></div>'}).join('')||'<div class="item">No artifacts.</div>';document.querySelectorAll('[data-artifact-select]').forEach(c=>c.onchange=()=>{if(c.checked)state.selectedArtifactTurns.add(c.dataset.artifactSelect);else state.selectedArtifactTurns.delete(c.dataset.artifactSelect)});document.querySelectorAll('[data-del-art]').forEach(b=>b.onclick=()=>safe(async()=>{if(confirm('Delete artifact turn '+b.dataset.delArt+'?')){await api('/api/artifacts?turnId='+encodeURIComponent(b.dataset.delArt),{method:'DELETE'});state.selectedArtifactTurns.delete(b.dataset.delArt);loadArtifacts()}}));document.querySelectorAll('[data-preview-turn]').forEach(b=>b.onclick=()=>previewArtifact(b.dataset.previewTurn,b.dataset.previewPath))}
|
|
1124
|
-
document.getElementById('reloadArtifactsBtn').onclick=loadArtifacts;
|
|
1125
|
-
document.getElementById('artifactSearch').oninput=renderArtifacts;
|
|
1126
|
-
document.getElementById('artifactKind').onchange=renderArtifacts;
|
|
1127
|
-
document.getElementById('zipSelectedArtifactsBtn').onclick=()=>{const turnIds=[...state.selectedArtifactTurns];if(turnIds.length===0){toast('No artifact turns selected');return}turnIds.forEach(turnId=>window.open('/api/artifacts/zip?turnId='+encodeURIComponent(turnId)+(token?'&token='+encodeURIComponent(token):''),'_blank'))};
|
|
1128
|
-
document.getElementById('deleteSelectedArtifactsBtn').onclick=()=>safe(async()=>{const turnIds=[...state.selectedArtifactTurns];if(turnIds.length===0){toast('No artifact turns selected');return}if(confirm('Delete '+turnIds.length+' selected artifact turn(s)?')){const r=await api('/api/artifacts/bulk',{method:'POST',body:JSON.stringify({action:'delete',turnIds})});state.selectedArtifactTurns.clear();toast('Deleted '+(r.removed||[]).length+' artifact turn(s)');loadArtifacts()}});
|
|
1129
|
-
function highlightCode(text){return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g,'<span class="chip">$1</span>')}
|
|
1130
|
-
async function previewArtifact(turnId,path){const data=await api('/api/artifacts/preview?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path));const target=document.getElementById('artifactPreview');if(data.kind==='image'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><img src="/api/artifacts/file?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path)+(token?'&token='+encodeURIComponent(token):'')+'"></div>';return}if(data.kind==='text'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+' '+fmtBytes(data.sizeBytes)+'</h2><pre>'+highlightCode(data.text||'')+'</pre>'+(data.truncated?'<small>Preview truncated.</small>':'')+'</div>';return}target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><p>'+esc(data.detail||'Preview unavailable')+'</p></div>'}
|
|
1131
|
-
async function loadActivity(){const q='?source='+encodeURIComponent(val('activitySource'))+'&status='+encodeURIComponent(val('activityStatus'))+'&limit='+encodeURIComponent(val('activityLimit')||'100');const data=await api('/api/activity'+q);state.activityEvents=data.events||[];renderActivity(state.activityEvents)}
|
|
1132
|
-
function renderActivity(events){const since=val('activitySince')?new Date(val('activitySince')).getTime():0;const filtered=(events||[]).filter(e=>!since||new Date(e.timestamp).getTime()>=since);document.getElementById('activityList').innerHTML=filtered.map(e=>'<div class="item"><strong><span class="chip '+(e.status==='failed'?'error':e.status==='queued'?'warn':'')+'">'+esc(e.status)+'</span>'+esc(fmtDate(e.timestamp)+' / '+e.source+' / '+e.type)+'</strong><small>'+esc(short(e.prompt||e.detail||'',220))+'</small><small><button type="button" class="copy-id" data-copy-id="'+attr(e.threadId||'')+'">'+esc(e.threadId||'-')+'</button> / '+esc((e.workspace||'-')+' / '+fmtDuration(e.durationMs))+'</small></div>').join('')||'<div class="item">No activity.</div>';document.querySelectorAll('#activityList [data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||'','Thread ID copied'))}
|
|
1133
|
-
document.getElementById('loadActivityBtn').onclick=()=>loadActivity();
|
|
1134
|
-
document.getElementById('activitySince').onchange=()=>renderActivity(state.activityEvents||[]);
|
|
1135
|
-
document.getElementById('exportActivityBtn').onclick=()=>{const rows=(state.activityEvents||[]).map(e=>[e.timestamp,e.source,e.status,e.type,e.threadId||'',e.prompt||e.detail||''].join('\\t')).join('\\n');const blob=new Blob([rows],{type:'text/tab-separated-values'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-activity.tsv';a.click();URL.revokeObjectURL(a.href)};
|
|
1136
|
-
async function loadSettings(){const data=await api('/api/settings');state.settings=data.settings;renderSettings()}
|
|
1137
|
-
const settingsGroupOrder=['Agents','Codex','Pi','Hermes','OpenClaw','Claude Code','Telegram','Operations','Artifacts','Workspace','Voice','Dashboard'];
|
|
1138
|
-
const agentSettingGroups=['Codex','Pi','Hermes','OpenClaw','Claude Code'];
|
|
1139
|
-
function orderedSettingsGroups(groups){const known=settingsGroupOrder.filter(name=>groups[name]);const extra=Object.keys(groups).filter(name=>!settingsGroupOrder.includes(name)).sort();return known.concat(extra)}
|
|
1140
|
-
function agentSettingsNav(current){return '<div class="agent-settings-nav"><strong>Agent settings</strong>'+agentSettingGroups.map(name=>'<button type="button" data-setting-tab="'+attr(name)+'" class="'+(name===current?'active':'')+'">'+esc(name)+'</button>').join('')+'</div>'}
|
|
1141
|
-
function renderSettings(){const groups={};state.settings.forEach(s=>(groups[s.group]??=[]).push(s));const names=orderedSettingsGroups(groups);if(!state.settingsGroup||!groups[state.settingsGroup])state.settingsGroup=groups.Agents?'Agents':names[0];document.getElementById('settingsTabs').innerHTML=names.map(name=>'<button data-setting-tab="'+attr(name)+'" class="'+(name===state.settingsGroup?'active':'')+'">'+esc(name)+' ('+groups[name].length+')</button>').join('');document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});const items=groups[state.settingsGroup]||[];const nav=(state.settingsGroup==='Agents'||agentSettingGroups.includes(state.settingsGroup))?agentSettingsNav(state.settingsGroup):'';document.getElementById('settingsForm').innerHTML='<div class="settings-section"><h2>'+esc(state.settingsGroup||'Settings')+'</h2><div id="settingsRestartBanner"></div>'+nav+items.map(s=>'<div class="setting" data-setting-box="'+attr(s.key)+'" data-restart-required="'+(s.restartRequired?'true':'false')+'"><label>'+esc(s.label)+'</label>'+settingInput(s)+'<small>'+esc(s.key)+' - '+esc(s.description)+(s.effectiveValue?' Active: '+esc(s.effectiveValue)+'.':'')+(s.restartRequired?' Restart required.':'')+(s.configured?' Configured.':' Inherited/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>';document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});bindSettingsUx()}
|
|
1142
|
-
function settingAttrs(s,original){return ' data-setting="'+attr(s.key)+'" data-original-value="'+attr(original)+'" data-configured="'+(s.configured?'true':'false')+'"'}
|
|
1143
|
-
function settingInput(s){const display=s.configured?(s.value||''):(s.effectiveValue||''); if(s.options){const blankLabel=s.effectiveValue?'Use active default ('+s.effectiveValue+')':'Use active default';return '<select'+settingAttrs(s,s.configured?s.value:'')+'><option value="" '+(!s.configured?'selected':'')+'>'+esc(blankLabel)+'</option>'+s.options.map(o=>'<option value="'+attr(o)+'" '+(s.configured&&s.value===o?'selected':'')+'>'+esc(o)+'</option>').join('')+'</select>'} if(s.kind==='boolean'){const blankLabel=s.effectiveValue?'Use active default ('+s.effectiveValue+')':'Use active default';return '<select'+settingAttrs(s,s.configured?s.value:'')+'><option value="" '+(!s.configured?'selected':'')+'>'+esc(blankLabel)+'</option><option value="true" '+(s.configured&&s.value==='true'?'selected':'')+'>true</option><option value="false" '+(s.configured&&s.value==='false'?'selected':'')+'>false</option></select>'} const value=esc(display); if(s.kind==='json')return '<textarea rows="4"'+settingAttrs(s,display)+'>'+value+'</textarea>'; return '<input'+settingAttrs(s,display)+' value="'+value+'" '+(s.kind==='secret'?'type="password"':'')+'>'}
|
|
1144
|
-
function bindSettingsUx(){document.querySelectorAll('[data-setting]').forEach(el=>{el.oninput=markSettingDirty;el.onchange=markSettingDirty});document.querySelectorAll('[data-reset-setting]').forEach(b=>b.onclick=()=>{const input=document.querySelector('[data-setting="'+cssEscape(b.dataset.resetSetting)+'"]');if(input){input.value='';markSettingDirty({target:input})}});document.querySelectorAll('[data-reveal-setting]').forEach(b=>b.onclick=()=>{const input=document.querySelector('[data-setting="'+cssEscape(b.dataset.revealSetting)+'"]');if(input){input.type=input.type==='password'?'text':'password';input.focus()}})}
|
|
1145
|
-
function markSettingDirty(e){const el=e.target;const box=el.closest('.setting');const dirty=el.value!==(el.dataset.originalValue??'');box.classList.toggle('dirty',dirty);const dirtyInputs=Array.from(document.querySelectorAll('[data-setting]')).filter(x=>x.value!==(x.dataset.originalValue??''));const restart=dirtyInputs.some(x=>x.closest('.setting')?.dataset.restartRequired==='true');document.getElementById('settingsStatus').textContent=dirtyInputs.length?dirtyInputs.length+' unsaved change(s)':'';const banner=document.getElementById('settingsRestartBanner');if(banner)banner.innerHTML=restart?'<div class="restart-banner">Some changed settings require a NordRelay restart.</div>':''}
|
|
1146
|
-
document.getElementById('saveSettingsBtn').onclick=()=>safe(async()=>{document.querySelectorAll('.setting-error').forEach(e=>e.textContent='');const patch={};document.querySelectorAll('[data-setting]').forEach(el=>{const original=el.dataset.originalValue??'';if(el.value!==original)patch[el.dataset.setting]=el.value});const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings:patch})});(r.errors||[]).forEach(err=>{const box=document.querySelector('[data-setting-box="'+cssEscape(err.key)+'"] .setting-error');if(box)box.textContent=err.message});document.getElementById('settingsStatus').textContent=(r.errors&&r.errors.length)?'Fix '+r.errors.length+' setting error(s)':(r.changedKeys.length?'Saved '+r.changedKeys.length+' setting(s)'+(r.restartRequired?' - restart required':''):'No changes');toast((r.errors&&r.errors.length)?'Settings need attention':'Settings saved');if(!(r.errors&&r.errors.length))await loadSettings()});
|
|
1147
|
-
document.getElementById('restartBtn').onclick=()=>safe(async()=>{if(confirm('Restart NordRelay now?')){await api('/api/runtime/restart',{method:'POST'});toast('Restart requested')}});
|
|
1148
|
-
async function loadAccess(){const d=await api('/api/permissions');document.getElementById('accessPanel').innerHTML=['TELEGRAM_ADMIN_USER_IDS','TELEGRAM_ALLOWED_USER_IDS','TELEGRAM_READONLY_USER_IDS','TELEGRAM_ALLOWED_CHAT_IDS','TELEGRAM_ALLOW_ANY_CHAT','TELEGRAM_ROLE_POLICIES_JSON'].map(key=>{const value=key==='TELEGRAM_ADMIN_USER_IDS'?d.telegramAdminUserIds.join(','):key==='TELEGRAM_ALLOWED_USER_IDS'?d.telegramAllowedUserIds.join(','):key==='TELEGRAM_READONLY_USER_IDS'?d.telegramReadOnlyUserIds.join(','):key==='TELEGRAM_ALLOWED_CHAT_IDS'?d.telegramAllowedChatIds.join(','):key==='TELEGRAM_ALLOW_ANY_CHAT'?String(d.telegramAllowAnyChat):JSON.stringify(d.telegramRolePolicies||{},null,2);return '<div class="setting"><label>'+esc(key)+'</label>'+(key.endsWith('_JSON')?'<textarea rows="5" data-access-setting="'+key+'">'+esc(value)+'</textarea>':'<input data-access-setting="'+key+'" value="'+esc(value)+'">')+'<small>Access control setting. Restart required after saving.</small></div>'}).join('');await loadLocks();await loadAudit()}
|
|
1149
|
-
document.getElementById('loadAccessBtn').onclick=()=>loadAccess();
|
|
1150
|
-
document.getElementById('saveAccessBtn').onclick=()=>safe(async()=>{const settings={};document.querySelectorAll('[data-access-setting]').forEach(el=>settings[el.dataset.accessSetting]=el.value);const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings})});toast((r.errors&&r.errors.length)?'Access settings need attention':'Access settings saved. Restart required.');if(r.errors&&r.errors.length)document.getElementById('accessPanel').insertAdjacentHTML('afterbegin','<div class="restart-banner">'+esc(r.errors.map(e=>e.key+': '+e.message).join(' / '))+'</div>')});
|
|
1151
|
-
async function loadLocks(){const d=await api('/api/locks');document.getElementById('locksList').innerHTML=(d.locks||[]).map(l=>'<div class="item"><strong>'+esc(l.contextKey)+'</strong><small>'+esc((l.ownerName||'owner')+' / '+l.ownerId+' / expires '+fmtDate(l.expiresAt))+'</small></div>').join('')||'<div class="item">No active locks.</div>'}
|
|
1152
|
-
document.getElementById('lockSessionBtn').onclick=()=>safe(async()=>{await api('/api/locks',{method:'POST',body:JSON.stringify({ownerName:'Web dashboard'})});toast('Web session locked');loadLocks()});
|
|
1153
|
-
document.getElementById('unlockSessionBtn').onclick=()=>safe(async()=>{await api('/api/locks',{method:'DELETE'});toast('Web session unlocked');loadLocks()});
|
|
1154
|
-
async function loadAudit(){const d=await api('/api/audit?limit='+encodeURIComponent(val('auditLimit')||'50'));document.getElementById('auditList').innerHTML=(d.events||[]).map(e=>'<div class="item"><strong>'+esc(fmtDate(e.timestamp)+' / '+(e.channelId||'-')+' / '+e.status+' / '+e.action)+'</strong><small>'+esc((e.contextKey||'-')+' / '+(e.agentId||'-')+' / '+(e.threadId||'-'))+'</small><small>'+esc(e.description||e.detail||'')+'</small></div>').join('')||'<div class="item">No audit events.</div>'}
|
|
1155
|
-
document.getElementById('loadAuditBtn').onclick=()=>loadAudit();
|
|
1156
|
-
async function loadLogs(){const target=document.getElementById('logTarget').value;const lines=document.getElementById('logLines').value;const data=await api('/api/logs?target='+target+'&lines='+lines);state.logsPlain=data.plain||'';renderLogs();if(document.getElementById('logFollow').checked)document.getElementById('logs').scrollTop=document.getElementById('logs').scrollHeight}document.getElementById('loadLogsBtn').onclick=loadLogs;
|
|
1157
|
-
function logLevelOf(line){if(line.includes(' ERROR '))return'ERROR';if(line.includes(' WARN '))return'WARN';if(line.includes(' INFO '))return'INFO';return''}
|
|
1158
|
-
function logTimeOf(line){const m=line.match(/^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})/);return m?new Date(m[1].replace(' ','T')).getTime():0}
|
|
1159
|
-
function renderLogs(){const level=val('logLevel');const query=val('logSearch').toLowerCase();const since=val('logSince')?new Date(val('logSince')).getTime():0;const lines=state.logsPlain.split(/\\n/).filter(line=>(level==='all'||line.includes(level))&&(!query||line.toLowerCase().includes(query))&&(!since||!logTimeOf(line)||logTimeOf(line)>=since));document.getElementById('logs').innerHTML=lines.map(line=>'<span class="log-line '+logLevelOf(line)+'">'+esc(line)+'</span>').join('\\n')||'(empty)'}
|
|
1160
|
-
document.getElementById('logLevel').onchange=renderLogs;document.getElementById('logSearch').oninput=renderLogs;document.getElementById('logSince').onchange=renderLogs;document.getElementById('logAutoRefresh').onchange=e=>{clearInterval(state.logTimer);state.logTimer=null;if(e.target.checked)state.logTimer=setInterval(loadLogs,5000)};document.getElementById('downloadLogsBtn').onclick=()=>{const blob=new Blob([state.logsPlain||''],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-log.txt';a.click();URL.revokeObjectURL(a.href)};
|
|
1161
|
-
async function loadAdapterHealth(){const d=await api('/api/adapters/health');document.getElementById('adapterHealth').innerHTML=(d.adapters||[]).map(a=>'<div class="item"><strong>'+esc(a.label)+' <span class="adapter-status '+esc(a.status)+'">'+esc(a.status)+'</span></strong><small>'+esc('CLI: '+(a.cli.label||'-')+' / path '+(a.cli.path||'-')+' / version '+(a.cli.version||'-'))+'</small><small>'+esc('Auth: '+(a.auth.supported?(a.auth.authenticated?'authenticated':'not authenticated'):'not managed')+' '+(a.auth.detail||''))+'</small><small>'+esc('Version: '+a.version.installed+' / latest '+(a.version.latest||'-')+' / '+a.version.status)+'</small><div class="row"><button data-auth-status="'+attr(a.id)+'">Auth status</button><button data-auth-login="'+attr(a.id)+'" class="secondary" '+(!a.capabilities.login?'disabled':'')+'>Login</button><button data-auth-logout="'+attr(a.id)+'" class="secondary" '+(!a.capabilities.logout?'disabled':'')+'>Logout</button></div></div>').join('')||'<div class="item">No adapters.</div>';document.querySelectorAll('[data-auth-status]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/status?agent='+encodeURIComponent(b.dataset.authStatus));toast(r.agentLabel+': '+r.detail,{duration:6000})}));document.querySelectorAll('[data-auth-login]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({agentId:b.dataset.authLogin})});toast((r.result?.message||r.detail),{duration:8000});loadAdapterHealth()}));document.querySelectorAll('[data-auth-logout]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/logout',{method:'POST',body:JSON.stringify({agentId:b.dataset.authLogout})});toast((r.result?.message||r.detail),{duration:8000});loadAdapterHealth()}))}
|
|
1162
|
-
document.getElementById('reloadAdaptersBtn').onclick=()=>loadAdapterHealth();
|
|
1163
|
-
async function loadVersion(){const d=await api('/api/version');const checks=d.versionChecks||{};document.getElementById('versionPanel').innerHTML=Object.values(checks).map(v=>'<div class="item"><strong>'+esc((v.status==='current'?'OK ':v.status==='outdated'?'WARN ':'')+v.label)+'</strong><small>'+esc('Installed: '+(v.installedLabel||'-'))+'</small><small>'+esc('Latest: '+(v.latestVersion||'-')+' / '+v.status)+'</small><small>'+esc(v.detail||'')+'</small></div>').join('')+card('Runtime',[['Status',d.state?.status],['Version',d.health?.version],['Codex CLI',d.health?.codexCli],['Pi CLI',d.health?.piCli],['Hermes CLI',d.health?.hermesCli],['OpenClaw CLI',d.health?.openClawCli],['Claude Code CLI',d.health?.claudeCodeCli]])}
|
|
1164
|
-
document.getElementById('loadVersionBtn').onclick=()=>loadVersion();
|
|
1165
|
-
document.getElementById('updateBtn').onclick=()=>safe(async()=>{if(confirm('Start NordRelay self-update now?')){const r=await api('/api/update',{method:'POST'});toast('Update started via '+r.method+'. Log: '+r.logPath,{duration:8000});page('logs');document.getElementById('logTarget').value='update';loadLogs()}});
|
|
1166
|
-
async function loadDiagnostics(){const data=await api('/api/diagnostics');document.getElementById('diagnostics').innerHTML=diagnosticsHtml(data)}
|
|
1167
|
-
function diagnosticsHtml(d){const h=d.health||{};const s=d.snapshot?.session||{};const vc=d.versionChecks||{};const caps=s.capabilities||{};const agentDiag=d.runtime?.agentDiagnostics;return '<div class="list">'+card('Runtime',[['Status',h.state?.status],['PID',h.state?.pid],['App PID',h.state?.appPid],['State',h.stateFile],['Log',h.logFile],['State backend',d.runtime?.stateBackend],['Uptime',h.uptimeSeconds+'s']])+card('Agent',[['Agent',s.agentLabel],['Thread',s.threadId],['Workspace',s.workspace],['Model',s.model],['Reasoning',s.reasoningEffort],['Fast',caps.fastMode?(s.fastMode?'on':'off'):'n/a']])+card('Agent State',(agentDiag?.lines||[]).map(x=>[x.label,x.value]))+card('CLI Versions',Object.values(vc).map(v=>[v.label,(v.status==='current'?'OK ':'WARN ')+(v.installedLabel||'-')+' latest '+(v.latestVersion||'-')]))+card('External Mirror',d.runtime?.externalMirror?Object.entries(d.runtime.externalMirror):[['Status','idle']])+'</div>'}
|
|
1168
|
-
function card(title,rows){return '<div class="item"><strong>'+esc(title)+'</strong>'+rows.map(r=>'<small>'+esc(r[0])+': '+esc(r[1]??'-')+'</small>').join('')+'</div>'}
|
|
1169
|
-
function safe(fn,event){if(event&&event.preventDefault)event.preventDefault();Promise.resolve().then(fn).catch(err=>toast(err.message||String(err)))}
|
|
1170
|
-
loadBootstrap().then(()=>{connectEvents();loadChatHistory();loadSessions();loadArtifacts();loadSettings();loadLogs();loadDiagnostics();loadActivity()}).catch(err=>toast(err.message));
|
|
1171
|
-
`;
|
|
1172
|
-
}
|