mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-12 13:27:31 -08:00
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:
parent
99a9ea497a
commit
77bc8ecd4b
|
@ -116,6 +116,7 @@ import { UserService } from './services/user.service';
|
||||||
import { OrchestrationController } from './controllers/orchestration.controller';
|
import { OrchestrationController } from './controllers/orchestration.controller';
|
||||||
import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee';
|
import { WorkflowHistoryController } from './workflows/workflowHistory/workflowHistory.controller.ee';
|
||||||
import { InvitationController } from './controllers/invitation.controller';
|
import { InvitationController } from './controllers/invitation.controller';
|
||||||
|
import { CollaborationService } from './collaboration/collaboration.service';
|
||||||
|
|
||||||
const exec = promisify(callbackExec);
|
const exec = promisify(callbackExec);
|
||||||
|
|
||||||
|
@ -138,6 +139,8 @@ export class Server extends AbstractServer {
|
||||||
|
|
||||||
private postHog: PostHogClient;
|
private postHog: PostHogClient;
|
||||||
|
|
||||||
|
private collaborationService: CollaborationService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('main');
|
super('main');
|
||||||
|
|
||||||
|
@ -233,6 +236,7 @@ export class Server extends AbstractServer {
|
||||||
.then(async (workflow) =>
|
.then(async (workflow) =>
|
||||||
Container.get(InternalHooks).onServerStarted(diagnosticInfo, workflow?.createdAt),
|
Container.get(InternalHooks).onServerStarted(diagnosticInfo, workflow?.createdAt),
|
||||||
);
|
);
|
||||||
|
this.collaborationService = Container.get(CollaborationService);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async registerControllers(ignoredEndpoints: Readonly<string[]>) {
|
private async registerControllers(ignoredEndpoints: Readonly<string[]>) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import type { Workflow } from 'n8n-workflow';
|
import type { Workflow } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
import config from '@/config';
|
||||||
import { Push } from '../push';
|
import { Push } from '../push';
|
||||||
import { Logger } from '@/Logger';
|
import { Logger } from '@/Logger';
|
||||||
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
|
import type { WorkflowClosedMessage, WorkflowOpenedMessage } from './collaboration.message';
|
||||||
|
@ -8,6 +9,13 @@ import { UserService } from '../services/user.service';
|
||||||
import type { IActiveWorkflowUsersChanged } from '../Interfaces';
|
import type { IActiveWorkflowUsersChanged } from '../Interfaces';
|
||||||
import type { OnPushMessageEvent } from '@/push/types';
|
import type { OnPushMessageEvent } from '@/push/types';
|
||||||
import { CollaborationState } from '@/collaboration/collaboration.state';
|
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
|
* Service for managing collaboration feature between users. E.g. keeping
|
||||||
|
@ -28,6 +36,14 @@ export class CollaborationService {
|
||||||
return;
|
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) => {
|
this.push.on('message', async (event: OnPushMessageEvent) => {
|
||||||
try {
|
try {
|
||||||
await this.handleUserMessage(event.userId, event.msg);
|
await this.handleUserMessage(event.userId, event.msg);
|
||||||
|
@ -53,6 +69,7 @@ export class CollaborationService {
|
||||||
const { workflowId } = msg;
|
const { workflowId } = msg;
|
||||||
|
|
||||||
this.state.addActiveWorkflowUser(workflowId, userId);
|
this.state.addActiveWorkflowUser(workflowId, userId);
|
||||||
|
this.state.cleanInactiveUsers(workflowId, INACTIVITY_CLEAN_UP_TIME_IN_MS);
|
||||||
|
|
||||||
await this.sendWorkflowUsersChangedMessage(workflowId);
|
await this.sendWorkflowUsersChangedMessage(workflowId);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,4 +59,21 @@ export class CollaborationState {
|
||||||
|
|
||||||
return [...workflowState.values()];
|
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);
|
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) {
|
handleRequest(req: SSEPushRequest | WebSocketPushRequest, res: PushResponse) {
|
||||||
const {
|
const {
|
||||||
userId,
|
userId,
|
||||||
|
@ -37,7 +45,6 @@ export class Push extends EventEmitter {
|
||||||
} = req;
|
} = req;
|
||||||
if (req.ws) {
|
if (req.ws) {
|
||||||
(this.backend as WebSocketPush).add(sessionId, userId, req.ws);
|
(this.backend as WebSocketPush).add(sessionId, userId, req.ws);
|
||||||
this.backend.on('message', (msg) => this.emit('message', msg));
|
|
||||||
} else if (!useWebSockets) {
|
} else if (!useWebSockets) {
|
||||||
(this.backend as SSEPush).add(sessionId, userId, { req, res });
|
(this.backend as SSEPush).add(sessionId, userId, { req, res });
|
||||||
} else {
|
} 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') },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -75,26 +75,31 @@ const menuHeight = computed(() => {
|
||||||
:max-height="menuHeight"
|
:max-height="menuHeight"
|
||||||
popper-class="user-stack-popper"
|
popper-class="user-stack-popper"
|
||||||
>
|
>
|
||||||
<div :class="$style.avatars">
|
<div :class="$style.avatars" data-test-id="user-stack-avatars">
|
||||||
<n8n-avatar
|
<n8n-avatar
|
||||||
v-for="user in flatUserList.slice(0, visibleAvatarCount)"
|
v-for="user in flatUserList.slice(0, visibleAvatarCount)"
|
||||||
:key="user.id"
|
:key="user.id"
|
||||||
:firstName="user.firstName"
|
:firstName="user.firstName"
|
||||||
:lastName="user.lastName"
|
:lastName="user.lastName"
|
||||||
:class="$style.avatar"
|
:class="$style.avatar"
|
||||||
|
:data-test-id="`user-stack-avatar-${user.id}`"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<div v-if="hiddenUsersCount > 0" :class="$style.hiddenBadge">+{{ hiddenUsersCount }}</div>
|
<div v-if="hiddenUsersCount > 0" :class="$style.hiddenBadge">+{{ hiddenUsersCount }}</div>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu class="user-stack-list">
|
<el-dropdown-menu class="user-stack-list" data-test-id="user-stack-list">
|
||||||
<div v-for="(groupUsers, index) in nonEmptyGroups" :key="index">
|
<div v-for="(groupUsers, index) in nonEmptyGroups" :key="index">
|
||||||
<div :class="$style.groupContainer">
|
<div :class="$style.groupContainer">
|
||||||
<el-dropdown-item>
|
<el-dropdown-item>
|
||||||
<header v-if="groupCount > 1" :class="$style.groupName">{{ index }}</header>
|
<header v-if="groupCount > 1" :class="$style.groupName">{{ index }}</header>
|
||||||
</el-dropdown-item>
|
</el-dropdown-item>
|
||||||
<div :class="$style.groupUsers">
|
<div :class="$style.groupUsers">
|
||||||
<el-dropdown-item v-for="user in groupUsers" :key="user.id">
|
<el-dropdown-item
|
||||||
|
v-for="user in groupUsers"
|
||||||
|
:key="user.id"
|
||||||
|
:data-test-id="`user-stack-info-${user.id}`"
|
||||||
|
>
|
||||||
<n8n-user-info
|
<n8n-user-info
|
||||||
v-bind="user"
|
v-bind="user"
|
||||||
:isCurrentUser="user.email === props.currentUserEmail"
|
:isCurrentUser="user.email === props.currentUserEmail"
|
||||||
|
@ -156,11 +161,12 @@ const menuHeight = computed(() => {
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.user-stack-list {
|
ul.user-stack-list {
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: var(--spacing-s);
|
||||||
|
padding-bottom: var(--spacing-2xs);
|
||||||
|
|
||||||
.el-dropdown-menu__item {
|
.el-dropdown-menu__item {
|
||||||
line-height: var(--font-line-height-regular);
|
line-height: var(--font-line-height-regular);
|
||||||
|
|
|
@ -57,6 +57,7 @@ import {
|
||||||
useCloudPlanStore,
|
useCloudPlanStore,
|
||||||
useSourceControlStore,
|
useSourceControlStore,
|
||||||
useUsageStore,
|
useUsageStore,
|
||||||
|
usePushConnectionStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
import { useHistoryHelper } from '@/composables/useHistoryHelper';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
@ -92,6 +93,7 @@ export default defineComponent({
|
||||||
useSourceControlStore,
|
useSourceControlStore,
|
||||||
useCloudPlanStore,
|
useCloudPlanStore,
|
||||||
useUsageStore,
|
useUsageStore,
|
||||||
|
usePushConnectionStore,
|
||||||
),
|
),
|
||||||
defaultLocale(): string {
|
defaultLocale(): string {
|
||||||
return this.rootStore.defaultLocale;
|
return this.rootStore.defaultLocale;
|
||||||
|
@ -168,6 +170,7 @@ export default defineComponent({
|
||||||
void this.onAfterAuthenticate();
|
void this.onAfterAuthenticate();
|
||||||
|
|
||||||
void runExternalHook('app.mount');
|
void runExternalHook('app.mount');
|
||||||
|
this.pushStore.pushConnect();
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
@ -411,6 +411,16 @@ export interface IExecutionDeleteFilter {
|
||||||
ids?: string[];
|
ids?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PushDataUsersForWorkflow = {
|
||||||
|
workflowId: string;
|
||||||
|
activeUsers: Array<{ user: IUser; lastSeen: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PushDataWorkflowUsersChanged = {
|
||||||
|
data: PushDataUsersForWorkflow;
|
||||||
|
type: 'activeWorkflowUsersChanged';
|
||||||
|
};
|
||||||
|
|
||||||
export type IPushData =
|
export type IPushData =
|
||||||
| PushDataExecutionFinished
|
| PushDataExecutionFinished
|
||||||
| PushDataExecutionStarted
|
| PushDataExecutionStarted
|
||||||
|
@ -424,7 +434,8 @@ export type IPushData =
|
||||||
| PushDataWorkerStatusMessage
|
| PushDataWorkerStatusMessage
|
||||||
| PushDataActiveWorkflowAdded
|
| PushDataActiveWorkflowAdded
|
||||||
| PushDataActiveWorkflowRemoved
|
| PushDataActiveWorkflowRemoved
|
||||||
| PushDataWorkflowFailedToActivate;
|
| PushDataWorkflowFailedToActivate
|
||||||
|
| PushDataWorkflowUsersChanged;
|
||||||
|
|
||||||
type PushDataActiveWorkflowAdded = {
|
type PushDataActiveWorkflowAdded = {
|
||||||
data: IActiveWorkflowAdded;
|
data: IActiveWorkflowAdded;
|
||||||
|
@ -690,6 +701,7 @@ export interface IUser extends IUserResponse {
|
||||||
fullName?: string;
|
fullName?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
mfaEnabled: boolean;
|
mfaEnabled: boolean;
|
||||||
|
globalRoleId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionNotificationSettings {
|
export interface IVersionNotificationSettings {
|
||||||
|
|
|
@ -0,0 +1,81 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUsersStore } from '@/stores/users.store';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||||
|
import { onBeforeUnmount } from 'vue';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { TIME } from '@/constants';
|
||||||
|
|
||||||
|
const collaborationStore = useCollaborationStore();
|
||||||
|
const usersStore = useUsersStore();
|
||||||
|
const workflowsStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL = 5 * TIME.MINUTE;
|
||||||
|
const heartbeatTimer = ref<number | null>(null);
|
||||||
|
|
||||||
|
const activeUsersSorted = computed(() => {
|
||||||
|
const currentWorkflowUsers = (collaborationStore.getUsersForCurrentWorkflow ?? []).map(
|
||||||
|
(userInfo) => userInfo.user,
|
||||||
|
);
|
||||||
|
const owner = currentWorkflowUsers.find((user) => user.globalRoleId === 1);
|
||||||
|
return {
|
||||||
|
defaultGroup: owner
|
||||||
|
? [owner, ...currentWorkflowUsers.filter((user) => user.id !== owner.id)]
|
||||||
|
: currentWorkflowUsers,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentUserEmail = computed(() => {
|
||||||
|
return usersStore.currentUser?.email;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startHeartbeat = () => {
|
||||||
|
if (heartbeatTimer.value !== null) {
|
||||||
|
clearInterval(heartbeatTimer.value);
|
||||||
|
heartbeatTimer.value = null;
|
||||||
|
}
|
||||||
|
heartbeatTimer.value = window.setInterval(() => {
|
||||||
|
collaborationStore.notifyWorkflowOpened(workflowsStore.workflow.id);
|
||||||
|
}, HEARTBEAT_INTERVAL);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopHeartbeat = () => {
|
||||||
|
if (heartbeatTimer.value !== null) {
|
||||||
|
clearInterval(heartbeatTimer.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
stopHeartbeat();
|
||||||
|
} else {
|
||||||
|
startHeartbeat();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startHeartbeat();
|
||||||
|
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||||
|
stopHeartbeat();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="`collaboration-pane-container ${$style.container}`"
|
||||||
|
data-test-id="collaboration-pane"
|
||||||
|
>
|
||||||
|
<n8n-user-stack :users="activeUsersSorted" :currentUserEmail="currentUserEmail" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.container {
|
||||||
|
margin: 0 var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -90,11 +90,6 @@ export default defineComponent({
|
||||||
mounted() {
|
mounted() {
|
||||||
this.dirtyState = this.uiStore.stateIsDirty;
|
this.dirtyState = this.uiStore.stateIsDirty;
|
||||||
this.syncTabsWithRoute(this.$route);
|
this.syncTabsWithRoute(this.$route);
|
||||||
// Initialize the push connection
|
|
||||||
this.pushConnect();
|
|
||||||
},
|
|
||||||
beforeUnmount() {
|
|
||||||
this.pushDisconnect();
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
$route(to, from) {
|
$route(to, from) {
|
||||||
|
|
|
@ -56,18 +56,20 @@
|
||||||
<span v-else class="tags"></span>
|
<span v-else class="tags"></span>
|
||||||
|
|
||||||
<PushConnectionTracker class="actions">
|
<PushConnectionTracker class="actions">
|
||||||
<span class="activator">
|
<span :class="`activator ${$style.group}`">
|
||||||
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" />
|
<WorkflowActivator :workflow-active="isWorkflowActive" :workflow-id="currentWorkflowId" />
|
||||||
</span>
|
</span>
|
||||||
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
|
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]">
|
||||||
|
<div :class="$style.group">
|
||||||
|
<collaboration-pane />
|
||||||
<n8n-button
|
<n8n-button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
class="mr-2xs"
|
|
||||||
@click="onShareButtonClick"
|
@click="onShareButtonClick"
|
||||||
data-test-id="workflow-share-button"
|
data-test-id="workflow-share-button"
|
||||||
>
|
>
|
||||||
{{ $locale.baseText('workflowDetails.share') }}
|
{{ $locale.baseText('workflowDetails.share') }}
|
||||||
</n8n-button>
|
</n8n-button>
|
||||||
|
</div>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<n8n-tooltip>
|
<n8n-tooltip>
|
||||||
<n8n-button type="secondary" :class="['mr-2xs', $style.disabledShareButton]">
|
<n8n-button type="secondary" :class="['mr-2xs', $style.disabledShareButton]">
|
||||||
|
@ -94,6 +96,7 @@
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</template>
|
</template>
|
||||||
</enterprise-edition>
|
</enterprise-edition>
|
||||||
|
<div :class="$style.group">
|
||||||
<SaveButton
|
<SaveButton
|
||||||
type="primary"
|
type="primary"
|
||||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||||
|
@ -115,7 +118,8 @@
|
||||||
text
|
text
|
||||||
/>
|
/>
|
||||||
</router-link>
|
</router-link>
|
||||||
<div :class="$style.workflowMenuContainer">
|
</div>
|
||||||
|
<div :class="[$style.workflowMenuContainer, $style.group]">
|
||||||
<input
|
<input
|
||||||
:class="$style.hiddenInput"
|
:class="$style.hiddenInput"
|
||||||
type="file"
|
type="file"
|
||||||
|
@ -159,6 +163,7 @@ import SaveButton from '@/components/SaveButton.vue';
|
||||||
import TagsDropdown from '@/components/TagsDropdown.vue';
|
import TagsDropdown from '@/components/TagsDropdown.vue';
|
||||||
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
import InlineTextEdit from '@/components/InlineTextEdit.vue';
|
||||||
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
|
||||||
|
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
|
||||||
import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
|
import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '@/Interface';
|
||||||
|
|
||||||
import { saveAs } from 'file-saver';
|
import { saveAs } from 'file-saver';
|
||||||
|
@ -201,6 +206,7 @@ export default defineComponent({
|
||||||
TagsDropdown,
|
TagsDropdown,
|
||||||
InlineTextEdit,
|
InlineTextEdit,
|
||||||
BreakpointsObserver,
|
BreakpointsObserver,
|
||||||
|
CollaborationPane,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
readOnly: {
|
readOnly: {
|
||||||
|
@ -681,7 +687,6 @@ $--header-spacing: 20px;
|
||||||
line-height: $--text-line-height;
|
line-height: $--text-line-height;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 30px;
|
|
||||||
|
|
||||||
> span {
|
> span {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
|
@ -717,14 +722,15 @@ $--header-spacing: 20px;
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: var(--spacing-m);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.workflowMenuContainer {
|
.group {
|
||||||
margin-left: var(--spacing-2xs);
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hiddenInput {
|
.hiddenInput {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -740,8 +746,6 @@ $--header-spacing: 20px;
|
||||||
.workflowHistoryButton {
|
.workflowHistoryButton {
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
margin-left: var(--spacing-m);
|
|
||||||
margin-right: var(--spacing-4xs);
|
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
border-radius: var(--border-radius-base);
|
border-radius: var(--border-radius-base);
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { merge } from 'lodash-es';
|
||||||
|
import { SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
|
import CollaborationPane from '@/components//MainHeader/CollaborationPane.vue';
|
||||||
|
import type { RenderOptions } from '@/__tests__/render';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
|
||||||
|
const OWNER_USER = {
|
||||||
|
createdAt: '2023-11-22T10:17:12.246Z',
|
||||||
|
id: 'aaaaaa',
|
||||||
|
email: 'owner@user.com',
|
||||||
|
firstName: 'Owner',
|
||||||
|
lastName: 'User',
|
||||||
|
globalRoleId: 1,
|
||||||
|
disabled: false,
|
||||||
|
globalRole: {
|
||||||
|
id: '1',
|
||||||
|
name: 'owner',
|
||||||
|
scope: 'global',
|
||||||
|
},
|
||||||
|
isPending: false,
|
||||||
|
isOwner: true,
|
||||||
|
fullName: 'Owner User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEMBER_USER = {
|
||||||
|
createdAt: '2023-11-22T10:17:12.246Z',
|
||||||
|
id: 'aaabbb',
|
||||||
|
email: 'member@user.com',
|
||||||
|
firstName: 'Member',
|
||||||
|
lastName: 'User',
|
||||||
|
globalRoleId: 2,
|
||||||
|
disabled: false,
|
||||||
|
globalRole: {
|
||||||
|
id: '2',
|
||||||
|
name: 'member',
|
||||||
|
scope: 'global',
|
||||||
|
},
|
||||||
|
isPending: false,
|
||||||
|
isOwner: false,
|
||||||
|
fullName: 'Member User',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MEMBER_USER_2 = {
|
||||||
|
createdAt: '2023-11-22T10:17:12.246Z',
|
||||||
|
id: 'aaaccc',
|
||||||
|
email: 'member2@user.com',
|
||||||
|
firstName: 'Another Member',
|
||||||
|
lastName: 'User',
|
||||||
|
globalRoleId: 2,
|
||||||
|
disabled: false,
|
||||||
|
globalRole: {
|
||||||
|
id: '2',
|
||||||
|
name: 'member',
|
||||||
|
scope: 'global',
|
||||||
|
},
|
||||||
|
isPending: false,
|
||||||
|
isOwner: false,
|
||||||
|
fullName: 'Another Member User',
|
||||||
|
};
|
||||||
|
|
||||||
|
let uiStore: ReturnType<typeof useUIStore>;
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
[STORES.SETTINGS]: {
|
||||||
|
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
|
||||||
|
},
|
||||||
|
[STORES.WORKFLOWS]: {
|
||||||
|
workflow: {
|
||||||
|
id: 'w1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[STORES.USERS]: {
|
||||||
|
currentUserId: 'aaaaaa',
|
||||||
|
users: {
|
||||||
|
aaaaaa: OWNER_USER,
|
||||||
|
aaabbb: MEMBER_USER,
|
||||||
|
aaaccc: MEMBER_USER_2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[STORES.COLLABORATION]: {
|
||||||
|
usersForWorkflows: {
|
||||||
|
w1: [
|
||||||
|
{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER },
|
||||||
|
{ lastSeen: '2023-11-22T10:17:12.246Z', user: OWNER_USER },
|
||||||
|
],
|
||||||
|
w2: [{ lastSeen: '2023-11-22T10:17:12.246Z', user: MEMBER_USER_2 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultRenderOptions: RenderOptions = {
|
||||||
|
pinia: createTestingPinia({ initialState }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(CollaborationPane, defaultRenderOptions);
|
||||||
|
|
||||||
|
describe('CollaborationPane', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
uiStore = useUIStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show only current workflow users', async () => {
|
||||||
|
const { getByTestId, queryByTestId } = renderComponent();
|
||||||
|
await waitAllPromises();
|
||||||
|
|
||||||
|
expect(getByTestId('collaboration-pane')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('user-stack-avatars')).toBeInTheDocument();
|
||||||
|
expect(getByTestId(`user-stack-avatar-${OWNER_USER.id}`)).toBeInTheDocument();
|
||||||
|
expect(getByTestId(`user-stack-avatar-${MEMBER_USER.id}`)).toBeInTheDocument();
|
||||||
|
expect(queryByTestId(`user-stack-avatar-${MEMBER_USER_2.id}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render current user correctly', async () => {
|
||||||
|
const { getByText, queryByText } = renderComponent();
|
||||||
|
await waitAllPromises();
|
||||||
|
expect(getByText(`${OWNER_USER.fullName} (you)`)).toBeInTheDocument();
|
||||||
|
expect(queryByText(`${MEMBER_USER.fullName} (you)`)).toBeNull();
|
||||||
|
expect(queryByText(`${MEMBER_USER.fullName}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should always render owner first in the list', async () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
await waitAllPromises();
|
||||||
|
const firstAvatar = getByTestId('user-stack-avatars').querySelector('.n8n-avatar');
|
||||||
|
// Owner is second in the store bur shourld be rendered first
|
||||||
|
expect(firstAvatar).toHaveAttribute('data-test-id', `user-stack-avatar-${OWNER_USER.id}`);
|
||||||
|
});
|
||||||
|
});
|
|
@ -567,6 +567,8 @@ export const enum STORES {
|
||||||
WEBHOOKS = 'webhooks',
|
WEBHOOKS = 'webhooks',
|
||||||
HISTORY = 'history',
|
HISTORY = 'history',
|
||||||
CLOUD_PLAN = 'cloudPlan',
|
CLOUD_PLAN = 'cloudPlan',
|
||||||
|
COLLABORATION = 'collaboration',
|
||||||
|
PUSH = 'push',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum SignInType {
|
export const enum SignInType {
|
||||||
|
@ -651,3 +653,13 @@ export const NOT_DUPLICATABE_NODE_TYPES = [FORM_TRIGGER_NODE_TYPE];
|
||||||
export const UPDATE_WEBHOOK_ID_NODE_TYPES = [FORM_TRIGGER_NODE_TYPE];
|
export const UPDATE_WEBHOOK_ID_NODE_TYPES = [FORM_TRIGGER_NODE_TYPE];
|
||||||
|
|
||||||
export const CREATOR_HUB_URL = 'https://creators.n8n.io/hub';
|
export const CREATOR_HUB_URL = 'https://creators.n8n.io/hub';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Units of time in milliseconds
|
||||||
|
*/
|
||||||
|
export const TIME = {
|
||||||
|
SECOND: 1000,
|
||||||
|
MINUTE: 60 * 1000,
|
||||||
|
HOUR: 60 * 60 * 1000,
|
||||||
|
DAY: 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
|
|
@ -35,6 +35,8 @@ import { parse } from 'flatted';
|
||||||
import { useSegment } from '@/stores/segment.store';
|
import { useSegment } from '@/stores/segment.store';
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import { useOrchestrationStore } from '@/stores/orchestration.store';
|
import { useOrchestrationStore } from '@/stores/orchestration.store';
|
||||||
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
|
import { useCollaborationStore } from '@/stores/collaboration.store';
|
||||||
|
|
||||||
export const pushConnection = defineComponent({
|
export const pushConnection = defineComponent({
|
||||||
setup() {
|
setup() {
|
||||||
|
@ -43,15 +45,16 @@ export const pushConnection = defineComponent({
|
||||||
...useToast(),
|
...useToast(),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
created() {
|
||||||
|
this.pushStore.addEventListener((message) => {
|
||||||
|
void this.pushMessageReceived(message);
|
||||||
|
});
|
||||||
|
},
|
||||||
mixins: [externalHooks, nodeHelpers, workflowHelpers],
|
mixins: [externalHooks, nodeHelpers, workflowHelpers],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
pushSource: null as WebSocket | EventSource | null,
|
|
||||||
reconnectTimeout: null as NodeJS.Timeout | null,
|
|
||||||
retryTimeout: null as NodeJS.Timeout | null,
|
retryTimeout: null as NodeJS.Timeout | null,
|
||||||
pushMessageQueue: [] as Array<{ event: Event; retriesLeft: number }>,
|
pushMessageQueue: [] as Array<{ message: IPushData; retriesLeft: number }>,
|
||||||
connectRetries: 0,
|
|
||||||
lostConnection: false,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
@ -63,95 +66,22 @@ export const pushConnection = defineComponent({
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useSegment,
|
useSegment,
|
||||||
useOrchestrationStore,
|
useOrchestrationStore,
|
||||||
|
usePushConnectionStore,
|
||||||
|
useCollaborationStore,
|
||||||
),
|
),
|
||||||
sessionId(): string {
|
sessionId(): string {
|
||||||
return this.rootStore.sessionId;
|
return this.rootStore.sessionId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
attemptReconnect() {
|
|
||||||
this.pushConnect();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to server to receive data via a WebSocket or EventSource
|
|
||||||
*/
|
|
||||||
pushConnect(): void {
|
|
||||||
// always close the previous connection so that we do not end up with multiple connections
|
|
||||||
this.pushDisconnect();
|
|
||||||
|
|
||||||
if (this.reconnectTimeout) {
|
|
||||||
clearTimeout(this.reconnectTimeout);
|
|
||||||
this.reconnectTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const useWebSockets = this.settingsStore.pushBackend === 'websocket';
|
|
||||||
|
|
||||||
const { getRestUrl: restUrl } = this.rootStore;
|
|
||||||
const url = `/push?sessionId=${this.sessionId}`;
|
|
||||||
|
|
||||||
if (useWebSockets) {
|
|
||||||
const { protocol, host } = window.location;
|
|
||||||
const baseUrl = restUrl.startsWith('http')
|
|
||||||
? restUrl.replace(/^http/, 'ws')
|
|
||||||
: `${protocol === 'https:' ? 'wss' : 'ws'}://${host + restUrl}`;
|
|
||||||
this.pushSource = new WebSocket(`${baseUrl}${url}`);
|
|
||||||
} else {
|
|
||||||
this.pushSource = new EventSource(`${restUrl}${url}`, { withCredentials: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pushSource.addEventListener('open', this.onConnectionSuccess, false);
|
|
||||||
this.pushSource.addEventListener('message', this.pushMessageReceived, false);
|
|
||||||
this.pushSource.addEventListener(
|
|
||||||
useWebSockets ? 'close' : 'error',
|
|
||||||
this.onConnectionError,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
onConnectionSuccess() {
|
|
||||||
this.connectRetries = 0;
|
|
||||||
this.lostConnection = false;
|
|
||||||
this.rootStore.pushConnectionActive = true;
|
|
||||||
try {
|
|
||||||
// in the workers view context this fn is not defined
|
|
||||||
this.clearAllStickyNotifications();
|
|
||||||
} catch {}
|
|
||||||
this.pushSource?.removeEventListener('open', this.onConnectionSuccess);
|
|
||||||
},
|
|
||||||
|
|
||||||
onConnectionError() {
|
|
||||||
this.pushDisconnect();
|
|
||||||
this.connectRetries++;
|
|
||||||
this.reconnectTimeout = setTimeout(
|
|
||||||
this.attemptReconnect,
|
|
||||||
Math.min(this.connectRetries * 2000, 8000), // maximum 8 seconds backoff
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close connection to server
|
|
||||||
*/
|
|
||||||
pushDisconnect(): void {
|
|
||||||
if (this.pushSource !== null) {
|
|
||||||
this.pushSource.removeEventListener('error', this.onConnectionError);
|
|
||||||
this.pushSource.removeEventListener('close', this.onConnectionError);
|
|
||||||
this.pushSource.removeEventListener('message', this.pushMessageReceived);
|
|
||||||
if (this.pushSource.readyState < 2) this.pushSource.close();
|
|
||||||
this.pushSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rootStore.pushConnectionActive = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sometimes the push message is faster as the result from
|
* Sometimes the push message is faster as the result from
|
||||||
* the REST API so we do not know yet what execution ID
|
* the REST API so we do not know yet what execution ID
|
||||||
* is currently active. So internally resend the message
|
* is currently active. So internally resend the message
|
||||||
* a few more times
|
* a few more times
|
||||||
*/
|
*/
|
||||||
queuePushMessage(event: Event, retryAttempts: number) {
|
queuePushMessage(event: IPushData, retryAttempts: number) {
|
||||||
this.pushMessageQueue.push({ event, retriesLeft: retryAttempts });
|
this.pushMessageQueue.push({ message: event, retriesLeft: retryAttempts });
|
||||||
|
|
||||||
if (this.retryTimeout === null) {
|
if (this.retryTimeout === null) {
|
||||||
this.retryTimeout = setTimeout(this.processWaitingPushMessages, 20);
|
this.retryTimeout = setTimeout(this.processWaitingPushMessages, 20);
|
||||||
|
@ -161,7 +91,7 @@ export const pushConnection = defineComponent({
|
||||||
/**
|
/**
|
||||||
* Process the push messages which are waiting in the queue
|
* Process the push messages which are waiting in the queue
|
||||||
*/
|
*/
|
||||||
processWaitingPushMessages() {
|
async processWaitingPushMessages() {
|
||||||
if (this.retryTimeout !== null) {
|
if (this.retryTimeout !== null) {
|
||||||
clearTimeout(this.retryTimeout);
|
clearTimeout(this.retryTimeout);
|
||||||
this.retryTimeout = null;
|
this.retryTimeout = null;
|
||||||
|
@ -171,7 +101,8 @@ export const pushConnection = defineComponent({
|
||||||
for (let i = 0; i < queueLength; i++) {
|
for (let i = 0; i < queueLength; i++) {
|
||||||
const messageData = this.pushMessageQueue.shift();
|
const messageData = this.pushMessageQueue.shift();
|
||||||
|
|
||||||
if (this.pushMessageReceived(messageData!.event, true) === false) {
|
const result = await this.pushMessageReceived(messageData!.message, true);
|
||||||
|
if (result === false) {
|
||||||
// Was not successful
|
// Was not successful
|
||||||
messageData!.retriesLeft -= 1;
|
messageData!.retriesLeft -= 1;
|
||||||
|
|
||||||
|
@ -191,15 +122,8 @@ export const pushConnection = defineComponent({
|
||||||
/**
|
/**
|
||||||
* Process a newly received message
|
* Process a newly received message
|
||||||
*/
|
*/
|
||||||
async pushMessageReceived(event: Event, isRetry?: boolean): Promise<boolean> {
|
async pushMessageReceived(receivedData: IPushData, isRetry?: boolean): Promise<boolean> {
|
||||||
const retryAttempts = 5;
|
const retryAttempts = 5;
|
||||||
let receivedData: IPushData;
|
|
||||||
try {
|
|
||||||
// @ts-ignore
|
|
||||||
receivedData = JSON.parse(event.data);
|
|
||||||
} catch (error) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (receivedData.type === 'sendWorkerStatusMessage') {
|
if (receivedData.type === 'sendWorkerStatusMessage') {
|
||||||
const pushData = receivedData.data;
|
const pushData = receivedData.data;
|
||||||
|
@ -220,7 +144,7 @@ export const pushConnection = defineComponent({
|
||||||
) {
|
) {
|
||||||
// If there are already messages in the queue add the new one that all of them
|
// If there are already messages in the queue add the new one that all of them
|
||||||
// get executed in order
|
// get executed in order
|
||||||
this.queuePushMessage(event, retryAttempts);
|
this.queuePushMessage(receivedData, retryAttempts);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -596,7 +520,7 @@ export const pushConnection = defineComponent({
|
||||||
this.workflowsStore.activeExecutionId = pushData.executionId;
|
this.workflowsStore.activeExecutionId = pushData.executionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.processWaitingPushMessages();
|
void this.processWaitingPushMessages();
|
||||||
} else if (receivedData.type === 'reloadNodeType') {
|
} else if (receivedData.type === 'reloadNodeType') {
|
||||||
await this.nodeTypesStore.getNodeTypes();
|
await this.nodeTypesStore.getNodeTypes();
|
||||||
await this.nodeTypesStore.getFullNodesProperties([receivedData.data]);
|
await this.nodeTypesStore.getFullNodesProperties([receivedData.data]);
|
||||||
|
|
53
packages/editor-ui/src/stores/collaboration.store.ts
Normal file
53
packages/editor-ui/src/stores/collaboration.store.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { usePushConnectionStore } from '@/stores/pushConnection.store';
|
||||||
|
import { STORES } from '@/constants';
|
||||||
|
import type { IUser } from '@/Interface';
|
||||||
|
|
||||||
|
type ActiveUsersForWorkflows = {
|
||||||
|
[workflowId: string]: Array<{ user: IUser; lastSeen: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCollaborationStore = defineStore(STORES.COLLABORATION, () => {
|
||||||
|
const pushStore = usePushConnectionStore();
|
||||||
|
const workflowStore = useWorkflowsStore();
|
||||||
|
|
||||||
|
const usersForWorkflows = ref<ActiveUsersForWorkflows>({});
|
||||||
|
|
||||||
|
pushStore.addEventListener((event) => {
|
||||||
|
if (event.type === 'activeWorkflowUsersChanged') {
|
||||||
|
const activeWorkflowId = workflowStore.workflowId;
|
||||||
|
if (event.data.workflowId === activeWorkflowId) {
|
||||||
|
usersForWorkflows.value[activeWorkflowId] = event.data.activeUsers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const workflowUsersUpdated = (data: ActiveUsersForWorkflows) => {
|
||||||
|
usersForWorkflows.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyWorkflowOpened = (workflowId: string) => {
|
||||||
|
pushStore.send({
|
||||||
|
type: 'workflowOpened',
|
||||||
|
workflowId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifyWorkflowClosed = (workflowId: string) => {
|
||||||
|
pushStore.send({ type: 'workflowClosed', workflowId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUsersForCurrentWorkflow = computed(() => {
|
||||||
|
return usersForWorkflows.value[workflowStore.workflowId];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
usersForWorkflows,
|
||||||
|
notifyWorkflowOpened,
|
||||||
|
notifyWorkflowClosed,
|
||||||
|
workflowUsersUpdated,
|
||||||
|
getUsersForCurrentWorkflow,
|
||||||
|
};
|
||||||
|
});
|
|
@ -26,3 +26,5 @@ export * from './cloudPlan.store';
|
||||||
export * from './sourceControl.store';
|
export * from './sourceControl.store';
|
||||||
export * from './sso.store';
|
export * from './sso.store';
|
||||||
export * from './auditLogs.store';
|
export * from './auditLogs.store';
|
||||||
|
export * from './collaboration.store';
|
||||||
|
export * from './pushConnection.store';
|
||||||
|
|
154
packages/editor-ui/src/stores/pushConnection.store.ts
Normal file
154
packages/editor-ui/src/stores/pushConnection.store.ts
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { STORES, TIME } from '@/constants';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useSettingsStore } from './settings.store';
|
||||||
|
import { useRootStore } from './n8nRoot.store';
|
||||||
|
import type { IPushData } from '../Interface';
|
||||||
|
|
||||||
|
export interface PushState {
|
||||||
|
sessionId: string;
|
||||||
|
pushSource: WebSocket | EventSource | null;
|
||||||
|
reconnectTimeout: NodeJS.Timeout | null;
|
||||||
|
retryTimeout: NodeJS.Timeout | null;
|
||||||
|
pushMessageQueue: Array<{ event: Event; retriesLeft: number }>;
|
||||||
|
connectRetries: number;
|
||||||
|
lostConnection: boolean;
|
||||||
|
outgoingQueue: unknown[];
|
||||||
|
isConnectionOpen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OnPushMessageHandler = (event: IPushData) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store for managing a push connection to the server
|
||||||
|
*/
|
||||||
|
export const usePushConnectionStore = defineStore(STORES.PUSH, () => {
|
||||||
|
const rootStore = useRootStore();
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
|
||||||
|
const sessionId = computed(() => rootStore.sessionId);
|
||||||
|
const pushSource = ref<WebSocket | EventSource | null>(null);
|
||||||
|
const reconnectTimeout = ref<NodeJS.Timeout | null>(null);
|
||||||
|
const connectRetries = ref(0);
|
||||||
|
const lostConnection = ref(false);
|
||||||
|
const outgoingQueue = ref<unknown[]>([]);
|
||||||
|
const isConnectionOpen = ref(false);
|
||||||
|
const onMessageReceivedHandlers = ref<OnPushMessageHandler[]>([]);
|
||||||
|
|
||||||
|
const addEventListener = (handler: OnPushMessageHandler) => {
|
||||||
|
onMessageReceivedHandlers.value.push(handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
function onConnectionError() {
|
||||||
|
pushDisconnect();
|
||||||
|
connectRetries.value++;
|
||||||
|
reconnectTimeout.value = setTimeout(
|
||||||
|
attemptReconnect,
|
||||||
|
Math.min(connectRetries.value * 2000, 8 * TIME.SECOND), // maximum 8 seconds backoff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close connection to server
|
||||||
|
*/
|
||||||
|
function pushDisconnect() {
|
||||||
|
if (pushSource.value !== null) {
|
||||||
|
pushSource.value.removeEventListener('error', onConnectionError);
|
||||||
|
pushSource.value.removeEventListener('close', onConnectionError);
|
||||||
|
pushSource.value.removeEventListener('message', pushMessageReceived);
|
||||||
|
if (pushSource.value.readyState < 2) pushSource.value.close();
|
||||||
|
pushSource.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnectionOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to server to receive data via a WebSocket or EventSource
|
||||||
|
*/
|
||||||
|
function pushConnect() {
|
||||||
|
// always close the previous connection so that we do not end up with multiple connections
|
||||||
|
pushDisconnect();
|
||||||
|
|
||||||
|
if (reconnectTimeout.value) {
|
||||||
|
clearTimeout(reconnectTimeout.value);
|
||||||
|
reconnectTimeout.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useWebSockets = settingsStore.pushBackend === 'websocket';
|
||||||
|
|
||||||
|
const { getRestUrl: restUrl } = rootStore;
|
||||||
|
const url = `/push?sessionId=${sessionId.value}`;
|
||||||
|
|
||||||
|
if (useWebSockets) {
|
||||||
|
const { protocol, host } = window.location;
|
||||||
|
const baseUrl = restUrl.startsWith('http')
|
||||||
|
? restUrl.replace(/^http/, 'ws')
|
||||||
|
: `${protocol === 'https:' ? 'wss' : 'ws'}://${host + restUrl}`;
|
||||||
|
pushSource.value = new WebSocket(`${baseUrl}${url}`);
|
||||||
|
} else {
|
||||||
|
pushSource.value = new EventSource(`${restUrl}${url}`, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
pushSource.value.addEventListener('open', onConnectionSuccess, false);
|
||||||
|
pushSource.value.addEventListener('message', pushMessageReceived, false);
|
||||||
|
pushSource.value.addEventListener(useWebSockets ? 'close' : 'error', onConnectionError, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attemptReconnect() {
|
||||||
|
pushConnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeAndSend(message: unknown) {
|
||||||
|
if (pushSource.value && 'send' in pushSource.value) {
|
||||||
|
pushSource.value.send(JSON.stringify(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConnectionSuccess() {
|
||||||
|
isConnectionOpen.value = true;
|
||||||
|
connectRetries.value = 0;
|
||||||
|
lostConnection.value = false;
|
||||||
|
rootStore.pushConnectionActive = true;
|
||||||
|
pushSource.value?.removeEventListener('open', onConnectionSuccess);
|
||||||
|
|
||||||
|
if (outgoingQueue.value.length) {
|
||||||
|
for (const message of outgoingQueue.value) {
|
||||||
|
serializeAndSend(message);
|
||||||
|
}
|
||||||
|
outgoingQueue.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message: unknown) {
|
||||||
|
if (!isConnectionOpen.value) {
|
||||||
|
outgoingQueue.value.push(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serializeAndSend(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a newly received message
|
||||||
|
*/
|
||||||
|
async function pushMessageReceived(event: Event) {
|
||||||
|
let receivedData: IPushData;
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
receivedData = JSON.parse(event.data);
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// TODO: Why is this received multiple times?
|
||||||
|
onMessageReceivedHandlers.value.forEach((handler) => handler(receivedData));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
pushSource,
|
||||||
|
isConnectionOpen,
|
||||||
|
addEventListener,
|
||||||
|
pushConnect,
|
||||||
|
send,
|
||||||
|
};
|
||||||
|
});
|
|
@ -230,6 +230,7 @@ import {
|
||||||
AI_NODE_CREATOR_VIEW,
|
AI_NODE_CREATOR_VIEW,
|
||||||
DRAG_EVENT_DATA_KEY,
|
DRAG_EVENT_DATA_KEY,
|
||||||
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
UPDATE_WEBHOOK_ID_NODE_TYPES,
|
||||||
|
TIME,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { copyPaste } from '@/mixins/copyPaste';
|
import { copyPaste } from '@/mixins/copyPaste';
|
||||||
import { externalHooks } from '@/mixins/externalHooks';
|
import { externalHooks } from '@/mixins/externalHooks';
|
||||||
|
@ -329,6 +330,7 @@ import {
|
||||||
useUIStore,
|
useUIStore,
|
||||||
useHistoryStore,
|
useHistoryStore,
|
||||||
useExternalSecretsStore,
|
useExternalSecretsStore,
|
||||||
|
useCollaborationStore,
|
||||||
} from '@/stores';
|
} from '@/stores';
|
||||||
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
import * as NodeViewUtils from '@/utils/nodeViewUtils';
|
||||||
import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils';
|
import { getAccountAge, getConnectionInfo, getNodeViewTab } from '@/utils';
|
||||||
|
@ -523,15 +525,18 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
this.collaborationStore.notifyWorkflowClosed(this.currentWorkflow);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
} else if (confirmModal === MODAL_CANCEL) {
|
} else if (confirmModal === MODAL_CANCEL) {
|
||||||
|
this.collaborationStore.notifyWorkflowClosed(this.currentWorkflow);
|
||||||
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
this.workflowsStore.setWorkflowId(PLACEHOLDER_EMPTY_WORKFLOW_ID);
|
||||||
this.resetWorkspace();
|
this.resetWorkspace();
|
||||||
this.uiStore.stateIsDirty = false;
|
this.uiStore.stateIsDirty = false;
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
this.collaborationStore.notifyWorkflowClosed(this.currentWorkflow);
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -554,6 +559,7 @@ export default defineComponent({
|
||||||
useWorkflowsEEStore,
|
useWorkflowsEEStore,
|
||||||
useHistoryStore,
|
useHistoryStore,
|
||||||
useExternalSecretsStore,
|
useExternalSecretsStore,
|
||||||
|
useCollaborationStore,
|
||||||
),
|
),
|
||||||
nativelyNumberSuffixedDefaults(): string[] {
|
nativelyNumberSuffixedDefaults(): string[] {
|
||||||
return this.nodeTypesStore.nativelyNumberSuffixedDefaults;
|
return this.nodeTypesStore.nativelyNumberSuffixedDefaults;
|
||||||
|
@ -717,6 +723,7 @@ export default defineComponent({
|
||||||
suspendRecordingDetachedConnections: false,
|
suspendRecordingDetachedConnections: false,
|
||||||
NODE_CREATOR_OPEN_SOURCES,
|
NODE_CREATOR_OPEN_SOURCES,
|
||||||
eventsAttached: false,
|
eventsAttached: false,
|
||||||
|
unloadTimeout: undefined as undefined | ReturnType<typeof setTimeout>,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
@ -1064,6 +1071,7 @@ export default defineComponent({
|
||||||
this.workflowsStore.activeWorkflowExecution = selectedExecution;
|
this.workflowsStore.activeWorkflowExecution = selectedExecution;
|
||||||
}
|
}
|
||||||
this.stopLoading();
|
this.stopLoading();
|
||||||
|
this.collaborationStore.notifyWorkflowOpened(workflow.id);
|
||||||
},
|
},
|
||||||
touchTap(e: MouseEvent | TouchEvent) {
|
touchTap(e: MouseEvent | TouchEvent) {
|
||||||
if (this.isTouchDevice) {
|
if (this.isTouchDevice) {
|
||||||
|
@ -3047,20 +3055,30 @@ export default defineComponent({
|
||||||
|
|
||||||
this.eventsAttached = false;
|
this.eventsAttached = false;
|
||||||
},
|
},
|
||||||
onBeforeUnload(e) {
|
onBeforeUnload(e: BeforeUnloadEvent) {
|
||||||
if (this.isDemo || window.preventNodeViewBeforeUnload) {
|
if (this.isDemo || window.preventNodeViewBeforeUnload) {
|
||||||
return;
|
return;
|
||||||
} else if (this.uiStore.stateIsDirty) {
|
} else if (this.uiStore.stateIsDirty) {
|
||||||
const confirmationMessage = this.$locale.baseText(
|
// A bit hacky solution to detecting users leaving the page after prompt:
|
||||||
'nodeView.itLooksLikeYouHaveBeenEditingSomething',
|
// 1. Notify that workflow is closed straight away
|
||||||
);
|
this.collaborationStore.notifyWorkflowClosed(this.workflowsStore.workflowId);
|
||||||
(e || window.event).returnValue = confirmationMessage; //Gecko + IE
|
// 2. If user decided to stay on the page we notify that the workflow is opened again
|
||||||
return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc.
|
this.unloadTimeout = setTimeout(() => {
|
||||||
|
this.collaborationStore.notifyWorkflowOpened(this.workflowsStore.workflowId);
|
||||||
|
}, 5 * TIME.SECOND);
|
||||||
|
e.returnValue = true; //Gecko + IE
|
||||||
|
return true; //Gecko + Webkit, Safari, Chrome etc.
|
||||||
} else {
|
} else {
|
||||||
this.startLoading(this.$locale.baseText('nodeView.redirecting'));
|
this.startLoading(this.$locale.baseText('nodeView.redirecting'));
|
||||||
|
this.collaborationStore.notifyWorkflowClosed(this.workflowsStore.workflowId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onUnload() {
|
||||||
|
// This will fire if users decides to leave the page after prompted
|
||||||
|
// Clear the interval to prevent the notification from being sent
|
||||||
|
clearTimeout(this.unloadTimeout);
|
||||||
|
},
|
||||||
async newWorkflow(): Promise<void> {
|
async newWorkflow(): Promise<void> {
|
||||||
this.startLoading();
|
this.startLoading();
|
||||||
this.resetWorkspace();
|
this.resetWorkspace();
|
||||||
|
@ -3159,6 +3177,7 @@ export default defineComponent({
|
||||||
document.addEventListener('keyup', this.keyUp);
|
document.addEventListener('keyup', this.keyUp);
|
||||||
|
|
||||||
window.addEventListener('beforeunload', this.onBeforeUnload);
|
window.addEventListener('beforeunload', this.onBeforeUnload);
|
||||||
|
window.addEventListener('unload', this.onUnload);
|
||||||
},
|
},
|
||||||
getOutputEndpointUUID(
|
getOutputEndpointUUID(
|
||||||
nodeName: string,
|
nodeName: string,
|
||||||
|
|
Loading…
Reference in a new issue