feat(editor): Show avatars for users currently working on the same workflow (#7763)
This PR introduces the following changes: - New Vue stores: `collaborationStore` and `pushConnectionStore` - Front-end push connection handling overhaul: Keep only a singe connection open and handle it from the new store - Add user avatars in the editor header when there are multiple users working on the same workflow - Sending a heartbeat event to back-end service periodically to confirm user is still active - Back-end overhauls (authored by @tomi): - Implementing a cleanup procedure that removes inactive users - Refactoring collaboration service current implementation --------- Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
99a9ea497a
commit
77bc8ecd4b
@@ -116,6 +116,7 @@ import { UserService } from './services/user.service';
|
||||
import { OrchestrationController } from './controllers/orchestration.controller';
|
||||
import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee';
|
||||
import { InvitationController } from './controllers/invitation.controller';
|
||||
import { CollaborationService } from './collaboration/collaboration.service';
|
||||
|
||||
const exec = promisify(callbackExec);
|
||||
|
||||
@@ -138,6 +139,8 @@ export class Server extends AbstractServer {
|
||||
|
||||
private postHog: PostHogClient;
|
||||
|
||||
private collaborationService: CollaborationService;
|
||||
|
||||
constructor() {
|
||||
super('main');
|
||||
|
||||
@@ -233,6 +236,7 @@ export class Server extends AbstractServer {
|
||||
.then(async (workflow) =>
|
||||
Container.get(InternalHooks).onServerStarted(diagnosticInfo, workflow?.createdAt),
|
||||
);
|
||||
this.collaborationService = Container.get(CollaborationService);
|
||||
}
|
||||
|
||||
private async registerControllers(ignoredEndpoints: Readonly<string[]>) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Workflow } from 'n8n-workflow';
|
||||
import { Service } from 'typedi';
|
||||
import config from '@/config';
|
||||
import { Push } from '../push';
|
||||
import { Logger } from '@/Logger';
|
||||
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
|
||||
@@ -8,6 +9,13 @@ import { UserService } from '../services/user.service';
|
||||
import type { IActiveWorkflowUsersChanged } from '../Interfaces';
|
||||
import type { OnPushMessageEvent } from '@/push/types';
|
||||
import { CollaborationState } from '@/collaboration/collaboration.state';
|
||||
import { TIME } from '@/constants';
|
||||
|
||||
/**
|
||||
* After how many minutes of inactivity a user should be removed
|
||||
* as being an active user of a workflow.
|
||||
*/
|
||||
const INACTIVITY_CLEAN_UP_TIME_IN_MS = 15 * TIME.MINUTE;
|
||||
|
||||
/**
|
||||
* Service for managing collaboration feature between users. E.g. keeping
|
||||
@@ -28,6 +36,14 @@ export class CollaborationService {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMultiMainSetup = config.get('multiMainSetup.enabled');
|
||||
if (isMultiMainSetup) {
|
||||
// TODO: We should support collaboration in multi-main setup as well
|
||||
// This requires using redis as the state store instead of in-memory
|
||||
logger.warn('Collaboration features are disabled because multi-main setup is enabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.push.on('message', async (event: OnPushMessageEvent) => {
|
||||
try {
|
||||
await this.handleUserMessage(event.userId, event.msg);
|
||||
@@ -53,6 +69,7 @@ export class CollaborationService {
|
||||
const { workflowId } = msg;
|
||||
|
||||
this.state.addActiveWorkflowUser(workflowId, userId);
|
||||
this.state.cleanInactiveUsers(workflowId, INACTIVITY_CLEAN_UP_TIME_IN_MS);
|
||||
|
||||
await this.sendWorkflowUsersChangedMessage(workflowId);
|
||||
}
|
||||
|
||||
@@ -59,4 +59,21 @@ export class CollaborationState {
|
||||
|
||||
return [...workflowState.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all users that have not been seen in a given time
|
||||
*/
|
||||
cleanInactiveUsers(workflowId: Workflow['id'], inactivityCleanUpTimeInMs: number) {
|
||||
const activeUsers = this.state.activeUsersByWorkflowId.get(workflowId);
|
||||
if (!activeUsers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const user of activeUsers.values()) {
|
||||
if (now - user.lastSeen.getTime() > inactivityCleanUpTimeInMs) {
|
||||
activeUsers.delete(user.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,14 @@ export class Push extends EventEmitter {
|
||||
|
||||
private backend = useWebSockets ? Container.get(WebSocketPush) : Container.get(SSEPush);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
if (useWebSockets) {
|
||||
this.backend.on('message', (msg) => this.emit('message', msg));
|
||||
}
|
||||
}
|
||||
|
||||
handleRequest(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) {
|
||||
const {
|
||||
userId,
|
||||
@@ -37,7 +45,6 @@ export class Push extends EventEmitter {
|
||||
} = req;
|
||||
if (req.ws) {
|
||||
(this.backend as WebSocketPush).add(sessionId, userId, req.ws);
|
||||
this.backend.on('message', (msg) => this.emit('message', msg));
|
||||
} else if (!useWebSockets) {
|
||||
(this.backend as SSEPush).add(sessionId, userId, { req, res });
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TIME } from '@/constants';
|
||||
import { CollaborationState } from '@/collaboration/collaboration.state';
|
||||
|
||||
const origDate = global.Date;
|
||||
|
||||
const mockDateFactory = (currentDate: string) => {
|
||||
return class CustomDate extends origDate {
|
||||
constructor() {
|
||||
super(currentDate);
|
||||
}
|
||||
} as DateConstructor;
|
||||
};
|
||||
|
||||
describe('CollaborationState', () => {
|
||||
let collaborationState: CollaborationState;
|
||||
|
||||
beforeEach(() => {
|
||||
collaborationState = new CollaborationState();
|
||||
});
|
||||
|
||||
describe('cleanInactiveUsers', () => {
|
||||
const workflowId = 'workflow';
|
||||
|
||||
it('should remove inactive users', () => {
|
||||
// Setup
|
||||
global.Date = mockDateFactory('2023-01-01T00:00:00.000Z');
|
||||
collaborationState.addActiveWorkflowUser(workflowId, 'inactiveUser');
|
||||
|
||||
global.Date = mockDateFactory('2023-01-01T00:30:00.000Z');
|
||||
collaborationState.addActiveWorkflowUser(workflowId, 'activeUser');
|
||||
|
||||
// Act: Clean inactive users
|
||||
jest
|
||||
.spyOn(global.Date, 'now')
|
||||
.mockReturnValue(new origDate('2023-01-01T00:35:00.000Z').getTime());
|
||||
collaborationState.cleanInactiveUsers(workflowId, 10 * TIME.MINUTE);
|
||||
|
||||
// Assert: The inactive user should be removed
|
||||
expect(collaborationState.getActiveWorkflowUsers(workflowId)).toEqual([
|
||||
{ userId: 'activeUser', lastSeen: new origDate('2023-01-01T00:30:00.000Z') },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not remove active users', () => {
|
||||
// Setup: Add an active user to the state
|
||||
global.Date = mockDateFactory('2023-01-01T00:30:00.000Z');
|
||||
collaborationState.addActiveWorkflowUser(workflowId, 'activeUser');
|
||||
|
||||
// Act: Clean inactive users
|
||||
jest
|
||||
.spyOn(global.Date, 'now')
|
||||
.mockReturnValue(new origDate('2023-01-01T00:35:00.000Z').getTime());
|
||||
collaborationState.cleanInactiveUsers(workflowId, 10 * TIME.MINUTE);
|
||||
|
||||
// Assert: The active user should still be present
|
||||
expect(collaborationState.getActiveWorkflowUsers(workflowId)).toEqual([
|
||||
{ userId: 'activeUser', lastSeen: new origDate('2023-01-01T00:30:00.000Z') },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user