fix(editor): Update concurrency UI considering different types of instances (#12068)

This commit is contained in:
Csaba Tuncsik 2024-12-09 15:22:09 +01:00 committed by GitHub
parent 127d864bbb
commit fa572bbca4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 286 additions and 39 deletions

View file

@ -46,13 +46,12 @@ describe('ConcurrentExecutionsHeader', () => {
}, },
); );
it('should show tooltip on hover and call "goToUpgrade" on click', async () => { it('should show tooltip on hover with Upgrade link and emit "goToUpgrade" on click when on cloud', async () => {
const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); const { container, getByText, getByRole, queryByRole, emitted } = renderComponent({
const { container, getByText, getByRole, queryByRole } = renderComponent({
props: { props: {
runningExecutionsCount: 2, runningExecutionsCount: 2,
concurrencyCap: 5, concurrencyCap: 5,
isCloudDeployment: true,
}, },
}); });
@ -68,6 +67,25 @@ describe('ConcurrentExecutionsHeader', () => {
await userEvent.click(getByText('Upgrade now')); 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();
}); });
}); });

View file

@ -1,15 +1,18 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, defineProps } from 'vue'; import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = defineProps<{ const props = defineProps<{
runningExecutionsCount: number; runningExecutionsCount: number;
concurrencyCap: number; concurrencyCap: number;
isCloudDeployment?: boolean;
}>();
const emit = defineEmits<{
goToUpgrade: [];
}>(); }>();
const i18n = useI18n(); const i18n = useI18n();
const pageRedirectionHelper = usePageRedirectionHelper();
const tooltipText = computed(() => const tooltipText = computed(() =>
i18n.baseText('executionsList.activeExecutions.tooltip', { i18n.baseText('executionsList.activeExecutions.tooltip', {
@ -31,10 +34,6 @@ const headerText = computed(() => {
}, },
}); });
}); });
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script> </script>
<template> <template>
@ -43,9 +42,22 @@ const goToUpgrade = () => {
<template #content> <template #content>
<div :class="$style.tooltip"> <div :class="$style.tooltip">
{{ tooltipText }} {{ tooltipText }}
<n8n-link bold size="small" :class="$style.upgrade" @click="goToUpgrade"> <N8nLink
v-if="props.isCloudDeployment"
bold
size="small"
:class="$style.link"
@click="emit('goToUpgrade')"
>
{{ i18n.baseText('generic.upgradeNow') }} {{ i18n.baseText('generic.upgradeNow') }}
</n8n-link> </N8nLink>
<N8nLink
v-else
:class="$style.link"
:href="i18n.baseText('executions.concurrency.docsLink')"
target="_blank"
>{{ i18n.baseText('generic.viewDocs') }}</N8nLink
>
</div> </div>
</template> </template>
<font-awesome-icon icon="info-circle" class="mr-2xs" /> <font-awesome-icon icon="info-circle" class="mr-2xs" />
@ -54,12 +66,12 @@ const goToUpgrade = () => {
</div> </div>
</template> </template>
<style module scoped> <style lang="scss" module>
.tooltip { .tooltip {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.upgrade { .link {
margin-top: var(--spacing-xs); margin-top: var(--spacing-xs);
} }
</style> </style>

View file

@ -16,6 +16,7 @@ import { getResourcePermissions } from '@/permissions';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import ProjectHeader from '@/components/Projects/ProjectHeader.vue'; import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -40,6 +41,7 @@ const telemetry = useTelemetry();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const isMounted = ref(false); const isMounted = ref(false);
const allVisibleSelected = ref(false); const allVisibleSelected = ref(false);
@ -317,6 +319,10 @@ async function onAutoRefreshToggle(value: boolean) {
executionsStore.stopAutoRefreshInterval(); executionsStore.stopAutoRefreshInterval();
} }
} }
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script> </script>
<template> <template>
@ -330,6 +336,8 @@ async function onAutoRefreshToggle(value: boolean) {
class="mr-xl" class="mr-xl"
:running-executions-count="runningExecutionsCount" :running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency" :concurrency-cap="settingsStore.concurrency"
:is-cloud-deployment="settingsStore.isCloudDeployment"
@go-to-upgrade="goToUpgrade"
/> />
<N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" /> <N8nLoading v-if="!isMounted" :class="$style.filterLoader" variant="custom" />
<ElCheckbox <ElCheckbox
@ -400,12 +408,14 @@ async function onAutoRefreshToggle(value: boolean) {
:workflow-permissions="getExecutionWorkflowPermissions(execution)" :workflow-permissions="getExecutionWorkflowPermissions(execution)"
:selected="selectedItems[execution.id] || allExistingSelected" :selected="selectedItems[execution.id] || allExistingSelected"
:concurrency-cap="settingsStore.concurrency" :concurrency-cap="settingsStore.concurrency"
:is-cloud-deployment="settingsStore.isCloudDeployment"
data-test-id="global-execution-list-item" data-test-id="global-execution-list-item"
@stop="stopExecution" @stop="stopExecution"
@delete="deleteExecution" @delete="deleteExecution"
@select="toggleSelectExecution" @select="toggleSelectExecution"
@retry-saved="retrySavedExecution" @retry-saved="retrySavedExecution"
@retry-original="retryOriginalExecution" @retry-original="retryOriginalExecution"
@go-to-upgrade="goToUpgrade"
/> />
</TransitionGroup> </TransitionGroup>
</table> </table>

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'; import { vi } from 'vitest';
import { fireEvent } from '@testing-library/vue'; 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 { createComponentRenderer } from '@/__tests__/render';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
@ -15,9 +16,20 @@ vi.mock('vue-router', async () => {
}; };
}); });
const globalExecutionsListItemQueuedTooltipRenderSpy = vi.fn();
const renderComponent = createComponentRenderer(GlobalExecutionsListItem, { const renderComponent = createComponentRenderer(GlobalExecutionsListItem, {
global: { 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')); await fireEvent.click(getByText('TestWorkflow'));
expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank'); expect(window.open).toHaveBeenCalledWith('mockedRoute', '_blank');
expect(globalExecutionsListItemQueuedTooltipRenderSpy).not.toHaveBeenCalled();
}); });
it('should show formatted start date', () => { 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`), getByText(`1 Jan, 2022 at ${DateTime.fromJSDate(new Date(testDate)).toFormat('HH')}:00:00`),
).toBeInTheDocument(); ).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();
});
}); });

View file

@ -8,6 +8,7 @@ import { i18n as locale } from '@/plugins/i18n';
import ExecutionsTime from '@/components/executions/ExecutionsTime.vue'; import ExecutionsTime from '@/components/executions/ExecutionsTime.vue';
import { useExecutionHelpers } from '@/composables/useExecutionHelpers'; import { useExecutionHelpers } from '@/composables/useExecutionHelpers';
import type { PermissionsRecord } from '@/permissions'; import type { PermissionsRecord } from '@/permissions';
import GlobalExecutionsListItemQueuedTooltip from '@/components/executions/global/GlobalExecutionsListItemQueuedTooltip.vue';
type Command = 'retrySaved' | 'retryOriginal' | 'delete'; type Command = 'retrySaved' | 'retryOriginal' | 'delete';
@ -17,6 +18,7 @@ const emit = defineEmits<{
retrySaved: [data: ExecutionSummary]; retrySaved: [data: ExecutionSummary];
retryOriginal: [data: ExecutionSummary]; retryOriginal: [data: ExecutionSummary];
delete: [data: ExecutionSummary]; delete: [data: ExecutionSummary];
goToUpgrade: [];
}>(); }>();
const props = withDefaults( const props = withDefaults(
@ -26,6 +28,7 @@ const props = withDefaults(
workflowName?: string; workflowName?: string;
workflowPermissions: PermissionsRecord['workflow']; workflowPermissions: PermissionsRecord['workflow'];
concurrencyCap: number; concurrencyCap: number;
isCloudDeployment?: boolean;
}>(), }>(),
{ {
selected: false, 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(() => { const statusText = computed(() => {
switch (props.execution.status) { switch (props.execution.status) {
case 'waiting': case 'waiting':
@ -208,12 +198,15 @@ async function handleActionItemClick(commandData: Command) {
/> />
</template> </template>
</i18n-t> </i18n-t>
<N8nTooltip v-else placement="top"> <GlobalExecutionsListItemQueuedTooltip
<template #content> v-else
<span>{{ statusTooltipText }}</span> :status="props.execution.status"
</template> :concurrency-cap="props.concurrencyCap"
:is-cloud-deployment="props.isCloudDeployment"
@go-to-upgrade="emit('goToUpgrade')"
>
<span :class="$style.status">{{ statusText }}</span> <span :class="$style.status">{{ statusText }}</span>
</N8nTooltip> </GlobalExecutionsListItemQueuedTooltip>
</div> </div>
</td> </td>
<td> <td>

View file

@ -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);
});
});

View file

@ -0,0 +1,67 @@
<script lang="ts" setup="">
import type { ExecutionStatus } from 'n8n-workflow';
import { useI18n } from '@/composables/useI18n';
const props = defineProps<{
status: ExecutionStatus;
concurrencyCap: number;
isCloudDeployment?: boolean;
}>();
const emit = defineEmits<{
goToUpgrade: [];
}>();
const i18n = useI18n();
</script>
<template>
<N8nTooltip placement="top">
<template #content>
<i18n-t
v-if="props.status === 'waiting'"
keypath="executionsList.statusTooltipText.theWorkflowIsWaitingIndefinitely"
/>
<i18n-t
v-if="props.status === 'new'"
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity"
>
<template #instance>
<i18n-t
v-if="props.isCloudDeployment"
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity.cloud"
>
<template #concurrencyCap>{{ props.concurrencyCap }}</template>
<template #link>
<N8nLink bold size="small" :class="$style.link" @click="emit('goToUpgrade')">
{{ i18n.baseText('generic.upgradeNow') }}
</N8nLink>
</template>
</i18n-t>
<i18n-t
v-else
keypath="executionsList.statusTooltipText.waitingForConcurrencyCapacity.self"
>
<template #concurrencyCap>{{ props.concurrencyCap }}</template>
<template #link>
<N8nLink
:class="$style.link"
:href="i18n.baseText('executions.concurrency.docsLink')"
target="_blank"
>{{ i18n.baseText('generic.viewDocs') }}</N8nLink
>
</template>
</i18n-t>
</template>
</i18n-t>
</template>
<slot />
</N8nTooltip>
</template>
<style lang="scss" module>
.link {
display: inline-block;
margin-top: var(--spacing-xs);
}
</style>

View file

@ -15,6 +15,7 @@ import { getResourcePermissions } from '@/permissions';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue'; import ConcurrentExecutionsHeader from '@/components/executions/ConcurrentExecutionsHeader.vue';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean }; type AutoScrollDeps = { activeExecutionSet: boolean; cardsMounted: boolean; scroll: boolean };
@ -39,6 +40,7 @@ const i18n = useI18n();
const executionsStore = useExecutionsStore(); const executionsStore = useExecutionsStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const pageRedirectionHelper = usePageRedirectionHelper();
const mountedItems = ref<string[]>([]); const mountedItems = ref<string[]>([]);
const autoScrollDeps = ref<AutoScrollDeps>({ const autoScrollDeps = ref<AutoScrollDeps>({
@ -169,6 +171,10 @@ function scrollToActiveCard(): void {
} }
} }
} }
const goToUpgrade = () => {
void pageRedirectionHelper.goToUpgrade('concurrency', 'upgrade-concurrency');
};
</script> </script>
<template> <template>
@ -186,6 +192,8 @@ function scrollToActiveCard(): void {
v-if="settingsStore.isConcurrencyEnabled" v-if="settingsStore.isConcurrencyEnabled"
:running-executions-count="runningExecutionsCount" :running-executions-count="runningExecutionsCount"
:concurrency-cap="settingsStore.concurrency" :concurrency-cap="settingsStore.concurrency"
:is-cloud-deployment="settingsStore.isCloudDeployment"
@go-to-upgrade="goToUpgrade"
/> />
</div> </div>
<div :class="$style.controls"> <div :class="$style.controls">

View file

@ -77,6 +77,7 @@
"generic.ownedByMe": "(You)", "generic.ownedByMe": "(You)",
"generic.moreInfo": "More info", "generic.moreInfo": "More info",
"generic.next": "Next", "generic.next": "Next",
"generic.viewDocs": "View docs",
"about.aboutN8n": "About n8n", "about.aboutN8n": "About n8n",
"about.close": "Close", "about.close": "Close",
"about.license": "License", "about.license": "License",
@ -662,6 +663,7 @@
"error.goBack": "Go back", "error.goBack": "Go back",
"error.pageNotFound": "Oops, couldnt find that", "error.pageNotFound": "Oops, couldnt find that",
"executions.ExecutionStatus": "Execution status", "executions.ExecutionStatus": "Execution status",
"executions.concurrency.docsLink": "https://docs.n8n.io/hosting/scaling/concurrency-control/",
"executionDetails.confirmMessage.confirmButtonText": "Yes, delete", "executionDetails.confirmMessage.confirmButtonText": "Yes, delete",
"executionDetails.confirmMessage.headline": "Delete Execution?", "executionDetails.confirmMessage.headline": "Delete Execution?",
"executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?", "executionDetails.confirmMessage.message": "Are you sure that you want to delete the current execution?",
@ -702,7 +704,7 @@
"executionsLandingPage.noResults": "No executions found", "executionsLandingPage.noResults": "No executions found",
"executionsList.activeExecutions.none": "No active executions", "executionsList.activeExecutions.none": "No active executions",
"executionsList.activeExecutions.header": "{running}/{cap} 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.allWorkflows": "All Workflows",
"executionsList.anyStatus": "Any Status", "executionsList.anyStatus": "Any Status",
"executionsList.autoRefresh": "Auto refresh", "executionsList.autoRefresh": "Auto refresh",
@ -771,7 +773,9 @@
"executionsList.view": "View", "executionsList.view": "View",
"executionsList.stop": "Stop", "executionsList.stop": "Stop",
"executionsList.statusTooltipText.waitingForWebhook": "The workflow is waiting indefinitely for an incoming webhook call.", "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.statusTooltipText.theWorkflowIsWaitingIndefinitely": "The workflow is waiting indefinitely for an incoming webhook call.",
"executionsList.debug.button.copyToEditor": "Copy to editor", "executionsList.debug.button.copyToEditor": "Copy to editor",
"executionsList.debug.button.debugInEditor": "Debug in editor", "executionsList.debug.button.debugInEditor": "Debug in editor",