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
65 changed files with 3601 additions and 2960 deletions

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