diff --git a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts
index 7e7c45b8d4..9089fa2934 100644
--- a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts
+++ b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.test.ts
@@ -46,13 +46,12 @@ describe('ConcurrentExecutionsHeader', () => {
},
);
- 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({
+ it('should show tooltip on hover with Upgrade link and emit "goToUpgrade" on click when on cloud', async () => {
+ const { container, getByText, getByRole, queryByRole, emitted } = renderComponent({
props: {
runningExecutionsCount: 2,
concurrencyCap: 5,
+ isCloudDeployment: true,
},
});
@@ -68,6 +67,25 @@ describe('ConcurrentExecutionsHeader', () => {
await userEvent.click(getByText('Upgrade now'));
- expect(windowOpenSpy).toHaveBeenCalled();
+ expect(emitted().goToUpgrade).toHaveLength(1);
+ });
+
+ it('should show tooltip on hover with Viev docs link when self-hosted', async () => {
+ 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('View docs')).toBeVisible();
});
});
diff --git a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue
index 9ff4600546..0cf3c220ff 100644
--- a/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue
+++ b/packages/editor-ui/src/components/executions/ConcurrentExecutionsHeader.vue
@@ -1,15 +1,18 @@
@@ -43,9 +42,22 @@ const goToUpgrade = () => {
{{ tooltipText }}
-
+
{{ i18n.baseText('generic.upgradeNow') }}
-
+
+ {{ i18n.baseText('generic.viewDocs') }}
@@ -54,12 +66,12 @@ const goToUpgrade = () => {
-
diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue
index 38aeddda80..01b72dde50 100644
--- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue
+++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsList.vue
@@ -16,6 +16,7 @@ import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
+import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = withDefaults(
defineProps<{
@@ -40,6 +41,7 @@ const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore();
+const pageRedirectionHelper = usePageRedirectionHelper();
const isMounted = ref(false);
const allVisibleSelected = ref(false);
@@ -317,6 +319,10 @@ async function onAutoRefreshToggle(value: boolean) {
executionsStore.stopAutoRefreshInterval();
}
}
+
+const goToUpgrade = () => {
+ void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
+};
@@ -330,6 +336,8 @@ async function onAutoRefreshToggle(value: boolean) {
class="mr-xl"
:running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency"
+ :is-cloud-deployment="settingsStore.isCloudDeployment"
+ @go-to-upgrade="goToUpgrade"
/>
diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.test.ts b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.test.ts
index 5f15c9534f..47e89f8714 100644
--- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.test.ts
+++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.test.ts
@@ -1,6 +1,7 @@
-import { describe, it, expect, vi } from 'vitest';
+import { vi } from 'vitest';
import { fireEvent } from '@testing-library/vue';
-import GlobalExecutionsListItem from './GlobalExecutionsListItem.vue';
+import { WAIT_INDEFINITELY } from 'n8n-workflow';
+import GlobalExecutionsListItem from '@/components/executions/global/GlobalExecutionsListItem.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { DateTime } from 'luxon';
@@ -15,9 +16,20 @@ vi.mock('vue-router', async () => {
};
});
+const globalExecutionsListItemQueuedTooltipRenderSpy = vi.fn();
+
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
global: {
- stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
+ //stubs: ['font-awesome-icon', 'n8n-tooltip', 'n8n-button', 'i18n-t'],
+ stubs: {
+ 'font-awesome-icon': true,
+ 'n8n-tooltip': true,
+ 'n8n-button': true,
+ 'i18n-t': true,
+ GlobalExecutionsListItemQueuedTooltip: {
+ render: globalExecutionsListItemQueuedTooltipRenderSpy,
+ },
+ },
},
});
@@ -98,6 +110,7 @@ describe('GlobalExecutionsListItem', () => {
await fireEvent.click(getByText('TestWorkflow'));
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
+ expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
});
it('should show formatted start date', () => {
@@ -113,4 +126,50 @@ describe('GlobalExecutionsListItem', () => {
getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`),
).toBeInTheDocument();
});
+
+ it('should not render queued tooltip for a not indefinitely waiting execution', async () => {
+ renderComponent({
+ props: {
+ execution: {
+ status: 'waiting',
+ waitTill: new Date(Date.now() + 10000000).toISOString(),
+ id: 123,
+ workflowName: 'Test Workflow',
+ },
+ workflowPermissions: {},
+ concurrencyCap: 5,
+ },
+ });
+
+ expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
+ });
+
+ it('should render queued tooltip for an indefinitely waiting execution', async () => {
+ renderComponent({
+ props: {
+ execution: {
+ status: 'waiting',
+ waitTill: WAIT_INDEFINITELY,
+ id: 123,
+ workflowName: 'Test Workflow',
+ },
+ workflowPermissions: {},
+ concurrencyCap: 5,
+ },
+ });
+
+ expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
+ });
+
+ it('should render queued tooltip for a new execution', async () => {
+ renderComponent({
+ props: {
+ execution: { status: 'new', id: 123, workflowName: 'Test Workflow' },
+ workflowPermissions: {},
+ concurrencyCap: 5,
+ },
+ });
+
+ expect(globalExecutionsListItemQueuedTooltipRenderSpy).toHaveBeenCalled();
+ });
});
diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue
index d179476531..db8e7a6ef4 100644
--- a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue
+++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItem.vue
@@ -8,6 +8,7 @@ import { i18n as locale } from '@/plugins/i18n';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { PermissionsRecord } from '@/permissions';
+import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
type Command = 'retrySaved' | 'retryOriginal' | 'delete';
@@ -17,6 +18,7 @@ const emit = defineEmits<{
retrySaved: [data: ExecutionSummary];
retryOriginal: [data: ExecutionSummary];
delete: [data: ExecutionSummary];
+ goToUpgrade: [];
}>();
const props = withDefaults(
@@ -26,6 +28,7 @@ const props = withDefaults(
workflowName?: string;
workflowPermissions: PermissionsRecord['workflow'];
concurrencyCap: number;
+ isCloudDeployment?: boolean;
}>(),
{
selected: false,
@@ -84,19 +87,6 @@ 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');
- }
- return '';
-});
-
const statusText = computed(() => {
switch (props.execution.status) {
case 'waiting':
@@ -208,12 +198,15 @@ async function handleActionItemClick(commandData: Command) {
/>
-
-
- {{ statusTooltipText }}
-
+
{{ statusText }}
-
+
diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItemQueuedTooltip.test.ts b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItemQueuedTooltip.test.ts
new file mode 100644
index 0000000000..b200822799
--- /dev/null
+++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItemQueuedTooltip.test.ts
@@ -0,0 +1,76 @@
+import userEvent from '@testing-library/user-event';
+import { createComponentRenderer } from '@/__tests__/render';
+import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
+
+const renderComponent = createComponentRenderer(GlobalExecutionsListItemQueuedTooltip);
+
+describe('GlobalExecutionsListItemQueuedTooltip', () => {
+ it('should not throw error when rendered', async () => {
+ expect(() =>
+ renderComponent({
+ props: {
+ status: 'waiting',
+ concurrencyCap: 0,
+ },
+ slots: {
+ default: 'Waiting',
+ },
+ }),
+ ).not.toThrow();
+ });
+
+ it('should show waiting indefinitely tooltip', async () => {
+ const { getByText } = renderComponent({
+ props: {
+ status: 'waiting',
+ concurrencyCap: 0,
+ },
+ slots: {
+ default: 'Waiting',
+ },
+ });
+
+ await userEvent.hover(getByText('Waiting'));
+
+ expect(getByText(/waiting indefinitely/)).toBeVisible();
+ });
+
+ it('should show queued tooltip for self-hosted', async () => {
+ const { getByText } = renderComponent({
+ props: {
+ status: 'new',
+ concurrencyCap: 0,
+ },
+ slots: {
+ default: 'Queued',
+ },
+ });
+
+ await userEvent.hover(getByText('Queued'));
+
+ expect(getByText(/instance is limited/)).toBeVisible();
+ expect(getByText('View docs')).toBeVisible();
+ });
+
+ it('should show queued tooltip for cloud', async () => {
+ const { getByText, emitted } = renderComponent({
+ props: {
+ status: 'new',
+ concurrencyCap: 0,
+ isCloudDeployment: true,
+ },
+ slots: {
+ default: 'Queued',
+ },
+ });
+
+ await userEvent.hover(getByText('Queued'));
+
+ expect(getByText(/plan is limited/)).toBeVisible();
+ expect(getByText('Upgrade now')).toBeVisible();
+
+ await userEvent.click(getByText('Upgrade now'));
+
+ expect(emitted().goToUpgrade).toHaveLength(1);
+ });
+});
diff --git a/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue
new file mode 100644
index 0000000000..666f9b7668
--- /dev/null
+++ b/packages/editor-ui/src/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+ {{ props.concurrencyCap }}
+
+
+ {{ i18n.baseText('generic.upgradeNow') }}
+
+
+
+
+ {{ props.concurrencyCap }}
+
+ {{ i18n.baseText('generic.viewDocs') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue
index 443a3d0f5d..b43f67708d 100644
--- a/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue
+++ b/packages/editor-ui/src/components/executions/workflow/WorkflowExecutionsSidebar.vue
@@ -15,6 +15,7 @@ import { getResourcePermissions } from '@/permissions';
import { useI18n } from '@/composables/useI18n';
import { useSettingsStore } from '@/stores/settings.store';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
+import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
@@ -39,6 +40,7 @@ const i18n = useI18n();
const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore();
+const pageRedirectionHelper = usePageRedirectionHelper();
const mountedItems = ref([]);
const autoScrollDeps = ref({
@@ -169,6 +171,10 @@ function scrollToActiveCard(): void {
}
}
}
+
+const goToUpgrade = () => {
+ void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
+};
@@ -186,6 +192,8 @@ function scrollToActiveCard(): void {
v-if="settingsStore.isConcurrencyEnabled"
:running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency"
+ :is-cloud-deployment="settingsStore.isCloudDeployment"
+ @go-to-upgrade="goToUpgrade"
/>
diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json
index 9c74e92f2e..a5c9361e9f 100644
--- a/packages/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/editor-ui/src/plugins/i18n/locales/en.json
@@ -77,6 +77,7 @@
"generic.ownedByMe": "(You)",
"generic.moreInfo": "More info",
"generic.next": "Next",
+ "generic.viewDocs": "View docs",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@@ -662,6 +663,7 @@
"error.goBack": "Go back",
"error.pageNotFound": "Oops, couldn’t find that",
"executions.ExecutionStatus": "Execution status",
+ "executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/",
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
"executionDetails.confirmMessage.headline": "Delete Execution?",
"executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?",
@@ -702,7 +704,7 @@
"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.activeExecutions.tooltip": "Current active executions: {running} out of {cap}. This instance is limited to {cap} concurrent production executions.",
"executionsList.allWorkflows": "All Workflows",
"executionsList.anyStatus": "Any Status",
"executionsList.autoRefresh": "Auto refresh",
@@ -771,7 +773,9 @@
"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.waitingForConcurrencyCapacity": "This execution will start once concurrency capacity is available. {instance}",
+ "executionsList.statusTooltipText.waitingForConcurrencyCapacity.cloud": "Your plan is limited to {concurrencyCap} concurrent production executions. {link}",
+ "executionsList.statusTooltipText.waitingForConcurrencyCapacity.self": "This instance is limited to {concurrencyCap} concurrent production executions. {link}",
"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",
|