diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 07ac7543565..f3464a24674 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -7,6 +7,7 @@ on: pull_request: paths: - packages/cli/src/databases/** + - packages/cli/src/modules/*/database/** - .github/workflows/ci-postgres-mysql.yml - .github/docker-compose.yml pull_request_review: diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index dfd8fc93363..c8247051a0e 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -65,11 +65,9 @@ jobs: run: echo "value=sha-$GITHUB_SHA-time-$(date +"%s")" >> $GITHUB_OUTPUT install: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2204 needs: ['prepare'] - container: - image: cypress/${{ inputs.run-env }} - options: --user 1001 + steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: @@ -83,10 +81,22 @@ jobs: - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 + - name: Cache build artifacts + id: cache-build-artifacts + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + with: + path: | + /home/runner/.cache/Cypress + /github/home/.pnpm-store + ./packages/**/dist + key: ${{ github.sha }}:build-artifacts + - name: Install dependencies + if: steps.cache-build-artifacts.outputs.cache-hit != 'true' run: pnpm install --frozen-lockfile - name: Cypress build + if: steps.cache-build-artifacts.outputs.cache-hit != 'true' uses: cypress-io/github-action@1b70233146622b69e789ccdd4f9452adc638d25a # v6.6.1 with: # Disable running of tests within install job @@ -95,23 +105,13 @@ jobs: build: pnpm build - name: Cypress install + if: steps.cache-build-artifacts.outputs.cache-hit != 'true' working-directory: cypress run: pnpm cypress:install - - name: Cache build artifacts - uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 - with: - path: | - /github/home/.cache - /github/home/.pnpm-store - ./packages/**/dist - key: ${{ github.sha }}-e2e testing: runs-on: ubuntu-latest - container: - image: cypress/${{ inputs.run-env }} - options: --user 1001 needs: ['prepare', 'install'] strategy: fail-fast: false @@ -133,13 +133,14 @@ jobs: - uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2 # v4.0.0 - name: Restore cached pnpm modules - uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 + id: cache-build-artifacts + uses: actions/cache@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0 with: path: | - /github/home/.cache + /home/runner/.cache/Cypress /github/home/.pnpm-store ./packages/**/dist - key: ${{ github.sha }}-e2e + key: ${{ github.sha }}:build-artifacts - name: Install dependencies run: pnpm install --frozen-lockfile diff --git a/.github/workflows/linting-reusable.yml b/.github/workflows/linting-reusable.yml index 03e4257c45e..32db6d1e483 100644 --- a/.github/workflows/linting-reusable.yml +++ b/.github/workflows/linting-reusable.yml @@ -17,9 +17,7 @@ on: jobs: lint: name: Lint - runs-on: blacksmith-2vcpu-ubuntu-2204 - env: - NODE_OPTIONS: '--max-old-space-size=4096' + runs-on: blacksmith-4vcpu-ubuntu-2204 steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc41d7386f..fb5f8b3eb16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +# [1.85.0](https://github.com/n8n-io/n8n/compare/n8n@1.84.0...n8n@1.85.0) (2025-03-24) + + +### Bug Fixes + +* Allow saved credenitals types of up to 64 characters instead of 32 ([#13985](https://github.com/n8n-io/n8n/issues/13985)) ([bc15bb1](https://github.com/n8n-io/n8n/commit/bc15bb18d9f33abdeed24e26826e7f3308d3eef2)) +* Allow username to be set in Redis chat memory ([#13926](https://github.com/n8n-io/n8n/issues/13926)) ([b2e359a](https://github.com/n8n-io/n8n/commit/b2e359ac1c2dfdf79f8d50fe83998eda5fc34dd2)) +* **core:** Allow running webhook servers in multi-main mode ([#13989](https://github.com/n8n-io/n8n/issues/13989)) ([e0fd505](https://github.com/n8n-io/n8n/commit/e0fd50554d48c873c8f77169d1a17438391dd973)) +* **core:** Bring back the missing GMT and UTC timezone for workflow settings ([#13999](https://github.com/n8n-io/n8n/issues/13999)) ([bda0688](https://github.com/n8n-io/n8n/commit/bda068880ea7a44718e01a156e97f09c9ec2bc46)) +* **core:** Do not use `url.includes` to check for domain names ([#13802](https://github.com/n8n-io/n8n/issues/13802)) ([d3bc80c](https://github.com/n8n-io/n8n/commit/d3bc80c22bbbf0ae39c88a6f085d5f80aa8a0e82)) +* **core:** Don't fail partial execution when an unrelated node is dirty ([#13925](https://github.com/n8n-io/n8n/issues/13925)) ([918cc51](https://github.com/n8n-io/n8n/commit/918cc51abc79bbcfb6a333d5ecafa07a9e986b6f)) +* **core:** Ensure frontend sentry releases also follow semver ([#14019](https://github.com/n8n-io/n8n/issues/14019)) ([401ed2c](https://github.com/n8n-io/n8n/commit/401ed2ce1194ad7ff238debff418f0db77eb06e6)) +* **editor:** Add "time saved per execution" workflow setting ([#13369](https://github.com/n8n-io/n8n/issues/13369)) ([6992c36](https://github.com/n8n-io/n8n/commit/6992c36ebb3aa608ce31396f9b7ed0aa10c80299)) +* **editor:** Add smart decimals directive ([#14054](https://github.com/n8n-io/n8n/issues/14054)) ([1a26fc2](https://github.com/n8n-io/n8n/commit/1a26fc2762dee366d2ce7ccf24e173cdc761c70c)) +* **editor:** Fix routing between workflow editing and new workflow pages ([#14031](https://github.com/n8n-io/n8n/issues/14031)) ([6817abe](https://github.com/n8n-io/n8n/commit/6817abe47facd7ff0e42a66599827d42c4df757c)) + + +### Features + +* Add appendN8nAttribution option to sendAndWait operation ([#13697](https://github.com/n8n-io/n8n/issues/13697)) ([d6d5a66](https://github.com/n8n-io/n8n/commit/d6d5a66f5dc28d926755ca8153f91c7be0742cf5)) +* Add xAiGrok Chat Model node and credentials ([#13670](https://github.com/n8n-io/n8n/issues/13670)) ([cc502fb](https://github.com/n8n-io/n8n/commit/cc502fb8c34b65d569b4abe4603cc8ef1eadc7a7)) +* Allow custom scopes for Entra credential ([#13796](https://github.com/n8n-io/n8n/issues/13796)) ([7e10361](https://github.com/n8n-io/n8n/commit/7e1036187ff7bd5be990f191a3ac8ef002e7812a)) +* **API:** Fix generation strategy for mysql/mariadb ([#14028](https://github.com/n8n-io/n8n/issues/14028)) ([24d8eac](https://github.com/n8n-io/n8n/commit/24d8eac85d8ce95671aabf8500139b3ef3e19a56)) +* **API:** Implement compaction logic for insights ([#14062](https://github.com/n8n-io/n8n/issues/14062)) ([d8433d2](https://github.com/n8n-io/n8n/commit/d8433d289543c40854e59b0384be356a3d7b947d)) +* Cat 720 improve pre merge ci ([#14116](https://github.com/n8n-io/n8n/issues/14116)) ([743b63e](https://github.com/n8n-io/n8n/commit/743b63e97a9a96dfaf35f138a79eddaad9bb2dbb)) +* **core:** Add folder synchronization to environments feature ([#14005](https://github.com/n8n-io/n8n/issues/14005)) ([198f17d](https://github.com/n8n-io/n8n/commit/198f17dbcf0b21e579f9a68466494662257dbe44)) +* **core:** Add tool to uninstall a community node ([#14026](https://github.com/n8n-io/n8n/issues/14026)) ([e0f9506](https://github.com/n8n-io/n8n/commit/e0f9506912aa6a129df332185063291f0627f9ca)) +* **core:** Allow community nodes to be used as tools ([#14042](https://github.com/n8n-io/n8n/issues/14042)) ([9d698ed](https://github.com/n8n-io/n8n/commit/9d698edcebc8cdbf9fefc3bf89a13f9daa32f40b)) +* **core:** Allow customizing auth cookie samesite attribute and CSP headers ([#13855](https://github.com/n8n-io/n8n/issues/13855)) ([17fc5c1](https://github.com/n8n-io/n8n/commit/17fc5c148b99b8f346abf2142a1d2bee567b2621)) +* **core:** Enable folders feature via license server ([#13942](https://github.com/n8n-io/n8n/issues/13942)) ([fa7e7ac](https://github.com/n8n-io/n8n/commit/fa7e7ac2e7b38418619ebe1f3839d47c491419d2)) +* **core:** Implement API to retrieve summary metrics ([#13927](https://github.com/n8n-io/n8n/issues/13927)) ([b616ceb](https://github.com/n8n-io/n8n/commit/b616ceb08b712ecd350114acc48a9a0f35843c0a)) +* **core:** Support importing a singular workflow object ([#14041](https://github.com/n8n-io/n8n/issues/14041)) ([91b2796](https://github.com/n8n-io/n8n/commit/91b27964d80309ce493200289b31a83ef6051b4d)) +* **core:** Update endpoint to update a workflow, to support updating the workflow parent folder (no-chagelog) ([#13906](https://github.com/n8n-io/n8n/issues/13906)) ([3a5cc4a](https://github.com/n8n-io/n8n/commit/3a5cc4ae957ea5f370472f08d2af4ac29c3b21b2)) +* **editor:** Add variables and context section to schema view ([#13875](https://github.com/n8n-io/n8n/issues/13875)) ([c06ce76](https://github.com/n8n-io/n8n/commit/c06ce765f11dcde4731d3739e1aa5f27351c3cc2)) +* **editor:** Always show collapsed panel at the bottom of canvas ([#13715](https://github.com/n8n-io/n8n/issues/13715)) ([2e9d3ad](https://github.com/n8n-io/n8n/commit/2e9d3ad3e14da7aa2f3b3b9577858791e9128908)) +* **editor:** Insights summary banner ([#13424](https://github.com/n8n-io/n8n/issues/13424)) ([df474f3](https://github.com/n8n-io/n8n/commit/df474f3ccbc629a8e308359e6a4973cc00b86e17)) +* **Extract from File Node:** Add relax_quote option ([#13607](https://github.com/n8n-io/n8n/issues/13607)) ([830d2c5](https://github.com/n8n-io/n8n/commit/830d2c5df53c5436f89868dfe23cf55c41585a46)) +* **n8n Form Trigger Node:** Respond with File ([#13507](https://github.com/n8n-io/n8n/issues/13507)) ([8f46371](https://github.com/n8n-io/n8n/commit/8f46371d77262aa0a924e1c58cf9691327e0f193)) +* **Salesforce Node:** Add support for PKCE ([#14082](https://github.com/n8n-io/n8n/issues/14082)) ([defeb2e](https://github.com/n8n-io/n8n/commit/defeb2e817dbc559844124f20e6bebf7717d878a)) +* **SeaTable Node:** Update node with new options ([#11431](https://github.com/n8n-io/n8n/issues/11431)) ([d0fdb11](https://github.com/n8n-io/n8n/commit/d0fdb11499de2e5fb1602b7cc86f2b24543ce50f)) +* **Simple Vector Store Node:** Implement store cleaning based on age/used memory ([#13986](https://github.com/n8n-io/n8n/issues/13986)) ([e06c552](https://github.com/n8n-io/n8n/commit/e06c552a6a0471ec60862247f6a597b8ab5f9cd3)) + + + # [1.84.0](https://github.com/n8n-io/n8n/compare/n8n@1.83.0...n8n@1.84.0) (2025-03-17) diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts index 56085c0a3e2..ab710a279de 100644 --- a/cypress/composables/folders.ts +++ b/cypress/composables/folders.ts @@ -212,6 +212,11 @@ export function getNewFolderNameInput() { export function getNewFolderModalErrorMessage() { return cy.get('.el-message-box__errormsg').filter(':visible'); } + +export function getProjectTab(tabId: string) { + return cy.getByTestId('project-tabs').find(`#${tabId}`); +} + /** * Actions */ diff --git a/cypress/composables/modals/save-changes-modal.ts b/cypress/composables/modals/save-changes-modal.ts index d44b09bd460..e2a629a63ec 100644 --- a/cypress/composables/modals/save-changes-modal.ts +++ b/cypress/composables/modals/save-changes-modal.ts @@ -1,3 +1,13 @@ export function getSaveChangesModal() { return cy.get('.el-overlay').contains('Save changes before leaving?'); } + +// this is the button next to 'Save Changes' +export function getCancelSaveChangesButton() { + return cy.get('.btn--cancel'); +} + +// This is the top right 'x' +export function getCloseSaveChangesButton() { + return cy.get('.el-message-box__headerbtn'); +} diff --git a/cypress/composables/workflowsPage.ts b/cypress/composables/workflowsPage.ts index c7bcf398886..cbab641de21 100644 --- a/cypress/composables/workflowsPage.ts +++ b/cypress/composables/workflowsPage.ts @@ -6,6 +6,10 @@ export function getWorkflowsPageUrl() { return '/home/workflows'; } +export const getCreateWorkflowButton = () => cy.getByTestId('add-resource-workflow'); + +export const getNewWorkflowCardButton = () => cy.getByTestId('new-workflow-card'); + /** * Actions */ diff --git a/cypress/e2e/44-routing.cy.ts b/cypress/e2e/44-routing.cy.ts index 1d3a8746a93..c3129c4e9d5 100644 --- a/cypress/e2e/44-routing.cy.ts +++ b/cypress/e2e/44-routing.cy.ts @@ -1,26 +1,59 @@ -import { getSaveChangesModal } from '../composables/modals/save-changes-modal'; +import { + getCancelSaveChangesButton, + getCloseSaveChangesButton, + getSaveChangesModal, +} from '../composables/modals/save-changes-modal'; +import { getHomeButton } from '../composables/projects'; +import { addNodeToCanvas } from '../composables/workflow'; +import { + getCreateWorkflowButton, + getNewWorkflowCardButton, + getWorkflowsPageUrl, + visitWorkflowsPage, +} from '../composables/workflowsPage'; import { EDIT_FIELDS_SET_NODE_NAME } from '../constants'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; -import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; - -const WorkflowsPage = new WorkflowsPageClass(); -const WorkflowPage = new WorkflowPageClass(); describe('Workflows', () => { beforeEach(() => { - cy.visit(WorkflowsPage.url); + visitWorkflowsPage(); }); it('should ask to save unsaved changes before leaving route', () => { - WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible'); - WorkflowsPage.getters.newWorkflowButtonCard().click(); + getNewWorkflowCardButton().should('be.visible'); + getNewWorkflowCardButton().click(); cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow'); - WorkflowPage.actions.addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); - cy.getByTestId('project-home-menu-item').click(); + getHomeButton().click(); + + // We expect to still be on the workflow route here + cy.url().should('include', '/workflow/'); getSaveChangesModal().should('be.visible'); + getCancelSaveChangesButton().click(); + + // Only now do we switch + cy.url().should('include', getWorkflowsPageUrl()); + }); + + it('should correct route after cancelling saveChangesModal', () => { + getCreateWorkflowButton().click(); + + cy.createFixtureWorkflow('Test_workflow_1.json', 'Empty State Card Workflow'); + addNodeToCanvas(EDIT_FIELDS_SET_NODE_NAME); + + // Here we go back via browser rather than the home button + // As this already updates the route + cy.go(-1); + + cy.url().should('include', getWorkflowsPageUrl()); + + getSaveChangesModal().should('be.visible'); + getCloseSaveChangesButton().click(); + + // Confirm the url is back to the workflow + cy.url().should('include', '/workflow/'); }); }); diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts index a0ee846c1c7..1cf4f17d857 100644 --- a/cypress/e2e/49-folders.cy.ts +++ b/cypress/e2e/49-folders.cy.ts @@ -32,6 +32,7 @@ import { getPersonalProjectMenuItem, getProjectEmptyState, getProjectMenuItem, + getProjectTab, getVisibleListBreadcrumbs, getWorkflowCard, getWorkflowCardBreadcrumbs, @@ -64,12 +65,16 @@ describe('Folders', () => { describe('Create and navigate folders', () => { it('should create folder from the project header', () => { + // 1. In project root getPersonalProjectMenuItem().click(); createFolderFromProjectHeader('My Folder'); getFolderCards().should('have.length.greaterThan', 0); // Clicking on the success toast should navigate to the folder successToast().find('a').click(); getCurrentBreadcrumb().should('contain.text', 'My Folder'); + // 2. In a folder + createFolderFromListHeaderButton('My Folder 2'); + getFolderCard('My Folder 2').should('exist'); }); it('should not allow illegal folder names', () => { @@ -217,6 +222,10 @@ describe('Folders', () => { cy.getByTestId('action-folder').should('exist'); createFolderFromProjectHeader('Personal Folder'); getFolderCards().should('exist'); + // Create folder option should not be available on credentials tab + getProjectTab('ProjectsCredentials').click(); + getAddResourceDropdown().click(); + cy.getByTestId('action-folder').should('not.exist'); }); }); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index be784cdc466..4b67a0d9887 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -1,5 +1,10 @@ import { setCredentialValues } from '../composables/modals/credential-modal'; -import { clickCreateNewCredential, setParameterSelectByContent } from '../composables/ndv'; +import { + clickCreateNewCredential, + clickGetBackToCanvas, + setParameterSelectByContent, +} from '../composables/ndv'; +import { openNode } from '../composables/workflow'; import { EDIT_FIELDS_SET_NODE_NAME, MANUAL_TRIGGER_NODE_DISPLAY_NAME, @@ -617,6 +622,42 @@ describe('NDV', () => { // Sinse code tool require alphanumeric tool name it would also show an error(2 errors, 1 for each tool node) cy.get('[class*=hasIssues]').should('have.length', 3); }); + + it('should have the floating nodes in correct order', () => { + cy.createFixtureWorkflow('Floating_Nodes.json', 'Floating Nodes'); + + cy.ifCanvasVersion( + () => {}, + () => { + // Needed in V2 as all nodes remain selected when clicking on a selected node + workflowPage.actions.deselectAll(); + }, + ); + + // The first merge node has the wires crossed, so `Edit Fields1` is first in the order of connected nodes + openNode('Merge'); + getFloatingNodeByPosition('inputMain').should('exist'); + getFloatingNodeByPosition('inputMain').should('have.length', 2); + getFloatingNodeByPosition('inputMain') + .first() + .should('have.attr', 'data-node-name', 'Edit Fields1'); + getFloatingNodeByPosition('inputMain') + .last() + .should('have.attr', 'data-node-name', 'Edit Fields0'); + + clickGetBackToCanvas(); + + // The second merge node does not have wires crossed, so `Edit Fields0` is first + openNode('Merge1'); + getFloatingNodeByPosition('inputMain').should('exist'); + getFloatingNodeByPosition('inputMain').should('have.length', 2); + getFloatingNodeByPosition('inputMain') + .first() + .should('have.attr', 'data-node-name', 'Edit Fields0'); + getFloatingNodeByPosition('inputMain') + .last() + .should('have.attr', 'data-node-name', 'Edit Fields1'); + }); }); it('should show node name and version in settings', () => { diff --git a/cypress/fixtures/Floating_Nodes.json b/cypress/fixtures/Floating_Nodes.json index 6624c53ac66..01b715e0271 100644 --- a/cypress/fixtures/Floating_Nodes.json +++ b/cypress/fixtures/Floating_Nodes.json @@ -87,6 +87,54 @@ 1600, 740 ] + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.1, + "position": [ + 440, + -140 + ], + "id": "a00959d3-8d4b-40af-b4f2-35ca3d73fd84", + "name": "Merge" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + -20, + -120 + ], + "id": "a5cbc221-ccfd-4034-a648-6a192834af81", + "name": "Edit Fields0" + }, + { + "parameters": { + "options": {} + }, + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 0, + 100 + ], + "id": "d3b4c17a-bee8-418b-a721-5debafd1ce11", + "name": "Edit Fields1" + }, + { + "parameters": {}, + "type": "n8n-nodes-base.merge", + "typeVersion": 3.1, + "position": [ + 440, + 100 + ], + "id": "b23a2a43-ffac-41a5-a265-054e21a57d70", + "name": "Merge1" } ], "pinData": {}, @@ -161,7 +209,40 @@ } ] ] + }, + "Edit Fields0": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 1 + }, + { + "node": "Merge1", + "type": "main", + "index": 0 + } + ] + ] + }, + "Edit Fields1": { + "main": [ + [ + { + "node": "Merge", + "type": "main", + "index": 0 + }, + { + "node": "Merge1", + "type": "main", + "index": 1 + } + ] + ] } + }, "active": false, "settings": { diff --git a/jest.config.js b/jest.config.js index 5c14b7800a5..e0892381419 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,5 @@ -const { compilerOptions } = require('./tsconfig.json'); +const { pathsToModuleNameMapper } = require('ts-jest') +const { compilerOptions } = require('get-tsconfig').getTsconfig().config; /** @type {import('ts-jest').TsJestGlobalOptions} */ const tsJestOptions = { @@ -10,7 +11,6 @@ const tsJestOptions = { }, }; -const { baseUrl, paths } = require('get-tsconfig').getTsconfig().config?.compilerOptions; const isCoverageEnabled = process.env.COVERAGE_ENABLED === 'true'; @@ -24,15 +24,7 @@ const config = { '^.+\\.ts$': ['ts-jest', tsJestOptions], }, // This resolve the path mappings from the tsconfig relative to each jest.config.js - moduleNameMapper: Object.entries(paths || {}).reduce((acc, [path, [mapping]]) => { - path = `^${path.replace(/\/\*$/, '/(.*)$')}`; - mapping = mapping.replace(/^\.?\.\/(?:(.*)\/)?\*$/, '$1'); - mapping = mapping ? `/${mapping}` : ''; - acc[path] = mapping.startsWith('/test') - ? '' + mapping + '/$1' - : '' + (baseUrl ? `/${baseUrl.replace(/^\.\//, '')}` : '') + mapping + '/$1'; - return acc; - }, {}), + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: `${compilerOptions.baseUrl ? `/${compilerOptions.baseUrl.replace(/^\.\//, '')}` : ''}` }), setupFilesAfterEnv: ['jest-expect-message'], collectCoverage: isCoverageEnabled, coverageReporters: ['text-summary', 'lcov', 'html-spa'], diff --git a/package.json b/package.json index 9908102a61e..f9fdc2828ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.84.0", + "version": "1.85.0", "private": true, "engines": { "node": ">=20.15", diff --git a/packages/@n8n/api-types/package.json b/packages/@n8n/api-types/package.json index 27d63519b60..bf91088a52a 100644 --- a/packages/@n8n/api-types/package.json +++ b/packages/@n8n/api-types/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/api-types", - "version": "0.19.0", + "version": "0.20.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index a71042048ed..3fd35441b8b 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -59,3 +59,5 @@ export { CreateFolderDto } from './folders/create-folder.dto'; export { UpdateFolderDto } from './folders/update-folder.dto'; export { DeleteFolderDto } from './folders/delete-folder.dto'; export { ListFolderQueryDto } from './folders/list-folder-query.dto'; + +export { ListInsightsWorkflowQueryDto } from './insights/list-workflow-query.dto'; diff --git a/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts b/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts new file mode 100644 index 00000000000..70455a56ce2 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/insights/__tests__/list-workflow-query.dto.test.ts @@ -0,0 +1,94 @@ +import { ListInsightsWorkflowQueryDto } from '../list-workflow-query.dto'; + +const DEFAULT_PAGINATION = { skip: 0, take: 10 }; + +describe('ListInsightsWorkflowQueryDto', () => { + describe('Valid requests', () => { + test.each([ + { + name: 'empty object (no filters)', + request: {}, + parsedResult: DEFAULT_PAGINATION, + }, + { + name: 'valid sortBy', + request: { + sortBy: 'total:asc', + }, + parsedResult: { + ...DEFAULT_PAGINATION, + sortBy: 'total:asc', + }, + }, + { + name: 'valid skip and take', + request: { + skip: '0', + take: '20', + }, + parsedResult: { + skip: 0, + take: 20, + }, + }, + { + name: 'full query parameters', + request: { + skip: '0', + take: '10', + sortBy: 'total:desc', + }, + parsedResult: { + skip: 0, + take: 10, + sortBy: 'total:desc', + }, + }, + ])('should validate $name', ({ request, parsedResult }) => { + const result = ListInsightsWorkflowQueryDto.safeParse(request); + expect(result.success).toBe(true); + if (parsedResult) { + expect(result.data).toMatchObject(parsedResult); + } + }); + }); + + describe('Invalid requests', () => { + test.each([ + { + name: 'invalid skip format', + request: { + skip: 'not-a-number', + take: '10', + }, + expectedErrorPath: ['skip'], + }, + { + name: 'invalid take format', + request: { + skip: '0', + take: 'not-a-number', + }, + expectedErrorPath: ['take'], + }, + { + name: 'invalid sortBy value', + request: { + sortBy: 'invalid-value', + }, + expectedErrorPath: ['sortBy'], + }, + ])('should fail validation for $name', ({ request, expectedErrorPath }) => { + const result = ListInsightsWorkflowQueryDto.safeParse(request); + + expect(result.success).toBe(false); + + if (expectedErrorPath && !result.success) { + if (Array.isArray(expectedErrorPath)) { + const errorPaths = result.error.issues[0].path; + expect(errorPaths).toContain(expectedErrorPath[0]); + } + } + }); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts new file mode 100644 index 00000000000..9ed00a6550d --- /dev/null +++ b/packages/@n8n/api-types/src/dto/insights/list-workflow-query.dto.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { Z } from 'zod-class'; + +const VALID_SORT_OPTIONS = [ + 'total:asc', + 'total:desc', + 'succeeded:asc', + 'succeeded:desc', + 'failed:asc', + 'failed:desc', + 'timeSaved:asc', + 'timeSaved:desc', + 'runTime:asc', + 'runTime:desc', + 'averageRunTime:asc', + 'averageRunTime:desc', +] as const; + +// --------------------- +// Parameter Validators +// --------------------- + +// Skip parameter validation +const skipValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 0)) + .refine((val) => !isNaN(val), { + message: 'Skip must be a valid number', + }); + +// Take parameter validation +const takeValidator = z + .string() + .optional() + .transform((val) => (val ? parseInt(val, 10) : 10)) + .refine((val) => !isNaN(val), { + message: 'Take must be a valid number', + }); + +// SortBy parameter validation +const sortByValidator = z + .enum(VALID_SORT_OPTIONS, { message: `sortBy must be one of: ${VALID_SORT_OPTIONS.join(', ')}` }) + .optional(); + +export class ListInsightsWorkflowQueryDto extends Z.class({ + skip: skipValidator, + take: takeValidator, + sortBy: sortByValidator, +}) {} diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 6b8a4d53168..c0bc13020ee 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -32,4 +32,6 @@ export type { InsightsSummaryType, InsightsSummaryUnit, InsightsSummary, + InsightsByWorkflow, + InsightsByTime, } from './schemas/insights.schema'; diff --git a/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts b/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts index 020b3df5408..2c2a8737b23 100644 --- a/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts +++ b/packages/@n8n/api-types/src/schemas/__tests__/insights.schema.test.ts @@ -1,4 +1,8 @@ -import { insightsSummarySchema } from '../insights.schema'; +import { + insightsByTimeSchema, + insightsByWorkflowSchema, + insightsSummarySchema, +} from '../insights.schema'; describe('insightsSummarySchema', () => { test.each([ @@ -62,3 +66,170 @@ describe('insightsSummarySchema', () => { expect(result.success).toBe(expected); }); }); + +describe('insightsByWorkflowSchema', () => { + test.each([ + { + name: 'valid workflow insights', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + workflowName: 'Test Workflow', + projectId: 'p1', + projectName: 'Test Project', + total: 100, + succeeded: 90, + failed: 10, + failureRate: 0.56, + runTime: 300, + averageRunTime: 30.5, + timeSaved: 50, + }, + ], + }, + expected: true, + }, + { + name: 'wrong data type', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + total: '100', + succeeded: 90, + failed: 10, + failureRate: 10, + runTime: 300, + averageRunTime: 30, + timeSaved: 50, + }, + ], + }, + expected: false, + }, + { + name: 'missing required field', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + total: 100, + succeeded: 90, + failed: 10, + failureRate: 10, + runTime: 300, + averageRunTime: 30, + }, + ], + }, + expected: false, + }, + { + name: 'unexpected key', + value: { + count: 2, + data: [ + { + workflowId: 'w1', + total: 100, + succeeded: 90, + failed: 10, + failureRate: 10, + runTime: 300, + averageRunTime: 30, + timeSaved: 50, + extraKey: 'value', + }, + ], + }, + expected: false, + }, + ])('should validate $name', ({ value, expected }) => { + const result = insightsByWorkflowSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); + +describe('insightsByTimeSchema', () => { + test.each([ + { + name: 'valid insights by time', + value: { + date: '2025-03-25T10:34:36.484Z', + values: { + total: 200, + succeeded: 180, + failed: 20, + failureRate: 10, + averageRunTime: 40, + timeSaved: 100, + }, + }, + expected: true, + }, + { + name: 'invalid date format', + value: { + date: '20240325', // Should be a string + values: { + total: 200, + succeeded: 180, + failed: 20, + failureRate: 10, + averageRunTime: 40, + timeSaved: 100, + }, + }, + expected: false, + }, + { + name: 'invalid field type', + value: { + date: 20240325, // Should be a string + values: { + total: 200, + succeeded: 180, + failed: 20, + failureRate: 10, + averageRunTime: 40, + timeSaved: 100, + }, + }, + expected: false, + }, + { + name: 'missing required key', + value: { + date: '2025-03-25T10:34:36.484Z', + values: { + total: 200, + succeeded: 180, + failed: 20, + failureRate: 10, + averageRunTime: 40, + }, + }, + expected: false, + }, + { + name: 'unexpected key', + value: { + date: '2025-03-25T10:34:36.484Z', + values: { + total: 200, + failureRate: 10, + averageRunTime: 40, + extraKey: 'value', + }, + }, + expected: false, + }, + ])('should validate $name', ({ value, expected }) => { + const result = insightsByTimeSchema.safeParse(value); + expect(result.success).toBe(expected); + }); +}); diff --git a/packages/@n8n/api-types/src/schemas/insights.schema.ts b/packages/@n8n/api-types/src/schemas/insights.schema.ts index 170a74abfe8..57f2608769f 100644 --- a/packages/@n8n/api-types/src/schemas/insights.schema.ts +++ b/packages/@n8n/api-types/src/schemas/insights.schema.ts @@ -42,3 +42,46 @@ export const insightsSummaryDataSchemas = { export const insightsSummarySchema = z.object(insightsSummaryDataSchemas).strict(); export type InsightsSummary = z.infer; + +export const insightsByWorkflowDataSchemas = { + count: z.number(), + data: z.array( + z + .object({ + workflowId: z.string(), + workflowName: z.string().optional(), + projectId: z.string().optional(), + projectName: z.string().optional(), + total: z.number(), + succeeded: z.number(), + failed: z.number(), + failureRate: z.number(), + runTime: z.number(), + averageRunTime: z.number(), + timeSaved: z.number(), + }) + .strict(), + ), +} as const; + +export const insightsByWorkflowSchema = z.object(insightsByWorkflowDataSchemas).strict(); +export type InsightsByWorkflow = z.infer; + +export const insightsByTimeDataSchemas = { + date: z.string().refine((val) => !isNaN(Date.parse(val)) && new Date(val).toISOString() === val, { + message: 'Invalid date format, must be ISO 8601 format', + }), + values: z + .object({ + total: z.number(), + succeeded: z.number(), + failed: z.number(), + failureRate: z.number(), + averageRunTime: z.number(), + timeSaved: z.number(), + }) + .strict(), +} as const; + +export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict(); +export type InsightsByTime = z.infer; diff --git a/packages/@n8n/config/package.json b/packages/@n8n/config/package.json index 48eac78ba61..87968268cba 100644 --- a/packages/@n8n/config/package.json +++ b/packages/@n8n/config/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/config", - "version": "1.32.0", + "version": "1.33.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/nodes-langchain/credentials/XAiApi.credentials.ts b/packages/@n8n/nodes-langchain/credentials/XAiApi.credentials.ts new file mode 100644 index 00000000000..74878901841 --- /dev/null +++ b/packages/@n8n/nodes-langchain/credentials/XAiApi.credentials.ts @@ -0,0 +1,47 @@ +import type { + IAuthenticateGeneric, + ICredentialTestRequest, + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class XAiApi implements ICredentialType { + name = 'xAiApi'; + + displayName = 'xAi'; + + documentationUrl = 'xAi'; + + properties: INodeProperties[] = [ + { + displayName: 'API Key', + name: 'apiKey', + type: 'string', + typeOptions: { password: true }, + required: true, + default: '', + }, + { + displayName: 'Base URL', + name: 'url', + type: 'hidden', + default: 'https://api.x.ai/v1', + }, + ]; + + authenticate: IAuthenticateGeneric = { + type: 'generic', + properties: { + headers: { + Authorization: '=Bearer {{$credentials.apiKey}}', + }, + }, + }; + + test: ICredentialTestRequest = { + request: { + baseURL: '={{ $credentials.url }}', + url: '/models', + }, + }; +} diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts index 2830ca02c30..67a67528dbc 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/Agent.node.ts @@ -100,6 +100,7 @@ function getInputs( '@n8n/n8n-nodes-langchain.lmChatAzureOpenAi', '@n8n/n8n-nodes-langchain.lmChatDeepSeek', '@n8n/n8n-nodes-langchain.lmChatOpenRouter', + '@n8n/n8n-nodes-langchain.lmChatXAiGrok', ], }, }, @@ -130,6 +131,7 @@ function getInputs( '@n8n/n8n-nodes-langchain.lmChatGoogleGemini', '@n8n/n8n-nodes-langchain.lmChatDeepSeek', '@n8n/n8n-nodes-langchain.lmChatOpenRouter', + '@n8n/n8n-nodes-langchain.lmChatXAiGrok', ], }, }, diff --git a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts index 577634ca98f..ac232eeee46 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/SentimentAnalysis/SentimentAnalysis.node.ts @@ -24,7 +24,7 @@ const configuredOutputs = (parameters: INodeParameters, defaultCategories: strin const categories = (options?.categories as string) ?? defaultCategories; const categoriesArray = categories.split(',').map((cat) => cat.trim()); - const ret = categoriesArray.map((cat) => ({ type: NodeConnectionTypes.Main, displayName: cat })); + const ret = categoriesArray.map((cat) => ({ type: 'main', displayName: cat })); return ret; }; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts index 8cc241c294e..bfbaa4c635f 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/TextClassifier/TextClassifier.node.ts @@ -22,9 +22,9 @@ const configuredOutputs = (parameters: INodeParameters) => { const categories = ((parameters.categories as IDataObject)?.categories as IDataObject[]) ?? []; const fallback = (parameters.options as IDataObject)?.fallback as string; const ret = categories.map((cat) => { - return { type: NodeConnectionTypes.Main, displayName: cat.category }; + return { type: 'main', displayName: cat.category }; }); - if (fallback === 'other') ret.push({ type: NodeConnectionTypes.Main, displayName: 'Other' }); + if (fallback === 'other') ret.push({ type: 'main', displayName: 'Other' }); return ret; }; diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts new file mode 100644 index 00000000000..1926f013335 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.ts @@ -0,0 +1,253 @@ +/* eslint-disable n8n-nodes-base/node-dirname-against-convention */ + +import { ChatOpenAI, type ClientOptions } from '@langchain/openai'; +import { + NodeConnectionTypes, + type INodeType, + type INodeTypeDescription, + type ISupplyDataFunctions, + type SupplyData, +} from 'n8n-workflow'; + +import { getConnectionHintNoticeField } from '@utils/sharedFields'; + +import { openAiFailedAttemptHandler } from '../../vendors/OpenAi/helpers/error-handling'; +import { makeN8nLlmFailedAttemptHandler } from '../n8nLlmFailedAttemptHandler'; +import { N8nLlmTracing } from '../N8nLlmTracing'; + +export class LmChatXAiGrok implements INodeType { + description: INodeTypeDescription = { + displayName: 'xAI Grok Chat Model', + // eslint-disable-next-line n8n-nodes-base/node-class-description-name-miscased + name: 'lmChatXAiGrok', + icon: { light: 'file:logo.dark.svg', dark: 'file:logo.svg' }, + group: ['transform'], + version: [1], + description: 'For advanced usage with an AI chain', + defaults: { + name: 'xAI Grok Chat Model', + }, + codex: { + categories: ['AI'], + subcategories: { + AI: ['Language Models', 'Root Nodes'], + 'Language Models': ['Chat Models (Recommended)'], + }, + resources: { + primaryDocumentation: [ + { + url: 'https://docs.n8n.io/integrations/builtin/cluster-nodes/sub-nodes/n8n-nodes-langchain.lmchatxaigrok/', + }, + ], + }, + }, + // eslint-disable-next-line n8n-nodes-base/node-class-description-inputs-wrong-regular-node + inputs: [], + // eslint-disable-next-line n8n-nodes-base/node-class-description-outputs-wrong + outputs: [NodeConnectionTypes.AiLanguageModel], + outputNames: ['Model'], + credentials: [ + { + name: 'xAiApi', + required: true, + }, + ], + requestDefaults: { + ignoreHttpStatusErrors: true, + baseURL: '={{ $credentials?.url }}', + }, + properties: [ + getConnectionHintNoticeField([NodeConnectionTypes.AiChain, NodeConnectionTypes.AiAgent]), + { + displayName: + 'If using JSON response format, you must include word "json" in the prompt in your chain or agent. Also, make sure to select latest models released post November 2023.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + '/options.responseFormat': ['json_object'], + }, + }, + }, + { + displayName: 'Model', + name: 'model', + type: 'options', + description: + 'The model which will generate the completion. Learn more.', + typeOptions: { + loadOptions: { + routing: { + request: { + method: 'GET', + url: '/models', + }, + output: { + postReceive: [ + { + type: 'rootProperty', + properties: { + property: 'data', + }, + }, + { + type: 'setKeyValue', + properties: { + name: '={{$responseItem.id}}', + value: '={{$responseItem.id}}', + }, + }, + { + type: 'sort', + properties: { + key: 'name', + }, + }, + ], + }, + }, + }, + }, + routing: { + send: { + type: 'body', + property: 'model', + }, + }, + default: 'grok-2-vision-1212', + }, + { + displayName: 'Options', + name: 'options', + placeholder: 'Add Option', + description: 'Additional options to add', + type: 'collection', + default: {}, + options: [ + { + displayName: 'Frequency Penalty', + name: 'frequencyPenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on their existing frequency in the text so far, decreasing the model's likelihood to repeat the same line verbatim", + type: 'number', + }, + { + displayName: 'Maximum Number of Tokens', + name: 'maxTokens', + default: -1, + description: + 'The maximum number of tokens to generate in the completion. Most models have a context length of 2048 tokens (except for the newest models, which support 32,768).', + type: 'number', + typeOptions: { + maxValue: 32768, + }, + }, + { + displayName: 'Response Format', + name: 'responseFormat', + default: 'text', + type: 'options', + options: [ + { + name: 'Text', + value: 'text', + description: 'Regular text response', + }, + { + name: 'JSON', + value: 'json_object', + description: + 'Enables JSON mode, which should guarantee the message the model generates is valid JSON', + }, + ], + }, + { + displayName: 'Presence Penalty', + name: 'presencePenalty', + default: 0, + typeOptions: { maxValue: 2, minValue: -2, numberPrecision: 1 }, + description: + "Positive values penalize new tokens based on whether they appear in the text so far, increasing the model's likelihood to talk about new topics", + type: 'number', + }, + { + displayName: 'Sampling Temperature', + name: 'temperature', + default: 0.7, + typeOptions: { maxValue: 2, minValue: 0, numberPrecision: 1 }, + description: + 'Controls randomness: Lowering results in less random completions. As the temperature approaches zero, the model will become deterministic and repetitive.', + type: 'number', + }, + { + displayName: 'Timeout', + name: 'timeout', + default: 360000, + description: 'Maximum amount of time a request is allowed to take in milliseconds', + type: 'number', + }, + { + displayName: 'Max Retries', + name: 'maxRetries', + default: 2, + description: 'Maximum number of retries to attempt', + type: 'number', + }, + { + displayName: 'Top P', + name: 'topP', + default: 1, + typeOptions: { maxValue: 1, minValue: 0, numberPrecision: 1 }, + description: + 'Controls diversity via nucleus sampling: 0.5 means half of all likelihood-weighted options are considered. We generally recommend altering this or temperature but not both.', + type: 'number', + }, + ], + }, + ], + }; + + async supplyData(this: ISupplyDataFunctions, itemIndex: number): Promise { + const credentials = await this.getCredentials('xAiApi'); + + const modelName = this.getNodeParameter('model', itemIndex) as string; + + const options = this.getNodeParameter('options', itemIndex, {}) as { + frequencyPenalty?: number; + maxTokens?: number; + maxRetries: number; + timeout: number; + presencePenalty?: number; + temperature?: number; + topP?: number; + responseFormat?: 'text' | 'json_object'; + }; + + const configuration: ClientOptions = { + baseURL: credentials.url, + }; + + const model = new ChatOpenAI({ + openAIApiKey: credentials.apiKey, + modelName, + ...options, + timeout: options.timeout ?? 60000, + maxRetries: options.maxRetries ?? 2, + configuration, + callbacks: [new N8nLlmTracing(this)], + modelKwargs: options.responseFormat + ? { + response_format: { type: options.responseFormat }, + } + : undefined, + onFailedAttempt: makeN8nLlmFailedAttemptHandler(this, openAiFailedAttemptHandler), + }); + + return { + response: model, + }; + } +} diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/logo.dark.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/logo.dark.svg new file mode 100644 index 00000000000..53f973030f4 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/logo.dark.svg @@ -0,0 +1 @@ + diff --git a/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/logo.svg b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/logo.svg new file mode 100644 index 00000000000..58bdc2cbb35 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/llms/LmChatXAiGrok/logo.svg @@ -0,0 +1 @@ + diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts index 64d2c105971..00263163a6f 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/versionDescription.ts @@ -50,25 +50,22 @@ const configureNodeInputs = ( ) => { if (resource === 'assistant' && operation === 'message') { const inputs: INodeInputConfiguration[] = [ - { type: NodeConnectionTypes.Main }, - { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, + { type: 'main' }, + { type: 'ai_tool', displayName: 'Tools' }, ]; if (memory !== 'threadId') { - inputs.push({ type: NodeConnectionTypes.AiMemory, displayName: 'Memory', maxConnections: 1 }); + inputs.push({ type: 'ai_memory', displayName: 'Memory', maxConnections: 1 }); } return inputs; } if (resource === 'text' && operation === 'message') { if (hideTools === 'hide') { - return [NodeConnectionTypes.Main]; + return ['main']; } - return [ - { type: NodeConnectionTypes.Main }, - { type: NodeConnectionTypes.AiTool, displayName: 'Tools' }, - ]; + return [{ type: 'main' }, { type: 'ai_tool', displayName: 'Tools' }]; } - return [NodeConnectionTypes.Main]; + return ['main']; }; // eslint-disable-next-line n8n-nodes-base/node-class-description-missing-subtitle diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index 1f51f6ac7aa..00a8e9862b9 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.84.0", + "version": "1.85.0", "description": "", "main": "index.js", "scripts": { @@ -37,6 +37,7 @@ "dist/credentials/QdrantApi.credentials.js", "dist/credentials/SerpApi.credentials.js", "dist/credentials/WolframAlphaApi.credentials.js", + "dist/credentials/XAiApi.credentials.js", "dist/credentials/XataApi.credentials.js", "dist/credentials/ZepApi.credentials.js" ], @@ -73,6 +74,7 @@ "dist/nodes/llms/LmChatMistralCloud/LmChatMistralCloud.node.js", "dist/nodes/llms/LMChatOllama/LmChatOllama.node.js", "dist/nodes/llms/LmChatOpenRouter/LmChatOpenRouter.node.js", + "dist/nodes/llms/LmChatXAiGrok/LmChatXAiGrok.node.js", "dist/nodes/llms/LMChatOpenAi/LmChatOpenAi.node.js", "dist/nodes/llms/LMOpenAi/LmOpenAi.node.js", "dist/nodes/llms/LMCohere/LmCohere.node.js", diff --git a/packages/@n8n/permissions/package.json b/packages/@n8n/permissions/package.json index d96e13234cd..021bfc00930 100644 --- a/packages/@n8n/permissions/package.json +++ b/packages/@n8n/permissions/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/permissions", - "version": "0.20.0", + "version": "0.21.0", "scripts": { "clean": "rimraf dist .turbo", "dev": "pnpm watch", diff --git a/packages/@n8n/task-runner/package.json b/packages/@n8n/task-runner/package.json index 87ab8c0015a..d521acfb799 100644 --- a/packages/@n8n/task-runner/package.json +++ b/packages/@n8n/task-runner/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/task-runner", - "version": "1.21.0", + "version": "1.22.0", "scripts": { "clean": "rimraf dist .turbo", "start": "node dist/start.js", diff --git a/packages/@n8n/utils/package.json b/packages/@n8n/utils/package.json index a95ae83ac83..aee911b2058 100644 --- a/packages/@n8n/utils/package.json +++ b/packages/@n8n/utils/package.json @@ -1,7 +1,7 @@ { "name": "@n8n/utils", "type": "module", - "version": "1.4.0", + "version": "1.5.0", "files": [ "dist" ], diff --git a/packages/cli/package.json b/packages/cli/package.json index 12fa4323532..88d4c2f07e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.84.0", + "version": "1.85.0", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index fe662c9ddf3..dc557dec767 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -195,3 +195,5 @@ export const WsStatusCodes = { } as const; export const FREE_AI_CREDITS_CREDENTIAL_NAME = 'n8n free OpenAI API credits'; + +export const EVALUATION_METRICS_NODE = `${NODE_PACKAGE_PREFIX}base.evaluationMetrics`; diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 1066aa059c6..aab1044abf3 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -33,9 +33,9 @@ import { WorkflowEntity } from './workflow-entity'; import { WorkflowHistory } from './workflow-history'; import { WorkflowStatistics } from './workflow-statistics'; import { WorkflowTagMapping } from './workflow-tag-mapping'; -import { InsightsByPeriod } from '../../modules/insights/entities/insights-by-period'; -import { InsightsMetadata } from '../../modules/insights/entities/insights-metadata'; -import { InsightsRaw } from '../../modules/insights/entities/insights-raw'; +import { InsightsByPeriod } from '../../modules/insights/database/entities/insights-by-period'; +import { InsightsMetadata } from '../../modules/insights/database/entities/insights-metadata'; +import { InsightsRaw } from '../../modules/insights/database/entities/insights-raw'; export const entities = { AnnotationTagEntity, diff --git a/packages/cli/src/evaluation.ee/metrics.controller.ts b/packages/cli/src/evaluation.ee/metrics.controller.ts deleted file mode 100644 index 5d279311666..00000000000 --- a/packages/cli/src/evaluation.ee/metrics.controller.ts +++ /dev/null @@ -1,141 +0,0 @@ -import express from 'express'; - -import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; -import { Delete, Get, Patch, Post, RestController } from '@/decorators'; -import { NotFoundError } from '@/errors/response-errors/not-found.error'; -import { - testMetricCreateRequestBodySchema, - testMetricPatchRequestBodySchema, -} from '@/evaluation.ee/metric.schema'; -import { getSharedWorkflowIds } from '@/public-api/v1/handlers/workflows/workflows.service'; -import { Telemetry } from '@/telemetry'; - -import { TestDefinitionService } from './test-definition.service.ee'; -import { TestMetricsRequest } from './test-definitions.types.ee'; - -@RestController('/evaluation/test-definitions') -export class TestMetricsController { - constructor( - private readonly testDefinitionService: TestDefinitionService, - private readonly testMetricRepository: TestMetricRepository, - private readonly telemetry: Telemetry, - ) {} - - // This method is used in multiple places in the controller to get the test definition - // (or just check that it exists and the user has access to it). - private async getTestDefinition( - req: - | TestMetricsRequest.GetOne - | TestMetricsRequest.GetMany - | TestMetricsRequest.Patch - | TestMetricsRequest.Delete - | TestMetricsRequest.Create, - ) { - const { testDefinitionId } = req.params; - - const userAccessibleWorkflowIds = await getSharedWorkflowIds(req.user, ['workflow:read']); - - const testDefinition = await this.testDefinitionService.findOne( - testDefinitionId, - userAccessibleWorkflowIds, - ); - - if (!testDefinition) throw new NotFoundError('Test definition not found'); - - return testDefinition; - } - - @Get('/:testDefinitionId/metrics') - async getMany(req: TestMetricsRequest.GetMany) { - const { testDefinitionId } = req.params; - - await this.getTestDefinition(req); - - return await this.testMetricRepository.find({ - where: { testDefinition: { id: testDefinitionId } }, - }); - } - - @Get('/:testDefinitionId/metrics/:id') - async getOne(req: TestMetricsRequest.GetOne) { - const { id: metricId, testDefinitionId } = req.params; - - await this.getTestDefinition(req); - - const metric = await this.testMetricRepository.findOne({ - where: { id: metricId, testDefinition: { id: testDefinitionId } }, - }); - - if (!metric) throw new NotFoundError('Metric not found'); - - return metric; - } - - @Post('/:testDefinitionId/metrics') - async create(req: TestMetricsRequest.Create, res: express.Response) { - const bodyParseResult = testMetricCreateRequestBodySchema.safeParse(req.body); - if (!bodyParseResult.success) { - res.status(400).json({ errors: bodyParseResult.error.errors }); - return; - } - - const testDefinition = await this.getTestDefinition(req); - - const metric = this.testMetricRepository.create({ - ...req.body, - testDefinition, - }); - - return await this.testMetricRepository.save(metric); - } - - @Patch('/:testDefinitionId/metrics/:id') - async patch(req: TestMetricsRequest.Patch, res: express.Response) { - const { id: metricId, testDefinitionId } = req.params; - - const bodyParseResult = testMetricPatchRequestBodySchema.safeParse(req.body); - if (!bodyParseResult.success) { - res.status(400).json({ errors: bodyParseResult.error.errors }); - return; - } - - await this.getTestDefinition(req); - - const metric = await this.testMetricRepository.findOne({ - where: { id: metricId, testDefinition: { id: testDefinitionId } }, - }); - - if (!metric) throw new NotFoundError('Metric not found'); - - const updateResult = await this.testMetricRepository.update(metricId, bodyParseResult.data); - - // Send telemetry event if the metric was updated - if (updateResult.affected === 1 && metric.name !== bodyParseResult.data.name) { - this.telemetry.track('User added metrics to test', { - metric_id: metricId, - metric_name: bodyParseResult.data.name, - test_id: testDefinitionId, - }); - } - - // Respond with the updated metric - return await this.testMetricRepository.findOneBy({ id: metricId }); - } - - @Delete('/:testDefinitionId/metrics/:id') - async delete(req: TestMetricsRequest.Delete) { - const { id: metricId, testDefinitionId } = req.params; - - await this.getTestDefinition(req); - - const metric = await this.testMetricRepository.findOne({ - where: { id: metricId, testDefinition: { id: testDefinitionId } }, - }); - - if (!metric) throw new NotFoundError('Metric not found'); - - await this.testMetricRepository.delete(metricId); - - return { success: true }; - } -} diff --git a/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts b/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts index 98feea7e5d8..eb19f964f7c 100644 --- a/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts +++ b/packages/cli/src/evaluation.ee/test-definitions.types.ee.ts @@ -47,36 +47,6 @@ export declare namespace TestDefinitionsRequest { >; } -// ---------------------------------- -// /test-definitions/:testDefinitionId/metrics -// ---------------------------------- - -export declare namespace TestMetricsRequest { - namespace RouteParams { - type TestDefinitionId = { - testDefinitionId: string; - }; - - type TestMetricId = { - id: string; - }; - } - - type GetOne = AuthenticatedRequest; - - type GetMany = AuthenticatedRequest; - - type Create = AuthenticatedRequest; - - type Patch = AuthenticatedRequest< - RouteParams.TestDefinitionId & RouteParams.TestMetricId, - {}, - { name: string } - >; - - type Delete = AuthenticatedRequest; -} - // ---------------------------------- // /test-definitions/:testDefinitionId/runs // ---------------------------------- diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts index d7bb9ec910d..d9ddbde162a 100644 --- a/packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/evaluation-metrics.ee.test.ts @@ -24,14 +24,6 @@ describe('EvaluationMetrics', () => { ); }); - test('should throw when missing values', () => { - const testMetricNames = new Set(['metric1', 'metric2']); - const metrics = new EvaluationMetrics(testMetricNames); - - expect(() => metrics.addResults({ metric1: 1 })).toThrow('METRICS_MISSING'); - expect(() => metrics.addResults({ metric2: 0.2 })).toThrow('METRICS_MISSING'); - }); - test('should handle empty metrics', () => { const testMetricNames = new Set(['metric1', 'metric2']); const metrics = new EvaluationMetrics(testMetricNames); diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation-middle.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation-middle.json new file mode 100644 index 00000000000..ba203bbac53 --- /dev/null +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation-middle.json @@ -0,0 +1,85 @@ +{ + "nodes": [ + { + "parameters": {}, + "id": "6dde1608-135f-441d-8438-40f605e4dae3", + "name": "Execute Workflow Trigger", + "type": "n8n-nodes-base.executeWorkflowTrigger", + "typeVersion": 1, + "position": [-180, -40] + }, + { + "parameters": { + "metrics": { + "assignments": [ + { + "id": "f1cab1e4-dabc-4750-a1f7-6669a60213c9", + "name": "metric1", + "value": 20, + "type": "number" + }, + { + "id": "a66ed953-5341-47f6-8565-47640d987f5f", + "name": "metric2", + "value": 30, + "type": "number" + } + ] + } + }, + "id": "dfc71b70-7b7a-4dde-914f-cc4ffc99b18c", + "name": "Success", + "type": "n8n-nodes-base.evaluationMetrics", + "typeVersion": 1, + "position": [620, -40] + }, + { + "parameters": { + "metrics": { + "assignments": [ + { + "id": "1e1153da-77c0-4cb5-804a-3f4bc40833dd", + "name": "metric2", + "value": 10, + "type": "number" + } + ] + } + }, + "id": "5ef5be33-37e0-4c95-81c8-3fd677bdca88", + "name": "First Metric", + "type": "n8n-nodes-base.evaluationMetrics", + "typeVersion": 1, + "position": [160, -40] + } + ], + "connections": { + "Execute Workflow Trigger": { + "main": [ + [ + { + "node": "First Metric", + "type": "main", + "index": 0 + } + ] + ] + }, + "First Metric": { + "main": [ + [ + { + "node": "Success", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "pinData": {}, + "meta": { + "templateCredsSetupCompleted": true, + "instanceId": "27cc9b56542ad45b38725555722c50a1c3fee1670bbb67980558314ee08517c4" + } +} diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json index 6ec7f2c3861..4db9e2bd6dc 100644 --- a/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/mock-data/workflow.evaluation.json @@ -50,7 +50,7 @@ }, { "parameters": { - "assignments": { + "metrics": { "assignments": [ { "id": "3b65d55a-158f-40c6-9853-a1c44b7ba1e5", @@ -70,13 +70,13 @@ }, "id": "0c7a1ee8-0cf0-4d7f-99a3-186bbcd8815a", "name": "Success", - "type": "n8n-nodes-base.set", - "typeVersion": 3.4, + "type": "n8n-nodes-base.evaluationMetrics", + "typeVersion": 1, "position": [980, 220] }, { "parameters": { - "assignments": { + "metrics": { "assignments": [ { "id": "6cc8b402-4a30-4873-b825-963a1f1b8b82", @@ -90,8 +90,8 @@ }, "id": "50d3f84a-d99f-4e04-bdbd-3e8c2668e708", "name": "Fail", - "type": "n8n-nodes-base.set", - "typeVersion": 3.4, + "type": "n8n-nodes-base.evaluationMetrics", + "typeVersion": 1, "position": [980, 420] } ], diff --git a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts index d91a2d0ee75..8d61fcb9019 100644 --- a/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts +++ b/packages/cli/src/evaluation.ee/test-runner/__tests__/test-runner.service.ee.test.ts @@ -46,6 +46,12 @@ const wfEvaluationJson = JSON.parse( readFileSync(path.join(__dirname, './mock-data/workflow.evaluation.json'), { encoding: 'utf-8' }), ); +const wfEvaluationMiddleJson = JSON.parse( + readFileSync(path.join(__dirname, './mock-data/workflow.evaluation-middle.json'), { + encoding: 'utf-8', + }), +); + const wfMultipleTriggersJson = JSON.parse( readFileSync(path.join(__dirname, './mock-data/workflow.multiple-triggers.json'), { encoding: 'utf-8', @@ -131,9 +137,22 @@ function mockEvaluationExecutionData(metrics: Record) { return mock({ data: { resultData: { - lastNodeExecuted: 'lastNode', + lastNodeExecuted: 'Success', runData: { - lastNode: [ + Success: [ + { + data: { + main: [ + [ + { + json: metrics, + }, + ], + ], + }, + }, + ], + Fail: [ { data: { main: [ @@ -155,6 +174,52 @@ function mockEvaluationExecutionData(metrics: Record) { }); } +function mockEvaluationMiddleExecutionData( + firstMetrics: Record, + secondMetrics: Record, +) { + // Clone the metrics to avoid modifying the passed object + // For test assertions, these run-data need special handling + const runData: Record = { + 'First Metric': [ + { + data: { + main: [ + [ + { + json: firstMetrics, + }, + ], + ], + }, + }, + ], + Success: [ + { + data: { + main: [ + [ + { + json: secondMetrics, + }, + ], + ], + }, + }, + ], + }; + + return mock({ + data: { + resultData: { + lastNodeExecuted: 'Success', + runData, + error: undefined, + }, + }, + }); +} + const errorReporter = mock(); const logger = mockLogger(); const telemetry = mock(); @@ -363,7 +428,6 @@ describe('TestRunnerService', () => { expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1); expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', { metric1: 0.75, - metric2: 50, }); expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2); @@ -868,6 +932,218 @@ describe('TestRunnerService', () => { expect(workflowRunner.run).toHaveBeenCalledTimes(1); }); + test('should run workflow with metrics defined in the middle of the workflow', async () => { + const testRunnerService = new TestRunnerService( + logger, + telemetry, + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + testCaseExecutionRepository, + testMetricRepository, + mockNodeTypes, + errorReporter, + ); + + workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ + id: 'workflow-under-test-id', + ...wfUnderTestJson, + }); + + workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({ + id: 'evaluation-workflow-id', + ...wfEvaluationMiddleJson, + }); + + workflowRunner.run.mockResolvedValueOnce('some-execution-id'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-2'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-3'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-4'); + + // Mock executions of workflow under test + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id') + .mockResolvedValue(mockExecutionData()); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-3') + .mockResolvedValue(mockExecutionData()); + + // Mock executions of evaluation workflow + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-2') + .mockResolvedValue(mockEvaluationMiddleExecutionData({ metric2: 1 }, { metric1: 1 })); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-4') + .mockResolvedValue(mockEvaluationMiddleExecutionData({ metric2: 2 }, { metric1: 0.5 })); + + await testRunnerService.runTest( + mock(), + mock({ + workflowId: 'workflow-under-test-id', + evaluationWorkflowId: 'evaluation-workflow-id', + mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }], + }), + ); + + expect(workflowRunner.run).toHaveBeenCalledTimes(4); + + // Check workflow under test was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'evaluation', + pinData: { + 'When clicking ‘Test workflow’': + executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0], + }, + workflowData: expect.objectContaining({ + id: 'workflow-under-test-id', + }), + }), + ); + + // Check evaluation workflow was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'integrated', + executionData: expect.objectContaining({ + executionData: expect.objectContaining({ + nodeExecutionStack: expect.arrayContaining([ + expect.objectContaining({ data: expect.anything() }), + ]), + }), + }), + workflowData: expect.objectContaining({ + id: 'evaluation-workflow-id', + }), + }), + ); + + // Check Test Run status was updated correctly + expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number)); + expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', { + metric1: 0.75, + metric2: 1.5, + }); + + expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2); + expect(testRunRepository.incrementFailed).not.toHaveBeenCalled(); + }); + + test('should properly override metrics from earlier nodes with later ones', async () => { + const testRunnerService = new TestRunnerService( + logger, + telemetry, + workflowRepository, + workflowRunner, + executionRepository, + activeExecutions, + testRunRepository, + testCaseExecutionRepository, + testMetricRepository, + mockNodeTypes, + errorReporter, + ); + + workflowRepository.findById.calledWith('workflow-under-test-id').mockResolvedValueOnce({ + id: 'workflow-under-test-id', + ...wfUnderTestJson, + }); + + workflowRepository.findById.calledWith('evaluation-workflow-id').mockResolvedValueOnce({ + id: 'evaluation-workflow-id', + ...wfEvaluationMiddleJson, + }); + + workflowRunner.run.mockResolvedValueOnce('some-execution-id'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-2'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-3'); + workflowRunner.run.mockResolvedValueOnce('some-execution-id-4'); + + // Mock executions of workflow under test + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id') + .mockResolvedValue(mockExecutionData()); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-3') + .mockResolvedValue(mockExecutionData()); + + // Mock executions of evaluation workflow + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-2') + .mockResolvedValue( + mockEvaluationMiddleExecutionData({ metric2: 5 }, { metric1: 1, metric2: 5 }), + ); + + activeExecutions.getPostExecutePromise + .calledWith('some-execution-id-4') + .mockResolvedValue( + mockEvaluationMiddleExecutionData({ metric2: 10 }, { metric1: 0.5, metric2: 10 }), + ); + + await testRunnerService.runTest( + mock(), + mock({ + workflowId: 'workflow-under-test-id', + evaluationWorkflowId: 'evaluation-workflow-id', + mockedNodes: [{ id: '72256d90-3a67-4e29-b032-47df4e5768af' }], + }), + ); + + expect(workflowRunner.run).toHaveBeenCalledTimes(4); + + // Check workflow under test was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'evaluation', + pinData: { + 'When clicking ‘Test workflow’': + executionDataJson.resultData.runData['When clicking ‘Test workflow’'][0].data.main[0], + }, + workflowData: expect.objectContaining({ + id: 'workflow-under-test-id', + }), + }), + ); + + // Check evaluation workflow was executed + expect(workflowRunner.run).toHaveBeenCalledWith( + expect.objectContaining({ + executionMode: 'integrated', + executionData: expect.objectContaining({ + executionData: expect.objectContaining({ + nodeExecutionStack: expect.arrayContaining([ + expect.objectContaining({ data: expect.anything() }), + ]), + }), + }), + workflowData: expect.objectContaining({ + id: 'evaluation-workflow-id', + }), + }), + ); + + // Check Test Run status was updated correctly + expect(testRunRepository.createTestRun).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsRunning).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsRunning).toHaveBeenCalledWith('test-run-id', expect.any(Number)); + expect(testRunRepository.markAsCompleted).toHaveBeenCalledTimes(1); + expect(testRunRepository.markAsCompleted).toHaveBeenCalledWith('test-run-id', { + metric1: 0.75, + metric2: 7.5, + }); + + expect(testRunRepository.incrementPassed).toHaveBeenCalledTimes(2); + expect(testRunRepository.incrementFailed).not.toHaveBeenCalled(); + }); + describe('Test Run cancellation', () => { beforeAll(() => { jest.useFakeTimers(); diff --git a/packages/cli/src/evaluation.ee/test-runner/errors.ee.ts b/packages/cli/src/evaluation.ee/test-runner/errors.ee.ts index 0b2df294b2c..bd11ba479ee 100644 --- a/packages/cli/src/evaluation.ee/test-runner/errors.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/errors.ee.ts @@ -6,8 +6,6 @@ export type TestCaseExecutionErrorCode = | 'FAILED_TO_EXECUTE_WORKFLOW' | 'EVALUATION_WORKFLOW_DOES_NOT_EXIST' | 'FAILED_TO_EXECUTE_EVALUATION_WORKFLOW' - | 'METRICS_MISSING' - | 'UNKNOWN_METRICS' | 'INVALID_METRICS' | 'PAYLOAD_LIMIT_EXCEEDED' | 'UNKNOWN_ERROR'; diff --git a/packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts b/packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts index b2422f4b6b7..1e063e262a5 100644 --- a/packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/evaluation-metrics.ee.ts @@ -1,4 +1,3 @@ -import difference from 'lodash/difference'; import type { IDataObject } from 'n8n-workflow'; import { TestCaseExecutionError } from '@/evaluation.ee/test-runner/errors.ee'; @@ -43,16 +42,6 @@ export class EvaluationMetrics { } } - // Check that result contains all expected metrics - if ( - difference(Array.from(this.metricNames), Object.keys(addResultsInfo.addedMetrics)).length > 0 - ) { - throw new TestCaseExecutionError('METRICS_MISSING', { - expectedMetrics: Array.from(this.metricNames).sort(), - receivedMetrics: Object.keys(addResultsInfo.addedMetrics).sort(), - }); - } - return addResultsInfo; } diff --git a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts index 628349bf85a..c987523a7d1 100644 --- a/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts +++ b/packages/cli/src/evaluation.ee/test-runner/test-runner.service.ee.ts @@ -1,8 +1,10 @@ import { Service } from '@n8n/di'; import { parse } from 'flatted'; +import difference from 'lodash/difference'; import { ErrorReporter, Logger } from 'n8n-core'; import { ExecutionCancelledError, NodeConnectionTypes, Workflow } from 'n8n-workflow'; import type { + AssignmentCollectionValue, IDataObject, IRun, IRunExecutionData, @@ -13,6 +15,7 @@ import assert from 'node:assert'; import { ActiveExecutions } from '@/active-executions'; import config from '@/config'; +import { EVALUATION_METRICS_NODE } from '@/constants'; import type { ExecutionEntity } from '@/databases/entities/execution-entity'; import type { MockedNodeItem, TestDefinition } from '@/databases/entities/test-definition.ee'; import type { TestRun } from '@/databases/entities/test-run.ee'; @@ -225,6 +228,50 @@ export class TestRunnerService { return await executePromise; } + /** + * Sync the metrics of the test definition with the evaluation workflow. + */ + async syncMetrics( + testDefinitionId: string, + evaluationWorkflow: IWorkflowBase, + ): Promise> { + const usedTestMetricNames = await this.getUsedTestMetricNames(evaluationWorkflow); + const existingTestMetrics = await this.testMetricRepository.find({ + where: { + testDefinition: { id: testDefinitionId }, + }, + }); + + const existingMetricNames = new Set(existingTestMetrics.map((metric) => metric.name)); + const metricsToAdd = difference( + Array.from(usedTestMetricNames), + Array.from(existingMetricNames), + ); + const metricsToRemove = difference( + Array.from(existingMetricNames), + Array.from(usedTestMetricNames), + ); + + // Add new metrics + const metricsToAddEntities = metricsToAdd.map((metricName) => + this.testMetricRepository.create({ + name: metricName, + testDefinition: { id: testDefinitionId }, + }), + ); + await this.testMetricRepository.save(metricsToAddEntities); + + // Remove no longer used metrics + metricsToRemove.forEach(async (metricName) => { + const metric = existingTestMetrics.find((m) => m.name === metricName); + assert(metric, 'Existing metric not found'); + + await this.testMetricRepository.delete(metric.id); + }); + + return usedTestMetricNames; + } + /** * Run the evaluation workflow with the expected and actual run data. */ @@ -265,35 +312,45 @@ export class TestRunnerService { return await executePromise; } + /** + * Get the evaluation metrics nodes from a workflow. + */ + static getEvaluationMetricsNodes(workflow: IWorkflowBase) { + return workflow.nodes.filter((node) => node.type === EVALUATION_METRICS_NODE); + } + /** * Evaluation result is the first item in the output of the last node * executed in the evaluation workflow. Defaults to an empty object * in case the node doesn't produce any output items. */ - private extractEvaluationResult(execution: IRun): IDataObject { + private extractEvaluationResult(execution: IRun, evaluationWorkflow: IWorkflowBase): IDataObject { const lastNodeExecuted = execution.data.resultData.lastNodeExecuted; assert(lastNodeExecuted, 'Could not find the last node executed in evaluation workflow'); + const metricsNodes = TestRunnerService.getEvaluationMetricsNodes(evaluationWorkflow); + const metricsRunData = metricsNodes.flatMap( + (node) => execution.data.resultData.runData[node.name], + ); + const metricsData = metricsRunData.reverse().map((data) => data.data?.main?.[0]?.[0]?.json); + const metricsResult = metricsData.reduce((acc, curr) => ({ ...acc, ...curr }), {}) ?? {}; - // Extract the output of the last node executed in the evaluation workflow - // We use only the first item of a first main output - const lastNodeTaskData = execution.data.resultData.runData[lastNodeExecuted]?.[0]; - const mainConnectionData = lastNodeTaskData?.data?.main?.[0]; - return mainConnectionData?.[0]?.json ?? {}; + return metricsResult; } /** * Get the metrics to collect from the evaluation workflow execution results. */ - private async getTestMetricNames(testDefinitionId: string) { - const metrics = await this.testMetricRepository.find({ - where: { - testDefinition: { - id: testDefinitionId, - }, - }, + private async getUsedTestMetricNames(evaluationWorkflow: IWorkflowBase) { + const metricsNodes = TestRunnerService.getEvaluationMetricsNodes(evaluationWorkflow); + const metrics = metricsNodes.map((node) => { + const metricsParameter = node.parameters?.metrics as AssignmentCollectionValue; + assert(metricsParameter, 'Metrics parameter not found'); + + const metricsNames = metricsParameter.assignments.map((assignment) => assignment.name); + return metricsNames; }); - return new Set(metrics.map((m) => m.name)); + return new Set(metrics.flat()); } /** @@ -329,7 +386,6 @@ export class TestRunnerService { if (!evaluationWorkflow) { throw new TestRunError('EVALUATION_WORKFLOW_NOT_FOUND'); } - /// // 1. Make test cases from previous executions /// @@ -359,8 +415,8 @@ export class TestRunnerService { pastExecutions.map((e) => e.id), ); - // Get the metrics to collect from the evaluation workflow - const testMetricNames = await this.getTestMetricNames(test.id); + // Sync the metrics of the test definition with the evaluation workflow + const testMetricNames = await this.syncMetrics(test.id, evaluationWorkflow); // 2. Run over all the test cases const pastExecutionIds = pastExecutions.map((e) => e.id); @@ -465,8 +521,8 @@ export class TestRunnerService { this.logger.debug('Evaluation execution finished', { pastExecutionId }); // Extract the output of the last node executed in the evaluation workflow - const { addedMetrics, unknownMetrics } = metrics.addResults( - this.extractEvaluationResult(evalExecution), + const { addedMetrics } = metrics.addResults( + this.extractEvaluationResult(evalExecution, evaluationWorkflow), ); if (evalExecution.data.resultData.error) { @@ -483,22 +539,12 @@ export class TestRunnerService { await Db.transaction(async (trx) => { await this.testRunRepository.incrementPassed(testRun.id, trx); - // Add warning if the evaluation workflow produced an unknown metric - if (unknownMetrics.size > 0) { - await this.testCaseExecutionRepository.markAsWarning({ - testRunId: testRun.id, - pastExecutionId, - errorCode: 'UNKNOWN_METRICS', - errorDetails: { unknownMetrics: Array.from(unknownMetrics) }, - }); - } else { - await this.testCaseExecutionRepository.markAsCompleted({ - testRunId: testRun.id, - pastExecutionId, - metrics: addedMetrics, - trx, - }); - } + await this.testCaseExecutionRepository.markAsCompleted({ + testRunId: testRun.id, + pastExecutionId, + metrics: addedMetrics, + trx, + }); }); } } catch (e) { diff --git a/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts new file mode 100644 index 00000000000..c13f0b8f437 --- /dev/null +++ b/packages/cli/src/modules/insights/__tests__/insights.controller.test.ts @@ -0,0 +1,93 @@ +import { Container } from '@n8n/di'; + +import { mockInstance } from '@test/mocking'; +import * as testDb from '@test-integration/test-db'; + +import { TypeToNumber } from '../database/entities/insights-shared'; +import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; +import { InsightsController } from '../insights.controller'; + +// Initialize DB once for all tests +beforeAll(async () => { + await testDb.init(); +}); + +// Terminate DB once after all tests complete +afterAll(async () => { + await testDb.terminate(); +}); + +describe('InsightsController', () => { + const insightsByPeriodRepository = mockInstance(InsightsByPeriodRepository); + let controller: InsightsController; + beforeAll(async () => { + controller = Container.get(InsightsController); + }); + + describe('getInsightsSummary', () => { + it('should return default insights if no data', async () => { + // ARRANGE + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([]); + + // ACT + const response = await controller.getInsightsSummary(); + + // ASSERT + expect(response).toEqual({ + total: { deviation: 0, unit: 'count', value: 0 }, + failed: { deviation: 0, unit: 'count', value: 0 }, + failureRate: { deviation: 0, unit: 'ratio', value: 0 }, + averageRunTime: { deviation: 0, unit: 'time', value: 0 }, + timeSaved: { deviation: 0, unit: 'time', value: 0 }, + }); + }); + + it('should return the insights summary with deviation = current if insights exist only for current period', async () => { + // ARRANGE + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([ + { period: 'current', type: TypeToNumber.success, total_value: 20 }, + { period: 'current', type: TypeToNumber.failure, total_value: 10 }, + { period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 }, + { period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 }, + ]); + + // ACT + const response = await controller.getInsightsSummary(); + + // ASSERT + expect(response).toEqual({ + total: { deviation: 30, unit: 'count', value: 30 }, + failed: { deviation: 10, unit: 'count', value: 10 }, + failureRate: { deviation: 0.33, unit: 'ratio', value: 0.33 }, + averageRunTime: { deviation: 10, unit: 'time', value: 10 }, + timeSaved: { deviation: 10, unit: 'time', value: 10 }, + }); + }); + + it('should return the insights summary if insights exist for both periods', async () => { + // ARRANGE + insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates.mockResolvedValue([ + { period: 'previous', type: TypeToNumber.success, total_value: 16 }, + { period: 'previous', type: TypeToNumber.failure, total_value: 4 }, + { period: 'previous', type: TypeToNumber.runtime_ms, total_value: 40 }, + { period: 'previous', type: TypeToNumber.time_saved_min, total_value: 5 }, + { period: 'current', type: TypeToNumber.success, total_value: 20 }, + { period: 'current', type: TypeToNumber.failure, total_value: 10 }, + { period: 'current', type: TypeToNumber.runtime_ms, total_value: 300 }, + { period: 'current', type: TypeToNumber.time_saved_min, total_value: 10 }, + ]); + + // ACT + const response = await controller.getInsightsSummary(); + + // ASSERT + expect(response).toEqual({ + total: { deviation: 10, unit: 'count', value: 30 }, + failed: { deviation: 6, unit: 'count', value: 10 }, + failureRate: { deviation: 0.33 - 0.2, unit: 'ratio', value: 0.33 }, + averageRunTime: { deviation: 300 / 30 - 40 / 20, unit: 'time', value: 10 }, + timeSaved: { deviation: 5, unit: 'time', value: 10 }, + }); + }); + }); +}); diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index 9b84cbea6ad..0805d190c25 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -7,15 +7,21 @@ import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow'; import type { Project } from '@/databases/entities/project'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import type { IWorkflowDb } from '@/interfaces'; -import type { TypeUnits } from '@/modules/insights/entities/insights-shared'; -import { InsightsMetadataRepository } from '@/modules/insights/repositories/insights-metadata.repository'; -import { InsightsRawRepository } from '@/modules/insights/repositories/insights-raw.repository'; +import type { TypeUnit } from '@/modules/insights/database/entities/insights-shared'; +import { InsightsMetadataRepository } from '@/modules/insights/database/repositories/insights-metadata.repository'; +import { InsightsRawRepository } from '@/modules/insights/database/repositories/insights-raw.repository'; import { createTeamProject } from '@test-integration/db/projects'; import { createWorkflow } from '@test-integration/db/workflows'; import * as testDb from '@test-integration/test-db'; +import { + createMetadata, + createRawInsightsEvent, + createCompactedInsightsEvent, + createRawInsightsEvents, +} from '../database/entities/__tests__/db-utils'; +import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; import { InsightsService } from '../insights.service'; -import { InsightsByPeriodRepository } from '../repositories/insights-by-period.repository'; async function truncateAll() { const insightsRawRepository = Container.get(InsightsRawRepository); @@ -30,13 +36,21 @@ async function truncateAll() { } } +// Initialize DB once for all tests +beforeAll(async () => { + await testDb.init(); +}); + +// Terminate DB once after all tests complete +afterAll(async () => { + await testDb.terminate(); +}); + describe('workflowExecuteAfterHandler', () => { let insightsService: InsightsService; let insightsRawRepository: InsightsRawRepository; let insightsMetadataRepository: InsightsMetadataRepository; beforeAll(async () => { - await testDb.init(); - insightsService = Container.get(InsightsService); insightsRawRepository = Container.get(InsightsRawRepository); insightsMetadataRepository = Container.get(InsightsMetadataRepository); @@ -59,7 +73,7 @@ describe('workflowExecuteAfterHandler', () => { ); }); - test.each<{ status: ExecutionStatus; type: TypeUnits }>([ + test.each<{ status: ExecutionStatus; type: TypeUnit }>([ { status: 'success', type: 'success' }, { status: 'error', type: 'failure' }, { status: 'crashed', type: 'failure' }, @@ -245,3 +259,418 @@ describe('workflowExecuteAfterHandler', () => { ); }); }); + +describe('compaction', () => { + beforeEach(async () => { + await truncateAll(); + }); + + describe('compactRawToHour', () => { + type TestData = { + name: string; + timestamps: DateTime[]; + batches: number[]; + }; + + test.each([ + { + name: 'compact into 2 rows', + timestamps: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 0, 59), + DateTime.utc(2000, 1, 1, 1, 0), + ], + batches: [2, 1], + }, + { + name: 'compact into 3 rows', + timestamps: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 1, 0), + DateTime.utc(2000, 1, 1, 2, 0), + ], + batches: [1, 1, 1], + }, + ])('$name', async ({ timestamps, batches }) => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + // create before so we can create the raw events in parallel + await createMetadata(workflow); + for (const timestamp of timestamps) { + await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp }); + } + + // ACT + const compactedRows = await insightsService.compactRawToHour(); + + // ASSERT + expect(compactedRows).toBe(timestamps.length); + await expect(insightsRawRepository.count()).resolves.toBe(0); + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + expect(allCompacted).toHaveLength(batches.length); + for (const [index, compacted] of allCompacted.entries()) { + expect(compacted.value).toBe(batches[index]); + } + }); + + test('batch compaction split events in hourly insight periods', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc().startOf('hour'); + for (let i = 0; i < batchSize; i++) { + await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp }); + // create 60 events per hour + timestamp = timestamp.plus({ minute: 1 }); + } + + // ACT + await insightsService.compactInsights(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0); + expect(accumulatedValues).toBe(batchSize); + expect(allCompacted[0].value).toBe(60); + expect(allCompacted[1].value).toBe(40); + }); + + test('batch compaction split events in hourly insight periods by type and workflow', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow1 = await createWorkflow({}, project); + const workflow2 = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc().startOf('hour'); + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow1, { type: 'success', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow1, { type: 'failure', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow2, { type: 'runtime_ms', value: 1200, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + for (let i = 0; i < batchSize / 4; i++) { + await createRawInsightsEvent(workflow2, { type: 'time_saved_min', value: 3, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + + // ACT + await insightsService.compactInsights(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + + const allCompacted = await insightsByPeriodRepository.find({ + order: { metaId: 'ASC', periodStart: 'ASC' }, + }); + + // Expect 2 insights for workflow 1 (for success and failure) + // and 3 for workflow 2 (2 period starts for runtime_ms and 1 for time_saved_min) + expect(allCompacted).toHaveLength(5); + const metaIds = allCompacted.map((event) => event.metaId); + + // meta id are ordered. first 2 are for workflow 1, last 3 are for workflow 2 + const uniqueMetaIds = [metaIds[0], metaIds[2]]; + const workflow1Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[0]); + const workflow2Insights = allCompacted.filter((event) => event.metaId === uniqueMetaIds[1]); + + expect(workflow1Insights).toHaveLength(2); + expect(workflow2Insights).toHaveLength(3); + + const successInsights = workflow1Insights.find((event) => event.type === 'success'); + const failureInsights = workflow1Insights.find((event) => event.type === 'failure'); + + expect(successInsights).toBeTruthy(); + expect(failureInsights).toBeTruthy(); + // success and failure insights should have the value matching the number or raw events (because value = 1) + expect(successInsights!.value).toBe(25); + expect(failureInsights!.value).toBe(25); + + const runtimeMsEvents = workflow2Insights.filter((event) => event.type === 'runtime_ms'); + const timeSavedMinEvents = workflow2Insights.find((event) => event.type === 'time_saved_min'); + expect(runtimeMsEvents).toHaveLength(2); + + // The last 10 minutes of the first hour + expect(runtimeMsEvents[0].value).toBe(1200 * 10); + + // The first 15 minutes of the second hour + expect(runtimeMsEvents[1].value).toBe(1200 * 15); + expect(timeSavedMinEvents).toBeTruthy(); + expect(timeSavedMinEvents!.value).toBe(3 * 25); + }); + + test('should return the number of compacted events', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc(2000, 1, 1, 0, 0); + for (let i = 0; i < batchSize; i++) { + await createRawInsightsEvent(workflow, { type: 'success', value: 1, timestamp }); + // create 60 events per hour + timestamp = timestamp.plus({ minute: 1 }); + } + + // ACT + const numberOfCompactedData = await insightsService.compactRawToHour(); + + // ASSERT + expect(numberOfCompactedData).toBe(100); + }); + + test('works with data in the compacted table', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 100; + + let timestamp = DateTime.utc().startOf('hour'); + + // Create an existing compacted event for the first hour + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 10, + periodUnit: 'hour', + periodStart: timestamp, + }); + + const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>(); + for (let i = 0; i < batchSize; i++) { + events.push({ type: 'success', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + await createRawInsightsEvents(workflow, events); + + // ACT + await insightsService.compactInsights(); + + // ASSERT + await expect(insightsRawRepository.count()).resolves.toBe(0); + + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0); + expect(accumulatedValues).toBe(batchSize + 10); + expect(allCompacted[0].value).toBe(70); + expect(allCompacted[1].value).toBe(40); + }); + + test('works with data bigger than the batch size', async () => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + // spy on the compactRawToHour method to check if it's called multiple times + const rawToHourSpy = jest.spyOn(insightsService, 'compactRawToHour'); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + + const batchSize = 600; + + let timestamp = DateTime.utc().startOf('hour'); + const events = Array<{ type: 'success'; value: number; timestamp: DateTime }>(); + for (let i = 0; i < batchSize; i++) { + events.push({ type: 'success', value: 1, timestamp }); + timestamp = timestamp.plus({ minute: 1 }); + } + await createRawInsightsEvents(workflow, events); + + // ACT + await insightsService.compactInsights(); + + // ASSERT + expect(rawToHourSpy).toHaveBeenCalledTimes(3); + await expect(insightsRawRepository.count()).resolves.toBe(0); + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + const accumulatedValues = allCompacted.reduce((acc, event) => acc + event.value, 0); + expect(accumulatedValues).toBe(batchSize); + }); + }); + + describe('compactionSchedule', () => { + test('compaction is running on schedule', async () => { + jest.useFakeTimers(); + try { + // ARRANGE + const insightsService = Container.get(InsightsService); + insightsService.initializeCompaction(); + + // spy on the compactInsights method to check if it's called + insightsService.compactInsights = jest.fn(); + + // ACT + // advance by 1 hour and 1 minute + jest.advanceTimersByTime(1000 * 60 * 61); + + // ASSERT + expect(insightsService.compactInsights).toHaveBeenCalledTimes(1); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('compactHourToDay', () => { + type TestData = { + name: string; + periodStarts: DateTime[]; + batches: number[]; + }; + + test.each([ + { + name: 'compact into 2 rows', + periodStarts: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 23, 59), + DateTime.utc(2000, 1, 2, 1, 0), + ], + batches: [2, 1], + }, + { + name: 'compact into 3 rows', + periodStarts: [ + DateTime.utc(2000, 1, 1, 0, 0), + DateTime.utc(2000, 1, 1, 23, 59), + DateTime.utc(2000, 1, 2, 0, 0), + DateTime.utc(2000, 1, 2, 23, 59), + DateTime.utc(2000, 1, 3, 23, 59), + ], + batches: [2, 2, 1], + }, + ])('$name', async ({ periodStarts, batches }) => { + // ARRANGE + const insightsService = Container.get(InsightsService); + const insightsRawRepository = Container.get(InsightsRawRepository); + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + + const project = await createTeamProject(); + const workflow = await createWorkflow({}, project); + // create before so we can create the raw events in parallel + await createMetadata(workflow); + for (const periodStart of periodStarts) { + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'hour', + periodStart, + }); + } + + // ACT + const compactedRows = await insightsService.compactHourToDay(); + + // ASSERT + expect(compactedRows).toBe(periodStarts.length); + await expect(insightsRawRepository.count()).resolves.toBe(0); + const allCompacted = await insightsByPeriodRepository.find({ order: { periodStart: 1 } }); + expect(allCompacted).toHaveLength(batches.length); + for (const [index, compacted] of allCompacted.entries()) { + expect(compacted.value).toBe(batches[index]); + } + }); + }); +}); + +describe('getInsightsSummary', () => { + let insightsService: InsightsService; + beforeAll(async () => { + insightsService = Container.get(InsightsService); + }); + + let project: Project; + let workflow: IWorkflowDb & WorkflowEntity; + + beforeEach(async () => { + await truncateAll(); + + project = await createTeamProject(); + workflow = await createWorkflow({}, project); + }); + + test('compacted data are summarized correctly', async () => { + // ARRANGE + // last 7 days + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ day: 2 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'failure', + value: 2, + periodUnit: 'day', + periodStart: DateTime.utc(), + }); + // last 14 days + await createCompactedInsightsEvent(workflow, { + type: 'success', + value: 1, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + await createCompactedInsightsEvent(workflow, { + type: 'runtime_ms', + value: 123, + periodUnit: 'day', + periodStart: DateTime.utc().minus({ days: 10 }), + }); + + // ACT + const summary = await insightsService.getInsightsSummary(); + + // ASSERT + expect(summary).toEqual({ + averageRunTime: { deviation: -123, unit: 'time', value: 0 }, + failed: { deviation: 2, unit: 'count', value: 2 }, + failureRate: { deviation: 0.5, unit: 'ratio', value: 0.5 }, + timeSaved: { deviation: 0, unit: 'time', value: 0 }, + total: { deviation: 3, unit: 'count', value: 4 }, + }); + }); +}); diff --git a/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts b/packages/cli/src/modules/insights/database/entities/__tests__/db-utils.ts similarity index 55% rename from packages/cli/src/modules/insights/entities/__tests__/db-utils.ts rename to packages/cli/src/modules/insights/database/entities/__tests__/db-utils.ts index 8781220b93a..03c4b0f31cf 100644 --- a/packages/cli/src/modules/insights/entities/__tests__/db-utils.ts +++ b/packages/cli/src/modules/insights/database/entities/__tests__/db-utils.ts @@ -5,10 +5,12 @@ import type { IWorkflowBase } from 'n8n-workflow'; import type { WorkflowEntity } from '@/databases/entities/workflow-entity'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { InsightsMetadata } from '../../entities/insights-metadata'; -import { InsightsRaw } from '../../entities/insights-raw'; +import { InsightsByPeriodRepository } from '../../repositories/insights-by-period.repository'; import { InsightsMetadataRepository } from '../../repositories/insights-metadata.repository'; import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; +import { InsightsByPeriod } from '../insights-by-period'; +import { InsightsMetadata } from '../insights-metadata'; +import { InsightsRaw } from '../insights-raw'; async function getWorkflowSharing(workflow: IWorkflowBase) { return await Container.get(SharedWorkflowRepository).find({ @@ -62,3 +64,49 @@ export async function createRawInsightsEvent( } return await insightsRawRepository.save(event); } + +export async function createRawInsightsEvents( + workflow: WorkflowEntity, + parametersArray: Array<{ + type: InsightsRaw['type']; + value: number; + timestamp?: DateTime; + }>, +) { + const insightsRawRepository = Container.get(InsightsRawRepository); + const metadata = await createMetadata(workflow); + + const events = parametersArray.map((parameters) => { + const event = new InsightsRaw(); + event.metaId = metadata.metaId; + event.type = parameters.type; + event.value = parameters.value; + if (parameters.timestamp) { + event.timestamp = parameters.timestamp.toUTC().toJSDate(); + } + return event; + }); + await insightsRawRepository.save(events); +} + +export async function createCompactedInsightsEvent( + workflow: WorkflowEntity, + parameters: { + type: InsightsByPeriod['type']; + value: number; + periodUnit: InsightsByPeriod['periodUnit']; + periodStart: DateTime; + }, +) { + const insightsByPeriodRepository = Container.get(InsightsByPeriodRepository); + const metadata = await createMetadata(workflow); + + const event = new InsightsByPeriod(); + event.metaId = metadata.metaId; + event.type = parameters.type; + event.value = parameters.value; + event.periodUnit = parameters.periodUnit; + event.periodStart = parameters.periodStart.toUTC().startOf(parameters.periodUnit).toJSDate(); + + return await insightsByPeriodRepository.save(event); +} diff --git a/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts b/packages/cli/src/modules/insights/database/entities/__tests__/insights-by-period.test.ts similarity index 88% rename from packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts rename to packages/cli/src/modules/insights/database/entities/__tests__/insights-by-period.test.ts index 33af07ad561..3b5a05f7e79 100644 --- a/packages/cli/src/modules/insights/entities/__tests__/insights-by-period.test.ts +++ b/packages/cli/src/modules/insights/database/entities/__tests__/insights-by-period.test.ts @@ -4,7 +4,7 @@ import * as testDb from '@test-integration/test-db'; import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; import { InsightsByPeriod } from '../insights-by-period'; -import type { PeriodUnits, TypeUnits } from '../insights-shared'; +import type { PeriodUnit, TypeUnit } from '../insights-shared'; let insightsRawRepository: InsightsRawRepository; @@ -22,7 +22,7 @@ afterAll(async () => { }); describe('Insights By Period', () => { - test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnits[])( + test.each(['time_saved_min', 'runtime_ms', 'failure', 'success'] satisfies TypeUnit[])( '`%s` can be serialized and deserialized correctly', (typeUnit) => { // ARRANGE @@ -35,7 +35,7 @@ describe('Insights By Period', () => { expect(insightByPeriod.type).toBe(typeUnit); }, ); - test.each(['hour', 'day', 'week'] satisfies PeriodUnits[])( + test.each(['hour', 'day', 'week'] satisfies PeriodUnit[])( '`%s` can be serialized and deserialized correctly', (periodUnit) => { // ARRANGE diff --git a/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts b/packages/cli/src/modules/insights/database/entities/__tests__/insights-raw.test.ts similarity index 96% rename from packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts rename to packages/cli/src/modules/insights/database/entities/__tests__/insights-raw.test.ts index 848e0870e0d..fd29a094150 100644 --- a/packages/cli/src/modules/insights/entities/__tests__/insights-raw.test.ts +++ b/packages/cli/src/modules/insights/database/entities/__tests__/insights-raw.test.ts @@ -8,7 +8,7 @@ import * as testDb from '@test-integration/test-db'; import { createMetadata, createRawInsightsEvent } from './db-utils'; import { InsightsRawRepository } from '../../repositories/insights-raw.repository'; import { InsightsRaw } from '../insights-raw'; -import type { TypeUnits } from '../insights-shared'; +import type { TypeUnit } from '../insights-shared'; let insightsRawRepository: InsightsRawRepository; @@ -26,7 +26,7 @@ afterAll(async () => { }); describe('Insights Raw Entity', () => { - test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnits[])( + test.each(['success', 'failure', 'runtime_ms', 'time_saved_min'] satisfies TypeUnit[])( '`%s` can be serialized and deserialized correctly', (typeUnit) => { // ARRANGE diff --git a/packages/cli/src/modules/insights/entities/insights-by-period.ts b/packages/cli/src/modules/insights/database/entities/insights-by-period.ts similarity index 87% rename from packages/cli/src/modules/insights/entities/insights-by-period.ts rename to packages/cli/src/modules/insights/database/entities/insights-by-period.ts index b2e532be33b..25c8ae6cc06 100644 --- a/packages/cli/src/modules/insights/entities/insights-by-period.ts +++ b/packages/cli/src/modules/insights/database/entities/insights-by-period.ts @@ -1,7 +1,7 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm'; import { UnexpectedError } from 'n8n-workflow'; -import type { PeriodUnits } from './insights-shared'; +import type { PeriodUnit } from './insights-shared'; import { isValidPeriodNumber, isValidTypeNumber, @@ -10,7 +10,7 @@ import { PeriodUnitToNumber, TypeToNumber, } from './insights-shared'; -import { datetimeColumnType } from '../../../databases/entities/abstract-entity'; +import { datetimeColumnType } from '../../../../databases/entities/abstract-entity'; @Entity() export class InsightsByPeriod extends BaseEntity { @@ -53,7 +53,7 @@ export class InsightsByPeriod extends BaseEntity { return NumberToPeriodUnit[this.periodUnit_]; } - set periodUnit(value: PeriodUnits) { + set periodUnit(value: PeriodUnit) { this.periodUnit_ = PeriodUnitToNumber[value]; } diff --git a/packages/cli/src/modules/insights/entities/insights-metadata.ts b/packages/cli/src/modules/insights/database/entities/insights-metadata.ts similarity index 100% rename from packages/cli/src/modules/insights/entities/insights-metadata.ts rename to packages/cli/src/modules/insights/database/entities/insights-metadata.ts diff --git a/packages/cli/src/modules/insights/entities/insights-raw.ts b/packages/cli/src/modules/insights/database/entities/insights-raw.ts similarity index 92% rename from packages/cli/src/modules/insights/entities/insights-raw.ts rename to packages/cli/src/modules/insights/database/entities/insights-raw.ts index ceff552a985..4c20e04e64c 100644 --- a/packages/cli/src/modules/insights/entities/insights-raw.ts +++ b/packages/cli/src/modules/insights/database/entities/insights-raw.ts @@ -4,7 +4,7 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from '@n8n/typeorm import { UnexpectedError } from 'n8n-workflow'; import { isValidTypeNumber, NumberToType, TypeToNumber } from './insights-shared'; -import { datetimeColumnType } from '../../../databases/entities/abstract-entity'; +import { datetimeColumnType } from '../../../../databases/entities/abstract-entity'; export const { type: dbType } = Container.get(GlobalConfig).database; diff --git a/packages/cli/src/modules/insights/entities/insights-shared.ts b/packages/cli/src/modules/insights/database/entities/insights-shared.ts similarity index 64% rename from packages/cli/src/modules/insights/entities/insights-shared.ts rename to packages/cli/src/modules/insights/database/entities/insights-shared.ts index 14260dd69e5..3e12622e850 100644 --- a/packages/cli/src/modules/insights/entities/insights-shared.ts +++ b/packages/cli/src/modules/insights/database/entities/insights-shared.ts @@ -11,14 +11,16 @@ export const PeriodUnitToNumber = { day: 1, week: 2, } as const; -export type PeriodUnits = keyof typeof PeriodUnitToNumber; -export type PeriodUnitNumbers = (typeof PeriodUnitToNumber)[PeriodUnits]; + +export type PeriodUnit = keyof typeof PeriodUnitToNumber; + +export type PeriodUnitNumber = (typeof PeriodUnitToNumber)[PeriodUnit]; export const NumberToPeriodUnit = Object.entries(PeriodUnitToNumber).reduce( - (acc, [key, value]: [PeriodUnits, PeriodUnitNumbers]) => { + (acc, [key, value]: [PeriodUnit, PeriodUnitNumber]) => { acc[value] = key; return acc; }, - {} as Record, + {} as Record, ); export function isValidPeriodNumber(value: number) { return isValid(value, NumberToPeriodUnit); @@ -31,14 +33,16 @@ export const TypeToNumber = { success: 2, failure: 3, } as const; -export type TypeUnits = keyof typeof TypeToNumber; -export type TypeUnitNumbers = (typeof TypeToNumber)[TypeUnits]; + +export type TypeUnit = keyof typeof TypeToNumber; + +export type TypeUnitNumber = (typeof TypeToNumber)[TypeUnit]; export const NumberToType = Object.entries(TypeToNumber).reduce( - (acc, [key, value]: [TypeUnits, TypeUnitNumbers]) => { + (acc, [key, value]: [TypeUnit, TypeUnitNumber]) => { acc[value] = key; return acc; }, - {} as Record, + {} as Record, ); export function isValidTypeNumber(value: number) { diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts new file mode 100644 index 00000000000..c4caa960f41 --- /dev/null +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts @@ -0,0 +1,231 @@ +import { GlobalConfig } from '@n8n/config'; +import { Container, Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; +import { z } from 'zod'; + +import { sql } from '@/utils/sql'; + +import { InsightsByPeriod } from '../entities/insights-by-period'; +import type { PeriodUnit } from '../entities/insights-shared'; +import { PeriodUnitToNumber } from '../entities/insights-shared'; + +const dbType = Container.get(GlobalConfig).database.type; + +const summaryParser = z + .object({ + period: z.enum(['previous', 'current']), + type: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]), + + // depending on db engine, sum(value) can be a number or a string - because of big numbers + total_value: z.union([z.number(), z.string()]), + }) + .array(); + +@Service() +export class InsightsByPeriodRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsByPeriod, dataSource.manager); + } + + private escapeField(fieldName: string) { + return this.manager.connection.driver.escape(fieldName); + } + + private getPeriodFilterExpr(periodUnit: PeriodUnit) { + const daysAgo = periodUnit === 'day' ? 90 : 180; + // Database-specific period start expression to filter out data to compact by days matching the periodUnit + let periodStartExpr = `date('now', '-${daysAgo} days')`; + if (dbType === 'postgresdb') { + periodStartExpr = `CURRENT_DATE - INTERVAL '${daysAgo} day'`; + } else if (dbType === 'mysqldb' || dbType === 'mariadb') { + periodStartExpr = `DATE_SUB(CURRENT_DATE, INTERVAL ${daysAgo} DAY)`; + } + + return periodStartExpr; + } + + private getPeriodStartExpr(periodUnit: PeriodUnit) { + // Database-specific period start expression to truncate timestamp to the periodUnit + // SQLite by default + let periodStartExpr = `strftime('%Y-%m-%d ${periodUnit === 'hour' ? '%H' : '00'}:00:00.000', periodStart)`; + if (dbType === 'mysqldb' || dbType === 'mariadb') { + periodStartExpr = + periodUnit === 'hour' + ? "DATE_FORMAT(periodStart, '%Y-%m-%d %H:00:00')" + : "DATE_FORMAT(periodStart, '%Y-%m-%d 00:00:00')"; + } else if (dbType === 'postgresdb') { + periodStartExpr = `DATE_TRUNC('${periodUnit}', ${this.escapeField('periodStart')})`; + } + + return periodStartExpr; + } + + getPeriodInsightsBatchQuery(periodUnit: PeriodUnit, compactionBatchSize: number) { + // Build the query to gather period insights data for the batch + const batchQuery = this.createQueryBuilder() + .select( + ['id', 'metaId', 'type', 'periodStart', 'value'].map((fieldName) => + this.escapeField(fieldName), + ), + ) + .where(`${this.escapeField('periodUnit')} = ${PeriodUnitToNumber[periodUnit]}`) + .andWhere(`${this.escapeField('periodStart')} < ${this.getPeriodFilterExpr('day')}`) + .orderBy(this.escapeField('periodStart'), 'ASC') + .limit(compactionBatchSize); + return batchQuery; + } + + getAggregationQuery(periodUnit: PeriodUnit) { + // Get the start period expression depending on the period unit and database type + const periodStartExpr = this.getPeriodStartExpr(periodUnit); + + // Function to get the aggregation query + const aggregationQuery = this.manager + .createQueryBuilder() + .select(this.escapeField('metaId')) + .addSelect(this.escapeField('type')) + .addSelect(PeriodUnitToNumber[periodUnit].toString(), 'periodUnit') + .addSelect(periodStartExpr, 'periodStart') + .addSelect(`SUM(${this.escapeField('value')})`, 'value') + .from('rows_to_compact', 'rtc') + .groupBy(this.escapeField('metaId')) + .addGroupBy(this.escapeField('type')) + .addGroupBy(periodStartExpr); + + return aggregationQuery; + } + + async compactSourceDataIntoInsightPeriod({ + sourceBatchQuery, // Query to get batch source data. Must return those fields: 'id', 'metaId', 'type', 'periodStart', 'value' + sourceTableName = this.metadata.tableName, // Repository references for table operations + periodUnit, + }: { + sourceBatchQuery: string; + sourceTableName?: string; + periodUnit: PeriodUnit; + }): Promise { + // Create temp table that only exists in this transaction for rows to compact + const getBatchAndStoreInTemporaryTable = sql` + CREATE TEMPORARY TABLE rows_to_compact AS + ${sourceBatchQuery}; + `; + + const countBatch = sql` + SELECT COUNT(*) ${this.escapeField('rowsInBatch')} FROM rows_to_compact; + `; + + const targetColumnNamesStr = ['metaId', 'type', 'periodUnit', 'periodStart'] + .map((param) => this.escapeField(param)) + .join(', '); + const targetColumnNamesWithValue = `${targetColumnNamesStr}, value`; + + // Function to get the aggregation query + const aggregationQuery = this.getAggregationQuery(periodUnit); + + // Insert or update aggregated data + const insertQueryBase = sql` + INSERT INTO ${this.metadata.tableName} + (${targetColumnNamesWithValue}) + ${aggregationQuery.getSql()} + `; + + // Database-specific duplicate key logic + let deduplicateQuery: string; + if (dbType === 'mysqldb' || dbType === 'mariadb') { + deduplicateQuery = sql` + ON DUPLICATE KEY UPDATE value = value + VALUES(value)`; + } else { + deduplicateQuery = sql` + ON CONFLICT(${targetColumnNamesStr}) + DO UPDATE SET value = ${this.metadata.tableName}.value + excluded.value + RETURNING *`; + } + + const upsertEvents = sql` + ${insertQueryBase} + ${deduplicateQuery} + `; + + // Delete the processed rows + const deleteBatch = sql` + DELETE FROM ${sourceTableName} + WHERE id IN (SELECT id FROM rows_to_compact); + `; + + // Clean up + const dropTemporaryTable = sql` + DROP TABLE rows_to_compact; + `; + + const result = await this.manager.transaction(async (trx) => { + await trx.query(getBatchAndStoreInTemporaryTable); + + await trx.query>(upsertEvents); + + const rowsInBatch = await trx.query<[{ rowsInBatch: number | string }]>(countBatch); + + await trx.query(deleteBatch); + await trx.query(dropTemporaryTable); + + return Number(rowsInBatch[0].rowsInBatch); + }); + + return result; + } + + async getPreviousAndCurrentPeriodTypeAggregates(): Promise< + Array<{ + period: 'previous' | 'current'; + type: 0 | 1 | 2 | 3; + total_value: string | number; + }> + > { + const cte = + dbType === 'sqlite' + ? sql` + SELECT + datetime('now', '-7 days') AS current_start, + datetime('now') AS current_end, + datetime('now', '-14 days') AS previous_start + ` + : dbType === 'postgresdb' + ? sql` + SELECT + (CURRENT_DATE - INTERVAL '7 days')::timestamptz AS current_start, + CURRENT_DATE::timestamptz AS current_end, + (CURRENT_DATE - INTERVAL '14 days')::timestamptz AS previous_start + ` + : sql` + SELECT + DATE_SUB(CURDATE(), INTERVAL 7 DAY) AS current_start, + CURDATE() AS current_end, + DATE_SUB(CURDATE(), INTERVAL 14 DAY) AS previous_start + `; + + const rawRows = await this.createQueryBuilder('insights') + .addCommonTableExpression(cte, 'date_ranges') + .select( + sql` + CASE + WHEN insights.periodStart >= date_ranges.current_start AND insights.periodStart <= date_ranges.current_end + THEN 'current' + ELSE 'previous' + END + `, + 'period', + ) + .addSelect('insights.type', 'type') + .addSelect('SUM(value)', 'total_value') + // Use a cross join with the CTE + .innerJoin('date_ranges', 'date_ranges', '1=1') + // Filter to only include data from the last 14 days + .where('insights.periodStart >= date_ranges.previous_start') + .andWhere('insights.periodStart <= date_ranges.current_end') + // Group by both period and type + .groupBy('period') + .addGroupBy('insights.type') + .getRawMany(); + + return summaryParser.parse(rawRows); + } +} diff --git a/packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-metadata.repository.ts similarity index 100% rename from packages/cli/src/modules/insights/repositories/insights-metadata.repository.ts rename to packages/cli/src/modules/insights/database/repositories/insights-metadata.repository.ts diff --git a/packages/cli/src/modules/insights/database/repositories/insights-raw.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-raw.repository.ts new file mode 100644 index 00000000000..05eba9ff167 --- /dev/null +++ b/packages/cli/src/modules/insights/database/repositories/insights-raw.repository.ts @@ -0,0 +1,25 @@ +import { Service } from '@n8n/di'; +import { DataSource, Repository } from '@n8n/typeorm'; + +import { InsightsRaw } from '../entities/insights-raw'; + +@Service() +export class InsightsRawRepository extends Repository { + constructor(dataSource: DataSource) { + super(InsightsRaw, dataSource.manager); + } + + getRawInsightsBatchQuery(compactionBatchSize: number) { + // Build the query to gather raw insights data for the batch + const batchQuery = this.createQueryBuilder() + .select( + ['id', 'metaId', 'type', 'value'].map((fieldName) => + this.manager.connection.driver.escape(fieldName), + ), + ) + .addSelect('timestamp', 'periodStart') + .orderBy('timestamp', 'ASC') + .limit(compactionBatchSize); + return batchQuery; + } +} diff --git a/packages/cli/src/modules/insights/insights.config.ts b/packages/cli/src/modules/insights/insights.config.ts new file mode 100644 index 00000000000..e8f44984826 --- /dev/null +++ b/packages/cli/src/modules/insights/insights.config.ts @@ -0,0 +1,18 @@ +import { Config, Env } from '@n8n/config'; + +@Config +export class InsightsConfig { + /** + * The interval in minutes at which the insights data should be compacted. + * Default: 60 + */ + @Env('N8N_INSIGHTS_COMPACTION_INTERVAL_MINUTES') + compactionIntervalMinutes: number = 60; + + /** + * The number of raw insights data to compact in a single batch. + * Default: 500 + */ + @Env('N8N_INSIGHTS_COMPACTION_BATCH_SIZE') + compactionBatchSize: number = 500; +} diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts new file mode 100644 index 00000000000..95cfa728363 --- /dev/null +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -0,0 +1,16 @@ +import type { InsightsSummary } from '@n8n/api-types'; + +import { Get, GlobalScope, RestController } from '@/decorators'; + +import { InsightsService } from './insights.service'; + +@RestController('/insights') +export class InsightsController { + constructor(private readonly insightsService: InsightsService) {} + + @Get('/summary') + @GlobalScope('insights:list') + async getInsightsSummary(): Promise { + return await this.insightsService.getInsightsSummary(); + } +} diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index 0c7920cf3d4..85d638b466e 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -6,6 +6,8 @@ import { N8nModule } from '@/decorators/module'; import { InsightsService } from './insights.service'; +import './insights.controller'; + @N8nModule() export class InsightsModule implements BaseN8nModule { constructor( diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 3ccd92b9860..1bcd06e9164 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -1,12 +1,26 @@ -import { Service } from '@n8n/di'; +import type { InsightsSummary } from '@n8n/api-types'; +import { Container, Service } from '@n8n/di'; import type { ExecutionLifecycleHooks } from 'n8n-core'; -import { UnexpectedError } from 'n8n-workflow'; -import type { ExecutionStatus, IRun, WorkflowExecuteMode } from 'n8n-workflow'; +import { + UnexpectedError, + type ExecutionStatus, + type IRun, + type WorkflowExecuteMode, +} from 'n8n-workflow'; import { SharedWorkflow } from '@/databases/entities/shared-workflow'; import { SharedWorkflowRepository } from '@/databases/repositories/shared-workflow.repository'; -import { InsightsMetadata } from '@/modules/insights/entities/insights-metadata'; -import { InsightsRaw } from '@/modules/insights/entities/insights-raw'; +import { OnShutdown } from '@/decorators/on-shutdown'; +import { InsightsMetadata } from '@/modules/insights/database/entities/insights-metadata'; +import { InsightsRaw } from '@/modules/insights/database/entities/insights-raw'; + +import type { TypeUnit } from './database/entities/insights-shared'; +import { NumberToType } from './database/entities/insights-shared'; +import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository'; +import { InsightsRawRepository } from './database/repositories/insights-raw.repository'; +import { InsightsConfig } from './insights.config'; + +const config = Container.get(InsightsConfig); const shouldSkipStatus: Record = { success: false, @@ -35,7 +49,34 @@ const shouldSkipMode: Record = { @Service() export class InsightsService { - constructor(private readonly sharedWorkflowRepository: SharedWorkflowRepository) {} + private compactInsightsTimer: NodeJS.Timer | undefined; + + constructor( + private readonly sharedWorkflowRepository: SharedWorkflowRepository, + private readonly insightsByPeriodRepository: InsightsByPeriodRepository, + private readonly insightsRawRepository: InsightsRawRepository, + ) { + this.initializeCompaction(); + } + + initializeCompaction() { + if (this.compactInsightsTimer !== undefined) { + clearInterval(this.compactInsightsTimer); + } + const intervalMilliseconds = config.compactionIntervalMinutes * 60 * 1000; + this.compactInsightsTimer = setInterval( + async () => await this.compactInsights(), + intervalMilliseconds, + ); + } + + @OnShutdown() + shutdown() { + if (this.compactInsightsTimer !== undefined) { + clearInterval(this.compactInsightsTimer); + this.compactInsightsTimer = undefined; + } + } async workflowExecuteAfterHandler(ctx: ExecutionLifecycleHooks, fullRunData: IRun) { if (shouldSkipStatus[fullRunData.status] || shouldSkipMode[fullRunData.mode]) { @@ -107,4 +148,128 @@ export class InsightsService { } }); } + + async compactInsights() { + let numberOfCompactedRawData: number; + + // Compact raw data to hourly aggregates + do { + numberOfCompactedRawData = await this.compactRawToHour(); + } while (numberOfCompactedRawData > 0); + + let numberOfCompactedHourData: number; + + // Compact hourly data to daily aggregates + do { + numberOfCompactedHourData = await this.compactHourToDay(); + } while (numberOfCompactedHourData > 0); + } + + // Compacts raw data to hourly aggregates + async compactRawToHour() { + // Build the query to gather raw insights data for the batch + const batchQuery = this.insightsRawRepository.getRawInsightsBatchQuery( + config.compactionBatchSize, + ); + + return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({ + sourceBatchQuery: batchQuery.getSql(), + sourceTableName: this.insightsRawRepository.metadata.tableName, + periodUnit: 'hour', + }); + } + + // Compacts hourly data to daily aggregates + async compactHourToDay() { + // get hour data query for batching + const batchQuery = this.insightsByPeriodRepository.getPeriodInsightsBatchQuery( + 'hour', + config.compactionBatchSize, + ); + + return await this.insightsByPeriodRepository.compactSourceDataIntoInsightPeriod({ + sourceBatchQuery: batchQuery.getSql(), + periodUnit: 'day', + }); + } + + // TODO: add return type once rebased on master and InsightsSummary is + // available + async getInsightsSummary(): Promise { + const rows = await this.insightsByPeriodRepository.getPreviousAndCurrentPeriodTypeAggregates(); + + // Initialize data structures for both periods + const data = { + current: { byType: {} as Record }, + previous: { byType: {} as Record }, + }; + + // Organize data by period and type + rows.forEach((row) => { + const { period, type, total_value } = row; + if (!data[period]) return; + + data[period].byType[NumberToType[type]] = total_value ? Number(total_value) : 0; + }); + + // Get values with defaults for missing data + const getValueByType = (period: 'current' | 'previous', type: TypeUnit) => + data[period]?.byType[type] ?? 0; + + // Calculate metrics + const currentSuccesses = getValueByType('current', 'success'); + const currentFailures = getValueByType('current', 'failure'); + const previousSuccesses = getValueByType('previous', 'success'); + const previousFailures = getValueByType('previous', 'failure'); + + const currentTotal = currentSuccesses + currentFailures; + const previousTotal = previousSuccesses + previousFailures; + + const currentFailureRate = + currentTotal > 0 ? Math.round((currentFailures / currentTotal) * 100) / 100 : 0; + const previousFailureRate = + previousTotal > 0 ? Math.round((previousFailures / previousTotal) * 100) / 100 : 0; + + const currentTotalRuntime = getValueByType('current', 'runtime_ms') ?? 0; + const previousTotalRuntime = getValueByType('previous', 'runtime_ms') ?? 0; + + const currentAvgRuntime = + currentTotal > 0 ? Math.round((currentTotalRuntime / currentTotal) * 100) / 100 : 0; + const previousAvgRuntime = + previousTotal > 0 ? Math.round((previousTotalRuntime / previousTotal) * 100) / 100 : 0; + + const currentTimeSaved = getValueByType('current', 'time_saved_min'); + const previousTimeSaved = getValueByType('previous', 'time_saved_min'); + + // Return the formatted result + const result: InsightsSummary = { + averageRunTime: { + value: currentAvgRuntime, + unit: 'time', + deviation: currentAvgRuntime - previousAvgRuntime, + }, + failed: { + value: currentFailures, + unit: 'count', + deviation: currentFailures - previousFailures, + }, + failureRate: { + value: currentFailureRate, + unit: 'ratio', + deviation: currentFailureRate - previousFailureRate, + }, + timeSaved: { + value: currentTimeSaved, + unit: 'time', + deviation: currentTimeSaved - previousTimeSaved, + }, + total: { + value: currentTotal, + unit: 'count', + deviation: currentTotal - previousTotal, + }, + }; + + return result; + } } diff --git a/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts deleted file mode 100644 index 94bc0572711..00000000000 --- a/packages/cli/src/modules/insights/repositories/insights-by-period.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Service } from '@n8n/di'; -import { DataSource, Repository } from '@n8n/typeorm'; - -import { InsightsByPeriod } from '../entities/insights-by-period'; - -@Service() -export class InsightsByPeriodRepository extends Repository { - constructor(dataSource: DataSource) { - super(InsightsByPeriod, dataSource.manager); - } -} diff --git a/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts b/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts deleted file mode 100644 index 9bad708eed8..00000000000 --- a/packages/cli/src/modules/insights/repositories/insights-raw.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Service } from '@n8n/di'; -import { DataSource, Repository } from '@n8n/typeorm'; - -import { InsightsRaw } from '../entities/insights-raw'; - -@Service() -export class InsightsRawRepository extends Repository { - constructor(dataSource: DataSource) { - super(InsightsRaw, dataSource.manager); - } -} diff --git a/packages/cli/src/permissions.ee/global-roles.ts b/packages/cli/src/permissions.ee/global-roles.ts index 7ea1b575da9..e15153ffba0 100644 --- a/packages/cli/src/permissions.ee/global-roles.ts +++ b/packages/cli/src/permissions.ee/global-roles.ts @@ -75,6 +75,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'project:read', 'project:update', 'project:delete', + 'insights:list', ]; export const GLOBAL_ADMIN_SCOPES = GLOBAL_OWNER_SCOPES.concat(); diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index 9ba7aa930cf..e2d05f2a662 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -66,7 +66,6 @@ import '@/executions/executions.controller'; import '@/external-secrets.ee/external-secrets.controller.ee'; import '@/license/license.controller'; import '@/evaluation.ee/test-definitions.controller.ee'; -import '@/evaluation.ee/metrics.controller'; import '@/evaluation.ee/test-runs.controller.ee'; import '@/workflows/workflow-history.ee/workflow-history.controller.ee'; import '@/workflows/workflows.controller'; diff --git a/packages/cli/test/integration/evaluation/metrics.api.test.ts b/packages/cli/test/integration/evaluation/metrics.api.test.ts deleted file mode 100644 index ff04aedf124..00000000000 --- a/packages/cli/test/integration/evaluation/metrics.api.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -import { Container } from '@n8n/di'; -import type { IWorkflowBase } from 'n8n-workflow'; - -import type { TestDefinition } from '@/databases/entities/test-definition.ee'; -import type { User } from '@/databases/entities/user'; -import { TestDefinitionRepository } from '@/databases/repositories/test-definition.repository.ee'; -import { TestMetricRepository } from '@/databases/repositories/test-metric.repository.ee'; -import { createUserShell } from '@test-integration/db/users'; -import { createWorkflow } from '@test-integration/db/workflows'; -import * as testDb from '@test-integration/test-db'; -import type { SuperAgentTest } from '@test-integration/types'; -import * as utils from '@test-integration/utils'; - -let authOwnerAgent: SuperAgentTest; -let workflowUnderTest: IWorkflowBase; -let otherWorkflow: IWorkflowBase; -let testDefinition: TestDefinition; -let otherTestDefinition: TestDefinition; -let ownerShell: User; - -const testServer = utils.setupTestServer({ endpointGroups: ['evaluation'] }); - -beforeAll(async () => { - ownerShell = await createUserShell('global:owner'); - authOwnerAgent = testServer.authAgentFor(ownerShell); -}); - -beforeEach(async () => { - await testDb.truncate(['TestDefinition', 'TestMetric']); - - workflowUnderTest = await createWorkflow({ name: 'workflow-under-test' }, ownerShell); - - testDefinition = Container.get(TestDefinitionRepository).create({ - name: 'test', - workflow: { id: workflowUnderTest.id }, - }); - await Container.get(TestDefinitionRepository).save(testDefinition); - - otherWorkflow = await createWorkflow({ name: 'other-workflow' }); - - otherTestDefinition = Container.get(TestDefinitionRepository).create({ - name: 'other-test', - workflow: { id: otherWorkflow.id }, - }); - await Container.get(TestDefinitionRepository).save(otherTestDefinition); -}); - -describe('GET /evaluation/test-definitions/:testDefinitionId/metrics', () => { - test('should retrieve empty list of metrics for a test definition', async () => { - const resp = await authOwnerAgent.get( - `/evaluation/test-definitions/${testDefinition.id}/metrics`, - ); - - expect(resp.statusCode).toBe(200); - expect(resp.body.data.length).toBe(0); - }); - - test('should retrieve metrics for a test definition', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const newMetric2 = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-2', - }); - await Container.get(TestMetricRepository).save(newMetric2); - - const resp = await authOwnerAgent.get( - `/evaluation/test-definitions/${testDefinition.id}/metrics`, - ); - - expect(resp.statusCode).toBe(200); - expect(resp.body.data.length).toBe(2); - expect(resp.body.data).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: expect.any(String), - name: 'metric-1', - }), - expect.objectContaining({ - id: expect.any(String), - name: 'metric-2', - }), - ]), - ); - }); - - test('should return 404 if test definition does not exist', async () => { - const resp = await authOwnerAgent.get('/evaluation/test-definitions/999/metrics'); - - expect(resp.statusCode).toBe(404); - }); - - test('should return 404 if test definition is not accessible to the user', async () => { - const resp = await authOwnerAgent.get( - `/evaluation/test-definitions/${otherTestDefinition.id}/metrics`, - ); - - expect(resp.statusCode).toBe(404); - }); -}); - -describe('GET /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => { - test('should retrieve a metric for a test definition', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent.get( - `/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`, - ); - - expect(resp.statusCode).toBe(200); - expect(resp.body.data).toEqual( - expect.objectContaining({ - id: newMetric.id, - name: 'metric-1', - }), - ); - }); - - test('should return 404 if metric does not exist', async () => { - const resp = await authOwnerAgent.get( - `/evaluation/test-definitions/${testDefinition.id}/metrics/999`, - ); - - expect(resp.statusCode).toBe(404); - }); - - test('should return 404 if metric is not accessible to the user', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: otherTestDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent.get( - `/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`, - ); - - expect(resp.statusCode).toBe(404); - }); -}); - -describe('POST /evaluation/test-definitions/:testDefinitionId/metrics', () => { - test('should create a metric for a test definition', async () => { - const resp = await authOwnerAgent - .post(`/evaluation/test-definitions/${testDefinition.id}/metrics`) - .send({ - name: 'metric-1', - }); - - expect(resp.statusCode).toBe(200); - expect(resp.body.data).toEqual( - expect.objectContaining({ - id: expect.any(String), - name: 'metric-1', - }), - ); - - const metrics = await Container.get(TestMetricRepository).find({ - where: { testDefinition: { id: testDefinition.id } }, - }); - expect(metrics.length).toBe(1); - expect(metrics[0].name).toBe('metric-1'); - }); - - test('should return 400 if name is missing', async () => { - const resp = await authOwnerAgent - .post(`/evaluation/test-definitions/${testDefinition.id}/metrics`) - .send({}); - - expect(resp.statusCode).toBe(400); - expect(resp.body.errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'invalid_type', - message: 'Required', - path: ['name'], - }), - ]), - ); - }); - - test('should return 400 if name is not a string', async () => { - const resp = await authOwnerAgent - .post(`/evaluation/test-definitions/${testDefinition.id}/metrics`) - .send({ - name: 123, - }); - - expect(resp.statusCode).toBe(400); - expect(resp.body.errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'invalid_type', - message: 'Expected string, received number', - path: ['name'], - }), - ]), - ); - }); - - test('should return 404 if test definition does not exist', async () => { - const resp = await authOwnerAgent.post('/evaluation/test-definitions/999/metrics').send({ - name: 'metric-1', - }); - - expect(resp.statusCode).toBe(404); - }); - - test('should return 404 if test definition is not accessible to the user', async () => { - const resp = await authOwnerAgent - .post(`/evaluation/test-definitions/${otherTestDefinition.id}/metrics`) - .send({ - name: 'metric-1', - }); - - expect(resp.statusCode).toBe(404); - }); -}); - -describe('PATCH /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => { - test('should update a metric for a test definition', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent - .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`) - .send({ - name: 'metric-2', - }); - - expect(resp.statusCode).toBe(200); - expect(resp.body.data).toEqual( - expect.objectContaining({ - id: newMetric.id, - name: 'metric-2', - }), - ); - - const metrics = await Container.get(TestMetricRepository).find({ - where: { testDefinition: { id: testDefinition.id } }, - }); - expect(metrics.length).toBe(1); - expect(metrics[0].name).toBe('metric-2'); - }); - - test('should return 400 if name is missing', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent - .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`) - .send({}); - - expect(resp.statusCode).toBe(400); - expect(resp.body.errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'invalid_type', - message: 'Required', - path: ['name'], - }), - ]), - ); - }); - - test('should return 400 if name is not a string', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent - .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`) - .send({ - name: 123, - }); - - expect(resp.statusCode).toBe(400); - expect(resp.body.errors).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - code: 'invalid_type', - message: 'Expected string, received number', - }), - ]), - ); - }); - - test('should return 404 if metric does not exist', async () => { - const resp = await authOwnerAgent - .patch(`/evaluation/test-definitions/${testDefinition.id}/metrics/999`) - .send({ - name: 'metric-1', - }); - - expect(resp.statusCode).toBe(404); - }); - - test('should return 404 if test definition does not exist', async () => { - const resp = await authOwnerAgent.patch('/evaluation/test-definitions/999/metrics/999').send({ - name: 'metric-1', - }); - - expect(resp.statusCode).toBe(404); - }); - - test('should return 404 if metric is not accessible to the user', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: otherTestDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent - .patch(`/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`) - .send({ - name: 'metric-2', - }); - - expect(resp.statusCode).toBe(404); - }); -}); - -describe('DELETE /evaluation/test-definitions/:testDefinitionId/metrics/:id', () => { - test('should delete a metric for a test definition', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: testDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent.delete( - `/evaluation/test-definitions/${testDefinition.id}/metrics/${newMetric.id}`, - ); - - expect(resp.statusCode).toBe(200); - expect(resp.body.data).toEqual({ success: true }); - - const metrics = await Container.get(TestMetricRepository).find({ - where: { testDefinition: { id: testDefinition.id } }, - }); - expect(metrics.length).toBe(0); - }); - - test('should return 404 if metric does not exist', async () => { - const resp = await authOwnerAgent.delete( - `/evaluation/test-definitions/${testDefinition.id}/metrics/999`, - ); - - expect(resp.statusCode).toBe(404); - }); - - test('should return 404 if metric is not accessible to the user', async () => { - const newMetric = Container.get(TestMetricRepository).create({ - testDefinition: { id: otherTestDefinition.id }, - name: 'metric-1', - }); - await Container.get(TestMetricRepository).save(newMetric); - - const resp = await authOwnerAgent.delete( - `/evaluation/test-definitions/${otherTestDefinition.id}/metrics/${newMetric.id}`, - ); - - expect(resp.statusCode).toBe(404); - }); -}); diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index b242915c922..739a89305c4 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -281,7 +281,6 @@ export const setupTestServer = ({ break; case 'evaluation': - await import('@/evaluation.ee/metrics.controller'); await import('@/evaluation.ee/test-definitions.controller.ee'); await import('@/evaluation.ee/test-runs.controller.ee'); break; diff --git a/packages/core/package.json b/packages/core/package.json index b973ea296fb..323f0e2ed8b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "1.83.0", + "version": "1.84.0", "description": "Core functionality of n8n", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/frontend/@n8n/chat/package.json b/packages/frontend/@n8n/chat/package.json index 0825aff00e0..2330ab58ef8 100644 --- a/packages/frontend/@n8n/chat/package.json +++ b/packages/frontend/@n8n/chat/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/chat", - "version": "0.36.0", + "version": "0.37.0", "scripts": { "dev": "pnpm run storybook", "build": "pnpm build:vite && pnpm build:bundle", diff --git a/packages/frontend/@n8n/design-system/package.json b/packages/frontend/@n8n/design-system/package.json index 42ac372a0be..aabcc2b90bf 100644 --- a/packages/frontend/@n8n/design-system/package.json +++ b/packages/frontend/@n8n/design-system/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/design-system", - "version": "1.72.0", + "version": "1.73.0", "main": "src/index.ts", "import": "src/index.ts", "scripts": { diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 785c4df536a..7399676bd2c 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.84.0", + "version": "1.85.0", "description": "Workflow Editor UI for n8n", "main": "index.js", "scripts": { diff --git a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts index ed7a6dc6707..520508fca11 100644 --- a/packages/frontend/editor-ui/src/__tests__/data/canvas.ts +++ b/packages/frontend/editor-ui/src/__tests__/data/canvas.ts @@ -56,7 +56,7 @@ export function createCanvasNodeData({ export function createCanvasNodeElement({ id = '1', - type = 'default', + type = 'canvas-node', label = 'Node', position = { x: 100, y: 100 }, data, diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 80e8de9fb66..8ef9ad3637f 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -23,6 +23,7 @@ import { MANUAL_TRIGGER_NODE_TYPE, NO_OP_NODE_TYPE, SET_NODE_TYPE, + SIMULATE_NODE_TYPE, STICKY_NODE_TYPE, } from '@/constants'; import type { INodeUi, IWorkflowDb } from '@/Interface'; @@ -50,6 +51,7 @@ export const mockNode = ({ export const mockNodeTypeDescription = ({ name = SET_NODE_TYPE, + icon = 'fa:pen', version = 1, credentials = [], inputs = [NodeConnectionTypes.Main], @@ -58,6 +60,7 @@ export const mockNodeTypeDescription = ({ properties = [], }: { name?: INodeTypeDescription['name']; + icon?: INodeTypeDescription['icon']; version?: INodeTypeDescription['version']; credentials?: INodeTypeDescription['credentials']; inputs?: INodeTypeDescription['inputs']; @@ -67,6 +70,7 @@ export const mockNodeTypeDescription = ({ } = {}) => mock({ name, + icon, displayName: name, description: '', version, @@ -82,6 +86,7 @@ export const mockNodeTypeDescription = ({ codex, credentials, documentationUrl: 'https://docs', + iconUrl: 'nodes/test-node/icon.svg', webhooks: undefined, }); @@ -101,6 +106,7 @@ export const mockNodes = [ mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }), mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }), mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }), + mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }), mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }), mockNode({ name: 'End', type: NO_OP_NODE_TYPE }), ]; diff --git a/packages/frontend/editor-ui/src/api/testDefinition.ee.ts b/packages/frontend/editor-ui/src/api/testDefinition.ee.ts index c15b2bc9d3e..cf4a123b10a 100644 --- a/packages/frontend/editor-ui/src/api/testDefinition.ee.ts +++ b/packages/frontend/editor-ui/src/api/testDefinition.ee.ts @@ -83,8 +83,6 @@ export interface TestCaseExecutionRecord { } const endpoint = '/evaluation/test-definitions'; -const getMetricsEndpoint = (testDefinitionId: string, metricId?: string) => - `${endpoint}/${testDefinitionId}/metrics${metricId ? `/${metricId}` : ''}`; export async function getTestDefinitions( context: IRestApiContext, @@ -141,86 +139,6 @@ export async function getExampleEvaluationInput( ); } -// Metrics -export interface TestMetricRecord { - id: string; - name: string; - testDefinitionId: string; - createdAt?: string; - updatedAt?: string; -} - -export interface CreateTestMetricParams { - testDefinitionId: string; - name: string; -} - -export interface UpdateTestMetricParams { - name: string; - id: string; - testDefinitionId: string; -} - -export interface DeleteTestMetricParams { - testDefinitionId: string; - id: string; -} - -export const getTestMetrics = async (context: IRestApiContext, testDefinitionId: string) => { - return await makeRestApiRequest( - context, - 'GET', - getMetricsEndpoint(testDefinitionId), - ); -}; - -export const getTestMetric = async ( - context: IRestApiContext, - testDefinitionId: string, - id: string, -) => { - return await makeRestApiRequest( - context, - 'GET', - getMetricsEndpoint(testDefinitionId, id), - ); -}; - -export const createTestMetric = async ( - context: IRestApiContext, - params: CreateTestMetricParams, -) => { - return await makeRestApiRequest( - context, - 'POST', - getMetricsEndpoint(params.testDefinitionId), - { name: params.name }, - ); -}; - -export const updateTestMetric = async ( - context: IRestApiContext, - params: UpdateTestMetricParams, -) => { - return await makeRestApiRequest( - context, - 'PATCH', - getMetricsEndpoint(params.testDefinitionId, params.id), - { name: params.name }, - ); -}; - -export const deleteTestMetric = async ( - context: IRestApiContext, - params: DeleteTestMetricParams, -) => { - return await makeRestApiRequest( - context, - 'DELETE', - getMetricsEndpoint(params.testDefinitionId, params.id), - ); -}; - const getRunsEndpoint = (testDefinitionId: string, runId?: string) => `${endpoint}/${testDefinitionId}/runs${runId ? `/${runId}` : ''}`; diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue index 96f940711eb..5e9512fb213 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/Assignment.vue @@ -17,6 +17,7 @@ interface Props { modelValue: AssignmentValue; issues: string[]; hideType?: boolean; + disableType?: boolean; isReadOnly?: boolean; index?: number; } @@ -163,7 +164,7 @@ const onBlur = (): void => { diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts index 39a9638fa00..43c31c1e0ff 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.test.ts @@ -151,4 +151,69 @@ describe('AssignmentCollection.vue', () => { expect(getAssignmentType(assignments[3])).toEqual('Object'); expect(getAssignmentType(assignments[4])).toEqual('Array'); }); + + describe('defaultType prop', () => { + it('should use string as default type when no defaultType is specified', async () => { + const { getByTestId, findAllByTestId } = renderComponent(); + + await userEvent.click(getByTestId('assignment-collection-drop-area')); + + const assignments = await findAllByTestId('assignment'); + expect(assignments.length).toBe(1); + expect(getAssignmentType(assignments[0])).toEqual('String'); + }); + + it('should use specified defaultType when adding a new assignment manually', async () => { + const { getByTestId, findAllByTestId } = renderComponent({ + props: { + defaultType: 'number', + }, + }); + + await userEvent.click(getByTestId('assignment-collection-drop-area')); + + const assignments = await findAllByTestId('assignment'); + expect(assignments.length).toBe(1); + expect(getAssignmentType(assignments[0])).toEqual('Number'); + }); + + it('should use defaultType for drag and drop when disableType is true', async () => { + const { getByTestId, findAllByTestId } = renderComponent({ + props: { + defaultType: 'number', + disableType: true, + }, + }); + + const dropArea = getByTestId('drop-area'); + + // Even though we're dropping a string value, it should use number type because of defaultType + await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea }); + + const assignments = await findAllByTestId('assignment'); + expect(assignments.length).toBe(1); + expect(getAssignmentType(assignments[0])).toEqual('Number'); + }); + + it('should respect defaultType for all assignments when provided', async () => { + const { getByTestId, findAllByTestId } = renderComponent({ + props: { + defaultType: 'boolean', + }, + }); + + const dropArea = getByTestId('drop-area'); + + await userEvent.click(getByTestId('assignment-collection-drop-area')); + + await dropAssignment({ key: 'stringKey', value: 'stringValue', dropArea }); + await dropAssignment({ key: 'numberKey', value: 25, dropArea }); + + const assignments = await findAllByTestId('assignment'); + expect(assignments.length).toBe(3); + expect(getAssignmentType(assignments[0])).toEqual('Boolean'); + expect(getAssignmentType(assignments[1])).toEqual('Boolean'); + expect(getAssignmentType(assignments[2])).toEqual('Boolean'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue index 213269ab2f3..6d0d3bfcbd3 100644 --- a/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue +++ b/packages/frontend/editor-ui/src/components/AssignmentCollection/AssignmentCollection.vue @@ -5,6 +5,7 @@ import { useNDVStore } from '@/stores/ndv.store'; import type { AssignmentCollectionValue, AssignmentValue, + FieldTypeMap, INode, INodeProperties, } from 'n8n-workflow'; @@ -20,11 +21,17 @@ interface Props { parameter: INodeProperties; value: AssignmentCollectionValue; path: string; + defaultType?: keyof FieldTypeMap; + disableType?: boolean; node: INode | null; isReadOnly?: boolean; } -const props = withDefaults(defineProps(), { isReadOnly: false }); +const props = withDefaults(defineProps(), { + isReadOnly: false, + defaultType: undefined, + disableType: false, +}); const emit = defineEmits<{ valueChanged: [value: { name: string; node: string; value: AssignmentCollectionValue }]; @@ -82,7 +89,7 @@ function addAssignment(): void { id: crypto.randomUUID(), name: '', value: '', - type: 'string', + type: props.defaultType ?? 'string', }); } @@ -91,7 +98,7 @@ function dropAssignment(expression: string): void { id: crypto.randomUUID(), name: propertyNameFromExpression(expression), value: `=${expression}`, - type: typeFromExpression(expression), + type: props.defaultType ?? typeFromExpression(expression), }); } @@ -157,6 +164,7 @@ function optionSelected(action: string) { :issues="getIssues(index)" :class="$style.assignment" :is-read-only="isReadOnly" + :disable-type="disableType" @update:model-value="(value) => onAssignmentUpdate(index, value)" @remove="() => onAssignmentRemove(index)" > diff --git a/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue b/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue index fddfe8e3fd5..a2f25fc18e3 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue +++ b/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue @@ -3,7 +3,7 @@ import { ref } from 'vue'; import { createEventBus } from '@n8n/utils/event-bus'; import type { Validatable, IValidator } from '@n8n/design-system'; import { N8nFormInput } from '@n8n/design-system'; -import { VALID_EMAIL_REGEX } from '@/constants'; +import { VALID_EMAIL_REGEX, COMMUNITY_PLUS_DOCS_URL } from '@/constants'; import Modal from '@/components/Modal.vue'; import { useI18n } from '@/composables/useI18n'; import { useToast } from '@/composables/useToast'; @@ -86,9 +86,6 @@ const confirm = async () => { > diff --git a/packages/frontend/editor-ui/src/components/ParameterInputList.vue b/packages/frontend/editor-ui/src/components/ParameterInputList.vue index 740e4de47cb..8c625b348d8 100644 --- a/packages/frontend/editor-ui/src/components/ParameterInputList.vue +++ b/packages/frontend/editor-ui/src/components/ParameterInputList.vue @@ -665,6 +665,8 @@ function getParameterValue
diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts index 3fca181e4c8..57f5c2cd621 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts @@ -12,6 +12,7 @@ import { VIEWS } from '@/constants'; import userEvent from '@testing-library/user-event'; import { waitFor, within } from '@testing-library/vue'; import { useSettingsStore } from '@/stores/settings.store'; +import { useOverview } from '@/composables/useOverview'; const mockPush = vi.fn(); vi.mock('vue-router', async () => { @@ -30,6 +31,12 @@ vi.mock('vue-router', async () => { }; }); +vi.mock('@/composables/useOverview', () => ({ + useOverview: vi.fn().mockReturnValue({ + isOverviewSubPage: false, + }), +})); + const projectTabsSpy = vi.fn().mockReturnValue({ render: vi.fn(), }); @@ -45,6 +52,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, { let route: ReturnType; let projectsStore: ReturnType>; let settingsStore: ReturnType>; +let overview: ReturnType; describe('ProjectHeader', () => { beforeEach(() => { @@ -52,6 +60,7 @@ describe('ProjectHeader', () => { route = router.useRoute(); projectsStore = mockedStore(useProjectsStore); settingsStore = mockedStore(useSettingsStore); + overview = useOverview(); projectsStore.teamProjectsLimit = -1; settingsStore.settings.folders = { enabled: false }; @@ -61,6 +70,27 @@ describe('ProjectHeader', () => { vi.clearAllMocks(); }); + it('should not render title icon on overview page', async () => { + vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(true); + const { container } = renderComponent(); + + expect(container.querySelector('.fa-home')).not.toBeInTheDocument(); + }); + + it('should render the correct icon', async () => { + vi.spyOn(overview, 'isOverviewSubPage', 'get').mockReturnValue(false); + const { container, rerender } = renderComponent(); + + projectsStore.currentProject = { type: ProjectTypes.Personal } as Project; + await rerender({}); + expect(container.querySelector('.fa-user')).toBeVisible(); + + const projectName = 'My Project'; + projectsStore.currentProject = { name: projectName } as Project; + await rerender({}); + expect(container.querySelector('.fa-layer-group')).toBeVisible(); + }); + it('should render the correct title and subtitle', async () => { const { getByText, queryByText, rerender } = renderComponent(); const subtitle = 'All the workflows, credentials and executions you have access to'; diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index 5cd8f35f877..0450e200b63 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -4,14 +4,16 @@ import { useRoute, useRouter } from 'vue-router'; import type { UserAction } from '@n8n/design-system'; import { N8nButton, N8nTooltip } from '@n8n/design-system'; import { useI18n } from '@/composables/useI18n'; -import { ProjectTypes } from '@/types/projects.types'; +import { type ProjectIcon as ProjectIconType, ProjectTypes } from '@/types/projects.types'; import { useProjectsStore } from '@/stores/projects.store'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; +import ProjectIcon from '@/components/Projects/ProjectIcon.vue'; import { getResourcePermissions } from '@/permissions'; import { VIEWS } from '@/constants'; import { useSourceControlStore } from '@/stores/sourceControl.store'; import ProjectCreateResource from '@/components/Projects/ProjectCreateResource.vue'; import { useSettingsStore } from '@/stores/settings.store'; +import { useOverview } from '@/composables/useOverview'; const route = useRoute(); const router = useRouter(); @@ -19,11 +21,22 @@ const i18n = useI18n(); const projectsStore = useProjectsStore(); const sourceControlStore = useSourceControlStore(); const settingsStore = useSettingsStore(); +const overview = useOverview(); const emit = defineEmits<{ createFolder: []; }>(); +const headerIcon = computed((): ProjectIconType => { + if (projectsStore.currentProject?.type === ProjectTypes.Personal) { + return { type: 'icon', value: 'user' }; + } else if (projectsStore.currentProject?.name) { + return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' }; + } else { + return { type: 'icon', value: 'home' }; + } +}); + const projectName = computed(() => { if (!projectsStore.currentProject) { return i18n.baseText('projects.menu.overview'); @@ -48,7 +61,10 @@ const showSettings = computed( const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject); const showFolders = computed(() => { - return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS; + return ( + settingsStore.isFoldersFeatureEnabled && + [VIEWS.PROJECTS_WORKFLOWS, VIEWS.PROJECTS_FOLDERS].includes(route.name as VIEWS) + ); }); const ACTION_TYPES = { @@ -126,6 +142,12 @@ const onSelect = (action: string) => {
+
{{ projectName }} @@ -168,7 +190,7 @@ const onSelect = (action: string) => { .projectHeader, .projectDescription { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; padding-bottom: var(--spacing-m); min-height: var(--spacing-3xl); diff --git a/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue b/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue deleted file mode 100644 index f679b6cdfa9..00000000000 --- a/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/MetricsInput.vue +++ /dev/null @@ -1,70 +0,0 @@ - - - - - diff --git a/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue b/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue index 038bbf71f30..c02d02064da 100644 --- a/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue +++ b/packages/frontend/editor-ui/src/components/TestDefinition/EditDefinition/sections/ConfigSection.vue @@ -1,8 +1,6 @@ diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue index a6366de606a..2c9aab41b83 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/CanvasNodeDefault.vue @@ -107,6 +107,10 @@ const isStrikethroughVisible = computed(() => { return isDisabled.value && isSingleMainInputNode && isSingleMainOutputNode; }); +const iconSize = computed(() => (renderOptions.value.configuration ? 30 : 40)); + +const iconSource = computed(() => renderOptions.value.icon); + const showTooltip = ref(false); watch(initialized, () => { @@ -140,7 +144,7 @@ function onActivate() { @dblclick.stop="onActivate" > - +
@@ -171,6 +175,7 @@ function onActivate() { --configurable-node--icon-size: 30px; --trigger-node--border-radius: 36px; --canvas-node--status-icons-offset: var(--spacing-3xs); + --node-icon-color: var(--color-foreground-dark); position: relative; height: var(--canvas-node--height); diff --git a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap index 71b7c37c5c3..cd5075b450b 100644 --- a/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap +++ b/packages/frontend/editor-ui/src/components/canvas/elements/nodes/render-types/__snapshots__/CanvasNodeDefault.test.ts.snap @@ -7,8 +7,24 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
configuration > should render configurable configur style="--configurable-node--input-count: 0; --canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
configuration > should render configuration node co style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
should render node correctly 1`] = ` style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
trigger > should render trigger node correctly 1`] style="--canvas-node--main-input-count: 0; --canvas-node--main-output-count: 0;" > - - +
+
+ + +
+ ? +
+ +
+
{ const pinia = createTestingPinia({ @@ -147,6 +148,10 @@ describe('useCanvasMapping', () => { options: { configurable: false, configuration: false, + icon: { + src: '/nodes/test-node/icon.svg', + type: 'file', + }, trigger: true, inputs: { labelSize: 'small', @@ -280,12 +285,19 @@ describe('useCanvasMapping', () => { workflowObject: ref(workflowObject) as Ref, }); + const rootStore = mockedStore(useRootStore); + rootStore.baseUrl = 'http://test.local/'; + expect(mappedNodes.value[0]?.data?.render).toEqual({ type: CanvasNodeRenderType.Default, options: { configurable: false, configuration: false, trigger: true, + icon: { + src: 'http://test.local/nodes/test-node/icon.svg', + type: 'file', + }, inputs: { labelSize: 'small', }, diff --git a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts index 4cc9d1d2948..ae92ec6c4dd 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasMapping.ts @@ -44,12 +44,20 @@ import { WAIT_INDEFINITELY, } from 'n8n-workflow'; import type { INodeUi } from '@/Interface'; -import { CUSTOM_API_CALL_KEY, FORM_NODE_TYPE, STICKY_NODE_TYPE, WAIT_NODE_TYPE } from '@/constants'; +import { + CUSTOM_API_CALL_KEY, + FORM_NODE_TYPE, + SIMULATE_NODE_TYPE, + SIMULATE_TRIGGER_NODE_TYPE, + STICKY_NODE_TYPE, + WAIT_NODE_TYPE, +} from '@/constants'; import { sanitizeHtml } from '@/utils/htmlUtils'; import { MarkerType } from '@vue-flow/core'; import { useNodeHelpers } from './useNodeHelpers'; import { getTriggerNodeServiceName } from '@/utils/nodeTypesUtils'; import { useNodeDirtiness } from '@/composables/useNodeDirtiness'; +import { getNodeIconSource } from '../utils/nodeIcon'; export function useCanvasMapping({ nodes, @@ -86,6 +94,13 @@ export function useCanvasMapping({ } function createDefaultNodeRenderType(node: INodeUi): CanvasNodeDefaultRender { + const nodeType = nodeTypeDescriptionByNodeId.value[node.id]; + const icon = getNodeIconSource( + simulatedNodeTypeDescriptionByNodeId.value[node.id] + ? simulatedNodeTypeDescriptionByNodeId.value[node.id] + : nodeType, + ); + return { type: CanvasNodeRenderType.Default, options: { @@ -100,6 +115,7 @@ export function useCanvasMapping({ }, tooltip: nodeTooltipById.value[node.id], dirtiness: dirtinessByName.value[node.name], + icon, }, }; } @@ -510,6 +526,26 @@ export function useCanvasMapping({ ); }); + const simulatedNodeTypeDescriptionByNodeId = computed(() => { + return nodes.value.reduce>((acc, node) => { + if ([SIMULATE_NODE_TYPE, SIMULATE_TRIGGER_NODE_TYPE].includes(node.type)) { + const icon = node.parameters?.icon as string; + const iconValue = workflowObject.value.expression.getSimpleParameterValue( + node, + icon, + 'internal', + {}, + ); + + if (iconValue && typeof iconValue === 'string') { + acc[node.id] = nodeTypesStore.getNodeType(iconValue); + } + } + + return acc; + }, {}); + }); + const mappedNodes = computed(() => [ ...nodes.value.map((node) => { const inputConnections = workflowObject.value.connectionsByDestinationNode[node.name] ?? {}; diff --git a/packages/frontend/editor-ui/src/composables/useDataSchema.ts b/packages/frontend/editor-ui/src/composables/useDataSchema.ts index 44197c18f2d..32420280a3c 100644 --- a/packages/frontend/editor-ui/src/composables/useDataSchema.ts +++ b/packages/frontend/editor-ui/src/composables/useDataSchema.ts @@ -233,6 +233,7 @@ export type SchemaNode = { itemsCount: number; schema: Schema; preview: boolean; + hasBinary: boolean; }; export type RenderItem = { @@ -295,10 +296,12 @@ const icons = { const getIconBySchemaType = (type: Schema['type']): string => icons[type]; -const emptyItem = (): RenderItem => ({ +const emptyItem = ( + message = useI18n().baseText('dataMapping.schemaView.emptyData'), +): RenderItem => ({ id: `empty-${window.crypto.randomUUID()}`, icon: '', - value: useI18n().baseText('dataMapping.schemaView.emptyData'), + value: message, type: 'item', }); @@ -457,7 +460,12 @@ export const useFlattenSchema = () => { } if (isDataEmpty(item.schema)) { - acc.push(emptyItem()); + const message = useI18n().baseText( + item.hasBinary + ? 'dataMapping.schemaView.emptyDataWithBinary' + : 'dataMapping.schemaView.emptyData', + ); + acc.push(emptyItem(message)); return acc; } diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts index cb1d8871b3a..5652851931a 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts @@ -1,6 +1,7 @@ import { HTTP_REQUEST_NODE_TYPE, MODAL_CANCEL, + MODAL_CLOSE, MODAL_CONFIRM, PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_FILLED_AT_EXECUTION_TIME, @@ -827,6 +828,12 @@ export function useWorkflowHelpers(options: { router: ReturnType {\n const newExecutionRuns = Object.values($json.newExecution)\n .reduce((acc, node) => {\n acc.push(node.runs.filter(run => run.output.main !== undefined))\n return acc\n }, []).flat()\n\n const latency = newExecutionRuns.reduce((acc, run) => acc + run.executionTime, 0)\n\n return latency\n})()}}', type: 'number', + id: '1ebc15e9-f079-4d1f-a08d-d4880ea0ddb5', }, ], }, - options: {}, }, + type: 'n8n-nodes-base.evaluationMetrics', id: '33e2e94a-ec48-4e7b-b750-f56718d5105c', name: 'Return metric(s)', - type: 'n8n-nodes-base.set', - typeVersion: 3.4, + typeVersion: 1, position: [600, 440], }, { diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index 04b5e900e0b..2b39e92dec3 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -667,6 +667,7 @@ "dataMapping.tableView.tableColumnsExceeded.tooltip": "Your data has more than {columnLimit} columns so some are hidden. Switch to {link} to see all data.", "dataMapping.tableView.tableColumnsExceeded.tooltip.link": "JSON view", "dataMapping.schemaView.emptyData": "No fields - item(s) exist, but they're empty", + "dataMapping.schemaView.emptyDataWithBinary": "Only binary data exists. View it using the 'Binary' tab", "dataMapping.schemaView.disabled": "This node is disabled and will just pass data through", "dataMapping.schemaView.noMatches": "No results for '{search}'", "dataMapping.schemaView.preview": "Usually outputs the following fields. Execute the node to see the actual ones. {link}", @@ -2909,6 +2910,7 @@ "communityPlusModal.input.email.label": "Enter email to receive your license key", "communityPlusModal.button.skip": "Skip", "communityPlusModal.button.confirm": "Send me a free license key", + "communityPlusModal.notice": "Included features may change, but once unlocked, you'll keep them forever.", "executeWorkflowTrigger.createNewSubworkflow": "Create a Sub-Workflow in {projectName}", "executeWorkflowTrigger.createNewSubworkflow.noProject": "Create a New Sub-Workflow", "testDefinition.edit.descriptionPlaceholder": "Enter test description", @@ -2916,11 +2918,6 @@ "testDefinition.edit.hideConfig": "Hide config", "testDefinition.edit.backButtonTitle": "Back to Workflow Evaluation", "testDefinition.edit.namePlaceholder": "Enter test name", - "testDefinition.edit.metricsTitle": "Metrics", - "testDefinition.edit.metricsHelpText": "The output field of the last node in the evaluation workflow. Metrics will be averaged across all test cases.", - "testDefinition.edit.metricsFields": "Output fields to use as metrics", - "testDefinition.edit.metricsPlaceholder": "e.g. latency", - "testDefinition.edit.metricsNew": "New metric", "testDefinition.edit.selectTag": "Select tag...", "testDefinition.edit.tagsHelpText": "Executions with this tag will be added as test cases to this test.", "testDefinition.edit.workflowSelectorLabel": "Use a second workflow to make the comparison", @@ -2950,9 +2947,6 @@ "testDefinition.edit.step.reRunExecutions.tooltip": "Each past execution is re-run using the latest version of the workflow being tested", "testDefinition.edit.step.compareExecutions": "4. Compare each past and new execution", "testDefinition.edit.step.compareExecutions.tooltip": "Each past execution is compared with its new equivalent to check how similar they are. This is done using a separate evaluation workflow: it receives the two execution versions as input, and outputs metrics.", - "testDefinition.edit.step.metrics": "5. Summarise metrics", - "testDefinition.edit.step.metrics.tooltip": "Metrics returned by the evaluation workflow (defined above). If included in this section, they are displayed in the test run results and averaged to give a score for the entire test run.", - "testDefinition.edit.step.metrics.description": "The names of fields output by your evaluation workflow in the step above.", "testDefinition.edit.step.collapse": "Collapse", "testDefinition.edit.step.configure": "Configure", "testDefinition.edit.selectNodes": "Pin nodes to mock them", @@ -3023,10 +3017,6 @@ "testDefinition.runDetail.error.evaluationFailed.solution": "View evaluation execution", "testDefinition.runDetail.error.triggerNoLongerExists": "Trigger in benchmark execution no longer exists in workflow.{link}.", "testDefinition.runDetail.error.triggerNoLongerExists.solution": "View benchmark", - "testDefinition.runDetail.error.metricsMissing": "Metrics defined in test were not returned by evaluation workflow {link}.", - "testDefinition.runDetail.error.metricsMissing.solution": "Fix test configuration", - "testDefinition.runDetail.error.unknownMetrics": "Evaluation workflow defined metrics that are not defined in the test. {link}.", - "testDefinition.runDetail.error.unknownMetrics.solution": "Fix test configuration", "testDefinition.runDetail.error.invalidMetrics": "Evaluation workflow returned invalid metrics. Only numeric values are expected. View evaluation execution. {link}.", "testDefinition.runDetail.error.invalidMetrics.solution": "View evaluation execution", "testDefinition.runTest": "Run Test", diff --git a/packages/frontend/editor-ui/src/plugins/icons/index.ts b/packages/frontend/editor-ui/src/plugins/icons/index.ts index cb526515c47..ef2d11a2cc3 100644 --- a/packages/frontend/editor-ui/src/plugins/icons/index.ts +++ b/packages/frontend/editor-ui/src/plugins/icons/index.ts @@ -29,6 +29,7 @@ import { faChartBar, faCheck, faCheckCircle, + faCheckDouble, faCheckSquare, faChevronDown, faChevronUp, @@ -225,6 +226,7 @@ export const FontAwesomePlugin: Plugin = { addIcon(faChartBar); addIcon(faCheck); addIcon(faCheckCircle); + addIcon(faCheckDouble); addIcon(faCheckSquare); addIcon(faChevronLeft); addIcon(faChevronRight); diff --git a/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.test.ts b/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.test.ts index 6f43a939bb0..ed055a85b34 100644 --- a/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.test.ts +++ b/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.test.ts @@ -11,10 +11,6 @@ const { deleteTestDefinition, getTestDefinitions, updateTestDefinition, - getTestMetrics, - createTestMetric, - updateTestMetric, - deleteTestMetric, getTestRuns, getTestRun, startTestRun, @@ -24,10 +20,6 @@ const { createTestDefinition: vi.fn(), updateTestDefinition: vi.fn(), deleteTestDefinition: vi.fn(), - getTestMetrics: vi.fn(), - createTestMetric: vi.fn(), - updateTestMetric: vi.fn(), - deleteTestMetric: vi.fn(), getTestRuns: vi.fn(), getTestRun: vi.fn(), startTestRun: vi.fn(), @@ -39,10 +31,6 @@ vi.mock('@/api/testDefinition.ee', () => ({ deleteTestDefinition, getTestDefinitions, updateTestDefinition, - getTestMetrics, - createTestMetric, - updateTestMetric, - deleteTestMetric, getTestRuns, getTestRun, startTestRun, @@ -77,13 +65,6 @@ const TEST_DEF_NEW: TestDefinitionRecord = { createdAt: '2023-01-01T00:00:00.000Z', }; -const TEST_METRIC = { - id: 'metric1', - name: 'Test Metric', - testDefinitionId: '1', - createdAt: '2023-01-01T00:00:00.000Z', -}; - const TEST_RUN: TestRunRecord = { id: 'run1', testDefinitionId: '1', @@ -124,7 +105,6 @@ describe('testDefinition.store.ee', () => { getTestRun.mockResolvedValue(TEST_RUN); startTestRun.mockResolvedValue({ success: true }); deleteTestRun.mockResolvedValue({ success: true }); - getTestMetrics.mockResolvedValue([TEST_METRIC]); }); test('Initialization', () => { @@ -280,80 +260,6 @@ describe('testDefinition.store.ee', () => { }); }); - describe('Metrics', () => { - test('Fetching Metrics for a Test Definition', async () => { - const metrics = await store.fetchMetrics('1'); - - expect(getTestMetrics).toHaveBeenCalledWith(rootStoreMock.restApiContext, '1'); - expect(store.metricsById).toEqual({ - metric1: TEST_METRIC, - }); - expect(metrics).toEqual([TEST_METRIC]); - }); - - test('Creating a Metric', async () => { - createTestMetric.mockResolvedValue(TEST_METRIC); - - const params = { - name: 'Test Metric', - testDefinitionId: '1', - }; - - const result = await store.createMetric(params); - - expect(createTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, params); - expect(store.metricsById).toEqual({ - metric1: TEST_METRIC, - }); - expect(result).toEqual(TEST_METRIC); - }); - - test('Updating a Metric', async () => { - const updatedMetric = { ...TEST_METRIC, name: 'Updated Metric' }; - updateTestMetric.mockResolvedValue(updatedMetric); - - const result = await store.updateMetric(updatedMetric); - - expect(updateTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, updatedMetric); - expect(store.metricsById).toEqual({ - metric1: updatedMetric, - }); - expect(result).toEqual(updatedMetric); - }); - - test('Deleting a Metric', async () => { - store.metricsById = { - metric1: TEST_METRIC, - }; - - const params = { id: 'metric1', testDefinitionId: '1' }; - deleteTestMetric.mockResolvedValue(undefined); - - await store.deleteMetric(params); - - expect(deleteTestMetric).toHaveBeenCalledWith(rootStoreMock.restApiContext, params); - expect(store.metricsById).toEqual({}); - }); - - test('Getting Metrics by Test ID', () => { - const metric1 = { ...TEST_METRIC, id: 'metric1', testDefinitionId: '1' }; - const metric2 = { ...TEST_METRIC, id: 'metric2', testDefinitionId: '1' }; - const metric3 = { ...TEST_METRIC, id: 'metric3', testDefinitionId: '2' }; - - store.metricsById = { - metric1, - metric2, - metric3, - }; - - const metricsForTest1 = store.metricsByTestId['1']; - expect(metricsForTest1).toEqual([metric1, metric2]); - - const metricsForTest2 = store.metricsByTestId['2']; - expect(metricsForTest2).toEqual([metric3]); - }); - }); - describe('Computed Properties', () => { test('hasTestDefinitions', () => { store.testDefinitionsById = {}; diff --git a/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.ts b/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.ts index 283947f4be8..9206044bd9b 100644 --- a/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.ts +++ b/packages/frontend/editor-ui/src/stores/testDefinition.store.ee.ts @@ -21,7 +21,6 @@ export const useTestDefinitionStore = defineStore( const testDefinitionsById = ref>({}); const loading = ref(false); const fetchedAll = ref(false); - const metricsById = ref>({}); const testRunsById = ref>({}); const testCaseExecutionsById = ref>({}); const pollingTimeouts = ref>({}); @@ -61,19 +60,6 @@ export const useTestDefinitionStore = defineStore( const hasTestDefinitions = computed(() => Object.keys(testDefinitionsById.value).length > 0); - const metricsByTestId = computed(() => { - return Object.values(metricsById.value).reduce( - (acc: Record, metric) => { - if (!acc[metric.testDefinitionId]) { - acc[metric.testDefinitionId] = []; - } - acc[metric.testDefinitionId].push(metric); - return acc; - }, - {}, - ); - }); - const testRunsByTestId = computed(() => { return Object.values(testRunsById.value).reduce( (acc: Record, run) => { @@ -157,11 +143,6 @@ export const useTestDefinitionStore = defineStore( } }; - const fetchMetricsForAllTests = async () => { - const testDefinitions = Object.values(testDefinitionsById.value); - await Promise.all(testDefinitions.map(async (testDef) => await fetchMetrics(testDef.id))); - }; - const fetchTestDefinition = async (id: string) => { const testDefinition = await testDefinitionsApi.getTestDefinition( rootStore.restApiContext, @@ -221,7 +202,6 @@ export const useTestDefinitionStore = defineStore( await Promise.all([ tagsStore.fetchAll({ force: true, withUsageCount: true }), fetchRunsForAllTests(), - fetchMetricsForAllTests(), ]); return retrievedDefinitions; } finally { @@ -289,48 +269,6 @@ export const useTestDefinitionStore = defineStore( return result.success; }; - const fetchMetrics = async (testId: string) => { - loading.value = true; - try { - const metrics = await testDefinitionsApi.getTestMetrics(rootStore.restApiContext, testId); - metrics.forEach((metric) => { - metricsById.value[metric.id] = { ...metric, testDefinitionId: testId }; - }); - return metrics.map((metric) => ({ ...metric, testDefinitionId: testId })); - } finally { - loading.value = false; - } - }; - - const createMetric = async (params: { - name: string; - testDefinitionId: string; - }): Promise => { - const metric = await testDefinitionsApi.createTestMetric(rootStore.restApiContext, params); - metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId }; - return metric; - }; - - const updateMetric = async ( - params: testDefinitionsApi.TestMetricRecord, - ): Promise => { - const metric = await testDefinitionsApi.updateTestMetric(rootStore.restApiContext, params); - metricsById.value[metric.id] = { ...metric, testDefinitionId: params.testDefinitionId }; - - updateRunFieldIssues(params.testDefinitionId); - return metric; - }; - - const deleteMetric = async ( - params: testDefinitionsApi.DeleteTestMetricParams, - ): Promise => { - await testDefinitionsApi.deleteTestMetric(rootStore.restApiContext, params); - const { [params.id]: deleted, ...rest } = metricsById.value; - metricsById.value = rest; - - updateRunFieldIssues(params.testDefinitionId); - }; - // Test Runs Methods const fetchTestRuns = async (testDefinitionId: string) => { loading.value = true; @@ -436,14 +374,6 @@ export const useTestDefinitionStore = defineStore( }); } - const metrics = metricsByTestId.value[testId] || []; - if (metrics.filter((metric) => metric.name).length === 0) { - issues.push({ - field: 'metrics', - message: locale.baseText('testDefinition.configError.noMetrics'), - }); - } - fieldsIssues.value = { ...fieldsIssues.value, [testId]: issues, @@ -464,8 +394,6 @@ export const useTestDefinitionStore = defineStore( isLoading, hasTestDefinitions, isFeatureEnabled, - metricsById, - metricsByTestId, testRunsByTestId, lastRunByTestId, @@ -480,10 +408,6 @@ export const useTestDefinitionStore = defineStore( deleteById, upsertTestDefinitions, deleteTestDefinition, - fetchMetrics, - createMetric, - updateMetric, - deleteMetric, fetchTestRuns, getTestRun, startTestRun, diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts index c1c05e49666..0fe29d33e60 100644 --- a/packages/frontend/editor-ui/src/types/canvas.ts +++ b/packages/frontend/editor-ui/src/types/canvas.ts @@ -11,6 +11,7 @@ import type { import type { IExecutionResponse, INodeUi } from '@/Interface'; import type { ComputedRef, Ref } from 'vue'; import type { EventBus } from '@n8n/utils/event-bus'; +import type { NodeIconSource } from '../utils/nodeIcon'; export const enum CanvasConnectionMode { Input = 'inputs', @@ -71,6 +72,7 @@ export type CanvasNodeDefaultRender = { }; tooltip?: string; dirtiness?: CanvasNodeDirtinessType; + icon?: NodeIconSource; }>; }; diff --git a/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts b/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts new file mode 100644 index 00000000000..906d8568c5f --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/nodeIcon.test.ts @@ -0,0 +1,140 @@ +import { mock } from 'vitest-mock-extended'; +import { + getNodeIcon, + getNodeIconUrl, + getBadgeIconUrl, + getNodeIconSource, + type IconNodeType, +} from './nodeIcon'; + +vi.mock('../stores/root.store', () => ({ + useRootStore: vi.fn(() => ({ + baseUrl: 'https://example.com/', + })), +})); + +vi.mock('../stores/ui.store', () => ({ + useUIStore: vi.fn(() => ({ + appliedTheme: 'light', + })), +})); + +vi.mock('./nodeTypesUtils', () => ({ + getThemedValue: vi.fn((value, theme) => { + if (typeof value === 'object' && value !== null) { + return value[theme] || value.dark || value.light || null; + } + return value; + }), +})); + +describe('util: Node Icon', () => { + describe('getNodeIcon', () => { + it('should return the icon from nodeType', () => { + expect(getNodeIcon(mock({ icon: 'user', iconUrl: undefined }))).toBe('user'); + }); + + it('should return null if no icon is present', () => { + expect( + getNodeIcon(mock({ icon: undefined, iconUrl: '/test.svg' })), + ).toBeUndefined(); + }); + }); + + describe('getNodeIconUrl', () => { + it('should return the iconUrl from nodeType', () => { + expect( + getNodeIconUrl( + mock({ + iconUrl: { light: 'images/light-icon.svg', dark: 'images/dark-icon.svg' }, + }), + ), + ).toBe('images/light-icon.svg'); + }); + + it('should return null if no iconUrl is present', () => { + expect( + getNodeIconUrl(mock({ icon: 'foo', iconUrl: undefined })), + ).toBeUndefined(); + }); + }); + + describe('getBadgeIconUrl', () => { + it('should return the badgeIconUrl from nodeType', () => { + expect(getBadgeIconUrl({ badgeIconUrl: 'images/badge.svg' })).toBe('images/badge.svg'); + }); + + it('should return null if no badgeIconUrl is present', () => { + expect(getBadgeIconUrl({ badgeIconUrl: undefined })).toBeUndefined(); + }); + }); + + describe('getNodeIconSource', () => { + it('should return undefined if nodeType is null or undefined', () => { + expect(getNodeIconSource(null)).toBeUndefined(); + expect(getNodeIconSource(undefined)).toBeUndefined(); + }); + + it('should create an icon source from iconData.icon if available', () => { + const result = getNodeIconSource( + mock({ iconData: { type: 'icon', icon: 'pencil' } }), + ); + expect(result).toEqual({ + type: 'icon', + name: 'pencil', + color: undefined, + badge: undefined, + }); + }); + + it('should create a file source from iconData.fileBuffer if available', () => { + const result = getNodeIconSource( + mock({ + iconData: { + type: 'file', + icon: undefined, + fileBuffer: 'data://foo', + }, + }), + ); + expect(result).toEqual({ + type: 'file', + src: 'data://foo', + badge: undefined, + }); + }); + + it('should create a file source from iconUrl if available', () => { + const result = getNodeIconSource(mock({ iconUrl: 'images/node-icon.svg' })); + expect(result).toEqual({ + type: 'file', + src: 'https://example.com/images/node-icon.svg', + badge: undefined, + }); + }); + + it('should create an icon source from icon if available', () => { + const result = getNodeIconSource( + mock({ + icon: 'icon:user', + iconColor: 'blue', + iconData: undefined, + iconUrl: undefined, + }), + ); + expect(result).toEqual({ + type: 'icon', + name: 'user', + color: 'var(--color-node-icon-blue)', + }); + }); + + it('should include badge if available', () => { + const result = getNodeIconSource(mock({ badgeIconUrl: 'images/badge.svg' })); + expect(result?.badge).toEqual({ + type: 'file', + src: 'https://example.com/images/badge.svg', + }); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/utils/nodeIcon.ts b/packages/frontend/editor-ui/src/utils/nodeIcon.ts new file mode 100644 index 00000000000..1f79f1fd4e3 --- /dev/null +++ b/packages/frontend/editor-ui/src/utils/nodeIcon.ts @@ -0,0 +1,109 @@ +import { type INodeTypeDescription } from 'n8n-workflow'; +import type { IVersionNode } from '../Interface'; +import { useRootStore } from '../stores/root.store'; +import { useUIStore } from '../stores/ui.store'; +import { getThemedValue } from './nodeTypesUtils'; + +type NodeIconSourceIcon = { type: 'icon'; name: string; color?: string }; +type NodeIconSourceFile = { + type: 'file'; + src: string; +}; + +type BaseNodeIconSource = NodeIconSourceIcon | NodeIconSourceFile; +export type NodeIconSource = BaseNodeIconSource & { badge?: BaseNodeIconSource }; + +export type NodeIconType = 'file' | 'icon' | 'unknown'; + +type IconNodeTypeDescription = Pick< + INodeTypeDescription, + 'icon' | 'iconUrl' | 'iconColor' | 'defaults' | 'badgeIconUrl' +>; +type IconVersionNode = Pick; +export type IconNodeType = IconNodeTypeDescription | IconVersionNode; + +export const getNodeIcon = (nodeType: IconNodeType): string | null => { + return getThemedValue(nodeType.icon, useUIStore().appliedTheme); +}; + +export const getNodeIconUrl = (nodeType: IconNodeType): string | null => { + return getThemedValue(nodeType.iconUrl, useUIStore().appliedTheme); +}; + +export const getBadgeIconUrl = ( + nodeType: Pick, +): string | null => { + return getThemedValue(nodeType.badgeIconUrl, useUIStore().appliedTheme); +}; + +function getNodeIconColor(nodeType: IconNodeType) { + if ('iconColor' in nodeType && nodeType.iconColor) { + return `var(--color-node-icon-${nodeType.iconColor})`; + } + return nodeType?.defaults?.color?.toString(); +} + +function prefixBaseUrl(url: string) { + return useRootStore().baseUrl + url; +} + +export function getNodeIconSource(nodeType?: IconNodeType | null): NodeIconSource | undefined { + if (!nodeType) return undefined; + const createFileIconSource = (src: string): NodeIconSource => ({ + type: 'file', + src, + badge: getNodeBadgeIconSource(nodeType), + }); + const createNamedIconSource = (name: string): NodeIconSource => ({ + type: 'icon', + name, + color: getNodeIconColor(nodeType), + badge: getNodeBadgeIconSource(nodeType), + }); + + // If node type has icon data, use it + if ('iconData' in nodeType && nodeType.iconData) { + if (nodeType.iconData.icon) { + return createNamedIconSource(nodeType.iconData.icon); + } + + if (nodeType.iconData.fileBuffer) { + return createFileIconSource(nodeType.iconData.fileBuffer); + } + } + + const iconUrl = getNodeIconUrl(nodeType); + if (iconUrl) { + return createFileIconSource(prefixBaseUrl(iconUrl)); + } + + // Otherwise, extract it from icon prop + if (nodeType.icon) { + const icon = getNodeIcon(nodeType); + + if (icon) { + const [type, iconName] = icon.split(':'); + if (type === 'file') { + return undefined; + } + + return createNamedIconSource(iconName); + } + } + + return undefined; +} + +function getNodeBadgeIconSource(nodeType: IconNodeType): BaseNodeIconSource | undefined { + if (nodeType && 'badgeIconUrl' in nodeType && nodeType.badgeIconUrl) { + const badgeUrl = getBadgeIconUrl(nodeType); + + if (!badgeUrl) return undefined; + return { + type: 'file', + src: prefixBaseUrl(badgeUrl), + }; + } + + return undefined; +} diff --git a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts index 3b732dcda62..8ced5b02423 100644 --- a/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts +++ b/packages/frontend/editor-ui/src/utils/nodeTypesUtils.ts @@ -3,9 +3,7 @@ import type { INodeUi, INodeUpdatePropertiesInformation, ITemplatesNode, - IVersionNode, NodeAuthenticationOption, - SimplifiedNodeType, } from '@/Interface'; import { CORE_NODES_CATEGORY, @@ -502,33 +500,3 @@ export const getThemedValue = ( return value[theme]; }; - -export const getNodeIcon = ( - nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode, - theme: AppliedThemeOption = 'light', -): string | null => { - return getThemedValue(nodeType.icon, theme); -}; - -export const getNodeIconUrl = ( - nodeType: INodeTypeDescription | SimplifiedNodeType | IVersionNode, - theme: AppliedThemeOption = 'light', -): string | null => { - return getThemedValue(nodeType.iconUrl, theme); -}; - -export const getBadgeIconUrl = ( - nodeType: INodeTypeDescription | SimplifiedNodeType, - theme: AppliedThemeOption = 'light', -): string | null => { - return getThemedValue(nodeType.badgeIconUrl, theme); -}; - -export const getNodeIconColor = ( - nodeType?: INodeTypeDescription | SimplifiedNodeType | IVersionNode | null, -) => { - if (nodeType && 'iconColor' in nodeType && nodeType.iconColor) { - return `var(--color-node-icon-${nodeType.iconColor})`; - } - return nodeType?.defaults?.color?.toString(); -}; diff --git a/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue b/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue index 719f5605ac1..45a89c405e2 100644 --- a/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue +++ b/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionEditView.vue @@ -9,7 +9,7 @@ import { useAnnotationTagsStore } from '@/stores/tags.store'; import { computed, ref, watch } from 'vue'; import { useRouter } from 'vue-router'; -import type { TestMetricRecord, TestRunRecord } from '@/api/testDefinition.ee'; +import type { TestRunRecord } from '@/api/testDefinition.ee'; import InlineNameEdit from '@/components/InlineNameEdit.vue'; import ConfigSection from '@/components/TestDefinition/EditDefinition/sections/ConfigSection.vue'; import RunsSection from '@/components/TestDefinition/EditDefinition/sections/RunsSection.vue'; @@ -44,17 +44,8 @@ watch(visibility, async () => { testDefinitionStore.updateRunFieldIssues(props.testId); }); -const { - state, - isSaving, - cancelEditing, - loadTestData, - updateTest, - startEditing, - saveChanges, - deleteMetric, - updateMetrics, -} = useTestDefinitionForm(); +const { state, isSaving, cancelEditing, loadTestData, updateTest, startEditing, saveChanges } = + useTestDefinitionForm(); const isLoading = computed(() => tagsStore.isLoading); const tagsById = computed(() => tagsStore.tagsById); @@ -79,22 +70,11 @@ const handleUpdateTest = async () => { }; const handleUpdateTestDebounced = debounce(handleUpdateTest, { debounceTime: 400, trailing: true }); -const handleUpdateMetricsDebounced = debounce( - async (testId: string) => { - await updateMetrics(testId); - testDefinitionStore.updateRunFieldIssues(testId); - }, - { debounceTime: 400, trailing: true }, -); function getFieldIssues(key: string) { return fieldsIssues.value.filter((issue) => issue.field === key); } -async function onDeleteMetric(deletedMetric: TestMetricRecord) { - await deleteMetric(deletedMetric.id, props.testId); -} - async function openPinningModal() { uiStore.openModal(NODE_PINNING_MODAL_KEY); } @@ -253,7 +233,6 @@ function onEvaluationWorkflowCreated(workflowId: string) { v-if="showConfig" v-model:tags="state.tags" v-model:evaluationWorkflow="state.evaluationWorkflow" - v-model:metrics="state.metrics" v-model:mockedNodes="state.mockedNodes" :class="$style.config" :cancel-editing="cancelEditing" @@ -266,11 +245,9 @@ function onEvaluationWorkflowCreated(workflowId: string) { :example-pinned-data="examplePinnedData" :sample-workflow-name="workflowName" @rename-tag="renameTag" - @update:metrics="() => handleUpdateMetricsDebounced(testId)" @update:evaluation-workflow="handleUpdateTestDebounced" @update:mocked-nodes="handleUpdateTestDebounced" @open-pinning-modal="openPinningModal" - @delete-metric="onDeleteMetric" @open-executions-view-for-tag="openExecutionsViewForTag" @evaluation-workflow-created="onEvaluationWorkflowCreated($event)" /> diff --git a/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionRunDetailView.vue b/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionRunDetailView.vue index 11d242a8bf6..aaffda172ff 100644 --- a/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionRunDetailView.vue +++ b/packages/frontend/editor-ui/src/views/TestDefinition/TestDefinitionRunDetailView.vue @@ -19,8 +19,6 @@ const TEST_CASE_EXECUTION_ERROR_CODE = { FAILED_TO_EXECUTE_WORKFLOW: 'FAILED_TO_EXECUTE_WORKFLOW', EVALUATION_WORKFLOW_DOES_NOT_EXIST: 'EVALUATION_WORKFLOW_DOES_NOT_EXIST', FAILED_TO_EXECUTE_EVALUATION_WORKFLOW: 'FAILED_TO_EXECUTE_EVALUATION_WORKFLOW', - METRICS_MISSING: 'METRICS_MISSING', - UNKNOWN_METRICS: 'UNKNOWN_METRICS', INVALID_METRICS: 'INVALID_METRICS', PAYLOAD_LIMIT_EXCEEDED: 'PAYLOAD_LIMIT_EXCEEDED', UNKNOWN_ERROR: 'UNKNOWN_ERROR', @@ -97,8 +95,6 @@ const testCaseErrorDictionary: Partial { executionId: row.pastExecutionId, }, }; - } else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.METRICS_MISSING) { - return { - name: VIEWS.TEST_DEFINITION_EDIT, - params: { - testId: testId.value, - }, - }; - } else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.UNKNOWN_METRICS) { - return { - name: VIEWS.TEST_DEFINITION_EDIT, - params: { - testId: testId.value, - }, - }; } else if (row.errorCode === TEST_CASE_EXECUTION_ERROR_CODE.INVALID_METRICS) { return { name: VIEWS.EXECUTION_PREVIEW, diff --git a/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts b/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts index e900649e924..7f709a332eb 100644 --- a/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts +++ b/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionEditView.test.ts @@ -14,7 +14,6 @@ const form: Partial> = { description: { value: '', isEditing: false, tempValue: '' }, tags: { value: [], tempValue: [], isEditing: false }, evaluationWorkflow: { mode: 'list', value: '', __rl: true }, - metrics: [], mockedNodes: [], }), loadTestData: vi.fn(), @@ -22,8 +21,6 @@ const form: Partial> = { updateTest: vi.fn(), startEditing: vi.fn(), saveChanges: vi.fn(), - deleteMetric: vi.fn(), - updateMetrics: vi.fn(), createTest: vi.fn(), }; vi.mock('@/components/TestDefinition/composables/useTestDefinitionForm', () => ({ diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue index c478ecd78cf..3bbbbca12ca 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue @@ -327,11 +327,16 @@ const hasFilters = computed(() => { ); }); -const isCommunity = computed(() => usageStore.planName.toLowerCase() === 'community'); +const isSelfHostedDeployment = computed(() => settingsStore.deploymentType === 'default'); + const canUserRegisterCommunityPlus = computed( () => getResourcePermissions(usersStore.currentUser?.globalScopes).community.register, ); +const showRegisteredCommunityCTA = computed( + () => isSelfHostedDeployment.value && !foldersEnabled.value && canUserRegisterCommunityPlus.value, +); + /** * WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS */ @@ -1042,8 +1047,8 @@ const renameFolder = async (folderId: string) => { }; const createFolderInCurrent = async () => { - // Show the community plus enrollment modal if the user is in a community plan - if (isCommunity.value && canUserRegisterCommunityPlus.value) { + // Show the community plus enrollment modal if the user is self-hosted, and hasn't enabled folders + if (showRegisteredCommunityCTA.value) { uiStore.openModalWithData({ name: COMMUNITY_PLUS_ENROLLMENT_MODAL, data: { customHeading: i18n.baseText('folders.registeredCommunity.cta.heading') }, @@ -1125,7 +1130,7 @@ const moveWorkflowToFolder = async (payload: { name: string; parentFolderId?: string; }) => { - if (isCommunity.value && canUserRegisterCommunityPlus.value) { + if (showRegisteredCommunityCTA.value) { uiStore.openModalWithData({ name: COMMUNITY_PLUS_ENROLLMENT_MODAL, data: { customHeading: i18n.baseText('folders.registeredCommunity.cta.heading') }, @@ -1218,7 +1223,7 @@ const onCreateWorkflowClick = () => { @sort="onSortUpdated" > -