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