From cbc690907fa36e2fde0218dd6f7737d00498c674 Mon Sep 17 00:00:00 2001 From: Michael Auerswald Date: Fri, 10 Nov 2023 23:48:31 +0100 Subject: [PATCH] feat(editor): Adds a EE view to show worker details and job status (#7600) This change expands on the command channel communication introduced lately between the main instance(s) and the workers. The frontend gets a new menu entry "Workers" which will, when opened, trigger a regular call to getStatus from the workers. The workers then respond via their response channel to the backend, which then pushes the status to the frontend. This introduces the use of ChartJS for metrics. This feature is still in MVP state and thus disabled by default for the moment. --- cypress/e2e/32-worker-view.cy.ts | 43 +++++ cypress/pages/index.ts | 1 + cypress/pages/workerView.ts | 15 ++ cypress/support/commands.ts | 9 +- cypress/support/index.ts | 2 + packages/cli/src/Interfaces.ts | 35 +++- packages/cli/src/License.ts | 4 + packages/cli/src/constants.ts | 1 + .../cli/src/controllers/e2e.controller.ts | 8 + .../controllers/orchestration.controller.ts | 20 ++- packages/cli/src/services/frontend.service.ts | 4 + .../main/handleWorkerResponseMessageMain.ts | 20 ++- .../worker/handleCommandMessageWorker.ts | 31 ++-- .../services/redis/RedisServiceCommands.ts | 17 +- packages/editor-ui/package.json | 2 + packages/editor-ui/src/Interface.ts | 46 +++++- .../__tests__/server/endpoints/settings.ts | 6 +- packages/editor-ui/src/api/orchestration.ts | 8 + .../editor-ui/src/components/MainSidebar.vue | 15 ++ .../src/components/WorkerList.ee.vue | 130 +++++++++++++++ .../components/Workers/WorkerAccordion.ee.vue | 69 ++++++++ .../src/components/Workers/WorkerCard.ee.vue | 133 +++++++++++++++ .../Workers/WorkerChartsAccordion.ee.vue | 151 ++++++++++++++++++ .../Workers/WorkerJobAccordion.ee.vue | 68 ++++++++ .../Workers/WorkerNetAccordion.ee.vue | 69 ++++++++ packages/editor-ui/src/constants.ts | 2 + packages/editor-ui/src/main.ts | 2 + .../editor-ui/src/mixins/pushConnection.ts | 13 +- packages/editor-ui/src/plugins/chartjs.ts | 26 +++ .../src/plugins/i18n/locales/en.json | 14 ++ packages/editor-ui/src/plugins/icons/index.ts | 2 + packages/editor-ui/src/plugins/index.ts | 1 + packages/editor-ui/src/router.ts | 16 ++ .../src/stores/orchestration.store.ts | 76 +++++++++ .../editor-ui/src/stores/settings.store.ts | 3 + packages/editor-ui/src/utils/workerUtils.ts | 11 ++ packages/editor-ui/src/views/WorkerView.vue | 66 ++++++++ packages/workflow/src/Interfaces.ts | 1 + pnpm-lock.yaml | 35 +++- 39 files changed, 1125 insertions(+), 50 deletions(-) create mode 100644 cypress/e2e/32-worker-view.cy.ts create mode 100644 cypress/pages/workerView.ts create mode 100644 packages/editor-ui/src/api/orchestration.ts create mode 100644 packages/editor-ui/src/components/WorkerList.ee.vue create mode 100644 packages/editor-ui/src/components/Workers/WorkerAccordion.ee.vue create mode 100644 packages/editor-ui/src/components/Workers/WorkerCard.ee.vue create mode 100644 packages/editor-ui/src/components/Workers/WorkerChartsAccordion.ee.vue create mode 100644 packages/editor-ui/src/components/Workers/WorkerJobAccordion.ee.vue create mode 100644 packages/editor-ui/src/components/Workers/WorkerNetAccordion.ee.vue create mode 100644 packages/editor-ui/src/plugins/chartjs.ts create mode 100644 packages/editor-ui/src/stores/orchestration.store.ts create mode 100644 packages/editor-ui/src/utils/workerUtils.ts create mode 100644 packages/editor-ui/src/views/WorkerView.vue diff --git a/cypress/e2e/32-worker-view.cy.ts b/cypress/e2e/32-worker-view.cy.ts new file mode 100644 index 00000000000..2522fb881fc --- /dev/null +++ b/cypress/e2e/32-worker-view.cy.ts @@ -0,0 +1,43 @@ +import { INSTANCE_MEMBERS } from '../constants'; +import { WorkerViewPage } from '../pages'; + +const workerViewPage = new WorkerViewPage(); + +describe('Worker View (unlicensed)', () => { + beforeEach(() => { + cy.disableFeature('workerView'); + cy.disableQueueMode(); + }); + + it('should not show up in the menu sidebar', () => { + cy.signin(INSTANCE_MEMBERS[0]); + cy.visit(workerViewPage.url); + workerViewPage.getters.menuItem().should('not.exist'); + }); + + it('should show action box', () => { + cy.signin(INSTANCE_MEMBERS[0]); + cy.visit(workerViewPage.url); + workerViewPage.getters.workerViewUnlicensed().should('exist'); + }); +}); + +describe('Worker View (licensed)', () => { + beforeEach(() => { + cy.enableFeature('workerView'); + cy.enableQueueMode(); + }); + + it('should show up in the menu sidebar', () => { + cy.signin(INSTANCE_MEMBERS[0]); + cy.enableQueueMode(); + cy.visit(workerViewPage.url); + workerViewPage.getters.menuItem().should('exist'); + }); + + it('should show worker list view', () => { + cy.signin(INSTANCE_MEMBERS[0]); + cy.visit(workerViewPage.url); + workerViewPage.getters.workerViewLicensed().should('exist'); + }); +}); diff --git a/cypress/pages/index.ts b/cypress/pages/index.ts index ebdb0a3ac76..6f03962c2ac 100644 --- a/cypress/pages/index.ts +++ b/cypress/pages/index.ts @@ -11,3 +11,4 @@ export * from './bannerStack'; export * from './workflow-executions-tab'; export * from './signin'; export * from './workflow-history'; +export * from './workerView'; diff --git a/cypress/pages/workerView.ts b/cypress/pages/workerView.ts new file mode 100644 index 00000000000..12b57cc27cf --- /dev/null +++ b/cypress/pages/workerView.ts @@ -0,0 +1,15 @@ +import { BasePage } from './base'; + +export class WorkerViewPage extends BasePage { + url = '/workers'; + getters = { + workerCards: () => cy.getByTestId('worker-card'), + workerCard: (workerId: string) => this.getters.workerCards().contains(workerId), + workerViewLicensed: () => cy.getByTestId('worker-view-licensed'), + workerViewUnlicensed: () => cy.getByTestId('worker-view-unlicensed'), + menuItems: () => cy.get('.el-menu-item'), + menuItem: () => this.getters.menuItems().get('#workersview'), + }; + + actions = {}; +} diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 8bd9577a97e..4cd7b924a23 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -66,8 +66,15 @@ const setFeature = (feature: string, enabled: boolean) => enabled, }); +const setQueueMode = (enabled: boolean) => + cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/queue-mode`, { + enabled, + }); + Cypress.Commands.add('enableFeature', (feature: string) => setFeature(feature, true)); -Cypress.Commands.add('disableFeature', (feature): string => setFeature(feature, false)); +Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature, false)); +Cypress.Commands.add('enableQueueMode', () => setQueueMode(true)); +Cypress.Commands.add('disableQueueMode', () => setQueueMode(false)); Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => { if (Cypress.isBrowser('chrome')) { diff --git a/cypress/support/index.ts b/cypress/support/index.ts index bce17e3a2cb..77180a7ce54 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -27,6 +27,8 @@ declare global { interceptREST(method: string, url: string): Chainable; enableFeature(feature: string): void; disableFeature(feature: string): void; + enableQueueMode(): void; + disableQueueMode(): void; waitForLoad(waitForIntercepts?: boolean): void; grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 846604bd1aa..8bcdb7875fe 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -46,6 +46,7 @@ import type { UserRepository } from '@db/repositories/user.repository'; import type { WorkflowRepository } from '@db/repositories/workflow.repository'; import type { LICENSE_FEATURES, LICENSE_QUOTAS } from './constants'; import type { WorkflowWithSharingsAndCredentials } from './workflows/workflows.types'; +import type { WorkerJobStatusSummary } from './services/orchestration/worker/types'; export interface ICredentialsTypeData { [key: string]: CredentialLoadingDetails; @@ -466,7 +467,8 @@ export type IPushData = | PushDataTestWebhook | PushDataNodeDescriptionUpdated | PushDataExecutionRecovered - | PushDataActiveWorkflowUsersChanged; + | PushDataActiveWorkflowUsersChanged + | PushDataWorkerStatusMessage; type PushDataActiveWorkflowUsersChanged = { data: IActiveWorkflowUsersChanged; @@ -503,7 +505,12 @@ export type PushDataConsoleMessage = { type: 'sendConsoleMessage'; }; -export type PushDataReloadNodeType = { +type PushDataWorkerStatusMessage = { + data: IPushDataWorkerStatusMessage; + type: 'sendWorkerStatusMessage'; +}; + +type PushDataReloadNodeType = { data: IPushDataReloadNodeType; type: 'reloadNodeType'; }; @@ -583,6 +590,30 @@ export interface IPushDataConsoleMessage { message: string; } +export interface IPushDataWorkerStatusMessage { + workerId: string; + status: IPushDataWorkerStatusPayload; +} + +export interface IPushDataWorkerStatusPayload { + workerId: string; + runningJobsSummary: WorkerJobStatusSummary[]; + freeMem: number; + totalMem: number; + uptime: number; + loadAvg: number[]; + cpus: string; + arch: string; + platform: NodeJS.Platform; + hostname: string; + interfaces: Array<{ + family: 'IPv4' | 'IPv6'; + address: string; + internal: boolean; + }>; + version: string; +} + export interface IResponseCallbackData { data?: IDataObject | IDataObject[]; headers?: object; diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 5c45894dbb3..c06f83b271d 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -253,6 +253,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.API_DISABLED); } + isWorkerViewLicensed() { + return this.isFeatureEnabled(LICENSE_FEATURES.WORKER_VIEW); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index b72e7be5570..a48ab3e94cc 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -80,6 +80,7 @@ export const LICENSE_FEATURES = { DEBUG_IN_EDITOR: 'feat:debugInEditor', BINARY_DATA_S3: 'feat:binaryDataS3', MULTIPLE_MAIN_INSTANCES: 'feat:multipleMainInstances', + WORKER_VIEW: 'feat:workerView', } as const; export const LICENSE_QUOTAS = { diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 3b736e70384..e96aaddc731 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -70,6 +70,7 @@ export class E2EController { [LICENSE_FEATURES.DEBUG_IN_EDITOR]: false, [LICENSE_FEATURES.BINARY_DATA_S3]: false, [LICENSE_FEATURES.MULTIPLE_MAIN_INSTANCES]: false, + [LICENSE_FEATURES.WORKER_VIEW]: false, }; constructor( @@ -99,6 +100,13 @@ export class E2EController { this.enabledFeatures[feature] = enabled; } + @Patch('/queue-mode') + async setQueueMode(req: Request<{}, {}, { enabled: boolean }>) { + const { enabled } = req.body; + config.set('executions.mode', enabled ? 'queue' : 'regular'); + return { success: true, message: `Queue mode set to ${config.getEnv('executions.mode')}` }; + } + private resetFeatures() { for (const feature of Object.keys(this.enabledFeatures)) { this.enabledFeatures[feature as BooleanLicenseFeature] = false; diff --git a/packages/cli/src/controllers/orchestration.controller.ts b/packages/cli/src/controllers/orchestration.controller.ts index b11957f682f..cbe9f285d7c 100644 --- a/packages/cli/src/controllers/orchestration.controller.ts +++ b/packages/cli/src/controllers/orchestration.controller.ts @@ -1,32 +1,38 @@ -import { Authorized, Get, RestController } from '@/decorators'; +import { Authorized, Post, RestController } from '@/decorators'; import { OrchestrationRequest } from '@/requests'; import { Service } from 'typedi'; import { SingleMainInstancePublisher } from '@/services/orchestration/main/SingleMainInstance.publisher'; +import { License } from '../License'; @Authorized(['global', 'owner']) @RestController('/orchestration') @Service() export class OrchestrationController { - constructor(private readonly orchestrationService: SingleMainInstancePublisher) {} + constructor( + private readonly orchestrationService: SingleMainInstancePublisher, + private readonly licenseService: License, + ) {} /** - * These endpoint currently do not return anything, they just trigger the messsage to + * These endpoints do not return anything, they just trigger the messsage to * the workers to respond on Redis with their status. - * TODO: these responses need to be forwarded to and handled by the frontend */ - @Get('/worker/status/:id') + @Post('/worker/status/:id') async getWorkersStatus(req: OrchestrationRequest.Get) { + if (!this.licenseService.isWorkerViewLicensed()) return; const id = req.params.id; return this.orchestrationService.getWorkerStatus(id); } - @Get('/worker/status') + @Post('/worker/status') async getWorkersStatusAll() { + if (!this.licenseService.isWorkerViewLicensed()) return; return this.orchestrationService.getWorkerStatus(); } - @Get('/worker/ids') + @Post('/worker/ids') async getWorkerIdsAll() { + if (!this.licenseService.isWorkerViewLicensed()) return; return this.orchestrationService.getWorkerIds(); } } diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index 276494fef0a..34ba3c1f083 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -175,6 +175,7 @@ export class FrontendService { debugInEditor: false, binaryDataS3: false, workflowHistory: false, + workerView: false, }, mfa: { enabled: false, @@ -263,6 +264,7 @@ export class FrontendService { binaryDataS3: isS3Available && isS3Selected && isS3Licensed, workflowHistory: this.license.isWorkflowHistoryLicensed() && config.getEnv('workflowHistory.enabled'), + workerView: this.license.isWorkerViewLicensed(), }); if (this.license.isLdapEnabled()) { @@ -296,6 +298,8 @@ export class FrontendService { this.settings.mfa.enabled = config.get('mfa.enabled'); + this.settings.executionMode = config.getEnv('executions.mode'); + return this.settings; } diff --git a/packages/cli/src/services/orchestration/main/handleWorkerResponseMessageMain.ts b/packages/cli/src/services/orchestration/main/handleWorkerResponseMessageMain.ts index 246df5e4f7c..47a1a080192 100644 --- a/packages/cli/src/services/orchestration/main/handleWorkerResponseMessageMain.ts +++ b/packages/cli/src/services/orchestration/main/handleWorkerResponseMessageMain.ts @@ -1,15 +1,27 @@ import { jsonParse } from 'n8n-workflow'; import Container from 'typedi'; import { Logger } from '@/Logger'; +import { Push } from '../../../push'; import type { RedisServiceWorkerResponseObject } from '../../redis/RedisServiceCommands'; export async function handleWorkerResponseMessageMain(messageString: string) { const workerResponse = jsonParse(messageString); if (workerResponse) { - // TODO: Handle worker response - Container.get(Logger).debug( - `Received worker response ${workerResponse.command} from ${workerResponse.workerId}`, - ); + switch (workerResponse.command) { + case 'getStatus': + const push = Container.get(Push); + push.broadcast('sendWorkerStatusMessage', { + workerId: workerResponse.workerId, + status: workerResponse.payload, + }); + break; + case 'getId': + break; + default: + Container.get(Logger).debug( + `Received worker response ${workerResponse.command} from ${workerResponse.workerId}`, + ); + } } return workerResponse; } diff --git a/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts b/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts index 91b801b066d..32c8a5c6318 100644 --- a/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts +++ b/packages/cli/src/services/orchestration/worker/handleCommandMessageWorker.ts @@ -9,6 +9,7 @@ import { ExternalSecretsManager } from '@/ExternalSecrets/ExternalSecretsManager import { debounceMessageReceiver, getOsCpuString } from '../helpers'; import type { WorkerCommandReceivedHandlerOptions } from './types'; import { Logger } from '@/Logger'; +import { N8N_VERSION } from '@/constants'; export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHandlerOptions) { return async (channel: string, messageString: string) => { @@ -33,13 +34,12 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa } switch (message.command) { case 'getStatus': - if (!debounceMessageReceiver(message, 200)) return; + if (!debounceMessageReceiver(message, 500)) return; await options.redisPublisher.publishToWorkerChannel({ workerId: options.queueModeId, - command: message.command, + command: 'getStatus', payload: { workerId: options.queueModeId, - runningJobs: options.getRunningJobIds(), runningJobsSummary: options.getRunningJobsSummary(), freeMem: os.freemem(), totalMem: os.totalmem(), @@ -49,27 +49,32 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa arch: os.arch(), platform: os.platform(), hostname: os.hostname(), - net: Object.values(os.networkInterfaces()).flatMap( + interfaces: Object.values(os.networkInterfaces()).flatMap( (interfaces) => - interfaces?.map((net) => `${net.family} - address: ${net.address}`) ?? '', + (interfaces ?? [])?.map((net) => ({ + family: net.family, + address: net.address, + internal: net.internal, + })), ), + version: N8N_VERSION, }, }); break; case 'getId': - if (!debounceMessageReceiver(message, 200)) return; + if (!debounceMessageReceiver(message, 500)) return; await options.redisPublisher.publishToWorkerChannel({ workerId: options.queueModeId, - command: message.command, + command: 'getId', }); break; case 'restartEventBus': - if (!debounceMessageReceiver(message, 100)) return; + if (!debounceMessageReceiver(message, 500)) return; try { await Container.get(MessageEventBus).restart(); await options.redisPublisher.publishToWorkerChannel({ workerId: options.queueModeId, - command: message.command, + command: 'restartEventBus', payload: { result: 'success', }, @@ -77,7 +82,7 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa } catch (error) { await options.redisPublisher.publishToWorkerChannel({ workerId: options.queueModeId, - command: message.command, + command: 'restartEventBus', payload: { result: 'error', error: (error as Error).message, @@ -86,12 +91,12 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa } break; case 'reloadExternalSecretsProviders': - if (!debounceMessageReceiver(message, 200)) return; + if (!debounceMessageReceiver(message, 500)) return; try { await Container.get(ExternalSecretsManager).reloadAllProviders(); await options.redisPublisher.publishToWorkerChannel({ workerId: options.queueModeId, - command: message.command, + command: 'reloadExternalSecretsProviders', payload: { result: 'success', }, @@ -99,7 +104,7 @@ export function getWorkerCommandReceivedHandler(options: WorkerCommandReceivedHa } catch (error) { await options.redisPublisher.publishToWorkerChannel({ workerId: options.queueModeId, - command: message.command, + command: 'reloadExternalSecretsProviders', payload: { result: 'error', error: (error as Error).message, diff --git a/packages/cli/src/services/redis/RedisServiceCommands.ts b/packages/cli/src/services/redis/RedisServiceCommands.ts index 8423c3a0f74..6fcc48276a5 100644 --- a/packages/cli/src/services/redis/RedisServiceCommands.ts +++ b/packages/cli/src/services/redis/RedisServiceCommands.ts @@ -1,4 +1,4 @@ -import type { WorkerJobStatusSummary } from '../orchestration/worker/types'; +import type { IPushDataWorkerStatusPayload } from '@/Interfaces'; export type RedisServiceCommand = | 'getStatus' @@ -28,20 +28,7 @@ export type RedisServiceWorkerResponseObject = { | RedisServiceBaseCommand | { command: 'getStatus'; - payload: { - workerId: string; - runningJobs: string[]; - runningJobsSummary: WorkerJobStatusSummary[]; - freeMem: number; - totalMem: number; - uptime: number; - loadAvg: number[]; - cpus: string; - arch: string; - platform: NodeJS.Platform; - hostname: string; - net: string[]; - }; + payload: IPushDataWorkerStatusPayload; } | { command: 'getId'; diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 270bec88f98..795f1807d43 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -50,6 +50,7 @@ "@vueuse/components": "^10.5.0", "@vueuse/core": "^10.5.0", "axios": "^0.21.1", + "chart.js": "^4.4.0", "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.2.0", "copy-to-clipboard": "^3.3.3", @@ -73,6 +74,7 @@ "v3-infinite-loading": "^1.2.2", "vue": "^3.3.4", "vue-agile": "^2.0.0", + "vue-chartjs": "^5.2.0", "vue-i18n": "^9.2.2", "vue-json-pretty": "2.2.4", "vue-markdown-render": "^2.0.1", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 173cc6d15f9..1f641a2c981 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -45,6 +45,7 @@ import type { BannerName, INodeExecutionData, INodeProperties, + NodeConnectionType, } from 'n8n-workflow'; import type { BulkCommand, Undoable } from '@/models/history'; import type { PartialBy, TupleToUnion } from '@/utils/typeHelpers'; @@ -98,6 +99,8 @@ declare global { getVariant: (name: string) => string | boolean | undefined; override: (name: string, value: string) => void; }; + // eslint-disable-next-line @typescript-eslint/naming-convention + Cypress: unknown; } } @@ -415,7 +418,8 @@ export type IPushData = | PushDataReloadNodeType | PushDataRemoveNodeType | PushDataTestWebhook - | PushDataExecutionRecovered; + | PushDataExecutionRecovered + | PushDataWorkerStatusMessage; type PushDataExecutionRecovered = { data: IPushDataExecutionRecovered; @@ -462,6 +466,11 @@ type PushDataTestWebhook = { type: 'testWebhookDeleted' | 'testWebhookReceived'; }; +type PushDataWorkerStatusMessage = { + data: IPushDataWorkerStatusMessage; + type: 'sendWorkerStatusMessage'; +}; + export interface IPushDataExecutionStarted { executionId: string; mode: WorkflowExecuteMode; @@ -519,6 +528,41 @@ export interface IPushDataConsoleMessage { messages: string[]; } +export interface WorkerJobStatusSummary { + jobId: string; + executionId: string; + retryOf?: string; + startedAt: Date; + mode: WorkflowExecuteMode; + workflowName: string; + workflowId: string; + status: ExecutionStatus; +} + +export interface IPushDataWorkerStatusPayload { + workerId: string; + runningJobsSummary: WorkerJobStatusSummary[]; + freeMem: number; + totalMem: number; + uptime: number; + loadAvg: number[]; + cpus: string; + arch: string; + platform: NodeJS.Platform; + hostname: string; + interfaces: Array<{ + family: 'IPv4' | 'IPv6'; + address: string; + internal: boolean; + }>; + version: string; +} + +export interface IPushDataWorkerStatusMessage { + workerId: string; + status: IPushDataWorkerStatusPayload; +} + export type IPersonalizationSurveyAnswersV1 = { codingSkill?: string | null; companyIndustry?: string[] | null; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts index 04fc4b2df08..14e8c3a6208 100644 --- a/packages/editor-ui/src/__tests__/server/endpoints/settings.ts +++ b/packages/editor-ui/src/__tests__/server/endpoints/settings.ts @@ -20,9 +20,11 @@ const defaultSettings: IN8nUISettings = { sourceControl: false, auditLogs: false, showNonProdBanner: false, - externalSecrets: false, - binaryDataS3: false, workflowHistory: false, + debugInEditor: false, + binaryDataS3: false, + externalSecrets: false, + workerView: false, }, expressions: { evaluator: 'tournament', diff --git a/packages/editor-ui/src/api/orchestration.ts b/packages/editor-ui/src/api/orchestration.ts new file mode 100644 index 00000000000..147c9aa2e37 --- /dev/null +++ b/packages/editor-ui/src/api/orchestration.ts @@ -0,0 +1,8 @@ +import type { IRestApiContext } from '@/Interface'; +import { makeRestApiRequest } from '@/utils'; + +const GET_STATUS_ENDPOINT = '/orchestration/worker/status'; + +export const sendGetWorkerStatus = async (context: IRestApiContext): Promise => { + await makeRestApiRequest(context, 'POST', GET_STATUS_ENDPOINT); +}; diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index 24831b4650b..2711a150c17 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -263,6 +263,15 @@ export default defineComponent({ position: 'top', activateOnRouteNames: [VIEWS.EXECUTIONS], }, + { + id: 'workersview', + icon: 'truck-monster', + label: this.$locale.baseText('mainSidebar.workersView'), + position: 'top', + available: + this.settingsStore.isQueueModeEnabled && this.settingsStore.isWorkerViewAvailable, + activateOnRouteNames: [VIEWS.WORKER_VIEW], + }, { id: 'settings', icon: 'cog', @@ -431,6 +440,12 @@ export default defineComponent({ } break; } + case 'workersview': { + if (this.$router.currentRoute.name !== VIEWS.WORKER_VIEW) { + this.goToRoute({ name: VIEWS.WORKER_VIEW }); + } + break; + } case 'settings': { const defaultRoute = this.findFirstAccessibleSettingsRoute(); if (defaultRoute) { diff --git a/packages/editor-ui/src/components/WorkerList.ee.vue b/packages/editor-ui/src/components/WorkerList.ee.vue new file mode 100644 index 00000000000..d96ff2b1049 --- /dev/null +++ b/packages/editor-ui/src/components/WorkerList.ee.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/packages/editor-ui/src/components/Workers/WorkerAccordion.ee.vue b/packages/editor-ui/src/components/Workers/WorkerAccordion.ee.vue new file mode 100644 index 00000000000..4f5d1453213 --- /dev/null +++ b/packages/editor-ui/src/components/Workers/WorkerAccordion.ee.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/editor-ui/src/components/Workers/WorkerCard.ee.vue b/packages/editor-ui/src/components/Workers/WorkerCard.ee.vue new file mode 100644 index 00000000000..5bea9073a9e --- /dev/null +++ b/packages/editor-ui/src/components/Workers/WorkerCard.ee.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/packages/editor-ui/src/components/Workers/WorkerChartsAccordion.ee.vue b/packages/editor-ui/src/components/Workers/WorkerChartsAccordion.ee.vue new file mode 100644 index 00000000000..f82db2cdc0c --- /dev/null +++ b/packages/editor-ui/src/components/Workers/WorkerChartsAccordion.ee.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/packages/editor-ui/src/components/Workers/WorkerJobAccordion.ee.vue b/packages/editor-ui/src/components/Workers/WorkerJobAccordion.ee.vue new file mode 100644 index 00000000000..c8f6f949b30 --- /dev/null +++ b/packages/editor-ui/src/components/Workers/WorkerJobAccordion.ee.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/editor-ui/src/components/Workers/WorkerNetAccordion.ee.vue b/packages/editor-ui/src/components/Workers/WorkerNetAccordion.ee.vue new file mode 100644 index 00000000000..c9ed50961d0 --- /dev/null +++ b/packages/editor-ui/src/components/Workers/WorkerNetAccordion.ee.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index c4b2f482392..ce3518295fe 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -429,6 +429,7 @@ export const enum VIEWS { AUDIT_LOGS = 'AuditLogs', MFA_VIEW = 'MfaView', WORKFLOW_HISTORY = 'WorkflowHistory', + WORKER_VIEW = 'WorkerView', } export const enum FAKE_DOOR_FEATURES { @@ -501,6 +502,7 @@ export const enum EnterpriseEditionFeature { AuditLogs = 'auditLogs', DebugInEditor = 'debugInEditor', WorkflowHistory = 'workflowHistory', + WorkerView = 'workerView', } export const MAIN_NODE_PANEL_WIDTH = 360; diff --git a/packages/editor-ui/src/main.ts b/packages/editor-ui/src/main.ts index 63a00c9ab4e..21da68448f3 100644 --- a/packages/editor-ui/src/main.ts +++ b/packages/editor-ui/src/main.ts @@ -22,6 +22,7 @@ import { FontAwesomePlugin } from './plugins/icons'; import { createPinia, PiniaVuePlugin } from 'pinia'; import { JsPlumbPlugin } from '@/plugins/jsplumb'; +import { ChartJSPlugin } from '@/plugins/chartjs'; const pinia = createPinia(); @@ -37,6 +38,7 @@ app.use(JsPlumbPlugin); app.use(pinia); app.use(router); app.use(i18nInstance); +app.use(ChartJSPlugin); app.mount('#app'); diff --git a/packages/editor-ui/src/mixins/pushConnection.ts b/packages/editor-ui/src/mixins/pushConnection.ts index dd12ca0139b..2cde57284c3 100644 --- a/packages/editor-ui/src/mixins/pushConnection.ts +++ b/packages/editor-ui/src/mixins/pushConnection.ts @@ -34,6 +34,7 @@ import { useSettingsStore } from '@/stores/settings.store'; import { parse } from 'flatted'; import { useSegment } from '@/stores/segment.store'; import { defineComponent } from 'vue'; +import { useOrchestrationStore } from '@/stores/orchestration.store'; export const pushConnection = defineComponent({ setup() { @@ -61,6 +62,7 @@ export const pushConnection = defineComponent({ useWorkflowsStore, useSettingsStore, useSegment, + useOrchestrationStore, ), sessionId(): string { return this.rootStore.sessionId; @@ -111,7 +113,10 @@ export const pushConnection = defineComponent({ this.connectRetries = 0; this.lostConnection = false; this.rootStore.pushConnectionActive = true; - this.clearAllStickyNotifications(); + try { + // in the workers view context this fn is not defined + this.clearAllStickyNotifications(); + } catch {} this.pushSource?.removeEventListener('open', this.onConnectionSuccess); }, @@ -196,6 +201,12 @@ export const pushConnection = defineComponent({ return false; } + if (receivedData.type === 'sendWorkerStatusMessage') { + const pushData = receivedData.data; + this.orchestrationManagerStore.updateWorkerStatus(pushData.status); + return true; + } + if (receivedData.type === 'sendConsoleMessage') { const pushData = receivedData.data; console.log(pushData.source, ...pushData.messages); diff --git a/packages/editor-ui/src/plugins/chartjs.ts b/packages/editor-ui/src/plugins/chartjs.ts new file mode 100644 index 00000000000..edd2b3b2c96 --- /dev/null +++ b/packages/editor-ui/src/plugins/chartjs.ts @@ -0,0 +1,26 @@ +import { + Chart as ChartJS, + Title, + Tooltip, + Legend, + BarElement, + LineElement, + PointElement, + CategoryScale, + LinearScale, +} from 'chart.js'; + +export const ChartJSPlugin = { + install: () => { + ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + Title, + Tooltip, + Legend, + ); + }, +}; diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index a1122aab8dd..0a9f61b4605 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -608,6 +608,19 @@ "executionsList.debug.paywall.content": "Debug in Editor allows you to debug a previous execution with the actual data pinned, right in your editor.", "executionsList.debug.paywall.link.text": "Read more in the docs", "executionsList.debug.paywall.link.url": "https://docs.n8n.io/workflows/executions/debug/", + "workerList.pageTitle": "Workers", + "workerList.empty": "No workers are responding or available", + "workerList.item.lastUpdated": "Last updated", + "workerList.item.jobList.empty": "No current jobs", + "workerList.item.jobListTitle": "Current Jobs", + "workerList.item.netListTitle": "Network Interfaces", + "workerList.item.chartsTitle": "Performance Monitoring", + "workerList.item.copyAddressToClipboard": "Address copied to clipboard", + "workerList.actionBox.title": "Available on the Enterprise plan", + "workerList.actionBox.description": "View the current state of workers connected to your instance.", + "workerList.actionBox.description.link": "More info", + "workerList.actionBox.buttonText": "See plans", + "workerList.docs.url": "https://docs.n8n.io", "executionSidebar.executionName": "Execution {id}", "executionSidebar.searchPlaceholder": "Search executions...", "executionView.onPaste.title": "Cannot paste here", @@ -710,6 +723,7 @@ "mainSidebar.workflows": "Workflows", "mainSidebar.workflows.readOnlyEnv.tooltip": "Protected mode is active, so no workflows changes are allowed. Change this in Settings, under 'Source Control'", "mainSidebar.executions": "All executions", + "mainSidebar.workersView": "Workers", "menuActions.duplicate": "Duplicate", "menuActions.download": "Download", "menuActions.push": "Push to Git", diff --git a/packages/editor-ui/src/plugins/icons/index.ts b/packages/editor-ui/src/plugins/icons/index.ts index 073b306996e..6993396a86d 100644 --- a/packages/editor-ui/src/plugins/icons/index.ts +++ b/packages/editor-ui/src/plugins/icons/index.ts @@ -130,6 +130,7 @@ import { faTerminal, faThLarge, faThumbtack, + faTruckMonster, faTimes, faTimesCircle, faToolbox, @@ -315,6 +316,7 @@ export const FontAwesomePlugin: Plugin<{}> = { addIcon(faGem); addIcon(faXmark); addIcon(faDownload); + addIcon(faTruckMonster); app.component('font-awesome-icon', FontAwesomeIcon); }, diff --git a/packages/editor-ui/src/plugins/index.ts b/packages/editor-ui/src/plugins/index.ts index 809f2a95fc6..0a059f69245 100644 --- a/packages/editor-ui/src/plugins/index.ts +++ b/packages/editor-ui/src/plugins/index.ts @@ -1,3 +1,4 @@ import './icons'; import './directives'; import './components'; +import './chartjs'; diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index d96cb8dfc22..f6687028fcb 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -48,6 +48,7 @@ const SamlOnboarding = async () => import('@/views/SamlOnboarding.vue'); const SettingsSourceControl = async () => import('./views/SettingsSourceControl.vue'); const SettingsExternalSecrets = async () => import('./views/SettingsExternalSecrets.vue'); const SettingsAuditLogs = async () => import('./views/SettingsAuditLogs.vue'); +const WorkerView = async () => import('./views/WorkerView.vue'); const WorkflowHistory = async () => import('@/views/WorkflowHistory.vue'); const WorkflowOnboardingView = async () => import('@/views/WorkflowOnboardingView.vue'); @@ -217,6 +218,21 @@ export const routes = [ }, }, }, + { + path: '/workers', + name: VIEWS.WORKER_VIEW, + components: { + default: WorkerView, + sidebar: MainSidebar, + }, + meta: { + permissions: { + allow: { + loginStatus: [LOGIN_STATUS.LoggedIn], + }, + }, + }, + }, { path: '/workflows', name: VIEWS.WORKFLOWS, diff --git a/packages/editor-ui/src/stores/orchestration.store.ts b/packages/editor-ui/src/stores/orchestration.store.ts new file mode 100644 index 00000000000..618cbc11c1b --- /dev/null +++ b/packages/editor-ui/src/stores/orchestration.store.ts @@ -0,0 +1,76 @@ +import { defineStore } from 'pinia'; +import type { IPushDataWorkerStatusPayload } from '../Interface'; +import { useRootStore } from './n8nRoot.store'; +import { sendGetWorkerStatus } from '../api/orchestration'; + +export const WORKER_HISTORY_LENGTH = 100; +const STALE_SECONDS = 120 * 1000; + +export interface IOrchestrationStoreState { + workers: { [id: string]: IPushDataWorkerStatusPayload }; + workersHistory: { + [id: string]: IWorkerHistoryItem[]; + }; + workersLastUpdated: { [id: string]: number }; + statusInterval: NodeJS.Timer | null; +} + +export interface IWorkerHistoryItem { + timestamp: number; + data: IPushDataWorkerStatusPayload; +} + +export const useOrchestrationStore = defineStore('orchestrationManager', { + state: (): IOrchestrationStoreState => ({ + workers: {}, + workersHistory: {}, + workersLastUpdated: {}, + statusInterval: null, + }), + actions: { + updateWorkerStatus(data: IPushDataWorkerStatusPayload) { + this.workers[data.workerId] = data; + if (!this.workersHistory[data.workerId]) { + this.workersHistory[data.workerId] = []; + } + this.workersHistory[data.workerId].push({ data, timestamp: Date.now() }); + if (this.workersHistory[data.workerId].length > WORKER_HISTORY_LENGTH) { + this.workersHistory[data.workerId].shift(); + } + this.workersLastUpdated[data.workerId] = Date.now(); + }, + removeStaleWorkers() { + for (const id in this.workersLastUpdated) { + if (this.workersLastUpdated[id] + STALE_SECONDS < Date.now()) { + delete this.workers[id]; + delete this.workersHistory[id]; + delete this.workersLastUpdated[id]; + } + } + }, + startWorkerStatusPolling() { + const rootStore = useRootStore(); + if (!this.statusInterval) { + this.statusInterval = setInterval(async () => { + await sendGetWorkerStatus(rootStore.getRestApiContext); + this.removeStaleWorkers(); + }, 1000); + } + }, + stopWorkerStatusPolling() { + if (this.statusInterval) { + clearInterval(this.statusInterval); + this.statusInterval = null; + } + }, + getWorkerLastUpdated(workerId: string): number { + return this.workersLastUpdated[workerId] ?? 0; + }, + getWorkerStatus(workerId: string): IPushDataWorkerStatusPayload | undefined { + return this.workers[workerId]; + }, + getWorkerStatusHistory(workerId: string): IWorkerHistoryItem[] { + return this.workersHistory[workerId] ?? []; + }, + }, +}); diff --git a/packages/editor-ui/src/stores/settings.store.ts b/packages/editor-ui/src/stores/settings.store.ts index 485ee4e108e..7fed0683058 100644 --- a/packages/editor-ui/src/stores/settings.store.ts +++ b/packages/editor-ui/src/stores/settings.store.ts @@ -174,6 +174,9 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, { isQueueModeEnabled(): boolean { return this.settings.executionMode === 'queue'; }, + isWorkerViewAvailable(): boolean { + return !!this.settings.enterprise?.workerView; + }, workflowCallerPolicyDefaultOption(): WorkflowSettings.CallerPolicy { return this.settings.workflowCallerPolicyDefaultOption; }, diff --git a/packages/editor-ui/src/utils/workerUtils.ts b/packages/editor-ui/src/utils/workerUtils.ts new file mode 100644 index 00000000000..d7891ff0629 --- /dev/null +++ b/packages/editor-ui/src/utils/workerUtils.ts @@ -0,0 +1,11 @@ +export function averageWorkerLoadFromLoads(loads: number[]): number { + return loads.reduce((prev, curr) => prev + curr, 0) / loads.length; +} + +export function averageWorkerLoadFromLoadsAsString(loads: number[]): string { + return averageWorkerLoadFromLoads(loads).toFixed(2); +} + +export function memAsGb(mem: number): number { + return mem / 1024 / 1024 / 1024; +} diff --git a/packages/editor-ui/src/views/WorkerView.vue b/packages/editor-ui/src/views/WorkerView.vue new file mode 100644 index 00000000000..067be72cb40 --- /dev/null +++ b/packages/editor-ui/src/views/WorkerView.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index a8ea204546f..2703f06bec2 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -2315,6 +2315,7 @@ export interface IN8nUISettings { debugInEditor: boolean; binaryDataS3: boolean; workflowHistory: boolean; + workerView: boolean; }; hideUsagePage: boolean; license: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9729932125..2f1b11e804f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,7 +198,7 @@ importers: version: link:../@n8n/client-oauth2 '@n8n_io/license-sdk': specifier: ~2.7.1 - version: 2.7.1 + version: 2.7.2 '@oclif/command': specifier: ^1.8.16 version: 1.8.18(@oclif/config@1.18.17)(supports-color@8.1.1) @@ -856,6 +856,9 @@ importers: axios: specifier: ^0.21.1 version: 0.21.4 + chart.js: + specifier: ^4.4.0 + version: 4.4.0 codemirror-lang-html-n8n: specifier: ^1.0.0 version: 1.0.0 @@ -925,6 +928,9 @@ importers: vue-agile: specifier: ^2.0.0 version: 2.0.0 + vue-chartjs: + specifier: ^5.2.0 + version: 5.2.0(chart.js@4.4.0)(vue@3.3.4) vue-i18n: specifier: ^9.2.2 version: 9.2.2(vue@3.3.4) @@ -4444,6 +4450,10 @@ packages: mappersmith: 2.40.0 dev: false + /@kurkle/color@0.3.2: + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + dev: false + /@kwsites/file-exists@1.1.1: resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} dependencies: @@ -4621,11 +4631,11 @@ packages: acorn-walk: 8.2.0 dev: false - /@n8n_io/license-sdk@2.7.1: - resolution: {integrity: sha512-SiPKI/wN2coLPB8Tyb28UlLgAszU2SkSR8PWNioTWAd8PnUhTYg8KN9jfUOZipVF+YMOAHc/hQUq6kJA1PF0xg==} + /@n8n_io/license-sdk@2.7.2: + resolution: {integrity: sha512-GalBo+2YxbFUR1I7cx3JDEiippqKw8ml3FdFK9hRRB9NfpP+H0QYnWf/sGOmKcb4nZ2lqU38nU7ZdIF96jBkXg==} engines: {node: '>=18.12.1', npm: '>=8.19.2'} dependencies: - crypto-js: 4.1.1 + crypto-js: 4.2.0 node-machine-id: 1.1.12 node-rsa: 1.1.1 undici: 5.26.4 @@ -10355,6 +10365,13 @@ packages: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} dev: false + /chart.js@4.4.0: + resolution: {integrity: sha512-vQEj6d+z0dcsKLlQvbKIMYFHd3t8W/7L2vfJIbYcfyPcRx92CsHqECpueN8qVGNlKyDcr5wBrYAYKnfu/9Q1hQ==} + engines: {pnpm: '>=7'} + dependencies: + '@kurkle/color': 0.3.2 + dev: false + /check-error@1.0.2: resolution: {integrity: sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==} dev: true @@ -22274,6 +22291,16 @@ packages: vue: 3.3.4 dev: false + /vue-chartjs@5.2.0(chart.js@4.4.0)(vue@3.3.4): + resolution: {integrity: sha512-d3zpKmGZr2OWHQ1xmxBcAn5ShTG917+/UCLaSpaCDDqT0U7DBsvFzTs69ZnHCgKoXT55GZDW8YEj9Av+dlONLA==} + peerDependencies: + chart.js: ^4.1.1 + vue: ^3.0.0-0 || ^2.7.0 + dependencies: + chart.js: 4.4.0 + vue: 3.3.4 + dev: false + /vue-component-type-helpers@1.8.22: resolution: {integrity: sha512-LK3wJHs3vJxHG292C8cnsRusgyC5SEZDCzDCD01mdE/AoREFMl2tzLRuzwyuEsOIz13tqgBcnvysN3Lxsa14Fw==} dev: true