diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index dbc613bd64e60..8ce3bc4080e83 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -15,7 +15,7 @@ import { TRELLO_NODE_NAME, } from '../constants'; import { CredentialsModal, CredentialsPage, NDV, WorkflowPage } from '../pages'; -import { successToast } from '../pages/notifications'; +import { errorToast, successToast } from '../pages/notifications'; import { getVisibleSelect } from '../utils'; const credentialsPage = new CredentialsPage(); @@ -278,4 +278,25 @@ describe('Credentials', () => { credentialsModal.getters.credentialAuthTypeRadioButtons().first().click(); nodeDetailsView.getters.copyInput().should('not.exist'); }); + + it('ADO-2583 should show notifications above credential modal overlay', () => { + // check error notifications because they are sticky + cy.intercept('POST', '/rest/credentials', { forceNetworkError: true }); + credentialsPage.getters.createCredentialButton().click(); + + credentialsModal.getters.newCredentialModal().should('be.visible'); + credentialsModal.getters.newCredentialTypeSelect().should('be.visible'); + credentialsModal.getters.newCredentialTypeOption('Notion API').click(); + + credentialsModal.getters.newCredentialTypeButton().click(); + credentialsModal.getters.connectionParameter('Internal Integration Secret').type('1234567890'); + + credentialsModal.actions.setName('My awesome Notion account'); + credentialsModal.getters.saveButton().click({ force: true }); + errorToast().should('have.length', 1); + errorToast().should('be.visible'); + + errorToast().should('have.css', 'z-index', '2100'); + cy.get('.el-overlay').should('have.css', 'z-index', '2001'); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts index fecf8d163aff4..cbd2cef75adaa 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreInMemory/VectorStoreInMemory.node.ts @@ -19,7 +19,7 @@ const insertFields: INodeProperties[] = [ }, ]; -export const VectorStoreInMemory = createVectorStoreNode({ +export class VectorStoreInMemory extends createVectorStoreNode({ meta: { displayName: 'In-Memory Vector Store', name: 'vectorStoreInMemory', @@ -56,4 +56,4 @@ export const VectorStoreInMemory = createVectorStoreNode({ void vectorStoreInstance.addDocuments(`${workflowId}__${memoryKey}`, documents, clearStore); }, -}); +}) {} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts index 0c9a148bec6ef..8336958cc5dfc 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePGVector/VectorStorePGVector.node.ts @@ -212,7 +212,7 @@ class ExtendedPGVectorStore extends PGVectorStore { } } -export const VectorStorePGVector = createVectorStoreNode({ +export class VectorStorePGVector extends createVectorStoreNode({ meta: { description: 'Work with your data in Postgresql with the PGVector extension', icon: 'file:postgres.svg', @@ -308,4 +308,4 @@ export const VectorStorePGVector = createVectorStoreNode({ await PGVectorStore.fromDocuments(documents, embeddings, config); }, -}); +}) {} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts index 85509a6287064..d153979ef4c1c 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStorePinecone/VectorStorePinecone.node.ts @@ -49,7 +49,7 @@ const insertFields: INodeProperties[] = [ }, ]; -export const VectorStorePinecone = createVectorStoreNode({ +export class VectorStorePinecone extends createVectorStoreNode({ meta: { displayName: 'Pinecone Vector Store', name: 'vectorStorePinecone', @@ -132,4 +132,4 @@ export const VectorStorePinecone = createVectorStoreNode({ pineconeIndex, }); }, -}); +}) {} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts index 5568243418fed..0b5859e0bc191 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreQdrant/VectorStoreQdrant.node.ts @@ -78,7 +78,7 @@ const retrieveFields: INodeProperties[] = [ }, ]; -export const VectorStoreQdrant = createVectorStoreNode({ +export class VectorStoreQdrant extends createVectorStoreNode({ meta: { displayName: 'Qdrant Vector Store', name: 'vectorStoreQdrant', @@ -134,4 +134,4 @@ export const VectorStoreQdrant = createVectorStoreNode({ await QdrantVectorStore.fromDocuments(documents, embeddings, config); }, -}); +}) {} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts index 0269a0cef2e60..549fcd5e7f364 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreSupabase/VectorStoreSupabase.node.ts @@ -39,7 +39,7 @@ const retrieveFields: INodeProperties[] = [ const updateFields: INodeProperties[] = [...insertFields]; -export const VectorStoreSupabase = createVectorStoreNode({ +export class VectorStoreSupabase extends createVectorStoreNode({ meta: { description: 'Work with your data in Supabase Vector Store', icon: 'file:supabase.svg', @@ -109,4 +109,4 @@ export const VectorStoreSupabase = createVectorStoreNode({ } } }, -}); +}) {} diff --git a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts index d041b43d6bf97..184b720d31242 100644 --- a/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/vector_store/VectorStoreZep/VectorStoreZep.node.ts @@ -44,7 +44,7 @@ const retrieveFields: INodeProperties[] = [ }, ]; -export const VectorStoreZep = createVectorStoreNode({ +export class VectorStoreZep extends createVectorStoreNode({ meta: { displayName: 'Zep Vector Store', name: 'vectorStoreZep', @@ -130,4 +130,4 @@ export const VectorStoreZep = createVectorStoreNode({ throw new NodeOperationError(context.getNode(), error as Error, { itemIndex }); } }, -}); +}) {} diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 72a653e07d841..5420435df2020 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -14,4 +14,5 @@ module.exports = { ], coveragePathIgnorePatterns: ['/src/databases/migrations/'], testTimeout: 10_000, + prettierPath: null, }; diff --git a/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts new file mode 100644 index 0000000000000..e485bbe435295 --- /dev/null +++ b/packages/cli/src/metrics/__tests__/prometheus-metrics.service.unmocked.test.ts @@ -0,0 +1,109 @@ +import { GlobalConfig } from '@n8n/config'; +import type express from 'express'; +import { mock } from 'jest-mock-extended'; +import type { InstanceSettings } from 'n8n-core'; +import promClient from 'prom-client'; + +import { EventMessageWorkflow } from '@/eventbus/event-message-classes/event-message-workflow'; +import type { EventService } from '@/events/event.service'; +import { mockInstance } from '@test/mocking'; + +import { MessageEventBus } from '../../eventbus/message-event-bus/message-event-bus'; +import { PrometheusMetricsService } from '../prometheus-metrics.service'; + +jest.unmock('@/eventbus/message-event-bus/message-event-bus'); + +const customPrefix = 'custom_'; + +const eventService = mock(); +const instanceSettings = mock({ instanceType: 'main' }); +const app = mock(); +const eventBus = new MessageEventBus( + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), + mock(), +); + +describe('workflow_success_total', () => { + test('support workflow id labels', async () => { + // ARRANGE + const globalConfig = mockInstance(GlobalConfig, { + endpoints: { + metrics: { + prefix: '', + includeMessageEventBusMetrics: true, + includeWorkflowIdLabel: true, + }, + }, + }); + + const prometheusMetricsService = new PrometheusMetricsService( + mock(), + eventBus, + globalConfig, + eventService, + instanceSettings, + ); + + await prometheusMetricsService.init(app); + + // ACT + const event = new EventMessageWorkflow({ + eventName: 'n8n.workflow.success', + payload: { workflowId: '1234' }, + }); + + eventBus.emit('metrics.eventBus.event', event); + + // ASSERT + const workflowSuccessCounter = + await promClient.register.getSingleMetricAsString('workflow_success_total'); + + expect(workflowSuccessCounter).toMatchInlineSnapshot(` +"# HELP workflow_success_total Total number of n8n.workflow.success events. +# TYPE workflow_success_total counter +workflow_success_total{workflow_id="1234"} 1" +`); + }); + + test('support a custom prefix', async () => { + // ARRANGE + const globalConfig = mockInstance(GlobalConfig, { + endpoints: { + metrics: { + prefix: customPrefix, + }, + }, + }); + + const prometheusMetricsService = new PrometheusMetricsService( + mock(), + eventBus, + globalConfig, + eventService, + instanceSettings, + ); + + await prometheusMetricsService.init(app); + + // ACT + const event = new EventMessageWorkflow({ + eventName: 'n8n.workflow.success', + payload: { workflowId: '1234' }, + }); + + eventBus.emit('metrics.eventBus.event', event); + + // ASSERT + const versionInfoMetric = promClient.register.getSingleMetric(`${customPrefix}version_info`); + + if (!versionInfoMetric) { + fail(`Could not find a metric called "${customPrefix}version_info"`); + } + }); +}); diff --git a/packages/cli/src/metrics/prometheus-metrics.service.ts b/packages/cli/src/metrics/prometheus-metrics.service.ts index 2565b0a6b1400..41714d25adff0 100644 --- a/packages/cli/src/metrics/prometheus-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus-metrics.service.ts @@ -211,7 +211,6 @@ export class PrometheusMetricsService { help: `Total number of ${eventName} events.`, labelNames: Object.keys(labels), }); - counter.labels(labels).inc(0); this.counters[eventName] = counter; } @@ -224,7 +223,9 @@ export class PrometheusMetricsService { this.eventBus.on('metrics.eventBus.event', (event: EventMessageTypes) => { const counter = this.toCounter(event); if (!counter) return; - counter.inc(1); + + const labels = this.toLabels(event); + counter.inc(labels, 1); }); } diff --git a/packages/cli/src/runners/task-runner-server.ts b/packages/cli/src/runners/task-runner-server.ts index 5baaf9fc9016e..2199e70b38af5 100644 --- a/packages/cli/src/runners/task-runner-server.ts +++ b/packages/cli/src/runners/task-runner-server.ts @@ -125,11 +125,13 @@ export class TaskRunnerServer { const { app } = this; // Augment errors sent to Sentry - const { - Handlers: { requestHandler, errorHandler }, - } = await import('@sentry/node'); - app.use(requestHandler()); - app.use(errorHandler()); + if (this.globalConfig.sentry.backendDsn) { + const { + Handlers: { requestHandler, errorHandler }, + } = await import('@sentry/node'); + app.use(requestHandler()); + app.use(errorHandler()); + } } private setupCommonMiddlewares() { diff --git a/packages/cli/src/webhooks/waiting-forms.ts b/packages/cli/src/webhooks/waiting-forms.ts index 72963f21dadf9..5a491c1fb3bf8 100644 --- a/packages/cli/src/webhooks/waiting-forms.ts +++ b/packages/cli/src/webhooks/waiting-forms.ts @@ -38,6 +38,25 @@ export class WaitingForms extends WaitingWebhooks { }); } + private async reloadForm(req: WaitingWebhookRequest, res: express.Response) { + try { + await sleep(1000); + + const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + const page = await axios({ url }); + + if (page) { + res.send(` + + `); + } + } catch (error) {} + } + async executeWebhook( req: WaitingWebhookRequest, res: express.Response, @@ -56,30 +75,17 @@ export class WaitingForms extends WaitingWebhooks { } if (execution.data.resultData.error) { - throw new ConflictError(`The execution "${executionId}" has finished with error.`); + const message = `The execution "${executionId}" has finished with error.`; + this.logger.debug(message, { error: execution.data.resultData.error }); + throw new ConflictError(message); } if (execution.status === 'running') { if (this.includeForms && req.method === 'GET') { - await sleep(1000); - - const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`; - const page = await axios({ url }); - - if (page) { - res.send(` - - `); - } - - return { - noWebhookResponse: true, - }; + await this.reloadForm(req, res); + return { noWebhookResponse: true }; } + throw new ConflictError(`The execution "${executionId}" is running already.`); } diff --git a/packages/cli/src/webhooks/waiting-webhooks.ts b/packages/cli/src/webhooks/waiting-webhooks.ts index a14563eea2e58..9529d04c04a97 100644 --- a/packages/cli/src/webhooks/waiting-webhooks.ts +++ b/packages/cli/src/webhooks/waiting-webhooks.ts @@ -102,7 +102,9 @@ export class WaitingWebhooks implements IWebhookManager { } if (execution.data?.resultData?.error) { - throw new ConflictError(`The execution "${executionId} has finished already.`); + const message = `The execution "${executionId}" has finished with error.`; + this.logger.debug(message, { error: execution.data.resultData.error }); + throw new ConflictError(message); } if (execution.finished) { @@ -182,23 +184,25 @@ export class WaitingWebhooks implements IWebhookManager { if (this.isSendAndWaitRequest(workflow.nodes, suffix)) { res.render('send-and-wait-no-action-required', { isTestWebhook: false }); return { noWebhookResponse: true }; - } else if (!execution.data.resultData.error && execution.status === 'waiting') { + } + + if (!execution.data.resultData.error && execution.status === 'waiting') { const childNodes = workflow.getChildNodes( execution.data.resultData.lastNodeExecuted as string, ); + const hasChildForms = childNodes.some( (node) => workflow.nodes[node].type === FORM_NODE_TYPE || workflow.nodes[node].type === WAIT_NODE_TYPE, ); + if (hasChildForms) { return { noWebhookResponse: true }; - } else { - throw new NotFoundError(errorMessage); } - } else { - throw new NotFoundError(errorMessage); } + + throw new NotFoundError(errorMessage); } const runExecutionData = execution.data; diff --git a/packages/cli/templates/form-trigger.handlebars b/packages/cli/templates/form-trigger.handlebars index 39627c0204f29..08f6d2e290cf5 100644 --- a/packages/cli/templates/form-trigger.handlebars +++ b/packages/cli/templates/form-trigger.handlebars @@ -752,6 +752,8 @@ console.error('Error:', error); }); + const isWaitingForm = window.location.href.includes('form-waiting'); + if(isWaitingForm) { const interval = setInterval(function() { const isSubmited = document.querySelector('#submitted-form').style.display; if(isSubmited === 'block') { @@ -760,6 +762,7 @@ } window.location.reload(); }, 2000); + } } }); diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index 1acffa82fa022..78b4ccbf51971 100644 --- a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -226,6 +226,16 @@ async function onCopyButtonClick(content: string, e: MouseEvent) { data-test-id="chat-message-system" > ⚠️ {{ message.content }} + + {{ t('generic.retry') }} +
{ }); expect(container).toMatchSnapshot(); }); + + it('renders error message correctly with retry button', () => { + const wrapper = render(AskAssistantChat, { + global: { + directives: { + n8nHtml, + }, + stubs, + }, + props: { + user: { firstName: 'Kobi', lastName: 'Dog' }, + messages: [ + { + id: '1', + role: 'assistant', + type: 'error', + content: 'This is an error message.', + read: false, + // Button is not shown without a retry function + retry: async () => {}, + }, + ], + }, + }); + expect(wrapper.container).toMatchSnapshot(); + expect(wrapper.getByTestId('error-retry-button')).toBeInTheDocument(); + }); + + it('does not render retry button if no error is present', () => { + const wrapper = render(AskAssistantChat, { + global: { + directives: { + n8nHtml, + }, + stubs, + }, + props: { + user: { firstName: 'Kobi', lastName: 'Dog' }, + messages: [ + { + id: '1', + type: 'text', + role: 'assistant', + content: + 'Hi Max! Here is my top solution to fix the error in your **Transform data** node👇', + read: false, + }, + ], + }, + }); + + expect(wrapper.container).toMatchSnapshot(); + expect(wrapper.queryByTestId('error-retry-button')).not.toBeInTheDocument(); + }); }); diff --git a/packages/design-system/src/components/AskAssistantChat/__tests__/__snapshots__/AskAssistantChat.spec.ts.snap b/packages/design-system/src/components/AskAssistantChat/__tests__/__snapshots__/AskAssistantChat.spec.ts.snap index 891c10abf606d..c26913e405e1f 100644 --- a/packages/design-system/src/components/AskAssistantChat/__tests__/__snapshots__/AskAssistantChat.spec.ts.snap +++ b/packages/design-system/src/components/AskAssistantChat/__tests__/__snapshots__/AskAssistantChat.spec.ts.snap @@ -1,5 +1,179 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`AskAssistantChat > does not render retry button if no error is present 1`] = ` +
+
+
+
+
+ + + + + + + + + + + + AI Assistant + +
+
+ beta +
+
+
+ +
+
+
+
+ +
+
+
+ + + + + + + + + + +
+ + Assistant + +
+
+
+

+ Hi Max! Here is my top solution to fix the error in your + + Transform data + + node👇 +

+ + +
+ + +
+ +
+ +
+ +
+
+