swarmy 0.1.1 → 0.2.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/README.md +34 -5
- package/bin/swarmy.js +171 -15
- package/manager/src/server/auth.ts +52 -0
- package/manager/src/server/db.ts +251 -0
- package/manager/src/server/index.ts +85 -0
- package/manager/src/server/reconcile.ts +58 -0
- package/manager/src/server/routes/containers.ts +51 -0
- package/manager/src/server/routes/nodes.ts +39 -0
- package/manager/src/server/routes/sessions.ts +56 -0
- package/manager/src/server/routes/status.ts +18 -0
- package/manager/src/server/thumbnails.ts +26 -0
- package/manager/src/server/ws/session.ts +98 -0
- package/manager/src/server/ws/ui.ts +32 -0
- package/manager/src/server/ws/vnc.ts +82 -0
- package/manager/src/server/ws/worker.ts +127 -0
- package/manager/tsconfig.json +20 -0
- package/manager/tsconfig.node.json +9 -0
- package/package.json +6 -6
- package/worker/src/docker.ts +75 -0
- package/worker/src/index.ts +157 -0
- package/worker/src/pty.ts +66 -0
- package/worker/src/vnc-proxy.ts +59 -0
- package/worker/tsconfig.json +14 -0
package/README.md
CHANGED
|
@@ -39,7 +39,35 @@ Provision ephemeral Docker containers across multiple nodes and interact with th
|
|
|
39
39
|
- Docker
|
|
40
40
|
- npm
|
|
41
41
|
|
|
42
|
-
###
|
|
42
|
+
### Using npx (recommended)
|
|
43
|
+
|
|
44
|
+
**Manager:**
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx --yes swarmy@latest manager start
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This prints the token and connection instructions for workers.
|
|
51
|
+
|
|
52
|
+
**Worker** (on each node):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx --yes swarmy@latest worker start --url http://<manager-ip>:5174 --token <token>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**Other commands:**
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx swarmy manager status # Show PID + token
|
|
62
|
+
npx swarmy manager stop # Stop manager
|
|
63
|
+
npx swarmy manager restart # Restart manager
|
|
64
|
+
npx swarmy worker status # Show worker PID
|
|
65
|
+
npx swarmy worker stop # Stop worker
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### From source (development)
|
|
69
|
+
|
|
70
|
+
**Manager:**
|
|
43
71
|
|
|
44
72
|
```bash
|
|
45
73
|
cd manager
|
|
@@ -49,9 +77,7 @@ npm run dev
|
|
|
49
77
|
|
|
50
78
|
The manager starts on port 5174. An auth token is auto-generated at `manager/data/token` on first run.
|
|
51
79
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
On each node that will run containers:
|
|
80
|
+
**Worker** (on each node):
|
|
55
81
|
|
|
56
82
|
```bash
|
|
57
83
|
cd worker
|
|
@@ -90,7 +116,7 @@ Installs to `/opt/swarm`. Services auto-restart on failure.
|
|
|
90
116
|
|
|
91
117
|
| Variable | Component | Description |
|
|
92
118
|
| --------------- | --------- | -------------------------------------- |
|
|
93
|
-
| `PORT` | Manager | HTTP port (default:
|
|
119
|
+
| `PORT` | Manager | HTTP port (default: 5174) |
|
|
94
120
|
| `SWARM_URL` | Worker | Manager URL to connect to |
|
|
95
121
|
| `SWARM_TOKEN` | Worker | Auth token for registration |
|
|
96
122
|
| `SWARM_NODE_ID` | Worker | Node identifier (default: hostname) |
|
|
@@ -110,6 +136,9 @@ All REST endpoints require `Authorization: Bearer <token>` header.
|
|
|
110
136
|
| `POST` | `/api/sessions` | Spawn terminal session in container |
|
|
111
137
|
| `DELETE` | `/api/sessions/:id` | Kill session |
|
|
112
138
|
| `POST` | `/api/sessions/:id/input` | Send input to session |
|
|
139
|
+
| `PATCH` | `/api/sessions/:id` | Toggle interactive mode |
|
|
140
|
+
| `POST` | `/api/containers/:id/thumbnail` | Upload VNC thumbnail (base64 JPEG) |
|
|
141
|
+
| `GET` | `/api/containers/:id/thumbnail` | Get VNC thumbnail (supports `?token=`) |
|
|
113
142
|
| `GET` | `/api/status` | Server status |
|
|
114
143
|
|
|
115
144
|
## Tech Stack
|
package/bin/swarmy.js
CHANGED
|
@@ -139,31 +139,177 @@ const cliArgs = process.argv.slice(2);
|
|
|
139
139
|
const role = cliArgs[0];
|
|
140
140
|
const action = cliArgs[1];
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
const hasFlag = (flag) => cliArgs.includes(flag);
|
|
143
|
+
|
|
144
|
+
function parseFlag(flag) {
|
|
145
|
+
const idx = cliArgs.indexOf(flag);
|
|
146
|
+
if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
|
|
147
|
+
return cliArgs[idx + 1];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function usageMain() {
|
|
143
151
|
console.log(`
|
|
144
152
|
swarmy - Distributed container management
|
|
145
153
|
|
|
146
154
|
Usage:
|
|
147
|
-
swarmy manager
|
|
155
|
+
swarmy manager <command> Manage the central server
|
|
156
|
+
swarmy worker <command> Manage a worker node
|
|
157
|
+
swarmy --help Show this help
|
|
158
|
+
|
|
159
|
+
Run 'swarmy manager --help' or 'swarmy worker --help' for command details.
|
|
160
|
+
`);
|
|
161
|
+
process.exit(0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function usageManager() {
|
|
165
|
+
console.log(`
|
|
166
|
+
swarmy manager - Manage the central server
|
|
167
|
+
|
|
168
|
+
Commands:
|
|
169
|
+
start [--port PORT] Start the manager (default port: 5174)
|
|
170
|
+
stop Stop the manager
|
|
171
|
+
restart [--port PORT] Restart the manager
|
|
172
|
+
status Show manager status and token
|
|
173
|
+
|
|
174
|
+
Examples:
|
|
175
|
+
swarmy manager start
|
|
176
|
+
swarmy manager start --port 3000
|
|
177
|
+
swarmy manager status
|
|
178
|
+
swarmy manager stop
|
|
179
|
+
`);
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function usageWorker() {
|
|
184
|
+
console.log(`
|
|
185
|
+
swarmy worker - Manage a worker node
|
|
186
|
+
|
|
187
|
+
Commands:
|
|
188
|
+
start --url URL --token TOKEN [--hostname NAME]
|
|
189
|
+
Connect worker to a manager
|
|
190
|
+
stop Stop the worker
|
|
191
|
+
restart --url URL --token TOKEN [--hostname NAME]
|
|
192
|
+
Restart the worker
|
|
193
|
+
status Show worker status
|
|
194
|
+
|
|
195
|
+
Examples:
|
|
196
|
+
swarmy worker start --url http://192.168.1.10:5174 --token abc123
|
|
197
|
+
swarmy worker start --url http://manager:5174 --token abc123 --hostname gpu-node-01
|
|
198
|
+
swarmy worker status
|
|
199
|
+
swarmy worker stop
|
|
200
|
+
`);
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Only handle --help at top level (swarmy --help) or role level (swarmy manager --help)
|
|
205
|
+
// Action-level help (swarmy manager start --help) is handled in the switch cases
|
|
206
|
+
if (role === "--help" || role === "-h") usageMain();
|
|
207
|
+
|
|
208
|
+
if (!role) usageMain();
|
|
209
|
+
if (!action) {
|
|
210
|
+
if (role === "manager") usageManager();
|
|
211
|
+
else if (role === "worker") usageWorker();
|
|
212
|
+
else usageMain();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function usageManagerStart() {
|
|
216
|
+
console.log(`
|
|
217
|
+
swarmy manager start - Start the central server
|
|
218
|
+
|
|
219
|
+
Options:
|
|
220
|
+
--port PORT HTTP port (default: 5174)
|
|
221
|
+
|
|
222
|
+
Examples:
|
|
223
|
+
swarmy manager start
|
|
224
|
+
swarmy manager start --port 3000
|
|
225
|
+
`);
|
|
226
|
+
process.exit(0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function usageManagerStop() {
|
|
230
|
+
console.log(`
|
|
231
|
+
swarmy manager stop - Stop the central server
|
|
232
|
+
|
|
233
|
+
Usage:
|
|
148
234
|
swarmy manager stop
|
|
149
|
-
|
|
235
|
+
`);
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function usageManagerRestart() {
|
|
240
|
+
console.log(`
|
|
241
|
+
swarmy manager restart - Restart the central server
|
|
242
|
+
|
|
243
|
+
Options:
|
|
244
|
+
--port PORT HTTP port (default: 5174)
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
swarmy manager restart
|
|
248
|
+
swarmy manager restart --port 3000
|
|
249
|
+
`);
|
|
250
|
+
process.exit(0);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function usageManagerStatus() {
|
|
254
|
+
console.log(`
|
|
255
|
+
swarmy manager status - Show manager status and token
|
|
256
|
+
|
|
257
|
+
Usage:
|
|
150
258
|
swarmy manager status
|
|
259
|
+
`);
|
|
260
|
+
process.exit(0);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function usageWorkerStart() {
|
|
264
|
+
console.log(`
|
|
265
|
+
swarmy worker start - Connect worker to a manager
|
|
266
|
+
|
|
267
|
+
Options:
|
|
268
|
+
--url URL Manager URL (required)
|
|
269
|
+
--token TOKEN Auth token (required)
|
|
270
|
+
--hostname NAME Custom hostname identifier
|
|
271
|
+
|
|
272
|
+
Examples:
|
|
273
|
+
swarmy worker start --url http://192.168.1.10:5174 --token abc123
|
|
274
|
+
swarmy worker start --url http://manager:5174 --token abc123 --hostname gpu-node-01
|
|
275
|
+
`);
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
151
278
|
|
|
152
|
-
|
|
279
|
+
function usageWorkerStop() {
|
|
280
|
+
console.log(`
|
|
281
|
+
swarmy worker stop - Stop the worker
|
|
282
|
+
|
|
283
|
+
Usage:
|
|
153
284
|
swarmy worker stop
|
|
154
|
-
swarmy worker restart --url URL --token TOKEN [--hostname NAME]
|
|
155
|
-
swarmy worker status
|
|
156
285
|
`);
|
|
157
|
-
process.exit(
|
|
286
|
+
process.exit(0);
|
|
158
287
|
}
|
|
159
288
|
|
|
160
|
-
function
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
289
|
+
function usageWorkerRestart() {
|
|
290
|
+
console.log(`
|
|
291
|
+
swarmy worker restart - Restart the worker
|
|
292
|
+
|
|
293
|
+
Options:
|
|
294
|
+
--url URL Manager URL (required)
|
|
295
|
+
--token TOKEN Auth token (required)
|
|
296
|
+
--hostname NAME Custom hostname identifier
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
swarmy worker restart --url http://192.168.1.10:5174 --token abc123
|
|
300
|
+
`);
|
|
301
|
+
process.exit(0);
|
|
164
302
|
}
|
|
165
303
|
|
|
166
|
-
|
|
304
|
+
function usageWorkerStatus() {
|
|
305
|
+
console.log(`
|
|
306
|
+
swarmy worker status - Show worker status
|
|
307
|
+
|
|
308
|
+
Usage:
|
|
309
|
+
swarmy worker status
|
|
310
|
+
`);
|
|
311
|
+
process.exit(0);
|
|
312
|
+
}
|
|
167
313
|
|
|
168
314
|
function startManager() {
|
|
169
315
|
const port = parseFlag("--port") ?? "5174";
|
|
@@ -189,44 +335,54 @@ function startWorker() {
|
|
|
189
335
|
}
|
|
190
336
|
|
|
191
337
|
if (role === "manager") {
|
|
338
|
+
if (action === "--help" || action === "-h") usageManager();
|
|
192
339
|
switch (action) {
|
|
193
340
|
case "start":
|
|
341
|
+
if (hasFlag("--help") || hasFlag("-h")) usageManagerStart();
|
|
194
342
|
startManager();
|
|
195
343
|
break;
|
|
196
344
|
case "stop":
|
|
345
|
+
if (hasFlag("--help") || hasFlag("-h")) usageManagerStop();
|
|
197
346
|
stopProcess("manager");
|
|
198
347
|
break;
|
|
199
348
|
case "restart":
|
|
349
|
+
if (hasFlag("--help") || hasFlag("-h")) usageManagerRestart();
|
|
200
350
|
stopProcess("manager");
|
|
201
351
|
setTimeout(startManager, 1000);
|
|
202
352
|
break;
|
|
203
353
|
case "status":
|
|
354
|
+
if (hasFlag("--help") || hasFlag("-h")) usageManagerStatus();
|
|
204
355
|
showStatus("manager");
|
|
205
356
|
if (getStatus("manager").running) {
|
|
206
357
|
console.log(`Token: ${ensureToken()}`);
|
|
207
358
|
}
|
|
208
359
|
break;
|
|
209
360
|
default:
|
|
210
|
-
|
|
361
|
+
usageManager();
|
|
211
362
|
}
|
|
212
363
|
} else if (role === "worker") {
|
|
364
|
+
if (action === "--help" || action === "-h") usageWorker();
|
|
213
365
|
switch (action) {
|
|
214
366
|
case "start":
|
|
367
|
+
if (hasFlag("--help") || hasFlag("-h")) usageWorkerStart();
|
|
215
368
|
startWorker();
|
|
216
369
|
break;
|
|
217
370
|
case "stop":
|
|
371
|
+
if (hasFlag("--help") || hasFlag("-h")) usageWorkerStop();
|
|
218
372
|
stopProcess("worker");
|
|
219
373
|
break;
|
|
220
374
|
case "restart":
|
|
375
|
+
if (hasFlag("--help") || hasFlag("-h")) usageWorkerRestart();
|
|
221
376
|
stopProcess("worker");
|
|
222
377
|
setTimeout(startWorker, 1000);
|
|
223
378
|
break;
|
|
224
379
|
case "status":
|
|
380
|
+
if (hasFlag("--help") || hasFlag("-h")) usageWorkerStatus();
|
|
225
381
|
showStatus("worker");
|
|
226
382
|
break;
|
|
227
383
|
default:
|
|
228
|
-
|
|
384
|
+
usageWorker();
|
|
229
385
|
}
|
|
230
386
|
} else {
|
|
231
|
-
|
|
387
|
+
usageMain();
|
|
232
388
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import type { Request, Response, NextFunction } from "express";
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const TOKEN_PATH = path.join(__dirname, "../../data/token");
|
|
9
|
+
|
|
10
|
+
let cachedToken: string | null = null;
|
|
11
|
+
|
|
12
|
+
export function getToken(): string {
|
|
13
|
+
if (cachedToken) return cachedToken;
|
|
14
|
+
|
|
15
|
+
// Allow env var override
|
|
16
|
+
if (process.env.SWARM_TOKEN) {
|
|
17
|
+
cachedToken = process.env.SWARM_TOKEN;
|
|
18
|
+
return cachedToken;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const tokenDir = path.dirname(TOKEN_PATH);
|
|
22
|
+
fs.mkdirSync(tokenDir, { recursive: true });
|
|
23
|
+
|
|
24
|
+
if (fs.existsSync(TOKEN_PATH)) {
|
|
25
|
+
cachedToken = fs.readFileSync(TOKEN_PATH, "utf-8").trim();
|
|
26
|
+
} else {
|
|
27
|
+
cachedToken = crypto.randomBytes(32).toString("hex");
|
|
28
|
+
fs.writeFileSync(TOKEN_PATH, cachedToken, { mode: 0o600 });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return cachedToken;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function validateToken(token: string): boolean {
|
|
35
|
+
return token === getToken();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
|
|
39
|
+
const header = req.headers.authorization;
|
|
40
|
+
if (!header || !header.startsWith("Bearer ")) {
|
|
41
|
+
res.status(401).json({ error: "Missing or invalid Authorization header" });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const token = header.slice(7);
|
|
46
|
+
if (!validateToken(token)) {
|
|
47
|
+
res.status(403).json({ error: "Invalid token" });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
next();
|
|
52
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const DB_PATH = path.join(__dirname, "../../data/swarm.db");
|
|
8
|
+
|
|
9
|
+
let db: Database.Database;
|
|
10
|
+
|
|
11
|
+
export function getDb(): Database.Database {
|
|
12
|
+
if (!db) {
|
|
13
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
14
|
+
db = new Database(DB_PATH);
|
|
15
|
+
db.pragma("journal_mode = WAL");
|
|
16
|
+
db.pragma("foreign_keys = ON");
|
|
17
|
+
initSchema(db);
|
|
18
|
+
}
|
|
19
|
+
return db;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function initSchema(db: Database.Database): void {
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
hostname TEXT,
|
|
27
|
+
ip TEXT,
|
|
28
|
+
status TEXT DEFAULT 'offline',
|
|
29
|
+
max_containers INTEGER DEFAULT 5,
|
|
30
|
+
last_heartbeat INTEGER,
|
|
31
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
32
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS containers (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
node_id TEXT REFERENCES nodes(id),
|
|
38
|
+
docker_id TEXT,
|
|
39
|
+
image TEXT DEFAULT 'swarm-base',
|
|
40
|
+
status TEXT DEFAULT 'running',
|
|
41
|
+
vnc_port INTEGER,
|
|
42
|
+
max_sessions INTEGER DEFAULT 10,
|
|
43
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
44
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
container_id TEXT REFERENCES containers(id),
|
|
50
|
+
command TEXT DEFAULT 'bash',
|
|
51
|
+
status TEXT DEFAULT 'running',
|
|
52
|
+
exit_code INTEGER,
|
|
53
|
+
interactive INTEGER DEFAULT 1,
|
|
54
|
+
scrollback TEXT DEFAULT '',
|
|
55
|
+
created_at INTEGER DEFAULT (unixepoch()),
|
|
56
|
+
updated_at INTEGER DEFAULT (unixepoch())
|
|
57
|
+
);
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Node queries ---
|
|
62
|
+
|
|
63
|
+
export function upsertNode(
|
|
64
|
+
id: string,
|
|
65
|
+
hostname: string,
|
|
66
|
+
ip: string,
|
|
67
|
+
maxContainers: number
|
|
68
|
+
): void {
|
|
69
|
+
getDb()
|
|
70
|
+
.prepare(
|
|
71
|
+
`INSERT INTO nodes (id, hostname, ip, status, max_containers, last_heartbeat, updated_at)
|
|
72
|
+
VALUES (?, ?, ?, 'online', ?, unixepoch(), unixepoch())
|
|
73
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
74
|
+
hostname = excluded.hostname,
|
|
75
|
+
ip = excluded.ip,
|
|
76
|
+
status = 'online',
|
|
77
|
+
max_containers = excluded.max_containers,
|
|
78
|
+
last_heartbeat = unixepoch(),
|
|
79
|
+
updated_at = unixepoch()`
|
|
80
|
+
)
|
|
81
|
+
.run(id, hostname, ip, maxContainers);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function updateNodeHeartbeat(id: string): void {
|
|
85
|
+
getDb()
|
|
86
|
+
.prepare(
|
|
87
|
+
`UPDATE nodes SET last_heartbeat = unixepoch(), status = 'online', updated_at = unixepoch() WHERE id = ?`
|
|
88
|
+
)
|
|
89
|
+
.run(id);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function markNodeStatus(id: string, status: string): void {
|
|
93
|
+
getDb()
|
|
94
|
+
.prepare(`UPDATE nodes SET status = ?, updated_at = unixepoch() WHERE id = ?`)
|
|
95
|
+
.run(status, id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function getAllNodes(): any[] {
|
|
99
|
+
return getDb().prepare(`SELECT * FROM nodes ORDER BY created_at`).all();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getNode(id: string): any {
|
|
103
|
+
return getDb().prepare(`SELECT * FROM nodes WHERE id = ?`).get(id);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getStaleNodes(thresholdSeconds: number): any[] {
|
|
107
|
+
return getDb()
|
|
108
|
+
.prepare(
|
|
109
|
+
`SELECT * FROM nodes WHERE status = 'online' AND last_heartbeat < unixepoch() - ?`
|
|
110
|
+
)
|
|
111
|
+
.all(thresholdSeconds);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function markReconnectingNodes(): void {
|
|
115
|
+
getDb()
|
|
116
|
+
.prepare(
|
|
117
|
+
`UPDATE nodes SET status = 'reconnecting', updated_at = unixepoch() WHERE status = 'online'`
|
|
118
|
+
)
|
|
119
|
+
.run();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function removeNode(id: string): void {
|
|
123
|
+
const containers = getContainersByNode(id);
|
|
124
|
+
for (const c of containers) {
|
|
125
|
+
getDb().prepare(`DELETE FROM sessions WHERE container_id = ?`).run(c.id);
|
|
126
|
+
}
|
|
127
|
+
getDb().prepare(`DELETE FROM containers WHERE node_id = ?`).run(id);
|
|
128
|
+
getDb().prepare(`DELETE FROM nodes WHERE id = ?`).run(id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function markStaleReconnectingOffline(thresholdSeconds: number): number {
|
|
132
|
+
return getDb()
|
|
133
|
+
.prepare(
|
|
134
|
+
`UPDATE nodes SET status = 'offline', updated_at = unixepoch()
|
|
135
|
+
WHERE status = 'reconnecting' AND updated_at < unixepoch() - ?`
|
|
136
|
+
)
|
|
137
|
+
.run(thresholdSeconds).changes;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Container queries ---
|
|
141
|
+
|
|
142
|
+
export function insertContainer(
|
|
143
|
+
id: string,
|
|
144
|
+
nodeId: string,
|
|
145
|
+
dockerId: string,
|
|
146
|
+
image: string,
|
|
147
|
+
vncPort: number
|
|
148
|
+
): void {
|
|
149
|
+
getDb()
|
|
150
|
+
.prepare(
|
|
151
|
+
`INSERT INTO containers (id, node_id, docker_id, image, vnc_port) VALUES (?, ?, ?, ?, ?)`
|
|
152
|
+
)
|
|
153
|
+
.run(id, nodeId, dockerId, image, vncPort);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function updateContainerDocker(id: string, dockerId: string, vncPort: number): void {
|
|
157
|
+
getDb()
|
|
158
|
+
.prepare(
|
|
159
|
+
`UPDATE containers SET docker_id = ?, vnc_port = ?, status = 'running', updated_at = unixepoch() WHERE id = ?`
|
|
160
|
+
)
|
|
161
|
+
.run(dockerId, vncPort, id);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function removeContainer(id: string): void {
|
|
165
|
+
getDb().prepare(`DELETE FROM sessions WHERE container_id = ?`).run(id);
|
|
166
|
+
getDb().prepare(`DELETE FROM containers WHERE id = ?`).run(id);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getContainersByNode(nodeId: string): any[] {
|
|
170
|
+
return getDb()
|
|
171
|
+
.prepare(`SELECT * FROM containers WHERE node_id = ? ORDER BY created_at`)
|
|
172
|
+
.all(nodeId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function getContainer(id: string): any {
|
|
176
|
+
return getDb().prepare(`SELECT * FROM containers WHERE id = ?`).get(id);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function countContainersByNode(nodeId: string): number {
|
|
180
|
+
const row = getDb()
|
|
181
|
+
.prepare(`SELECT COUNT(*) as count FROM containers WHERE node_id = ? AND status = 'running'`)
|
|
182
|
+
.get(nodeId) as any;
|
|
183
|
+
return row.count;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Session queries ---
|
|
187
|
+
|
|
188
|
+
export function insertSession(id: string, containerId: string, command: string): void {
|
|
189
|
+
getDb()
|
|
190
|
+
.prepare(`INSERT INTO sessions (id, container_id, command) VALUES (?, ?, ?)`)
|
|
191
|
+
.run(id, containerId, command);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function markSessionExited(id: string, exitCode: number): void {
|
|
195
|
+
getDb()
|
|
196
|
+
.prepare(
|
|
197
|
+
`UPDATE sessions SET status = 'exited', exit_code = ?, updated_at = unixepoch() WHERE id = ?`
|
|
198
|
+
)
|
|
199
|
+
.run(exitCode, id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function removeSession(id: string): void {
|
|
203
|
+
getDb().prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function getSessionsByContainer(containerId: string): any[] {
|
|
207
|
+
return getDb()
|
|
208
|
+
.prepare(`SELECT * FROM sessions WHERE container_id = ? ORDER BY created_at`)
|
|
209
|
+
.all(containerId);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function getSession(id: string): any {
|
|
213
|
+
return getDb().prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function countSessionsByContainer(containerId: string): number {
|
|
217
|
+
const row = getDb()
|
|
218
|
+
.prepare(
|
|
219
|
+
`SELECT COUNT(*) as count FROM sessions WHERE container_id = ? AND status = 'running'`
|
|
220
|
+
)
|
|
221
|
+
.get(containerId) as any;
|
|
222
|
+
return row.count;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function updateScrollback(id: string, scrollback: string): void {
|
|
226
|
+
getDb()
|
|
227
|
+
.prepare(`UPDATE sessions SET scrollback = ?, updated_at = unixepoch() WHERE id = ?`)
|
|
228
|
+
.run(scrollback, id);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function updateSessionInteractive(id: string, interactive: boolean): void {
|
|
232
|
+
getDb()
|
|
233
|
+
.prepare(`UPDATE sessions SET interactive = ?, updated_at = unixepoch() WHERE id = ?`)
|
|
234
|
+
.run(interactive ? 1 : 0, id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Full state (for UI snapshot) ---
|
|
238
|
+
|
|
239
|
+
export function getFullState(): {
|
|
240
|
+
nodes: any[];
|
|
241
|
+
containers: any[];
|
|
242
|
+
sessions: any[];
|
|
243
|
+
} {
|
|
244
|
+
return {
|
|
245
|
+
nodes: getDb().prepare(`SELECT * FROM nodes ORDER BY created_at`).all(),
|
|
246
|
+
containers: getDb().prepare(`SELECT * FROM containers ORDER BY created_at`).all(),
|
|
247
|
+
sessions: getDb()
|
|
248
|
+
.prepare(`SELECT id, container_id, command, status, exit_code, interactive, created_at, updated_at FROM sessions ORDER BY created_at`)
|
|
249
|
+
.all(),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { createServer as createViteServer } from "vite";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { authMiddleware, getToken, validateToken } from "./auth.js";
|
|
7
|
+
import { ensureThumbnailDir, getThumbnailPath } from "./thumbnails.js";
|
|
8
|
+
import { markReconnectingNodes, markStaleReconnectingOffline, getStaleNodes, markNodeStatus } from "./db.js";
|
|
9
|
+
import { setupWorkerWs } from "./ws/worker.js";
|
|
10
|
+
import { setupUiWs } from "./ws/ui.js";
|
|
11
|
+
import { setupSessionWs, startScrollbackFlush } from "./ws/session.js";
|
|
12
|
+
import { setupVncWs } from "./ws/vnc.js";
|
|
13
|
+
import nodesRouter from "./routes/nodes.js";
|
|
14
|
+
import containersRouter from "./routes/containers.js";
|
|
15
|
+
import sessionsRouter from "./routes/sessions.js";
|
|
16
|
+
import statusRouter from "./routes/status.js";
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const PORT = parseInt(process.env.PORT ?? "5174", 10);
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
ensureThumbnailDir();
|
|
23
|
+
markReconnectingNodes();
|
|
24
|
+
const app = express();
|
|
25
|
+
app.use(express.json());
|
|
26
|
+
|
|
27
|
+
app.get("/api/containers/:id/thumbnail", (req, res) => {
|
|
28
|
+
const token = (req.query.token as string) ?? req.headers.authorization?.slice(7);
|
|
29
|
+
if (!token || !validateToken(token)) { res.status(403).json({ error: "Invalid token" }); return; }
|
|
30
|
+
const thumbPath = getThumbnailPath(req.params.id);
|
|
31
|
+
if (!thumbPath) { res.status(404).json({ error: "No thumbnail" }); return; }
|
|
32
|
+
res.set("Cache-Control", "no-cache");
|
|
33
|
+
res.sendFile(thumbPath);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.use("/api/nodes", authMiddleware, nodesRouter);
|
|
37
|
+
app.use("/api/containers", authMiddleware, containersRouter);
|
|
38
|
+
app.use("/api/sessions", authMiddleware, sessionsRouter);
|
|
39
|
+
app.use("/api/status", authMiddleware, statusRouter);
|
|
40
|
+
|
|
41
|
+
const httpServer = createServer(app);
|
|
42
|
+
|
|
43
|
+
const workerWss = setupWorkerWs();
|
|
44
|
+
const uiWss = setupUiWs();
|
|
45
|
+
const sessionWss = setupSessionWs();
|
|
46
|
+
const vncWss = setupVncWs();
|
|
47
|
+
startScrollbackFlush();
|
|
48
|
+
|
|
49
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
50
|
+
const url = new URL(req.url ?? "", `http://${req.headers.host}`);
|
|
51
|
+
const p = url.pathname;
|
|
52
|
+
if (p === "/ws/worker") {
|
|
53
|
+
workerWss.handleUpgrade(req, socket, head, (ws) => workerWss.emit("connection", ws, req));
|
|
54
|
+
} else if (p === "/ws/ui") {
|
|
55
|
+
uiWss.handleUpgrade(req, socket, head, (ws) => uiWss.emit("connection", ws, req));
|
|
56
|
+
} else if (p.startsWith("/ws/session/")) {
|
|
57
|
+
sessionWss.handleUpgrade(req, socket, head, (ws) => sessionWss.emit("connection", ws, req));
|
|
58
|
+
} else if (p.startsWith("/ws/vnc/")) {
|
|
59
|
+
vncWss.handleUpgrade(req, socket, head, (ws) => vncWss.emit("connection", ws, req));
|
|
60
|
+
}
|
|
61
|
+
// else: let Vite HMR handle its own upgrades
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const vite = await createViteServer({
|
|
65
|
+
configFile: path.join(__dirname, "../../vite.config.ts"),
|
|
66
|
+
server: {
|
|
67
|
+
middlewareMode: true,
|
|
68
|
+
hmr: { server: httpServer },
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
app.use(vite.middlewares);
|
|
72
|
+
|
|
73
|
+
setInterval(() => {
|
|
74
|
+
const stale = getStaleNodes(30);
|
|
75
|
+
for (const node of stale) { markNodeStatus(node.id, "offline"); }
|
|
76
|
+
markStaleReconnectingOffline(60);
|
|
77
|
+
}, 10_000);
|
|
78
|
+
|
|
79
|
+
httpServer.listen(PORT, () => {
|
|
80
|
+
console.log(`Swarm Manager running on http://localhost:${PORT}`);
|
|
81
|
+
console.log(`Token: ${getToken()}`);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch(console.error);
|