mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Deactivate crashed workflows (#21888)
Co-authored-by: Artem Sorokin <artem@n8n.io>
This commit is contained in:
co-authored by
Artem Sorokin
parent
7186dcfe7e
commit
710a6548d4
@@ -92,6 +92,7 @@ describe('DismissBannerRequestDto', () => {
|
||||
'EMAIL_CONFIRMATION',
|
||||
'DATA_TABLE_STORAGE_LIMIT_WARNING',
|
||||
'DATA_TABLE_STORAGE_LIMIT_ERROR',
|
||||
'WORKFLOW_AUTO_DEACTIVATED',
|
||||
];
|
||||
|
||||
expect(staticBannerNameSchema.options).toEqual(expectedBanners);
|
||||
|
||||
@@ -20,7 +20,15 @@ export type WorkflowDeactivated = {
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowAutoDeactivated = {
|
||||
type: 'workflowAutoDeactivated';
|
||||
data: {
|
||||
workflowId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WorkflowPushMessage =
|
||||
| WorkflowActivated
|
||||
| WorkflowFailedToActivate
|
||||
| WorkflowDeactivated;
|
||||
| WorkflowDeactivated
|
||||
| WorkflowAutoDeactivated;
|
||||
|
||||
@@ -8,6 +8,7 @@ export const staticBannerNameSchema = z.enum([
|
||||
'EMAIL_CONFIRMATION',
|
||||
'DATA_TABLE_STORAGE_LIMIT_WARNING',
|
||||
'DATA_TABLE_STORAGE_LIMIT_ERROR',
|
||||
'WORKFLOW_AUTO_DEACTIVATED',
|
||||
]);
|
||||
export const dynamicBannerNameSchema = z.string().regex(/^dynamic-banner-\d+$/);
|
||||
export const bannerNameSchema = z.union([staticBannerNameSchema, dynamicBannerNameSchema]);
|
||||
|
||||
@@ -39,6 +39,22 @@ class QueueRecoveryConfig {
|
||||
batchSize: number = 100;
|
||||
}
|
||||
|
||||
@Config
|
||||
class RecoveryConfig {
|
||||
/**
|
||||
* Number of last executions to check when determining if a workflow should be deactivated
|
||||
* when all of the last N executions have crashed.
|
||||
*/
|
||||
@Env('N8N_WORKFLOW_AUTODEACTIVATION_MAX_LAST_EXECUTIONS')
|
||||
maxLastExecutions: number = 3;
|
||||
|
||||
/**
|
||||
* Whether to automatically deactivate workflows that have all their last executions crashed.
|
||||
*/
|
||||
@Env('N8N_WORKFLOW_AUTODEACTIVATION_ENABLED')
|
||||
workflowDeactivationEnabled: boolean = false;
|
||||
}
|
||||
|
||||
const executionModeSchema = z.enum(['regular', 'queue']);
|
||||
|
||||
export type ExecutionMode = z.infer<typeof executionModeSchema>;
|
||||
@@ -93,6 +109,9 @@ export class ExecutionsConfig {
|
||||
@Nested
|
||||
queueRecovery: QueueRecoveryConfig;
|
||||
|
||||
@Nested
|
||||
recovery: RecoveryConfig;
|
||||
|
||||
/** Whether to save execution data for failed production executions. This default can be overridden at a workflow level. */
|
||||
@Env('EXECUTIONS_DATA_SAVE_ON_ERROR')
|
||||
saveDataOnError: 'all' | 'none' = 'all';
|
||||
|
||||
@@ -61,6 +61,10 @@ export class TemplateConfig {
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_SHARED')
|
||||
'workflow-shared': string = '';
|
||||
|
||||
/** Overrides default HTML template for notifying that a workflow was deactivated (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_WORKFLOW_AUTODEACTIVATED')
|
||||
'workflow-deactivated': string = '';
|
||||
|
||||
/** Overrides default HTML template for notifying that credentials were shared (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_CREDENTIALS_SHARED')
|
||||
'credentials-shared': string = '';
|
||||
|
||||
@@ -137,6 +137,7 @@ describe('GlobalConfig', () => {
|
||||
'credentials-shared': '',
|
||||
'user-invited': '',
|
||||
'password-reset-requested': '',
|
||||
'workflow-deactivated': '',
|
||||
'workflow-shared': '',
|
||||
'project-shared': '',
|
||||
},
|
||||
@@ -347,6 +348,10 @@ describe('GlobalConfig', () => {
|
||||
interval: 180,
|
||||
batchSize: 100,
|
||||
},
|
||||
recovery: {
|
||||
maxLastExecutions: 3,
|
||||
workflowDeactivationEnabled: false,
|
||||
},
|
||||
saveDataOnError: 'all',
|
||||
saveDataOnSuccess: 'all',
|
||||
saveExecutionProgress: false,
|
||||
|
||||
@@ -186,14 +186,20 @@ export class MessageEventBus extends EventEmitter {
|
||||
// start actual recovery process and write recovery process flag file
|
||||
this.logWriter?.startRecoveryProcess();
|
||||
const recoveredIds: string[] = [];
|
||||
const crashedWorkflowIds: Set<string> = new Set();
|
||||
|
||||
for (const executionId of unfinishedExecutionIds) {
|
||||
const logMesssages = unsentAndUnfinished.unfinishedExecutions[executionId];
|
||||
const logMessages = unsentAndUnfinished.unfinishedExecutions[executionId];
|
||||
const recoveredExecution = await this.recoveryService.recoverFromLogs(
|
||||
executionId,
|
||||
logMesssages ?? [],
|
||||
logMessages ?? [],
|
||||
);
|
||||
if (recoveredExecution) recoveredIds.push(executionId);
|
||||
if (recoveredExecution) {
|
||||
if (recoveredExecution.status === 'crashed') {
|
||||
crashedWorkflowIds.add(recoveredExecution.workflowId);
|
||||
}
|
||||
recoveredIds.push(executionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (recoveredIds.length > 0) {
|
||||
@@ -202,6 +208,13 @@ export class MessageEventBus extends EventEmitter {
|
||||
'This could be due to a crash of an active workflow or a restart of n8n',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.globalConfig.executions.recovery.workflowDeactivationEnabled &&
|
||||
crashedWorkflowIds.size > 0
|
||||
) {
|
||||
await this.recoveryService.autoDeactivateWorkflowsIfNeeded(crashedWorkflowIds);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the recovery process flag file
|
||||
|
||||
@@ -263,6 +263,7 @@ export type RelayEventMap = {
|
||||
| 'Reset password'
|
||||
| 'New user invite'
|
||||
| 'Resend invite'
|
||||
| 'Workflow auto-deactivated'
|
||||
| 'Workflow shared'
|
||||
| 'Credentials shared'
|
||||
| 'Project shared';
|
||||
@@ -301,6 +302,7 @@ export type RelayEventMap = {
|
||||
| 'New user invite'
|
||||
| 'Resend invite'
|
||||
| 'Workflow shared'
|
||||
| 'Workflow auto-deactivated'
|
||||
| 'Credentials shared'
|
||||
| 'Project shared';
|
||||
publicApi: boolean;
|
||||
|
||||
+57
-2
@@ -1,11 +1,20 @@
|
||||
import { createWorkflow, testDb, mockInstance } from '@n8n/backend-test-utils';
|
||||
import { ExecutionRepository } from '@n8n/db';
|
||||
import {
|
||||
createActiveWorkflow,
|
||||
createWorkflow,
|
||||
testDb,
|
||||
mockInstance,
|
||||
getWorkflowById,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { ExecutionRepository, WorkflowRepository, ProjectRelationRepository } from '@n8n/db';
|
||||
import type { Project, User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { stringify } from 'flatted';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { randomInt } from 'n8n-workflow';
|
||||
import assert from 'node:assert';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { ARTIFICIAL_TASK_DATA } from '@/constants';
|
||||
import { NodeCrashedError } from '@/errors/node-crashed.error';
|
||||
@@ -14,6 +23,7 @@ import type { EventMessageTypes as EventMessage } from '@/eventbus/event-message
|
||||
import { EventMessageNode } from '@/eventbus/event-message-classes/event-message-node';
|
||||
import { ExecutionRecoveryService } from '@/executions/execution-recovery.service';
|
||||
import { Push } from '@/push';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { createExecution } from '@test-integration/db/executions';
|
||||
|
||||
import { IN_PROGRESS_EXECUTION_DATA, OOM_WORKFLOW } from './constants';
|
||||
@@ -22,19 +32,30 @@ import { setupMessages } from './utils';
|
||||
describe('ExecutionRecoveryService', () => {
|
||||
const push = mockInstance(Push);
|
||||
const instanceSettings = Container.get(InstanceSettings);
|
||||
const ownershipService = mockInstance(OwnershipService);
|
||||
const projectRelationRepository = mockInstance(ProjectRelationRepository);
|
||||
|
||||
let executionRecoveryService: ExecutionRecoveryService;
|
||||
let executionRepository: ExecutionRepository;
|
||||
let workflowRepository: WorkflowRepository;
|
||||
let globalConfig: GlobalConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
executionRepository = Container.get(ExecutionRepository);
|
||||
workflowRepository = Container.get(WorkflowRepository);
|
||||
globalConfig = Container.get(GlobalConfig);
|
||||
|
||||
executionRecoveryService = new ExecutionRecoveryService(
|
||||
mock(),
|
||||
instanceSettings,
|
||||
push,
|
||||
executionRepository,
|
||||
globalConfig.executions,
|
||||
workflowRepository,
|
||||
mock(),
|
||||
ownershipService,
|
||||
projectRelationRepository,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,6 +65,7 @@ describe('ExecutionRecoveryService', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
jest.restoreAllMocks();
|
||||
globalConfig.executions.recovery.workflowDeactivationEnabled = false;
|
||||
await testDb.truncate(['ExecutionEntity', 'ExecutionData', 'WorkflowEntity']);
|
||||
});
|
||||
|
||||
@@ -394,6 +416,39 @@ describe('ExecutionRecoveryService', () => {
|
||||
expect(debugHelperTaskData?.error).toBeUndefined();
|
||||
expect(debugHelperTaskData?.data).toEqual(ARTIFICIAL_TASK_DATA);
|
||||
});
|
||||
|
||||
test('should deactivate workflow if all last executions are crashed', async () => {
|
||||
/**
|
||||
* Arrange
|
||||
*/
|
||||
globalConfig.executions.recovery.workflowDeactivationEnabled = true;
|
||||
|
||||
const workflow = await createActiveWorkflow({
|
||||
...OOM_WORKFLOW,
|
||||
});
|
||||
expect(workflow.activeVersionId).not.toBeNull();
|
||||
await createExecution({ status: 'crashed' }, workflow);
|
||||
await createExecution({ status: 'crashed' }, workflow);
|
||||
await createExecution({ status: 'crashed' }, workflow);
|
||||
|
||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(
|
||||
mock<Project>({ id: uuid(), type: 'personal' }),
|
||||
);
|
||||
ownershipService.getInstanceOwner.mockResolvedValue(mock<User>({ id: uuid() }));
|
||||
projectRelationRepository.find.mockResolvedValue([]);
|
||||
|
||||
/**
|
||||
* Act
|
||||
*/
|
||||
await executionRecoveryService.autoDeactivateWorkflowsIfNeeded(new Set([workflow.id]));
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*/
|
||||
const updatedWorkflow = await getWorkflowById(workflow.id);
|
||||
if (!updatedWorkflow) fail('Expected `updatedWorkflow` to be defined');
|
||||
expect(updatedWorkflow.activeVersionId).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import type { IExecutionResponse } from '@n8n/db';
|
||||
import { ExecutionRepository } from '@n8n/db';
|
||||
import { ExecutionsConfig } from '@n8n/config';
|
||||
import {
|
||||
In,
|
||||
type IExecutionResponse,
|
||||
ProjectRelationRepository,
|
||||
WorkflowEntity,
|
||||
User,
|
||||
} from '@n8n/db';
|
||||
import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { createEmptyRunExecutionData, sleep } from 'n8n-workflow';
|
||||
import type { IRun, ITaskData } from 'n8n-workflow';
|
||||
import { ExecutionStatus, type IRun, type ITaskData } from 'n8n-workflow';
|
||||
|
||||
import { ARTIFICIAL_TASK_DATA } from '@/constants';
|
||||
import { NodeCrashedError } from '@/errors/node-crashed.error';
|
||||
import { WorkflowCrashedError } from '@/errors/workflow-crashed.error';
|
||||
import { getLifecycleHooksForRegularMain } from '@/execution-lifecycle/execution-lifecycle-hooks';
|
||||
import { Push } from '@/push';
|
||||
import { OwnershipService } from '@/services/ownership.service';
|
||||
import { UserManagementMailer } from '@/user-management/email/user-management-mailer';
|
||||
|
||||
import type { EventMessageTypes } from '../eventbus/event-message-classes';
|
||||
|
||||
@@ -25,8 +35,63 @@ export class ExecutionRecoveryService {
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly push: Push,
|
||||
private readonly executionRepository: ExecutionRepository,
|
||||
private readonly executionsConfig: ExecutionsConfig,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly userManagementMailer: UserManagementMailer,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly projectRelationRepository: ProjectRelationRepository,
|
||||
) {}
|
||||
|
||||
async autoDeactivateWorkflowsIfNeeded(workflowIds: Set<string>) {
|
||||
for (const workflowId of workflowIds) {
|
||||
const maxLastExecutions = this.executionsConfig.recovery.maxLastExecutions;
|
||||
const lastExecutions = await this.executionRepository.findMultipleExecutions({
|
||||
select: ['id', 'status'],
|
||||
where: { workflowId },
|
||||
order: { startedAt: 'DESC' },
|
||||
take: maxLastExecutions,
|
||||
});
|
||||
const numberOfCrashedExecutions = lastExecutions.filter((e) => e.status === 'crashed').length;
|
||||
|
||||
// If all of the last N executions are crashed, deactivate the workflow
|
||||
if (
|
||||
lastExecutions.length >= maxLastExecutions &&
|
||||
lastExecutions.length === numberOfCrashedExecutions
|
||||
) {
|
||||
// Get workflow to preserve existing meta
|
||||
const workflow = await this.workflowRepository.findOne({ where: { id: workflowId } });
|
||||
|
||||
if (!workflow) {
|
||||
this.logger.warn(`Workflow ${workflowId} not found, skipping workflow auto-deactivation`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (workflow.activeVersionId !== null) {
|
||||
await this.workflowRepository.updateActiveState(workflowId, false);
|
||||
this.logger.warn(
|
||||
`Autodeactivated workflow ${workflowId} due to too many crashed executions.`,
|
||||
);
|
||||
|
||||
const recipient = await this.getAutodeactivationRecipient(workflow);
|
||||
await this.userManagementMailer.notifyWorkflowAutodeactivated({
|
||||
recipient,
|
||||
workflow,
|
||||
});
|
||||
|
||||
this.push.once('editorUiConnected', async () => {
|
||||
await sleep(1000);
|
||||
this.push.broadcast({ type: 'workflowAutoDeactivated', data: { workflowId } });
|
||||
});
|
||||
}
|
||||
|
||||
await this.executionRepository.update(
|
||||
{ workflowId, status: In<ExecutionStatus>(['running', 'new']) },
|
||||
{ status: 'crashed', stoppedAt: new Date() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover key properties of a truncated execution using event logs.
|
||||
*/
|
||||
@@ -210,4 +275,23 @@ export class ExecutionRecoveryService {
|
||||
|
||||
await lifecycleHooks.runHook('workflowExecuteAfter', [run]);
|
||||
}
|
||||
|
||||
private async getAutodeactivationRecipient(workflow: WorkflowEntity): Promise<User> {
|
||||
const project = await this.ownershipService.getWorkflowProjectCached(workflow.id);
|
||||
|
||||
const roleSlug = project.type === 'team' ? PROJECT_ADMIN_ROLE_SLUG : PROJECT_OWNER_ROLE_SLUG;
|
||||
const projectRelations = await this.projectRelationRepository.find({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
role: { slug: roleSlug },
|
||||
},
|
||||
relations: { user: true },
|
||||
});
|
||||
|
||||
if (projectRelations.length > 0) {
|
||||
return projectRelations[0].user;
|
||||
} else {
|
||||
return await this.ownershipService.getInstanceOwner();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import assert from 'assert';
|
||||
@Service()
|
||||
export class LegacySqliteExecutionRecoveryService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
logger: Logger,
|
||||
private readonly executionRepository: ExecutionRepository,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<mjml>
|
||||
<mj-include path="./_common.mjml" />
|
||||
<mj-body>
|
||||
<mj-section padding="0 20px">
|
||||
<mj-column>
|
||||
<mj-include path="./_logo.mjml" />
|
||||
<mj-text font-size="22px" font-weight="400">Workflow automatically deactivated</mj-text>
|
||||
<mj-text
|
||||
>n8n has automatically deactivated <b>"{{ workflowName }}"</b> due to repeated
|
||||
crashes.</mj-text
|
||||
>
|
||||
<mj-text
|
||||
>Please review its recent executions and resolve any issues before reactivating
|
||||
it.</mj-text
|
||||
>
|
||||
<mj-button href="{{ workflowUrl }}" background-color="#ff6d5a">View Workflow</mj-button>
|
||||
<mj-include path="./_footer.mjml" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
@@ -23,6 +23,7 @@ type Template = HandlebarsTemplateDelegate<unknown>;
|
||||
type TemplateName =
|
||||
| 'user-invited'
|
||||
| 'password-reset-requested'
|
||||
| 'workflow-deactivated'
|
||||
| 'workflow-shared'
|
||||
| 'credentials-shared'
|
||||
| 'project-shared';
|
||||
@@ -138,6 +139,29 @@ export class UserManagementMailer {
|
||||
}
|
||||
}
|
||||
|
||||
async notifyWorkflowAutodeactivated({
|
||||
recipient,
|
||||
workflow,
|
||||
}: {
|
||||
recipient: User;
|
||||
workflow: IWorkflowBase;
|
||||
}): Promise<SendEmailResult> {
|
||||
const recipients = await this.userRepository.getEmailsByIds([recipient.id]);
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
|
||||
return await this.sendNotificationEmails({
|
||||
mailerTemplate: 'workflow-deactivated',
|
||||
recipients,
|
||||
sharer: recipient,
|
||||
getTemplateData: () => ({
|
||||
workflowName: workflow.name,
|
||||
workflowUrl: `${baseUrl}/workflow/${workflow.id}`,
|
||||
}),
|
||||
subjectBuilder: () => 'n8n has automatically autodeactivated a workflow',
|
||||
messageType: 'Workflow auto-deactivated',
|
||||
});
|
||||
}
|
||||
|
||||
async notifyWorkflowShared({
|
||||
sharer,
|
||||
newShareeIds,
|
||||
|
||||
@@ -272,7 +272,6 @@ export class WorkflowService {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Convert 'active' boolean from frontend to 'activeVersionId' for backend
|
||||
// Forbid updating active fields with FF on
|
||||
if (isDraftPublishDisabled && 'active' in workflowUpdateData) {
|
||||
@@ -323,7 +322,6 @@ export class WorkflowService {
|
||||
}
|
||||
|
||||
const workflowSettings = workflowUpdateData.settings ?? {};
|
||||
|
||||
const keysAllowingDefault = [
|
||||
'timezone',
|
||||
'saveDataErrorExecution',
|
||||
@@ -362,7 +360,6 @@ export class WorkflowService {
|
||||
'description',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
// Forbid updating active fields with FF on
|
||||
if (isDraftPublishDisabled) {
|
||||
fieldsToUpdate.push('activeVersionId', 'active');
|
||||
@@ -377,7 +374,6 @@ export class WorkflowService {
|
||||
if (versionChanged) {
|
||||
await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId);
|
||||
}
|
||||
|
||||
if (isDraftPublishDisabled && needsActiveVersionUpdate) {
|
||||
const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId;
|
||||
const version = await this.workflowHistoryService.getVersion(
|
||||
@@ -385,14 +381,12 @@ export class WorkflowService {
|
||||
workflowId,
|
||||
versionIdToFetch,
|
||||
);
|
||||
|
||||
updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue(
|
||||
workflow,
|
||||
version,
|
||||
isNowActive,
|
||||
);
|
||||
}
|
||||
|
||||
if (parentFolderId) {
|
||||
const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id);
|
||||
if (parentFolderId !== PROJECT_ROOT) {
|
||||
@@ -407,9 +401,7 @@ export class WorkflowService {
|
||||
}
|
||||
updatePayload.parentFolder = parentFolderId === PROJECT_ROOT ? null : { id: parentFolderId };
|
||||
}
|
||||
|
||||
await this.workflowRepository.update(workflowId, updatePayload);
|
||||
|
||||
const tagsDisabled = this.globalConfig.tags.disabled;
|
||||
|
||||
if (tagIds && !tagsDisabled) {
|
||||
@@ -436,23 +428,20 @@ export class WorkflowService {
|
||||
requestOrder: tagIds,
|
||||
});
|
||||
}
|
||||
|
||||
await this.externalHooks.run('workflow.afterUpdate', [updatedWorkflow]);
|
||||
this.eventService.emit('workflow-saved', {
|
||||
user,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi,
|
||||
});
|
||||
|
||||
// Skip activation/deactivation logic if draft/publish feature flag is enabled
|
||||
if (isDraftPublishDisabled) {
|
||||
if (activationStatusChanged && isNowActive) {
|
||||
// Workflow is being activated
|
||||
this.eventService.emit('workflow-activated', {
|
||||
user,
|
||||
workflowId,
|
||||
workflow: updatedWorkflow,
|
||||
publicApi,
|
||||
publicApi: false,
|
||||
});
|
||||
} else if (activationStatusChanged && !isNowActive) {
|
||||
// Workflow is being deactivated
|
||||
@@ -490,7 +479,6 @@ export class WorkflowService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedWorkflow;
|
||||
}
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@
|
||||
"banners.trial.message": "1 day left in your n8n trial | {count} days left in your n8n trial",
|
||||
"banners.trialOver.message": "Your trial is over. Upgrade now to keep automating.",
|
||||
"banners.v1.message": "n8n has been updated to version 1, introducing some breaking changes. Please consult the <a target=\"_blank\" href=\"https://docs.n8n.io/1-0-migration-checklist\">migration guide</a> for more information.",
|
||||
"banners.workflowAutoDeactivated.message": "This workflow was automatically deactivated due to multiple crashed executions. Please review and reactivate it when ready.",
|
||||
"binaryDataDisplay.backToList": "Back to list",
|
||||
"binaryDataDisplay.backToOverviewPage": "Back to overview page",
|
||||
"binaryDataDisplay.noDataFoundToDisplay": "No data found to display",
|
||||
@@ -3277,7 +3278,7 @@
|
||||
"workflows.create.project.toast.title": "Workflow successfully created in {projectName}",
|
||||
"workflows.create.folder.toast.title": "Workflow successfully created in \"{projectName}\", within \"{folderName}\"",
|
||||
"workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.",
|
||||
"workflows.deactivated": "Workflow deactivated",
|
||||
"workflows.autodeactivated": "Workflow auto-deactivated",
|
||||
"workflows.ai.starter.collection.callout": "Learn how to build AI Agents in n8n",
|
||||
"workflows.ai.starter.collection.card": "Learn how to build AI Agents",
|
||||
"workflows.ai.starter.collection.folder.name": "🎁 n8n basics: Learn how to build Agents in n8n",
|
||||
|
||||
@@ -13,5 +13,6 @@ export * from './sendWorkerStatusMessage';
|
||||
export * from './testWebhookDeleted';
|
||||
export * from './testWebhookReceived';
|
||||
export * from './workflowActivated';
|
||||
export * from './workflowAutoDeactivated';
|
||||
export * from './workflowDeactivated';
|
||||
export * from './workflowFailedToActivate';
|
||||
|
||||
+7
@@ -1,8 +1,15 @@
|
||||
import type { WorkflowActivated } from '@n8n/api-types/push/workflow';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useBannersStore } from '@/features/shared/banners/banners.store';
|
||||
|
||||
export async function workflowActivated({ data }: WorkflowActivated) {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const bannersStore = useBannersStore();
|
||||
|
||||
workflowsStore.setWorkflowActive(data.workflowId);
|
||||
|
||||
// Remove auto-deactivated banner if viewing this workflow
|
||||
if (workflowsStore.workflowId === data.workflowId) {
|
||||
bannersStore.removeBannerFromStack('WORKFLOW_AUTO_DEACTIVATED');
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import type { WorkflowAutoDeactivated } from '@n8n/api-types/push/workflow';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { useBannersStore } from '@/features/shared/banners/banners.store';
|
||||
|
||||
export async function workflowAutoDeactivated({ data }: WorkflowAutoDeactivated) {
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const bannersStore = useBannersStore();
|
||||
|
||||
workflowsStore.setWorkflowInactive(data.workflowId);
|
||||
|
||||
if (workflowsStore.workflowId === data.workflowId) {
|
||||
bannersStore.pushBannerToStack('WORKFLOW_AUTO_DEACTIVATED');
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
executionRecovered,
|
||||
workflowActivated,
|
||||
workflowDeactivated,
|
||||
workflowAutoDeactivated,
|
||||
} from '@/app/composables/usePushConnection/handlers';
|
||||
import { injectWorkflowState, type WorkflowState } from '@/app/composables/useWorkflowState';
|
||||
import { createEventQueue } from '@n8n/utils/event-queue';
|
||||
@@ -91,6 +92,8 @@ export function usePushConnection({
|
||||
return await workflowActivated(event);
|
||||
case 'workflowDeactivated':
|
||||
return await workflowDeactivated(event);
|
||||
case 'workflowAutoDeactivated':
|
||||
return await workflowAutoDeactivated(event);
|
||||
case 'updateBuilderCredits':
|
||||
return await builderCreditsUpdated(event);
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export function useWorkflowSaving({
|
||||
if (missingActivatableTriggerNode) {
|
||||
// Automatically deactivate if all activatable triggers are removed
|
||||
return {
|
||||
title: i18n.baseText('workflows.deactivated'),
|
||||
title: i18n.baseText('workflows.autodeactivated'),
|
||||
message: i18n.baseText('workflowActivator.thisWorkflowHasNoTriggerNodes'),
|
||||
type: 'info',
|
||||
};
|
||||
|
||||
@@ -1899,6 +1899,7 @@ watch(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeRouteLeave(async (to, from, next) => {
|
||||
const toNodeViewTab = getNodeViewTab(to);
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ export const useBannersStore = defineStore(STORES.BANNERS, () => {
|
||||
dismissBanner,
|
||||
updateBannersHeight,
|
||||
pushBannerToStack,
|
||||
removeBannerFromStack,
|
||||
clearBannerStack,
|
||||
loadStaticBanners,
|
||||
loadDynamicBanners,
|
||||
|
||||
@@ -8,6 +8,7 @@ import V1Banner from './banners/V1Banner.vue';
|
||||
import EmailConfirmationBanner from './banners/EmailConfirmationBanner.vue';
|
||||
import DataTableStorageLimitWarningBanner from './banners/DataTableStorageLimitWarningBanner.vue';
|
||||
import DataTableStorageLimitErrorBanner from './banners/DataTableStorageLimitErrorBanner.vue';
|
||||
import WorkflowAutoDeactivatedBanner from './banners/WorkflowAutoDeactivatedBanner.vue';
|
||||
import type { Component } from 'vue';
|
||||
import type { N8nBanners } from '../banners.types';
|
||||
|
||||
@@ -17,6 +18,10 @@ import type { N8nBanners } from '../banners.types';
|
||||
// https://www.notion.so/n8n/Banner-stack-60948c4167c743718fde80d6745258d5
|
||||
export const N8N_BANNERS: N8nBanners = {
|
||||
V1: { priority: 350, component: V1Banner as Component },
|
||||
WORKFLOW_AUTO_DEACTIVATED: {
|
||||
priority: 340,
|
||||
component: WorkflowAutoDeactivatedBanner as Component,
|
||||
},
|
||||
TRIAL_OVER: { priority: 260, component: TrialOverBanner as Component },
|
||||
EMAIL_CONFIRMATION: { priority: 250, component: EmailConfirmationBanner as Component },
|
||||
TRIAL: { priority: 150, component: TrialBanner as Component },
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import BaseBanner from './BaseBanner.vue';
|
||||
import { i18n as locale } from '@n8n/i18n';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBanner name="WORKFLOW_AUTO_DEACTIVATED" theme="warning" custom-icon="info">
|
||||
<template #mainContent>
|
||||
<span>
|
||||
{{ locale.baseText('banners.workflowAutoDeactivated.message') }}
|
||||
</span>
|
||||
</template>
|
||||
</BaseBanner>
|
||||
</template>
|
||||
Reference in New Issue
Block a user