mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Refactor and unify executions views (no-changelog) (#8538)
This commit is contained in:
parent
eab01876ab
commit
a3eea3ac5e
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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']);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
} = {};
|
||||
|
|
|
@ -171,7 +171,7 @@ export interface IExecutionsListResponse {
|
|||
estimated: boolean;
|
||||
}
|
||||
|
||||
export interface IExecutionsStopData {
|
||||
export interface ExecutionStopResult {
|
||||
finished?: boolean;
|
||||
mode: WorkflowExecuteMode;
|
||||
startedAt: Date;
|
||||
|
|
|
@ -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<IExecutionsStopData> {
|
||||
async stopExecution(executionId: string): Promise<ExecutionStopResult> {
|
||||
if (this.waitingExecutions[executionId] !== undefined) {
|
||||
// The waiting execution was already scheduled to execute.
|
||||
// So stop timer and remove.
|
||||
|
|
|
@ -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> | string;
|
||||
finished?: boolean;
|
||||
mode?: string;
|
||||
retryOf?: string;
|
||||
retrySuccessId?: string;
|
||||
status?: ExecutionStatus[];
|
||||
workflowId?: string;
|
||||
waitTill?: FindOperator<any> | boolean;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
startedAfter?: string;
|
||||
startedBefore?: string;
|
||||
}
|
||||
|
||||
function parseFiltersToQueryBuilder(
|
||||
qb: SelectQueryBuilder<ExecutionEntity>,
|
||||
|
@ -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<ExecutionEntity> {
|
||||
private hardDeletionBatchSize = 100;
|
||||
|
@ -284,114 +307,6 @@ export class ExecutionRepository extends Repository<ExecutionEntity> {
|
|||
}
|
||||
}
|
||||
|
||||
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<ExecutionSummary[]> {
|
||||
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<ExecutionEntity> {
|
|||
});
|
||||
}
|
||||
|
||||
async getManyActive(
|
||||
activeExecutionIds: string[],
|
||||
accessibleWorkflowIds: string[],
|
||||
filter?: GetManyActiveFilter,
|
||||
) {
|
||||
const where: FindOptionsWhere<ExecutionEntity> = {
|
||||
id: In(activeExecutionIds),
|
||||
status: Not(In(['finished', 'stopped', 'error', 'crashed'])),
|
||||
};
|
||||
// ----------------------------------
|
||||
// new API
|
||||
// ----------------------------------
|
||||
|
||||
if (filter) {
|
||||
const { workflowId, status, finished } = filter;
|
||||
if (workflowId && accessibleWorkflowIds.includes(workflowId)) {
|
||||
where.workflowId = workflowId;
|
||||
} else {
|
||||
where.workflowId = In(accessibleWorkflowIds);
|
||||
}
|
||||
if (status) {
|
||||
// @ts-ignore
|
||||
where.status = In(status);
|
||||
}
|
||||
if (finished !== undefined) {
|
||||
where.finished = finished;
|
||||
}
|
||||
} else {
|
||||
where.workflowId = In(accessibleWorkflowIds);
|
||||
/**
|
||||
* 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<ExecutionSummary[]> {
|
||||
if (query?.accessibleWorkflowIds?.length === 0) {
|
||||
throw new ApplicationError('Expected accessible workflow IDs');
|
||||
}
|
||||
|
||||
return await this.findMultipleExecutions({
|
||||
select: ['id', 'workflowId', 'mode', 'retryOf', 'startedAt', 'stoppedAt', 'status'],
|
||||
order: { id: 'DESC' },
|
||||
where,
|
||||
});
|
||||
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 (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 {
|
||||
qb.orderBy({ 'execution.id': 'DESC' });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return qb;
|
||||
}
|
||||
|
||||
async getAllIds() {
|
||||
const executions = await this.find({ select: ['id'], order: { id: 'ASC' } });
|
||||
|
||||
return executions.map(({ id }) => id);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IGetExecutionsQueryFilter {
|
||||
id?: FindOperator<string> | string;
|
||||
finished?: boolean;
|
||||
mode?: string;
|
||||
retryOf?: string;
|
||||
retrySuccessId?: string;
|
||||
status?: ExecutionStatus[];
|
||||
workflowId?: string;
|
||||
waitTill?: FindOperator<any> | boolean;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
startedAfter?: string;
|
||||
startedBefore?: string;
|
||||
}
|
||||
|
|
|
@ -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 } });
|
||||
}
|
||||
}
|
|
@ -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<ExecutionSummary[]> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<RouteParams.ExecutionId, {}, {}, QueryParams.GetOne>;
|
||||
|
||||
|
@ -37,12 +39,47 @@ export declare namespace ExecutionRequest {
|
|||
type Retry = AuthenticatedRequest<RouteParams.ExecutionId, {}, { loadWorkflow: boolean }, {}>;
|
||||
|
||||
type Stop = AuthenticatedRequest<RouteParams.ExecutionId>;
|
||||
|
||||
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';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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<GetManyActiveFilter>(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);
|
||||
|
|
56
packages/cli/src/executions/parse-range-query.middleware.ts
Normal file
56
packages/cli/src/executions/parse-range-query.middleware.ts
Normal file
|
@ -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<JsonObject>(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));
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<Queue>();
|
||||
const activeExecutions = mock<ActiveExecutions>();
|
||||
const executionRepository = mock<ExecutionRepository>();
|
||||
const waitTracker = mock<WaitTracker>();
|
||||
|
||||
const jobIds = ['j1', 'j2'];
|
||||
const jobs = jobIds.map((executionId) => mock<Job>({ 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<IExecutionBase>({ 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<IExecutionBase>({ 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<IExecutionBase>({ 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<IExecutionBase>({ 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<IExecutionsCurrentSummary>({ 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<IExecutionsCurrentSummary>({ 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',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<ActiveExecutionService>();
|
||||
const executionService = mock<ExecutionService>();
|
||||
const workflowSharingService = mock<WorkflowSharingService>();
|
||||
|
||||
const req = mock<ExecutionRequest.GetManyActive>({ 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: [] };
|
||||
|
||||
it('should call `ActiveExecutionService.findManyInQueueMode()`', async () => {
|
||||
getEnv.calledWith('executions.mode').mockReturnValue('queue');
|
||||
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 },
|
||||
},
|
||||
];
|
||||
|
||||
await new ExecutionsController(
|
||||
mock(),
|
||||
mock(),
|
||||
workflowSharingService,
|
||||
activeExecutionService,
|
||||
mock(),
|
||||
).getActive(req);
|
||||
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 },
|
||||
},
|
||||
];
|
||||
|
||||
expect(activeExecutionService.findManyInQueueMode).toHaveBeenCalled();
|
||||
expect(activeExecutionService.findManyInRegularMode).not.toHaveBeenCalled();
|
||||
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<ExecutionRequest.GetMany>({ rangeQuery });
|
||||
|
||||
await executionsController.getMany(req);
|
||||
|
||||
expect(executionService.findAllRunningAndLatest).not.toHaveBeenCalled();
|
||||
expect(executionService.findRangeWithCount).toHaveBeenCalledWith(rangeQuery);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should call `ActiveExecutionService.findManyInRegularMode()`', async () => {
|
||||
getEnv.calledWith('executions.mode').mockReturnValue('regular');
|
||||
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);
|
||||
|
||||
await new ExecutionsController(
|
||||
mock(),
|
||||
mock(),
|
||||
workflowSharingService,
|
||||
activeExecutionService,
|
||||
mock(),
|
||||
).getActive(req);
|
||||
const req = mock<ExecutionRequest.GetMany>({ rangeQuery });
|
||||
|
||||
expect(activeExecutionService.findManyInQueueMode).not.toHaveBeenCalled();
|
||||
expect(activeExecutionService.findManyInRegularMode).toHaveBeenCalled();
|
||||
await executionsController.getMany(req);
|
||||
|
||||
expect(executionService.findAllRunningAndLatest).toHaveBeenCalled();
|
||||
expect(executionService.findRangeWithCount).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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<ExecutionRequest.GetMany>({ rangeQuery });
|
||||
|
||||
await executionsController.getMany(req);
|
||||
|
||||
expect(executionService.findAllRunningAndLatest).not.toHaveBeenCalled();
|
||||
expect(executionService.findRangeWithCount).toHaveBeenCalledWith(rangeQuery);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop()', () => {
|
||||
const req = mock<ExecutionRequest.Stop>({ params: { id: '999' } });
|
||||
const execution = mock<IExecutionBase>();
|
||||
describe('stop', () => {
|
||||
const executionId = '999';
|
||||
const req = mock<ExecutionRequest.Stop>({ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<express.Response>({
|
||||
status: () => mock<express.Response>({ 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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
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<ExecutionRequest.GetMany>({
|
||||
query: {
|
||||
filter: undefined,
|
||||
limit: undefined,
|
||||
firstId: undefined,
|
||||
lastId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
parseRangeQuery(req, res, nextFn);
|
||||
|
||||
expect(req.rangeQuery.range.limit).toEqual(20);
|
||||
expect(nextFn).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -1282,7 +1282,6 @@ export interface UIState {
|
|||
selectedNodes: INodeUi[];
|
||||
nodeViewInitialized: boolean;
|
||||
addFirstStepOnLoad: boolean;
|
||||
executionSidebarAutoRefresh: boolean;
|
||||
bannersHeight: number;
|
||||
bannerStack: BannerName[];
|
||||
theme: ThemeOption;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,775 +0,0 @@
|
|||
<template>
|
||||
<div :class="$style.container">
|
||||
<ExecutionsSidebar
|
||||
:executions="executions"
|
||||
:loading="loading && !executions.length"
|
||||
:loading-more="loadingMore"
|
||||
:temporary-execution="temporaryExecution"
|
||||
:auto-refresh="autoRefresh"
|
||||
@update:auto-refresh="onAutoRefreshToggle"
|
||||
@reload-executions="setExecutions"
|
||||
@filter-updated="onFilterUpdated"
|
||||
@load-more="onLoadMore"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<div v-if="!hidePreview" :class="$style.content">
|
||||
<router-view
|
||||
name="executionPreview"
|
||||
@delete-current-execution="onDeleteCurrentExecution"
|
||||
@retry-execution="onRetryExecution"
|
||||
@stop-execution="onStopExecution"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
|
||||
import ExecutionsSidebar from '@/components/ExecutionsView/ExecutionsSidebar.vue';
|
||||
import {
|
||||
MAIN_HEADER_TABS,
|
||||
MODAL_CANCEL,
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
} from '@/constants';
|
||||
import type {
|
||||
ExecutionFilterType,
|
||||
IExecutionsListResponse,
|
||||
INodeUi,
|
||||
ITag,
|
||||
IWorkflowDb,
|
||||
} from '@/Interface';
|
||||
import type {
|
||||
ExecutionSummary,
|
||||
IConnection,
|
||||
IConnections,
|
||||
IDataObject,
|
||||
INodeTypeDescription,
|
||||
INodeTypeNameVersion,
|
||||
} from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { useRouter, type Route } from 'vue-router';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import { range as _range } from 'lodash-es';
|
||||
import { NO_NETWORK_ERROR_CODE } from '@/utils/apiUtils';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
|
||||
// Number of execution pages that are fetched before temporary execution card is shown
|
||||
const MAX_LOADING_ATTEMPTS = 5;
|
||||
// Number of executions fetched on each page
|
||||
const LOAD_MORE_PAGE_SIZE = 100;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsList',
|
||||
components: {
|
||||
ExecutionsSidebar,
|
||||
},
|
||||
mixins: [executionHelpers],
|
||||
setup() {
|
||||
const externalHooks = useExternalHooks();
|
||||
const router = useRouter();
|
||||
const workflowHelpers = useWorkflowHelpers({ router });
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
return {
|
||||
externalHooks,
|
||||
workflowHelpers,
|
||||
callDebounced,
|
||||
...useToast(),
|
||||
...useMessage(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
filter: {} as ExecutionFilterType,
|
||||
temporaryExecution: null as ExecutionSummary | null,
|
||||
autoRefresh: false,
|
||||
autoRefreshTimeout: undefined as undefined | NodeJS.Timer,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore, useWorkflowsStore),
|
||||
hidePreview(): boolean {
|
||||
const activeNotPresent =
|
||||
this.filterApplied && !this.executions.find((ex) => ex.id === this.activeExecution?.id);
|
||||
return this.loading || !this.executions.length || activeNotPresent;
|
||||
},
|
||||
filterApplied(): boolean {
|
||||
return this.filter.status !== 'all';
|
||||
},
|
||||
workflowDataNotLoaded(): boolean {
|
||||
return (
|
||||
this.workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID &&
|
||||
this.workflowsStore.workflowName === ''
|
||||
);
|
||||
},
|
||||
loadedFinishedExecutionsCount(): number {
|
||||
return this.workflowsStore.getAllLoadedFinishedExecutions.length;
|
||||
},
|
||||
totalFinishedExecutionsCount(): number {
|
||||
return this.workflowsStore.getTotalFinishedExecutionsCount;
|
||||
},
|
||||
requestFilter(): IDataObject {
|
||||
return executionFilterToQueryFilter({
|
||||
...this.filter,
|
||||
workflowId: this.currentWorkflow,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route(to: Route, from: Route) {
|
||||
if (to.params.name) {
|
||||
const workflowChanged = from.params.name !== to.params.name;
|
||||
void this.initView(workflowChanged);
|
||||
}
|
||||
|
||||
if (to.params.executionId) {
|
||||
const execution = this.workflowsStore.getExecutionDataById(to.params.executionId);
|
||||
if (execution) {
|
||||
this.workflowsStore.activeWorkflowExecution = execution;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
async beforeRouteLeave(to, from, next) {
|
||||
if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (this.uiStore.stateIsDirty) {
|
||||
const confirmModal = await this.confirm(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
title: this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
type: 'warning',
|
||||
confirmButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.cancelButtonText',
|
||||
),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRM) {
|
||||
const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false);
|
||||
if (saved) {
|
||||
await this.settingsStore.fetchPromptsData();
|
||||
}
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.autoRefresh = this.uiStore.executionSidebarAutoRefresh;
|
||||
},
|
||||
async mounted() {
|
||||
this.loading = true;
|
||||
const workflowUpdated = this.$route.params.name !== this.workflowsStore.workflowId;
|
||||
const onNewWorkflow =
|
||||
this.$route.params.name === 'new' &&
|
||||
this.workflowsStore.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID;
|
||||
const shouldUpdate = workflowUpdated && !onNewWorkflow;
|
||||
await this.initView(shouldUpdate);
|
||||
if (!shouldUpdate) {
|
||||
if (this.workflowsStore.currentWorkflowExecutions.length > 0) {
|
||||
const workflowExecutions = await this.loadExecutions();
|
||||
this.workflowsStore.addToCurrentExecutions(workflowExecutions);
|
||||
await this.setActiveExecution();
|
||||
} else {
|
||||
await this.setExecutions();
|
||||
}
|
||||
}
|
||||
void this.startAutoRefreshInterval();
|
||||
document.addEventListener('visibilitychange', this.onDocumentVisibilityChange);
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
beforeUnmount() {
|
||||
document.removeEventListener('visibilitychange', this.onDocumentVisibilityChange);
|
||||
this.autoRefresh = false;
|
||||
this.stopAutoRefreshInterval();
|
||||
},
|
||||
methods: {
|
||||
async initView(loadWorkflow: boolean): Promise<void> {
|
||||
if (loadWorkflow) {
|
||||
await this.nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
await this.openWorkflow(this.$route.params.name);
|
||||
this.uiStore.nodeViewInitialized = false;
|
||||
if (this.workflowsStore.currentWorkflowExecutions.length === 0) {
|
||||
await this.setExecutions();
|
||||
}
|
||||
if (this.activeExecution) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.activeExecution.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
async onLoadMore(): Promise<void> {
|
||||
if (!this.loadingMore) {
|
||||
await this.callDebounced(this.loadMore, { debounceTime: 1000 });
|
||||
}
|
||||
},
|
||||
async loadMore(limit = 20): Promise<void> {
|
||||
if (
|
||||
this.filter.status === 'running' ||
|
||||
this.loadedFinishedExecutionsCount >= this.totalFinishedExecutionsCount
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.loadingMore = true;
|
||||
|
||||
let lastId: string | undefined;
|
||||
if (this.executions.length !== 0) {
|
||||
const lastItem = this.executions.slice(-1)[0];
|
||||
lastId = lastItem.id;
|
||||
}
|
||||
|
||||
let data: IExecutionsListResponse;
|
||||
try {
|
||||
data = await this.workflowsStore.getPastExecutions(this.requestFilter, limit, lastId);
|
||||
} catch (error) {
|
||||
this.loadingMore = false;
|
||||
this.showError(error, this.$locale.baseText('executionsList.showError.loadMore.title'));
|
||||
return;
|
||||
}
|
||||
|
||||
data.results = data.results.map((execution) => {
|
||||
// @ts-ignore
|
||||
return { ...execution, mode: execution.mode };
|
||||
});
|
||||
const currentExecutions = [...this.executions];
|
||||
for (const newExecution of data.results) {
|
||||
if (currentExecutions.find((ex) => ex.id === newExecution.id) === undefined) {
|
||||
currentExecutions.push(newExecution);
|
||||
}
|
||||
// If we loaded temp execution, put it into it's place and remove from top of the list
|
||||
if (newExecution.id === this.temporaryExecution?.id) {
|
||||
this.temporaryExecution = null;
|
||||
}
|
||||
}
|
||||
this.workflowsStore.currentWorkflowExecutions = currentExecutions;
|
||||
this.loadingMore = false;
|
||||
},
|
||||
async onDeleteCurrentExecution(): Promise<void> {
|
||||
this.loading = true;
|
||||
try {
|
||||
const executionIndex = this.executions.findIndex(
|
||||
(execution: ExecutionSummary) => execution.id === this.$route.params.executionId,
|
||||
);
|
||||
const nextExecution =
|
||||
this.executions[executionIndex + 1] ||
|
||||
this.executions[executionIndex - 1] ||
|
||||
this.executions[0];
|
||||
|
||||
await this.workflowsStore.deleteExecutions({ ids: [this.$route.params.executionId] });
|
||||
this.workflowsStore.deleteExecution(this.executions[executionIndex]);
|
||||
if (this.temporaryExecution?.id === this.$route.params.executionId) {
|
||||
this.temporaryExecution = null;
|
||||
}
|
||||
if (this.executions.length > 0) {
|
||||
await this.$router
|
||||
.replace({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: nextExecution.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
this.workflowsStore.activeWorkflowExecution = nextExecution;
|
||||
await this.setExecutions();
|
||||
} else {
|
||||
// If there are no executions left, show empty state and clear active execution from the store
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
await this.$router.replace({
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
params: { name: this.currentWorkflow },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.loading = false;
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.handleDeleteSelected.title'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.loading = false;
|
||||
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.handleDeleteSelected.title'),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
async onStopExecution(): Promise<void> {
|
||||
const activeExecutionId = this.$route.params.executionId;
|
||||
|
||||
try {
|
||||
await this.workflowsStore.stopCurrentExecution(activeExecutionId);
|
||||
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.stopExecution.title'),
|
||||
message: this.$locale.baseText('executionsList.showMessage.stopExecution.message', {
|
||||
interpolate: { activeExecutionId },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
await this.loadAutoRefresh();
|
||||
} catch (error) {
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.stopExecution.title'),
|
||||
);
|
||||
}
|
||||
},
|
||||
async onFilterUpdated(filter: ExecutionFilterType) {
|
||||
this.filter = filter;
|
||||
await this.setExecutions();
|
||||
},
|
||||
async setExecutions(): Promise<void> {
|
||||
this.workflowsStore.currentWorkflowExecutions = await this.loadExecutions();
|
||||
await this.setActiveExecution();
|
||||
},
|
||||
|
||||
async startAutoRefreshInterval() {
|
||||
if (this.autoRefresh) {
|
||||
await this.loadAutoRefresh();
|
||||
this.stopAutoRefreshInterval();
|
||||
this.autoRefreshTimeout = setTimeout(() => {
|
||||
void this.startAutoRefreshInterval();
|
||||
}, 4000);
|
||||
}
|
||||
},
|
||||
stopAutoRefreshInterval() {
|
||||
clearTimeout(this.autoRefreshTimeout);
|
||||
this.autoRefreshTimeout = undefined;
|
||||
},
|
||||
onAutoRefreshToggle(value: boolean): void {
|
||||
this.autoRefresh = value;
|
||||
this.uiStore.executionSidebarAutoRefresh = this.autoRefresh;
|
||||
|
||||
this.stopAutoRefreshInterval(); // Clear any previously existing intervals (if any - there shouldn't)
|
||||
void this.startAutoRefreshInterval();
|
||||
},
|
||||
onDocumentVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
void this.stopAutoRefreshInterval();
|
||||
} else {
|
||||
void this.startAutoRefreshInterval();
|
||||
}
|
||||
},
|
||||
async loadAutoRefresh(): Promise<void> {
|
||||
// Most of the auto-refresh logic is taken from the `ExecutionsList` component
|
||||
const fetchedExecutions: ExecutionSummary[] = await this.loadExecutions();
|
||||
let existingExecutions: ExecutionSummary[] = [...this.executions];
|
||||
const alreadyPresentExecutionIds = existingExecutions.map((exec) => parseInt(exec.id, 10));
|
||||
let lastId = 0;
|
||||
const gaps = [] as number[];
|
||||
let updatedActiveExecution = null;
|
||||
|
||||
for (let i = fetchedExecutions.length - 1; i >= 0; i--) {
|
||||
const currentItem = fetchedExecutions[i];
|
||||
const currentId = parseInt(currentItem.id, 10);
|
||||
if (lastId !== 0 && !isNaN(currentId)) {
|
||||
if (currentId - lastId > 1) {
|
||||
const range = _range(lastId + 1, currentId);
|
||||
gaps.push(...range);
|
||||
}
|
||||
}
|
||||
lastId = parseInt(currentItem.id, 10) || 0;
|
||||
|
||||
const executionIndex = alreadyPresentExecutionIds.indexOf(currentId);
|
||||
if (executionIndex !== -1) {
|
||||
const existingExecution = existingExecutions.find((ex) => ex.id === currentItem.id);
|
||||
const existingStillRunning =
|
||||
(existingExecution && existingExecution.finished === false) ||
|
||||
existingExecution?.stoppedAt === undefined;
|
||||
const currentFinished =
|
||||
currentItem.finished === true || currentItem.stoppedAt !== undefined;
|
||||
|
||||
if (existingStillRunning && currentFinished) {
|
||||
existingExecutions[executionIndex] = currentItem;
|
||||
if (currentItem.id === this.activeExecution?.id) {
|
||||
updatedActiveExecution = currentItem;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let j;
|
||||
for (j = existingExecutions.length - 1; j >= 0; j--) {
|
||||
if (currentId < parseInt(existingExecutions[j].id, 10)) {
|
||||
existingExecutions.splice(j + 1, 0, currentItem);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (j === -1) {
|
||||
existingExecutions.unshift(currentItem);
|
||||
}
|
||||
}
|
||||
|
||||
existingExecutions = existingExecutions.filter(
|
||||
(execution) =>
|
||||
!gaps.includes(parseInt(execution.id, 10)) && lastId >= parseInt(execution.id, 10),
|
||||
);
|
||||
this.workflowsStore.currentWorkflowExecutions = existingExecutions;
|
||||
if (updatedActiveExecution !== null) {
|
||||
this.workflowsStore.activeWorkflowExecution = updatedActiveExecution;
|
||||
} else {
|
||||
const activeInList = existingExecutions.some((ex) => ex.id === this.activeExecution?.id);
|
||||
if (!activeInList && this.executions.length > 0 && !this.temporaryExecution) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (this.executions.length === 0 && this.$route.name === VIEWS.EXECUTION_PREVIEW) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
params: {
|
||||
name: this.currentWorkflow,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
async loadExecutions(): Promise<ExecutionSummary[]> {
|
||||
if (!this.currentWorkflow) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await this.workflowsStore.loadCurrentWorkflowExecutions(this.requestFilter);
|
||||
} catch (error) {
|
||||
if (error.errorCode === NO_NETWORK_ERROR_CODE) {
|
||||
this.showMessage(
|
||||
{
|
||||
title: this.$locale.baseText('executionsList.showError.refreshData.title'),
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
duration: 3500,
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.refreshData.title'),
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
},
|
||||
async setActiveExecution(): Promise<void> {
|
||||
const activeExecutionId = this.$route.params.executionId;
|
||||
if (activeExecutionId) {
|
||||
const execution = this.workflowsStore.getExecutionDataById(activeExecutionId);
|
||||
if (execution) {
|
||||
this.workflowsStore.activeWorkflowExecution = execution;
|
||||
} else {
|
||||
await this.tryToFindExecution(activeExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no execution in the route, select the first one
|
||||
if (
|
||||
this.workflowsStore.activeWorkflowExecution === null &&
|
||||
this.executions.length > 0 &&
|
||||
!this.temporaryExecution
|
||||
) {
|
||||
this.workflowsStore.activeWorkflowExecution = this.executions[0];
|
||||
|
||||
if (this.$route.name === VIEWS.EXECUTION_HOME) {
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
async tryToFindExecution(executionId: string, attemptCount = 0): Promise<void> {
|
||||
// First check if executions exists in the DB at all
|
||||
if (attemptCount === 0) {
|
||||
const existingExecution = await this.workflowsStore.fetchExecutionDataById(executionId);
|
||||
if (!existingExecution) {
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
this.showError(
|
||||
new Error(
|
||||
this.$locale.baseText('executionView.notFound.message', {
|
||||
interpolate: { executionId },
|
||||
}),
|
||||
),
|
||||
this.$locale.baseText('nodeView.showError.openExecution.title'),
|
||||
);
|
||||
return;
|
||||
} else {
|
||||
this.temporaryExecution = existingExecution as ExecutionSummary;
|
||||
}
|
||||
}
|
||||
// stop if the execution wasn't found in the first 1000 lookups
|
||||
if (attemptCount >= MAX_LOADING_ATTEMPTS) {
|
||||
if (this.temporaryExecution) {
|
||||
this.workflowsStore.activeWorkflowExecution = this.temporaryExecution;
|
||||
return;
|
||||
}
|
||||
this.workflowsStore.activeWorkflowExecution = null;
|
||||
return;
|
||||
}
|
||||
// Fetch next batch of executions
|
||||
await this.loadMore(LOAD_MORE_PAGE_SIZE);
|
||||
const execution = this.workflowsStore.getExecutionDataById(executionId);
|
||||
if (!execution) {
|
||||
// If it's not there load next until found
|
||||
await this.$nextTick();
|
||||
// But skip fetching execution data since we at this point know it exists
|
||||
await this.tryToFindExecution(executionId, attemptCount + 1);
|
||||
} else {
|
||||
// When found set execution as active
|
||||
this.workflowsStore.activeWorkflowExecution = execution;
|
||||
this.temporaryExecution = null;
|
||||
return;
|
||||
}
|
||||
},
|
||||
async openWorkflow(workflowId: string): Promise<void> {
|
||||
await this.loadActiveWorkflows();
|
||||
|
||||
let data: IWorkflowDb | undefined;
|
||||
try {
|
||||
data = await this.workflowsStore.fetchWorkflow(workflowId);
|
||||
} catch (error) {
|
||||
this.showError(error, this.$locale.baseText('nodeView.showError.openWorkflow.title'));
|
||||
return;
|
||||
}
|
||||
if (data === undefined) {
|
||||
throw new Error(
|
||||
this.$locale.baseText('nodeView.workflowWithIdCouldNotBeFound', {
|
||||
interpolate: { workflowId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
await this.addNodes(data.nodes, data.connections);
|
||||
|
||||
this.workflowsStore.setActive(data.active || false);
|
||||
this.workflowsStore.setWorkflowId(workflowId);
|
||||
this.workflowsStore.setWorkflowName({ newName: data.name, setStateDirty: false });
|
||||
this.workflowsStore.setWorkflowSettings(data.settings || {});
|
||||
this.workflowsStore.setWorkflowPinData(data.pinData || {});
|
||||
const tags = (data.tags || []) as ITag[];
|
||||
const tagIds = tags.map((tag) => tag.id);
|
||||
this.workflowsStore.setWorkflowTagIds(tagIds || []);
|
||||
this.workflowsStore.setWorkflowVersionId(data.versionId);
|
||||
|
||||
this.tagsStore.upsertTags(tags);
|
||||
|
||||
void this.externalHooks.run('workflow.open', { workflowId, workflowName: data.name });
|
||||
this.uiStore.stateIsDirty = false;
|
||||
},
|
||||
async addNodes(nodes: INodeUi[], connections?: IConnections) {
|
||||
if (!nodes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadNodesProperties(
|
||||
nodes.map((node) => ({ name: node.type, version: node.typeVersion })),
|
||||
);
|
||||
|
||||
let nodeType: INodeTypeDescription | null;
|
||||
nodes.forEach((node) => {
|
||||
if (!node.id) {
|
||||
node.id = uuid();
|
||||
}
|
||||
|
||||
nodeType = this.nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
|
||||
// Make sure that some properties always exist
|
||||
if (!node.hasOwnProperty('disabled')) {
|
||||
node.disabled = false;
|
||||
}
|
||||
|
||||
if (!node.hasOwnProperty('parameters')) {
|
||||
node.parameters = {};
|
||||
}
|
||||
|
||||
// Load the defaul parameter values because only values which differ
|
||||
// from the defaults get saved
|
||||
if (nodeType !== null) {
|
||||
let nodeParameters = null;
|
||||
try {
|
||||
nodeParameters = NodeHelpers.getNodeParameters(
|
||||
nodeType.properties,
|
||||
node.parameters,
|
||||
true,
|
||||
false,
|
||||
node,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
this.$locale.baseText('nodeView.thereWasAProblemLoadingTheNodeParametersOfNode') +
|
||||
`: "${node.name}"`,
|
||||
);
|
||||
console.error(e);
|
||||
}
|
||||
node.parameters = nodeParameters !== null ? nodeParameters : {};
|
||||
|
||||
// if it's a webhook and the path is empty set the UUID as the default path
|
||||
if (node.type === WEBHOOK_NODE_TYPE && node.parameters.path === '') {
|
||||
node.parameters.path = node.webhookId as string;
|
||||
}
|
||||
}
|
||||
|
||||
this.workflowsStore.addNode(node);
|
||||
});
|
||||
|
||||
// Load the connections
|
||||
if (connections !== undefined) {
|
||||
let connectionData;
|
||||
for (const sourceNode of Object.keys(connections)) {
|
||||
for (const type of Object.keys(connections[sourceNode])) {
|
||||
for (
|
||||
let sourceIndex = 0;
|
||||
sourceIndex < connections[sourceNode][type].length;
|
||||
sourceIndex++
|
||||
) {
|
||||
const outwardConnections = connections[sourceNode][type][sourceIndex];
|
||||
if (!outwardConnections) {
|
||||
continue;
|
||||
}
|
||||
outwardConnections.forEach((targetData) => {
|
||||
connectionData = [
|
||||
{
|
||||
node: sourceNode,
|
||||
type,
|
||||
index: sourceIndex,
|
||||
},
|
||||
{
|
||||
node: targetData.node,
|
||||
type: targetData.type,
|
||||
index: targetData.index,
|
||||
},
|
||||
] as [IConnection, IConnection];
|
||||
|
||||
this.workflowsStore.addConnection({
|
||||
connection: connectionData,
|
||||
setStateDirty: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
|
||||
const allNodes: INodeTypeDescription[] = this.nodeTypesStore.allNodeTypes;
|
||||
|
||||
const nodesToBeFetched: INodeTypeNameVersion[] = [];
|
||||
allNodes.forEach((node) => {
|
||||
const nodeVersions = Array.isArray(node.version) ? node.version : [node.version];
|
||||
if (
|
||||
!!nodeInfos.find((n) => n.name === node.name && nodeVersions.includes(n.version)) &&
|
||||
!node.hasOwnProperty('properties')
|
||||
) {
|
||||
nodesToBeFetched.push({
|
||||
name: node.name,
|
||||
version: Array.isArray(node.version) ? node.version.slice(-1)[0] : node.version,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (nodesToBeFetched.length > 0) {
|
||||
// Only call API if node information is actually missing
|
||||
await this.nodeTypesStore.getNodesInformation(nodesToBeFetched);
|
||||
}
|
||||
},
|
||||
async loadActiveWorkflows(): Promise<void> {
|
||||
await this.workflowsStore.fetchActiveWorkflows();
|
||||
},
|
||||
async onRetryExecution(payload: { execution: ExecutionSummary; command: string }) {
|
||||
const loadWorkflow = payload.command === 'current-workflow';
|
||||
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('executionDetails.runningMessage'),
|
||||
type: 'info',
|
||||
duration: 2000,
|
||||
});
|
||||
await this.retryExecution(payload.execution, loadWorkflow);
|
||||
await this.loadAutoRefresh();
|
||||
|
||||
this.$telemetry.track('User clicked retry execution button', {
|
||||
workflow_id: this.workflowsStore.workflowId,
|
||||
execution_id: payload.execution.id,
|
||||
retry_type: loadWorkflow ? 'current' : 'original',
|
||||
});
|
||||
},
|
||||
async retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) {
|
||||
try {
|
||||
const retrySuccessful = await this.workflowsStore.retryExecution(
|
||||
execution.id,
|
||||
loadWorkflow,
|
||||
);
|
||||
|
||||
if (retrySuccessful) {
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
this.showMessage({
|
||||
title: this.$locale.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.showError(
|
||||
error,
|
||||
this.$locale.baseText('executionsList.showError.retryExecution.title'),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<HTMLIFrameElement | null>(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,
|
||||
}),
|
||||
'*',
|
||||
);
|
||||
|
|
|
@ -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<typeof createPinia>;
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
let executionsStore: ReturnType<typeof useExecutionsStore>;
|
||||
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);
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
|
@ -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<IWorkflowDb | IWorkflowShortResponse>;
|
||||
popoverPlacement?: Placement;
|
||||
teleported?: boolean;
|
||||
};
|
||||
|
@ -30,6 +31,7 @@ const { debounce } = useDebounce();
|
|||
const telemetry = useTelemetry();
|
||||
|
||||
const props = withDefaults(defineProps<ExecutionFilterProps>(), {
|
||||
workflows: [] as Array<IWorkflowDb | IWorkflowShortResponse>,
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
<template>
|
|
@ -8,7 +8,7 @@
|
|||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionTime',
|
||||
name: 'ExecutionsTime',
|
||||
props: ['startTime'],
|
||||
data() {
|
||||
return {
|
|
@ -4,18 +4,18 @@ import { createTestingPinia } from '@pinia/testing';
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { STORES, VIEWS } from '@/constants';
|
||||
import ExecutionsList from '@/components/ExecutionsList.vue';
|
||||
import ExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { retry, SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { RenderOptions } from '@/__tests__/render';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: vi.fn().mockReturnValue({
|
||||
name: VIEWS.WORKFLOW_EXECUTIONS,
|
||||
}),
|
||||
useRouter: vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
}));
|
||||
|
||||
|
@ -71,22 +71,21 @@ const generateExecutionsData = () =>
|
|||
estimated: false,
|
||||
}));
|
||||
|
||||
const defaultRenderOptions: RenderOptions = {
|
||||
const renderComponent = createComponentRenderer(ExecutionsList, {
|
||||
props: {
|
||||
autoRefreshEnabled: false,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
stubs: ['font-awesome-icon'],
|
||||
mocks: {
|
||||
$route: {
|
||||
params: {},
|
||||
},
|
||||
},
|
||||
stubs: ['font-awesome-icon'],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(ExecutionsList, defaultRenderOptions);
|
||||
|
||||
describe('ExecutionsList.vue', () => {
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
let workflowsData: IWorkflowDb[];
|
||||
describe('GlobalExecutionsList', () => {
|
||||
let executionsData: Array<{
|
||||
count: number;
|
||||
results: ExecutionSummary[];
|
||||
|
@ -94,11 +93,13 @@ describe('ExecutionsList.vue', () => {
|
|||
}>;
|
||||
|
||||
beforeEach(() => {
|
||||
workflowsData = generateWorkflowsData();
|
||||
executionsData = generateExecutionsData();
|
||||
|
||||
pinia = createTestingPinia({
|
||||
initialState: {
|
||||
[STORES.EXECUTIONS]: {
|
||||
executions: [],
|
||||
},
|
||||
[STORES.SETTINGS]: {
|
||||
settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, {
|
||||
enterprise: {
|
||||
|
@ -108,22 +109,14 @@ describe('ExecutionsList.vue', () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
workflowsStore = useWorkflowsStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'fetchAllWorkflows').mockResolvedValue(workflowsData);
|
||||
vi.spyOn(workflowsStore, 'getActiveExecutions').mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('should render empty list', async () => {
|
||||
vi.spyOn(workflowsStore, 'getPastExecutions').mockResolvedValueOnce({
|
||||
count: 0,
|
||||
results: [],
|
||||
estimated: false,
|
||||
});
|
||||
const { queryAllByTestId, queryByTestId, getByTestId } = renderComponent({
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
props: {
|
||||
executions: [],
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
|
@ -138,20 +131,16 @@ describe('ExecutionsList.vue', () => {
|
|||
it(
|
||||
'should handle selection flow when loading more items',
|
||||
async () => {
|
||||
const storeSpy = vi
|
||||
.spyOn(workflowsStore, 'getPastExecutions')
|
||||
.mockResolvedValueOnce(executionsData[0])
|
||||
.mockResolvedValueOnce(executionsData[1]);
|
||||
|
||||
const { getByTestId, getAllByTestId, queryByTestId } = renderComponent({
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
const { getByTestId, getAllByTestId, queryByTestId, rerender } = renderComponent({
|
||||
props: {
|
||||
executions: executionsData[0].results,
|
||||
total: executionsData[0].count,
|
||||
filteredExecutions: executionsData[0].results,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(getByTestId('select-visible-executions-checkbox'));
|
||||
|
||||
await retry(() =>
|
||||
|
@ -165,9 +154,12 @@ describe('ExecutionsList.vue', () => {
|
|||
expect(getByTestId('selected-executions-info').textContent).toContain(10);
|
||||
|
||||
await userEvent.click(getByTestId('load-more-button'));
|
||||
await rerender({
|
||||
executions: executionsData[0].results.concat(executionsData[1].results),
|
||||
filteredExecutions: executionsData[0].results.concat(executionsData[1].results),
|
||||
});
|
||||
|
||||
expect(storeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(getAllByTestId('select-execution-checkbox').length).toBe(20);
|
||||
await waitFor(() => expect(getAllByTestId('select-execution-checkbox').length).toBe(20));
|
||||
expect(
|
||||
getAllByTestId('select-execution-checkbox').filter((el) =>
|
||||
el.contains(el.querySelector(':checked')),
|
||||
|
@ -198,16 +190,18 @@ describe('ExecutionsList.vue', () => {
|
|||
);
|
||||
|
||||
it('should show "retry" data when appropriate', async () => {
|
||||
vi.spyOn(workflowsStore, 'getPastExecutions').mockResolvedValue(executionsData[0]);
|
||||
const retryOf = executionsData[0].results.filter((execution) => execution.retryOf);
|
||||
const retrySuccessId = executionsData[0].results.filter(
|
||||
(execution) => !execution.retryOf && execution.retrySuccessId,
|
||||
);
|
||||
|
||||
const { queryAllByText } = renderComponent({
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
props: {
|
||||
executions: executionsData[0].results,
|
||||
total: executionsData[0].count,
|
||||
filteredExecutions: executionsData[0].results,
|
||||
},
|
||||
pinia,
|
||||
});
|
||||
await waitAllPromises();
|
||||
|
|
@ -0,0 +1,550 @@
|
|||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import { watch, computed, ref, onMounted } from 'vue';
|
||||
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
||||
import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
const props = defineProps({
|
||||
executions: {
|
||||
type: Array as PropType<ExecutionSummary[]>,
|
||||
default: () => [],
|
||||
},
|
||||
filters: {
|
||||
type: Object as PropType<ExecutionFilterType>,
|
||||
default: () => ({}),
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
estimated: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['closeModal', 'execution:stop', 'update:autoRefresh', 'update:filters']);
|
||||
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
|
||||
const isMounted = ref(false);
|
||||
const allVisibleSelected = ref(false);
|
||||
const allExistingSelected = ref(false);
|
||||
const selectedItems = ref<Record<string, boolean>>({});
|
||||
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
if (allExistingSelected.value) {
|
||||
return props.total;
|
||||
}
|
||||
|
||||
return Object.keys(selectedItems.value).length;
|
||||
});
|
||||
|
||||
const workflows = computed<IWorkflowDb[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
name: i18n.baseText('executionsList.allWorkflows'),
|
||||
} as IWorkflowDb,
|
||||
...workflowsStore.allWorkflows,
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.executions,
|
||||
() => {
|
||||
if (props.executions.length === 0) {
|
||||
handleClearSelection();
|
||||
}
|
||||
adjustSelectionAfterMoreItemsLoaded();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
});
|
||||
|
||||
function handleCheckAllExistingChange() {
|
||||
allExistingSelected.value = !allExistingSelected.value;
|
||||
allVisibleSelected.value = !allExistingSelected.value;
|
||||
handleCheckAllVisibleChange();
|
||||
}
|
||||
|
||||
function handleCheckAllVisibleChange() {
|
||||
allVisibleSelected.value = !allVisibleSelected.value;
|
||||
if (!allVisibleSelected.value) {
|
||||
allExistingSelected.value = false;
|
||||
selectedItems.value = {};
|
||||
} else {
|
||||
selectAllVisibleExecutions();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectExecution(execution: ExecutionSummary) {
|
||||
const executionId = execution.id;
|
||||
if (selectedItems.value[executionId]) {
|
||||
const { [executionId]: removedSelectedItem, ...rest } = selectedItems.value;
|
||||
selectedItems.value = rest;
|
||||
} else {
|
||||
selectedItems.value = {
|
||||
...selectedItems.value,
|
||||
[executionId]: true,
|
||||
};
|
||||
}
|
||||
allVisibleSelected.value = Object.keys(selectedItems.value).length === props.executions.length;
|
||||
allExistingSelected.value = Object.keys(selectedItems.value).length === props.total;
|
||||
}
|
||||
|
||||
async function handleDeleteSelected() {
|
||||
const deleteExecutions = await message.confirm(
|
||||
i18n.baseText('executionsList.confirmMessage.message', {
|
||||
interpolate: { count: selectedCount.value.toString() },
|
||||
}),
|
||||
i18n.baseText('executionsList.confirmMessage.headline'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: i18n.baseText('executionsList.confirmMessage.confirmButtonText'),
|
||||
cancelButtonText: i18n.baseText('executionsList.confirmMessage.cancelButtonText'),
|
||||
},
|
||||
);
|
||||
|
||||
if (deleteExecutions !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await executionsStore.deleteExecutions({
|
||||
filters: executionsStore.executionsFilters,
|
||||
...(allExistingSelected.value
|
||||
? { deleteBefore: props.executions[0].startedAt }
|
||||
: {
|
||||
ids: Object.keys(selectedItems.value),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.handleDeleteSelected.title'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.handleDeleteSelected.title'),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
handleClearSelection();
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
allVisibleSelected.value = false;
|
||||
allExistingSelected.value = false;
|
||||
selectedItems.value = {};
|
||||
}
|
||||
|
||||
async function onFilterChanged(filters: ExecutionFilterType) {
|
||||
emit('update:filters', filters);
|
||||
handleClearSelection();
|
||||
}
|
||||
|
||||
function getExecutionWorkflowName(execution: ExecutionSummary): string {
|
||||
return (
|
||||
getWorkflowName(execution.workflowId ?? '') ?? i18n.baseText('executionsList.unsavedWorkflow')
|
||||
);
|
||||
}
|
||||
|
||||
function getWorkflowName(workflowId: string): string | undefined {
|
||||
return workflows.value.find((data: IWorkflowDb) => data.id === workflowId)?.name;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (executionsStore.filters.status === 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
let lastId: string | undefined;
|
||||
if (props.executions.length !== 0) {
|
||||
const lastItem = props.executions.slice(-1)[0];
|
||||
lastId = lastItem.id;
|
||||
}
|
||||
|
||||
try {
|
||||
await executionsStore.fetchExecutions(executionsStore.executionsFilters, lastId);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.loadMore.title'));
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllVisibleExecutions() {
|
||||
props.executions.forEach((execution: ExecutionSummary) => {
|
||||
selectedItems.value[execution.id] = true;
|
||||
});
|
||||
}
|
||||
|
||||
function adjustSelectionAfterMoreItemsLoaded() {
|
||||
if (allExistingSelected.value) {
|
||||
allVisibleSelected.value = true;
|
||||
selectAllVisibleExecutions();
|
||||
}
|
||||
}
|
||||
|
||||
async function retrySavedExecution(execution: ExecutionSummary) {
|
||||
await retryExecution(execution, true);
|
||||
}
|
||||
|
||||
async function retryOriginalExecution(execution: ExecutionSummary) {
|
||||
await retryExecution(execution, false);
|
||||
}
|
||||
|
||||
async function retryExecution(execution: ExecutionSummary, loadWorkflow?: boolean) {
|
||||
try {
|
||||
const retrySuccessful = await executionsStore.retryExecution(execution.id, loadWorkflow);
|
||||
|
||||
if (retrySuccessful) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.retryExecution.title'));
|
||||
}
|
||||
|
||||
telemetry.track('User clicked retry execution button', {
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
execution_id: execution.id,
|
||||
retry_type: loadWorkflow ? 'current' : 'original',
|
||||
});
|
||||
}
|
||||
|
||||
async function stopExecution(execution: ExecutionSummary) {
|
||||
try {
|
||||
await executionsStore.stopCurrentExecution(execution.id);
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.stopExecution.title'),
|
||||
message: i18n.baseText('executionsList.showMessage.stopExecution.message', {
|
||||
interpolate: { activeExecutionId: execution.id },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
emit('execution:stop');
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.stopExecution.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteExecution(execution: ExecutionSummary) {
|
||||
try {
|
||||
await executionsStore.deleteExecutions({ ids: [execution.id] });
|
||||
|
||||
if (allVisibleSelected.value) {
|
||||
const { [execution.id]: _, ...rest } = selectedItems.value;
|
||||
selectedItems.value = rest;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.handleDeleteSelected.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onAutoRefreshToggle(value: boolean) {
|
||||
if (value) {
|
||||
await executionsStore.startAutoRefreshInterval();
|
||||
} else {
|
||||
executionsStore.stopAutoRefreshInterval();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.execListWrapper">
|
||||
<div :class="$style.execList">
|
||||
<div :class="$style.execListHeader">
|
||||
<N8nHeading tag="h1" size="2xlarge">
|
||||
{{ i18n.baseText('executionsList.workflowExecutions') }}
|
||||
</N8nHeading>
|
||||
<div :class="$style.execListHeaderControls">
|
||||
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
|
||||
<ElCheckbox
|
||||
v-else
|
||||
v-model="executionsStore.autoRefresh"
|
||||
class="mr-xl"
|
||||
data-test-id="execution-auto-refresh-checkbox"
|
||||
@update:model-value="onAutoRefreshToggle($event)"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.autoRefresh') }}
|
||||
</ElCheckbox>
|
||||
<ExecutionsFilter
|
||||
v-show="isMounted"
|
||||
:workflows="workflows"
|
||||
@filter-changed="onFilterChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElCheckbox
|
||||
v-if="allVisibleSelected && total > 0"
|
||||
:class="$style.selectAll"
|
||||
:label="
|
||||
i18n.baseText('executionsList.selectAll', {
|
||||
adjustToNumber: total,
|
||||
interpolate: { executionNum: `${total}` },
|
||||
})
|
||||
"
|
||||
:model-value="allExistingSelected"
|
||||
data-test-id="select-all-executions-checkbox"
|
||||
@update:model-value="handleCheckAllExistingChange"
|
||||
/>
|
||||
|
||||
<div v-if="!isMounted">
|
||||
<N8nLoading :class="$style.tableLoader" variant="custom" />
|
||||
<N8nLoading :class="$style.tableLoader" variant="custom" />
|
||||
<N8nLoading :class="$style.tableLoader" variant="custom" />
|
||||
</div>
|
||||
<table v-else :class="$style.execTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<el-checkbox
|
||||
:model-value="allVisibleSelected"
|
||||
:disabled="total < 1"
|
||||
label=""
|
||||
data-test-id="select-visible-executions-checkbox"
|
||||
@update:model-value="handleCheckAllVisibleChange"
|
||||
/>
|
||||
</th>
|
||||
<th>{{ i18n.baseText('executionsList.name') }}</th>
|
||||
<th>{{ i18n.baseText('executionsList.startedAt') }}</th>
|
||||
<th>{{ i18n.baseText('executionsList.status') }}</th>
|
||||
<th>{{ i18n.baseText('executionsList.id') }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<TransitionGroup tag="tbody" name="executions-list">
|
||||
<GlobalExecutionsListItem
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:execution="execution"
|
||||
:workflow-name="getExecutionWorkflowName(execution)"
|
||||
:selected="selectedItems[execution.id] || allExistingSelected"
|
||||
@stop="stopExecution"
|
||||
@delete="deleteExecution"
|
||||
@select="toggleSelectExecution"
|
||||
@retry-saved="retrySavedExecution"
|
||||
@retry-original="retryOriginalExecution"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</table>
|
||||
|
||||
<div
|
||||
v-if="!executions.length && isMounted && !executionsStore.loading"
|
||||
:class="$style.loadedAll"
|
||||
data-test-id="execution-list-empty"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.empty') }}
|
||||
</div>
|
||||
<div v-else-if="total > executions.length || estimated" :class="$style.loadMore">
|
||||
<N8nButton
|
||||
icon="sync"
|
||||
:title="i18n.baseText('executionsList.loadMore')"
|
||||
:label="i18n.baseText('executionsList.loadMore')"
|
||||
:loading="executionsStore.loading"
|
||||
data-test-id="load-more-button"
|
||||
@click="loadMore()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isMounted && !executionsStore.loading"
|
||||
:class="$style.loadedAll"
|
||||
data-test-id="execution-all-loaded"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.loadedAll') }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedCount > 0"
|
||||
:class="$style.selectionOptions"
|
||||
data-test-id="selected-executions-info"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
i18n.baseText('executionsList.selected', {
|
||||
adjustToNumber: selectedCount,
|
||||
interpolate: { count: `${selectedCount}` },
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('generic.delete')"
|
||||
type="tertiary"
|
||||
data-test-id="delete-selected-button"
|
||||
@click="handleDeleteSelected"
|
||||
/>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('executionsList.clearSelection')"
|
||||
type="tertiary"
|
||||
data-test-id="clear-selection-button"
|
||||
@click="handleClearSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.execListWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.execList {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
||||
@media (min-width: 1200px) {
|
||||
padding: var(--spacing-2xl) var(--spacing-2xl) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.execListHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.execListHeaderControls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.selectionOptions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
padding: var(--spacing-2xs);
|
||||
z-index: 2;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: var(--spacing-3xl);
|
||||
background: var(--color-background-dark);
|
||||
border-radius: var(--border-radius-base);
|
||||
color: var(--color-text-xlight);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
button {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.execTable {
|
||||
/*
|
||||
Table height needs to be set to 0 in order to use height 100% for elements in table cells
|
||||
*/
|
||||
height: 0;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
font-size: var(--font-size-s);
|
||||
|
||||
thead th {
|
||||
position: sticky;
|
||||
top: calc(var(--spacing-3xl) * -1);
|
||||
z-index: 2;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) 0;
|
||||
background: var(--color-table-header-background);
|
||||
|
||||
&:first-child {
|
||||
padding-left: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
height: 100%;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) 0;
|
||||
|
||||
&:not(:first-child, :nth-last-child(-n + 3)) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&:nth-last-child(-n + 2) {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
@media (min-width: $breakpoint-sm) {
|
||||
&:not(:nth-child(2)) {
|
||||
&,
|
||||
div,
|
||||
span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
margin: var(--spacing-m) 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loadedAll {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-s);
|
||||
color: var(--color-text-light);
|
||||
margin: var(--spacing-l) 0;
|
||||
}
|
||||
|
||||
.actions.deleteOnly {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.retryAction + .deleteAction {
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
}
|
||||
|
||||
.selectAll {
|
||||
display: inline-block;
|
||||
margin: 0 0 var(--spacing-s) var(--spacing-s);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.filterLoader {
|
||||
width: 220px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.tableLoader {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,101 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import GlobalExecutionsListItem from './GlobalExecutionsListItem.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const actual = await vi.importActual('vue-router');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useRouter: vi.fn(() => ({
|
||||
resolve: vi.fn(() => ({ href: 'mockedRoute' })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
|
||||
global: {
|
||||
stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
|
||||
},
|
||||
});
|
||||
|
||||
describe('GlobalExecutionsListItem', () => {
|
||||
it('should render the status text for an execution', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: { execution: { status: 'running', id: 123, workflowName: 'Test Workflow' } },
|
||||
});
|
||||
|
||||
expect(getByTestId('execution-status')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should emit stop event on stop button click for a running execution', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { execution: { status: 'running', id: 123, stoppedAt: undefined, waitTill: true } },
|
||||
});
|
||||
|
||||
const stopButton = getByTestId('stop-execution-button');
|
||||
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(stopButton);
|
||||
expect(emitted().stop).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit retry events on retry original and retry saved dropdown items click', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
execution: {
|
||||
status: 'error',
|
||||
id: 123,
|
||||
stoppedAt: '01-01-2024',
|
||||
finished: false,
|
||||
retryOf: undefined,
|
||||
retrySuccessfulId: undefined,
|
||||
waitTill: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('execution-retry-saved-dropdown-item'));
|
||||
expect(emitted().retrySaved).toBeTruthy();
|
||||
|
||||
await fireEvent.click(getByTestId('execution-retry-original-dropdown-item'));
|
||||
expect(emitted().retryOriginal).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should emit delete event on delete dropdown item click', async () => {
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: {
|
||||
execution: {
|
||||
status: 'error',
|
||||
id: 123,
|
||||
stoppedAt: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fireEvent.click(getByTestId('execution-delete-dropdown-item'));
|
||||
expect(emitted().delete).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should open a new window on execution click', async () => {
|
||||
global.window.open = vi.fn();
|
||||
|
||||
const { getByText } = renderComponent({
|
||||
props: { execution: { status: 'success', id: 123, workflowName: 'TestWorkflow' } },
|
||||
});
|
||||
|
||||
await fireEvent.click(getByText('TestWorkflow'));
|
||||
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
|
||||
});
|
||||
|
||||
it('should show formatted start date', () => {
|
||||
const testDate = '2022-01-01T12:00:00Z';
|
||||
const { getByText } = renderComponent({
|
||||
props: { execution: { status: 'success', id: 123, startedAt: testDate } },
|
||||
});
|
||||
|
||||
expect(getByText(`1 Jan, 2022 at ${new Date(testDate).getHours()}:00:00`)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,404 @@
|
|||
<script lang="ts" setup>
|
||||
import type { PropType } from 'vue';
|
||||
import { ref, computed, useCssModule } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { VIEWS, WAIT_TIME_UNLIMITED } from '@/constants';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
|
||||
const emit = defineEmits(['stop', 'select', 'retrySaved', 'retryOriginal', 'delete']);
|
||||
|
||||
const props = defineProps({
|
||||
execution: {
|
||||
type: Object as PropType<ExecutionSummary>,
|
||||
required: true,
|
||||
},
|
||||
selected: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflowName: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
const router = useRouter();
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
const isStopping = ref(false);
|
||||
|
||||
const isRunning = computed(() => {
|
||||
return props.execution.status === 'running';
|
||||
});
|
||||
|
||||
const isWaitTillIndefinite = computed(() => {
|
||||
if (!props.execution.waitTill) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Date(props.execution.waitTill).toISOString() === WAIT_TIME_UNLIMITED;
|
||||
});
|
||||
|
||||
const isRetriable = computed(() => executionHelpers.isExecutionRetriable(props.execution));
|
||||
|
||||
const classes = computed(() => {
|
||||
return {
|
||||
[style.executionListItem]: true,
|
||||
[style[props.execution.status ?? '']]: !!props.execution.status,
|
||||
};
|
||||
});
|
||||
|
||||
const formattedStartedAtDate = computed(() => {
|
||||
return props.execution.startedAt ? formatDate(props.execution.startedAt) : '';
|
||||
});
|
||||
|
||||
const formattedWaitTillDate = computed(() => {
|
||||
return props.execution.waitTill ? formatDate(props.execution.waitTill) : '';
|
||||
});
|
||||
|
||||
const formattedStoppedAtDate = computed(() => {
|
||||
return props.execution.stoppedAt
|
||||
? i18n.displayTimer(
|
||||
new Date(props.execution.stoppedAt).getTime() -
|
||||
new Date(props.execution.startedAt).getTime(),
|
||||
true,
|
||||
)
|
||||
: '';
|
||||
});
|
||||
|
||||
const statusTooltipText = computed(() => {
|
||||
if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) {
|
||||
return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.execution.status) {
|
||||
case 'waiting':
|
||||
return i18n.baseText('executionsList.waiting');
|
||||
case 'canceled':
|
||||
return i18n.baseText('executionsList.canceled');
|
||||
case 'crashed':
|
||||
return i18n.baseText('executionsList.error');
|
||||
case 'new':
|
||||
return i18n.baseText('executionsList.running');
|
||||
case 'running':
|
||||
return i18n.baseText('executionsList.running');
|
||||
case 'success':
|
||||
return i18n.baseText('executionsList.succeeded');
|
||||
case 'error':
|
||||
return i18n.baseText('executionsList.error');
|
||||
default:
|
||||
return i18n.baseText('executionsList.unknown');
|
||||
}
|
||||
});
|
||||
|
||||
const statusTextTranslationPath = computed(() => {
|
||||
switch (props.execution.status) {
|
||||
case 'waiting':
|
||||
return 'executionsList.statusWaiting';
|
||||
case 'canceled':
|
||||
return 'executionsList.statusCanceled';
|
||||
case 'crashed':
|
||||
case 'error':
|
||||
case 'success':
|
||||
if (!props.execution.stoppedAt) {
|
||||
return 'executionsList.statusTextWithoutTime';
|
||||
} else {
|
||||
return 'executionsList.statusText';
|
||||
}
|
||||
case 'new':
|
||||
return 'executionsList.statusRunning';
|
||||
case 'running':
|
||||
return 'executionsList.statusRunning';
|
||||
default:
|
||||
return 'executionsList.statusUnknown';
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(fullDate: Date | string | number) {
|
||||
const { date, time } = convertToDisplayDate(fullDate);
|
||||
return locale.baseText('executionsList.started', { interpolate: { time, date } });
|
||||
}
|
||||
|
||||
function displayExecution() {
|
||||
const route = router.resolve({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: props.execution.workflowId, executionId: props.execution.id },
|
||||
});
|
||||
window.open(route.href, '_blank');
|
||||
}
|
||||
|
||||
function onStopExecution() {
|
||||
isStopping.value = true;
|
||||
emit('stop', props.execution);
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
emit('select', props.execution);
|
||||
}
|
||||
|
||||
async function handleActionItemClick(commandData: 'retrySaved' | 'retryOriginal' | 'delete') {
|
||||
emit(commandData, props.execution);
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<tr :class="classes">
|
||||
<td>
|
||||
<ElCheckbox
|
||||
v-if="!!execution.stoppedAt && execution.id"
|
||||
:model-value="selected"
|
||||
label=""
|
||||
data-test-id="select-execution-checkbox"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="$style.link" @click.stop="displayExecution">
|
||||
{{ execution.workflowName || workflowName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{{ formattedStartedAtDate }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.statusColumn">
|
||||
<span v-if="isRunning" :class="$style.spinner">
|
||||
<FontAwesomeIcon icon="spinner" spin />
|
||||
</span>
|
||||
<i18n-t
|
||||
v-if="!isWaitTillIndefinite"
|
||||
data-test-id="execution-status"
|
||||
tag="span"
|
||||
:keypath="statusTextTranslationPath"
|
||||
>
|
||||
<template #status>
|
||||
<span :class="$style.status">{{ statusText }}</span>
|
||||
</template>
|
||||
<template #time>
|
||||
<span v-if="execution.waitTill">{{ formattedWaitTillDate }}</span>
|
||||
<span v-else-if="!!execution.stoppedAt">
|
||||
{{ formattedStoppedAtDate }}
|
||||
</span>
|
||||
<ExecutionsTime v-else :start-time="execution.startedAt" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
<N8nTooltip v-else placement="top">
|
||||
<template #content>
|
||||
<span>{{ statusTooltipText }}</span>
|
||||
</template>
|
||||
<span :class="$style.status">{{ statusText }}</span>
|
||||
</N8nTooltip>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="execution.id">#{{ execution.id }}</span>
|
||||
<span v-if="execution.retryOf">
|
||||
<br />
|
||||
<small> ({{ i18n.baseText('executionsList.retryOf') }} #{{ execution.retryOf }}) </small>
|
||||
</span>
|
||||
<span v-else-if="execution.retrySuccessId">
|
||||
<br />
|
||||
<small>
|
||||
({{ i18n.baseText('executionsList.successRetry') }} #{{ execution.retrySuccessId }})
|
||||
</small>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<N8nTooltip v-if="execution.mode === 'manual'" placement="top">
|
||||
<template #content>
|
||||
<span>{{ i18n.baseText('executionsList.test') }}</span>
|
||||
</template>
|
||||
<FontAwesomeIcon icon="flask" />
|
||||
</N8nTooltip>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.buttonCell">
|
||||
<N8nButton
|
||||
v-if="!!execution.stoppedAt && execution.id"
|
||||
size="small"
|
||||
outline
|
||||
:label="i18n.baseText('executionsList.view')"
|
||||
@click.stop="displayExecution"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div :class="$style.buttonCell">
|
||||
<N8nButton
|
||||
v-if="!execution.stoppedAt || execution.waitTill"
|
||||
data-test-id="stop-execution-button"
|
||||
size="small"
|
||||
outline
|
||||
:label="i18n.baseText('executionsList.stop')"
|
||||
:loading="isStopping"
|
||||
@click.stop="onStopExecution"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ElDropdown v-if="!isRunning" trigger="click" @command="handleActionItemClick">
|
||||
<N8nIconButton text type="tertiary" size="mini" icon="ellipsis-v" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu
|
||||
:class="{
|
||||
[$style.actions]: true,
|
||||
[$style.deleteOnly]: !isRetriable,
|
||||
}"
|
||||
>
|
||||
<ElDropdownItem
|
||||
v-if="isRetriable"
|
||||
data-test-id="execution-retry-saved-dropdown-item"
|
||||
:class="$style.retryAction"
|
||||
command="retrySaved"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="isRetriable"
|
||||
data-test-id="execution-retry-original-dropdown-item"
|
||||
:class="$style.retryAction"
|
||||
command="retryOriginal"
|
||||
>
|
||||
{{ i18n.baseText('executionsList.retryWithOriginalWorkflow') }}
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
data-test-id="execution-delete-dropdown-item"
|
||||
:class="$style.deleteAction"
|
||||
command="delete"
|
||||
>
|
||||
{{ i18n.baseText('generic.delete') }}
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
@import '@/styles/variables';
|
||||
|
||||
.executionListItem {
|
||||
--execution-list-item-background: var(--color-table-row-background);
|
||||
--execution-list-item-highlight-background: var(--color-table-row-highlight-background);
|
||||
color: var(--color-text-base);
|
||||
|
||||
td {
|
||||
background: var(--execution-list-item-background);
|
||||
}
|
||||
|
||||
&:nth-child(even) td {
|
||||
--execution-list-item-background: var(--color-table-row-even-background);
|
||||
}
|
||||
|
||||
&:hover td {
|
||||
background: var(--color-table-row-hover-background);
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
width: 30px;
|
||||
padding: 0 var(--spacing-s) 0 0;
|
||||
|
||||
/*
|
||||
This is needed instead of table cell border because they are overlapping the sticky header
|
||||
*/
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: var(--spacing-4xs);
|
||||
height: 100%;
|
||||
vertical-align: middle;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
&.crashed td:first-child::before,
|
||||
&.error td:first-child::before {
|
||||
background: var(--execution-card-border-error);
|
||||
}
|
||||
|
||||
&.success td:first-child::before {
|
||||
background: var(--execution-card-border-success);
|
||||
}
|
||||
|
||||
&.new td:first-child::before,
|
||||
&.running td:first-child::before {
|
||||
background: var(--execution-card-border-running);
|
||||
}
|
||||
|
||||
&.waiting td:first-child::before {
|
||||
background: var(--execution-card-border-waiting);
|
||||
}
|
||||
|
||||
&.unknown td:first-child::before {
|
||||
background: var(--execution-card-border-unknown);
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-text-base);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.statusColumn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.status {
|
||||
line-height: 22.6px;
|
||||
text-align: center;
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-bold);
|
||||
|
||||
.crashed &,
|
||||
.error & {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.waiting & {
|
||||
color: var(--color-secondary);
|
||||
}
|
||||
|
||||
.success & {
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.new &,
|
||||
.running & {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.unknown & {
|
||||
color: var(--color-background-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.buttonCell {
|
||||
overflow: hidden;
|
||||
|
||||
button {
|
||||
transform: translateX(1000%);
|
||||
transition: transform 0s;
|
||||
|
||||
&:focus-visible,
|
||||
.executionListItem:hover & {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,8 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
|
||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
const renderComponent = createComponentRenderer(ExecutionCard, {
|
||||
const renderComponent = createComponentRenderer(WorkflowExecutionsCard, {
|
||||
global: {
|
||||
stubs: {
|
||||
'router-link': {
|
||||
|
@ -17,7 +17,7 @@ const renderComponent = createComponentRenderer(ExecutionCard, {
|
|||
},
|
||||
});
|
||||
|
||||
describe('ExecutionCard', () => {
|
||||
describe('WorkflowExecutionsCard', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
|
@ -2,7 +2,7 @@
|
|||
<div
|
||||
:class="{
|
||||
['execution-card']: true,
|
||||
[$style.executionCard]: true,
|
||||
[$style.WorkflowExecutionsCard]: true,
|
||||
[$style.active]: isActive,
|
||||
[$style[executionUIDetails.name]]: true,
|
||||
[$style.highlight]: highlight,
|
||||
|
@ -37,7 +37,7 @@
|
|||
size="small"
|
||||
>
|
||||
{{ $locale.baseText('executionDetails.runningTimeRunning') }}
|
||||
<ExecutionTime :start-time="execution.startedAt" />
|
||||
<ExecutionsTime :start-time="execution.startedAt" />
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-else-if="executionUIDetails.runningTime !== ''"
|
||||
|
@ -83,18 +83,19 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { IExecutionUIData } from '@/mixins/executionsHelpers';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||
import { VIEWS } from '@/constants';
|
||||
import ExecutionTime from '@/components/ExecutionTime.vue';
|
||||
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionCard',
|
||||
name: 'WorkflowExecutionsCard',
|
||||
components: {
|
||||
ExecutionTime,
|
||||
ExecutionsTime,
|
||||
},
|
||||
mixins: [executionHelpers],
|
||||
props: {
|
||||
execution: {
|
||||
type: Object as () => ExecutionSummary,
|
||||
|
@ -109,12 +110,19 @@ export default defineComponent({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
setup() {
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
return {
|
||||
executionHelpers,
|
||||
VIEWS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useWorkflowsStore),
|
||||
currentWorkflow(): string {
|
||||
return (this.$route.params.name as string) || this.workflowsStore.workflowId;
|
||||
},
|
||||
retryExecutionActions(): object[] {
|
||||
return [
|
||||
{
|
||||
|
@ -128,13 +136,13 @@ export default defineComponent({
|
|||
];
|
||||
},
|
||||
executionUIDetails(): IExecutionUIData {
|
||||
return this.getExecutionUIDetails(this.execution);
|
||||
return this.executionHelpers.getUIDetails(this.execution);
|
||||
},
|
||||
isActive(): boolean {
|
||||
return this.execution.id === this.$route.params.executionId;
|
||||
},
|
||||
isRetriable(): boolean {
|
||||
return this.isExecutionRetriable(this.execution);
|
||||
return this.executionHelpers.isExecutionRetriable(this.execution);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -146,7 +154,12 @@ export default defineComponent({
|
|||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.executionCard {
|
||||
@import '@/styles/variables';
|
||||
|
||||
.WorkflowExecutionsCard {
|
||||
--execution-list-item-background: var(--color-foreground-xlight);
|
||||
--execution-list-item-highlight-background: var(--color-warning-tint-1);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-right: var(--spacing-m);
|
||||
|
@ -162,10 +175,11 @@ export default defineComponent({
|
|||
&:hover,
|
||||
&.active {
|
||||
.executionLink {
|
||||
background-color: var(--execution-card-background-hover);
|
||||
--execution-list-item-background: var(--color-foreground-light);
|
||||
}
|
||||
}
|
||||
|
||||
&.new,
|
||||
&.running {
|
||||
.spinner {
|
||||
position: relative;
|
||||
|
@ -217,6 +231,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.executionLink {
|
||||
background: var(--execution-list-item-background);
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
|
@ -38,6 +38,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
@ -46,7 +47,6 @@ import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '@/co
|
|||
import type { IWorkflowSettings } from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface IWorkflowSaveSettings {
|
||||
saveFailedExecutions: boolean;
|
||||
|
@ -55,7 +55,7 @@ interface IWorkflowSaveSettings {
|
|||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsInfoAccordion',
|
||||
name: 'WorkflowExecutionsInfoAccordion',
|
||||
props: {
|
||||
initiallyExpanded: {
|
||||
type: Boolean,
|
|
@ -16,7 +16,7 @@
|
|||
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
|
||||
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
|
||||
</n8n-heading>
|
||||
<ExecutionsInfoAccordion />
|
||||
<WorkflowExecutionsInfoAccordion />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -28,12 +28,12 @@ import { useUIStore } from '@/stores/ui.store';
|
|||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { mapStores } from 'pinia';
|
||||
import { defineComponent } from 'vue';
|
||||
import ExecutionsInfoAccordion from './ExecutionsInfoAccordion.vue';
|
||||
import WorkflowExecutionsInfoAccordion from './WorkflowExecutionsInfoAccordion.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsLandingPage',
|
||||
components: {
|
||||
ExecutionsInfoAccordion,
|
||||
WorkflowExecutionsInfoAccordion,
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useWorkflowsStore),
|
|
@ -0,0 +1,208 @@
|
|||
<template>
|
||||
<div :class="$style.container">
|
||||
<WorkflowExecutionsSidebar
|
||||
:executions="executions"
|
||||
:loading="loading && !executions.length"
|
||||
:loading-more="loadingMore"
|
||||
:temporary-execution="temporaryExecution"
|
||||
@update:auto-refresh="$emit('update:auto-refresh', $event)"
|
||||
@reload-executions="$emit('reload')"
|
||||
@filter-updated="$emit('update:filters', $event)"
|
||||
@load-more="$emit('load-more')"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<div v-if="!hidePreview" :class="$style.content">
|
||||
<router-view
|
||||
name="executionPreview"
|
||||
:execution="execution"
|
||||
@delete-current-execution="onDeleteCurrentExecution"
|
||||
@retry-execution="onRetryExecution"
|
||||
@stop-execution="onStopExecution"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from 'vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useRouter } from 'vue-router';
|
||||
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
|
||||
import {
|
||||
MAIN_HEADER_TABS,
|
||||
MODAL_CANCEL,
|
||||
MODAL_CONFIRM,
|
||||
PLACEHOLDER_EMPTY_WORKFLOW_ID,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||
import type { ExecutionSummary, IDataObject } from 'n8n-workflow';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { getNodeViewTab } from '@/utils/canvasUtils';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { executionFilterToQueryFilter } from '@/utils/executionUtils';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'WorkflowExecutionsList',
|
||||
components: {
|
||||
WorkflowExecutionsSidebar,
|
||||
},
|
||||
async beforeRouteLeave(to, _, next) {
|
||||
if (getNodeViewTab(to) === MAIN_HEADER_TABS.WORKFLOW) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
if (this.uiStore.stateIsDirty) {
|
||||
const confirmModal = await this.confirm(
|
||||
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
|
||||
{
|
||||
title: this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
|
||||
type: 'warning',
|
||||
confirmButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: this.$locale.baseText(
|
||||
'generic.unsavedWork.confirmMessage.cancelButtonText',
|
||||
),
|
||||
showClose: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmModal === MODAL_CONFIRM) {
|
||||
const saved = await this.workflowHelpers.saveCurrentWorkflow({}, false);
|
||||
if (saved) {
|
||||
await this.settingsStore.fetchPromptsData();
|
||||
}
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
} else if (confirmModal === MODAL_CANCEL) {
|
||||
this.uiStore.stateIsDirty = false;
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
},
|
||||
props: {
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
workflow: {
|
||||
type: Object as PropType<IWorkflowDb>,
|
||||
required: true,
|
||||
},
|
||||
executions: {
|
||||
type: Array as PropType<ExecutionSummary[]>,
|
||||
default: () => [],
|
||||
},
|
||||
filters: {
|
||||
type: Object as PropType<ExecutionFilterType>,
|
||||
default: () => ({}),
|
||||
},
|
||||
execution: {
|
||||
type: Object as PropType<ExecutionSummary>,
|
||||
default: null,
|
||||
},
|
||||
loadingMore: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
'execution:delete',
|
||||
'execution:stop',
|
||||
'execution:retry',
|
||||
'update:auto-refresh',
|
||||
'update:filters',
|
||||
'load-more',
|
||||
'reload',
|
||||
],
|
||||
setup() {
|
||||
const externalHooks = useExternalHooks();
|
||||
const router = useRouter();
|
||||
const workflowHelpers = useWorkflowHelpers(router);
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
return {
|
||||
externalHooks,
|
||||
workflowHelpers,
|
||||
callDebounced,
|
||||
...useToast(),
|
||||
...useMessage(),
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useTagsStore, useNodeTypesStore, useSettingsStore, useUIStore),
|
||||
temporaryExecution(): ExecutionSummary | undefined {
|
||||
const isTemporary = !this.executions.find((execution) => execution.id === this.execution?.id);
|
||||
return isTemporary ? this.execution : undefined;
|
||||
},
|
||||
hidePreview(): boolean {
|
||||
return this.loading || (!this.execution && this.executions.length);
|
||||
},
|
||||
filterApplied(): boolean {
|
||||
return this.filters.status !== 'all';
|
||||
},
|
||||
workflowDataNotLoaded(): boolean {
|
||||
return this.workflow.id === PLACEHOLDER_EMPTY_WORKFLOW_ID && this.workflow.name === '';
|
||||
},
|
||||
requestFilter(): IDataObject {
|
||||
return executionFilterToQueryFilter({
|
||||
...this.filters,
|
||||
workflowId: this.workflow.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
execution(value: ExecutionSummary) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: this.workflow.id, executionId: value.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async onDeleteCurrentExecution(): Promise<void> {
|
||||
this.$emit('execution:delete', this.execution.id);
|
||||
},
|
||||
async onStopExecution(): Promise<void> {
|
||||
this.$emit('execution:stop', this.execution.id);
|
||||
},
|
||||
async onRetryExecution(payload: { execution: ExecutionSummary; command: string }) {
|
||||
const loadWorkflow = payload.command === 'current-workflow';
|
||||
|
||||
this.$emit('execution:retry', {
|
||||
id: payload.execution.id,
|
||||
loadWorkflow,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
|
@ -6,8 +6,7 @@ import { createRouter, createWebHistory } from 'vue-router';
|
|||
import { createPinia, PiniaVuePlugin, setActivePinia } from 'pinia';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import ExecutionPreview from '@/components/ExecutionsView/ExecutionPreview.vue';
|
||||
import WorkflowExecutionsPreview from '@/components/executions/workflow/WorkflowExecutionsPreview.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { i18nInstance, I18nPlugin } from '@/plugins/i18n';
|
||||
import { FontAwesomePlugin } from '@/plugins/icons';
|
||||
|
@ -62,8 +61,7 @@ const executionDataFactory = (): ExecutionSummary => ({
|
|||
retrySuccessId: generateUndefinedNullOrString(),
|
||||
});
|
||||
|
||||
describe('ExecutionPreview.vue', () => {
|
||||
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
|
||||
describe('WorkflowExecutionsPreview.vue', () => {
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
const executionData: ExecutionSummary = executionDataFactory();
|
||||
|
||||
|
@ -71,10 +69,7 @@ describe('ExecutionPreview.vue', () => {
|
|||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
|
||||
workflowsStore = useWorkflowsStore();
|
||||
settingsStore = useSettingsStore();
|
||||
|
||||
vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue(executionData);
|
||||
});
|
||||
|
||||
test.each([
|
||||
|
@ -88,7 +83,10 @@ describe('ExecutionPreview.vue', () => {
|
|||
);
|
||||
|
||||
// Not using createComponentRenderer helper here because this component should not stub `router-link`
|
||||
const { getByTestId } = render(ExecutionPreview, {
|
||||
const { getByTestId } = render(WorkflowExecutionsPreview, {
|
||||
props: {
|
||||
execution: executionData,
|
||||
},
|
||||
global: {
|
||||
plugins: [
|
||||
I18nPlugin,
|
|
@ -12,7 +12,7 @@
|
|||
</div>
|
||||
<div v-else :class="$style.previewContainer">
|
||||
<div
|
||||
v-if="activeExecution"
|
||||
v-if="execution"
|
||||
:class="$style.executionDetails"
|
||||
:data-test-id="`execution-preview-details-${executionId}`"
|
||||
>
|
||||
|
@ -40,7 +40,7 @@
|
|||
interpolate: { time: executionUIDetails?.runningTime },
|
||||
})
|
||||
}}
|
||||
| ID#{{ activeExecution.id }}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-else-if="executionUIDetails.name !== 'waiting'"
|
||||
|
@ -53,28 +53,28 @@
|
|||
interpolate: { time: executionUIDetails?.runningTime ?? 'unknown' },
|
||||
})
|
||||
}}
|
||||
| ID#{{ activeExecution.id }}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-else-if="executionUIDetails?.name === 'waiting'"
|
||||
color="text-base"
|
||||
size="medium"
|
||||
>
|
||||
| ID#{{ activeExecution.id }}
|
||||
| ID#{{ execution.id }}
|
||||
</n8n-text>
|
||||
<br /><n8n-text v-if="activeExecution.mode === 'retry'" color="text-base" size="medium">
|
||||
<br /><n8n-text v-if="execution.mode === 'retry'" color="text-base" size="medium">
|
||||
{{ $locale.baseText('executionDetails.retry') }}
|
||||
<router-link
|
||||
:class="$style.executionLink"
|
||||
:to="{
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: {
|
||||
workflowId: activeExecution.workflowId,
|
||||
executionId: activeExecution.retryOf,
|
||||
workflowId: execution.workflowId,
|
||||
executionId: execution.retryOf,
|
||||
},
|
||||
}"
|
||||
>
|
||||
#{{ activeExecution.retryOf }}
|
||||
#{{ execution.retryOf }}
|
||||
</router-link>
|
||||
</n8n-text>
|
||||
</div>
|
||||
|
@ -84,8 +84,8 @@
|
|||
:to="{
|
||||
name: VIEWS.EXECUTION_DEBUG,
|
||||
params: {
|
||||
name: activeExecution.workflowId,
|
||||
executionId: activeExecution.id,
|
||||
name: execution.workflowId,
|
||||
executionId: execution.id,
|
||||
},
|
||||
}"
|
||||
>
|
||||
|
@ -148,39 +148,50 @@ import { ElDropdown } from 'element-plus';
|
|||
import { useExecutionDebugging } from '@/composables/useExecutionDebugging';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||
import type { IExecutionUIData } from '@/mixins/executionsHelpers';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { IExecutionUIData } from '@/composables/useExecutionHelpers';
|
||||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { mapStores } from 'pinia';
|
||||
|
||||
type RetryDropdownRef = InstanceType<typeof ElDropdown> & { hide: () => void };
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionPreview',
|
||||
name: 'WorkflowExecutionsPreview',
|
||||
components: {
|
||||
ElDropdown,
|
||||
WorkflowPreview,
|
||||
},
|
||||
mixins: [executionHelpers],
|
||||
props: {
|
||||
execution: {
|
||||
type: Object as () => ExecutionSummary | null,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const executionHelpers = useExecutionHelpers();
|
||||
|
||||
return {
|
||||
VIEWS,
|
||||
executionHelpers,
|
||||
...useMessage(),
|
||||
...useExecutionDebugging(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
VIEWS,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useWorkflowsStore),
|
||||
executionId(): string {
|
||||
return this.$route.params.executionId as string;
|
||||
},
|
||||
executionUIDetails(): IExecutionUIData | null {
|
||||
return this.activeExecution ? this.getExecutionUIDetails(this.activeExecution) : null;
|
||||
return this.execution ? this.executionHelpers.getUIDetails(this.execution) : null;
|
||||
},
|
||||
executionMode(): string {
|
||||
return this.activeExecution?.mode || '';
|
||||
return this.execution?.mode || '';
|
||||
},
|
||||
debugButtonData(): Record<string, string> {
|
||||
return this.activeExecution?.status === 'success'
|
||||
return this.execution?.status === 'success'
|
||||
? {
|
||||
text: this.$locale.baseText('executionsList.debug.button.copyToEditor'),
|
||||
type: 'secondary',
|
||||
|
@ -191,7 +202,7 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
isRetriable(): boolean {
|
||||
return !!this.activeExecution && this.isExecutionRetriable(this.activeExecution);
|
||||
return !!this.execution && this.executionHelpers.isExecutionRetriable(this.execution);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
@ -213,7 +224,7 @@ export default defineComponent({
|
|||
this.$emit('deleteCurrentExecution');
|
||||
},
|
||||
handleRetryClick(command: string): void {
|
||||
this.$emit('retryExecution', { execution: this.activeExecution, command });
|
||||
this.$emit('retryExecution', { execution: this.execution, command });
|
||||
},
|
||||
handleStopClick(): void {
|
||||
this.$emit('stopExecution');
|
|
@ -11,13 +11,13 @@
|
|||
</div>
|
||||
<div :class="$style.controls">
|
||||
<el-checkbox
|
||||
:model-value="autoRefresh"
|
||||
v-model="executionsStore.autoRefresh"
|
||||
data-test-id="auto-refresh-checkbox"
|
||||
@update:model-value="$emit('update:autoRefresh', $event)"
|
||||
>
|
||||
{{ $locale.baseText('executionsList.autoRefresh') }}
|
||||
</el-checkbox>
|
||||
<ExecutionFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
||||
<ExecutionsFilter popover-placement="left-start" @filter-changed="onFilterChanged" />
|
||||
</div>
|
||||
<div
|
||||
ref="executionList"
|
||||
|
@ -28,12 +28,16 @@
|
|||
<div v-if="loading" class="mr-l">
|
||||
<n8n-loading variant="rect" />
|
||||
</div>
|
||||
<div v-if="!loading && executions.length === 0" :class="$style.noResultsContainer">
|
||||
<div
|
||||
v-if="!loading && executions.length === 0"
|
||||
:class="$style.noResultsContainer"
|
||||
data-test-id="execution-list-empty"
|
||||
>
|
||||
<n8n-text color="text-base" size="medium" align="center">
|
||||
{{ $locale.baseText('executionsLandingPage.noResults') }}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<ExecutionCard
|
||||
<WorkflowExecutionsCard
|
||||
v-else-if="temporaryExecution"
|
||||
:ref="`execution-${temporaryExecution.id}`"
|
||||
:execution="temporaryExecution"
|
||||
|
@ -41,52 +45,50 @@
|
|||
:show-gap="true"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<ExecutionCard
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:ref="`execution-${execution.id}`"
|
||||
:execution="execution"
|
||||
:data-test-id="`execution-details-${execution.id}`"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
<TransitionGroup name="executions-list">
|
||||
<WorkflowExecutionsCard
|
||||
v-for="execution in executions"
|
||||
:key="execution.id"
|
||||
:ref="`execution-${execution.id}`"
|
||||
:execution="execution"
|
||||
:data-test-id="`execution-details-${execution.id}`"
|
||||
@retry-execution="onRetryExecution"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-if="loadingMore" class="mr-m">
|
||||
<n8n-loading variant="p" :rows="1" />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="$style.infoAccordion">
|
||||
<ExecutionsInfoAccordion :initially-expanded="false" />
|
||||
<WorkflowExecutionsInfoAccordion :initially-expanded="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
|
||||
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
|
||||
import ExecutionFilter from '@/components/ExecutionFilter.vue';
|
||||
import WorkflowExecutionsCard from '@/components/executions/workflow/WorkflowExecutionsCard.vue';
|
||||
import WorkflowExecutionsInfoAccordion from '@/components/executions/workflow/WorkflowExecutionsInfoAccordion.vue';
|
||||
import ExecutionsFilter from '@/components/executions/ExecutionsFilter.vue';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import type { Route } from 'vue-router';
|
||||
import { defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import type { ExecutionFilterType } from '@/Interface';
|
||||
|
||||
type ExecutionCardRef = InstanceType<typeof ExecutionCard>;
|
||||
type WorkflowExecutionsCardRef = InstanceType<typeof WorkflowExecutionsCard>;
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsSidebar',
|
||||
name: 'WorkflowExecutionsSidebar',
|
||||
components: {
|
||||
ExecutionCard,
|
||||
ExecutionsInfoAccordion,
|
||||
ExecutionFilter,
|
||||
WorkflowExecutionsCard,
|
||||
WorkflowExecutionsInfoAccordion,
|
||||
ExecutionsFilter,
|
||||
},
|
||||
props: {
|
||||
autoRefresh: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
executions: {
|
||||
type: Array as PropType<ExecutionSummary[]>,
|
||||
required: true,
|
||||
|
@ -111,7 +113,7 @@ export default defineComponent({
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useWorkflowsStore),
|
||||
...mapStores(useExecutionsStore, useWorkflowsStore),
|
||||
},
|
||||
watch: {
|
||||
$route(to: Route, from: Route) {
|
||||
|
@ -125,7 +127,9 @@ export default defineComponent({
|
|||
// On larger screens, we need to load more then first page of executions
|
||||
// for the scroll bar to appear and infinite scrolling is enabled
|
||||
this.checkListSize();
|
||||
this.scrollToActiveCard();
|
||||
setTimeout(() => {
|
||||
this.scrollToActiveCard();
|
||||
}, 1000);
|
||||
},
|
||||
methods: {
|
||||
loadMore(limit = 20): void {
|
||||
|
@ -155,14 +159,14 @@ export default defineComponent({
|
|||
},
|
||||
checkListSize(): void {
|
||||
const sidebarContainerRef = this.$refs.container as HTMLElement | undefined;
|
||||
const currentExecutionCardRefs = this.$refs[
|
||||
`execution-${this.workflowsStore.activeWorkflowExecution?.id}`
|
||||
] as ExecutionCardRef[] | undefined;
|
||||
const currentWorkflowExecutionsCardRefs = this.$refs[
|
||||
`execution-${this.executionsStore.activeExecution?.id}`
|
||||
] as WorkflowExecutionsCardRef[] | undefined;
|
||||
|
||||
// Find out how many execution card can fit into list
|
||||
// and load more if needed
|
||||
if (sidebarContainerRef && currentExecutionCardRefs?.length) {
|
||||
const cardElement = currentExecutionCardRefs[0].$el as HTMLElement;
|
||||
if (sidebarContainerRef && currentWorkflowExecutionsCardRefs?.length) {
|
||||
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
|
||||
const listCapacity = Math.ceil(sidebarContainerRef.clientHeight / cardElement.clientHeight);
|
||||
|
||||
if (listCapacity > this.executions.length) {
|
||||
|
@ -172,16 +176,16 @@ export default defineComponent({
|
|||
},
|
||||
scrollToActiveCard(): void {
|
||||
const executionsListRef = this.$refs.executionList as HTMLElement | undefined;
|
||||
const currentExecutionCardRefs = this.$refs[
|
||||
`execution-${this.workflowsStore.activeWorkflowExecution?.id}`
|
||||
] as ExecutionCardRef[] | undefined;
|
||||
const currentWorkflowExecutionsCardRefs = this.$refs[
|
||||
`execution-${this.executionsStore.activeExecution?.id}`
|
||||
] as WorkflowExecutionsCardRef[] | undefined;
|
||||
|
||||
if (
|
||||
executionsListRef &&
|
||||
currentExecutionCardRefs?.length &&
|
||||
this.workflowsStore.activeWorkflowExecution
|
||||
currentWorkflowExecutionsCardRefs?.length &&
|
||||
this.executionsStore.activeExecution
|
||||
) {
|
||||
const cardElement = currentExecutionCardRefs[0].$el as HTMLElement;
|
||||
const cardElement = currentWorkflowExecutionsCardRefs[0].$el as HTMLElement;
|
||||
const cardRect = cardElement.getBoundingClientRect();
|
||||
const LIST_HEADER_OFFSET = 200;
|
||||
if (cardRect.top > executionsListRef.offsetHeight) {
|
|
@ -0,0 +1,50 @@
|
|||
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { i18n } from '@/plugins/i18n';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
|
||||
describe('useExecutionHelpers()', () => {
|
||||
describe('getUIDetails()', () => {
|
||||
it.each([
|
||||
['waiting', 'waiting', i18n.baseText('executionsList.waiting')],
|
||||
['canceled', 'unknown', i18n.baseText('executionsList.canceled')],
|
||||
['running', 'running', i18n.baseText('executionsList.running')],
|
||||
['new', 'running', i18n.baseText('executionsList.running')],
|
||||
['success', 'success', i18n.baseText('executionsList.succeeded')],
|
||||
['error', 'error', i18n.baseText('executionsList.error')],
|
||||
['crashed', 'error', i18n.baseText('executionsList.error')],
|
||||
[undefined, 'unknown', 'Status unknown'],
|
||||
])(
|
||||
'should return %s status name %s and label %s based on execution status',
|
||||
async (status, expectedName, expectedLabel) => {
|
||||
const date = new Date();
|
||||
const execution = {
|
||||
id: '1',
|
||||
startedAt: date,
|
||||
stoppedAt: date,
|
||||
status,
|
||||
};
|
||||
const { getUIDetails } = useExecutionHelpers();
|
||||
const uiDetails = getUIDetails(execution as ExecutionSummary);
|
||||
|
||||
expect(uiDetails.name).toEqual(expectedName);
|
||||
expect(uiDetails.label).toEqual(expectedLabel);
|
||||
expect(uiDetails.runningTime).toEqual('0s');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('formatDate()', () => {
|
||||
it('should return formatted date', async () => {
|
||||
const { formatDate } = useExecutionHelpers();
|
||||
const fullDate = new Date();
|
||||
const { date, time } = convertToDisplayDate(fullDate);
|
||||
|
||||
expect(formatDate(fullDate)).toEqual(
|
||||
i18n.baseText('executionsList.started', {
|
||||
interpolate: { time, date },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
70
packages/editor-ui/src/composables/useExecutionHelpers.ts
Normal file
70
packages/editor-ui/src/composables/useExecutionHelpers.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
export interface IExecutionUIData {
|
||||
name: string;
|
||||
label: string;
|
||||
startTime: string;
|
||||
runningTime: string;
|
||||
}
|
||||
|
||||
export function useExecutionHelpers() {
|
||||
const i18n = useI18n();
|
||||
|
||||
function getUIDetails(execution: ExecutionSummary): IExecutionUIData {
|
||||
const status = {
|
||||
name: 'unknown',
|
||||
startTime: formatDate(execution.startedAt),
|
||||
label: 'Status unknown',
|
||||
runningTime: '',
|
||||
};
|
||||
|
||||
if (execution.status === 'waiting') {
|
||||
status.name = 'waiting';
|
||||
status.label = i18n.baseText('executionsList.waiting');
|
||||
} else if (execution.status === 'canceled') {
|
||||
status.label = i18n.baseText('executionsList.canceled');
|
||||
} else if (execution.status === 'running' || execution.status === 'new') {
|
||||
status.name = 'running';
|
||||
status.label = i18n.baseText('executionsList.running');
|
||||
} else if (execution.status === 'success') {
|
||||
status.name = 'success';
|
||||
status.label = i18n.baseText('executionsList.succeeded');
|
||||
} else if (execution.status === 'error' || execution.status === 'crashed') {
|
||||
status.name = 'error';
|
||||
status.label = i18n.baseText('executionsList.error');
|
||||
}
|
||||
|
||||
if (!execution.status) execution.status = 'unknown';
|
||||
|
||||
if (execution.startedAt && execution.stoppedAt) {
|
||||
const stoppedAt = execution.stoppedAt ? new Date(execution.stoppedAt).getTime() : Date.now();
|
||||
status.runningTime = i18n.displayTimer(
|
||||
stoppedAt - new Date(execution.startedAt).getTime(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
function formatDate(fullDate: Date | string | number) {
|
||||
const { date, time } = convertToDisplayDate(fullDate);
|
||||
return i18n.baseText('executionsList.started', { interpolate: { time, date } });
|
||||
}
|
||||
|
||||
function isExecutionRetriable(execution: ExecutionSummary): boolean {
|
||||
return (
|
||||
['crashed', 'error'].includes(execution.status ?? '') &&
|
||||
!execution.retryOf &&
|
||||
!execution.retrySuccessId
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getUIDetails,
|
||||
formatDate,
|
||||
isExecutionRetriable,
|
||||
};
|
||||
}
|
|
@ -37,6 +37,7 @@ import type { useRouter } from 'vue-router';
|
|||
import { isEmpty } from '@/utils/typesUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { get } from 'lodash-es';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
|
||||
export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof useRouter> }) {
|
||||
const nodeHelpers = useNodeHelpers();
|
||||
|
@ -48,6 +49,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
|
||||
// Starts to execute a workflow on server
|
||||
async function runWorkflowApi(runData: IStartRunData): Promise<IExecutionPushResponse> {
|
||||
|
@ -384,10 +386,10 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
|
|||
}
|
||||
|
||||
try {
|
||||
await workflowsStore.stopCurrentExecution(executionId);
|
||||
await 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);
|
||||
const execution = await workflowsStore.getExecution(executionId);
|
||||
|
||||
if (execution === undefined) {
|
||||
// execution finished but was not saved (e.g. due to low connectivity)
|
||||
|
|
|
@ -595,6 +595,7 @@ export const enum STORES {
|
|||
USERS = 'users',
|
||||
WORKFLOWS = 'workflows',
|
||||
WORKFLOWS_EE = 'workflowsEE',
|
||||
EXECUTIONS = 'executions',
|
||||
NDV = 'ndv',
|
||||
TEMPLATES = 'templates',
|
||||
NODE_TYPES = 'nodeTypes',
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { i18n as locale } from '@/plugins/i18n';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
|
||||
|
||||
export interface IExecutionUIData {
|
||||
name: string;
|
||||
label: string;
|
||||
startTime: string;
|
||||
runningTime: string;
|
||||
}
|
||||
|
||||
export const executionHelpers = defineComponent({
|
||||
computed: {
|
||||
...mapStores(useWorkflowsStore),
|
||||
executionId(): string {
|
||||
return this.$route.params.executionId;
|
||||
},
|
||||
workflowName(): string {
|
||||
return this.workflowsStore.workflowName;
|
||||
},
|
||||
currentWorkflow(): string {
|
||||
return this.$route.params.name || this.workflowsStore.workflowId;
|
||||
},
|
||||
executions(): ExecutionSummary[] {
|
||||
return this.workflowsStore.currentWorkflowExecutions;
|
||||
},
|
||||
activeExecution(): ExecutionSummary | null {
|
||||
return this.workflowsStore.activeWorkflowExecution;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getExecutionUIDetails(execution: ExecutionSummary): IExecutionUIData {
|
||||
const status = {
|
||||
name: 'unknown',
|
||||
startTime: this.formatDate(execution.startedAt),
|
||||
label: 'Status unknown',
|
||||
runningTime: '',
|
||||
};
|
||||
|
||||
if (execution.status === 'waiting') {
|
||||
status.name = 'waiting';
|
||||
status.label = this.$locale.baseText('executionsList.waiting');
|
||||
} else if (execution.status === 'canceled') {
|
||||
status.label = this.$locale.baseText('executionsList.canceled');
|
||||
} else if (execution.status === 'running' || execution.status === 'new') {
|
||||
status.name = 'running';
|
||||
status.label = this.$locale.baseText('executionsList.running');
|
||||
} else if (execution.status === 'success') {
|
||||
status.name = 'success';
|
||||
status.label = this.$locale.baseText('executionsList.succeeded');
|
||||
} else if (execution.status === 'error' || execution.status === 'crashed') {
|
||||
status.name = 'error';
|
||||
status.label = this.$locale.baseText('executionsList.error');
|
||||
}
|
||||
|
||||
if (!execution.status) execution.status = 'unknown';
|
||||
|
||||
if (execution.startedAt && execution.stoppedAt) {
|
||||
const stoppedAt = execution.stoppedAt
|
||||
? new Date(execution.stoppedAt).getTime()
|
||||
: Date.now();
|
||||
status.runningTime = this.$locale.displayTimer(
|
||||
stoppedAt - new Date(execution.startedAt).getTime(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
return status;
|
||||
},
|
||||
formatDate(fullDate: Date | string | number) {
|
||||
const { date, time } = convertToDisplayDate(fullDate);
|
||||
return locale.baseText('executionsList.started', { interpolate: { time, date } });
|
||||
},
|
||||
isExecutionRetriable(execution: ExecutionSummary): boolean {
|
||||
return (
|
||||
['crashed', 'error'].includes(execution.status ?? '') &&
|
||||
!execution.retryOf &&
|
||||
!execution.retrySuccessId
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
|
@ -1,5 +1,5 @@
|
|||
@import '@n8n/chat/css';
|
||||
@import 'styles/plugins';
|
||||
@import 'styles';
|
||||
|
||||
:root {
|
||||
--node-type-background-l: 95%;
|
||||
|
|
|
@ -580,9 +580,9 @@
|
|||
"executionsList.confirmMessage.cancelButtonText": "",
|
||||
"executionsList.confirmMessage.confirmButtonText": "Yes, delete",
|
||||
"executionsList.confirmMessage.headline": "Delete Executions?",
|
||||
"executionsList.confirmMessage.message": "Are you sure that you want to delete the {numSelected} selected execution(s)?",
|
||||
"executionsList.confirmMessage.message": "Are you sure that you want to delete the {count} selected execution(s)?",
|
||||
"executionsList.clearSelection": "Clear selection",
|
||||
"executionsList.error": "Failed",
|
||||
"executionsList.error": "Error",
|
||||
"executionsList.filters": "Filters",
|
||||
"executionsList.loadMore": "Load more",
|
||||
"executionsList.empty": "No executions",
|
||||
|
@ -603,9 +603,8 @@
|
|||
"executionsList.succeeded": "Succeeded",
|
||||
"executionsList.selectStatus": "Select Status",
|
||||
"executionsList.selectWorkflow": "Select Workflow",
|
||||
"executionsList.selected": "{numSelected} execution selected: | {numSelected} executions selected:",
|
||||
"executionsList.selected": "{count} execution selected: | {count} executions selected:",
|
||||
"executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions",
|
||||
"executionsList.selected": "{numSelected} execution selected:",
|
||||
"executionsList.test": "Test execution",
|
||||
"executionsList.showError.handleDeleteSelected.title": "Problem deleting executions",
|
||||
"executionsList.showError.loadMore.title": "Problem loading executions",
|
||||
|
|
|
@ -24,12 +24,11 @@ const ForgotMyPasswordView = async () => await import('./views/ForgotMyPasswordV
|
|||
const MainHeader = async () => await import('@/components/MainHeader/MainHeader.vue');
|
||||
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
|
||||
const NodeView = async () => await import('@/views/NodeView.vue');
|
||||
const WorkflowExecutionsList = async () =>
|
||||
await import('@/components/ExecutionsView/ExecutionsList.vue');
|
||||
const ExecutionsLandingPage = async () =>
|
||||
await import('@/components/ExecutionsView/ExecutionsLandingPage.vue');
|
||||
const ExecutionPreview = async () =>
|
||||
await import('@/components/ExecutionsView/ExecutionPreview.vue');
|
||||
const WorkflowExecutionsView = async () => await import('@/views/WorkflowExecutionsView.vue');
|
||||
const WorkflowExecutionsLandingPage = async () =>
|
||||
await import('@/components/executions/workflow/WorkflowExecutionsLandingPage.vue');
|
||||
const WorkflowExecutionsPreview = async () =>
|
||||
await import('@/components/executions/workflow/WorkflowExecutionsPreview.vue');
|
||||
const SettingsView = async () => await import('./views/SettingsView.vue');
|
||||
const SettingsLdapView = async () => await import('./views/SettingsLdapView.vue');
|
||||
const SettingsPersonalView = async () => await import('./views/SettingsPersonalView.vue');
|
||||
|
@ -255,7 +254,7 @@ export const routes = [
|
|||
path: '/workflow/:name/executions',
|
||||
name: VIEWS.WORKFLOW_EXECUTIONS,
|
||||
components: {
|
||||
default: WorkflowExecutionsList,
|
||||
default: WorkflowExecutionsView,
|
||||
header: MainHeader,
|
||||
sidebar: MainSidebar,
|
||||
},
|
||||
|
@ -268,7 +267,7 @@ export const routes = [
|
|||
path: '',
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
components: {
|
||||
executionPreview: ExecutionsLandingPage,
|
||||
executionPreview: WorkflowExecutionsLandingPage,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
|
@ -279,7 +278,7 @@ export const routes = [
|
|||
path: ':executionId',
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
components: {
|
||||
executionPreview: ExecutionPreview,
|
||||
executionPreview: WorkflowExecutionsPreview,
|
||||
},
|
||||
meta: {
|
||||
keepWorkflowAlive: true,
|
||||
|
|
294
packages/editor-ui/src/stores/executions.store.ts
Normal file
294
packages/editor-ui/src/stores/executions.store.ts
Normal file
|
@ -0,0 +1,294 @@
|
|||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ExecutionStatus, IDataObject, ExecutionSummary } from 'n8n-workflow';
|
||||
import type {
|
||||
ExecutionFilterType,
|
||||
ExecutionsQueryFilter,
|
||||
IExecutionDeleteFilter,
|
||||
IExecutionFlattedResponse,
|
||||
IExecutionResponse,
|
||||
IExecutionsListResponse,
|
||||
IExecutionsStopData,
|
||||
} from '@/Interface';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils';
|
||||
import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils';
|
||||
|
||||
export const useExecutionsStore = defineStore('executions', () => {
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const itemsPerPage = ref(10);
|
||||
|
||||
const activeExecution = ref<ExecutionSummary | null>(null);
|
||||
|
||||
const filters = ref<ExecutionFilterType>(getDefaultExecutionFilters());
|
||||
const executionsFilters = computed<ExecutionsQueryFilter>(() =>
|
||||
executionFilterToQueryFilter(filters.value),
|
||||
);
|
||||
const currentExecutionsFilters = computed<Partial<ExecutionFilterType>>(() => ({
|
||||
...(filters.value.workflowId !== 'all' ? { workflowId: filters.value.workflowId } : {}),
|
||||
}));
|
||||
|
||||
const autoRefresh = ref(true);
|
||||
const autoRefreshTimeout = ref<NodeJS.Timeout | null>(null);
|
||||
const autoRefreshDelay = ref(4 * 1000); // Refresh data every 4 secs
|
||||
|
||||
const executionsById = ref<Record<string, ExecutionSummary>>({});
|
||||
const executionsCount = ref(0);
|
||||
const executionsCountEstimated = ref(false);
|
||||
const executions = computed(() => {
|
||||
const data = Object.values(executionsById.value);
|
||||
|
||||
data.sort((a, b) => {
|
||||
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
|
||||
});
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
const executionsByWorkflowId = computed(() =>
|
||||
executions.value.reduce<Record<string, ExecutionSummary[]>>((acc, execution) => {
|
||||
if (!acc[execution.workflowId]) {
|
||||
acc[execution.workflowId] = [];
|
||||
}
|
||||
acc[execution.workflowId].push(execution);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const currentExecutionsById = ref<Record<string, ExecutionSummary>>({});
|
||||
const currentExecutions = computed(() => {
|
||||
const data = Object.values(currentExecutionsById.value);
|
||||
|
||||
data.sort((a, b) => {
|
||||
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
|
||||
});
|
||||
|
||||
return data;
|
||||
});
|
||||
|
||||
const currentExecutionsByWorkflowId = computed(() =>
|
||||
currentExecutions.value.reduce<Record<string, ExecutionSummary[]>>((acc, execution) => {
|
||||
if (!acc[execution.workflowId]) {
|
||||
acc[execution.workflowId] = [];
|
||||
}
|
||||
acc[execution.workflowId].push(execution);
|
||||
return acc;
|
||||
}, {}),
|
||||
);
|
||||
|
||||
const allExecutions = computed(() => [...currentExecutions.value, ...executions.value]);
|
||||
|
||||
function addExecution(execution: ExecutionSummary) {
|
||||
executionsById.value[execution.id] = {
|
||||
...execution,
|
||||
status: execution.status ?? getExecutionStatus(execution),
|
||||
mode: execution.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function addCurrentExecution(execution: ExecutionSummary) {
|
||||
currentExecutionsById.value[execution.id] = {
|
||||
...execution,
|
||||
status: execution.status ?? getExecutionStatus(execution),
|
||||
mode: execution.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function removeExecution(id: string) {
|
||||
const { [id]: _, ...rest } = executionsById.value;
|
||||
executionsById.value = rest;
|
||||
}
|
||||
|
||||
function setFilters(value: ExecutionFilterType) {
|
||||
filters.value = value;
|
||||
}
|
||||
|
||||
async function initialize(workflowId?: string) {
|
||||
if (workflowId) {
|
||||
filters.value.workflowId = workflowId;
|
||||
}
|
||||
await fetchExecutions();
|
||||
await startAutoRefreshInterval(workflowId);
|
||||
}
|
||||
|
||||
function getExecutionStatus(execution: ExecutionSummary): ExecutionStatus {
|
||||
if (execution.status) {
|
||||
return execution.status;
|
||||
} else {
|
||||
if (execution.waitTill) {
|
||||
return 'waiting';
|
||||
} else if (execution.stoppedAt === undefined) {
|
||||
return 'running';
|
||||
} else if (execution.finished) {
|
||||
return 'success';
|
||||
} else if (execution.stoppedAt !== null) {
|
||||
return 'error';
|
||||
} else {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExecutions(
|
||||
filter = executionsFilters.value,
|
||||
lastId?: string,
|
||||
firstId?: string,
|
||||
) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await makeRestApiRequest<IExecutionsListResponse>(
|
||||
rootStore.getRestApiContext,
|
||||
'GET',
|
||||
'/executions',
|
||||
{
|
||||
...(filter ? { filter } : {}),
|
||||
...(firstId ? { firstId } : {}),
|
||||
...(lastId ? { lastId } : {}),
|
||||
limit: itemsPerPage.value,
|
||||
},
|
||||
);
|
||||
|
||||
currentExecutionsById.value = {};
|
||||
data.results.forEach((execution) => {
|
||||
if (['new', 'running'].includes(execution.status as string)) {
|
||||
addCurrentExecution(execution);
|
||||
} else {
|
||||
addExecution(execution);
|
||||
}
|
||||
});
|
||||
|
||||
executionsCount.value = data.count;
|
||||
executionsCountEstimated.value = data.estimated;
|
||||
} catch (e) {
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchExecution(id: string): Promise<IExecutionResponse | undefined> {
|
||||
const response = await makeRestApiRequest<IExecutionFlattedResponse>(
|
||||
rootStore.getRestApiContext,
|
||||
'GET',
|
||||
`/executions/${id}`,
|
||||
);
|
||||
|
||||
return response ? unflattenExecutionData(response) : undefined;
|
||||
}
|
||||
|
||||
async function loadAutoRefresh(workflowId?: string): Promise<void> {
|
||||
const autoRefreshExecutionFilters = {
|
||||
...executionsFilters.value,
|
||||
...(workflowId ? { workflowId } : {}),
|
||||
};
|
||||
|
||||
autoRefreshTimeout.value = setTimeout(async () => {
|
||||
if (autoRefresh.value) {
|
||||
await fetchExecutions(autoRefreshExecutionFilters);
|
||||
void startAutoRefreshInterval(workflowId);
|
||||
}
|
||||
}, autoRefreshDelay.value);
|
||||
}
|
||||
|
||||
async function startAutoRefreshInterval(workflowId?: string) {
|
||||
stopAutoRefreshInterval();
|
||||
await loadAutoRefresh(workflowId);
|
||||
}
|
||||
|
||||
function stopAutoRefreshInterval() {
|
||||
if (autoRefreshTimeout.value) {
|
||||
clearTimeout(autoRefreshTimeout.value);
|
||||
autoRefreshTimeout.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCurrentExecution(executionId: string): Promise<IExecutionsStopData> {
|
||||
return await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'POST',
|
||||
`/executions/${executionId}/stop`,
|
||||
);
|
||||
}
|
||||
|
||||
async function retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean> {
|
||||
return await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'POST',
|
||||
`/executions/${id}/retry`,
|
||||
loadWorkflow
|
||||
? {
|
||||
loadWorkflow: true,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void> {
|
||||
await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'POST',
|
||||
'/executions/delete',
|
||||
sendData as unknown as IDataObject,
|
||||
);
|
||||
|
||||
if (sendData.ids) {
|
||||
sendData.ids.forEach(removeExecution);
|
||||
}
|
||||
|
||||
if (sendData.deleteBefore) {
|
||||
const deleteBefore = new Date(sendData.deleteBefore);
|
||||
allExecutions.value.forEach((execution) => {
|
||||
if (new Date(execution.startedAt) < deleteBefore) {
|
||||
removeExecution(execution.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
executionsById.value = {};
|
||||
currentExecutionsById.value = {};
|
||||
executionsCount.value = 0;
|
||||
executionsCountEstimated.value = false;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
itemsPerPage.value = 10;
|
||||
filters.value = getDefaultExecutionFilters();
|
||||
autoRefresh.value = true;
|
||||
resetData();
|
||||
stopAutoRefreshInterval();
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
executionsById,
|
||||
executions,
|
||||
executionsCount,
|
||||
executionsCountEstimated,
|
||||
executionsByWorkflowId,
|
||||
currentExecutions,
|
||||
currentExecutionsByWorkflowId,
|
||||
activeExecution,
|
||||
fetchExecutions,
|
||||
fetchExecution,
|
||||
getExecutionStatus,
|
||||
autoRefresh,
|
||||
autoRefreshTimeout,
|
||||
startAutoRefreshInterval,
|
||||
stopAutoRefreshInterval,
|
||||
initialize,
|
||||
filters,
|
||||
setFilters,
|
||||
executionsFilters,
|
||||
currentExecutionsFilters,
|
||||
allExecutions,
|
||||
stopCurrentExecution,
|
||||
retryExecution,
|
||||
deleteExecutions,
|
||||
resetData,
|
||||
reset,
|
||||
};
|
||||
});
|
|
@ -179,7 +179,6 @@ export const useUIStore = defineStore(STORES.UI, {
|
|||
selectedNodes: [],
|
||||
nodeViewInitialized: false,
|
||||
addFirstStepOnLoad: false,
|
||||
executionSidebarAutoRefresh: true,
|
||||
bannersHeight: 0,
|
||||
bannerStack: [],
|
||||
suggestedTemplates: undefined,
|
||||
|
|
|
@ -11,12 +11,10 @@ import {
|
|||
} from '@/constants';
|
||||
import type {
|
||||
ExecutionsQueryFilter,
|
||||
IExecutionDeleteFilter,
|
||||
IExecutionPushResponse,
|
||||
IExecutionResponse,
|
||||
IExecutionsCurrentSummaryExtended,
|
||||
IExecutionsListResponse,
|
||||
IExecutionsStopData,
|
||||
INewWorkflowData,
|
||||
INodeMetadata,
|
||||
INodeUi,
|
||||
|
@ -1245,34 +1243,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
|
|||
setActiveExecutions(newActiveExecutions: IExecutionsCurrentSummaryExtended[]): void {
|
||||
this.activeExecutions = newActiveExecutions;
|
||||
},
|
||||
|
||||
async retryExecution(id: string, loadWorkflow?: boolean): Promise<boolean> {
|
||||
let sendData;
|
||||
if (loadWorkflow === true) {
|
||||
sendData = {
|
||||
loadWorkflow: true,
|
||||
};
|
||||
}
|
||||
const rootStore = useRootStore();
|
||||
return await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'POST',
|
||||
`/executions/${id}/retry`,
|
||||
sendData,
|
||||
);
|
||||
},
|
||||
|
||||
// Deletes executions
|
||||
async deleteExecutions(sendData: IExecutionDeleteFilter): Promise<void> {
|
||||
const rootStore = useRootStore();
|
||||
return await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'POST',
|
||||
'/executions/delete',
|
||||
sendData as unknown as IDataObject,
|
||||
);
|
||||
},
|
||||
|
||||
// TODO: For sure needs some kind of default filter like last day, with max 10 results, ...
|
||||
async getPastExecutions(
|
||||
filter: IDataObject,
|
||||
|
@ -1301,12 +1271,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
|
|||
};
|
||||
}
|
||||
const rootStore = useRootStore();
|
||||
return await makeRestApiRequest(
|
||||
const output = await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'GET',
|
||||
'/executions/active',
|
||||
'/executions',
|
||||
sendData,
|
||||
);
|
||||
|
||||
return output.results;
|
||||
},
|
||||
|
||||
async getExecution(id: string): Promise<IExecutionResponse | undefined> {
|
||||
|
@ -1376,16 +1348,6 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, {
|
|||
`/test-webhook/${workflowId}`,
|
||||
);
|
||||
},
|
||||
|
||||
async stopCurrentExecution(executionId: string): Promise<IExecutionsStopData> {
|
||||
const rootStore = useRootStore();
|
||||
return await makeRestApiRequest(
|
||||
rootStore.getRestApiContext,
|
||||
'POST',
|
||||
`/executions/active/${executionId}/stop`,
|
||||
);
|
||||
},
|
||||
|
||||
async loadCurrentWorkflowExecutions(
|
||||
requestFilter: ExecutionsQueryFilter,
|
||||
): Promise<ExecutionSummary[]> {
|
||||
|
|
15
packages/editor-ui/src/styles/_animations.scss
Normal file
15
packages/editor-ui/src/styles/_animations.scss
Normal file
|
@ -0,0 +1,15 @@
|
|||
.executions-list-move,
|
||||
.executions-list-enter-active,
|
||||
.executions-list-leave-active {
|
||||
transition: all 1.5s cubic-bezier(0.19, 1, 0.22, 1);
|
||||
}
|
||||
|
||||
.executions-list-enter-from,
|
||||
.executions-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-100px);
|
||||
}
|
||||
|
||||
.executions-list-leave-active {
|
||||
position: absolute;
|
||||
}
|
3
packages/editor-ui/src/styles/index.scss
Normal file
3
packages/editor-ui/src/styles/index.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
@import 'variables';
|
||||
@import 'plugins';
|
||||
@import 'animations';
|
|
@ -2,8 +2,19 @@ import type { ExecutionStatus, IDataObject } from 'n8n-workflow';
|
|||
import type { ExecutionFilterType, ExecutionsQueryFilter } from '@/Interface';
|
||||
import { isEmpty } from '@/utils/typesUtils';
|
||||
|
||||
export function getDefaultExecutionFilters(): ExecutionFilterType {
|
||||
return {
|
||||
workflowId: 'all',
|
||||
status: 'all',
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
tags: [],
|
||||
metadata: [],
|
||||
};
|
||||
}
|
||||
|
||||
export const executionFilterToQueryFilter = (
|
||||
filter: ExecutionFilterType,
|
||||
filter: Partial<ExecutionFilterType>,
|
||||
): ExecutionsQueryFilter => {
|
||||
const queryFilter: IDataObject = {};
|
||||
if (filter.workflowId !== 'all') {
|
||||
|
|
|
@ -1,15 +1,91 @@
|
|||
<template>
|
||||
<ExecutionsList />
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { onBeforeMount, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import GlobalExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
|
||||
import { setPageTitle } from '@/utils/htmlUtils';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useExternalHooks } from '@/composables/useExternalHooks';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import type { ExecutionFilterType } from '@/Interface';
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import ExecutionsList from '@/components/ExecutionsList.vue';
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const externalHooks = useExternalHooks();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const executionsStore = useExecutionsStore();
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ExecutionsView',
|
||||
components: {
|
||||
ExecutionsList,
|
||||
},
|
||||
const toast = useToast();
|
||||
|
||||
const animationsEnabled = ref(false);
|
||||
|
||||
const { executionsCount, executionsCountEstimated, filters, allExecutions } =
|
||||
storeToRefs(executionsStore);
|
||||
|
||||
onBeforeMount(async () => {
|
||||
await loadWorkflows();
|
||||
|
||||
void externalHooks.run('executionsList.openDialog');
|
||||
telemetry.track('User opened Executions log', {
|
||||
workflow_id: workflowsStore.workflowId,
|
||||
});
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
setPageTitle(`n8n - ${i18n.baseText('executionsList.workflowExecutions')}`);
|
||||
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
|
||||
await executionsStore.initialize();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
executionsStore.reset();
|
||||
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
});
|
||||
|
||||
async function loadWorkflows() {
|
||||
try {
|
||||
await workflowsStore.fetchAllWorkflows();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.loadWorkflows.title'));
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
executionsStore.stopAutoRefreshInterval();
|
||||
} else {
|
||||
void executionsStore.startAutoRefreshInterval();
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefreshData() {
|
||||
try {
|
||||
await executionsStore.fetchExecutions();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.refreshData.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpdateFilters(newFilters: ExecutionFilterType) {
|
||||
executionsStore.reset();
|
||||
executionsStore.setFilters(newFilters);
|
||||
await executionsStore.initialize();
|
||||
}
|
||||
|
||||
async function onExecutionStop() {
|
||||
await onRefreshData();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<GlobalExecutionsList
|
||||
:executions="allExecutions"
|
||||
:filters="filters"
|
||||
:total="executionsCount"
|
||||
:estimated-total="executionsCountEstimated"
|
||||
@execution:stop="onExecutionStop"
|
||||
@update:filters="onUpdateFilters"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -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<void> {
|
||||
|
@ -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({
|
|||
);
|
||||
}
|
||||
</style>
|
||||
, IRun, IPushDataExecutionFinished
|
||||
|
|
327
packages/editor-ui/src/views/WorkflowExecutionsView.vue
Normal file
327
packages/editor-ui/src/views/WorkflowExecutionsView.vue
Normal file
|
@ -0,0 +1,327 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import WorkflowExecutionsList from '@/components/executions/workflow/WorkflowExecutionsList.vue';
|
||||
import { useExecutionsStore } from '@/stores/executions.store';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { NO_NETWORK_ERROR_CODE } from '@/utils/apiUtils';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { ExecutionSummary } from 'n8n-workflow';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
|
||||
const executionsStore = useExecutionsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const i18n = useI18n();
|
||||
const telemetry = useTelemetry();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const { callDebounced } = useDebounce();
|
||||
|
||||
const { filters } = storeToRefs(executionsStore);
|
||||
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
|
||||
const workflow = ref<IWorkflowDb | undefined>();
|
||||
|
||||
const workflowId = computed(() => {
|
||||
return (route.params.name as string) || workflowsStore.workflowId;
|
||||
});
|
||||
|
||||
const executionId = computed(() => route.params.executionId as string);
|
||||
|
||||
const executions = computed(() => [
|
||||
...(executionsStore.currentExecutionsByWorkflowId[workflowId.value] ?? []),
|
||||
...(executionsStore.executionsByWorkflowId[workflowId.value] ?? []),
|
||||
]);
|
||||
|
||||
const execution = computed(() => {
|
||||
return executions.value.find((e) => e.id === executionId.value) ?? currentExecution.value;
|
||||
});
|
||||
|
||||
const currentExecution = ref<ExecutionSummary | undefined>();
|
||||
|
||||
watch(
|
||||
() => workflowId.value,
|
||||
async () => {
|
||||
await fetchWorkflow();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => executionId.value,
|
||||
async () => {
|
||||
await fetchExecution();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
await Promise.all([
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded(),
|
||||
fetchWorkflow(),
|
||||
executionsStore.initialize(workflowId.value),
|
||||
]);
|
||||
await fetchExecution();
|
||||
await initializeRoute();
|
||||
document.addEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
executionsStore.reset();
|
||||
document.removeEventListener('visibilitychange', onDocumentVisibilityChange);
|
||||
});
|
||||
|
||||
async function fetchExecution() {
|
||||
if (!executionId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
currentExecution.value = (await executionsStore.fetchExecution(
|
||||
executionId.value,
|
||||
)) as ExecutionSummary;
|
||||
executionsStore.activeExecution = currentExecution.value;
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.openExecution.title'));
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentVisibilityChange() {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
executionsStore.stopAutoRefreshInterval();
|
||||
} else {
|
||||
void executionsStore.startAutoRefreshInterval(workflowId.value);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRoute() {
|
||||
if (route.name === VIEWS.EXECUTION_HOME && executions.value.length > 0 && workflow.value) {
|
||||
await router
|
||||
.push({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflow.value.id, executionId: executions.value[0].id },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkflow() {
|
||||
let data: IWorkflowDb | undefined;
|
||||
try {
|
||||
// @TODO Retrieve from store if exists
|
||||
data = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error(
|
||||
i18n.baseText('nodeView.workflowWithIdCouldNotBeFound', {
|
||||
interpolate: { workflowId: workflowId.value },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
workflow.value = data;
|
||||
}
|
||||
|
||||
async function onAutoRefreshToggle(value: boolean) {
|
||||
if (value) {
|
||||
await executionsStore.startAutoRefreshInterval(workflowId.value);
|
||||
} else {
|
||||
executionsStore.stopAutoRefreshInterval();
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefreshData() {
|
||||
if (!workflowId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await executionsStore.fetchExecutions({
|
||||
...executionsStore.executionsFilters,
|
||||
workflowId: workflowId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.errorCode === NO_NETWORK_ERROR_CODE) {
|
||||
toast.showMessage(
|
||||
{
|
||||
title: i18n.baseText('executionsList.showError.refreshData.title'),
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
duration: 3500,
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.refreshData.title'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onUpdateFilters(newFilters: ExecutionFilterType) {
|
||||
executionsStore.reset();
|
||||
executionsStore.setFilters(newFilters);
|
||||
await executionsStore.initialize(workflowId.value);
|
||||
}
|
||||
|
||||
async function onExecutionStop(id: string) {
|
||||
try {
|
||||
await executionsStore.stopCurrentExecution(id);
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.stopExecution.title'),
|
||||
message: i18n.baseText('executionsList.showMessage.stopExecution.message', {
|
||||
interpolate: { activeExecutionId: id },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
await onRefreshData();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.stopExecution.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onExecutionDelete(id: string) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const executionIndex = executions.value.findIndex((e: ExecutionSummary) => e.id === id);
|
||||
|
||||
const nextExecution =
|
||||
executions.value[executionIndex + 1] ||
|
||||
executions.value[executionIndex - 1] ||
|
||||
executions.value[0];
|
||||
|
||||
await executionsStore.deleteExecutions({
|
||||
ids: [id],
|
||||
});
|
||||
|
||||
if (workflow.value) {
|
||||
if (executions.value.length > 0) {
|
||||
await router
|
||||
.replace({
|
||||
name: VIEWS.EXECUTION_PREVIEW,
|
||||
params: { name: workflow.value.id, executionId: nextExecution.id },
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
// If there are no executions left, show empty state
|
||||
await router.replace({
|
||||
name: VIEWS.EXECUTION_HOME,
|
||||
params: { name: workflow.value.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
loading.value = false;
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.handleDeleteSelected.title'));
|
||||
return;
|
||||
}
|
||||
loading.value = false;
|
||||
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.handleDeleteSelected.title'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
async function onExecutionRetry(payload: { id: string; loadWorkflow: boolean }) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionDetails.runningMessage'),
|
||||
type: 'info',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
await retryExecution(payload);
|
||||
await onRefreshData();
|
||||
|
||||
telemetry.track('User clicked retry execution button', {
|
||||
workflow_id: workflow.value?.id,
|
||||
execution_id: payload.id,
|
||||
retry_type: payload.loadWorkflow ? 'current' : 'original',
|
||||
});
|
||||
}
|
||||
|
||||
async function retryExecution(payload: { id: string; loadWorkflow: boolean }) {
|
||||
try {
|
||||
const retrySuccessful = await executionsStore.retryExecution(payload.id, payload.loadWorkflow);
|
||||
|
||||
if (retrySuccessful) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.retrySuccessfulTrue.title'),
|
||||
type: 'success',
|
||||
});
|
||||
} else {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('executionsList.showMessage.retrySuccessfulFalse.title'),
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.retryExecution.title'));
|
||||
}
|
||||
}
|
||||
|
||||
async function onLoadMore(): Promise<void> {
|
||||
if (!loadingMore.value) {
|
||||
await callDebounced(loadMore, { debounceTime: 1000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (
|
||||
!!executionsStore.executionsFilters.status?.includes('running') ||
|
||||
executions.value.length >= executionsStore.executionsCount
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadingMore.value = true;
|
||||
|
||||
let lastId: string | undefined;
|
||||
if (executions.value.length !== 0) {
|
||||
const lastItem = executions.value.slice(-1)[0];
|
||||
lastId = lastItem.id;
|
||||
}
|
||||
|
||||
try {
|
||||
await executionsStore.fetchExecutions(executionsStore.executionsFilters, lastId);
|
||||
} catch (error) {
|
||||
loadingMore.value = false;
|
||||
toast.showError(error, i18n.baseText('executionsList.showError.loadMore.title'));
|
||||
return;
|
||||
}
|
||||
|
||||
loadingMore.value = false;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<WorkflowExecutionsList
|
||||
v-if="workflow"
|
||||
:executions="executions"
|
||||
:execution="execution"
|
||||
:filters="filters"
|
||||
:workflow="workflow"
|
||||
:loading="loading"
|
||||
:loading-more="loadingMore"
|
||||
@execution:stop="onExecutionStop"
|
||||
@execution:delete="onExecutionDelete"
|
||||
@execution:retry="onExecutionRetry"
|
||||
@update:filters="onUpdateFilters"
|
||||
@update:auto-refresh="onAutoRefreshToggle"
|
||||
@load-more="onLoadMore"
|
||||
@reload="onRefreshData"
|
||||
/>
|
||||
</template>
|
|
@ -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];
|
||||
|
|
Loading…
Reference in a new issue