fix(editor): Add execution concurrency info and paywall (#11847)

This commit is contained in:
Csaba Tuncsik 2024-11-28 13:53:39 +01:00 committed by GitHub
parent 96e6be7fe7
commit 57d3269e40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 315 additions and 24 deletions

View file

@ -1474,6 +1474,7 @@ export interface ExternalSecretsProvider {
export type CloudUpdateLinkSourceType =
| 'advanced-permissions'
| 'canvas-nav'
| 'concurrency'
| 'custom-data-filter'
| 'workflow_sharing'
| 'credential_sharing'
@ -1496,6 +1497,7 @@ export type CloudUpdateLinkSourceType =
export type UTMCampaign =
| 'upgrade-custom-data-filter'
| 'upgrade-canvas-nav'
| 'upgrade-concurrency'
| 'upgrade-workflow-sharing'
| 'upgrade-credentials-sharing'
| 'upgrade-api'

View file

@ -0,0 +1,73 @@
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
vi.mock('vue-router', () => {
return {
useRouter: vi.fn(),
useRoute: vi.fn(),
RouterLink: {
template: '<a><slot /></a>',
},
};
});
const renderComponent = createComponentRenderer(ConcurrentExecutionsHeader, {
pinia: createTestingPinia(),
});
describe('ConcurrentExecutionsHeader', () => {
it('should not throw error when rendered', async () => {
expect(() =>
renderComponent({
props: {
runningExecutionsCount: 0,
concurrencyCap: 0,
},
}),
).not.toThrow();
});
test.each([
[0, 5, 'No active executions'],
[2, 5, '2/5 active executions'],
])(
'shows the correct text when there are %i running executions of %i',
async (runningExecutionsCount, concurrencyCap, text) => {
const { getByText } = renderComponent({
props: {
runningExecutionsCount,
concurrencyCap,
},
});
expect(getByText(text)).toBeVisible();
},
);
it('should show tooltip on hover and call "goToUpgrade" on click', async () => {
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const { container, getByText, getByRole, queryByRole } = renderComponent({
props: {
runningExecutionsCount: 2,
concurrencyCap: 5,
},
});
const tooltipTrigger = container.querySelector('svg') as SVGSVGElement;
expect(tooltipTrigger).toBeVisible();
expect(queryByRole('tooltip')).not.toBeInTheDocument();
await userEvent.hover(tooltipTrigger);
expect(getByRole('tooltip')).toBeVisible();
expect(getByText('Upgrade now')).toBeVisible();
await userEvent.click(getByText('Upgrade now'));
expect(windowOpenSpy).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,65 @@
<script lang="ts" setup>
import { computed, defineProps } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = defineProps<{
runningExecutionsCount: number;
concurrencyCap: number;
}>();
const i18n = useI18n();
const pageRedirectionHelper = usePageRedirectionHelper();
const tooltipText = computed(() =>
i18n.baseText('executionsList.activeExecutions.tooltip', {
interpolate: {
running: props.runningExecutionsCount,
cap: props.concurrencyCap,
},
}),
);
const headerText = computed(() => {
if (props.runningExecutionsCount === 0) {
return i18n.baseText('executionsList.activeExecutions.none');
}
return i18n.baseText('executionsList.activeExecutions.header', {
interpolate: {
running: props.runningExecutionsCount,
cap: props.concurrencyCap,
},
});
});
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script>
<template>
<div data-test-id="concurrent-executions-header">
<n8n-tooltip>
<template #content>
<div :class="$style.tooltip">
{{ tooltipText }}
<n8n-link bold size="small" :class="$style.upgrade" @click="goToUpgrade">
{{ i18n.baseText('generic.upgradeNow') }}
</n8n-link>
</div>
</template>
<font-awesome-icon icon="info-circle" class="mr-2xs" />
</n8n-tooltip>
<n8n-text>{{ headerText }}</n8n-text>
</div>
</template>
<style module scoped>
.tooltip {
display: flex;
flex-direction: column;
}
.upgrade {
margin-top: var(--spacing-xs);
}
</style>

View file

@ -6,9 +6,16 @@ import { faker } from '@faker-js/faker';
import { STORES, VIEWS } from '@/constants';
import ExecutionsList from '@/components/executions/global/GlobalExecutionsList.vue';
import { randomInt, type ExecutionSummary } from 'n8n-workflow';
import { retry, SETTINGS_STORE_DEFAULT_STATE, waitAllPromises } from '@/__tests__/utils';
import type { MockedStore } from '@/__tests__/utils';
import {
mockedStore,
retry,
SETTINGS_STORE_DEFAULT_STATE,
waitAllPromises,
} from '@/__tests__/utils';
import { createComponentRenderer } from '@/__tests__/render';
import { waitFor } from '@testing-library/vue';
import { useSettingsStore } from '@/stores/settings.store';
vi.mock('vue-router', () => ({
useRoute: vi.fn().mockReturnValue({
@ -18,7 +25,7 @@ vi.mock('vue-router', () => ({
RouterLink: vi.fn(),
}));
let pinia: ReturnType<typeof createTestingPinia>;
let settingsStore: MockedStore<typeof useSettingsStore>;
const generateUndefinedNullOrString = () => {
switch (randomInt(4)) {
@ -58,6 +65,20 @@ const generateExecutionsData = () =>
}));
const renderComponent = createComponentRenderer(ExecutionsList, {
pinia: createTestingPinia({
initialState: {
[STORES.EXECUTIONS]: {
executions: [],
},
[STORES.SETTINGS]: {
settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, {
enterprise: {
advancedExecutionFilters: true,
},
}),
},
},
}),
props: {
autoRefreshEnabled: false,
},
@ -80,21 +101,7 @@ describe('GlobalExecutionsList', () => {
beforeEach(() => {
executionsData = generateExecutionsData();
pinia = createTestingPinia({
initialState: {
[STORES.EXECUTIONS]: {
executions: [],
},
[STORES.SETTINGS]: {
settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, {
enterprise: {
advancedExecutionFilters: true,
},
}),
},
},
});
settingsStore = mockedStore(useSettingsStore);
});
it('should render empty list', async () => {
@ -105,7 +112,6 @@ describe('GlobalExecutionsList', () => {
total: 0,
estimated: false,
},
pinia,
});
await waitAllPromises();
@ -128,7 +134,6 @@ describe('GlobalExecutionsList', () => {
filters: {},
estimated: false,
},
pinia,
});
await waitAllPromises();
@ -194,11 +199,22 @@ describe('GlobalExecutionsList', () => {
filters: {},
estimated: false,
},
pinia,
});
await waitAllPromises();
expect(queryAllByText(/Retry of/).length).toBe(retryOf.length);
expect(queryAllByText(/Success retry/).length).toBe(retrySuccessId.length);
});
it('should render concurrent executions header if the feature is enabled', async () => {
settingsStore.concurrency = 5;
const { getByTestId } = renderComponent({
props: {
executions: executionsData[0].results,
filters: {},
},
});
expect(getByTestId('concurrent-executions-header')).toBeVisible();
});
});

View file

@ -15,6 +15,7 @@ import type { PermissionsRecord } from '@/permissions';
import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
const props = withDefaults(
defineProps<{
@ -70,6 +71,10 @@ const isAnnotationEnabled = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.AdvancedExecutionFilters],
);
const runningExecutionsCount = computed(() => {
return props.executions.filter((execution) => execution.status === 'running').length;
});
watch(
() => props.executions,
() => {
@ -320,6 +325,12 @@ async function onAutoRefreshToggle(value: boolean) {
<div :class="$style.execList">
<div :class="$style.execListHeader">
<div :class="$style.execListHeaderControls">
<ConcurrentExecutionsHeader
v-if="settingsStore.isConcurrencyEnabled"
class="mr-xl"
:running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency"
/>
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
<ElCheckbox
v-else
@ -388,6 +399,7 @@ async function onAutoRefreshToggle(value: boolean) {
:workflow-name="getExecutionWorkflowName(execution)"
:workflow-permissions="getExecutionWorkflowPermissions(execution)"
:selected="selectedItems[execution.id] || allExistingSelected"
:concurrency-cap="settingsStore.concurrency"
data-test-id="global-execution-list-item"
@stop="stopExecution"
@delete="deleteExecution"

View file

@ -25,6 +25,7 @@ const props = withDefaults(
selected?: boolean;
workflowName?: string;
workflowPermissions: PermissionsRecord['workflow'];
concurrencyCap: number;
}>(),
{
selected: false,
@ -42,6 +43,10 @@ const isRunning = computed(() => {
return props.execution.status === 'running';
});
const isQueued = computed(() => {
return props.execution.status === 'new';
});
const isWaitTillIndefinite = computed(() => {
if (!props.execution.waitTill) {
return false;
@ -80,6 +85,12 @@ const formattedStoppedAtDate = computed(() => {
});
const statusTooltipText = computed(() => {
if (isQueued.value) {
return i18n.baseText('executionsList.statusTooltipText.waitingForConcurrencyCapacity', {
interpolate: { concurrencyCap: props.concurrencyCap },
});
}
if (props.execution.status === 'waiting' && isWaitTillIndefinite.value) {
return i18n.baseText('executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely');
}
@ -178,7 +189,7 @@ async function handleActionItemClick(commandData: Command) {
<FontAwesomeIcon icon="spinner" spin />
</span>
<i18n-t
v-if="!isWaitTillIndefinite"
v-if="!isWaitTillIndefinite && !isQueued"
data-test-id="execution-status"
tag="span"
:keypath="statusTextTranslationPath"

View file

@ -0,0 +1,72 @@
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import type { MockedStore } from '@/__tests__/utils';
import WorkflowExecutionsSidebar from '@/components/executions/workflow/WorkflowExecutionsSidebar.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { mockedStore, SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { STORES } from '@/constants';
import { merge } from 'lodash-es';
import { expect, it } from 'vitest';
vi.mock('vue-router', () => {
const location = {};
return {
useRouter: vi.fn(),
useRoute: () => ({
location,
}),
RouterLink: {
template: '<a><slot /></a>',
},
};
});
const renderComponent = createComponentRenderer(WorkflowExecutionsSidebar, {
pinia: createTestingPinia({
initialState: {
[STORES.EXECUTIONS]: {
executions: [],
},
[STORES.SETTINGS]: {
settings: merge(SETTINGS_STORE_DEFAULT_STATE.settings, {
enterprise: {
advancedExecutionFilters: true,
},
}),
},
},
}),
});
let settingsStore: MockedStore<typeof useSettingsStore>;
describe('WorkflowExecutionsSidebar', () => {
beforeEach(() => {
settingsStore = mockedStore(useSettingsStore);
});
it('should not throw error when opened', async () => {
expect(() =>
renderComponent({
props: {
loading: false,
loadingMore: false,
executions: [],
},
}),
).not.toThrow();
});
it('should render concurrent executions header if the feature is enabled', async () => {
settingsStore.concurrency = 5;
const { getByTestId } = renderComponent({
props: {
loading: false,
loadingMore: false,
executions: [],
},
});
expect(getByTestId('concurrent-executions-header')).toBeVisible();
});
});

View file

@ -13,6 +13,8 @@ import type { ExecutionFilterType, IWorkflowDb } from '@/Interface';
import { isComponentPublicInstance } from '@/utils/typeGuards';
import { getResourcePermissions } from '@/permissions';
import { useI18n } from '@/composables/useI18n';
import { useSettingsStore } from '@/stores/settings.store';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
@ -36,6 +38,7 @@ const router = useRouter();
const i18n = useI18n();
const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore();
const mountedItems = ref<string[]>([]);
const autoScrollDeps = ref<AutoScrollDeps>({
@ -49,6 +52,10 @@ const executionListRef = ref<HTMLElement | null>(null);
const workflowPermissions = computed(() => getResourcePermissions(props.workflow?.scopes).workflow);
const runningExecutionsCount = computed(() => {
return props.executions.filter((execution) => execution.status === 'running').length;
});
watch(
() => route,
(to: RouteLocationNormalizedLoaded, from: RouteLocationNormalizedLoaded) => {
@ -174,6 +181,12 @@ function scrollToActiveCard(): void {
<n8n-heading tag="h2" size="medium" color="text-dark">
{{ i18n.baseText('generic.executions') }}
</n8n-heading>
<ConcurrentExecutionsHeader
v-if="settingsStore.isConcurrencyEnabled"
:running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency"
/>
</div>
<div :class="$style.controls">
<el-checkbox

View file

@ -697,6 +697,9 @@
"executionsLandingPage.emptyState.accordion.footer.tooltipLink": "Save your workflow",
"executionsLandingPage.emptyState.accordion.footer.tooltipText": "in order to access workflow settings",
"executionsLandingPage.noResults": "No executions found",
"executionsList.activeExecutions.none": "No active executions",
"executionsList.activeExecutions.header": "{running}/{cap} active executions",
"executionsList.activeExecutions.tooltip": "Current active executions: {running} out of {cap} allowed by your plan. Upgrade to increase the limit.",
"executionsList.allWorkflows": "All Workflows",
"executionsList.anyStatus": "Any Status",
"executionsList.autoRefresh": "Auto refresh",
@ -764,6 +767,8 @@
"executionsList.workflowExecutions": "Executions",
"executionsList.view": "View",
"executionsList.stop": "Stop",
"executionsList.statusTooltipText.waitingForWebhook": "The workflow is waiting indefinitely for an incoming webhook call.",
"executionsList.statusTooltipText.waitingForConcurrencyCapacity": "This execution will start once concurrency capacity is available. This instance is limited to {concurrencyCap} concurrent production executions.",
"executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.",
"executionsList.debug.button.copyToEditor": "Copy to editor",
"executionsList.debug.button.debugInEditor": "Debug in editor",

View file

@ -15,10 +15,12 @@ import { useRootStore } from '@/stores/root.store';
import { makeRestApiRequest, unflattenExecutionData } from '@/utils/apiUtils';
import { executionFilterToQueryFilter, getDefaultExecutionFilters } from '@/utils/executionUtils';
import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store';
export const useExecutionsStore = defineStore('executions', () => {
const rootStore = useRootStore();
const projectsStore = useProjectsStore();
const settingsStore = useSettingsStore();
const loading = ref(false);
const itemsPerPage = ref(10);
@ -67,12 +69,29 @@ export const useExecutionsStore = defineStore('executions', () => {
);
const currentExecutionsById = ref<Record<string, ExecutionSummaryWithScopes>>({});
const startedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) =>
new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
/**
* Prioritize `running` over `new` executions, then sort by start timestamp.
*/
const statusThenStartedAtSortFn = (a: ExecutionSummary, b: ExecutionSummary) => {
if (a.status && b.status) {
const statusPriority: { [key: string]: number } = { running: 1, new: 2 };
const statusComparison = statusPriority[a.status] - statusPriority[b.status];
if (statusComparison !== 0) return statusComparison;
}
return startedAtSortFn(a, b);
};
const sortFn = settingsStore.isConcurrencyEnabled ? statusThenStartedAtSortFn : startedAtSortFn;
const currentExecutions = computed(() => {
const data = Object.values(currentExecutionsById.value);
data.sort((a, b) => {
return new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime();
});
data.sort(sortFn);
return data;
});

View file

@ -70,6 +70,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const concurrency = computed(() => settings.value.concurrency);
const isConcurrencyEnabled = computed(() => concurrency.value !== -1);
const isPublicApiEnabled = computed(() => api.value.enabled);
const isSwaggerUIEnabled = computed(() => api.value.swaggerUi.enabled);
@ -384,6 +386,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
security,
nodeJsVersion,
concurrency,
isConcurrencyEnabled,
isPublicApiEnabled,
isSwaggerUIEnabled,
isPreviewMode,