@nordbyte/nordrelay 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -10
- package/dist/access-control.js +2 -0
- package/dist/agent-updates.js +43 -8
- package/dist/bot-ui.js +1 -0
- package/dist/bot.js +108 -1063
- package/dist/channel-actions.js +8 -8
- package/dist/operations.js +63 -9
- package/dist/relay-artifact-service.js +126 -0
- package/dist/relay-external-activity-monitor.js +216 -0
- package/dist/relay-queue-service.js +66 -0
- package/dist/relay-runtime-types.js +1 -0
- package/dist/relay-runtime.js +77 -359
- package/dist/support-bundle.js +205 -0
- package/dist/telegram-agent-commands.js +212 -0
- package/dist/telegram-artifact-commands.js +139 -0
- package/dist/telegram-command-menu.js +1 -0
- package/dist/telegram-command-types.js +1 -0
- package/dist/telegram-diagnostics-command.js +102 -0
- package/dist/telegram-general-commands.js +52 -0
- package/dist/telegram-operational-commands.js +153 -0
- package/dist/telegram-preference-commands.js +198 -0
- package/dist/telegram-queue-commands.js +278 -0
- package/dist/telegram-support-command.js +53 -0
- package/dist/telegram-update-commands.js +6 -1
- package/dist/web-api-contract.js +79 -31
- package/dist/web-api-types.js +1 -0
- package/dist/web-dashboard-access-routes.js +163 -0
- package/dist/web-dashboard-artifact-routes.js +65 -0
- package/dist/web-dashboard-assets.js +2 -0
- package/dist/web-dashboard-http.js +143 -0
- package/dist/web-dashboard-pages.js +257 -0
- package/dist/web-dashboard-runtime-routes.js +92 -0
- package/dist/web-dashboard-session-routes.js +209 -0
- package/dist/web-dashboard.js +43 -882
- package/dist/webui-assets/dashboard.css +74 -4
- package/dist/webui-assets/dashboard.js +163 -24
- package/dist/zip-writer.js +83 -0
- package/package.json +10 -4
package/dist/web-dashboard.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { createReadStream } from "node:fs";
|
|
2
1
|
import { createServer } from "node:http";
|
|
3
2
|
import os from "node:os";
|
|
4
3
|
import path from "node:path";
|
|
@@ -8,17 +7,20 @@ import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
|
8
7
|
import { isAgentId } from "./agent.js";
|
|
9
8
|
import { AuditLogStore } from "./audit-log.js";
|
|
10
9
|
import { listChannelDescriptors } from "./channel-adapter.js";
|
|
11
|
-
import {
|
|
10
|
+
import { permissionForWebRequest } from "./access-control.js";
|
|
12
11
|
import { loadConfig } from "./config.js";
|
|
13
12
|
import { friendlyErrorText } from "./error-messages.js";
|
|
14
|
-
import { escapeHTML } from "./format.js";
|
|
15
13
|
import { RelayRuntime } from "./relay-runtime.js";
|
|
16
14
|
import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
|
|
17
|
-
import { UserStore, publicUser
|
|
15
|
+
import { UserStore, publicUser } from "./user-management.js";
|
|
16
|
+
import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
|
|
17
|
+
import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
|
|
18
18
|
import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
|
|
19
|
-
import {
|
|
19
|
+
import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, } from "./web-dashboard-http.js";
|
|
20
|
+
import { renderDashboardApp, renderLoginPage } from "./web-dashboard-pages.js";
|
|
21
|
+
import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
|
|
22
|
+
import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
|
|
20
23
|
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
21
|
-
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
22
24
|
const options = parseOptions(process.argv.slice(2));
|
|
23
25
|
const config = loadConfig();
|
|
24
26
|
const runtime = new RelayRuntime(config);
|
|
@@ -122,6 +124,18 @@ async function handleApi(req, res, url, authUser) {
|
|
|
122
124
|
sendJson(res, 403, { error: `Access denied: ${permission} permission required.` });
|
|
123
125
|
return;
|
|
124
126
|
}
|
|
127
|
+
if (await handleDashboardRuntimeRoute(req, res, url, {
|
|
128
|
+
runtime,
|
|
129
|
+
users,
|
|
130
|
+
authUser,
|
|
131
|
+
parseAgentIdRequired,
|
|
132
|
+
assertScopedAgent,
|
|
133
|
+
assertAgentUpdateJobScope,
|
|
134
|
+
assertCurrentSessionScope,
|
|
135
|
+
scopedTasks,
|
|
136
|
+
})) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
125
139
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
126
140
|
await assertCurrentSessionScope(authUser);
|
|
127
141
|
sendJson(res, 200, {
|
|
@@ -140,256 +154,12 @@ async function handleApi(req, res, url, authUser) {
|
|
|
140
154
|
sendJson(res, 200, scopedControlOptions(authUser, await runtime.controlOptions(agentId)));
|
|
141
155
|
return;
|
|
142
156
|
}
|
|
143
|
-
if (req
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
sendJson(res, 200, await runtime.version());
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
if (req.method === "POST" && url.pathname === "/api/update") {
|
|
153
|
-
sendJson(res, 202, runtime.updateConnector());
|
|
154
|
-
return;
|
|
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
|
-
}
|
|
195
|
-
if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
|
|
196
|
-
sendJson(res, 200, await scopedTasks(authUser, runtime.tasks()));
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
if (req.method === "GET" && url.pathname === "/api/adapters/health") {
|
|
200
|
-
sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
if (req.method === "GET" && url.pathname === "/api/permissions") {
|
|
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 });
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
if (req.method === "GET" && url.pathname === "/api/audit") {
|
|
356
|
-
sendJson(res, 200, { events: runtime.audit(numberParam(url, "limit", 50)) });
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
if (req.method === "GET" && url.pathname === "/api/locks") {
|
|
360
|
-
await assertCurrentSessionScope(authUser);
|
|
361
|
-
sendJson(res, 200, { locks: runtime.locks() });
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
if (req.method === "POST" && url.pathname === "/api/locks") {
|
|
365
|
-
const body = await readJsonBody(req);
|
|
366
|
-
await assertCurrentSessionScope(authUser);
|
|
367
|
-
sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
if (req.method === "DELETE" && url.pathname === "/api/locks") {
|
|
371
|
-
await assertCurrentSessionScope(authUser);
|
|
372
|
-
sendJson(res, 200, runtime.unlockWebSession());
|
|
373
|
-
return;
|
|
374
|
-
}
|
|
375
|
-
if (req.method === "GET" && url.pathname === "/api/auth/status") {
|
|
376
|
-
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
377
|
-
assertScopedAgent(authUser, agentId);
|
|
378
|
-
sendJson(res, 200, await runtime.authStatus(agentId));
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
if (req.method === "POST" && url.pathname === "/api/auth/login") {
|
|
382
|
-
const body = await readJsonBody(req);
|
|
383
|
-
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
384
|
-
assertScopedAgent(authUser, agentId);
|
|
385
|
-
sendJson(res, 200, await runtime.login(agentId));
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
if (req.method === "POST" && url.pathname === "/api/auth/logout") {
|
|
389
|
-
const body = await readJsonBody(req);
|
|
390
|
-
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
391
|
-
assertScopedAgent(authUser, agentId);
|
|
392
|
-
sendJson(res, 200, await runtime.logout(agentId));
|
|
157
|
+
if (await handleDashboardAccessRoute(req, res, url, {
|
|
158
|
+
users,
|
|
159
|
+
runtime,
|
|
160
|
+
authUser,
|
|
161
|
+
auditUserAction,
|
|
162
|
+
})) {
|
|
393
163
|
return;
|
|
394
164
|
}
|
|
395
165
|
if (req.method === "GET" && url.pathname === "/api/settings") {
|
|
@@ -401,249 +171,25 @@ async function handleApi(req, res, url, authUser) {
|
|
|
401
171
|
sendJson(res, 200, await settings.update(objectRecord(body?.settings)));
|
|
402
172
|
return;
|
|
403
173
|
}
|
|
404
|
-
if (req
|
|
405
|
-
|
|
406
|
-
|
|
174
|
+
if (await handleDashboardSessionRoute(req, res, url, {
|
|
175
|
+
runtime,
|
|
176
|
+
authUser,
|
|
177
|
+
parseAgentId,
|
|
178
|
+
assertScopedAgent,
|
|
179
|
+
assertScopedWorkspace,
|
|
180
|
+
assertCurrentSessionScope,
|
|
181
|
+
assertSessionScope,
|
|
182
|
+
assertSessionDetailScope,
|
|
183
|
+
scopedSessionPage,
|
|
184
|
+
filterActivityByScope,
|
|
185
|
+
})) {
|
|
407
186
|
return;
|
|
408
187
|
}
|
|
409
|
-
if (req
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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));
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
if (req.method === "POST" && url.pathname === "/api/agent") {
|
|
422
|
-
const body = await readJsonBody(req);
|
|
423
|
-
const agentId = stringField(body, "agentId");
|
|
424
|
-
if (!isAgentId(agentId)) {
|
|
425
|
-
throw new Error(`Invalid agent: ${agentId}`);
|
|
426
|
-
}
|
|
427
|
-
assertScopedAgent(authUser, agentId);
|
|
428
|
-
sendJson(res, 200, { session: await runtime.setAgent(agentId) });
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (req.method === "POST" && url.pathname === "/api/sessions/new") {
|
|
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);
|
|
437
|
-
sendJson(res, 200, {
|
|
438
|
-
session: await runtime.newSession({
|
|
439
|
-
agentId,
|
|
440
|
-
workspace,
|
|
441
|
-
model: optionalStringField(body, "model"),
|
|
442
|
-
reasoningEffort: optionalStringField(body, "reasoningEffort"),
|
|
443
|
-
launchProfileId: optionalStringField(body, "launchProfileId"),
|
|
444
|
-
fastMode: optionalBooleanField(body, "fastMode"),
|
|
445
|
-
}),
|
|
446
|
-
});
|
|
447
|
-
return;
|
|
448
|
-
}
|
|
449
|
-
if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
|
|
450
|
-
const body = await readJsonBody(req);
|
|
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 });
|
|
459
|
-
return;
|
|
460
|
-
}
|
|
461
|
-
if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
|
|
462
|
-
const body = await readJsonBody(req);
|
|
463
|
-
const session = await runtime.attachSession(stringField(body, "threadId"));
|
|
464
|
-
assertSessionScope(authUser, session);
|
|
465
|
-
sendJson(res, 200, { session });
|
|
466
|
-
return;
|
|
467
|
-
}
|
|
468
|
-
if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
|
|
469
|
-
const threadId = requiredSearch(url, "threadId");
|
|
470
|
-
const detail = await runtime.sessionDetail(threadId);
|
|
471
|
-
assertSessionDetailScope(authUser, threadId, detail);
|
|
472
|
-
sendJson(res, 200, detail);
|
|
473
|
-
return;
|
|
474
|
-
}
|
|
475
|
-
if (req.method === "GET" && url.pathname === "/api/models") {
|
|
476
|
-
await assertCurrentSessionScope(authUser);
|
|
477
|
-
sendJson(res, 200, { models: await runtime.listModels() });
|
|
478
|
-
return;
|
|
479
|
-
}
|
|
480
|
-
if (req.method === "POST" && url.pathname === "/api/session/model") {
|
|
481
|
-
const body = await readJsonBody(req);
|
|
482
|
-
await assertCurrentSessionScope(authUser);
|
|
483
|
-
sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
|
|
487
|
-
const body = await readJsonBody(req);
|
|
488
|
-
await assertCurrentSessionScope(authUser);
|
|
489
|
-
sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
if (req.method === "POST" && url.pathname === "/api/session/fast") {
|
|
493
|
-
const body = await readJsonBody(req);
|
|
494
|
-
await assertCurrentSessionScope(authUser);
|
|
495
|
-
sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
if (req.method === "POST" && url.pathname === "/api/session/launch") {
|
|
499
|
-
const body = await readJsonBody(req);
|
|
500
|
-
await assertCurrentSessionScope(authUser);
|
|
501
|
-
sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
if (req.method === "POST" && url.pathname === "/api/prompt") {
|
|
505
|
-
const body = await readJsonBody(req);
|
|
506
|
-
await assertCurrentSessionScope(authUser);
|
|
507
|
-
sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
|
|
511
|
-
const body = await readJsonBody(req);
|
|
512
|
-
await assertCurrentSessionScope(authUser);
|
|
513
|
-
sendJson(res, 202, await runtime.sendUploadPrompt({
|
|
514
|
-
text: optionalStringField(body, "text"),
|
|
515
|
-
files: parseUploadFiles(body.files),
|
|
516
|
-
}));
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
|
|
520
|
-
await assertCurrentSessionScope(authUser);
|
|
521
|
-
await runtime.abort();
|
|
522
|
-
sendJson(res, 200, { ok: true });
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
if (req.method === "POST" && url.pathname === "/api/handback") {
|
|
526
|
-
await assertCurrentSessionScope(authUser);
|
|
527
|
-
sendJson(res, 200, await runtime.handback());
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
if (req.method === "POST" && url.pathname === "/api/retry") {
|
|
531
|
-
await assertCurrentSessionScope(authUser);
|
|
532
|
-
sendJson(res, 202, await runtime.retry());
|
|
533
|
-
return;
|
|
534
|
-
}
|
|
535
|
-
if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
536
|
-
await assertCurrentSessionScope(authUser);
|
|
537
|
-
sendJson(res, 200, await runtime.sync());
|
|
538
|
-
return;
|
|
539
|
-
}
|
|
540
|
-
if (req.method === "GET" && url.pathname === "/api/queue") {
|
|
541
|
-
await assertCurrentSessionScope(authUser);
|
|
542
|
-
sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
|
|
543
|
-
return;
|
|
544
|
-
}
|
|
545
|
-
if (req.method === "POST" && url.pathname === "/api/queue") {
|
|
546
|
-
const body = await readJsonBody(req);
|
|
547
|
-
await assertCurrentSessionScope(authUser);
|
|
548
|
-
sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
if (req.method === "GET" && url.pathname === "/api/chat/history") {
|
|
552
|
-
await assertCurrentSessionScope(authUser);
|
|
553
|
-
sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
|
|
557
|
-
await assertCurrentSessionScope(authUser);
|
|
558
|
-
sendJson(res, 200, await runtime.clearChatHistory());
|
|
559
|
-
return;
|
|
560
|
-
}
|
|
561
|
-
if (req.method === "GET" && url.pathname === "/api/activity") {
|
|
562
|
-
sendJson(res, 200, {
|
|
563
|
-
events: filterActivityByScope(authUser, runtime.activity({
|
|
564
|
-
limit: numberParam(url, "limit", 100),
|
|
565
|
-
source: (url.searchParams.get("source") || "all"),
|
|
566
|
-
status: (url.searchParams.get("status") || "all"),
|
|
567
|
-
})),
|
|
568
|
-
});
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
if (req.method === "GET" && url.pathname === "/api/artifacts") {
|
|
572
|
-
await assertCurrentSessionScope(authUser);
|
|
573
|
-
sendJson(res, 200, { reports: await runtime.artifacts() });
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
|
|
577
|
-
await assertCurrentSessionScope(authUser);
|
|
578
|
-
sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
|
|
582
|
-
const body = await readJsonBody(req);
|
|
583
|
-
await assertCurrentSessionScope(authUser);
|
|
584
|
-
const action = stringField(body, "action");
|
|
585
|
-
const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
|
|
586
|
-
if (action !== "delete") {
|
|
587
|
-
throw new Error("Unsupported artifact bulk action.");
|
|
588
|
-
}
|
|
589
|
-
const removed = [];
|
|
590
|
-
for (const turnId of turnIds) {
|
|
591
|
-
if (await runtime.deleteArtifact(turnId)) {
|
|
592
|
-
removed.push(turnId);
|
|
593
|
-
}
|
|
594
|
-
}
|
|
595
|
-
sendJson(res, 200, { removed });
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
|
|
599
|
-
await assertCurrentSessionScope(authUser);
|
|
600
|
-
const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
|
|
601
|
-
if (!bundle) {
|
|
602
|
-
sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
|
|
603
|
-
return;
|
|
604
|
-
}
|
|
605
|
-
sendFile(res, bundle.path, bundle.name);
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
|
|
609
|
-
await assertCurrentSessionScope(authUser);
|
|
610
|
-
const turnId = requiredSearch(url, "turnId");
|
|
611
|
-
const relativePath = requiredSearch(url, "path");
|
|
612
|
-
const report = await runtime.artifact(turnId);
|
|
613
|
-
const artifact = report?.artifacts.find((candidate) => candidate.relativePath === relativePath);
|
|
614
|
-
if (!artifact) {
|
|
615
|
-
sendJson(res, 404, { error: "Artifact not found" });
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
sendFile(res, artifact.localPath, artifact.name);
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
|
|
622
|
-
await assertCurrentSessionScope(authUser);
|
|
623
|
-
const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
|
|
624
|
-
if (!preview) {
|
|
625
|
-
sendJson(res, 404, { error: "Artifact not found" });
|
|
626
|
-
return;
|
|
627
|
-
}
|
|
628
|
-
sendJson(res, 200, preview);
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
if (req.method === "GET" && url.pathname === "/api/logs") {
|
|
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"))));
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
if (req.method === "GET" && url.pathname === "/api/diagnostics") {
|
|
641
|
-
await assertCurrentSessionScope(authUser);
|
|
642
|
-
sendJson(res, 200, await runtime.diagnostics());
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
|
|
646
|
-
sendJson(res, 202, runtime.restartConnector());
|
|
188
|
+
if (await handleDashboardArtifactRoute(req, res, url, {
|
|
189
|
+
runtime,
|
|
190
|
+
authUser,
|
|
191
|
+
assertCurrentSessionScope,
|
|
192
|
+
})) {
|
|
647
193
|
return;
|
|
648
194
|
}
|
|
649
195
|
sendJson(res, 404, { error: "Unknown endpoint" });
|
|
@@ -940,96 +486,6 @@ function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
|
|
|
940
486
|
function resetRateLimit(buckets, key) {
|
|
941
487
|
buckets.delete(key);
|
|
942
488
|
}
|
|
943
|
-
function parseCookies(cookieHeader) {
|
|
944
|
-
const cookies = {};
|
|
945
|
-
for (const part of cookieHeader.split(";")) {
|
|
946
|
-
const [key, ...valueParts] = part.trim().split("=");
|
|
947
|
-
if (key)
|
|
948
|
-
cookies[key] = decodeURIComponent(valueParts.join("=") ?? "");
|
|
949
|
-
}
|
|
950
|
-
return cookies;
|
|
951
|
-
}
|
|
952
|
-
async function readJsonBody(req) {
|
|
953
|
-
const chunks = [];
|
|
954
|
-
for await (const chunk of req) {
|
|
955
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
956
|
-
}
|
|
957
|
-
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
958
|
-
if (!text) {
|
|
959
|
-
return {};
|
|
960
|
-
}
|
|
961
|
-
return JSON.parse(text);
|
|
962
|
-
}
|
|
963
|
-
function sendJson(res, status, value) {
|
|
964
|
-
res.writeHead(status, JSON_HEADERS);
|
|
965
|
-
res.end(`${JSON.stringify(value)}\n`);
|
|
966
|
-
}
|
|
967
|
-
function sendText(res, status, text, contentType) {
|
|
968
|
-
res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
|
|
969
|
-
res.end(text);
|
|
970
|
-
}
|
|
971
|
-
function sendFile(res, filePath, filename) {
|
|
972
|
-
res.writeHead(200, {
|
|
973
|
-
"content-type": "application/octet-stream",
|
|
974
|
-
"content-disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
|
|
975
|
-
});
|
|
976
|
-
createReadStream(filePath).pipe(res);
|
|
977
|
-
}
|
|
978
|
-
function stringField(value, key) {
|
|
979
|
-
const field = value[key];
|
|
980
|
-
if (typeof field !== "string" || !field.trim()) {
|
|
981
|
-
throw new Error(`${key} is required`);
|
|
982
|
-
}
|
|
983
|
-
return field.trim();
|
|
984
|
-
}
|
|
985
|
-
function optionalStringField(value, key) {
|
|
986
|
-
const field = value[key];
|
|
987
|
-
return typeof field === "string" && field.trim() ? field.trim() : undefined;
|
|
988
|
-
}
|
|
989
|
-
function optionalBooleanField(value, key) {
|
|
990
|
-
const field = value[key];
|
|
991
|
-
return typeof field === "boolean" ? field : undefined;
|
|
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
|
-
}
|
|
1033
489
|
function parseAgentId(value) {
|
|
1034
490
|
if (!value) {
|
|
1035
491
|
return undefined;
|
|
@@ -1042,48 +498,6 @@ function parseAgentIdRequired(value) {
|
|
|
1042
498
|
}
|
|
1043
499
|
return value;
|
|
1044
500
|
}
|
|
1045
|
-
function parseLogTarget(value) {
|
|
1046
|
-
return value === "update" || value === "agent-updates" ? value : "connector";
|
|
1047
|
-
}
|
|
1048
|
-
function objectRecord(value) {
|
|
1049
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1050
|
-
return {};
|
|
1051
|
-
}
|
|
1052
|
-
return value;
|
|
1053
|
-
}
|
|
1054
|
-
function parseUploadFiles(value) {
|
|
1055
|
-
if (!Array.isArray(value)) {
|
|
1056
|
-
return [];
|
|
1057
|
-
}
|
|
1058
|
-
return value.map((item, index) => {
|
|
1059
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
1060
|
-
throw new Error(`files[${index}] must be an object`);
|
|
1061
|
-
}
|
|
1062
|
-
const record = item;
|
|
1063
|
-
const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : `upload-${index + 1}`;
|
|
1064
|
-
const mimeType = typeof record.mimeType === "string" ? record.mimeType.trim() : undefined;
|
|
1065
|
-
const dataBase64 = typeof record.dataBase64 === "string" ? record.dataBase64 : "";
|
|
1066
|
-
if (!dataBase64) {
|
|
1067
|
-
throw new Error(`files[${index}].dataBase64 is required`);
|
|
1068
|
-
}
|
|
1069
|
-
return { name, mimeType, data: Buffer.from(stripDataUrlPrefix(dataBase64), "base64") };
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
function stripDataUrlPrefix(value) {
|
|
1073
|
-
const comma = value.indexOf(",");
|
|
1074
|
-
return value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
|
|
1075
|
-
}
|
|
1076
|
-
function numberParam(url, key, fallback) {
|
|
1077
|
-
const value = Number(url.searchParams.get(key));
|
|
1078
|
-
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
1079
|
-
}
|
|
1080
|
-
function requiredSearch(url, key) {
|
|
1081
|
-
const value = url.searchParams.get(key);
|
|
1082
|
-
if (!value) {
|
|
1083
|
-
throw new Error(`${key} is required`);
|
|
1084
|
-
}
|
|
1085
|
-
return value;
|
|
1086
|
-
}
|
|
1087
501
|
function optionalEnv(key) {
|
|
1088
502
|
const value = process.env[key]?.trim();
|
|
1089
503
|
return value || undefined;
|
|
@@ -1197,256 +611,3 @@ function shutdown() {
|
|
|
1197
611
|
runtime.dispose();
|
|
1198
612
|
server.close(() => process.exit(0));
|
|
1199
613
|
}
|
|
1200
|
-
function renderLoginPage(options) {
|
|
1201
|
-
return `<!doctype html>
|
|
1202
|
-
<html lang="en">
|
|
1203
|
-
<head>
|
|
1204
|
-
<meta charset="utf-8">
|
|
1205
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1206
|
-
<title>NordRelay Login</title>
|
|
1207
|
-
<style>
|
|
1208
|
-
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
|
|
1209
|
-
form{width:min(420px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
|
|
1210
|
-
h1{font-size:24px;margin:0 0 8px}
|
|
1211
|
-
p{color:#5d665d;margin:0 0 18px}
|
|
1212
|
-
label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
|
|
1213
|
-
input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
|
|
1214
|
-
button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
|
|
1215
|
-
.error{color:#9b1c1c;min-height:22px;margin-top:12px}
|
|
1216
|
-
</style>
|
|
1217
|
-
</head>
|
|
1218
|
-
<body>
|
|
1219
|
-
<form id="login">
|
|
1220
|
-
<h1>NordRelay Dashboard</h1>
|
|
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>
|
|
1225
|
-
<div class="error" id="error"></div>
|
|
1226
|
-
</form>
|
|
1227
|
-
<script>
|
|
1228
|
-
document.getElementById('login').addEventListener('submit', async (event) => {
|
|
1229
|
-
event.preventDefault();
|
|
1230
|
-
const payload = {
|
|
1231
|
-
email: document.getElementById('email')?.value || undefined,
|
|
1232
|
-
password: document.getElementById('password')?.value || undefined,
|
|
1233
|
-
};
|
|
1234
|
-
const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
|
|
1235
|
-
if (!res.ok) {
|
|
1236
|
-
document.getElementById('error').textContent = 'Invalid credentials';
|
|
1237
|
-
return;
|
|
1238
|
-
}
|
|
1239
|
-
location.href = '/';
|
|
1240
|
-
});
|
|
1241
|
-
</script>
|
|
1242
|
-
</body>
|
|
1243
|
-
</html>`;
|
|
1244
|
-
}
|
|
1245
|
-
function renderDashboardApp() {
|
|
1246
|
-
return `<!doctype html>
|
|
1247
|
-
<html lang="en">
|
|
1248
|
-
<head>
|
|
1249
|
-
<meta charset="utf-8">
|
|
1250
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1251
|
-
<title>NordRelay Dashboard</title>
|
|
1252
|
-
<script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
|
|
1253
|
-
<link rel="stylesheet" href="/assets/dashboard.css">
|
|
1254
|
-
</head>
|
|
1255
|
-
<body>
|
|
1256
|
-
<div class="app">
|
|
1257
|
-
<aside class="sidebar" id="sidebar">
|
|
1258
|
-
<div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
|
|
1259
|
-
<nav>
|
|
1260
|
-
${renderDashboardNav()}
|
|
1261
|
-
</nav>
|
|
1262
|
-
</aside>
|
|
1263
|
-
<main>
|
|
1264
|
-
<header>
|
|
1265
|
-
<button class="menu" id="menuBtn">Menu</button>
|
|
1266
|
-
<div>
|
|
1267
|
-
<h1 id="pageTitle">Overview</h1>
|
|
1268
|
-
<p id="sessionLine">Loading session...</p>
|
|
1269
|
-
</div>
|
|
1270
|
-
<div class="header-actions">
|
|
1271
|
-
<span id="connectionStatus" class="badge">Connecting</span>
|
|
1272
|
-
<select id="agentSelect"></select>
|
|
1273
|
-
<button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
|
|
1274
|
-
<button id="refreshBtn">Refresh</button>
|
|
1275
|
-
<button id="logoutBtn" class="secondary">Logout</button>
|
|
1276
|
-
</div>
|
|
1277
|
-
</header>
|
|
1278
|
-
|
|
1279
|
-
<section class="page active" id="page-overview">
|
|
1280
|
-
<div class="metrics" id="metrics"></div>
|
|
1281
|
-
<div class="stack">
|
|
1282
|
-
<div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
|
|
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>
|
|
1287
|
-
</div>
|
|
1288
|
-
</section>
|
|
1289
|
-
|
|
1290
|
-
<section class="page" id="page-chat">
|
|
1291
|
-
<div class="chat-layout">
|
|
1292
|
-
<div class="panel chat-panel">
|
|
1293
|
-
<div class="chat-toolbar">
|
|
1294
|
-
<button id="newSessionBtn">New session</button>
|
|
1295
|
-
<button id="retryBtn" class="secondary">Retry</button>
|
|
1296
|
-
<button id="editLastBtn" class="secondary">Edit last</button>
|
|
1297
|
-
<button id="syncBtn" class="secondary">Sync</button>
|
|
1298
|
-
<button id="notifyBtn" class="secondary">Notify</button>
|
|
1299
|
-
<button id="clearChatBtn" class="secondary">Clear history</button>
|
|
1300
|
-
<button id="abortBtn">Abort</button>
|
|
1301
|
-
<button id="handbackBtn">Handback</button>
|
|
1302
|
-
</div>
|
|
1303
|
-
<div class="control-grid" id="sessionControls"></div>
|
|
1304
|
-
<div id="messages" class="messages"></div>
|
|
1305
|
-
<form id="promptForm" class="composer">
|
|
1306
|
-
<div class="composer-fields">
|
|
1307
|
-
<textarea id="promptInput" placeholder="Send a message to the active coding agent..." rows="3"></textarea>
|
|
1308
|
-
<div class="attachment-row">
|
|
1309
|
-
<label class="file-button" for="fileInput">Attach files</label>
|
|
1310
|
-
<input id="fileInput" type="file" multiple>
|
|
1311
|
-
<button type="button" id="recordBtn" class="secondary">Record voice</button>
|
|
1312
|
-
<span id="fileSummary">No files selected</span>
|
|
1313
|
-
<button type="button" id="clearFilesBtn" class="secondary">Clear</button>
|
|
1314
|
-
</div>
|
|
1315
|
-
</div>
|
|
1316
|
-
<button>Send</button>
|
|
1317
|
-
</form>
|
|
1318
|
-
</div>
|
|
1319
|
-
<div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
|
|
1320
|
-
</div>
|
|
1321
|
-
</section>
|
|
1322
|
-
|
|
1323
|
-
<section class="page" id="page-tasks">
|
|
1324
|
-
<div class="panel">
|
|
1325
|
-
<div class="row"><button id="reloadTasksBtn">Reload tasks</button></div>
|
|
1326
|
-
<div id="tasksList" class="list"></div>
|
|
1327
|
-
</div>
|
|
1328
|
-
</section>
|
|
1329
|
-
|
|
1330
|
-
<section class="page" id="page-sessions">
|
|
1331
|
-
<div class="panel">
|
|
1332
|
-
<div class="sessions-toolbar">
|
|
1333
|
-
<div class="row search-row"><input id="sessionSearch" placeholder="Search sessions"><button id="sessionSearchBtn">Search</button></div>
|
|
1334
|
-
<div class="row attach-row"><input id="attachInput" placeholder="Thread ID to attach/switch"><button id="attachBtn">Attach</button></div>
|
|
1335
|
-
</div>
|
|
1336
|
-
<div id="sessionsList" class="list"></div>
|
|
1337
|
-
<div id="sessionsPager" class="pager"></div>
|
|
1338
|
-
</div>
|
|
1339
|
-
</section>
|
|
1340
|
-
|
|
1341
|
-
<section class="page" id="page-queue">
|
|
1342
|
-
<div class="panel">
|
|
1343
|
-
<div class="row"><button data-queue="pause">Pause</button><button data-queue="resume">Resume</button><button data-queue="clear" class="danger">Clear</button><span id="queueStatus"></span></div>
|
|
1344
|
-
<div id="queueList" class="list"></div>
|
|
1345
|
-
</div>
|
|
1346
|
-
</section>
|
|
1347
|
-
|
|
1348
|
-
<section class="page" id="page-activity">
|
|
1349
|
-
<div class="panel">
|
|
1350
|
-
<div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="cli">CLI</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
|
|
1351
|
-
<div id="activityList" class="list"></div>
|
|
1352
|
-
</div>
|
|
1353
|
-
</section>
|
|
1354
|
-
|
|
1355
|
-
<section class="page" id="page-artifacts">
|
|
1356
|
-
<div class="panel">
|
|
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>
|
|
1358
|
-
<div id="artifactPreview" class="preview"></div>
|
|
1359
|
-
<div id="artifactList" class="list"></div>
|
|
1360
|
-
</div>
|
|
1361
|
-
</section>
|
|
1362
|
-
|
|
1363
|
-
<section class="page" id="page-adapters">
|
|
1364
|
-
<div class="panel">
|
|
1365
|
-
<div class="row"><button id="reloadAdaptersBtn">Reload adapters</button></div>
|
|
1366
|
-
<div id="adapterHealth" class="list"></div>
|
|
1367
|
-
</div>
|
|
1368
|
-
</section>
|
|
1369
|
-
|
|
1370
|
-
<section class="page" id="page-access">
|
|
1371
|
-
<div class="panel">
|
|
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>
|
|
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>
|
|
1378
|
-
<h2>Locks</h2>
|
|
1379
|
-
<div id="locksList" class="list"></div>
|
|
1380
|
-
<h2>Audit</h2>
|
|
1381
|
-
<div class="row"><input id="auditLimit" type="number" value="50" min="1" max="200"><button id="loadAuditBtn">Load audit</button></div>
|
|
1382
|
-
<div id="auditList" class="list"></div>
|
|
1383
|
-
</div>
|
|
1384
|
-
</section>
|
|
1385
|
-
|
|
1386
|
-
<section class="page" id="page-version">
|
|
1387
|
-
<div class="panel">
|
|
1388
|
-
<div class="row version-actions"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
|
|
1389
|
-
<div id="versionPanel" class="list"></div>
|
|
1390
|
-
<h2 class="version-update-title">Agent update jobs</h2>
|
|
1391
|
-
<div id="agentUpdateJobs" class="list"></div>
|
|
1392
|
-
</div>
|
|
1393
|
-
</section>
|
|
1394
|
-
|
|
1395
|
-
<section class="page" id="page-settings">
|
|
1396
|
-
<div class="panel">
|
|
1397
|
-
<div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
|
|
1398
|
-
<div id="settingsTabs" class="tabs"></div>
|
|
1399
|
-
<div id="settingsForm" class="settings-grid"></div>
|
|
1400
|
-
</div>
|
|
1401
|
-
</section>
|
|
1402
|
-
|
|
1403
|
-
<section class="page" id="page-logs">
|
|
1404
|
-
<div class="panel">
|
|
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>
|
|
1407
|
-
</div>
|
|
1408
|
-
</section>
|
|
1409
|
-
|
|
1410
|
-
<section class="page" id="page-diagnostics">
|
|
1411
|
-
<div class="panel"><div id="diagnostics" class="list"></div></div>
|
|
1412
|
-
</section>
|
|
1413
|
-
|
|
1414
|
-
<footer>
|
|
1415
|
-
<span id="footerVersion">NordRelay</span>
|
|
1416
|
-
<span id="footerHealth">Health: loading</span>
|
|
1417
|
-
<span id="footerUser">User: loading</span>
|
|
1418
|
-
</footer>
|
|
1419
|
-
</main>
|
|
1420
|
-
</div>
|
|
1421
|
-
<dialog id="newSessionDialog">
|
|
1422
|
-
<form method="dialog" id="newSessionForm">
|
|
1423
|
-
<h2>New Session</h2>
|
|
1424
|
-
<div class="form-grid">
|
|
1425
|
-
<label>Agent<select id="newAgent"></select></label>
|
|
1426
|
-
<label>Workspace<input id="newWorkspace" list="workspaceOptions" placeholder="Current workspace"></label>
|
|
1427
|
-
<label>Model<select id="newModel"></select></label>
|
|
1428
|
-
<label id="newReasoningWrap">Reasoning<select id="newReasoning"></select></label>
|
|
1429
|
-
<label id="newLaunchWrap">Launch profile<select id="newLaunch"></select></label>
|
|
1430
|
-
<label id="newFastWrap" class="checkbox"><input id="newFast" type="checkbox"> Fast mode</label>
|
|
1431
|
-
</div>
|
|
1432
|
-
<datalist id="workspaceOptions"></datalist>
|
|
1433
|
-
<div class="row dialog-actions"><button type="button" id="cancelSessionBtn" class="secondary">Cancel</button><button id="createSessionBtn" value="default">Create session</button></div>
|
|
1434
|
-
</form>
|
|
1435
|
-
</dialog>
|
|
1436
|
-
<dialog id="sessionDetailDialog">
|
|
1437
|
-
<div id="sessionDetail"></div>
|
|
1438
|
-
<div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
|
|
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>
|
|
1448
|
-
<div id="toast"></div>
|
|
1449
|
-
<script src="/assets/dashboard.js"></script>
|
|
1450
|
-
</body>
|
|
1451
|
-
</html>`;
|
|
1452
|
-
}
|