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:
Michael Auerswald 2023-11-10 23:48:31 +01:00 committed by GitHub
parent 0ddafd2b82
commit cbc690907f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 1125 additions and 50 deletions

View 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');
});
});

View file

@ -11,3 +11,4 @@ export * from './bannerStack';
export * from './workflow-executions-tab';
export * from './signin';
export * from './workflow-history';
export * from './workerView';

View 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 = {};
}

View file

@ -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')) {

View file

@ -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>;

View file

@ -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;

View file

@ -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() ?? [];
}

View file

@ -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 = {

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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;
}

View file

@ -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
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;
}

View file

@ -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,

View file

@ -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';

View file

@ -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",

View file

@ -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;

View file

@ -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',

View 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);
};

View file

@ -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) {

View 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>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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;

View file

@ -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');

View file

@ -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;
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);

View 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,
);
},
};

View file

@ -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",

View file

@ -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);
},

View file

@ -1,3 +1,4 @@
import './icons';
import './directives';
import './components';
import './chartjs';

View file

@ -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,

View 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] ?? [];
},
},
});

View file

@ -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;
},

View 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;
}

View 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>

View file

@ -2315,6 +2315,7 @@ export interface IN8nUISettings {
debugInEditor: boolean;
binaryDataS3: boolean;
workflowHistory: boolean;
workerView: boolean;
};
hideUsagePage: boolean;
license: {

View file

@ -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