Merge pull request #28 from AnsoCode/feature/distributed-api-pivot

refactor: Distributed API Pivot
This commit is contained in:
Anso
2026-03-19 12:23:25 -04:00
committed by GitHub
14 changed files with 522 additions and 899 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"env": {
"includeCoAuthoredBy": "false"
},
"permissions": {
"allow": [
"Bash(xargs wc:*)",
"Bash(grep -c \"TODO\\\\|FIXME\" /c/Users/Boris/Documents/Projects/Sencho/backend/src/services/*.ts)",
"Bash(git checkout:*)",
"Bash(git pull:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"mcp__github__create_pull_request",
"Bash(gh auth:*)",
"Bash(npm uninstall:*)",
"Bash(npm install:*)",
"Bash(npx tsc:*)"
]
}
}
+5 -6
View File
@@ -5,11 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- **Added:** TLS certificate support for secure remote Docker daemon connections.
- **Added:** SSH Private Key authentication UI for the Smart Proxy file system.
- **Fixed:** Fatal path.join crash in FileSystemService caused by malformed SSH directory reads.
- **Fixed:** Home dashboard System Stats incorrectly showing local hardware metrics when a remote node was active.
- **Fixed:** UI overlap between the 'Add Node' button and the Settings dialog close icon.
- **Removed:** SSH/SFTP file adapters and remote Docker TCP connections (net negative ~500 lines of code).
- **Added:** Distributed API proxying using http-proxy-middleware for HTTP and WebSockets.
- **Added:** Long-lived JWT generation for Sencho-to-Sencho API authentication (`POST /api/auth/generate-node-token`).
- **Changed:** Node Manager UI vastly simplified — remote nodes now only require an API URL and Token.
- **Fixed:** Critical port routing conflict — separated Docker API port (`port`) from SSH/SFTP port (`ssh_port`) in the `nodes` schema. Previously, a single `port` field served both protocols, causing ECONNREFUSED.
- **Fixed:** `FileSystemService` now reads the node's `compose_dir` from the database for remote nodes instead of always using the `COMPOSE_DIR` env var.
- **Fixed:** SSH/SFTP connections in `SSHFileAdapter`, `ComposeService.executeRemote()`, and `ComposeService.streamLogs()` now use `ssh_port` (default 22) instead of Docker API `port`.
@@ -19,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Added:** `IFileAdapter`, `LocalFileAdapter`, and `SSHFileAdapter` to abstract all filesystem interactions for remote node support.
- **Changed:** `MonitorService` now evaluates limits, fetches metrics, and detects container crashes across all registered nodes concurrently.
- **Added:** Node Context Middleware in Express API to dynamically extract `x-node-id` headers and parse WebSocket query parameters.
- **Added:** Remote Nodes Foundation (Strategy B) — `nodes` table in SQLite with auto-seeded default local node.
- **Added:** Remote Nodes Foundation - `nodes` table in SQLite with auto-seeded default local node.
- **Added:** `NodeRegistry` service for managing multiple Docker daemon connections (local socket + TCP).
- **Added:** Node management API endpoints: list, get, create, update, delete, and test connection.
- **Added:** Settings Hub → Nodes tab with full CRUD UI, connection testing, and Docker info display.
+90 -65
View File
@@ -12,6 +12,7 @@
"@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6",
"@types/http-proxy": "^1.17.17",
"@types/ws": "^8.18.1",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
@@ -21,10 +22,10 @@
"cors": "^2.8.6",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"node-pty": "^1.1.0",
"ssh2": "^1.17.0",
"ssh2-sftp-client": "^12.1.0",
"systeminformation": "^5.31.1",
"ws": "^8.19.0",
"yaml": "^2.8.2"
@@ -33,10 +34,9 @@
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/ssh2": "^1.15.5",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/yaml": "^1.9.6",
"nodemon": "^3.1.13",
"ts-node": "^10.9.2",
@@ -350,6 +350,27 @@
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/http-proxy-middleware": {
"version": "0.19.3",
"resolved": "https://registry.npmjs.org/@types/http-proxy-middleware/-/http-proxy-middleware-0.19.3.tgz",
"integrity": "sha512-lnBTx6HCOUeIJMLbI/LaL5EmdKLhczJY5oeXZpX/cXE4rRqb3RmV7VcMpiEfYkmTjipv3h7IAyIINe4plEv7cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/http-proxy": "*",
"@types/node": "*"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
@@ -418,16 +439,6 @@
"@types/node": "^18.11.18"
}
},
"node_modules/@types/ssh2-sftp-client": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz",
"integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ssh2": "^1.0.0"
}
},
"node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
@@ -739,7 +750,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -778,12 +788,6 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
@@ -953,21 +957,6 @@
"yaml": "^2.x"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -1324,6 +1313,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1408,7 +1403,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -1689,6 +1683,37 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz",
"integrity": "sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.15",
"debug": "^4.3.6",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.3",
"is-plain-object": "^5.0.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1779,7 +1804,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -1798,7 +1822,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -1811,12 +1834,20 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1981,6 +2012,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2229,7 +2273,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -2424,6 +2467,12 @@
"node": ">=0.10.0"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
@@ -2682,23 +2731,6 @@
"nan": "^2.23.0"
}
},
"node_modules/ssh2-sftp-client": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.0.tgz",
"integrity": "sha512-f8+EylryHXPg+pMHYqtK+jPOPXJTBDOdgwOPYNS7mJTkk5bPsLQfOFUVl99PQvUkE5MkbyR5jThBlfeQMXcvrA==",
"license": "Apache-2.0",
"dependencies": {
"concat-stream": "^2.0.0",
"ssh2": "^1.16.0"
},
"engines": {
"node": ">=18.20.4"
},
"funding": {
"type": "individual",
"url": "https://square.link/u/4g7sPflL"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2823,7 +2855,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -2927,12 +2958,6 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+4 -4
View File
@@ -17,10 +17,9 @@
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/http-proxy-middleware": "^0.19.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",
"@types/ssh2": "^1.15.5",
"@types/ssh2-sftp-client": "^9.0.6",
"@types/yaml": "^1.9.6",
"nodemon": "^3.1.13",
"ts-node": "^10.9.2",
@@ -30,6 +29,7 @@
"@types/cors": "^2.8.19",
"@types/dockerode": "^4.0.1",
"@types/express": "^5.0.6",
"@types/http-proxy": "^1.17.17",
"@types/ws": "^8.18.1",
"axios": "^1.13.6",
"bcrypt": "^6.0.0",
@@ -39,10 +39,10 @@
"cors": "^2.8.6",
"dockerode": "^4.0.9",
"express": "^5.2.1",
"http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.3",
"node-pty": "^1.1.0",
"ssh2": "^1.17.0",
"ssh2-sftp-client": "^12.1.0",
"systeminformation": "^5.31.1",
"ws": "^8.19.0",
"yaml": "^2.8.2"
+99 -26
View File
@@ -12,6 +12,8 @@ import crypto from 'crypto';
import composerize from 'composerize';
import si from 'systeminformation';
import http from 'http';
import httpProxy from 'http-proxy';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
@@ -92,9 +94,21 @@ declare global {
}
}
// WebSocket proxy server for forwarding remote node WS connections
const wsProxyServer = httpProxy.createProxyServer({ changeOrigin: true });
wsProxyServer.on('error', (err, _req, socket: any) => {
console.error('[WS Proxy] Error:', err.message);
try { socket?.destroy(); } catch {}
});
// Authentication Middleware
// Accepts both cookie auth (browser sessions) and Bearer token auth (Sencho-to-Sencho proxy)
const authMiddleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const token = req.cookies[COOKIE_NAME];
const cookieToken = req.cookies[COOKIE_NAME];
const bearerToken = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
const token = cookieToken || bearerToken;
if (!token) {
res.status(401).json({ error: 'Authentication required' });
@@ -105,8 +119,9 @@ const authMiddleware = async (req: Request, res: Response, next: NextFunction):
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username: string };
req.user = { username: decoded.username };
const decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string };
// Accept both user sessions and node proxy tokens
req.user = { username: decoded.username || 'node-proxy' };
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
@@ -263,6 +278,23 @@ app.get('/api/auth/check', authMiddleware, (req: Request, res: Response): void =
res.json({ authenticated: true, user: req.user });
});
// Generate a long-lived node proxy token for Sencho-to-Sencho authentication
app.post('/api/auth/generate-node-token', authMiddleware, async (req: Request, res: Response): Promise<void> => {
try {
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) {
res.status(500).json({ error: 'No JWT secret configured on this instance.' });
return;
}
// No expiry — this token is managed by the admin who pastes it into the main dashboard
const token = jwt.sign({ scope: 'node_proxy' }, jwtSecret);
res.json({ token });
} catch (error: any) {
res.status(500).json({ error: error.message || 'Failed to generate node token' });
}
});
// Apply authentication middleware to all /api/* routes except /api/auth/*
app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/')) {
@@ -272,6 +304,48 @@ app.use('/api', (req: Request, res: Response, next: NextFunction): void => {
authMiddleware(req, res, next);
});
// Remote Node HTTP Proxy
// Intercepts all /api/ requests for remote Distributed API nodes and forwards them
// to the target Sencho instance. Node management and auth routes always execute locally.
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes')) {
next();
return;
}
const node = NodeRegistry.getInstance().getNode(req.nodeId);
if (!node || node.type !== 'remote') {
next();
return;
}
if (!node.api_url || !node.api_token) {
res.status(503).json({
error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.`
});
return;
}
createProxyMiddleware<Request, Response>({
target: node.api_url,
changeOrigin: true,
on: {
proxyReq: (proxyReq) => {
// Remote Sencho is always "local" to itself — strip node context
proxyReq.removeHeader('x-node-id');
proxyReq.setHeader('Authorization', `Bearer ${node.api_token!}`);
},
error: (_err, _req, proxyRes) => {
if (!(proxyRes as Response).headersSent) {
(proxyRes as Response).status(502).json({
error: `Remote node "${node.name}" is unreachable. Check the API URL and ensure Sencho is running on that host.`
});
}
},
},
})(req, res, next);
});
// Create HTTP server for WebSocket upgrade handling
const server = http.createServer(app);
@@ -302,11 +376,25 @@ server.on('upgrade', async (req, socket, head) => {
if (!jwtSecret) throw new Error('No JWT secret');
jwt.verify(token, jwtSecret);
// Check if this is a stack logs WebSocket request
const url = req.url || '';
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
// Resolve node context from query param
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(nodeId);
// Remote Node WebSocket Proxy — forward the entire WS connection to the remote Sencho instance
if (node && node.type === 'remote' && node.api_url && node.api_token) {
const wsTarget = node.api_url.replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
req.headers['authorization'] = `Bearer ${node.api_token}`;
delete req.headers['x-node-id'];
wsProxyServer.ws(req, socket, head, { target: wsTarget });
return;
}
// Local node handling
const logsMatch = pathname.match(/^\/api\/stacks\/([^/]+)\/logs$/);
const hostConsoleMatch = pathname.match(/^\/api\/system\/host-console/);
@@ -315,9 +403,6 @@ server.on('upgrade', async (req, socket, head) => {
const logsWss = new WebSocket.Server({ noServer: true });
logsWss.handleUpgrade(req, socket, head, (ws) => {
const stackName = decodeURIComponent(logsMatch[1]);
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
try {
ComposeService.getInstance(nodeId).streamLogs(stackName, ws);
} catch (error) {
@@ -332,17 +417,12 @@ server.on('upgrade', async (req, socket, head) => {
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
let targetDirectory = '';
try {
const reqUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
const nodeIdParam = reqUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
targetDirectory = FileSystemService.getInstance(nodeId).getBaseDir();
const stackParam = reqUrl.searchParams.get('stack');
const stackParam = parsedUrl.searchParams.get('stack');
if (stackParam) {
targetDirectory = path.join(targetDirectory, stackParam);
}
} catch (e) {
// ignore parsing error, fallback to base dir
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
}
try {
@@ -1432,7 +1512,7 @@ app.get('/api/nodes/:id', async (req: Request, res: Response) => {
// Create a new node
app.post('/api/nodes', async (req: Request, res: Response) => {
try {
const { name, type, host, port, ssh_port, compose_dir, is_default, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key } = req.body;
const { name, type, compose_dir, is_default, api_url, api_token } = req.body;
if (!name || typeof name !== 'string') {
return res.status(400).json({ error: 'Node name is required' });
@@ -1440,24 +1520,17 @@ app.post('/api/nodes', async (req: Request, res: Response) => {
if (!type || !['local', 'remote'].includes(type)) {
return res.status(400).json({ error: 'Node type must be "local" or "remote"' });
}
if (type === 'remote' && (!host || typeof host !== 'string')) {
return res.status(400).json({ error: 'Host is required for remote nodes' });
if (type === 'remote' && (!api_url || typeof api_url !== 'string')) {
return res.status(400).json({ error: 'API URL is required for remote nodes' });
}
const id = DatabaseService.getInstance().addNode({
name,
type,
host: host || '',
port: port || 2375,
ssh_port: ssh_port || 22,
compose_dir: compose_dir || '/opt/docker',
compose_dir: compose_dir || '/app/compose',
is_default: is_default || false,
ssh_user: ssh_user || '',
ssh_password: ssh_password || '',
ssh_key: ssh_key || '',
tls_ca: tls_ca || '',
tls_cert: tls_cert || '',
tls_key: tls_key || '',
api_url: api_url || '',
api_token: api_token || '',
});
res.json({ success: true, id });
+36 -150
View File
@@ -1,14 +1,16 @@
import { spawn, exec } from 'child_process';
import { promisify } from 'util';
import { spawn } from 'child_process';
import path from 'path';
import WebSocket from 'ws';
import DockerController from './DockerController';
import { LogFormatter } from './LogFormatter';
import { NodeRegistry } from './NodeRegistry';
import { Client as SSHClient } from 'ssh2';
const execAsync = promisify(exec);
/**
* ComposeService — local docker compose CLI execution.
*
* In the Distributed API model, remote node compose operations are handled
* by the remote Sencho instance. This service only executes commands locally.
*/
export class ComposeService {
private baseDir: string;
private nodeId: number;
@@ -22,30 +24,10 @@ export class ComposeService {
return new ComposeService(nodeId);
}
/**
* Universal command execution (Local or Remote SSH)
*/
private async executeCommand(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
const node = NodeRegistry.getInstance().getNode(this.nodeId);
if (!node) throw new Error(`Node ${this.nodeId} not found`);
if (node.type === 'local' || !node.host) {
return this.executeLocal(command, args, cwd, ws, throwOnError);
} else {
return this.executeRemote(node, command, args, cwd, ws, throwOnError);
}
}
private async executeLocal(
command: string,
args: string[],
cwd: string,
private execute(
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
@@ -90,65 +72,9 @@ export class ComposeService {
});
}
private async executeRemote(
node: any,
command: string,
args: string[],
cwd: string,
ws?: WebSocket,
throwOnError = true
): Promise<void> {
return new Promise((resolve, reject) => {
const conn = new SSHClient();
let errorLog = '';
conn.on('ready', () => {
const cmdString = `cd "${cwd}" && ${command} ${args.map(a => `"${a}"`).join(' ')}`;
conn.exec(cmdString, (err, stream) => {
if (err) {
conn.end();
if (throwOnError) reject(err); else resolve();
return;
}
const onData = (data: any) => {
const text = data.toString();
errorLog += text;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(text);
}
};
stream.on('data', onData).stderr.on('data', onData);
stream.on('close', (code: any, signal: any) => {
conn.end();
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(`Command exited with code ${code}\n`);
}
if (code === 0) resolve();
else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`));
else resolve();
});
});
}).on('error', (err) => {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(`SSH Error: ${err.message}\n`);
if (throwOnError) reject(err); else resolve();
}).connect({
host: node.host,
port: node.ssh_port || 22,
username: node.ssh_user!,
password: node.ssh_password,
privateKey: node.ssh_key,
readyTimeout: 10000,
});
});
}
async runCommand(stackName: string, action: 'down' | 'start' | 'stop' | 'restart', ws?: WebSocket): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
await this.executeCommand('docker', ['compose', action], stackDir, ws);
await this.execute('docker', ['compose', action], stackDir, ws);
}
async deployStack(stackName: string, ws?: WebSocket): Promise<void> {
@@ -165,7 +91,7 @@ export class ComposeService {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
}
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
// Post-Deploy Health Probe
await new Promise(resolve => setTimeout(resolve, 3000));
@@ -231,13 +157,10 @@ export class ComposeService {
let activeProcesses = 0;
let streamEndedHandled = false;
let clientConnections: any[] = [];
let localProcesses: ReturnType<typeof spawn>[] = [];
const onWsClose = () => {
localProcesses.forEach(cp => { try { cp.kill(); } catch {} });
clientConnections.forEach(conn => { try { conn.end(); } catch {} });
};
ws.on('close', onWsClose);
@@ -253,80 +176,43 @@ export class ComposeService {
}
};
const node = NodeRegistry.getInstance().getNode(this.nodeId);
const isRemote = (node && node.type === 'remote' && node.host);
for (const container of containersToLog) {
const containerName = container.Names?.[0]?.replace(/^\//, '') || container.Id;
activeProcesses++;
let lineBuffer = '';
const sendOutput = (data: Buffer | any) => {
const sendOutput = (data: Buffer) => {
if (ws.readyState === WebSocket.OPEN) {
lineBuffer += data.toString();
const lines = lineBuffer.split(/\r?\n/);
lineBuffer = lines.pop() || '';
for (const line of lines) {
const formattedLine = LogFormatter.process(line);
ws.send(formattedLine + '\r\n');
ws.send(LogFormatter.process(line) + '\r\n');
}
}
};
const flushBuffer = () => {
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
const formattedLine = LogFormatter.process(lineBuffer);
ws.send(formattedLine + '\r\n');
lineBuffer = '';
if (lineBuffer && ws.readyState === WebSocket.OPEN) {
ws.send(LogFormatter.process(lineBuffer) + '\r\n');
lineBuffer = '';
}
}
};
if (isRemote) {
const conn = new SSHClient();
clientConnections.push(conn);
conn.on('ready', () => {
conn.exec(`docker logs -f --tail 100 "${containerName}"`, {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
}, (err, stream) => {
if (err) {
conn.end();
handleProcessEnd();
return;
}
stream.on('data', sendOutput).stderr.on('data', sendOutput);
stream.on('close', () => {
flushBuffer();
conn.end();
handleProcessEnd();
});
});
}).on('error', handleProcessEnd).connect({
host: node!.host,
port: node!.ssh_port || 22,
username: node!.ssh_user!,
password: node!.ssh_password,
privateKey: node!.ssh_key,
readyTimeout: 10000,
});
} else {
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
flushBuffer();
handleProcessEnd();
});
}
const child = spawn('docker', ['logs', '-f', '--tail', '100', containerName], {
env: {
...process.env,
PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
localProcesses.push(child);
child.stdout.on('data', sendOutput);
child.stderr.on('data', sendOutput);
child.on('error', handleProcessEnd);
child.on('close', () => {
flushBuffer();
handleProcessEnd();
});
}
} catch (err) {
if (!isClosed && ws.readyState === WebSocket.OPEN) {
@@ -360,17 +246,17 @@ export class ComposeService {
}
sendOutput('=== Pulling latest images ===\n');
await this.executeCommand('docker', ['compose', 'pull'], stackDir, ws);
await this.execute('docker', ['compose', 'pull'], stackDir, ws);
sendOutput('=== Recreating containers ===\n');
await this.executeCommand('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
await this.execute('docker', ['compose', 'up', '-d', '--remove-orphans'], stackDir, ws);
sendOutput('=== Stack updated successfully ===\n');
}
public async downStack(stackName: string): Promise<void> {
const stackPath = path.join(this.baseDir, stackName);
try {
await this.executeCommand('docker', ['compose', 'down'], stackPath, undefined, false);
await this.execute('docker', ['compose', 'down'], stackPath, undefined, false);
} catch (error) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
}
+25 -47
View File
@@ -29,19 +29,12 @@ export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
host: string;
port: number;
ssh_port: number;
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
ssh_user?: string;
ssh_password?: string;
ssh_key?: string;
tls_ca?: string;
tls_cert?: string;
tls_key?: string;
api_url?: string;
api_token?: string;
}
export interface NotificationHistory {
@@ -59,14 +52,12 @@ export class DatabaseService {
private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
// Ensure data directory exists
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'sencho.db');
this.db = new Database(dbPath);
// Default journal mode is safer for arbitrary Docker volume mounts than WAL
this.initSchema();
this.migrateJsonConfig(dataDir);
@@ -126,7 +117,7 @@ export class DatabaseService {
net_tx_mb REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);
@@ -134,8 +125,6 @@ export class DatabaseService {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
host TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 2375,
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
@@ -147,6 +136,14 @@ export class DatabaseService {
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
};
// Distributed API model columns
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written)
maybeAddCol('nodes', 'host', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375');
maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22');
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
@@ -162,15 +159,15 @@ export class DatabaseService {
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5'); // Default 5 seconds
stmt.run('developer_mode', '0'); // Default off
stmt.run('global_logs_refresh', '5');
stmt.run('developer_mode', '0');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
if (nodeCount === 0) {
this.db.prepare(
'INSERT INTO nodes (name, type, host, port, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run('Local', 'local', '', 0, process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
}
}
@@ -188,8 +185,6 @@ export class DatabaseService {
stmt.run('auth_jwt_secret', config.jwtSecret);
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
// Delete the file after migrating
fs.unlinkSync(configPath);
}
} catch (err) {
@@ -295,9 +290,8 @@ export class DatabaseService {
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
stmt.run(notification.level, notification.message, notification.timestamp);
// Cleanup old notifications (keep last 100)
this.db.exec(`
DELETE FROM notification_history
DELETE FROM notification_history
WHERE id NOT IN (
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
)
@@ -343,7 +337,7 @@ export class DatabaseService {
// --- Nodes ---
public getNodes(): Node[] {
const stmt = this.db.prepare('SELECT * FROM nodes ORDER BY is_default DESC, name ASC');
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
return stmt.all().map((row: any) => ({
...row,
is_default: row.is_default === 1
@@ -351,43 +345,35 @@ export class DatabaseService {
}
public getNode(id: number): Node | undefined {
const stmt = this.db.prepare('SELECT * FROM nodes WHERE id = ?');
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
const row = stmt.get(id) as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public getDefaultNode(): Node | undefined {
const stmt = this.db.prepare('SELECT * FROM nodes WHERE is_default = 1 LIMIT 1');
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
const row = stmt.get() as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
// If this node is set as default, clear other defaults first
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, host, port, ssh_port, compose_dir, is_default, status, created_at, ssh_user, ssh_password, ssh_key, tls_ca, tls_cert, tls_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
node.type,
node.host,
node.port,
node.ssh_port || 22,
node.compose_dir,
node.compose_dir || '/app/compose',
node.is_default ? 1 : 0,
'unknown',
Date.now(),
node.ssh_user || '',
node.ssh_password || '',
node.ssh_key || '',
node.tls_ca || '',
node.tls_cert || '',
node.tls_key || ''
node.api_url || '',
node.api_token || ''
);
return result.lastInsertRowid as number;
}
@@ -396,7 +382,6 @@ export class DatabaseService {
const node = this.getNode(id);
if (!node) throw new Error(`Node with id ${id} not found`);
// If setting as default, clear other defaults first
if (updates.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
@@ -406,18 +391,11 @@ export class DatabaseService {
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.host !== undefined) { fields.push('host = ?'); values.push(updates.host); }
if (updates.port !== undefined) { fields.push('port = ?'); values.push(updates.port); }
if (updates.ssh_port !== undefined) { fields.push('ssh_port = ?'); values.push(updates.ssh_port); }
if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.ssh_user !== undefined) { fields.push('ssh_user = ?'); values.push(updates.ssh_user); }
if (updates.ssh_password !== undefined) { fields.push('ssh_password = ?'); values.push(updates.ssh_password); }
if (updates.ssh_key !== undefined) { fields.push('ssh_key = ?'); values.push(updates.ssh_key); }
if (updates.tls_ca !== undefined) { fields.push('tls_ca = ?'); values.push(updates.tls_ca); }
if (updates.tls_cert !== undefined) { fields.push('tls_cert = ?'); values.push(updates.tls_cert); }
if (updates.tls_key !== undefined) { fields.push('tls_key = ?'); values.push(updates.tls_key); }
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
if (fields.length === 0) return;
+45 -123
View File
@@ -1,81 +1,59 @@
import path from 'path';
import { IFileAdapter } from './fs/IFileAdapter';
import { LocalFileAdapter } from './fs/LocalFileAdapter';
import { SSHFileAdapter } from './fs/SSHFileAdapter';
import fs from 'fs';
import { promises as fsPromises } from 'fs';
import { NodeRegistry } from './NodeRegistry';
/**
* FileSystemService — local-only file I/O for compose stack management.
*
* In the Distributed API model, remote node file operations are handled
* by the remote Sencho instance itself. This service only operates on
* the local filesystem.
*/
export class FileSystemService {
private baseDir: string;
private adapter: IFileAdapter;
private nodeId: number;
constructor(nodeId?: number) {
this.nodeId = nodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(this.nodeId);
if (!node || node.type === 'local' || !node.host) {
this.baseDir = process.env.COMPOSE_DIR || '/app/compose';
this.adapter = new LocalFileAdapter();
} else {
this.baseDir = node.compose_dir;
if (!this.baseDir || typeof this.baseDir !== 'string' || this.baseDir.trim() === '') {
throw new Error(`Remote node "${node.name}" has no compose_dir configured. Please set a compose directory in the Node Manager.`);
}
this.adapter = new SSHFileAdapter(node);
}
this.baseDir = NodeRegistry.getInstance().getComposeDir(
nodeId ?? NodeRegistry.getInstance().getDefaultNodeId()
);
}
public static getInstance(nodeId?: number): FileSystemService {
return new FileSystemService(nodeId);
}
/**
* Check if a directory contains a valid compose file
*/
private async hasComposeFile(dir: string): Promise<boolean> {
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
try {
await this.adapter.access(path.join(dir, file));
await fsPromises.access(path.join(dir, file));
return true;
} catch {
// Continue checking other options
// continue
}
}
return false;
}
/**
* Get the path to the compose file for a stack
* Throws if no compose file is found
*/
private async getComposeFilePath(stackName: string): Promise<string> {
const stackDir = path.join(this.baseDir, stackName);
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
for (const file of composeFiles) {
const filePath = path.join(stackDir, file);
try {
await this.adapter.access(filePath);
await fsPromises.access(filePath);
return filePath;
} catch {
// Continue checking other options
// continue
}
}
throw new Error(`No compose file found for stack: ${stackName}`);
}
/**
* Get all stacks (directories containing compose files)
* Returns array of stack names (directory names)
*/
async getStacks(): Promise<string[]> {
try {
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
const stackNames: string[] = [];
for (const item of items) {
@@ -83,46 +61,33 @@ export class FileSystemService {
if (!item.name || typeof item.name !== 'string') continue;
const stackDir = path.join(this.baseDir, item.name);
const hasCompose = await this.hasComposeFile(stackDir);
if (hasCompose) {
if (await this.hasComposeFile(stackDir)) {
stackNames.push(item.name);
}
}
return stackNames;
} catch (error: any) {
const nodeName = NodeRegistry.getInstance().getNode(this.nodeId)?.name || 'Unknown';
console.warn(`[SFTP] Failed to fetch stacks for Node ${nodeName}: ${error.message || error}`);
console.warn(`[FileSystemService] Failed to list stacks: ${error.message}`);
return [];
}
}
/**
* Get the content of a stack's compose file
*/
async getStackContent(stackName: string): Promise<string> {
try {
const filePath = await this.getComposeFilePath(stackName);
return await this.adapter.readFile(filePath, 'utf-8');
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', error);
throw new Error(`Failed to read stack: ${stackName}`);
}
}
/**
* Save content to a stack's compose file
* Always writes to compose.yaml (standardizing on this filename)
*/
async saveStackContent(stackName: string, content: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
const filePath = path.join(stackDir, 'compose.yaml');
const filePath = path.join(this.baseDir, stackName, 'compose.yaml');
console.log('Saving to path:', filePath);
try {
await this.adapter.writeFile(filePath, content, 'utf-8');
await fsPromises.writeFile(filePath, content, 'utf-8');
console.log('File written successfully');
} catch (error) {
console.error('Error writing file:', error);
@@ -130,55 +95,42 @@ export class FileSystemService {
}
}
/**
* Check if a stack has an .env file
*/
async envExists(stackName: string): Promise<boolean> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
await this.adapter.access(envPath);
await fsPromises.access(path.join(this.baseDir, stackName, '.env'));
return true;
} catch {
return false;
}
}
// Proxy to adapter read/write operations for use in other services and generic routes
async readFile(filePath: string, encoding: BufferEncoding = 'utf-8'): Promise<string> {
return this.adapter.readFile(filePath, encoding);
return fsPromises.readFile(filePath, encoding);
}
async writeFile(filePath: string, content: string, encoding: BufferEncoding = 'utf-8'): Promise<void> {
return this.adapter.writeFile(filePath, content, encoding);
return fsPromises.writeFile(filePath, content, encoding);
}
async access(filePath: string): Promise<void> {
return this.adapter.access(filePath);
return fsPromises.access(filePath);
}
/**
* Get the content of a stack's .env file
*/
async getEnvContent(stackName: string): Promise<string> {
const envPath = path.join(this.baseDir, stackName, '.env');
try {
return await this.adapter.readFile(envPath, 'utf-8');
return await fsPromises.readFile(envPath, 'utf-8');
} catch (error) {
console.error('Error reading env file:', error);
throw new Error(`Failed to read env file for stack: ${stackName}`);
}
}
/**
* Save content to a stack's .env file
*/
async saveEnvContent(stackName: string, content: string): Promise<void> {
const envPath = path.join(this.baseDir, stackName, '.env');
console.log('Saving env to path:', envPath);
try {
await this.adapter.writeFile(envPath, content, 'utf-8');
await fsPromises.writeFile(envPath, content, 'utf-8');
console.log('Env file written successfully');
} catch (error) {
console.error('Error writing env file:', error);
@@ -186,33 +138,22 @@ export class FileSystemService {
}
}
/**
* Create a new stack (directory with boilerplate compose.yaml)
*/
async createStack(stackName: string): Promise<void> {
// Validate stack name (no special characters, not empty)
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
}
const stackDir = path.join(this.baseDir, stackName);
// Check if directory already exists
try {
await this.adapter.access(stackDir);
await fsPromises.access(stackDir);
throw new Error(`Stack "${stackName}" already exists`);
} catch (error: any) {
if (error.message.includes('already exists')) {
throw error;
}
// Directory doesn't exist, proceed
if (error.message.includes('already exists')) throw error;
}
// Create the directory
await this.adapter.mkdir(stackDir, { recursive: true });
await fsPromises.mkdir(stackDir, { recursive: true });
// Write boilerplate compose.yaml
const composePath = path.join(stackDir, 'compose.yaml');
const boilerplate = `services:
app:
image: nginx:latest
@@ -221,7 +162,7 @@ export class FileSystemService {
restart: always
`;
try {
await this.adapter.writeFile(composePath, boilerplate, 'utf-8');
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), boilerplate, 'utf-8');
console.log('Stack created successfully:', stackName);
} catch (error) {
console.error('Error creating stack:', error);
@@ -229,14 +170,10 @@ export class FileSystemService {
}
}
/**
* Delete a stack (entire directory and its contents)
*/
public async deleteStack(stackName: string): Promise<void> {
const stackDir = path.join(this.baseDir, stackName);
try {
await this.adapter.rm(stackDir, { recursive: true, force: true });
await fsPromises.rm(stackDir, { recursive: true, force: true });
console.log('Stack deleted successfully:', stackName);
} catch (error: any) {
if (error.code !== 'ENOENT') {
@@ -246,73 +183,58 @@ export class FileSystemService {
}
}
/**
* Get the base directory path for stacks
*/
getBaseDir(): string {
return this.baseDir;
}
/**
* Migrate existing flat-file stacks to directory-based structure
* This runs automatically on server startup
*/
async migrateFlatToDirectory(): Promise<void> {
try {
// Ensure base directory exists
try {
await this.adapter.access(this.baseDir);
await fsPromises.access(this.baseDir);
} catch {
console.log('Creating compose directory:', this.baseDir);
await this.adapter.mkdir(this.baseDir, { recursive: true });
return; // No files to migrate in a new directory
await fsPromises.mkdir(this.baseDir, { recursive: true });
return;
}
const items = await this.adapter.readdir(this.baseDir, { withFileTypes: true });
const items = await fsPromises.readdir(this.baseDir, { withFileTypes: true });
for (const item of items) {
// Only process .yml/.yaml files (skip directories and other files)
if (!item.isFile()) continue;
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
const stackDir = path.join(this.baseDir, stackName);
// Check if target directory already exists
try {
await this.adapter.access(stackDir);
await fsPromises.access(stackDir);
console.log(`Skipping migration for "${stackName}": directory already exists`);
continue;
} catch {
// Directory doesn't exist, proceed with migration
// Directory doesn't exist, proceed
}
console.log(`Migrating stack: ${stackName}`);
await fsPromises.mkdir(stackDir, { recursive: true });
// Create the stack directory
await this.adapter.mkdir(stackDir, { recursive: true });
// Move compose file to new location (standardize on compose.yaml)
const oldComposePath = path.join(this.baseDir, item.name);
const newComposePath = path.join(stackDir, 'compose.yaml');
await this.adapter.rename(oldComposePath, newComposePath);
await fsPromises.rename(oldComposePath, newComposePath);
// Move env file if it exists (old pattern: stackname.env)
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
const newEnvPath = path.join(stackDir, '.env');
try {
await this.adapter.access(oldEnvPath);
await this.adapter.rename(oldEnvPath, newEnvPath);
await fsPromises.access(oldEnvPath);
await fsPromises.rename(oldEnvPath, newEnvPath);
console.log(`Migrated env file for: ${stackName}`);
} catch {
// No env file to migrate, that's fine
// No env file to migrate
}
console.log(`Successfully migrated stack: ${stackName}`);
}
} catch (error) {
console.error('Migration error:', error);
// Don't throw - allow the server to start even if migration fails
}
}
}
}
+82 -42
View File
@@ -1,10 +1,14 @@
import Docker from 'dockerode';
import axios from 'axios';
import { DatabaseService, Node } from './DatabaseService';
/**
* NodeRegistry: Manages Docker daemon connections for multiple nodes.
* Replaces the old singleton DockerController pattern. Each node
* (local or remote) gets its own dedicated Docker client instance.
* NodeRegistry: Manages connections for multiple nodes.
*
* In the Distributed API model:
* - Local nodes: direct Docker socket connection via Dockerode (unchanged)
* - Remote nodes: HTTP/WS proxy to a remote Sencho instance (api_url + api_token)
* No direct Docker TCP connections are made for remote nodes.
*/
export class NodeRegistry {
private static instance: NodeRegistry;
@@ -20,11 +24,10 @@ export class NodeRegistry {
}
/**
* Get a Docker client for a specific node.
* Creates the connection lazily on first request and caches it.
* Get a Docker client for a LOCAL node only.
* Remote nodes are never accessed via Dockerode — use the HTTP proxy instead.
*/
public getDocker(nodeId: number): Docker {
// Return cached connection if available
if (this.connections.has(nodeId)) {
return this.connections.get(nodeId)!;
}
@@ -36,21 +39,27 @@ export class NodeRegistry {
throw new Error(`Node with id ${nodeId} not found`);
}
const docker = this.createDockerClient(node);
if (node.type === 'remote') {
throw new Error(
`Node "${node.name}" is a remote Distributed API node. ` +
`Its Docker daemon is not directly accessible — all requests are proxied via HTTP.`
);
}
const docker = new Docker();
this.connections.set(nodeId, docker);
return docker;
}
/**
* Get the Docker client for the default node.
* This is the backward-compatible path for all existing code.
* Backward-compatible path for local-node code.
*/
public getDefaultDocker(): Docker {
const db = DatabaseService.getInstance();
const defaultNode = db.getDefaultNode();
if (!defaultNode || !defaultNode.id) {
// Absolute fallback: local socket (preserves legacy behavior)
return new Docker();
}
@@ -58,7 +67,7 @@ export class NodeRegistry {
}
/**
* Get the default node ID. Returns the ID of the node marked as default.
* Get the default node ID.
*/
public getDefaultNodeId(): number {
const db = DatabaseService.getInstance();
@@ -75,39 +84,21 @@ export class NodeRegistry {
}
/**
* Create a Docker client based on node configuration.
* - Local nodes: use the default socket (Docker autodetects)
* - Remote nodes: connect via TCP to host:port
* Get the HTTP proxy target for a remote node.
* Returns { apiUrl, apiToken } for use by the HTTP proxy middleware.
*/
private createDockerClient(node: Node): Docker {
if (node.type === 'local') {
// Local node: use the default Docker socket
return new Docker();
public getProxyTarget(nodeId: number): { apiUrl: string; apiToken: string } | null {
const node = DatabaseService.getInstance().getNode(nodeId);
if (!node || node.type !== 'remote' || !node.api_url || !node.api_token) {
return null;
}
// Remote node: connect via Docker TCP API
if (!node.host) {
throw new Error(`Remote node "${node.name}" is missing a host address`);
}
const dockerOptions: Docker.DockerOptions = {
host: node.host,
port: node.port || 2375,
};
// Phase 55.4 — TLS: if all three certs are present, enable secure connection
if (node.tls_ca && node.tls_cert && node.tls_key) {
dockerOptions.ca = node.tls_ca;
dockerOptions.cert = node.tls_cert;
dockerOptions.key = node.tls_key;
}
return new Docker(dockerOptions);
return { apiUrl: node.api_url, apiToken: node.api_token };
}
/**
* Test connectivity to a specific node.
* Returns true if we can ping the Docker daemon.
* - Local: pings the Docker daemon directly
* - Remote: makes a GET to /api/auth/check on the remote Sencho instance
*/
public async testConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
@@ -117,13 +108,21 @@ export class NodeRegistry {
return { success: false, error: 'Node not found' };
}
if (node.type === 'remote') {
return this.testRemoteConnection(node);
}
return this.testLocalConnection(nodeId);
}
private async testLocalConnection(nodeId: number): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
try {
const docker = this.createDockerClient(node);
const docker = new Docker();
const info = await docker.info();
// Validate the payload contains actual Docker daemon info, not arbitrary HTML
if (!info || !info.OperatingSystem || typeof info.Containers !== 'number') {
throw new Error("Invalid response from Docker API. Did you provide a web port instead of the Docker daemon port?");
throw new Error('Invalid response from Docker daemon.');
}
db.updateNodeStatus(nodeId, 'online');
@@ -147,8 +146,49 @@ export class NodeRegistry {
}
}
private async testRemoteConnection(node: Node): Promise<{ success: boolean; error?: string; info?: any }> {
const db = DatabaseService.getInstance();
if (!node.api_url || !node.api_token) {
return { success: false, error: 'Remote node is missing an API URL or token. Configure it in Settings → Nodes.' };
}
try {
const response = await axios.get(`${node.api_url}/api/auth/check`, {
headers: { Authorization: `Bearer ${node.api_token}` },
timeout: 8000,
});
if (response.status === 200) {
db.updateNodeStatus(node.id, 'online');
return {
success: true,
info: {
name: node.name,
serverVersion: 'Remote Sencho',
os: 'Remote',
architecture: 'Remote',
containers: '—',
containersRunning: '—',
images: '—',
memTotal: 0,
cpus: '—',
}
};
}
throw new Error(`Unexpected status ${response.status}`);
} catch (error: any) {
db.updateNodeStatus(node.id, 'offline');
const msg = error.response?.status === 401
? 'Authentication failed — check the API token.'
: (error.message || 'Connection failed');
return { success: false, error: msg };
}
}
/**
* Evict a cached connection (e.g., after node config changes).
* Evict a cached Docker connection (e.g., after node config change).
*/
public evictConnection(nodeId: number): void {
this.connections.delete(nodeId);
@@ -162,7 +202,7 @@ export class NodeRegistry {
}
/**
* Get the compose directory for a specific node.
* Get the compose directory for a local node.
*/
public getComposeDir(nodeId: number): string {
const db = DatabaseService.getInstance();
-9
View File
@@ -1,9 +0,0 @@
export interface IFileAdapter {
access(filePath: string): Promise<void>;
readdir(dirPath: string, options?: any): Promise<any[]>;
readFile(filePath: string, encoding: string): Promise<string>;
writeFile(filePath: string, content: string, encoding: string): Promise<void>;
mkdir(dirPath: string, options?: any): Promise<void>;
rm(targetPath: string, options?: any): Promise<void>;
rename(oldPath: string, newPath: string): Promise<void>;
}
@@ -1,27 +0,0 @@
import { promises as fs } from 'fs';
import { IFileAdapter } from './IFileAdapter';
export class LocalFileAdapter implements IFileAdapter {
async access(filePath: string): Promise<void> {
return fs.access(filePath);
}
async readdir(dirPath: string, options?: any): Promise<any[]> {
return fs.readdir(dirPath, options);
}
async readFile(filePath: string, encoding: any): Promise<string> {
const raw = await fs.readFile(filePath, encoding);
return typeof raw === 'string' ? raw : raw.toString(encoding || 'utf-8');
}
async writeFile(filePath: string, content: string, encoding: any): Promise<void> {
await fs.writeFile(filePath, content, encoding);
}
async mkdir(dirPath: string, options?: any): Promise<void> {
await fs.mkdir(dirPath, options);
}
async rm(targetPath: string, options?: any): Promise<void> {
return fs.rm(targetPath, options);
}
async rename(oldPath: string, newPath: string): Promise<void> {
return fs.rename(oldPath, newPath);
}
}
-125
View File
@@ -1,125 +0,0 @@
import Client from 'ssh2-sftp-client';
import { IFileAdapter } from './IFileAdapter';
import { Node } from '../DatabaseService';
export class SSHFileAdapter implements IFileAdapter {
private node: Node;
constructor(node: Node) {
this.node = node;
}
private async getClient() {
const sftp = new Client();
await sftp.connect({
host: this.node.host,
port: this.node.ssh_port || 22,
username: this.node.ssh_user!,
password: this.node.ssh_password,
privateKey: this.node.ssh_key,
readyTimeout: 10000,
});
return sftp;
}
async access(filePath: string): Promise<void> {
const sftp = await this.getClient();
try {
const exists = await sftp.exists(filePath);
if (!exists) throw Object.assign(new Error(), { code: 'ENOENT' });
} finally {
await sftp.end();
}
}
async readdir(dirPath: string, options?: any): Promise<any[]> {
const sftp = await this.getClient();
try {
const list = await sftp.list(dirPath);
// Guard: filter out any entries with missing or non-string names to prevent
// downstream path.join crashes (TypeError on undefined.split)
const valid = list.filter((item: any) => item.name && typeof item.name === 'string');
if (options?.withFileTypes) {
return valid.map((item: any) => ({
name: item.name,
isDirectory: () => item.type === 'd',
isFile: () => item.type === '-',
}));
}
return valid.map((item: any) => item.name);
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
throw err;
} finally {
await sftp.end();
}
}
async readFile(filePath: string, encoding: any): Promise<string> {
const sftp = await this.getClient();
try {
const buffer = await sftp.get(filePath);
if (Buffer.isBuffer(buffer)) {
return buffer.toString(encoding as BufferEncoding);
}
return buffer as unknown as string;
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) throw Object.assign(new Error(), { code: 'ENOENT' });
throw err;
} finally {
await sftp.end();
}
}
async writeFile(filePath: string, content: string, encoding: any): Promise<void> {
const sftp = await this.getClient();
try {
await sftp.put(Buffer.from(content, encoding as BufferEncoding), filePath);
} finally {
await sftp.end();
}
}
async mkdir(dirPath: string, options?: any): Promise<void> {
const sftp = await this.getClient();
try {
const exists = await sftp.exists(dirPath);
if (!exists) {
await sftp.mkdir(dirPath, options?.recursive);
}
} finally {
await sftp.end();
}
}
async rm(targetPath: string, options?: any): Promise<void> {
const sftp = await this.getClient();
try {
const type = await sftp.exists(targetPath);
if (type === 'd') {
await sftp.rmdir(targetPath, options?.recursive);
} else if (type === '-') {
await sftp.delete(targetPath);
} else if (!options?.force) {
throw Object.assign(new Error(), { code: 'ENOENT' });
}
} catch(err: any) {
if(err.code === 2 || err.message.includes('No such file')) {
if (options?.force) return;
throw Object.assign(new Error(), { code: 'ENOENT' });
}
throw err;
} finally {
await sftp.end();
}
}
async rename(oldPath: string, newPath: string): Promise<void> {
const sftp = await this.getClient();
try {
await sftp.rename(oldPath, newPath);
} finally {
await sftp.end();
}
}
}
+113 -263
View File
@@ -9,46 +9,27 @@ import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
import { Separator } from './ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, ShieldCheck } from 'lucide-react';
import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check } from 'lucide-react';
interface NodeFormData {
name: string;
type: 'local' | 'remote';
host: string;
port: number;
ssh_port: number;
api_url: string;
api_token: string;
compose_dir: string;
is_default: boolean;
ssh_user: string;
ssh_auth_type: 'password' | 'key';
ssh_password: string;
ssh_key: string;
tls_enabled: boolean;
tls_ca: string;
tls_cert: string;
tls_key: string;
}
const defaultFormData: NodeFormData = {
name: '',
type: 'remote',
host: '',
port: 2375,
ssh_port: 22,
compose_dir: '/opt/docker',
api_url: '',
api_token: '',
compose_dir: '/app/compose',
is_default: false,
ssh_user: '',
ssh_auth_type: 'password',
ssh_password: '',
ssh_key: '',
tls_enabled: false,
tls_ca: '',
tls_cert: '',
tls_key: '',
};
export function NodeManager() {
@@ -62,22 +43,16 @@ export function NodeManager() {
const [testing, setTesting] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ nodeId: number; info: any } | null>(null);
// Node token generation state
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
const [generatingToken, setGeneratingToken] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
const handleCreate = async () => {
try {
const payload = {
...formData,
// Only pass TLS fields if TLS is enabled
tls_ca: formData.tls_enabled ? formData.tls_ca : '',
tls_cert: formData.tls_enabled ? formData.tls_cert : '',
tls_key: formData.tls_enabled ? formData.tls_key : '',
// Only pass the relevant SSH credential
ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '',
ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '',
};
const res = await apiFetch('/nodes', {
method: 'POST',
body: JSON.stringify(payload),
body: JSON.stringify(formData),
});
if (!res.ok) {
const err = await res.json();
@@ -88,8 +63,8 @@ export function NodeManager() {
setCreateOpen(false);
setFormData(defaultFormData);
// Auto-test the new node connection immediately after creation
if (newNodeId) {
// Auto-test the new node connection immediately
if (newNodeId && formData.type === 'remote') {
setTesting(newNodeId);
try {
const testRes = await apiFetch(`/nodes/${newNodeId}/test`, { method: 'POST' });
@@ -101,7 +76,7 @@ export function NodeManager() {
toast.warning(`Node saved, but connection test failed: ${testData.error}`);
}
} catch {
// Non-fatal — node was created, test just didn't succeed
// Non-fatal
} finally {
setTesting(null);
}
@@ -116,18 +91,9 @@ export function NodeManager() {
const handleEdit = async () => {
if (!editingNodeId) return;
try {
const payload = {
...formData,
tls_ca: formData.tls_enabled ? formData.tls_ca : '',
tls_cert: formData.tls_enabled ? formData.tls_cert : '',
tls_key: formData.tls_enabled ? formData.tls_key : '',
ssh_password: formData.ssh_auth_type === 'password' ? formData.ssh_password : '',
ssh_key: formData.ssh_auth_type === 'key' ? formData.ssh_key : '',
};
const res = await apiFetch(`/nodes/${editingNodeId}`, {
method: 'PUT',
body: JSON.stringify(payload),
body: JSON.stringify(formData),
});
if (!res.ok) {
const err = await res.json();
@@ -144,23 +110,13 @@ export function NodeManager() {
};
const openEditDialog = (node: Node) => {
const hasTls = !!(node.tls_ca && node.tls_cert && node.tls_key);
setFormData({
name: node.name,
type: node.type,
host: node.host,
port: node.port,
ssh_port: node.ssh_port || 22,
api_url: node.api_url || '',
api_token: node.api_token || '',
compose_dir: node.compose_dir,
is_default: node.is_default,
ssh_user: node.ssh_user || '',
ssh_auth_type: node.ssh_key ? 'key' : 'password',
ssh_password: node.ssh_password || '',
ssh_key: node.ssh_key || '',
tls_enabled: hasTls,
tls_ca: node.tls_ca || '',
tls_cert: node.tls_cert || '',
tls_key: node.tls_key || '',
});
setEditingNodeId(node.id);
setEditOpen(true);
@@ -203,6 +159,30 @@ export function NodeManager() {
}
};
const generateNodeToken = async () => {
setGeneratingToken(true);
setGeneratedToken(null);
try {
const res = await apiFetch('/auth/generate-node-token', { method: 'POST' });
if (!res.ok) throw new Error('Failed to generate token');
const { token } = await res.json();
setGeneratedToken(token);
toast.success('Node token generated');
} catch (error: any) {
toast.error(error.message || 'Failed to generate token');
} finally {
setGeneratingToken(false);
}
};
const copyToken = async () => {
if (!generatedToken) return;
await navigator.clipboard.writeText(generatedToken);
setTokenCopied(true);
toast.success('Token copied to clipboard');
setTimeout(() => setTokenCopied(false), 2000);
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'online':
@@ -221,7 +201,7 @@ export function NodeManager() {
};
const renderFormFields = () => (
<div className="space-y-4 py-4 max-h-[65vh] overflow-y-auto pr-1">
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="node-name">Name</Label>
<Input
@@ -232,198 +212,33 @@ export function NodeManager() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-type">Type</Label>
<Select value={formData.type} onValueChange={(v) => setFormData({ ...formData, type: v as 'local' | 'remote' })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local (Docker Socket)</SelectItem>
<SelectItem value="remote">Remote (TCP)</SelectItem>
</SelectContent>
</Select>
</div>
{formData.type === 'remote' && (
<>
<div className="rounded-md border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/30 p-3 space-y-1.5">
<p className="text-xs font-semibold text-amber-800 dark:text-amber-400">How to expose Docker via TCP on the remote machine</p>
<p className="text-xs text-amber-700 dark:text-amber-500">
Edit <code className="font-mono bg-amber-100 dark:bg-amber-900 px-1 rounded">/lib/systemd/system/docker.service</code> and update the ExecStart line:
</p>
<code className="block text-xs font-mono bg-amber-100 dark:bg-amber-900 text-amber-900 dark:text-amber-300 px-2 py-1.5 rounded break-all">
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://0.0.0.0:2375 --containerd=/run/containerd/containerd.sock
</code>
<p className="text-xs text-amber-700 dark:text-amber-500">
Then restart: <code className="font-mono bg-amber-100 dark:bg-amber-900 px-1 rounded">systemctl daemon-reload && systemctl restart docker</code>
<div className="space-y-2">
<Label htmlFor="node-api-url">Sencho API URL</Label>
<Input
id="node-api-url"
placeholder="http://192.168.1.50:3000"
value={formData.api_url}
onChange={(e) => setFormData({ ...formData, api_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
The base URL of the Sencho instance running on the remote machine.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="node-host">Host</Label>
<Label htmlFor="node-api-token">API Token</Label>
<Input
id="node-host"
placeholder="e.g., 192.168.1.50 or vps.example.com"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
id="node-api-token"
type="password"
placeholder="Paste token from remote Sencho → Settings → Nodes → Generate Token"
value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="node-port">Docker API Port</Label>
<Input
id="node-port"
type="number"
placeholder="2375"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 2375 })}
/>
<p className="text-xs text-muted-foreground">
Default: 2375 (unencrypted) or 2376 (TLS)
</p>
</div>
<div className="space-y-2">
<Label htmlFor="node-ssh-port">SSH Port</Label>
<Input
id="node-ssh-port"
type="number"
placeholder="22"
value={formData.ssh_port}
onChange={(e) => setFormData({ ...formData, ssh_port: parseInt(e.target.value) || 22 })}
/>
<p className="text-xs text-muted-foreground">
Default: 22. Used for file operations (SFTP).
</p>
</div>
</div>
<Separator />
{/* SSH Credentials Section */}
<div className="space-y-3">
<p className="text-sm font-medium">SSH Credentials <span className="text-muted-foreground font-normal">(for file operations via SFTP)</span></p>
<div className="space-y-2">
<Label htmlFor="node-ssh-user">SSH Username</Label>
<Input
id="node-ssh-user"
placeholder="e.g., root"
value={formData.ssh_user}
onChange={(e) => setFormData({ ...formData, ssh_user: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Authentication Type</Label>
<Select
value={formData.ssh_auth_type}
onValueChange={(v) => setFormData({ ...formData, ssh_auth_type: v as 'password' | 'key' })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="password">Password</SelectItem>
<SelectItem value="key">Private Key</SelectItem>
</SelectContent>
</Select>
</div>
{formData.ssh_auth_type === 'password' ? (
<div className="space-y-2">
<Label htmlFor="node-ssh-password">SSH Password</Label>
<Input
id="node-ssh-password"
type="password"
placeholder="Enter SSH password"
value={formData.ssh_password}
onChange={(e) => setFormData({ ...formData, ssh_password: e.target.value })}
/>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="node-ssh-key">SSH Private Key</Label>
<textarea
id="node-ssh-key"
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
value={formData.ssh_key}
onChange={(e) => setFormData({ ...formData, ssh_key: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Paste the full private key contents (PEM format).
</p>
</div>
)}
</div>
<Separator />
{/* TLS Security Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium flex items-center gap-1.5">
<ShieldCheck className="w-4 h-4 text-blue-500" />
Enable TLS
</p>
<p className="text-xs text-muted-foreground mt-0.5">
Secure the Docker TCP connection with mutual TLS (port 2376).
</p>
</div>
<button
type="button"
role="switch"
aria-checked={formData.tls_enabled}
onClick={() => setFormData({ ...formData, tls_enabled: !formData.tls_enabled })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
formData.tls_enabled ? 'bg-blue-600' : 'bg-input'
}`}
>
<span
className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${
formData.tls_enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{formData.tls_enabled && (
<div className="space-y-3 rounded-md border p-3">
<div className="space-y-2">
<Label htmlFor="node-tls-ca">CA Certificate</Label>
<textarea
id="node-tls-ca"
className="flex min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono"
placeholder="-----BEGIN CERTIFICATE-----"
value={formData.tls_ca}
onChange={(e) => setFormData({ ...formData, tls_ca: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-tls-cert">Client Certificate</Label>
<textarea
id="node-tls-cert"
className="flex min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono"
placeholder="-----BEGIN CERTIFICATE-----"
value={formData.tls_cert}
onChange={(e) => setFormData({ ...formData, tls_cert: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="node-tls-key">Client Private Key</Label>
<textarea
id="node-tls-key"
className="flex min-h-[90px] w-full rounded-md border border-input bg-background px-3 py-2 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 font-mono"
placeholder="-----BEGIN RSA PRIVATE KEY-----"
value={formData.tls_key}
onChange={(e) => setFormData({ ...formData, tls_key: e.target.value })}
/>
</div>
</div>
)}
<p className="text-xs text-muted-foreground">
Generate this token on the <strong>remote</strong> Sencho instance using the "Generate Node Token" button in its Settings Nodes panel.
</p>
</div>
</>
)}
@@ -432,12 +247,12 @@ export function NodeManager() {
<Label htmlFor="node-compose-dir">Compose Directory</Label>
<Input
id="node-compose-dir"
placeholder="/opt/docker"
placeholder="/app/compose"
value={formData.compose_dir}
onChange={(e) => setFormData({ ...formData, compose_dir: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
The root directory where compose stack folders live on this node. SSH credentials above are used to read and write compose files via SFTP.
The root directory where compose stack folders live on this node.
</p>
</div>
</div>
@@ -445,7 +260,7 @@ export function NodeManager() {
return (
<div className="space-y-6">
{/* Header — pr-8 ensures the Add Node button clears any parent dialog's X close icon */}
{/* Header */}
<div className="flex items-center justify-between pr-8">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
@@ -453,7 +268,7 @@ export function NodeManager() {
Nodes
</h2>
<p className="text-sm text-muted-foreground">
Manage Docker daemon connections across local and remote hosts
Manage connections to local and remote Sencho instances
</p>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
@@ -463,14 +278,17 @@ export function NodeManager() {
Add Node
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl">
<DialogContent className="max-w-lg">
<DialogHeader className="pr-8">
<DialogTitle>Add Remote Node</DialogTitle>
</DialogHeader>
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={!formData.name || (formData.type === 'remote' && !formData.host)}>
<Button
onClick={handleCreate}
disabled={!formData.name || (formData.type === 'remote' && (!formData.api_url || !formData.api_token))}
>
Add Node
</Button>
</DialogFooter>
@@ -480,6 +298,39 @@ export function NodeManager() {
<Separator />
{/* Generate Node Token — for use on THIS instance as a remote target */}
<div className="rounded-md border p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium flex items-center gap-2">
<KeyRound className="w-4 h-4 text-blue-500" />
Generate Node Token
</p>
<p className="text-xs text-muted-foreground mt-1">
Create a long-lived token that allows another Sencho instance to use <strong>this</strong> instance as a remote node. Copy it and paste it into the main dashboard's "Add Node" form.
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={generateNodeToken}
disabled={generatingToken}
className="shrink-0"
>
{generatingToken ? 'Generating...' : 'Generate Token'}
</Button>
</div>
{generatedToken && (
<div className="flex items-center gap-2 rounded-md bg-muted p-2">
<code className="flex-1 text-xs font-mono truncate text-muted-foreground">{generatedToken}</code>
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={copyToken}>
{tokenCopied ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
</Button>
</div>
)}
</div>
{/* Nodes Table */}
<div className="rounded-md border overflow-x-auto w-full">
<Table>
@@ -489,7 +340,6 @@ export function NodeManager() {
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Compose Dir</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
@@ -519,10 +369,7 @@ export function NodeManager() {
<Badge variant="outline">{node.type === 'local' ? 'Local' : 'Remote'}</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-sm font-mono">
{node.type === 'local' ? 'docker.sock' : `${node.host}:${node.port}`}
</TableCell>
<TableCell className="text-muted-foreground text-sm font-mono">
{node.compose_dir}
{node.type === 'local' ? 'docker.sock' : (node.api_url || '')}
</TableCell>
<TableCell>{getStatusBadge(node.status)}</TableCell>
<TableCell className="text-right">
@@ -593,10 +440,10 @@ export function NodeManager() {
Connection Details — {nodes.find(n => n.id === testResult.nodeId)?.name}
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div><span className="text-muted-foreground">Docker:</span> v{testResult.info.serverVersion}</div>
<div><span className="text-muted-foreground">Instance:</span> {testResult.info.serverVersion}</div>
<div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div>
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div>
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers} ({testResult.info.containersRunning} running)</div>
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers}</div>
<div><span className="text-muted-foreground">Images:</span> {testResult.info.images}</div>
<div><span className="text-muted-foreground">CPUs:</span> {testResult.info.cpus}</div>
</div>
@@ -605,14 +452,17 @@ export function NodeManager() {
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-4xl">
<DialogContent className="max-w-lg">
<DialogHeader className="pr-8">
<DialogTitle>Edit Node</DialogTitle>
</DialogHeader>
{renderFormFields()}
<DialogFooter>
<Button variant="outline" onClick={() => { setEditOpen(false); setEditingNodeId(null); }}>Cancel</Button>
<Button onClick={handleEdit} disabled={!formData.name || (formData.type === 'remote' && !formData.host)}>
<Button
onClick={handleEdit}
disabled={!formData.name || (formData.type === 'remote' && !formData.api_url)}
>
Save Changes
</Button>
</DialogFooter>
@@ -625,7 +475,7 @@ export function NodeManager() {
<AlertDialogHeader>
<AlertDialogTitle>Delete Node</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove <strong>{deletingNode?.name}</strong>? This will only remove the node from Sencho it will not affect the remote Docker daemon or any running containers.
Are you sure you want to remove <strong>{deletingNode?.name}</strong>? This will only remove the node from Sencho it will not affect the remote instance or any running containers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
+2 -12
View File
@@ -5,19 +5,12 @@ export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
host: string;
port: number;
ssh_port: number;
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
ssh_user?: string;
ssh_password?: string;
ssh_key?: string;
tls_ca?: string;
tls_cert?: string;
tls_key?: string;
api_url?: string;
api_token?: string;
}
interface NodeContextType {
@@ -42,7 +35,6 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
const data = await res.json();
setNodes(data);
// If no active node is set, select the default node
if (!activeNode) {
const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) {
@@ -51,12 +43,10 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
setActiveNodeState(data[0]);
}
} else {
// Refresh the active node's data in case its status changed
const updatedActive = data.find((n: Node) => n.id === activeNode.id);
if (updatedActive) {
setActiveNodeState(updatedActive);
} else {
// The active node was deleted! Fallback to default
const defaultNode = data.find((n: Node) => n.is_default);
if (defaultNode) {
setActiveNodeState(defaultNode);