diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index a45de1a..3c02939 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -55,6 +55,8 @@ services: container_name: quizroom ports: - "4500:4500" + volumes: + - /var/run/docker.sock:/var/run/docker.sock networks: - quiz_network restart: always diff --git a/quizRoom/docker-compose.yml b/quizRoom/docker-compose.yml index bf98e56..aa43c9f 100644 --- a/quizRoom/docker-compose.yml +++ b/quizRoom/docker-compose.yml @@ -8,6 +8,8 @@ services: - PORT=${PORT:-4500} ports: - "${PORT:-4500}:${PORT:-4500}" + volumes: + - /var/run/docker.sock:/var/run/docker.sock environment: - PORT=${PORT:-4500} - ROOM_ID=${ROOM_ID} diff --git a/quizRoom/package-lock.json b/quizRoom/package-lock.json index fd913e2..105118b 100644 --- a/quizRoom/package-lock.json +++ b/quizRoom/package-lock.json @@ -9,17 +9,24 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "dockerode": "^4.0.2", "dotenv": "^16.4.5", "express": "^4.21.1", "http": "^0.0.1-security", "socket.io": "^4.8.1" }, "devDependencies": { + "@types/dockerode": "^3.3.32", "@types/express": "^5.0.0", "ts-node": "^10.9.2", "typescript": "^5.6.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==" + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -118,6 +125,27 @@ "@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, + "dependencies": { + "@types/node": "*", + "@types/ssh2": "*" + } + }, + "node_modules/@types/dockerode": { + "version": "3.3.32", + "resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-3.3.32.tgz", + "integrity": "sha512-xxcG0g5AWKtNyh7I7wswLdFvym4Mlqks5ZlKzxEUrGHS0r0PUOfxm2T0mspwu10mHQqu3Ck3MI3V2HqvLWE1fg==", + "dev": true, + "dependencies": { + "@types/docker-modem": "*", + "@types/node": "*", + "@types/ssh2": "*" + } + }, "node_modules/@types/express": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.0.tgz", @@ -195,6 +223,30 @@ "@types/send": "*" } }, + "node_modules/@types/ssh2": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.1.tgz", + "integrity": "sha512-ZIbEqKAsi5gj35y4P4vkJYly642wIbY6PqoN0xiyQGshKUGXR9WQjF/iF9mXBQ8uBKy3ezfsCkcoHKhd0BzuDA==", + "dev": true, + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.67", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.67.tgz", + "integrity": "sha512-wI8uHusga+0ZugNp0Ol/3BqQfEcCCNfojtO6Oou9iVNGPTL6QNSdnUdqq85fRgIorLhLMuPIKpsN98QE9Nh+KQ==", + "dev": true, + "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 + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -243,6 +295,33 @@ "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==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "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" + } + ] + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -251,6 +330,24 @@ "node": "^4.5.0 || >= 5.9" } }, + "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==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -290,6 +387,38 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "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" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "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", @@ -318,6 +447,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -365,6 +499,20 @@ "node": ">= 0.10" } }, + "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/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -432,6 +580,33 @@ "node": ">=0.3.1" } }, + "node_modules/docker-modem": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz", + "integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz", + "integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "docker-modem": "^5.0.3", + "tar-fs": "~2.0.1" + }, + "engines": { + "node": ">= 8.0" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -458,6 +633,14 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/engine.io": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz", @@ -639,6 +822,11 @@ "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==" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -760,6 +948,25 @@ "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" + } + ] + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -839,11 +1046,22 @@ "node": ">= 0.6" } }, + "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==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "node_modules/nan": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "optional": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -884,6 +1102,14 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -912,6 +1138,15 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -951,6 +1186,19 @@ "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==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1119,6 +1367,28 @@ "node": ">=10.0.0" } }, + "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==" + }, + "node_modules/ssh2": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz", + "integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==", + "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.20.0" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -1128,6 +1398,40 @@ "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==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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==", + "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==", + "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/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1180,6 +1484,11 @@ } } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1220,6 +1529,11 @@ "node": ">= 0.8" } }, + "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==" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1243,6 +1557,11 @@ "node": ">= 0.8" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", diff --git a/quizRoom/package.json b/quizRoom/package.json index b4fb43e..fb9f258 100644 --- a/quizRoom/package.json +++ b/quizRoom/package.json @@ -12,11 +12,13 @@ "license": "ISC", "description": "", "devDependencies": { + "@types/dockerode": "^3.3.32", "@types/express": "^5.0.0", "ts-node": "^10.9.2", "typescript": "^5.6.3" }, "dependencies": { + "dockerode": "^4.0.2", "dotenv": "^16.4.5", "express": "^4.21.1", "http": "^0.0.1-security", diff --git a/quizRoom/socket/setupWebSocket.ts b/quizRoom/socket/setupWebSocket.ts index a04a694..3ed5a24 100644 --- a/quizRoom/socket/setupWebSocket.ts +++ b/quizRoom/socket/setupWebSocket.ts @@ -1,5 +1,5 @@ import { Server, Socket } from "socket.io"; -import os from "os"; +import Docker from 'dockerode'; import fs from 'fs'; const MAX_USERS_PER_ROOM = 60; @@ -113,171 +113,116 @@ export const setupWebsocket = (io: Server): void => { socket.to(roomName).emit("message-sent-student", { message }); }); + interface ContainerStats { + containerId: string; + containerName: string; + memoryUsedMB: number | null; + memoryUsedPercentage: number | null; + cpuUsedPercentage: number | null; + error?: string; + } + class ContainerMetrics { - private totalSystemMemory = os.totalmem(); - private cgroupv2 = this.isCgroupV2(); - private lastCPUUsage = 0; - private lastCPUTime = Date.now(); + private docker: Docker; + private containerName: string; - private isCgroupV2(): boolean { - return fs.existsSync('/sys/fs/cgroup/cgroup.controllers'); + private bytesToMB(bytes: number): number { + return Math.round(bytes / (1024 * 1024)); } - private readCgroupFile(filepath: string): string { - try { - return fs.readFileSync(filepath, 'utf-8').trim(); - } catch (error) { - console.debug(`Could not read ${filepath}`); - return ''; - } + constructor() { + this.docker = new Docker({ + socketPath: process.platform === 'win32' ? '//./pipe/docker_engine' : '/var/run/docker.sock' + }); + this.containerName = `room_${process.env.ROOM_ID}`; } - private getCgroupCPUUsage(): number { - try { - if (this.cgroupv2) { - const usage = this.readCgroupFile('/sys/fs/cgroup/cpu.stat'); - const usageMatch = usage.match(/usage_usec\s+(\d+)/); - if (usageMatch) { - const currentUsage = Number(usageMatch[1]) / 1000000; - const currentTime = Date.now(); - const cpuDelta = currentUsage - this.lastCPUUsage; - const timeDelta = (currentTime - this.lastCPUTime) / 1000; - - this.lastCPUUsage = currentUsage; - this.lastCPUTime = currentTime; - - return (cpuDelta / timeDelta) * 100; - } - } - - const cgroupV1Paths = [ - '/sys/fs/cgroup/cpu/cpuacct.usage', - '/sys/fs/cgroup/cpuacct/cpuacct.usage', - '/sys/fs/cgroup/cpu,cpuacct/cpuacct.usage' - ]; - - for (const path of cgroupV1Paths) { - const usage = this.readCgroupFile(path); - if (usage) { - const currentUsage = Number(usage) / 1000000000; - const currentTime = Date.now(); - const cpuDelta = currentUsage - this.lastCPUUsage; - const timeDelta = (currentTime - this.lastCPUTime) / 1000; - - this.lastCPUUsage = currentUsage; - this.lastCPUTime = currentTime; - - return (cpuDelta / timeDelta) * 100; - } - } - - return this.getFallbackCPUUsage(); - } catch (error) { - return this.getFallbackCPUUsage(); - } + private async getContainerNetworks(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + const info = await container.inspect(); + return Object.keys(info.NetworkSettings.Networks); } - private getFallbackCPUUsage(): number { + public async getAllContainerStats(): Promise { try { - const cpus = os.cpus(); - return cpus.reduce((acc, cpu) => { - const total = Object.values(cpu.times).reduce((a, b) => a + b); - const idle = cpu.times.idle; - return acc + ((total - idle) / total) * 100; - }, 0) / cpus.length; - } catch (error) { - console.error('Error getting fallback CPU usage:', error); - return 0; - } - } + // First get our container to find its networks + const ourContainer = await this.docker.listContainers({ + all: true, + filters: { name: [this.containerName] } + }); - private getCgroupMemoryUsage(): { used: number; limit: number } | null { - try { - // First get process memory as baseline - const processMemory = process.memoryUsage(); - const baselineMemory = processMemory.rss; - - if (this.cgroupv2) { - const memUsage = Number(this.readCgroupFile('/sys/fs/cgroup/memory.current')); - if (!isNaN(memUsage) && memUsage > 0) { - return { - used: Math.max(baselineMemory, memUsage), - limit: this.totalSystemMemory - }; - } + if (!ourContainer.length) { + throw new Error(`Container ${this.containerName} not found`); } - // Try cgroup v1 - const v1Paths = { - usage: '/sys/fs/cgroup/memory/memory.usage_in_bytes', - limit: '/sys/fs/cgroup/memory/memory.limit_in_bytes' - }; + const ourNetworks = await this.getContainerNetworks(ourContainer[0].Id); - const memoryUsage = Number(this.readCgroupFile(v1Paths.usage)); - if (!isNaN(memoryUsage) && memoryUsage > 0) { - return { - used: Math.max(baselineMemory, memoryUsage), - limit: this.totalSystemMemory - }; - } - - // Fallback to process memory - return { - used: baselineMemory, - limit: this.totalSystemMemory - }; + // Get all containers + const allContainers = await this.docker.listContainers(); + // Get stats for containers on the same networks + const containerStats = await Promise.all( + allContainers.map(async (container): Promise => { + try { + const containerNetworks = await this.getContainerNetworks(container.Id); + // Check if container shares any network with our container + if (!containerNetworks.some(network => ourNetworks.includes(network))) { + return null; + } + + const stats = await this.docker.getContainer(container.Id).stats({ stream: false }); + + const memoryStats = { + usage: stats.memory_stats.usage, + limit: stats.memory_stats.limit || 0, + percent: stats.memory_stats.limit ? (stats.memory_stats.usage / stats.memory_stats.limit) * 100 : 0 + }; + + const cpuDelta = stats.cpu_stats?.cpu_usage?.total_usage - (stats.precpu_stats?.cpu_usage?.total_usage || 0); + const systemDelta = stats.cpu_stats?.system_cpu_usage - (stats.precpu_stats?.system_cpu_usage || 0); + const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * (stats.cpu_stats?.online_cpus || 1) * 100 : 0; + + return { + containerId: container.Id, + containerName: container.Names[0].replace(/^\//, ''), + memoryUsedMB: this.bytesToMB(memoryStats.usage), + memoryUsedPercentage: memoryStats.percent, + cpuUsedPercentage: cpuPercent + }; + } catch (error) { + return { + containerId: container.Id, + containerName: container.Names[0].replace(/^\//, ''), + memoryUsedMB: null, + memoryUsedPercentage: null, + cpuUsedPercentage: null, + error: error instanceof Error ? error.message : String(error) + }; + } + }) + ); + + // Change the filter to use proper type predicate + return containerStats.filter((stats): stats is ContainerStats => stats !== null); } catch (error) { - console.debug('Error reading cgroup memory:', error); - return null; - } - } - - public getMetrics() { - try { - const mbFactor = 1024 * 1024; - let memoryData = this.getCgroupMemoryUsage(); - - if (!memoryData) { - const processMemory = process.memoryUsage(); - memoryData = { - used: processMemory.rss, - limit: this.totalSystemMemory - }; - } - - const memoryUsedMB = memoryData.used / mbFactor; - const memoryTotalMB = memoryData.limit / mbFactor; - const memoryPercentage = (memoryData.used / memoryData.limit) * 100; - - console.debug(` - Memory Usage: ${memoryUsedMB.toFixed(2)} MB - Memory Total: ${memoryTotalMB.toFixed(2)} MB - Memory %: ${memoryPercentage.toFixed(2)}% - `); - - return { - memoryUsedMB: memoryUsedMB.toFixed(2), - memoryUsedPercentage: memoryPercentage.toFixed(2), - cpuUsedPercentage: this.getCgroupCPUUsage().toFixed(2) - }; - } catch (error) { - console.error("Error getting metrics:", error); - return { - memoryUsedMB: "0", - memoryUsedPercentage: "0", - cpuUsedPercentage: "0" - }; + console.error('Stats error:', error); + return [{ + containerId: 'unknown', + containerName: 'unknown', + memoryUsedMB: null, + memoryUsedPercentage: null, + cpuUsedPercentage: null, + error: error instanceof Error ? error.message : String(error) + }]; } } } - // Usage in WebSocket setup const containerMetrics = new ContainerMetrics(); - socket.on("get-usage", () => { + socket.on("get-usage", async () => { try { - const usageData = containerMetrics.getMetrics(); + const usageData = await containerMetrics.getAllContainerStats(); socket.emit("usage-data", usageData); } catch (error) { socket.emit("error", { message: "Failed to retrieve usage data" }); diff --git a/server/roomsProviders/docker-provider.js b/server/roomsProviders/docker-provider.js index 9312e7b..1da5c3b 100644 --- a/server/roomsProviders/docker-provider.js +++ b/server/roomsProviders/docker-provider.js @@ -99,7 +99,10 @@ class DockerRoomProvider extends BaseRoomProvider { NetworkMode: this.docker_network, RestartPolicy: { Name: 'unless-stopped' - } + }, + Binds: [ + '/var/run/docker.sock:/var/run/docker.sock' + ] }, Env: [ `ROOM_ID=${roomId}`, diff --git a/test/stressTest/.dockerignore b/test/stressTest/.dockerignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/test/stressTest/.dockerignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/test/stressTest/.env.example b/test/stressTest/.env.example index 633e2c3..254f2d4 100644 --- a/test/stressTest/.env.example +++ b/test/stressTest/.env.example @@ -1,8 +1,19 @@ -BASE_URL=http://host.docker.internal #via Docker -BASE_URL=http://localhost # Via npm +# Target url +BASE_URL=http://msevignyl.duckdns.org + +# Connection account USER_EMAIL=admin@admin.com USER_PASSWORD=admin + +# Stress test parameters NUMBER_ROOMS=5 USERS_PER_ROOM=60 -MAX_MESSAGES=20 -CONVERSATION_INTERVAL=1000 \ No newline at end of file + +# Optionnal + + +MAX_MESSAGES_ROUND=20 +CONVERSATION_INTERVAL=1000 +MESSAGE_RESPONSE_TIMEOUT=5000 +BATCH_DELAY=1000 +BATCH_SIZE=10 \ No newline at end of file diff --git a/test/stressTest/README.md b/test/stressTest/README.md new file mode 100644 index 0000000..fbed7e1 --- /dev/null +++ b/test/stressTest/README.md @@ -0,0 +1,51 @@ +# Test de Charge - EvalueTonSavoir + +Ce conteneur permet d'exécuter des tests de charge sur l'application EvalueTonSavoir. + +## Prérequis + +- Docker +- Docker Compose + +## Configuration + +1. Créez un fichier `.env` à partir du modèle `.env.example`: + +```bash +copy .env.example .env +``` + +2. Modifiez les variables dans le fichier .env: + +```bash +# URL de l'application cible +BASE_URL=http://votre-url.com + +# Compte de connexion +USER_EMAIL=admin@admin.com +USER_PASSWORD=admin + +# Paramètres du test de charge +NUMBER_ROOMS=5 # Nombre de salles à créer +USERS_PER_ROOM=60 # Nombre d'utilisateurs par salle + +``` +#### Paramètres optionnels +Dans le fichier .env, vous pouvez aussi configurer: + +```bash +MAX_MESSAGES_ROUND=20 # Messages maximum par cycle +CONVERSATION_INTERVAL=1000 # Intervalle entre les messages (ms) +MESSAGE_RESPONSE_TIMEOUT=5000 # Timeout des réponses (ms) +BATCH_DELAY=1000 # Délai entre les lots (ms) +BATCH_SIZE=10 # Taille des lots d'utilisateurs +``` + +## Démarrage +Pour lancer le test de charge: + +Les résultats seront disponibles dans le dossier output/. + +```bash +docker compose up +``` diff --git a/test/stressTest/class/watcher.js b/test/stressTest/class/watcher.js index 3bc1fce..e770e35 100644 --- a/test/stressTest/class/watcher.js +++ b/test/stressTest/class/watcher.js @@ -22,18 +22,34 @@ export class Watcher extends RoomParticipant { try { this.socket.emit("get-usage"); this.socket.once("usage-data", (data) => { - //console.log(`Watcher ${this.username} received data:`, data); - this.roomRessourcesData.push({ timestamp: Date.now(), ...data }); + const timestamp = Date.now(); + // Store each container's metrics separately with timestamp + data.forEach(containerStat => { + const existingData = this.roomRessourcesData.find(d => d.containerId === containerStat.containerId); + if (existingData) { + existingData.metrics.push({ + timestamp, + ...containerStat + }); + } else { + this.roomRessourcesData.push({ + containerId: containerStat.containerId, + containerName: containerStat.containerName, + metrics: [{ + timestamp, + ...containerStat + }] + }); + } + }); }); } catch (error) { console.warn(`Error capturing metrics for room ${this.roomName}:`, error.message); } - } else { - console.warn(`Socket not connected for room ${this.roomName}`); } } - startCheckingResources(intervalMs = 250) { + startCheckingResources(intervalMs = 500) { if (this.checkRessourceInterval) { console.warn(`Resource checking is already running for room ${this.roomName}.`); return; diff --git a/test/stressTest/docker-compose.yml b/test/stressTest/docker-compose.yml index f809588..51f4707 100644 --- a/test/stressTest/docker-compose.yml +++ b/test/stressTest/docker-compose.yml @@ -7,16 +7,10 @@ services: context: . dockerfile: Dockerfile container_name: stress-test - networks: - - quiz_network + network_mode: host env_file: - .env - ports: - - "9229:9229" + volumes: + - ./output:/app/output tty: true - stdin_open: true - #command: node --inspect=0.0.0.0:9229 main.js - -networks: - quiz_network: - driver: bridge \ No newline at end of file + stdin_open: true \ No newline at end of file diff --git a/test/stressTest/main.js b/test/stressTest/main.js index c066d3f..ca79b85 100644 --- a/test/stressTest/main.js +++ b/test/stressTest/main.js @@ -15,21 +15,22 @@ const config = { password: process.env.USER_PASSWORD || 'admin' }, rooms: { - count: parseInt(process.env.NUMBER_ROOMS || '2'), + count: parseInt(process.env.NUMBER_ROOMS || '15'), usersPerRoom: parseInt(process.env.USERS_PER_ROOM || '60'), - batchSize: 5, - batchDelay: 250 + batchSize: parseInt(process.env.BATCH_SIZE || 5), + batchDelay: parseInt(process.env.BATCH_DELAY || 250) }, simulation: { - maxMessages: parseInt(process.env.MAX_MESSAGES || '20'), + maxMessages: parseInt(process.env.MAX_MESSAGES_ROUND || '20'), messageInterval: parseInt(process.env.CONVERSATION_INTERVAL || '1000'), - responseTimeout: 5000 + responseTimeout: parseInt(process.env.MESSAGE_RESPONSE_TIMEOUT || 5000) } }; const rooms = new Map(); const metrics = new TestMetrics(); +// Changes to setupRoom function async function setupRoom(token, index) { try { const room = await createRoomContainer(config.baseUrl, token); @@ -37,8 +38,9 @@ async function setupRoom(token, index) { metrics.roomsCreated++; const teacher = new Teacher(`teacher_${index}`, room.id); - const watcher = new Watcher(`watcher_${index}`, room.id); - + // Only create watcher for first room (index 0) + const watcher = index === 0 ? new Watcher(`watcher_${index}`, room.id) : null; + await Promise.all([ teacher.connectToRoom(config.baseUrl) .then(() => metrics.usersConnected++) @@ -47,16 +49,24 @@ async function setupRoom(token, index) { metrics.logError('teacherConnection', err); console.warn(`Teacher ${index} connection failed:`, err.message); }), - watcher.connectToRoom(config.baseUrl) - .then(() => metrics.usersConnected++) - .catch(err => { - metrics.userConnectionsFailed++; - metrics.logError('watcherConnection', err); - console.warn(`Watcher ${index} connection failed:`, err.message); - }) + // Only connect watcher if it exists + ...(watcher ? [ + watcher.connectToRoom(config.baseUrl) + .then(() => metrics.usersConnected++) + .catch(err => { + metrics.userConnectionsFailed++; + metrics.logError('watcherConnection', err); + console.warn(`Watcher ${index} connection failed:`, err.message); + }) + ] : []) ]); - const students = Array.from({ length: config.rooms.usersPerRoom - 2 }, + // Adjust number of students based on whether room has a watcher + const studentCount = watcher ? + config.rooms.usersPerRoom - 2 : // Room with watcher: subtract teacher and watcher + config.rooms.usersPerRoom - 1; // Rooms without watcher: subtract only teacher + + const students = Array.from({ length: studentCount }, (_, i) => new Student(`student_${index}_${i}`, room.id)); rooms.set(room.id, { teacher, watcher, students }); @@ -115,7 +125,7 @@ async function simulate() { } }, 100); }), - new Promise((_, reject) => + new Promise((_, reject) => setTimeout(() => reject(new Error('Response timeout')), config.simulation.responseTimeout) ) ]); @@ -133,13 +143,14 @@ async function simulate() { } async function generateReport() { - const data = Object.fromEntries( - Array.from(rooms.entries()).map(([id, { watcher }]) => [ - id, - watcher.roomRessourcesData - ]) - ); - return generateMetricsReport(data,metrics); + const watcherRoom = Array.from(rooms.entries()).find(([_, room]) => room.watcher); + if (!watcherRoom) { + throw new Error('No watcher found in any room'); + } + const data = { + [watcherRoom[0]]: watcherRoom[1].watcher.roomRessourcesData + }; + return generateMetricsReport(data, metrics); } function cleanup() { @@ -178,6 +189,8 @@ async function main() { } catch (error) { metrics.logError('main', error); console.error('Error:', error.message); + } finally { + cleanup(); } } diff --git a/test/stressTest/utility/metrics_generator.js b/test/stressTest/utility/metrics_generator.js index e7d903a..022ff5b 100644 --- a/test/stressTest/utility/metrics_generator.js +++ b/test/stressTest/utility/metrics_generator.js @@ -2,126 +2,6 @@ import fs from 'fs'; import path from 'path'; import { ChartJSNodeCanvas } from 'chartjs-node-canvas'; -function ensureDirectoryExists(directory) { - if (!fs.existsSync(directory)) { - fs.mkdirSync(directory, { recursive: true }); - } -} - -async function saveChart(chartJSNodeCanvas, data, xLabel, yLabel, outputFile, title) { - const chartConfig = { - type: 'line', - data, - options: { - scales: { - x: { - title: { display: true, text: xLabel } - }, - y: { - title: { display: true, text: yLabel } - } - }, - plugins: { - legend: { - display: true, - position: 'top' - } - } - } - }; - - const buffer = await chartJSNodeCanvas.renderToBuffer(chartConfig); - fs.writeFileSync(outputFile, buffer); -} - -async function generateRoomGraphs(roomId, validRoomData, chartJSNodeCanvas, roomDir) { - const timeLabels = validRoomData.map(d => new Date(parseInt(d.timestamp)).toLocaleTimeString()); - - await Promise.all([ - // Room Memory Usage (MB) - saveChart(chartJSNodeCanvas, { - labels: timeLabels, - datasets: [{ - label: `Room ${roomId} Memory (MB)`, - data: validRoomData.map(d => parseFloat(d.memoryUsedMB || 0)), - borderColor: 'blue', - backgroundColor: 'rgba(54, 162, 235, 0.2)', - fill: true, - tension: 0.4 - }] - }, 'Time', 'Memory Usage (MB)', path.join(roomDir, 'memory-usage-mb.png'), - `Room ${roomId} Memory Usage in MB`), - - // Room Memory Usage (Percentage) - saveChart(chartJSNodeCanvas, { - labels: timeLabels, - datasets: [{ - label: `Room ${roomId} Memory %`, - data: validRoomData.map(d => parseFloat(d.memoryUsedPercentage || 0)), - borderColor: 'green', - backgroundColor: 'rgba(75, 192, 192, 0.2)', - fill: true, - tension: 0.4 - }] - }, 'Time', 'Memory Usage %', path.join(roomDir, 'memory-usage-percent.png'), - `Room ${roomId} Memory Usage Percentage`), - - // Room CPU Usage - saveChart(chartJSNodeCanvas, { - labels: timeLabels, - datasets: [{ - label: `Room ${roomId} CPU Usage %`, - data: validRoomData.map(d => parseFloat(d.cpuUsedPercentage || 0)), - borderColor: 'red', - backgroundColor: 'rgba(255, 99, 132, 0.2)', - fill: true, - tension: 0.4 - }] - }, 'Time', 'CPU Usage %', path.join(roomDir, 'cpu-usage.png'), - `Room ${roomId} CPU Impact`) - ]); -} - -async function generateGlobalGraphs(data, chartJSNodeCanvas, globalMetricsDir) { - await Promise.all([ - saveChart(chartJSNodeCanvas, { - labels: data.labels, - datasets: [{ - label: 'Total System Memory Used (MB)', - data: data.memoryMB, - borderColor: 'blue', - backgroundColor: 'rgba(54, 162, 235, 0.2)', - fill: true, - tension: 0.4 - }] - }, 'Time', 'Total Memory Usage (MB)', path.join(globalMetricsDir, 'total-system-memory-mb.png')), - - saveChart(chartJSNodeCanvas, { - labels: data.labels, - datasets: [{ - label: 'Total System Memory Used %', - data: data.memoryPercentage, - borderColor: 'green', - backgroundColor: 'rgba(75, 192, 192, 0.2)', - fill: true, - tension: 0.4 - }] - }, 'Time', 'Total Memory Usage %', path.join(globalMetricsDir, 'total-system-memory-percent.png')), - - saveChart(chartJSNodeCanvas, { - labels: data.labels, - datasets: [{ - label: 'Total System CPU Usage %', - data: data.cpuPercentage, - borderColor: 'red', - backgroundColor: 'rgba(255, 99, 132, 0.2)', - fill: true, - tension: 0.4 - }] - }, 'Time', 'Total CPU Usage %', path.join(globalMetricsDir, 'total-system-cpu.png')) - ]); -} - async function saveMetricsSummary(metrics, baseOutputDir) { const metricsData = metrics.getSummary(); @@ -167,121 +47,207 @@ ${Object.entries(metricsData.errors) ); } +// Common chart configurations +const CHART_CONFIG = { + width: 800, + height: 400, + chartStyles: { + memory: { + borderColor: 'blue', + backgroundColor: 'rgba(54, 162, 235, 0.2)' + }, + memoryPercent: { + borderColor: 'green', + backgroundColor: 'rgba(75, 192, 192, 0.2)' + }, + cpu: { + borderColor: 'red', + backgroundColor: 'rgba(255, 99, 132, 0.2)' + } + } +}; + +const createBaseChartConfig = (labels, dataset, xLabel, yLabel) => ({ + type: 'line', + data: { + labels, + datasets: [dataset] + }, + options: { + scales: { + x: { title: { display: true, text: xLabel }}, + y: { title: { display: true, text: yLabel }} + }, + plugins: { + legend: { display: true, position: 'top' } + } + } +}); + +function ensureDirectoryExists(directory) { + !fs.existsSync(directory) && fs.mkdirSync(directory, { recursive: true }); +} + +async function generateMetricsChart(chartJSNodeCanvas, data, style, label, timeLabels, metric, outputPath) { + const dataset = { + label, + data: data.map(m => m[metric] || 0), + ...CHART_CONFIG.chartStyles[style], + fill: true, + tension: 0.4 + }; + + const buffer = await chartJSNodeCanvas.renderToBuffer( + createBaseChartConfig(timeLabels, dataset, 'Time', label) + ); + + return fs.promises.writeFile(outputPath, buffer); +} + +async function generateContainerCharts(chartJSNodeCanvas, containerData, outputDir) { + const timeLabels = containerData.metrics.map(m => + new Date(m.timestamp).toLocaleTimeString() + ); + + const chartPromises = [ + generateMetricsChart( + chartJSNodeCanvas, + containerData.metrics, + 'memory', + `${containerData.containerName} Memory (MB)`, + timeLabels, + 'memoryUsedMB', + path.join(outputDir, 'memory-usage-mb.png') + ), + generateMetricsChart( + chartJSNodeCanvas, + containerData.metrics, + 'memoryPercent', + `${containerData.containerName} Memory %`, + timeLabels, + 'memoryUsedPercentage', + path.join(outputDir, 'memory-usage-percent.png') + ), + generateMetricsChart( + chartJSNodeCanvas, + containerData.metrics, + 'cpu', + `${containerData.containerName} CPU %`, + timeLabels, + 'cpuUsedPercentage', + path.join(outputDir, 'cpu-usage.png') + ) + ]; + + await Promise.all(chartPromises); +} + +async function processSummaryMetrics(containers, timeLabels) { + return timeLabels.map((_, timeIndex) => ({ + memoryUsedMB: containers.reduce((sum, container) => + sum + (container.metrics[timeIndex]?.memoryUsedMB || 0), 0), + memoryUsedPercentage: containers.reduce((sum, container) => + sum + (container.metrics[timeIndex]?.memoryUsedPercentage || 0), 0), + cpuUsedPercentage: containers.reduce((sum, container) => + sum + (container.metrics[timeIndex]?.cpuUsedPercentage || 0), 0) + })); +} + export default async function generateMetricsReport(allRoomsData, testMetrics) { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const baseOutputDir = `./output/${timestamp}`; - ensureDirectoryExists(baseOutputDir); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const baseOutputDir = `./output/${timestamp}`; + const dirs = [ + baseOutputDir, + path.join(baseOutputDir, 'all-containers'), + path.join(baseOutputDir, 'all-rooms') + ]; + + dirs.forEach(ensureDirectoryExists); - if (testMetrics) { - await saveMetricsSummary(testMetrics, baseOutputDir); - } - - const globalMetricsDir = path.join(baseOutputDir, 'global'); - ensureDirectoryExists(globalMetricsDir); - - const chartJSNodeCanvas = new ChartJSNodeCanvas({ width: 800, height: 400 }); - - // Process individual room graphs first - for (const [roomId, roomData] of Object.entries(allRoomsData)) { - if (!Array.isArray(roomData)) { - console.warn(`Invalid data format for room ${roomId}`); - continue; + if (testMetrics) { + await saveMetricsSummary(testMetrics, baseOutputDir); } - const roomDir = path.join(baseOutputDir, `room_${roomId}`); - ensureDirectoryExists(roomDir); + const chartJSNodeCanvas = new ChartJSNodeCanvas(CHART_CONFIG); + const allContainers = Object.values(allRoomsData).flat(); + const roomContainers = allContainers.filter(c => + c.containerName.startsWith('room_') + ); - const validRoomData = roomData.filter(d => { - const isValid = d && d.timestamp && - typeof d.memoryUsedMB !== 'undefined' && - typeof d.memoryUsedPercentage !== 'undefined' && - typeof d.cpuUsedPercentage !== 'undefined'; - if (!isValid) { - console.warn(`Invalid metric data in room ${roomId}:`, d); - } - return isValid; - }); - - if (validRoomData.length === 0) { - console.warn(`No valid data for room ${roomId}`); - continue; + if (allContainers.length > 0) { + const timeLabels = allContainers[0].metrics.map(m => + new Date(m.timestamp).toLocaleTimeString() + ); + const summedMetrics = await processSummaryMetrics(allContainers, timeLabels); + await generateContainerSummaryCharts( + chartJSNodeCanvas, + summedMetrics, + timeLabels, + path.join(baseOutputDir, 'all-containers') + ); } - await generateRoomGraphs(roomId, validRoomData, chartJSNodeCanvas, roomDir); + if (roomContainers.length > 0) { + const timeLabels = roomContainers[0].metrics.map(m => + new Date(m.timestamp).toLocaleTimeString() + ); + const summedMetrics = await processSummaryMetrics(roomContainers, timeLabels); + await generateContainerSummaryCharts( + chartJSNodeCanvas, + summedMetrics, + timeLabels, + path.join(baseOutputDir, 'all-rooms') + ); + } + + // Process individual containers + const containerPromises = Object.values(allRoomsData) + .flat() + .filter(container => !container.containerName.startsWith('room_')) + .map(async containerData => { + const containerDir = path.join(baseOutputDir, containerData.containerName); + ensureDirectoryExists(containerDir); + await generateContainerCharts(chartJSNodeCanvas, containerData, containerDir); + }); + + await Promise.all(containerPromises); + + return { outputDir: baseOutputDir }; + } catch (error) { + console.error('Error generating metrics report:', error); + throw error; } +} - - - // Process global metrics with time-based averaging - const timeWindows = {}; - const timeInterval = 1000; // 250ms windows - const totalRooms = Object.keys(allRoomsData).length; - - // Group data into time windows - Object.entries(allRoomsData).forEach(([roomId, roomData]) => { - if (!Array.isArray(roomData)) return; - - roomData.forEach(metric => { - if (!metric?.timestamp) return; - - const timeWindow = Math.floor(parseInt(metric.timestamp) / timeInterval) * timeInterval; - - if (!timeWindows[timeWindow]) { - timeWindows[timeWindow] = { - rooms: new Map(), - roomCount: 0 - }; - } - - if (!timeWindows[timeWindow].rooms.has(roomId)) { - timeWindows[timeWindow].rooms.set(roomId, { - memoryMB: [], - memoryPercentage: [], - cpuPercentage: [] - }); - timeWindows[timeWindow].roomCount++; - } - - const roomMetrics = timeWindows[timeWindow].rooms.get(roomId); - roomMetrics.memoryMB.push(parseFloat(metric.memoryUsedMB || 0)); - roomMetrics.memoryPercentage.push(parseFloat(metric.memoryUsedPercentage || 0)); - roomMetrics.cpuPercentage.push(parseFloat(metric.cpuUsedPercentage || 0)); - }); - }); - - // Process only windows with data from all rooms - const globalMetrics = Object.entries(timeWindows) - .filter(([_, data]) => data.roomCount === totalRooms) // Only windows with all rooms - .map(([timestamp, data]) => { - const totals = Array.from(data.rooms.values()).reduce((acc, room) => { - // Calculate room averages - const memoryMBAvg = room.memoryMB.reduce((a, b) => a + b, 0) / room.memoryMB.length; - const memoryPercentageAvg = room.memoryPercentage.reduce((a, b) => a + b, 0) / room.memoryPercentage.length; - const cpuPercentageAvg = room.cpuPercentage.reduce((a, b) => a + b, 0) / room.cpuPercentage.length; - - // Sum room averages - return { - memoryMB: acc.memoryMB + memoryMBAvg, - memoryPercentage: acc.memoryPercentage + memoryPercentageAvg, - cpuPercentage: acc.cpuPercentage + cpuPercentageAvg - }; - }, { memoryMB: 0, memoryPercentage: 0, cpuPercentage: 0 }); - - return { - timestamp: parseInt(timestamp), - ...totals - }; - }) - .sort((a, b) => a.timestamp - b.timestamp); - - // Generate global graphs with complete window data - const timeLabels = globalMetrics.map(d => new Date(d.timestamp).toLocaleTimeString()); - await generateGlobalGraphs({ - labels: timeLabels, - memoryMB: globalMetrics.map(d => d.memoryMB), - memoryPercentage: globalMetrics.map(d => d.memoryPercentage), - cpuPercentage: globalMetrics.map(d => d.cpuPercentage) - }, chartJSNodeCanvas, globalMetricsDir); - - return { outputDir: baseOutputDir }; +async function generateContainerSummaryCharts(chartJSNodeCanvas, metrics, timeLabels, outputDir) { + await Promise.all([ + generateMetricsChart( + chartJSNodeCanvas, + metrics, + 'memory', + 'Total Memory (MB)', + timeLabels, + 'memoryUsedMB', + path.join(outputDir, 'total-memory-usage-mb.png') + ), + generateMetricsChart( + chartJSNodeCanvas, + metrics, + 'memoryPercent', + 'Total Memory %', + timeLabels, + 'memoryUsedPercentage', + path.join(outputDir, 'total-memory-usage-percent.png') + ), + generateMetricsChart( + chartJSNodeCanvas, + metrics, + 'cpu', + 'Total CPU %', + timeLabels, + 'cpuUsedPercentage', + path.join(outputDir, 'total-cpu-usage.png') + ) + ]); } \ No newline at end of file