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 0000000000..2522fb881f --- /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 ebdb0a3ac7..6f03962c2a 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 0000000000..12b57cc27c --- /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 8bd9577a97..4cd7b924a2 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 bce17e3a2c..77180a7ce5 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 846604bd1a..8bcdb7875f 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 5c45894dbb..c06f83b271 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 b72e7be557..a48ab3e94c 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 3b736e7038..e96aaddc73 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 b11957f682..cbe9f285d7 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 276494fef0..34ba3c1f08 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 246df5e4f7..47a1a08019 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 91b801b066..32c8a5c631 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 8423c3a0f7..6fcc48276a 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 270bec88f9..795f1807d4 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 173cc6d15f..1f641a2c98 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 04fc4b2df0..14e8c3a620 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 0000000000..147c9aa2e3 --- /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 24831b4650..2711a150c1 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 0000000000..d96ff2b104 --- /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 0000000000..4f5d145321 --- /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 0000000000..5bea9073a9 --- /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 0000000000..f82db2cdc0 --- /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 0000000000..c8f6f949b3 --- /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 0000000000..c9ed50961d --- /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 c4b2f48239..ce3518295f 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 63a00c9ab4..21da68448f 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 dd12ca0139..2cde57284c 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 0000000000..edd2b3b2c9 --- /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 a1122aab8d..0a9f61b460 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 073b306996..6993396a86 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 809f2a95fc..0a059f6924 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 d96cb8dfc2..f6687028fc 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 0000000000..618cbc11c1 --- /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 485ee4e108..7fed068305 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 0000000000..d7891ff062 --- /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 0000000000..067be72cb4 --- /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 a8ea204546..2703f06bec 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 c972993212..2f1b11e804 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