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.InstalledNodes = linkRepository(entities.InstalledNodes);
collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics);
collections.ExecutionMetadata = linkRepository(entities.ExecutionMetadata);
collections.EventDestinations = linkRepository(entities.EventDestinations);
isInitialized = true;

View file

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

View file

@ -71,6 +71,7 @@ import { PermissionChecker } from './UserManagement/PermissionChecker';
import { WorkflowsService } from './workflows/workflows.services';
import { Container } from 'typedi';
import { InternalHooks } from '@/InternalHooks';
import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata';
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
*
@ -657,6 +674,14 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks {
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 the retry was successful save the reference it on the original execution
// await Db.collections.Execution.save(executionData as IExecutionFlattedDb);
@ -789,6 +814,14 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks {
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 the retry was successful save the reference it on the original execution
await Db.collections.Execution.update(this.retryOf, {

View file

@ -1,9 +1,10 @@
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 { IWorkflowDb } from '@/Interfaces';
import type { IExecutionFlattedDb } from '@/Interfaces';
import { idStringifier } from '../utils/transformers';
import type { ExecutionMetadata } from './ExecutionMetadata';
@Entity()
@Index(['workflowId', 'id'])
@ -49,4 +50,7 @@ export class ExecutionEntity implements IExecutionFlattedDb {
@Column({ type: datetimeColumnType, nullable: true })
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 { WorkflowEntity } from './WorkflowEntity';
import { WorkflowStatistics } from './WorkflowStatistics';
import { ExecutionMetadata } from './ExecutionMetadata';
export const entities = {
AuthIdentity,
@ -33,4 +34,5 @@ export const entities = {
WebhookEntity,
WorkflowEntity,
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 { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable';
export const mysqlMigrations = [
InitialMigration1588157391238,
@ -72,4 +73,5 @@ export const mysqlMigrations = [
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
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 { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable';
export const postgresMigrations = [
InitialMigration1587669153312,
@ -68,4 +69,5 @@ export const postgresMigrations = [
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
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 { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus';
import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus';
import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable';
const sqliteMigrations = [
InitialMigration1588102412422,
@ -66,6 +67,7 @@ const sqliteMigrations = [
AddStatusToExecutions1674138566000,
MigrateExecutionStatus1676996103000,
UpdateRunningExecutionStatus1677237073720,
CreateExecutionMetadataTable1679416281777,
];
export { sqliteMigrations };

View file

@ -14,7 +14,7 @@ import type {
} from 'n8n-workflow';
import { deepCopy, LoggerProxy, jsonParse, Workflow } from 'n8n-workflow';
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 config from '@/config';
import type { User } from '@db/entities/User';
@ -35,10 +35,15 @@ import * as Db from '@/Db';
import * as GenericHelpers from '@/GenericHelpers';
import { parse } from 'flatted';
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 {
id?: FindOperator<string>;
id?: FindOperator<string> | string;
finished?: boolean;
mode?: string;
retryOf?: string;
@ -47,12 +52,16 @@ interface IGetExecutionsQueryFilter {
workflowId?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
waitTill?: FindOperator<any> | boolean;
metadata?: Array<{ key: string; value: string }>;
startedAfter?: string;
startedBefore?: string;
}
const schemaGetExecutionsQueryFilter = {
$id: '/IGetExecutionsQueryFilter',
type: 'object',
properties: {
id: { type: 'string' },
finished: { type: 'boolean' },
mode: { type: 'string' },
retryOf: { type: 'string' },
@ -63,6 +72,21 @@ const schemaGetExecutionsQueryFilter = {
},
waitTill: { type: 'boolean' },
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(
countFilter: IDataObject,
user: User,
metadata?: Array<{ key: string; value: string }>,
): Promise<{ count: number; estimated: boolean }> {
const dbType = config.getEnv('database.type');
const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id');
// For databases other than Postgres, do a regular count
// 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 countParams = { where: { workflowId: In(sharedWorkflowIds), ...countFilter } };
const count = await Db.collections.Execution.count(countParams);
let query = Db.collections.Execution.createQueryBuilder('execution')
.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 };
}
@ -138,6 +183,18 @@ export class ExecutionsService {
} else {
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) {
rangeQuery.push('id < :lastId');
rangeQuery.push('execution.id < :lastId');
rangeQueryParams.lastId = req.query.lastId;
}
if (req.query.firstId) {
rangeQuery.push('id > :firstId');
rangeQuery.push('execution.id > :firstId');
rangeQueryParams.firstId = req.query.firstId;
}
if (executingWorkflowIds.length > 0) {
rangeQuery.push('id NOT IN (:...executingWorkflowIds)');
rangeQuery.push('execution.id NOT IN (:...executingWorkflowIds)');
rangeQueryParams.executingWorkflowIds = executingWorkflowIds;
}
@ -261,11 +318,36 @@ export class ExecutionsService {
'execution.workflowData',
'execution.status',
])
.orderBy('id', 'DESC')
.orderBy('execution.id', 'DESC')
.take(limit)
.where(findWhere);
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
if (filter?.status) {
Object.assign(filter, { status: In(filter.status) });
@ -285,6 +367,7 @@ export class ExecutionsService {
const { count, estimated } = await this.getExecutionsCount(
countFilter as IDataObject,
req.user,
metadata,
);
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 { PLACEHOLDER_EMPTY_EXECUTION_ID } from './Constants';
import { binaryToBuffer } from './BinaryDataManager/utils';
import {
getAllWorkflowExecutionMetadata,
getWorkflowExecutionMetadata,
setAllWorkflowExecutionMetadata,
setWorkflowExecutionMetadata,
} from './WorkflowExecutionMetadata';
axios.defaults.timeout = 300000;
// Prevent axios from adding x-form-www-urlencoded headers by default
@ -1616,6 +1622,7 @@ export async function requestWithAuthentication(
export function getAdditionalKeys(
additionalData: IWorkflowExecuteAdditionalData,
mode: WorkflowExecuteMode,
runExecutionData: IRunExecutionData | null,
): IWorkflowDataProxyAdditionalKeys {
const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID;
const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`;
@ -1624,6 +1631,22 @@ export function getAdditionalKeys(
id: executionId,
mode: mode === 'manual' ? 'test' : 'production',
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
@ -2122,7 +2145,7 @@ export function getExecutePollFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
@ -2181,7 +2204,7 @@ export function getExecuteTriggerFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
@ -2240,7 +2263,7 @@ export function getExecuteFunctions(
connectionInputData,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
executeData,
);
},
@ -2306,7 +2329,7 @@ export function getExecuteFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
executeData,
fallbackValue,
options,
@ -2323,7 +2346,7 @@ export function getExecuteFunctions(
{},
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
executeData,
);
return dataProxy.getDataProxy();
@ -2413,7 +2436,7 @@ export function getExecuteSingleFunctions(
connectionInputData,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
executeData,
);
},
@ -2484,7 +2507,7 @@ export function getExecuteSingleFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
executeData,
fallbackValue,
options,
@ -2501,7 +2524,7 @@ export function getExecuteSingleFunctions(
{},
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
executeData,
);
return dataProxy.getDataProxy();
@ -2594,7 +2617,7 @@ export function getLoadOptionsFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
@ -2643,7 +2666,7 @@ export function getExecuteHookFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, runExecutionData),
undefined,
fallbackValue,
options,
@ -2657,7 +2680,7 @@ export function getExecuteHookFunctions(
additionalData,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, null),
isTest,
);
},
@ -2720,7 +2743,7 @@ export function getExecuteWebhookFunctions(
itemIndex,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, null),
undefined,
fallbackValue,
options,
@ -2758,7 +2781,7 @@ export function getExecuteWebhookFunctions(
additionalData,
mode,
additionalData.timezone,
getAdditionalKeys(additionalData, mode),
getAdditionalKeys(additionalData, mode, null),
),
getWebhookName: () => webhookData.webhookDescription.name,
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 {
getActiveWorkflows(): Promise<string[]>;
getActivationError(id: string): Promise<IActivationError | undefined>;
getCurrentExecutions(filter: IDataObject): Promise<IExecutionsCurrentSummaryExtended[]>;
getCurrentExecutions(filter: ExecutionsQueryFilter): Promise<IExecutionsCurrentSummaryExtended[]>;
getPastExecutions(
filter: IDataObject,
filter: ExecutionsQueryFilter,
limit: number,
lastId?: string,
firstId?: string,
@ -393,7 +393,7 @@ export interface IExecutionsStopData {
export interface IExecutionDeleteFilter {
deleteBefore?: Date;
filters?: IDataObject;
filters?: ExecutionsQueryFilter;
ids?: string[];
}
@ -1455,3 +1455,27 @@ export type NodeAuthenticationOption = {
value: string;
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;
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[] = [
{
label: `${matcher}.id`,
@ -31,6 +40,30 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({
label: `${matcher}.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 {

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>
<div :class="$style.execListWrapper">
<div :class="$style.execList">
<n8n-heading tag="h1" size="2xlarge">{{ this.pageTitle }}</n8n-heading>
<div :class="$style.filters">
<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
v-model="autoRefresh"
@change="handleAutoRefreshToggle"
data-testid="execution-auto-refresh-checkbox"
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<div :class="$style.execListHeader">
<n8n-heading tag="h1" size="2xlarge">{{ this.pageTitle }}</n8n-heading>
<div :class="$style.execListHeaderControls">
<el-checkbox
class="mr-xl"
v-model="autoRefresh"
@change="handleAutoRefreshToggle"
data-testid="execution-auto-refresh-checkbox"
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<execution-filter :workflows="workflows" @filterChanged="onFilterChanged" />
</div>
</div>
<el-checkbox
@ -292,6 +268,7 @@
import Vue from 'vue';
import ExecutionTime from '@/components/ExecutionTime.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { externalHooks } from '@/mixins/externalHooks';
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
import { restApi } from '@/mixins/restApi';
@ -303,14 +280,17 @@ import {
IExecutionDeleteFilter,
IExecutionsListResponse,
IWorkflowShortResponse,
ExecutionFilterType,
ExecutionsQueryFilter,
} 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 mixins from 'vue-typed-mixins';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
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(
{
@ -318,6 +298,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
components: {
ExecutionTime,
WorkflowActivator,
ExecutionFilter,
},
data() {
return {
@ -330,10 +311,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
autoRefresh: true,
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
filter: {
status: 'ALL',
workflowId: 'ALL',
},
filter: {} as ExecutionFilterType,
isDataLoading: false,
@ -350,7 +328,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
},
async created() {
await this.loadWorkflows();
await this.refreshData();
//await this.refreshData();
this.handleAutoRefreshToggle();
this.$externalHooks().run('executionsList.openDialog');
@ -366,47 +344,22 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
},
computed: {
...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[] {
return this.workflowsStore.activeExecutions;
},
combinedExecutions(): IExecutionsSummary[] {
const returnData = [];
const returnData: IExecutionsSummary[] = [];
if (['ALL', 'running'].includes(this.filter.status)) {
returnData.push(...(this.activeExecutions as IExecutionsSummary[]));
if (['all', 'running'].includes(this.filter.status)) {
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);
}
return returnData.filter(
(execution) =>
this.filter.workflowId === 'ALL' || execution.workflowId === this.filter.workflowId,
this.filter.workflowId === 'all' || execution.workflowId === this.filter.workflowId,
);
},
numSelected(): number {
@ -416,33 +369,15 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
return Object.keys(this.selectedItems).length;
},
workflowFilterCurrent(): IDataObject {
const filter: IDataObject = {};
if (this.filter.workflowId !== 'ALL') {
workflowFilterCurrent(): ExecutionsQueryFilter {
const filter: ExecutionsQueryFilter = {};
if (this.filter.workflowId !== 'all') {
filter.workflowId = this.filter.workflowId;
}
return filter;
},
workflowFilterPast(): IDataObject {
const queryFilter: IDataObject = {};
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;
workflowFilterPast(): ExecutionsQueryFilter {
return executionFilterToQueryFilter(this.filter);
},
pageTitle() {
return this.$locale.baseText('executionsList.workflowExecutions');
@ -547,8 +482,10 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.allExistingSelected = false;
Vue.set(this, 'selectedItems', {});
},
handleFilterChanged(): void {
onFilterChanged(filter: ExecutionFilterType) {
this.filter = filter;
this.refreshData();
this.handleClearSelection();
},
handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) {
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;
},
async loadActiveExecutions(): Promise<void> {
const activeExecutions = await this.restApi().getCurrentExecutions(
this.workflowFilterCurrent,
);
const activeExecutions = isEmpty(this.workflowFilterCurrent.metadata)
? await this.restApi().getCurrentExecutions(this.workflowFilterCurrent)
: [];
for (const activeExecution of activeExecutions) {
if (
activeExecution.workflowId !== undefined &&
activeExecution.workflowName === undefined
) {
if (activeExecution.workflowId && !activeExecution.workflowName) {
activeExecution.workflowName = this.getWorkflowName(activeExecution.workflowId);
}
}
@ -589,7 +523,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.workflowsStore.addToCurrentExecutions(activeExecutions);
},
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
// You have execution ids 500 to 505 running.
// 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
const pastExecutionsPromise: Promise<IExecutionsListResponse> =
this.restApi().getPastExecutions(filter, this.requestItemsPerRequest);
const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> =
this.restApi().getCurrentExecutions({});
const currentExecutionsPromise: Promise<IExecutionsCurrentSummaryExtended[]> = isEmpty(
filter.metadata,
)
? this.restApi().getCurrentExecutions({})
: Promise.resolve([]);
const results = await Promise.all([pastExecutionsPromise, currentExecutionsPromise]);
@ -759,7 +696,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
// @ts-ignore
workflows.unshift({
id: 'ALL',
id: 'all',
name: this.$locale.baseText('executionsList.allWorkflows'),
});
@ -803,9 +740,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi,
this.isDataLoading = true;
try {
const activeExecutionsPromise = this.loadActiveExecutions();
const finishedExecutionsPromise = this.loadFinishedExecutions();
await Promise.all([activeExecutionsPromise, finishedExecutionsPromise]);
await Promise.all([this.loadActiveExecutions(), this.loadFinishedExecutions()]);
} catch (error) {
this.$showError(
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 {
display: flex;
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 {
display: flex;
align-items: center;

View file

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

View file

@ -17,64 +17,7 @@
>
{{ $locale.baseText('executionsList.autoRefresh') }}
</el-checkbox>
<n8n-popover trigger="click">
<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>
<execution-filter popover-placement="left-start" @filterChanged="onFilterChanged" />
</div>
<div
: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" />
</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">
{{ $locale.baseText('executionsLandingPage.noResults') }}
</n8n-text>
@ -115,20 +58,23 @@
<script lang="ts">
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
import ExecutionFilter from '@/components/ExecutionFilter.vue';
import { VIEWS } from '@/constants';
import { IExecutionsSummary } from '@/Interface';
import type { IExecutionsSummary } from 'n8n-workflow';
import { Route } from 'vue-router';
import Vue from 'vue';
import { PropType } from 'vue';
import { mapStores } from 'pinia';
import { useUIStore } from '@/stores/ui';
import { useWorkflowsStore } from '@/stores/workflows';
import { ExecutionFilterType } from '@/Interface';
export default Vue.extend({
name: 'executions-sidebar',
components: {
ExecutionCard,
ExecutionsInfoAccordion,
ExecutionFilter,
},
props: {
executions: {
@ -147,26 +93,13 @@ export default Vue.extend({
data() {
return {
VIEWS,
filter: {
status: '',
},
filter: {} as ExecutionFilterType,
autoRefresh: false,
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
};
},
computed: {
...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: {
$route(to: Route, from: Route) {
@ -215,8 +148,8 @@ export default Vue.extend({
onRefresh(): void {
this.$emit('refresh');
},
onFilterChange(): void {
this.$emit('filterUpdated', this.prepareFilter());
onFilterChanged(filter: ExecutionFilterType) {
this.$emit('filterUpdated', filter);
},
reloadExecutions(): void {
this.$emit('reloadExecutions');
@ -232,16 +165,6 @@ export default Vue.extend({
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 {
const sidebarContainer = this.$refs.container as HTMLElement;
const currentExecutionCard = this.$refs[
@ -304,7 +227,7 @@ export default Vue.extend({
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-l);
padding-right: var(--spacing-m);
button {
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,
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'));
expect(getPastExecutionsSpy).toHaveBeenCalledTimes(2);
expect(getAllByTestId('select-execution-checkbox').length).toBe(20);
expect(
getAllByTestId('select-execution-checkbox').filter((el) =>

View file

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

View file

@ -114,6 +114,10 @@
"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.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.all": "@:_reusableBaseText.codeNodeEditor.completer.all",
"codeNodeEditor.completer.$input.first": "@:_reusableBaseText.codeNodeEditor.completer.first",
@ -475,6 +479,7 @@
"executionsList.selectWorkflow": "Select Workflow",
"executionsList.selected": "{numSelected} execution selected: | {numSelected} executions selected:",
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
"executionsList.selected": "{numSelected} execution selected:",
"executionsList.test": "Test execution",
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
"executionsList.showError.loadMore.title": "Problem loading executions",
@ -501,7 +506,7 @@
"executionsList.unknown": "Could not complete",
"executionsList.unsavedWorkflow": "[UNSAVED WORKFLOW]",
"executionsList.waiting": "Waiting",
"executionsList.workflowExecutions": "All Executions",
"executionsList.workflowExecutions": "Executions",
"executionsList.view": "View",
"executionsList.stop": "Stop",
"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.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!",
"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.isJavaScript": "is JavaScript.",
"expressionEdit.learnMore": "Learn more",

View file

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