diff --git a/.drone.yml b/.drone.yml index 699d42d..1c7fc75 100644 --- a/.drone.yml +++ b/.drone.yml @@ -91,14 +91,20 @@ steps: # ─── Deploy ──────────────────────────────────────────────────────────────── - name: deploy image: appleboy/drone-ssh + environment: + GITEA_TOKEN: + from_secret: gitea_token settings: host: 100.116.40.103 username: root key: from_secret: ssh_private_key + envs: + - GITEA_TOKEN port: 22 script: - cd /opt/server-manager + - curl -sfH "Authorization:token $GITEA_TOKEN" https://git.jiosii.com/api/v1/repos/ops/server-manager/raw/docker-compose.yml -o docker-compose.yml - docker compose pull - docker compose up -d --remove-orphans - docker image prune -f diff --git a/backend/package-lock.json b/backend/package-lock.json index 9410671..7a8e73f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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": { diff --git a/backend/package.json b/backend/package.json index 580d385..0aedecd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/middleware/oidcAuth.ts b/backend/src/middleware/oidcAuth.ts new file mode 100644 index 0000000..023da36 --- /dev/null +++ b/backend/src/middleware/oidcAuth.ts @@ -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' }); + } +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..6a3db40 --- /dev/null +++ b/backend/src/routes/auth.ts @@ -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; + + 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; + + 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; diff --git a/backend/src/routes/containers.ts b/backend/src/routes/containers.ts index e482b1e..2be23da 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -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' }); diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index d812b4f..680848c 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -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', diff --git a/backend/src/routes/logs.ts b/backend/src/routes/logs.ts index 7459823..a02045f 100644 --- a/backend/src/routes/logs.ts +++ b/backend/src/routes/logs.ts @@ -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' }); } }); diff --git a/backend/src/routes/metrics.ts b/backend/src/routes/metrics.ts index fb84859..c75efb1 100644 --- a/backend/src/routes/metrics.ts +++ b/backend/src/routes/metrics.ts @@ -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) { diff --git a/backend/src/routes/services.ts b/backend/src/routes/services.ts index 24146e6..5810113 100644 --- a/backend/src/routes/services.ts +++ b/backend/src/routes/services.ts @@ -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(); + 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; diff --git a/docker-compose.yml b/docker-compose.yml index 0713006..10107da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,9 @@ services: dockerfile: Dockerfile container_name: server-manager-backend restart: unless-stopped + user: root + volumes: + - /var/run/docker.sock:/var/run/docker.sock environment: NODE_ENV: production PORT: 3001 diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..807fb02 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,6 @@ +# OIDC configuration for the server-manager frontend +# Copy to .env.local for local overrides + +VITE_OIDC_ISSUER=https://auth.jiosii.com/oidc +VITE_OIDC_CLIENT_ID=server-manager +VITE_OIDC_REDIRECT_URI=https://sm.jiosii.com/callback diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 154c203..b9384e7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,11 +7,47 @@ import Services from '@/pages/Services' import Logs from '@/pages/Logs' import Metrics from '@/pages/Metrics' import Settings from '@/pages/Settings' +import Callback from '@/pages/Callback' +import { useAuth } from '@/hooks/useAuth' + +function AuthGuard({ children }: { children: React.ReactNode }) { + const { authenticated, loading } = useAuth() + if (loading || !authenticated) { + return ( +
+ {loading ? 'Authenticating…' : 'Redirecting to login…'} +
+ ) + } + return <>{children} +} export default function App() { return ( - }> + {/* Public — OIDC callback, no auth required */} + } /> + + {/* Protected routes */} + + + + } + > } /> } /> } /> diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..1935d52 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,123 @@ +/** + * OIDC Authentication hook. + * + * Flow: + * 1. On mount, check sessionStorage for a valid access_token. + * 2. If none, build an OIDC auth URL with PKCE and redirect. + * 3. On /callback, exchange code + verifier via the backend (keeps client_secret server-side). + * 4. Store the token in sessionStorage and set it on axios. + */ + +import { useEffect, useState, useCallback } from 'react' +import { api } from '@/lib/api' + +const ISSUER = import.meta.env.VITE_OIDC_ISSUER ?? 'https://auth.jiosii.com/oidc' +const CLIENT_ID = import.meta.env.VITE_OIDC_CLIENT_ID ?? 'server-manager' +const REDIRECT_URI = import.meta.env.VITE_OIDC_REDIRECT_URI ?? `${window.location.origin}/callback` + +const TOKEN_KEY = 'sm_access_token' +const VERIFIER_KEY = 'sm_code_verifier' +const STATE_KEY = 'sm_oidc_state' + +// PKCE helpers +async function generateCodeVerifier(): Promise { + const array = crypto.getRandomValues(new Uint8Array(32)) + return btoa(String.fromCharCode(...array)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +async function generateCodeChallenge(verifier: string): Promise { + const data = new TextEncoder().encode(verifier) + const digest = await crypto.subtle.digest('SHA-256', data) + return btoa(String.fromCharCode(...new Uint8Array(digest))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +function generateState(): string { + const array = crypto.getRandomValues(new Uint8Array(16)) + return btoa(String.fromCharCode(...array)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +export function getStoredToken(): string | null { + return sessionStorage.getItem(TOKEN_KEY) +} + +export function clearAuth() { + sessionStorage.removeItem(TOKEN_KEY) + sessionStorage.removeItem(VERIFIER_KEY) + sessionStorage.removeItem(STATE_KEY) + delete api.defaults.headers.common['Authorization'] +} + +export function useAuth() { + const [authenticated, setAuthenticated] = useState(() => { + const token = getStoredToken() + if (token) { + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + return true + } + return false + }) + const [loading, setLoading] = useState(false) + + const startLogin = useCallback(async () => { + const verifier = await generateCodeVerifier() + const challenge = await generateCodeChallenge(verifier) + const state = generateState() + + sessionStorage.setItem(VERIFIER_KEY, verifier) + sessionStorage.setItem(STATE_KEY, state) + + const params = new URLSearchParams({ + response_type: 'code', + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: 'openid profile email offline_access', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }) + + window.location.href = `${ISSUER}/auth?${params}` + }, []) + + // Exchange the code on callback + const handleCallback = useCallback(async (code: string, state: string) => { + const storedState = sessionStorage.getItem(STATE_KEY) + const verifier = sessionStorage.getItem(VERIFIER_KEY) + + if (!verifier || state !== storedState) { + throw new Error('State mismatch — possible CSRF attack') + } + + setLoading(true) + try { + const res = await api.post<{ access_token: string }>('/auth/exchange', { + code, + code_verifier: verifier, + }) + + const token = res.data.access_token + sessionStorage.setItem(TOKEN_KEY, token) + sessionStorage.removeItem(VERIFIER_KEY) + sessionStorage.removeItem(STATE_KEY) + api.defaults.headers.common['Authorization'] = `Bearer ${token}` + setAuthenticated(true) + } finally { + setLoading(false) + } + }, []) + + // Auto-redirect to login if not authenticated and not on callback + useEffect(() => { + if (!authenticated && !loading) { + const isCallback = window.location.pathname === '/callback' + if (!isCallback) { + startLogin() + } + } + }, [authenticated, loading, startLogin]) + + return { authenticated, loading, startLogin, handleCallback } +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f88600e..099d510 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import { clearAuth } from '@/hooks/useAuth' export const api = axios.create({ baseURL: '/api', @@ -11,6 +12,10 @@ export const api = axios.create({ api.interceptors.response.use( (response) => response, (error) => { + if (error.response?.status === 401) { + // Clear stale token — the useAuth hook will redirect to login on next render + clearAuth() + } console.error('API Error:', error.response?.data || error.message) return Promise.reject(error) } diff --git a/frontend/src/pages/Callback.tsx b/frontend/src/pages/Callback.tsx new file mode 100644 index 0000000..ff96d05 --- /dev/null +++ b/frontend/src/pages/Callback.tsx @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '@/hooks/useAuth' + +export default function Callback() { + const navigate = useNavigate() + const { handleCallback } = useAuth() + const handled = useRef(false) + + useEffect(() => { + if (handled.current) return + handled.current = true + + const params = new URLSearchParams(window.location.search) + const code = params.get('code') + const state = params.get('state') + const error = params.get('error') + + if (error) { + console.error('OIDC error:', error, params.get('error_description')) + navigate('/', { replace: true }) + return + } + + if (!code || !state) { + navigate('/', { replace: true }) + return + } + + handleCallback(code, state) + .then(() => navigate('/', { replace: true })) + .catch((err) => { + console.error('Auth callback failed:', err) + navigate('/', { replace: true }) + }) + }, [handleCallback, navigate]) + + return ( +
+ Completing sign in… +
+ ) +} diff --git a/frontend/src/pages/Containers.tsx b/frontend/src/pages/Containers.tsx index 5449650..ce35693 100644 --- a/frontend/src/pages/Containers.tsx +++ b/frontend/src/pages/Containers.tsx @@ -1,60 +1,120 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/api' import { Box, Play, Square, RefreshCw } from 'lucide-react' +interface Container { + id: string + full_id: string + name: string + image: string + status: string + state: string + ports: { private: number; public: number; type: string }[] + created: string + compose_project: string | null + compose_service: string | null +} + +function StateChip({ state }: { state: string }) { + const cfg: Record = { + running: 'bg-green-500/10 text-green-400', + exited: 'bg-red-500/10 text-red-400', + paused: 'bg-amber-500/10 text-amber-400', + restarting: 'bg-blue-500/10 text-blue-400', + created: 'bg-zinc-500/10 text-zinc-400', + } + return ( + + {state} + + ) +} + export default function Containers() { + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['containers'], queryFn: () => api.get('/containers').then(r => r.data), - refetchInterval: 15000, + refetchInterval: 10000, }) - const containers = data?.containers ?? [] + const action = useMutation({ + mutationFn: ({ id, act }: { id: string; act: string }) => + api.post(`/containers/${id}/${act}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['containers'] }), + }) + + const containers: Container[] = data?.containers ?? [] + const running = containers.filter(c => c.state === 'running').length return (
-

{containers.length} containers

+

+ {running} running / {containers.length} total +

+
- {['Container', 'Image', 'Status', 'Actions'].map(h => ( + {['Container', 'Image', 'Project', 'Ports', 'State', ''].map(h => ( ))} {isLoading ? ( - + ) : containers.length === 0 ? ( - - ) : containers.map((c: any) => ( - + ) : containers.map(c => ( + - - + + +
{h}
Loading...
Loading...
+ -

No containers data yet.

-

Connect a server to see its containers.

+

No containers found.

+

Docker socket may not be mounted.

{c.name}{c.image} - - {c.status} - + {c.image}{c.compose_project ?? '—'} + {c.ports.length > 0 ? c.ports.map(p => `${p.public}→${p.private}`).join(', ') : '—'} -
- - -
diff --git a/frontend/src/pages/Logs.tsx b/frontend/src/pages/Logs.tsx index 042b946..6828b54 100644 --- a/frontend/src/pages/Logs.tsx +++ b/frontend/src/pages/Logs.tsx @@ -1,38 +1,40 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { api } from '@/lib/api' -import { FileText, Search } from 'lucide-react' +import { FileText, RefreshCw } from 'lucide-react' export default function Logs() { - const [resourceType, setResourceType] = useState('container') - const [resourceId, setResourceId] = useState('') - const [lines, setLines] = useState('100') + const [selected, setSelected] = useState('') + const [lines, setLines] = useState('100') + const [autoRefresh, setAutoRefresh] = useState(false) + + const { data: containerData } = useQuery({ + queryKey: ['containers'], + queryFn: () => api.get('/containers').then(r => r.data), + }) + + const containers = containerData?.containers ?? [] const { data, isLoading, refetch } = useQuery({ - queryKey: ['logs', resourceType, resourceId, lines], - queryFn: () => api.get(`/logs/${resourceType}/${resourceId}?lines=${lines}`).then(r => r.data), - enabled: !!resourceId, + queryKey: ['logs', selected, lines], + queryFn: () => api.get(`/logs/container/${selected}?lines=${lines}`).then(r => r.data), + enabled: !!selected, + refetchInterval: autoRefresh ? 5000 : false, }) return (
- {/* Controls */}
- setResourceId(e.target.value)} - placeholder="Container / service name..." - className="flex-1 px-3 py-2 rounded-lg bg-[hsl(222.2_84%_6%)] border border-[hsl(217.2_32.6%_17.5%)] text-white text-sm placeholder-[hsl(215_20.2%_40%)] focus:outline-none focus:border-blue-500" - /> +
- {/* Log Output */} -
- {!resourceId ? ( +
+ {!selected ? (
-

Enter a resource name and click Fetch Logs

+

Select a container to view logs

) : isLoading ? ( -
Loading...
+
+ Loading... +
) : (
-            {data?.logs?.join('\n') || `No logs found for ${resourceType}/${resourceId}`}
+            {data?.logs?.join('\n') || `No logs found for ${selected}`}
           
)}
diff --git a/frontend/src/pages/Metrics.tsx b/frontend/src/pages/Metrics.tsx index ff75102..d8ed2ca 100644 --- a/frontend/src/pages/Metrics.tsx +++ b/frontend/src/pages/Metrics.tsx @@ -2,12 +2,12 @@ import { useQuery } from '@tanstack/react-query' import { useState, useEffect } from 'react' import { api } from '@/lib/api' import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts' -import { Cpu, HardDrive, MemoryStick, Network } from 'lucide-react' +import { Cpu, HardDrive, MemoryStick, Box } from 'lucide-react' -function MetricCard({ label, value, max, unit, icon: Icon, color }: { - label: string, value: number, max: number, unit: string, icon: any, color: string +function MetricCard({ label, value, max, icon: Icon, color, subtitle }: { + label: string; value: number; max: number; icon: any; color: string; subtitle?: string }) { - const pct = max > 0 ? Math.round((value / max) * 100) : 0 + const pct = max > 0 ? Math.min(Math.round((value / max) * 100), 100) : 0 return (
@@ -23,7 +23,7 @@ function MetricCard({ label, value, max, unit, icon: Icon, color }: { style={{ width: `${pct}%` }} />
-

{value} / {max} {unit}

+

{subtitle}

) } @@ -49,19 +49,32 @@ export default function Metrics() { }) }, [data]) - const cpu = data?.cpu ?? { usage: 0, cores: 2 } - const mem = data?.memory ?? { used: 0, total: 3800 } - const disk = data?.disk ?? { used: 0, total: 116000 } + const cpu = data?.cpu ?? { usage: 0, cores: 2, load1: '0.00' } + const mem = data?.memory ?? { used: 0, total: 3800, percentage: 0 } + const disk = data?.disk ?? { used: 0, total: 116, unit: 'GB' } + const ctrs = data?.containers ?? { running: 0, total: 0 } const chartData = history.length > 1 ? history : [{ t: 0, cpu: 0, mem: 0 }, { t: 1, cpu: 0, mem: 0 }] return (
- - - - + + + +
@@ -83,11 +96,16 @@ export default function Metrics() { [`${Math.round(v)}%`]} /> - - + + +
+ CPU + Memory +
) diff --git a/frontend/src/pages/Services.tsx b/frontend/src/pages/Services.tsx index 60e213f..bb4be58 100644 --- a/frontend/src/pages/Services.tsx +++ b/frontend/src/pages/Services.tsx @@ -1,50 +1,93 @@ -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '@/lib/api' -import { Layers } from 'lucide-react' +import { Layers, CheckCircle, AlertTriangle, XCircle, RefreshCw } from 'lucide-react' + +function StackStatus({ status }: { status: string }) { + const cfg: Record = { + healthy: { icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-500/10' }, + degraded: { icon: AlertTriangle, color: 'text-amber-400', bg: 'bg-amber-500/10' }, + stopped: { icon: XCircle, color: 'text-red-400', bg: 'bg-red-500/10' }, + unknown: { icon: Layers, color: 'text-zinc-400', bg: 'bg-zinc-500/10' }, + } + const { icon: Icon, color, bg } = cfg[status] ?? cfg.unknown + return ( + + {status} + + ) +} export default function Services() { + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['services'], queryFn: () => api.get('/services').then(r => r.data), + refetchInterval: 15000, }) - const services = data?.services ?? [] + + const stacks = data?.services ?? [] return (
-
- - - - {['Service', 'Type', 'Port', 'Status'].map(h => ( - - ))} - - - - {isLoading ? ( - - ) : services.length === 0 ? ( - - - - ) : services.map((s: any) => ( - - - - - - - ))} - -
{h}
Loading...
- -

No services configured yet.

-
{s.name}{s.type}{s.port ?? '—'} - - {s.status ?? 'unknown'} - -
+
+

{stacks.length} stack{stacks.length !== 1 ? 's' : ''}

+
+ + {isLoading && ( +
Loading...
+ )} + {!isLoading && stacks.length === 0 && ( +
+ +

No services found.

+
+ )} + + {stacks.map((stack: any) => ( +
+
+
+ + {stack.name} + {stack.type} +
+
+ {stack.running}/{stack.total} running + +
+
+ + + + {['Service', 'Container', 'Image', 'State'].map(h => ( + + ))} + + + + {stack.services.map((svc: any) => ( + + + + + + + ))} + +
{h}
{svc.name}{svc.container}{svc.image} + + {svc.state} + +
+
+ ))}
) }