feat: live Docker data — containers, services, logs, metrics via dockerode
All checks were successful
continuous-integration/drone/push Build is passing

- Backend: rewrote containers/services/logs/metrics routes to use dockerode
- docker-compose: mount /var/run/docker.sock, run backend as root
- .drone.yml: sync docker-compose.yml from Gitea on deploy
- Frontend: Containers page shows real data with wired start/stop/restart
- Frontend: Services page shows Docker Compose stacks with health status
- Frontend: Metrics page adds disk (docker df) and containers cards + chart legend
- Frontend: Logs page replaces text input with container dropdown + auto-refresh
This commit is contained in:
Ernie Butcher
2026-03-18 18:33:25 -04:00
parent 36c76edb29
commit 4233734759
20 changed files with 1146 additions and 172 deletions

View File

@@ -11,9 +11,11 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dockerode": "^3.3.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"helmet": "^7.1.0",
"jose": "^5.9.6",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"pg": "^8.11.3",
@@ -22,6 +24,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/dockerode": "^3.3.9",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",
@@ -34,6 +37,12 @@
"typescript": "^5.3.3"
}
},
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
@@ -701,6 +710,29 @@
"@types/node": "*"
}
},
"node_modules/@types/docker-modem": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/docker-modem/-/docker-modem-3.0.6.tgz",
"integrity": "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/ssh2": "*"
}
},
"node_modules/@types/dockerode": {
"version": "3.3.47",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.47.tgz",
"integrity": "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/docker-modem": "*",
"@types/node": "*",
"@types/ssh2": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.25",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
@@ -838,6 +870,33 @@
"@types/node": "*"
}
},
"node_modules/@types/ssh2": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz",
"integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^18.11.18"
}
},
"node_modules/@types/ssh2/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ssh2/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
@@ -1180,6 +1239,15 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1187,6 +1255,26 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/basic-auth": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
@@ -1205,6 +1293,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
@@ -1214,6 +1311,17 @@
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -1276,12 +1384,45 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1347,6 +1488,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1427,6 +1574,20 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1446,7 +1607,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1486,6 +1646,35 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/docker-modem": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-3.0.8.tgz",
"integrity": "sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.1",
"readable-stream": "^3.5.0",
"split-ca": "^1.0.1",
"ssh2": "^1.11.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dockerode": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-3.3.5.tgz",
"integrity": "sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA==",
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"docker-modem": "^3.0.0",
"tar-fs": "~2.0.1"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/doctrine": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -1549,6 +1738,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -2037,6 +2235,12 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@@ -2287,6 +2491,26 @@
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2391,6 +2615,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -2642,6 +2875,12 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
@@ -2691,6 +2930,13 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.26.2",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.26.2.tgz",
"integrity": "sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==",
"license": "MIT",
"optional": true
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -2753,7 +2999,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@@ -3031,6 +3276,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3101,6 +3356,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3366,6 +3635,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/split-ca": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
"license": "ISC"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -3375,6 +3650,23 @@
"node": ">= 10.x"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -3384,6 +3676,15 @@
"node": ">= 0.8"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -3423,6 +3724,34 @@
"node": ">=8"
}
},
"node_modules/tar-fs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz",
"integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.0.0"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -3489,6 +3818,12 @@
"fsevents": "~2.3.3"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3568,6 +3903,12 @@
"punycode": "^2.1.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -3616,7 +3957,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/xtend": {

View File

@@ -19,9 +19,11 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"dockerode": "^3.3.5",
"dotenv": "^16.4.5",
"express": "^4.18.2",
"helmet": "^7.1.0",
"jose": "^5.9.6",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"pg": "^8.11.3",
@@ -30,6 +32,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/dockerode": "^3.3.9",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/morgan": "^1.9.9",

View File

@@ -0,0 +1,56 @@
import { Request, Response, NextFunction } from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = process.env.OIDC_ISSUER || 'https://auth.jiosii.com/oidc';
const JWKS_URL = `${ISSUER}/jwks`;
const AUDIENCE = 'server-manager';
// Cache the JWKS remote keyset (jose handles internal caching with TTL)
const jwks = createRemoteJWKSet(new URL(JWKS_URL), {
cacheMaxAge: 5 * 60 * 1000, // 5 minutes
});
export interface AuthUser {
sub: string;
email?: string;
name?: string;
preferred_username?: string;
role?: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Unauthorized', message: 'Bearer token required' });
return;
}
const token = authHeader.slice(7);
try {
const { payload } = await jwtVerify(token, jwks, {
issuer: ISSUER,
audience: AUDIENCE,
});
req.user = {
sub: payload.sub as string,
email: payload.email as string | undefined,
name: payload.name as string | undefined,
preferred_username: payload.preferred_username as string | undefined,
role: payload.role as string | undefined,
};
next();
} catch (err) {
res.status(401).json({ error: 'Unauthorized', message: 'Invalid or expired token' });
}
}

View File

@@ -0,0 +1,93 @@
import { Router, Request, Response } from 'express';
const router = Router();
const ISSUER = process.env.OIDC_ISSUER || 'https://auth.jiosii.com/oidc';
const CLIENT_ID = 'server-manager';
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET || '';
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || 'https://sm.jiosii.com/callback';
// POST /api/auth/exchange
// Exchanges an authorization code + PKCE verifier for tokens.
// Keeps client_secret server-side for security.
router.post('/exchange', async (req: Request, res: Response) => {
const { code, code_verifier } = req.body as { code?: string; code_verifier?: string };
if (!code || !code_verifier) {
res.status(400).json({ error: 'code and code_verifier are required' });
return;
}
try {
const tokenRes = await fetch(`${ISSUER}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code_verifier,
}),
});
const body = await tokenRes.json() as Record<string, unknown>;
if (!tokenRes.ok) {
res.status(400).json({ error: body.error, error_description: body.error_description });
return;
}
// Return tokens to the frontend — access_token used as Bearer on subsequent API calls
res.json({
access_token: body.access_token,
id_token: body.id_token,
expires_in: body.expires_in,
token_type: body.token_type,
});
} catch (err) {
console.error('token exchange error', err);
res.status(502).json({ error: 'Token exchange failed' });
}
});
// POST /api/auth/refresh
router.post('/refresh', async (req: Request, res: Response) => {
const { refresh_token } = req.body as { refresh_token?: string };
if (!refresh_token) {
res.status(400).json({ error: 'refresh_token required' });
return;
}
try {
const tokenRes = await fetch(`${ISSUER}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
}),
});
const body = await tokenRes.json() as Record<string, unknown>;
if (!tokenRes.ok) {
res.status(401).json({ error: body.error });
return;
}
res.json({
access_token: body.access_token,
id_token: body.id_token,
expires_in: body.expires_in,
});
} catch (err) {
console.error('refresh error', err);
res.status(502).json({ error: 'Refresh failed' });
}
});
export default router;

View File

@@ -1,51 +1,81 @@
import { Router, Request, Response } from 'express';
import Docker from 'dockerode';
const router = Router();
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
async function findContainer(nameOrId: string) {
const list = await docker.listContainers({ all: true });
return list.find(c =>
c.Id.startsWith(nameOrId) ||
c.Names.some(n => n.replace(/^\//, '') === nameOrId)
);
}
// GET /api/containers - List all containers
router.get('/', async (req: Request, res: Response) => {
try {
// TODO: Implement container listing via Docker API
res.json({ containers: [] });
const list = await docker.listContainers({ all: true });
const containers = list.map(c => ({
id: c.Id.slice(0, 12),
full_id: c.Id,
name: c.Names[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12),
image: c.Image,
status: c.Status,
state: c.State,
ports: c.Ports.map(p => ({
private: p.PrivatePort,
public: p.PublicPort,
type: p.Type,
})).filter(p => p.public),
created: new Date(c.Created * 1000).toISOString(),
compose_project: c.Labels?.['com.docker.compose.project'] ?? null,
compose_service: c.Labels?.['com.docker.compose.service'] ?? null,
}));
res.json({ containers });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch containers' });
console.error('Containers error:', error);
res.status(500).json({ error: 'Failed to fetch containers. Is the Docker socket mounted?' });
}
});
// GET /api/containers/:id - Get container details
router.get('/:id', async (req: Request, res: Response) => {
try {
// TODO: Implement container details
res.json({ id: req.params.id });
const found = await findContainer(req.params.id);
if (!found) return res.status(404).json({ error: 'Container not found' });
const info = await docker.getContainer(found.Id).inspect();
res.json(info);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch container' });
res.status(404).json({ error: 'Container not found' });
}
});
// POST /api/containers/:id/start - Start container
router.post('/:id/start', async (req: Request, res: Response) => {
try {
// TODO: Implement container start
const found = await findContainer(req.params.id);
if (!found) return res.status(404).json({ error: 'Container not found' });
await docker.getContainer(found.Id).start();
res.json({ status: 'started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start container' });
}
});
// POST /api/containers/:id/stop - Stop container
router.post('/:id/stop', async (req: Request, res: Response) => {
try {
// TODO: Implement container stop
const found = await findContainer(req.params.id);
if (!found) return res.status(404).json({ error: 'Container not found' });
await docker.getContainer(found.Id).stop();
res.json({ status: 'stopped' });
} catch (error) {
res.status(500).json({ error: 'Failed to stop container' });
}
});
// POST /api/containers/:id/restart - Restart container
router.post('/:id/restart', async (req: Request, res: Response) => {
try {
// TODO: Implement container restart
const found = await findContainer(req.params.id);
if (!found) return res.status(404).json({ error: 'Container not found' });
await docker.getContainer(found.Id).restart();
res.json({ status: 'restarted' });
} catch (error) {
res.status(500).json({ error: 'Failed to restart container' });

View File

@@ -4,14 +4,23 @@ import containersRouter from './containers';
import servicesRouter from './services';
import logsRouter from './logs';
import metricsRouter from './metrics';
import authRouter from './auth';
import { requireAuth } from '../middleware/oidcAuth';
const router = Router();
router.get('/health', (req, res) => {
// Public — health check and token exchange do not require auth
router.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime() });
});
router.get('/', (req, res) => {
// Public — OIDC token exchange (code → tokens, keeps client_secret server-side)
router.use('/auth', authRouter);
// Protected — all resource routes require a valid OIDC access token
router.use(requireAuth);
router.get('/', (_req, res) => {
res.json({
message: 'Server Manager API',
version: '1.0.0',

View File

@@ -1,21 +1,61 @@
import { Router, Request, Response } from 'express';
import Docker from 'dockerode';
const router = Router();
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
function demuxDockerBuffer(buffer: Buffer): string[] {
const lines: string[] = [];
let offset = 0;
while (offset + 8 <= buffer.length) {
const size = buffer.readUInt32BE(offset + 4);
offset += 8;
if (size > 0 && offset + size <= buffer.length) {
const chunk = buffer.slice(offset, offset + size).toString('utf8');
lines.push(...chunk.split('\n').filter(l => l.trim()));
offset += size;
} else {
break;
}
}
return lines;
}
// GET /api/logs/:type/:id - Get logs for a specific resource
router.get('/:type/:id', async (req: Request, res: Response) => {
try {
const { type, id } = req.params;
const { lines = 100, follow = false } = req.query;
// TODO: Implement log streaming (container logs, service logs, system logs)
res.json({
const lines = parseInt(req.query.lines as string) || 100;
if (type !== 'container') {
return res.json({ type, id, logs: [] });
}
const list = await docker.listContainers({ all: true });
const target = list.find(c =>
c.Id.startsWith(id) ||
c.Names.some(n => n.replace(/^\//, '') === id)
);
if (!target) {
return res.status(404).json({ error: `Container '${id}' not found` });
}
const logBuffer = await docker.getContainer(target.Id).logs({
stdout: true,
stderr: true,
tail: lines,
timestamps: true,
}) as unknown as Buffer;
const logLines = demuxDockerBuffer(logBuffer);
res.json({
type,
id,
logs: [],
lines: parseInt(lines as string)
logs: logLines,
container: target.Names[0]?.replace(/^\//, ''),
});
} catch (error) {
console.error('Logs error:', error);
res.status(500).json({ error: 'Failed to fetch logs' });
}
});

View File

@@ -1,7 +1,9 @@
import { Router, Request, Response } from 'express';
import os from 'os';
import Docker from 'dockerode';
const router = Router();
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
// GET /api/metrics - Get system metrics
router.get('/', async (req: Request, res: Response) => {
@@ -14,15 +16,38 @@ router.get('/', async (req: Request, res: Response) => {
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
// Docker disk usage and container counts via Docker API
let disk = { used: 0, total: 116, unit: 'GB' };
let containerStats = { running: 0, total: 0 };
try {
const [dfData, containers] = await Promise.all([
docker.df() as any,
docker.listContainers({ all: true }),
]);
const totalBytes =
(dfData.LayersSize ?? 0) +
(dfData.Volumes ?? []).reduce((sum: number, v: any) => sum + (v.UsageData?.Size ?? 0), 0) +
(dfData.BuildCache ?? []).reduce((sum: number, b: any) => sum + (b.Size ?? 0), 0);
disk = {
used: Math.round((totalBytes / 1024 / 1024 / 1024) * 10) / 10,
total: 116,
unit: 'GB',
};
containerStats = {
running: containers.filter(c => c.State === 'running').length,
total: containers.length,
};
} catch { /* Docker not available */ }
res.json({
cpu: { usage: cpuUsage, cores: cpus.length },
cpu: { usage: cpuUsage, cores: cpus.length, load1: parseFloat(loadAvg.toFixed(2)) },
memory: {
used: Math.round(usedMem / 1024 / 1024),
total: Math.round(totalMem / 1024 / 1024),
percentage: Math.round((usedMem / totalMem) * 100),
},
disk: { used: 0, total: 0, percentage: 0 },
network: { rx: 0, tx: 0 },
disk,
containers: containerStats,
timestamp: new Date().toISOString(),
});
} catch (error) {

View File

@@ -1,55 +1,66 @@
import { Router, Request, Response } from 'express';
import Docker from 'dockerode';
const router = Router();
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
// GET /api/services - List all services
router.get('/', async (req: Request, res: Response) => {
try {
// TODO: Implement service listing (systemd, docker compose, etc.)
res.json({ services: [] });
const list = await docker.listContainers({ all: true });
const projects = new Map<string, any[]>();
const standalone: any[] = [];
for (const c of list) {
const project = c.Labels?.['com.docker.compose.project'];
const service = c.Labels?.['com.docker.compose.service'];
if (project && service) {
if (!projects.has(project)) projects.set(project, []);
projects.get(project)!.push({
name: service,
container: c.Names[0]?.replace(/^\//, ''),
state: c.State,
status: c.Status,
image: c.Image,
});
} else {
standalone.push({
name: c.Names[0]?.replace(/^\//, '') ?? c.Id.slice(0, 12),
container: c.Names[0]?.replace(/^\//, ''),
state: c.State,
status: c.Status,
image: c.Image,
});
}
}
const stacks = [
...Array.from(projects.entries()).map(([project, svcs]) => ({
id: project,
name: project,
type: 'compose',
services: svcs,
running: svcs.filter(s => s.state === 'running').length,
total: svcs.length,
status: svcs.every(s => s.state === 'running') ? 'healthy'
: svcs.some(s => s.state === 'running') ? 'degraded' : 'stopped',
})),
...(standalone.length > 0 ? [{
id: 'standalone',
name: 'standalone',
type: 'standalone',
services: standalone,
running: standalone.filter(s => s.state === 'running').length,
total: standalone.length,
status: 'unknown',
}] : []),
];
res.json({ services: stacks });
} catch (error) {
console.error('Services error:', error);
res.status(500).json({ error: 'Failed to fetch services' });
}
});
// GET /api/services/:name - Get service status
router.get('/:name', async (req: Request, res: Response) => {
try {
// TODO: Implement service status check
res.json({ name: req.params.name, status: 'unknown' });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch service status' });
}
});
// POST /api/services/:name/start - Start service
router.post('/:name/start', async (req: Request, res: Response) => {
try {
// TODO: Implement service start
res.json({ status: 'started' });
} catch (error) {
res.status(500).json({ error: 'Failed to start service' });
}
});
// POST /api/services/:name/stop - Stop service
router.post('/:name/stop', async (req: Request, res: Response) => {
try {
// TODO: Implement service stop
res.json({ status: 'stopped' });
} catch (error) {
res.status(500).json({ error: 'Failed to stop service' });
}
});
// POST /api/services/:name/restart - Restart service
router.post('/:name/restart', async (req: Request, res: Response) => {
try {
// TODO: Implement service restart
res.json({ status: 'restarted' });
} catch (error) {
res.status(500).json({ error: 'Failed to restart service' });
}
});
export default router;