mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
Merge branch 'master' into ai-430-metrics
This commit is contained in:
commit
42926ba960
|
@ -98,6 +98,10 @@ describe('Workflow Selector Parameter', () => {
|
||||||
|
|
||||||
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
|
getVisiblePopper().findChildByTestId('rlc-item').eq(0).click();
|
||||||
|
|
||||||
cy.get('@windowOpen').should('be.calledWith', '/workflows/onboarding/0?sampleSubWorkflows=0');
|
const SAMPLE_SUBWORKFLOW_TEMPLATE_ID = 'VMiAxXa3lCAizGB5f7dVZQSFfg3FtHkdTKvLuupqBls=';
|
||||||
|
cy.get('@windowOpen').should(
|
||||||
|
'be.calledWith',
|
||||||
|
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_TEMPLATE_ID}?sampleSubWorkflows=0`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
"cypress:install": "cypress install",
|
"cypress:install": "cypress install",
|
||||||
"test:e2e:ui": "scripts/run-e2e.js ui",
|
"test:e2e:ui": "scripts/run-e2e.js ui",
|
||||||
"test:e2e:dev": "scripts/run-e2e.js dev",
|
"test:e2e:dev": "scripts/run-e2e.js dev",
|
||||||
|
"test:e2e:dev:v2": "scripts/run-e2e.js dev:v2",
|
||||||
"test:e2e:all": "scripts/run-e2e.js all",
|
"test:e2e:all": "scripts/run-e2e.js all",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome ci .",
|
"format:check": "biome ci .",
|
||||||
|
|
|
@ -57,6 +57,17 @@ switch (scenario) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'dev:v2':
|
||||||
|
runTests({
|
||||||
|
startCommand: 'develop',
|
||||||
|
url: 'http://localhost:8080/favicon.ico',
|
||||||
|
testCommand: 'cypress open',
|
||||||
|
customEnv: {
|
||||||
|
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||||
|
CYPRESS_BASE_URL: 'http://localhost:8080',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'all':
|
case 'all':
|
||||||
const specSuiteFilter = process.argv[3];
|
const specSuiteFilter = process.argv[3];
|
||||||
const specParam = specSuiteFilter ? ` --spec **/*${specSuiteFilter}*` : '';
|
const specParam = specSuiteFilter ? ` --spec **/*${specSuiteFilter}*` : '';
|
||||||
|
|
|
@ -33,7 +33,7 @@ COPY docker/images/n8n/docker-entrypoint.sh /
|
||||||
|
|
||||||
# Setup the Task Runner Launcher
|
# Setup the Task Runner Launcher
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ARG LAUNCHER_VERSION=0.7.0-rc
|
ARG LAUNCHER_VERSION=1.0.0
|
||||||
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
|
COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json
|
||||||
# Download, verify, then extract the launcher binary
|
# Download, verify, then extract the launcher binary
|
||||||
RUN \
|
RUN \
|
||||||
|
|
|
@ -24,7 +24,7 @@ RUN set -eux; \
|
||||||
|
|
||||||
# Setup the Task Runner Launcher
|
# Setup the Task Runner Launcher
|
||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
ARG LAUNCHER_VERSION=0.7.0-rc
|
ARG LAUNCHER_VERSION=1.0.0
|
||||||
COPY n8n-task-runners.json /etc/n8n-task-runners.json
|
COPY n8n-task-runners.json /etc/n8n-task-runners.json
|
||||||
# Download, verify, then extract the launcher binary
|
# Download, verify, then extract the launcher binary
|
||||||
RUN \
|
RUN \
|
||||||
|
|
|
@ -9,12 +9,12 @@
|
||||||
"PATH",
|
"PATH",
|
||||||
"GENERIC_TIMEZONE",
|
"GENERIC_TIMEZONE",
|
||||||
"N8N_RUNNERS_GRANT_TOKEN",
|
"N8N_RUNNERS_GRANT_TOKEN",
|
||||||
"N8N_RUNNERS_N8N_URI",
|
"N8N_RUNNERS_TASK_BROKER_URI",
|
||||||
"N8N_RUNNERS_MAX_PAYLOAD",
|
"N8N_RUNNERS_MAX_PAYLOAD",
|
||||||
"N8N_RUNNERS_MAX_CONCURRENCY",
|
"N8N_RUNNERS_MAX_CONCURRENCY",
|
||||||
"N8N_RUNNERS_SERVER_ENABLED",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED",
|
||||||
"N8N_RUNNERS_SERVER_HOST",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST",
|
||||||
"N8N_RUNNERS_SERVER_PORT",
|
"N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT",
|
||||||
"NODE_FUNCTION_ALLOW_BUILTIN",
|
"NODE_FUNCTION_ALLOW_BUILTIN",
|
||||||
"NODE_FUNCTION_ALLOW_EXTERNAL",
|
"NODE_FUNCTION_ALLOW_EXTERNAL",
|
||||||
"NODE_OPTIONS",
|
"NODE_OPTIONS",
|
||||||
|
|
|
@ -4,4 +4,5 @@ import { Z } from 'zod-class';
|
||||||
export class SettingsUpdateRequestDto extends Z.class({
|
export class SettingsUpdateRequestDto extends Z.class({
|
||||||
userActivated: z.boolean().optional(),
|
userActivated: z.boolean().optional(),
|
||||||
allowSSOManualLogin: z.boolean().optional(),
|
allowSSOManualLogin: z.boolean().optional(),
|
||||||
|
easyAIWorkflowOnboarded: z.boolean().optional(),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
|
@ -163,7 +163,7 @@ export interface FrontendSettings {
|
||||||
pruneTime: number;
|
pruneTime: number;
|
||||||
licensePruneTime: number;
|
licensePruneTime: number;
|
||||||
};
|
};
|
||||||
pruning: {
|
pruning?: {
|
||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
maxAge: number;
|
maxAge: number;
|
||||||
maxCount: number;
|
maxCount: number;
|
||||||
|
@ -173,4 +173,5 @@ export interface FrontendSettings {
|
||||||
};
|
};
|
||||||
betaFeatures: FrontendBetaFeatures[];
|
betaFeatures: FrontendBetaFeatures[];
|
||||||
virtualSchemaView: boolean;
|
virtualSchemaView: boolean;
|
||||||
|
easyAIWorkflowOnboarded: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,7 +24,7 @@ export class TaskRunnersConfig {
|
||||||
authToken: string = '';
|
authToken: string = '';
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners server should listen on */
|
||||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||||
port: number = 5679;
|
port: number = 5679;
|
||||||
|
|
||||||
/** IP address task runners server should listen on */
|
/** IP address task runners server should listen on */
|
||||||
|
|
|
@ -4,7 +4,7 @@ import type {
|
||||||
INodeExecutionData,
|
INodeExecutionData,
|
||||||
IDataObject,
|
IDataObject,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
import { ApplicationError, NodeOperationError, updateDisplayOptions } from 'n8n-workflow';
|
||||||
import { apiRequest } from '../../transport';
|
import { apiRequest } from '../../transport';
|
||||||
import { assistantRLC, modelRLC } from '../descriptions';
|
import { assistantRLC, modelRLC } from '../descriptions';
|
||||||
|
|
||||||
|
@ -116,6 +116,18 @@ const displayOptions = {
|
||||||
|
|
||||||
export const description = updateDisplayOptions(displayOptions, properties);
|
export const description = updateDisplayOptions(displayOptions, properties);
|
||||||
|
|
||||||
|
function getFileIds(file_ids: unknown): string[] {
|
||||||
|
if (Array.isArray(file_ids)) {
|
||||||
|
return file_ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof file_ids === 'string') {
|
||||||
|
return file_ids.split(',').map((file_id) => file_id.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApplicationError('Invalid file_ids type');
|
||||||
|
}
|
||||||
|
|
||||||
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
export async function execute(this: IExecuteFunctions, i: number): Promise<INodeExecutionData[]> {
|
||||||
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
|
const assistantId = this.getNodeParameter('assistantId', i, '', { extractValue: true }) as string;
|
||||||
const options = this.getNodeParameter('options', i, {});
|
const options = this.getNodeParameter('options', i, {});
|
||||||
|
@ -137,11 +149,8 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
||||||
const body: IDataObject = {};
|
const body: IDataObject = {};
|
||||||
|
|
||||||
if (file_ids) {
|
if (file_ids) {
|
||||||
let files = file_ids;
|
const files = getFileIds(file_ids);
|
||||||
if (typeof files === 'string') {
|
if (files.length > 20) {
|
||||||
files = files.split(',').map((file_id) => file_id.trim());
|
|
||||||
}
|
|
||||||
if ((file_ids as IDataObject[]).length > 20) {
|
|
||||||
throw new NodeOperationError(
|
throw new NodeOperationError(
|
||||||
this.getNode(),
|
this.getNode(),
|
||||||
'The maximum number of files that can be attached to the assistant is 20',
|
'The maximum number of files that can be attached to the assistant is 20',
|
||||||
|
@ -152,15 +161,12 @@ export async function execute(this: IExecuteFunctions, i: number): Promise<INode
|
||||||
body.tool_resources = {
|
body.tool_resources = {
|
||||||
...((body.tool_resources as object) ?? {}),
|
...((body.tool_resources as object) ?? {}),
|
||||||
code_interpreter: {
|
code_interpreter: {
|
||||||
file_ids,
|
file_ids: files,
|
||||||
},
|
|
||||||
file_search: {
|
|
||||||
vector_stores: [
|
|
||||||
{
|
|
||||||
file_ids,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
|
// updating file_ids for file_search directly is not supported by OpenAI API
|
||||||
|
// only updating vector_store_ids for file_search is supported
|
||||||
|
// support for this to be added as part of ADO-2968
|
||||||
|
// https://platform.openai.com/docs/api-reference/assistants/modifyAssistant
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -210,12 +210,89 @@ describe('OpenAi, Assistant resource', () => {
|
||||||
code_interpreter: {
|
code_interpreter: {
|
||||||
file_ids: [],
|
file_ids: [],
|
||||||
},
|
},
|
||||||
file_search: {
|
},
|
||||||
vector_stores: [
|
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||||
{
|
},
|
||||||
file_ids: [],
|
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||||
},
|
});
|
||||||
],
|
});
|
||||||
|
|
||||||
|
it('update => should call apiRequest with file_ids as an array for search', async () => {
|
||||||
|
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||||
|
tools: [{ type: 'existing_tool' }],
|
||||||
|
});
|
||||||
|
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await assistant.update.execute.call(
|
||||||
|
createExecuteFunctionsMock({
|
||||||
|
assistantId: 'assistant-id',
|
||||||
|
options: {
|
||||||
|
modelId: 'gpt-model',
|
||||||
|
name: 'name',
|
||||||
|
instructions: 'some instructions',
|
||||||
|
codeInterpreter: true,
|
||||||
|
knowledgeRetrieval: true,
|
||||||
|
file_ids: ['1234'],
|
||||||
|
removeCustomTools: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||||
|
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
|
||||||
|
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||||
|
});
|
||||||
|
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
|
||||||
|
body: {
|
||||||
|
instructions: 'some instructions',
|
||||||
|
model: 'gpt-model',
|
||||||
|
name: 'name',
|
||||||
|
tool_resources: {
|
||||||
|
code_interpreter: {
|
||||||
|
file_ids: ['1234'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||||
|
},
|
||||||
|
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update => should call apiRequest with file_ids as strings for search', async () => {
|
||||||
|
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({
|
||||||
|
tools: [{ type: 'existing_tool' }],
|
||||||
|
});
|
||||||
|
(transport.apiRequest as jest.Mock).mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
await assistant.update.execute.call(
|
||||||
|
createExecuteFunctionsMock({
|
||||||
|
assistantId: 'assistant-id',
|
||||||
|
options: {
|
||||||
|
modelId: 'gpt-model',
|
||||||
|
name: 'name',
|
||||||
|
instructions: 'some instructions',
|
||||||
|
codeInterpreter: true,
|
||||||
|
knowledgeRetrieval: true,
|
||||||
|
file_ids: '1234, 5678, 90',
|
||||||
|
removeCustomTools: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(transport.apiRequest).toHaveBeenCalledTimes(2);
|
||||||
|
expect(transport.apiRequest).toHaveBeenCalledWith('GET', '/assistants/assistant-id', {
|
||||||
|
headers: { 'OpenAI-Beta': 'assistants=v2' },
|
||||||
|
});
|
||||||
|
expect(transport.apiRequest).toHaveBeenCalledWith('POST', '/assistants/assistant-id', {
|
||||||
|
body: {
|
||||||
|
instructions: 'some instructions',
|
||||||
|
model: 'gpt-model',
|
||||||
|
name: 'name',
|
||||||
|
tool_resources: {
|
||||||
|
code_interpreter: {
|
||||||
|
file_ids: ['1234', '5678', '90'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
tools: [{ type: 'existing_tool' }, { type: 'code_interpreter' }, { type: 'file_search' }],
|
||||||
|
|
|
@ -2,20 +2,20 @@ import { Config, Env, Nested } from '@n8n/config';
|
||||||
|
|
||||||
@Config
|
@Config
|
||||||
class HealthcheckServerConfig {
|
class HealthcheckServerConfig {
|
||||||
@Env('N8N_RUNNERS_SERVER_ENABLED')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED')
|
||||||
enabled: boolean = false;
|
enabled: boolean = false;
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_SERVER_HOST')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST')
|
||||||
host: string = '127.0.0.1';
|
host: string = '127.0.0.1';
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_SERVER_PORT')
|
@Env('N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT')
|
||||||
port: number = 5681;
|
port: number = 5681;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Config
|
@Config
|
||||||
export class BaseRunnerConfig {
|
export class BaseRunnerConfig {
|
||||||
@Env('N8N_RUNNERS_N8N_URI')
|
@Env('N8N_RUNNERS_TASK_BROKER_URI')
|
||||||
n8nUri: string = '127.0.0.1:5679';
|
taskBrokerUri: string = 'http://127.0.0.1:5679';
|
||||||
|
|
||||||
@Env('N8N_RUNNERS_GRANT_TOKEN')
|
@Env('N8N_RUNNERS_GRANT_TOKEN')
|
||||||
grantToken: string = '';
|
grantToken: string = '';
|
||||||
|
|
|
@ -34,7 +34,7 @@ describe('JsTaskRunner', () => {
|
||||||
...defaultConfig.baseRunnerConfig,
|
...defaultConfig.baseRunnerConfig,
|
||||||
grantToken: 'grantToken',
|
grantToken: 'grantToken',
|
||||||
maxConcurrency: 1,
|
maxConcurrency: 1,
|
||||||
n8nUri: 'localhost',
|
taskBrokerUri: 'http://localhost',
|
||||||
...baseRunnerOpts,
|
...baseRunnerOpts,
|
||||||
},
|
},
|
||||||
jsRunnerConfig: {
|
jsRunnerConfig: {
|
||||||
|
@ -311,10 +311,10 @@ describe('JsTaskRunner', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not expose task runner's env variables even if no env state is received", async () => {
|
it("should not expose task runner's env variables even if no env state is received", async () => {
|
||||||
process.env.N8N_RUNNERS_N8N_URI = 'http://127.0.0.1:5679';
|
process.env.N8N_RUNNERS_TASK_BROKER_URI = 'http://127.0.0.1:5679';
|
||||||
const outcome = await execTaskWithParams({
|
const outcome = await execTaskWithParams({
|
||||||
task: newTaskWithSettings({
|
task: newTaskWithSettings({
|
||||||
code: 'return { val: $env.N8N_RUNNERS_N8N_URI }',
|
code: 'return { val: $env.N8N_RUNNERS_TASK_BROKER_URI }',
|
||||||
nodeMode: 'runOnceForAllItems',
|
nodeMode: 'runOnceForAllItems',
|
||||||
}),
|
}),
|
||||||
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
taskData: newDataRequestResponse(inputItems.map(wrapIntoJson), {
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { WebSocket } from 'ws';
|
||||||
|
|
||||||
|
import { TaskRunner } from '@/task-runner';
|
||||||
|
|
||||||
|
class TestRunner extends TaskRunner {}
|
||||||
|
|
||||||
|
jest.mock('ws');
|
||||||
|
|
||||||
|
describe('TestRunner', () => {
|
||||||
|
describe('constructor', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly construct WebSocket URI with provided taskBrokerUri', () => {
|
||||||
|
const runner = new TestRunner({
|
||||||
|
taskType: 'test-task',
|
||||||
|
maxConcurrency: 5,
|
||||||
|
idleTimeout: 60,
|
||||||
|
grantToken: 'test-token',
|
||||||
|
maxPayloadSize: 1024,
|
||||||
|
taskBrokerUri: 'http://localhost:8080',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
healthcheckServer: {
|
||||||
|
enabled: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 8081,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(WebSocket).toHaveBeenCalledWith(
|
||||||
|
`ws://localhost:8080/runners/_ws?id=${runner.id}`,
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer test-token',
|
||||||
|
},
|
||||||
|
maxPayload: 1024,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different taskBrokerUri formats correctly', () => {
|
||||||
|
const runner = new TestRunner({
|
||||||
|
taskType: 'test-task',
|
||||||
|
maxConcurrency: 5,
|
||||||
|
idleTimeout: 60,
|
||||||
|
grantToken: 'test-token',
|
||||||
|
maxPayloadSize: 1024,
|
||||||
|
taskBrokerUri: 'https://example.com:3000/path',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
healthcheckServer: {
|
||||||
|
enabled: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 8081,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(WebSocket).toHaveBeenCalledWith(
|
||||||
|
`ws://example.com:3000/runners/_ws?id=${runner.id}`,
|
||||||
|
expect.objectContaining({
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer test-token',
|
||||||
|
},
|
||||||
|
maxPayload: 1024,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if taskBrokerUri is invalid', () => {
|
||||||
|
expect(
|
||||||
|
() =>
|
||||||
|
new TestRunner({
|
||||||
|
taskType: 'test-task',
|
||||||
|
maxConcurrency: 5,
|
||||||
|
idleTimeout: 60,
|
||||||
|
grantToken: 'test-token',
|
||||||
|
maxPayloadSize: 1024,
|
||||||
|
taskBrokerUri: 'not-a-valid-uri',
|
||||||
|
timezone: 'America/New_York',
|
||||||
|
healthcheckServer: {
|
||||||
|
enabled: false,
|
||||||
|
host: 'localhost',
|
||||||
|
port: 8081,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrowError(/Invalid URL/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -92,7 +92,9 @@ export abstract class TaskRunner extends EventEmitter {
|
||||||
this.maxConcurrency = opts.maxConcurrency;
|
this.maxConcurrency = opts.maxConcurrency;
|
||||||
this.idleTimeout = opts.idleTimeout;
|
this.idleTimeout = opts.idleTimeout;
|
||||||
|
|
||||||
const wsUrl = `ws://${opts.n8nUri}/runners/_ws?id=${this.id}`;
|
const { host: taskBrokerHost } = new URL(opts.taskBrokerUri);
|
||||||
|
|
||||||
|
const wsUrl = `ws://${taskBrokerHost}/runners/_ws?id=${this.id}`;
|
||||||
this.ws = new WebSocket(wsUrl, {
|
this.ws = new WebSocket(wsUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
authorization: `Bearer ${opts.grantToken}`,
|
authorization: `Bearer ${opts.grantToken}`,
|
||||||
|
@ -109,11 +111,11 @@ export abstract class TaskRunner extends EventEmitter {
|
||||||
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
|
['ECONNREFUSED', 'ENOTFOUND'].some((code) => code === error.code)
|
||||||
) {
|
) {
|
||||||
console.error(
|
console.error(
|
||||||
`Error: Failed to connect to n8n. Please ensure n8n is reachable at: ${opts.n8nUri}`,
|
`Error: Failed to connect to n8n task broker. Please ensure n8n task broker is reachable at: ${taskBrokerHost}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
console.error(`Error: Failed to connect to n8n at ${opts.n8nUri}`);
|
console.error(`Error: Failed to connect to n8n task broker at ${taskBrokerHost}`);
|
||||||
console.error('Details:', event.message || 'Unknown error');
|
console.error('Details:', event.message || 'Unknown error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { InstanceSettings } from 'n8n-core';
|
import type { InstanceSettings } from 'n8n-core';
|
||||||
import type { IWorkflowBase } from 'n8n-workflow';
|
import type { IRun, IWorkflowBase } from 'n8n-workflow';
|
||||||
|
import { createDeferredPromise } from 'n8n-workflow';
|
||||||
|
|
||||||
|
import type { ActiveExecutions } from '@/active-executions';
|
||||||
import type { Project } from '@/databases/entities/project';
|
import type { Project } from '@/databases/entities/project';
|
||||||
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import type { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import type { IExecutionResponse } from '@/interfaces';
|
import type { IExecutionResponse } from '@/interfaces';
|
||||||
|
@ -12,9 +14,10 @@ import { WaitTracker } from '@/wait-tracker';
|
||||||
import type { WorkflowRunner } from '@/workflow-runner';
|
import type { WorkflowRunner } from '@/workflow-runner';
|
||||||
import { mockLogger } from '@test/mocking';
|
import { mockLogger } from '@test/mocking';
|
||||||
|
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers({ advanceTimers: true });
|
||||||
|
|
||||||
describe('WaitTracker', () => {
|
describe('WaitTracker', () => {
|
||||||
|
const activeExecutions = mock<ActiveExecutions>();
|
||||||
const ownershipService = mock<OwnershipService>();
|
const ownershipService = mock<OwnershipService>();
|
||||||
const workflowRunner = mock<WorkflowRunner>();
|
const workflowRunner = mock<WorkflowRunner>();
|
||||||
const executionRepository = mock<ExecutionRepository>();
|
const executionRepository = mock<ExecutionRepository>();
|
||||||
|
@ -30,6 +33,7 @@ describe('WaitTracker', () => {
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
data: mock({
|
data: mock({
|
||||||
pushRef: 'push_ref',
|
pushRef: 'push_ref',
|
||||||
|
parentExecution: undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' });
|
execution.workflowData = mock<IWorkflowBase>({ id: 'abcd' });
|
||||||
|
@ -40,6 +44,7 @@ describe('WaitTracker', () => {
|
||||||
mockLogger(),
|
mockLogger(),
|
||||||
executionRepository,
|
executionRepository,
|
||||||
ownershipService,
|
ownershipService,
|
||||||
|
activeExecutions,
|
||||||
workflowRunner,
|
workflowRunner,
|
||||||
orchestrationService,
|
orchestrationService,
|
||||||
instanceSettings,
|
instanceSettings,
|
||||||
|
@ -80,7 +85,9 @@ describe('WaitTracker', () => {
|
||||||
let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>;
|
let startExecutionSpy: jest.SpyInstance<Promise<void>, [executionId: string]>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
executionRepository.findSingleExecution
|
||||||
|
.calledWith(execution.id)
|
||||||
|
.mockResolvedValue(execution);
|
||||||
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
|
executionRepository.getWaitingExecutions.mockResolvedValue([execution]);
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
|
ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
|
||||||
|
|
||||||
|
@ -110,13 +117,17 @@ describe('WaitTracker', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('startExecution()', () => {
|
describe('startExecution()', () => {
|
||||||
it('should query for execution to start', async () => {
|
beforeEach(() => {
|
||||||
executionRepository.getWaitingExecutions.mockResolvedValue([]);
|
executionRepository.getWaitingExecutions.mockResolvedValue([]);
|
||||||
waitTracker.init();
|
waitTracker.init();
|
||||||
|
|
||||||
executionRepository.findSingleExecution.mockResolvedValue(execution);
|
executionRepository.findSingleExecution.calledWith(execution.id).mockResolvedValue(execution);
|
||||||
ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
|
ownershipService.getWorkflowProjectCached.mockResolvedValue(project);
|
||||||
|
|
||||||
|
execution.data.parentExecution = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should query for execution to start', async () => {
|
||||||
await waitTracker.startExecution(execution.id);
|
await waitTracker.startExecution(execution.id);
|
||||||
|
|
||||||
expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, {
|
expect(executionRepository.findSingleExecution).toHaveBeenCalledWith(execution.id, {
|
||||||
|
@ -137,6 +148,65 @@ describe('WaitTracker', () => {
|
||||||
execution.id,
|
execution.id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should also resume parent execution once sub-workflow finishes', async () => {
|
||||||
|
const parentExecution = mock<IExecutionResponse>({
|
||||||
|
id: 'parent_execution_id',
|
||||||
|
finished: false,
|
||||||
|
});
|
||||||
|
parentExecution.workflowData = mock<IWorkflowBase>({ id: 'parent_workflow_id' });
|
||||||
|
execution.data.parentExecution = {
|
||||||
|
executionId: parentExecution.id,
|
||||||
|
workflowId: parentExecution.workflowData.id,
|
||||||
|
};
|
||||||
|
executionRepository.findSingleExecution
|
||||||
|
.calledWith(parentExecution.id)
|
||||||
|
.mockResolvedValue(parentExecution);
|
||||||
|
const postExecutePromise = createDeferredPromise<IRun | undefined>();
|
||||||
|
activeExecutions.getPostExecutePromise
|
||||||
|
.calledWith(execution.id)
|
||||||
|
.mockReturnValue(postExecutePromise.promise);
|
||||||
|
|
||||||
|
await waitTracker.startExecution(execution.id);
|
||||||
|
|
||||||
|
expect(executionRepository.findSingleExecution).toHaveBeenNthCalledWith(1, execution.id, {
|
||||||
|
includeData: true,
|
||||||
|
unflattenData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||||
|
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
executionMode: execution.mode,
|
||||||
|
executionData: execution.data,
|
||||||
|
workflowData: execution.workflowData,
|
||||||
|
projectId: project.id,
|
||||||
|
pushRef: execution.data.pushRef,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
execution.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
postExecutePromise.resolve(mock<IRun>());
|
||||||
|
await jest.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(workflowRunner.run).toHaveBeenCalledTimes(2);
|
||||||
|
expect(workflowRunner.run).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
executionMode: parentExecution.mode,
|
||||||
|
executionData: parentExecution.data,
|
||||||
|
workflowData: parentExecution.workflowData,
|
||||||
|
projectId: project.id,
|
||||||
|
pushRef: parentExecution.data.pushRef,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
parentExecution.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('single-main setup', () => {
|
describe('single-main setup', () => {
|
||||||
|
@ -165,6 +235,7 @@ describe('WaitTracker', () => {
|
||||||
mockLogger(),
|
mockLogger(),
|
||||||
executionRepository,
|
executionRepository,
|
||||||
ownershipService,
|
ownershipService,
|
||||||
|
activeExecutions,
|
||||||
workflowRunner,
|
workflowRunner,
|
||||||
orchestrationService,
|
orchestrationService,
|
||||||
mock<InstanceSettings>({ isLeader: false }),
|
mock<InstanceSettings>({ isLeader: false }),
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import { mock } from 'jest-mock-extended';
|
import { mock } from 'jest-mock-extended';
|
||||||
import type { IWorkflowBase } from 'n8n-workflow';
|
import type { IWorkflowBase } from 'n8n-workflow';
|
||||||
import {
|
import type {
|
||||||
type IExecuteWorkflowInfo,
|
IExecuteWorkflowInfo,
|
||||||
type IWorkflowExecuteAdditionalData,
|
IWorkflowExecuteAdditionalData,
|
||||||
type ExecuteWorkflowOptions,
|
ExecuteWorkflowOptions,
|
||||||
type IRun,
|
IRun,
|
||||||
type INodeExecutionData,
|
INodeExecutionData,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import type PCancelable from 'p-cancelable';
|
import type PCancelable from 'p-cancelable';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
@ -50,6 +50,7 @@ const getMockRun = ({ lastNodeOutput }: { lastNodeOutput: Array<INodeExecutionDa
|
||||||
mode: 'manual',
|
mode: 'manual',
|
||||||
startedAt: new Date(),
|
startedAt: new Date(),
|
||||||
status: 'new',
|
status: 'new',
|
||||||
|
waitTill: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getCancelablePromise = async (run: IRun) =>
|
const getCancelablePromise = async (run: IRun) =>
|
||||||
|
@ -114,7 +115,9 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('executeWorkflow', () => {
|
describe('executeWorkflow', () => {
|
||||||
const runWithData = getMockRun({ lastNodeOutput: [[{ json: { test: 1 } }]] });
|
const runWithData = getMockRun({
|
||||||
|
lastNodeOutput: [[{ json: { test: 1 } }]],
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
workflowRepository.get.mockResolvedValue(
|
workflowRepository.get.mockResolvedValue(
|
||||||
|
@ -159,6 +162,23 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
|
|
||||||
expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID);
|
expect(executionRepository.setRunning).toHaveBeenCalledWith(EXECUTION_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return waitTill property when workflow execution is waiting', async () => {
|
||||||
|
const waitTill = new Date();
|
||||||
|
runWithData.waitTill = waitTill;
|
||||||
|
|
||||||
|
const response = await executeWorkflow(
|
||||||
|
mock<IExecuteWorkflowInfo>(),
|
||||||
|
mock<IWorkflowExecuteAdditionalData>(),
|
||||||
|
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: false }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response).toEqual({
|
||||||
|
data: runWithData.data.resultData.runData[LAST_NODE_EXECUTED][0].data!.main,
|
||||||
|
executionId: EXECUTION_ID,
|
||||||
|
waitTill,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRunData', () => {
|
describe('getRunData', () => {
|
||||||
|
@ -230,6 +250,10 @@ describe('WorkflowExecuteAdditionalData', () => {
|
||||||
waitingExecution: {},
|
waitingExecution: {},
|
||||||
waitingExecutionSource: {},
|
waitingExecutionSource: {},
|
||||||
},
|
},
|
||||||
|
parentExecution: {
|
||||||
|
executionId: '123',
|
||||||
|
workflowId: '567',
|
||||||
|
},
|
||||||
resultData: { runData: {} },
|
resultData: { runData: {} },
|
||||||
startData: {},
|
startData: {},
|
||||||
},
|
},
|
||||||
|
|
|
@ -80,6 +80,7 @@ type ExceptionPaths = {
|
||||||
processedDataManager: IProcessedDataConfig;
|
processedDataManager: IProcessedDataConfig;
|
||||||
'userManagement.isInstanceOwnerSetUp': boolean;
|
'userManagement.isInstanceOwnerSetUp': boolean;
|
||||||
'ui.banners.dismissed': string[] | undefined;
|
'ui.banners.dismissed': string[] | undefined;
|
||||||
|
easyAIWorkflowOnboarded: boolean | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// -----------------------------------
|
// -----------------------------------
|
||||||
|
|
|
@ -5,7 +5,12 @@ import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity.
|
||||||
import type { TestMetric } from '@/databases/entities/test-metric.ee';
|
import type { TestMetric } from '@/databases/entities/test-metric.ee';
|
||||||
import { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
import { WorkflowEntity } from '@/databases/entities/workflow-entity';
|
||||||
|
|
||||||
import { WithTimestampsAndStringId } from './abstract-entity';
|
import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
|
||||||
|
|
||||||
|
// Entity representing a node in a workflow under test, for which data should be mocked during test execution
|
||||||
|
export type MockedNodeItem = {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Entity representing a Test Definition
|
* Entity representing a Test Definition
|
||||||
|
@ -27,6 +32,9 @@ export class TestDefinition extends WithTimestampsAndStringId {
|
||||||
@Column('text')
|
@Column('text')
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
|
@Column(jsonColumnType, { default: '[]' })
|
||||||
|
mockedNodes: MockedNodeItem[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Relation to the workflow under test
|
* Relation to the workflow under test
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
|
||||||
|
|
||||||
|
// We have to use raw query migration instead of schemaBuilder helpers,
|
||||||
|
// because the typeorm schema builder implements addColumns by a table recreate for sqlite
|
||||||
|
// which causes weird issues with the migration
|
||||||
|
export class AddMockedNodesColumnToTestDefinition1733133775640 implements ReversibleMigration {
|
||||||
|
async up({ escape, runQuery }: MigrationContext) {
|
||||||
|
const tableName = escape.tableName('test_definition');
|
||||||
|
const mockedNodesColumnName = escape.columnName('mockedNodes');
|
||||||
|
|
||||||
|
await runQuery(
|
||||||
|
`ALTER TABLE ${tableName} ADD COLUMN ${mockedNodesColumnName} JSON DEFAULT '[]' NOT NULL`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down({ escape, runQuery }: MigrationContext) {
|
||||||
|
const tableName = escape.tableName('test_definition');
|
||||||
|
const columnName = escape.columnName('mockedNodes');
|
||||||
|
|
||||||
|
await runQuery(`ALTER TABLE ${tableName} DROP COLUMN ${columnName}`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -73,6 +73,7 @@ import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-
|
||||||
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
||||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
|
|
||||||
export const mysqlMigrations: Migration[] = [
|
export const mysqlMigrations: Migration[] = [
|
||||||
InitialMigration1588157391238,
|
InitialMigration1588157391238,
|
||||||
|
@ -148,4 +149,5 @@ export const mysqlMigrations: Migration[] = [
|
||||||
MigrateTestDefinitionKeyToString1731582748663,
|
MigrateTestDefinitionKeyToString1731582748663,
|
||||||
CreateTestMetricTable1732271325258,
|
CreateTestMetricTable1732271325258,
|
||||||
CreateTestRun1732549866705,
|
CreateTestRun1732549866705,
|
||||||
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
];
|
];
|
||||||
|
|
|
@ -73,6 +73,7 @@ import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-
|
||||||
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
import { AddDescriptionToTestDefinition1731404028106 } from '../common/1731404028106-AddDescriptionToTestDefinition';
|
||||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
|
|
||||||
export const postgresMigrations: Migration[] = [
|
export const postgresMigrations: Migration[] = [
|
||||||
InitialMigration1587669153312,
|
InitialMigration1587669153312,
|
||||||
|
@ -148,4 +149,5 @@ export const postgresMigrations: Migration[] = [
|
||||||
MigrateTestDefinitionKeyToString1731582748663,
|
MigrateTestDefinitionKeyToString1731582748663,
|
||||||
CreateTestMetricTable1732271325258,
|
CreateTestMetricTable1732271325258,
|
||||||
CreateTestRun1732549866705,
|
CreateTestRun1732549866705,
|
||||||
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
];
|
];
|
||||||
|
|
|
@ -70,6 +70,7 @@ import { UpdateProcessedDataValueColumnToText1729607673464 } from '../common/172
|
||||||
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
import { CreateTestDefinitionTable1730386903556 } from '../common/1730386903556-CreateTestDefinitionTable';
|
||||||
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
import { CreateTestMetricTable1732271325258 } from '../common/1732271325258-CreateTestMetricTable';
|
||||||
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
import { CreateTestRun1732549866705 } from '../common/1732549866705-CreateTestRunTable';
|
||||||
|
import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/1733133775640-AddMockedNodesColumnToTestDefinition';
|
||||||
|
|
||||||
const sqliteMigrations: Migration[] = [
|
const sqliteMigrations: Migration[] = [
|
||||||
InitialMigration1588102412422,
|
InitialMigration1588102412422,
|
||||||
|
@ -142,6 +143,7 @@ const sqliteMigrations: Migration[] = [
|
||||||
MigrateTestDefinitionKeyToString1731582748663,
|
MigrateTestDefinitionKeyToString1731582748663,
|
||||||
CreateTestMetricTable1732271325258,
|
CreateTestMetricTable1732271325258,
|
||||||
CreateTestRun1732549866705,
|
CreateTestRun1732549866705,
|
||||||
|
AddMockedNodesColumnToTestDefinition1733133775640,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { sqliteMigrations };
|
export { sqliteMigrations };
|
||||||
|
|
|
@ -211,4 +211,13 @@ export class SharedWorkflowRepository extends Repository<SharedWorkflow> {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllRelationsForWorkflows(workflowIds: string[]) {
|
||||||
|
return await this.find({
|
||||||
|
where: {
|
||||||
|
workflowId: In(workflowIds),
|
||||||
|
},
|
||||||
|
relations: ['project'],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -95,7 +95,8 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMany(sharedWorkflowIds: string[], options?: ListQuery.Options) {
|
async getMany(sharedWorkflowIds: string[], originalOptions: ListQuery.Options = {}) {
|
||||||
|
const options = structuredClone(originalOptions);
|
||||||
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
|
if (sharedWorkflowIds.length === 0) return { workflows: [], count: 0 };
|
||||||
|
|
||||||
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
|
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
|
||||||
|
|
|
@ -16,5 +16,6 @@ export const testDefinitionPatchRequestBodySchema = z
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
evaluationWorkflowId: z.string().min(1).optional(),
|
evaluationWorkflowId: z.string().min(1).optional(),
|
||||||
annotationTagId: z.string().min(1).optional(),
|
annotationTagId: z.string().min(1).optional(),
|
||||||
|
mockedNodes: z.array(z.object({ name: z.string() })).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
import type { TestDefinition } from '@/databases/entities/test-definition.ee';
|
import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee';
|
||||||
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
|
import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository.ee';
|
||||||
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee';
|
||||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||||
|
@ -31,6 +31,7 @@ export class TestDefinitionService {
|
||||||
evaluationWorkflowId?: string;
|
evaluationWorkflowId?: string;
|
||||||
annotationTagId?: string;
|
annotationTagId?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
mockedNodes?: MockedNodeItem[];
|
||||||
}) {
|
}) {
|
||||||
const entity: TestDefinitionLike = {};
|
const entity: TestDefinitionLike = {};
|
||||||
|
|
||||||
|
@ -64,6 +65,10 @@ export class TestDefinitionService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attrs.mockedNodes) {
|
||||||
|
entity.mockedNodes = attrs.mockedNodes;
|
||||||
|
}
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -107,6 +112,24 @@ export class TestDefinitionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there are mocked nodes, validate them
|
||||||
|
if (attrs.mockedNodes && attrs.mockedNodes.length > 0) {
|
||||||
|
const existingTestDefinition = await this.testDefinitionRepository.findOneOrFail({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
relations: ['workflow'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingNodeNames = new Set(existingTestDefinition.workflow.nodes.map((n) => n.name));
|
||||||
|
|
||||||
|
attrs.mockedNodes.forEach((node) => {
|
||||||
|
if (!existingNodeNames.has(node.name)) {
|
||||||
|
throw new BadRequestError(`Pinned node not found in the workflow: ${node.name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Update the test definition
|
// Update the test definition
|
||||||
const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs));
|
const queryResult = await this.testDefinitionRepository.update(id, this.toEntityLike(attrs));
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { MockedNodeItem } from '@/databases/entities/test-definition.ee';
|
||||||
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
import type { AuthenticatedRequest, ListQuery } from '@/requests';
|
||||||
|
|
||||||
// ----------------------------------
|
// ----------------------------------
|
||||||
|
@ -26,7 +27,12 @@ export declare namespace TestDefinitionsRequest {
|
||||||
type Patch = AuthenticatedRequest<
|
type Patch = AuthenticatedRequest<
|
||||||
RouteParams.TestId,
|
RouteParams.TestId,
|
||||||
{},
|
{},
|
||||||
{ name?: string; evaluationWorkflowId?: string; annotationTagId?: string }
|
{
|
||||||
|
name?: string;
|
||||||
|
evaluationWorkflowId?: string;
|
||||||
|
annotationTagId?: string;
|
||||||
|
mockedNodes?: MockedNodeItem[];
|
||||||
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type Delete = AuthenticatedRequest<RouteParams.TestId>;
|
type Delete = AuthenticatedRequest<RouteParams.TestId>;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { readFileSync } from 'fs';
|
import { readFileSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { getPastExecutionStartNode } from '../utils.ee';
|
import { getPastExecutionTriggerNode } from '../utils.ee';
|
||||||
|
|
||||||
const executionDataJson = JSON.parse(
|
const executionDataJson = JSON.parse(
|
||||||
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
readFileSync(path.join(__dirname, './mock-data/execution-data.json'), { encoding: 'utf-8' }),
|
||||||
|
@ -21,19 +21,19 @@ const executionDataMultipleTriggersJson2 = JSON.parse(
|
||||||
|
|
||||||
describe('getPastExecutionStartNode', () => {
|
describe('getPastExecutionStartNode', () => {
|
||||||
test('should return the start node of the past execution', () => {
|
test('should return the start node of the past execution', () => {
|
||||||
const startNode = getPastExecutionStartNode(executionDataJson);
|
const startNode = getPastExecutionTriggerNode(executionDataJson);
|
||||||
|
|
||||||
expect(startNode).toEqual('When clicking ‘Test workflow’');
|
expect(startNode).toEqual('When clicking ‘Test workflow’');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return the start node of the past execution with multiple triggers', () => {
|
test('should return the start node of the past execution with multiple triggers', () => {
|
||||||
const startNode = getPastExecutionStartNode(executionDataMultipleTriggersJson);
|
const startNode = getPastExecutionTriggerNode(executionDataMultipleTriggersJson);
|
||||||
|
|
||||||
expect(startNode).toEqual('When clicking ‘Test workflow’');
|
expect(startNode).toEqual('When clicking ‘Test workflow’');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return the start node of the past execution with multiple triggers - chat trigger', () => {
|
test('should return the start node of the past execution with multiple triggers - chat trigger', () => {
|
||||||
const startNode = getPastExecutionStartNode(executionDataMultipleTriggersJson2);
|
const startNode = getPastExecutionTriggerNode(executionDataMultipleTriggersJson2);
|
||||||
|
|
||||||
expect(startNode).toEqual('When chat message received');
|
expect(startNode).toEqual('When chat message received');
|
||||||
});
|
});
|
||||||
|
|
|
@ -22,7 +22,7 @@ import { getRunData } from '@/workflow-execute-additional-data';
|
||||||
import { WorkflowRunner } from '@/workflow-runner';
|
import { WorkflowRunner } from '@/workflow-runner';
|
||||||
|
|
||||||
import { EvaluationMetrics } from './evaluation-metrics.ee';
|
import { EvaluationMetrics } from './evaluation-metrics.ee';
|
||||||
import { createPinData, getPastExecutionStartNode } from './utils.ee';
|
import { createPinData, getPastExecutionTriggerNode } from './utils.ee';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This service orchestrates the running of test cases.
|
* This service orchestrates the running of test cases.
|
||||||
|
@ -58,7 +58,7 @@ export class TestRunnerService {
|
||||||
const pinData = createPinData(workflow, pastExecutionData);
|
const pinData = createPinData(workflow, pastExecutionData);
|
||||||
|
|
||||||
// Determine the start node of the past execution
|
// Determine the start node of the past execution
|
||||||
const pastExecutionStartNode = getPastExecutionStartNode(pastExecutionData);
|
const pastExecutionStartNode = getPastExecutionTriggerNode(pastExecutionData);
|
||||||
|
|
||||||
// Prepare the data to run the workflow
|
// Prepare the data to run the workflow
|
||||||
const data: IWorkflowExecutionDataProcess = {
|
const data: IWorkflowExecutionDataProcess = {
|
||||||
|
|
|
@ -26,7 +26,7 @@ export function createPinData(workflow: WorkflowEntity, executionData: IRunExecu
|
||||||
* Returns the start node of the past execution.
|
* Returns the start node of the past execution.
|
||||||
* The start node is the node that has no source and has run data.
|
* The start node is the node that has no source and has run data.
|
||||||
*/
|
*/
|
||||||
export function getPastExecutionStartNode(executionData: IRunExecutionData) {
|
export function getPastExecutionTriggerNode(executionData: IRunExecutionData) {
|
||||||
return Object.keys(executionData.resultData.runData).find((nodeName) => {
|
return Object.keys(executionData.resultData.runData).find((nodeName) => {
|
||||||
const data = executionData.resultData.runData[nodeName];
|
const data = executionData.resultData.runData[nodeName];
|
||||||
return !data[0].source || data[0].source.length === 0 || data[0].source[0] === null;
|
return !data[0].source || data[0].source.length === 0 || data[0].source[0] === null;
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
|
|
||||||
|
import type { Logger } from '@/logging/logger.service';
|
||||||
|
import type { TaskRunnerAuthService } from '@/runners/auth/task-runner-auth.service';
|
||||||
|
import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error';
|
||||||
|
import { RunnerLifecycleEvents } from '@/runners/runner-lifecycle-events';
|
||||||
|
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector';
|
||||||
|
|
||||||
|
describe('TaskRunnerProcessRestartLoopDetector', () => {
|
||||||
|
const mockLogger = mock<Logger>();
|
||||||
|
const mockAuthService = mock<TaskRunnerAuthService>();
|
||||||
|
const runnerConfig = new TaskRunnersConfig();
|
||||||
|
const taskRunnerProcess = new TaskRunnerProcess(
|
||||||
|
mockLogger,
|
||||||
|
runnerConfig,
|
||||||
|
mockAuthService,
|
||||||
|
new RunnerLifecycleEvents(),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('should detect a restart loop if process exits 5 times within 5s', () => {
|
||||||
|
const restartLoopDetector = new TaskRunnerProcessRestartLoopDetector(taskRunnerProcess);
|
||||||
|
let emittedError: TaskRunnerRestartLoopError | undefined = undefined;
|
||||||
|
restartLoopDetector.on('restart-loop-detected', (error) => {
|
||||||
|
emittedError = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
|
||||||
|
expect(emittedError).toBeInstanceOf(TaskRunnerRestartLoopError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect a restart loop if process exits less than 5 times within 5s', () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
const restartLoopDetector = new TaskRunnerProcessRestartLoopDetector(taskRunnerProcess);
|
||||||
|
let emittedError: TaskRunnerRestartLoopError | undefined = undefined;
|
||||||
|
restartLoopDetector.on('restart-loop-detected', (error) => {
|
||||||
|
emittedError = error;
|
||||||
|
});
|
||||||
|
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(5010);
|
||||||
|
|
||||||
|
taskRunnerProcess.emit('exit');
|
||||||
|
|
||||||
|
expect(emittedError).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { ApplicationError } from 'n8n-workflow';
|
||||||
|
|
||||||
|
export class TaskRunnerRestartLoopError extends ApplicationError {
|
||||||
|
constructor(
|
||||||
|
public readonly howManyTimes: number,
|
||||||
|
public readonly timePeriodMs: number,
|
||||||
|
) {
|
||||||
|
const message = `Task runner has restarted ${howManyTimes} times within ${timePeriodMs / 1000} seconds. This is an abnormally high restart rate that suggests a bug or other issue is preventing your runner process from starting up. If this issues persists, please file a report at: https://github.com/n8n-io/n8n/issues`;
|
||||||
|
|
||||||
|
super(message, {
|
||||||
|
level: 'fatal',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,13 @@
|
||||||
import { TaskRunnersConfig } from '@n8n/config';
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import { ErrorReporterProxy, sleep } from 'n8n-workflow';
|
||||||
import * as a from 'node:assert/strict';
|
import * as a from 'node:assert/strict';
|
||||||
import Container, { Service } from 'typedi';
|
import Container, { Service } from 'typedi';
|
||||||
|
|
||||||
import { OnShutdown } from '@/decorators/on-shutdown';
|
import { OnShutdown } from '@/decorators/on-shutdown';
|
||||||
|
import { Logger } from '@/logging/logger.service';
|
||||||
|
import type { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error';
|
||||||
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
|
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector';
|
||||||
|
|
||||||
import { MissingAuthTokenError } from './errors/missing-auth-token.error';
|
import { MissingAuthTokenError } from './errors/missing-auth-token.error';
|
||||||
import { TaskRunnerWsServer } from './runner-ws-server';
|
import { TaskRunnerWsServer } from './runner-ws-server';
|
||||||
|
@ -25,7 +29,14 @@ export class TaskRunnerModule {
|
||||||
|
|
||||||
private taskRunnerProcess: TaskRunnerProcess | undefined;
|
private taskRunnerProcess: TaskRunnerProcess | undefined;
|
||||||
|
|
||||||
constructor(private readonly runnerConfig: TaskRunnersConfig) {}
|
private taskRunnerProcessRestartLoopDetector: TaskRunnerProcessRestartLoopDetector | undefined;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly logger: Logger,
|
||||||
|
private readonly runnerConfig: TaskRunnersConfig,
|
||||||
|
) {
|
||||||
|
this.logger = this.logger.scoped('task-runner');
|
||||||
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
a.ok(this.runnerConfig.enabled, 'Task runner is disabled');
|
a.ok(this.runnerConfig.enabled, 'Task runner is disabled');
|
||||||
|
@ -83,6 +94,14 @@ export class TaskRunnerModule {
|
||||||
|
|
||||||
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
|
const { TaskRunnerProcess } = await import('@/runners/task-runner-process');
|
||||||
this.taskRunnerProcess = Container.get(TaskRunnerProcess);
|
this.taskRunnerProcess = Container.get(TaskRunnerProcess);
|
||||||
|
this.taskRunnerProcessRestartLoopDetector = new TaskRunnerProcessRestartLoopDetector(
|
||||||
|
this.taskRunnerProcess,
|
||||||
|
);
|
||||||
|
this.taskRunnerProcessRestartLoopDetector.on(
|
||||||
|
'restart-loop-detected',
|
||||||
|
this.onRunnerRestartLoopDetected,
|
||||||
|
);
|
||||||
|
|
||||||
await this.taskRunnerProcess.start();
|
await this.taskRunnerProcess.start();
|
||||||
|
|
||||||
const { InternalTaskRunnerDisconnectAnalyzer } = await import(
|
const { InternalTaskRunnerDisconnectAnalyzer } = await import(
|
||||||
|
@ -92,4 +111,13 @@ export class TaskRunnerModule {
|
||||||
Container.get(InternalTaskRunnerDisconnectAnalyzer),
|
Container.get(InternalTaskRunnerDisconnectAnalyzer),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onRunnerRestartLoopDetected = async (error: TaskRunnerRestartLoopError) => {
|
||||||
|
this.logger.error(error.message);
|
||||||
|
ErrorReporterProxy.error(error);
|
||||||
|
|
||||||
|
// Allow some time for the error to be flushed
|
||||||
|
await sleep(1000);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Time } from '@/constants';
|
||||||
|
import { TaskRunnerRestartLoopError } from '@/runners/errors/task-runner-restart-loop-error';
|
||||||
|
import type { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
import { TypedEmitter } from '@/typed-emitter';
|
||||||
|
|
||||||
|
const MAX_RESTARTS = 5;
|
||||||
|
const RESTARTS_WINDOW = 2 * Time.seconds.toMilliseconds;
|
||||||
|
|
||||||
|
type TaskRunnerProcessRestartLoopDetectorEventMap = {
|
||||||
|
'restart-loop-detected': TaskRunnerRestartLoopError;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class to monitor the task runner process for restart loops
|
||||||
|
*/
|
||||||
|
export class TaskRunnerProcessRestartLoopDetector extends TypedEmitter<TaskRunnerProcessRestartLoopDetectorEventMap> {
|
||||||
|
/**
|
||||||
|
* How many times the process needs to restart for it to be detected
|
||||||
|
* being in a loop.
|
||||||
|
*/
|
||||||
|
private readonly maxCount = MAX_RESTARTS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time interval in which the process needs to restart `maxCount` times
|
||||||
|
* to be detected as being in a loop.
|
||||||
|
*/
|
||||||
|
private readonly restartsWindow = RESTARTS_WINDOW;
|
||||||
|
|
||||||
|
private numRestarts = 0;
|
||||||
|
|
||||||
|
/** Time when the first restart of a loop happened within a time window */
|
||||||
|
private firstRestartedAt = Date.now();
|
||||||
|
|
||||||
|
constructor(private readonly taskRunnerProcess: TaskRunnerProcess) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.taskRunnerProcess.on('exit', () => {
|
||||||
|
this.increment();
|
||||||
|
|
||||||
|
if (this.isMaxCountExceeded()) {
|
||||||
|
this.emit(
|
||||||
|
'restart-loop-detected',
|
||||||
|
new TaskRunnerRestartLoopError(this.numRestarts, this.msSinceFirstIncrement()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments the counter
|
||||||
|
*/
|
||||||
|
private increment() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now > this.firstRestartedAt + this.restartsWindow) {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.numRestarts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset() {
|
||||||
|
this.numRestarts = 0;
|
||||||
|
this.firstRestartedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isMaxCountExceeded() {
|
||||||
|
return this.numRestarts >= this.maxCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private msSinceFirstIncrement() {
|
||||||
|
return Date.now() - this.firstRestartedAt;
|
||||||
|
}
|
||||||
|
}
|
|
@ -95,19 +95,19 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
|
|
||||||
const grantToken = await this.authService.createGrantToken();
|
const grantToken = await this.authService.createGrantToken();
|
||||||
|
|
||||||
const n8nUri = `127.0.0.1:${this.runnerConfig.port}`;
|
const taskBrokerUri = `http://127.0.0.1:${this.runnerConfig.port}`;
|
||||||
this.process = this.startNode(grantToken, n8nUri);
|
this.process = this.startNode(grantToken, taskBrokerUri);
|
||||||
|
|
||||||
forwardToLogger(this.logger, this.process, '[Task Runner]: ');
|
forwardToLogger(this.logger, this.process, '[Task Runner]: ');
|
||||||
|
|
||||||
this.monitorProcess(this.process);
|
this.monitorProcess(this.process);
|
||||||
}
|
}
|
||||||
|
|
||||||
startNode(grantToken: string, n8nUri: string) {
|
startNode(grantToken: string, taskBrokerUri: string) {
|
||||||
const startScript = require.resolve('@n8n/task-runner/start');
|
const startScript = require.resolve('@n8n/task-runner/start');
|
||||||
|
|
||||||
return spawn('node', [startScript], {
|
return spawn('node', [startScript], {
|
||||||
env: this.getProcessEnvVars(grantToken, n8nUri),
|
env: this.getProcessEnvVars(grantToken, taskBrokerUri),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,10 +159,10 @@ export class TaskRunnerProcess extends TypedEmitter<TaskRunnerProcessEventMap> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getProcessEnvVars(grantToken: string, n8nUri: string) {
|
private getProcessEnvVars(grantToken: string, taskBrokerUri: string) {
|
||||||
const envVars: Record<string, string> = {
|
const envVars: Record<string, string> = {
|
||||||
N8N_RUNNERS_GRANT_TOKEN: grantToken,
|
N8N_RUNNERS_GRANT_TOKEN: grantToken,
|
||||||
N8N_RUNNERS_N8N_URI: n8nUri,
|
N8N_RUNNERS_TASK_BROKER_URI: taskBrokerUri,
|
||||||
N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(),
|
N8N_RUNNERS_MAX_PAYLOAD: this.runnerConfig.maxPayload.toString(),
|
||||||
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
|
N8N_RUNNERS_MAX_CONCURRENCY: this.runnerConfig.maxConcurrency.toString(),
|
||||||
...this.getPassthroughEnvVars(),
|
...this.getPassthroughEnvVars(),
|
||||||
|
|
|
@ -232,6 +232,7 @@ export class FrontendService {
|
||||||
},
|
},
|
||||||
betaFeatures: this.frontendConfig.betaFeatures,
|
betaFeatures: this.frontendConfig.betaFeatures,
|
||||||
virtualSchemaView: config.getEnv('virtualSchemaView'),
|
virtualSchemaView: config.getEnv('virtualSchemaView'),
|
||||||
|
easyAIWorkflowOnboarded: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -274,6 +275,11 @@ export class FrontendService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.settings.banners.dismissed = dismissedBanners;
|
this.settings.banners.dismissed = dismissedBanners;
|
||||||
|
try {
|
||||||
|
this.settings.easyAIWorkflowOnboarded = config.getEnv('easyAIWorkflowOnboarded') ?? false;
|
||||||
|
} catch {
|
||||||
|
this.settings.easyAIWorkflowOnboarded = false;
|
||||||
|
}
|
||||||
|
|
||||||
const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3';
|
const isS3Selected = config.getEnv('binaryDataManager.mode') === 's3';
|
||||||
const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3');
|
const isS3Available = config.getEnv('binaryDataManager.availableModes').includes('s3');
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { InstanceSettings } from 'n8n-core';
|
||||||
import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
import { ApplicationError, type IWorkflowExecutionDataProcess } from 'n8n-workflow';
|
||||||
import { Service } from 'typedi';
|
import { Service } from 'typedi';
|
||||||
|
|
||||||
|
import { ActiveExecutions } from '@/active-executions';
|
||||||
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
import { ExecutionRepository } from '@/databases/repositories/execution.repository';
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
import { OrchestrationService } from '@/services/orchestration.service';
|
import { OrchestrationService } from '@/services/orchestration.service';
|
||||||
|
@ -23,6 +24,7 @@ export class WaitTracker {
|
||||||
private readonly logger: Logger,
|
private readonly logger: Logger,
|
||||||
private readonly executionRepository: ExecutionRepository,
|
private readonly executionRepository: ExecutionRepository,
|
||||||
private readonly ownershipService: OwnershipService,
|
private readonly ownershipService: OwnershipService,
|
||||||
|
private readonly activeExecutions: ActiveExecutions,
|
||||||
private readonly workflowRunner: WorkflowRunner,
|
private readonly workflowRunner: WorkflowRunner,
|
||||||
private readonly orchestrationService: OrchestrationService,
|
private readonly orchestrationService: OrchestrationService,
|
||||||
private readonly instanceSettings: InstanceSettings,
|
private readonly instanceSettings: InstanceSettings,
|
||||||
|
@ -133,6 +135,14 @@ export class WaitTracker {
|
||||||
|
|
||||||
// Start the execution again
|
// Start the execution again
|
||||||
await this.workflowRunner.run(data, false, false, executionId);
|
await this.workflowRunner.run(data, false, false, executionId);
|
||||||
|
|
||||||
|
const { parentExecution } = fullExecutionData.data;
|
||||||
|
if (parentExecution) {
|
||||||
|
// on child execution completion, resume parent execution
|
||||||
|
void this.activeExecutions.getPostExecutePromise(executionId).then(() => {
|
||||||
|
void this.startExecution(parentExecution.executionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopTracking() {
|
stopTracking() {
|
||||||
|
|
|
@ -48,11 +48,12 @@ import type { Project } from '@/databases/entities/project';
|
||||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||||
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
|
||||||
import type { IExecutionDb, IWorkflowDb } from '@/interfaces';
|
import type { IWorkflowDb } from '@/interfaces';
|
||||||
import { Logger } from '@/logging/logger.service';
|
import { Logger } from '@/logging/logger.service';
|
||||||
import { parseBody } from '@/middlewares';
|
import { parseBody } from '@/middlewares';
|
||||||
import { OwnershipService } from '@/services/ownership.service';
|
import { OwnershipService } from '@/services/ownership.service';
|
||||||
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
|
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
|
||||||
|
import { WaitTracker } from '@/wait-tracker';
|
||||||
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
|
import { createMultiFormDataParser } from '@/webhooks/webhook-form-data';
|
||||||
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
|
||||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||||
|
@ -548,11 +549,21 @@ export async function executeWebhook(
|
||||||
{ executionId },
|
{ executionId },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const activeExecutions = Container.get(ActiveExecutions);
|
||||||
|
|
||||||
|
// Get a promise which resolves when the workflow did execute and send then response
|
||||||
|
const executePromise = activeExecutions.getPostExecutePromise(executionId);
|
||||||
|
|
||||||
|
const { parentExecution } = runExecutionData;
|
||||||
|
if (parentExecution) {
|
||||||
|
// on child execution completion, resume parent execution
|
||||||
|
void executePromise.then(() => {
|
||||||
|
const waitTracker = Container.get(WaitTracker);
|
||||||
|
void waitTracker.startExecution(parentExecution.executionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!didSendResponse) {
|
if (!didSendResponse) {
|
||||||
// Get a promise which resolves when the workflow did execute and send then response
|
|
||||||
const executePromise = Container.get(ActiveExecutions).getPostExecutePromise(
|
|
||||||
executionId,
|
|
||||||
) as Promise<IExecutionDb | undefined>;
|
|
||||||
executePromise
|
executePromise
|
||||||
// eslint-disable-next-line complexity
|
// eslint-disable-next-line complexity
|
||||||
.then(async (data) => {
|
.then(async (data) => {
|
||||||
|
|
|
@ -709,6 +709,7 @@ export async function getRunData(
|
||||||
waitingExecution: {},
|
waitingExecution: {},
|
||||||
waitingExecutionSource: {},
|
waitingExecutionSource: {},
|
||||||
},
|
},
|
||||||
|
parentExecution,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -944,6 +945,7 @@ async function startExecution(
|
||||||
return {
|
return {
|
||||||
executionId,
|
executionId,
|
||||||
data: returnData!.data!.main,
|
data: returnData!.data!.main,
|
||||||
|
waitTill: data.waitTill,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
activeExecutions.finalizeExecution(executionId, data);
|
activeExecutions.finalizeExecution(executionId, data);
|
||||||
|
|
|
@ -66,6 +66,20 @@ export class WorkflowService {
|
||||||
let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
|
let { workflows, count } = await this.workflowRepository.getMany(sharedWorkflowIds, options);
|
||||||
|
|
||||||
if (hasSharing(workflows)) {
|
if (hasSharing(workflows)) {
|
||||||
|
// Since we're filtering using project ID as part of the relation,
|
||||||
|
// we end up filtering out all the other relations, meaning that if
|
||||||
|
// it's shared to a project, it won't be able to find the home project.
|
||||||
|
// To solve this, we have to get all the relation now, even though
|
||||||
|
// we're deleting them later.
|
||||||
|
if (typeof options?.filter?.projectId === 'string' && options.filter.projectId !== '') {
|
||||||
|
const relations = await this.sharedWorkflowRepository.getAllRelationsForWorkflows(
|
||||||
|
workflows.map((c) => c.id),
|
||||||
|
);
|
||||||
|
workflows.forEach((c) => {
|
||||||
|
c.shared = relations.filter((r) => r.workflowId === c.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w));
|
workflows = workflows.map((w) => this.ownershipService.addOwnedByAndSharedWith(w));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,8 +89,8 @@ export class WorkflowService {
|
||||||
}
|
}
|
||||||
|
|
||||||
workflows.forEach((w) => {
|
workflows.forEach((w) => {
|
||||||
// @ts-expect-error: This is to emulate the old behaviour of removing the shared
|
// This is to emulate the old behaviour of removing the shared field as
|
||||||
// field as part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
|
// part of `addOwnedByAndSharedWith`. We need this field in `addScopes`
|
||||||
// though. So to avoid leaking the information we just delete it.
|
// though. So to avoid leaking the information we just delete it.
|
||||||
delete w.shared;
|
delete w.shared;
|
||||||
});
|
});
|
||||||
|
|
|
@ -394,6 +394,57 @@ describe('PATCH /evaluation/test-definitions/:id', () => {
|
||||||
expect(resp.statusCode).toBe(400);
|
expect(resp.statusCode).toBe(400);
|
||||||
expect(resp.body.message).toBe('Annotation tag not found');
|
expect(resp.body.message).toBe('Annotation tag not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should update pinned nodes', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
mockedNodes: [
|
||||||
|
{
|
||||||
|
name: 'Schedule Trigger',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(200);
|
||||||
|
expect(resp.body.data.mockedNodes).toEqual([{ name: 'Schedule Trigger' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if pinned nodes are invalid', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
mockedNodes: ['Simple string'],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return error if pinned nodes are not in the workflow', async () => {
|
||||||
|
const newTest = Container.get(TestDefinitionRepository).create({
|
||||||
|
name: 'test',
|
||||||
|
workflow: { id: workflowUnderTest.id },
|
||||||
|
});
|
||||||
|
await Container.get(TestDefinitionRepository).save(newTest);
|
||||||
|
|
||||||
|
const resp = await authOwnerAgent.patch(`/evaluation/test-definitions/${newTest.id}`).send({
|
||||||
|
mockedNodes: [
|
||||||
|
{
|
||||||
|
name: 'Invalid Node',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resp.statusCode).toBe(400);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DELETE /evaluation/test-definitions/:id', () => {
|
describe('DELETE /evaluation/test-definitions/:id', () => {
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { TaskRunnersConfig } from '@n8n/config';
|
import { TaskRunnersConfig } from '@n8n/config';
|
||||||
|
import { mock } from 'jest-mock-extended';
|
||||||
import Container from 'typedi';
|
import Container from 'typedi';
|
||||||
|
|
||||||
import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error';
|
import { MissingAuthTokenError } from '@/runners/errors/missing-auth-token.error';
|
||||||
|
@ -32,7 +33,7 @@ describe('TaskRunnerModule in external mode', () => {
|
||||||
runnerConfig.enabled = true;
|
runnerConfig.enabled = true;
|
||||||
runnerConfig.authToken = '';
|
runnerConfig.authToken = '';
|
||||||
|
|
||||||
const module = new TaskRunnerModule(runnerConfig);
|
const module = new TaskRunnerModule(mock(), runnerConfig);
|
||||||
|
|
||||||
await expect(module.start()).rejects.toThrowError(MissingAuthTokenError);
|
await expect(module.start()).rejects.toThrowError(MissingAuthTokenError);
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,6 +3,7 @@ import Container from 'typedi';
|
||||||
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
|
import { TaskRunnerWsServer } from '@/runners/runner-ws-server';
|
||||||
import { TaskBroker } from '@/runners/task-broker.service';
|
import { TaskBroker } from '@/runners/task-broker.service';
|
||||||
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
import { TaskRunnerProcess } from '@/runners/task-runner-process';
|
||||||
|
import { TaskRunnerProcessRestartLoopDetector } from '@/runners/task-runner-process-restart-loop-detector';
|
||||||
import { retryUntil } from '@test-integration/retry-until';
|
import { retryUntil } from '@test-integration/retry-until';
|
||||||
import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server';
|
import { setupBrokerTestServer } from '@test-integration/utils/task-broker-test-server';
|
||||||
|
|
||||||
|
@ -84,4 +85,33 @@ describe('TaskRunnerProcess', () => {
|
||||||
expect(getNumRegisteredRunners()).toBe(1);
|
expect(getNumRegisteredRunners()).toBe(1);
|
||||||
expect(runnerProcess.pid).not.toBe(processId);
|
expect(runnerProcess.pid).not.toBe(processId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should work together with restart loop detector', async () => {
|
||||||
|
// Arrange
|
||||||
|
const restartLoopDetector = new TaskRunnerProcessRestartLoopDetector(runnerProcess);
|
||||||
|
let restartLoopDetectedEventEmitted = false;
|
||||||
|
restartLoopDetector.once('restart-loop-detected', () => {
|
||||||
|
restartLoopDetectedEventEmitted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await runnerProcess.start();
|
||||||
|
|
||||||
|
// Simulate a restart loop
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
await retryUntil(() => {
|
||||||
|
expect(runnerProcess.pid).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-expect-error private property
|
||||||
|
runnerProcess.process?.kill();
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
runnerProcess.once('exit', resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(restartLoopDetectedEventEmitted).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -17,10 +17,14 @@ import { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
|
||||||
|
|
||||||
import { mockInstance } from '../../shared/mocking';
|
import { mockInstance } from '../../shared/mocking';
|
||||||
import { saveCredential } from '../shared/db/credentials';
|
import { saveCredential } from '../shared/db/credentials';
|
||||||
import { createTeamProject, linkUserToProject } from '../shared/db/projects';
|
import { createTeamProject, getPersonalProject, linkUserToProject } from '../shared/db/projects';
|
||||||
import { createTag } from '../shared/db/tags';
|
import { createTag } from '../shared/db/tags';
|
||||||
import { createManyUsers, createMember, createOwner } from '../shared/db/users';
|
import { createManyUsers, createMember, createOwner } from '../shared/db/users';
|
||||||
import { createWorkflow, shareWorkflowWithProjects } from '../shared/db/workflows';
|
import {
|
||||||
|
createWorkflow,
|
||||||
|
shareWorkflowWithProjects,
|
||||||
|
shareWorkflowWithUsers,
|
||||||
|
} from '../shared/db/workflows';
|
||||||
import { randomCredentialPayload } from '../shared/random';
|
import { randomCredentialPayload } from '../shared/random';
|
||||||
import * as testDb from '../shared/test-db';
|
import * as testDb from '../shared/test-db';
|
||||||
import type { SuperAgentTest } from '../shared/types';
|
import type { SuperAgentTest } from '../shared/types';
|
||||||
|
@ -676,6 +680,21 @@ describe('GET /workflows', () => {
|
||||||
|
|
||||||
expect(response2.body.data).toHaveLength(0);
|
expect(response2.body.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return homeProject when filtering workflows by projectId', async () => {
|
||||||
|
const workflow = await createWorkflow({ name: 'First' }, owner);
|
||||||
|
await shareWorkflowWithUsers(workflow, [member]);
|
||||||
|
const pp = await getPersonalProject(member);
|
||||||
|
|
||||||
|
const response = await authMemberAgent
|
||||||
|
.get('/workflows')
|
||||||
|
.query(`filter={ "projectId": "${pp.id}" }`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body.data).toHaveLength(1);
|
||||||
|
expect(response.body.data[0].id).toBe(workflow.id);
|
||||||
|
expect(response.body.data[0].homeProject).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('select', () => {
|
describe('select', () => {
|
||||||
|
|
|
@ -13,13 +13,7 @@ import type {
|
||||||
OAuth2CredentialData,
|
OAuth2CredentialData,
|
||||||
} from '@n8n/client-oauth2';
|
} from '@n8n/client-oauth2';
|
||||||
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
import { ClientOAuth2 } from '@n8n/client-oauth2';
|
||||||
import type {
|
import type { AxiosError, AxiosHeaders, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
AxiosError,
|
|
||||||
AxiosHeaders,
|
|
||||||
AxiosPromise,
|
|
||||||
AxiosRequestConfig,
|
|
||||||
AxiosResponse,
|
|
||||||
} from 'axios';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import crypto, { createHmac } from 'crypto';
|
import crypto, { createHmac } from 'crypto';
|
||||||
import FileType from 'file-type';
|
import FileType from 'file-type';
|
||||||
|
@ -748,6 +742,26 @@ export async function binaryToString(body: Buffer | Readable, encoding?: string)
|
||||||
return iconv.decode(buffer, encoding ?? 'utf-8');
|
return iconv.decode(buffer, encoding ?? 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function invokeAxios(
|
||||||
|
axiosConfig: AxiosRequestConfig,
|
||||||
|
authOptions: IRequestOptions['auth'] = {},
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return await axios(axiosConfig);
|
||||||
|
} catch (error) {
|
||||||
|
if (authOptions.sendImmediately !== false || !(error instanceof axios.AxiosError)) throw error;
|
||||||
|
// for digest-auth
|
||||||
|
const { response } = error;
|
||||||
|
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { auth } = axiosConfig;
|
||||||
|
delete axiosConfig.auth;
|
||||||
|
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
|
||||||
|
return await axios(axiosConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function proxyRequestToAxios(
|
export async function proxyRequestToAxios(
|
||||||
workflow: Workflow | undefined,
|
workflow: Workflow | undefined,
|
||||||
additionalData: IWorkflowExecuteAdditionalData | undefined,
|
additionalData: IWorkflowExecuteAdditionalData | undefined,
|
||||||
|
@ -768,29 +782,8 @@ export async function proxyRequestToAxios(
|
||||||
|
|
||||||
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
|
axiosConfig = Object.assign(axiosConfig, await parseRequestObject(configObject));
|
||||||
|
|
||||||
let requestFn: () => AxiosPromise;
|
|
||||||
if (configObject.auth?.sendImmediately === false) {
|
|
||||||
// for digest-auth
|
|
||||||
requestFn = async () => {
|
|
||||||
try {
|
|
||||||
return await axios(axiosConfig);
|
|
||||||
} catch (error) {
|
|
||||||
const { response } = error;
|
|
||||||
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const { auth } = axiosConfig;
|
|
||||||
delete axiosConfig.auth;
|
|
||||||
axiosConfig = digestAuthAxiosConfig(axiosConfig, response, auth);
|
|
||||||
return await axios(axiosConfig);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
requestFn = async () => await axios(axiosConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestFn();
|
const response = await invokeAxios(axiosConfig, configObject.auth);
|
||||||
let body = response.data;
|
let body = response.data;
|
||||||
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
|
if (body instanceof IncomingMessage && axiosConfig.responseType === 'stream') {
|
||||||
parseIncomingMessage(body);
|
parseIncomingMessage(body);
|
||||||
|
@ -982,7 +975,7 @@ export async function httpRequest(
|
||||||
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
): Promise<IN8nHttpFullResponse | IN8nHttpResponse> {
|
||||||
removeEmptyBody(requestOptions);
|
removeEmptyBody(requestOptions);
|
||||||
|
|
||||||
let axiosRequest = convertN8nRequestToAxios(requestOptions);
|
const axiosRequest = convertN8nRequestToAxios(requestOptions);
|
||||||
if (
|
if (
|
||||||
axiosRequest.data === undefined ||
|
axiosRequest.data === undefined ||
|
||||||
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
|
(axiosRequest.method !== undefined && axiosRequest.method.toUpperCase() === 'GET')
|
||||||
|
@ -990,23 +983,7 @@ export async function httpRequest(
|
||||||
delete axiosRequest.data;
|
delete axiosRequest.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: AxiosResponse<any>;
|
const result = await invokeAxios(axiosRequest, requestOptions.auth);
|
||||||
try {
|
|
||||||
result = await axios(axiosRequest);
|
|
||||||
} catch (error) {
|
|
||||||
if (requestOptions.auth?.sendImmediately === false) {
|
|
||||||
const { response } = error;
|
|
||||||
if (response?.status !== 401 || !response.headers['www-authenticate']?.includes('nonce')) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { auth } = axiosRequest;
|
|
||||||
delete axiosRequest.auth;
|
|
||||||
axiosRequest = digestAuthAxiosConfig(axiosRequest, response, auth);
|
|
||||||
result = await axios(axiosRequest);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requestOptions.returnFullResponse) {
|
if (requestOptions.returnFullResponse) {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -42,6 +42,10 @@ export class DirectedGraph {
|
||||||
|
|
||||||
private connections: Map<DirectedGraphKey, GraphConnection> = new Map();
|
private connections: Map<DirectedGraphKey, GraphConnection> = new Map();
|
||||||
|
|
||||||
|
hasNode(nodeName: string) {
|
||||||
|
return this.nodes.has(nodeName);
|
||||||
|
}
|
||||||
|
|
||||||
getNodes() {
|
getNodes() {
|
||||||
return new Map(this.nodes.entries());
|
return new Map(this.nodes.entries());
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,8 +43,8 @@ describe('DirectedGraph', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ┌─────┐ ┌─────┐──► null
|
// ┌─────┐ ┌─────┐──► null
|
||||||
// │node1├───►│node2| ┌─────┐
|
// │node1├───►│node2│ ┌─────┐
|
||||||
// └─────┘ └─────┘──►│node3|
|
// └─────┘ └─────┘──►│node3│
|
||||||
// └─────┘
|
// └─────┘
|
||||||
//
|
//
|
||||||
test('linear workflow with null connections', () => {
|
test('linear workflow with null connections', () => {
|
||||||
|
@ -472,4 +472,24 @@ describe('DirectedGraph', () => {
|
||||||
expect(graph).toEqual(expectedGraph);
|
expect(graph).toEqual(expectedGraph);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hasNode', () => {
|
||||||
|
test("returns node if it's part of the graph", () => {
|
||||||
|
// ARRANGE
|
||||||
|
const node = createNodeData({ name: 'node' });
|
||||||
|
const graph = new DirectedGraph().addNodes(node);
|
||||||
|
|
||||||
|
// ACT & ASSERT
|
||||||
|
expect(graph.hasNode(node.name)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined if there is no node with that name in the graph', () => {
|
||||||
|
// ARRANGE
|
||||||
|
const node = createNodeData({ name: 'node' });
|
||||||
|
const graph = new DirectedGraph().addNodes(node);
|
||||||
|
|
||||||
|
// ACT & ASSERT
|
||||||
|
expect(graph.hasNode(node.name + 'foo')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -84,4 +84,31 @@ describe('cleanRunData', () => {
|
||||||
// TODO: Find out if this is a desirable result in milestone 2
|
// TODO: Find out if this is a desirable result in milestone 2
|
||||||
expect(newRunData).toEqual({});
|
expect(newRunData).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ┌─────┐ ┌─────┐
|
||||||
|
// │node1├───►│node2│
|
||||||
|
// └─────┘ └─────┘
|
||||||
|
test('removes run data of nodes that are not in the subgraph', () => {
|
||||||
|
// ARRANGE
|
||||||
|
const node1 = createNodeData({ name: 'Node1' });
|
||||||
|
const node2 = createNodeData({ name: 'Node2' });
|
||||||
|
const graph = new DirectedGraph()
|
||||||
|
.addNodes(node1, node2)
|
||||||
|
.addConnections({ from: node1, to: node2 });
|
||||||
|
// not part of the graph
|
||||||
|
const node3 = createNodeData({ name: 'Node3' });
|
||||||
|
const runData: IRunData = {
|
||||||
|
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
|
||||||
|
[node2.name]: [toITaskData([{ data: { value: 2 } }])],
|
||||||
|
[node3.name]: [toITaskData([{ data: { value: 3 } }])],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const newRunData = cleanRunData(runData, graph, new Set([node2]));
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(newRunData).toEqual({
|
||||||
|
[node1.name]: [toITaskData([{ data: { value: 1 } }])],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -442,4 +442,126 @@ describe('findStartNodes', () => {
|
||||||
expect(startNodes.size).toBe(1);
|
expect(startNodes.size).toBe(1);
|
||||||
expect(startNodes).toContainEqual(node2);
|
expect(startNodes).toContainEqual(node2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('custom loop logic', () => {
|
||||||
|
test('if the last run of loop node has no data (null) on the done output, then the loop is the start node', () => {
|
||||||
|
// ARRANGE
|
||||||
|
const trigger = createNodeData({ name: 'trigger' });
|
||||||
|
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
|
||||||
|
const inLoop = createNodeData({ name: 'inLoop' });
|
||||||
|
const afterLoop = createNodeData({ name: 'afterLoop' });
|
||||||
|
const graph = new DirectedGraph()
|
||||||
|
.addNodes(trigger, loop, inLoop, afterLoop)
|
||||||
|
.addConnections(
|
||||||
|
{ from: trigger, to: loop },
|
||||||
|
{ from: loop, outputIndex: 1, to: inLoop },
|
||||||
|
{ from: inLoop, to: loop },
|
||||||
|
{ from: loop, to: afterLoop },
|
||||||
|
);
|
||||||
|
const runData: IRunData = {
|
||||||
|
[trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])],
|
||||||
|
[loop.name]: [
|
||||||
|
// only output on the `loop` branch, but no output on the `done`
|
||||||
|
// branch
|
||||||
|
toITaskData([{ outputIndex: 1, data: { name: 'loop' } }]),
|
||||||
|
],
|
||||||
|
[inLoop.name]: [toITaskData([{ data: { name: 'inLoop' } }])],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const startNodes = findStartNodes({
|
||||||
|
graph,
|
||||||
|
trigger,
|
||||||
|
destination: afterLoop,
|
||||||
|
runData,
|
||||||
|
pinData: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(startNodes.size).toBe(1);
|
||||||
|
expect(startNodes).toContainEqual(loop);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the last run of loop node has no data (empty array) on the done output, then the loop is the start node', () => {
|
||||||
|
// ARRANGE
|
||||||
|
const trigger = createNodeData({ name: 'trigger' });
|
||||||
|
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
|
||||||
|
const inLoop = createNodeData({ name: 'inLoop' });
|
||||||
|
const afterLoop = createNodeData({ name: 'afterLoop' });
|
||||||
|
const graph = new DirectedGraph()
|
||||||
|
.addNodes(trigger, loop, inLoop, afterLoop)
|
||||||
|
.addConnections(
|
||||||
|
{ from: trigger, to: loop },
|
||||||
|
{ from: loop, outputIndex: 1, to: inLoop },
|
||||||
|
{ from: inLoop, to: loop },
|
||||||
|
{ from: loop, to: afterLoop },
|
||||||
|
);
|
||||||
|
const runData: IRunData = {
|
||||||
|
[trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])],
|
||||||
|
[loop.name]: [
|
||||||
|
// This is handcrafted because `toITaskData` does not allow inserting
|
||||||
|
// an empty array like the first element of `main` below. But the
|
||||||
|
// execution engine creates ITaskData like this.
|
||||||
|
{
|
||||||
|
executionStatus: 'success',
|
||||||
|
executionTime: 0,
|
||||||
|
startTime: 0,
|
||||||
|
source: [],
|
||||||
|
data: { main: [[], [{ json: { name: 'loop' } }]] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[inLoop.name]: [toITaskData([{ data: { name: 'inLoop' } }])],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const startNodes = findStartNodes({
|
||||||
|
graph,
|
||||||
|
trigger,
|
||||||
|
destination: afterLoop,
|
||||||
|
runData,
|
||||||
|
pinData: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(startNodes.size).toBe(1);
|
||||||
|
expect(startNodes).toContainEqual(loop);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if the loop has data on the done output in the last run it does not become a start node', () => {
|
||||||
|
// ARRANGE
|
||||||
|
const trigger = createNodeData({ name: 'trigger' });
|
||||||
|
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
|
||||||
|
const inLoop = createNodeData({ name: 'inLoop' });
|
||||||
|
const afterLoop = createNodeData({ name: 'afterLoop' });
|
||||||
|
const graph = new DirectedGraph()
|
||||||
|
.addNodes(trigger, loop, inLoop, afterLoop)
|
||||||
|
.addConnections(
|
||||||
|
{ from: trigger, to: loop },
|
||||||
|
{ from: loop, outputIndex: 1, to: inLoop },
|
||||||
|
{ from: inLoop, to: loop },
|
||||||
|
{ from: loop, to: afterLoop },
|
||||||
|
);
|
||||||
|
const runData: IRunData = {
|
||||||
|
[trigger.name]: [toITaskData([{ data: { name: 'trigger' } }])],
|
||||||
|
[loop.name]: [
|
||||||
|
toITaskData([{ outputIndex: 1, data: { name: 'loop' } }]),
|
||||||
|
toITaskData([{ outputIndex: 0, data: { name: 'done' } }]),
|
||||||
|
],
|
||||||
|
[inLoop.name]: [toITaskData([{ data: { name: 'inLoop' } }])],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
const startNodes = findStartNodes({
|
||||||
|
graph,
|
||||||
|
trigger,
|
||||||
|
destination: afterLoop,
|
||||||
|
runData,
|
||||||
|
pinData: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(startNodes.size).toBe(1);
|
||||||
|
expect(startNodes).toContainEqual(afterLoop);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,7 +5,7 @@ interface StubNode {
|
||||||
name: string;
|
name: string;
|
||||||
parameters?: INodeParameters;
|
parameters?: INodeParameters;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: string;
|
type?: 'n8n-nodes-base.manualTrigger' | 'n8n-nodes-base.splitInBatches' | (string & {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNodeData(stubData: StubNode): INode {
|
export function createNodeData(stubData: StubNode): INode {
|
||||||
|
|
|
@ -23,5 +23,13 @@ export function cleanRunData(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove run data for all nodes that are not part of the subgraph
|
||||||
|
for (const nodeName of Object.keys(newRunData)) {
|
||||||
|
if (!graph.hasNode(nodeName)) {
|
||||||
|
// remove run data for node that is not part of the graph
|
||||||
|
delete newRunData[nodeName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return newRunData;
|
return newRunData;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import type { INode, IPinData, IRunData } from 'n8n-workflow';
|
import { NodeConnectionType, type INode, type IPinData, type IRunData } from 'n8n-workflow';
|
||||||
|
|
||||||
import type { DirectedGraph } from './DirectedGraph';
|
import type { DirectedGraph } from './DirectedGraph';
|
||||||
import { getIncomingData } from './getIncomingData';
|
import { getIncomingData, getIncomingDataFromAnyRun } from './getIncomingData';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A node is dirty if either of the following is true:
|
* A node is dirty if either of the following is true:
|
||||||
|
@ -73,6 +73,25 @@ function findStartNodesRecursive(
|
||||||
return startNodes;
|
return startNodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the current node is a loop node, check if the `done` output has data on
|
||||||
|
// the last run. If it doesn't the loop wasn't fully executed and needs to be
|
||||||
|
// re-run from the start. Thus the loop node become the start node.
|
||||||
|
if (current.type === 'n8n-nodes-base.splitInBatches') {
|
||||||
|
const nodeRunData = getIncomingData(
|
||||||
|
runData,
|
||||||
|
current.name,
|
||||||
|
// last run
|
||||||
|
-1,
|
||||||
|
NodeConnectionType.Main,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nodeRunData === null || nodeRunData.length === 0) {
|
||||||
|
startNodes.add(current);
|
||||||
|
return startNodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If we detect a cycle stop following the branch, there is no start node on
|
// If we detect a cycle stop following the branch, there is no start node on
|
||||||
// this branch.
|
// this branch.
|
||||||
if (seen.has(current)) {
|
if (seen.has(current)) {
|
||||||
|
@ -82,19 +101,16 @@ function findStartNodesRecursive(
|
||||||
// Recurse with every direct child that is part of the sub graph.
|
// Recurse with every direct child that is part of the sub graph.
|
||||||
const outGoingConnections = graph.getDirectChildConnections(current);
|
const outGoingConnections = graph.getDirectChildConnections(current);
|
||||||
for (const outGoingConnection of outGoingConnections) {
|
for (const outGoingConnection of outGoingConnections) {
|
||||||
const nodeRunData = getIncomingData(
|
const nodeRunData = getIncomingDataFromAnyRun(
|
||||||
runData,
|
runData,
|
||||||
outGoingConnection.from.name,
|
outGoingConnection.from.name,
|
||||||
// NOTE: It's always 0 until I fix the bug that removes the run data for
|
|
||||||
// old runs. The FE only sends data for one run for each node.
|
|
||||||
0,
|
|
||||||
outGoingConnection.type,
|
outGoingConnection.type,
|
||||||
outGoingConnection.outputIndex,
|
outGoingConnection.outputIndex,
|
||||||
);
|
);
|
||||||
|
|
||||||
// If the node has multiple outputs, only follow the outputs that have run data.
|
// If the node has multiple outputs, only follow the outputs that have run data.
|
||||||
const hasNoRunData =
|
const hasNoRunData =
|
||||||
nodeRunData === null || nodeRunData === undefined || nodeRunData.length === 0;
|
nodeRunData === null || nodeRunData === undefined || nodeRunData.data.length === 0;
|
||||||
if (hasNoRunData) {
|
if (hasNoRunData) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import * as a from 'assert';
|
|
||||||
import type { INodeExecutionData, IRunData, NodeConnectionType } from 'n8n-workflow';
|
import type { INodeExecutionData, IRunData, NodeConnectionType } from 'n8n-workflow';
|
||||||
|
|
||||||
export function getIncomingData(
|
export function getIncomingData(
|
||||||
|
@ -7,18 +6,8 @@ export function getIncomingData(
|
||||||
runIndex: number,
|
runIndex: number,
|
||||||
connectionType: NodeConnectionType,
|
connectionType: NodeConnectionType,
|
||||||
outputIndex: number,
|
outputIndex: number,
|
||||||
): INodeExecutionData[] | null | undefined {
|
): INodeExecutionData[] | null {
|
||||||
a.ok(runData[nodeName], `Can't find node with name '${nodeName}' in runData.`);
|
return runData[nodeName]?.at(runIndex)?.data?.[connectionType].at(outputIndex) ?? null;
|
||||||
a.ok(
|
|
||||||
runData[nodeName][runIndex],
|
|
||||||
`Can't find a run for index '${runIndex}' for node name '${nodeName}'`,
|
|
||||||
);
|
|
||||||
a.ok(
|
|
||||||
runData[nodeName][runIndex].data,
|
|
||||||
`Can't find data for index '${runIndex}' for node name '${nodeName}'`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return runData[nodeName][runIndex].data[connectionType][outputIndex];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRunIndexLength(runData: IRunData, nodeName: string) {
|
function getRunIndexLength(runData: IRunData, nodeName: string) {
|
||||||
|
|
|
@ -354,24 +354,24 @@ export class WorkflowExecute {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Find the Subgraph
|
// 2. Find the Subgraph
|
||||||
const graph = DirectedGraph.fromWorkflow(workflow);
|
let graph = DirectedGraph.fromWorkflow(workflow);
|
||||||
const subgraph = findSubgraph({ graph: filterDisabledNodes(graph), destination, trigger });
|
graph = findSubgraph({ graph: filterDisabledNodes(graph), destination, trigger });
|
||||||
const filteredNodes = subgraph.getNodes();
|
const filteredNodes = graph.getNodes();
|
||||||
|
|
||||||
// 3. Find the Start Nodes
|
// 3. Find the Start Nodes
|
||||||
runData = omit(runData, dirtyNodeNames);
|
runData = omit(runData, dirtyNodeNames);
|
||||||
let startNodes = findStartNodes({ graph: subgraph, trigger, destination, runData, pinData });
|
let startNodes = findStartNodes({ graph, trigger, destination, runData, pinData });
|
||||||
|
|
||||||
// 4. Detect Cycles
|
// 4. Detect Cycles
|
||||||
// 5. Handle Cycles
|
// 5. Handle Cycles
|
||||||
startNodes = handleCycles(graph, startNodes, trigger);
|
startNodes = handleCycles(graph, startNodes, trigger);
|
||||||
|
|
||||||
// 6. Clean Run Data
|
// 6. Clean Run Data
|
||||||
const newRunData: IRunData = cleanRunData(runData, graph, startNodes);
|
runData = cleanRunData(runData, graph, startNodes);
|
||||||
|
|
||||||
// 7. Recreate Execution Stack
|
// 7. Recreate Execution Stack
|
||||||
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
|
const { nodeExecutionStack, waitingExecution, waitingExecutionSource } =
|
||||||
recreateNodeExecutionStack(subgraph, new Set(startNodes), runData, pinData ?? {});
|
recreateNodeExecutionStack(graph, new Set(startNodes), runData, pinData ?? {});
|
||||||
|
|
||||||
// 8. Execute
|
// 8. Execute
|
||||||
this.status = 'running';
|
this.status = 'running';
|
||||||
|
@ -381,7 +381,7 @@ export class WorkflowExecute {
|
||||||
runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name),
|
runNodeFilter: Array.from(filteredNodes.values()).map((node) => node.name),
|
||||||
},
|
},
|
||||||
resultData: {
|
resultData: {
|
||||||
runData: newRunData,
|
runData,
|
||||||
pinData,
|
pinData,
|
||||||
},
|
},
|
||||||
executionData: {
|
executionData: {
|
||||||
|
@ -393,7 +393,7 @@ export class WorkflowExecute {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.processRunExecutionData(subgraph.toWorkflow({ ...workflow }));
|
return this.processRunExecutionData(graph.toWorkflow({ ...workflow }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { captor, mock } from 'jest-mock-extended';
|
import { captor, mock, type MockProxy } from 'jest-mock-extended';
|
||||||
import type {
|
import type {
|
||||||
IRunExecutionData,
|
IRunExecutionData,
|
||||||
ContextType,
|
ContextType,
|
||||||
|
@ -9,11 +9,21 @@ import type {
|
||||||
ITaskMetadata,
|
ITaskMetadata,
|
||||||
ISourceData,
|
ISourceData,
|
||||||
IExecuteData,
|
IExecuteData,
|
||||||
|
IWorkflowExecuteAdditionalData,
|
||||||
|
ExecuteWorkflowData,
|
||||||
|
RelatedExecution,
|
||||||
|
IExecuteWorkflowInfo,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { ApplicationError, NodeHelpers } from 'n8n-workflow';
|
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||||
|
import Container from 'typedi';
|
||||||
|
|
||||||
|
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
||||||
|
|
||||||
import type { BaseExecuteContext } from '../base-execute-context';
|
import type { BaseExecuteContext } from '../base-execute-context';
|
||||||
|
|
||||||
|
const binaryDataService = mock<BinaryDataService>();
|
||||||
|
Container.set(BinaryDataService, binaryDataService);
|
||||||
|
|
||||||
export const describeCommonTests = (
|
export const describeCommonTests = (
|
||||||
context: BaseExecuteContext,
|
context: BaseExecuteContext,
|
||||||
{
|
{
|
||||||
|
@ -31,7 +41,7 @@ export const describeCommonTests = (
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
// @ts-expect-error `additionalData` is private
|
// @ts-expect-error `additionalData` is private
|
||||||
const { additionalData } = context;
|
const additionalData = context.additionalData as MockProxy<IWorkflowExecuteAdditionalData>;
|
||||||
|
|
||||||
describe('getExecutionCancelSignal', () => {
|
describe('getExecutionCancelSignal', () => {
|
||||||
it('should return the abort signal', () => {
|
it('should return the abort signal', () => {
|
||||||
|
@ -178,4 +188,55 @@ export const describeCommonTests = (
|
||||||
resolveSimpleParameterValueSpy.mockRestore();
|
resolveSimpleParameterValueSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('putExecutionToWait', () => {
|
||||||
|
it('should set waitTill and execution status', async () => {
|
||||||
|
const waitTill = new Date();
|
||||||
|
|
||||||
|
await context.putExecutionToWait(waitTill);
|
||||||
|
|
||||||
|
expect(runExecutionData.waitTill).toEqual(waitTill);
|
||||||
|
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('executeWorkflow', () => {
|
||||||
|
const data = [[{ json: { test: true } }]];
|
||||||
|
const executeWorkflowData = mock<ExecuteWorkflowData>();
|
||||||
|
const workflowInfo = mock<IExecuteWorkflowInfo>();
|
||||||
|
const parentExecution: RelatedExecution = {
|
||||||
|
executionId: 'parent_execution_id',
|
||||||
|
workflowId: 'parent_workflow_id',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should execute workflow and return data', async () => {
|
||||||
|
additionalData.executeWorkflow.mockResolvedValue(executeWorkflowData);
|
||||||
|
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
|
||||||
|
|
||||||
|
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
|
||||||
|
parentExecution,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.data).toEqual(data);
|
||||||
|
expect(binaryDataService.duplicateBinaryData).toHaveBeenCalledWith(
|
||||||
|
workflow.id,
|
||||||
|
additionalData.executionId,
|
||||||
|
executeWorkflowData.data,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should put execution to wait if waitTill is returned', async () => {
|
||||||
|
const waitTill = new Date();
|
||||||
|
additionalData.executeWorkflow.mockResolvedValue({ ...executeWorkflowData, waitTill });
|
||||||
|
binaryDataService.duplicateBinaryData.mockResolvedValue(data);
|
||||||
|
|
||||||
|
const result = await context.executeWorkflow(workflowInfo, undefined, undefined, {
|
||||||
|
parentExecution,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(additionalData.setExecutionStatus).toHaveBeenCalledWith('waiting');
|
||||||
|
expect(runExecutionData.waitTill).toEqual(WAIT_INDEFINITELY);
|
||||||
|
expect(result.waitTill).toBe(waitTill);
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -22,7 +22,7 @@ import type {
|
||||||
ISourceData,
|
ISourceData,
|
||||||
AiEvent,
|
AiEvent,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { ApplicationError, NodeHelpers, WorkflowDataProxy } from 'n8n-workflow';
|
import { ApplicationError, NodeHelpers, WAIT_INDEFINITELY, WorkflowDataProxy } from 'n8n-workflow';
|
||||||
import { Container } from 'typedi';
|
import { Container } from 'typedi';
|
||||||
|
|
||||||
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
import { BinaryDataService } from '@/BinaryData/BinaryData.service';
|
||||||
|
@ -97,6 +97,13 @@ export class BaseExecuteContext extends NodeExecutionContext {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async putExecutionToWait(waitTill: Date): Promise<void> {
|
||||||
|
this.runExecutionData.waitTill = waitTill;
|
||||||
|
if (this.additionalData.setExecutionStatus) {
|
||||||
|
this.additionalData.setExecutionStatus('waiting');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async executeWorkflow(
|
async executeWorkflow(
|
||||||
workflowInfo: IExecuteWorkflowInfo,
|
workflowInfo: IExecuteWorkflowInfo,
|
||||||
inputData?: INodeExecutionData[],
|
inputData?: INodeExecutionData[],
|
||||||
|
@ -106,23 +113,28 @@ export class BaseExecuteContext extends NodeExecutionContext {
|
||||||
parentExecution?: RelatedExecution;
|
parentExecution?: RelatedExecution;
|
||||||
},
|
},
|
||||||
): Promise<ExecuteWorkflowData> {
|
): Promise<ExecuteWorkflowData> {
|
||||||
return await this.additionalData
|
const result = await this.additionalData.executeWorkflow(workflowInfo, this.additionalData, {
|
||||||
.executeWorkflow(workflowInfo, this.additionalData, {
|
...options,
|
||||||
...options,
|
parentWorkflowId: this.workflow.id,
|
||||||
parentWorkflowId: this.workflow.id?.toString(),
|
inputData,
|
||||||
inputData,
|
parentWorkflowSettings: this.workflow.settings,
|
||||||
parentWorkflowSettings: this.workflow.settings,
|
node: this.node,
|
||||||
node: this.node,
|
parentCallbackManager,
|
||||||
parentCallbackManager,
|
});
|
||||||
})
|
|
||||||
.then(async (result) => {
|
// If a sub-workflow execution goes into the waiting state
|
||||||
const data = await this.binaryDataService.duplicateBinaryData(
|
if (result.waitTill) {
|
||||||
this.workflow.id,
|
// then put the parent workflow execution also into the waiting state,
|
||||||
this.additionalData.executionId!,
|
// but do not use the sub-workflow `waitTill` to avoid WaitTracker resuming the parent execution at the same time as the sub-workflow
|
||||||
result.data,
|
await this.putExecutionToWait(WAIT_INDEFINITELY);
|
||||||
);
|
}
|
||||||
return { ...result, data };
|
|
||||||
});
|
const data = await this.binaryDataService.duplicateBinaryData(
|
||||||
|
this.workflow.id,
|
||||||
|
this.additionalData.executionId!,
|
||||||
|
result.data,
|
||||||
|
);
|
||||||
|
return { ...result, data };
|
||||||
}
|
}
|
||||||
|
|
||||||
getNodeInputs(): INodeInputConfiguration[] {
|
getNodeInputs(): INodeInputConfiguration[] {
|
||||||
|
|
|
@ -179,13 +179,6 @@ export class ExecuteContext extends BaseExecuteContext implements IExecuteFuncti
|
||||||
return inputData[inputIndex];
|
return inputData[inputIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
async putExecutionToWait(waitTill: Date): Promise<void> {
|
|
||||||
this.runExecutionData.waitTill = waitTill;
|
|
||||||
if (this.additionalData.setExecutionStatus) {
|
|
||||||
this.additionalData.setExecutionStatus('waiting');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logNodeOutput(...args: unknown[]): void {
|
logNodeOutput(...args: unknown[]): void {
|
||||||
if (this.mode === 'manual') {
|
if (this.mode === 'manual') {
|
||||||
this.sendMessageToUI(...args);
|
this.sendMessageToUI(...args);
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import FormData from 'form-data';
|
||||||
import { mkdtempSync, readFileSync } from 'fs';
|
import { mkdtempSync, readFileSync } from 'fs';
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import type { Agent } from 'https';
|
import type { Agent } from 'https';
|
||||||
|
@ -26,6 +27,7 @@ import {
|
||||||
binaryToString,
|
binaryToString,
|
||||||
copyInputItems,
|
copyInputItems,
|
||||||
getBinaryDataBuffer,
|
getBinaryDataBuffer,
|
||||||
|
invokeAxios,
|
||||||
isFilePathBlocked,
|
isFilePathBlocked,
|
||||||
parseContentDisposition,
|
parseContentDisposition,
|
||||||
parseContentType,
|
parseContentType,
|
||||||
|
@ -543,6 +545,46 @@ describe('NodeExecuteFunctions', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parseRequestObject', () => {
|
describe('parseRequestObject', () => {
|
||||||
|
test('should handle basic request options', async () => {
|
||||||
|
const axiosOptions = await parseRequestObject({
|
||||||
|
url: 'https://example.com',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: { key: 'value' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axiosOptions).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
url: 'https://example.com',
|
||||||
|
method: 'POST',
|
||||||
|
headers: { accept: '*/*', 'content-type': 'application/json' },
|
||||||
|
data: { key: 'value' },
|
||||||
|
maxRedirects: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should set correct headers for FormData', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('key', 'value');
|
||||||
|
|
||||||
|
const axiosOptions = await parseRequestObject({
|
||||||
|
url: 'https://example.com',
|
||||||
|
formData,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axiosOptions.headers).toMatchObject({
|
||||||
|
accept: '*/*',
|
||||||
|
'content-length': 163,
|
||||||
|
'content-type': expect.stringMatching(/^multipart\/form-data; boundary=/),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(axiosOptions.data).toBeInstanceOf(FormData);
|
||||||
|
});
|
||||||
|
|
||||||
test('should not use Host header for SNI', async () => {
|
test('should not use Host header for SNI', async () => {
|
||||||
const axiosOptions = await parseRequestObject({
|
const axiosOptions = await parseRequestObject({
|
||||||
url: 'https://example.de/foo/bar',
|
url: 'https://example.de/foo/bar',
|
||||||
|
@ -628,6 +670,78 @@ describe('NodeExecuteFunctions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('invokeAxios', () => {
|
||||||
|
const baseUrl = 'http://example.de';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nock.cleanAll();
|
||||||
|
nock.disableNetConnect();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for non-401 status codes', async () => {
|
||||||
|
nock(baseUrl).get('/test').reply(500, {});
|
||||||
|
|
||||||
|
await expect(invokeAxios({ url: `${baseUrl}/test` })).rejects.toThrow(
|
||||||
|
'Request failed with status code 500',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error on 401 without digest auth challenge', async () => {
|
||||||
|
nock(baseUrl).get('/test').reply(401, {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
invokeAxios(
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/test`,
|
||||||
|
},
|
||||||
|
{ sendImmediately: false },
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Request failed with status code 401');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should make successful requests', async () => {
|
||||||
|
nock(baseUrl).get('/test').reply(200, { success: true });
|
||||||
|
|
||||||
|
const response = await invokeAxios({
|
||||||
|
url: `${baseUrl}/test`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.data).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle digest auth when receiving 401 with nonce', async () => {
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/test')
|
||||||
|
.matchHeader('authorization', 'Basic dXNlcjpwYXNz')
|
||||||
|
.once()
|
||||||
|
.reply(401, {}, { 'www-authenticate': 'Digest realm="test", nonce="abc123", qop="auth"' });
|
||||||
|
|
||||||
|
nock(baseUrl)
|
||||||
|
.get('/test')
|
||||||
|
.matchHeader(
|
||||||
|
'authorization',
|
||||||
|
/^Digest username="user",realm="test",nonce="abc123",uri="\/test",qop="auth",algorithm="MD5",response="[0-9a-f]{32}"/,
|
||||||
|
)
|
||||||
|
.reply(200, { success: true });
|
||||||
|
|
||||||
|
const response = await invokeAxios(
|
||||||
|
{
|
||||||
|
url: `${baseUrl}/test`,
|
||||||
|
auth: {
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ sendImmediately: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.data).toEqual({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('copyInputItems', () => {
|
describe('copyInputItems', () => {
|
||||||
it('should pick only selected properties', () => {
|
it('should pick only selected properties', () => {
|
||||||
const output = copyInputItems(
|
const output = copyInputItems(
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
// XX denotes that the node is disabled
|
// XX denotes that the node is disabled
|
||||||
// PD denotes that the node has pinned data
|
// PD denotes that the node has pinned data
|
||||||
|
|
||||||
|
import { pick } from 'lodash';
|
||||||
import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow';
|
import type { IPinData, IRun, IRunData, WorkflowTestData } from 'n8n-workflow';
|
||||||
import {
|
import {
|
||||||
ApplicationError,
|
ApplicationError,
|
||||||
|
@ -18,6 +19,7 @@ import {
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
|
|
||||||
import { DirectedGraph } from '@/PartialExecutionUtils';
|
import { DirectedGraph } from '@/PartialExecutionUtils';
|
||||||
|
import * as partialExecutionUtils from '@/PartialExecutionUtils';
|
||||||
import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers';
|
import { createNodeData, toITaskData } from '@/PartialExecutionUtils/__tests__/helpers';
|
||||||
import { WorkflowExecute } from '@/WorkflowExecute';
|
import { WorkflowExecute } from '@/WorkflowExecute';
|
||||||
|
|
||||||
|
@ -324,5 +326,122 @@ describe('WorkflowExecute', () => {
|
||||||
expect(nodes).toContain(node2.name);
|
expect(nodes).toContain(node2.name);
|
||||||
expect(nodes).not.toContain(node1.name);
|
expect(nodes).not.toContain(node1.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ►►
|
||||||
|
// ┌────┐0 ┌─────────┐
|
||||||
|
// ┌───────┐1 │ ├──────►afterLoop│
|
||||||
|
// │trigger├───┬──►loop│1 └─────────┘
|
||||||
|
// └───────┘ │ │ ├─┐
|
||||||
|
// │ └────┘ │
|
||||||
|
// │ │ ┌──────┐1
|
||||||
|
// │ └─►inLoop├─┐
|
||||||
|
// │ └──────┘ │
|
||||||
|
// └────────────────────┘
|
||||||
|
test('passes filtered run data to `recreateNodeExecutionStack`', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const waitPromise = createDeferredPromise<IRun>();
|
||||||
|
const nodeExecutionOrder: string[] = [];
|
||||||
|
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder);
|
||||||
|
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
|
||||||
|
|
||||||
|
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
|
||||||
|
const loop = createNodeData({ name: 'loop', type: 'n8n-nodes-base.splitInBatches' });
|
||||||
|
const inLoop = createNodeData({ name: 'inLoop' });
|
||||||
|
const afterLoop = createNodeData({ name: 'afterLoop' });
|
||||||
|
const workflow = new DirectedGraph()
|
||||||
|
.addNodes(trigger, loop, inLoop, afterLoop)
|
||||||
|
.addConnections(
|
||||||
|
{ from: trigger, to: loop },
|
||||||
|
{ from: loop, to: afterLoop },
|
||||||
|
{ from: loop, to: inLoop, outputIndex: 1 },
|
||||||
|
{ from: inLoop, to: loop },
|
||||||
|
)
|
||||||
|
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||||
|
|
||||||
|
const pinData: IPinData = {};
|
||||||
|
const runData: IRunData = {
|
||||||
|
[trigger.name]: [toITaskData([{ data: { value: 1 } }])],
|
||||||
|
[loop.name]: [toITaskData([{ data: { nodeName: loop.name }, outputIndex: 1 }])],
|
||||||
|
[inLoop.name]: [toITaskData([{ data: { nodeName: inLoop.name } }])],
|
||||||
|
};
|
||||||
|
const dirtyNodeNames: string[] = [];
|
||||||
|
|
||||||
|
jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn());
|
||||||
|
const recreateNodeExecutionStackSpy = jest.spyOn(
|
||||||
|
partialExecutionUtils,
|
||||||
|
'recreateNodeExecutionStack',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await workflowExecute.runPartialWorkflow2(
|
||||||
|
workflow,
|
||||||
|
runData,
|
||||||
|
pinData,
|
||||||
|
dirtyNodeNames,
|
||||||
|
afterLoop.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(recreateNodeExecutionStackSpy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
expect.any(DirectedGraph),
|
||||||
|
expect.any(Set),
|
||||||
|
// The run data should only contain the trigger node because the loop
|
||||||
|
// node has no data on the done branch. That means we have to rerun the
|
||||||
|
// whole loop, because we don't know how many iterations would be left.
|
||||||
|
pick(runData, trigger.name),
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ┌───────┐ ┌─────┐
|
||||||
|
// │trigger├┬──►│node1│
|
||||||
|
// └───────┘│ └─────┘
|
||||||
|
// │ ┌─────┐
|
||||||
|
// └──►│node2│
|
||||||
|
// └─────┘
|
||||||
|
test('passes subgraph to `cleanRunData`', async () => {
|
||||||
|
// ARRANGE
|
||||||
|
const waitPromise = createDeferredPromise<IRun>();
|
||||||
|
const nodeExecutionOrder: string[] = [];
|
||||||
|
const additionalData = Helpers.WorkflowExecuteAdditionalData(waitPromise, nodeExecutionOrder);
|
||||||
|
const workflowExecute = new WorkflowExecute(additionalData, 'manual');
|
||||||
|
|
||||||
|
const trigger = createNodeData({ name: 'trigger', type: 'n8n-nodes-base.manualTrigger' });
|
||||||
|
const node1 = createNodeData({ name: 'node1' });
|
||||||
|
const node2 = createNodeData({ name: 'node2' });
|
||||||
|
const workflow = new DirectedGraph()
|
||||||
|
.addNodes(trigger, node1, node2)
|
||||||
|
.addConnections({ from: trigger, to: node1 }, { from: trigger, to: node2 })
|
||||||
|
.toWorkflow({ name: '', active: false, nodeTypes });
|
||||||
|
|
||||||
|
const pinData: IPinData = {};
|
||||||
|
const runData: IRunData = {
|
||||||
|
[trigger.name]: [toITaskData([{ data: { value: 1 } }])],
|
||||||
|
[node1.name]: [toITaskData([{ data: { nodeName: node1.name } }])],
|
||||||
|
[node2.name]: [toITaskData([{ data: { nodeName: node2.name } }])],
|
||||||
|
};
|
||||||
|
const dirtyNodeNames: string[] = [];
|
||||||
|
|
||||||
|
jest.spyOn(workflowExecute, 'processRunExecutionData').mockImplementationOnce(jest.fn());
|
||||||
|
const cleanRunDataSpy = jest.spyOn(partialExecutionUtils, 'cleanRunData');
|
||||||
|
|
||||||
|
// ACT
|
||||||
|
await workflowExecute.runPartialWorkflow2(
|
||||||
|
workflow,
|
||||||
|
runData,
|
||||||
|
pinData,
|
||||||
|
dirtyNodeNames,
|
||||||
|
node1.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ASSERT
|
||||||
|
expect(cleanRunDataSpy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
runData,
|
||||||
|
new DirectedGraph().addNodes(trigger, node1).addConnections({ from: trigger, to: node1 }),
|
||||||
|
new Set([node1]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { ManualTrigger } from '../../../nodes-base/dist/nodes/ManualTrigger/Manu
|
||||||
import { Merge } from '../../../nodes-base/dist/nodes/Merge/Merge.node';
|
import { Merge } from '../../../nodes-base/dist/nodes/Merge/Merge.node';
|
||||||
import { NoOp } from '../../../nodes-base/dist/nodes/NoOp/NoOp.node';
|
import { NoOp } from '../../../nodes-base/dist/nodes/NoOp/NoOp.node';
|
||||||
import { Set } from '../../../nodes-base/dist/nodes/Set/Set.node';
|
import { Set } from '../../../nodes-base/dist/nodes/Set/Set.node';
|
||||||
|
import { SplitInBatches } from '../../../nodes-base/dist/nodes/SplitInBatches/SplitInBatches.node';
|
||||||
import { Start } from '../../../nodes-base/dist/nodes/Start/Start.node';
|
import { Start } from '../../../nodes-base/dist/nodes/Start/Start.node';
|
||||||
|
|
||||||
export const predefinedNodesTypes: INodeTypeData = {
|
export const predefinedNodesTypes: INodeTypeData = {
|
||||||
|
@ -38,6 +39,10 @@ export const predefinedNodesTypes: INodeTypeData = {
|
||||||
type: new ManualTrigger(),
|
type: new ManualTrigger(),
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
},
|
},
|
||||||
|
'n8n-nodes-base.splitInBatches': {
|
||||||
|
type: new SplitInBatches(),
|
||||||
|
sourcePath: '',
|
||||||
|
},
|
||||||
'n8n-nodes-base.versionTest': {
|
'n8n-nodes-base.versionTest': {
|
||||||
sourcePath: '',
|
sourcePath: '',
|
||||||
type: {
|
type: {
|
||||||
|
|
|
@ -33,7 +33,7 @@ describe('N8nNavigationDropdown', () => {
|
||||||
it('default slot should trigger first level', async () => {
|
it('default slot should trigger first level', async () => {
|
||||||
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
const { getByTestId, queryByTestId } = render(NavigationDropdown, {
|
||||||
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
||||||
props: { menu: [{ id: 'aaa', title: 'aaa', route: { name: 'projects' } }] },
|
props: { menu: [{ id: 'first', title: 'first', route: { name: 'projects' } }] },
|
||||||
global: {
|
global: {
|
||||||
plugins: [router],
|
plugins: [router],
|
||||||
},
|
},
|
||||||
|
@ -51,9 +51,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' } }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' } }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -80,9 +80,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -100,9 +100,9 @@ describe('N8nNavigationDropdown', () => {
|
||||||
props: {
|
props: {
|
||||||
menu: [
|
menu: [
|
||||||
{
|
{
|
||||||
id: 'aaa',
|
id: 'first',
|
||||||
title: 'aaa',
|
title: 'first',
|
||||||
submenu: [{ id: 'bbb', title: 'bbb', route: { name: 'projects' }, icon: 'user' }],
|
submenu: [{ id: 'nested', title: 'nested', route: { name: 'projects' }, icon: 'user' }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
@ -114,8 +114,53 @@ describe('N8nNavigationDropdown', () => {
|
||||||
await userEvent.click(getByTestId('navigation-submenu-item'));
|
await userEvent.click(getByTestId('navigation-submenu-item'));
|
||||||
|
|
||||||
expect(emitted('itemClick')).toStrictEqual([
|
expect(emitted('itemClick')).toStrictEqual([
|
||||||
[{ active: true, index: 'bbb', indexPath: ['-1', 'aaa', 'bbb'] }],
|
[{ active: true, index: 'nested', indexPath: ['-1', 'first', 'nested'] }],
|
||||||
]);
|
]);
|
||||||
expect(emitted('select')).toStrictEqual([['bbb']]);
|
expect(emitted('select')).toStrictEqual([['nested']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open first level on click', async () => {
|
||||||
|
const { getByTestId, getByText } = render(NavigationDropdown, {
|
||||||
|
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
||||||
|
props: {
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
id: 'first',
|
||||||
|
title: 'first',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getByText('first')).not.toBeVisible();
|
||||||
|
await userEvent.click(getByTestId('test-trigger'));
|
||||||
|
expect(getByText('first')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle nested level on mouseenter / mouseleave', async () => {
|
||||||
|
const { getByTestId, getByText } = render(NavigationDropdown, {
|
||||||
|
slots: { default: h('button', { 'data-test-id': 'test-trigger' }) },
|
||||||
|
props: {
|
||||||
|
menu: [
|
||||||
|
{
|
||||||
|
id: 'first',
|
||||||
|
title: 'first',
|
||||||
|
submenu: [{ id: 'nested', title: 'nested' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(getByText('first')).not.toBeVisible();
|
||||||
|
await userEvent.click(getByTestId('test-trigger'));
|
||||||
|
expect(getByText('first')).toBeVisible();
|
||||||
|
|
||||||
|
expect(getByText('nested')).not.toBeVisible();
|
||||||
|
await userEvent.hover(getByTestId('navigation-submenu'));
|
||||||
|
await waitFor(() => expect(getByText('nested')).toBeVisible());
|
||||||
|
|
||||||
|
await userEvent.pointer([
|
||||||
|
{ target: getByTestId('navigation-submenu') },
|
||||||
|
{ target: getByTestId('test-trigger') },
|
||||||
|
]);
|
||||||
|
await waitFor(() => expect(getByText('nested')).not.toBeVisible());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -29,7 +29,7 @@ defineProps<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const menuRef = ref<typeof ElMenu | null>(null);
|
const menuRef = ref<typeof ElMenu | null>(null);
|
||||||
const menuIndex = ref('-1');
|
const ROOT_MENU_INDEX = '-1';
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
itemClick: [item: MenuItemRegistered];
|
itemClick: [item: MenuItemRegistered];
|
||||||
|
@ -37,7 +37,18 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
menuRef.value?.close(menuIndex.value);
|
menuRef.value?.close(ROOT_MENU_INDEX);
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuTrigger = ref<'click' | 'hover'>('click');
|
||||||
|
const onOpen = (index: string) => {
|
||||||
|
if (index !== ROOT_MENU_INDEX) return;
|
||||||
|
menuTrigger.value = 'hover';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClose = (index: string) => {
|
||||||
|
if (index !== ROOT_MENU_INDEX) return;
|
||||||
|
menuTrigger.value = 'click';
|
||||||
};
|
};
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
|
@ -50,14 +61,16 @@ defineExpose({
|
||||||
ref="menuRef"
|
ref="menuRef"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
unique-opened
|
unique-opened
|
||||||
menu-trigger="click"
|
:menu-trigger="menuTrigger"
|
||||||
:ellipsis="false"
|
:ellipsis="false"
|
||||||
:class="$style.dropdown"
|
:class="$style.dropdown"
|
||||||
@select="emit('select', $event)"
|
@select="emit('select', $event)"
|
||||||
@keyup.escape="close"
|
@keyup.escape="close"
|
||||||
|
@open="onOpen"
|
||||||
|
@close="onClose"
|
||||||
>
|
>
|
||||||
<ElSubMenu
|
<ElSubMenu
|
||||||
:index="menuIndex"
|
:index="ROOT_MENU_INDEX"
|
||||||
:class="$style.trigger"
|
:class="$style.trigger"
|
||||||
:popper-offset="-10"
|
:popper-offset="-10"
|
||||||
:popper-class="$style.submenu"
|
:popper-class="$style.submenu"
|
||||||
|
@ -70,10 +83,15 @@ defineExpose({
|
||||||
|
|
||||||
<template v-for="item in menu" :key="item.id">
|
<template v-for="item in menu" :key="item.id">
|
||||||
<template v-if="item.submenu">
|
<template v-if="item.submenu">
|
||||||
<ElSubMenu :index="item.id" :popper-offset="-10" data-test-id="navigation-submenu">
|
<ElSubMenu
|
||||||
|
:popper-class="$style.nestedSubmenu"
|
||||||
|
:index="item.id"
|
||||||
|
:popper-offset="-10"
|
||||||
|
data-test-id="navigation-submenu"
|
||||||
|
>
|
||||||
<template #title>{{ item.title }}</template>
|
<template #title>{{ item.title }}</template>
|
||||||
<template v-for="subitem in item.submenu" :key="subitem.id">
|
<template v-for="subitem in item.submenu" :key="subitem.id">
|
||||||
<ConditionalRouterLink :to="!subitem.disabled && subitem.route">
|
<ConditionalRouterLink :to="(!subitem.disabled && subitem.route) || undefined">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
data-test-id="navigation-submenu-item"
|
data-test-id="navigation-submenu-item"
|
||||||
:index="subitem.id"
|
:index="subitem.id"
|
||||||
|
@ -82,18 +100,20 @@ defineExpose({
|
||||||
>
|
>
|
||||||
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
<N8nIcon v-if="subitem.icon" :icon="subitem.icon" :class="$style.submenu__icon" />
|
||||||
{{ subitem.title }}
|
{{ subitem.title }}
|
||||||
|
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ConditionalRouterLink>
|
</ConditionalRouterLink>
|
||||||
</template>
|
</template>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
</template>
|
</template>
|
||||||
<ConditionalRouterLink v-else :to="!item.disabled && item.route">
|
<ConditionalRouterLink v-else :to="(!item.disabled && item.route) || undefined">
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
:index="item.id"
|
:index="item.id"
|
||||||
:disabled="item.disabled"
|
:disabled="item.disabled"
|
||||||
data-test-id="navigation-menu-item"
|
data-test-id="navigation-menu-item"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
|
<slot :name="`item.append.${item.id}`" v-bind="{ item }" />
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</ConditionalRouterLink>
|
</ConditionalRouterLink>
|
||||||
</template>
|
</template>
|
||||||
|
@ -125,17 +145,25 @@ defineExpose({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nestedSubmenu {
|
||||||
|
:global(.el-menu) {
|
||||||
|
max-height: 450px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
padding: 5px 0 !important;
|
padding: 5px 0 !important;
|
||||||
|
|
||||||
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
:global(.el-menu--horizontal .el-menu .el-menu-item),
|
||||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
:global(.el-menu--horizontal .el-menu .el-sub-menu__title) {
|
||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
|
background-color: var(--color-menu-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover),
|
:global(.el-menu--horizontal .el-menu .el-menu-item:not(.is-disabled):hover),
|
||||||
:global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) {
|
:global(.el-menu--horizontal .el-menu .el-sub-menu__title:not(.is-disabled):hover) {
|
||||||
background-color: var(--color-foreground-base);
|
background-color: var(--color-menu-hover-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(.el-popper) {
|
:global(.el-popper) {
|
||||||
|
|
|
@ -462,6 +462,10 @@
|
||||||
--color-configurable-node-name: var(--color-text-dark);
|
--color-configurable-node-name: var(--color-text-dark);
|
||||||
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
--color-secondary-link: var(--prim-color-secondary-tint-200);
|
||||||
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);
|
--color-secondary-link-hover: var(--prim-color-secondary-tint-100);
|
||||||
|
|
||||||
|
--color-menu-background: var(--prim-gray-740);
|
||||||
|
--color-menu-hover-background: var(--prim-gray-670);
|
||||||
|
--color-menu-active-background: var(--prim-gray-670);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-theme='dark'] {
|
body[data-theme='dark'] {
|
||||||
|
|
|
@ -533,6 +533,11 @@
|
||||||
--color-secondary-link: var(--color-secondary);
|
--color-secondary-link: var(--color-secondary);
|
||||||
--color-secondary-link-hover: var(--color-secondary-shade-1);
|
--color-secondary-link-hover: var(--color-secondary-shade-1);
|
||||||
|
|
||||||
|
// Menu
|
||||||
|
--color-menu-background: var(--prim-gray-0);
|
||||||
|
--color-menu-hover-background: var(--prim-gray-120);
|
||||||
|
--color-menu-active-background: var(--prim-gray-120);
|
||||||
|
|
||||||
// Generated Color Shades from 50 to 950
|
// Generated Color Shades from 50 to 950
|
||||||
// Not yet used in design system
|
// Not yet used in design system
|
||||||
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
@each $color in ('neutral', 'success', 'warning', 'danger') {
|
||||||
|
|
|
@ -59,7 +59,7 @@ import type {
|
||||||
ROLE,
|
ROLE,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type { BulkCommand, Undoable } from '@/models/history';
|
import type { BulkCommand, Undoable } from '@/models/history';
|
||||||
import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers';
|
import type { PartialBy } from '@/utils/typeHelpers';
|
||||||
|
|
||||||
import type { ProjectSharingData } from '@/types/projects.types';
|
import type { ProjectSharingData } from '@/types/projects.types';
|
||||||
|
|
||||||
|
@ -249,6 +249,16 @@ export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow data with mandatory `templateId`
|
||||||
|
* This is used to identify sample workflows that we create for onboarding
|
||||||
|
*/
|
||||||
|
export interface WorkflowDataWithTemplateId extends Omit<IWorkflowDataCreate, 'meta'> {
|
||||||
|
meta: WorkflowMetadata & {
|
||||||
|
templateId: Required<WorkflowMetadata>['templateId'];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
export interface IWorkflowToShare extends IWorkflowDataUpdate {
|
||||||
meta: WorkflowMetadata;
|
meta: WorkflowMetadata;
|
||||||
}
|
}
|
||||||
|
@ -1361,51 +1371,6 @@ export type SamlPreferencesExtractedData = {
|
||||||
returnUrl: string;
|
returnUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SshKeyTypes = ['ed25519', 'rsa'];
|
|
||||||
|
|
||||||
export type SourceControlPreferences = {
|
|
||||||
connected: boolean;
|
|
||||||
repositoryUrl: string;
|
|
||||||
branchName: string;
|
|
||||||
branches: string[];
|
|
||||||
branchReadOnly: boolean;
|
|
||||||
branchColor: string;
|
|
||||||
publicKey?: string;
|
|
||||||
keyGeneratorType?: TupleToUnion<SshKeyTypes>;
|
|
||||||
currentBranch?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SourceControlStatus {
|
|
||||||
ahead: number;
|
|
||||||
behind: number;
|
|
||||||
conflicted: string[];
|
|
||||||
created: string[];
|
|
||||||
current: string;
|
|
||||||
deleted: string[];
|
|
||||||
detached: boolean;
|
|
||||||
files: Array<{
|
|
||||||
path: string;
|
|
||||||
index: string;
|
|
||||||
working_dir: string;
|
|
||||||
}>;
|
|
||||||
modified: string[];
|
|
||||||
not_added: string[];
|
|
||||||
renamed: string[];
|
|
||||||
staged: string[];
|
|
||||||
tracking: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SourceControlAggregatedFile {
|
|
||||||
conflict: boolean;
|
|
||||||
file: string;
|
|
||||||
id: string;
|
|
||||||
location: string;
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
type: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export declare namespace Cloud {
|
export declare namespace Cloud {
|
||||||
export interface PlanData {
|
export interface PlanData {
|
||||||
planId: number;
|
planId: number;
|
||||||
|
|
|
@ -127,4 +127,5 @@ export const defaultSettings: FrontendSettings = {
|
||||||
},
|
},
|
||||||
betaFeatures: [],
|
betaFeatures: [],
|
||||||
virtualSchemaView: false,
|
virtualSchemaView: false,
|
||||||
|
easyAIWorkflowOnboarded: false,
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,7 +2,7 @@ import type { Server, Request } from 'miragejs';
|
||||||
import { Response } from 'miragejs';
|
import { Response } from 'miragejs';
|
||||||
import { jsonParse } from 'n8n-workflow';
|
import { jsonParse } from 'n8n-workflow';
|
||||||
import type { AppSchema } from '@/__tests__/server/types';
|
import type { AppSchema } from '@/__tests__/server/types';
|
||||||
import type { SourceControlPreferences } from '@/Interface';
|
import type { SourceControlPreferences } from '@/types/sourceControl.types';
|
||||||
|
|
||||||
export function routesForSourceControl(server: Server) {
|
export function routesForSourceControl(server: Server) {
|
||||||
const sourceControlApiRoot = '/rest/source-control';
|
const sourceControlApiRoot = '/rest/source-control';
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import type { IDataObject } from 'n8n-workflow';
|
import type { IDataObject } from 'n8n-workflow';
|
||||||
|
import type { IRestApiContext } from '@/Interface';
|
||||||
import type {
|
import type {
|
||||||
IRestApiContext,
|
|
||||||
SourceControlAggregatedFile,
|
SourceControlAggregatedFile,
|
||||||
SourceControlPreferences,
|
SourceControlPreferences,
|
||||||
SourceControlStatus,
|
SourceControlStatus,
|
||||||
SshKeyTypes,
|
SshKeyTypes,
|
||||||
} from '@/Interface';
|
} from '@/types/sourceControl.types';
|
||||||
|
|
||||||
import { makeRestApiRequest } from '@/utils/apiUtils';
|
import { makeRestApiRequest } from '@/utils/apiUtils';
|
||||||
import type { TupleToUnion } from '@/utils/typeHelpers';
|
import type { TupleToUnion } from '@/utils/typeHelpers';
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { generateCodeForAiTransform } from './utils';
|
import { generateCodeForAiTransform, reducePayloadSizeOrThrow } from './utils';
|
||||||
import { createPinia, setActivePinia } from 'pinia';
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
import { generateCodeForPrompt } from '@/api/ai';
|
import { generateCodeForPrompt } from '@/api/ai';
|
||||||
|
import type { AskAiRequest } from '@/types/assistant.types';
|
||||||
|
import type { Schema } from '@/Interface';
|
||||||
|
|
||||||
vi.mock('./utils', async () => {
|
vi.mock('./utils', async () => {
|
||||||
const actual = await vi.importActual('./utils');
|
const actual = await vi.importActual('./utils');
|
||||||
|
@ -86,3 +88,69 @@ describe('generateCodeForAiTransform - Retry Tests', () => {
|
||||||
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
|
expect(generateCodeForPrompt).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockPayload = () =>
|
||||||
|
({
|
||||||
|
context: {
|
||||||
|
schema: [
|
||||||
|
{ nodeName: 'node1', data: 'some data' },
|
||||||
|
{ nodeName: 'node2', data: 'other data' },
|
||||||
|
],
|
||||||
|
inputSchema: {
|
||||||
|
schema: {
|
||||||
|
value: [
|
||||||
|
{ key: 'prop1', value: 'value1' },
|
||||||
|
{ key: 'prop2', value: 'value2' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
question: 'What is node1 and prop1?',
|
||||||
|
}) as unknown as AskAiRequest.RequestPayload;
|
||||||
|
|
||||||
|
describe('reducePayloadSizeOrThrow', () => {
|
||||||
|
it('reduces schema size when tokens exceed the limit', () => {
|
||||||
|
const payload = mockPayload();
|
||||||
|
const error = new Error('Limit is 100 tokens, but 104 were provided');
|
||||||
|
|
||||||
|
reducePayloadSizeOrThrow(payload, error);
|
||||||
|
|
||||||
|
expect(payload.context.schema.length).toBe(1);
|
||||||
|
expect(payload.context.schema[0]).toEqual({ nodeName: 'node1', data: 'some data' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes unreferenced properties in input schema', () => {
|
||||||
|
const payload = mockPayload();
|
||||||
|
const error = new Error('Limit is 100 tokens, but 150 were provided');
|
||||||
|
|
||||||
|
reducePayloadSizeOrThrow(payload, error);
|
||||||
|
|
||||||
|
expect(payload.context.inputSchema.schema.value.length).toBe(1);
|
||||||
|
expect((payload.context.inputSchema.schema.value as Schema[])[0].key).toBe('prop1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes all parent nodes if needed', () => {
|
||||||
|
const payload = mockPayload();
|
||||||
|
const error = new Error('Limit is 100 tokens, but 150 were provided');
|
||||||
|
|
||||||
|
payload.question = '';
|
||||||
|
|
||||||
|
reducePayloadSizeOrThrow(payload, error);
|
||||||
|
|
||||||
|
expect(payload.context.schema.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error if tokens still exceed after reductions', () => {
|
||||||
|
const payload = mockPayload();
|
||||||
|
const error = new Error('Limit is 100 tokens, but 200 were provided');
|
||||||
|
|
||||||
|
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error if message format is invalid', () => {
|
||||||
|
const payload = mockPayload();
|
||||||
|
const error = new Error('Invalid token message format');
|
||||||
|
|
||||||
|
expect(() => reducePayloadSizeOrThrow(payload, error)).toThrowError(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -57,6 +57,134 @@ export function getSchemas() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//------ Reduce payload ------
|
||||||
|
|
||||||
|
const estimateNumberOfTokens = (item: unknown, averageTokenLength: number): number => {
|
||||||
|
if (typeof item === 'object') {
|
||||||
|
return Math.ceil(JSON.stringify(item).length / averageTokenLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateRemainingTokens = (error: Error) => {
|
||||||
|
// Expected message format:
|
||||||
|
//'This model's maximum context length is 8192 tokens. However, your messages resulted in 10514 tokens.'
|
||||||
|
const tokens = error.message.match(/\d+/g);
|
||||||
|
|
||||||
|
if (!tokens || tokens.length < 2) throw error;
|
||||||
|
|
||||||
|
const maxTokens = parseInt(tokens[0], 10);
|
||||||
|
const currentTokens = parseInt(tokens[1], 10);
|
||||||
|
|
||||||
|
return currentTokens - maxTokens;
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimParentNodesSchema = (
|
||||||
|
payload: AskAiRequest.RequestPayload,
|
||||||
|
remainingTokensToReduce: number,
|
||||||
|
averageTokenLength: number,
|
||||||
|
) => {
|
||||||
|
//check if parent nodes schema takes more tokens than available
|
||||||
|
let parentNodesTokenCount = estimateNumberOfTokens(payload.context.schema, averageTokenLength);
|
||||||
|
|
||||||
|
if (remainingTokensToReduce > parentNodesTokenCount) {
|
||||||
|
remainingTokensToReduce -= parentNodesTokenCount;
|
||||||
|
payload.context.schema = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
//remove parent nodes not referenced in the prompt
|
||||||
|
if (payload.context.schema.length) {
|
||||||
|
const nodes = [...payload.context.schema];
|
||||||
|
|
||||||
|
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
|
||||||
|
if (payload.question.includes(nodes[nodeIndex].nodeName)) continue;
|
||||||
|
|
||||||
|
const nodeTokens = estimateNumberOfTokens(nodes[nodeIndex], averageTokenLength);
|
||||||
|
remainingTokensToReduce -= nodeTokens;
|
||||||
|
parentNodesTokenCount -= nodeTokens;
|
||||||
|
payload.context.schema.splice(nodeIndex, 1);
|
||||||
|
|
||||||
|
if (remainingTokensToReduce <= 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [remainingTokensToReduce, parentNodesTokenCount];
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimInputSchemaProperties = (
|
||||||
|
payload: AskAiRequest.RequestPayload,
|
||||||
|
remainingTokensToReduce: number,
|
||||||
|
averageTokenLength: number,
|
||||||
|
parentNodesTokenCount: number,
|
||||||
|
) => {
|
||||||
|
if (remainingTokensToReduce <= 0) return remainingTokensToReduce;
|
||||||
|
|
||||||
|
//remove properties not referenced in the prompt from the input schema
|
||||||
|
if (Array.isArray(payload.context.inputSchema.schema.value)) {
|
||||||
|
const props = [...payload.context.inputSchema.schema.value];
|
||||||
|
|
||||||
|
for (let index = 0; index < props.length; index++) {
|
||||||
|
const key = props[index].key;
|
||||||
|
|
||||||
|
if (key && payload.question.includes(key)) continue;
|
||||||
|
|
||||||
|
const propTokens = estimateNumberOfTokens(props[index], averageTokenLength);
|
||||||
|
remainingTokensToReduce -= propTokens;
|
||||||
|
payload.context.inputSchema.schema.value.splice(index, 1);
|
||||||
|
|
||||||
|
if (remainingTokensToReduce <= 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//if tokensToReduce is still remaining, remove all parent nodes
|
||||||
|
if (remainingTokensToReduce > 0) {
|
||||||
|
payload.context.schema = [];
|
||||||
|
remainingTokensToReduce -= parentNodesTokenCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return remainingTokensToReduce;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to reduce the size of the payload to fit within token limits or throws an error if unsuccessful,
|
||||||
|
* payload would be modified in place
|
||||||
|
*
|
||||||
|
* @param {AskAiRequest.RequestPayload} payload - The request payload to be trimmed,
|
||||||
|
* 'schema' and 'inputSchema.schema' will be modified.
|
||||||
|
* @param {Error} error - The error to throw if the token reduction fails.
|
||||||
|
* @param {number} [averageTokenLength=4] - The average token length used for estimation.
|
||||||
|
* @throws {Error} - Throws the provided error if the payload cannot be reduced sufficiently.
|
||||||
|
*/
|
||||||
|
export function reducePayloadSizeOrThrow(
|
||||||
|
payload: AskAiRequest.RequestPayload,
|
||||||
|
error: Error,
|
||||||
|
averageTokenLength = 4,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
let remainingTokensToReduce = calculateRemainingTokens(error);
|
||||||
|
|
||||||
|
const [remaining, parentNodesTokenCount] = trimParentNodesSchema(
|
||||||
|
payload,
|
||||||
|
remainingTokensToReduce,
|
||||||
|
averageTokenLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
remainingTokensToReduce = remaining;
|
||||||
|
|
||||||
|
remainingTokensToReduce = trimInputSchemaProperties(
|
||||||
|
payload,
|
||||||
|
remainingTokensToReduce,
|
||||||
|
averageTokenLength,
|
||||||
|
parentNodesTokenCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (remainingTokensToReduce > 0) throw error;
|
||||||
|
} catch (e) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
|
export async function generateCodeForAiTransform(prompt: string, path: string, retries = 1) {
|
||||||
const schemas = getSchemas();
|
const schemas = getSchemas();
|
||||||
|
|
||||||
|
@ -83,6 +211,11 @@ export async function generateCodeForAiTransform(prompt: string, path: string, r
|
||||||
code = generatedCode;
|
code = generatedCode;
|
||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.message.includes('maximum context length')) {
|
||||||
|
reducePayloadSizeOrThrow(payload, e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
retries--;
|
retries--;
|
||||||
if (!retries) throw e;
|
if (!retries) throw e;
|
||||||
}
|
}
|
||||||
|
|
|
@ -291,7 +291,12 @@ const checkWidthAndAdjustSidebar = async (width: number) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { menu, handleSelect: handleMenuSelect } = useGlobalEntityCreation();
|
const {
|
||||||
|
menu,
|
||||||
|
handleSelect: handleMenuSelect,
|
||||||
|
createProjectAppendSlotName,
|
||||||
|
projectsLimitReachedMessage,
|
||||||
|
} = useGlobalEntityCreation();
|
||||||
onClickOutside(createBtn as Ref<VueInstance>, () => {
|
onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
createBtn.value?.close();
|
createBtn.value?.close();
|
||||||
});
|
});
|
||||||
|
@ -311,8 +316,8 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
:class="['clickable', $style.sideMenuCollapseButton]"
|
:class="['clickable', $style.sideMenuCollapseButton]"
|
||||||
@click="toggleCollapse"
|
@click="toggleCollapse"
|
||||||
>
|
>
|
||||||
<n8n-icon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
|
<N8nIcon v-if="isCollapsed" icon="chevron-right" size="xsmall" class="ml-5xs" />
|
||||||
<n8n-icon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
|
<N8nIcon v-else icon="chevron-left" size="xsmall" class="mr-5xs" />
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.logo">
|
<div :class="$style.logo">
|
||||||
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
<img :src="logoPath" data-test-id="n8n-logo" :class="$style.icon" alt="n8n" />
|
||||||
|
@ -323,9 +328,21 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
@select="handleMenuSelect"
|
@select="handleMenuSelect"
|
||||||
>
|
>
|
||||||
<N8nIconButton icon="plus" type="secondary" outline />
|
<N8nIconButton icon="plus" type="secondary" outline />
|
||||||
|
<template #[createProjectAppendSlotName]="{ item }">
|
||||||
|
<N8nTooltip v-if="item.disabled" placement="right" :content="projectsLimitReachedMessage">
|
||||||
|
<N8nButton
|
||||||
|
:size="'mini'"
|
||||||
|
style="margin-left: auto"
|
||||||
|
type="tertiary"
|
||||||
|
@click="handleMenuSelect(item.id)"
|
||||||
|
>
|
||||||
|
{{ i18n.baseText('generic.upgrade') }}
|
||||||
|
</N8nButton>
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
</N8nNavigationDropdown>
|
</N8nNavigationDropdown>
|
||||||
</div>
|
</div>
|
||||||
<n8n-menu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
<N8nMenu :items="mainMenuItems" :collapsed="isCollapsed" @select="handleSelect">
|
||||||
<template #header>
|
<template #header>
|
||||||
<ProjectNavigation
|
<ProjectNavigation
|
||||||
:collapsed="isCollapsed"
|
:collapsed="isCollapsed"
|
||||||
|
@ -347,14 +364,14 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
<div :class="$style.giftContainer">
|
<div :class="$style.giftContainer">
|
||||||
<GiftNotificationIcon />
|
<GiftNotificationIcon />
|
||||||
</div>
|
</div>
|
||||||
<n8n-text
|
<N8nText
|
||||||
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
:class="{ ['ml-xs']: true, [$style.expanded]: fullyExpanded }"
|
||||||
color="text-base"
|
color="text-base"
|
||||||
>
|
>
|
||||||
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
{{ nextVersions.length > 99 ? '99+' : nextVersions.length }} update{{
|
||||||
nextVersions.length > 1 ? 's' : ''
|
nextVersions.length > 1 ? 's' : ''
|
||||||
}}
|
}}
|
||||||
</n8n-text>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
<MainSidebarSourceControl :is-collapsed="isCollapsed" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -363,35 +380,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
<div ref="user" :class="$style.userArea">
|
<div ref="user" :class="$style.userArea">
|
||||||
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
<div class="ml-3xs" data-test-id="main-sidebar-user-menu">
|
||||||
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
<!-- This dropdown is only enabled when sidebar is collapsed -->
|
||||||
<el-dropdown placement="right-end" trigger="click" @command="onUserActionToggle">
|
<ElDropdown placement="right-end" trigger="click" @command="onUserActionToggle">
|
||||||
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
<div :class="{ [$style.avatar]: true, ['clickable']: isCollapsed }">
|
||||||
<n8n-avatar
|
<N8nAvatar
|
||||||
:first-name="usersStore.currentUser?.firstName"
|
:first-name="usersStore.currentUser?.firstName"
|
||||||
:last-name="usersStore.currentUser?.lastName"
|
:last-name="usersStore.currentUser?.lastName"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="isCollapsed" #dropdown>
|
<template v-if="isCollapsed" #dropdown>
|
||||||
<el-dropdown-menu>
|
<ElDropdownMenu>
|
||||||
<el-dropdown-item command="settings">
|
<ElDropdownItem command="settings">
|
||||||
{{ i18n.baseText('settings') }}
|
{{ i18n.baseText('settings') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
<el-dropdown-item command="logout">
|
<ElDropdownItem command="logout">
|
||||||
{{ i18n.baseText('auth.signout') }}
|
{{ i18n.baseText('auth.signout') }}
|
||||||
</el-dropdown-item>
|
</ElDropdownItem>
|
||||||
</el-dropdown-menu>
|
</ElDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</ElDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
|
:class="{ ['ml-2xs']: true, [$style.userName]: true, [$style.expanded]: fullyExpanded }"
|
||||||
>
|
>
|
||||||
<n8n-text size="small" :bold="true" color="text-dark">{{
|
<N8nText size="small" :bold="true" color="text-dark">{{
|
||||||
usersStore.currentUser?.fullName
|
usersStore.currentUser?.fullName
|
||||||
}}</n8n-text>
|
}}</N8nText>
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
<div :class="{ [$style.userActions]: true, [$style.expanded]: fullyExpanded }">
|
||||||
<n8n-action-dropdown
|
<N8nActionDropdown
|
||||||
:items="userMenuItems"
|
:items="userMenuItems"
|
||||||
placement="top-start"
|
placement="top-start"
|
||||||
data-test-id="user-menu"
|
data-test-id="user-menu"
|
||||||
|
@ -400,7 +417,7 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n8n-menu>
|
</N8nMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { useLoadingService } from '@/composables/useLoadingService';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
import { SOURCE_CONTROL_PULL_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||||
import type { SourceControlAggregatedFile } from '../Interface';
|
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
|
||||||
import { sourceControlEventBus } from '@/event-bus/source-control';
|
import { sourceControlEventBus } from '@/event-bus/source-control';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
SIMULATE_NODE_TYPE,
|
SIMULATE_NODE_TYPE,
|
||||||
SIMULATE_TRIGGER_NODE_TYPE,
|
SIMULATE_TRIGGER_NODE_TYPE,
|
||||||
WAIT_NODE_TYPE,
|
WAIT_NODE_TYPE,
|
||||||
WAIT_TIME_UNLIMITED,
|
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
ExecutionSummary,
|
ExecutionSummary,
|
||||||
|
@ -18,7 +17,12 @@ import type {
|
||||||
NodeOperationError,
|
NodeOperationError,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
import {
|
||||||
|
NodeConnectionType,
|
||||||
|
NodeHelpers,
|
||||||
|
SEND_AND_WAIT_OPERATION,
|
||||||
|
WAIT_INDEFINITELY,
|
||||||
|
} from 'n8n-workflow';
|
||||||
import type { StyleValue } from 'vue';
|
import type { StyleValue } from 'vue';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import xss from 'xss';
|
import xss from 'xss';
|
||||||
|
@ -345,7 +349,7 @@ const waiting = computed(() => {
|
||||||
return i18n.baseText('node.theNodeIsWaitingFormCall');
|
return i18n.baseText('node.theNodeIsWaitingFormCall');
|
||||||
}
|
}
|
||||||
const waitDate = new Date(workflowExecution.waitTill);
|
const waitDate = new Date(workflowExecution.waitTill);
|
||||||
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
|
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
|
||||||
return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
|
return i18n.baseText('node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall');
|
||||||
}
|
}
|
||||||
return i18n.baseText('node.nodeIsWaitingTill', {
|
return i18n.baseText('node.nodeIsWaitingTill', {
|
||||||
|
|
|
@ -194,7 +194,8 @@ function onDrop(newParamValue: string) {
|
||||||
watch(
|
watch(
|
||||||
() => props.isReadOnly,
|
() => props.isReadOnly,
|
||||||
(isReadOnly) => {
|
(isReadOnly) => {
|
||||||
if (isReadOnly) {
|
// Patch fix, see https://linear.app/n8n/issue/ADO-2974/resource-mapper-values-are-emptied-when-refreshing-the-columns
|
||||||
|
if (isReadOnly && props.parameter.disabledOptions !== undefined) {
|
||||||
valueChanged({ name: props.path, value: props.parameter.default });
|
valueChanged({ name: props.path, value: props.parameter.default });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -79,7 +79,6 @@ import {
|
||||||
REPORTED_SOURCE_OTHER,
|
REPORTED_SOURCE_OTHER,
|
||||||
REPORTED_SOURCE_OTHER_KEY,
|
REPORTED_SOURCE_OTHER_KEY,
|
||||||
VIEWS,
|
VIEWS,
|
||||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT,
|
|
||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
@ -552,12 +551,9 @@ const onSave = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCallback = () => {
|
const closeCallback = () => {
|
||||||
const isPartOfOnboardingExperiment =
|
|
||||||
posthogStore.getVariant(MORE_ONBOARDING_OPTIONS_EXPERIMENT.name) ===
|
|
||||||
MORE_ONBOARDING_OPTIONS_EXPERIMENT.control;
|
|
||||||
// In case the redirect to homepage for new users didn't happen
|
// In case the redirect to homepage for new users didn't happen
|
||||||
// we try again after closing the modal
|
// we try again after closing the modal
|
||||||
if (route.name !== VIEWS.HOMEPAGE && !isPartOfOnboardingExperiment) {
|
if (route.name !== VIEWS.HOMEPAGE) {
|
||||||
void router.replace({ name: VIEWS.HOMEPAGE });
|
void router.replace({ name: VIEWS.HOMEPAGE });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,11 +3,13 @@ import { within } from '@testing-library/dom';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { mockedStore } from '@/__tests__/utils';
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
import { createTestProject } from '@/__tests__/data/projects';
|
import { createTestProject } from '@/__tests__/data/projects';
|
||||||
import { useRoute } from 'vue-router';
|
import * as router from 'vue-router';
|
||||||
|
import type { RouteLocationNormalizedLoadedGeneric } from 'vue-router';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import type { Project } from '@/types/projects.types';
|
import type { Project } from '@/types/projects.types';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
vi.mock('vue-router', async () => {
|
vi.mock('vue-router', async () => {
|
||||||
const actual = await vi.importActual('vue-router');
|
const actual = await vi.importActual('vue-router');
|
||||||
|
@ -35,13 +37,13 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let route: ReturnType<typeof useRoute>;
|
let route: ReturnType<typeof router.useRoute>;
|
||||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||||
|
|
||||||
describe('ProjectHeader', () => {
|
describe('ProjectHeader', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
createTestingPinia();
|
createTestingPinia();
|
||||||
route = useRoute();
|
route = router.useRoute();
|
||||||
projectsStore = mockedStore(useProjectsStore);
|
projectsStore = mockedStore(useProjectsStore);
|
||||||
|
|
||||||
projectsStore.teamProjectsLimit = -1;
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
@ -159,4 +161,21 @@ describe('ProjectHeader', () => {
|
||||||
|
|
||||||
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
|
expect(within(getByTestId('resource-add')).getByRole('button', { name: label })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not render creation button in setting page', async () => {
|
||||||
|
projectsStore.currentProject = createTestProject({ type: ProjectTypes.Personal });
|
||||||
|
vi.spyOn(router, 'useRoute').mockReturnValueOnce({
|
||||||
|
name: VIEWS.PROJECT_SETTINGS,
|
||||||
|
} as RouteLocationNormalizedLoadedGeneric);
|
||||||
|
const { queryByTestId } = renderComponent({
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
N8nNavigationDropdown: {
|
||||||
|
template: '<div><slot></slot></div>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queryByTestId('resource-add')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, type Ref, ref } from 'vue';
|
import { computed, type Ref, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { N8nNavigationDropdown } from 'n8n-design-system';
|
import { N8nNavigationDropdown, N8nButton, N8nIconButton, N8nTooltip } from 'n8n-design-system';
|
||||||
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
import { onClickOutside, type VueInstance } from '@vueuse/core';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { ProjectTypes } from '@/types/projects.types';
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
@ -9,6 +9,7 @@ import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
import { useGlobalEntityCreation } from '@/composables/useGlobalEntityCreation';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -47,9 +48,8 @@ const showSettings = computed(
|
||||||
projectsStore.currentProject?.type === ProjectTypes.Team,
|
projectsStore.currentProject?.type === ProjectTypes.Team,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { menu, handleSelect } = useGlobalEntityCreation(
|
const { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage } =
|
||||||
computed(() => !Boolean(projectsStore.currentProject)),
|
useGlobalEntityCreation(computed(() => !Boolean(projectsStore.currentProject)));
|
||||||
);
|
|
||||||
|
|
||||||
const createLabel = computed(() => {
|
const createLabel = computed(() => {
|
||||||
if (!projectsStore.currentProject) {
|
if (!projectsStore.currentProject) {
|
||||||
|
@ -82,17 +82,35 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
</slot>
|
</slot>
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="route.name !== VIEWS.PROJECT_SETTINGS" :class="[$style.headerActions]">
|
||||||
|
<N8nNavigationDropdown
|
||||||
|
ref="createBtn"
|
||||||
|
data-test-id="resource-add"
|
||||||
|
:menu="menu"
|
||||||
|
@select="handleSelect"
|
||||||
|
>
|
||||||
|
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
|
||||||
|
<template #[createProjectAppendSlotName]="{ item }">
|
||||||
|
<N8nTooltip
|
||||||
|
v-if="item.disabled"
|
||||||
|
placement="right"
|
||||||
|
:content="projectsLimitReachedMessage"
|
||||||
|
>
|
||||||
|
<N8nButton
|
||||||
|
:size="'mini'"
|
||||||
|
style="margin-left: auto"
|
||||||
|
type="tertiary"
|
||||||
|
@click="handleSelect(item.id)"
|
||||||
|
>
|
||||||
|
{{ i18n.baseText('generic.upgrade') }}
|
||||||
|
</N8nButton>
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
|
</N8nNavigationDropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.actions">
|
<div :class="$style.actions">
|
||||||
<ProjectTabs :show-settings="showSettings" />
|
<ProjectTabs :show-settings="showSettings" />
|
||||||
<N8nNavigationDropdown
|
|
||||||
ref="createBtn"
|
|
||||||
data-test-id="resource-add"
|
|
||||||
:menu="menu"
|
|
||||||
@select="handleSelect"
|
|
||||||
>
|
|
||||||
<N8nIconButton :label="createLabel" icon="plus" style="width: auto" />
|
|
||||||
</N8nNavigationDropdown>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -106,6 +124,10 @@ onClickOutside(createBtn as Ref<VueInstance>, () => {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerActions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
border: 1px solid var(--color-foreground-light);
|
border: 1px solid var(--color-foreground-light);
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
|
import { SOURCE_CONTROL_PULL_MODAL_KEY } from '@/constants';
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import type { SourceControlAggregatedFile } from '@/Interface';
|
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useLoadingService } from '@/composables/useLoadingService';
|
import { useLoadingService } from '@/composables/useLoadingService';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
|
@ -1,16 +1,20 @@
|
||||||
import { within } from '@testing-library/dom';
|
import { within, waitFor } from '@testing-library/dom';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
import { createEventBus } from 'n8n-design-system';
|
import { createEventBus } from 'n8n-design-system';
|
||||||
import type { SourceControlAggregatedFile } from '@/Interface';
|
import type { SourceControlAggregatedFile } from '@/types/sourceControl.types';
|
||||||
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
|
import { mockedStore } from '@/__tests__/utils';
|
||||||
|
import { VIEWS } from '@/constants';
|
||||||
|
|
||||||
const eventBus = createEventBus();
|
const eventBus = createEventBus();
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', () => ({
|
||||||
useRoute: vi.fn().mockReturnValue({
|
useRoute: vi.fn().mockReturnValue({
|
||||||
|
name: vi.fn(),
|
||||||
params: vi.fn(),
|
params: vi.fn(),
|
||||||
fullPath: vi.fn(),
|
fullPath: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
@ -20,9 +24,17 @@ vi.mock('vue-router', () => ({
|
||||||
|
|
||||||
let route: ReturnType<typeof useRoute>;
|
let route: ReturnType<typeof useRoute>;
|
||||||
|
|
||||||
|
const RecycleScroller = {
|
||||||
|
props: {
|
||||||
|
items: Array,
|
||||||
|
},
|
||||||
|
template: '<div><template v-for="item in items"><slot v-bind="{ item }"></slot></template></div>',
|
||||||
|
};
|
||||||
|
|
||||||
const renderModal = createComponentRenderer(SourceControlPushModal, {
|
const renderModal = createComponentRenderer(SourceControlPushModal, {
|
||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
|
RecycleScroller,
|
||||||
Modal: {
|
Modal: {
|
||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
|
@ -40,12 +52,13 @@ const renderModal = createComponentRenderer(SourceControlPushModal, {
|
||||||
describe('SourceControlPushModal', () => {
|
describe('SourceControlPushModal', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
route = useRoute();
|
route = useRoute();
|
||||||
|
createTestingPinia();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mounts', () => {
|
it('mounts', () => {
|
||||||
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
|
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('');
|
||||||
|
|
||||||
const { getByTitle } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
pinia: createTestingPinia(),
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
|
@ -54,7 +67,7 @@ describe('SourceControlPushModal', () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(getByTitle('Commit and push changes')).toBeInTheDocument();
|
expect(getByText('Commit and push changes')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should toggle checkboxes', async () => {
|
it('should toggle checkboxes', async () => {
|
||||||
|
@ -81,10 +94,7 @@ describe('SourceControlPushModal', () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
vi.spyOn(route, 'fullPath', 'get').mockReturnValue('/home/workflows');
|
|
||||||
|
|
||||||
const { getByTestId, getAllByTestId } = renderModal({
|
const { getByTestId, getAllByTestId } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
|
@ -148,4 +158,222 @@ describe('SourceControlPushModal', () => {
|
||||||
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
|
expect(within(files[0]).getByRole('checkbox')).not.toBeChecked();
|
||||||
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
|
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should push non workflow entities', async () => {
|
||||||
|
const status: SourceControlAggregatedFile[] = [
|
||||||
|
{
|
||||||
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
|
name: 'credential',
|
||||||
|
type: 'credential',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '',
|
||||||
|
updatedAt: '2024-09-20T10:31:40.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'JIGKevgZagmJAnM6',
|
||||||
|
name: 'variables',
|
||||||
|
type: 'variables',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '',
|
||||||
|
updatedAt: '2024-09-20T14:42:51.968Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mappings',
|
||||||
|
name: 'tags',
|
||||||
|
type: 'tags',
|
||||||
|
status: 'modified',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/Users/raul/.n8n/git/tags.json',
|
||||||
|
updatedAt: '2024-12-04T11:29:22.095Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const sourceControlStore = mockedStore(useSourceControlStore);
|
||||||
|
|
||||||
|
const { getByTestId, getByText } = renderModal({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = getByTestId('source-control-push-modal-submit');
|
||||||
|
const commitMessage = 'commit message';
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
expect(
|
||||||
|
getByText(
|
||||||
|
'No workflow changes to push. Only modified credentials, variables, and tags will be pushed.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.type(getByTestId('source-control-push-modal-commit'), commitMessage);
|
||||||
|
|
||||||
|
expect(submitButton).not.toBeDisabled();
|
||||||
|
await userEvent.click(submitButton);
|
||||||
|
|
||||||
|
expect(sourceControlStore.pushWorkfolder).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
commitMessage,
|
||||||
|
fileNames: expect.arrayContaining(status),
|
||||||
|
force: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should auto select currentWorkflow', async () => {
|
||||||
|
const status: SourceControlAggregatedFile[] = [
|
||||||
|
{
|
||||||
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
|
name: 'My workflow 1',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
|
||||||
|
updatedAt: '2024-09-20T10:31:40.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'JIGKevgZagmJAnM6',
|
||||||
|
name: 'My workflow 2',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
|
||||||
|
updatedAt: '2024-09-20T14:42:51.968Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.spyOn(route, 'name', 'get').mockReturnValue(VIEWS.WORKFLOW);
|
||||||
|
vi.spyOn(route, 'params', 'get').mockReturnValue({ name: 'gTbbBkkYTnNyX1jD' });
|
||||||
|
|
||||||
|
const { getByTestId, getAllByTestId } = renderModal({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButton = getByTestId('source-control-push-modal-submit');
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
|
||||||
|
const files = getAllByTestId('source-control-push-modal-file-checkbox');
|
||||||
|
expect(files).toHaveLength(2);
|
||||||
|
|
||||||
|
await waitFor(() => expect(within(files[0]).getByRole('checkbox')).toBeChecked());
|
||||||
|
expect(within(files[1]).getByRole('checkbox')).not.toBeChecked();
|
||||||
|
|
||||||
|
await userEvent.type(getByTestId('source-control-push-modal-commit'), 'message');
|
||||||
|
expect(submitButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('filters', () => {
|
||||||
|
it('should filter by name', async () => {
|
||||||
|
const status: SourceControlAggregatedFile[] = [
|
||||||
|
{
|
||||||
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
|
name: 'My workflow 1',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
|
||||||
|
updatedAt: '2024-09-20T10:31:40.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'JIGKevgZagmJAnM6',
|
||||||
|
name: 'My workflow 2',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
|
||||||
|
updatedAt: '2024-09-20T14:42:51.968Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId, getAllByTestId } = renderModal({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
|
||||||
|
|
||||||
|
await userEvent.type(getByTestId('source-control-push-search'), '1');
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by status', async () => {
|
||||||
|
const status: SourceControlAggregatedFile[] = [
|
||||||
|
{
|
||||||
|
id: 'gTbbBkkYTnNyX1jD',
|
||||||
|
name: 'Created Workflow',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'created',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/home/user/.n8n/git/workflows/gTbbBkkYTnNyX1jD.json',
|
||||||
|
updatedAt: '2024-09-20T10:31:40.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'JIGKevgZagmJAnM6',
|
||||||
|
name: 'Modified workflow',
|
||||||
|
type: 'workflow',
|
||||||
|
status: 'modified',
|
||||||
|
location: 'local',
|
||||||
|
conflict: false,
|
||||||
|
file: '/home/user/.n8n/git/workflows/JIGKevgZagmJAnM6.json',
|
||||||
|
updatedAt: '2024-09-20T14:42:51.968Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { getByTestId, getAllByTestId } = renderModal({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
eventBus,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(2);
|
||||||
|
|
||||||
|
await userEvent.click(getByTestId('source-control-filter-dropdown'));
|
||||||
|
|
||||||
|
expect(getByTestId('source-control-status-filter')).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(
|
||||||
|
within(getByTestId('source-control-status-filter')).getByRole('combobox'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(getAllByTestId('source-control-status-filter-option')[0]).toBeVisible(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const menu = getAllByTestId('source-control-status-filter-option')[0]
|
||||||
|
.parentElement as HTMLElement;
|
||||||
|
|
||||||
|
await userEvent.click(within(menu).getByText('New'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const items = getAllByTestId('source-control-push-modal-file-checkbox');
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0]).toHaveTextContent('Created Workflow');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Modal from './Modal.vue';
|
import Modal from './Modal.vue';
|
||||||
import { CREDENTIAL_EDIT_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import type { EventBus } from 'n8n-design-system/utils';
|
import type { EventBus } from 'n8n-design-system/utils';
|
||||||
import type { SourceControlAggregatedFile } from '@/Interface';
|
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useLoadingService } from '@/composables/useLoadingService';
|
import { useLoadingService } from '@/composables/useLoadingService';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
@ -11,13 +10,38 @@ import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import dateformat from 'dateformat';
|
import dateformat from 'dateformat';
|
||||||
|
import { RecycleScroller } from 'vue-virtual-scroller';
|
||||||
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
|
||||||
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
|
import { refDebounced } from '@vueuse/core';
|
||||||
|
import {
|
||||||
|
N8nHeading,
|
||||||
|
N8nText,
|
||||||
|
N8nLink,
|
||||||
|
N8nCheckbox,
|
||||||
|
N8nInput,
|
||||||
|
N8nIcon,
|
||||||
|
N8nButton,
|
||||||
|
N8nBadge,
|
||||||
|
N8nNotice,
|
||||||
|
N8nPopover,
|
||||||
|
N8nSelect,
|
||||||
|
N8nOption,
|
||||||
|
N8nInputLabel,
|
||||||
|
} from 'n8n-design-system';
|
||||||
|
import {
|
||||||
|
SOURCE_CONTROL_FILE_STATUS,
|
||||||
|
SOURCE_CONTROL_FILE_TYPE,
|
||||||
|
SOURCE_CONTROL_FILE_LOCATION,
|
||||||
|
type SourceControlledFileStatus,
|
||||||
|
type SourceControlAggregatedFile,
|
||||||
|
} from '@/types/sourceControl.types';
|
||||||
|
import { orderBy } from 'lodash-es';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
|
data: { eventBus: EventBus; status: SourceControlAggregatedFile[] };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const defaultStagedFileTypes = ['tags', 'variables', 'credential'];
|
|
||||||
|
|
||||||
const loadingService = useLoadingService();
|
const loadingService = useLoadingService();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
@ -25,165 +49,178 @@ const i18n = useI18n();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const staged = ref<Record<string, boolean>>({});
|
type Changes = {
|
||||||
const files = ref<SourceControlAggregatedFile[]>(
|
tags: SourceControlAggregatedFile[];
|
||||||
props.data.status.filter((file, index, self) => {
|
variables: SourceControlAggregatedFile[];
|
||||||
// do not show remote workflows that are not yet created locally during push
|
credentials: SourceControlAggregatedFile[];
|
||||||
if (file.location === 'remote' && file.type === 'workflow' && file.status === 'created') {
|
workflows: SourceControlAggregatedFile[];
|
||||||
return false;
|
currentWorkflow?: SourceControlAggregatedFile;
|
||||||
}
|
|
||||||
return self.findIndex((f) => f.id === file.id) === index;
|
|
||||||
}) || [],
|
|
||||||
);
|
|
||||||
|
|
||||||
const commitMessage = ref('');
|
|
||||||
const loading = ref(true);
|
|
||||||
const context = ref<'workflow' | 'workflows' | 'credentials' | ''>('');
|
|
||||||
|
|
||||||
const statusToBadgeThemeMap: Record<string, string> = {
|
|
||||||
created: 'success',
|
|
||||||
deleted: 'danger',
|
|
||||||
modified: 'warning',
|
|
||||||
renamed: 'warning',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSubmitDisabled = computed(() => {
|
const classifyFilesByType = (
|
||||||
return !commitMessage.value || Object.values(staged.value).every((value) => !value);
|
files: SourceControlAggregatedFile[],
|
||||||
});
|
currentWorkflowId?: string,
|
||||||
|
): Changes =>
|
||||||
const workflowId = computed(() => {
|
files.reduce<Changes>(
|
||||||
if (context.value === 'workflow') {
|
(acc, file) => {
|
||||||
return route.params.name as string;
|
// do not show remote workflows that are not yet created locally during push
|
||||||
}
|
if (
|
||||||
|
file.location === SOURCE_CONTROL_FILE_LOCATION.REMOTE &&
|
||||||
return '';
|
file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW &&
|
||||||
});
|
file.status === SOURCE_CONTROL_FILE_STATUS.CREATED
|
||||||
|
) {
|
||||||
const sortedFiles = computed(() => {
|
return acc;
|
||||||
const statusPriority: Record<string, number> = {
|
|
||||||
modified: 1,
|
|
||||||
renamed: 2,
|
|
||||||
created: 3,
|
|
||||||
deleted: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [...files.value].sort((a, b) => {
|
|
||||||
if (context.value === 'workflow') {
|
|
||||||
if (a.id === workflowId.value) {
|
|
||||||
return -1;
|
|
||||||
} else if (b.id === workflowId.value) {
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.VARIABLES) {
|
||||||
|
acc.variables.push(file);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.TAGS) {
|
||||||
|
acc.tags.push(file);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW && currentWorkflowId === file.id) {
|
||||||
|
acc.currentWorkflow = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW) {
|
||||||
|
acc.workflows.push(file);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL) {
|
||||||
|
acc.credentials.push(file);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ tags: [], variables: [], credentials: [], workflows: [], currentWorkflow: undefined },
|
||||||
|
);
|
||||||
|
|
||||||
|
const workflowId = computed(
|
||||||
|
() =>
|
||||||
|
([VIEWS.WORKFLOW].includes(route.name as VIEWS) && route.params.name?.toString()) || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const changes = computed(() => classifyFilesByType(props.data.status, workflowId.value));
|
||||||
|
|
||||||
|
const selectedChanges = ref<Set<string>>(new Set());
|
||||||
|
const toggleSelected = (id: string) => {
|
||||||
|
if (selectedChanges.value.has(id)) {
|
||||||
|
selectedChanges.value.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedChanges.value.add(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeSelectCurrentWorkflow = (workflow?: SourceControlAggregatedFile) =>
|
||||||
|
workflow && selectedChanges.value.add(workflow.id);
|
||||||
|
onMounted(() => maybeSelectCurrentWorkflow(changes.value.currentWorkflow));
|
||||||
|
|
||||||
|
const filters = ref<{ status?: SourceControlledFileStatus }>({
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
const statusFilterOptions: Array<{ label: string; value: SourceControlledFileStatus }> = [
|
||||||
|
{
|
||||||
|
label: 'New',
|
||||||
|
value: SOURCE_CONTROL_FILE_STATUS.CREATED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Modified',
|
||||||
|
value: SOURCE_CONTROL_FILE_STATUS.MODIFIED,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Deleted',
|
||||||
|
value: SOURCE_CONTROL_FILE_STATUS.DELETED,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const search = ref('');
|
||||||
|
const debouncedSearch = refDebounced(search, 250);
|
||||||
|
|
||||||
|
const filterCount = computed(() =>
|
||||||
|
Object.values(filters.value).reduce((acc, item) => (item ? acc + 1 : acc), 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredWorkflows = computed(() => {
|
||||||
|
const searchQuery = debouncedSearch.value.toLocaleLowerCase();
|
||||||
|
|
||||||
|
return changes.value.workflows.filter((workflow) => {
|
||||||
|
if (!workflow.name.toLocaleLowerCase().includes(searchQuery)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusPriority[a.status] < statusPriority[b.status]) {
|
if (filters.value.status && filters.value.status !== workflow.status) {
|
||||||
return -1;
|
return false;
|
||||||
} else if (statusPriority[a.status] > statusPriority[b.status]) {
|
|
||||||
return 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (a.updatedAt ?? 0) < (b.updatedAt ?? 0)
|
return true;
|
||||||
? 1
|
|
||||||
: (a.updatedAt ?? 0) > (b.updatedAt ?? 0)
|
|
||||||
? -1
|
|
||||||
: 0;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectAll = computed(() => {
|
const statusPriority: Partial<Record<SourceControlledFileStatus, number>> = {
|
||||||
return files.value.every((file) => staged.value[file.file]);
|
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 1,
|
||||||
});
|
[SOURCE_CONTROL_FILE_STATUS.RENAMED]: 2,
|
||||||
|
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 3,
|
||||||
|
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 4,
|
||||||
|
} as const;
|
||||||
|
const getPriorityByStatus = (status: SourceControlledFileStatus): number =>
|
||||||
|
statusPriority[status] ?? 0;
|
||||||
|
|
||||||
const workflowFiles = computed(() => {
|
const sortedWorkflows = computed(() => {
|
||||||
return files.value.filter((file) => file.type === 'workflow');
|
const sorted = orderBy(
|
||||||
});
|
filteredWorkflows.value,
|
||||||
|
[
|
||||||
const stagedWorkflowFiles = computed(() => {
|
// keep the current workflow at the top of the list
|
||||||
return workflowFiles.value.filter((workflow) => staged.value[workflow.file]);
|
({ id }) => id === changes.value.currentWorkflow?.id,
|
||||||
});
|
({ status }) => getPriorityByStatus(status),
|
||||||
|
'updatedAt',
|
||||||
const selectAllIndeterminate = computed(() => {
|
],
|
||||||
return (
|
['desc', 'asc', 'desc'],
|
||||||
stagedWorkflowFiles.value.length > 0 &&
|
|
||||||
stagedWorkflowFiles.value.length < workflowFiles.value.length
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return sorted;
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
const commitMessage = ref('');
|
||||||
context.value = getContext();
|
const isSubmitDisabled = computed(() => {
|
||||||
try {
|
if (!commitMessage.value.trim()) {
|
||||||
staged.value = getStagedFilesByContext(files.value);
|
return true;
|
||||||
} catch (error) {
|
|
||||||
toast.showError(error, i18n.baseText('error'));
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toBePushed =
|
||||||
|
changes.value.credentials.length +
|
||||||
|
changes.value.tags.length +
|
||||||
|
changes.value.variables.length +
|
||||||
|
selectedChanges.value.size;
|
||||||
|
if (toBePushed <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectAll = computed(
|
||||||
|
() =>
|
||||||
|
selectedChanges.value.size > 0 && selectedChanges.value.size === sortedWorkflows.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectAllIndeterminate = computed(
|
||||||
|
() => selectedChanges.value.size > 0 && selectedChanges.value.size < sortedWorkflows.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
function onToggleSelectAll() {
|
function onToggleSelectAll() {
|
||||||
if (selectAll.value) {
|
if (selectAll.value) {
|
||||||
files.value.forEach((file) => {
|
selectedChanges.value.clear();
|
||||||
if (!defaultStagedFileTypes.includes(file.type)) {
|
|
||||||
staged.value[file.file] = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
files.value.forEach((file) => {
|
selectedChanges.value = new Set(changes.value.workflows.map((file) => file.id));
|
||||||
if (!defaultStagedFileTypes.includes(file.type)) {
|
|
||||||
staged.value[file.file] = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContext() {
|
|
||||||
if (route.fullPath.startsWith('/workflows')) {
|
|
||||||
return 'workflows';
|
|
||||||
} else if (
|
|
||||||
route.fullPath.startsWith('/credentials') ||
|
|
||||||
uiStore.modalsById[CREDENTIAL_EDIT_MODAL_KEY].open
|
|
||||||
) {
|
|
||||||
return 'credentials';
|
|
||||||
} else if (route.fullPath.startsWith('/workflow/')) {
|
|
||||||
return 'workflow';
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStagedFilesByContext(
|
|
||||||
filesByContext: SourceControlAggregatedFile[],
|
|
||||||
): Record<string, boolean> {
|
|
||||||
const stagedFiles = filesByContext.reduce(
|
|
||||||
(acc, file) => {
|
|
||||||
acc[file.file] = false;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, boolean>,
|
|
||||||
);
|
|
||||||
|
|
||||||
filesByContext.forEach((file) => {
|
|
||||||
if (defaultStagedFileTypes.includes(file.type)) {
|
|
||||||
stagedFiles[file.file] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.value === 'workflow') {
|
|
||||||
if (file.type === 'workflow' && file.id === workflowId.value) {
|
|
||||||
stagedFiles[file.file] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return stagedFiles;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStagedStatus(file: SourceControlAggregatedFile, status: boolean) {
|
|
||||||
staged.value = {
|
|
||||||
...staged.value,
|
|
||||||
[file.file]: status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
|
uiStore.closeModal(SOURCE_CONTROL_PUSH_MODAL_KEY);
|
||||||
}
|
}
|
||||||
|
@ -209,7 +246,10 @@ async function onCommitKeyDownEnter() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commitAndPush() {
|
async function commitAndPush() {
|
||||||
const fileNames = files.value.filter((file) => staged.value[file.file]);
|
const files = changes.value.tags
|
||||||
|
.concat(changes.value.variables)
|
||||||
|
.concat(changes.value.credentials)
|
||||||
|
.concat(changes.value.workflows.filter((file) => selectedChanges.value.has(file.id)));
|
||||||
|
|
||||||
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
|
loadingService.startLoading(i18n.baseText('settings.sourceControl.loading.push'));
|
||||||
close();
|
close();
|
||||||
|
@ -218,7 +258,7 @@ async function commitAndPush() {
|
||||||
await sourceControlStore.pushWorkfolder({
|
await sourceControlStore.pushWorkfolder({
|
||||||
force: true,
|
force: true,
|
||||||
commitMessage: commitMessage.value,
|
commitMessage: commitMessage.value,
|
||||||
fileNames,
|
fileNames: files,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.showToast({
|
toast.showToast({
|
||||||
|
@ -233,159 +273,203 @@ async function commitAndPush() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusText(file: SourceControlAggregatedFile): string {
|
const getStatusText = (status: SourceControlledFileStatus) =>
|
||||||
if (file.status === 'deleted') {
|
i18n.baseText(`settings.sourceControl.status.${status}` as BaseTextKey);
|
||||||
return i18n.baseText('settings.sourceControl.status.deleted');
|
const getStatusTheme = (status: SourceControlledFileStatus) => {
|
||||||
}
|
const statusToBadgeThemeMap: Partial<
|
||||||
|
Record<SourceControlledFileStatus, 'success' | 'danger' | 'warning'>
|
||||||
if (file.status === 'created') {
|
> = {
|
||||||
return i18n.baseText('settings.sourceControl.status.created');
|
[SOURCE_CONTROL_FILE_STATUS.CREATED]: 'success',
|
||||||
}
|
[SOURCE_CONTROL_FILE_STATUS.DELETED]: 'danger',
|
||||||
|
[SOURCE_CONTROL_FILE_STATUS.MODIFIED]: 'warning',
|
||||||
if (file.status === 'modified') {
|
} as const;
|
||||||
return i18n.baseText('settings.sourceControl.status.modified');
|
return statusToBadgeThemeMap[status];
|
||||||
}
|
};
|
||||||
|
|
||||||
return i18n.baseText('settings.sourceControl.status.renamed');
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal
|
<Modal
|
||||||
width="812px"
|
width="812px"
|
||||||
:title="i18n.baseText('settings.sourceControl.modals.push.title')"
|
|
||||||
:event-bus="data.eventBus"
|
:event-bus="data.eventBus"
|
||||||
:name="SOURCE_CONTROL_PUSH_MODAL_KEY"
|
:name="SOURCE_CONTROL_PUSH_MODAL_KEY"
|
||||||
max-height="80%"
|
max-height="80%"
|
||||||
|
:custom-class="$style.sourceControlPush"
|
||||||
>
|
>
|
||||||
<template #content>
|
<template #header>
|
||||||
<div :class="$style.container">
|
<N8nHeading tag="h1" size="xlarge">
|
||||||
<div v-if="files.length > 0">
|
{{ i18n.baseText('settings.sourceControl.modals.push.title') }}
|
||||||
<div v-if="workflowFiles.length > 0">
|
</N8nHeading>
|
||||||
<n8n-text tag="div" class="mb-l">
|
<div class="mb-l mt-l">
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.description') }}
|
<N8nText tag="div">
|
||||||
<n8n-link :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
{{ i18n.baseText('settings.sourceControl.modals.push.description') }}
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
<N8nLink :to="i18n.baseText('settings.sourceControl.docs.using.pushPull.url')">
|
||||||
</n8n-link>
|
{{ i18n.baseText('settings.sourceControl.modals.push.description.learnMore') }}
|
||||||
</n8n-text>
|
</N8nLink>
|
||||||
|
</N8nText>
|
||||||
|
|
||||||
<n8n-checkbox
|
<N8nNotice v-if="!changes.workflows.length" class="mt-xs">
|
||||||
:class="$style.selectAll"
|
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
|
||||||
:indeterminate="selectAllIndeterminate"
|
<template #link>
|
||||||
:model-value="selectAll"
|
<N8nLink size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
|
||||||
data-test-id="source-control-push-modal-toggle-all"
|
{{ i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo') }}
|
||||||
@update:model-value="onToggleSelectAll"
|
</N8nLink>
|
||||||
>
|
</template>
|
||||||
<n8n-text bold tag="strong">
|
</i18n-t>
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
|
</N8nNotice>
|
||||||
</n8n-text>
|
|
||||||
<n8n-text v-show="workflowFiles.length > 0" tag="strong">
|
|
||||||
({{ stagedWorkflowFiles.length }}/{{ workflowFiles.length }})
|
|
||||||
</n8n-text>
|
|
||||||
</n8n-checkbox>
|
|
||||||
|
|
||||||
<n8n-checkbox
|
|
||||||
v-for="file in sortedFiles"
|
|
||||||
:key="file.file"
|
|
||||||
:class="[
|
|
||||||
'scopedListItem',
|
|
||||||
$style.listItem,
|
|
||||||
{ [$style.hiddenListItem]: defaultStagedFileTypes.includes(file.type) },
|
|
||||||
]"
|
|
||||||
data-test-id="source-control-push-modal-file-checkbox"
|
|
||||||
:model-value="staged[file.file]"
|
|
||||||
@update:model-value="setStagedStatus(file, !staged[file.file])"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<n8n-text v-if="file.status === 'deleted'" color="text-light">
|
|
||||||
<span v-if="file.type === 'workflow'"> Deleted Workflow: </span>
|
|
||||||
<span v-if="file.type === 'credential'"> Deleted Credential: </span>
|
|
||||||
<strong>{{ file.name || file.id }}</strong>
|
|
||||||
</n8n-text>
|
|
||||||
<n8n-text v-else bold> {{ file.name }} </n8n-text>
|
|
||||||
<n8n-text
|
|
||||||
v-if="file.updatedAt"
|
|
||||||
tag="p"
|
|
||||||
class="mt-0"
|
|
||||||
color="text-light"
|
|
||||||
size="small"
|
|
||||||
>
|
|
||||||
{{ renderUpdatedAt(file) }}
|
|
||||||
</n8n-text>
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<n8n-badge v-if="workflowId === file.id && file.type === 'workflow'" class="mr-2xs">
|
|
||||||
Current workflow
|
|
||||||
</n8n-badge>
|
|
||||||
<n8n-badge :theme="statusToBadgeThemeMap[file.status] || 'default'">
|
|
||||||
{{ getStatusText(file) }}
|
|
||||||
</n8n-badge>
|
|
||||||
</span>
|
|
||||||
</n8n-checkbox>
|
|
||||||
</div>
|
|
||||||
<n8n-notice v-else class="mt-0">
|
|
||||||
<i18n-t keypath="settings.sourceControl.modals.push.noWorkflowChanges">
|
|
||||||
<template #link>
|
|
||||||
<n8n-link size="small" :to="i18n.baseText('settings.sourceControl.docs.using.url')">
|
|
||||||
{{
|
|
||||||
i18n.baseText('settings.sourceControl.modals.push.noWorkflowChanges.moreInfo')
|
|
||||||
}}
|
|
||||||
</n8n-link>
|
|
||||||
</template>
|
|
||||||
</i18n-t>
|
|
||||||
</n8n-notice>
|
|
||||||
|
|
||||||
<n8n-text bold tag="p" class="mt-l mb-2xs">
|
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
|
|
||||||
</n8n-text>
|
|
||||||
<n8n-input
|
|
||||||
v-model="commitMessage"
|
|
||||||
type="text"
|
|
||||||
:placeholder="
|
|
||||||
i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')
|
|
||||||
"
|
|
||||||
@keydown.enter="onCommitKeyDownEnter"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!loading">
|
|
||||||
<n8n-notice class="mt-0 mb-0">
|
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.everythingIsUpToDate') }}
|
|
||||||
</n8n-notice>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div :class="[$style.filers]">
|
||||||
|
<N8nCheckbox
|
||||||
|
:class="$style.selectAll"
|
||||||
|
:indeterminate="selectAllIndeterminate"
|
||||||
|
:model-value="selectAll"
|
||||||
|
data-test-id="source-control-push-modal-toggle-all"
|
||||||
|
@update:model-value="onToggleSelectAll"
|
||||||
|
>
|
||||||
|
<N8nText bold tag="strong">
|
||||||
|
{{ i18n.baseText('settings.sourceControl.modals.push.workflowsToCommit') }}
|
||||||
|
</N8nText>
|
||||||
|
<N8nText tag="strong">
|
||||||
|
({{ selectedChanges.size }}/{{ sortedWorkflows.length }})
|
||||||
|
</N8nText>
|
||||||
|
</N8nCheckbox>
|
||||||
|
<N8nPopover trigger="click" width="304" style="align-self: normal">
|
||||||
|
<template #reference>
|
||||||
|
<N8nButton
|
||||||
|
icon="filter"
|
||||||
|
type="tertiary"
|
||||||
|
style="height: 100%"
|
||||||
|
:active="Boolean(filterCount)"
|
||||||
|
data-test-id="source-control-filter-dropdown"
|
||||||
|
>
|
||||||
|
<N8nBadge v-show="filterCount" theme="primary" class="mr-4xs">
|
||||||
|
{{ filterCount }}
|
||||||
|
</N8nBadge>
|
||||||
|
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
||||||
|
</N8nButton>
|
||||||
|
</template>
|
||||||
|
<N8nInputLabel
|
||||||
|
:label="i18n.baseText('workflows.filters.status')"
|
||||||
|
:bold="false"
|
||||||
|
size="small"
|
||||||
|
color="text-base"
|
||||||
|
class="mb-3xs"
|
||||||
|
/>
|
||||||
|
<N8nSelect v-model="filters.status" data-test-id="source-control-status-filter" clearable>
|
||||||
|
<N8nOption
|
||||||
|
v-for="option in statusFilterOptions"
|
||||||
|
:key="option.label"
|
||||||
|
data-test-id="source-control-status-filter-option"
|
||||||
|
v-bind="option"
|
||||||
|
>
|
||||||
|
</N8nOption>
|
||||||
|
</N8nSelect>
|
||||||
|
</N8nPopover>
|
||||||
|
<N8nInput
|
||||||
|
v-model="search"
|
||||||
|
data-test-id="source-control-push-search"
|
||||||
|
:placeholder="i18n.baseText('workflows.search.placeholder')"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<N8nIcon icon="search" />
|
||||||
|
</template>
|
||||||
|
</N8nInput>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<RecycleScroller
|
||||||
|
:class="[$style.scroller]"
|
||||||
|
:items="sortedWorkflows"
|
||||||
|
:item-size="69"
|
||||||
|
key-field="id"
|
||||||
|
>
|
||||||
|
<template #default="{ item: file }">
|
||||||
|
<N8nCheckbox
|
||||||
|
:class="['scopedListItem', $style.listItem]"
|
||||||
|
data-test-id="source-control-push-modal-file-checkbox"
|
||||||
|
:model-value="selectedChanges.has(file.id)"
|
||||||
|
@update:model-value="toggleSelected(file.id)"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<N8nText v-if="file.status === SOURCE_CONTROL_FILE_STATUS.DELETED" color="text-light">
|
||||||
|
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.WORKFLOW">
|
||||||
|
Deleted Workflow:
|
||||||
|
</span>
|
||||||
|
<span v-if="file.type === SOURCE_CONTROL_FILE_TYPE.CREDENTIAL">
|
||||||
|
Deleted Credential:
|
||||||
|
</span>
|
||||||
|
<strong>{{ file.name || file.id }}</strong>
|
||||||
|
</N8nText>
|
||||||
|
<N8nText v-else bold> {{ file.name }} </N8nText>
|
||||||
|
<N8nText v-if="file.updatedAt" tag="p" class="mt-0" color="text-light" size="small">
|
||||||
|
{{ renderUpdatedAt(file) }}
|
||||||
|
</N8nText>
|
||||||
|
</span>
|
||||||
|
<span :class="[$style.badges]">
|
||||||
|
<N8nBadge
|
||||||
|
v-if="changes.currentWorkflow && file.id === changes.currentWorkflow.id"
|
||||||
|
class="mr-2xs"
|
||||||
|
>
|
||||||
|
Current workflow
|
||||||
|
</N8nBadge>
|
||||||
|
<N8nBadge :theme="getStatusTheme(file.status)">
|
||||||
|
{{ getStatusText(file.status) }}
|
||||||
|
</N8nBadge>
|
||||||
|
</span>
|
||||||
|
</N8nCheckbox>
|
||||||
|
</template>
|
||||||
|
</RecycleScroller>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
<N8nText bold tag="p" class="mb-2xs">
|
||||||
|
{{ i18n.baseText('settings.sourceControl.modals.push.commitMessage') }}
|
||||||
|
</N8nText>
|
||||||
|
<N8nInput
|
||||||
|
v-model="commitMessage"
|
||||||
|
data-test-id="source-control-push-modal-commit"
|
||||||
|
:placeholder="i18n.baseText('settings.sourceControl.modals.push.commitMessage.placeholder')"
|
||||||
|
@keydown.enter="onCommitKeyDownEnter"
|
||||||
|
/>
|
||||||
|
|
||||||
<div :class="$style.footer">
|
<div :class="$style.footer">
|
||||||
<n8n-button type="tertiary" class="mr-2xs" @click="close">
|
<N8nButton type="tertiary" class="mr-2xs" @click="close">
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
|
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.cancel') }}
|
||||||
</n8n-button>
|
</N8nButton>
|
||||||
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush">
|
<N8nButton
|
||||||
|
data-test-id="source-control-push-modal-submit"
|
||||||
|
type="primary"
|
||||||
|
:disabled="isSubmitDisabled"
|
||||||
|
@click="commitAndPush"
|
||||||
|
>
|
||||||
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
|
{{ i18n.baseText('settings.sourceControl.modals.push.buttons.save') }}
|
||||||
</n8n-button>
|
</N8nButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module lang="scss">
|
<style module lang="scss">
|
||||||
.container > * {
|
.filers {
|
||||||
overflow-wrap: break-word;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButtons {
|
.selectAll {
|
||||||
display: flex;
|
flex-shrink: 0;
|
||||||
justify-content: flex-end;
|
margin-bottom: 0;
|
||||||
align-items: center;
|
}
|
||||||
|
|
||||||
|
.scroller {
|
||||||
|
height: 380px;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.listItem {
|
.listItem {
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: var(--spacing-2xs) 0 var(--spacing-2xs);
|
|
||||||
padding: var(--spacing-xs);
|
padding: var(--spacing-xs);
|
||||||
cursor: pointer;
|
|
||||||
transition: border 0.3s ease;
|
transition: border 0.3s ease;
|
||||||
border-radius: var(--border-radius-large);
|
border-radius: var(--border-radius-large);
|
||||||
border: var(--border-base);
|
border: var(--border-base);
|
||||||
|
@ -394,37 +478,32 @@ function getStatusText(file: SourceControlAggregatedFile): string {
|
||||||
border-color: var(--color-foreground-dark);
|
border-color: var(--color-foreground-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:first-child {
|
:global(.el-checkbox__label) {
|
||||||
margin-top: 0;
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
:global(.el-checkbox__inner) {
|
||||||
margin-bottom: 0;
|
transition: none;
|
||||||
}
|
|
||||||
|
|
||||||
&.hiddenListItem {
|
|
||||||
display: none !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.selectAll {
|
.badges {
|
||||||
float: left;
|
display: flex;
|
||||||
clear: both;
|
|
||||||
margin: 0 0 var(--spacing-2xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
.sourceControlPush {
|
||||||
.scopedListItem :deep(.el-checkbox__label) {
|
:global(.el-dialog__header) {
|
||||||
display: flex;
|
padding-bottom: var(--spacing-xs);
|
||||||
width: 100%;
|
}
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -20,7 +20,8 @@ import { useWorkflowResourceLocatorModes } from './useWorkflowResourceLocatorMod
|
||||||
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
|
import { useWorkflowResourcesLocator } from './useWorkflowResourcesLocator';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL, SAMPLE_SUBWORKFLOW_WORKFLOW_ID } from '@/constants';
|
import { NEW_SAMPLE_WORKFLOW_CREATED_CHANNEL } from '@/constants';
|
||||||
|
import { SAMPLE_SUBWORKFLOW_WORKFLOW } from '@/constants.workflows';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: INodeParameterResourceLocator;
|
modelValue: INodeParameterResourceLocator;
|
||||||
|
@ -231,7 +232,7 @@ const onAddResourceClicked = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
window.open(
|
window.open(
|
||||||
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW_ID}?${urlSearchParams.toString()}`,
|
`/workflows/onboarding/${SAMPLE_SUBWORKFLOW_WORKFLOW.meta.templateId}?${urlSearchParams.toString()}`,
|
||||||
'_blank',
|
'_blank',
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { computed, useSlots } from 'vue';
|
import { computed, useSlots } from 'vue';
|
||||||
import type { BannerName } from 'n8n-workflow';
|
import type { BannerName } from 'n8n-workflow';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: BannerName;
|
name: BannerName;
|
||||||
|
@ -10,6 +11,8 @@ interface Props {
|
||||||
dismissible?: boolean;
|
dismissible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
|
|
||||||
|
@ -51,7 +54,7 @@ async function onCloseClick() {
|
||||||
v-if="dismissible"
|
v-if="dismissible"
|
||||||
size="small"
|
size="small"
|
||||||
icon="times"
|
icon="times"
|
||||||
title="Dismiss"
|
:title="i18n.baseText('generic.dismiss')"
|
||||||
class="clickable"
|
class="clickable"
|
||||||
:data-test-id="`banner-${props.name}-close`"
|
:data-test-id="`banner-${props.name}-close`"
|
||||||
@click="onCloseClick"
|
@click="onCloseClick"
|
||||||
|
|
|
@ -200,13 +200,18 @@ describe('Canvas', () => {
|
||||||
describe('background', () => {
|
describe('background', () => {
|
||||||
it('should render default background', () => {
|
it('should render default background', () => {
|
||||||
const { container } = renderComponent();
|
const { container } = renderComponent();
|
||||||
expect(container.querySelector('#pattern-canvas')).toBeInTheDocument();
|
const patternCanvas = container.querySelector('#pattern-canvas');
|
||||||
|
expect(patternCanvas).toBeInTheDocument();
|
||||||
|
expect(patternCanvas?.innerHTML).toContain('<circle');
|
||||||
|
expect(patternCanvas?.innerHTML).not.toContain('<path');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render striped background', () => {
|
it('should render striped background', () => {
|
||||||
const { container } = renderComponent({ props: { readOnly: true } });
|
const { container } = renderComponent({ props: { readOnly: true } });
|
||||||
expect(container.querySelector('#pattern-canvas')).not.toBeInTheDocument();
|
const patternCanvas = container.querySelector('#pattern-canvas');
|
||||||
expect(container.querySelector('#diagonalHatch')).toBeInTheDocument();
|
expect(patternCanvas).toBeInTheDocument();
|
||||||
|
expect(patternCanvas?.innerHTML).toContain('<path');
|
||||||
|
expect(patternCanvas?.innerHTML).not.toContain('<circle');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -690,8 +690,13 @@ provide(CanvasKey, {
|
||||||
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
|
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
|
||||||
|
|
||||||
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
|
<Background data-test-id="canvas-background" pattern-color="#aaa" :gap="GRID_SIZE">
|
||||||
<template v-if="readOnly" #pattern-container>
|
<template v-if="readOnly" #pattern-container="patternProps">
|
||||||
<CanvasBackgroundStripedPattern :x="viewport.x" :y="viewport.y" :zoom="viewport.zoom" />
|
<CanvasBackgroundStripedPattern
|
||||||
|
:id="patternProps.id"
|
||||||
|
:x="viewport.x"
|
||||||
|
:y="viewport.y"
|
||||||
|
:zoom="viewport.zoom"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Background>
|
</Background>
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
id: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
zoom: number;
|
zoom: number;
|
||||||
|
@ -16,7 +17,7 @@ const patternOffset = computed(() => scaledGap.value / 2);
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<pattern
|
<pattern
|
||||||
id="diagonalHatch"
|
:id="id"
|
||||||
patternUnits="userSpaceOnUse"
|
patternUnits="userSpaceOnUse"
|
||||||
:x="x % scaledGap"
|
:x="x % scaledGap"
|
||||||
:y="y % scaledGap"
|
:y="y % scaledGap"
|
||||||
|
@ -26,7 +27,6 @@ const patternOffset = computed(() => scaledGap.value / 2);
|
||||||
>
|
>
|
||||||
<path :d="`M0 ${scaledGap / 2} H${scaledGap}`" :stroke-width="scaledGap / 2" />
|
<path :d="`M0 ${scaledGap / 2} H${scaledGap}`" :stroke-width="scaledGap / 2" />
|
||||||
</pattern>
|
</pattern>
|
||||||
<rect x="0" y="0" width="100%" height="100%" fill="url(#diagonalHatch)" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -46,13 +46,12 @@ describe('ConcurrentExecutionsHeader', () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('should show tooltip on hover and call "goToUpgrade" on click', async () => {
|
it('should show tooltip on hover with Upgrade link and emit "goToUpgrade" on click when on cloud', async () => {
|
||||||
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
const { container, getByText, getByRole, queryByRole, emitted } = renderComponent({
|
||||||
|
|
||||||
const { container, getByText, getByRole, queryByRole } = renderComponent({
|
|
||||||
props: {
|
props: {
|
||||||
runningExecutionsCount: 2,
|
runningExecutionsCount: 2,
|
||||||
concurrencyCap: 5,
|
concurrencyCap: 5,
|
||||||
|
isCloudDeployment: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -68,6 +67,25 @@ describe('ConcurrentExecutionsHeader', () => {
|
||||||
|
|
||||||
await userEvent.click(getByText('Upgrade now'));
|
await userEvent.click(getByText('Upgrade now'));
|
||||||
|
|
||||||
expect(windowOpenSpy).toHaveBeenCalled();
|
expect(emitted().goToUpgrade).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show tooltip on hover with Viev docs link when self-hosted', async () => {
|
||||||
|
const { container, getByText, getByRole, queryByRole } = renderComponent({
|
||||||
|
props: {
|
||||||
|
runningExecutionsCount: 2,
|
||||||
|
concurrencyCap: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tooltipTrigger = container.querySelector('svg') as SVGSVGElement;
|
||||||
|
|
||||||
|
expect(tooltipTrigger).toBeVisible();
|
||||||
|
expect(queryByRole('tooltip')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.hover(tooltipTrigger);
|
||||||
|
|
||||||
|
expect(getByRole('tooltip')).toBeVisible();
|
||||||
|
expect(getByText('View docs')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, defineProps } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
runningExecutionsCount: number;
|
runningExecutionsCount: number;
|
||||||
concurrencyCap: number;
|
concurrencyCap: number;
|
||||||
|
isCloudDeployment?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
goToUpgrade: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const pageRedirectionHelper = usePageRedirectionHelper();
|
|
||||||
|
|
||||||
const tooltipText = computed(() =>
|
const tooltipText = computed(() =>
|
||||||
i18n.baseText('executionsList.activeExecutions.tooltip', {
|
i18n.baseText('executionsList.activeExecutions.tooltip', {
|
||||||
|
@ -31,10 +34,6 @@ const headerText = computed(() => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const goToUpgrade = () => {
|
|
||||||
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -43,9 +42,22 @@ const goToUpgrade = () => {
|
||||||
<template #content>
|
<template #content>
|
||||||
<div :class="$style.tooltip">
|
<div :class="$style.tooltip">
|
||||||
{{ tooltipText }}
|
{{ tooltipText }}
|
||||||
<n8n-link bold size="small" :class="$style.upgrade" @click="goToUpgrade">
|
<N8nLink
|
||||||
|
v-if="props.isCloudDeployment"
|
||||||
|
bold
|
||||||
|
size="small"
|
||||||
|
:class="$style.link"
|
||||||
|
@click="emit('goToUpgrade')"
|
||||||
|
>
|
||||||
{{ i18n.baseText('generic.upgradeNow') }}
|
{{ i18n.baseText('generic.upgradeNow') }}
|
||||||
</n8n-link>
|
</N8nLink>
|
||||||
|
<N8nLink
|
||||||
|
v-else
|
||||||
|
:class="$style.link"
|
||||||
|
:href="i18n.baseText('executions.concurrency.docsLink')"
|
||||||
|
target="_blank"
|
||||||
|
>{{ i18n.baseText('generic.viewDocs') }}</N8nLink
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<font-awesome-icon icon="info-circle" class="mr-2xs" />
|
<font-awesome-icon icon="info-circle" class="mr-2xs" />
|
||||||
|
@ -54,12 +66,12 @@ const goToUpgrade = () => {
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style module scoped>
|
<style lang="scss" module>
|
||||||
.tooltip {
|
.tooltip {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.upgrade {
|
.link {
|
||||||
margin-top: var(--spacing-xs);
|
margin-top: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { getResourcePermissions } from '@/permissions';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
|
||||||
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
|
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -40,6 +41,7 @@ const telemetry = useTelemetry();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
|
||||||
const isMounted = ref(false);
|
const isMounted = ref(false);
|
||||||
const allVisibleSelected = ref(false);
|
const allVisibleSelected = ref(false);
|
||||||
|
@ -317,6 +319,10 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
executionsStore.stopAutoRefreshInterval();
|
executionsStore.stopAutoRefreshInterval();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToUpgrade = () => {
|
||||||
|
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -330,6 +336,8 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
class="mr-xl"
|
class="mr-xl"
|
||||||
:running-executions-count="runningExecutionsCount"
|
:running-executions-count="runningExecutionsCount"
|
||||||
:concurrency-cap="settingsStore.concurrency"
|
:concurrency-cap="settingsStore.concurrency"
|
||||||
|
:is-cloud-deployment="settingsStore.isCloudDeployment"
|
||||||
|
@go-to-upgrade="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
||||||
<ElCheckbox
|
<ElCheckbox
|
||||||
|
@ -400,12 +408,14 @@ async function onAutoRefreshToggle(value: boolean) {
|
||||||
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
|
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
|
||||||
:selected="selectedItems[execution.id] || allExistingSelected"
|
:selected="selectedItems[execution.id] || allExistingSelected"
|
||||||
:concurrency-cap="settingsStore.concurrency"
|
:concurrency-cap="settingsStore.concurrency"
|
||||||
|
:is-cloud-deployment="settingsStore.isCloudDeployment"
|
||||||
data-test-id="global-execution-list-item"
|
data-test-id="global-execution-list-item"
|
||||||
@stop="stopExecution"
|
@stop="stopExecution"
|
||||||
@delete="deleteExecution"
|
@delete="deleteExecution"
|
||||||
@select="toggleSelectExecution"
|
@select="toggleSelectExecution"
|
||||||
@retry-saved="retrySavedExecution"
|
@retry-saved="retrySavedExecution"
|
||||||
@retry-original="retryOriginalExecution"
|
@retry-original="retryOriginalExecution"
|
||||||
|
@go-to-upgrade="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
</TransitionGroup>
|
</TransitionGroup>
|
||||||
</table>
|
</table>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
import { fireEvent } from '@testing-library/vue';
|
import { fireEvent } from '@testing-library/vue';
|
||||||
import GlobalExecutionsListItem from './GlobalExecutionsListItem.vue';
|
import { WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||||
|
import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
|
||||||
import { createComponentRenderer } from '@/__tests__/render';
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
|
||||||
|
@ -15,9 +16,20 @@ vi.mock('vue-router', async () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const globalExecutionsListItemQueuedTooltipRenderSpy = vi.fn();
|
||||||
|
|
||||||
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
|
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
|
||||||
global: {
|
global: {
|
||||||
stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
|
//stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
|
||||||
|
stubs: {
|
||||||
|
'font-awesome-icon': true,
|
||||||
|
'n8n-tooltip': true,
|
||||||
|
'n8n-button': true,
|
||||||
|
'i18n-t': true,
|
||||||
|
GlobalExecutionsListItemQueuedTooltip: {
|
||||||
|
render: globalExecutionsListItemQueuedTooltipRenderSpy,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -98,6 +110,7 @@ describe('GlobalExecutionsListItem', () => {
|
||||||
|
|
||||||
await fireEvent.click(getByText('TestWorkflow'));
|
await fireEvent.click(getByText('TestWorkflow'));
|
||||||
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
|
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
|
||||||
|
expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show formatted start date', () => {
|
it('should show formatted start date', () => {
|
||||||
|
@ -113,4 +126,50 @@ describe('GlobalExecutionsListItem', () => {
|
||||||
getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`),
|
getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not render queued tooltip for a not indefinitely waiting execution', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
execution: {
|
||||||
|
status: 'waiting',
|
||||||
|
waitTill: new Date(Date.now() + 10000000).toISOString(),
|
||||||
|
id: 123,
|
||||||
|
workflowName: 'Test Workflow',
|
||||||
|
},
|
||||||
|
workflowPermissions: {},
|
||||||
|
concurrencyCap: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render queued tooltip for an indefinitely waiting execution', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
execution: {
|
||||||
|
status: 'waiting',
|
||||||
|
waitTill: WAIT_INDEFINITELY,
|
||||||
|
id: 123,
|
||||||
|
workflowName: 'Test Workflow',
|
||||||
|
},
|
||||||
|
workflowPermissions: {},
|
||||||
|
concurrencyCap: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render queued tooltip for a new execution', async () => {
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
execution: { status: 'new', id: 123, workflowName: 'Test Workflow' },
|
||||||
|
workflowPermissions: {},
|
||||||
|
concurrencyCap: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, useCssModule } from 'vue';
|
import { ref, computed, useCssModule } from 'vue';
|
||||||
import type { ExecutionSummary } from 'n8n-workflow';
|
import type { ExecutionSummary } from 'n8n-workflow';
|
||||||
|
import { WAIT_INDEFINITELY } from 'n8n-workflow';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { WAIT_TIME_UNLIMITED } from '@/constants';
|
|
||||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||||
import { i18n as locale } from '@/plugins/i18n';
|
import { i18n as locale } from '@/plugins/i18n';
|
||||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||||
import type { PermissionsRecord } from '@/permissions';
|
import type { PermissionsRecord } from '@/permissions';
|
||||||
|
import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
|
||||||
|
|
||||||
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
|
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
|
||||||
|
|
||||||
|
@ -17,6 +18,7 @@ const emit = defineEmits<{
|
||||||
retrySaved: [data: ExecutionSummary];
|
retrySaved: [data: ExecutionSummary];
|
||||||
retryOriginal: [data: ExecutionSummary];
|
retryOriginal: [data: ExecutionSummary];
|
||||||
delete: [data: ExecutionSummary];
|
delete: [data: ExecutionSummary];
|
||||||
|
goToUpgrade: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
|
@ -26,6 +28,7 @@ const props = withDefaults(
|
||||||
workflowName?: string;
|
workflowName?: string;
|
||||||
workflowPermissions: PermissionsRecord['workflow'];
|
workflowPermissions: PermissionsRecord['workflow'];
|
||||||
concurrencyCap: number;
|
concurrencyCap: number;
|
||||||
|
isCloudDeployment?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
selected: false,
|
selected: false,
|
||||||
|
@ -52,7 +55,7 @@ const isWaitTillIndefinite = computed(() => {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Date(props.execution.waitTill).toISOString() === WAIT_TIME_UNLIMITED;
|
return new Date(props.execution.waitTill).getTime() === WAIT_INDEFINITELY.getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
|
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
|
||||||
|
@ -84,19 +87,6 @@ const formattedStoppedAtDate = computed(() => {
|
||||||
: '';
|
: '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusTooltipText = computed(() => {
|
|
||||||
if (isQueued.value) {
|
|
||||||
return i18n.baseText('executionsList.statusTooltipText.waitingForConcurrencyCapacity', {
|
|
||||||
interpolate: { concurrencyCap: props.concurrencyCap },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) {
|
|
||||||
return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const statusText = computed(() => {
|
const statusText = computed(() => {
|
||||||
switch (props.execution.status) {
|
switch (props.execution.status) {
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
|
@ -208,12 +198,15 @@ async function handleActionItemClick(commandData: Command) {
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
<N8nTooltip v-else placement="top">
|
<GlobalExecutionsListItemQueuedTooltip
|
||||||
<template #content>
|
v-else
|
||||||
<span>{{ statusTooltipText }}</span>
|
:status="props.execution.status"
|
||||||
</template>
|
:concurrency-cap="props.concurrencyCap"
|
||||||
|
:is-cloud-deployment="props.isCloudDeployment"
|
||||||
|
@go-to-upgrade="emit('goToUpgrade')"
|
||||||
|
>
|
||||||
<span :class="$style.status">{{ statusText }}</span>
|
<span :class="$style.status">{{ statusText }}</span>
|
||||||
</N8nTooltip>
|
</GlobalExecutionsListItemQueuedTooltip>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(GlobalExecutionsListItemQueuedTooltip);
|
||||||
|
|
||||||
|
describe('GlobalExecutionsListItemQueuedTooltip', () => {
|
||||||
|
it('should not throw error when rendered', async () => {
|
||||||
|
expect(() =>
|
||||||
|
renderComponent({
|
||||||
|
props: {
|
||||||
|
status: 'waiting',
|
||||||
|
concurrencyCap: 0,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: 'Waiting',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show waiting indefinitely tooltip', async () => {
|
||||||
|
const { getByText } = renderComponent({
|
||||||
|
props: {
|
||||||
|
status: 'waiting',
|
||||||
|
concurrencyCap: 0,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: 'Waiting',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.hover(getByText('Waiting'));
|
||||||
|
|
||||||
|
expect(getByText(/waiting indefinitely/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show queued tooltip for self-hosted', async () => {
|
||||||
|
const { getByText } = renderComponent({
|
||||||
|
props: {
|
||||||
|
status: 'new',
|
||||||
|
concurrencyCap: 0,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: 'Queued',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.hover(getByText('Queued'));
|
||||||
|
|
||||||
|
expect(getByText(/instance is limited/)).toBeVisible();
|
||||||
|
expect(getByText('View docs')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show queued tooltip for cloud', async () => {
|
||||||
|
const { getByText, emitted } = renderComponent({
|
||||||
|
props: {
|
||||||
|
status: 'new',
|
||||||
|
concurrencyCap: 0,
|
||||||
|
isCloudDeployment: true,
|
||||||
|
},
|
||||||
|
slots: {
|
||||||
|
default: 'Queued',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.hover(getByText('Queued'));
|
||||||
|
|
||||||
|
expect(getByText(/plan is limited/)).toBeVisible();
|
||||||
|
expect(getByText('Upgrade now')).toBeVisible();
|
||||||
|
|
||||||
|
await userEvent.click(getByText('Upgrade now'));
|
||||||
|
|
||||||
|
expect(emitted().goToUpgrade).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,67 @@
|
||||||
|
<script lang="ts" setup="">
|
||||||
|
import type { ExecutionStatus } from 'n8n-workflow';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
status: ExecutionStatus;
|
||||||
|
concurrencyCap: number;
|
||||||
|
isCloudDeployment?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
goToUpgrade: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<N8nTooltip placement="top">
|
||||||
|
<template #content>
|
||||||
|
<i18n-t
|
||||||
|
v-if="props.status === 'waiting'"
|
||||||
|
keypath="executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely"
|
||||||
|
/>
|
||||||
|
<i18n-t
|
||||||
|
v-if="props.status === 'new'"
|
||||||
|
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity"
|
||||||
|
>
|
||||||
|
<template #instance>
|
||||||
|
<i18n-t
|
||||||
|
v-if="props.isCloudDeployment"
|
||||||
|
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity.cloud"
|
||||||
|
>
|
||||||
|
<template #concurrencyCap>{{ props.concurrencyCap }}</template>
|
||||||
|
<template #link>
|
||||||
|
<N8nLink bold size="small" :class="$style.link" @click="emit('goToUpgrade')">
|
||||||
|
{{ i18n.baseText('generic.upgradeNow') }}
|
||||||
|
</N8nLink>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
<i18n-t
|
||||||
|
v-else
|
||||||
|
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity.self"
|
||||||
|
>
|
||||||
|
<template #concurrencyCap>{{ props.concurrencyCap }}</template>
|
||||||
|
<template #link>
|
||||||
|
<N8nLink
|
||||||
|
:class="$style.link"
|
||||||
|
:href="i18n.baseText('executions.concurrency.docsLink')"
|
||||||
|
target="_blank"
|
||||||
|
>{{ i18n.baseText('generic.viewDocs') }}</N8nLink
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</template>
|
||||||
|
</i18n-t>
|
||||||
|
</template>
|
||||||
|
<slot />
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -15,6 +15,7 @@ import { getResourcePermissions } from '@/permissions';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
|
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
|
||||||
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
|
||||||
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
|
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
|
||||||
|
|
||||||
|
@ -39,6 +40,7 @@ const i18n = useI18n();
|
||||||
|
|
||||||
const executionsStore = useExecutionsStore();
|
const executionsStore = useExecutionsStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
|
const pageRedirectionHelper = usePageRedirectionHelper();
|
||||||
|
|
||||||
const mountedItems = ref<string[]>([]);
|
const mountedItems = ref<string[]>([]);
|
||||||
const autoScrollDeps = ref<AutoScrollDeps>({
|
const autoScrollDeps = ref<AutoScrollDeps>({
|
||||||
|
@ -169,6 +171,10 @@ function scrollToActiveCard(): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const goToUpgrade = () => {
|
||||||
|
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -186,6 +192,8 @@ function scrollToActiveCard(): void {
|
||||||
v-if="settingsStore.isConcurrencyEnabled"
|
v-if="settingsStore.isConcurrencyEnabled"
|
||||||
:running-executions-count="runningExecutionsCount"
|
:running-executions-count="runningExecutionsCount"
|
||||||
:concurrency-cap="settingsStore.concurrency"
|
:concurrency-cap="settingsStore.concurrency"
|
||||||
|
:is-cloud-deployment="settingsStore.isCloudDeployment"
|
||||||
|
@go-to-upgrade="goToUpgrade"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.controls">
|
<div :class="$style.controls">
|
||||||
|
|
|
@ -37,15 +37,14 @@ import type {
|
||||||
ITaskData,
|
ITaskData,
|
||||||
Workflow,
|
Workflow,
|
||||||
} from 'n8n-workflow';
|
} from 'n8n-workflow';
|
||||||
import { NodeConnectionType, NodeHelpers, SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
|
||||||
import type { INodeUi } from '@/Interface';
|
|
||||||
import {
|
import {
|
||||||
CUSTOM_API_CALL_KEY,
|
NodeConnectionType,
|
||||||
FORM_NODE_TYPE,
|
NodeHelpers,
|
||||||
STICKY_NODE_TYPE,
|
SEND_AND_WAIT_OPERATION,
|
||||||
WAIT_NODE_TYPE,
|
WAIT_INDEFINITELY,
|
||||||
WAIT_TIME_UNLIMITED,
|
} from 'n8n-workflow';
|
||||||
} from '@/constants';
|
import type { INodeUi } from '@/Interface';
|
||||||
|
import { CUSTOM_API_CALL_KEY, FORM_NODE_TYPE, STICKY_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants';
|
||||||
import { sanitizeHtml } from '@/utils/htmlUtils';
|
import { sanitizeHtml } from '@/utils/htmlUtils';
|
||||||
import { MarkerType } from '@vue-flow/core';
|
import { MarkerType } from '@vue-flow/core';
|
||||||
import { useNodeHelpers } from './useNodeHelpers';
|
import { useNodeHelpers } from './useNodeHelpers';
|
||||||
|
@ -419,7 +418,7 @@ export function useCanvasMapping({
|
||||||
|
|
||||||
const waitDate = new Date(workflowExecution.waitTill);
|
const waitDate = new Date(workflowExecution.waitTill);
|
||||||
|
|
||||||
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
|
if (waitDate.getTime() === WAIT_INDEFINITELY.getTime()) {
|
||||||
acc[node.id] = i18n.baseText(
|
acc[node.id] = i18n.baseText(
|
||||||
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
|
'node.theNodeIsWaitingIndefinitelyForAnIncomingWebhookCall',
|
||||||
);
|
);
|
||||||
|
|
|
@ -96,12 +96,12 @@ export function useDebugInfo() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const pruningInfo = () => {
|
const pruningInfo = () => {
|
||||||
if (!settingsStore.pruning.isEnabled) return { enabled: false } as const;
|
if (!settingsStore.pruning?.isEnabled) return { enabled: false } as const;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
maxAge: `${settingsStore.pruning.maxAge} hours`,
|
maxAge: `${settingsStore.pruning?.maxAge} hours`,
|
||||||
maxCount: `${settingsStore.pruning.maxCount} executions`,
|
maxCount: `${settingsStore.pruning?.maxCount} executions`,
|
||||||
} as const;
|
} as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,9 @@ import type router from 'vue-router';
|
||||||
import { flushPromises } from '@vue/test-utils';
|
import { flushPromises } from '@vue/test-utils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
|
import type { CloudPlanState } from '@/Interface';
|
||||||
|
|
||||||
import { VIEWS } from '@/constants';
|
import { VIEWS } from '@/constants';
|
||||||
import type { Project, ProjectListItem } from '@/types/projects.types';
|
import type { Project, ProjectListItem } from '@/types/projects.types';
|
||||||
|
@ -153,6 +156,7 @@ describe('useGlobalEntityCreation', () => {
|
||||||
describe('global', () => {
|
describe('global', () => {
|
||||||
it('should show personal + all team projects', () => {
|
it('should show personal + all team projects', () => {
|
||||||
const projectsStore = mockedStore(useProjectsStore);
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.teamProjectsLimit = -1;
|
||||||
|
|
||||||
const personalProjectId = 'personal-project';
|
const personalProjectId = 'personal-project';
|
||||||
projectsStore.isTeamProjectFeatureEnabled = true;
|
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||||
|
@ -222,4 +226,25 @@ describe('useGlobalEntityCreation', () => {
|
||||||
expect(redirect.goToUpgrade).toHaveBeenCalled();
|
expect(redirect.goToUpgrade).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show plan and limit according to deployment type', () => {
|
||||||
|
const settingsStore = mockedStore(useSettingsStore);
|
||||||
|
|
||||||
|
const cloudPlanStore = mockedStore(useCloudPlanStore);
|
||||||
|
cloudPlanStore.currentPlanData = { displayName: 'Pro' } as CloudPlanState['data'];
|
||||||
|
const projectsStore = mockedStore(useProjectsStore);
|
||||||
|
projectsStore.isTeamProjectFeatureEnabled = true;
|
||||||
|
projectsStore.teamProjectsLimit = 10;
|
||||||
|
|
||||||
|
settingsStore.isCloudDeployment = true;
|
||||||
|
const { projectsLimitReachedMessage } = useGlobalEntityCreation(true);
|
||||||
|
expect(projectsLimitReachedMessage.value).toContain(
|
||||||
|
'You have reached the Pro plan limit of 10.',
|
||||||
|
);
|
||||||
|
|
||||||
|
settingsStore.isCloudDeployment = false;
|
||||||
|
expect(projectsLimitReachedMessage.value).toContain(
|
||||||
|
'Upgrade to unlock projects for more granular control over sharing, access and organisation of workflows',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,6 +5,8 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
import { sortByProperty } from '@/utils/sortUtils';
|
import { sortByProperty } from '@/utils/sortUtils';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
@ -28,6 +30,8 @@ export const useGlobalEntityCreation = (
|
||||||
) => {
|
) => {
|
||||||
const CREATE_PROJECT_ID = 'create-project';
|
const CREATE_PROJECT_ID = 'create-project';
|
||||||
|
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
const cloudPlanStore = useCloudPlanStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
@ -172,6 +176,7 @@ export const useGlobalEntityCreation = (
|
||||||
{
|
{
|
||||||
id: CREATE_PROJECT_ID,
|
id: CREATE_PROJECT_ID,
|
||||||
title: 'Project',
|
title: 'Project',
|
||||||
|
disabled: !projectsStore.canCreateProjects,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
@ -204,5 +209,21 @@ export const useGlobalEntityCreation = (
|
||||||
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
|
void usePageRedirectionHelper().goToUpgrade('rbac', 'upgrade-rbac');
|
||||||
};
|
};
|
||||||
|
|
||||||
return { menu, handleSelect };
|
const projectsLimitReachedMessage = computed(() => {
|
||||||
|
if (settingsStore.isCloudDeployment) {
|
||||||
|
return i18n.baseText('projects.create.limitReached', {
|
||||||
|
adjustToNumber: projectsStore.teamProjectsLimit,
|
||||||
|
interpolate: {
|
||||||
|
planName: cloudPlanStore.currentPlanData?.displayName ?? '',
|
||||||
|
limit: projectsStore.teamProjectsLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return i18n.baseText('projects.create.limitReached.self');
|
||||||
|
});
|
||||||
|
|
||||||
|
const createProjectAppendSlotName = computed(() => `item.append.${CREATE_PROJECT_ID}`);
|
||||||
|
|
||||||
|
return { menu, handleSelect, createProjectAppendSlotName, projectsLimitReachedMessage };
|
||||||
};
|
};
|
||||||
|
|
|
@ -36,6 +36,7 @@ import type { PushMessageQueueItem } from '@/types';
|
||||||
import { useAssistantStore } from '@/stores/assistant.store';
|
import { useAssistantStore } from '@/stores/assistant.store';
|
||||||
import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue';
|
import NodeExecutionErrorMessage from '@/components/NodeExecutionErrorMessage.vue';
|
||||||
import type { IExecutionResponse } from '@/Interface';
|
import type { IExecutionResponse } from '@/Interface';
|
||||||
|
import { EASY_AI_WORKFLOW_JSON } from '@/constants.workflows';
|
||||||
|
|
||||||
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
|
export function usePushConnection({ router }: { router: ReturnType<typeof useRouter> }) {
|
||||||
const workflowHelpers = useWorkflowHelpers({ router });
|
const workflowHelpers = useWorkflowHelpers({ router });
|
||||||
|
@ -199,6 +200,23 @@ export function usePushConnection({ router }: { router: ReturnType<typeof useRou
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (receivedData.type === 'executionFinished') {
|
||||||
|
const workflow = workflowsStore.getWorkflowById(receivedData.data.workflowId);
|
||||||
|
if (workflow?.meta?.templateId) {
|
||||||
|
const isEasyAIWorkflow =
|
||||||
|
workflow.meta.templateId === EASY_AI_WORKFLOW_JSON.meta.templateId;
|
||||||
|
if (isEasyAIWorkflow) {
|
||||||
|
telemetry.track(
|
||||||
|
'User executed test AI workflow',
|
||||||
|
{
|
||||||
|
status: receivedData.data.status,
|
||||||
|
},
|
||||||
|
{ withPostHog: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { executionId } = receivedData.data;
|
const { executionId } = receivedData.data;
|
||||||
const { activeExecutionId } = workflowsStore;
|
const { activeExecutionId } = workflowsStore;
|
||||||
if (executionId !== activeExecutionId) {
|
if (executionId !== activeExecutionId) {
|
||||||
|
|
|
@ -1181,6 +1181,14 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
||||||
tagsStore.upsertTags(tags);
|
tagsStore.upsertTags(tags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if workflow contains any node from specified package
|
||||||
|
* by performing a quick check based on the node type name.
|
||||||
|
*/
|
||||||
|
const containsNodeFromPackage = (workflow: IWorkflowDb, packageName: string) => {
|
||||||
|
return workflow.nodes.some((node) => node.type.startsWith(packageName));
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setDocumentTitle,
|
setDocumentTitle,
|
||||||
resolveParameter,
|
resolveParameter,
|
||||||
|
@ -1207,5 +1215,6 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
||||||
promptSaveUnsavedWorkflowChanges,
|
promptSaveUnsavedWorkflowChanges,
|
||||||
initState,
|
initState,
|
||||||
getNodeParametersWithResolvedExpressions,
|
getNodeParametersWithResolvedExpressions,
|
||||||
|
containsNodeFromPackage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue