diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 59f08c570b0ff..2a8a8828b4509 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -233,9 +233,11 @@ const createMockExecutions = () => { executionsTab.actions.createManualExecutions(5); // Make some failed executions by enabling Code node with syntax error executionsTab.actions.toggleNodeEnabled('Error'); + workflowPage.getters.disabledNodes().should('have.length', 0); executionsTab.actions.createManualExecutions(2); // Then add some more successful ones executionsTab.actions.toggleNodeEnabled('Error'); + workflowPage.getters.disabledNodes().should('have.length', 1); executionsTab.actions.createManualExecutions(4); }; diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index 3b2fa5885b999..1d7860fd3b468 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -582,7 +582,13 @@ describe('NDV', () => { ndv.getters.outputTableRow(1).find('mark').should('have.text', '; export type AuditLogsScope = ResourceScope<'auditLogs', 'manage'>; export type BannerScope = ResourceScope<'banner', 'dismiss'>; export type CommunityPackageScope = ResourceScope< @@ -44,6 +45,7 @@ export type WorkflowScope = ResourceScope< >; export type Scope = + | AnnotationTagScope | AuditLogsScope | BannerScope | CommunityPackageScope diff --git a/packages/cli/src/controllers/annotation-tags.controller.ts b/packages/cli/src/controllers/annotation-tags.controller.ts new file mode 100644 index 0000000000000..a66137d51748c --- /dev/null +++ b/packages/cli/src/controllers/annotation-tags.controller.ts @@ -0,0 +1,45 @@ +import { Delete, Get, Patch, Post, RestController, GlobalScope } from '@/decorators'; +import { AnnotationTagService } from '@/services/annotation-tag.service'; +import { AnnotationTagsRequest } from '@/requests'; + +@RestController('/annotation-tags') +export class AnnotationTagsController { + constructor(private readonly annotationTagService: AnnotationTagService) {} + + @Get('/') + @GlobalScope('annotationTag:list') + async getAll(req: AnnotationTagsRequest.GetAll) { + return await this.annotationTagService.getAll({ + withUsageCount: req.query.withUsageCount === 'true', + }); + } + + @Post('/') + @GlobalScope('annotationTag:create') + async createTag(req: AnnotationTagsRequest.Create) { + const tag = this.annotationTagService.toEntity({ name: req.body.name }); + + return await this.annotationTagService.save(tag); + } + + @Patch('/:id(\\w+)') + @GlobalScope('annotationTag:update') + async updateTag(req: AnnotationTagsRequest.Update) { + const newTag = this.annotationTagService.toEntity({ + id: req.params.id, + name: req.body.name.trim(), + }); + + return await this.annotationTagService.save(newTag); + } + + @Delete('/:id(\\w+)') + @GlobalScope('annotationTag:delete') + async deleteTag(req: AnnotationTagsRequest.Delete) { + const { id } = req.params; + + await this.annotationTagService.delete(id); + + return true; + } +} diff --git a/packages/cli/src/databases/config.ts b/packages/cli/src/databases/config.ts index aa6c0a570a834..1f8c748fa7afe 100644 --- a/packages/cli/src/databases/config.ts +++ b/packages/cli/src/databases/config.ts @@ -62,6 +62,7 @@ const getSqliteConnectionOptions = (): SqliteConnectionOptions | SqlitePooledCon database: path.resolve(Container.get(InstanceSettings).n8nFolder, sqliteConfig.database), migrations: sqliteMigrations, }; + if (sqliteConfig.poolSize > 0) { return { type: 'sqlite-pooled', diff --git a/packages/cli/src/databases/entities/annotation-tag-entity.ts b/packages/cli/src/databases/entities/annotation-tag-entity.ts new file mode 100644 index 0000000000000..b62853cb796d4 --- /dev/null +++ b/packages/cli/src/databases/entities/annotation-tag-entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, Index, ManyToMany, OneToMany } from '@n8n/typeorm'; +import { IsString, Length } from 'class-validator'; +import { WithTimestampsAndStringId } from './abstract-entity'; +import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; +import type { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; + +@Entity() +export class AnnotationTagEntity extends WithTimestampsAndStringId { + @Column({ length: 24 }) + @Index({ unique: true }) + @IsString({ message: 'Tag name must be of type string.' }) + @Length(1, 24, { message: 'Tag name must be $constraint1 to $constraint2 characters long.' }) + name: string; + + @ManyToMany('ExecutionAnnotation', 'tags') + annotations: ExecutionAnnotation[]; + + @OneToMany('AnnotationTagMapping', 'tags') + annotationMappings: AnnotationTagMapping[]; +} diff --git a/packages/cli/src/databases/entities/annotation-tag-mapping.ts b/packages/cli/src/databases/entities/annotation-tag-mapping.ts new file mode 100644 index 0000000000000..9a4948c2ccbfa --- /dev/null +++ b/packages/cli/src/databases/entities/annotation-tag-mapping.ts @@ -0,0 +1,23 @@ +import { Entity, JoinColumn, ManyToOne, PrimaryColumn } from '@n8n/typeorm'; +import type { ExecutionAnnotation } from './execution-annotation'; +import type { AnnotationTagEntity } from './annotation-tag-entity'; + +/** + * This entity represents the junction table between the execution annotations and the tags + */ +@Entity({ name: 'execution_annotation_tags' }) +export class AnnotationTagMapping { + @PrimaryColumn() + annotationId: number; + + @ManyToOne('ExecutionAnnotation', 'tagMappings') + @JoinColumn({ name: 'annotationId' }) + annotations: ExecutionAnnotation[]; + + @PrimaryColumn() + tagId: string; + + @ManyToOne('AnnotationTagEntity', 'annotationMappings') + @JoinColumn({ name: 'tagId' }) + tags: AnnotationTagEntity[]; +} diff --git a/packages/cli/src/databases/entities/execution-annotation.ts b/packages/cli/src/databases/entities/execution-annotation.ts new file mode 100644 index 0000000000000..804bf99c153b7 --- /dev/null +++ b/packages/cli/src/databases/entities/execution-annotation.ts @@ -0,0 +1,61 @@ +import { + Column, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + RelationId, +} from '@n8n/typeorm'; +import { ExecutionEntity } from './execution-entity'; +import type { AnnotationTagEntity } from './annotation-tag-entity'; +import type { AnnotationTagMapping } from './annotation-tag-mapping'; +import type { AnnotationVote } from 'n8n-workflow'; + +@Entity({ name: 'execution_annotations' }) +export class ExecutionAnnotation { + @PrimaryGeneratedColumn() + id: number; + + /** + * This field stores the up- or down-vote of the execution by user. + */ + @Column({ type: 'varchar', nullable: true }) + vote: AnnotationVote | null; + + /** + * Custom text note added to the execution by user. + */ + @Column({ type: 'varchar', nullable: true }) + note: string | null; + + @RelationId((annotation: ExecutionAnnotation) => annotation.execution) + executionId: string; + + @Index({ unique: true }) + @OneToOne('ExecutionEntity', 'annotation', { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'executionId' }) + execution: ExecutionEntity; + + @ManyToMany('AnnotationTagEntity', 'annotations') + @JoinTable({ + name: 'execution_annotation_tags', // table name for the junction table of this relation + joinColumn: { + name: 'annotationId', + referencedColumnName: 'id', + }, + inverseJoinColumn: { + name: 'tagId', + referencedColumnName: 'id', + }, + }) + tags?: AnnotationTagEntity[]; + + @OneToMany('AnnotationTagMapping', 'annotations') + tagMappings: AnnotationTagMapping[]; +} diff --git a/packages/cli/src/databases/entities/execution-entity.ts b/packages/cli/src/databases/entities/execution-entity.ts index f7a9455b5ab22..7a4f595fe6ce0 100644 --- a/packages/cli/src/databases/entities/execution-entity.ts +++ b/packages/cli/src/databases/entities/execution-entity.ts @@ -16,6 +16,7 @@ import { idStringifier } from '../utils/transformers'; import type { ExecutionData } from './execution-data'; import type { ExecutionMetadata } from './execution-metadata'; import { WorkflowEntity } from './workflow-entity'; +import type { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; @Entity() @Index(['workflowId', 'id']) @@ -65,6 +66,9 @@ export class ExecutionEntity { @OneToOne('ExecutionData', 'execution') executionData: Relation; + @OneToOne('ExecutionAnnotation', 'execution') + annotation?: Relation; + @ManyToOne('WorkflowEntity') workflow: WorkflowEntity; } diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index daac91e8c3aa0..114df0723f2a8 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -22,13 +22,19 @@ import { WorkflowHistory } from './workflow-history'; import { Project } from './project'; import { ProjectRelation } from './project-relation'; import { InvalidAuthToken } from './invalid-auth-token'; +import { AnnotationTagEntity } from './annotation-tag-entity'; +import { AnnotationTagMapping } from './annotation-tag-mapping'; +import { ExecutionAnnotation } from './execution-annotation'; export const entities = { + AnnotationTagEntity, + AnnotationTagMapping, AuthIdentity, AuthProviderSyncHistory, AuthUser, CredentialsEntity, EventDestinations, + ExecutionAnnotation, ExecutionEntity, InstalledNodes, InstalledPackages, diff --git a/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts b/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts new file mode 100644 index 0000000000000..94c5e5ff68bff --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1724753530828-CreateExecutionAnnotationTables.ts @@ -0,0 +1,48 @@ +import type { MigrationContext, ReversibleMigration } from '@/databases/types'; + +const annotationsTableName = 'execution_annotations'; +const annotationTagsTableName = 'annotation_tag_entity'; +const annotationTagMappingsTableName = 'execution_annotation_tags'; + +export class CreateAnnotationTables1724753530828 implements ReversibleMigration { + async up({ schemaBuilder: { createTable, column } }: MigrationContext) { + await createTable(annotationsTableName) + .withColumns( + column('id').int.notNull.primary.autoGenerate, + column('executionId').int.notNull, + column('vote').varchar(6), + column('note').text, + ) + .withIndexOn('executionId', true) + .withForeignKey('executionId', { + tableName: 'execution_entity', + columnName: 'id', + onDelete: 'CASCADE', + }).withTimestamps; + + await createTable(annotationTagsTableName) + .withColumns(column('id').varchar(16).primary.notNull, column('name').varchar(24).notNull) + .withIndexOn('name', true).withTimestamps; + + await createTable(annotationTagMappingsTableName) + .withColumns(column('annotationId').int.notNull, column('tagId').varchar(24).notNull) + .withForeignKey('annotationId', { + tableName: annotationsTableName, + columnName: 'id', + onDelete: 'CASCADE', + }) + .withIndexOn('tagId') + .withIndexOn('annotationId') + .withForeignKey('tagId', { + tableName: annotationTagsTableName, + columnName: 'id', + onDelete: 'CASCADE', + }); + } + + async down({ schemaBuilder: { dropTable } }: MigrationContext) { + await dropTable(annotationTagMappingsTableName); + await dropTable(annotationTagsTableName); + await dropTable(annotationsTableName); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index ac55780b3236f..05f9fad15dede 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -61,6 +61,7 @@ import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActiv import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; +import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; export const mysqlMigrations: Migration[] = [ InitialMigration1588157391238, @@ -125,4 +126,5 @@ export const mysqlMigrations: Migration[] = [ AddConstraintToExecutionMetadata1720101653148, CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, + CreateAnnotationTables1724753530828, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index d836eda509bd1..0b0f17e8a3592 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -61,6 +61,7 @@ import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-R import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata'; import { FixExecutionMetadataSequence1721377157740 } from './1721377157740-FixExecutionMetadataSequence'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; +import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -125,4 +126,5 @@ export const postgresMigrations: Migration[] = [ FixExecutionMetadataSequence1721377157740, CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, + CreateAnnotationTables1724753530828, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 9200cee080702..5696b77726d27 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -58,6 +58,7 @@ import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActiv import { RefactorExecutionIndices1723796243146 } from '../common/1723796243146-RefactorExecutionIndices'; import { AddConstraintToExecutionMetadata1720101653148 } from '../common/1720101653148-AddConstraintToExecutionMetadata'; import { CreateInvalidAuthTokenTable1723627610222 } from '../common/1723627610222-CreateInvalidAuthTokenTable'; +import { CreateAnnotationTables1724753530828 } from '../common/1724753530828-CreateExecutionAnnotationTables'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -119,6 +120,7 @@ const sqliteMigrations: Migration[] = [ AddConstraintToExecutionMetadata1720101653148, CreateInvalidAuthTokenTable1723627610222, RefactorExecutionIndices1723796243146, + CreateAnnotationTables1724753530828, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ts b/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ts new file mode 100644 index 0000000000000..b478348319819 --- /dev/null +++ b/packages/cli/src/databases/repositories/annotation-tag-mapping.repository.ts @@ -0,0 +1,26 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from '@n8n/typeorm'; +import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; + +@Service() +export class AnnotationTagMappingRepository extends Repository { + constructor(dataSource: DataSource) { + super(AnnotationTagMapping, dataSource.manager); + } + + /** + * Overwrite annotation tags for the given execution. Annotation should already exist. + */ + async overwriteTags(annotationId: number, tagIds: string[]) { + return await this.manager.transaction(async (tx) => { + await tx.delete(AnnotationTagMapping, { annotationId }); + + const tagMappings = tagIds.map((tagId) => ({ + annotationId, + tagId, + })); + + return await tx.insert(AnnotationTagMapping, tagMappings); + }); + } +} diff --git a/packages/cli/src/databases/repositories/annotation-tag.repository.ts b/packages/cli/src/databases/repositories/annotation-tag.repository.ts new file mode 100644 index 0000000000000..bf15e5626b820 --- /dev/null +++ b/packages/cli/src/databases/repositories/annotation-tag.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from '@n8n/typeorm'; +import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; + +@Service() +export class AnnotationTagRepository extends Repository { + constructor(dataSource: DataSource) { + super(AnnotationTagEntity, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/execution-annotation.repository.ts b/packages/cli/src/databases/repositories/execution-annotation.repository.ts new file mode 100644 index 0000000000000..1b971031cc8f7 --- /dev/null +++ b/packages/cli/src/databases/repositories/execution-annotation.repository.ts @@ -0,0 +1,10 @@ +import { Service } from 'typedi'; +import { DataSource, Repository } from '@n8n/typeorm'; +import { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; + +@Service() +export class ExecutionAnnotationRepository extends Repository { + constructor(dataSource: DataSource) { + super(ExecutionAnnotation, dataSource.manager); + } +} diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index f6438b5a833cf..711d2b73356ea 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -1,4 +1,5 @@ import { Service } from 'typedi'; +import pick from 'lodash/pick'; import { Brackets, DataSource, @@ -21,14 +22,18 @@ import type { } from '@n8n/typeorm'; import { parse, stringify } from 'flatted'; import { GlobalConfig } from '@n8n/config'; +import { BinaryDataService } from 'n8n-core'; import { + ExecutionCancelledError, + ErrorReporterProxy as ErrorReporter, ApplicationError, - type ExecutionStatus, - type ExecutionSummary, - type IRunExecutionData, } from 'n8n-workflow'; -import { BinaryDataService } from 'n8n-core'; -import { ExecutionCancelledError, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; +import type { + AnnotationVote, + ExecutionStatus, + ExecutionSummary, + IRunExecutionData, +} from 'n8n-workflow'; import type { ExecutionPayload, @@ -46,6 +51,9 @@ import { Logger } from '@/logger'; import type { ExecutionSummaries } from '@/executions/execution.types'; import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error'; import { separate } from '@/utils'; +import { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; +import { AnnotationTagMapping } from '@/databases/entities/annotation-tag-mapping'; +import { ExecutionAnnotation } from '@/databases/entities/execution-annotation'; export interface IGetExecutionsQueryFilter { id?: FindOperator | string; @@ -201,10 +209,22 @@ export class ExecutionRepository extends Repository { ); } + private serializeAnnotation(annotation: ExecutionEntity['annotation']) { + if (!annotation) return null; + + const { id, vote, tags } = annotation; + return { + id, + vote, + tags: tags?.map((tag) => pick(tag, ['id', 'name'])) ?? [], + }; + } + async findSingleExecution( id: string, options?: { includeData: true; + includeAnnotation?: boolean; unflattenData: true; where?: FindOptionsWhere; }, @@ -213,6 +233,7 @@ export class ExecutionRepository extends Repository { id: string, options?: { includeData: true; + includeAnnotation?: boolean; unflattenData?: false | undefined; where?: FindOptionsWhere; }, @@ -221,6 +242,7 @@ export class ExecutionRepository extends Repository { id: string, options?: { includeData?: boolean; + includeAnnotation?: boolean; unflattenData?: boolean; where?: FindOptionsWhere; }, @@ -229,6 +251,7 @@ export class ExecutionRepository extends Repository { id: string, options?: { includeData?: boolean; + includeAnnotation?: boolean; unflattenData?: boolean; where?: FindOptionsWhere; }, @@ -240,7 +263,16 @@ export class ExecutionRepository extends Repository { }, }; if (options?.includeData) { - findOptions.relations = ['executionData', 'metadata']; + findOptions.relations = { executionData: true, metadata: true }; + } + + if (options?.includeAnnotation) { + findOptions.relations = { + ...findOptions.relations, + annotation: { + tags: true, + }, + }; } const execution = await this.findOne(findOptions); @@ -249,25 +281,21 @@ export class ExecutionRepository extends Repository { return undefined; } - const { executionData, metadata, ...rest } = execution; + const { executionData, metadata, annotation, ...rest } = execution; + const serializedAnnotation = this.serializeAnnotation(annotation); - if (options?.includeData && options?.unflattenData) { - return { - ...rest, - data: parse(execution.executionData.data) as IRunExecutionData, - workflowData: execution.executionData.workflowData, + return { + ...rest, + ...(options?.includeData && { + data: options?.unflattenData + ? (parse(executionData.data) as IRunExecutionData) + : executionData.data, + workflowData: executionData?.workflowData, customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), - } as IExecutionResponse; - } else if (options?.includeData) { - return { - ...rest, - data: execution.executionData.data, - workflowData: execution.executionData.workflowData, - customData: Object.fromEntries(metadata.map((m) => [m.key, m.value])), - } as IExecutionFlattedDb; - } - - return rest; + }), + ...(options?.includeAnnotation && + serializedAnnotation && { annotation: serializedAnnotation }), + }; } /** @@ -410,6 +438,13 @@ export class ExecutionRepository extends Repository { const maxAge = config.getEnv('executions.pruneDataMaxAge'); // in h const maxCount = config.getEnv('executions.pruneDataMaxCount'); + // Sub-query to exclude executions having annotations + const annotatedExecutionsSubQuery = this.manager + .createQueryBuilder() + .subQuery() + .select('annotation.executionId') + .from(ExecutionAnnotation, 'annotation'); + // Find ids of all executions that were stopped longer that pruneDataMaxAge ago const date = new Date(); date.setHours(date.getHours() - maxAge); @@ -420,12 +455,13 @@ export class ExecutionRepository extends Repository { ]; if (maxCount > 0) { - const executions = await this.find({ - select: ['id'], - skip: maxCount, - take: 1, - order: { id: 'DESC' }, - }); + const executions = await this.createQueryBuilder('execution') + .select('execution.id') + .where('execution.id NOT IN ' + annotatedExecutionsSubQuery.getQuery()) + .skip(maxCount) + .take(1) + .orderBy('execution.id', 'DESC') + .getMany(); if (executions[0]) { toPrune.push({ id: LessThanOrEqual(executions[0].id) }); @@ -442,6 +478,8 @@ export class ExecutionRepository extends Repository { // Only mark executions as deleted if they are in an end state status: Not(In(['new', 'running', 'waiting'])), }) + // Only mark executions as deleted if they are not annotated + .andWhere('id NOT IN ' + annotatedExecutionsSubQuery.getQuery()) .andWhere( new Brackets((qb) => countBasedWhere @@ -612,6 +650,7 @@ export class ExecutionRepository extends Repository { }, includeData: true, unflattenData: true, + includeAnnotation: true, }); } @@ -622,6 +661,7 @@ export class ExecutionRepository extends Repository { }, includeData: true, unflattenData: false, + includeAnnotation: true, }); } @@ -683,12 +723,80 @@ export class ExecutionRepository extends Repository { stoppedAt: true, }; + private annotationFields = { + id: true, + vote: true, + }; + + /** + * This function reduces duplicate rows in the raw result set of the query builder from *toQueryBuilderWithAnnotations* + * by merging the tags of the same execution annotation. + */ + private reduceExecutionsWithAnnotations( + rawExecutionsWithTags: Array< + ExecutionSummary & { + annotation_id: number; + annotation_vote: AnnotationVote; + annotation_tags_id: string; + annotation_tags_name: string; + } + >, + ) { + return rawExecutionsWithTags.reduce( + ( + acc, + { + annotation_id: _, + annotation_vote: vote, + annotation_tags_id: tagId, + annotation_tags_name: tagName, + ...row + }, + ) => { + const existingExecution = acc.find((e) => e.id === row.id); + + if (existingExecution) { + if (tagId) { + existingExecution.annotation = existingExecution.annotation ?? { + vote, + tags: [] as Array<{ id: string; name: string }>, + }; + existingExecution.annotation.tags.push({ id: tagId, name: tagName }); + } + } else { + acc.push({ + ...row, + annotation: { + vote, + tags: tagId ? [{ id: tagId, name: tagName }] : [], + }, + }); + } + return acc; + }, + [] as ExecutionSummary[], + ); + } + async findManyByRangeQuery(query: ExecutionSummaries.RangeQuery): Promise { if (query?.accessibleWorkflowIds?.length === 0) { throw new ApplicationError('Expected accessible workflow IDs'); } - const executions: ExecutionSummary[] = await this.toQueryBuilder(query).getRawMany(); + // Due to performance reasons, we use custom query builder with raw SQL. + // IMPORTANT: it produces duplicate rows for executions with multiple tags, which we need to reduce manually + const qb = this.toQueryBuilderWithAnnotations(query); + + const rawExecutionsWithTags: Array< + ExecutionSummary & { + annotation_id: number; + annotation_vote: AnnotationVote; + annotation_tags_id: string; + annotation_tags_name: string; + } + > = await qb.getRawMany(); + + const executions = this.reduceExecutionsWithAnnotations(rawExecutionsWithTags); return executions.map((execution) => this.toSummary(execution)); } @@ -764,6 +872,8 @@ export class ExecutionRepository extends Repository { startedBefore, startedAfter, metadata, + annotationTags, + vote, } = query; const fields = Object.keys(this.summaryFields) @@ -812,9 +922,62 @@ export class ExecutionRepository extends Repository { qb.setParameter('value', value); } + if (annotationTags?.length || vote) { + // If there is a filter by one or multiple tags or by vote - we need to join the annotations table + qb.innerJoin('execution.annotation', 'annotation'); + + // Add an inner join for each tag + if (annotationTags?.length) { + for (let index = 0; index < annotationTags.length; index++) { + qb.innerJoin( + AnnotationTagMapping, + `atm_${index}`, + `atm_${index}.annotationId = annotation.id AND atm_${index}.tagId = :tagId_${index}`, + ); + + qb.setParameter(`tagId_${index}`, annotationTags[index]); + } + } + + // Add filter by vote + if (vote) { + qb.andWhere('annotation.vote = :vote', { vote }); + } + } + return qb; } + /** + * This method is used to add the annotation fields to the executions query + * It uses original query builder as a subquery and adds the annotation fields to it + * IMPORTANT: Query made with this query builder fetches duplicate execution rows for each tag, + * this is intended, as we are working with raw query. + * The duplicates are reduced in the *reduceExecutionsWithAnnotations* method. + */ + private toQueryBuilderWithAnnotations(query: ExecutionSummaries.Query) { + const annotationFields = Object.keys(this.annotationFields).map( + (key) => `annotation.${key} AS "annotation_${key}"`, + ); + + const subQuery = this.toQueryBuilder(query).addSelect(annotationFields); + + // Ensure the join with annotations is made only once + // It might be already present as an inner join if the query includes filter by annotation tags + // If not, it must be added as a left join + if (!subQuery.expressionMap.joinAttributes.some((join) => join.alias.name === 'annotation')) { + subQuery.leftJoin('execution.annotation', 'annotation'); + } + + return this.manager + .createQueryBuilder() + .select(['e.*', 'ate.id AS "annotation_tags_id"', 'ate.name AS "annotation_tags_name"']) + .from(`(${subQuery.getQuery()})`, 'e') + .setParameters(subQuery.getParameters()) + .leftJoin(AnnotationTagMapping, 'atm', 'atm.annotationId = e.annotation_id') + .leftJoin(AnnotationTagEntity, 'ate', 'ate.id = atm.tagId'); + } + async getAllIds() { const executions = await this.find({ select: ['id'], order: { id: 'ASC' } }); diff --git a/packages/cli/src/executions/__tests__/execution.service.test.ts b/packages/cli/src/executions/__tests__/execution.service.test.ts index 7b2801f7795de..253e91e6d521e 100644 --- a/packages/cli/src/executions/__tests__/execution.service.test.ts +++ b/packages/cli/src/executions/__tests__/execution.service.test.ts @@ -25,6 +25,8 @@ describe('ExecutionService', () => { mock(), mock(), activeExecutions, + mock(), + mock(), executionRepository, mock(), mock(), diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index a546a7f9eec0d..ee25cd5c47af7 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -2,12 +2,12 @@ import { Container, Service } from 'typedi'; import { GlobalConfig } from '@n8n/config'; import { validate as jsonSchemaValidate } from 'jsonschema'; import type { - IWorkflowBase, ExecutionError, + ExecutionStatus, INode, IRunExecutionData, + IWorkflowBase, WorkflowExecuteMode, - ExecutionStatus, } from 'n8n-workflow'; import { ApplicationError, @@ -41,6 +41,8 @@ import { AbortedExecutionRetryError } from '@/errors/aborted-execution-retry.err import { License } from '@/license'; import type { User } from '@/databases/entities/user'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; +import { AnnotationTagMappingRepository } from '@/databases/repositories/annotation-tag-mapping.repository'; +import { ExecutionAnnotationRepository } from '@/databases/repositories/execution-annotation.repository'; export const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', @@ -60,6 +62,8 @@ export const schemaGetExecutionsQueryFilter = { metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } }, startedAfter: { type: 'date-time' }, startedBefore: { type: 'date-time' }, + annotationTags: { type: 'array', items: { type: 'string' } }, + vote: { type: 'string' }, }, $defs: { metadata: { @@ -85,6 +89,8 @@ export class ExecutionService { private readonly globalConfig: GlobalConfig, private readonly logger: Logger, private readonly activeExecutions: ActiveExecutions, + private readonly executionAnnotationRepository: ExecutionAnnotationRepository, + private readonly annotationTagMappingRepository: AnnotationTagMappingRepository, private readonly executionRepository: ExecutionRepository, private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, @@ -96,7 +102,7 @@ export class ExecutionService { ) {} async findOne( - req: ExecutionRequest.GetOne, + req: ExecutionRequest.GetOne | ExecutionRequest.Update, sharedWorkflowIds: string[], ): Promise { if (!sharedWorkflowIds.length) return undefined; @@ -495,4 +501,42 @@ export class ExecutionService { s.scopes = scopes[s.workflowId] ?? []; } } + + public async annotate( + executionId: string, + updateData: ExecutionRequest.ExecutionUpdatePayload, + sharedWorkflowIds: string[], + ) { + // Check if user can access the execution + const execution = await this.executionRepository.findIfAccessible( + executionId, + sharedWorkflowIds, + ); + + if (!execution) { + this.logger.info('Attempt to read execution was blocked due to insufficient permissions', { + executionId, + }); + + throw new NotFoundError('Execution not found'); + } + + // Create or update execution annotation + await this.executionAnnotationRepository.upsert( + { execution: { id: executionId }, vote: updateData.vote }, + ['execution'], + ); + + // Upsert behavior differs for Postgres, MySQL and sqlite, + // so we need to fetch the annotation to get the ID + const annotation = await this.executionAnnotationRepository.findOneOrFail({ + where: { + execution: { id: executionId }, + }, + }); + + if (updateData.tags) { + await this.annotationTagMappingRepository.overwriteTags(annotation.id, updateData.tags); + } + } } diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts index d3d3c9fed8ce8..57379622c287f 100644 --- a/packages/cli/src/executions/execution.types.ts +++ b/packages/cli/src/executions/execution.types.ts @@ -2,6 +2,7 @@ import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { AuthenticatedRequest } from '@/requests'; import type { Scope } from '@n8n/permissions'; import type { + AnnotationVote, ExecutionStatus, ExecutionSummary, IDataObject, @@ -34,6 +35,11 @@ export declare namespace ExecutionRequest { }; } + type ExecutionUpdatePayload = { + tags?: string[]; + vote?: AnnotationVote | null; + }; + type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & { rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params }; @@ -45,6 +51,8 @@ export declare namespace ExecutionRequest { type Retry = AuthenticatedRequest; type Stop = AuthenticatedRequest; + + type Update = AuthenticatedRequest; } export namespace ExecutionSummaries { @@ -69,6 +77,8 @@ export namespace ExecutionSummaries { metadata: Array<{ key: string; value: string }>; startedAfter: string; startedBefore: string; + annotationTags: string[]; // tag IDs + vote: AnnotationVote; }>; type AccessFields = { diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 233aea6064052..6979ff90d91bf 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -1,6 +1,7 @@ import { ExecutionRequest, type ExecutionSummaries } from './execution.types'; import { ExecutionService } from './execution.service'; -import { Get, Post, RestController } from '@/decorators'; +import { validateExecutionUpdatePayload } from './validation'; +import { Get, Patch, Post, RestController } from '@/decorators'; import { EnterpriseExecutionsService } from './execution.service.ee'; import { License } from '@/license'; import { WorkflowSharingService } from '@/workflows/workflow-sharing.service'; @@ -47,7 +48,10 @@ export class ExecutionsController { query.accessibleWorkflowIds = accessibleWorkflowIds; - if (!this.license.isAdvancedExecutionFiltersEnabled()) delete query.metadata; + if (!this.license.isAdvancedExecutionFiltersEnabled()) { + delete query.metadata; + delete query.annotationTags; + } const noStatus = !query.status || query.status.length === 0; const noRange = !query.range.lastId || !query.range.firstId; @@ -110,4 +114,23 @@ export class ExecutionsController { return await this.executionService.delete(req, workflowIds); } + + @Patch('/:id') + async update(req: ExecutionRequest.Update) { + if (!isPositiveInteger(req.params.id)) { + throw new BadRequestError('Execution ID is not a number'); + } + + const workflowIds = await this.getAccessibleWorkflowIds(req.user, 'workflow:read'); + + // Fail fast if no workflows are accessible + if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + + const { body: payload } = req; + const validatedPayload = validateExecutionUpdatePayload(payload); + + await this.executionService.annotate(req.params.id, validatedPayload, workflowIds); + + return await this.executionService.findOne(req, workflowIds); + } } diff --git a/packages/cli/src/executions/validation.ts b/packages/cli/src/executions/validation.ts new file mode 100644 index 0000000000000..243c3c78b2c73 --- /dev/null +++ b/packages/cli/src/executions/validation.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import type { ExecutionRequest } from '@/executions/execution.types'; + +const executionUpdateSchema = z.object({ + tags: z.array(z.string()).optional(), + vote: z.enum(['up', 'down']).nullable().optional(), +}); + +export function validateExecutionUpdatePayload( + payload: unknown, +): ExecutionRequest.ExecutionUpdatePayload { + try { + const validatedPayload = executionUpdateSchema.parse(payload); + + // Additional check to ensure that at least one property is provided + const { tags, vote } = validatedPayload; + if (!tags && vote === undefined) { + throw new BadRequestError('No annotation provided'); + } + + return validatedPayload; + } catch (e) { + if (e instanceof z.ZodError) { + throw new BadRequestError(e.message); + } + + throw e; + } +} diff --git a/packages/cli/src/generic-helpers.ts b/packages/cli/src/generic-helpers.ts index 459af55923a92..7010e0d4dcb6e 100644 --- a/packages/cli/src/generic-helpers.ts +++ b/packages/cli/src/generic-helpers.ts @@ -1,6 +1,7 @@ import { validate } from 'class-validator'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { CredentialsEntity } from '@/databases/entities/credentials-entity'; +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; import type { TagEntity } from '@/databases/entities/tag-entity'; import type { User } from '@/databases/entities/user'; import type { @@ -16,6 +17,7 @@ export async function validateEntity( | WorkflowEntity | CredentialsEntity | TagEntity + | AnnotationTagEntity | User | UserUpdatePayload | UserRoleChangePayload diff --git a/packages/cli/src/interfaces.ts b/packages/cli/src/interfaces.ts index 68b4483beb2c4..78a597e5b3977 100644 --- a/packages/cli/src/interfaces.ts +++ b/packages/cli/src/interfaces.ts @@ -31,6 +31,7 @@ import type { WorkflowExecute } from 'n8n-core'; import type PCancelable from 'p-cancelable'; +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; import type { AuthProviderType } from '@/databases/entities/auth-identity'; import type { SharedCredentials } from '@/databases/entities/shared-credentials'; import type { TagEntity } from '@/databases/entities/tag-entity'; @@ -57,9 +58,12 @@ export interface ICredentialsOverwrite { // tags // ---------------------------------- -export interface ITagToImport { +export interface ITagBase { id: string; name: string; +} + +export interface ITagToImport extends ITagBase { createdAt?: string; updatedAt?: string; } @@ -68,8 +72,13 @@ export type UsageCount = { usageCount: number; }; -export type ITagWithCountDb = Pick & - UsageCount; +export type ITagDb = Pick; + +export type ITagWithCountDb = ITagDb & UsageCount; + +export type IAnnotationTagDb = Pick; + +export type IAnnotationTagWithCountDb = IAnnotationTagDb & UsageCount; // ---------------------------------- // workflows @@ -145,6 +154,9 @@ export interface IExecutionResponse extends IExecutionBase { retrySuccessId?: string; workflowData: IWorkflowBase | WorkflowWithSharingsAndCredentials; customData: Record; + annotation: { + tags: ITagBase[]; + }; } // Flatted data to save memory when saving in database or transferring diff --git a/packages/cli/src/permissions/global-roles.ts b/packages/cli/src/permissions/global-roles.ts index ad930dfdd21d8..664cd8384ea7e 100644 --- a/packages/cli/src/permissions/global-roles.ts +++ b/packages/cli/src/permissions/global-roles.ts @@ -1,6 +1,11 @@ import type { Scope } from '@n8n/permissions'; export const GLOBAL_OWNER_SCOPES: Scope[] = [ + 'annotationTag:create', + 'annotationTag:read', + 'annotationTag:update', + 'annotationTag:delete', + 'annotationTag:list', 'auditLogs:manage', 'banner:dismiss', 'credential:create', @@ -75,6 +80,11 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); export const GLOBAL_MEMBER_SCOPES: Scope[] = [ + 'annotationTag:create', + 'annotationTag:read', + 'annotationTag:update', + 'annotationTag:delete', + 'annotationTag:list', 'eventBusDestination:list', 'eventBusDestination:test', 'tag:create', diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index 6340cf8b532ff..a6afe6afaf6c5 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -448,6 +448,17 @@ export declare namespace TagsRequest { type Delete = AuthenticatedRequest<{ id: string }>; } +// ---------------------------------- +// /annotation-tags +// ---------------------------------- + +export declare namespace AnnotationTagsRequest { + type GetAll = AuthenticatedRequest<{}, {}, {}, { withUsageCount: string }>; + type Create = AuthenticatedRequest<{}, {}, { name: string }>; + type Update = AuthenticatedRequest<{ id: string }, {}, { name: string }>; + type Delete = AuthenticatedRequest<{ id: string }>; +} + // ---------------------------------- // /nodes // ---------------------------------- diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 29e60d37762f9..9176402a8898c 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -38,6 +38,7 @@ import { OrchestrationService } from '@/services/orchestration.service'; import { LogStreamingEventRelay } from '@/events/log-streaming-event-relay'; import '@/controllers/active-workflows.controller'; +import '@/controllers/annotation-tags.controller'; import '@/controllers/auth.controller'; import '@/controllers/binary-data.controller'; import '@/controllers/curl.controller'; diff --git a/packages/cli/src/services/annotation-tag.service.ts b/packages/cli/src/services/annotation-tag.service.ts new file mode 100644 index 0000000000000..7b28b399822af --- /dev/null +++ b/packages/cli/src/services/annotation-tag.service.ts @@ -0,0 +1,52 @@ +import { Service } from 'typedi'; +import { validateEntity } from '@/generic-helpers'; +import type { IAnnotationTagDb, IAnnotationTagWithCountDb } from '@/interfaces'; +import type { AnnotationTagEntity } from '@/databases/entities/annotation-tag-entity'; +import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository'; + +type GetAllResult = T extends { withUsageCount: true } + ? IAnnotationTagWithCountDb[] + : IAnnotationTagDb[]; + +@Service() +export class AnnotationTagService { + constructor(private tagRepository: AnnotationTagRepository) {} + + toEntity(attrs: { name: string; id?: string }) { + attrs.name = attrs.name.trim(); + + return this.tagRepository.create(attrs); + } + + async save(tag: AnnotationTagEntity) { + await validateEntity(tag); + + return await this.tagRepository.save(tag, { transaction: false }); + } + + async delete(id: string) { + return await this.tagRepository.delete(id); + } + + async getAll(options?: T): Promise> { + if (options?.withUsageCount) { + const allTags = await this.tagRepository.find({ + select: ['id', 'name', 'createdAt', 'updatedAt'], + relations: ['annotationMappings'], + }); + + return allTags.map(({ annotationMappings, ...rest }) => { + return { + ...rest, + usageCount: annotationMappings.length, + } as IAnnotationTagWithCountDb; + }) as GetAllResult; + } + + const allTags = (await this.tagRepository.find({ + select: ['id', 'name', 'createdAt', 'updatedAt'], + })) as IAnnotationTagDb[]; + + return allTags as GetAllResult; + } +} diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts index 3130fea5a186d..72240c33d5461 100644 --- a/packages/cli/test/integration/execution.service.integration.test.ts +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -3,7 +3,7 @@ import { ExecutionService } from '@/executions/execution.service'; import { mock } from 'jest-mock-extended'; import Container from 'typedi'; import { createWorkflow } from './shared/db/workflows'; -import { createExecution } from './shared/db/executions'; +import { annotateExecution, createAnnotationTags, createExecution } from './shared/db/executions'; import * as testDb from './shared/test-db'; import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; import type { ExecutionSummaries } from '@/executions/execution.types'; @@ -19,6 +19,8 @@ describe('ExecutionService', () => { executionRepository = Container.get(ExecutionRepository); executionService = new ExecutionService( + mock(), + mock(), mock(), mock(), mock(), @@ -70,6 +72,10 @@ describe('ExecutionService', () => { waitTill: null, retrySuccessId: null, workflowName: expect.any(String), + annotation: { + tags: expect.arrayContaining([]), + vote: null, + }, }; expect(output.count).toBe(2); @@ -462,4 +468,201 @@ describe('ExecutionService', () => { expect(results[1].status).toBe('running'); }); }); + + describe('annotation', () => { + const summaryShape = { + id: expect.any(String), + workflowId: expect.any(String), + mode: expect.any(String), + retryOf: null, + status: expect.any(String), + startedAt: expect.any(String), + stoppedAt: expect.any(String), + waitTill: null, + retrySuccessId: null, + workflowName: expect.any(String), + }; + + afterEach(async () => { + await testDb.truncate(['AnnotationTag', 'ExecutionAnnotation']); + }); + + test('should add and retrieve annotation', async () => { + const workflow = await createWorkflow(); + + const execution1 = await createExecution({ status: 'success' }, workflow); + const execution2 = await createExecution({ status: 'success' }, workflow); + + const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']); + + await annotateExecution( + execution1.id, + { vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] }, + [workflow.id], + ); + await annotateExecution(execution2.id, { vote: 'down', tags: [annotationTags[2].id] }, [ + workflow.id, + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + status: ['success'], + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(2); + expect(output.estimated).toBe(false); + expect(output.results).toEqual( + expect.arrayContaining([ + { + ...summaryShape, + annotation: { + tags: [expect.objectContaining({ name: 'tag3' })], + vote: 'down', + }, + }, + { + ...summaryShape, + annotation: { + tags: [ + expect.objectContaining({ name: 'tag1' }), + expect.objectContaining({ name: 'tag2' }), + ], + vote: 'up', + }, + }, + ]), + ); + }); + + test('should update annotation', async () => { + const workflow = await createWorkflow(); + + const execution = await createExecution({ status: 'success' }, workflow); + + const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']); + + await annotateExecution(execution.id, { vote: 'up', tags: [annotationTags[0].id] }, [ + workflow.id, + ]); + + await annotateExecution(execution.id, { vote: 'down', tags: [annotationTags[1].id] }, [ + workflow.id, + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + status: ['success'], + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([ + { + ...summaryShape, + annotation: { + tags: [expect.objectContaining({ name: 'tag2' })], + vote: 'down', + }, + }, + ]); + }); + + test('should filter by annotation tags', async () => { + const workflow = await createWorkflow(); + + const executions = await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + ]); + + const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']); + + await annotateExecution( + executions[0].id, + { vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] }, + [workflow.id], + ); + await annotateExecution(executions[1].id, { vote: 'down', tags: [annotationTags[2].id] }, [ + workflow.id, + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + status: ['success'], + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + annotationTags: [annotationTags[0].id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([ + { + ...summaryShape, + annotation: { + tags: [ + expect.objectContaining({ name: 'tag1' }), + expect.objectContaining({ name: 'tag2' }), + ], + vote: 'up', + }, + }, + ]); + }); + + test('should filter by annotation vote', async () => { + const workflow = await createWorkflow(); + + const executions = await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + ]); + + const annotationTags = await createAnnotationTags(['tag1', 'tag2', 'tag3']); + + await annotateExecution( + executions[0].id, + { vote: 'up', tags: [annotationTags[0].id, annotationTags[1].id] }, + [workflow.id], + ); + await annotateExecution(executions[1].id, { vote: 'down', tags: [annotationTags[2].id] }, [ + workflow.id, + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + status: ['success'], + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + vote: 'up', + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([ + { + ...summaryShape, + annotation: { + tags: [ + expect.objectContaining({ name: 'tag1' }), + expect.objectContaining({ name: 'tag2' }), + ], + vote: 'up', + }, + }, + ]); + }); + }); }); diff --git a/packages/cli/test/integration/pruning.service.test.ts b/packages/cli/test/integration/pruning.service.test.ts index f4257c148b4bf..36ec15e490419 100644 --- a/packages/cli/test/integration/pruning.service.test.ts +++ b/packages/cli/test/integration/pruning.service.test.ts @@ -13,7 +13,11 @@ import { Logger } from '@/logger'; import { mockInstance } from '../shared/mocking'; import { createWorkflow } from './shared/db/workflows'; -import { createExecution, createSuccessfulExecution } from './shared/db/executions'; +import { + annotateExecution, + createExecution, + createSuccessfulExecution, +} from './shared/db/executions'; import { mock } from 'jest-mock-extended'; describe('softDeleteOnPruningCycle()', () => { @@ -40,7 +44,7 @@ describe('softDeleteOnPruningCycle()', () => { }); beforeEach(async () => { - await testDb.truncate(['Execution']); + await testDb.truncate(['Execution', 'ExecutionAnnotation']); }); afterAll(async () => { @@ -138,6 +142,25 @@ describe('softDeleteOnPruningCycle()', () => { expect.objectContaining({ id: executions[1].id, deletedAt: null }), ]); }); + + test('should not prune annotated executions', async () => { + const executions = [ + await createSuccessfulExecution(workflow), + await createSuccessfulExecution(workflow), + await createSuccessfulExecution(workflow), + ]; + + await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]); + + await pruningService.softDeleteOnPruningCycle(); + + const result = await findAllExecutions(); + expect(result).toEqual([ + expect.objectContaining({ id: executions[0].id, deletedAt: null }), + expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }), + expect.objectContaining({ id: executions[2].id, deletedAt: null }), + ]); + }); }); describe('when EXECUTIONS_DATA_MAX_AGE is set', () => { @@ -226,5 +249,33 @@ describe('softDeleteOnPruningCycle()', () => { expect.objectContaining({ id: executions[1].id, deletedAt: null }), ]); }); + + test('should not prune annotated executions', async () => { + const executions = [ + await createExecution( + { finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' }, + workflow, + ), + await createExecution( + { finished: true, startedAt: yesterday, stoppedAt: yesterday, status: 'success' }, + workflow, + ), + await createExecution( + { finished: true, startedAt: now, stoppedAt: now, status: 'success' }, + workflow, + ), + ]; + + await annotateExecution(executions[0].id, { vote: 'up' }, [workflow.id]); + + await pruningService.softDeleteOnPruningCycle(); + + const result = await findAllExecutions(); + expect(result).toEqual([ + expect.objectContaining({ id: executions[0].id, deletedAt: null }), + expect.objectContaining({ id: executions[1].id, deletedAt: expect.any(Date) }), + expect.objectContaining({ id: executions[2].id, deletedAt: null }), + ]); + }); }); }); diff --git a/packages/cli/test/integration/shared/db/executions.ts b/packages/cli/test/integration/shared/db/executions.ts index 0409f68703b77..09010ece914e6 100644 --- a/packages/cli/test/integration/shared/db/executions.ts +++ b/packages/cli/test/integration/shared/db/executions.ts @@ -5,6 +5,13 @@ import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { ExecutionRepository } from '@/databases/repositories/execution.repository'; import { ExecutionDataRepository } from '@/databases/repositories/execution-data.repository'; import { ExecutionMetadataRepository } from '@/databases/repositories/execution-metadata.repository'; +import { ExecutionService } from '@/executions/execution.service'; +import type { AnnotationVote } from 'n8n-workflow'; +import { mockInstance } from '@test/mocking'; +import { Telemetry } from '@/telemetry'; +import { AnnotationTagRepository } from '@/databases/repositories/annotation-tag.repository'; + +mockInstance(Telemetry); export async function createManyExecutions( amount: number, @@ -85,6 +92,19 @@ export async function createWaitingExecution(workflow: WorkflowEntity) { ); } +export async function annotateExecution( + executionId: string, + annotation: { vote?: AnnotationVote | null; tags?: string[] }, + sharedWorkflowIds: string[], +) { + await Container.get(ExecutionService).annotate(executionId, annotation, sharedWorkflowIds); +} + export async function getAllExecutions() { return await Container.get(ExecutionRepository).find(); } + +export async function createAnnotationTags(annotationTags: string[]) { + const tagRepository = Container.get(AnnotationTagRepository); + return await tagRepository.save(annotationTags.map((name) => tagRepository.create({ name }))); +} diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index dc0b13b13dd98..06b1adb962500 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -48,11 +48,13 @@ export async function terminate() { // Can't use `Object.keys(entities)` here because some entities have a `Entity` suffix, while the repositories don't const repositories = [ + 'AnnotationTag', 'AuthIdentity', 'AuthProviderSyncHistory', 'Credentials', 'EventDestinations', 'Execution', + 'ExecutionAnnotation', 'ExecutionData', 'ExecutionMetadata', 'InstalledNodes', diff --git a/packages/cli/test/integration/shared/types.ts b/packages/cli/test/integration/shared/types.ts index e5292e4982297..60fb6860824c3 100644 --- a/packages/cli/test/integration/shared/types.ts +++ b/packages/cli/test/integration/shared/types.ts @@ -26,6 +26,7 @@ type EndpointGroup = | 'eventBus' | 'license' | 'variables' + | 'annotationTags' | 'tags' | 'externalSecrets' | 'mfa' diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index 5036d680b109a..a3cac9b4134c8 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -122,6 +122,10 @@ export const setupTestServer = ({ if (endpointGroups.length) { for (const group of endpointGroups) { switch (group) { + case 'annotationTags': + await import('@/controllers/annotation-tags.controller'); + break; + case 'credentials': await import('@/credentials/credentials.controller'); break; diff --git a/packages/design-system/src/components/N8nSelect/Select.vue b/packages/design-system/src/components/N8nSelect/Select.vue index 27779ff5eea27..8fc1223abb5c8 100644 --- a/packages/design-system/src/components/N8nSelect/Select.vue +++ b/packages/design-system/src/components/N8nSelect/Select.vue @@ -100,6 +100,7 @@ defineExpose({ focus, blur, focusOnInput, + innerSelect, }); diff --git a/packages/design-system/src/components/N8nTag/Tag.vue b/packages/design-system/src/components/N8nTag/Tag.vue index 92c725fb96d79..e3d8552219e1a 100644 --- a/packages/design-system/src/components/N8nTag/Tag.vue +++ b/packages/design-system/src/components/N8nTag/Tag.vue @@ -1,13 +1,16 @@ @@ -20,11 +23,15 @@ defineProps(); background-color: var(--color-background-base); border-radius: var(--border-radius-base); font-size: var(--font-size-2xs); - cursor: pointer; + transition: background-color 0.3s ease; - &:hover { - background-color: var(--color-background-medium); + &.clickable { + cursor: pointer; + + &:hover { + background-color: var(--color-background-medium); + } } } diff --git a/packages/design-system/src/components/N8nTags/Tags.vue b/packages/design-system/src/components/N8nTags/Tags.vue index dfc1b57060ee9..43ebb107e74b5 100644 --- a/packages/design-system/src/components/N8nTags/Tags.vue +++ b/packages/design-system/src/components/N8nTags/Tags.vue @@ -4,7 +4,7 @@ import N8nTag from '../N8nTag'; import N8nLink from '../N8nLink'; import { useI18n } from '../../composables/useI18n'; -export interface ITag { +interface ITag { id: string; name: string; } @@ -13,6 +13,7 @@ interface TagsProp { tags?: ITag[]; truncate?: boolean; truncateAt?: number; + clickable?: boolean; } defineOptions({ name: 'N8nTags' }); @@ -20,6 +21,7 @@ const props = withDefaults(defineProps(), { tags: () => [], truncate: false, truncateAt: 3, + clickable: true, }); const emit = defineEmits<{ @@ -53,6 +55,7 @@ const onExpand = () => { v-for="tag in visibleTags" :key="tag.id" :text="tag.name" + :clickable="clickable" @click="emit('click:tag', tag.id, $event)" /> ; startedAfter?: string; startedBefore?: string; + annotationTags?: string[]; + vote?: ExecutionFilterVote; }; export type SamlAttributeMapping = { diff --git a/packages/editor-ui/src/api/tags.ts b/packages/editor-ui/src/api/tags.ts index 7442a40ef16cf..0e429b64333d5 100644 --- a/packages/editor-ui/src/api/tags.ts +++ b/packages/editor-ui/src/api/tags.ts @@ -1,22 +1,32 @@ import type { IRestApiContext, ITag } from '@/Interface'; import { makeRestApiRequest } from '@/utils/apiUtils'; -export async function getTags(context: IRestApiContext, withUsageCount = false): Promise { - return await makeRestApiRequest(context, 'GET', '/tags', { withUsageCount }); -} - -export async function createTag(context: IRestApiContext, params: { name: string }): Promise { - return await makeRestApiRequest(context, 'POST', '/tags', params); -} +type TagsApiEndpoint = '/tags' | '/annotation-tags'; -export async function updateTag( - context: IRestApiContext, - id: string, - params: { name: string }, -): Promise { - return await makeRestApiRequest(context, 'PATCH', `/tags/${id}`, params); +export interface ITagsApi { + getTags: (context: IRestApiContext, withUsageCount?: boolean) => Promise; + createTag: (context: IRestApiContext, params: { name: string }) => Promise; + updateTag: (context: IRestApiContext, id: string, params: { name: string }) => Promise; + deleteTag: (context: IRestApiContext, id: string) => Promise; } -export async function deleteTag(context: IRestApiContext, id: string): Promise { - return await makeRestApiRequest(context, 'DELETE', `/tags/${id}`); +export function createTagsApi(endpoint: TagsApiEndpoint): ITagsApi { + return { + getTags: async (context: IRestApiContext, withUsageCount = false): Promise => { + return await makeRestApiRequest(context, 'GET', endpoint, { withUsageCount }); + }, + createTag: async (context: IRestApiContext, params: { name: string }): Promise => { + return await makeRestApiRequest(context, 'POST', endpoint, params); + }, + updateTag: async ( + context: IRestApiContext, + id: string, + params: { name: string }, + ): Promise => { + return await makeRestApiRequest(context, 'PATCH', `${endpoint}/${id}`, params); + }, + deleteTag: async (context: IRestApiContext, id: string): Promise => { + return await makeRestApiRequest(context, 'DELETE', `${endpoint}/${id}`); + }, + }; } diff --git a/packages/editor-ui/src/components/AnnotationTagsContainer.vue b/packages/editor-ui/src/components/AnnotationTagsContainer.vue new file mode 100644 index 0000000000000..a3d2e1c1d156c --- /dev/null +++ b/packages/editor-ui/src/components/AnnotationTagsContainer.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/editor-ui/src/components/AnnotationTagsDropdown.vue b/packages/editor-ui/src/components/AnnotationTagsDropdown.vue new file mode 100644 index 0000000000000..d98bda762721e --- /dev/null +++ b/packages/editor-ui/src/components/AnnotationTagsDropdown.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue b/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue index 0864d839110df..ce59b181f7541 100644 --- a/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue +++ b/packages/editor-ui/src/components/DuplicateWorkflowDialog.vue @@ -3,7 +3,7 @@ import { defineComponent } from 'vue'; import { mapStores } from 'pinia'; import { MAX_WORKFLOW_NAME_LENGTH, PLACEHOLDER_EMPTY_WORKFLOW_ID } from '@/constants'; import { useToast } from '@/composables/useToast'; -import TagsDropdown from '@/components/TagsDropdown.vue'; +import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue'; import Modal from '@/components/Modal.vue'; import { useSettingsStore } from '@/stores/settings.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; @@ -16,7 +16,7 @@ import { useRouter } from 'vue-router'; export default defineComponent({ name: 'DuplicateWorkflow', - components: { TagsDropdown, Modal }, + components: { WorkflowTagsDropdown, Modal }, props: ['modalName', 'isActive', 'data'], setup() { const router = useRouter(); @@ -167,7 +167,7 @@ export default defineComponent({ :placeholder="$locale.baseText('duplicateWorkflowDialog.enterWorkflowName')" :maxlength="MAX_WORKFLOW_NAME_LENGTH" /> - - - - + + + + + diff --git a/packages/editor-ui/src/components/TagsContainer.vue b/packages/editor-ui/src/components/TagsContainer.vue index 4eb4b92c2e50b..93e211bdc3efc 100644 --- a/packages/editor-ui/src/components/TagsContainer.vue +++ b/packages/editor-ui/src/components/TagsContainer.vue @@ -1,133 +1,121 @@ - diff --git a/packages/editor-ui/src/components/TagsDropdown.vue b/packages/editor-ui/src/components/TagsDropdown.vue index f9407606a9fb8..cae9c74627dfd 100644 --- a/packages/editor-ui/src/components/TagsDropdown.vue +++ b/packages/editor-ui/src/components/TagsDropdown.vue @@ -1,236 +1,194 @@ - diff --git a/packages/editor-ui/src/components/TagsManager/AnnotationTagsManager.vue b/packages/editor-ui/src/components/TagsManager/AnnotationTagsManager.vue new file mode 100644 index 0000000000000..a36bb19f076c5 --- /dev/null +++ b/packages/editor-ui/src/components/TagsManager/AnnotationTagsManager.vue @@ -0,0 +1,107 @@ + + + diff --git a/packages/editor-ui/src/components/TagsManager/NoTagsView.vue b/packages/editor-ui/src/components/TagsManager/NoTagsView.vue index 988a556305dca..c34ed41bd6439 100644 --- a/packages/editor-ui/src/components/TagsManager/NoTagsView.vue +++ b/packages/editor-ui/src/components/TagsManager/NoTagsView.vue @@ -1,3 +1,17 @@ + + diff --git a/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue b/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue index a6e6f7fe00c72..15ff038a3589d 100644 --- a/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue +++ b/packages/editor-ui/src/components/TagsManager/TagsView/TagsTable.vue @@ -2,8 +2,10 @@ import type { ElTable } from 'element-plus'; import { MAX_TAG_NAME_LENGTH } from '@/constants'; import type { ITagRow } from '@/Interface'; +import type { PropType } from 'vue'; import { defineComponent } from 'vue'; import type { N8nInput } from 'n8n-design-system'; +import type { BaseTextKey } from '@/plugins/i18n'; type TableRef = InstanceType; type N8nInputRef = InstanceType; @@ -13,7 +15,28 @@ const DELETE_TRANSITION_TIMEOUT = 100; export default defineComponent({ name: 'TagsTable', - props: ['rows', 'isLoading', 'newName', 'isSaving'], + props: { + rows: { + type: Array as () => ITagRow[], + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + newName: { + type: String, + required: true, + }, + isSaving: { + type: Boolean, + required: true, + }, + usageColumnTitleLocaleKey: { + type: String as PropType, + default: 'tagsTable.usage', + }, + }, data() { return { maxLength: MAX_TAG_NAME_LENGTH, @@ -139,7 +162,7 @@ export default defineComponent({ - + diff --git a/packages/editor-ui/src/composables/useExecutionHelpers.ts b/packages/editor-ui/src/composables/useExecutionHelpers.ts index 9740f8dc06639..a65182c475272 100644 --- a/packages/editor-ui/src/composables/useExecutionHelpers.ts +++ b/packages/editor-ui/src/composables/useExecutionHelpers.ts @@ -8,6 +8,7 @@ export interface IExecutionUIData { startTime: string; runningTime: string; showTimestamp: boolean; + tags: Array<{ id: string; name: string }>; } export function useExecutionHelpers() { @@ -20,6 +21,7 @@ export function useExecutionHelpers() { label: 'Status unknown', runningTime: '', showTimestamp: true, + tags: execution.annotation?.tags ?? [], }; if (execution.status === 'new') { diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 3f2771cd107f7..f44046139ebab 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -48,6 +48,7 @@ export const DELETE_USER_MODAL_KEY = 'deleteUser'; export const INVITE_USER_MODAL_KEY = 'inviteUser'; export const DUPLICATE_MODAL_KEY = 'duplicate'; export const TAGS_MANAGER_MODAL_KEY = 'tagsManager'; +export const ANNOTATION_TAGS_MANAGER_MODAL_KEY = 'annotationTagsManager'; export const VERSIONS_MODAL_KEY = 'versions'; export const WORKFLOW_SETTINGS_MODAL_KEY = 'settings'; export const WORKFLOW_LM_CHAT_MODAL_KEY = 'lmChat'; @@ -630,6 +631,7 @@ export const enum STORES { NODE_TYPES = 'nodeTypes', CREDENTIALS = 'credentials', TAGS = 'tags', + ANNOTATION_TAGS = 'annotationTags', VERSIONS = 'versions', NODE_CREATOR = 'nodeCreator', WEBHOOKS = 'webhooks', @@ -691,6 +693,8 @@ export const MORE_ONBOARDING_OPTIONS_EXPERIMENT = { variant: 'variant', }; +export const EXECUTION_ANNOTATION_EXPERIMENT = '023_execution_annotation'; + export const EXPERIMENTS_TO_TRACK = [ ASK_AI_EXPERIMENT.name, TEMPLATE_CREDENTIAL_SETUP_EXPERIMENT, diff --git a/packages/editor-ui/src/permissions.spec.ts b/packages/editor-ui/src/permissions.spec.ts index bef37aa9a8e2f..e7946ce421491 100644 --- a/packages/editor-ui/src/permissions.spec.ts +++ b/packages/editor-ui/src/permissions.spec.ts @@ -5,6 +5,7 @@ import type { Scope } from '@n8n/permissions'; describe('permissions', () => { it('getResourcePermissions for empty scopes', () => { expect(getResourcePermissions()).toEqual({ + annotationTag: {}, auditLogs: {}, banner: {}, communityPackage: {}, @@ -58,6 +59,7 @@ describe('permissions', () => { ]; const permissionRecord: PermissionsRecord = { + annotationTag: {}, auditLogs: {}, banner: {}, communityPackage: {}, diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 3fd9714b8e1c7..e1c12c350d7b1 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -28,6 +28,8 @@ "clientSecret": "Client Secret" } }, + "generic.annotations": "Annotations", + "generic.annotationData": "Highlighted data", "generic.any": "Any", "generic.cancel": "Cancel", "generic.close": "Close", @@ -51,6 +53,7 @@ "generic.beta": "beta", "generic.yes": "Yes", "generic.no": "No", + "generic.rating": "Rating", "generic.retry": "Retry", "generic.error": "Something went wrong", "generic.settings": "Settings", @@ -96,6 +99,9 @@ "activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.", "activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.", "activationModal.yourWorkflowWillNowRegularlyCheck": "Your workflow will now regularly check {serviceName} for events and trigger executions for them.", + "annotationTagsManager.manageTags": "Manage execution tags", + "annotationTagsView.usage": "Usage (all workflows)", + "annotationTagsView.inUse": "{count} execution | {count} executions", "auth.changePassword": "Change password", "auth.changePassword.currentPassword": "Current password", "auth.changePassword.mfaCode": "Two-factor code", @@ -759,12 +765,23 @@ "executionView.onPaste.title": "Cannot paste here", "executionView.onPaste.message": "This view is read-only. Switch to Workflow tab to be able to edit the current workflow", "executionView.notFound.message": "Execution with id '{executionId}' could not be found!", + "executionAnnotationView.data.notFound": "Show important data from executions here by adding an execution data node to your workflow", + "executionAnnotationView.vote.error": "Unable to save annotation vote", + "executionAnnotationView.tag.error": "Unable to save annotation tags", + "executionAnnotationView.addTag": "Add tag", + "executionAnnotationView.chooseOrCreateATag": "Choose or create a tag", + "executionsFilter.annotation.tags": "Execution tags", + "executionsFilter.annotation.rating": "Rating", + "executionsFilter.annotation.rating.all": "Any rating", + "executionsFilter.annotation.rating.good": "Good", + "executionsFilter.annotation.rating.bad": "Bad", + "executionsFilter.annotation.selectVoteFilter": "Select Rating", "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.savedData": "Highlighted data", "executionsFilter.savedDataKey": "Key", "executionsFilter.savedDataKeyPlaceholder": "ID", "executionsFilter.savedDataValue": "Value (exact match)", @@ -772,7 +789,7 @@ "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": "Filter executions by data you have saved in them using an ‘Execution Data’ node. {link}", "executionsFilter.customData.docsTooltip.link": "More info", "expressionEdit.anythingInside": "Anything inside ", "expressionEdit.isJavaScript": " is JavaScript.", @@ -965,6 +982,8 @@ "ndv.httpRequest.credentialOnly.docsNotice": "Use the {nodeName} docs to construct your request. We'll take care of the authentication part if you add a {nodeName} credential below.", "noTagsView.readyToOrganizeYourWorkflows": "Ready to organize your workflows?", "noTagsView.withWorkflowTagsYouReFree": "With workflow tags, you're free to create the perfect tagging system for your flows", + "noAnnotationTagsView.title": "Organize your executions", + "noAnnotationTagsView.description": "Execution tags help you label and identify different classes of execution. Plus once you tag an execution, it’s never deleted", "node.thisIsATriggerNode": "This is a Trigger node. Learn more", "node.activateDeactivateNode": "Activate/Deactivate Node", "node.changeColor": "Change color", @@ -1914,6 +1933,8 @@ "tagsManager.couldNotDeleteTag": "Could not delete tag", "tagsManager.done": "Done", "tagsManager.manageTags": "Manage tags", + "tagsManager.showError.onFetch.title": "Could not fetch tags", + "tagsManager.showError.onFetch.message": "A problem occurred when trying to fetch tags", "tagsManager.showError.onCreate.message": "A problem occurred when trying to create the tag '{escapedName}'", "tagsManager.showError.onCreate.title": "Could not create tag", "tagsManager.showError.onDelete.message": "A problem occurred when trying to delete the tag '{escapedName}'", diff --git a/packages/editor-ui/src/stores/executions.store.ts b/packages/editor-ui/src/stores/executions.store.ts index af3850fd11304..c96443ea41a36 100644 --- a/packages/editor-ui/src/stores/executions.store.ts +++ b/packages/editor-ui/src/stores/executions.store.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia'; import { computed, ref } from 'vue'; -import type { IDataObject, ExecutionSummary } from 'n8n-workflow'; +import type { IDataObject, ExecutionSummary, AnnotationVote } from 'n8n-workflow'; import type { ExecutionFilterType, ExecutionsQueryFilter, @@ -82,9 +82,12 @@ export const useExecutionsStore = defineStore('executions', () => { const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]); function addExecution(execution: ExecutionSummaryWithScopes) { - executionsById.value[execution.id] = { - ...execution, - mode: execution.mode, + executionsById.value = { + ...executionsById.value, + [execution.id]: { + ...execution, + mode: execution.mode, + }, }; } @@ -185,6 +188,24 @@ export const useExecutionsStore = defineStore('executions', () => { } } + async function annotateExecution( + id: string, + data: { tags?: string[]; vote?: AnnotationVote | null }, + ): Promise { + const updatedExecution: ExecutionSummaryWithScopes = await makeRestApiRequest( + rootStore.restApiContext, + 'PATCH', + `/executions/${id}`, + data, + ); + + addExecution(updatedExecution); + + if (updatedExecution.id === activeExecution.value?.id) { + activeExecution.value = updatedExecution; + } + } + async function stopCurrentExecution(executionId: string): Promise { return await makeRestApiRequest( rootStore.restApiContext, @@ -245,6 +266,7 @@ export const useExecutionsStore = defineStore('executions', () => { return { loading, + annotateExecution, executionsById, executions, executionsCount, diff --git a/packages/editor-ui/src/stores/rbac.store.ts b/packages/editor-ui/src/stores/rbac.store.ts index a45d0964ae521..d51bbc8538b86 100644 --- a/packages/editor-ui/src/stores/rbac.store.ts +++ b/packages/editor-ui/src/stores/rbac.store.ts @@ -14,6 +14,7 @@ export const useRBACStore = defineStore(STORES.RBAC, () => { const scopesByResourceId = ref>>({ workflow: {}, tag: {}, + annotationTag: {}, user: {}, credential: {}, variable: {}, diff --git a/packages/editor-ui/src/stores/tags.store.ts b/packages/editor-ui/src/stores/tags.store.ts index 4dab82a8cb711..fade74a39c03e 100644 --- a/packages/editor-ui/src/stores/tags.store.ts +++ b/packages/editor-ui/src/stores/tags.store.ts @@ -1,4 +1,4 @@ -import * as tagsApi from '@/api/tags'; +import { createTagsApi } from '@/api/tags'; import { STORES } from '@/constants'; import type { ITag } from '@/Interface'; import { defineStore } from 'pinia'; @@ -6,109 +6,129 @@ import { useRootStore } from './root.store'; import { computed, ref } from 'vue'; import { useWorkflowsStore } from './workflows.store'; -export const useTagsStore = defineStore(STORES.TAGS, () => { - const tagsById = ref>({}); - const loading = ref(false); - const fetchedAll = ref(false); - const fetchedUsageCount = ref(false); - - const rootStore = useRootStore(); - const workflowsStore = useWorkflowsStore(); - - // Computed - - const allTags = computed(() => { - return Object.values(tagsById.value).sort((a, b) => a.name.localeCompare(b.name)); - }); - - const isLoading = computed(() => loading.value); - - const hasTags = computed(() => Object.keys(tagsById.value).length > 0); - - // Methods - - const setAllTags = (loadedTags: ITag[]) => { - tagsById.value = loadedTags.reduce((accu: { [id: string]: ITag }, tag: ITag) => { - accu[tag.id] = tag; - - return accu; - }, {}); - fetchedAll.value = true; - }; - - const upsertTags = (toUpsertTags: ITag[]) => { - toUpsertTags.forEach((toUpsertTag) => { - const tagId = toUpsertTag.id; - const currentTag = tagsById.value[tagId]; - if (currentTag) { - const newTag = { - ...currentTag, - ...toUpsertTag, - }; - tagsById.value = { - ...tagsById.value, - [tagId]: newTag, - }; - } else { - tagsById.value = { - ...tagsById.value, - [tagId]: toUpsertTag, - }; - } - }); - }; - - const deleteTag = (id: string) => { - const { [id]: deleted, ...rest } = tagsById.value; - tagsById.value = rest; - }; - - const fetchAll = async (params?: { force?: boolean; withUsageCount?: boolean }) => { - const { force = false, withUsageCount = false } = params || {}; - if (!force && fetchedAll.value && fetchedUsageCount.value === withUsageCount) { - return Object.values(tagsById.value); - } - - loading.value = true; - const retrievedTags = await tagsApi.getTags(rootStore.restApiContext, Boolean(withUsageCount)); - setAllTags(retrievedTags); - loading.value = false; - return retrievedTags; - }; - - const create = async (name: string) => { - const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name }); - upsertTags([createdTag]); - return createdTag; - }; - - const rename = async ({ id, name }: { id: string; name: string }) => { - const updatedTag = await tagsApi.updateTag(rootStore.restApiContext, id, { name }); - upsertTags([updatedTag]); - return updatedTag; - }; - - const deleteTagById = async (id: string) => { - const deleted = await tagsApi.deleteTag(rootStore.restApiContext, id); - - if (deleted) { - deleteTag(id); - workflowsStore.removeWorkflowTagId(id); - } - - return deleted; - }; - - return { - allTags, - isLoading, - hasTags, - tagsById, - fetchAll, - create, - rename, - deleteTagById, - upsertTags, - deleteTag, - }; -}); +const apiMapping = { + [STORES.TAGS]: createTagsApi('/tags'), + [STORES.ANNOTATION_TAGS]: createTagsApi('/annotation-tags'), +}; + +const createTagsStore = (id: STORES.TAGS | STORES.ANNOTATION_TAGS) => { + const tagsApi = apiMapping[id]; + + return defineStore( + id, + () => { + const tagsById = ref>({}); + const loading = ref(false); + const fetchedAll = ref(false); + const fetchedUsageCount = ref(false); + + const rootStore = useRootStore(); + const workflowsStore = useWorkflowsStore(); + + // Computed + + const allTags = computed(() => { + return Object.values(tagsById.value).sort((a, b) => a.name.localeCompare(b.name)); + }); + + const isLoading = computed(() => loading.value); + + const hasTags = computed(() => Object.keys(tagsById.value).length > 0); + + // Methods + + const setAllTags = (loadedTags: ITag[]) => { + tagsById.value = loadedTags.reduce((accu: { [id: string]: ITag }, tag: ITag) => { + accu[tag.id] = tag; + + return accu; + }, {}); + fetchedAll.value = true; + }; + + const upsertTags = (toUpsertTags: ITag[]) => { + toUpsertTags.forEach((toUpsertTag) => { + const tagId = toUpsertTag.id; + const currentTag = tagsById.value[tagId]; + if (currentTag) { + const newTag = { + ...currentTag, + ...toUpsertTag, + }; + tagsById.value = { + ...tagsById.value, + [tagId]: newTag, + }; + } else { + tagsById.value = { + ...tagsById.value, + [tagId]: toUpsertTag, + }; + } + }); + }; + + const deleteTag = (id: string) => { + const { [id]: deleted, ...rest } = tagsById.value; + tagsById.value = rest; + }; + + const fetchAll = async (params?: { force?: boolean; withUsageCount?: boolean }) => { + const { force = false, withUsageCount = false } = params || {}; + if (!force && fetchedAll.value && fetchedUsageCount.value === withUsageCount) { + return Object.values(tagsById.value); + } + + loading.value = true; + const retrievedTags = await tagsApi.getTags( + rootStore.restApiContext, + Boolean(withUsageCount), + ); + setAllTags(retrievedTags); + loading.value = false; + return retrievedTags; + }; + + const create = async (name: string) => { + const createdTag = await tagsApi.createTag(rootStore.restApiContext, { name }); + upsertTags([createdTag]); + return createdTag; + }; + + const rename = async ({ id, name }: { id: string; name: string }) => { + const updatedTag = await tagsApi.updateTag(rootStore.restApiContext, id, { name }); + upsertTags([updatedTag]); + return updatedTag; + }; + + const deleteTagById = async (id: string) => { + const deleted = await tagsApi.deleteTag(rootStore.restApiContext, id); + + if (deleted) { + deleteTag(id); + workflowsStore.removeWorkflowTagId(id); + } + + return deleted; + }; + + return { + allTags, + isLoading, + hasTags, + tagsById, + fetchAll, + create, + rename, + deleteTagById, + upsertTags, + deleteTag, + }; + }, + {}, + ); +}; + +export const useTagsStore = createTagsStore(STORES.TAGS); + +export const useAnnotationTagsStore = createTagsStore(STORES.ANNOTATION_TAGS); diff --git a/packages/editor-ui/src/stores/ui.store.ts b/packages/editor-ui/src/stores/ui.store.ts index 6ade0750bef51..18cb3fbe37fce 100644 --- a/packages/editor-ui/src/stores/ui.store.ts +++ b/packages/editor-ui/src/stores/ui.store.ts @@ -19,6 +19,7 @@ import { PERSONALIZATION_MODAL_KEY, STORES, TAGS_MANAGER_MODAL_KEY, + ANNOTATION_TAGS_MANAGER_MODAL_KEY, NPS_SURVEY_MODAL_KEY, VERSIONS_MODAL_KEY, VIEWS, @@ -108,6 +109,7 @@ export const useUIStore = defineStore(STORES.UI, () => { PERSONALIZATION_MODAL_KEY, INVITE_USER_MODAL_KEY, TAGS_MANAGER_MODAL_KEY, + ANNOTATION_TAGS_MANAGER_MODAL_KEY, NPS_SURVEY_MODAL_KEY, VERSIONS_MODAL_KEY, WORKFLOW_LM_CHAT_MODAL_KEY, diff --git a/packages/editor-ui/src/utils/executionUtils.ts b/packages/editor-ui/src/utils/executionUtils.ts index db75c5687a540..ad2712572cd2c 100644 --- a/packages/editor-ui/src/utils/executionUtils.ts +++ b/packages/editor-ui/src/utils/executionUtils.ts @@ -9,7 +9,9 @@ export function getDefaultExecutionFilters(): ExecutionFilterType { startDate: '', endDate: '', tags: [], + annotationTags: [], metadata: [], + vote: 'all', }; } @@ -25,6 +27,14 @@ export const executionFilterToQueryFilter = ( queryFilter.tags = filter.tags; } + if (!isEmpty(filter.annotationTags)) { + queryFilter.annotationTags = filter.annotationTags; + } + + if (filter.vote !== 'all') { + queryFilter.vote = filter.vote; + } + if (!isEmpty(filter.metadata)) { queryFilter.metadata = filter.metadata; } @@ -54,6 +64,7 @@ export const executionFilterToQueryFilter = ( queryFilter.status = ['canceled']; break; } + return queryFilter; }; diff --git a/packages/editor-ui/src/views/WorkflowsView.vue b/packages/editor-ui/src/views/WorkflowsView.vue index 86df4f4408a32..3bc5a8b914bcb 100644 --- a/packages/editor-ui/src/views/WorkflowsView.vue +++ b/packages/editor-ui/src/views/WorkflowsView.vue @@ -2,9 +2,9 @@ import { defineComponent } from 'vue'; import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue'; import WorkflowCard from '@/components/WorkflowCard.vue'; +import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue'; import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; import type { ITag, IUser, IWorkflowDb } from '@/Interface'; -import TagsDropdown from '@/components/TagsDropdown.vue'; import { mapStores } from 'pinia'; import { useUIStore } from '@/stores/ui.store'; import { useSettingsStore } from '@/stores/settings.store'; @@ -36,7 +36,7 @@ const WorkflowsView = defineComponent({ components: { ResourcesListLayout, WorkflowCard, - TagsDropdown, + WorkflowTagsDropdown, ProjectTabs, }, data() { @@ -432,7 +432,7 @@ export default WorkflowsView; color="text-base" class="mb-3xs" /> - This feature is available on our Pro and Enterprise plans. More Info.", + "Save important data using this node. It will be displayed on each execution for easy reference and you can filter by it.
Filtering is available on Pro and Enterprise plans. More Info", name: 'notice', type: 'notice', default: '', @@ -38,9 +38,9 @@ export class ExecutionData implements INodeType { noDataExpression: true, options: [ { - name: 'Save Execution Data for Search', + name: 'Save Highlight Data (for Search/review)', value: 'save', - action: 'Save execution data for search', + action: 'Save Highlight Data (for search/review)', }, ], }, diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index e0ac8653819ac..d828fa58b4665 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2435,6 +2435,8 @@ export interface NodeExecutionWithMetadata extends INodeExecutionData { pairedItem: IPairedItemData | IPairedItemData[]; } +export type AnnotationVote = 'up' | 'down'; + export interface ExecutionSummary { id: string; finished?: boolean; @@ -2452,6 +2454,13 @@ export interface ExecutionSummary { nodeExecutionStatus?: { [key: string]: IExecutionSummaryNodeExecutionResult; }; + annotation?: { + vote: AnnotationVote; + tags: Array<{ + id: string; + name: string; + }>; + }; } export interface IExecutionSummaryNodeExecutionResult {