Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
7bdc7dc
feat: add recording dashboard UI and nav element migration
Mar 22, 2026
2dbd5a1
feat: add recording dashboard component
Mar 22, 2026
b788500
feat: rename recording to socket profiler
Mar 24, 2026
3117dae
feat: add recording and trace tables with models
Mar 28, 2026
be58e84
feat: update socket profiler dashboard with subscribeTable and correc…
Mar 28, 2026
de3f283
feat: implement socket recorder with onAny listeners and real-time tr…
Mar 30, 2026
6e19b77
feat: add deleted and deletedAt columns to trace table
Mar 30, 2026
add5478
fix: format timestamps, remove stop button from table rows
Apr 5, 2026
535a693
feat: add persistent recording bar to dashboard
Apr 6, 2026
d98c81d
feat: add save recording modal with trace selection
Apr 7, 2026
862885c
fix: recordings persist after reload, set deleted false on create
Apr 7, 2026
7bb50f1
refactor: remove catch-all listeners by reference instead of nuking all
Apr 8, 2026
becee70
fix: default deleted to false on recording and trace models
Apr 8, 2026
c24ac5a
chore: stop tracking root logs directory
Apr 12, 2026
cf906f8
refactor: collapse trace migrations and use proper timestamps
Apr 12, 2026
daf9445
fix: set publicTable false on recording and trace, move deleted defau…
Apr 12, 2026
17c9b8d
refactor: remove redundant sendToast calls and unused return from sta…
Apr 12, 2026
a1fb6e2
refactor: use BasicButton in recording modal for behavior tracking
Apr 12, 2026
41c6a27
feat: add edit recording modal with trace pruning
Apr 16, 2026
38b0bda
feat: capture multi-user events across all connected sockets
Apr 16, 2026
c5d691c
feat: multi-user recording with participant selection and online indi…
Apr 17, 2026
b1966c3
Merge remote-tracking branch 'origin/dev' into feat-83-recording
Apr 20, 2026
6bbf384
feat: add replay engine with results modal
Apr 20, 2026
e9780eb
feat: add event exclusion filter and update modals to use BasicTable
Apr 21, 2026
ddc98ac
feat: rename 'Level' to 'Iteration' in replay UI
Apr 27, 2026
89339c0
docs: note coupling between replay HMAC and session secret
Apr 27, 2026
a5f866d
fix: recover interrupted recordings, await save operations
Apr 27, 2026
8e991d9
feat: tag traces with socketId for per-session granularity
Apr 27, 2026
2376e83
fix: correct selected count, show session count in start modal
Apr 27, 2026
ccd234a
feat: replay groups traces by socketId for per-session granularity
Apr 27, 2026
ecac28f
feat: pure session-based recording selection
May 3, 2026
cd41288
feat: persistent recording icon replaces global recording bar
May 3, 2026
dae7a5b
feat: replay configuration modal with pooled scaling
May 6, 2026
7aa5b2d
Merge branch 'dev' into feat-83-recording
May 6, 2026
d796191
fix: move Socket Profiler nav element into Manage group
May 6, 2026
8bd3b00
feat: live elapsed-time display on recording icon + restore click nav…
May 11, 2026
e7f6a7b
chore: ignore .DS_Store files
May 11, 2026
6fc56cb
fix: close mounted() method properly in Topbar.vue
May 11, 2026
58fb9c2
feat: add Time and Elapsed columns to recording traces modal
May 11, 2026
a11a8e0
style: fix Vue attribute-order lint warnings
May 12, 2026
e50f23f
chore: remove leftover debug console.log from replay worker
May 12, 2026
13e13e4
Merge remote-tracking branch 'origin/dev' into feat-83-recording
May 19, 2026
f3ff342
feat: export and import recordings as JSON
May 20, 2026
bc66320
feat: configurable ack timeout for replay
May 20, 2026
a550780
style: clean up Server.js indentation, JSDoc, and trailing whitespace
May 21, 2026
df75b95
chore: sync backend package-lock with nodemon devDependency
May 21, 2026
88f24ef
feat: auto-download replay results as JSON
May 25, 2026
acc0eb7
fix: warm-up delay before replay to avoid first-trace race
May 28, 2026
31bcbae
refactor: use BasicForm for replay config fields
May 28, 2026
5bdb0c1
feat: strip dbChanges from downloaded replay results
May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ backend/sessions
backend/node_modules
backend/logs
backend/coverage
logs/

# ignore build files
dist/
Expand Down Expand Up @@ -47,4 +48,4 @@ utils/rpcs/moodleAPI/moodle_api/__pycache__
*.pyc
email-mappings.py
gitleaks-clean.sh
.mailmap
.mailmap.DS_Store
50 changes: 50 additions & 0 deletions backend/db/migrations/20260322000001-extend-nav-socket-profiler.js
Original file line number Diff line number Diff line change
@@ -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),
},
{}
);
},
};
82 changes: 82 additions & 0 deletions backend/db/migrations/20260412231117-create-recording.js
Original file line number Diff line number Diff line change
@@ -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');
},
};
86 changes: 86 additions & 0 deletions backend/db/migrations/20260412231119-create-trace.js
Original file line number Diff line number Diff line change
@@ -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');
},
};
Original file line number Diff line number Diff line change
@@ -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 } }
);
},
};
45 changes: 45 additions & 0 deletions backend/db/models/recording.js
Original file line number Diff line number Diff line change
@@ -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;
};
45 changes: 45 additions & 0 deletions backend/db/models/trace.js
Original file line number Diff line number Diff line change
@@ -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;
};
Loading
Loading