mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
Merge pull request #28 from AnsoCode/feature/distributed-api-pivot
refactor: Distributed API Pivot
This commit is contained in:
@@ -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
@@ -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.
|
||||
|
||||
Generated
+90
-65
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user