feat: Execution custom data saving and filtering (#5496)

* wip: workflow execution filtering

* fix: import type failing to build

* fix: remove console.logs

* feat: execution metadata migrations

* fix(editor): Move global executions filter to its own component

* fix(editor): Using the same filter component in workflow level

* fix(editor): a small housekeeping

* checking workflowId in filter applied

* fix(editor): update filter after resolving merge conflicts

* fix(editor): unify empy filter status

* feat(editor): add datetime picker to filter

* feat(editor): add meta fields

* fix: fix button override in datepicker panel

* feat(editor): add filter metadata

* feat(core): add 'startedBefore' execution filter prop

* feat(core): add 'tags' execution query filter

* Revert "feat(core): add 'tags' execution query filter"

This reverts commit a7b968081c.

* feat(editor): add translations and tooltip and counting selected filter props

* fix(editor): fix label layouts

* fix(editor): update custom data docs link

* fix(editor): update custom data tooltip position

* fix(editor): update tooltip text

* refactor: Ignore metadata if not enabled by license

* fix(editor): Add paywall states to advanced execution filter

* refactor: Save custom data also for worker mode

* fix: Remove duplicate migration name from list

* fix(editor): Reducing filter complexity and add debounce to text inputs

* fix(editor): Remove unused import, add comment

* fix(editor): simplify event listener

* fix: Prevent error when there are running executions

* test(editor): Add advanced execution filter basic unit test

* test(editor): Add advanced execution filter state change unit test

* fix: Small lint issue

* feat: Add indices to speed up queries

* feat: add customData limits

* refactor: put metadata save in transaction

* chore: remove unneed comment

* test: add tests for execution metadata

* fix(editor): Fixes after merge conflict

* fix(editor): Remove unused import

* wordings and ui fixes

* fix(editor): type fixes

* feat: add code node autocompletions for customData

* fix: Prevent transaction issues and ambiguous ID in sql clauses

* fix(editor): Suppress requesting current executions if metadata is used in filter (#5739)

* fix(editor): Suppress requesting current executions if metadata is used in filter

* fix(editor): Fix arrows for select in popover

* refactor: Improve performance by correcting database indices

* fix: Lint issue

* test: Fix broken test

* fix: Broken test

* test: add call data check for saveExecutionMetadata test

---------

Co-authored-by: Valya Bullions <valya@n8n.io>
Co-authored-by: Alex Grozav <alex@grozav.com>
Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: Romain Minaud <romain.minaud@gmail.com>
This commit is contained in:
Csaba Tuncsik 2023-03-23 18:07:46 +01:00 committed by GitHub
parent 4c583e2be4
commit d78a41db54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1430 additions and 269 deletions

View file

@ -169,6 +169,8 @@ export async function init(
collections.InstalledPackages = linkRepository(entities.InstalledPackages); collections.InstalledPackages = linkRepository(entities.InstalledPackages);
collections.InstalledNodes = linkRepository(entities.InstalledNodes); collections.InstalledNodes = linkRepository(entities.InstalledNodes);
collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics); collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics);
collections.ExecutionMetadata = linkRepository(entities.ExecutionMetadata);
collections.EventDestinations = linkRepository(entities.EventDestinations); collections.EventDestinations = linkRepository(entities.EventDestinations);
isInitialized = true; isInitialized = true;

View file

@ -48,6 +48,7 @@ import type { WebhookEntity } from '@db/entities/WebhookEntity';
import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity';
import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics';
import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity'; import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity';
import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata';
export interface IActivationError { export interface IActivationError {
time: number; time: number;
@ -88,6 +89,7 @@ export interface IDatabaseCollections {
InstalledNodes: Repository<InstalledNodes>; InstalledNodes: Repository<InstalledNodes>;
WorkflowStatistics: Repository<WorkflowStatistics>; WorkflowStatistics: Repository<WorkflowStatistics>;
EventDestinations: Repository<EventDestinations>; EventDestinations: Repository<EventDestinations>;
ExecutionMetadata: Repository<ExecutionMetadata>;
} }
// ---------------------------------- // ----------------------------------

View file

@ -71,6 +71,7 @@ import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services'; import { WorkflowsService } from './workflows/workflows.services';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks'; import { InternalHooks } from '@/InternalHooks';
import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata';
const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType');
@ -264,6 +265,22 @@ async function pruneExecutionData(this: WorkflowHooks): Promise<void> {
} }
} }
export async function saveExecutionMetadata(
executionId: string,
executionMetadata: Record<string, string>,
): Promise<ExecutionMetadata[]> {
const metadataRows = [];
for (const [key, value] of Object.entries(executionMetadata)) {
metadataRows.push({
execution: { id: executionId },
key,
value,
});
}
return Db.collections.ExecutionMetadata.save(metadataRows);
}
/** /**
* Returns hook functions to push data to Editor-UI * Returns hook functions to push data to Editor-UI
* *
@ -657,6 +674,14 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
executionData as IExecutionFlattedDb, executionData as IExecutionFlattedDb,
); );
try {
if (fullRunData.data.resultData.metadata) {
await saveExecutionMetadata(this.executionId, fullRunData.data.resultData.metadata);
}
} catch (e) {
Logger.error(`Failed to save metadata for execution ID ${this.executionId}`, e);
}
if (fullRunData.finished === true && this.retryOf !== undefined) { if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution // If the retry was successful save the reference it on the original execution
// await Db.collections.Execution.save(executionData as IExecutionFlattedDb); // await Db.collections.Execution.save(executionData as IExecutionFlattedDb);
@ -789,6 +814,14 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
status: executionData.status, status: executionData.status,
}); });
try {
if (fullRunData.data.resultData.metadata) {
await saveExecutionMetadata(this.executionId, fullRunData.data.resultData.metadata);
}
} catch (e) {
Logger.error(`Failed to save metadata for execution ID ${this.executionId}`, e);
}
if (fullRunData.finished === true && this.retryOf !== undefined) { if (fullRunData.finished === true && this.retryOf !== undefined) {
// If the retry was successful save the reference it on the original execution // If the retry was successful save the reference it on the original execution
await Db.collections.Execution.update(this.retryOf, { await Db.collections.Execution.update(this.retryOf, {

View file

@ -1,9 +1,10 @@
import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow';
import { Column, Entity, Generated, Index, PrimaryColumn } from 'typeorm'; import { Column, Entity, Generated, Index, OneToMany, PrimaryColumn } from 'typeorm';
import { datetimeColumnType, jsonColumnType } from './AbstractEntity'; import { datetimeColumnType, jsonColumnType } from './AbstractEntity';
import { IWorkflowDb } from '@/Interfaces'; import { IWorkflowDb } from '@/Interfaces';
import type { IExecutionFlattedDb } from '@/Interfaces'; import type { IExecutionFlattedDb } from '@/Interfaces';
import { idStringifier } from '../utils/transformers'; import { idStringifier } from '../utils/transformers';
import type { ExecutionMetadata } from './ExecutionMetadata';
@Entity() @Entity()
@Index(['workflowId', 'id']) @Index(['workflowId', 'id'])
@ -49,4 +50,7 @@ export class ExecutionEntity implements IExecutionFlattedDb {
@Column({ type: datetimeColumnType, nullable: true }) @Column({ type: datetimeColumnType, nullable: true })
waitTill: Date; waitTill: Date;
@OneToMany('ExecutionMetadata', 'execution')
metadata: ExecutionMetadata[];
} }

View file

@ -0,0 +1,22 @@
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm';
import { ExecutionEntity } from './ExecutionEntity';
@Entity()
export class ExecutionMetadata {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne('ExecutionEntity', 'metadata', {
onDelete: 'CASCADE',
})
execution: ExecutionEntity;
@RelationId((executionMetadata: ExecutionMetadata) => executionMetadata.execution)
executionId: number;
@Column('text')
key: string;
@Column('text')
value: string;
}

View file

@ -15,6 +15,7 @@ import { User } from './User';
import { WebhookEntity } from './WebhookEntity'; import { WebhookEntity } from './WebhookEntity';
import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowEntity } from './WorkflowEntity';
import { WorkflowStatistics } from './WorkflowStatistics'; import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata';
export const entities = { export const entities = {
AuthIdentity, AuthIdentity,
@ -33,4 +34,5 @@ export const entities = {
WebhookEntity, WebhookEntity,
WorkflowEntity, WorkflowEntity,
WorkflowStatistics, WorkflowStatistics,
ExecutionMetadata,
}; };

View file

@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
export class CreateExecutionMetadataTable1679416281779 implements MigrationInterface {
name = 'CreateExecutionMetadataTable1679416281779';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(
`CREATE TABLE ${tablePrefix}execution_metadata (
id int(11) auto_increment NOT NULL PRIMARY KEY,
executionId int(11) NOT NULL,
\`key\` TEXT NOT NULL,
value TEXT NOT NULL,
CONSTRAINT \`${tablePrefix}execution_metadata_FK\` FOREIGN KEY (\`executionId\`) REFERENCES \`${tablePrefix}execution_entity\` (\`id\`) ON DELETE CASCADE,
INDEX \`IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb\` (\`executionId\` ASC)
)
ENGINE=InnoDB`,
);
// Remove indices that are no longer needed since the addition of the status column
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}06da892aaf92a48e7d3e400003\` ON \`${tablePrefix}execution_entity\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}78d62b89dc1433192b86dce18a\` ON \`${tablePrefix}execution_entity\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}1688846335d274033e15c846a4\` ON \`${tablePrefix}execution_entity\``,
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}cefb067df2402f6aed0638a6c1\` ON \`${tablePrefix}execution_entity\``,
);
// Add index to the new status column
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584\` ON \`${tablePrefix}execution_entity\` (\`status\`, \`workflowId\`)`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`);
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}06da892aaf92a48e7d3e400003\` ON \`${tablePrefix}execution_entity\` (\`workflowId\`, \`waitTill\`, \`id\`)`,
);
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}78d62b89dc1433192b86dce18a\` ON \`${tablePrefix}execution_entity\` (\`workflowId\`, \`finished\`, \`id\`)`,
);
await queryRunner.query(
`CREATE INDEX \`IDX_${tablePrefix}1688846335d274033e15c846a4\` ON \`${tablePrefix}execution_entity\` (\`finished\`, \`id\`)`,
);
await queryRunner.query(
'CREATE INDEX `IDX_' +
tablePrefix +
'cefb067df2402f6aed0638a6c1` ON `' +
tablePrefix +
'execution_entity` (`stoppedAt`)',
);
await queryRunner.query(
`DROP INDEX \`IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584\` ON \`${tablePrefix}execution_entity\``,
);
}
}

View file

@ -34,6 +34,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable';
export const mysqlMigrations = [ export const mysqlMigrations = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -72,4 +73,5 @@ export const mysqlMigrations = [
AddStatusToExecutions1674138566000, AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000, MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236788851, UpdateRunningExecutionStatus1677236788851,
CreateExecutionMetadataTable1679416281779,
]; ];

View file

@ -0,0 +1,62 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
export class CreateExecutionMetadataTable1679416281778 implements MigrationInterface {
name = 'CreateExecutionMetadataTable1679416281778';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(
`CREATE TABLE ${tablePrefix}execution_metadata (
"id" serial4 NOT NULL PRIMARY KEY,
"executionId" int4 NOT NULL,
"key" text NOT NULL,
"value" text NOT NULL,
CONSTRAINT ${tablePrefix}execution_metadata_fk FOREIGN KEY ("executionId") REFERENCES ${tablePrefix}execution_entity(id) ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb" ON "${tablePrefix}execution_metadata" ("executionId");`,
);
// Remove indices that are no longer needed since the addition of the status column
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}33228da131bb1112247cf52a42"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}72ffaaab9f04c2c1f1ea86e662"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}58154df94c686818c99fb754ce"`);
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}4f474ac92be81610439aaad61e"`);
// Create new index for status
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584" ON "${tablePrefix}execution_entity" ("status", "workflowId");`,
);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();
// Re-add removed indices
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `,
);
await queryRunner.query(
`DROP INDEX IF EXISTS "IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584"`,
);
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`);
}
}

View file

@ -32,6 +32,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable';
export const postgresMigrations = [ export const postgresMigrations = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -68,4 +69,5 @@ export const postgresMigrations = [
AddStatusToExecutions1674138566000, AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000, MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677236854063, UpdateRunningExecutionStatus1677236854063,
CreateExecutionMetadataTable1679416281778,
]; ];

View file

@ -0,0 +1,64 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
export class CreateExecutionMetadataTable1679416281777 implements MigrationInterface {
name = 'CreateExecutionMetadataTable1679416281777';
public async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);
const tablePrefix = getTablePrefix();
await queryRunner.query(
`CREATE TABLE "${tablePrefix}execution_metadata" (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
executionId INTEGER NOT NULL,
"key" TEXT NOT NULL,
value TEXT NOT NULL,
CONSTRAINT ${tablePrefix}execution_metadata_entity_FK FOREIGN KEY (executionId) REFERENCES ${tablePrefix}execution_entity(id) ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb" ON "${tablePrefix}execution_metadata" ("executionId");`,
);
// Re add some lost indices from migration DeleteExecutionsWithWorkflows.ts
// that were part of AddExecutionEntityIndexes.ts
// not all were needed since we added the `status` column to execution_entity
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `,
);
// Also add index to the new status column
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584' ON '${tablePrefix}execution_entity' ('status', 'workflowId') `,
);
// Remove no longer needed index to waitTill since it's already covered by the index b94b45ce2c73ce46c54f20b5f9 above
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`);
// Remove index for stoppedAt since it's not used anymore
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}cefb067df2402f6aed0638a6c1'`);
logMigrationEnd(this.name);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();
await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`);
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`);
await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`);
await queryRunner.query(
`DROP INDEX IF EXISTS 'IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`,
);
}
}

View file

@ -31,6 +31,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu
import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions';
import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable';
const sqliteMigrations = [ const sqliteMigrations = [
InitialMigration1588102412422, InitialMigration1588102412422,
@ -66,6 +67,7 @@ const sqliteMigrations = [
AddStatusToExecutions1674138566000, AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000, MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677237073720, UpdateRunningExecutionStatus1677237073720,
CreateExecutionMetadataTable1679416281777,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -14,7 +14,7 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { deepCopy, LoggerProxy, jsonParse, Workflow } from 'n8n-workflow'; import { deepCopy, LoggerProxy, jsonParse, Workflow } from 'n8n-workflow';
import type { FindOperator, FindOptionsWhere } from 'typeorm'; import type { FindOperator, FindOptionsWhere } from 'typeorm';
import { In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm'; import { In, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, Raw } from 'typeorm';
import { ActiveExecutions } from '@/ActiveExecutions'; import { ActiveExecutions } from '@/ActiveExecutions';
import config from '@/config'; import config from '@/config';
import type { User } from '@db/entities/User'; import type { User } from '@db/entities/User';
@ -35,10 +35,15 @@ import * as Db from '@/Db';
import * as GenericHelpers from '@/GenericHelpers'; import * as GenericHelpers from '@/GenericHelpers';
import { parse } from 'flatted'; import { parse } from 'flatted';
import { Container } from 'typedi'; import { Container } from 'typedi';
import { getStatusUsingPreviousExecutionStatusMethod } from './executionHelpers'; import {
getStatusUsingPreviousExecutionStatusMethod,
isAdvancedExecutionFiltersEnabled,
} from './executionHelpers';
import { ExecutionMetadata } from '@/databases/entities/ExecutionMetadata';
import { DateUtils } from 'typeorm/util/DateUtils';
interface IGetExecutionsQueryFilter { interface IGetExecutionsQueryFilter {
id?: FindOperator<string>; id?: FindOperator<string> | string;
finished?: boolean; finished?: boolean;
mode?: string; mode?: string;
retryOf?: string; retryOf?: string;
@ -47,12 +52,16 @@ interface IGetExecutionsQueryFilter {
workflowId?: string; workflowId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
waitTill?: FindOperator<any> | boolean; waitTill?: FindOperator<any> | boolean;
metadata?: Array<{ key: string; value: string }>;
startedAfter?: string;
startedBefore?: string;
} }
const schemaGetExecutionsQueryFilter = { const schemaGetExecutionsQueryFilter = {
$id: '/IGetExecutionsQueryFilter', $id: '/IGetExecutionsQueryFilter',
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string' },
finished: { type: 'boolean' }, finished: { type: 'boolean' },
mode: { type: 'string' }, mode: { type: 'string' },
retryOf: { type: 'string' }, retryOf: { type: 'string' },
@ -63,6 +72,21 @@ const schemaGetExecutionsQueryFilter = {
}, },
waitTill: { type: 'boolean' }, waitTill: { type: 'boolean' },
workflowId: { anyOf: [{ type: 'integer' }, { type: 'string' }] }, workflowId: { anyOf: [{ type: 'integer' }, { type: 'string' }] },
metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } },
startedAfter: { type: 'date-time' },
startedBefore: { type: 'date-time' },
},
$defs: {
metadata: {
type: 'object',
required: ['key', 'value'],
properties: {
key: {
type: 'string',
},
value: { type: 'string' },
},
},
}, },
}; };
@ -84,17 +108,38 @@ export class ExecutionsService {
static async getExecutionsCount( static async getExecutionsCount(
countFilter: IDataObject, countFilter: IDataObject,
user: User, user: User,
metadata?: Array<{ key: string; value: string }>,
): Promise<{ count: number; estimated: boolean }> { ): Promise<{ count: number; estimated: boolean }> {
const dbType = config.getEnv('database.type'); const dbType = config.getEnv('database.type');
const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id'); const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id');
// For databases other than Postgres, do a regular count // For databases other than Postgres, do a regular count
// when filtering based on `workflowId` or `finished` fields. // when filtering based on `workflowId` or `finished` fields.
if (dbType !== 'postgresdb' || filteredFields.length > 0 || user.globalRole.name !== 'owner') { if (
dbType !== 'postgresdb' ||
metadata?.length ||
filteredFields.length > 0 ||
user.globalRole.name !== 'owner'
) {
const sharedWorkflowIds = await this.getWorkflowIdsForUser(user); const sharedWorkflowIds = await this.getWorkflowIdsForUser(user);
const countParams = { where: { workflowId: In(sharedWorkflowIds), ...countFilter } }; let query = Db.collections.Execution.createQueryBuilder('execution')
const count = await Db.collections.Execution.count(countParams); .select()
.orderBy('execution.id', 'DESC')
.where({ workflowId: In(sharedWorkflowIds) });
if (metadata?.length) {
query = query.leftJoinAndSelect(ExecutionMetadata, 'md', 'md.executionId = execution.id');
for (const md of metadata) {
query = query.andWhere('md.key = :key AND md.value = :value', md);
}
}
if (filteredFields.length > 0) {
query = query.andWhere(countFilter);
}
const count = await query.getCount();
return { count, estimated: false }; return { count, estimated: false };
} }
@ -138,6 +183,18 @@ export class ExecutionsService {
} else { } else {
delete filter.waitTill; delete filter.waitTill;
} }
if (Array.isArray(filter.metadata)) {
delete filter.metadata;
}
if ('startedAfter' in filter) {
delete filter.startedAfter;
}
if ('startedBefore' in filter) {
delete filter.startedBefore;
}
} }
} }
@ -227,17 +284,17 @@ export class ExecutionsService {
} = {}; } = {};
if (req.query.lastId) { if (req.query.lastId) {
rangeQuery.push('id < :lastId'); rangeQuery.push('execution.id < :lastId');
rangeQueryParams.lastId = req.query.lastId; rangeQueryParams.lastId = req.query.lastId;
} }
if (req.query.firstId) { if (req.query.firstId) {
rangeQuery.push('id > :firstId'); rangeQuery.push('execution.id > :firstId');
rangeQueryParams.firstId = req.query.firstId; rangeQueryParams.firstId = req.query.firstId;
} }
if (executingWorkflowIds.length > 0) { if (executingWorkflowIds.length > 0) {
rangeQuery.push('id NOT IN (:...executingWorkflowIds)'); rangeQuery.push('execution.id NOT IN (:...executingWorkflowIds)');
rangeQueryParams.executingWorkflowIds = executingWorkflowIds; rangeQueryParams.executingWorkflowIds = executingWorkflowIds;
} }
@ -261,11 +318,36 @@ export class ExecutionsService {
'execution.workflowData', 'execution.workflowData',
'execution.status', 'execution.status',
]) ])
.orderBy('id', 'DESC') .orderBy('execution.id', 'DESC')
.take(limit) .take(limit)
.where(findWhere); .where(findWhere);
const countFilter = deepCopy(filter ?? {}); const countFilter = deepCopy(filter ?? {});
const metadata = isAdvancedExecutionFiltersEnabled() ? filter?.metadata : undefined;
if (metadata?.length) {
query = query.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id');
for (const md of metadata) {
query = query.andWhere('md.key = :key AND md.value = :value', md);
}
}
if (filter?.startedAfter) {
query = query.andWhere({
startedAt: MoreThanOrEqual(
DateUtils.mixedDateToUtcDatetimeString(new Date(filter.startedAfter)),
),
});
}
if (filter?.startedBefore) {
query = query.andWhere({
startedAt: LessThanOrEqual(
DateUtils.mixedDateToUtcDatetimeString(new Date(filter.startedBefore)),
),
});
}
// deepcopy breaks the In operator so we need to reapply it // deepcopy breaks the In operator so we need to reapply it
if (filter?.status) { if (filter?.status) {
Object.assign(filter, { status: In(filter.status) }); Object.assign(filter, { status: In(filter.status) });
@ -285,6 +367,7 @@ export class ExecutionsService {
const { count, estimated } = await this.getExecutionsCount( const { count, estimated } = await this.getExecutionsCount(
countFilter as IDataObject, countFilter as IDataObject,
req.user, req.user,
metadata,
); );
const formattedExecutions: IExecutionsSummary[] = executions.map((execution) => { const formattedExecutions: IExecutionsSummary[] = executions.map((execution) => {

View file

@ -0,0 +1,41 @@
import { saveExecutionMetadata } from '@/WorkflowExecuteAdditionalData';
import * as Db from '@/Db';
import { mocked } from 'jest-mock';
jest.mock('@/Db', () => {
return {
collections: {
ExecutionMetadata: {
save: jest.fn(async () => Promise.resolve([])),
},
},
};
});
describe('WorkflowExecuteAdditionalData', () => {
test('Execution metadata is saved in a batch', async () => {
const toSave = {
test1: 'value1',
test2: 'value2',
};
const executionId = '1234';
await saveExecutionMetadata(executionId, toSave);
expect(mocked(Db.collections.ExecutionMetadata.save)).toHaveBeenCalledTimes(1);
expect(mocked(Db.collections.ExecutionMetadata.save).mock.calls[0]).toEqual([
[
{
execution: { id: executionId },
key: 'test1',
value: 'value1',
},
{
execution: { id: executionId },
key: 'test2',
value: 'value2',
},
],
]);
});
});

View file

@ -112,6 +112,12 @@ import { extractValue } from './ExtractValue';
import { getClientCredentialsToken } from './OAuth2Helper'; import { getClientCredentialsToken } from './OAuth2Helper';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from './Constants'; import { PLACEHOLDER_EMPTY_EXECUTION_ID } from './Constants';
import { binaryToBuffer } from './BinaryDataManager/utils'; import { binaryToBuffer } from './BinaryDataManager/utils';
import {
getAllWorkflowExecutionMetadata,
getWorkflowExecutionMetadata,
setAllWorkflowExecutionMetadata,
setWorkflowExecutionMetadata,
} from './WorkflowExecutionMetadata';
axios.defaults.timeout = 300000; axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default // Prevent axios from adding x-form-www-urlencoded headers by default
@ -1616,6 +1622,7 @@ export async function requestWithAuthentication(
export function getAdditionalKeys( export function getAdditionalKeys(
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode, mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData | null,
): IWorkflowDataProxyAdditionalKeys { ): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID; const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`; const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
@ -1624,6 +1631,22 @@ export function getAdditionalKeys(
id: executionId, id: executionId,
mode: mode === 'manual' ? 'test' : 'production', mode: mode === 'manual' ? 'test' : 'production',
resumeUrl, resumeUrl,
customData: runExecutionData
? {
set(key: string, value: string): void {
setWorkflowExecutionMetadata(runExecutionData, key, value);
},
setAll(obj: Record<string, string>): void {
setAllWorkflowExecutionMetadata(runExecutionData, obj);
},
get(key: string): string {
return getWorkflowExecutionMetadata(runExecutionData, key);
},
getAll(): Record<string, string> {
return getAllWorkflowExecutionMetadata(runExecutionData);
},
}
: undefined,
}, },
// deprecated // deprecated
@ -2122,7 +2145,7 @@ export function getExecutePollFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2181,7 +2204,7 @@ export function getExecuteTriggerFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2240,7 +2263,7 @@ export function getExecuteFunctions(
connectionInputData, connectionInputData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
executeData, executeData,
); );
}, },
@ -2306,7 +2329,7 @@ export function getExecuteFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
executeData, executeData,
fallbackValue, fallbackValue,
options, options,
@ -2323,7 +2346,7 @@ export function getExecuteFunctions(
{}, {},
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
executeData, executeData,
); );
return dataProxy.getDataProxy(); return dataProxy.getDataProxy();
@ -2413,7 +2436,7 @@ export function getExecuteSingleFunctions(
connectionInputData, connectionInputData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
executeData, executeData,
); );
}, },
@ -2484,7 +2507,7 @@ export function getExecuteSingleFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
executeData, executeData,
fallbackValue, fallbackValue,
options, options,
@ -2501,7 +2524,7 @@ export function getExecuteSingleFunctions(
{}, {},
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
executeData, executeData,
); );
return dataProxy.getDataProxy(); return dataProxy.getDataProxy();
@ -2594,7 +2617,7 @@ export function getLoadOptionsFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2643,7 +2666,7 @@ export function getExecuteHookFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, runExecutionData),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2657,7 +2680,7 @@ export function getExecuteHookFunctions(
additionalData, additionalData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, null),
isTest, isTest,
); );
}, },
@ -2720,7 +2743,7 @@ export function getExecuteWebhookFunctions(
itemIndex, itemIndex,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, null),
undefined, undefined,
fallbackValue, fallbackValue,
options, options,
@ -2758,7 +2781,7 @@ export function getExecuteWebhookFunctions(
additionalData, additionalData,
mode, mode,
additionalData.timezone, additionalData.timezone,
getAdditionalKeys(additionalData, mode), getAdditionalKeys(additionalData, mode, null),
), ),
getWebhookName: () => webhookData.webhookDescription.name, getWebhookName: () => webhookData.webhookDescription.name,
prepareOutputData: NodeHelpers.prepareOutputData, prepareOutputData: NodeHelpers.prepareOutputData,

View file

@ -0,0 +1,44 @@
import type { IRunExecutionData } from 'n8n-workflow';
export const KV_LIMIT = 10;
export function setWorkflowExecutionMetadata(
executionData: IRunExecutionData,
key: string,
value: unknown,
) {
if (!executionData.resultData.metadata) {
executionData.resultData.metadata = {};
}
// Currently limited to 10 metadata KVs
if (
!(key in executionData.resultData.metadata) &&
Object.keys(executionData.resultData.metadata).length >= KV_LIMIT
) {
return;
}
executionData.resultData.metadata[String(key).slice(0, 50)] = String(value).slice(0, 255);
}
export function setAllWorkflowExecutionMetadata(
executionData: IRunExecutionData,
obj: Record<string, string>,
) {
Object.entries(obj).forEach(([key, value]) =>
setWorkflowExecutionMetadata(executionData, key, value),
);
}
export function getAllWorkflowExecutionMetadata(
executionData: IRunExecutionData,
): Record<string, string> {
// Make a copy so it can't be modified directly
return { ...executionData.resultData.metadata } ?? {};
}
export function getWorkflowExecutionMetadata(
executionData: IRunExecutionData,
key: string,
): string {
return getAllWorkflowExecutionMetadata(executionData)[String(key).slice(0, 50)];
}

View file

@ -0,0 +1,165 @@
import {
getAllWorkflowExecutionMetadata,
getWorkflowExecutionMetadata,
KV_LIMIT,
setAllWorkflowExecutionMetadata,
setWorkflowExecutionMetadata,
} from '@/WorkflowExecutionMetadata';
import type { IRunExecutionData } from 'n8n-workflow';
describe('Execution Metadata functions', () => {
test('setWorkflowExecutionMetadata will set a value', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(executionData, 'test1', 'value1');
expect(metadata).toEqual({
test1: 'value1',
});
});
test('setAllWorkflowExecutionMetadata will set multiple values', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setAllWorkflowExecutionMetadata(executionData, {
test1: 'value1',
test2: 'value2',
});
expect(metadata).toEqual({
test1: 'value1',
test2: 'value2',
});
});
test('setWorkflowExecutionMetadata should convert values to strings', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(executionData, 'test1', 1234);
expect(metadata).toEqual({
test1: '1234',
});
});
test('setWorkflowExecutionMetadata should limit the number of metadata entries', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
const expected: Record<string, string> = {};
for (let i = 0; i < KV_LIMIT; i++) {
expected[`test${i + 1}`] = `value${i + 1}`;
}
for (let i = 0; i < KV_LIMIT + 10; i++) {
setWorkflowExecutionMetadata(executionData, `test${i + 1}`, `value${i + 1}`);
}
expect(metadata).toEqual(expected);
});
test('getWorkflowExecutionMetadata should return a single value for an existing key', () => {
const metadata: Record<string, string> = { test1: 'value1' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(getWorkflowExecutionMetadata(executionData, 'test1')).toBe('value1');
});
test('getWorkflowExecutionMetadata should return undefined for an unset key', () => {
const metadata: Record<string, string> = { test1: 'value1' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(getWorkflowExecutionMetadata(executionData, 'test2')).toBeUndefined();
});
test('getAllWorkflowExecutionMetadata should return all metadata', () => {
const metadata: Record<string, string> = { test1: 'value1', test2: 'value2' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
expect(getAllWorkflowExecutionMetadata(executionData)).toEqual(metadata);
});
test('getAllWorkflowExecutionMetadata should not an object that modifies internal state', () => {
const metadata: Record<string, string> = { test1: 'value1', test2: 'value2' };
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
getAllWorkflowExecutionMetadata(executionData).test1 = 'changed';
expect(metadata.test1).not.toBe('changed');
expect(metadata.test1).toBe('value1');
});
test('setWorkflowExecutionMetadata should truncate long keys', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(
executionData,
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
'value1',
);
expect(metadata).toEqual({
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: 'value1',
});
});
test('setWorkflowExecutionMetadata should truncate long values', () => {
const metadata = {};
const executionData = {
resultData: {
metadata,
},
} as IRunExecutionData;
setWorkflowExecutionMetadata(
executionData,
'test1',
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab',
);
expect(metadata).toEqual({
test1:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
});
});
});

View file

@ -137,9 +137,9 @@ export interface IExternalHooks {
export interface IRestApi { export interface IRestApi {
getActiveWorkflows(): Promise<string[]>; getActiveWorkflows(): Promise<string[]>;
getActivationError(id: string): Promise<IActivationError | undefined>; getActivationError(id: string): Promise<IActivationError | undefined>;
getCurrentExecutions(filter: IDataObject): Promise<IExecutionsCurrentSummaryExtended[]>; getCurrentExecutions(filter: ExecutionsQueryFilter): Promise<IExecutionsCurrentSummaryExtended[]>;
getPastExecutions( getPastExecutions(
filter: IDataObject, filter: ExecutionsQueryFilter,
limit: number, limit: number,
lastId?: string, lastId?: string,
firstId?: string, firstId?: string,
@ -393,7 +393,7 @@ export interface IExecutionsStopData {
export interface IExecutionDeleteFilter { export interface IExecutionDeleteFilter {
deleteBefore?: Date; deleteBefore?: Date;
filters?: IDataObject; filters?: ExecutionsQueryFilter;
ids?: string[]; ids?: string[];
} }
@ -1455,3 +1455,27 @@ export type NodeAuthenticationOption = {
value: string; value: string;
displayOptions?: IDisplayOptions; displayOptions?: IDisplayOptions;
}; };
export type ExecutionFilterMetadata = {
key: string;
value: string;
};
export type ExecutionFilterType = {
status: string;
workflowId: string;
startDate: string | Date;
endDate: string | Date;
tags: string[];
metadata: ExecutionFilterMetadata[];
};
export type ExecutionsQueryFilter = {
status?: ExecutionStatus[];
workflowId?: string;
finished?: boolean;
waitTill?: boolean;
metadata?: Array<{ key: string; value: string }>;
startedAfter?: string;
startedBefore?: string;
};

View file

@ -18,6 +18,15 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null;
const buildLinkNode = (text: string) => {
const wrapper = document.createElement('span');
// This is being loaded from the locales file. This could
// cause an XSS of some kind but multiple other locales strings
// do the same thing.
wrapper.innerHTML = text;
return () => wrapper;
};
const options: Completion[] = [ const options: Completion[] = [
{ {
label: `${matcher}.id`, label: `${matcher}.id`,
@ -31,6 +40,30 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
label: `${matcher}.resumeUrl`, label: `${matcher}.resumeUrl`,
info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'), info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'),
}, },
{
label: `${matcher}.customData.set("key", "value")`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.set()'),
),
},
{
label: `${matcher}.customData.get("key")`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.get()'),
),
},
{
label: `${matcher}.customData.setAll({})`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.setAll()'),
),
},
{
label: `${matcher}.customData.getAll()`,
info: buildLinkNode(
this.$locale.baseText('codeNodeEditor.completer.$execution.customData.getAll()'),
),
},
]; ];
return { return {

View file

@ -0,0 +1,418 @@
<script lang="ts" setup>
import { computed, reactive, onBeforeMount } from 'vue';
import debounce from 'lodash/debounce';
import type { PopoverPlacement } from 'element-ui/types/popover';
import type {
ExecutionFilterType,
ExecutionFilterMetadata,
IWorkflowShortResponse,
} from '@/Interface';
import { i18n as locale } from '@/plugins/i18n';
import TagsDropdown from '@/components/TagsDropdown.vue';
import { getObjectKeys, isEmpty } from '@/utils';
import { EnterpriseEditionFeature } from '@/constants';
import { useSettingsStore } from '@/stores/settings';
import { useUsageStore } from '@/stores/usage';
export type ExecutionFilterProps = {
workflows?: IWorkflowShortResponse[];
popoverPlacement?: PopoverPlacement;
};
const DATE_TIME_MASK = 'yyyy-MM-dd HH:mm';
const CLOUD_UPGRADE_LINK = 'https://app.n8n.cloud/manage?edition=cloud';
const settingsStore = useSettingsStore();
const usageStore = useUsageStore();
const props = withDefaults(defineProps<ExecutionFilterProps>(), {
popoverPlacement: 'bottom',
});
const emit = defineEmits<{
(event: 'filterChanged', value: ExecutionFilterType): void;
}>();
const debouncedEmit = debounce(emit, 500);
const viewPlansLink = computed(() =>
settingsStore.isCloudDeployment
? CLOUD_UPGRADE_LINK
: `${usageStore.viewPlansUrl}&source=custom-data-filter`,
);
const isAdvancedExecutionFilterEnabled = computed(() =>
settingsStore.isEnterpriseFeatureEnabled(EnterpriseEditionFeature.AdvancedExecutionFilters),
);
const showTags = computed(() => false);
const getDefaultFilter = (): ExecutionFilterType => ({
status: 'all',
workflowId: 'all',
tags: [],
startDate: '',
endDate: '',
metadata: [{ key: '', value: '' }],
});
const filter = reactive(getDefaultFilter());
// Automatically set up v-models based on filter properties
const vModel = reactive(
getObjectKeys(filter).reduce((acc, key) => {
acc[key] = computed({
get() {
return filter[key];
},
set(value) {
// TODO: find out what exactly is typechecker complaining about
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
filter[key] = value;
emit('filterChanged', filter);
},
});
return acc;
}, {} as Record<keyof ExecutionFilterType, ReturnType<typeof computed>>),
);
const statuses = computed(() => [
{ id: 'all', name: locale.baseText('executionsList.anyStatus') },
{ id: 'error', name: locale.baseText('executionsList.error') },
{ id: 'running', name: locale.baseText('executionsList.running') },
{ id: 'success', name: locale.baseText('executionsList.success') },
{ id: 'waiting', name: locale.baseText('executionsList.waiting') },
]);
const countSelectedFilterProps = computed(() => {
let count = 0;
if (filter.status !== 'all') {
count++;
}
if (filter.workflowId !== 'all') {
count++;
}
if (!isEmpty(filter.tags)) {
count++;
}
if (!isEmpty(filter.metadata)) {
count++;
}
if (!!filter.startDate) {
count++;
}
if (!!filter.endDate) {
count++;
}
return count;
});
// vModel.metadata is a text input and needs a debounced emit to avoid too many requests
// We use the :value and @input combo instead of v-model with this event listener
const onFilterMetaChange = (index: number, prop: keyof ExecutionFilterMetadata, value: string) => {
if (!filter.metadata[index]) {
filter.metadata[index] = {
key: '',
value: '',
};
}
filter.metadata[index][prop] = value;
debouncedEmit('filterChanged', filter);
};
// Can't use v-model on TagsDropdown component and thus vModel.tags is useless
// We just emit the updated filter
const onTagsChange = (tags: string[]) => {
filter.tags = tags;
emit('filterChanged', filter);
};
const onFilterReset = () => {
Object.assign(filter, getDefaultFilter());
emit('filterChanged', filter);
};
onBeforeMount(() => {
emit('filterChanged', filter);
});
</script>
<template>
<div :class="$style.filter">
<n8n-popover trigger="click" :placement="popoverPlacement">
<template #reference>
<n8n-button
icon="filter"
type="tertiary"
size="medium"
:active="!!countSelectedFilterProps"
data-testid="executions-filter-button"
>
<n8n-badge
v-if="!!countSelectedFilterProps"
theme="primary"
class="mr-4xs"
data-testid="execution-filter-badge"
>{{ countSelectedFilterProps }}</n8n-badge
>
{{ $locale.baseText('executionsList.filters') }}
</n8n-button>
</template>
<div data-testid="execution-filter-form">
<div v-if="workflows?.length" :class="$style.group">
<label for="execution-filter-workflows">{{
$locale.baseText('workflows.heading')
}}</label>
<n8n-select
id="execution-filter-workflows"
v-model="vModel.workflowId"
:placeholder="$locale.baseText('executionsFilter.selectWorkflow')"
size="medium"
filterable
data-testid="executions-filter-workflows-select"
>
<div class="ph-no-capture">
<n8n-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</div>
</n8n-select>
</div>
<div v-if="showTags" :class="$style.group">
<label for="execution-filter-tags">{{
$locale.baseText('workflows.filters.tags')
}}</label>
<TagsDropdown
id="execution-filter-tags"
:placeholder="$locale.baseText('workflowOpen.filterWorkflows')"
:currentTagIds="filter.tags"
:createEnabled="false"
@update="onTagsChange"
data-testid="executions-filter-tags-select"
/>
</div>
<div :class="$style.group">
<label for="execution-filter-status">{{
$locale.baseText('executionsList.status')
}}</label>
<n8n-select
id="execution-filter-status"
v-model="vModel.status"
:placeholder="$locale.baseText('executionsFilter.selectStatus')"
size="medium"
filterable
data-testid="executions-filter-status-select"
>
<n8n-option
v-for="item in statuses"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</n8n-select>
</div>
<div :class="$style.group">
<label for="execution-filter-start-date">{{
$locale.baseText('executionsFilter.start')
}}</label>
<div :class="$style.dates">
<el-date-picker
id="execution-filter-start-date"
type="datetime"
v-model="vModel.startDate"
:format="DATE_TIME_MASK"
:placeholder="$locale.baseText('executionsFilter.startDate')"
data-testid="executions-filter-start-date-picker"
/>
<span :class="$style.divider">to</span>
<el-date-picker
id="execution-filter-end-date"
type="datetime"
v-model="vModel.endDate"
:format="DATE_TIME_MASK"
:placeholder="$locale.baseText('executionsFilter.endDate')"
data-testid="executions-filter-end-date-picker"
/>
</div>
</div>
<div :class="$style.group">
<n8n-tooltip placement="right">
<template #content>
<i18n tag="span" path="executionsFilter.customData.docsTooltip">
<template #link>
<a
target="_blank"
href="https://docs.n8n.io/workflows/executions/custom-executions-data/"
>
{{ $locale.baseText('executionsFilter.customData.docsTooltip.link') }}
</a>
</template>
</i18n>
</template>
<span :class="$style.label">
{{ $locale.baseText('executionsFilter.savedData') }}
<n8n-icon :class="$style.tooltipIcon" icon="question-circle" size="small" />
</span>
</n8n-tooltip>
<div :class="$style.subGroup">
<label for="execution-filter-saved-data-key">{{
$locale.baseText('executionsFilter.savedDataKey')
}}</label>
<n8n-tooltip :disabled="isAdvancedExecutionFilterEnabled" placement="top">
<template #content>
<i18n tag="span" path="executionsFilter.customData.inputTooltip">
<template #link>
<a
target="_blank"
:href="viewPlansLink"
data-testid="executions-filter-view-plans-link"
>{{ $locale.baseText('executionsFilter.customData.inputTooltip.link') }}</a
>
</template>
</i18n>
</template>
<n8n-input
id="execution-filter-saved-data-key"
name="execution-filter-saved-data-key"
type="text"
size="medium"
:disabled="!isAdvancedExecutionFilterEnabled"
:placeholder="$locale.baseText('executionsFilter.savedDataKeyPlaceholder')"
:value="filter.metadata[0]?.key"
@input="onFilterMetaChange(0, 'key', $event)"
data-testid="execution-filter-saved-data-key-input"
/>
</n8n-tooltip>
<label for="execution-filter-saved-data-value">{{
$locale.baseText('executionsFilter.savedDataValue')
}}</label>
<n8n-tooltip :disabled="isAdvancedExecutionFilterEnabled" placement="top">
<template #content>
<i18n tag="span" path="executionsFilter.customData.inputTooltip">
<template #link>
<a target="_blank" :href="viewPlansLink">{{
$locale.baseText('executionsFilter.customData.inputTooltip.link')
}}</a>
</template>
</i18n>
</template>
<n8n-input
id="execution-filter-saved-data-value"
name="execution-filter-saved-data-value"
type="text"
size="medium"
:disabled="!isAdvancedExecutionFilterEnabled"
:placeholder="$locale.baseText('executionsFilter.savedDataValuePlaceholder')"
:value="filter.metadata[0]?.value"
@input="onFilterMetaChange(0, 'value', $event)"
data-testid="execution-filter-saved-data-value-input"
/>
</n8n-tooltip>
</div>
</div>
<n8n-button
v-if="!!countSelectedFilterProps"
:class="$style.resetBtn"
@click="onFilterReset"
size="large"
text
data-testid="executions-filter-reset-button"
>
{{ $locale.baseText('executionsFilter.reset') }}
</n8n-button>
</div>
</n8n-popover>
</div>
</template>
<style lang="scss" module>
.filter {
display: inline-block;
}
.group {
label,
.label {
display: inline-block;
font-size: var(--font-size-2xs);
margin: var(--spacing-s) 0 var(--spacing-3xs);
}
}
.subGroup {
padding: 0 0 var(--spacing-xs) var(--spacing-s);
label,
.label {
font-size: var(--font-size-3xs);
margin: var(--spacing-4xs) 0 var(--spacing-4xs);
}
}
.dates {
display: flex;
border: 1px solid var(--color-foreground-base);
border-radius: var(--border-radius-base);
white-space: nowrap;
align-items: center;
}
.divider {
padding: 0 var(--spacing-m);
line-height: 100%;
}
.resetBtn {
padding: 0;
margin: var(--spacing-xs) 0 0;
}
.tooltipIcon {
color: var(--color-text-light);
}
</style>
<style lang="scss" scoped>
:deep(.el-date-editor) {
input {
height: 36px;
border: 0;
padding-right: 0;
}
.el-input__prefix {
color: var(--color-foreground-dark);
}
&:last-of-type {
input {
padding-left: 0;
}
.el-input__prefix {
display: none;
}
}
}
:deep(.el-select-dropdown.el-popper[x-placement^='bottom']) {
> .popper__arrow {
top: -6px;
left: 50%;
right: unset;
margin-bottom: 0;
margin-right: 3px;
border-left-width: 6px;
border-top-width: 0;
border-bottom-color: var(--border-color-light);
border-right-color: transparent;
&::after {
top: 1px;
left: unset;
bottom: unset;
margin-left: -6px;
border-left-width: 6px;
border-top-width: 0;
border-bottom-color: var(--color-foreground-xlight);
border-right-color: transparent;
}
}
}
</style>

View file

@ -1,43 +1,19 @@
<template> <template>
<div :class="$style.execListWrapper"> <div :class="$style.execListWrapper">
<div :class="$style.execList"> <div :class="$style.execList">
<div :class="$style.execListHeader">
<n8n-heading tag="h1" size="2xlarge">{{ this.pageTitle }}</n8n-heading> <n8n-heading tag="h1" size="2xlarge">{{ this.pageTitle }}</n8n-heading>
<div :class="$style.filters"> <div :class="$style.execListHeaderControls">
<span :class="$style.filterItem">{{ $locale.baseText('executionsList.filters') }}:</span>
<n8n-select
:class="$style.filterItem"
v-model="filter.workflowId"
:placeholder="$locale.baseText('executionsList.selectWorkflow')"
size="medium"
filterable
@change="handleFilterChanged"
>
<div class="ph-no-capture">
<n8n-option
v-for="item in workflows"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</div>
</n8n-select>
<n8n-select
:class="$style.filterItem"
v-model="filter.status"
:placeholder="$locale.baseText('executionsList.selectStatus')"
size="medium"
filterable
@change="handleFilterChanged"
>
<n8n-option v-for="item in statuses" :key="item.id" :label="item.name" :value="item.id" />
</n8n-select>
<el-checkbox <el-checkbox
class="mr-xl"
v-model="autoRefresh" v-model="autoRefresh"
@change="handleAutoRefreshToggle" @change="handleAutoRefreshToggle"
data-testid="execution-auto-refresh-checkbox" data-testid="execution-auto-refresh-checkbox"
> >
{{ $locale.baseText('executionsList.autoRefresh') }} {{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox> </el-checkbox>
<execution-filter :workflows="workflows" @filterChanged="onFilterChanged" />
</div>
</div> </div>
<el-checkbox <el-checkbox
@ -292,6 +268,7 @@
import Vue from 'vue'; import Vue from 'vue';
import ExecutionTime from '@/components/ExecutionTime.vue'; import ExecutionTime from '@/components/ExecutionTime.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue'; import WorkflowActivator from '@/components/WorkflowActivator.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { externalHooks } from '@/mixins/externalHooks'; import { externalHooks } from '@/mixins/externalHooks';
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants'; import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
import { restApi } from '@/mixins/restApi'; import { restApi } from '@/mixins/restApi';
@ -303,14 +280,17 @@ import {
IExecutionDeleteFilter, IExecutionDeleteFilter,
IExecutionsListResponse, IExecutionsListResponse,
IWorkflowShortResponse, IWorkflowShortResponse,
ExecutionFilterType,
ExecutionsQueryFilter,
} from '@/Interface'; } from '@/Interface';
import type { IExecutionsSummary, ExecutionStatus, IDataObject } from 'n8n-workflow'; import type { IExecutionsSummary, ExecutionStatus } from 'n8n-workflow';
import { range as _range } from 'lodash-es'; import { range as _range } from 'lodash-es';
import mixins from 'vue-typed-mixins'; import mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui'; import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
import { setPageTitle } from '@/utils'; import { isEmpty, setPageTitle } from '@/utils';
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, showMessage).extend( export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, showMessage).extend(
{ {
@ -318,6 +298,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
components: { components: {
ExecutionTime, ExecutionTime,
WorkflowActivator, WorkflowActivator,
ExecutionFilter,
}, },
data() { data() {
return { return {
@ -330,10 +311,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
autoRefresh: true, autoRefresh: true,
autoRefreshInterval: undefined as undefined | NodeJS.Timer, autoRefreshInterval: undefined as undefined | NodeJS.Timer,
filter: { filter: {} as ExecutionFilterType,
status: 'ALL',
workflowId: 'ALL',
},
isDataLoading: false, isDataLoading: false,
@ -350,7 +328,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
}, },
async created() { async created() {
await this.loadWorkflows(); await this.loadWorkflows();
await this.refreshData(); //await this.refreshData();
this.handleAutoRefreshToggle(); this.handleAutoRefreshToggle();
this.$externalHooks().run('executionsList.openDialog'); this.$externalHooks().run('executionsList.openDialog');
@ -366,47 +344,22 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
}, },
computed: { computed: {
...mapStores(useUIStore, useWorkflowsStore), ...mapStores(useUIStore, useWorkflowsStore),
statuses() {
return [
{
id: 'ALL',
name: this.$locale.baseText('executionsList.anyStatus'),
},
{
id: 'error',
name: this.$locale.baseText('executionsList.error'),
},
{
id: 'running',
name: this.$locale.baseText('executionsList.running'),
},
{
id: 'success',
name: this.$locale.baseText('executionsList.success'),
},
{
id: 'waiting',
name: this.$locale.baseText('executionsList.waiting'),
},
];
},
activeExecutions(): IExecutionsCurrentSummaryExtended[] { activeExecutions(): IExecutionsCurrentSummaryExtended[] {
return this.workflowsStore.activeExecutions; return this.workflowsStore.activeExecutions;
}, },
combinedExecutions(): IExecutionsSummary[] { combinedExecutions(): IExecutionsSummary[] {
const returnData = []; const returnData: IExecutionsSummary[] = [];
if (['ALL', 'running'].includes(this.filter.status)) { if (['all', 'running'].includes(this.filter.status)) {
returnData.push(...(this.activeExecutions as IExecutionsSummary[])); returnData.push(...this.activeExecutions);
} }
if (['all', 'error', 'success', 'waiting'].includes(this.filter.status)) {
if (['ALL', 'error', 'success', 'waiting'].includes(this.filter.status)) {
returnData.push(...this.finishedExecutions); returnData.push(...this.finishedExecutions);
} }
return returnData.filter( return returnData.filter(
(execution) => (execution) =>
this.filter.workflowId === 'ALL' || execution.workflowId === this.filter.workflowId, this.filter.workflowId === 'all' || execution.workflowId === this.filter.workflowId,
); );
}, },
numSelected(): number { numSelected(): number {
@ -416,33 +369,15 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
return Object.keys(this.selectedItems).length; return Object.keys(this.selectedItems).length;
}, },
workflowFilterCurrent(): IDataObject { workflowFilterCurrent(): ExecutionsQueryFilter {
const filter: IDataObject = {}; const filter: ExecutionsQueryFilter = {};
if (this.filter.workflowId !== 'ALL') { if (this.filter.workflowId !== 'all') {
filter.workflowId = this.filter.workflowId; filter.workflowId = this.filter.workflowId;
} }
return filter; return filter;
}, },
workflowFilterPast(): IDataObject { workflowFilterPast(): ExecutionsQueryFilter {
const queryFilter: IDataObject = {}; return executionFilterToQueryFilter(this.filter);
if (this.filter.workflowId !== 'ALL') {
queryFilter.workflowId = this.filter.workflowId;
}
switch (this.filter.status as ExecutionStatus) {
case 'waiting':
queryFilter.status = ['waiting'];
break;
case 'error':
queryFilter.status = ['failed', 'crashed'];
break;
case 'success':
queryFilter.status = ['success'];
break;
case 'running':
queryFilter.status = ['running'];
break;
}
return queryFilter;
}, },
pageTitle() { pageTitle() {
return this.$locale.baseText('executionsList.workflowExecutions'); return this.$locale.baseText('executionsList.workflowExecutions');
@ -547,8 +482,10 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.allExistingSelected = false; this.allExistingSelected = false;
Vue.set(this, 'selectedItems', {}); Vue.set(this, 'selectedItems', {});
}, },
handleFilterChanged(): void { onFilterChanged(filter: ExecutionFilterType) {
this.filter = filter;
this.refreshData(); this.refreshData();
this.handleClearSelection();
}, },
handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) { handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) {
if (['currentlySaved', 'original'].includes(commandData.command)) { if (['currentlySaved', 'original'].includes(commandData.command)) {
@ -573,14 +510,11 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
return this.workflows.find((data) => data.id === workflowId)?.name; return this.workflows.find((data) => data.id === workflowId)?.name;
}, },
async loadActiveExecutions(): Promise<void> { async loadActiveExecutions(): Promise<void> {
const activeExecutions = await this.restApi().getCurrentExecutions( const activeExecutions = isEmpty(this.workflowFilterCurrent.metadata)
this.workflowFilterCurrent, ? await this.restApi().getCurrentExecutions(this.workflowFilterCurrent)
); : [];
for (const activeExecution of activeExecutions) { for (const activeExecution of activeExecutions) {
if ( if (activeExecution.workflowId && !activeExecution.workflowName) {
activeExecution.workflowId !== undefined &&
activeExecution.workflowName === undefined
) {
activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId); activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId);
} }
} }
@ -589,7 +523,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.workflowsStore.addToCurrentExecutions(activeExecutions); this.workflowsStore.addToCurrentExecutions(activeExecutions);
}, },
async loadAutoRefresh(): Promise<void> { async loadAutoRefresh(): Promise<void> {
const filter = this.workflowFilterPast; const filter: ExecutionsQueryFilter = this.workflowFilterPast;
// We cannot use firstId here as some executions finish out of order. Let's say // We cannot use firstId here as some executions finish out of order. Let's say
// You have execution ids 500 to 505 running. // You have execution ids 500 to 505 running.
// Suppose 504 finishes before 500, 501, 502 and 503. // Suppose 504 finishes before 500, 501, 502 and 503.
@ -597,8 +531,11 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
// ever get ids 500, 501, 502 and 503 when they finish // ever get ids 500, 501, 502 and 503 when they finish
const pastExecutionsPromise: Promise<IExecutionsListResponse> = const pastExecutionsPromise: Promise<IExecutionsListResponse> =
this.restApi().getPastExecutions(filter, this.requestItemsPerRequest); this.restApi().getPastExecutions(filter, this.requestItemsPerRequest);
const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> = const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> = isEmpty(
this.restApi().getCurrentExecutions({}); filter.metadata,
)
? this.restApi().getCurrentExecutions({})
: Promise.resolve([]);
const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]); const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]);
@ -759,7 +696,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
// @ts-ignore // @ts-ignore
workflows.unshift({ workflows.unshift({
id: 'ALL', id: 'all',
name: this.$locale.baseText('executionsList.allWorkflows'), name: this.$locale.baseText('executionsList.allWorkflows'),
}); });
@ -803,9 +740,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.isDataLoading = true; this.isDataLoading = true;
try { try {
const activeExecutionsPromise = this.loadActiveExecutions(); await Promise.all([this.loadActiveExecutions(), this.loadFinishedExecutions()]);
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
} catch (error) { } catch (error) {
this.$showError( this.$showError(
error, error,
@ -994,6 +929,19 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
} }
} }
.execListHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--spacing-s);
}
.execListHeaderControls {
display: flex;
align-items: center;
justify-content: flex-end;
}
.selectionOptions { .selectionOptions {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1013,16 +961,6 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
} }
} }
.filters {
display: flex;
line-height: 2em;
margin: var(--spacing-l) 0;
}
.filterItem {
margin: 0 var(--spacing-3xl) 0 0;
}
.statusColumn { .statusColumn {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -31,9 +31,14 @@ import {
VIEWS, VIEWS,
WEBHOOK_NODE_TYPE, WEBHOOK_NODE_TYPE,
} from '@/constants'; } from '@/constants';
import { IExecutionsListResponse, INodeUi, ITag, IWorkflowDb } from '@/Interface';
import { import {
ExecutionStatus, ExecutionFilterType,
IExecutionsListResponse,
INodeUi,
ITag,
IWorkflowDb,
} from '@/Interface';
import {
IExecutionsSummary, IExecutionsSummary,
IConnection, IConnection,
IConnections, IConnections,
@ -50,7 +55,7 @@ import { Route } from 'vue-router';
import { executionHelpers } from '@/mixins/executionsHelpers'; import { executionHelpers } from '@/mixins/executionsHelpers';
import { range as _range } from 'lodash-es'; import { range as _range } from 'lodash-es';
import { debounceHelper } from '@/mixins/debounce'; import { debounceHelper } from '@/mixins/debounce';
import { getNodeViewTab, NO_NETWORK_ERROR_CODE } from '@/utils'; import { getNodeViewTab, isEmpty, NO_NETWORK_ERROR_CODE } from '@/utils';
import { workflowHelpers } from '@/mixins/workflowHelpers'; import { workflowHelpers } from '@/mixins/workflowHelpers';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
@ -58,6 +63,7 @@ import { useUIStore } from '@/stores/ui';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { useNodeTypesStore } from '@/stores/nodeTypes'; import { useNodeTypesStore } from '@/stores/nodeTypes';
import { useTagsStore } from '@/stores/tags'; import { useTagsStore } from '@/stores/tags';
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
export default mixins( export default mixins(
restApi, restApi,
@ -74,7 +80,7 @@ export default mixins(
return { return {
loading: false, loading: false,
loadingMore: false, loadingMore: false,
filter: { finished: true, status: '' }, filter: {} as ExecutionFilterType,
}; };
}, },
computed: { computed: {
@ -86,7 +92,7 @@ export default mixins(
return this.loading || !this.executions.length || activeNotPresent; return this.loading || !this.executions.length || activeNotPresent;
}, },
filterApplied(): boolean { filterApplied(): boolean {
return this.filter.status !== ''; return this.filter.status !== 'all';
}, },
workflowDataNotLoaded(): boolean { workflowDataNotLoaded(): boolean {
return ( return (
@ -101,29 +107,10 @@ export default mixins(
return this.workflowsStore.getTotalFinishedExecutionsCount; return this.workflowsStore.getTotalFinishedExecutionsCount;
}, },
requestFilter(): IDataObject { requestFilter(): IDataObject {
const rFilter: IDataObject = { workflowId: this.currentWorkflow }; return executionFilterToQueryFilter({
if (this.filter.status === 'waiting') { ...this.filter,
rFilter.waitTill = true; workflowId: this.currentWorkflow,
} else if (this.filter.status !== '') { });
rFilter.finished = this.filter.status === 'success';
}
switch (this.filter.status as ExecutionStatus) {
case 'waiting':
rFilter.status = ['waiting'];
break;
case 'error':
rFilter.status = ['failed', 'crashed'];
break;
case 'success':
rFilter.status = ['success'];
break;
case 'running':
rFilter.status = ['running'];
break;
}
return rFilter;
}, },
}, },
watch: { watch: {
@ -317,8 +304,8 @@ export default mixins(
); );
} }
}, },
onFilterUpdated(newFilter: { finished: boolean; status: string }): void { onFilterUpdated(filter: ExecutionFilterType): void {
this.filter = newFilter; this.filter = filter;
this.setExecutions(); this.setExecutions();
}, },
async setExecutions(): Promise<void> { async setExecutions(): Promise<void> {

View file

@ -17,64 +17,7 @@
> >
{{ $locale.baseText('executionsList.autoRefresh') }} {{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox> </el-checkbox>
<n8n-popover trigger="click"> <execution-filter popover-placement="left-start" @filterChanged="onFilterChanged" />
<template #reference>
<div :class="$style.filterButton">
<n8n-button
icon="filter"
type="tertiary"
size="medium"
:active="statusFilterApplied"
data-test-id="executions-filter-button"
>
<n8n-badge v-if="statusFilterApplied" theme="primary" class="mr-4xs">1</n8n-badge>
{{ $locale.baseText('executionsList.filters') }}
</n8n-button>
</div>
</template>
<div :class="$style['filters-dropdown']">
<div class="mb-s">
<n8n-input-label
:label="$locale.baseText('executions.ExecutionStatus')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<n8n-select
v-model="filter.status"
size="small"
ref="typeInput"
:class="$style['type-input']"
:placeholder="$locale.baseText('generic.any')"
data-test-id="execution-status-select"
@change="onFilterChange"
>
<n8n-option
v-for="item in executionStatuses"
:key="item.id"
:label="item.name"
:value="item.id"
:data-test-id="`execution-status-${item.id}`"
>
</n8n-option>
</n8n-select>
</div>
<div :class="[$style.filterMessage, 'mt-s']" v-if="statusFilterApplied">
<n8n-link @click="resetFilters">
{{ $locale.baseText('generic.reset') }}
</n8n-link>
</div>
</div>
</n8n-popover>
</div>
<div v-show="statusFilterApplied" class="mb-xs">
<n8n-info-tip :bold="false">
{{ $locale.baseText('generic.filtersApplied') }}
<n8n-link @click="resetFilters" size="small">
{{ $locale.baseText('generic.resetAllFilters') }}
</n8n-link>
</n8n-info-tip>
</div> </div>
<div <div
:class="$style.executionList" :class="$style.executionList"
@ -87,7 +30,7 @@
<n8n-loading :class="$style.loader" variant="p" :rows="1" /> <n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" /> <n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div> </div>
<div v-if="executions.length === 0 && statusFilterApplied" :class="$style.noResultsContainer"> <div v-if="executions.length === 0" :class="$style.noResultsContainer">
<n8n-text color="text-base" size="medium" align="center"> <n8n-text color="text-base" size="medium" align="center">
{{ $locale.baseText('executionsLandingPage.noResults') }} {{ $locale.baseText('executionsLandingPage.noResults') }}
</n8n-text> </n8n-text>
@ -115,20 +58,23 @@
<script lang="ts"> <script lang="ts">
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue'; import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue'; import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import { IExecutionsSummary } from '@/Interface'; import type { IExecutionsSummary } from 'n8n-workflow';
import { Route } from 'vue-router'; import { Route } from 'vue-router';
import Vue from 'vue'; import Vue from 'vue';
import { PropType } from 'vue'; import { PropType } from 'vue';
import { mapStores } from 'pinia'; import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui'; import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows'; import { useWorkflowsStore } from '@/stores/workflows';
import { ExecutionFilterType } from '@/Interface';
export default Vue.extend({ export default Vue.extend({
name: 'executions-sidebar', name: 'executions-sidebar',
components: { components: {
ExecutionCard, ExecutionCard,
ExecutionsInfoAccordion, ExecutionsInfoAccordion,
ExecutionFilter,
}, },
props: { props: {
executions: { executions: {
@ -147,26 +93,13 @@ export default Vue.extend({
data() { data() {
return { return {
VIEWS, VIEWS,
filter: { filter: {} as ExecutionFilterType,
status: '',
},
autoRefresh: false, autoRefresh: false,
autoRefreshInterval: undefined as undefined | NodeJS.Timer, autoRefreshInterval: undefined as undefined | NodeJS.Timer,
}; };
}, },
computed: { computed: {
...mapStores(useUIStore, useWorkflowsStore), ...mapStores(useUIStore, useWorkflowsStore),
statusFilterApplied(): boolean {
return this.filter.status !== '';
},
executionStatuses(): Array<{ id: string; name: string }> {
return [
{ id: 'error', name: this.$locale.baseText('executionsList.error') },
{ id: 'running', name: this.$locale.baseText('executionsList.running') },
{ id: 'success', name: this.$locale.baseText('executionsList.success') },
{ id: 'waiting', name: this.$locale.baseText('executionsList.waiting') },
];
},
}, },
watch: { watch: {
$route(to: Route, from: Route) { $route(to: Route, from: Route) {
@ -215,8 +148,8 @@ export default Vue.extend({
onRefresh(): void { onRefresh(): void {
this.$emit('refresh'); this.$emit('refresh');
}, },
onFilterChange(): void { onFilterChanged(filter: ExecutionFilterType) {
this.$emit('filterUpdated', this.prepareFilter()); this.$emit('filterUpdated', filter);
}, },
reloadExecutions(): void { reloadExecutions(): void {
this.$emit('reloadExecutions'); this.$emit('reloadExecutions');
@ -232,16 +165,6 @@ export default Vue.extend({
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4 * 1000); // refresh data every 4 secs this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4 * 1000); // refresh data every 4 secs
} }
}, },
async resetFilters(): Promise<void> {
this.filter.status = '';
this.$emit('filterUpdated', this.prepareFilter());
},
prepareFilter(): object {
return {
finished: this.filter.status !== 'running',
status: this.filter.status,
};
},
checkListSize(): void { checkListSize(): void {
const sidebarContainer = this.$refs.container as HTMLElement; const sidebarContainer = this.$refs.container as HTMLElement;
const currentExecutionCard = this.$refs[ const currentExecutionCard = this.$refs[
@ -304,7 +227,7 @@ export default Vue.extend({
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-right: var(--spacing-l); padding-right: var(--spacing-m);
button { button {
display: flex; display: flex;

View file

@ -0,0 +1,130 @@
import { describe, test, expect } from 'vitest';
import Vue from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { render, RenderOptions } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import { faker } from '@faker-js/faker';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { STORES } from '@/constants';
import { i18nInstance } from '@/plugins/i18n';
import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface';
Vue.use(PiniaVuePlugin);
const CLOUD_HOST = 'https://app.n8n.cloud';
const PRODUCTION_SUBSCRIPTION_HOST = 'https://subscription.n8n.io';
const DEVELOPMENT_SUBSCRIPTION_HOST = 'https://staging-subscription.n8n.io';
const defaultFilterState: ExecutionFilterType = {
status: 'all',
workflowId: 'all',
tags: [],
startDate: '',
endDate: '',
metadata: [{ key: '', value: '' }],
};
const workflowDataFactory = (): IWorkflowShortResponse => ({
createdAt: faker.date.past().toDateString(),
updatedAt: faker.date.past().toDateString(),
id: faker.datatype.uuid(),
name: faker.datatype.string(),
active: faker.datatype.boolean(),
tags: [],
});
const workflowsData = Array.from({ length: 10 }, workflowDataFactory);
const initialState = {
[STORES.SETTINGS]: {
settings: {
templates: {
enabled: true,
host: 'https://api.n8n.io/api/',
},
license: {
environment: 'development',
},
deployment: {
type: 'default',
},
enterprise: {
advancedExecutionFilters: true,
},
},
},
};
const renderOptions: RenderOptions<ExecutionFilter> = {
i18n: i18nInstance,
};
describe('ExecutionFilter', () => {
test.each([
['development', 'default', DEVELOPMENT_SUBSCRIPTION_HOST, false, workflowsData],
['development', 'default', '', true, workflowsData],
['development', 'cloud', CLOUD_HOST, false, undefined],
['development', 'cloud', '', true, undefined],
['production', 'cloud', CLOUD_HOST, false, workflowsData],
['production', 'cloud', '', true, undefined],
['production', 'default', PRODUCTION_SUBSCRIPTION_HOST, false, undefined],
['production', 'default', '', true, workflowsData],
])(
'renders in %s environment on %s deployment with advancedExecutionFilters %s and workflows %s',
async (environment, deployment, plansLinkUrlBase, advancedExecutionFilters, workflows) => {
initialState[STORES.SETTINGS].settings.license.environment = environment;
initialState[STORES.SETTINGS].settings.deployment.type = deployment;
initialState[STORES.SETTINGS].settings.enterprise.advancedExecutionFilters =
advancedExecutionFilters;
renderOptions.pinia = createTestingPinia({ initialState });
renderOptions.props = { workflows };
const { getByTestId, queryByTestId } = render(ExecutionFilter, renderOptions);
await userEvent.click(getByTestId('executions-filter-button'));
await userEvent.hover(getByTestId('execution-filter-saved-data-key-input'));
if (!advancedExecutionFilters) {
expect(getByTestId('executions-filter-view-plans-link').getAttribute('href')).contains(
plansLinkUrlBase,
);
} else {
expect(queryByTestId('executions-filter-view-plans-link')).not.toBeInTheDocument();
}
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(!!queryByTestId('executions-filter-workflows-select')).toBe(!!workflows?.length);
},
);
test('state change', async () => {
const { getByTestId, queryByTestId, emitted } = render(ExecutionFilter, renderOptions);
const filterChangedEvent = emitted().filterChanged;
expect(filterChangedEvent).toHaveLength(1);
expect(filterChangedEvent[0]).toEqual([defaultFilterState]);
expect(getByTestId('execution-filter-form')).not.toBeVisible();
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument();
await userEvent.click(getByTestId('executions-filter-button'));
expect(getByTestId('execution-filter-form')).toBeVisible();
await userEvent.click(getByTestId('executions-filter-status-select'));
await userEvent.click(getByTestId('executions-filter-status-select').querySelectorAll('li')[1]);
expect(emitted().filterChanged).toHaveLength(2);
expect(filterChangedEvent[1]).toEqual([{ ...defaultFilterState, status: 'error' }]);
expect(getByTestId('executions-filter-reset-button')).toBeInTheDocument();
expect(getByTestId('execution-filter-badge')).toBeInTheDocument();
await userEvent.click(getByTestId('executions-filter-reset-button'));
expect(emitted().filterChanged).toHaveLength(3);
expect(filterChangedEvent[2]).toEqual([defaultFilterState]);
expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument();
expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument();
});
});

View file

@ -69,6 +69,15 @@ const renderOptions = {
enabled: true, enabled: true,
host: 'https://api.n8n.io/api/', host: 'https://api.n8n.io/api/',
}, },
license: {
environment: 'development',
},
deployment: {
type: 'default',
},
enterprise: {
advancedExecutionFilters: true,
},
}, },
}, },
}, },
@ -137,6 +146,7 @@ describe('ExecutionsList.vue', () => {
await userEvent.click(getByTestId('load-more-button')); await userEvent.click(getByTestId('load-more-button'));
expect(getPastExecutionsSpy).toHaveBeenCalledTimes(2);
expect(getAllByTestId('select-execution-checkbox').length).toBe(20); expect(getAllByTestId('select-execution-checkbox').length).toBe(20);
expect( expect(
getAllByTestId('select-execution-checkbox').filter((el) => getAllByTestId('select-execution-checkbox').filter((el) =>

View file

@ -445,6 +445,7 @@ export enum WORKFLOW_MENU_ACTIONS {
* Enterprise edition * Enterprise edition
*/ */
export enum EnterpriseEditionFeature { export enum EnterpriseEditionFeature {
AdvancedExecutionFilters = 'advancedExecutionFilters',
Sharing = 'sharing', Sharing = 'sharing',
Ldap = 'ldap', Ldap = 'ldap',
LogStreaming = 'logStreaming', LogStreaming = 'logStreaming',

View file

@ -114,6 +114,10 @@
"codeNodeEditor.completer.$execution.id": "The ID of the current execution", "codeNodeEditor.completer.$execution.id": "The ID of the current execution",
"codeNodeEditor.completer.$execution.mode": "How the execution was triggered: 'test' or 'production'", "codeNodeEditor.completer.$execution.mode": "How the execution was triggered: 'test' or 'production'",
"codeNodeEditor.completer.$execution.resumeUrl": "Used when using the 'wait' node to wait for a webhook. The webhook to call to resume execution", "codeNodeEditor.completer.$execution.resumeUrl": "Used when using the 'wait' node to wait for a webhook. The webhook to call to resume execution",
"codeNodeEditor.completer.$execution.customData.set()": "Set custom data for the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",
"codeNodeEditor.completer.$execution.customData.get()": "Get custom data set in the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",
"codeNodeEditor.completer.$execution.customData.setAll()": "Set multiple custom data key/value pairs with an object for the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",
"codeNodeEditor.completer.$execution.customData.getAll()": "Get all custom data for the current execution. <a href=\"https://docs.n8n.io/workflows/executions/custom-executions-data/\" target=\"_blank\">Learn More</a>",
"codeNodeEditor.completer.$input": "This nodes input data", "codeNodeEditor.completer.$input": "This nodes input data",
"codeNodeEditor.completer.$input.all": "@:_reusableBaseText.codeNodeEditor.completer.all", "codeNodeEditor.completer.$input.all": "@:_reusableBaseText.codeNodeEditor.completer.all",
"codeNodeEditor.completer.$input.first": "@:_reusableBaseText.codeNodeEditor.completer.first", "codeNodeEditor.completer.$input.first": "@:_reusableBaseText.codeNodeEditor.completer.first",
@ -475,6 +479,7 @@
"executionsList.selectWorkflow": "Select Workflow", "executionsList.selectWorkflow": "Select Workflow",
"executionsList.selected": "{numSelected} execution selected: | {numSelected} executions selected:", "executionsList.selected": "{numSelected} execution selected: | {numSelected} executions selected:",
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions", "executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
"executionsList.selected": "{numSelected} execution selected:",
"executionsList.test": "Test execution", "executionsList.test": "Test execution",
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions", "executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
"executionsList.showError.loadMore.title": "Problem loading executions", "executionsList.showError.loadMore.title": "Problem loading executions",
@ -501,7 +506,7 @@
"executionsList.unknown": "Could not complete", "executionsList.unknown": "Could not complete",
"executionsList.unsavedWorkflow": "[UNSAVED WORKFLOW]", "executionsList.unsavedWorkflow": "[UNSAVED WORKFLOW]",
"executionsList.waiting": "Waiting", "executionsList.waiting": "Waiting",
"executionsList.workflowExecutions": "All Executions", "executionsList.workflowExecutions": "Executions",
"executionsList.view": "View", "executionsList.view": "View",
"executionsList.stop": "Stop", "executionsList.stop": "Stop",
"executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.", "executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.",
@ -510,6 +515,21 @@
"executionView.onPaste.title": "Cannot paste here", "executionView.onPaste.title": "Cannot paste here",
"executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow", "executionView.onPaste.message": "This view is read-only. Switch to <i>Workflow</i> tab to be able to edit the current workflow",
"executionView.notFound.message": "Execution with id '{executionId}' could not be found!", "executionView.notFound.message": "Execution with id '{executionId}' could not be found!",
"executionsFilter.selectStatus": "Select Status",
"executionsFilter.selectWorkflow": "Select Workflow",
"executionsFilter.start": "Execution start",
"executionsFilter.startDate": "Earliest",
"executionsFilter.endDate": "Latest",
"executionsFilter.savedData": "Custom data (saved in execution)",
"executionsFilter.savedDataKey": "Key",
"executionsFilter.savedDataKeyPlaceholder": "ID",
"executionsFilter.savedDataValue": "Value (exact match)",
"executionsFilter.savedDataValuePlaceholder": "123",
"executionsFilter.reset": "Reset all",
"executionsFilter.customData.inputTooltip": "Upgrade plan to filter executions by custom data set at runtime. {link}",
"executionsFilter.customData.inputTooltip.link": "View plans",
"executionsFilter.customData.docsTooltip": "Filter executions by data that you have explicitly saved in them (by calling $execution.customData.set(key, value)). {link}",
"executionsFilter.customData.docsTooltip.link": "More info",
"expressionEdit.anythingInside": "Anything inside", "expressionEdit.anythingInside": "Anything inside",
"expressionEdit.isJavaScript": "is JavaScript.", "expressionEdit.isJavaScript": "is JavaScript.",
"expressionEdit.learnMore": "Learn more", "expressionEdit.learnMore": "Learn more",

View file

@ -7,6 +7,7 @@ import {
STORES, STORES,
} from '@/constants'; } from '@/constants';
import { import {
ExecutionsQueryFilter,
IExecutionResponse, IExecutionResponse,
IExecutionsCurrentSummaryExtended, IExecutionsCurrentSummaryExtended,
INewWorkflowData, INewWorkflowData,
@ -62,6 +63,7 @@ import {
getPairedItemsMapping, getPairedItemsMapping,
stringSizeInBytes, stringSizeInBytes,
isObjectLiteral, isObjectLiteral,
isEmpty,
} from '@/utils'; } from '@/utils';
import { useNDVStore } from './ndv'; import { useNDVStore } from './ndv';
import { useNodeTypesStore } from './nodeTypes'; import { useNodeTypesStore } from './nodeTypes';
@ -936,7 +938,9 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
Vue.set(this, 'activeExecutions', newActiveExecutions); Vue.set(this, 'activeExecutions', newActiveExecutions);
}, },
async loadCurrentWorkflowExecutions(requestFilter: IDataObject): Promise<IExecutionsSummary[]> { async loadCurrentWorkflowExecutions(
requestFilter: ExecutionsQueryFilter,
): Promise<IExecutionsSummary[]> {
let activeExecutions = []; let activeExecutions = [];
let finishedExecutions = []; let finishedExecutions = [];
@ -945,7 +949,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
} }
try { try {
const rootStore = useRootStore(); const rootStore = useRootStore();
if (!requestFilter.status || !requestFilter.finished) { if ((!requestFilter.status || !requestFilter.finished) && isEmpty(requestFilter.metadata)) {
activeExecutions = await getCurrentExecutions(rootStore.getRestApiContext, { activeExecutions = await getCurrentExecutions(rootStore.getRestApiContext, {
workflowId: requestFilter.workflowId, workflowId: requestFilter.workflowId,
}); });

View file

@ -0,0 +1,50 @@
import { ExecutionStatus, IDataObject } from 'n8n-workflow';
import { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface';
import { isEmpty } from '@/utils/typesUtils';
export const executionFilterToQueryFilter = (
filter: ExecutionFilterType,
): ExecutionsQueryFilter => {
const queryFilter: IDataObject = {};
if (filter.status === 'waiting') {
queryFilter.waitTill = true;
} else if (filter.status !== 'all') {
queryFilter.finished = filter.status === 'success';
}
if (filter.workflowId !== 'all') {
queryFilter.workflowId = filter.workflowId;
}
if (!isEmpty(filter.tags)) {
queryFilter.tags = filter.tags;
}
if (!isEmpty(filter.metadata)) {
queryFilter.metadata = filter.metadata;
}
if (!!filter.startDate) {
queryFilter.startedAfter = filter.startDate;
}
if (!!filter.endDate) {
queryFilter.startedBefore = filter.endDate;
}
switch (filter.status as ExecutionStatus) {
case 'waiting':
queryFilter.status = ['waiting'];
break;
case 'error':
queryFilter.status = ['failed', 'crashed'];
break;
case 'success':
queryFilter.status = ['success'];
break;
case 'running':
queryFilter.status = ['running'];
break;
}
return queryFilter;
};

View file

@ -1566,6 +1566,7 @@ export interface IRunExecutionData {
runData: IRunData; runData: IRunData;
pinData?: IPinData; pinData?: IPinData;
lastNodeExecuted?: string; lastNodeExecuted?: string;
metadata?: Record<string, string>;
}; };
executionData?: { executionData?: {
contextData: IExecuteContextData; contextData: IExecuteContextData;