mirror of
https://github.com/n8n-io/n8n.git
synced 2025-01-11 12:57:29 -08:00
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:
parent
4c583e2be4
commit
d78a41db54
|
@ -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;
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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[];
|
||||
}
|
||||
|
|
22
packages/cli/src/databases/entities/ExecutionMetadata.ts
Normal file
22
packages/cli/src/databases/entities/ExecutionMetadata.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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\``,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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"`);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
];
|
||||
|
|
|
@ -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")`,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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) => {
|
||||
|
|
41
packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts
Normal file
41
packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
44
packages/core/src/WorkflowExecutionMetadata.ts
Normal file
44
packages/core/src/WorkflowExecutionMetadata.ts
Normal 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)];
|
||||
}
|
165
packages/core/test/WorkflowExecutionMetadata.test.ts
Normal file
165
packages/core/test/WorkflowExecutionMetadata.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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 {
|
||||
|
|
418
packages/editor-ui/src/components/ExecutionFilter.vue
Normal file
418
packages/editor-ui/src/components/ExecutionFilter.vue
Normal 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>
|
|
@ -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;
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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) =>
|
||||
|
|
|
@ -445,6 +445,7 @@ export enum WORKFLOW_MENU_ACTIONS {
|
|||
* Enterprise edition
|
||||
*/
|
||||
export enum EnterpriseEditionFeature {
|
||||
AdvancedExecutionFilters = 'advancedExecutionFilters',
|
||||
Sharing = 'sharing',
|
||||
Ldap = 'ldap',
|
||||
LogStreaming = 'logStreaming',
|
||||
|
|
|
@ -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 node’s 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",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
50
packages/editor-ui/src/utils/executionUtils.ts
Normal file
50
packages/editor-ui/src/utils/executionUtils.ts
Normal 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;
|
||||
};
|
|
@ -1566,6 +1566,7 @@ export interface IRunExecutionData {
|
|||
runData: IRunData;
|
||||
pinData?: IPinData;
|
||||
lastNodeExecuted?: string;
|
||||
metadata?: Record<string, string>;
|
||||
};
|
||||
executionData?: {
|
||||
contextData: IExecuteContextData;
|
||||
|
|
Loading…
Reference in a new issue