swarmy 0.1.1 → 0.1.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.
- 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
|
@@ -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);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getContainersByNode,
|
|
3
|
+
getSessionsByContainer,
|
|
4
|
+
removeContainer,
|
|
5
|
+
updateContainerDocker,
|
|
6
|
+
markSessionExited,
|
|
7
|
+
} from "./db.js";
|
|
8
|
+
import { broadcastToUi } from "./ws/ui.js";
|
|
9
|
+
import { deleteThumbnail } from "./thumbnails.js";
|
|
10
|
+
|
|
11
|
+
interface WorkerContainer {
|
|
12
|
+
dockerId: string;
|
|
13
|
+
containerId?: string;
|
|
14
|
+
image: string;
|
|
15
|
+
status: string;
|
|
16
|
+
vncPort: number;
|
|
17
|
+
sessions: Array<{ pid: number; sessionId?: string; command: string }>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function reconcileWorkerState(
|
|
21
|
+
nodeId: string,
|
|
22
|
+
reportedContainers: WorkerContainer[]
|
|
23
|
+
): void {
|
|
24
|
+
const dbContainers = getContainersByNode(nodeId);
|
|
25
|
+
const reportedDockerIds = new Set(reportedContainers.map((c) => c.dockerId));
|
|
26
|
+
|
|
27
|
+
// Remove DB containers no longer running on worker
|
|
28
|
+
for (const dbC of dbContainers) {
|
|
29
|
+
if (dbC.docker_id && !reportedDockerIds.has(dbC.docker_id)) {
|
|
30
|
+
removeContainer(dbC.id);
|
|
31
|
+
deleteThumbnail(dbC.id);
|
|
32
|
+
broadcastToUi({ type: "container-stopped", containerId: dbC.id });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Reconcile sessions for each reported container
|
|
37
|
+
for (const reported of reportedContainers) {
|
|
38
|
+
const dbC = dbContainers.find(
|
|
39
|
+
(c: any) => c.id === reported.containerId || c.docker_id === reported.dockerId
|
|
40
|
+
);
|
|
41
|
+
if (!dbC) continue;
|
|
42
|
+
|
|
43
|
+
if (dbC.docker_id !== reported.dockerId || dbC.vnc_port !== reported.vncPort) {
|
|
44
|
+
updateContainerDocker(dbC.id, reported.dockerId, reported.vncPort);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const dbSessions = getSessionsByContainer(dbC.id);
|
|
48
|
+
for (const dbS of dbSessions) {
|
|
49
|
+
if (dbS.status === "running") {
|
|
50
|
+
const stillAlive = reported.sessions.some((s) => s.sessionId === dbS.id);
|
|
51
|
+
if (!stillAlive) {
|
|
52
|
+
markSessionExited(dbS.id, -1);
|
|
53
|
+
broadcastToUi({ type: "session-exited", sessionId: dbS.id, exitCode: -1 });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { v4 as uuid } from "uuid";
|
|
3
|
+
import { getContainer, getNode, countContainersByNode, insertContainer, removeContainer, getSessionsByContainer } from "../db.js";
|
|
4
|
+
import { sendToWorker } from "../ws/worker.js";
|
|
5
|
+
import { saveThumbnail, deleteThumbnail } from "../thumbnails.js";
|
|
6
|
+
|
|
7
|
+
const router = Router();
|
|
8
|
+
|
|
9
|
+
router.post("/", (req, res) => {
|
|
10
|
+
const { nodeId, image = "swarm-base" } = req.body ?? {};
|
|
11
|
+
if (!nodeId) { res.status(400).json({ error: "nodeId is required" }); return; }
|
|
12
|
+
const node = getNode(nodeId);
|
|
13
|
+
if (!node || node.status !== "online") { res.status(400).json({ error: "Node not found or offline" }); return; }
|
|
14
|
+
const count = countContainersByNode(nodeId);
|
|
15
|
+
if (count >= node.max_containers) { res.status(409).json({ error: `Node at container limit (${node.max_containers})` }); return; }
|
|
16
|
+
const containerId = uuid();
|
|
17
|
+
insertContainer(containerId, nodeId, "", image, 0);
|
|
18
|
+
sendToWorker(nodeId, { type: "start-container", containerId, image });
|
|
19
|
+
res.status(201).json({ id: containerId, nodeId, image, status: "starting" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
router.delete("/:id", (req, res) => {
|
|
23
|
+
const container = getContainer(req.params.id);
|
|
24
|
+
if (!container) { res.status(404).json({ error: "Container not found" }); return; }
|
|
25
|
+
sendToWorker(container.node_id, { type: "stop-container", containerId: container.id });
|
|
26
|
+
removeContainer(container.id);
|
|
27
|
+
deleteThumbnail(container.id);
|
|
28
|
+
res.json({ ok: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
router.get("/:id/sessions", (req, res) => {
|
|
32
|
+
const container = getContainer(req.params.id);
|
|
33
|
+
if (!container) { res.status(404).json({ error: "Container not found" }); return; }
|
|
34
|
+
res.json(getSessionsByContainer(req.params.id));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
router.post("/:id/thumbnail", (req, res) => {
|
|
38
|
+
const container = getContainer(req.params.id);
|
|
39
|
+
if (!container) { res.status(404).json({ error: "Container not found" }); return; }
|
|
40
|
+
const { data } = req.body ?? {};
|
|
41
|
+
if (!data || typeof data !== "string") { res.status(400).json({ error: "data (base64) is required" }); return; }
|
|
42
|
+
try {
|
|
43
|
+
saveThumbnail(container.id, data);
|
|
44
|
+
res.json({ ok: true });
|
|
45
|
+
} catch (e: any) {
|
|
46
|
+
if (e.message === "Thumbnail too large") { res.status(413).json({ error: "Thumbnail exceeds 50KB" }); return; }
|
|
47
|
+
throw e;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export default router;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { getAllNodes, getNode, getContainersByNode, getSessionsByContainer, removeNode } from "../db.js";
|
|
3
|
+
import { broadcastToUi } from "../ws/ui.js";
|
|
4
|
+
import { sendToWorker } from "../ws/worker.js";
|
|
5
|
+
|
|
6
|
+
const router = Router();
|
|
7
|
+
|
|
8
|
+
router.get("/", (_req, res) => {
|
|
9
|
+
res.json(getAllNodes());
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
router.get("/:id", (req, res) => {
|
|
13
|
+
const node = getNode(req.params.id);
|
|
14
|
+
if (!node) { res.status(404).json({ error: "Node not found" }); return; }
|
|
15
|
+
const containers = getContainersByNode(node.id).map((c: any) => ({
|
|
16
|
+
...c, sessions: getSessionsByContainer(c.id),
|
|
17
|
+
}));
|
|
18
|
+
res.json({ ...node, containers });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
router.get("/:id/containers", (req, res) => {
|
|
22
|
+
const node = getNode(req.params.id);
|
|
23
|
+
if (!node) { res.status(404).json({ error: "Node not found" }); return; }
|
|
24
|
+
res.json(getContainersByNode(req.params.id));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
router.delete("/:id", (req, res) => {
|
|
28
|
+
const node = getNode(req.params.id);
|
|
29
|
+
if (!node) { res.status(404).json({ error: "Node not found" }); return; }
|
|
30
|
+
const containers = getContainersByNode(node.id);
|
|
31
|
+
for (const c of containers) {
|
|
32
|
+
sendToWorker(node.id, { type: "stop-container", containerId: c.id });
|
|
33
|
+
}
|
|
34
|
+
removeNode(node.id);
|
|
35
|
+
broadcastToUi({ type: "node-deleted", nodeId: node.id });
|
|
36
|
+
res.json({ ok: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export default router;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { v4 as uuid } from "uuid";
|
|
3
|
+
import { getSession, getContainer, countSessionsByContainer, insertSession, removeSession, updateSessionInteractive } from "../db.js";
|
|
4
|
+
import { sendToWorker } from "../ws/worker.js";
|
|
5
|
+
import { getScrollbackBuffer } from "../ws/session.js";
|
|
6
|
+
import { broadcastToUi } from "../ws/ui.js";
|
|
7
|
+
|
|
8
|
+
const router = Router();
|
|
9
|
+
|
|
10
|
+
router.post("/", (req, res) => {
|
|
11
|
+
const { containerId, command = "bash" } = req.body ?? {};
|
|
12
|
+
if (!containerId) { res.status(400).json({ error: "containerId is required" }); return; }
|
|
13
|
+
const container = getContainer(containerId);
|
|
14
|
+
if (!container || container.status !== "running") { res.status(400).json({ error: "Container not found or not running" }); return; }
|
|
15
|
+
const count = countSessionsByContainer(containerId);
|
|
16
|
+
if (count >= container.max_sessions) { res.status(409).json({ error: `Container at session limit (${container.max_sessions})` }); return; }
|
|
17
|
+
const sessionId = uuid();
|
|
18
|
+
insertSession(sessionId, containerId, command);
|
|
19
|
+
sendToWorker(container.node_id, { type: "spawn-session", containerId: container.id, sessionId, command });
|
|
20
|
+
res.status(201).json({ id: sessionId, containerId, command, status: "starting" });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
router.post("/:id/input", (req, res) => {
|
|
24
|
+
const session = getSession(req.params.id);
|
|
25
|
+
if (!session || session.status !== "running") { res.status(404).json({ error: "Session not found or not running" }); return; }
|
|
26
|
+
const container = getContainer(session.container_id);
|
|
27
|
+
if (!container) { res.status(404).json({ error: "Container not found" }); return; }
|
|
28
|
+
sendToWorker(container.node_id, { type: "session-input", sessionId: session.id, data: req.body.data });
|
|
29
|
+
res.json({ ok: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
router.get("/:id/output", (req, res) => {
|
|
33
|
+
const session = getSession(req.params.id);
|
|
34
|
+
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
|
|
35
|
+
const buffer = getScrollbackBuffer(req.params.id);
|
|
36
|
+
res.json({ id: session.id, output: buffer ?? session.scrollback ?? "" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
router.patch("/:id", (req, res) => {
|
|
40
|
+
const session = getSession(req.params.id);
|
|
41
|
+
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
|
|
42
|
+
if (req.body.interactive !== undefined) { updateSessionInteractive(req.params.id, req.body.interactive); }
|
|
43
|
+
res.json({ ok: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
router.delete("/:id", (req, res) => {
|
|
47
|
+
const session = getSession(req.params.id);
|
|
48
|
+
if (!session) { res.status(404).json({ error: "Session not found" }); return; }
|
|
49
|
+
const container = getContainer(session.container_id);
|
|
50
|
+
if (container) { sendToWorker(container.node_id, { type: "kill-session", sessionId: session.id }); }
|
|
51
|
+
removeSession(session.id);
|
|
52
|
+
broadcastToUi({ type: "session-deleted", sessionId: session.id });
|
|
53
|
+
res.json({ ok: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export default router;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { getDb } from "../db.js";
|
|
3
|
+
|
|
4
|
+
const router = Router();
|
|
5
|
+
|
|
6
|
+
router.get("/", (_req, res) => {
|
|
7
|
+
const db = getDb();
|
|
8
|
+
const nodes = db.prepare(`SELECT status, COUNT(*) as count FROM nodes GROUP BY status`).all() as any[];
|
|
9
|
+
const containers = db.prepare(`SELECT status, COUNT(*) as count FROM containers GROUP BY status`).all() as any[];
|
|
10
|
+
const sessions = db.prepare(`SELECT status, COUNT(*) as count FROM sessions GROUP BY status`).all() as any[];
|
|
11
|
+
res.json({
|
|
12
|
+
nodes: Object.fromEntries(nodes.map((r: any) => [r.status, r.count])),
|
|
13
|
+
containers: Object.fromEntries(containers.map((r: any) => [r.status, r.count])),
|
|
14
|
+
sessions: Object.fromEntries(sessions.map((r: any) => [r.status, r.count])),
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export default router;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const THUMB_DIR = path.join(__dirname, "../../data/thumbnails");
|
|
7
|
+
|
|
8
|
+
export function ensureThumbnailDir(): void {
|
|
9
|
+
fs.mkdirSync(THUMB_DIR, { recursive: true });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function saveThumbnail(containerId: string, base64Data: string): void {
|
|
13
|
+
const buf = Buffer.from(base64Data, "base64");
|
|
14
|
+
if (buf.length > 50 * 1024) throw new Error("Thumbnail too large");
|
|
15
|
+
fs.writeFileSync(path.join(THUMB_DIR, `${containerId}.jpg`), buf);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getThumbnailPath(containerId: string): string | null {
|
|
19
|
+
const p = path.join(THUMB_DIR, `${containerId}.jpg`);
|
|
20
|
+
return fs.existsSync(p) ? p : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function deleteThumbnail(containerId: string): void {
|
|
24
|
+
const p = path.join(THUMB_DIR, `${containerId}.jpg`);
|
|
25
|
+
try { fs.unlinkSync(p); } catch {}
|
|
26
|
+
}
|