@nordbyte/nordrelay 0.5.0 → 0.5.2

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.
Files changed (44) hide show
  1. package/.env.example +2 -0
  2. package/README.md +23 -14
  3. package/dist/access-control.js +2 -0
  4. package/dist/agent-updates.js +61 -10
  5. package/dist/bot-ui.js +1 -0
  6. package/dist/bot.js +142 -1065
  7. package/dist/channel-actions.js +8 -8
  8. package/dist/codex-cli.js +1 -1
  9. package/dist/config-metadata.js +2 -0
  10. package/dist/operations.js +233 -122
  11. package/dist/relay-artifact-service.js +126 -0
  12. package/dist/relay-external-activity-monitor.js +216 -0
  13. package/dist/relay-queue-service.js +66 -0
  14. package/dist/relay-runtime-types.js +1 -0
  15. package/dist/relay-runtime.js +119 -371
  16. package/dist/state-backend.js +3 -0
  17. package/dist/support-bundle.js +221 -0
  18. package/dist/telegram-agent-commands.js +212 -0
  19. package/dist/telegram-artifact-commands.js +139 -0
  20. package/dist/telegram-command-menu.js +1 -0
  21. package/dist/telegram-command-types.js +1 -0
  22. package/dist/telegram-diagnostics-command.js +102 -0
  23. package/dist/telegram-general-commands.js +52 -0
  24. package/dist/telegram-operational-commands.js +153 -0
  25. package/dist/telegram-preference-commands.js +198 -0
  26. package/dist/telegram-queue-commands.js +278 -0
  27. package/dist/telegram-support-command.js +53 -0
  28. package/dist/telegram-update-commands.js +6 -1
  29. package/dist/web-api-contract.js +79 -31
  30. package/dist/web-api-types.js +1 -0
  31. package/dist/web-dashboard-access-routes.js +163 -0
  32. package/dist/web-dashboard-artifact-routes.js +65 -0
  33. package/dist/web-dashboard-assets.js +2 -0
  34. package/dist/web-dashboard-http.js +143 -0
  35. package/dist/web-dashboard-pages.js +257 -0
  36. package/dist/web-dashboard-runtime-routes.js +92 -0
  37. package/dist/web-dashboard-session-routes.js +209 -0
  38. package/dist/web-dashboard.js +44 -882
  39. package/dist/webui-assets/dashboard.css +74 -4
  40. package/dist/webui-assets/dashboard.js +163 -24
  41. package/dist/zip-writer.js +83 -0
  42. package/package.json +10 -4
  43. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  44. package/plugins/nordrelay/scripts/nordrelay.mjs +258 -5
@@ -0,0 +1,65 @@
1
+ import { readJsonBody, requiredSearch, sendFile, sendJson, stringField, } from "./web-dashboard-http.js";
2
+ export async function handleDashboardArtifactRoute(req, res, url, options) {
3
+ const { runtime, authUser } = options;
4
+ if (req.method === "GET" && url.pathname === "/api/artifacts") {
5
+ await options.assertCurrentSessionScope(authUser);
6
+ sendJson(res, 200, { reports: await runtime.artifacts() });
7
+ return true;
8
+ }
9
+ if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
10
+ await options.assertCurrentSessionScope(authUser);
11
+ sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
12
+ return true;
13
+ }
14
+ if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
15
+ const body = await readJsonBody(req);
16
+ await options.assertCurrentSessionScope(authUser);
17
+ const action = stringField(body, "action");
18
+ const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
19
+ if (action !== "delete") {
20
+ throw new Error("Unsupported artifact bulk action.");
21
+ }
22
+ const removed = [];
23
+ for (const turnId of turnIds) {
24
+ if (await runtime.deleteArtifact(turnId)) {
25
+ removed.push(turnId);
26
+ }
27
+ }
28
+ sendJson(res, 200, { removed });
29
+ return true;
30
+ }
31
+ if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
32
+ await options.assertCurrentSessionScope(authUser);
33
+ const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
34
+ if (!bundle) {
35
+ sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
36
+ return true;
37
+ }
38
+ sendFile(res, bundle.path, bundle.name);
39
+ return true;
40
+ }
41
+ if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
42
+ await options.assertCurrentSessionScope(authUser);
43
+ const turnId = requiredSearch(url, "turnId");
44
+ const relativePath = requiredSearch(url, "path");
45
+ const report = await runtime.artifact(turnId);
46
+ const artifact = report?.artifacts.find((candidate) => candidate.relativePath === relativePath);
47
+ if (!artifact) {
48
+ sendJson(res, 404, { error: "Artifact not found" });
49
+ return true;
50
+ }
51
+ sendFile(res, artifact.localPath, artifact.name);
52
+ return true;
53
+ }
54
+ if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
55
+ await options.assertCurrentSessionScope(authUser);
56
+ const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
57
+ if (!preview) {
58
+ sendJson(res, 404, { error: "Artifact not found" });
59
+ return true;
60
+ }
61
+ sendJson(res, 200, preview);
62
+ return true;
63
+ }
64
+ return false;
65
+ }
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
5
5
  const clientSources = [
6
+ "client/core/api-routes.generated.js",
7
+ "client/core/api-client.js",
6
8
  "client/core/runtime.js",
7
9
  "client/overview.js",
8
10
  "client/events.js",
@@ -0,0 +1,143 @@
1
+ import { createReadStream } from "node:fs";
2
+ const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
3
+ export function parseCookies(cookieHeader) {
4
+ const cookies = {};
5
+ for (const part of cookieHeader.split(";")) {
6
+ const [key, ...valueParts] = part.trim().split("=");
7
+ if (key)
8
+ cookies[key] = decodeURIComponent(valueParts.join("=") ?? "");
9
+ }
10
+ return cookies;
11
+ }
12
+ export async function readJsonBody(req) {
13
+ const chunks = [];
14
+ for await (const chunk of req) {
15
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
16
+ }
17
+ const text = Buffer.concat(chunks).toString("utf8").trim();
18
+ if (!text) {
19
+ return {};
20
+ }
21
+ return JSON.parse(text);
22
+ }
23
+ export function sendJson(res, status, value) {
24
+ res.writeHead(status, JSON_HEADERS);
25
+ res.end(`${JSON.stringify(value)}\n`);
26
+ }
27
+ export function sendText(res, status, text, contentType) {
28
+ res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
29
+ res.end(text);
30
+ }
31
+ export function sendFile(res, filePath, filename) {
32
+ res.writeHead(200, {
33
+ "content-type": "application/octet-stream",
34
+ "content-disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
35
+ });
36
+ createReadStream(filePath).pipe(res);
37
+ }
38
+ export function stringField(value, key) {
39
+ const field = value[key];
40
+ if (typeof field !== "string" || !field.trim()) {
41
+ throw new Error(`${key} is required`);
42
+ }
43
+ return field.trim();
44
+ }
45
+ export function optionalStringField(value, key) {
46
+ const field = value[key];
47
+ return typeof field === "string" && field.trim() ? field.trim() : undefined;
48
+ }
49
+ export function optionalBooleanField(value, key) {
50
+ const field = value[key];
51
+ return typeof field === "boolean" ? field : undefined;
52
+ }
53
+ export function numberField(value, key) {
54
+ const field = value[key];
55
+ const parsed = typeof field === "number" ? field : typeof field === "string" ? Number(field) : Number.NaN;
56
+ if (!Number.isInteger(parsed)) {
57
+ throw new Error(`${key} must be an integer`);
58
+ }
59
+ return parsed;
60
+ }
61
+ export function optionalNumberField(value, key) {
62
+ if (value[key] === undefined || value[key] === "") {
63
+ return undefined;
64
+ }
65
+ return numberField(value, key);
66
+ }
67
+ export function arrayStringField(value, key) {
68
+ const field = value[key];
69
+ if (field === undefined || field === null || field === "") {
70
+ return [];
71
+ }
72
+ if (Array.isArray(field)) {
73
+ return field.filter((item) => typeof item === "string");
74
+ }
75
+ if (typeof field === "string") {
76
+ return field.split(",").map((item) => item.trim()).filter(Boolean);
77
+ }
78
+ throw new Error(`${key} must be a string list`);
79
+ }
80
+ export function arrayNumberField(value, key) {
81
+ const field = value[key];
82
+ if (field === undefined || field === null || field === "") {
83
+ return [];
84
+ }
85
+ if (Array.isArray(field)) {
86
+ return field.map((item) => typeof item === "number" ? item : Number(item)).filter((item) => Number.isInteger(item));
87
+ }
88
+ if (typeof field === "string") {
89
+ return field.split(",").map((item) => Number(item.trim())).filter((item) => Number.isInteger(item));
90
+ }
91
+ throw new Error(`${key} must be a number list`);
92
+ }
93
+ export function parseAgentUpdateOperation(value) {
94
+ if (!value || value === "update") {
95
+ return "update";
96
+ }
97
+ if (value === "install") {
98
+ return "install";
99
+ }
100
+ throw new Error(`Invalid agent update operation: ${value}`);
101
+ }
102
+ export function parseLogTarget(value) {
103
+ return value === "update" || value === "agent-updates" ? value : "connector";
104
+ }
105
+ export function objectRecord(value) {
106
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
107
+ return {};
108
+ }
109
+ return value;
110
+ }
111
+ export function parseUploadFiles(value) {
112
+ if (!Array.isArray(value)) {
113
+ return [];
114
+ }
115
+ return value.map((item, index) => {
116
+ if (!item || typeof item !== "object" || Array.isArray(item)) {
117
+ throw new Error(`files[${index}] must be an object`);
118
+ }
119
+ const record = item;
120
+ const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : `upload-${index + 1}`;
121
+ const mimeType = typeof record.mimeType === "string" ? record.mimeType.trim() : undefined;
122
+ const dataBase64 = typeof record.dataBase64 === "string" ? record.dataBase64 : "";
123
+ if (!dataBase64) {
124
+ throw new Error(`files[${index}].dataBase64 is required`);
125
+ }
126
+ return { name, mimeType, data: Buffer.from(stripDataUrlPrefix(dataBase64), "base64") };
127
+ });
128
+ }
129
+ export function numberParam(url, key, fallback) {
130
+ const value = Number(url.searchParams.get(key));
131
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
132
+ }
133
+ export function requiredSearch(url, key) {
134
+ const value = url.searchParams.get(key);
135
+ if (!value) {
136
+ throw new Error(`${key} is required`);
137
+ }
138
+ return value;
139
+ }
140
+ function stripDataUrlPrefix(value) {
141
+ const comma = value.indexOf(",");
142
+ return value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
143
+ }
@@ -0,0 +1,257 @@
1
+ import { renderDashboardNav } from "./web-dashboard-ui.js";
2
+ export function renderLoginPage(options) {
3
+ return `<!doctype html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="utf-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1">
8
+ <title>NordRelay Login</title>
9
+ <style>
10
+ 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}
11
+ 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)}
12
+ h1{font-size:24px;margin:0 0 8px}
13
+ p{color:#5d665d;margin:0 0 18px}
14
+ label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
15
+ input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
16
+ button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
17
+ .error{color:#9b1c1c;min-height:22px;margin-top:12px}
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <form id="login">
22
+ <h1>NordRelay Dashboard</h1>
23
+ <p>${options.adminConfigured ? "Sign in with your NordRelay user account." : "No admin user exists. Run nordrelay user create-admin on this host first."}</p>
24
+ <label>Email</label><input id="email" name="email" type="email" autocomplete="username" ${options.adminConfigured ? "" : "disabled"}>
25
+ <label>Password</label><input id="password" name="password" type="password" autocomplete="current-password" ${options.adminConfigured ? "" : "disabled"}>
26
+ <button ${options.adminConfigured ? "" : "disabled"}>Sign in</button>
27
+ <div class="error" id="error"></div>
28
+ </form>
29
+ <script>
30
+ document.getElementById('login').addEventListener('submit', async (event) => {
31
+ event.preventDefault();
32
+ const payload = {
33
+ email: document.getElementById('email')?.value || undefined,
34
+ password: document.getElementById('password')?.value || undefined,
35
+ };
36
+ const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
37
+ if (!res.ok) {
38
+ document.getElementById('error').textContent = 'Invalid credentials';
39
+ return;
40
+ }
41
+ location.href = '/';
42
+ });
43
+ </script>
44
+ </body>
45
+ </html>`;
46
+ }
47
+ export function renderDashboardApp() {
48
+ return `<!doctype html>
49
+ <html lang="en">
50
+ <head>
51
+ <meta charset="utf-8">
52
+ <meta name="viewport" content="width=device-width, initial-scale=1">
53
+ <title>NordRelay Dashboard</title>
54
+ <script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
55
+ <link rel="stylesheet" href="/assets/dashboard.css">
56
+ </head>
57
+ <body>
58
+ <div class="app">
59
+ <aside class="sidebar" id="sidebar">
60
+ <div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
61
+ <nav>
62
+ ${renderDashboardNav()}
63
+ </nav>
64
+ </aside>
65
+ <main>
66
+ <header>
67
+ <button class="menu" id="menuBtn">Menu</button>
68
+ <div>
69
+ <h1 id="pageTitle">Overview</h1>
70
+ <p id="sessionLine">Loading session...</p>
71
+ </div>
72
+ <div class="header-actions">
73
+ <span id="connectionStatus" class="badge">Connecting</span>
74
+ <select id="agentSelect"></select>
75
+ <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
76
+ <button id="refreshBtn">Refresh</button>
77
+ <button id="logoutBtn" class="secondary">Logout</button>
78
+ </div>
79
+ </header>
80
+
81
+ <section class="page active" id="page-overview">
82
+ <div class="metrics" id="metrics"></div>
83
+ <div class="stack">
84
+ <div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
85
+ <div class="overview-adapter-grid">
86
+ <div class="panel"><h2>Agent Adapters</h2><div id="agentAdapters"></div></div>
87
+ <div class="panel"><h2>Chat Adapters</h2><div id="chatAdapters"></div></div>
88
+ </div>
89
+ </div>
90
+ </section>
91
+
92
+ <section class="page" id="page-chat">
93
+ <div class="chat-layout">
94
+ <div class="panel chat-panel">
95
+ <div class="chat-toolbar">
96
+ <button id="newSessionBtn">New session</button>
97
+ <button id="retryBtn" class="secondary">Retry</button>
98
+ <button id="editLastBtn" class="secondary">Edit last</button>
99
+ <button id="syncBtn" class="secondary">Sync</button>
100
+ <button id="notifyBtn" class="secondary">Notify</button>
101
+ <button id="clearChatBtn" class="secondary">Clear history</button>
102
+ <button id="abortBtn">Abort</button>
103
+ <button id="handbackBtn">Handback</button>
104
+ </div>
105
+ <div class="control-grid" id="sessionControls"></div>
106
+ <div id="messages" class="messages"></div>
107
+ <form id="promptForm" class="composer">
108
+ <div class="composer-fields">
109
+ <textarea id="promptInput" placeholder="Send a message to the active coding agent..." rows="3"></textarea>
110
+ <div class="attachment-row">
111
+ <label class="file-button" for="fileInput">Attach files</label>
112
+ <input id="fileInput" type="file" multiple>
113
+ <button type="button" id="recordBtn" class="secondary">Record voice</button>
114
+ <span id="fileSummary">No files selected</span>
115
+ <button type="button" id="clearFilesBtn" class="secondary">Clear</button>
116
+ </div>
117
+ </div>
118
+ <button>Send</button>
119
+ </form>
120
+ </div>
121
+ <div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
122
+ </div>
123
+ </section>
124
+
125
+ <section class="page" id="page-tasks">
126
+ <div class="panel">
127
+ <div class="row"><button id="reloadTasksBtn">Reload tasks</button></div>
128
+ <div id="tasksList" class="list"></div>
129
+ </div>
130
+ </section>
131
+
132
+ <section class="page" id="page-sessions">
133
+ <div class="panel">
134
+ <div class="sessions-toolbar">
135
+ <div class="row search-row"><input id="sessionSearch" placeholder="Search sessions"><button id="sessionSearchBtn">Search</button></div>
136
+ <div class="row attach-row"><input id="attachInput" placeholder="Thread ID to attach/switch"><button id="attachBtn">Attach</button></div>
137
+ </div>
138
+ <div id="sessionsList" class="list"></div>
139
+ <div id="sessionsPager" class="pager"></div>
140
+ </div>
141
+ </section>
142
+
143
+ <section class="page" id="page-queue">
144
+ <div class="panel">
145
+ <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>
146
+ <div id="queueList" class="list"></div>
147
+ </div>
148
+ </section>
149
+
150
+ <section class="page" id="page-activity">
151
+ <div class="panel">
152
+ <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>
153
+ <div id="activityList" class="list"></div>
154
+ </div>
155
+ </section>
156
+
157
+ <section class="page" id="page-artifacts">
158
+ <div class="panel">
159
+ <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>
160
+ <div id="artifactPreview" class="preview"></div>
161
+ <div id="artifactList" class="list"></div>
162
+ </div>
163
+ </section>
164
+
165
+ <section class="page" id="page-adapters">
166
+ <div class="panel">
167
+ <div class="row"><button id="reloadAdaptersBtn">Reload adapters</button></div>
168
+ <div id="adapterHealth" class="list"></div>
169
+ </div>
170
+ </section>
171
+
172
+ <section class="page" id="page-access">
173
+ <div class="panel">
174
+ <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>
175
+ <div id="accessPanel" class="settings-grid"></div>
176
+ <h2>Groups</h2>
177
+ <div id="groupsList" class="list"></div>
178
+ <h2>Telegram chats</h2>
179
+ <div id="telegramChatsList" class="list"></div>
180
+ <h2>Locks</h2>
181
+ <div id="locksList" class="list"></div>
182
+ <h2>Audit</h2>
183
+ <div class="row"><input id="auditLimit" type="number" value="50" min="1" max="200"><button id="loadAuditBtn">Load audit</button></div>
184
+ <div id="auditList" class="list"></div>
185
+ </div>
186
+ </section>
187
+
188
+ <section class="page" id="page-version">
189
+ <div class="panel">
190
+ <div class="row version-actions"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
191
+ <div id="versionPanel" class="list"></div>
192
+ <h2 class="version-update-title">Agent update jobs</h2>
193
+ <div id="agentUpdateJobs" class="list"></div>
194
+ </div>
195
+ </section>
196
+
197
+ <section class="page" id="page-settings">
198
+ <div class="panel">
199
+ <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
200
+ <div id="settingsTabs" class="tabs"></div>
201
+ <div id="settingsForm" class="settings-grid"></div>
202
+ </div>
203
+ </section>
204
+
205
+ <section class="page" id="page-logs">
206
+ <div class="panel">
207
+ <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>
208
+ <pre id="logs" class="log-view"></pre>
209
+ </div>
210
+ </section>
211
+
212
+ <section class="page" id="page-diagnostics">
213
+ <div class="panel">
214
+ <div class="row"><button id="exportDiagnosticsBundleBtn" class="secondary">Export diagnostics bundle</button></div>
215
+ <div id="diagnostics" class="list"></div>
216
+ </div>
217
+ </section>
218
+
219
+ <footer>
220
+ <span id="footerVersion">NordRelay</span>
221
+ <span id="footerHealth">Health: loading</span>
222
+ <span id="footerUser">User: loading</span>
223
+ </footer>
224
+ </main>
225
+ </div>
226
+ <dialog id="newSessionDialog">
227
+ <form method="dialog" id="newSessionForm">
228
+ <h2>New Session</h2>
229
+ <div class="form-grid">
230
+ <label>Agent<select id="newAgent"></select></label>
231
+ <label>Workspace<input id="newWorkspace" list="workspaceOptions" placeholder="Current workspace"></label>
232
+ <label>Model<select id="newModel"></select></label>
233
+ <label id="newReasoningWrap">Reasoning<select id="newReasoning"></select></label>
234
+ <label id="newLaunchWrap">Launch profile<select id="newLaunch"></select></label>
235
+ <label id="newFastWrap" class="checkbox"><input id="newFast" type="checkbox"> Fast mode</label>
236
+ </div>
237
+ <datalist id="workspaceOptions"></datalist>
238
+ <div class="row dialog-actions"><button type="button" id="cancelSessionBtn" class="secondary">Cancel</button><button id="createSessionBtn" value="default">Create session</button></div>
239
+ </form>
240
+ </dialog>
241
+ <dialog id="sessionDetailDialog">
242
+ <div id="sessionDetail"></div>
243
+ <div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
244
+ </dialog>
245
+ <dialog id="adminDialog">
246
+ <form method="dialog" id="adminDialogForm">
247
+ <h2 id="adminDialogTitle">Edit</h2>
248
+ <div id="adminDialogBody" class="form-grid"></div>
249
+ <div class="row dialog-actions"><button type="button" id="adminDialogCancel" class="secondary">Cancel</button><button id="adminDialogSubmit" value="default">Save</button></div>
250
+ </form>
251
+ </dialog>
252
+ <div id="toolTooltip" class="tool-tooltip"></div>
253
+ <div id="toast"></div>
254
+ <script src="/assets/dashboard.js"></script>
255
+ </body>
256
+ </html>`;
257
+ }
@@ -0,0 +1,92 @@
1
+ import { numberParam, optionalStringField, parseAgentUpdateOperation, parseLogTarget, readJsonBody, sendFile, sendJson, stringField, } from "./web-dashboard-http.js";
2
+ export async function handleDashboardRuntimeRoute(req, res, url, options) {
3
+ const { runtime, users, authUser } = options;
4
+ if (req.method === "GET" && url.pathname === "/api/health") {
5
+ await options.assertCurrentSessionScope(authUser);
6
+ sendJson(res, 200, await runtime.status());
7
+ return true;
8
+ }
9
+ if (req.method === "GET" && url.pathname === "/api/version") {
10
+ sendJson(res, 200, await runtime.version());
11
+ return true;
12
+ }
13
+ if (req.method === "POST" && url.pathname === "/api/update") {
14
+ sendJson(res, 202, runtime.updateConnector());
15
+ return true;
16
+ }
17
+ if (req.method === "GET" && url.pathname === "/api/agent-updates") {
18
+ sendJson(res, 200, { jobs: runtime.agentUpdateJobs().filter((job) => users.canUseAgent(authUser, job.agentId)) });
19
+ return true;
20
+ }
21
+ if (req.method === "POST" && url.pathname === "/api/agent-update") {
22
+ const body = await readJsonBody(req);
23
+ const agentId = options.parseAgentIdRequired(stringField(body, "agentId"));
24
+ const operation = parseAgentUpdateOperation(optionalStringField(body, "operation"));
25
+ options.assertScopedAgent(authUser, agentId);
26
+ sendJson(res, 202, { job: runtime.startAgentUpdate(agentId, operation) });
27
+ return true;
28
+ }
29
+ const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
30
+ if (req.method === "GET" && agentUpdateLogMatch?.[1]) {
31
+ const id = decodeURIComponent(agentUpdateLogMatch[1]);
32
+ options.assertAgentUpdateJobScope(authUser, id);
33
+ sendJson(res, 200, runtime.agentUpdateLog(id));
34
+ return true;
35
+ }
36
+ if (req.method === "DELETE" && agentUpdateLogMatch?.[1]) {
37
+ const id = decodeURIComponent(agentUpdateLogMatch[1]);
38
+ options.assertAgentUpdateJobScope(authUser, id);
39
+ sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id) });
40
+ return true;
41
+ }
42
+ const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
43
+ if (req.method === "POST" && agentUpdateInputMatch?.[1]) {
44
+ const body = await readJsonBody(req);
45
+ const id = decodeURIComponent(agentUpdateInputMatch[1]);
46
+ options.assertAgentUpdateJobScope(authUser, id);
47
+ sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input")) });
48
+ return true;
49
+ }
50
+ const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
51
+ if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
52
+ const id = decodeURIComponent(agentUpdateCancelMatch[1]);
53
+ options.assertAgentUpdateJobScope(authUser, id);
54
+ sendJson(res, 200, { job: runtime.cancelAgentUpdate(id) });
55
+ return true;
56
+ }
57
+ if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
58
+ sendJson(res, 200, await options.scopedTasks(authUser, runtime.tasks()));
59
+ return true;
60
+ }
61
+ if (req.method === "GET" && url.pathname === "/api/adapters/health") {
62
+ sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
63
+ return true;
64
+ }
65
+ if (req.method === "GET" && url.pathname === "/api/logs") {
66
+ const target = parseLogTarget(url.searchParams.get("target") ?? undefined);
67
+ sendJson(res, 200, await runtime.logs(target, numberParam(url, "lines", 100)));
68
+ return true;
69
+ }
70
+ if (req.method === "POST" && url.pathname === "/api/logs/clear") {
71
+ const body = await readJsonBody(req);
72
+ const target = parseLogTarget(optionalStringField(body, "target"));
73
+ sendJson(res, 200, runtime.clearLogs(target));
74
+ return true;
75
+ }
76
+ if (req.method === "GET" && url.pathname === "/api/diagnostics") {
77
+ await options.assertCurrentSessionScope(authUser);
78
+ sendJson(res, 200, await runtime.diagnostics());
79
+ return true;
80
+ }
81
+ if (req.method === "GET" && url.pathname === "/api/diagnostics/bundle") {
82
+ await options.assertCurrentSessionScope(authUser);
83
+ const bundle = await runtime.supportBundle();
84
+ sendFile(res, bundle.path, bundle.name);
85
+ return true;
86
+ }
87
+ if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
88
+ sendJson(res, 202, runtime.restartConnector());
89
+ return true;
90
+ }
91
+ return false;
92
+ }