diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 000000000..19b0e4ffd Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 2d9daf0c0..1b03cd092 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ backend/sessions backend/node_modules backend/logs backend/coverage +logs/ # ignore build files dist/ @@ -47,4 +48,4 @@ utils/rpcs/moodleAPI/moodle_api/__pycache__ *.pyc email-mappings.py gitleaks-clean.sh -.mailmap \ No newline at end of file +.mailmap.DS_Store diff --git a/backend/db/migrations/20260322000001-extend-nav-socket-profiler.js b/backend/db/migrations/20260322000001-extend-nav-socket-profiler.js new file mode 100644 index 000000000..8373b85e6 --- /dev/null +++ b/backend/db/migrations/20260322000001-extend-nav-socket-profiler.js @@ -0,0 +1,50 @@ +'use strict'; + +const navElements = [ + { + name: "Socket Profiler", + groupId: "Admin", + icon: "record-circle", + order: 16, + admin: true, + path: "Socket_Profiler", + component: "SocketProfiler", + } +]; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.bulkInsert( + "nav_element", + await Promise.all( + navElements.map(async (t) => { + const groupId = await queryInterface.rawSelect( + "nav_group", + { + where: { name: t.groupId }, + }, + ["id"] + ); + + t["createdAt"] = new Date(); + t["updatedAt"] = new Date(); + t["groupId"] = groupId; + + return t; + }), + {} + ) + ); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.bulkDelete( + "nav_element", + { + name: navElements.map((t) => t.name), + }, + {} + ); + }, +}; \ No newline at end of file diff --git a/backend/db/migrations/20260412231117-create-recording.js b/backend/db/migrations/20260412231117-create-recording.js new file mode 100644 index 000000000..0b0b1e0a6 --- /dev/null +++ b/backend/db/migrations/20260412231117-create-recording.js @@ -0,0 +1,82 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('recording', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + name: { + type: Sequelize.STRING, + allowNull: true, + }, + status: { + type: Sequelize.STRING, + allowNull: false, + defaultValue: 'recording', + }, + startTime: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + endTime: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + userId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + participantUserIds: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: null, + }, + participantSocketIds: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: null, + }, + excludeEvents: { + type: Sequelize.JSONB, + allowNull: true, + defaultValue: null, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('recording'); + }, +}; \ No newline at end of file diff --git a/backend/db/migrations/20260412231119-create-trace.js b/backend/db/migrations/20260412231119-create-trace.js new file mode 100644 index 000000000..91688d59c --- /dev/null +++ b/backend/db/migrations/20260412231119-create-trace.js @@ -0,0 +1,86 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable('trace', { + id: { + type: Sequelize.INTEGER, + primaryKey: true, + autoIncrement: true, + allowNull: false, + }, + recordingId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: 'recording', + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + userId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: 'user', + key: 'id', + }, + onDelete: 'SET NULL', + onUpdate: 'CASCADE', + }, + socketId: { + type: Sequelize.STRING, + allowNull: true, + }, + action: { + type: Sequelize.STRING, + allowNull: false, + }, + payload: { + type: Sequelize.JSONB, + allowNull: true, + }, + direction: { + type: Sequelize.BOOLEAN, + allowNull: false, + }, + startTime: { + type: Sequelize.DATE, + allowNull: true, + }, + endTime: { + type: Sequelize.DATE, + allowNull: true, + }, + deleted: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: false, + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn('NOW'), + }, + }); + + await queryInterface.addIndex('trace', ['socketId'], { + name: 'trace_socketId_idx', + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.dropTable('trace'); + }, +}; \ No newline at end of file diff --git a/backend/db/migrations/20260506220447-move-socket-profiler-to-manage.js b/backend/db/migrations/20260506220447-move-socket-profiler-to-manage.js new file mode 100644 index 000000000..ec0fe7d73 --- /dev/null +++ b/backend/db/migrations/20260506220447-move-socket-profiler-to-manage.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Move Socket Profiler nav element into the Manage group. + * + * The original `extend-nav-socket-profiler` migration (March 22, 2026) + * inserted Socket Profiler into groupId 2 (Admin). The later + * `restructure-nav_group` migration (April 25, 2026) replaced the old + * Admin-centric layout with category-based groups (Home, Study, Manage, + * Settings, AI), but didn't touch Socket Profiler because it lived on + * a separate feature branch at the time. + * + * After merge, Socket Profiler ends up stranded in the legacy Admin group. + * This migration relocates it into Manage, alongside other admin + * operational tools like Users. + */ +module.exports = { + async up(queryInterface, Sequelize) { + // Look up Manage group's id by name (don't hard-code 5; safer if IDs ever shift). + const [groups] = await queryInterface.sequelize.query( + `SELECT id FROM nav_group WHERE name = 'Manage' LIMIT 1` + ); + if (!groups || groups.length === 0) { + // Manage group missing — bail out silently rather than failing migration. + return; + } + const manageId = groups[0].id; + + await queryInterface.sequelize.query( + `UPDATE nav_element SET "groupId" = :manageId, "updatedAt" = NOW() + WHERE name = 'Socket Profiler'`, + { replacements: { manageId } } + ); + }, + + async down(queryInterface, Sequelize) { + // Revert to the legacy Admin group (id 2 in the pre-restructure schema). + // If Admin no longer exists for any reason, do nothing. + const [groups] = await queryInterface.sequelize.query( + `SELECT id FROM nav_group WHERE name = 'Admin' LIMIT 1` + ); + if (!groups || groups.length === 0) return; + const adminId = groups[0].id; + + await queryInterface.sequelize.query( + `UPDATE nav_element SET "groupId" = :adminId, "updatedAt" = NOW() + WHERE name = 'Socket Profiler'`, + { replacements: { adminId } } + ); + }, +}; \ No newline at end of file diff --git a/backend/db/models/recording.js b/backend/db/models/recording.js new file mode 100644 index 000000000..b7d0f355d --- /dev/null +++ b/backend/db/models/recording.js @@ -0,0 +1,45 @@ +"use strict"; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + class Recording extends MetaModel { + static autoTable = true; + static fields = []; + static publicTable = true; + + static associate(models) { + Recording.belongsTo(models["user"], { + foreignKey: "userId", + as: "user", + }); + Recording.hasMany(models["trace"], { + foreignKey: "recordingId", + as: "traces", + }); + } + } + + Recording.init( + { + name: DataTypes.STRING, + status: DataTypes.STRING, + startTime: DataTypes.DATE, + endTime: DataTypes.DATE, + userId: DataTypes.INTEGER, + participantUserIds: DataTypes.JSONB, + participantSocketIds: DataTypes.JSONB, + excludeEvents: DataTypes.JSONB, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: "recording", + tableName: "recording", + } + ); + + return Recording; +}; \ No newline at end of file diff --git a/backend/db/models/trace.js b/backend/db/models/trace.js new file mode 100644 index 000000000..8492a94c0 --- /dev/null +++ b/backend/db/models/trace.js @@ -0,0 +1,45 @@ +"use strict"; +const MetaModel = require("../MetaModel.js"); + +module.exports = (sequelize, DataTypes) => { + class Trace extends MetaModel { + static autoTable = true; + static fields = []; + static publicTable = false; + + static associate(models) { + Trace.belongsTo(models["recording"], { + foreignKey: "recordingId", + as: "recording", + }); + Trace.belongsTo(models["user"], { + foreignKey: "userId", + as: "user", + }); + } + } + + Trace.init( + { + recordingId: DataTypes.INTEGER, + userId: DataTypes.INTEGER, + socketId: DataTypes.STRING, + action: DataTypes.STRING, + payload: DataTypes.JSONB, + direction: DataTypes.BOOLEAN, + startTime: DataTypes.DATE, + endTime: DataTypes.DATE, + deleted: DataTypes.BOOLEAN, + deletedAt: DataTypes.DATE, + createdAt: DataTypes.DATE, + updatedAt: DataTypes.DATE, + }, + { + sequelize, + modelName: "trace", + tableName: "trace", + } + ); + + return Trace; +}; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index c8ac72d58..e10abd5fa 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,7 +56,8 @@ }, "devDependencies": { "cross-env": "^10.1.0", - "jest": "^30.2.0" + "jest": "^30.2.0", + "nodemon": "^3.1.10" }, "engines": { "node": ">=18.0.0" @@ -2451,6 +2452,19 @@ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", "license": "MIT" }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2496,6 +2510,19 @@ "balanced-match": "^1.0.0" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -2706,6 +2733,31 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/ci-info": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", @@ -3848,6 +3900,19 @@ "moment": "^2.29.1" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -4143,6 +4208,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -4289,6 +4367,13 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -4382,6 +4467,19 @@ "dev": true, "license": "MIT" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", @@ -4403,6 +4501,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4422,6 +4530,29 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5792,6 +5923,110 @@ "node": ">=6.0.0" } }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/nodemon/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nodemon/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -6498,6 +6733,13 @@ "node": ">=10" } }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -6619,6 +6861,32 @@ "node": ">=10" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7213,6 +7481,32 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7806,6 +8100,19 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -7821,6 +8128,16 @@ "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", "license": "MIT" }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, "node_modules/triple-beam": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", @@ -7914,6 +8231,13 @@ "node": ">=6.0.0" } }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 1459e014a..e80206e39 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -376,6 +376,23 @@ module.exports = class Server { await this.availSockets[socket.id][socketName].init(); }) + // If a recording is in progress and this new connection isn't in the + // participant list, notify the recording's owner that the activity + // won't be captured. + if (this.activeRecordingId && this.activeRecordingOwnerSocketId) { + const participants = this.activeParticipantSocketIds || []; + if (!participants.includes(socket.id)) { + const ownerSocket = this.io.sockets.sockets.get(this.activeRecordingOwnerSocketId); + if (ownerSocket) { + ownerSocket.emit("toast", { + title: "Uncaptured connection", + message: "Uncaptured connection detected — not part of this recording", + variant: "warning", + }); + } + } + } + socket.on("disconnect", async (reason) => { try { this.logger.debug("Socket disconnected: " + reason); @@ -483,6 +500,10 @@ module.exports = class Server { } catch (e) { this.logger.warn('Failed to start DB stats scheduler: ' + e.message); } + // Recover any recordings interrupted by the previous server shutdown + this.recoverInterruptedRecordings().catch((e) => { + this.logger.warn("recoverInterruptedRecordings failed: " + e); + }); return this.http; } @@ -499,8 +520,8 @@ module.exports = class Server { } if (this._statsScheduler) { this._statsScheduler.stop(this.logger); - } } + } /** * Flush statistics buffers for all connected sockets. @@ -522,5 +543,31 @@ module.exports = class Server { this.logger.error("flushAllStats encountered an error: " + e); } } + /** + * Mark any recordings still in "recording" status as "interrupted". + * These are recordings whose server died mid-capture — the in-memory + * activeRecordingId is gone but the DB row was never closed out. + */ + async recoverInterruptedRecordings() { + try { + const stale = await this.db.models["recording"].findAll({ + where: { status: "recording", deleted: false }, + }); + for (const rec of stale) { + await this.db.models["recording"].updateById(rec.id, { + status: "interrupted", + endTime: rec.endTime || new Date(), + }); + this.logger.warn( + `Marked recording ${rec.id} as interrupted (server was not running cleanly when stopped)` + ); + } + if (stale.length > 0) { + this.logger.info(`Recovered ${stale.length} interrupted recording(s) on startup`); + } + } catch (e) { + this.logger.error("Failed to recover interrupted recordings: " + e); + } + } } diff --git a/backend/webserver/replay/auth.js b/backend/webserver/replay/auth.js new file mode 100644 index 000000000..495d0aab4 --- /dev/null +++ b/backend/webserver/replay/auth.js @@ -0,0 +1,124 @@ +'use strict'; + +const crypto = require('crypto'); +const { io: SocketIOClient } = require('socket.io-client'); +// Delay between a replay client connecting and being handed off for replay. +// Gives the server time to finish its asynchronous per-socket handler init +// (see Server.js connection handler) so the first trace doesn't race setup. +const WARMUP_DELAY_MS = 500; + +/** + * Sign a session ID using the express-session HMAC-SHA256 scheme. + * @param {string} sid - Raw session identifier + * @param {string} secret - Session secret from server config + * @returns {string} Signed session ID in format s:. + */ +function signSessionId(sid, secret) { + const signature = crypto + .createHmac('sha256', secret) + .update(sid) + .digest('base64') + .replace(/=+$/, ''); + return `s:${sid}.${signature}`; +} + +/** + * Create an authenticated socket.io-client by writing a Passport + * session directly to the session store. + * @param {Object} server - CARE server instance + * @param {Object} user - User row from DB + * @param {string} serverUrl - Target server URL + * @returns {Promise} Connected client + * @throws {Error} If the client fails to connect + */ +async function createAuthenticatedClient(server, user, serverUrl) { + const sid = crypto.randomBytes(18).toString('hex'); + + const sessionData = JSON.stringify({ + cookie: { + originalMaxAge: null, + expires: null, + httpOnly: true, + path: '/', + }, + passport: { + user: { + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + userName: user.userName, + email: user.email, + rolesUpdatedAt: user.rolesUpdatedAt || null, + }, + }, + }); + + const expires = new Date(Date.now() + 24 * 60 * 60 * 1000); + await server.db.sequelize.query( + `INSERT INTO "Sessions" ("sid", "expires", "data", "createdAt", "updatedAt") + VALUES (:sid, :expires, :data, :now, :now)`, + { + replacements: { + sid, + expires: expires.toISOString(), + data: sessionData, + now: new Date().toISOString(), + }, + type: server.db.sequelize.QueryTypes.INSERT, + } + ); + // NOTE: This must match the express-session secret in Server.js (#initSessionManagement). + // Replay clients mint their own session cookies using this HMAC secret so that the + // session middleware accepts them as valid logged-in sessions. When the session secret + // is moved to an env var (see GitHub issue on hardcoded session secret), this literal + // must be updated to read from the same env var — otherwise replay auth will break. + const secret = 'secretString'; + const signedSid = signSessionId(sid, secret); + const cookie = `connect.sid=${encodeURIComponent(signedSid)}`; + + const client = SocketIOClient(serverUrl, { + extraHeaders: { cookie }, + reconnection: false, + timeout: 10000, + }); + + return new Promise((resolve, reject) => { + client.on('connect', () => { + // The server finishes registering this socket's handlers (StatisticSocket, + // UserSocket, etc.) asynchronously after the connect event fires. Firing a + // trace before that init completes races the handler setup and the first + // event times out. A short warm-up delay lets the server finish wiring up + // the socket before we start replaying. + setTimeout(() => resolve(client), WARMUP_DELAY_MS); + }); + client.on('connect_error', (err) => { + reject(new Error(`Replay auth failed for user ${user.id}: ${err.message}`)); + }); + }); +} + +/** + * Disconnect a replay client and remove its session from the store. + * @param {Object} server - CARE server instance + * @param {import("socket.io-client").Socket} client - The replay client to clean up + */ +async function cleanupSession(server, client) { + try { + const cookie = client.io.opts.extraHeaders?.cookie || ''; + const match = cookie.match(/connect\.sid=s%3A([^.]+)\./); + if (match) { + await server.db.sequelize.query( + `DELETE FROM "Sessions" WHERE "sid" = :sid`, + { replacements: { sid: match[1] } } + ); + } + client.disconnect(); + } catch (err) { + // best-effort cleanup + } +} + +module.exports = { + createAuthenticatedClient, + cleanupSession, +}; \ No newline at end of file diff --git a/backend/webserver/replay/worker.js b/backend/webserver/replay/worker.js new file mode 100644 index 000000000..586c24a79 --- /dev/null +++ b/backend/webserver/replay/worker.js @@ -0,0 +1,135 @@ +'use strict'; + +const { createAuthenticatedClient, cleanupSession } = require('./auth'); + +/** + * Emit a socket event and wait for the server acknowledgement. + * @param {import("socket.io-client").Socket} client - Connected socket client + * @param {string} action - Event name to emit + * @param {Object} payload - Event payload + * @param {number} timeoutMs - Max wait time for ack + * @returns {Promise} Server response + * @throws {Error} If no ack received within timeout + */ +function emitWithTimeout(client, action, payload, timeoutMs) { + return new Promise((resolve) => { + const timer = setTimeout(() => { + resolve({ success: false, timedOut: true, message: `No ack within ${timeoutMs}ms` }); + }, timeoutMs); + + client.emit(action, payload || {}, (response) => { + clearTimeout(timer); + resolve(response); + }); + }); +} + +/** + * Replay a single user's traces through an authenticated socket connection. + * @param {Object} server - CARE server instance + * @param {Object} user - User row from DB + * @param {Array} traces - Trace rows (direction: true only), sorted by startTime + * @param {string} serverUrl - Target server URL + * @param {string} timingMode - "realtime" to preserve original delays, "fast" to skip them + * @param {number} ackTimeout - Max wait time in ms for the server to ack each trace (default 2000) + * @returns {Promise} Results with pass/fail counts, errors, latencies, and DB changes + */ +async function replayUserTraces(server, user, traces, serverUrl, timingMode, ackTimeout = 2000) { + const results = { + userId: user.id, + userName: user.userName, + total: traces.length, + passed: 0, + failed: 0, + errors: [], + latencies: [], + }; + + let client; + try { + client = await createAuthenticatedClient(server, user, serverUrl); + } catch (err) { + results.failed = traces.length; + results.errors.push({ action: 'connect', message: err.message }); + return results; + } + + try { + let pendingDbChanges = []; + + client.onAny((eventName, ...args) => { + if (eventName.endsWith('Refresh')) { + const records = Array.isArray(args[0]) ? args[0] : [args[0]]; + pendingDbChanges.push({ + table: eventName.replace('Refresh', ''), + recordCount: records.length, + records: records.map(r => ({ + id: r?.id, + fields: r ? Object.keys(r).filter(k => k !== 'id') : [], + })), + }); + } + }); + + let prevTime = traces.length > 0 ? new Date(traces[0].startTime).getTime() : 0; + + for (const trace of traces) { + if (timingMode === 'realtime') { + const traceTime = new Date(trace.startTime).getTime(); + const delay = traceTime - prevTime; + if (delay > 0) { + await new Promise(resolve => setTimeout(resolve, delay)); + } + prevTime = traceTime; + } + + pendingDbChanges = []; + + try { + const start = Date.now(); + const ack = await emitWithTimeout(client, trace.action, trace.payload, ackTimeout); + const latency = Date.now() - start; + + // Small delay to let any remaining Refresh events arrive + await new Promise(resolve => setTimeout(resolve, 50)); + const dbChanges = [...pendingDbChanges]; + + + + if (ack && ack.success === false) { + results.failed++; + results.errors.push({ + traceId: trace.id, + action: trace.action, + message: ack.message || 'Server returned success: false', + dbChanges, + }); + } else { + results.passed++; + results.latencies.push({ + traceId: trace.id, + action: trace.action, + latency, + dbChanges, + }); + } + } catch (err) { + results.failed++; + results.errors.push({ + traceId: trace.id, + action: trace.action, + message: err.message, + dbChanges: [], + }); + } + } + } finally { + await cleanupSession(server, client); + } + + return results; +} + +module.exports = { + replayUserTraces, +}; \ No newline at end of file diff --git a/backend/webserver/sockets/recorder.js b/backend/webserver/sockets/recorder.js new file mode 100644 index 000000000..db1765545 --- /dev/null +++ b/backend/webserver/sockets/recorder.js @@ -0,0 +1,243 @@ +const Socket = require("../Socket.js"); + +/** + * Recorder Socket + * + * Captures WebSocket events across connected sockets for stress-test replay. + * Recording is a server-wide toggle — admin chooses which active sessions + * (sockets) to include. Pure session-based selection: only the listed + * socketIds are recorded. New connections during a recording are NOT + * automatically captured (a warning toast goes to the recording owner). + */ +class RecorderSocket extends Socket { + + constructor(server, io, socket) { + super(server, io, socket); + this.incomingHandler = null; + this.outgoingHandler = null; + } + + /** + * Returns true if this socket should be recorded under the current configuration. + */ + isSessionIncluded(socketId) { + const participants = this.server.activeParticipantSocketIds; + if (!participants || participants.length === 0) { + return false; + } + return participants.includes(socketId); + } + + attachListeners() { + if (this.incomingHandler || this.outgoingHandler) return; + + this.incomingHandler = async (eventName, ...args) => { + const recordingId = this.server.activeRecordingId; + if (!recordingId) return; + const excludes = this.server.activeExcludeEvents; + if (excludes && excludes.includes(eventName)) return; + try { + await this.models["trace"].add({ + recordingId, + userId: this.userId, + socketId: this.socket.id, + action: eventName, + payload: args[0] || null, + direction: true, + startTime: new Date(), + endTime: new Date(), + }); + } catch (err) { + this.logger.error("Failed to save trace: " + err.message); + } + }; + + this.outgoingHandler = async (eventName, ...args) => { + const recordingId = this.server.activeRecordingId; + if (!recordingId) return; + const excludes = this.server.activeExcludeEvents; + if (excludes && excludes.includes(eventName)) return; + try { + await this.models["trace"].add({ + recordingId, + userId: this.userId, + socketId: this.socket.id, + action: eventName, + payload: args[0] || null, + direction: false, + startTime: new Date(), + endTime: new Date(), + }); + } catch (err) { + this.logger.error("Failed to save trace: " + err.message); + } + }; + + this.socket.onAny(this.incomingHandler); + this.socket.onAnyOutgoing(this.outgoingHandler); + } + + detachListeners() { + if (this.incomingHandler) { + this.socket.offAny(this.incomingHandler); + this.incomingHandler = null; + } + if (this.outgoingHandler) { + this.socket.offAnyOutgoing(this.outgoingHandler); + this.outgoingHandler = null; + } + } + + async startRecording(data, options) { + if (this.server.activeRecordingId) { + throw new Error("A recording is already in progress"); + } + + const participantSocketIds = Array.isArray(data?.participantSocketIds) && data.participantSocketIds.length > 0 + ? data.participantSocketIds + : null; + + if (!participantSocketIds) { + throw new Error("At least one session must be selected"); + } + + const excludeEvents = Array.isArray(data?.excludeEvents) && data.excludeEvents.length > 0 + ? data.excludeEvents + : null; + + const recording = await this.models["recording"].add({ + name: data.name || "Recording " + new Date().toLocaleString(), + status: "recording", + startTime: new Date(), + userId: this.userId, + participantSocketIds, + excludeEvents, + }, options); + + this.server.activeRecordingId = recording.id; + this.server.activeParticipantSocketIds = participantSocketIds; + this.server.activeRecordingOwnerSocketId = this.socket.id; + this.server.activeExcludeEvents = excludeEvents; + + + for (const socketId of Object.keys(this.server.availSockets)) { + const recorder = this.server.availSockets[socketId]["RecorderSocket"]; + const included = recorder ? recorder.isSessionIncluded(recorder.socket.id) : "no recorder"; + if (recorder && recorder.isSessionIncluded(recorder.socket.id)) { + recorder.attachListeners(); + } + } + } + + async stopRecording(data, options) { + const recordingId = this.server.activeRecordingId || (data && data.id); + if (!recordingId) { + throw new Error("No active recording"); + } + + for (const socketId of Object.keys(this.server.availSockets)) { + const recorder = this.server.availSockets[socketId]["RecorderSocket"]; + if (recorder) recorder.detachListeners(); + } + + await this.models["recording"].updateById( + recordingId, + { status: "finished", endTime: new Date() }, + options + ); + + this.server.activeRecordingId = null; + this.server.activeParticipantSocketIds = null; + this.server.activeRecordingOwnerSocketId = null; + this.server.activeExcludeEvents = null; + + const traces = await this.models["trace"].findAll({ + where: { recordingId }, + order: [["id", "ASC"]], + }); + + return { + id: recordingId, + traces: traces.map(t => ({ + id: t.id, + recordingId: t.recordingId, + userId: t.userId, + socketId: t.socketId, + action: t.action, + direction: t.direction, + startTime: t.startTime, + endTime: t.endTime, + })), + }; + } + + async getTraces(data, options) { + if (!data || !data.id) throw new Error("Recording ID required"); + const traces = await this.models["trace"].findAll({ + where: { recordingId: data.id, deleted: false }, + order: [["id", "ASC"]], + }); + return traces.map(t => ({ + id: t.id, + recordingId: t.recordingId, + userId: t.userId, + socketId: t.socketId, + action: t.action, + direction: t.direction, + startTime: t.startTime, + endTime: t.endTime, + })); + } + + /** + * Returns one entry per active socket connection (= session). + * Used by the Start Recording modal to populate the session selection table. + * Offline users have no sessions and are not returned. + */ + async getOnlineSessions(data, options) { + const userIds = new Set(); + const sessions = []; + for (const socketId of Object.keys(this.server.availSockets)) { + const bucket = this.server.availSockets[socketId]; + const userSocket = bucket["UserSocket"]; + if (userSocket && userSocket.userId) { + userIds.add(userSocket.userId); + sessions.push({ + socketId, + userId: userSocket.userId, + connectedAt: userSocket.connectedAt || null, + }); + } + } + + // Resolve userNames in one query + const userMap = {}; + if (userIds.size > 0) { + const users = await this.models["user"].findAll({ + where: { id: Array.from(userIds) }, + }); + for (const u of users) { + userMap[u.id] = u.userName; + } + } + + return sessions.map(s => ({ + ...s, + userName: userMap[s.userId] || "Unknown", + })); + + } + + init() { + this.createSocket("recorderStart", this.startRecording, {}, true); + this.createSocket("recorderStop", this.stopRecording, {}, false); + this.createSocket("recordingGetTraces", this.getTraces, {}, false); + this.createSocket("recordingGetOnlineSessions", this.getOnlineSessions, {}, false); + + if (this.server.activeRecordingId && this.isSessionIncluded(this.socket.id)) { + this.attachListeners(); + } + } +} + +module.exports = RecorderSocket; \ No newline at end of file diff --git a/backend/webserver/sockets/replayer.js b/backend/webserver/sockets/replayer.js new file mode 100644 index 000000000..75c99dadf --- /dev/null +++ b/backend/webserver/sockets/replayer.js @@ -0,0 +1,200 @@ +'use strict'; + +const Socket = require('../Socket.js'); +const { replayUserTraces } = require('../replay/worker'); + +/** + * Handles replaying recorded socket events for stress testing. + * + * Scaling mode (pooled): all selected recordings' sessions are combined + * into one pool of N sessions. Iteration K runs K * N parallel sockets, + * cycling through the pool with wraparound (linear add per iteration). + * + * Example: recording A=[a1, a2], recording B=[b3], maxIterations=3 + * Iteration 1: [a1, a2, b3] (3 sockets) + * Iteration 2: [a1, a2, b3, a1, a2, b3] (6 sockets) + * Iteration 3: [a1, a2, b3, a1, a2, b3, a1, a2, b3] (9 sockets) + * + * @type {ReplayerSocket} + * @class ReplayerSocket + */ +class ReplayerSocket extends Socket { + + /** + * Pool sessions from all selected recordings and run a single scaling test + * across the combined pool. + * @param {Object} data - Input data from the frontend + * @param {Array} data.recordingIds - IDs of recordings whose sessions get pooled + * @param {string} data.timingMode - "realtime" or "fast" + * @param {boolean} data.continueOnFailure - If true, scaling continues past failed iterations + * @param {number} data.maxIterations - How many scaling iterations to run (required, > 0) + * @param {Object} options - Additional configuration parameter + * @returns {Promise>} Iteration results + * @throws {Error} If recordingIds is missing/empty, maxIterations invalid, or pool is empty + */ + async replayRun(data, options) { + const { + recordingIds, + timingMode = 'fast', + continueOnFailure = false, + maxIterations, + ackTimeout = 2000, + } = data; + + if (!Array.isArray(recordingIds) || recordingIds.length === 0) { + throw new Error('recordingIds must be a non-empty array'); + } + if (!Number.isInteger(maxIterations) || maxIterations < 1) { + throw new Error('maxIterations must be a positive integer'); + } + + // Pool sessions from every selected recording + const pool = await this.buildSessionPool(recordingIds); + + if (pool.sessions.length === 0) { + throw new Error('No replayable sessions found in selected recordings'); + } + + const serverUrl = `http://localhost:${process.env.CONTENT_SERVER_PORT || 3001}`; + + const iterations = await this.runScalingTest( + pool, serverUrl, timingMode, continueOnFailure, maxIterations, ackTimeout + ); + + return iterations; + } + + /** + * Load all selected recordings' traces and combine them into one pool of + * sessions. Each session keeps a reference to its source recording so we + * can label results with the recording name. + * + * @param {Array} recordingIds - Recordings to pool + * @returns {Promise<{sessions: Array, userMap: Map}>} Pooled sessions and the user lookup needed for replay + */ + async buildSessionPool(recordingIds) { + const sessions = []; + const userIdSet = new Set(); + + for (const recordingId of recordingIds) { + const recording = await this.models['recording'].findByPk(recordingId); + if (!recording) continue; + + const traces = await this.models['trace'].findAll({ + where: { recordingId, direction: true, deleted: false }, + order: [['startTime', 'ASC']], + }); + if (traces.length === 0) continue; + + const sessionMap = this.groupTracesBySocket(traces); + for (const [key, session] of sessionMap) { + sessions.push({ + sessionKey: key, + userId: session.userId, + traces: session.traces, + recordingId, + recordingName: recording.name, + }); + userIdSet.add(session.userId); + } + } + + const users = await this.models['user'].findAll({ + where: { id: Array.from(userIdSet) }, + }); + const userMap = new Map(users.map(u => [u.id, u])); + + return { sessions, userMap }; + } + + /** + * Group trace rows by socketId, falling back to "user-{userId}" when + * socketId is null (older recordings captured before per-session + * tracking was added). + * @param {Array} traces - Trace rows sorted by startTime + * @returns {Map}>} Map of session key to its traces and owning user + */ + groupTracesBySocket(traces) { + const map = new Map(); + for (const t of traces) { + if (!t.userId) continue; + const key = t.socketId || `user-${t.userId}`; + if (!map.has(key)) { + map.set(key, { userId: t.userId, traces: [] }); + } + map.get(key).traces.push(t); + } + return map; + } + + /** + * Execute the scaling-correctness loop on the pooled session list. + * Iteration K runs K * N parallel sockets, cycling through the pool + * with wraparound (linear add: each iteration adds one full copy of + * the pool to the previous iteration's count). + * + * Stops at first failure unless continueOnFailure is true. + * + * @param {{sessions: Array, userMap: Map}} pool - Combined session pool from buildSessionPool + * @param {string} serverUrl - Target server URL + * @param {string} timingMode - "realtime" or "fast" + * @param {boolean} continueOnFailure - If true, scaling continues past failed iterations + * @param {number} maxIterations - Number of iterations to run + * @returns {Promise>} Iteration results + */ + async runScalingTest(pool, serverUrl, timingMode, continueOnFailure, maxIterations, ackTimeout) { + const allResults = []; + const N = pool.sessions.length; + + for (let level = 1; level <= maxIterations; level++) { + // Iteration K runs K full copies of the pool. Total sockets = K * N. + // Sessions are picked with wraparound, so iteration K's list is + // pool[0], pool[1], ..., pool[N-1], pool[0], pool[1], ... (K times). + const totalSockets = level * N; + const activeSessions = []; + for (let i = 0; i < totalSockets; i++) { + activeSessions.push(pool.sessions[i % N]); + } + + this.sendToast( + `Iteration ${level}/${maxIterations}: ${totalSockets} parallel session(s)`, + 'Replay', + 'info' + ); + + const levelResults = await Promise.all( + activeSessions.map(session => { + const user = pool.userMap.get(session.userId); + return replayUserTraces(this.server, user, session.traces, serverUrl, timingMode, ackTimeout) + .then(result => ({ + ...result, + sessionKey: session.sessionKey, + recordingId: session.recordingId, + recordingName: session.recordingName, + })); + }) + ); + + const levelFailed = levelResults.some(r => r.failed > 0); + allResults.push({ + level, + sessions: totalSockets, + results: levelResults, + passed: !levelFailed, + }); + + if (levelFailed && !continueOnFailure) { + this.sendToast(`Replay stopped at iteration ${level}`, 'Replay', 'danger'); + break; + } + } + + return allResults; + } + + init() { + this.createSocket('replayRun', this.replayRun, {}, false); + } +} + +module.exports = ReplayerSocket; \ No newline at end of file diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 000000000..ce7e51cd5 Binary files /dev/null and b/docs/.DS_Store differ diff --git a/frontend/src/basic/navigation/Topbar.vue b/frontend/src/basic/navigation/Topbar.vue index c4889116e..9f1df9118 100644 --- a/frontend/src/basic/navigation/Topbar.vue +++ b/frontend/src/basic/navigation/Topbar.vue @@ -26,13 +26,30 @@ :height="30" /> -
-
+
+
@@ -38,6 +39,7 @@ import { defineAsyncComponent } from "vue"; import Loading from "@/basic/Loading.vue"; import NotFoundPage from "@/auth/NotFound.vue"; + const dashboardModules = import.meta.glob("./dashboard/*.vue"); export default { diff --git a/frontend/src/components/dashboard/SocketProfiler.vue b/frontend/src/components/dashboard/SocketProfiler.vue new file mode 100644 index 000000000..956410d0d --- /dev/null +++ b/frontend/src/components/dashboard/SocketProfiler.vue @@ -0,0 +1,359 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/navigation/RecordingBar.vue b/frontend/src/components/dashboard/navigation/RecordingBar.vue new file mode 100644 index 000000000..3dd429f43 --- /dev/null +++ b/frontend/src/components/dashboard/navigation/RecordingBar.vue @@ -0,0 +1,96 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/socketprofiler/ImportRecordingModal.vue b/frontend/src/components/dashboard/socketprofiler/ImportRecordingModal.vue new file mode 100644 index 000000000..56ff8d7a5 --- /dev/null +++ b/frontend/src/components/dashboard/socketprofiler/ImportRecordingModal.vue @@ -0,0 +1,292 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/socketprofiler/RecordingModal.vue b/frontend/src/components/dashboard/socketprofiler/RecordingModal.vue new file mode 100644 index 000000000..0f7797c0f --- /dev/null +++ b/frontend/src/components/dashboard/socketprofiler/RecordingModal.vue @@ -0,0 +1,213 @@ + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/socketprofiler/ReplayResultsModal.vue b/frontend/src/components/dashboard/socketprofiler/ReplayResultsModal.vue new file mode 100644 index 000000000..e25f3ddde --- /dev/null +++ b/frontend/src/components/dashboard/socketprofiler/ReplayResultsModal.vue @@ -0,0 +1,246 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/socketprofiler/StartRecordingModal.vue b/frontend/src/components/dashboard/socketprofiler/StartRecordingModal.vue new file mode 100644 index 000000000..bc05c61c1 --- /dev/null +++ b/frontend/src/components/dashboard/socketprofiler/StartRecordingModal.vue @@ -0,0 +1,228 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/dashboard/socketprofiler/StartReplayModal.vue b/frontend/src/components/dashboard/socketprofiler/StartReplayModal.vue new file mode 100644 index 000000000..877dca848 --- /dev/null +++ b/frontend/src/components/dashboard/socketprofiler/StartReplayModal.vue @@ -0,0 +1,231 @@ + + + \ No newline at end of file