From a3eea3ac5e560172d62bd260b11a25dc9a5ee0ea Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Fri, 19 Apr 2024 07:50:18 +0200 Subject: [PATCH] feat(editor): Refactor and unify executions views (no-changelog) (#8538) --- cypress/e2e/20-workflow-executions.cy.ts | 5 +- cypress/e2e/28-debug.cy.ts | 11 +- .../e2e/30-editor-after-route-changes.cy.ts | 6 +- cypress/e2e/7-workflow-actions.cy.ts | 26 +- packages/cli/src/ActiveExecutions.ts | 3 + packages/cli/src/Interfaces.ts | 2 +- packages/cli/src/WaitTracker.ts | 4 +- .../repositories/execution.repository.ts | 310 +++-- .../postgres-live-rows-retrieval.error.ts | 7 + .../executions/active-execution.service.ts | 134 -- .../cli/src/executions/execution.service.ts | 206 +-- .../cli/src/executions/execution.types.ts | 55 +- .../cli/src/executions/executionHelpers.ts | 5 - .../src/executions/executions.controller.ts | 58 +- .../parse-range-query.middleware.ts | 56 + .../execution.service.integration.test.ts | 411 ++++++ .../unit/active-execution.service.test.ts | 127 -- .../controllers/executions.controller.test.ts | 173 ++- .../parse-range-query.middleware.test.ts | 178 +++ .../design-system/src/css/_tokens.dark.scss | 1 + packages/design-system/src/css/_tokens.scss | 1 + packages/editor-ui/src/Interface.ts | 1 - packages/editor-ui/src/api/workflows.ts | 4 +- .../src/components/ActivationModal.vue | 5 +- .../src/components/ExecutionsList.vue | 1203 ----------------- .../ExecutionsView/ExecutionsList.vue | 775 ----------- .../src/components/MainHeader/MainHeader.vue | 47 +- .../editor-ui/src/components/MainSidebar.vue | 2 +- .../src/components/WorkerList.ee.vue | 3 +- .../src/components/WorkflowPreview.vue | 8 +- .../__tests__/WorkflowPreview.test.ts | 8 +- .../ExecutionsFilter.test.ts} | 23 +- .../ExecutionsFilter.vue} | 7 +- .../ExecutionsTime.vue} | 2 +- .../{ => executions}/ExecutionsUsage.vue | 0 .../global/GlobalExecutionsList.test.ts} | 72 +- .../global/GlobalExecutionsList.vue | 550 ++++++++ .../global/GlobalExecutionsListItem.test.ts | 101 ++ .../global/GlobalExecutionsListItem.vue | 404 ++++++ .../workflow/WorkflowExecutionsCard.test.ts} | 6 +- .../workflow/WorkflowExecutionsCard.vue} | 43 +- .../WorkflowExecutionsInfoAccordion.vue} | 4 +- .../WorkflowExecutionsLandingPage.vue} | 6 +- .../workflow/WorkflowExecutionsList.vue | 208 +++ .../WorkflowExecutionsPreview.test.ts} | 14 +- .../workflow/WorkflowExecutionsPreview.vue} | 59 +- .../workflow/WorkflowExecutionsSidebar.vue} | 82 +- .../__tests__/useExecutionHelpers.test.ts | 50 + .../src/composables/useExecutionHelpers.ts | 70 + .../src/composables/useRunWorkflow.ts | 6 +- packages/editor-ui/src/constants.ts | 1 + .../editor-ui/src/mixins/executionsHelpers.ts | 85 -- packages/editor-ui/src/n8n-theme.scss | 2 +- .../src/plugins/i18n/locales/en.json | 7 +- packages/editor-ui/src/router.ts | 17 +- .../editor-ui/src/stores/executions.store.ts | 294 ++++ packages/editor-ui/src/stores/ui.store.ts | 1 - .../editor-ui/src/stores/workflows.store.ts | 46 +- .../editor-ui/src/styles/_animations.scss | 15 + packages/editor-ui/src/styles/index.scss | 3 + .../editor-ui/src/utils/executionUtils.ts | 13 +- .../editor-ui/src/views/ExecutionsView.vue | 98 +- packages/editor-ui/src/views/NodeView.vue | 77 +- .../src/views/WorkflowExecutionsView.vue | 327 +++++ packages/workflow/src/ExecutionStatus.ts | 23 +- 65 files changed, 3596 insertions(+), 2955 deletions(-) create mode 100644 packages/cli/src/errors/postgres-live-rows-retrieval.error.ts delete mode 100644 packages/cli/src/executions/active-execution.service.ts create mode 100644 packages/cli/src/executions/parse-range-query.middleware.ts create mode 100644 packages/cli/test/integration/execution.service.integration.test.ts delete mode 100644 packages/cli/test/unit/active-execution.service.test.ts create mode 100644 packages/cli/test/unit/middleware/executions/parse-range-query.middleware.test.ts delete mode 100644 packages/editor-ui/src/components/ExecutionsList.vue delete mode 100644 packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue rename packages/editor-ui/src/components/{__tests__/ExecutionFilter.test.ts => executions/ExecutionsFilter.test.ts} (88%) rename packages/editor-ui/src/components/{ExecutionFilter.vue => executions/ExecutionsFilter.vue} (98%) rename packages/editor-ui/src/components/{ExecutionTime.vue => executions/ExecutionsTime.vue} (97%) rename packages/editor-ui/src/components/{ => executions}/ExecutionsUsage.vue (100%) rename packages/editor-ui/src/components/{__tests__/ExecutionsList.test.ts => executions/global/GlobalExecutionsList.test.ts} (79%) create mode 100644 packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue create mode 100644 packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.test.ts create mode 100644 packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue rename packages/editor-ui/src/components/{ExecutionsView/__tests__/ExecutionCard.test.ts => executions/workflow/WorkflowExecutionsCard.test.ts} (84%) rename packages/editor-ui/src/components/{ExecutionsView/ExecutionCard.vue => executions/workflow/WorkflowExecutionsCard.vue} (82%) rename packages/editor-ui/src/components/{ExecutionsView/ExecutionsInfoAccordion.vue => executions/workflow/WorkflowExecutionsInfoAccordion.vue} (99%) rename packages/editor-ui/src/components/{ExecutionsView/ExecutionsLandingPage.vue => executions/workflow/WorkflowExecutionsLandingPage.vue} (94%) create mode 100644 packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsList.vue rename packages/editor-ui/src/components/{ExecutionsView/__tests__/ExecutionPreview.test.ts => executions/workflow/WorkflowExecutionsPreview.test.ts} (86%) rename packages/editor-ui/src/components/{ExecutionsView/ExecutionPreview.vue => executions/workflow/WorkflowExecutionsPreview.vue} (83%) rename packages/editor-ui/src/components/{ExecutionsView/ExecutionsSidebar.vue => executions/workflow/WorkflowExecutionsSidebar.vue} (73%) create mode 100644 packages/editor-ui/src/composables/__tests__/useExecutionHelpers.test.ts create mode 100644 packages/editor-ui/src/composables/useExecutionHelpers.ts delete mode 100644 packages/editor-ui/src/mixins/executionsHelpers.ts create mode 100644 packages/editor-ui/src/stores/executions.store.ts create mode 100644 packages/editor-ui/src/styles/_animations.scss create mode 100644 packages/editor-ui/src/styles/index.scss create mode 100644 packages/editor-ui/src/views/WorkflowExecutionsView.vue diff --git a/cypress/e2e/20-workflow-executions.cy.ts b/cypress/e2e/20-workflow-executions.cy.ts index 712927de97fd1..37036a7971d6e 100644 --- a/cypress/e2e/20-workflow-executions.cy.ts +++ b/cypress/e2e/20-workflow-executions.cy.ts @@ -16,11 +16,12 @@ describe('Current Workflow Executions', () => { it('should render executions tab correctly', () => { createMockExecutions(); cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); - cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); + + executionsTab.getters.executionsList().scrollTo(0, 500).wait(0); executionsTab.getters.executionListItems().should('have.length', 11); executionsTab.getters.successfulExecutionListItems().should('have.length', 9); diff --git a/cypress/e2e/28-debug.cy.ts b/cypress/e2e/28-debug.cy.ts index b022ce5ac6333..955d33ce28c28 100644 --- a/cypress/e2e/28-debug.cy.ts +++ b/cypress/e2e/28-debug.cy.ts @@ -19,7 +19,6 @@ describe('Debug', () => { it('should be able to debug executions', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); cy.signin({ email: INSTANCE_OWNER.email, password: INSTANCE_OWNER.password }); @@ -41,7 +40,7 @@ describe('Debug', () => { executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); executionsTab.getters.executionDebugButton().should('have.text', 'Debug in editor').click(); cy.url().should('include', '/debug'); @@ -66,7 +65,7 @@ describe('Debug', () => { executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); executionsTab.getters.executionListItems().should('have.length', 2).first().click(); cy.wait(['@getExecution']); @@ -77,7 +76,7 @@ describe('Debug', () => { confirmDialog.find('li').should('have.length', 2); confirmDialog.get('.btn--cancel').click(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); executionsTab.getters.executionListItems().should('have.length', 2).first().click(); cy.wait(['@getExecution']); @@ -108,7 +107,7 @@ describe('Debug', () => { cy.url().should('not.include', '/debug'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); confirmDialog = cy.get('.matching-pinned-nodes-confirmation').filter(':visible'); @@ -130,7 +129,7 @@ describe('Debug', () => { workflowPage.actions.deleteNode(IF_NODE_NAME); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); executionsTab.getters.executionListItems().should('have.length', 3).first().click(); cy.wait(['@getExecution']); executionsTab.getters.executionDebugButton().should('have.text', 'Copy to editor').click(); diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 733753314b5ff..727078e7352c9 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -136,10 +136,9 @@ describe('Editor actions should work', () => { it('after switching between Editor and Executions', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); - cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); cy.wait(500); executionsTab.actions.switchToEditorTab(); editWorkflowAndDeactivate(); @@ -149,7 +148,6 @@ describe('Editor actions should work', () => { it('after switching between Editor and Debug', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); cy.intercept('GET', '/rest/executions/*').as('getExecution'); - cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); cy.intercept('POST', '/rest/workflows/run').as('postWorkflowRun'); editWorkflowAndDeactivate(); @@ -157,7 +155,7 @@ describe('Editor actions should work', () => { cy.wait(['@postWorkflowRun']); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); executionsTab.getters.executionListItems().should('have.length', 1).first().click(); cy.wait(['@getExecution']); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index 04dc441c14794..794e2ee605821 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -111,16 +111,19 @@ describe('Workflow Actions', () => { // This happens when users click save button from workflow name input // In this case blur on the input saves the workflow and then click on the button saves it again WorkflowPage.actions.visit(); - WorkflowPage.getters.workflowNameInput().invoke('val').then((oldName) => { - WorkflowPage.getters.workflowNameInputContainer().click(); - WorkflowPage.getters.workflowNameInput().type('{selectall}'); - WorkflowPage.getters.workflowNameInput().type('Test'); - WorkflowPage.getters.saveButton().click(); - WorkflowPage.getters.workflowNameInput().should('have.value', 'Test'); - cy.visit(WorkflowPages.url); - // There should be no workflow with the old name (duplicate save) - WorkflowPages.getters.workflowCards().contains(String(oldName)).should('not.exist'); - }); + WorkflowPage.getters + .workflowNameInput() + .invoke('val') + .then((oldName) => { + WorkflowPage.getters.workflowNameInputContainer().click(); + WorkflowPage.getters.workflowNameInput().type('{selectall}'); + WorkflowPage.getters.workflowNameInput().type('Test'); + WorkflowPage.getters.saveButton().click(); + WorkflowPage.getters.workflowNameInput().should('have.value', 'Test'); + cy.visit(WorkflowPages.url); + // There should be no workflow with the old name (duplicate save) + WorkflowPages.getters.workflowCards().contains(String(oldName)).should('not.exist'); + }); }); it('should copy nodes', () => { @@ -252,7 +255,6 @@ describe('Workflow Actions', () => { it('should keep endpoint click working when switching between execution and editor tab', () => { cy.intercept('GET', '/rest/executions?filter=*').as('getExecutions'); - cy.intercept('GET', '/rest/executions/active?filter=*').as('getActiveExecutions'); WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); @@ -263,7 +265,7 @@ describe('Workflow Actions', () => { cy.get('body').type('{esc}'); executionsTab.actions.switchToExecutionsTab(); - cy.wait(['@getExecutions', '@getActiveExecutions']); + cy.wait(['@getExecutions']); cy.wait(500); executionsTab.actions.switchToEditorTab(); diff --git a/packages/cli/src/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index fdb97b6867e9c..e799dba13e65c 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -21,6 +21,9 @@ import { Logger } from '@/Logger'; @Service() export class ActiveExecutions { + /** + * Active executions in the current process, not globally. + */ private activeExecutions: { [executionId: string]: IExecutingWorkflowData; } = {}; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 290c65e79b787..c0048d4f551b3 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -171,7 +171,7 @@ export interface IExecutionsListResponse { estimated: boolean; } -export interface IExecutionsStopData { +export interface ExecutionStopResult { finished?: boolean; mode: WorkflowExecuteMode; startedAt: Date; diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index af71c22c49d71..6b79d10a9bd7b 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -4,7 +4,7 @@ import { WorkflowOperationError, } from 'n8n-workflow'; import { Container, Service } from 'typedi'; -import type { IExecutionsStopData, IWorkflowExecutionDataProcess } from '@/Interfaces'; +import type { ExecutionStopResult, IWorkflowExecutionDataProcess } from '@/Interfaces'; import { WorkflowRunner } from '@/WorkflowRunner'; import { ExecutionRepository } from '@db/repositories/execution.repository'; import { OwnershipService } from '@/services/ownership.service'; @@ -99,7 +99,7 @@ export class WaitTracker { } } - async stopExecution(executionId: string): Promise { + async stopExecution(executionId: string): Promise { if (this.waitingExecutions[executionId] !== undefined) { // The waiting execution was already scheduled to execute. // So stop timer and remove. diff --git a/packages/cli/src/databases/repositories/execution.repository.ts b/packages/cli/src/databases/repositories/execution.repository.ts index 2c962f28bb8a1..8043ee5e5d396 100644 --- a/packages/cli/src/databases/repositories/execution.repository.ts +++ b/packages/cli/src/databases/repositories/execution.repository.ts @@ -41,7 +41,22 @@ import { ExecutionEntity } from '../entities/ExecutionEntity'; import { ExecutionMetadata } from '../entities/ExecutionMetadata'; import { ExecutionDataRepository } from './executionData.repository'; import { Logger } from '@/Logger'; -import type { GetManyActiveFilter } from '@/executions/execution.types'; +import type { ExecutionSummaries } from '@/executions/execution.types'; +import { PostgresLiveRowsRetrievalError } from '@/errors/postgres-live-rows-retrieval.error'; + +export interface IGetExecutionsQueryFilter { + id?: FindOperator | string; + finished?: boolean; + mode?: string; + retryOf?: string; + retrySuccessId?: string; + status?: ExecutionStatus[]; + workflowId?: string; + waitTill?: FindOperator | boolean; + metadata?: Array<{ key: string; value: string }>; + startedAfter?: string; + startedBefore?: string; +} function parseFiltersToQueryBuilder( qb: SelectQueryBuilder, @@ -82,6 +97,14 @@ function parseFiltersToQueryBuilder( } } +const lessThanOrEqual = (date: string): unknown => { + return LessThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(new Date(date))); +}; + +const moreThanOrEqual = (date: string): unknown => { + return MoreThanOrEqual(DateUtils.mixedDateToUtcDatetimeString(new Date(date))); +}; + @Service() export class ExecutionRepository extends Repository { private hardDeletionBatchSize = 100; @@ -284,114 +307,6 @@ export class ExecutionRepository extends Repository { } } - async countExecutions( - filters: IGetExecutionsQueryFilter | undefined, - accessibleWorkflowIds: string[], - currentlyRunningExecutions: string[], - hasGlobalRead: boolean, - ): Promise<{ count: number; estimated: boolean }> { - const dbType = config.getEnv('database.type'); - if (dbType !== 'postgresdb' || (filters && Object.keys(filters).length > 0) || !hasGlobalRead) { - const query = this.createQueryBuilder('execution').andWhere( - 'execution.workflowId IN (:...accessibleWorkflowIds)', - { accessibleWorkflowIds }, - ); - if (currentlyRunningExecutions.length > 0) { - query.andWhere('execution.id NOT IN (:...currentlyRunningExecutions)', { - currentlyRunningExecutions, - }); - } - - parseFiltersToQueryBuilder(query, filters); - - const count = await query.getCount(); - return { count, estimated: false }; - } - - try { - // Get an estimate of rows count. - const estimateRowsNumberSql = - "SELECT n_live_tup FROM pg_stat_all_tables WHERE relname = 'execution_entity';"; - const rows = (await this.query(estimateRowsNumberSql)) as Array<{ n_live_tup: string }>; - - const estimate = parseInt(rows[0].n_live_tup, 10); - // If over 100k, return just an estimate. - if (estimate > 100_000) { - // if less than 100k, we get the real count as even a full - // table scan should not take so long. - return { count: estimate, estimated: true }; - } - } catch (error) { - if (error instanceof Error) { - this.logger.warn(`Failed to get executions count from Postgres: ${error.message}`, { - error, - }); - } - } - - const count = await this.count({ - where: { - workflowId: In(accessibleWorkflowIds), - }, - }); - - return { count, estimated: false }; - } - - async searchExecutions( - filters: IGetExecutionsQueryFilter | undefined, - limit: number, - excludedExecutionIds: string[], - accessibleWorkflowIds: string[], - additionalFilters?: { lastId?: string; firstId?: string }, - ): Promise { - if (accessibleWorkflowIds.length === 0) { - return []; - } - const query = this.createQueryBuilder('execution') - .select([ - 'execution.id', - 'execution.finished', - 'execution.mode', - 'execution.retryOf', - 'execution.retrySuccessId', - 'execution.status', - 'execution.startedAt', - 'execution.stoppedAt', - 'execution.workflowId', - 'execution.waitTill', - 'workflow.name', - ]) - .innerJoin('execution.workflow', 'workflow') - .limit(limit) - // eslint-disable-next-line @typescript-eslint/naming-convention - .orderBy({ 'execution.id': 'DESC' }) - .andWhere('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds }); - if (excludedExecutionIds.length > 0) { - query.andWhere('execution.id NOT IN (:...excludedExecutionIds)', { excludedExecutionIds }); - } - - if (additionalFilters?.lastId) { - query.andWhere('execution.id < :lastId', { lastId: additionalFilters.lastId }); - } - if (additionalFilters?.firstId) { - query.andWhere('execution.id > :firstId', { firstId: additionalFilters.firstId }); - } - - parseFiltersToQueryBuilder(query, filters); - - const executions = await query.getMany(); - - return executions.map((execution) => { - const { workflow, waitTill, ...rest } = execution; - return { - ...rest, - waitTill: waitTill ?? undefined, - workflowName: workflow.name, - }; - }); - } - async deleteExecutionsByFilter( filters: IGetExecutionsQueryFilter | undefined, accessibleWorkflowIds: string[], @@ -682,52 +597,151 @@ export class ExecutionRepository extends Repository { }); } - async getManyActive( - activeExecutionIds: string[], - accessibleWorkflowIds: string[], - filter?: GetManyActiveFilter, - ) { - const where: FindOptionsWhere = { - id: In(activeExecutionIds), - status: Not(In(['finished', 'stopped', 'error', 'crashed'])), + // ---------------------------------- + // new API + // ---------------------------------- + + /** + * Fields to include in the summary of an execution when querying for many. + */ + private summaryFields = { + id: true, + workflowId: true, + mode: true, + retryOf: true, + status: true, + startedAt: true, + stoppedAt: true, + }; + + 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(); + + return executions.map((execution) => this.toSummary(execution)); + } + + // @tech_debt: These transformations should not be needed + private toSummary(execution: { + id: number | string; + startedAt?: Date | string; + stoppedAt?: Date | string; + waitTill?: Date | string | null; + }): ExecutionSummary { + execution.id = execution.id.toString(); + + const normalizeDateString = (date: string) => { + if (date.includes(' ')) return date.replace(' ', 'T') + 'Z'; + return date; }; - if (filter) { - const { workflowId, status, finished } = filter; - if (workflowId && accessibleWorkflowIds.includes(workflowId)) { - where.workflowId = workflowId; + if (execution.startedAt) { + execution.startedAt = + execution.startedAt instanceof Date + ? execution.startedAt.toISOString() + : normalizeDateString(execution.startedAt); + } + + if (execution.waitTill) { + execution.waitTill = + execution.waitTill instanceof Date + ? execution.waitTill.toISOString() + : normalizeDateString(execution.waitTill); + } + + if (execution.stoppedAt) { + execution.stoppedAt = + execution.stoppedAt instanceof Date + ? execution.stoppedAt.toISOString() + : normalizeDateString(execution.stoppedAt); + } + + return execution as ExecutionSummary; + } + + async fetchCount(query: ExecutionSummaries.CountQuery) { + return await this.toQueryBuilder(query).getCount(); + } + + async getLiveExecutionRowsOnPostgres() { + const tableName = `${config.getEnv('database.tablePrefix')}execution_entity`; + + const pgSql = `SELECT n_live_tup as result FROM pg_stat_all_tables WHERE relname = '${tableName}';`; + + try { + const rows = (await this.query(pgSql)) as Array<{ result: string }>; + + if (rows.length !== 1) throw new PostgresLiveRowsRetrievalError(rows); + + const [row] = rows; + + return parseInt(row.result, 10); + } catch (error) { + if (error instanceof Error) this.logger.error(error.message, { error }); + + return -1; + } + } + + private toQueryBuilder(query: ExecutionSummaries.Query) { + const { + accessibleWorkflowIds, + status, + finished, + workflowId, + startedBefore, + startedAfter, + metadata, + } = query; + + const fields = Object.keys(this.summaryFields) + .concat(['waitTill', 'retrySuccessId']) + .map((key) => `execution.${key} AS "${key}"`) + .concat('workflow.name AS "workflowName"'); + + const qb = this.createQueryBuilder('execution') + .select(fields) + .innerJoin('execution.workflow', 'workflow') + .where('execution.workflowId IN (:...accessibleWorkflowIds)', { accessibleWorkflowIds }); + + if (query.kind === 'range') { + const { limit, firstId, lastId } = query.range; + + qb.limit(limit); + + if (firstId) qb.andWhere('execution.id > :firstId', { firstId }); + if (lastId) qb.andWhere('execution.id < :lastId', { lastId }); + + if (query.order?.stoppedAt === 'DESC') { + qb.orderBy({ 'execution.stoppedAt': 'DESC' }); } else { - where.workflowId = In(accessibleWorkflowIds); - } - if (status) { - // @ts-ignore - where.status = In(status); + qb.orderBy({ 'execution.id': 'DESC' }); } - if (finished !== undefined) { - where.finished = finished; + } + + if (status) qb.andWhere('execution.status IN (:...status)', { status }); + if (finished) qb.andWhere({ finished }); + if (workflowId) qb.andWhere({ workflowId }); + if (startedBefore) qb.andWhere({ startedAt: lessThanOrEqual(startedBefore) }); + if (startedAfter) qb.andWhere({ startedAt: moreThanOrEqual(startedAfter) }); + + if (metadata) { + qb.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id'); + + for (const item of metadata) { + qb.andWhere('md.key = :key AND md.value = :value', item); } - } else { - where.workflowId = In(accessibleWorkflowIds); } - return await this.findMultipleExecutions({ - select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt', 'stoppedAt', 'status'], - order: { id: 'DESC' }, - where, - }); + return qb; } -} -export interface IGetExecutionsQueryFilter { - id?: FindOperator | string; - finished?: boolean; - mode?: string; - retryOf?: string; - retrySuccessId?: string; - status?: ExecutionStatus[]; - workflowId?: string; - waitTill?: FindOperator | boolean; - metadata?: Array<{ key: string; value: string }>; - startedAfter?: string; - startedBefore?: string; + async getAllIds() { + const executions = await this.find({ select: ['id'], order: { id: 'ASC' } }); + + return executions.map(({ id }) => id); + } } diff --git a/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts b/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts new file mode 100644 index 0000000000000..ea97f80684d52 --- /dev/null +++ b/packages/cli/src/errors/postgres-live-rows-retrieval.error.ts @@ -0,0 +1,7 @@ +import { ApplicationError } from 'n8n-workflow'; + +export class PostgresLiveRowsRetrievalError extends ApplicationError { + constructor(rows: unknown) { + super('Failed to retrieve live execution rows in Postgres', { extra: { rows } }); + } +} diff --git a/packages/cli/src/executions/active-execution.service.ts b/packages/cli/src/executions/active-execution.service.ts deleted file mode 100644 index 4f5918378b66f..0000000000000 --- a/packages/cli/src/executions/active-execution.service.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Service } from 'typedi'; -import { ActiveExecutions } from '@/ActiveExecutions'; -import { Logger } from '@/Logger'; -import { Queue } from '@/Queue'; -import { WaitTracker } from '@/WaitTracker'; -import { ExecutionRepository } from '@db/repositories/execution.repository'; -import { getStatusUsingPreviousExecutionStatusMethod } from '@/executions/executionHelpers'; -import config from '@/config'; - -import type { ExecutionSummary } from 'n8n-workflow'; -import type { IExecutionBase, IExecutionsCurrentSummary } from '@/Interfaces'; -import type { GetManyActiveFilter } from './execution.types'; - -@Service() -export class ActiveExecutionService { - constructor( - private readonly logger: Logger, - private readonly queue: Queue, - private readonly activeExecutions: ActiveExecutions, - private readonly executionRepository: ExecutionRepository, - private readonly waitTracker: WaitTracker, - ) {} - - private readonly isRegularMode = config.getEnv('executions.mode') === 'regular'; - - async findOne(executionId: string, accessibleWorkflowIds: string[]) { - return await this.executionRepository.findIfAccessible(executionId, accessibleWorkflowIds); - } - - private toSummary(execution: IExecutionsCurrentSummary | IExecutionBase): ExecutionSummary { - return { - id: execution.id, - workflowId: execution.workflowId ?? '', - mode: execution.mode, - retryOf: execution.retryOf !== null ? execution.retryOf : undefined, - startedAt: new Date(execution.startedAt), - status: execution.status, - stoppedAt: 'stoppedAt' in execution ? execution.stoppedAt : undefined, - }; - } - - // ---------------------------------- - // regular mode - // ---------------------------------- - - async findManyInRegularMode( - filter: GetManyActiveFilter, - accessibleWorkflowIds: string[], - ): Promise { - return this.activeExecutions - .getActiveExecutions() - .filter(({ workflowId }) => { - if (filter.workflowId && filter.workflowId !== workflowId) return false; - if (workflowId && !accessibleWorkflowIds.includes(workflowId)) return false; - return true; - }) - .map((execution) => this.toSummary(execution)) - .sort((a, b) => Number(b.id) - Number(a.id)); - } - - // ---------------------------------- - // queue mode - // ---------------------------------- - - async findManyInQueueMode(filter: GetManyActiveFilter, accessibleWorkflowIds: string[]) { - const activeManualExecutionIds = this.activeExecutions - .getActiveExecutions() - .map((execution) => execution.id); - - const activeJobs = await this.queue.getJobs(['active', 'waiting']); - - const activeProductionExecutionIds = activeJobs.map((job) => job.data.executionId); - - const activeExecutionIds = activeProductionExecutionIds.concat(activeManualExecutionIds); - - if (activeExecutionIds.length === 0) return []; - - const activeExecutions = await this.executionRepository.getManyActive( - activeExecutionIds, - accessibleWorkflowIds, - filter, - ); - - return activeExecutions.map((execution) => { - if (!execution.status) { - // @tech-debt Status should never be nullish - execution.status = getStatusUsingPreviousExecutionStatusMethod(execution); - } - - return this.toSummary(execution); - }); - } - - async stop(execution: IExecutionBase) { - const result = await this.activeExecutions.stopExecution(execution.id); - - if (result) { - return { - mode: result.mode, - startedAt: new Date(result.startedAt), - stoppedAt: result.stoppedAt ? new Date(result.stoppedAt) : undefined, - finished: result.finished, - status: result.status, - }; - } - - if (this.isRegularMode) return await this.waitTracker.stopExecution(execution.id); - - // queue mode - - try { - return await this.waitTracker.stopExecution(execution.id); - } catch {} - - const activeJobs = await this.queue.getJobs(['active', 'waiting']); - const job = activeJobs.find(({ data }) => data.executionId === execution.id); - - if (!job) { - this.logger.debug('Could not stop job because it is no longer in queue', { - jobId: execution.id, - }); - } else { - await this.queue.stopJob(job); - } - - return { - mode: execution.mode, - startedAt: new Date(execution.startedAt), - stoppedAt: execution.stoppedAt ? new Date(execution.stoppedAt) : undefined, - finished: execution.finished, - status: execution.status, - }; - } -} diff --git a/packages/cli/src/executions/execution.service.ts b/packages/cli/src/executions/execution.service.ts index 8d78c17d66d23..2487dac2be556 100644 --- a/packages/cli/src/executions/execution.service.ts +++ b/packages/cli/src/executions/execution.service.ts @@ -2,16 +2,19 @@ import { Service } from 'typedi'; import { validate as jsonSchemaValidate } from 'jsonschema'; import type { IWorkflowBase, - JsonObject, ExecutionError, INode, IRunExecutionData, WorkflowExecuteMode, + ExecutionStatus, +} from 'n8n-workflow'; +import { + ApplicationError, + ExecutionStatusList, + Workflow, + WorkflowOperationError, } from 'n8n-workflow'; -import { ApplicationError, jsonParse, Workflow, WorkflowOperationError } from 'n8n-workflow'; - import { ActiveExecutions } from '@/ActiveExecutions'; -import config from '@/config'; import type { ExecutionPayload, IExecutionFlattedResponse, @@ -21,9 +24,8 @@ import type { } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; import { Queue } from '@/Queue'; -import type { ExecutionRequest } from './execution.types'; +import type { ExecutionRequest, ExecutionSummaries } from './execution.types'; import { WorkflowRunner } from '@/WorkflowRunner'; -import * as GenericHelpers from '@/GenericHelpers'; import { getStatusUsingPreviousExecutionStatusMethod } from './executionHelpers'; import type { IGetExecutionsQueryFilter } from '@db/repositories/execution.repository'; import { ExecutionRepository } from '@db/repositories/execution.repository'; @@ -31,8 +33,11 @@ import { WorkflowRepository } from '@db/repositories/workflow.repository'; import { Logger } from '@/Logger'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import config from '@/config'; +import { WaitTracker } from '@/WaitTracker'; +import type { ExecutionEntity } from '@/databases/entities/ExecutionEntity'; -const schemaGetExecutionsQueryFilter = { +export const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', type: 'object', properties: { @@ -65,7 +70,9 @@ const schemaGetExecutionsQueryFilter = { }, }; -const allowedExecutionsQueryFilterFields = Object.keys(schemaGetExecutionsQueryFilter.properties); +export const allowedExecutionsQueryFilterFields = Object.keys( + schemaGetExecutionsQueryFilter.properties, +); @Service() export class ExecutionService { @@ -76,83 +83,10 @@ export class ExecutionService { private readonly executionRepository: ExecutionRepository, private readonly workflowRepository: WorkflowRepository, private readonly nodeTypes: NodeTypes, + private readonly waitTracker: WaitTracker, private readonly workflowRunner: WorkflowRunner, ) {} - async findMany(req: ExecutionRequest.GetMany, sharedWorkflowIds: string[]) { - // parse incoming filter object and remove non-valid fields - let filter: IGetExecutionsQueryFilter | undefined = undefined; - if (req.query.filter) { - try { - const filterJson: JsonObject = jsonParse(req.query.filter); - if (filterJson) { - Object.keys(filterJson).map((key) => { - if (!allowedExecutionsQueryFilterFields.includes(key)) delete filterJson[key]; - }); - if (jsonSchemaValidate(filterJson, schemaGetExecutionsQueryFilter).valid) { - filter = filterJson as IGetExecutionsQueryFilter; - } - } - } catch (error) { - this.logger.error('Failed to parse filter', { - userId: req.user.id, - filter: req.query.filter, - }); - throw new InternalServerError('Parameter "filter" contained invalid JSON string.'); - } - } - - // safeguard against querying workflowIds not shared with the user - const workflowId = filter?.workflowId?.toString(); - if (workflowId !== undefined && !sharedWorkflowIds.includes(workflowId)) { - this.logger.verbose( - `User ${req.user.id} attempted to query non-shared workflow ${workflowId}`, - ); - return { - count: 0, - estimated: false, - results: [], - }; - } - - const limit = req.query.limit - ? parseInt(req.query.limit, 10) - : GenericHelpers.DEFAULT_EXECUTIONS_GET_ALL_LIMIT; - - const executingWorkflowIds: string[] = []; - - if (config.getEnv('executions.mode') === 'queue') { - const currentJobs = await this.queue.getJobs(['active', 'waiting']); - executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId)); - } - - // We may have manual executions even with queue so we must account for these. - executingWorkflowIds.push(...this.activeExecutions.getActiveExecutions().map(({ id }) => id)); - - const { count, estimated } = await this.executionRepository.countExecutions( - filter, - sharedWorkflowIds, - executingWorkflowIds, - req.user.hasGlobalScope('workflow:list'), - ); - - const formattedExecutions = await this.executionRepository.searchExecutions( - filter, - limit, - executingWorkflowIds, - sharedWorkflowIds, - { - lastId: req.query.lastId, - firstId: req.query.firstId, - }, - ); - return { - count, - results: formattedExecutions, - estimated, - }; - } - async findOne( req: ExecutionRequest.GetOne, sharedWorkflowIds: string[], @@ -384,4 +318,112 @@ export class ExecutionService { await this.executionRepository.createNewExecution(fullExecutionData); } + + // ---------------------------------- + // new API + // ---------------------------------- + + private readonly isRegularMode = config.getEnv('executions.mode') === 'regular'; + + /** + * Find summaries of executions that satisfy a query. + * + * Return also the total count of all executions that satisfy the query, + * and whether the total is an estimate or not. + */ + async findRangeWithCount(query: ExecutionSummaries.RangeQuery) { + const results = await this.executionRepository.findManyByRangeQuery(query); + + if (config.getEnv('database.type') === 'postgresdb') { + const liveRows = await this.executionRepository.getLiveExecutionRowsOnPostgres(); + + if (liveRows === -1) return { count: -1, estimated: false, results }; + + if (liveRows > 100_000) { + // likely too high to fetch exact count fast + return { count: liveRows, estimated: true, results }; + } + } + + const { range: _, ...countQuery } = query; + + const count = await this.executionRepository.fetchCount({ ...countQuery, kind: 'count' }); + + return { results, count, estimated: false }; + } + + /** + * Find summaries of active and finished executions that satisfy a query. + * + * Return also the total count of all finished executions that satisfy the query, + * and whether the total is an estimate or not. Active executions are excluded + * from the total and count for pagination purposes. + */ + async findAllRunningAndLatest(query: ExecutionSummaries.RangeQuery) { + const currentlyRunningStatuses: ExecutionStatus[] = ['new', 'running']; + const allStatuses = new Set(ExecutionStatusList); + currentlyRunningStatuses.forEach((status) => allStatuses.delete(status)); + const notRunningStatuses: ExecutionStatus[] = Array.from(allStatuses); + + const [activeResult, finishedResult] = await Promise.all([ + this.findRangeWithCount({ ...query, status: currentlyRunningStatuses }), + this.findRangeWithCount({ + ...query, + status: notRunningStatuses, + order: { stoppedAt: 'DESC' }, + }), + ]); + + return { + results: activeResult.results.concat(finishedResult.results), + count: finishedResult.count, + estimated: finishedResult.estimated, + }; + } + + /** + * Stop an active execution. + */ + async stop(executionId: string) { + const execution = await this.executionRepository.findOneBy({ id: executionId }); + + if (!execution) throw new NotFoundError('Execution not found'); + + const stopResult = await this.activeExecutions.stopExecution(execution.id); + + if (stopResult) return this.toExecutionStopResult(execution); + + if (this.isRegularMode) { + return await this.waitTracker.stopExecution(execution.id); + } + + // queue mode + + try { + return await this.waitTracker.stopExecution(execution.id); + } catch { + // @TODO: Why are we swallowing this error in queue mode? + } + + const activeJobs = await this.queue.getJobs(['active', 'waiting']); + const job = activeJobs.find(({ data }) => data.executionId === execution.id); + + if (job) { + await this.queue.stopJob(job); + } else { + this.logger.debug('Job to stop no longer in queue', { jobId: execution.id }); + } + + return this.toExecutionStopResult(execution); + } + + private toExecutionStopResult(execution: ExecutionEntity) { + return { + mode: execution.mode, + startedAt: new Date(execution.startedAt), + stoppedAt: execution.stoppedAt ? new Date(execution.stoppedAt) : undefined, + finished: execution.finished, + status: execution.status, + }; + } } diff --git a/packages/cli/src/executions/execution.types.ts b/packages/cli/src/executions/execution.types.ts index 3ad21ae357f36..91df96b858fb3 100644 --- a/packages/cli/src/executions/execution.types.ts +++ b/packages/cli/src/executions/execution.types.ts @@ -5,7 +5,7 @@ import type { ExecutionStatus, IDataObject } from 'n8n-workflow'; export declare namespace ExecutionRequest { namespace QueryParams { type GetMany = { - filter: string; // '{ waitTill: string; finished: boolean, [other: string]: string }' + filter: string; // stringified `FilterFields` limit: string; lastId: string; firstId: string; @@ -28,7 +28,9 @@ export declare namespace ExecutionRequest { }; } - type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany>; + type GetMany = AuthenticatedRequest<{}, {}, {}, QueryParams.GetMany> & { + rangeQuery: ExecutionSummaries.RangeQuery; // parsed from query params + }; type GetOne = AuthenticatedRequest; @@ -37,12 +39,47 @@ export declare namespace ExecutionRequest { type Retry = AuthenticatedRequest; type Stop = AuthenticatedRequest; - - type GetManyActive = AuthenticatedRequest<{}, {}, {}, { filter?: string }>; } -export type GetManyActiveFilter = { - workflowId?: string; - status?: ExecutionStatus; - finished?: boolean; -}; +export namespace ExecutionSummaries { + export type Query = RangeQuery | CountQuery; + + export type RangeQuery = { kind: 'range' } & FilterFields & + AccessFields & + RangeFields & + OrderFields; + + export type CountQuery = { kind: 'count' } & FilterFields & AccessFields; + + type FilterFields = Partial<{ + id: string; + finished: boolean; + mode: string; + retryOf: string; + retrySuccessId: string; + status: ExecutionStatus[]; + workflowId: string; + waitTill: boolean; + metadata: Array<{ key: string; value: string }>; + startedAfter: string; + startedBefore: string; + }>; + + type AccessFields = { + accessibleWorkflowIds?: string[]; + }; + + type RangeFields = { + range: { + limit: number; + firstId?: string; + lastId?: string; + }; + }; + + type OrderFields = { + order?: { + stoppedAt: 'DESC'; + }; + }; +} diff --git a/packages/cli/src/executions/executionHelpers.ts b/packages/cli/src/executions/executionHelpers.ts index f58a07761e34d..b0354aa1c6d9c 100644 --- a/packages/cli/src/executions/executionHelpers.ts +++ b/packages/cli/src/executions/executionHelpers.ts @@ -23,8 +23,3 @@ export function isAdvancedExecutionFiltersEnabled(): boolean { const license = Container.get(License); return license.isAdvancedExecutionFiltersEnabled(); } - -export function isDebugInEditorLicensed(): boolean { - const license = Container.get(License); - return license.isDebugInEditorLicensed(); -} diff --git a/packages/cli/src/executions/executions.controller.ts b/packages/cli/src/executions/executions.controller.ts index 3d778b2bc7770..bffb0c383fa98 100644 --- a/packages/cli/src/executions/executions.controller.ts +++ b/packages/cli/src/executions/executions.controller.ts @@ -1,25 +1,19 @@ -import type { GetManyActiveFilter } from './execution.types'; import { ExecutionRequest } from './execution.types'; import { ExecutionService } from './execution.service'; import { Get, Post, RestController } from '@/decorators'; import { EnterpriseExecutionsService } from './execution.service.ee'; import { License } from '@/License'; import { WorkflowSharingService } from '@/workflows/workflowSharing.service'; -import type { User } from '@/databases/entities/User'; -import config from '@/config'; -import { jsonParse } from 'n8n-workflow'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { ActiveExecutionService } from './active-execution.service'; +import { parseRangeQuery } from './parse-range-query.middleware'; +import type { User } from '@/databases/entities/User'; @RestController('/executions') export class ExecutionsController { - private readonly isQueueMode = config.getEnv('executions.mode') === 'queue'; - constructor( private readonly executionService: ExecutionService, private readonly enterpriseExecutionService: EnterpriseExecutionsService, private readonly workflowSharingService: WorkflowSharingService, - private readonly activeExecutionService: ActiveExecutionService, private readonly license: License, ) {} @@ -29,37 +23,32 @@ export class ExecutionsController { : await this.workflowSharingService.getSharedWorkflowIds(user, ['workflow:owner']); } - @Get('/') + @Get('/', { middlewares: [parseRangeQuery] }) async getMany(req: ExecutionRequest.GetMany) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const accessibleWorkflowIds = await this.getAccessibleWorkflowIds(req.user); - if (workflowIds.length === 0) return { count: 0, estimated: false, results: [] }; + if (accessibleWorkflowIds.length === 0) { + return { count: 0, estimated: false, results: [] }; + } - return await this.executionService.findMany(req, workflowIds); - } + const { rangeQuery: query } = req; - @Get('/active') - async getActive(req: ExecutionRequest.GetManyActive) { - const filter = req.query.filter?.length ? jsonParse(req.query.filter) : {}; + if (query.workflowId && !accessibleWorkflowIds.includes(query.workflowId)) { + return { count: 0, estimated: false, results: [] }; + } - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + query.accessibleWorkflowIds = accessibleWorkflowIds; - return this.isQueueMode - ? await this.activeExecutionService.findManyInQueueMode(filter, workflowIds) - : await this.activeExecutionService.findManyInRegularMode(filter, workflowIds); - } + if (!this.license.isAdvancedExecutionFiltersEnabled()) delete query.metadata; - @Post('/active/:id/stop') - async stop(req: ExecutionRequest.Stop) { - const workflowIds = await this.getAccessibleWorkflowIds(req.user); + const noStatus = !query.status || query.status.length === 0; + const noRange = !query.range.lastId || !query.range.firstId; - if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + if (noStatus && noRange) { + return await this.executionService.findAllRunningAndLatest(query); + } - const execution = await this.activeExecutionService.findOne(req.params.id, workflowIds); - - if (!execution) throw new NotFoundError('Execution not found'); - - return await this.activeExecutionService.stop(execution); + return await this.executionService.findRangeWithCount(query); } @Get('/:id') @@ -73,6 +62,15 @@ export class ExecutionsController { : await this.executionService.findOne(req, workflowIds); } + @Post('/:id/stop') + async stop(req: ExecutionRequest.Stop) { + const workflowIds = await this.getAccessibleWorkflowIds(req.user); + + if (workflowIds.length === 0) throw new NotFoundError('Execution not found'); + + return await this.executionService.stop(req.params.id); + } + @Post('/:id/retry') async retry(req: ExecutionRequest.Retry) { const workflowIds = await this.getAccessibleWorkflowIds(req.user); diff --git a/packages/cli/src/executions/parse-range-query.middleware.ts b/packages/cli/src/executions/parse-range-query.middleware.ts new file mode 100644 index 0000000000000..528c00f676e94 --- /dev/null +++ b/packages/cli/src/executions/parse-range-query.middleware.ts @@ -0,0 +1,56 @@ +import * as ResponseHelper from '@/ResponseHelper'; +import type { NextFunction, Response } from 'express'; +import type { ExecutionRequest } from './execution.types'; +import type { JsonObject } from 'n8n-workflow'; +import { ApplicationError, jsonParse } from 'n8n-workflow'; +import { + allowedExecutionsQueryFilterFields as ALLOWED_FILTER_FIELDS, + schemaGetExecutionsQueryFilter as SCHEMA, +} from './execution.service'; +import { validate } from 'jsonschema'; +import { BadRequestError } from '@/errors/response-errors/bad-request.error'; + +const isValid = (arg: JsonObject) => validate(arg, SCHEMA).valid; + +/** + * Middleware to parse the query string in a request to retrieve a range of execution summaries. + */ +export const parseRangeQuery = ( + req: ExecutionRequest.GetMany, + res: Response, + next: NextFunction, +) => { + const { limit, firstId, lastId } = req.query; + + try { + req.rangeQuery = { + kind: 'range', + range: { limit: limit ? Math.min(parseInt(limit, 10), 100) : 20 }, + }; + + if (firstId) req.rangeQuery.range.firstId = firstId; + if (lastId) req.rangeQuery.range.lastId = lastId; + + if (req.query.filter) { + const jsonFilter = jsonParse(req.query.filter, { + errorMessage: 'Failed to parse query string', + }); + + for (const key of Object.keys(jsonFilter)) { + if (!ALLOWED_FILTER_FIELDS.includes(key)) delete jsonFilter[key]; + } + + if (jsonFilter.waitTill) jsonFilter.waitTill = Boolean(jsonFilter.waitTill); + + if (!isValid(jsonFilter)) throw new ApplicationError('Query does not match schema'); + + req.rangeQuery = { ...req.rangeQuery, ...jsonFilter }; + } + + next(); + } catch (error) { + if (error instanceof Error) { + ResponseHelper.sendErrorResponse(res, new BadRequestError(error.message)); + } + } +}; diff --git a/packages/cli/test/integration/execution.service.integration.test.ts b/packages/cli/test/integration/execution.service.integration.test.ts new file mode 100644 index 0000000000000..834720696d582 --- /dev/null +++ b/packages/cli/test/integration/execution.service.integration.test.ts @@ -0,0 +1,411 @@ +import { ExecutionRepository } from '@/databases/repositories/execution.repository'; +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 * as testDb from './shared/testDb'; +import { WorkflowRepository } from '@/databases/repositories/workflow.repository'; +import type { ExecutionSummaries } from '@/executions/execution.types'; +import { ExecutionMetadataRepository } from '@/databases/repositories/executionMetadata.repository'; + +describe('ExecutionService', () => { + let executionService: ExecutionService; + let executionRepository: ExecutionRepository; + + beforeAll(async () => { + await testDb.init(); + + executionRepository = Container.get(ExecutionRepository); + + executionService = new ExecutionService( + mock(), + mock(), + mock(), + executionRepository, + Container.get(WorkflowRepository), + mock(), + mock(), + mock(), + ); + }); + + afterEach(async () => { + await testDb.truncate(['Execution']); + }); + + afterAll(async () => { + await testDb.terminate(); + }); + + describe('findRangeWithCount', () => { + test('should return execution summaries', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + status: ['success'], + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + 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), + }; + + expect(output.count).toBe(2); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([summaryShape, summaryShape]); + }); + + test('should limit executions', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + status: ['success'], + range: { limit: 2 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(3); + expect(output.estimated).toBe(false); + expect(output.results).toHaveLength(2); + }); + + test('should retrieve executions before `lastId`, excluding it', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + ]); + + const [firstId, secondId] = await executionRepository.getAllIds(); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20, lastId: secondId }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(4); + expect(output.estimated).toBe(false); + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ id: firstId })]), + ); + }); + + test('should retrieve executions after `firstId`, excluding it', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + ]); + + const [firstId, secondId, thirdId, fourthId] = await executionRepository.getAllIds(); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20, firstId }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(4); + expect(output.estimated).toBe(false); + expect(output.results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: fourthId }), + expect.objectContaining({ id: thirdId }), + expect.objectContaining({ id: secondId }), + ]), + ); + }); + + test('should filter executions by `status`', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'success' }, workflow), + createExecution({ status: 'waiting' }, workflow), + createExecution({ status: 'waiting' }, workflow), + ]); + + 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.objectContaining({ status: 'success' }), + expect.objectContaining({ status: 'success' }), + ]); + }); + + test('should filter executions by `workflowId`', async () => { + const firstWorkflow = await createWorkflow(); + const secondWorkflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, firstWorkflow), + createExecution({ status: 'success' }, secondWorkflow), + createExecution({ status: 'success' }, secondWorkflow), + createExecution({ status: 'success' }, secondWorkflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + workflowId: firstWorkflow.id, + accessibleWorkflowIds: [firstWorkflow.id, secondWorkflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual( + expect.arrayContaining([expect.objectContaining({ workflowId: firstWorkflow.id })]), + ); + }); + + test('should filter executions by `startedBefore`', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ startedAt: new Date('2020-06-01') }, workflow), + createExecution({ startedAt: new Date('2020-12-31') }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + startedBefore: '2020-07-01', + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([ + expect.objectContaining({ startedAt: '2020-06-01T00:00:00.000Z' }), + ]); + }); + + test('should filter executions by `startedAfter`', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ startedAt: new Date('2020-06-01') }, workflow), + createExecution({ startedAt: new Date('2020-12-31') }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + startedAfter: '2020-07-01', + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([ + expect.objectContaining({ startedAt: '2020-12-31T00:00:00.000Z' }), + ]); + }); + + test('should exclude executions by inaccessible `workflowId`', async () => { + const accessibleWorkflow = await createWorkflow(); + const inaccessibleWorkflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'success' }, accessibleWorkflow), + createExecution({ status: 'success' }, inaccessibleWorkflow), + createExecution({ status: 'success' }, inaccessibleWorkflow), + createExecution({ status: 'success' }, inaccessibleWorkflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + workflowId: inaccessibleWorkflow.id, + accessibleWorkflowIds: [accessibleWorkflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(0); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([]); + }); + + test('should support advanced filters', async () => { + const workflow = await createWorkflow(); + + await Promise.all([createExecution({}, workflow), createExecution({}, workflow)]); + + const [firstId, secondId] = await executionRepository.getAllIds(); + + const executionMetadataRepository = Container.get(ExecutionMetadataRepository); + + await executionMetadataRepository.save({ + key: 'key1', + value: 'value1', + execution: { id: firstId }, + }); + + await executionMetadataRepository.save({ + key: 'key2', + value: 'value2', + execution: { id: secondId }, + }); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + metadata: [{ key: 'key1', value: 'value1' }], + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findRangeWithCount(query); + + expect(output.count).toBe(1); + expect(output.estimated).toBe(false); + expect(output.results).toEqual([expect.objectContaining({ id: firstId })]); + }); + }); + + describe('findAllActiveAndLatestFinished', () => { + test('should return all active and latest 20 finished executions', async () => { + const workflow = await createWorkflow(); + + const totalFinished = 21; + + await Promise.all([ + createExecution({ status: 'running' }, workflow), + createExecution({ status: 'running' }, workflow), + createExecution({ status: 'running' }, workflow), + ...new Array(totalFinished) + .fill(null) + .map(async () => await createExecution({ status: 'success' }, workflow)), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findAllRunningAndLatest(query); + + expect(output.results).toHaveLength(23); // 3 active + 20 finished (excludes 21st) + expect(output.count).toBe(totalFinished); // 21 finished, excludes active + expect(output.estimated).toBe(false); + }); + + test('should handle zero active executions', async () => { + const workflow = await createWorkflow(); + + const totalFinished = 5; + + await Promise.all( + new Array(totalFinished) + .fill(null) + .map(async () => await createExecution({ status: 'success' }, workflow)), + ); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findAllRunningAndLatest(query); + + expect(output.results).toHaveLength(totalFinished); // 5 finished + expect(output.count).toBe(totalFinished); // 5 finished, excludes active + expect(output.estimated).toBe(false); + }); + + test('should handle zero finished executions', async () => { + const workflow = await createWorkflow(); + + await Promise.all([ + createExecution({ status: 'running' }, workflow), + createExecution({ status: 'running' }, workflow), + createExecution({ status: 'running' }, workflow), + ]); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findAllRunningAndLatest(query); + + expect(output.results).toHaveLength(3); // 3 finished + expect(output.count).toBe(0); // 0 finished, excludes active + expect(output.estimated).toBe(false); + }); + + test('should handle zero executions', async () => { + const workflow = await createWorkflow(); + + const query: ExecutionSummaries.RangeQuery = { + kind: 'range', + range: { limit: 20 }, + accessibleWorkflowIds: [workflow.id], + }; + + const output = await executionService.findAllRunningAndLatest(query); + + expect(output.results).toHaveLength(0); + expect(output.count).toBe(0); + expect(output.estimated).toBe(false); + }); + }); +}); diff --git a/packages/cli/test/unit/active-execution.service.test.ts b/packages/cli/test/unit/active-execution.service.test.ts deleted file mode 100644 index 60a8fa48cfcfb..0000000000000 --- a/packages/cli/test/unit/active-execution.service.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { mock, mockFn } from 'jest-mock-extended'; -import { ActiveExecutionService } from '@/executions/active-execution.service'; -import config from '@/config'; -import type { ExecutionRepository } from '@db/repositories/execution.repository'; -import type { ActiveExecutions } from '@/ActiveExecutions'; -import type { Job, Queue } from '@/Queue'; -import type { IExecutionBase, IExecutionsCurrentSummary } from '@/Interfaces'; -import type { WaitTracker } from '@/WaitTracker'; - -describe('ActiveExecutionsService', () => { - const queue = mock(); - const activeExecutions = mock(); - const executionRepository = mock(); - const waitTracker = mock(); - - const jobIds = ['j1', 'j2']; - const jobs = jobIds.map((executionId) => mock({ data: { executionId } })); - - const activeExecutionService = new ActiveExecutionService( - mock(), - queue, - activeExecutions, - executionRepository, - waitTracker, - ); - - const getEnv = mockFn<(typeof config)['getEnv']>(); - config.getEnv = getEnv; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe('stop()', () => { - describe('in regular mode', () => { - getEnv.calledWith('executions.mode').mockReturnValue('regular'); - - it('should call `ActiveExecutions.stopExecution()`', async () => { - const execution = mock({ id: '123' }); - - await activeExecutionService.stop(execution); - - expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); - }); - - it('should call `WaitTracker.stopExecution()` if `ActiveExecutions.stopExecution()` found no execution', async () => { - activeExecutions.stopExecution.mockResolvedValue(undefined); - const execution = mock({ id: '123' }); - - await activeExecutionService.stop(execution); - - expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); - }); - }); - - describe('in queue mode', () => { - it('should call `ActiveExecutions.stopExecution()`', async () => { - const execution = mock({ id: '123' }); - - await activeExecutionService.stop(execution); - - expect(activeExecutions.stopExecution).toHaveBeenCalledWith(execution.id); - }); - - it('should call `WaitTracker.stopExecution` if `ActiveExecutions.stopExecution()` found no execution', async () => { - activeExecutions.stopExecution.mockResolvedValue(undefined); - const execution = mock({ id: '123' }); - - await activeExecutionService.stop(execution); - - expect(waitTracker.stopExecution).toHaveBeenCalledWith(execution.id); - }); - }); - }); - - describe('findManyInQueueMode()', () => { - it('should query for active jobs, waiting jobs, and in-memory executions', async () => { - const sharedWorkflowIds = ['123']; - const filter = {}; - const executionIds = ['e1', 'e2']; - const summaries = executionIds.map((e) => mock({ id: e })); - - activeExecutions.getActiveExecutions.mockReturnValue(summaries); - queue.getJobs.mockResolvedValue(jobs); - executionRepository.findMultipleExecutions.mockResolvedValue([]); - executionRepository.getManyActive.mockResolvedValue([]); - - await activeExecutionService.findManyInQueueMode(filter, sharedWorkflowIds); - - expect(queue.getJobs).toHaveBeenCalledWith(['active', 'waiting']); - - expect(executionRepository.getManyActive).toHaveBeenCalledWith( - jobIds.concat(executionIds), - sharedWorkflowIds, - filter, - ); - }); - }); - - describe('findManyInRegularMode()', () => { - it('should return summaries of in-memory executions', async () => { - const sharedWorkflowIds = ['123']; - const filter = {}; - const executionIds = ['e1', 'e2']; - const summaries = executionIds.map((e) => - mock({ id: e, workflowId: '123', status: 'running' }), - ); - - activeExecutions.getActiveExecutions.mockReturnValue(summaries); - - const result = await activeExecutionService.findManyInRegularMode(filter, sharedWorkflowIds); - - expect(result).toEqual([ - expect.objectContaining({ - id: 'e1', - workflowId: '123', - status: 'running', - }), - expect.objectContaining({ - id: 'e2', - workflowId: '123', - status: 'running', - }), - ]); - }); - }); -}); diff --git a/packages/cli/test/unit/controllers/executions.controller.test.ts b/packages/cli/test/unit/controllers/executions.controller.test.ts index 04263ea2c8927..06c64e1c18893 100644 --- a/packages/cli/test/unit/controllers/executions.controller.test.ts +++ b/packages/cli/test/unit/controllers/executions.controller.test.ts @@ -1,94 +1,145 @@ -import { mock, mockFn } from 'jest-mock-extended'; -import config from '@/config'; +import { mock } from 'jest-mock-extended'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { ExecutionsController } from '@/executions/executions.controller'; -import { License } from '@/License'; -import { mockInstance } from '../../shared/mocking'; -import type { IExecutionBase } from '@/Interfaces'; -import type { ActiveExecutionService } from '@/executions/active-execution.service'; -import type { ExecutionRequest } from '@/executions/execution.types'; +import type { ExecutionRequest, ExecutionSummaries } from '@/executions/execution.types'; +import type { ExecutionService } from '@/executions/execution.service'; import type { WorkflowSharingService } from '@/workflows/workflowSharing.service'; describe('ExecutionsController', () => { - const getEnv = mockFn<(typeof config)['getEnv']>(); - config.getEnv = getEnv; - - mockInstance(License); - const activeExecutionService = mock(); + const executionService = mock(); const workflowSharingService = mock(); - const req = mock({ query: { filter: '{}' } }); + const executionsController = new ExecutionsController( + executionService, + mock(), + workflowSharingService, + mock(), + ); beforeEach(() => { jest.clearAllMocks(); }); - describe('getActive()', () => { - workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']); + describe('getMany', () => { + const NO_EXECUTIONS = { count: 0, estimated: false, results: [] }; + + const QUERIES_WITH_EITHER_STATUS_OR_RANGE: ExecutionSummaries.RangeQuery[] = [ + { + kind: 'range', + workflowId: undefined, + status: undefined, + range: { lastId: '999', firstId: '111', limit: 20 }, + }, + { + kind: 'range', + workflowId: undefined, + status: [], + range: { lastId: '999', firstId: '111', limit: 20 }, + }, + { + kind: 'range', + workflowId: undefined, + status: ['waiting'], + range: { lastId: undefined, firstId: undefined, limit: 20 }, + }, + { + kind: 'range', + workflowId: undefined, + status: [], + range: { lastId: '999', firstId: '111', limit: 20 }, + }, + ]; + + const QUERIES_NEITHER_STATUS_NOR_RANGE_PROVIDED: ExecutionSummaries.RangeQuery[] = [ + { + kind: 'range', + workflowId: undefined, + status: undefined, + range: { lastId: undefined, firstId: undefined, limit: 20 }, + }, + { + kind: 'range', + workflowId: undefined, + status: [], + range: { lastId: undefined, firstId: undefined, limit: 20 }, + }, + ]; + + describe('if either status or range provided', () => { + test.each(QUERIES_WITH_EITHER_STATUS_OR_RANGE)( + 'should fetch executions per query', + async (rangeQuery) => { + workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']); + executionService.findAllRunningAndLatest.mockResolvedValue(NO_EXECUTIONS); + + const req = mock({ rangeQuery }); + + await executionsController.getMany(req); + + expect(executionService.findAllRunningAndLatest).not.toHaveBeenCalled(); + expect(executionService.findRangeWithCount).toHaveBeenCalledWith(rangeQuery); + }, + ); + }); + + describe('if neither status nor range provided', () => { + test.each(QUERIES_NEITHER_STATUS_NOR_RANGE_PROVIDED)( + 'should fetch executions per query', + async (rangeQuery) => { + workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']); + executionService.findAllRunningAndLatest.mockResolvedValue(NO_EXECUTIONS); - it('should call `ActiveExecutionService.findManyInQueueMode()`', async () => { - getEnv.calledWith('executions.mode').mockReturnValue('queue'); + const req = mock({ rangeQuery }); - await new ExecutionsController( - mock(), - mock(), - workflowSharingService, - activeExecutionService, - mock(), - ).getActive(req); + await executionsController.getMany(req); - expect(activeExecutionService.findManyInQueueMode).toHaveBeenCalled(); - expect(activeExecutionService.findManyInRegularMode).not.toHaveBeenCalled(); + expect(executionService.findAllRunningAndLatest).toHaveBeenCalled(); + expect(executionService.findRangeWithCount).not.toHaveBeenCalled(); + }, + ); }); - it('should call `ActiveExecutionService.findManyInRegularMode()`', async () => { - getEnv.calledWith('executions.mode').mockReturnValue('regular'); + describe('if both status and range provided', () => { + it('should fetch executions per query', async () => { + workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']); + executionService.findAllRunningAndLatest.mockResolvedValue(NO_EXECUTIONS); + + const rangeQuery: ExecutionSummaries.RangeQuery = { + kind: 'range', + workflowId: undefined, + status: ['success'], + range: { lastId: '999', firstId: '111', limit: 5 }, + }; + + const req = mock({ rangeQuery }); - await new ExecutionsController( - mock(), - mock(), - workflowSharingService, - activeExecutionService, - mock(), - ).getActive(req); + await executionsController.getMany(req); - expect(activeExecutionService.findManyInQueueMode).not.toHaveBeenCalled(); - expect(activeExecutionService.findManyInRegularMode).toHaveBeenCalled(); + expect(executionService.findAllRunningAndLatest).not.toHaveBeenCalled(); + expect(executionService.findRangeWithCount).toHaveBeenCalledWith(rangeQuery); + }); }); }); - describe('stop()', () => { - const req = mock({ params: { id: '999' } }); - const execution = mock(); + describe('stop', () => { + const executionId = '999'; + const req = mock({ params: { id: executionId } }); - it('should 404 when execution is not found or inaccessible for user', async () => { - activeExecutionService.findOne.mockResolvedValue(undefined); + it('should 404 when execution is inaccessible for user', async () => { + workflowSharingService.getSharedWorkflowIds.mockResolvedValue([]); - const promise = new ExecutionsController( - mock(), - mock(), - workflowSharingService, - activeExecutionService, - mock(), - ).stop(req); + const promise = executionsController.stop(req); await expect(promise).rejects.toThrow(NotFoundError); - expect(activeExecutionService.findOne).toHaveBeenCalledWith('999', ['123']); + expect(executionService.stop).not.toHaveBeenCalled(); }); - it('should call `ActiveExecutionService.stop()`', async () => { - getEnv.calledWith('executions.mode').mockReturnValue('regular'); - activeExecutionService.findOne.mockResolvedValue(execution); + it('should call ask for an execution to be stopped', async () => { + workflowSharingService.getSharedWorkflowIds.mockResolvedValue(['123']); - await new ExecutionsController( - mock(), - mock(), - workflowSharingService, - activeExecutionService, - mock(), - ).stop(req); + await executionsController.stop(req); - expect(activeExecutionService.stop).toHaveBeenCalled(); + expect(executionService.stop).toHaveBeenCalledWith(executionId); }); }); }); diff --git a/packages/cli/test/unit/middleware/executions/parse-range-query.middleware.test.ts b/packages/cli/test/unit/middleware/executions/parse-range-query.middleware.test.ts new file mode 100644 index 0000000000000..d52d09b80cef8 --- /dev/null +++ b/packages/cli/test/unit/middleware/executions/parse-range-query.middleware.test.ts @@ -0,0 +1,178 @@ +import { parseRangeQuery } from '@/executions/parse-range-query.middleware'; +import { mock } from 'jest-mock-extended'; +import type { NextFunction } from 'express'; +import type * as express from 'express'; +import type { ExecutionRequest } from '@/executions/execution.types'; + +describe('`parseRangeQuery` middleware', () => { + const res = mock({ + status: () => mock({ json: jest.fn() }), + }); + + const nextFn: NextFunction = jest.fn(); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + describe('errors', () => { + test('should fail on invalid JSON', () => { + const statusSpy = jest.spyOn(res, 'status'); + + const req = mock({ + query: { + filter: '{ "status": ["waiting }', + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(nextFn).toBeCalledTimes(0); + expect(statusSpy).toBeCalledWith(400); + }); + + test('should fail on invalid schema', () => { + const statusSpy = jest.spyOn(res, 'status'); + + const req = mock({ + query: { + filter: '{ "status": 123 }', + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(nextFn).toBeCalledTimes(0); + expect(statusSpy).toBeCalledWith(400); + }); + }); + + describe('filter', () => { + test('should parse status and mode fields', () => { + const req = mock({ + query: { + filter: '{ "status": ["waiting"], "mode": "manual" }', + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.status).toEqual(['waiting']); + expect(req.rangeQuery.mode).toEqual('manual'); + expect(nextFn).toBeCalledTimes(1); + }); + + test('should parse date-related fields', () => { + const req = mock({ + query: { + filter: + '{ "startedBefore": "2021-01-01", "startedAfter": "2020-01-01", "waitTill": "true" }', + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.startedBefore).toBe('2021-01-01'); + expect(req.rangeQuery.startedAfter).toBe('2020-01-01'); + expect(req.rangeQuery.waitTill).toBe(true); + expect(nextFn).toBeCalledTimes(1); + }); + + test('should parse ID-related fields', () => { + const req = mock({ + query: { + filter: '{ "id": "123", "workflowId": "456" }', + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.id).toBe('123'); + expect(req.rangeQuery.workflowId).toBe('456'); + expect(nextFn).toBeCalledTimes(1); + }); + + test('should delete invalid fields', () => { + const req = mock({ + query: { + filter: '{ "id": "123", "test": "789" }', + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.id).toBe('123'); + expect('test' in req.rangeQuery).toBe(false); + expect(nextFn).toBeCalledTimes(1); + }); + }); + + describe('range', () => { + test('should parse first and last IDs', () => { + const req = mock({ + query: { + filter: undefined, + limit: undefined, + firstId: '111', + lastId: '999', + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.range.firstId).toBe('111'); + expect(req.rangeQuery.range.lastId).toBe('999'); + expect(nextFn).toBeCalledTimes(1); + }); + + test('should parse limit', () => { + const req = mock({ + query: { + filter: undefined, + limit: '50', + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.range.limit).toEqual(50); + expect(nextFn).toBeCalledTimes(1); + }); + + test('should default limit to 20 if absent', () => { + const req = mock({ + query: { + filter: undefined, + limit: undefined, + firstId: undefined, + lastId: undefined, + }, + }); + + parseRangeQuery(req, res, nextFn); + + expect(req.rangeQuery.range.limit).toEqual(20); + expect(nextFn).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/design-system/src/css/_tokens.dark.scss b/packages/design-system/src/css/_tokens.dark.scss index 1ed6514283981..0c9b00b8d3e1f 100644 --- a/packages/design-system/src/css/_tokens.dark.scss +++ b/packages/design-system/src/css/_tokens.dark.scss @@ -131,6 +131,7 @@ --color-table-row-background: var(--prim-gray-820); --color-table-row-even-background: var(--prim-gray-800); --color-table-row-hover-background: var(--prim-gray-740); + --color-table-row-highlight-background: var(--color-warning-tint-1); // Notification --color-notification-background: var(--prim-gray-740); diff --git a/packages/design-system/src/css/_tokens.scss b/packages/design-system/src/css/_tokens.scss index 8376fcc9abfe1..b6998894408b8 100644 --- a/packages/design-system/src/css/_tokens.scss +++ b/packages/design-system/src/css/_tokens.scss @@ -192,6 +192,7 @@ --color-table-row-background: var(--color-background-xlight); --color-table-row-even-background: var(--color-background-light); --color-table-row-hover-background: var(--color-primary-tint-3); + --color-table-row-highlight-background: var(--color-warning-tint-1); // Notification --color-notification-background: var(--color-background-xlight); diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 7fa606bca4871..1818b22776683 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1282,7 +1282,6 @@ export interface UIState { selectedNodes: INodeUi[]; nodeViewInitialized: boolean; addFirstStepOnLoad: boolean; - executionSidebarAutoRefresh: boolean; bannersHeight: number; bannerStack: BannerName[]; theme: ThemeOption; diff --git a/packages/editor-ui/src/api/workflows.ts b/packages/editor-ui/src/api/workflows.ts index 82ae637c4247b..48a70be3171d8 100644 --- a/packages/editor-ui/src/api/workflows.ts +++ b/packages/editor-ui/src/api/workflows.ts @@ -28,7 +28,9 @@ export async function getActiveWorkflows(context: IRestApiContext) { } export async function getActiveExecutions(context: IRestApiContext, filter: IDataObject) { - return await makeRestApiRequest(context, 'GET', '/executions/active', { filter }); + const output = await makeRestApiRequest(context, 'GET', '/executions', { filter }); + + return output.results; } export async function getExecutions( diff --git a/packages/editor-ui/src/components/ActivationModal.vue b/packages/editor-ui/src/components/ActivationModal.vue index 51d75414d0296..349bf5d9ddb00 100644 --- a/packages/editor-ui/src/components/ActivationModal.vue +++ b/packages/editor-ui/src/components/ActivationModal.vue @@ -51,6 +51,7 @@ import { useUIStore } from '@/stores/ui.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useNodeTypesStore } from '@/stores/nodeTypes.store'; import { useStorage } from '@/composables/useStorage'; +import { useExecutionsStore } from '@/stores/executions.store'; export default defineComponent({ name: 'ActivationModal', @@ -67,7 +68,7 @@ export default defineComponent({ }, methods: { async showExecutionsList() { - const activeExecution = this.workflowsStore.activeWorkflowExecution; + const activeExecution = this.executionsStore.activeExecution; const currentWorkflow = this.workflowsStore.workflowId; if (activeExecution) { @@ -93,7 +94,7 @@ export default defineComponent({ }, }, computed: { - ...mapStores(useNodeTypesStore, useUIStore, useWorkflowsStore), + ...mapStores(useNodeTypesStore, useUIStore, useWorkflowsStore, useExecutionsStore), triggerContent(): string { const foundTriggers = getActivatableTriggerNodes(this.workflowsStore.workflowTriggerNodes); if (!foundTriggers.length) { diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue deleted file mode 100644 index 5f89a8c5bfc07..0000000000000 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ /dev/null @@ -1,1203 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue deleted file mode 100644 index 103c805e7884e..0000000000000 --- a/packages/editor-ui/src/components/ExecutionsView/ExecutionsList.vue +++ /dev/null @@ -1,775 +0,0 @@ - - - - - diff --git a/packages/editor-ui/src/components/MainHeader/MainHeader.vue b/packages/editor-ui/src/components/MainHeader/MainHeader.vue index dfc22dc2f2434..668b8b5d2a580 100644 --- a/packages/editor-ui/src/components/MainHeader/MainHeader.vue +++ b/packages/editor-ui/src/components/MainHeader/MainHeader.vue @@ -18,7 +18,6 @@ import { defineComponent } from 'vue'; import type { Route, RouteLocationRaw } from 'vue-router'; import { mapStores } from 'pinia'; -import type { ExecutionSummary } from 'n8n-workflow'; import { pushConnection } from '@/mixins/pushConnection'; import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue'; import TabBar from '@/components/MainHeader/TabBar.vue'; @@ -32,6 +31,8 @@ import type { INodeUi, ITabBarItem } from '@/Interface'; import { useNDVStore } from '@/stores/ndv.store'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useUIStore } from '@/stores/ui.store'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useExecutionsStore } from '@/stores/executions.store'; export default defineComponent({ name: 'MainHeader', @@ -50,11 +51,18 @@ export default defineComponent({ return { activeHeaderTab: MAIN_HEADER_TABS.WORKFLOW, workflowToReturnTo: '', + executionToReturnTo: '', dirtyState: false, }; }, computed: { - ...mapStores(useNDVStore, useUIStore, useSourceControlStore), + ...mapStores( + useNDVStore, + useUIStore, + useSourceControlStore, + useWorkflowsStore, + useExecutionsStore, + ), tabBarItems(): ITabBarItem[] { return [ { value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.editor') }, @@ -79,16 +87,13 @@ export default defineComponent({ (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true) ); }, - activeExecution(): ExecutionSummary { - return this.workflowsStore.activeWorkflowExecution as ExecutionSummary; - }, readOnly(): boolean { return this.sourceControlStore.preferences.branchReadOnly; }, }, watch: { $route(to, from) { - this.syncTabsWithRoute(to); + this.syncTabsWithRoute(to, from); }, }, mounted() { @@ -96,23 +101,27 @@ export default defineComponent({ this.syncTabsWithRoute(this.$route); }, methods: { - syncTabsWithRoute(route: Route): void { + syncTabsWithRoute(to: Route, from?: Route): void { if ( - route.name === VIEWS.EXECUTION_HOME || - route.name === VIEWS.WORKFLOW_EXECUTIONS || - route.name === VIEWS.EXECUTION_PREVIEW + to.name === VIEWS.EXECUTION_HOME || + to.name === VIEWS.WORKFLOW_EXECUTIONS || + to.name === VIEWS.EXECUTION_PREVIEW ) { this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS; } else if ( - route.name === VIEWS.WORKFLOW || - route.name === VIEWS.NEW_WORKFLOW || - route.name === VIEWS.EXECUTION_DEBUG + to.name === VIEWS.WORKFLOW || + to.name === VIEWS.NEW_WORKFLOW || + to.name === VIEWS.EXECUTION_DEBUG ) { this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW; } - const workflowName = route.params.name; - if (workflowName !== 'new') { - this.workflowToReturnTo = workflowName; + + if (to.params.name !== 'new') { + this.workflowToReturnTo = to.params.name; + } + + if (from?.name === VIEWS.EXECUTION_PREVIEW && to.params.name === from.params.name) { + this.executionToReturnTo = from.params.executionId; } }, onTabSelected(tab: MAIN_HEADER_TABS, event: MouseEvent) { @@ -158,10 +167,12 @@ export default defineComponent({ async navigateToExecutionsView(openInNewTab: boolean) { const routeWorkflowId = this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : this.currentWorkflow; - const routeToNavigateTo: RouteLocationRaw = this.activeExecution + const executionToReturnTo = + this.executionsStore.activeExecution?.id || this.executionToReturnTo; + const routeToNavigateTo: RouteLocationRaw = executionToReturnTo ? { name: VIEWS.EXECUTION_PREVIEW, - params: { name: routeWorkflowId, executionId: this.activeExecution.id }, + params: { name: routeWorkflowId, executionId: executionToReturnTo }, } : { name: VIEWS.EXECUTION_HOME, diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index ba5f8701b2eab..27e304fb95d30 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -119,7 +119,7 @@ import { useUsersStore } from '@/stores/users.store'; import { useVersionsStore } from '@/stores/versions.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; import { useTemplatesStore } from '@/stores/templates.store'; -import ExecutionsUsage from '@/components/ExecutionsUsage.vue'; +import ExecutionsUsage from '@/components/executions/ExecutionsUsage.vue'; import BecomeTemplateCreatorCta from '@/components/BecomeTemplateCreatorCta/BecomeTemplateCreatorCta.vue'; import MainSidebarSourceControl from '@/components/MainSidebarSourceControl.vue'; import { hasPermission } from '@/rbac/permissions'; diff --git a/packages/editor-ui/src/components/WorkerList.ee.vue b/packages/editor-ui/src/components/WorkerList.ee.vue index dffc42566e012..2c47f5ed63148 100644 --- a/packages/editor-ui/src/components/WorkerList.ee.vue +++ b/packages/editor-ui/src/components/WorkerList.ee.vue @@ -22,7 +22,6 @@ import { defineComponent } from 'vue'; import { mapStores } from 'pinia'; import PushConnectionTracker from '@/components/PushConnectionTracker.vue'; -import { executionHelpers } from '@/mixins/executionsHelpers'; import { useI18n } from '@/composables/useI18n'; import { useToast } from '@/composables/useToast'; import type { IPushDataWorkerStatusPayload } from '@/Interface'; @@ -38,7 +37,7 @@ export default defineComponent({ name: 'WorkerList', // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/naming-convention components: { PushConnectionTracker, WorkerCard }, - mixins: [pushConnection, executionHelpers], + mixins: [pushConnection], props: { autoRefreshEnabled: { type: Boolean, diff --git a/packages/editor-ui/src/components/WorkflowPreview.vue b/packages/editor-ui/src/components/WorkflowPreview.vue index b79099d4bb433..106706262d20c 100644 --- a/packages/editor-ui/src/components/WorkflowPreview.vue +++ b/packages/editor-ui/src/components/WorkflowPreview.vue @@ -27,7 +27,7 @@ import { useI18n } from '@/composables/useI18n'; import { useToast } from '@/composables/useToast'; import type { IWorkflowDb } from '@/Interface'; import { useRootStore } from '@/stores/n8nRoot.store'; -import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useExecutionsStore } from '@/stores/executions.store'; const props = withDefaults( defineProps<{ @@ -56,7 +56,7 @@ const emit = defineEmits<{ const i18n = useI18n(); const toast = useToast(); const rootStore = useRootStore(); -const workflowsStore = useWorkflowsStore(); +const executionsStore = useExecutionsStore(); const iframeRef = ref(null); const nodeViewDetailsOpened = ref(false); @@ -115,11 +115,11 @@ const loadExecution = () => { '*', ); - if (workflowsStore.activeWorkflowExecution) { + if (executionsStore.activeExecution) { iframeRef.value?.contentWindow?.postMessage?.( JSON.stringify({ command: 'setActiveExecution', - execution: workflowsStore.activeWorkflowExecution, + execution: executionsStore.activeExecution, }), '*', ); diff --git a/packages/editor-ui/src/components/__tests__/WorkflowPreview.test.ts b/packages/editor-ui/src/components/__tests__/WorkflowPreview.test.ts index dcda41cea23bf..c2505912a4326 100644 --- a/packages/editor-ui/src/components/__tests__/WorkflowPreview.test.ts +++ b/packages/editor-ui/src/components/__tests__/WorkflowPreview.test.ts @@ -5,12 +5,12 @@ import type { ExecutionSummary } from 'n8n-workflow'; import { createComponentRenderer } from '@/__tests__/render'; import type { INodeUi, IWorkflowDb } from '@/Interface'; import WorkflowPreview from '@/components/WorkflowPreview.vue'; -import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useExecutionsStore } from '@/stores/executions.store'; const renderComponent = createComponentRenderer(WorkflowPreview); let pinia: ReturnType; -let workflowsStore: ReturnType; +let executionsStore: ReturnType; let postMessageSpy: vi.SpyInstance; let consoleErrorSpy: vi.SpyInstance; @@ -22,7 +22,7 @@ describe('WorkflowPreview', () => { beforeEach(() => { pinia = createPinia(); setActivePinia(pinia); - workflowsStore = useWorkflowsStore(); + executionsStore = useExecutionsStore(); consoleErrorSpy = vi.spyOn(console, 'error'); postMessageSpy = vi.fn(); @@ -150,7 +150,7 @@ describe('WorkflowPreview', () => { }); it('should call also iframe postMessage with "setActiveExecution" if active execution is set', async () => { - vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue({ + vi.spyOn(executionsStore, 'activeExecution', 'get').mockReturnValue({ id: 'abc', } as ExecutionSummary); diff --git a/packages/editor-ui/src/components/__tests__/ExecutionFilter.test.ts b/packages/editor-ui/src/components/executions/ExecutionsFilter.test.ts similarity index 88% rename from packages/editor-ui/src/components/__tests__/ExecutionFilter.test.ts rename to packages/editor-ui/src/components/executions/ExecutionsFilter.test.ts index bd86e1606ed92..923bb14dd55cb 100644 --- a/packages/editor-ui/src/components/__tests__/ExecutionFilter.test.ts +++ b/packages/editor-ui/src/components/executions/ExecutionsFilter.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from 'vitest'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; import { faker } from '@faker-js/faker'; -import ExecutionFilter from '@/components/ExecutionFilter.vue'; +import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue'; import { STORES } from '@/constants'; import type { IWorkflowShortResponse, ExecutionFilterType } from '@/Interface'; import { createComponentRenderer } from '@/__tests__/render'; @@ -50,13 +50,13 @@ const initialState = { }, }; -const renderComponent = createComponentRenderer(ExecutionFilter, { +const renderComponent = createComponentRenderer(ExecutionsFilter, { props: { teleported: false, }, }); -describe('ExecutionFilter', () => { +describe('ExecutionsFilter', () => { afterAll(() => { vi.clearAllMocks(); }); @@ -134,13 +134,11 @@ describe('ExecutionFilter', () => { ); test('state change', async () => { - const { html, getByTestId, queryByTestId, emitted } = renderComponent({ + const { getByTestId, queryByTestId, emitted } = renderComponent({ pinia: createTestingPinia({ initialState }), }); - const filterChangedEvent = emitted().filterChanged; - expect(filterChangedEvent).toHaveLength(1); - expect(filterChangedEvent[0]).toEqual([defaultFilterState]); + let filterChangedEvent = emitted().filterChanged; expect(getByTestId('execution-filter-form')).not.toBeVisible(); expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument(); @@ -152,15 +150,18 @@ describe('ExecutionFilter', () => { await userEvent.click(getByTestId('executions-filter-status-select')); await userEvent.click(getByTestId('executions-filter-status-select').querySelectorAll('li')[1]); + filterChangedEvent = emitted().filterChanged; - expect(emitted().filterChanged).toHaveLength(2); - expect(filterChangedEvent[1]).toEqual([{ ...defaultFilterState, status: 'error' }]); + expect(filterChangedEvent).toHaveLength(1); + expect(filterChangedEvent[0]).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]); + filterChangedEvent = emitted().filterChanged; + + expect(filterChangedEvent).toHaveLength(2); + expect(filterChangedEvent[1]).toEqual([defaultFilterState]); expect(queryByTestId('executions-filter-reset-button')).not.toBeInTheDocument(); expect(queryByTestId('execution-filter-badge')).not.toBeInTheDocument(); }); diff --git a/packages/editor-ui/src/components/ExecutionFilter.vue b/packages/editor-ui/src/components/executions/ExecutionsFilter.vue similarity index 98% rename from packages/editor-ui/src/components/ExecutionFilter.vue rename to packages/editor-ui/src/components/executions/ExecutionsFilter.vue index 4f953bf129fab..3133fcc0912d1 100644 --- a/packages/editor-ui/src/components/ExecutionFilter.vue +++ b/packages/editor-ui/src/components/executions/ExecutionsFilter.vue @@ -4,6 +4,7 @@ import type { ExecutionFilterType, ExecutionFilterMetadata, IWorkflowShortResponse, + IWorkflowDb, } from '@/Interface'; import { i18n as locale } from '@/plugins/i18n'; import TagsDropdown from '@/components/TagsDropdown.vue'; @@ -16,7 +17,7 @@ import type { Placement } from '@floating-ui/core'; import { useDebounce } from '@/composables/useDebounce'; export type ExecutionFilterProps = { - workflows?: IWorkflowShortResponse[]; + workflows?: Array; popoverPlacement?: Placement; teleported?: boolean; }; @@ -30,6 +31,7 @@ const { debounce } = useDebounce(); const telemetry = useTelemetry(); const props = withDefaults(defineProps(), { + workflows: [] as Array, popoverPlacement: 'bottom' as Placement, teleported: true, }); @@ -92,7 +94,7 @@ const countSelectedFilterProps = computed(() => { if (filter.status !== 'all') { count++; } - if (filter.workflowId !== 'all') { + if (filter.workflowId !== 'all' && props.workflows.length) { count++; } if (!isEmpty(filter.tags)) { @@ -147,7 +149,6 @@ const goToUpgrade = () => { onBeforeMount(() => { isCustomDataFilterTracked.value = false; - emit('filterChanged', filter); }); + diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 9ea6de100f4ce..77b1afc4366a9 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -376,6 +376,7 @@ import { usePinnedData } from '@/composables/usePinnedData'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useDeviceSupport } from 'n8n-design-system'; import { useDebounce } from '@/composables/useDebounce'; +import { useExecutionsStore } from '@/stores/executions.store'; import { useCanvasPanning } from '@/composables/useCanvasPanning'; import { tryToParseNumber } from '@/utils/typesUtils'; import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; @@ -604,6 +605,7 @@ export default defineComponent({ useCollaborationStore, usePushConnectionStore, useSourceControlStore, + useExecutionsStore, ), nativelyNumberSuffixedDefaults(): string[] { return this.nodeTypesStore.nativelyNumberSuffixedDefaults; @@ -1328,7 +1330,7 @@ export default defineComponent({ this.resetWorkspace(); this.workflowsStore.currentWorkflowExecutions = []; - this.workflowsStore.activeWorkflowExecution = null; + this.executionsStore.activeExecution = null; let data: IWorkflowTemplate | undefined; try { @@ -1380,7 +1382,7 @@ export default defineComponent({ async openWorkflow(workflow: IWorkflowDb) { this.canvasStore.startLoading(); - const selectedExecution = this.workflowsStore.activeWorkflowExecution; + const selectedExecution = this.executionsStore.activeExecution; this.resetWorkspace(); @@ -1427,10 +1429,10 @@ export default defineComponent({ workflowName: workflow.name, }); if (selectedExecution?.workflowId !== workflow.id) { - this.workflowsStore.activeWorkflowExecution = null; + this.executionsStore.activeExecution = null; this.workflowsStore.currentWorkflowExecutions = []; } else { - this.workflowsStore.activeWorkflowExecution = selectedExecution; + this.executionsStore.activeExecution = selectedExecution; } this.canvasStore.stopLoading(); this.collaborationStore.notifyWorkflowOpened(workflow.id); @@ -1935,7 +1937,65 @@ export default defineComponent({ }); }, async stopExecution() { - await this.stopCurrentExecution(); + const executionId = this.workflowsStore.activeExecutionId; + if (executionId === null) { + return; + } + + try { + this.stopExecutionInProgress = true; + await this.executionsStore.stopCurrentExecution(executionId); + } catch (error) { + // Execution stop might fail when the execution has already finished. Let's treat this here. + const execution = await this.workflowsStore.getExecution(executionId); + + if (execution === undefined) { + // execution finished but was not saved (e.g. due to low connectivity) + + this.workflowsStore.finishActiveExecution({ + executionId, + data: { finished: true, stoppedAt: new Date() }, + }); + this.workflowsStore.executingNode.length = 0; + this.uiStore.removeActiveAction('workflowRunning'); + + this.titleSet(this.workflowsStore.workflowName, 'IDLE'); + this.showMessage({ + title: this.$locale.baseText('nodeView.showMessage.stopExecutionCatch.unsaved.title'), + message: this.$locale.baseText( + 'nodeView.showMessage.stopExecutionCatch.unsaved.message', + ), + type: 'success', + }); + } else if (execution?.finished) { + // execution finished before it could be stopped + + const executedData = { + data: execution.data, + finished: execution.finished, + mode: execution.mode, + startedAt: execution.startedAt, + stoppedAt: execution.stoppedAt, + } as IRun; + const pushData = { + data: executedData, + executionId, + retryOf: execution.retryOf, + } as IPushDataExecutionFinished; + this.workflowsStore.finishActiveExecution(pushData); + this.titleSet(execution.workflowData.name, 'IDLE'); + this.workflowsStore.executingNode.length = 0; + this.workflowsStore.setWorkflowExecutionData(executedData as IExecutionResponse); + this.uiStore.removeActiveAction('workflowRunning'); + this.showMessage({ + title: this.$locale.baseText('nodeView.showMessage.stopExecutionCatch.title'), + message: this.$locale.baseText('nodeView.showMessage.stopExecutionCatch.message'), + type: 'success', + }); + } else { + this.showError(error, this.$locale.baseText('nodeView.showError.stopExecution.title')); + } + } this.stopExecutionInProgress = false; void this.workflowHelpers.getWorkflowDataToSave().then((workflowData) => { const trackProps = { @@ -3484,14 +3544,14 @@ export default defineComponent({ this.resetWorkspace(); this.workflowData = await this.workflowsStore.getNewWorkflowData(); this.workflowsStore.currentWorkflowExecutions = []; - this.workflowsStore.activeWorkflowExecution = null; + this.executionsStore.activeExecution = null; this.uiStore.stateIsDirty = false; this.canvasStore.setZoomLevel(1, [0, 0]); await this.tryToAddWelcomeSticky(); this.uiStore.nodeViewInitialized = true; this.historyStore.reset(); - this.workflowsStore.activeWorkflowExecution = null; + this.executionsStore.activeExecution = null; this.canvasStore.stopLoading(); }, async tryToAddWelcomeSticky(): Promise { @@ -4583,7 +4643,7 @@ export default defineComponent({ }); } } else if (json?.command === 'setActiveExecution') { - this.workflowsStore.activeWorkflowExecution = json.execution; + this.executionsStore.activeExecution = json.execution; } } catch (e) {} }, @@ -5174,3 +5234,4 @@ export default defineComponent({ ); } +, IRun, IPushDataExecutionFinished diff --git a/packages/editor-ui/src/views/WorkflowExecutionsView.vue b/packages/editor-ui/src/views/WorkflowExecutionsView.vue new file mode 100644 index 0000000000000..460705d9f0e45 --- /dev/null +++ b/packages/editor-ui/src/views/WorkflowExecutionsView.vue @@ -0,0 +1,327 @@ + + diff --git a/packages/workflow/src/ExecutionStatus.ts b/packages/workflow/src/ExecutionStatus.ts index 3ec2f385d396c..715abbaeabfb1 100644 --- a/packages/workflow/src/ExecutionStatus.ts +++ b/packages/workflow/src/ExecutionStatus.ts @@ -1,10 +1,13 @@ -export type ExecutionStatus = - | 'canceled' - | 'crashed' - | 'error' - | 'new' - | 'running' - | 'success' - | 'unknown' - | 'waiting' - | 'warning'; +export const ExecutionStatusList = [ + 'canceled' as const, + 'crashed' as const, + 'error' as const, + 'new' as const, + 'running' as const, + 'success' as const, + 'unknown' as const, + 'waiting' as const, + 'warning' as const, +]; + +export type ExecutionStatus = (typeof ExecutionStatusList)[number];