mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 01:56:46 +00: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:
committed by
GitHub
parent
0ddafd2b82
commit
cbc690907f
@@ -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>
|
||||
Reference in New Issue
Block a user