mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-24 20:24:05 -08:00
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.
This commit is contained in:
parent
0ddafd2b82
commit
cbc690907f
43
cypress/e2e/32-worker-view.cy.ts
Normal file
43
cypress/e2e/32-worker-view.cy.ts
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -11,3 +11,4 @@ export * from './bannerStack';
|
|||
export * from './workflow-executions-tab';
|
||||
export * from './signin';
|
||||
export * from './workflow-history';
|
||||
export * from './workerView';
|
||||
|
|
15
cypress/pages/workerView.ts
Normal file
15
cypress/pages/workerView.ts
Normal file
|
@ -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 = {};
|
||||
}
|
|
@ -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')) {
|
||||
|
|
|
@ -27,6 +27,8 @@ declare global {
|
|||
interceptREST(method: string, url: string): Chainable<Interception>;
|
||||
enableFeature(feature: string): void;
|
||||
disableFeature(feature: string): void;
|
||||
enableQueueMode(): void;
|
||||
disableQueueMode(): void;
|
||||
waitForLoad(waitForIntercepts?: boolean): void;
|
||||
grantBrowserPermissions(...permissions: string[]): void;
|
||||
readClipboard(): Chainable<string>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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() ?? [];
|
||||
}
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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<RedisServiceWorkerResponseObject>(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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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',
|
||||
|
|
8
packages/editor-ui/src/api/orchestration.ts
Normal file
8
packages/editor-ui/src/api/orchestration.ts
Normal file
|
@ -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<void> => {
|
||||
await makeRestApiRequest(context, 'POST', GET_STATUS_ENDPOINT);
|
||||
};
|
|
@ -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) {
|
||||
|
|
130
packages/editor-ui/src/components/WorkerList.ee.vue
Normal file
130
packages/editor-ui/src/components/WorkerList.ee.vue
Normal file
|
@ -0,0 +1,130 @@
|
|||
<template>
|
||||
<div>
|
||||
<PushConnectionTracker class="actions"></PushConnectionTracker>
|
||||
<div :class="$style.workerListHeader">
|
||||
<n8n-heading tag="h1" size="2xlarge">{{ pageTitle }}</n8n-heading>
|
||||
</div>
|
||||
<div v-if="isMounting">
|
||||
<n8n-loading :class="$style.tableLoader" variant="custom" />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="workerIds.length === 0">{{ $locale.baseText('workerList.empty') }}</div>
|
||||
<div v-else>
|
||||
<div v-for="workerId in workerIds" :key="workerId" :class="$style.card">
|
||||
<WorkerCard :workerId="workerId" data-test-id="worker-card" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
import { mapStores } from 'pinia';
|
||||
import { externalHooks } from '@/mixins/externalHooks';
|
||||
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
import { executionHelpers } from '@/mixins/executionsHelpers';
|
||||
import { useI18n, useToast } from '@/composables';
|
||||
import type { IPushDataWorkerStatusPayload } from '@/Interface';
|
||||
import type { ExecutionStatus } from 'n8n-workflow';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useOrchestrationStore } from '../stores/orchestration.store';
|
||||
import { setPageTitle } from '@/utils';
|
||||
import { pushConnection } from '../mixins/pushConnection';
|
||||
import WorkerCard from './Workers/WorkerCard.ee.vue';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineComponent({
|
||||
name: 'WorkerList',
|
||||
mixins: [pushConnection, externalHooks, genericHelpers, executionHelpers],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/naming-convention
|
||||
components: { PushConnectionTracker, WorkerCard },
|
||||
props: {
|
||||
autoRefreshEnabled: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
return {
|
||||
i18n,
|
||||
...useToast(),
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isMounting: true,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
setPageTitle(`n8n - ${this.pageTitle}`);
|
||||
this.isMounting = false;
|
||||
},
|
||||
beforeMount() {
|
||||
if (window.Cypress !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.pushConnect();
|
||||
this.orchestrationManagerStore.startWorkerStatusPolling();
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (window.Cypress !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.orchestrationManagerStore.stopWorkerStatusPolling();
|
||||
this.pushDisconnect();
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useUIStore, useOrchestrationStore),
|
||||
combinedWorkers(): IPushDataWorkerStatusPayload[] {
|
||||
const returnData: IPushDataWorkerStatusPayload[] = [];
|
||||
for (const workerId in this.orchestrationManagerStore.workers) {
|
||||
returnData.push(this.orchestrationManagerStore.workers[workerId]);
|
||||
}
|
||||
return returnData;
|
||||
},
|
||||
workerIds(): string[] {
|
||||
return Object.keys(this.orchestrationManagerStore.workers);
|
||||
},
|
||||
pageTitle() {
|
||||
return this.i18n.baseText('workerList.pageTitle');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
averageLoadAvg(loads: number[]) {
|
||||
return (loads.reduce((prev, curr) => prev + curr, 0) / loads.length).toFixed(2);
|
||||
},
|
||||
getStatus(payload: IPushDataWorkerStatusPayload): ExecutionStatus {
|
||||
if (payload.runningJobsSummary.length > 0) {
|
||||
return 'running';
|
||||
} else {
|
||||
return 'success';
|
||||
}
|
||||
},
|
||||
getRowClass(payload: IPushDataWorkerStatusPayload): string {
|
||||
return [this.$style.execRow, this.$style[this.getStatus(payload)]].join(' ');
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.workerListHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.tableLoader {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<div :class="['accordion', $style.container]">
|
||||
<div :class="{ [$style.header]: true, [$style.expanded]: expanded }" @click="toggle">
|
||||
<n8n-icon :icon="icon" :color="iconColor" size="small" class="mr-2xs" />
|
||||
<n8n-text :class="$style.headerText" color="text-base" size="small" align="left" bold>
|
||||
<slot name="title"></slot>
|
||||
</n8n-text>
|
||||
<n8n-icon :icon="expanded ? 'chevron-up' : 'chevron-down'" bold />
|
||||
</div>
|
||||
<div v-if="expanded" :class="{ [$style.description]: true, [$style.collapsed]: !expanded }">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
type: String,
|
||||
default: 'tasks',
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: 'black',
|
||||
},
|
||||
initialExpanded: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const expanded = ref<boolean>(props.initialExpanded);
|
||||
|
||||
function toggle() {
|
||||
expanded.value = !expanded.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
padding-top: var(--spacing-s);
|
||||
align-items: center;
|
||||
|
||||
.headerText {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.expanded {
|
||||
padding: var(--spacing-s) 0 0 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
display: flex;
|
||||
padding: 0 var(--spacing-s) var(--spacing-s) var(--spacing-s);
|
||||
|
||||
b {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
</style>
|
133
packages/editor-ui/src/components/Workers/WorkerCard.ee.vue
Normal file
133
packages/editor-ui/src/components/Workers/WorkerCard.ee.vue
Normal file
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<n8n-card :class="$style.cardLink" v-if="worker">
|
||||
<template #header>
|
||||
<n8n-heading
|
||||
tag="h2"
|
||||
bold
|
||||
:class="stale ? [$style.cardHeading, $style.stale] : [$style.cardHeading]"
|
||||
data-test-id="worker-card-name"
|
||||
>
|
||||
{{ worker.workerId }} ({{ worker.hostname }}) | Average Load:
|
||||
{{ averageWorkerLoadFromLoadsAsString(worker.loadAvg ?? [0]) }} | Free Memory:
|
||||
{{ memAsGb(worker.freeMem).toFixed(2) }}GB / {{ memAsGb(worker.totalMem).toFixed(2) }}GB
|
||||
{{ stale ? ' (stale)' : '' }}
|
||||
</n8n-heading>
|
||||
</template>
|
||||
<div :class="$style.cardDescription">
|
||||
<n8n-text color="text-light" size="small" :class="$style.container">
|
||||
<span
|
||||
>{{ $locale.baseText('workerList.item.lastUpdated') }} {{ secondsSinceLastUpdateString }}s
|
||||
ago | Architecture: {{ worker.arch }} | Platform: {{ worker.platform }} | n8n-Version:
|
||||
{{ worker.version }} | Uptime: {{ upTime(worker.uptime) }}</span
|
||||
>
|
||||
<WorkerJobAccordion :items="worker.runningJobsSummary" />
|
||||
<WorkerNetAccordion :items="sortedWorkerInterfaces" />
|
||||
<WorkerChartsAccordion :worker-id="worker.workerId" />
|
||||
</n8n-text>
|
||||
</div>
|
||||
<template #append>
|
||||
<div :class="$style.cardActions" ref="cardActions">
|
||||
<!-- For future Worker actions -->
|
||||
</div>
|
||||
</template>
|
||||
</n8n-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useOrchestrationStore } from '@/stores/orchestration.store';
|
||||
import type { IPushDataWorkerStatusPayload } from '@/Interface';
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { averageWorkerLoadFromLoadsAsString, memAsGb } from '../../utils/workerUtils';
|
||||
import WorkerJobAccordion from './WorkerJobAccordion.ee.vue';
|
||||
import WorkerNetAccordion from './WorkerNetAccordion.ee.vue';
|
||||
import WorkerChartsAccordion from './WorkerChartsAccordion.ee.vue';
|
||||
|
||||
let interval: NodeJS.Timer;
|
||||
|
||||
const orchestrationStore = useOrchestrationStore();
|
||||
|
||||
const props = defineProps<{
|
||||
workerId: string;
|
||||
}>();
|
||||
|
||||
const secondsSinceLastUpdateString = ref<string>('0');
|
||||
const stale = ref<boolean>(false);
|
||||
|
||||
const worker = computed((): IPushDataWorkerStatusPayload | undefined => {
|
||||
return orchestrationStore.getWorkerStatus(props.workerId);
|
||||
});
|
||||
|
||||
const sortedWorkerInterfaces = computed(
|
||||
() => worker.value?.interfaces.toSorted((a, b) => a.family.localeCompare(b.family)) ?? [],
|
||||
);
|
||||
|
||||
function upTime(seconds: number): string {
|
||||
const days = Math.floor(seconds / (3600 * 24));
|
||||
seconds -= days * 3600 * 24;
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
seconds -= hrs * 3600;
|
||||
const mnts = Math.floor(seconds / 60);
|
||||
seconds -= mnts * 60;
|
||||
return `${days}d ${hrs}h ${mnts}m ${Math.floor(seconds)}s`;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
interval = setInterval(() => {
|
||||
const lastUpdated = orchestrationStore.getWorkerLastUpdated(props.workerId);
|
||||
if (!lastUpdated) {
|
||||
return;
|
||||
}
|
||||
const secondsSinceLastUpdate = Math.ceil((Date.now() - lastUpdated) / 1000);
|
||||
stale.value = secondsSinceLastUpdate > 10;
|
||||
secondsSinceLastUpdateString.value = secondsSinceLastUpdate.toFixed(0);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardLink {
|
||||
transition: box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.cardHeading {
|
||||
font-size: var(--font-size-s);
|
||||
word-break: break-word;
|
||||
padding: var(--spacing-s) 0 0 var(--spacing-s);
|
||||
}
|
||||
|
||||
.stale {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
min-height: 19px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 0 var(--spacing-s) var(--spacing-s);
|
||||
}
|
||||
|
||||
.cardActions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
padding: 0 var(--spacing-s) 0 0;
|
||||
cursor: default;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,151 @@
|
|||
<template>
|
||||
<WorkerAccordion icon="tasks" icon-color="black" :initial-expanded="false">
|
||||
<template #title>
|
||||
{{ $locale.baseText('workerList.item.chartsTitle') }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.charts">
|
||||
<Chart
|
||||
ref="chartRefJobs"
|
||||
type="line"
|
||||
:data="dataJobs"
|
||||
:options="optionsJobs"
|
||||
:class="$style.chart"
|
||||
/>
|
||||
<Chart
|
||||
ref="chartRefCPU"
|
||||
type="line"
|
||||
:data="dataCPU"
|
||||
:options="optionsCPU"
|
||||
:class="$style.chart"
|
||||
/>
|
||||
<Chart
|
||||
ref="chartRefMemory"
|
||||
type="line"
|
||||
:data="dataMemory"
|
||||
:options="optionsMemory"
|
||||
:class="$style.chart"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</WorkerAccordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WorkerAccordion from './WorkerAccordion.ee.vue';
|
||||
import { WORKER_HISTORY_LENGTH, useOrchestrationStore } from '@/stores/orchestration.store';
|
||||
import { ref } from 'vue';
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import type { ChartComponentRef } from 'vue-chartjs';
|
||||
import { Chart } from 'vue-chartjs';
|
||||
import { averageWorkerLoadFromLoads, memAsGb } from '../../utils/workerUtils';
|
||||
|
||||
const props = defineProps<{
|
||||
workerId: string;
|
||||
}>();
|
||||
|
||||
const blankDataSet = (label: string, color: string, prefill: number = 0) => ({
|
||||
datasets: [
|
||||
{
|
||||
label,
|
||||
backgroundColor: color,
|
||||
data: prefill ? Array<number>(Math.min(WORKER_HISTORY_LENGTH, prefill)).fill(0) : [],
|
||||
},
|
||||
],
|
||||
labels: Array<string>(Math.min(WORKER_HISTORY_LENGTH, prefill)).fill(''),
|
||||
});
|
||||
|
||||
const orchestrationStore = useOrchestrationStore();
|
||||
const chartRefJobs = ref<ChartComponentRef | undefined>(undefined);
|
||||
const chartRefCPU = ref<ChartComponentRef | undefined>(undefined);
|
||||
const chartRefMemory = ref<ChartComponentRef | undefined>(undefined);
|
||||
const optionsBase: () => Partial<ChartOptions<'line'>> = () => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
scales: {
|
||||
y: {
|
||||
type: 'linear',
|
||||
display: true,
|
||||
position: 'left',
|
||||
min: 0,
|
||||
suggestedMax: 5,
|
||||
},
|
||||
},
|
||||
// uncomment to disable animation
|
||||
// animation: {
|
||||
// duration: 0,
|
||||
// },
|
||||
});
|
||||
const optionsJobs: Partial<ChartOptions<'line'>> = optionsBase();
|
||||
const optionsCPU: Partial<ChartOptions<'line'>> = optionsBase();
|
||||
if (optionsCPU.scales?.y) optionsCPU.scales.y.suggestedMax = 100;
|
||||
const maxMemory = memAsGb(orchestrationStore.workers[props.workerId]?.totalMem) ?? 1;
|
||||
const optionsMemory: Partial<ChartOptions<'line'>> = optionsBase();
|
||||
if (optionsMemory.scales?.y) optionsMemory.scales.y.suggestedMax = maxMemory;
|
||||
|
||||
// prefilled initial arrays
|
||||
const dataJobs = ref<ChartData>(
|
||||
blankDataSet('Job Count', 'rgb(255, 111, 92)', WORKER_HISTORY_LENGTH),
|
||||
);
|
||||
const dataCPU = ref<ChartData>(
|
||||
blankDataSet('Processor Usage', 'rgb(19, 205, 103)', WORKER_HISTORY_LENGTH),
|
||||
);
|
||||
const dataMemory = ref<ChartData>(
|
||||
blankDataSet('Memory Usage', 'rgb(244, 216, 174)', WORKER_HISTORY_LENGTH),
|
||||
);
|
||||
|
||||
orchestrationStore.$onAction(({ name, store }) => {
|
||||
if (name === 'updateWorkerStatus') {
|
||||
const prefillCount =
|
||||
WORKER_HISTORY_LENGTH - (store.workersHistory[props.workerId]?.length ?? 0);
|
||||
const newDataJobs: ChartData = blankDataSet('Job Count', 'rgb(255, 111, 92)', prefillCount);
|
||||
const newDataCPU: ChartData = blankDataSet(
|
||||
'Processor Usage',
|
||||
'rgb(19, 205, 103)',
|
||||
prefillCount,
|
||||
);
|
||||
const newDataMemory: ChartData = blankDataSet(
|
||||
'Memory Usage',
|
||||
'rgb(244, 216, 174)',
|
||||
prefillCount,
|
||||
);
|
||||
store.workersHistory[props.workerId]?.forEach((item) => {
|
||||
newDataJobs.datasets[0].data.push(item.data.runningJobsSummary.length);
|
||||
newDataJobs.labels?.push(new Date(item.timestamp).toLocaleTimeString());
|
||||
newDataCPU.datasets[0].data.push(averageWorkerLoadFromLoads(item.data.loadAvg));
|
||||
newDataCPU.labels = newDataJobs.labels;
|
||||
newDataMemory.datasets[0].data.push(maxMemory - memAsGb(item.data.freeMem));
|
||||
newDataMemory.labels = newDataJobs.labels;
|
||||
});
|
||||
dataJobs.value = newDataJobs;
|
||||
dataCPU.value = newDataCPU;
|
||||
dataMemory.value = newDataMemory;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.accordionItems {
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordionItem {
|
||||
display: block !important;
|
||||
text-align: left;
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.charts {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chart {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<WorkerAccordion icon="tasks" icon-color="black" :initial-expanded="true">
|
||||
<template #title>
|
||||
{{ $locale.baseText('workerList.item.jobListTitle') }} ({{ items.length }})
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="props.items.length > 0" :class="$style.accordionItems">
|
||||
<div v-for="item in props.items" :key="item.executionId" :class="$style.accordionItem">
|
||||
<a :href="'/workflow/' + item.workflowId + '/executions/' + item.executionId">
|
||||
Execution {{ item.executionId }} - {{ item.workflowName }}</a
|
||||
>
|
||||
<n8n-text color="text-base" size="small" align="left">
|
||||
| Started at:
|
||||
{{ new Date(item.startedAt)?.toLocaleTimeString() }} | Running for
|
||||
{{ runningSince(new Date(item.startedAt)) }}
|
||||
{{ item.retryOf ? `| Retry of: ${item.retryOf}` : '' }} |
|
||||
</n8n-text>
|
||||
<a target="_blank" :href="'/workflow/' + item.workflowId"> (Open workflow)</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="$style.accordionItems">
|
||||
<span :class="$style.empty">
|
||||
{{ $locale.baseText('workerList.item.jobList.empty') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</WorkerAccordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WorkerJobStatusSummary } from '@/Interface';
|
||||
import WorkerAccordion from './WorkerAccordion.ee.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
items: WorkerJobStatusSummary[];
|
||||
}>();
|
||||
|
||||
function runningSince(started: Date): string {
|
||||
let seconds = Math.floor((new Date().getTime() - started.getTime()) / 1000);
|
||||
const hrs = Math.floor(seconds / 3600);
|
||||
seconds -= hrs * 3600;
|
||||
const mnts = Math.floor(seconds / 60);
|
||||
seconds -= mnts * 60;
|
||||
return `${hrs}h ${mnts}m ${Math.floor(seconds)}s`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.accordionItems {
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.accordionItem {
|
||||
display: block !important;
|
||||
text-align: left;
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: block !important;
|
||||
text-align: left;
|
||||
margin-top: var(--spacing-2xs);
|
||||
margin-left: var(--spacing-4xs);
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<WorkerAccordion icon="tasks" icon-color="black" :initial-expanded="false">
|
||||
<template #title>
|
||||
{{ $locale.baseText('workerList.item.netListTitle') }} ({{ items.length }})
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="props.items.length > 0" :class="$style.accordionItems">
|
||||
<div
|
||||
v-for="item in props.items"
|
||||
:key="item.address"
|
||||
:class="$style.accordionItem"
|
||||
@click="copyToClipboard(item.address)"
|
||||
>
|
||||
{{ item.family }}: <span :class="$style.clickable">{{ item.address }}</span>
|
||||
{{ item.internal ? '(internal)' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</WorkerAccordion>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { IPushDataWorkerStatusPayload } from '@/Interface';
|
||||
import WorkerAccordion from './WorkerAccordion.ee.vue';
|
||||
import { useCopyToClipboard, useToast, useI18n } from '@/composables';
|
||||
|
||||
const props = defineProps<{
|
||||
items: IPushDataWorkerStatusPayload['interfaces'];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
function copyToClipboard(content: string) {
|
||||
const copyToClipboardFn = useCopyToClipboard();
|
||||
const { showMessage } = useToast();
|
||||
|
||||
try {
|
||||
copyToClipboardFn(content);
|
||||
showMessage({
|
||||
title: i18n.baseText('workerList.item.copyAddressToClipboard'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" module>
|
||||
.accordionItems {
|
||||
display: flex;
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
width: 100%;
|
||||
margin-top: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.accordionItem {
|
||||
display: block !important;
|
||||
text-align: left;
|
||||
margin-bottom: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer !important;
|
||||
|
||||
&:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
26
packages/editor-ui/src/plugins/chartjs.ts
Normal file
26
packages/editor-ui/src/plugins/chartjs.ts
Normal file
|
@ -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,
|
||||
);
|
||||
},
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import './icons';
|
||||
import './directives';
|
||||
import './components';
|
||||
import './chartjs';
|
||||
|
|
|
@ -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,
|
||||
|
|
76
packages/editor-ui/src/stores/orchestration.store.ts
Normal file
76
packages/editor-ui/src/stores/orchestration.store.ts
Normal file
|
@ -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] ?? [];
|
||||
},
|
||||
},
|
||||
});
|
|
@ -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;
|
||||
},
|
||||
|
|
11
packages/editor-ui/src/utils/workerUtils.ts
Normal file
11
packages/editor-ui/src/utils/workerUtils.ts
Normal file
|
@ -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;
|
||||
}
|
66
packages/editor-ui/src/views/WorkerView.vue
Normal file
66
packages/editor-ui/src/views/WorkerView.vue
Normal file
|
@ -0,0 +1,66 @@
|
|||
<template>
|
||||
<div :class="$style.workerListWrapper">
|
||||
<div :class="$style.workerList">
|
||||
<WorkerList
|
||||
v-if="settingsStore.isQueueModeEnabled && settingsStore.isWorkerViewAvailable"
|
||||
data-test-id="worker-view-licensed"
|
||||
/>
|
||||
<n8n-action-box
|
||||
v-else
|
||||
data-test-id="worker-view-unlicensed"
|
||||
:class="$style.actionBox"
|
||||
:description="$locale.baseText('workerList.actionBox.description')"
|
||||
:buttonText="$locale.baseText('workerList.actionBox.buttonText')"
|
||||
@click:button="goToUpgrade"
|
||||
>
|
||||
<template #heading>
|
||||
<span>{{ $locale.baseText('workerList.actionBox.title') }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ $locale.baseText('workerList.actionBox.description') }}
|
||||
<a :href="$locale.baseText('workerList.docs.url')" target="_blank">
|
||||
{{ $locale.baseText('workerList.actionBox.description.link') }}
|
||||
</a>
|
||||
</template>
|
||||
</n8n-action-box>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import WorkerList from '@/components/WorkerList.ee.vue';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const goToUpgrade = () => {
|
||||
void uiStore.goToUpgrade('source-control', 'upgrade-source-control');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style module lang="scss">
|
||||
.workerListWrapper {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr 0;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.workerList {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: var(--spacing-l) var(--spacing-l) 0;
|
||||
@media (min-width: 1200px) {
|
||||
padding: var(--spacing-2xl) var(--spacing-2xl) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.actionBox {
|
||||
margin: var(--spacing-2xl) 0 0;
|
||||
}
|
||||
</style>
|
|
@ -2315,6 +2315,7 @@ export interface IN8nUISettings {
|
|||
debugInEditor: boolean;
|
||||
binaryDataS3: boolean;
|
||||
workflowHistory: boolean;
|
||||
workerView: boolean;
|
||||
};
|
||||
hideUsagePage: boolean;
|
||||
license: {
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue