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
This commit is contained in:
Milorad FIlipović
2022-10-26 10:02:56 +02:00
committed by GitHub
parent 99157cf581
commit d833345092
46 changed files with 2473 additions and 184 deletions

View File

@@ -37,7 +37,7 @@
import Vue from 'vue';
import Modal from '@/components/Modal.vue';
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG } from '../constants';
import { WORKFLOW_ACTIVE_MODAL_KEY, EXECUTIONS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY, LOCAL_STORAGE_ACTIVATION_FLAG, VIEWS } from '../constants';
import { getActivatableTriggerNodes, getTriggerNodeServiceName } from './helpers';
import { INodeTypeDescription } from 'n8n-workflow';
@@ -53,11 +53,23 @@ export default Vue.extend({
return {
WORKFLOW_ACTIVE_MODAL_KEY,
checked: false,
modalBus: new Vue(),
};
},
methods: {
async showExecutionsList () {
this.$store.dispatch('ui/openModal', EXECUTIONS_MODAL_KEY);
const activeExecution = this.$store.getters['workflows/getActiveWorkflowExecution'];
const currentWorkflow = this.$store.getters.workflowId;
if (activeExecution) {
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: activeExecution.id },
}).catch(()=>{});;
} else {
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }).catch(() => {});
}
this.$store.commit('ui/closeModal', WORKFLOW_ACTIVE_MODAL_KEY);
},
async showSettings() {
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);

View File

@@ -344,16 +344,13 @@ export default mixins(
convertToDisplayDate,
displayExecution (execution: IExecutionShortResponse, e: PointerEvent) {
if (e.metaKey || e.ctrlKey) {
const route = this.$router.resolve({name: VIEWS.EXECUTION, params: {id: execution.id}});
const route = this.$router.resolve({ name: VIEWS.EXECUTION_PREVIEW, params: { name: execution.workflowId, executionId: execution.id } });
window.open(route.href, '_blank');
return;
}
this.$router.push({
name: VIEWS.EXECUTION,
params: { id: execution.id },
});
this.$router.push({ name: VIEWS.EXECUTION_PREVIEW, params: { name: execution.workflowId, executionId: execution.id } }).catch(()=>{});;
this.modalBus.$emit('closeAll');
},
handleAutoRefreshToggle () {
@@ -409,6 +406,35 @@ export default mixins(
try {
await this.restApi().deleteExecutions(sendData);
let removedCurrentlyLoadedExecution = false;
let removedActiveExecution = false;
const currentWorkflow: string = this.$store.getters.workflowId;
const activeExecution: IExecutionsSummary = this.$store.getters['workflows/getActiveWorkflowExecution'];
// Also update current workflow executions view if needed
for (const selectedId of Object.keys(this.selectedItems)) {
const execution: IExecutionsSummary = this.$store.getters['workflows/getExecutionDataById'](selectedId);
if (execution && execution.workflowId === currentWorkflow) {
this.$store.commit('workflows/deleteExecution', execution);
removedCurrentlyLoadedExecution = true;
}
if (execution.id === activeExecution.id) {
removedActiveExecution = true;
}
}
// Also update route if needed
if (removedCurrentlyLoadedExecution) {
const currentWorkflowExecutions: IExecutionsSummary[] = this.$store.getters['workflows/currentWorkflowExecutions'];
if (currentWorkflowExecutions.length === 0) {
this.$store.commit('workflows/setActiveWorkflowExecution', null);
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } });
} else if (removedActiveExecution) {
this.$store.commit('workflows/setActiveWorkflowExecution', currentWorkflowExecutions[0]);
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: currentWorkflow, executionId: currentWorkflowExecutions[0].id },
}).catch(()=>{});;
}
}
} catch (error) {
this.isDataLoading = false;
this.$showError(

View File

@@ -0,0 +1,206 @@
<template>
<div
:class="{
['execution-card']: true,
[$style.executionCard]: true,
[$style.active]: isActive,
[$style[executionUIDetails.name]]: true,
[$style.highlight]: highlight,
}"
>
<router-link
:class="$style.executionLink"
:to="{ name: VIEWS.EXECUTION_PREVIEW, params: { workflowId: currentWorkflow, executionId: execution.id }}"
>
<div :class="$style.description">
<n8n-text color="text-dark" :bold="true" size="medium">{{ executionUIDetails.startTime }}</n8n-text>
<div :class="$style.executionStatus">
<n8n-spinner v-if="executionUIDetails.name === 'running'" size="small" :class="[$style.spinner, 'mr-4xs']"/>
<n8n-text :class="$style.statusLabel" size="small">{{ executionUIDetails.label }}</n8n-text>
<n8n-text v-if="executionUIDetails.name === 'running'" :color="isActive? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.runningTimeRunning', { interpolate: { time: executionUIDetails.runningTime } }) }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name !== 'waiting' && executionUIDetails.name !== 'unknown'" :color="isActive? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.runningTimeFinished', { interpolate: { time: executionUIDetails.runningTime } }) }}
</n8n-text>
</div>
<div v-if="execution.mode === 'retry'">
<n8n-text :color="isActive? 'text-dark' : 'text-base'" size="small">
{{ $locale.baseText('executionDetails.retry') }} #{{ execution.retryOf }}
</n8n-text>
</div>
</div>
<div :class="$style.icons">
<n8n-action-dropdown
v-if="executionUIDetails.name === 'error'"
:class="[$style.icon, $style.retry]"
:items="retryExecutionActions"
activatorIcon="redo"
@select="onRetryMenuItemSelect"
/>
<font-awesome-icon
v-if="execution.mode === 'manual'"
:class="[$style.icon, $style.manual]"
:title="$locale.baseText('executionsList.manual')"
icon="flask"
/>
</div>
</router-link>
</div>
</template>
<script lang="ts">
import { IExecutionsSummary } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { executionHelpers, IExecutionUIData } from '../mixins/executionsHelpers';
import { VIEWS } from '../../constants';
import { showMessage } from '../mixins/showMessage';
import { restApi } from '../mixins/restApi';
export default mixins(
executionHelpers,
showMessage,
restApi,
).extend({
name: 'execution-card',
data() {
return {
VIEWS,
};
},
props: {
execution: {
type: Object as () => IExecutionsSummary,
required: true,
},
highlight: {
type: Boolean,
default: false,
},
},
computed: {
retryExecutionActions(): object[] {
return [
{ id: 'current-workflow', label: this.$locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') },
{ id: 'original-workflow', label: this.$locale.baseText('executionsList.retryWithOriginalWorkflow') },
];
},
executionUIDetails(): IExecutionUIData {
return this.getExecutionUIDetails(this.execution);
},
isActive(): boolean {
return this.execution.id === this.$route.params.executionId;
},
},
methods: {
onRetryMenuItemSelect(action: string): void {
this.$emit('retryExecution', { execution: this.execution, command: action });
},
},
});
</script>
<style module lang="scss">
.executionCard {
display: flex;
padding-right: var(--spacing-2xs);
&.active {
padding: 0 var(--spacing-2xs) var(--spacing-2xs) 0;
border-left: var(--spacing-4xs) var(--border-style-base) transparent !important;
.executionStatus {
color: var(--color-text-dark) !important;
}
}
& + &.active { padding-top: var(--spacing-2xs); }
&:hover, &.active {
.executionLink {
background-color: var(--color-foreground-base);
}
}
&.running {
.spinner {
position: relative;
top: 1px;
}
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-warning-h), 94%, 80%);
}
.statusLabel, .spinner { color: var(--color-warning); }
}
&.success {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-success-h), 60%, 70%);
}
}
&.waiting {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-secondary-h), 94%, 80%);
}
.statusLabel { color: var(--color-secondary); }
}
&.error {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) hsl(var(--color-danger-h), 94%, 80%);
}
.statusLabel { color: var(--color-danger ); }
}
&.unknown {
&, & .executionLink {
border-left: var(--spacing-4xs) var(--border-style-base) var(--color-text-light);
}
}
}
.executionLink {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
color: var(--color-text-base);
font-size: var(--font-size-xs);
padding: var(--spacing-xs);
padding-right: var(--spacing-s);
border-radius: var(--border-radius-base);
position: relative;
left: calc(-1 * var(--spacing-4xs)); // Hide link border under card border so it's not visible when not hovered
&:active {
.icon, .statusLabel {
color: var(--color-text-base);;
}
}
}
.icons {
display: flex;
align-items: baseline;
}
.icon {
font-size: var(--font-size-s);
&.retry {
svg {
color: var(--color-primary);
}
}
&.manual {
position: relative;
top: 1px;
}
& + & {
margin-left: var(--spacing-2xs);
}
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div v-if="executionUIDetails && executionUIDetails.name === 'running'" :class="$style.runningInfo">
<div :class="$style.spinner">
<font-awesome-icon icon="spinner" spin />
</div>
<n8n-text :class="$style.runningMessage">
{{ $locale.baseText('executionDetails.runningMessage') }}
</n8n-text>
</div>
<div v-else :class="$style.previewContainer">
<div :class="{[$style.executionDetails]: true, [$style.sidebarCollapsed]: sidebarCollapsed }" v-if="activeExecution">
<div>
<n8n-text size="large" color="text-base" :bold="true">{{ executionUIDetails.startTime }}</n8n-text><br>
<n8n-spinner v-if="executionUIDetails.name === 'running'" size="small" :class="[$style.spinner, 'mr-4xs']"/>
<n8n-text size="medium" :class="[$style.status, $style[executionUIDetails.name]]">{{ executionUIDetails.label }}</n8n-text>
<n8n-text v-if="executionUIDetails.name === 'running'" color="text-base" size="medium">
{{ $locale.baseText('executionDetails.runningTimeRunning', { interpolate: { time: executionUIDetails.runningTime } }) }} | ID#{{ activeExecution.id }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name !== 'waiting'" color="text-base" size="medium">
{{ $locale.baseText('executionDetails.runningTimeFinished', { interpolate: { time: executionUIDetails.runningTime } }) }} | ID#{{ activeExecution.id }}
</n8n-text>
<n8n-text v-else-if="executionUIDetails.name === 'waiting'" color="text-base" size="medium">
| ID#{{ activeExecution.id }}
</n8n-text>
<br><n8n-text v-if="activeExecution.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 }}"
>
#{{ activeExecution.retryOf }}
</router-link>
</n8n-text>
</div>
<div>
<el-dropdown v-if="executionUIDetails.name === 'error'" trigger="click" class="mr-xs" @command="handleRetryClick">
<span class="retry-button">
<n8n-icon-button
size="large"
type="tertiary"
:title="$locale.baseText('executionsList.retryExecution')"
icon="redo"
/>
</span>
<el-dropdown-menu slot="dropdown">
<el-dropdown-item command="current-workflow">
{{ $locale.baseText('executionsList.retryWithCurrentlySavedWorkflow') }}
</el-dropdown-item>
<el-dropdown-item command="original-workflow">
{{ $locale.baseText('executionsList.retryWithOriginalworkflow') }}
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
<n8n-icon-button :title="$locale.baseText('executionDetails.deleteExecution')" icon="trash" size="large" type="tertiary" @click="onDeleteExecution" />
</div>
</div>
<workflow-preview mode="execution" loaderType="spinner" :executionId="executionId"/>
</div>
</template>
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { restApi } from '@/components/mixins/restApi';
import { showMessage } from '../mixins/showMessage';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { executionHelpers, IExecutionUIData } from '../mixins/executionsHelpers';
import { VIEWS } from '../../constants';
export default mixins(restApi, showMessage, executionHelpers).extend({
name: 'execution-preview',
components: {
WorkflowPreview,
},
data() {
return {
VIEWS,
};
},
computed: {
executionUIDetails(): IExecutionUIData | null {
return this.activeExecution ? this.getExecutionUIDetails(this.activeExecution) : null;
},
sidebarCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
},
methods: {
async onDeleteExecution(): Promise<void> {
const deleteConfirmed = await this.confirmMessage(
this.$locale.baseText('executionDetails.confirmMessage.message'),
this.$locale.baseText('executionDetails.confirmMessage.headline'),
'warning',
this.$locale.baseText('executionDetails.confirmMessage.confirmButtonText'),
'',
);
if (!deleteConfirmed) {
return;
}
this.$emit('deleteCurrentExecution');
},
handleRetryClick(command: string): void {
this.$emit('retryExecution', { execution: this.activeExecution, command });
},
},
});
</script>
<style module lang="scss">
.previewContainer {
height: calc(100% - $header-height);
overflow: hidden;
}
.executionDetails {
position: absolute;
padding: var(--spacing-m);
padding-right: var(--spacing-xl);
width: calc(100% - 510px);
display: flex;
justify-content: space-between;
transition: all 150ms ease-in-out;
&.sidebarCollapsed {
width: calc(100% - 375px);
}
}
.running, .spinner { color: var(--color-warning); }
.waiting { color: var(--color-secondary); }
.success { color: var(--color-success); }
.error { color: var(--color-danger); }
.runningInfo {
display: flex;
flex-direction: column;
align-items: center;
margin-top: var(--spacing-4xl);
}
.spinner {
font-size: var(--font-size-2xl);
color: var(--color-primary);
}
.runningMessage {
width: 200px;
margin-top: var(--spacing-l);
text-align: center;
}
</style>

View File

@@ -0,0 +1,174 @@
<template>
<n8n-info-accordion
:class="[$style.accordion, 'mt-2xl']"
:title="$locale.baseText('executionsLandingPage.emptyState.accordion.title')"
:items="accordionItems"
:description="accordionDescription"
:initiallyExpanded="shouldExpandAccordion"
:headerIcon="accordionIcon"
@click="onAccordionClick"
@tooltipClick="onItemTooltipClick"
></n8n-info-accordion>
</template>
<script lang="ts">
import { WORKFLOW_SETTINGS_MODAL_KEY } from '@/constants';
import { deepCopy, IWorkflowSettings } from 'n8n-workflow';
import Vue from 'vue';
interface IWorkflowSaveSettings {
saveFailedExecutions: boolean,
saveSuccessfulExecutions: boolean,
saveManualExecutions: boolean,
};
export default Vue.extend({
name: 'executions-info-accordion',
props: {
initiallyExpanded: {
type: Boolean,
default: false,
},
},
data() {
return {
defaultValues: {
saveFailedExecutions: 'all',
saveSuccessfulExecutions: 'all',
saveManualExecutions: false,
},
workflowSaveSettings: {
saveFailedExecutions: false,
saveSuccessfulExecutions: false,
saveManualExecutions: false,
} as IWorkflowSaveSettings,
};
},
mounted() {
this.defaultValues.saveFailedExecutions = this.$store.getters.saveDataErrorExecution;
this.defaultValues.saveSuccessfulExecutions = this.$store.getters.saveDataSuccessExecution;
this.defaultValues.saveManualExecutions = this.$store.getters.saveManualExecutions;
this.updateSettings(this.workflowSettings);
},
watch: {
workflowSettings(newSettings: IWorkflowSettings) {
this.updateSettings(newSettings);
},
},
computed: {
accordionItems(): Object[] {
return [
{
id: 'productionExecutions',
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutions'),
icon: this.productionExecutionsIcon.icon,
iconColor: this.productionExecutionsIcon.color,
tooltip: this.productionExecutionsStatus === 'unknown' ? this.$locale.baseText('executionsLandingPage.emptyState.accordion.productionExecutionsWarningTooltip') : null,
},
{
id: 'manualExecutions',
label: this.$locale.baseText('executionsLandingPage.emptyState.accordion.manualExecutions'),
icon: this.workflowSaveSettings.saveManualExecutions ? 'check' : 'times',
iconColor: this.workflowSaveSettings.saveManualExecutions ? 'success' : 'danger',
},
];
},
shouldExpandAccordion(): boolean {
if (this.initiallyExpanded === false) {
return false;
}
return this.workflowSaveSettings.saveFailedExecutions === false ||
this.workflowSaveSettings.saveSuccessfulExecutions === false ||
this.workflowSaveSettings.saveManualExecutions === false;
},
productionExecutionsIcon(): { icon: string, color: string } {
if (this.productionExecutionsStatus === 'saving') {
return { icon: 'check', color: 'success' };
} else if (this.productionExecutionsStatus === 'not-saving') {
return { icon: 'times', color: 'danger' };
}
return { icon: 'exclamation-triangle', color: 'warning' };
},
productionExecutionsStatus(): string {
if (this.workflowSaveSettings.saveSuccessfulExecutions === this.workflowSaveSettings.saveFailedExecutions) {
if (this.workflowSaveSettings.saveSuccessfulExecutions === true) {
return 'saving';
}
return 'not-saving';
} else {
return 'unknown';
}
},
workflowSettings(): IWorkflowSettings {
const workflowSettings = deepCopy(this.$store.getters.workflowSettings);
return workflowSettings;
},
accordionDescription(): string {
return `
<footer class="mt-2xs">
${this.$locale.baseText('executionsLandingPage.emptyState.accordion.footer')}
</footer>
`;
},
accordionIcon(): { icon: string, color: string }|null {
if (this.workflowSaveSettings.saveManualExecutions !== true || this.productionExecutionsStatus !== 'saving') {
return { icon: 'exclamation-triangle', color: 'warning' };
}
return null;
},
},
methods: {
updateSettings(settingsInStore: IWorkflowSettings): void {
this.workflowSaveSettings.saveFailedExecutions = settingsInStore.saveDataErrorExecution !== 'none';
this.workflowSaveSettings.saveSuccessfulExecutions = settingsInStore.saveDataSuccessExecution !== 'none';
this.workflowSaveSettings.saveManualExecutions = settingsInStore.saveManualExecutions === undefined ? this.defaultValues.saveManualExecutions : settingsInStore.saveManualExecutions as boolean;
},
onAccordionClick(event: MouseEvent): void {
if (event.target instanceof HTMLAnchorElement) {
event.preventDefault();
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
}
},
onItemTooltipClick(item: string, event: MouseEvent): void {
if (item === 'productionExecutions' && event.target instanceof HTMLAnchorElement) {
event.preventDefault();
this.$store.dispatch('ui/openModal', WORKFLOW_SETTINGS_MODAL_KEY);
}
},
},
});
</script>
<style module lang="scss">
.accordion {
background: none;
width: 320px;
// Accordion header
& > div:nth-child(1) {
display: flex;
flex-direction: row;
padding: var(--spacing-xs);
width: 100%;
user-select: none;
color: var(--color-text-base) !important;
}
// Accordion description
& > div:nth-child(2) {
display: flex;
flex-direction: column;
width: 100%;
padding: 0 var(--spacing-l) var(--spacing-s) !important;
span { width: 100%; }
}
footer {
text-align: left;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div :class="['workflow-executions-container', $style.container]">
<div v-if="executionCount === 0" :class="[$style.messageContainer, $style.noExecutionsMessage]">
<div v-if="!containsTrigger">
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.heading') }}
</n8n-heading>
<n8n-text size="medium">
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
</n8n-text>
<n8n-button class="mt-l" type="tertiary" size="large" @click="onSetupFirstStep">
{{ $locale.baseText('executionsLandingPage.emptyState.noTrigger.buttonText') }}
</n8n-button>
</div>
<div v-else>
<n8n-heading tag="h2" size="xlarge" color="text-dark" class="mb-2xs">
{{ $locale.baseText('executionsLandingPage.emptyState.heading') }}
</n8n-heading>
<n8n-text size="medium">
{{ $locale.baseText('executionsLandingPage.emptyState.message') }}
</n8n-text>
<executions-info-accordion />
</div>
</div>
</div>
</template>
<script lang="ts">
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, VIEWS } from '@/constants';
import { IExecutionsSummary } from '@/Interface';
import Vue from 'vue';
import ExecutionsInfoAccordion from './ExecutionsInfoAccordion.vue';
export default Vue.extend({
name: 'executions-landing-page',
components: {
ExecutionsInfoAccordion,
},
computed: {
executionCount(): number {
return (this.$store.getters['workflows/currentWorkflowExecutions'] as IExecutionsSummary[]).length;
},
containsTrigger(): boolean {
return this.$store.getters.workflowTriggerNodes.length > 0;
},
currentWorkflowId(): string {
return this.$store.getters.workflowId;
},
},
methods: {
onSetupFirstStep(event: MouseEvent): void {
this.$store.commit('ui/setAddFirstStepOnLoad', true);
const workflowRoute = this.getWorkflowRoute();
this.$router.push(workflowRoute);
},
getWorkflowRoute(): { name: string, params: {}} {
const workflowId = this.currentWorkflowId || this.$route.params.name;
if (workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
return { name: VIEWS.NEW_WORKFLOW, params: {} };
} else {
return { name: VIEWS.WORKFLOW, params: { name: workflowId } };
}
},
},
});
</script>
<style module lang="scss">
.container {
width: 100%;
height: 100%;
flex: 1;
background-color: var(--color-background-light);
display: flex;
flex-direction: column;
align-items: center;
}
.messageContainer {
margin-top: var(--spacing-4xl);
color: var(--color-text-base);
div {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
}
.icon {
font-size: 24px;
color: var(--color-foreground-dark);
}
</style>

View File

@@ -0,0 +1,270 @@
<template>
<div :class="['executions-sidebar', $style.container]">
<div :class="$style.heading">
<n8n-heading tag="h2" size="medium" color="text-dark">
{{ $locale.baseText('generic.executions') }}
</n8n-heading>
</div>
<div :class="$style.controls">
<el-checkbox v-model="autoRefresh" @change="onAutoRefreshToggle">{{ $locale.baseText('executionsList.autoRefresh') }}</el-checkbox>
<n8n-popover trigger="click" >
<template slot="reference">
<div :class="$style.filterButton">
<n8n-button icon="filter" type="tertiary" size="medium" :active="statusFilterApplied">
<n8n-badge v-if="statusFilterApplied" theme="primary" class="mr-4xs">1</n8n-badge>
{{ $locale.baseText('executionsList.filters') }}
</n8n-button>
</div>
</template>
<div :class="$style['filters-dropdown']">
<div class="mb-s">
<n8n-input-label
:label="$locale.baseText('executions.ExecutionStatus')"
:bold="false"
size="small"
color="text-base"
class="mb-3xs"
/>
<n8n-select
v-model="filter.status"
size="small"
ref="typeInput"
:class="$style['type-input']"
:placeholder="$locale.baseText('generic.any')"
@change="onFilterChange"
>
<n8n-option
v-for="item in executionStatuses"
:key="item.id"
:label="item.name"
:value="item.id">
</n8n-option>
</n8n-select>
</div>
<div :class="[$style.filterMessage, 'mt-s']" v-if="statusFilterApplied">
<n8n-link @click="resetFilters">
{{ $locale.baseText('generic.reset') }}
</n8n-link>
</div>
</div>
</n8n-popover>
</div>
<div v-show="statusFilterApplied" class="mb-xs">
<n8n-info-tip :bold="false">
{{ $locale.baseText('generic.filtersApplied') }}
<n8n-link @click="resetFilters" size="small">
{{ $locale.baseText('generic.resetAllFilters') }}
</n8n-link>
</n8n-info-tip>
</div>
<div :class="$style.executionList" ref="executionList" @scroll="loadMore">
<div v-if="loading" class="mr-m">
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
<execution-card
v-else
v-for="execution in executions"
:key="execution.id"
:execution="execution"
@refresh="onRefresh"
@retryExecution="onRetryExecution"
/>
<div v-if="loadingMore" class="mr-m">
<n8n-loading :class="$style.loader" variant="p" :rows="1" />
</div>
</div>
<div :class="$style.infoAccordion">
<executions-info-accordion :initiallyExpanded="false" />
</div>
</div>
</template>
<script lang="ts">
import ExecutionCard from '@/components/ExecutionsView/ExecutionCard.vue';
import ExecutionsInfoAccordion from '@/components/ExecutionsView/ExecutionsInfoAccordion.vue';
import { VIEWS } from '../../constants';
import { range as _range } from 'lodash';
import { IExecutionsSummary } from "@/Interface";
import { Route } from 'vue-router';
import Vue from 'vue';
import { PropType } from 'vue';
export default Vue.extend({
name: 'executions-sidebar',
components: {
ExecutionCard,
ExecutionsInfoAccordion,
},
props: {
executions: {
type: Array as PropType<IExecutionsSummary[]>,
required: true,
},
loading: {
type: Boolean,
default: true,
},
loadingMore: {
type: Boolean,
default: false,
},
},
data() {
return {
VIEWS,
filter: {
status: '',
},
autoRefresh: false,
autoRefreshInterval: undefined as undefined | NodeJS.Timer,
};
},
computed: {
statusFilterApplied(): boolean {
return this.filter.status !== '';
},
executionStatuses(): Array<{ id: string, name: string }> {
return [
{ id: 'error', name: this.$locale.baseText('executionsList.error') },
{ id: 'running', name: this.$locale.baseText('executionsList.running') },
{ id: 'success', name: this.$locale.baseText('executionsList.success') },
{ id: 'waiting', name: this.$locale.baseText('executionsList.waiting') },
];
},
},
watch: {
$route (to: Route, from: Route) {
if (from.name === VIEWS.EXECUTION_PREVIEW && to.name === VIEWS.EXECUTION_HOME) {
// Skip parent route when navigating through executions with back button
this.$router.go(-1);
}
},
},
mounted() {
this.autoRefresh = this.$store.getters['ui/isExecutionSidebarAutoRefreshOn'];
if (this.autoRefresh) {
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4000);
}
},
beforeDestroy() {
if (this.autoRefreshInterval) {
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = undefined;
}
},
methods: {
loadMore(): void {
if (!this.loading) {
const executionsList = this.$refs.executionList as HTMLElement;
if (executionsList) {
const diff = executionsList.offsetHeight - (executionsList.scrollHeight - executionsList.scrollTop);
if (diff > -10 && diff < 10) {
this.$emit('loadMore');
}
}
}
},
onRetryExecution(payload: Object) {
this.$emit('retryExecution', payload);
},
onRefresh(): void {
this.$emit('refresh');
},
onFilterChange(): void {
this.$emit('filterUpdated', this.prepareFilter());
},
reloadExecutions(): void {
this.$emit('reloadExecutions');
},
onAutoRefreshToggle(): void {
this.$store.commit('ui/setExecutionsSidebarAutoRefresh', this.autoRefresh);
if (this.autoRefreshInterval) {
// Clear any previously existing intervals (if any - there shouldn't)
clearInterval(this.autoRefreshInterval);
this.autoRefreshInterval = undefined;
}
if (this.autoRefresh) {
this.autoRefreshInterval = setInterval(() => this.onRefresh(), 4 * 1000); // refresh data every 4 secs
}
},
async resetFilters(): Promise<void> {
this.filter.status = '';
this.$emit('filterUpdated', this.prepareFilter());
},
prepareFilter(): object {
return {
finished: this.filter.status !== 'running',
status: this.filter.status,
};
},
},
});
</script>
<style module lang="scss">
.container {
flex: 310px 0 0;
background-color: var(--color-background-xlight);
border-right: var(--border-base);
padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-l);
z-index: 1;
overflow: hidden;
}
.heading {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-right: var(--spacing-l);
}
.controls {
padding: var(--spacing-s) 0 var(--spacing-xs);
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-l);
button {
display: flex;
align-items: center;
}
}
.executionList {
height: calc(100% - 10.5em);
overflow: auto;
margin-bottom: var(--spacing-m);
background-color: var(--color-background-xlight) !important;
// Scrolling fader
&::before {
position: absolute;
display: block;
width: 270px;
height: 6px;
background: linear-gradient(to bottom, rgba(251, 251, 251, 1) 0%, rgba(251, 251, 251, 0) 100%);
z-index: 999;
}
// Lower first execution card so fader is not visible when not scrolled
& > div:first-child {
margin-top: 3px;
}
}
.infoAccordion {
position: absolute;
bottom: 0;
margin-left: calc(-1 * var(--spacing-l));
border-top: var(--border-base);
& > div {
width: 309px;
background-color: var(--color-background-light);
margin-top: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,517 @@
<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>

View File

@@ -4,6 +4,7 @@
<div v-show="!hideMenuBar" class="top-menu">
<ExecutionDetails v-if="isExecutionPage" />
<WorkflowDetails v-else />
<tab-bar v-if="onWorkflowPage && !isExecutionPage" :items="tabBarItems" :activeTab="activeHeaderTab" @select="onTabSelected"/>
</div>
</div>
</div>
@@ -12,27 +13,42 @@
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { mapGetters } from 'vuex';
import { pushConnection } from '@/components/mixins/pushConnection';
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import ExecutionDetails from '@/components/MainHeader/ExecutionDetails/ExecutionDetails.vue';
import { STICKY_NODE_TYPE, VIEWS } from '@/constants';
import { INodeUi } from '@/Interface';
import TabBar from '@/components/MainHeader/TabBar.vue';
import { MAIN_HEADER_TABS, PLACEHOLDER_EMPTY_WORKFLOW_ID, STICKY_NODE_TYPE, VIEWS } from '@/constants';
import { IExecutionsSummary, INodeUi, ITabBarItem } from '@/Interface';
import { workflowHelpers } from '../mixins/workflowHelpers';
import { Route } from 'vue-router';
export default mixins(
pushConnection,
)
.extend({
workflowHelpers,
).extend({
name: 'MainHeader',
components: {
WorkflowDetails,
ExecutionDetails,
TabBar,
},
data() {
return {
activeHeaderTab: MAIN_HEADER_TABS.WORKFLOW,
workflowToReturnTo: '',
dirtyState: this.$store.getters.getStateIsDirty,
};
},
computed: {
...mapGetters('ui', [
'sidebarMenuCollapsed',
]),
tabBarItems(): ITabBarItem[] {
return [
{ value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.workflow') },
{ value: MAIN_HEADER_TABS.EXECUTIONS, label: this.$locale.baseText('generic.executions') },
];
},
isExecutionPage (): boolean {
return this.$route.name === VIEWS.EXECUTION;
},
@@ -42,14 +58,82 @@ export default mixins(
hideMenuBar(): boolean {
return Boolean(this.activeNode && this.activeNode.type !== STICKY_NODE_TYPE);
},
workflowName (): string {
return this.$store.getters.workflowName;
},
currentWorkflow (): string {
return this.$route.params.name || this.$store.getters.workflowId;
},
onWorkflowPage(): boolean {
return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true);
},
activeExecution(): IExecutionsSummary {
return this.$store.getters['workflows/getActiveWorkflowExecution'];
},
},
async mounted() {
mounted() {
this.syncTabsWithRoute(this.$route);
// Initialize the push connection
this.pushConnect();
},
beforeDestroy() {
this.pushDisconnect();
},
watch: {
$route (to, from){
this.syncTabsWithRoute(to);
},
},
methods: {
syncTabsWithRoute(route: Route): void {
if (route.name === VIEWS.EXECUTION_HOME || route.name === VIEWS.EXECUTIONS || route.name === VIEWS.EXECUTION_PREVIEW) {
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
} else if (route.name === VIEWS.WORKFLOW || route.name === VIEWS.NEW_WORKFLOW) {
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
}
const workflowName = route.params.name;
if (workflowName !== 'new') {
this.workflowToReturnTo = workflowName;
}
},
onTabSelected(tab: string, event: MouseEvent) {
switch (tab) {
case MAIN_HEADER_TABS.WORKFLOW:
if (!['', 'new', PLACEHOLDER_EMPTY_WORKFLOW_ID].includes(this.workflowToReturnTo)) {
if (this.$route.name !== VIEWS.WORKFLOW) {
this.$router.push({
name: VIEWS.WORKFLOW,
params: { name: this.workflowToReturnTo },
});
}
} else {
if (this.$route.name !== VIEWS.NEW_WORKFLOW) {
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
this.$store.commit('setStateDirty', this.dirtyState);
}
}
this.activeHeaderTab = MAIN_HEADER_TABS.WORKFLOW;
break;
case MAIN_HEADER_TABS.EXECUTIONS:
this.dirtyState = this.$store.getters.getStateIsDirty;
this.workflowToReturnTo = this.currentWorkflow;
const routeWorkflowId = this.currentWorkflow === PLACEHOLDER_EMPTY_WORKFLOW_ID ? 'new' : this.currentWorkflow;
if (this.activeExecution) {
this.$router.push({
name: VIEWS.EXECUTION_PREVIEW,
params: { name: routeWorkflowId, executionId: this.activeExecution.id },
}).catch(()=>{});;
} else {
this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: routeWorkflowId } });
}
// this.modalBus.$emit('closeAll');
this.activeHeaderTab = MAIN_HEADER_TABS.EXECUTIONS;
break;
default:
break;
}
},
},
});
</script>
@@ -68,6 +152,6 @@ export default mixins(
font-size: 0.9em;
height: $header-height;
font-weight: 400;
padding: 0 20px;
padding: 0 var(--spacing-m) 0 var(--spacing-xs);
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<div v-if="items" :class="{[$style.container]: true, ['tab-bar-container']: true, [$style.menuCollapsed]: mainSidebarCollapsed}">
<n8n-radio-buttons
:value="activeTab"
:options="items"
@input="onSelect"
/>
</div>
</template>
<script lang="ts">
import Vue, { PropType } from 'vue';
import { ITabBarItem } from '@/Interface';
import { MAIN_HEADER_TABS } from '@/constants';
export default Vue.extend({
name: 'tab-bar',
data() {
return {
MAIN_HEADER_TABS,
};
},
props: {
items: {
type: Array as PropType<ITabBarItem[]>,
required: true,
},
activeTab: {
type: String,
default: MAIN_HEADER_TABS.WORKFLOW,
},
},
computed: {
mainSidebarCollapsed(): boolean {
return this.$store.getters['ui/sidebarMenuCollapsed'];
},
},
methods: {
onSelect(tab: string, event: MouseEvent): void {
this.$emit('select', tab, event);
},
},
});
</script>
<style module lang="scss">
.container {
position: absolute;
top: 47px;
left: calc(50% + 100px);
transform: translateX(-50%);
min-height: 30px;
display: flex;
padding: var(--spacing-5xs);
background-color: var(--color-foreground-base);
border-radius: var(--border-radius-base);
transition: all 150ms ease-in-out;
&.menuCollapsed {
left: 52%;
}
}
@media screen and (max-width: 430px) {
.container {
flex-direction: column;
}
}
</style>

View File

@@ -86,6 +86,7 @@ import { mapGetters } from "vuex";
import {
DUPLICATE_MODAL_KEY,
MAX_WORKFLOW_NAME_LENGTH,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
VIEWS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY,
} from "@/constants";
@@ -145,29 +146,29 @@ export default mixins(workflowHelpers, titleChange).extend({
}),
...mapGetters('settings', ['areTagsEnabled']),
isNewWorkflow(): boolean {
return !this.$route.params.name;
return !this.currentWorkflowId || (this.currentWorkflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID || this.currentWorkflowId === 'new');
},
isWorkflowSaving(): boolean {
return this.$store.getters.isActionActive("workflowSaving");
},
currentWorkflowId(): string {
return this.$route.params.name;
},
currentWorkflow (): string {
return this.$route.params.name;
return this.$store.getters.workflowId;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
onWorkflowPage(): boolean {
return this.$route.meta && this.$route.meta.nodeView;
return this.$route.meta && (this.$route.meta.nodeView || this.$route.meta.keepWorkflowAlive === true);
},
onExecutionsTab(): boolean {
return [ VIEWS.EXECUTION_HOME.toString(), VIEWS.EXECUTIONS.toString(), VIEWS.EXECUTION_PREVIEW ].includes(this.$route.name || '');
},
workflowMenuItems(): Array<{}> {
return [
{
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: this.$locale.baseText('menuActions.duplicate'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
},
{
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
@@ -177,22 +178,22 @@ export default mixins(workflowHelpers, titleChange).extend({
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
label: this.$locale.baseText('menuActions.importFromUrl'),
disabled: !this.onWorkflowPage,
disabled: !this.onWorkflowPage || this.onExecutionsTab,
},
{
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
label: this.$locale.baseText('menuActions.importFromFile'),
disabled: !this.onWorkflowPage,
disabled: !this.onWorkflowPage || this.onExecutionsTab,
},
{
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
label: this.$locale.baseText('generic.settings'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
disabled: !this.onWorkflowPage || this.isNewWorkflow,
},
{
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: this.$locale.baseText('menuActions.delete'),
disabled: !this.onWorkflowPage || !this.currentWorkflow,
disabled: !this.onWorkflowPage || this.isNewWorkflow,
customClass: this.$style.deleteItem,
divided: true,
},
@@ -201,7 +202,13 @@ export default mixins(workflowHelpers, titleChange).extend({
},
methods: {
async onSaveButtonClick () {
const saved = await this.saveCurrentWorkflow();
let currentId = undefined;
if (this.currentWorkflowId !== PLACEHOLDER_EMPTY_WORKFLOW_ID) {
currentId = this.currentWorkflowId;
} else if (this.$route.params.name && this.$route.params.name !== 'new') {
currentId = this.$route.params.name;
}
const saved = await this.saveCurrentWorkflow({ id: currentId, name: this.workflowName, tags: this.currentWorkflowTagIds });
if (saved) this.$store.dispatch('settings/fetchPromptsData');
},
onTagsEditEnable() {
@@ -389,7 +396,7 @@ export default mixins(workflowHelpers, titleChange).extend({
}
try {
await this.restApi().deleteWorkflow(this.currentWorkflow);
await this.restApi().deleteWorkflow(this.currentWorkflowId);
} catch (error) {
this.$showError(
error,

View File

@@ -78,6 +78,8 @@ import {
VERSIONS_MODAL_KEY,
EXECUTIONS_MODAL_KEY,
VIEWS,
WORKFLOW_OPEN_MODAL_KEY,
PLACEHOLDER_EMPTY_WORKFLOW_ID,
} from '@/constants';
import { userHelpers } from './mixins/userHelpers';
import { debounceHelper } from './mixins/debounce';
@@ -194,7 +196,7 @@ export default mixins(
{
id: 'executions',
icon: 'tasks',
label: this.$locale.baseText('mainSidebar.executions'),
label: this.$locale.baseText('generic.executions'),
position: 'top',
},
{
@@ -267,7 +269,11 @@ export default mixins(
if (this.$refs.user) {
this.$externalHooks().run('mainSidebar.mounted', { userRef: this.$refs.user });
}
this.checkWidthAndAdjustSidebar(window.innerWidth);
if (window.innerWidth < 900 || this.isNodeView) {
this.$store.commit('ui/collapseSidebarMenu');
} else {
this.$store.commit('ui/expandSidebarMenu');
}
await Vue.nextTick();
this.fullyExpanded = !this.isCollapsed;
},
@@ -394,6 +400,7 @@ export default mixins(
if (this.$router.currentRoute.name === VIEWS.NEW_WORKFLOW) {
this.$root.$emit('newWorkflow');
} else {
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
@@ -405,6 +412,7 @@ export default mixins(
}
} else {
if (this.$router.currentRoute.name !== VIEWS.NEW_WORKFLOW) {
this.$store.commit('setWorkflowId', PLACEHOLDER_EMPTY_WORKFLOW_ID);
this.$router.push({ name: VIEWS.NEW_WORKFLOW });
}
this.$showMessage({
@@ -438,10 +446,11 @@ export default mixins(
this.checkWidthAndAdjustSidebar(browserWidth);
},
checkWidthAndAdjustSidebar (width: number) {
if (width < 900 || this.isNodeView) {
if (width < 900) {
this.$store.commit('ui/collapseSidebarMenu');
} else {
this.$store.commit('ui/expandSidebarMenu');
Vue.nextTick(() => {
this.fullyExpanded = !this.isCollapsed;
});
}
},
},

View File

@@ -374,9 +374,11 @@ export default mixins(
},
mounted() {
this.setSubtitle();
setTimeout(() => {
this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
if (this.nodeRunData) {
setTimeout(() => {
this.$emit('run', {name: this.data && this.data.name, data: this.nodeRunData, waiting: !!this.waiting});
}, 0);
}
},
data () {
return {

View File

@@ -1,9 +1,15 @@
<template>
<div :class="$style.container">
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
<div v-if="loaderType === 'image' && !showPreview" :class="$style.imageLoader">
<n8n-loading :loading="!showPreview" :rows="1" variant="image" />
</div>
<div v-else-if="loaderType === 'spinner' && !showPreview" :class="$style.spinner">
<n8n-spinner type="dots" />
</div>
<iframe
:class="{
[$style.workflow]: !this.nodeViewDetailsOpened,
[$style.executionPreview]: mode === 'execution',
[$style.openNDV]: this.nodeViewDetailsOpened,
[$style.show]: this.showPreview,
}"
@@ -18,10 +24,36 @@
<script lang="ts">
import mixins from 'vue-typed-mixins';
import { showMessage } from '@/components/mixins/showMessage';
import { IWorkflowDb } from '../Interface';
export default mixins(showMessage).extend({
name: 'WorkflowPreview',
props: ['loading', 'workflow'],
props: {
loading: {
type: Boolean,
default: false,
},
mode: {
type: String,
default: 'workflow',
validator: (value: string): boolean =>
['workflow', 'execution', 'medium'].includes(value),
},
workflow: {
type: Object as () => IWorkflowDb,
required: false,
},
executionId: {
type: String,
required: false,
},
loaderType: {
type: String,
default: 'image',
validator: (value: string): boolean =>
['image', 'spinner'].includes(value),
},
},
data() {
return {
nodeViewDetailsOpened: false,
@@ -33,7 +65,12 @@ export default mixins(showMessage).extend({
},
computed: {
showPreview(): boolean {
return !this.loading && !!this.workflow && this.ready;
return !this.loading &&
(
(this.mode === 'workflow' && !!this.workflow) ||
(this.mode === 'execution' && !!this.executionId)
) &&
this.ready;
},
},
methods: {
@@ -72,6 +109,29 @@ export default mixins(showMessage).extend({
);
}
},
loadExecution() {
try {
if (!this.executionId) {
throw new Error(this.$locale.baseText('workflowPreview.showError.missingExecution'));
}
const iframe = this.$refs.preview_iframe as HTMLIFrameElement;
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
JSON.stringify({
command: 'openExecution',
executionId: this.executionId,
}),
'*',
);
}
} catch (error) {
this.$showError(
error,
this.$locale.baseText('workflowPreview.showError.previewError.title'),
this.$locale.baseText('workflowPreview.executionMode.showError.previewError.message'),
);
}
},
receiveMessage({ data }: MessageEvent) {
try {
const json = JSON.parse(data);
@@ -96,7 +156,16 @@ export default mixins(showMessage).extend({
watch: {
showPreview(show) {
if (show) {
this.loadWorkflow();
if (this.mode === 'workflow') {
this.loadWorkflow();
} else if (this.mode === 'execution') {
this.loadExecution();
}
}
},
executionId(value) {
if (this.mode === 'execution' && this.executionId) {
this.loadExecution();
}
},
},
@@ -114,13 +183,12 @@ export default mixins(showMessage).extend({
<style lang="scss" module>
.container {
width: 100%;
height: 500px;
height: 100%;
display: flex;
justify-content: center;
}
.workflow {
border: var(--border-base);
border-radius: var(--border-radius-large);
// firefox bug requires loading iframe as such
visibility: hidden;
height: 0;
@@ -141,4 +209,20 @@ export default mixins(showMessage).extend({
width: 100%;
z-index: 9999999;
}
.spinner {
color: var(--color-primary);
position: absolute;
top: 50% !important;
-ms-transform: translateY(-50%);
transform: translateY(-50%);
}
.imageLoader {
width: 100%;
}
.executionPreview {
height: 100%;
}
</style>

View File

@@ -187,7 +187,7 @@ import {
IWorkflowShortResponse,
} from '@/Interface';
import Modal from './Modal.vue';
import { WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
import { PLACEHOLDER_EMPTY_WORKFLOW_ID, WORKFLOW_SETTINGS_MODAL_KEY } from '../constants';
import mixins from 'vue-typed-mixins';
@@ -244,7 +244,7 @@ export default mixins(
},
async mounted () {
if (this.$route.params.name === undefined) {
if (!this.workflowId || this.workflowId === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
this.$showMessage({
title: 'No workflow active',
message: `No workflow active to display settings of.`,
@@ -519,7 +519,7 @@ export default mixins(
this.isLoading = true;
try {
await this.restApi().updateWorkflow(this.$route.params.name, data);
await this.restApi().updateWorkflow(this.workflowId, data);
} catch (error) {
this.$showError(
error,

View File

@@ -1,9 +1,10 @@
import { CORE_NODES_CATEGORY, ERROR_TRIGGER_NODE_TYPE, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
import { CORE_NODES_CATEGORY, MAIN_HEADER_TABS, MAPPING_PARAMS, TEMPLATES_NODES_FILTER, VIEWS, NON_ACTIVATABLE_TRIGGER_NODE_TYPES } from '@/constants';
import { INodeUi, ITemplatesNode } from '@/Interface';
import { isResourceLocatorValue } from '@/typeGuards';
import dateformat from 'dateformat';
import {IDataObject, INodeProperties, INodeTypeDescription, NodeParameterValueType,INodeExecutionData, jsonParse} from 'n8n-workflow';
import { isJsonKeyObject } from "@/utils";
import { Route } from 'vue-router';
const CRED_KEYWORDS_TO_FILTER = ['API', 'OAuth1', 'OAuth2'];
const NODE_KEYWORDS_TO_FILTER = ['Trigger'];
@@ -175,3 +176,21 @@ export const clearJsonKey = (userInput: string | object) => {
return parsedUserInput.map(item => isJsonKeyObject(item) ? item.json : item);
};
export const getNodeViewTab = (route: Route): string|null => {
const routeMeta = route.meta;
if (routeMeta && routeMeta.nodeView === true) {
return MAIN_HEADER_TABS.WORKFLOW;
} else {
const executionTabRoutes = [
VIEWS.EXECUTION.toString(),
VIEWS.EXECUTION_PREVIEW.toString(),
VIEWS.EXECUTION_HOME.toString(),
];
if (executionTabRoutes.includes(route.name || '')) {
return MAIN_HEADER_TABS.EXECUTIONS;
}
}
return null;
};

View File

@@ -0,0 +1,70 @@
import { IExecutionsSummary } from "@/Interface";
import dateFormat from "dateformat";
import mixins from "vue-typed-mixins";
import { genericHelpers } from "./genericHelpers";
export interface IExecutionUIData {
name: string;
label: string;
startTime: string;
runningTime: string;
}
export const executionHelpers = mixins(genericHelpers).extend({
computed: {
executionId(): string {
return this.$route.params.executionId;
},
workflowName (): string {
return this.$store.getters.workflowName;
},
currentWorkflow (): string {
return this.$route.params.name || this.$store.getters.workflowId;
},
executions(): IExecutionsSummary[] {
return this.$store.getters['workflows/currentWorkflowExecutions'];
},
activeExecution(): IExecutionsSummary {
return this.$store.getters['workflows/getActiveWorkflowExecution'];
},
},
methods: {
getExecutionUIDetails(execution: IExecutionsSummary): IExecutionUIData {
const status = {
name: 'unknown',
startTime: this.formatDate(new Date(execution.startedAt)),
label: 'Status unknown',
runningTime: '',
};
if (execution.waitTill) {
status.name = 'waiting';
status.label = this.$locale.baseText('executionsList.waiting');
} else if (execution.stoppedAt === undefined) {
status.name = 'running';
status.label = this.$locale.baseText('executionsList.running');
status.runningTime = this.displayTimer(new Date().getTime() - new Date(execution.startedAt).getTime(), true);
} else if (execution.finished) {
status.name = 'success';
status.label = this.$locale.baseText('executionsList.succeeded');
if (execution.stoppedAt) {
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
}
} else if (execution.stoppedAt !== null) {
status.name = 'error';
status.label = this.$locale.baseText('executionsList.error');
if (execution.stoppedAt) {
status.runningTime = this.displayTimer(new Date(execution.stoppedAt).getTime() - new Date(execution.startedAt).getTime(), true);
}
}
return status;
},
formatDate(date: Date) {
if (date.getFullYear() === new Date().getFullYear()) {
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm');
}
return dateFormat(date.getTime(), 'HH:MM:ss "on" d mmm yyyy');
},
},
});

View File

@@ -36,7 +36,7 @@ export const genericHelpers = mixins(showMessage).extend({
// title: 'Workflow can not be changed!',
title: this.$locale.baseText('genericHelpers.showMessage.title'),
message: this.$locale.baseText('genericHelpers.showMessage.message'),
type: 'error',
type: 'info',
duration: 0,
});

View File

@@ -44,7 +44,11 @@
}
},
reload() {
window.location.reload();
if (window.top) {
window.top.location.reload();
} else {
window.location.reload();
}
},
},
});

View File

@@ -3,7 +3,7 @@ import { INodeUi, XYPosition } from '@/Interface';
import mixins from 'vue-typed-mixins';
import { deviceSupportHelpers } from '@/components/mixins/deviceSupportHelpers';
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
import { getMousePosition, getRelativePosition, HEADER_HEIGHT, INNER_SIDEBAR_WIDTH, SIDEBAR_WIDTH, SIDEBAR_WIDTH_EXPANDED } from '@/views/canvasHelpers';
import { VIEWS } from '@/constants';
export const mouseSelect = mixins(
@@ -49,8 +49,8 @@ export const mouseSelect = mixins(
},
getMousePositionWithinNodeView (event: MouseEvent | TouchEvent): XYPosition {
const [x, y] = getMousePosition(event);
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
const headerOffset = this.isDemo ? 0 : HEADER_HEIGHT;
const sidebarOffset = this.isDemo ? 0 : this.$store.getters['ui/sidebarMenuCollapsed'] ? SIDEBAR_WIDTH : SIDEBAR_WIDTH_EXPANDED;
// @ts-ignore
return getRelativePosition(x - sidebarOffset, y - headerOffset, this.nodeViewScale, this.$store.getters.getNodeViewOffsetPosition);
},

View File

@@ -9,6 +9,7 @@ import {
INodeTypeDescription,
} from 'n8n-workflow';
import { getStyleTokenValue } from '../helpers';
import { readonly } from 'vue';
export const nodeBase = mixins(
deviceSupportHelpers,
@@ -16,7 +17,12 @@ export const nodeBase = mixins(
mounted () {
// Initialize the node
if (this.data !== null) {
this.__addNode(this.data);
try {
this.__addNode(this.data);
} catch(error) {
// This breaks when new nodes are loaded into store but workflow tab is not currently active
// Shouldn't affect anything
}
}
},
computed: {

View File

@@ -245,7 +245,7 @@ export const pushConnection = mixins(
action = '<a data-action="open-settings">Turn on saving manual executions</a> and run again to see what happened after this node.';
}
else {
action = `<a href="/execution/${activeExecutionId}" target="_blank">View the execution</a> to see what happened after this node.`;
action = `<a href="/workflow/${workflow.id}/executions/${activeExecutionId}">View the execution</a> to see what happened after this node.`;
}
// Workflow did start but had been put to wait

View File

@@ -680,8 +680,9 @@ export const workflowHelpers = mixins(
}
},
async saveCurrentWorkflow({name, tags}: {name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
const currentWorkflow = this.$route.params.name;
async saveCurrentWorkflow({id, name, tags}: {id?: string, name?: string, tags?: string[]} = {}, redirect = true): Promise<boolean> {
const currentWorkflow = id || this.$route.params.name;
if (!currentWorkflow) {
return this.saveAsNewWorkflow({name, tags}, redirect);
}

View File

@@ -230,9 +230,9 @@ export const workflowRun = mixins(
this.$store.commit('setWorkflowExecutionData', executionData);
this.updateNodesExecutionIssues();
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
const runWorkflowApiResponse = await this.runWorkflowApi(startRunData);
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
this.$externalHooks().run('workflowRun.runWorkflow', { nodeName, source });
return runWorkflowApiResponse;
} catch (error) {