feat: live Docker data — containers, services, logs, metrics via dockerode
All checks were successful
continuous-integration/drone/push Build is passing
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:
@@ -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
|
||||
|
||||
346
backend/package-lock.json
generated
346
backend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
56
backend/src/middleware/oidcAuth.ts
Normal file
56
backend/src/middleware/oidcAuth.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
93
backend/src/routes/auth.ts
Normal file
93
backend/src/routes/auth.ts
Normal 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;
|
||||
@@ -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' });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
6
frontend/.env.example
Normal file
6
frontend/.env.example
Normal file
@@ -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
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
background: 'hsl(222, 84%, 5%)',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#94a3b8',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '0.9375rem',
|
||||
}}
|
||||
>
|
||||
{loading ? 'Authenticating…' : 'Redirecting to login…'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
{/* Public — OIDC callback, no auth required */}
|
||||
<Route path="/callback" element={<Callback />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<AuthGuard>
|
||||
<Layout />
|
||||
</AuthGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="servers" element={<Servers />} />
|
||||
<Route path="containers" element={<Containers />} />
|
||||
|
||||
123
frontend/src/hooks/useAuth.ts
Normal file
123
frontend/src/hooks/useAuth.ts
Normal file
@@ -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<string> {
|
||||
const array = crypto.getRandomValues(new Uint8Array(32))
|
||||
return btoa(String.fromCharCode(...array))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
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<boolean>(() => {
|
||||
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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
54
frontend/src/pages/Callback.tsx
Normal file
54
frontend/src/pages/Callback.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
background: 'hsl(222, 84%, 5%)',
|
||||
color: '#94a3b8',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
fontSize: '0.9375rem',
|
||||
}}
|
||||
>
|
||||
Completing sign in…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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 (
|
||||
<span className={`text-xs px-2 py-1 rounded ${cfg[state] ?? 'bg-zinc-500/10 text-zinc-400'}`}>
|
||||
{state}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">{containers.length} containers</p>
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">
|
||||
{running} running / {containers.length} total
|
||||
</p>
|
||||
<button
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['containers'] })}
|
||||
className="p-2 rounded-lg text-[hsl(215_20.2%_65.1%)] hover:bg-[hsl(217.2_32.6%_17.5%)] hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[hsl(217.2_32.6%_17.5%)]">
|
||||
{['Container', 'Image', 'Status', 'Actions'].map(h => (
|
||||
{['Container', 'Image', 'Project', 'Ports', 'State', ''].map(h => (
|
||||
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-[hsl(215_20.2%_65.1%)] uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={4} className="text-center py-10 text-[hsl(215_20.2%_65.1%)] text-sm">Loading...</td></tr>
|
||||
<tr><td colSpan={6} className="text-center py-10 text-[hsl(215_20.2%_65.1%)] text-sm">Loading...</td></tr>
|
||||
) : containers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-12 text-center">
|
||||
<td colSpan={6} className="py-12 text-center">
|
||||
<Box className="w-10 h-10 mx-auto mb-3 text-[hsl(215_20.2%_40%)]" />
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No containers data yet.</p>
|
||||
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-1">Connect a server to see its containers.</p>
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No containers found.</p>
|
||||
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-1">Docker socket may not be mounted.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : containers.map((c: any) => (
|
||||
<tr key={c.id} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)]">
|
||||
) : containers.map(c => (
|
||||
<tr key={c.full_id} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)] transition-colors">
|
||||
<td className="px-5 py-4 text-sm font-medium text-white">{c.name}</td>
|
||||
<td className="px-5 py-4 text-sm text-[hsl(215_20.2%_65.1%)] font-mono">{c.image}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`text-xs px-2 py-1 rounded ${c.status === 'running' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{c.status}
|
||||
</span>
|
||||
<td className="px-5 py-4 text-xs text-[hsl(215_20.2%_65.1%)] font-mono max-w-[180px] truncate" title={c.image}>{c.image}</td>
|
||||
<td className="px-5 py-4 text-xs text-[hsl(215_20.2%_65.1%)]">{c.compose_project ?? '—'}</td>
|
||||
<td className="px-5 py-4 text-xs text-[hsl(215_20.2%_65.1%)] font-mono">
|
||||
{c.ports.length > 0 ? c.ports.map(p => `${p.public}→${p.private}`).join(', ') : '—'}
|
||||
</td>
|
||||
<td className="px-5 py-4"><StateChip state={c.state} /></td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-green-400 hover:bg-green-500/10 transition-colors" title="Start">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => action.mutate({ id: c.name, act: 'start' })}
|
||||
disabled={c.state === 'running' || action.isPending}
|
||||
className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-green-400 hover:bg-green-500/10 disabled:opacity-30 transition-colors"
|
||||
title="Start"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-red-400 hover:bg-red-500/10 transition-colors" title="Stop">
|
||||
<button
|
||||
onClick={() => action.mutate({ id: c.name, act: 'stop' })}
|
||||
disabled={c.state !== 'running' || action.isPending}
|
||||
className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 transition-colors"
|
||||
title="Stop"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-amber-400 hover:bg-amber-500/10 transition-colors" title="Restart">
|
||||
<button
|
||||
onClick={() => action.mutate({ id: c.name, act: 'restart' })}
|
||||
disabled={action.isPending}
|
||||
className="p-1.5 rounded text-[hsl(215_20.2%_40%)] hover:text-amber-400 hover:bg-amber-500/10 disabled:opacity-30 transition-colors"
|
||||
title="Restart"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<select
|
||||
value={resourceType}
|
||||
onChange={e => setResourceType(e.target.value)}
|
||||
className="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 focus:outline-none focus:border-blue-500"
|
||||
value={selected}
|
||||
onChange={e => setSelected(e.target.value)}
|
||||
className="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 focus:outline-none focus:border-blue-500 min-w-[220px]"
|
||||
>
|
||||
<option value="container">Container</option>
|
||||
<option value="service">Service</option>
|
||||
<option value="system">System</option>
|
||||
<option value="">Select container...</option>
|
||||
{containers.map((c: any) => (
|
||||
<option key={c.full_id} value={c.name}>{c.name} ({c.state})</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={resourceId}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<select
|
||||
value={lines}
|
||||
onChange={e => setLines(e.target.value)}
|
||||
@@ -40,27 +42,38 @@ export default function Logs() {
|
||||
>
|
||||
{['50', '100', '200', '500'].map(n => <option key={n} value={n}>{n} lines</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-[hsl(215_20.2%_65.1%)] cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={e => setAutoRefresh(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Auto-refresh (5s)
|
||||
</label>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={!resourceId || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
disabled={!selected || isLoading}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-[hsl(215_20.2%_65.1%)] hover:bg-[hsl(217.2_32.6%_17.5%)] disabled:opacity-50 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<Search className="w-4 h-4" /> Fetch Logs
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Log Output */}
|
||||
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(217.2_32.6%_5%)] min-h-[400px] font-mono text-sm overflow-auto">
|
||||
{!resourceId ? (
|
||||
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(217.2_32.6%_5%)] min-h-[400px] max-h-[600px] font-mono text-sm overflow-auto">
|
||||
{!selected ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[hsl(215_20.2%_40%)]">
|
||||
<FileText className="w-10 h-10 mb-3" />
|
||||
<p>Enter a resource name and click Fetch Logs</p>
|
||||
<p>Select a container to view logs</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center h-64 text-[hsl(215_20.2%_65.1%)]">Loading...</div>
|
||||
<div className="flex items-center justify-center h-64 text-[hsl(215_20.2%_65.1%)]">
|
||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" /> Loading...
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-4 text-green-400 whitespace-pre-wrap text-xs leading-5">
|
||||
{data?.logs?.join('\n') || `No logs found for ${resourceType}/${resourceId}`}
|
||||
{data?.logs?.join('\n') || `No logs found for ${selected}`}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -23,7 +23,7 @@ function MetricCard({ label, value, max, unit, icon: Icon, color }: {
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-2">{value} / {max} {unit}</p>
|
||||
<p className="text-xs text-[hsl(215_20.2%_40%)] mt-2">{subtitle}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard label="CPU" value={cpu.usage} max={100} unit="%" icon={Cpu} color="text-blue-400" />
|
||||
<MetricCard label="Memory" value={mem.used} max={mem.total} unit="MB" icon={MemoryStick} color="text-violet-400" />
|
||||
<MetricCard label="Disk" value={disk.used} max={disk.total} unit="MB" icon={HardDrive} color="text-emerald-400" />
|
||||
<MetricCard label="Network" value={0} max={1000} unit="Mbps" icon={Network} color="text-amber-400" />
|
||||
<MetricCard
|
||||
label="CPU Load" value={cpu.usage} max={100} icon={Cpu} color="text-blue-400"
|
||||
subtitle={`${cpu.cores} cores · load avg ${cpu.load1}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Memory" value={mem.used} max={mem.total} icon={MemoryStick} color="text-violet-400"
|
||||
subtitle={`${mem.used} MB / ${mem.total} MB`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Docker Disk" value={disk.used} max={disk.total} icon={HardDrive} color="text-emerald-400"
|
||||
subtitle={`${disk.used} ${disk.unit} used by Docker`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Containers" value={ctrs.running} max={ctrs.total || 1} icon={Box} color="text-amber-400"
|
||||
subtitle={`${ctrs.running} running / ${ctrs.total} total`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] p-5">
|
||||
@@ -83,11 +96,16 @@ export default function Metrics() {
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'hsl(222.2 84% 6%)', border: '1px solid hsl(217.2 32.6% 17.5%)', borderRadius: '8px', fontSize: 12 }}
|
||||
labelStyle={{ display: 'none' }}
|
||||
formatter={(v: any) => [`${Math.round(v)}%`]}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#cpu)" strokeWidth={2} name="CPU %" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#8b5cf6" fill="url(#mem)" strokeWidth={2} name="Mem %" />
|
||||
<Area type="monotone" dataKey="cpu" stroke="#3b82f6" fill="url(#cpu)" strokeWidth={2} name="CPU" />
|
||||
<Area type="monotone" dataKey="mem" stroke="#8b5cf6" fill="url(#mem)" strokeWidth={2} name="Mem" />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<span className="flex items-center gap-2 text-xs text-[hsl(215_20.2%_65.1%)]"><span className="w-3 h-0.5 bg-blue-500 inline-block" /> CPU</span>
|
||||
<span className="flex items-center gap-2 text-xs text-[hsl(215_20.2%_65.1%)]"><span className="w-3 h-0.5 bg-violet-500 inline-block" /> Memory</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<string, { icon: any; color: string; bg: string }> = {
|
||||
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 (
|
||||
<span className={`flex items-center gap-1.5 text-xs px-2 py-1 rounded w-fit ${bg} ${color}`}>
|
||||
<Icon className="w-3 h-3" /> {status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[hsl(217.2_32.6%_17.5%)]">
|
||||
{['Service', 'Type', 'Port', 'Status'].map(h => (
|
||||
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-[hsl(215_20.2%_65.1%)] uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr><td colSpan={4} className="text-center py-10 text-sm text-[hsl(215_20.2%_65.1%)]">Loading...</td></tr>
|
||||
) : services.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-12 text-center">
|
||||
<Layers className="w-10 h-10 mx-auto mb-3 text-[hsl(215_20.2%_40%)]" />
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No services configured yet.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : services.map((s: any) => (
|
||||
<tr key={s.id} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)]">
|
||||
<td className="px-5 py-4 text-sm font-medium text-white">{s.name}</td>
|
||||
<td className="px-5 py-4 text-sm text-[hsl(215_20.2%_65.1%)]">{s.type}</td>
|
||||
<td className="px-5 py-4 text-sm text-[hsl(215_20.2%_65.1%)]">{s.port ?? '—'}</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`text-xs px-2 py-1 rounded ${s.status === 'active' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{s.status ?? 'unknown'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">{stacks.length} stack{stacks.length !== 1 ? 's' : ''}</p>
|
||||
<button
|
||||
onClick={() => qc.invalidateQueries({ queryKey: ['services'] })}
|
||||
className="p-2 rounded-lg text-[hsl(215_20.2%_65.1%)] hover:bg-[hsl(217.2_32.6%_17.5%)] hover:text-white transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-center py-10 text-[hsl(215_20.2%_65.1%)] text-sm">Loading...</div>
|
||||
)}
|
||||
{!isLoading && stacks.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<Layers className="w-10 h-10 mx-auto mb-3 text-[hsl(215_20.2%_40%)]" />
|
||||
<p className="text-sm text-[hsl(215_20.2%_65.1%)]">No services found.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stacks.map((stack: any) => (
|
||||
<div key={stack.id} className="rounded-xl border border-[hsl(217.2_32.6%_17.5%)] bg-[hsl(222.2_84%_6%)] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[hsl(217.2_32.6%_17.5%)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Layers className="w-4 h-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-white">{stack.name}</span>
|
||||
<span className="text-xs text-[hsl(215_20.2%_65.1%)] px-2 py-0.5 rounded bg-[hsl(217.2_32.6%_17.5%)]">{stack.type}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-[hsl(215_20.2%_65.1%)]">{stack.running}/{stack.total} running</span>
|
||||
<StackStatus status={stack.status} />
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[hsl(217.2_32.6%_17.5%)]">
|
||||
{['Service', 'Container', 'Image', 'State'].map(h => (
|
||||
<th key={h} className="text-left px-5 py-2 text-xs font-medium text-[hsl(215_20.2%_40%)] uppercase tracking-wider">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stack.services.map((svc: any) => (
|
||||
<tr key={svc.name} className="border-t border-[hsl(217.2_32.6%_17.5%)] hover:bg-[hsl(217.2_32.6%_10%)]">
|
||||
<td className="px-5 py-3 text-sm font-medium text-white">{svc.name}</td>
|
||||
<td className="px-5 py-3 text-xs text-[hsl(215_20.2%_65.1%)] font-mono">{svc.container}</td>
|
||||
<td className="px-5 py-3 text-xs text-[hsl(215_20.2%_65.1%)] font-mono max-w-[200px] truncate" title={svc.image}>{svc.image}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={`text-xs px-2 py-1 rounded ${svc.state === 'running' ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
||||
{svc.state}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user