1
1
mirror of https://github.com/n8n-io/n8n.git synced 2024-09-17 16:08:12 +03:00

feat(editor): Refactor and unify executions views (no-changelog) (#8538)

This commit is contained in:
Alex Grozav 2024-04-19 07:50:18 +02:00 committed by GitHub
parent eab01876ab
commit a3eea3ac5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 3601 additions and 2960 deletions

View File

@ -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);

View File

@ -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();

View File

@ -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']);

View File

@ -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();

View File

@ -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;
} = {};

View File

@ -171,7 +171,7 @@ export interface IExecutionsListResponse {
estimated: boolean;
}
export interface IExecutionsStopData {
export interface ExecutionStopResult {
finished?: boolean;
mode: WorkflowExecuteMode;
startedAt: Date;

View File

@ -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.

View File

@ -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;
}

View File

@ -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 } });
}
}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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';
};
};
}

View File

@ -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();
}

View File

@ -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);

View 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));
}
}
};

View File

@ -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);
});
});
});

View File

@ -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',
}),
]);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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);

View File

@ -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);

View File

@ -1282,7 +1282,6 @@ export interface UIState {
selectedNodes: INodeUi[];
nodeViewInitialized: boolean;
addFirstStepOnLoad: boolean;
executionSidebarAutoRefresh: boolean;
bannersHeight: number;
bannerStack: BannerName[];
theme: ThemeOption;

View File

@ -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(

View File

@ -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

View File

@ -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>

View File

@ -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,

View File

@ -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';

View File

@ -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,

View File

@ -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,
}),
'*',
);

View File

@ -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);

View File

@ -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();
});

View File

@ -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>

View File

@ -8,7 +8,7 @@
import { defineComponent } from 'vue';
export default defineComponent({
name: 'ExecutionTime',
name: 'ExecutionsTime',
props: ['startTime'],
data() {
return {

View File

@ -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();

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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>

View File

@ -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());
});

View File

@ -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;

View File

@ -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,

View File

@ -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),

View File

@ -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>

View File

@ -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,

View File

@ -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');

View File

@ -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) {

View File

@ -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 },
}),
);
});
});
});

View 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,
};
}

View File

@ -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)

View File

@ -595,6 +595,7 @@ export const enum STORES {
USERS = 'users',
WORKFLOWS = 'workflows',
WORKFLOWS_EE = 'workflowsEE',
EXECUTIONS = 'executions',
NDV = 'ndv',
TEMPLATES = 'templates',
NODE_TYPES = 'nodeTypes',

View File

@ -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
);
},
},
});

View File

@ -1,5 +1,5 @@
@import '@n8n/chat/css';
@import 'styles/plugins';
@import 'styles';
:root {
--node-type-background-l: 95%;

View File

@ -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",

View File

@ -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,

View 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,
};
});

View File

@ -179,7 +179,6 @@ export const useUIStore = defineStore(STORES.UI, {
selectedNodes: [],
nodeViewInitialized: false,
addFirstStepOnLoad: false,
executionSidebarAutoRefresh: true,
bannersHeight: 0,
bannerStack: [],
suggestedTemplates: undefined,

View File

@ -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[]> {

View 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;
}

View File

@ -0,0 +1,3 @@
@import 'variables';
@import 'plugins';
@import 'animations';

View File

@ -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') {

View File

@ -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>

View File

@ -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

View 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>

View File

@ -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];