mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-22 12:19:09 +00:00
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:
committed by
GitHub
parent
99157cf581
commit
d833345092
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
70
packages/editor-ui/src/components/MainHeader/TabBar.vue
Normal file
70
packages/editor-ui/src/components/MainHeader/TabBar.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,11 @@
|
||||
}
|
||||
},
|
||||
reload() {
|
||||
window.location.reload();
|
||||
if (window.top) {
|
||||
window.top.location.reload();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user