From 77bc8ecd4b1552f7253bc1348087db518ce7ce07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Thu, 23 Nov 2023 10:14:34 +0100 Subject: [PATCH] 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> --- packages/cli/src/Server.ts | 4 + .../collaboration/collaboration.service.ts | 17 ++ .../src/collaboration/collaboration.state.ts | 17 ++ packages/cli/src/push/index.ts | 9 +- .../collaboration/collaboration.state.test.ts | 61 +++++++ .../src/components/N8nUserStack/UserStack.vue | 16 +- packages/editor-ui/src/App.vue | 3 + packages/editor-ui/src/Interface.ts | 14 +- .../MainHeader/CollaborationPane.vue | 81 +++++++++ .../src/components/MainHeader/MainHeader.vue | 5 - .../components/MainHeader/WorkflowDetails.vue | 76 +++++---- .../__tests__/CollaborationPane.test.ts | 135 +++++++++++++++ packages/editor-ui/src/constants.ts | 12 ++ .../editor-ui/src/mixins/pushConnection.ts | 112 ++----------- .../src/stores/collaboration.store.ts | 53 ++++++ packages/editor-ui/src/stores/index.ts | 2 + .../src/stores/pushConnection.store.ts | 154 ++++++++++++++++++ packages/editor-ui/src/views/NodeView.vue | 31 +++- 18 files changed, 654 insertions(+), 148 deletions(-) create mode 100644 packages/cli/test/unit/collaboration/collaboration.state.test.ts create mode 100644 packages/editor-ui/src/components/MainHeader/CollaborationPane.vue create mode 100644 packages/editor-ui/src/components/__tests__/CollaborationPane.test.ts create mode 100644 packages/editor-ui/src/stores/collaboration.store.ts create mode 100644 packages/editor-ui/src/stores/pushConnection.store.ts diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 5ea20083d6..b7e6b51250 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -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) { diff --git a/packages/cli/src/collaboration/collaboration.service.ts b/packages/cli/src/collaboration/collaboration.service.ts index 4181995c0f..b19378e372 100644 --- a/packages/cli/src/collaboration/collaboration.service.ts +++ b/packages/cli/src/collaboration/collaboration.service.ts @@ -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); } diff --git a/packages/cli/src/collaboration/collaboration.state.ts b/packages/cli/src/collaboration/collaboration.state.ts index 02dff0d450..530a529650 100644 --- a/packages/cli/src/collaboration/collaboration.state.ts +++ b/packages/cli/src/collaboration/collaboration.state.ts @@ -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); + } + } + } } diff --git a/packages/cli/src/push/index.ts b/packages/cli/src/push/index.ts index 451a0e2e79..0cbd28da3c 100644 --- a/packages/cli/src/push/index.ts +++ b/packages/cli/src/push/index.ts @@ -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 { diff --git a/packages/cli/test/unit/collaboration/collaboration.state.test.ts b/packages/cli/test/unit/collaboration/collaboration.state.test.ts new file mode 100644 index 0000000000..b390bf9ad9 --- /dev/null +++ b/packages/cli/test/unit/collaboration/collaboration.state.test.ts @@ -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') }, + ]); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nUserStack/UserStack.vue b/packages/design-system/src/components/N8nUserStack/UserStack.vue index a437006d09..d95397d134 100644 --- a/packages/design-system/src/components/N8nUserStack/UserStack.vue +++ b/packages/design-system/src/components/N8nUserStack/UserStack.vue @@ -75,26 +75,31 @@ const menuHeight = computed(() => { :max-height="menuHeight" popper-class="user-stack-popper" > -
+
+{{ hiddenUsersCount }}