swarmy 0.1.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 +121 -0
- package/bin/swarmy.js +232 -0
- package/bin/swarmy.mjs +232 -0
- package/manager/package.json +43 -0
- package/manager/src/client/App.tsx +168 -0
- package/manager/src/client/components/AllNodesView.tsx +29 -0
- package/manager/src/client/components/Breadcrumbs.tsx +63 -0
- package/manager/src/client/components/ContainerCard.tsx +36 -0
- package/manager/src/client/components/NodeCard.tsx +51 -0
- package/manager/src/client/components/NodeView.tsx +52 -0
- package/manager/src/client/components/Sidebar.tsx +152 -0
- package/manager/src/client/components/TerminalPanel.tsx +132 -0
- package/manager/src/client/components/TopBar.tsx +24 -0
- package/manager/src/client/components/VncPanel.tsx +104 -0
- package/manager/src/client/components/ui/ConfirmDialog.tsx +54 -0
- package/manager/src/client/hooks/useHashRouter.ts +37 -0
- package/manager/src/client/hooks/useLocalStorage.ts +22 -0
- package/manager/src/client/hooks/useSwarmState.ts +174 -0
- package/manager/src/client/index.css +1 -0
- package/manager/src/client/index.html +12 -0
- package/manager/src/client/lib/utils.ts +6 -0
- package/manager/src/client/main.tsx +10 -0
- package/manager/src/client/novnc.d.ts +9 -0
- package/manager/vite.config.ts +60 -0
- package/package.json +27 -0
- package/scripts/build.sh +11 -0
- package/scripts/run.sh +35 -0
- package/scripts/stop.sh +15 -0
- package/worker/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Swarm
|
|
2
|
+
|
|
3
|
+
Distributed container management with browser-based VNC and terminal access.
|
|
4
|
+
|
|
5
|
+
Provision ephemeral Docker containers across multiple nodes and interact with them through a web dashboard — full desktop GUI via VNC and interactive terminal sessions, all in the browser.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
┌─────────────────────────────────────────────────────┐
|
|
11
|
+
│ Browser Client │
|
|
12
|
+
│ React + xterm.js + noVNC │
|
|
13
|
+
└──────────┬───────────────────────────┬──────────────┘
|
|
14
|
+
│ WebSocket (UI/session/VNC)│ REST API
|
|
15
|
+
▼ ▼
|
|
16
|
+
┌─────────────────────────────────────────────────────┐
|
|
17
|
+
│ Manager │
|
|
18
|
+
│ Express.js · SQLite · WebSocket Hub │
|
|
19
|
+
└──────────┬──────────────────────────────────────────┘
|
|
20
|
+
│ WebSocket (/ws/worker)
|
|
21
|
+
┌─────┴─────┐
|
|
22
|
+
▼ ▼
|
|
23
|
+
┌──────────┐ ┌──────────┐
|
|
24
|
+
│ Worker │ │ Worker │ ...
|
|
25
|
+
│ Docker │ │ Docker │
|
|
26
|
+
│ node-pty │ │ node-pty │
|
|
27
|
+
└──────────┘ └──────────┘
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Manager** — Central server with React dashboard. Maintains state in SQLite, brokers WebSocket communication between workers and browser clients.
|
|
31
|
+
- **Worker** — Node agent on each machine. Manages Docker containers, PTY sessions, and VNC proxy connections.
|
|
32
|
+
- **swarm-base** — Docker image (Ubuntu 24.04) with TigerVNC, Fluxbox, noVNC, Chrome, and s6-overlay for process supervision.
|
|
33
|
+
|
|
34
|
+
## Quick Start
|
|
35
|
+
|
|
36
|
+
### Prerequisites
|
|
37
|
+
|
|
38
|
+
- Node.js (v24+ recommended)
|
|
39
|
+
- Docker
|
|
40
|
+
- npm
|
|
41
|
+
|
|
42
|
+
### Manager
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd manager
|
|
46
|
+
npm install
|
|
47
|
+
npm run dev
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The manager starts on port 5174. An auth token is auto-generated at `manager/data/token` on first run.
|
|
51
|
+
|
|
52
|
+
### Worker
|
|
53
|
+
|
|
54
|
+
On each node that will run containers:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
cd worker
|
|
58
|
+
npm install
|
|
59
|
+
SWARM_URL=http://<manager-host>:5174 SWARM_TOKEN=<token> npm run dev
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Docker Image
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
scripts/build.sh # Build swarm-base image (linux/amd64)
|
|
66
|
+
scripts/run.sh # Run a standalone container
|
|
67
|
+
PORT=8080 RESOLUTION=1280x720 scripts/run.sh my-browser # Custom settings
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Production Deployment
|
|
71
|
+
|
|
72
|
+
The install script handles cloning, building, and registering system services (systemd on Linux, launchd on macOS):
|
|
73
|
+
|
|
74
|
+
**Manager:**
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
curl -sfL https://raw.githubusercontent.com/tkhoa87/swarm/main/install.sh | sh -
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**Worker:**
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
curl -sfL https://raw.githubusercontent.com/tkhoa87/swarm/main/install.sh | \
|
|
84
|
+
SWARM_URL=http://<manager-host>:5174 SWARM_TOKEN=<token> sh -
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Installs to `/opt/swarm`. Services auto-restart on failure.
|
|
88
|
+
|
|
89
|
+
## Environment Variables
|
|
90
|
+
|
|
91
|
+
| Variable | Component | Description |
|
|
92
|
+
| --------------- | --------- | -------------------------------------- |
|
|
93
|
+
| `PORT` | Manager | HTTP port (default: 3000) |
|
|
94
|
+
| `SWARM_URL` | Worker | Manager URL to connect to |
|
|
95
|
+
| `SWARM_TOKEN` | Worker | Auth token for registration |
|
|
96
|
+
| `SWARM_NODE_ID` | Worker | Node identifier (default: hostname) |
|
|
97
|
+
| `RESOLUTION` | Docker | Screen resolution (default: 1920x1080) |
|
|
98
|
+
|
|
99
|
+
## API
|
|
100
|
+
|
|
101
|
+
All REST endpoints require `Authorization: Bearer <token>` header.
|
|
102
|
+
|
|
103
|
+
| Method | Endpoint | Description |
|
|
104
|
+
| -------- | ------------------------- | ----------------------------------- |
|
|
105
|
+
| `GET` | `/api/nodes` | List all nodes |
|
|
106
|
+
| `GET` | `/api/nodes/:id` | Get node with containers |
|
|
107
|
+
| `DELETE` | `/api/nodes/:id` | Remove node (stops containers) |
|
|
108
|
+
| `POST` | `/api/containers` | Create container on a node |
|
|
109
|
+
| `DELETE` | `/api/containers/:id` | Stop and remove container |
|
|
110
|
+
| `POST` | `/api/sessions` | Spawn terminal session in container |
|
|
111
|
+
| `DELETE` | `/api/sessions/:id` | Kill session |
|
|
112
|
+
| `POST` | `/api/sessions/:id/input` | Send input to session |
|
|
113
|
+
| `GET` | `/api/status` | Server status |
|
|
114
|
+
|
|
115
|
+
## Tech Stack
|
|
116
|
+
|
|
117
|
+
**Manager:** Express.js, React 19, Tailwind CSS 4, xterm.js, noVNC, better-sqlite3, Vite
|
|
118
|
+
|
|
119
|
+
**Worker:** Dockerode, node-pty, ws
|
|
120
|
+
|
|
121
|
+
**Container:** Ubuntu 24.04, TigerVNC, Fluxbox, noVNC, Google Chrome, s6-overlay v3
|
package/bin/swarmy.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, execFileSync } from "child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync } from "fs";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ROOT = join(__dirname, "..");
|
|
11
|
+
const DATA_DIR = join(ROOT, ".swarmy");
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function ensureDataDir() {
|
|
16
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pidFile(role) {
|
|
20
|
+
return join(DATA_DIR, `${role}.pid`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function logFile(role) {
|
|
24
|
+
return join(DATA_DIR, `${role}.log`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readPid(role) {
|
|
28
|
+
const f = pidFile(role);
|
|
29
|
+
if (!existsSync(f)) return null;
|
|
30
|
+
const pid = parseInt(readFileSync(f, "utf-8").trim(), 10);
|
|
31
|
+
if (isNaN(pid)) return null;
|
|
32
|
+
return pid;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRunning(pid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getStatus(role) {
|
|
45
|
+
const pid = readPid(role);
|
|
46
|
+
if (!pid) return { running: false, pid: null };
|
|
47
|
+
if (isRunning(pid)) return { running: true, pid };
|
|
48
|
+
try { unlinkSync(pidFile(role)); } catch {}
|
|
49
|
+
return { running: false, pid: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureBuilt(role) {
|
|
53
|
+
const distDir = join(ROOT, role, "dist");
|
|
54
|
+
if (!existsSync(distDir)) {
|
|
55
|
+
console.log(`Building ${role}...`);
|
|
56
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
57
|
+
execFileSync(npmCmd, ["install"], { cwd: join(ROOT, role), stdio: "inherit" });
|
|
58
|
+
execFileSync(npmCmd, ["run", "build"], { cwd: join(ROOT, role), stdio: "inherit" });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Token ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function ensureToken() {
|
|
65
|
+
const tokenDir = join(ROOT, "manager", "data");
|
|
66
|
+
const tokenFile = join(tokenDir, "token");
|
|
67
|
+
mkdirSync(tokenDir, { recursive: true });
|
|
68
|
+
if (existsSync(tokenFile)) {
|
|
69
|
+
return readFileSync(tokenFile, "utf-8").trim();
|
|
70
|
+
}
|
|
71
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
72
|
+
writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
73
|
+
return token;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Start ────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function startProcess(role, args, env = {}) {
|
|
79
|
+
const status = getStatus(role);
|
|
80
|
+
if (status.running) {
|
|
81
|
+
console.log(`${role} is already running (PID ${status.pid})`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ensureDataDir();
|
|
86
|
+
ensureBuilt(role);
|
|
87
|
+
|
|
88
|
+
const log = logFile(role);
|
|
89
|
+
const out = openSync(log, "a");
|
|
90
|
+
|
|
91
|
+
const child = spawn("node", args, {
|
|
92
|
+
cwd: join(ROOT, role),
|
|
93
|
+
env: { ...process.env, ...env },
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: ["ignore", out, out],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
writeFileSync(pidFile(role), String(child.pid));
|
|
99
|
+
child.unref();
|
|
100
|
+
|
|
101
|
+
console.log(`${role} started (PID ${child.pid})`);
|
|
102
|
+
console.log(`Log: ${log}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Stop ─────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
function stopProcess(role) {
|
|
108
|
+
const status = getStatus(role);
|
|
109
|
+
if (!status.running) {
|
|
110
|
+
console.log(`${role} is not running`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
process.kill(-status.pid, "SIGTERM");
|
|
116
|
+
} catch {
|
|
117
|
+
try { process.kill(status.pid, "SIGTERM"); } catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try { unlinkSync(pidFile(role)); } catch {}
|
|
121
|
+
console.log(`${role} stopped (was PID ${status.pid})`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Commands ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function showStatus(role) {
|
|
127
|
+
const status = getStatus(role);
|
|
128
|
+
if (status.running) {
|
|
129
|
+
console.log(`${role}: running (PID ${status.pid})`);
|
|
130
|
+
console.log(`Log: ${logFile(role)}`);
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`${role}: stopped`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── CLI ──────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const cliArgs = process.argv.slice(2);
|
|
139
|
+
const role = cliArgs[0];
|
|
140
|
+
const action = cliArgs[1];
|
|
141
|
+
|
|
142
|
+
function usage() {
|
|
143
|
+
console.log(`
|
|
144
|
+
swarmy - Distributed container management
|
|
145
|
+
|
|
146
|
+
Usage:
|
|
147
|
+
swarmy manager start [--port PORT]
|
|
148
|
+
swarmy manager stop
|
|
149
|
+
swarmy manager restart [--port PORT]
|
|
150
|
+
swarmy manager status
|
|
151
|
+
|
|
152
|
+
swarmy worker start --url URL --token TOKEN [--hostname NAME]
|
|
153
|
+
swarmy worker stop
|
|
154
|
+
swarmy worker restart --url URL --token TOKEN [--hostname NAME]
|
|
155
|
+
swarmy worker status
|
|
156
|
+
`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseFlag(flag) {
|
|
161
|
+
const idx = cliArgs.indexOf(flag);
|
|
162
|
+
if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
|
|
163
|
+
return cliArgs[idx + 1];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!role || !action) usage();
|
|
167
|
+
|
|
168
|
+
function startManager() {
|
|
169
|
+
const port = parseFlag("--port") ?? "5174";
|
|
170
|
+
const token = ensureToken();
|
|
171
|
+
startProcess("manager", ["dist/server/index.js"], { PORT: port, NODE_ENV: "production" });
|
|
172
|
+
console.log(`\nManager: http://localhost:${port}`);
|
|
173
|
+
console.log(`Token: ${token}`);
|
|
174
|
+
console.log(`\nTo connect a worker:\n npx swarmy worker start --url http://<this-ip>:${port} --token ${token}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function startWorker() {
|
|
178
|
+
const url = parseFlag("--url");
|
|
179
|
+
const token = parseFlag("--token");
|
|
180
|
+
const hostname = parseFlag("--hostname");
|
|
181
|
+
if (!url || !token) {
|
|
182
|
+
console.error("Error: --url and --token are required");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
const env = { SWARM_URL: url, SWARM_TOKEN: token };
|
|
186
|
+
if (hostname) env.HOSTNAME = hostname;
|
|
187
|
+
startProcess("worker", ["dist/index.js"], env);
|
|
188
|
+
console.log(`\nConnecting to: ${url}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (role === "manager") {
|
|
192
|
+
switch (action) {
|
|
193
|
+
case "start":
|
|
194
|
+
startManager();
|
|
195
|
+
break;
|
|
196
|
+
case "stop":
|
|
197
|
+
stopProcess("manager");
|
|
198
|
+
break;
|
|
199
|
+
case "restart":
|
|
200
|
+
stopProcess("manager");
|
|
201
|
+
setTimeout(startManager, 1000);
|
|
202
|
+
break;
|
|
203
|
+
case "status":
|
|
204
|
+
showStatus("manager");
|
|
205
|
+
if (getStatus("manager").running) {
|
|
206
|
+
console.log(`Token: ${ensureToken()}`);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
usage();
|
|
211
|
+
}
|
|
212
|
+
} else if (role === "worker") {
|
|
213
|
+
switch (action) {
|
|
214
|
+
case "start":
|
|
215
|
+
startWorker();
|
|
216
|
+
break;
|
|
217
|
+
case "stop":
|
|
218
|
+
stopProcess("worker");
|
|
219
|
+
break;
|
|
220
|
+
case "restart":
|
|
221
|
+
stopProcess("worker");
|
|
222
|
+
setTimeout(startWorker, 1000);
|
|
223
|
+
break;
|
|
224
|
+
case "status":
|
|
225
|
+
showStatus("worker");
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
usage();
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
usage();
|
|
232
|
+
}
|
package/bin/swarmy.mjs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn, execFileSync } from "child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, openSync } from "fs";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import crypto from "crypto";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const ROOT = join(__dirname, "..");
|
|
11
|
+
const DATA_DIR = join(ROOT, ".swarmy");
|
|
12
|
+
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
function ensureDataDir() {
|
|
16
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pidFile(role) {
|
|
20
|
+
return join(DATA_DIR, `${role}.pid`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function logFile(role) {
|
|
24
|
+
return join(DATA_DIR, `${role}.log`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readPid(role) {
|
|
28
|
+
const f = pidFile(role);
|
|
29
|
+
if (!existsSync(f)) return null;
|
|
30
|
+
const pid = parseInt(readFileSync(f, "utf-8").trim(), 10);
|
|
31
|
+
if (isNaN(pid)) return null;
|
|
32
|
+
return pid;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRunning(pid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getStatus(role) {
|
|
45
|
+
const pid = readPid(role);
|
|
46
|
+
if (!pid) return { running: false, pid: null };
|
|
47
|
+
if (isRunning(pid)) return { running: true, pid };
|
|
48
|
+
try { unlinkSync(pidFile(role)); } catch {}
|
|
49
|
+
return { running: false, pid: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureBuilt(role) {
|
|
53
|
+
const distDir = join(ROOT, role, "dist");
|
|
54
|
+
if (!existsSync(distDir)) {
|
|
55
|
+
console.log(`Building ${role}...`);
|
|
56
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
57
|
+
execFileSync(npmCmd, ["install"], { cwd: join(ROOT, role), stdio: "inherit" });
|
|
58
|
+
execFileSync(npmCmd, ["run", "build"], { cwd: join(ROOT, role), stdio: "inherit" });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Token ────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function ensureToken() {
|
|
65
|
+
const tokenDir = join(ROOT, "manager", "data");
|
|
66
|
+
const tokenFile = join(tokenDir, "token");
|
|
67
|
+
mkdirSync(tokenDir, { recursive: true });
|
|
68
|
+
if (existsSync(tokenFile)) {
|
|
69
|
+
return readFileSync(tokenFile, "utf-8").trim();
|
|
70
|
+
}
|
|
71
|
+
const token = crypto.randomBytes(32).toString("hex");
|
|
72
|
+
writeFileSync(tokenFile, token, { mode: 0o600 });
|
|
73
|
+
return token;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Start ────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
function startProcess(role, args, env = {}) {
|
|
79
|
+
const status = getStatus(role);
|
|
80
|
+
if (status.running) {
|
|
81
|
+
console.log(`${role} is already running (PID ${status.pid})`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
ensureDataDir();
|
|
86
|
+
ensureBuilt(role);
|
|
87
|
+
|
|
88
|
+
const log = logFile(role);
|
|
89
|
+
const out = openSync(log, "a");
|
|
90
|
+
|
|
91
|
+
const child = spawn("node", args, {
|
|
92
|
+
cwd: join(ROOT, role),
|
|
93
|
+
env: { ...process.env, ...env },
|
|
94
|
+
detached: true,
|
|
95
|
+
stdio: ["ignore", out, out],
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
writeFileSync(pidFile(role), String(child.pid));
|
|
99
|
+
child.unref();
|
|
100
|
+
|
|
101
|
+
console.log(`${role} started (PID ${child.pid})`);
|
|
102
|
+
console.log(`Log: ${log}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Stop ─────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
function stopProcess(role) {
|
|
108
|
+
const status = getStatus(role);
|
|
109
|
+
if (!status.running) {
|
|
110
|
+
console.log(`${role} is not running`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
process.kill(-status.pid, "SIGTERM");
|
|
116
|
+
} catch {
|
|
117
|
+
try { process.kill(status.pid, "SIGTERM"); } catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try { unlinkSync(pidFile(role)); } catch {}
|
|
121
|
+
console.log(`${role} stopped (was PID ${status.pid})`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Commands ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
function showStatus(role) {
|
|
127
|
+
const status = getStatus(role);
|
|
128
|
+
if (status.running) {
|
|
129
|
+
console.log(`${role}: running (PID ${status.pid})`);
|
|
130
|
+
console.log(`Log: ${logFile(role)}`);
|
|
131
|
+
} else {
|
|
132
|
+
console.log(`${role}: stopped`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── CLI ──────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const cliArgs = process.argv.slice(2);
|
|
139
|
+
const role = cliArgs[0];
|
|
140
|
+
const action = cliArgs[1];
|
|
141
|
+
|
|
142
|
+
function usage() {
|
|
143
|
+
console.log(`
|
|
144
|
+
swarmy - Distributed container management
|
|
145
|
+
|
|
146
|
+
Usage:
|
|
147
|
+
swarmy manager start [--port PORT]
|
|
148
|
+
swarmy manager stop
|
|
149
|
+
swarmy manager restart [--port PORT]
|
|
150
|
+
swarmy manager status
|
|
151
|
+
|
|
152
|
+
swarmy worker start --url URL --token TOKEN [--hostname NAME]
|
|
153
|
+
swarmy worker stop
|
|
154
|
+
swarmy worker restart --url URL --token TOKEN [--hostname NAME]
|
|
155
|
+
swarmy worker status
|
|
156
|
+
`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function parseFlag(flag) {
|
|
161
|
+
const idx = cliArgs.indexOf(flag);
|
|
162
|
+
if (idx === -1 || idx + 1 >= cliArgs.length) return undefined;
|
|
163
|
+
return cliArgs[idx + 1];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!role || !action) usage();
|
|
167
|
+
|
|
168
|
+
function startManager() {
|
|
169
|
+
const port = parseFlag("--port") ?? "5174";
|
|
170
|
+
const token = ensureToken();
|
|
171
|
+
startProcess("manager", ["dist/server/index.js"], { PORT: port, NODE_ENV: "production" });
|
|
172
|
+
console.log(`\nManager: http://localhost:${port}`);
|
|
173
|
+
console.log(`Token: ${token}`);
|
|
174
|
+
console.log(`\nTo connect a worker:\n npx swarmy worker start --url http://<this-ip>:${port} --token ${token}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function startWorker() {
|
|
178
|
+
const url = parseFlag("--url");
|
|
179
|
+
const token = parseFlag("--token");
|
|
180
|
+
const hostname = parseFlag("--hostname");
|
|
181
|
+
if (!url || !token) {
|
|
182
|
+
console.error("Error: --url and --token are required");
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
const env = { SWARM_URL: url, SWARM_TOKEN: token };
|
|
186
|
+
if (hostname) env.HOSTNAME = hostname;
|
|
187
|
+
startProcess("worker", ["dist/index.js"], env);
|
|
188
|
+
console.log(`\nConnecting to: ${url}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (role === "manager") {
|
|
192
|
+
switch (action) {
|
|
193
|
+
case "start":
|
|
194
|
+
startManager();
|
|
195
|
+
break;
|
|
196
|
+
case "stop":
|
|
197
|
+
stopProcess("manager");
|
|
198
|
+
break;
|
|
199
|
+
case "restart":
|
|
200
|
+
stopProcess("manager");
|
|
201
|
+
setTimeout(startManager, 1000);
|
|
202
|
+
break;
|
|
203
|
+
case "status":
|
|
204
|
+
showStatus("manager");
|
|
205
|
+
if (getStatus("manager").running) {
|
|
206
|
+
console.log(`Token: ${ensureToken()}`);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
default:
|
|
210
|
+
usage();
|
|
211
|
+
}
|
|
212
|
+
} else if (role === "worker") {
|
|
213
|
+
switch (action) {
|
|
214
|
+
case "start":
|
|
215
|
+
startWorker();
|
|
216
|
+
break;
|
|
217
|
+
case "stop":
|
|
218
|
+
stopProcess("worker");
|
|
219
|
+
break;
|
|
220
|
+
case "restart":
|
|
221
|
+
stopProcess("worker");
|
|
222
|
+
setTimeout(startWorker, 1000);
|
|
223
|
+
break;
|
|
224
|
+
case "status":
|
|
225
|
+
showStatus("worker");
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
usage();
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
usage();
|
|
232
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "swarm-manager",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx src/server/index.ts",
|
|
8
|
+
"build": "vite build && tsc -p tsconfig.node.json",
|
|
9
|
+
"start": "node dist/server/index.js"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@novnc/novnc": "^1.6.0",
|
|
13
|
+
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
14
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
15
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
16
|
+
"@xterm/xterm": "^6.0.0",
|
|
17
|
+
"better-sqlite3": "^11.7.0",
|
|
18
|
+
"class-variance-authority": "^0.7.1",
|
|
19
|
+
"clsx": "^2.1.1",
|
|
20
|
+
"express": "^4.21.0",
|
|
21
|
+
"lucide-react": "^0.577.0",
|
|
22
|
+
"tailwind-merge": "^3.5.0",
|
|
23
|
+
"uuid": "^10.0.0",
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
28
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
29
|
+
"@types/express": "^5.0.0",
|
|
30
|
+
"@types/node": "^22.10.0",
|
|
31
|
+
"@types/react": "^19.0.0",
|
|
32
|
+
"@types/react-dom": "^19.0.0",
|
|
33
|
+
"@types/uuid": "^10.0.0",
|
|
34
|
+
"@types/ws": "^8.5.13",
|
|
35
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
36
|
+
"react": "^19.0.0",
|
|
37
|
+
"react-dom": "^19.0.0",
|
|
38
|
+
"tailwindcss": "^4.0.0",
|
|
39
|
+
"tsx": "^4.19.0",
|
|
40
|
+
"typescript": "^5.7.0",
|
|
41
|
+
"vite": "^6.0.0"
|
|
42
|
+
}
|
|
43
|
+
}
|