n8n/packages/editor-ui/src/components/ExecutionsView/ExecutionsView.vue
Milorad FIlipović d833345092
feat(editor): implement executions preview via the new executions tab in node view (#4311)
*  Added main header tabs with current workflow execution count
*  feat(editor): header tab navigation (no-changelog) (#4244)
*  Adding current workflow execution list to the Vuex store
*  Updating current workflow executions after running a workflow from the node view
*  Keeping the tab view content alive when switching tabs in main header
*  Updating main header controls to work with current workflow regardless of active tab
* 🐛 Fixing a bug with previous WF executions still visible after creating a new WF
*  Updating saved status when new WF is created
*  Implemented initial version of execution perview
*  Keeping the WF view alive when switching to executions tab in new navigation
*  Implemented executions landing page
*  Simplifying node view navigation
*  Updating executions view zoom and selection to work with the new layout
*  Using N8nRadioButtons component for main header tabs
* 💄 Implementing executions page states. Minor refactoring.
*  Merge conflict fixes and pieces of code that were left behind
*  Fixing layout and scrolling changes introduced after sync with master branch
*  Removing keep-alive from node view which broke template opening and some more leftover code
* ✔️ Fixing linting errors
* ✔️ One more lint error
*  Implemented executions preview using iframes
*  Fixing zoom menu positioning in iframe and adding different loading types to workflow preview
*  Fixing navigation to and from WF templates and template loading
*  Updating and fixing navigation to and from node view
* 👌 Addressing previous PR comments
* 🐛 Fixing infinite loading when saving a new workflow
* 🐛 Handling opening already opened WF when not on Node view
*  Implemented empty states for executions view
*  Adding execute button shake flag to the store so it doesn't mess up navigation by modifying route params
* 💄 Started adding new styles to execution sidebar
* 💄 Adding hover style for execution list
*  Added ExecutionsCard component and added executions helper mixin
* ✔️ Fixing leftover conflict
* ✔️ One more conflict
*  Implemented retry execution menu and manual execution icon. Other minor updates
*  Implemented executions filtering
* 💄 Updating running executions details in preview
*  Added info accordion to executions sidebar
*  Implemented auto-refresh for executions sidebar
* 💄 Adding running execution landing page, minor fixes
* 💄 General refactoring
* ✔️ Adding leftover conflict changes
* ✔️ Updating `InfoTip` component test snapshots
* ✔️ Fixing linting error
* ✔️ Fixing lint errors in vuex store module
* 👌 Started addressing review feedback
*  Updating executions preview behaviour when filters are applied
* 🐛 Fixing a bug where nodes and connections disappear if something is saved from executions view before loading WF in the main NodeView
* 🐛 Fixing pasting in executions view and wrong workflow activator state
*  Improved workflow switching and navigation, updated error message when trying to paste into execution
*  Some more navigation updates
* 💄 Fixing tab centering, execution filter button layout, added auto-refresh checkbox
* 🐛 Fixing a bug when saving workflow using save button
* 💄 Addressing design feedback, added delete execution button
*  Moving main execution logic to the root executions view
*  Implemented execution delete function
*  Updating how switching tabs for new unsaved workflows work
*  Remembering active execution when switching tabs
* 💄 Addressing design feedback regarding info accordion
* 💄 Updating execution card styling
*  Resetting executions when creating new workflow
* Fixing lint error
*  Hiding executions preview is active execution is not in the results. Updated execution list spacing
*  Fixing navigation to and from templates and executions
*  Implemented execution lazy loading and added new background to execution preview
* 💄 Disabling import when on executions tab
*  Handling opening executions from different workflow
*  Updating active execution on route change
*  Updating execution tab detection
*  Simplifying and updating navigation. Adding new route for new workflows
*  Updating workflow saving logic to work with new routes
* 🐛 Fixing a bug when returning to executions from different workflow
* 💄 Updating executions info accordion and node details view modal in execution preview
* 💄 Updating workflow activated modal to point to new executions view
*  Implemented opening new executions view from execution modal
*  Handling jsplumb init errors, updating unknown executions style
*  Updating main sidebar after syncing branch
*  Opening new trigger menu from executions view
* 💄 Updating sidebar resize behaviour
* ✔️ Fixing lint errors
*  Loading executions when mounting executions view
*  Resetting execution data when creating a new workflow
* 💄 Minor wording updates
*  Not reloading node view when new workflows are saved
* Removing leftover console log
* 🐛 Fixed a bug with save dialog not appearing when leaving executions tab
*  Updating manual execution settings detection in info accordion
* 💄 Addressing UI issues found during bug bash
* Fixing workflow saving logic
*  Preventing navigation if clicked tab is already opened
*  Updating lazy loading behaviour
*  Updating delete executions flow
*  Added retry executions button to the execution preview
*  Adding empty execution state, updating trigger detection logic, removing listeners when node view is not active
* 💄 Cosmetic code improvements
*  Trying the performance fix for nodeBase
*  Removing the `NodeBase`fix
* 🐛 Fixing a bug when saving the current workflow
* 👌 Addressing code review feedback
2022-10-26 10:02:56 +02:00

518 lines
18 KiB
Vue

<template>
<div :class="$style.container" v-if="!loading">
<executions-sidebar
v-if="showSidebar"
:executions="executions"
:loading="loading"
:loadingMore="loadingMore"
@reloadExecutions="setExecutions"
@filterUpdated="onFilterUpdated"
@loadMore="loadMore"
@retryExecution="onRetryExecution"
/>
<div :class="$style.content" v-if="!hidePreview">
<router-view name="executionPreview" @deleteCurrentExecution="onDeleteCurrentExecution" @retryExecution="onRetryExecution"/>
</div>
<div v-if="executions.length === 0 && filterApplied" :class="$style.noResultsContainer">
<n8n-text color="text-base" size="medium" align="center">
{{ $locale.baseText('executionsLandingPage.noResults') }}
</n8n-text>
</div>
</div>
</template>
<script lang="ts">
import ExecutionsSidebar from '@/components/ExecutionsView/ExecutionsSidebar.vue';
import { MODAL_CANCEL, MODAL_CLOSE, MODAL_CONFIRMED, PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS, WEBHOOK_NODE_TYPE } from '@/constants';
import { IExecutionsListResponse, IExecutionsSummary, INodeUi, ITag, IWorkflowDb } from '@/Interface';
import { IConnection, IConnections, IDataObject, INodeTypeDescription, INodeTypeNameVersion, NodeHelpers } from 'n8n-workflow';
import mixins from 'vue-typed-mixins';
import { restApi } from '../mixins/restApi';
import { showMessage } from '../mixins/showMessage';
import { v4 as uuid } from 'uuid';
import { Route } from 'vue-router';
import { executionHelpers } from '../mixins/executionsHelpers';
import { range as _range } from 'lodash';
import { debounceHelper } from '../mixins/debounce';
import { getNodeViewTab } from '../helpers';
import { workflowHelpers } from '../mixins/workflowHelpers';
export default mixins(restApi, showMessage, executionHelpers, debounceHelper, workflowHelpers).extend({
name: 'executions-page',
components: {
ExecutionsSidebar,
},
data() {
return {
loading: false,
loadingMore: false,
filter: { finished: true, status: '' },
};
},
computed: {
hidePreview(): boolean {
const nothingToShow = this.executions.length === 0 && this.filterApplied;
const activeNotPresent = this.filterApplied && (this.executions as IExecutionsSummary[]).find(ex => ex.id === this.activeExecution.id) === undefined;
return this.loading || nothingToShow || activeNotPresent;
},
showSidebar(): boolean {
if (this.executions.length === 0) {
return this.filterApplied;
}
return true;
},
filterApplied(): boolean {
return this.filter.status !== '';
},
workflowDataNotLoaded(): boolean {
return this.$store.getters.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID && this.$store.getters.workflowName === '';
},
loadedFinishedExecutionsCount(): number {
return (this.$store.getters['workflows/getAllLoadedFinishedExecutions'] as IExecutionsSummary[]).length;
},
totalFinishedExecutionsCount(): number {
return this.$store.getters['workflows/getTotalFinishedExecutionsCount'];
},
},
watch:{
$route (to: Route, from: Route) {
const workflowChanged = from.params.name !== to.params.name;
this.initView(workflowChanged);
if (to.params.executionId) {
const execution = this.$store.getters['workflows/getExecutionDataById'](to.params.executionId);
if (execution) {
this.$store.commit('workflows/setActiveWorkflowExecution', execution);
}
}
},
},
async beforeRouteLeave(to, from, next) {
const nextTab = getNodeViewTab(to);
// When leaving for a page that's not a workflow view tab, ask to save changes
if (!nextTab) {
const result = this.$store.getters.getStateIsDirty;
if (result) {
const confirmModal = await this.confirmModal(
this.$locale.baseText('generic.unsavedWork.confirmMessage.message'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.headline'),
'warning',
this.$locale.baseText('generic.unsavedWork.confirmMessage.confirmButtonText'),
this.$locale.baseText('generic.unsavedWork.confirmMessage.cancelButtonText'),
true,
);
if (confirmModal === MODAL_CONFIRMED) {
const saved = await this.saveCurrentWorkflow({}, false);
if (saved) this.$store.dispatch('settings/fetchPromptsData');
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CANCEL) {
this.$store.commit('setStateDirty', false);
next();
} else if (confirmModal === MODAL_CLOSE) {
next(false);
}
} else {
next();
}
}
next();
},
async mounted() {
this.loading = true;
const workflowUpdated = this.$route.params.name !== this.$store.getters.workflowId;
const onNewWorkflow = this.$route.params.name === 'new' && this.$store.getters.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID;
const shouldUpdate = workflowUpdated && !onNewWorkflow;
await this.initView(shouldUpdate);
if (!shouldUpdate) {
await this.setExecutions();
}
this.loading = false;
},
methods: {
async initView(loadWorkflow: boolean) : Promise<void> {
if (loadWorkflow) {
if (this.$store.getters['nodeTypes/allNodeTypes'].length === 0) {
await this.$store.dispatch('nodeTypes/getNodeTypes');
}
await this.openWorkflow(this.$route.params.name);
this.$store.commit('ui/setNodeViewInitialized', false);
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) {
this.callDebounced("loadMore", { debounceTime: 1000 });
}
},
async loadMore(): Promise<void> {
if (this.filter.status === 'running' || this.loadedFinishedExecutionsCount >= this.totalFinishedExecutionsCount) {
return;
}
this.loadingMore = true;
let lastId: string | number | undefined;
if (this.executions.length !== 0) {
const lastItem = this.executions.slice(-1)[0];
lastId = lastItem.id;
}
const requestFilter: IDataObject = { workflowId: this.currentWorkflow };
if (this.filter.status === 'waiting') {
requestFilter.waitTill = true;
} else if (this.filter.status !== '') {
requestFilter.finished = this.filter.status === 'success';
}
let data: IExecutionsListResponse;
try {
data = await this.restApi().getPastExecutions(requestFilter, 20, 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);
}
}
this.$store.commit('workflows/setCurrentWorkflowExecutions', currentExecutions);
this.loadingMore = false;
},
async onDeleteCurrentExecution(): Promise<void> {
this.loading = true;
try {
await this.restApi().deleteExecutions({ ids: [ this.$route.params.executionId ] });
await this.setExecutions();
// Select first execution in the list after deleting the current one
if (this.executions.length > 0) {
this.$store.commit('workflows/setActiveWorkflowExecution', this.executions[0]);
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
}).catch(()=>{});;
} else { // If there are no executions left, show empty state and clear active execution from the store
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.$router.push({ 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',
});
},
onFilterUpdated(newFilter: { finished: boolean, status: string }): void {
this.filter = newFilter;
this.setExecutions();
},
async setExecutions(): Promise<void> {
const workflowExecutions = await this.loadExecutions();
this.$store.commit('workflows/setCurrentWorkflowExecutions', workflowExecutions);
this.setActiveExecution();
},
async loadAutoRefresh(): Promise<void> {
// Most of the auto-refresh logic is taken from the `ExecutionsList` component
const fetchedExecutions: IExecutionsSummary[] = await this.loadExecutions();
let existingExecutions: IExecutionsSummary[] = [ ...this.executions ];
const alreadyPresentExecutionIds = existingExecutions.map(exec => parseInt(exec.id, 10));
let lastId = 0;
const gaps = [] as number[];
for(let i = fetchedExecutions.length - 1; i >= 0; i--) {
const currentItem = fetchedExecutions[i];
const currentId = parseInt(currentItem.id, 10);
if (lastId !== 0 && isNaN(currentId) === false) {
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;
}
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.$store.commit('workflows/setCurrentWorkflowExecutions', existingExecutions);
},
async loadExecutions(): Promise<IExecutionsSummary[]> {
if (!this.currentWorkflow) {
return [];
}
try {
const executions: IExecutionsSummary[] =
await this.$store.dispatch('workflows/loadCurrentWorkflowExecutions', this.filter);
return executions;
} catch (error) {
this.$showError(
error,
this.$locale.baseText('executionsList.showError.refreshData.title'),
);
return [];
}
},
setActiveExecution(): void {
const activeExecutionId = this.$route.params.executionId;
if (activeExecutionId) {
const execution = this.$store.getters['workflows/getExecutionDataById'](activeExecutionId);
if (execution) {
this.$store.commit('workflows/setActiveWorkflowExecution', execution);
}
}
// If there is no execution in the route, select the first one
if (this.$store.getters['workflows/getActiveWorkflowExecution'] === null && this.executions.length > 0) {
this.$store.commit('workflows/setActiveWorkflowExecution', this.executions[0]);
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: this.currentWorkflow, executionId: this.executions[0].id },
}).catch(()=>{});;
}
},
async openWorkflow(workflowId: string): Promise<void> {
await this.loadActiveWorkflows();
let data: IWorkflowDb | undefined;
try {
data = await this.restApi().getWorkflow(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.$store.commit('setActive', data.active || false);
this.$store.commit('setWorkflowId', workflowId);
this.$store.commit('setWorkflowName', { newName: data.name, setStateDirty: false });
this.$store.commit('setWorkflowSettings', data.settings || {});
this.$store.commit('setWorkflowPinData', data.pinData || {});
const tags = (data.tags || []) as ITag[];
this.$store.commit('tags/upsertTags', tags);
const tagIds = tags.map((tag) => tag.id);
this.$store.commit('setWorkflowTagIds', tagIds || []);
this.$externalHooks().run('workflow.open', { workflowId, workflowName: data.name });
this.$store.commit('setStateDirty', false);
},
async addNodes(nodes: INodeUi[], connections?: IConnections) {
if (!nodes || !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.$store.getters['nodeTypes/getNodeType'](node.type, node.typeVersion) as INodeTypeDescription | null;
// 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}"`); // eslint-disable-line no-console
console.error(e); // eslint-disable-line no-console
}
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.$store.commit('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.$store.commit('addConnection', { connection: connectionData, setStateDirty: false });
});
}
}
}
}
},
async loadNodesProperties(nodeInfos: INodeTypeNameVersion[]): Promise<void> {
const allNodes: INodeTypeDescription[] = this.$store.getters['nodeTypes/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.$store.dispatch('nodeTypes/getNodesInformation', nodesToBeFetched);
}
},
async loadActiveWorkflows(): Promise<void> {
const activeWorkflows = await this.restApi().getActiveWorkflows();
this.$store.commit('setActiveWorkflows', activeWorkflows);
},
async onRetryExecution(payload: { execution: IExecutionsSummary, 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);
this.loadAutoRefresh();
this.$telemetry.track('User clicked retry execution button', {
workflow_id: this.$store.getters.workflowId,
execution_id: payload.execution.id,
retry_type: loadWorkflow ? 'current' : 'original',
});
},
async retryExecution(execution: IExecutionsSummary, loadWorkflow?: boolean) {
try {
const retrySuccessful = await this.restApi().retryExecution(execution.id, loadWorkflow);
if (retrySuccessful === true) {
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%;
}
.content {
flex: 1;
}
.noResultsContainer {
width: 100%;
margin-top: var(--spacing-2xl);
text-align: center;
}
</style>