From 095ce93cf774528b264bb57a654ef302c84a32b0 Mon Sep 17 00:00:00 2001 From: pemontto Date: Sun, 17 Oct 2021 17:48:24 +0100 Subject: [PATCH 01/29] =?UTF-8?q?=E2=9C=A8=20Add=20loadOptions=20for=20Hom?= =?UTF-8?q?e=20Assistant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HomeAssistant/CameraProxyDescription.ts | 5 +- .../nodes/HomeAssistant/GenericFunctions.ts | 49 ++++++++++++++++++- .../nodes/HomeAssistant/HomeAssistant.node.ts | 26 ++++++++++ .../nodes/HomeAssistant/ServiceDescription.ts | 13 ++++- .../nodes/HomeAssistant/StateDescription.ts | 10 +++- 5 files changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/nodes-base/nodes/HomeAssistant/CameraProxyDescription.ts b/packages/nodes-base/nodes/HomeAssistant/CameraProxyDescription.ts index 35764bfc2a..3f28c6dc9a 100644 --- a/packages/nodes-base/nodes/HomeAssistant/CameraProxyDescription.ts +++ b/packages/nodes-base/nodes/HomeAssistant/CameraProxyDescription.ts @@ -33,7 +33,10 @@ export const cameraProxyFields = [ { displayName: 'Camera Entity ID', name: 'cameraEntityId', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getCameraEntities', + }, default: '', required: true, displayOptions: { diff --git a/packages/nodes-base/nodes/HomeAssistant/GenericFunctions.ts b/packages/nodes-base/nodes/HomeAssistant/GenericFunctions.ts index 796b03ee51..673caab7c3 100644 --- a/packages/nodes-base/nodes/HomeAssistant/GenericFunctions.ts +++ b/packages/nodes-base/nodes/HomeAssistant/GenericFunctions.ts @@ -4,15 +4,17 @@ import { import { IExecuteFunctions, + ILoadOptionsFunctions, } from 'n8n-core'; import { IDataObject, + INodePropertyOptions, NodeApiError, NodeOperationError, } from 'n8n-workflow'; -export async function homeAssistantApiRequest(this: IExecuteFunctions, method: string, resource: string, body: IDataObject = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}) { +export async function homeAssistantApiRequest(this: IExecuteFunctions | ILoadOptionsFunctions, method: string, resource: string, body: IDataObject = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}) { const credentials = await this.getCredentials('homeAssistantApi'); if (credentials === undefined) { @@ -35,8 +37,51 @@ export async function homeAssistantApiRequest(this: IExecuteFunctions, method: s delete options.body; } try { - return await this.helpers.request(options); + if (this.helpers.request) { + return await this.helpers.request(options); + } } catch (error) { throw new NodeApiError(this.getNode(), error); } } + +export async function getHomeAssistantEntities(this: IExecuteFunctions | ILoadOptionsFunctions, domain = '') { + const returnData: INodePropertyOptions[] = []; + const entities = await homeAssistantApiRequest.call(this, 'GET', '/states'); + for (const entity of entities) { + const entityId = entity.entity_id as string; + if (domain === '' || domain && entityId.startsWith(domain)) { + const entityName = entity.attributes.friendly_name as string || entityId; + returnData.push({ + name: entityName, + value: entityId, + }); + } + } + return returnData; +} + +export async function getHomeAssistantServices(this: IExecuteFunctions | ILoadOptionsFunctions, domain = '') { + const returnData: INodePropertyOptions[] = []; + const services = await homeAssistantApiRequest.call(this, 'GET', '/services'); + if (domain === '') { + // If no domain specified return domains + const domains = services.map(({ domain }: IDataObject) => domain as string).sort(); + returnData.push(...domains.map((service: string) => ({ name: service, value: service }))); + return returnData; + } else { + // If we have a domain, return all relevant services + const domainServices = services.filter((service: IDataObject) => service.domain === domain); + for (const domainService of domainServices) { + for (const [serviceID, value] of Object.entries(domainService.services)) { + const serviceProperties = value as IDataObject; + const serviceName = serviceProperties.description || serviceID; + returnData.push({ + name: serviceName as string, + value: serviceID, + }); + } + } + } + return returnData; +} diff --git a/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts b/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts index d84525f159..008ebf5680 100644 --- a/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts +++ b/packages/nodes-base/nodes/HomeAssistant/HomeAssistant.node.ts @@ -4,7 +4,9 @@ import { import { IDataObject, + ILoadOptionsFunctions, INodeExecutionData, + INodePropertyOptions, INodeType, INodeTypeDescription, } from 'n8n-workflow'; @@ -49,6 +51,8 @@ import { } from './CameraProxyDescription'; import { + getHomeAssistantEntities, + getHomeAssistantServices, homeAssistantApiRequest, } from './GenericFunctions'; @@ -133,6 +137,28 @@ export class HomeAssistant implements INodeType { ], }; + methods = { + loadOptions: { + async getAllEntities(this: ILoadOptionsFunctions): Promise { + return await getHomeAssistantEntities.call(this); + }, + async getCameraEntities(this: ILoadOptionsFunctions): Promise { + return await getHomeAssistantEntities.call(this, 'camera'); + }, + async getDomains(this: ILoadOptionsFunctions): Promise { + return await getHomeAssistantServices.call(this); + }, + async getDomainServices(this: ILoadOptionsFunctions): Promise { + const currentDomain = this.getCurrentNodeParameter('domain') as string; + if (currentDomain) { + return await getHomeAssistantServices.call(this, currentDomain); + } else { + return []; + } + }, + }, + }; + async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: IDataObject[] = []; diff --git a/packages/nodes-base/nodes/HomeAssistant/ServiceDescription.ts b/packages/nodes-base/nodes/HomeAssistant/ServiceDescription.ts index d278d47227..100bbff1fb 100644 --- a/packages/nodes-base/nodes/HomeAssistant/ServiceDescription.ts +++ b/packages/nodes-base/nodes/HomeAssistant/ServiceDescription.ts @@ -83,7 +83,10 @@ export const serviceFields = [ { displayName: 'Domain', name: 'domain', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getDomains', + }, default: '', required: true, displayOptions: { @@ -100,7 +103,13 @@ export const serviceFields = [ { displayName: 'Service', name: 'service', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsDependsOn: [ + 'domain', + ], + loadOptionsMethod: 'getDomainServices', + }, default: '', required: true, displayOptions: { diff --git a/packages/nodes-base/nodes/HomeAssistant/StateDescription.ts b/packages/nodes-base/nodes/HomeAssistant/StateDescription.ts index aa356f1c7a..580e1fa292 100644 --- a/packages/nodes-base/nodes/HomeAssistant/StateDescription.ts +++ b/packages/nodes-base/nodes/HomeAssistant/StateDescription.ts @@ -43,7 +43,10 @@ export const stateFields = [ { displayName: 'Entity ID', name: 'entityId', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllEntities', + }, displayOptions: { show: { operation: [ @@ -110,7 +113,10 @@ export const stateFields = [ { displayName: 'Entity ID', name: 'entityId', - type: 'string', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getAllEntities', + }, displayOptions: { show: { operation: [ From 1d05185c4a0db9ae5df2c2b706a675d4e1c1aa4b Mon Sep 17 00:00:00 2001 From: Anuj Kapoor Date: Fri, 10 Dec 2021 18:54:09 +0530 Subject: [PATCH 02/29] :zap: Added toast tweaks (#2530) * Added toast fallback * Added a parameter to copy function to deduce cut operations * :shirt: Fix Liniting --- packages/editor-ui/src/views/NodeView.vue | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/editor-ui/src/views/NodeView.vue b/packages/editor-ui/src/views/NodeView.vue index 9204683ab6..348c0732cb 100644 --- a/packages/editor-ui/src/views/NodeView.vue +++ b/packages/editor-ui/src/views/NodeView.vue @@ -840,15 +840,22 @@ export default mixins( }, cutSelectedNodes () { - this.copySelectedNodes(); + this.copySelectedNodes(true); this.deleteSelectedNodes(); }, - copySelectedNodes () { + copySelectedNodes (isCut: boolean) { this.getSelectedNodesToSave().then((data) => { const nodeData = JSON.stringify(data, null, 2); this.copyToClipboard(nodeData); if (data.nodes.length > 0) { + if(!isCut){ + this.$showMessage({ + title: 'Copied!', + message: '', + type: 'success', + }); + } this.$telemetry.track('User copied nodes', { node_types: data.nodes.map((node) => node.type), workflow_id: this.$store.getters.workflowId, @@ -963,12 +970,6 @@ export default mixins( this.$showError(error, 'Problem deleting the test-webhook', 'There was a problem deleting webhook:'); return; } - - this.$showMessage({ - title: 'Webhook deleted', - message: `The webhook was deleted successfully`, - type: 'success', - }); }, /** From 75c7b5ed97ae1917a42f7f7376e44d941762925e Mon Sep 17 00:00:00 2001 From: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Date: Fri, 10 Dec 2021 14:48:56 +0100 Subject: [PATCH 03/29] :bug: Hide trigger tooltip for polling nodes (#2547) * hide trigger tooltip for polling nodes * fix spacing --- packages/editor-ui/src/components/Node.vue | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/editor-ui/src/components/Node.vue b/packages/editor-ui/src/components/Node.vue index 96f57696e6..1d015c75ff 100644 --- a/packages/editor-ui/src/components/Node.vue +++ b/packages/editor-ui/src/components/Node.vue @@ -123,6 +123,9 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext return `Waiting for you to create an event in ${this.nodeType && this.nodeType.displayName.replace(/Trigger/, "")}`; } }, + isPollingTypeNode (): boolean { + return !!(this.nodeType && this.nodeType.polling); + }, isExecuting (): boolean { return this.$store.getters.executingNode === this.data.name; }, @@ -243,7 +246,16 @@ export default mixins(externalHooks, nodeBase, nodeHelpers, workflowHelpers).ext return !!(this.nodeType && this.nodeType.outputs.length > 2); }, shouldShowTriggerTooltip () : boolean { - return !!this.node && this.workflowRunning && this.workflowDataItems === 0 && this.isTriggerNode && this.isSingleActiveTriggerNode && !this.isTriggerNodeTooltipEmpty && !this.isNodeDisabled && !this.hasIssues && !this.dragging; + return !!this.node && + this.isTriggerNode && + !this.isPollingTypeNode && + !this.isNodeDisabled && + this.workflowRunning && + this.workflowDataItems === 0 && + this.isSingleActiveTriggerNode && + !this.isTriggerNodeTooltipEmpty && + !this.hasIssues && + !this.dragging; }, }, watch: { From 2125f2579157ba261d78f4af5bc7c96e0917262c Mon Sep 17 00:00:00 2001 From: Ahsan Virani Date: Fri, 10 Dec 2021 15:29:05 +0100 Subject: [PATCH 04/29] :zap: Tweaks to diagnostic events (#2544) * Tweaks to events * more tweaks and fixes --- packages/cli/commands/execute.ts | 4 +- packages/cli/commands/executeBatch.ts | 4 +- packages/cli/commands/start.ts | 14 +-- packages/cli/commands/webhook.ts | 3 +- packages/cli/commands/worker.ts | 6 +- packages/cli/src/Interfaces.ts | 5 +- packages/cli/src/InternalHooks.ts | 29 ++++-- packages/cli/src/InternalHooksManager.ts | 7 +- packages/cli/src/Server.ts | 9 +- packages/cli/src/WorkflowRunnerProcess.ts | 4 +- packages/cli/src/telemetry/index.ts | 90 ++++++++++++++----- packages/editor-ui/src/App.vue | 10 ++- .../editor-ui/src/plugins/telemetry/index.ts | 6 ++ packages/editor-ui/src/views/NodeView.vue | 1 + 14 files changed, 142 insertions(+), 50 deletions(-) diff --git a/packages/cli/commands/execute.ts b/packages/cli/commands/execute.ts index b74eb7397d..c9eefd9145 100644 --- a/packages/cli/commands/execute.ts +++ b/packages/cli/commands/execute.ts @@ -11,6 +11,7 @@ import { CredentialTypes, Db, ExternalHooks, + GenericHelpers, InternalHooksManager, IWorkflowBase, IWorkflowExecutionDataProcess, @@ -125,7 +126,8 @@ export class Execute extends Command { await externalHooks.init(); const instanceId = await UserSettings.getInstanceId(); - InternalHooksManager.init(instanceId); + const { cli } = await GenericHelpers.getVersions(); + InternalHooksManager.init(instanceId, cli); // Add the found types to an instance other parts of the application can use const nodeTypes = NodeTypes(); diff --git a/packages/cli/commands/executeBatch.ts b/packages/cli/commands/executeBatch.ts index f415e3c5c2..69827f468c 100644 --- a/packages/cli/commands/executeBatch.ts +++ b/packages/cli/commands/executeBatch.ts @@ -28,6 +28,7 @@ import { CredentialTypes, Db, ExternalHooks, + GenericHelpers, InternalHooksManager, IWorkflowDb, IWorkflowExecutionDataProcess, @@ -305,7 +306,8 @@ export class ExecuteBatch extends Command { await externalHooks.init(); const instanceId = await UserSettings.getInstanceId(); - InternalHooksManager.init(instanceId); + const { cli } = await GenericHelpers.getVersions(); + InternalHooksManager.init(instanceId, cli); // Add the found types to an instance other parts of the application can use const nodeTypes = NodeTypes(); diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 253877e564..e34b457810 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -153,17 +153,6 @@ export class Start extends Command { LoggerProxy.init(logger); logger.info('Initializing n8n process'); - logger.info( - '\n' + - '****************************************************\n' + - '* *\n' + - '* n8n now sends selected, anonymous telemetry. *\n' + - '* For more details (and how to opt out): *\n' + - '* https://docs.n8n.io/reference/telemetry.html *\n' + - '* *\n' + - '****************************************************\n', - ); - // Start directly with the init of the database to improve startup time const startDbInitPromise = Db.init().catch((error: Error) => { logger.error(`There was an error initializing DB: "${error.message}"`); @@ -313,7 +302,8 @@ export class Start extends Command { } const instanceId = await UserSettings.getInstanceId(); - InternalHooksManager.init(instanceId); + const { cli } = await GenericHelpers.getVersions(); + InternalHooksManager.init(instanceId, cli); await Server.start(); diff --git a/packages/cli/commands/webhook.ts b/packages/cli/commands/webhook.ts index a5f926682f..ebf683e6ca 100644 --- a/packages/cli/commands/webhook.ts +++ b/packages/cli/commands/webhook.ts @@ -149,7 +149,8 @@ export class Webhook extends Command { await startDbInitPromise; const instanceId = await UserSettings.getInstanceId(); - InternalHooksManager.init(instanceId); + const { cli } = await GenericHelpers.getVersions(); + InternalHooksManager.init(instanceId, cli); if (config.get('executions.mode') === 'queue') { const redisHost = config.get('queue.bull.redis.host'); diff --git a/packages/cli/commands/worker.ts b/packages/cli/commands/worker.ts index 28a02b9b39..1290868abf 100644 --- a/packages/cli/commands/worker.ts +++ b/packages/cli/commands/worker.ts @@ -271,10 +271,10 @@ export class Worker extends Command { // eslint-disable-next-line @typescript-eslint/no-floating-promises Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, nodeTypes)); - const instanceId = await UserSettings.getInstanceId(); - InternalHooksManager.init(instanceId); - const versions = await GenericHelpers.getVersions(); + const instanceId = await UserSettings.getInstanceId(); + + InternalHooksManager.init(instanceId, versions.cli); console.info('\nn8n worker is now ready'); console.info(` * Version: ${versions.cli}`); diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 556aa74492..30cd03b8b0 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -314,7 +314,10 @@ export interface IDiagnosticInfo { export interface IInternalHooksClass { onN8nStop(): Promise; - onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise; + onServerStarted( + diagnosticInfo: IDiagnosticInfo, + firstWorkflowCreatedAt?: Date, + ): Promise; onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise; onWorkflowCreated(workflow: IWorkflowBase): Promise; onWorkflowDeleted(workflowId: string): Promise; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 4f6d4839d6..fc67a3634e 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -9,9 +9,16 @@ import { import { Telemetry } from './telemetry'; export class InternalHooksClass implements IInternalHooksClass { - constructor(private telemetry: Telemetry) {} + private versionCli: string; - async onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise { + constructor(private telemetry: Telemetry, versionCli: string) { + this.versionCli = versionCli; + } + + async onServerStarted( + diagnosticInfo: IDiagnosticInfo, + earliestWorkflowCreatedAt?: Date, + ): Promise { const info = { version_cli: diagnosticInfo.versionCli, db_type: diagnosticInfo.databaseType, @@ -25,7 +32,10 @@ export class InternalHooksClass implements IInternalHooksClass { return Promise.all([ this.telemetry.identify(info), - this.telemetry.track('Instance started', info), + this.telemetry.track('Instance started', { + ...info, + earliest_workflow_created: earliestWorkflowCreatedAt, + }), ]); } @@ -39,9 +49,11 @@ export class InternalHooksClass implements IInternalHooksClass { } async onWorkflowCreated(workflow: IWorkflowBase): Promise { + const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow); return this.telemetry.track('User created workflow', { workflow_id: workflow.id, - node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, + node_graph: nodeGraph, + node_graph_string: JSON.stringify(nodeGraph), }); } @@ -52,9 +64,13 @@ export class InternalHooksClass implements IInternalHooksClass { } async onWorkflowSaved(workflow: IWorkflowBase): Promise { + const { nodeGraph } = TelemetryHelpers.generateNodesGraph(workflow); + return this.telemetry.track('User saved workflow', { workflow_id: workflow.id, - node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, + node_graph: nodeGraph, + node_graph_string: JSON.stringify(nodeGraph), + version_cli: this.versionCli, }); } @@ -62,6 +78,7 @@ export class InternalHooksClass implements IInternalHooksClass { const properties: IDataObject = { workflow_id: workflow.id, is_manual: false, + version_cli: this.versionCli, }; if (runData !== undefined) { @@ -92,6 +109,8 @@ export class InternalHooksClass implements IInternalHooksClass { if (properties.is_manual) { const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow); properties.node_graph = nodeGraphResult.nodeGraph; + properties.node_graph_string = JSON.stringify(nodeGraphResult.nodeGraph); + if (errorNodeName) { properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; } diff --git a/packages/cli/src/InternalHooksManager.ts b/packages/cli/src/InternalHooksManager.ts index 28087b3702..d050cb04c8 100644 --- a/packages/cli/src/InternalHooksManager.ts +++ b/packages/cli/src/InternalHooksManager.ts @@ -13,9 +13,12 @@ export class InternalHooksManager { throw new Error('InternalHooks not initialized'); } - static init(instanceId: string): InternalHooksClass { + static init(instanceId: string, versionCli: string): InternalHooksClass { if (!this.internalHooksInstance) { - this.internalHooksInstance = new InternalHooksClass(new Telemetry(instanceId)); + this.internalHooksInstance = new InternalHooksClass( + new Telemetry(instanceId, versionCli), + versionCli, + ); } return this.internalHooksInstance; diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 6ea877f54e..c636db56f0 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -2896,7 +2896,14 @@ export async function start(): Promise { deploymentType: config.get('deployment.type'), }; - void InternalHooksManager.getInstance().onServerStarted(diagnosticInfo); + void Db.collections + .Workflow!.findOne({ + select: ['createdAt'], + order: { createdAt: 'ASC' }, + }) + .then(async (workflow) => + InternalHooksManager.getInstance().onServerStarted(diagnosticInfo, workflow?.createdAt), + ); }); } diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index e8b8274c9f..862fa4303f 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -31,6 +31,7 @@ import { CredentialTypes, Db, ExternalHooks, + GenericHelpers, IWorkflowExecuteProcess, IWorkflowExecutionDataProcessWithExecution, NodeTypes, @@ -137,7 +138,8 @@ export class WorkflowRunnerProcess { await externalHooks.init(); const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? ''; - InternalHooksManager.init(instanceId); + const { cli } = await GenericHelpers.getVersions(); + InternalHooksManager.init(instanceId, cli); // Credentials should now be loaded from database. // We check if any node uses credentials. If it does, then diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index d350c6d8bd..fb4f53460d 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -5,28 +5,57 @@ import { IDataObject, LoggerProxy } from 'n8n-workflow'; import config = require('../../config'); import { getLogger } from '../Logger'; -interface IExecutionCountsBufferItem { - manual_success_count: number; - manual_error_count: number; - prod_success_count: number; - prod_error_count: number; -} +type CountBufferItemKey = + | 'manual_success_count' + | 'manual_error_count' + | 'prod_success_count' + | 'prod_error_count'; + +type FirstExecutionItemKey = + | 'first_manual_success' + | 'first_manual_error' + | 'first_prod_success' + | 'first_prod_error'; + +type IExecutionCountsBufferItem = { + [key in CountBufferItemKey]: number; +}; interface IExecutionCountsBuffer { [workflowId: string]: IExecutionCountsBufferItem; } +type IFirstExecutions = { + [key in FirstExecutionItemKey]: Date | undefined; +}; + +interface IExecutionsBuffer { + counts: IExecutionCountsBuffer; + firstExecutions: IFirstExecutions; +} + export class Telemetry { private client?: TelemetryClient; private instanceId: string; + private versionCli: string; + private pulseIntervalReference: NodeJS.Timeout; - private executionCountsBuffer: IExecutionCountsBuffer = {}; + private executionCountsBuffer: IExecutionsBuffer = { + counts: {}, + firstExecutions: { + first_manual_error: undefined, + first_manual_success: undefined, + first_prod_error: undefined, + first_prod_success: undefined, + }, + }; - constructor(instanceId: string) { + constructor(instanceId: string, versionCli: string) { this.instanceId = instanceId; + this.versionCli = versionCli; const enabled = config.get('diagnostics.enabled') as boolean; if (enabled) { @@ -53,33 +82,41 @@ export class Telemetry { return Promise.resolve(); } - const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => { + const allPromises = Object.keys(this.executionCountsBuffer.counts).map(async (workflowId) => { const promise = this.track('Workflow execution count', { + version_cli: this.versionCli, workflow_id: workflowId, - ...this.executionCountsBuffer[workflowId], + ...this.executionCountsBuffer.counts[workflowId], + ...this.executionCountsBuffer.firstExecutions, }); - this.executionCountsBuffer[workflowId].manual_error_count = 0; - this.executionCountsBuffer[workflowId].manual_success_count = 0; - this.executionCountsBuffer[workflowId].prod_error_count = 0; - this.executionCountsBuffer[workflowId].prod_success_count = 0; + + this.executionCountsBuffer.counts[workflowId].manual_error_count = 0; + this.executionCountsBuffer.counts[workflowId].manual_success_count = 0; + this.executionCountsBuffer.counts[workflowId].prod_error_count = 0; + this.executionCountsBuffer.counts[workflowId].prod_success_count = 0; return promise; }); - allPromises.push(this.track('pulse')); + allPromises.push(this.track('pulse', { version_cli: this.versionCli })); return Promise.all(allPromises); } async trackWorkflowExecution(properties: IDataObject): Promise { if (this.client) { const workflowId = properties.workflow_id as string; - this.executionCountsBuffer[workflowId] = this.executionCountsBuffer[workflowId] ?? { + this.executionCountsBuffer.counts[workflowId] = this.executionCountsBuffer.counts[ + workflowId + ] ?? { manual_error_count: 0, manual_success_count: 0, prod_error_count: 0, prod_success_count: 0, }; + let countKey: CountBufferItemKey; + let firstExecKey: FirstExecutionItemKey; + if ( properties.success === false && properties.error_node_type && @@ -89,15 +126,28 @@ export class Telemetry { void this.track('Workflow execution errored', properties); if (properties.is_manual) { - this.executionCountsBuffer[workflowId].manual_error_count++; + firstExecKey = 'first_manual_error'; + countKey = 'manual_error_count'; } else { - this.executionCountsBuffer[workflowId].prod_error_count++; + firstExecKey = 'first_prod_error'; + countKey = 'prod_error_count'; } } else if (properties.is_manual) { - this.executionCountsBuffer[workflowId].manual_success_count++; + countKey = 'manual_success_count'; + firstExecKey = 'first_manual_success'; } else { - this.executionCountsBuffer[workflowId].prod_success_count++; + countKey = 'prod_success_count'; + firstExecKey = 'first_prod_success'; } + + if ( + !this.executionCountsBuffer.firstExecutions[firstExecKey] && + this.executionCountsBuffer.counts[workflowId][countKey] === 0 + ) { + this.executionCountsBuffer.firstExecutions[firstExecKey] = new Date(); + } + + this.executionCountsBuffer.counts[workflowId][countKey]++; } } diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index 48d48b0958..1fba7209f0 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -14,14 +14,20 @@ Figma.logoCreated using Figma \ No newline at end of file diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index 2da78b154a..cc685e21f2 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -88,6 +88,7 @@ "dist/credentials/EventbriteOAuth2Api.credentials.js", "dist/credentials/FacebookGraphApi.credentials.js", "dist/credentials/FacebookGraphAppApi.credentials.js", + "dist/credentials/FigmaApi.credentials.js", "dist/credentials/FileMaker.credentials.js", "dist/credentials/FlowApi.credentials.js", "dist/credentials/FormIoApi.credentials.js", @@ -399,6 +400,7 @@ "dist/nodes/ExecuteWorkflow/ExecuteWorkflow.node.js", "dist/nodes/Facebook/FacebookGraphApi.node.js", "dist/nodes/Facebook/FacebookTrigger.node.js", + "dist/nodes/Figma/FigmaTrigger.node.js", "dist/nodes/FileMaker/FileMaker.node.js", "dist/nodes/Flow/Flow.node.js", "dist/nodes/Flow/FlowTrigger.node.js", From b62dc505591a2083fce7909c2783d5d5c33b187b Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Fri, 10 Dec 2021 12:36:24 -0500 Subject: [PATCH 09/29] :sparkles: Add Workable Trigger Node (#2522) * :zap: Workable Trigger * :zap: Improvements * :zap: Small improvement * :zap: Minor improvements Co-authored-by: Jan Oberhauser --- .../credentials/WorkableApi.credentials.ts | 24 +++ .../nodes/Workable/GenericFunctions.ts | 37 ++++ .../nodes/Workable/WorkableTrigger.node.ts | 201 ++++++++++++++++++ .../nodes-base/nodes/Workable/workable.png | Bin 0 -> 766 bytes packages/nodes-base/package.json | 2 + 5 files changed, 264 insertions(+) create mode 100644 packages/nodes-base/credentials/WorkableApi.credentials.ts create mode 100644 packages/nodes-base/nodes/Workable/GenericFunctions.ts create mode 100644 packages/nodes-base/nodes/Workable/WorkableTrigger.node.ts create mode 100644 packages/nodes-base/nodes/Workable/workable.png diff --git a/packages/nodes-base/credentials/WorkableApi.credentials.ts b/packages/nodes-base/credentials/WorkableApi.credentials.ts new file mode 100644 index 0000000000..4666b0945c --- /dev/null +++ b/packages/nodes-base/credentials/WorkableApi.credentials.ts @@ -0,0 +1,24 @@ +import { + ICredentialType, + INodeProperties, +} from 'n8n-workflow'; + +export class WorkableApi implements ICredentialType { + name = 'workableApi'; + displayName = 'Workable API'; + documentationUrl = 'workable'; + properties: INodeProperties[] = [ + { + displayName: 'Subdomain', + name: 'subdomain', + type: 'string', + default: '', + }, + { + displayName: 'Access Token', + name: 'accessToken', + type: 'string', + default: '', + }, + ]; +} diff --git a/packages/nodes-base/nodes/Workable/GenericFunctions.ts b/packages/nodes-base/nodes/Workable/GenericFunctions.ts new file mode 100644 index 0000000000..3f2829cfa9 --- /dev/null +++ b/packages/nodes-base/nodes/Workable/GenericFunctions.ts @@ -0,0 +1,37 @@ +import { + OptionsWithUri, +} from 'request'; + +import { + IExecuteFunctions, + IExecuteSingleFunctions, + IHookFunctions, + ILoadOptionsFunctions, +} from 'n8n-core'; + +import { + IDataObject, + NodeApiError, +} from 'n8n-workflow'; + +export async function workableApiRequest(this: IHookFunctions | IExecuteFunctions | IExecuteSingleFunctions | ILoadOptionsFunctions, method: string, resource: string, body: any = {}, qs: IDataObject = {}, uri?: string, option: IDataObject = {}): Promise { // tslint:disable-line:no-any + const credentials = await this.getCredentials('workableApi') as { accessToken: string, subdomain: string }; + + let options: OptionsWithUri = { + headers: { 'Authorization': `Bearer ${credentials.accessToken}` }, + method, + qs, + body, + uri: uri || `https://${credentials.subdomain}.workable.com/spi/v3${resource}`, + json: true, + }; + options = Object.assign({}, options, option); + if (Object.keys(options.body).length === 0) { + delete options.body; + } + try { + return await this.helpers.request!(options); + } catch (error) { + throw new NodeApiError(this.getNode(), error); + } +} diff --git a/packages/nodes-base/nodes/Workable/WorkableTrigger.node.ts b/packages/nodes-base/nodes/Workable/WorkableTrigger.node.ts new file mode 100644 index 0000000000..56cfbe64d8 --- /dev/null +++ b/packages/nodes-base/nodes/Workable/WorkableTrigger.node.ts @@ -0,0 +1,201 @@ +import { + IHookFunctions, + IWebhookFunctions, +} from 'n8n-core'; + +import { + IDataObject, + ILoadOptionsFunctions, + INodePropertyOptions, + INodeType, + INodeTypeDescription, + IWebhookResponseData, +} from 'n8n-workflow'; + +import { + workableApiRequest, +} from './GenericFunctions'; + +import { + snakeCase, +} from 'change-case'; + +export class WorkableTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Workable Trigger', + name: 'workableTrigger', + icon: 'file:workable.png', + group: ['trigger'], + version: 1, + subtitle: '={{$parameter["triggerOn"]}}', + description: 'Starts the workflow when Workable events occur', + defaults: { + name: 'Workable Trigger', + color: '#29b6f6', + }, + inputs: [], + outputs: ['main'], + credentials: [ + { + name: 'workableApi', + required: true, + }, + ], + webhooks: [ + { + name: 'default', + httpMethod: 'POST', + responseMode: 'onReceived', + path: 'webhook', + }, + ], + properties: [ + { + displayName: 'Trigger On', + name: 'triggerOn', + type: 'options', + options: [ + { + name: 'Candidate Created', + value: 'candidateCreated', + }, + { + name: 'Candidate Moved', + value: 'candidateMoved', + }, + ], + default: '', + required: true, + }, + { + displayName: 'Filters', + name: 'filters', + type: 'collection', + placeholder: 'Add Filter', + default: {}, + options: [ + { + displayName: 'Job', + name: 'job', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getJobs', + }, + default: '', + description: `Get notifications only for one job`, + }, + { + displayName: 'Stage', + name: 'stage', + type: 'options', + typeOptions: { + loadOptionsMethod: 'getStages', + }, + default: '', + description: `Get notifications for specific stages. e.g. 'hired'`, + }, + ], + }, + ], + }; + + methods = { + loadOptions: { + async getJobs(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { jobs } = await workableApiRequest.call(this, 'GET', '/jobs'); + for (const job of jobs) { + returnData.push({ + name: job.full_title, + value: job.shortcode, + }); + } + return returnData; + }, + async getStages(this: ILoadOptionsFunctions): Promise { + const returnData: INodePropertyOptions[] = []; + const { stages } = await workableApiRequest.call(this, 'GET', '/stages'); + for (const stage of stages) { + returnData.push({ + name: stage.name, + value: stage.slug, + }); + } + return returnData; + }, + }, + }; + + // @ts-ignore (because of request) + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + const webhookUrl = this.getNodeWebhookUrl('default'); + const webhookData = this.getWorkflowStaticData('node'); + // Check all the webhooks which exist already if it is identical to the + // one that is supposed to get created. + const { subscriptions } = await workableApiRequest.call(this, 'GET', `/subscriptions`); + for (const subscription of subscriptions) { + if (subscription.target === webhookUrl) { + webhookData.webhookId = subscription.id as string; + return true; + } + } + return false; + }, + async create(this: IHookFunctions): Promise { + const credentials = await this.getCredentials('workableApi') as { accessToken: string, subdomain: string }; + const webhookData = this.getWorkflowStaticData('node'); + const webhookUrl = this.getNodeWebhookUrl('default'); + const triggerOn = this.getNodeParameter('triggerOn') as string; + const { stage, job } = this.getNodeParameter('filters') as IDataObject; + const endpoint = '/subscriptions'; + + const body: IDataObject = { + event: snakeCase(triggerOn).toLowerCase(), + args: { + account_id: credentials.subdomain, + ...(job) && { job_shortcode: job }, + ...(stage) && { stage_slug: stage }, + }, + target: webhookUrl, + }; + + const responseData = await workableApiRequest.call(this, 'POST', endpoint, body); + + if (responseData.id === undefined) { + // Required data is missing so was not successful + return false; + } + + webhookData.webhookId = responseData.id as string; + return true; + }, + async delete(this: IHookFunctions): Promise { + const webhookData = this.getWorkflowStaticData('node'); + if (webhookData.webhookId !== undefined) { + + const endpoint = `/subscriptions/${webhookData.webhookId}`; + try { + await workableApiRequest.call(this, 'DELETE', endpoint); + } catch (error) { + return false; + } + // Remove from the static workflow data so that it is clear + // that no webhooks are registred anymore + delete webhookData.webhookId; + } + return true; + }, + }, + }; + + async webhook(this: IWebhookFunctions): Promise { + const bodyData = this.getBodyData(); + return { + workflowData: [ + this.helpers.returnJsonArray(bodyData), + ], + }; + } +} diff --git a/packages/nodes-base/nodes/Workable/workable.png b/packages/nodes-base/nodes/Workable/workable.png new file mode 100644 index 0000000000000000000000000000000000000000..f1c650bdd917cb1c9ef578c30983e23997c8531c GIT binary patch literal 766 zcmVh$wNnUNNOcbUk<8-#ihesuc% z{nO~?&*kIGy%Tl76OP-c5kA@$HeDe7CF37a-n7@$e7Li9wXbfls$;99 zJ(i9%k%;j3_TTR8+w1DT-P*0v&yT{rXsxF?l#BHF`MKHFiom;vzPW_Fw_vHFSEQUF zhkdWq(4fr7lElA8o0BPwgC&W66@YfF)6cmLVDbO}00MMUPE-8&ef$}x0005;NklD8~R zuSEHLTHN9m|457?Z@$DLAHntwg}=70zIP6>jm=nd(lpJwRi&l}DggLV@&UZ0O5=Bl z@ic`Zodg@F{Xli9j?&&_Ak+7-0pkiN!xHN%fbloKH^h7z@lfE$5+AAMFl5*Cd0X~U zYH{pS*?`OD0l=_O!LwS;2cJgggw^40hj3uax?6zWhH&CnJApaC><(+N7ZElmvTocH z=w*)TdocTkdc59j;X{EwrZK>(y+;=GEf6 zIpNtKS!vTx@7#NUg(tg?atRf#4k)~NEO8HlqXFAkS~mb#a-|ld)gv6bQuCGR%m5yE zc@k9ECRmRZwgk$dT=jDv0tDqcHYsfhQ{KU7nS`e!1&h-(j$YmhifDDIHSc0Qrf4gm zn7J*0Jxq&{#=6V6JIZJzTK>Bj-G$IJP+O<(tRAG`5|^+QtS Date: Fri, 10 Dec 2021 18:53:31 +0100 Subject: [PATCH 10/29] :bug: Fix recommendation logic (#2543) * fix: expect multiple answers for survey * chore: refactor survey input methods * fix error; fix tracking Co-authored-by: Mutasem --- packages/cli/src/Interfaces.ts | 6 ++- packages/cli/src/InternalHooks.ts | 2 + packages/editor-ui/src/Interface.ts | 7 ++- .../src/components/PersonalizationModal.vue | 46 ++++++++----------- packages/editor-ui/src/modules/helper.ts | 32 ++++++++----- 5 files changed, 51 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 30cd03b8b0..8559dd4968 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -406,10 +406,12 @@ export interface IN8nUISettings { } export interface IPersonalizationSurveyAnswers { - companySize: string | null; codingSkill: string | null; - workArea: string | null; + companyIndustry: string[]; + companySize: string | null; + otherCompanyIndustry: string | null; otherWorkArea: string | null; + workArea: string[] | string | null; } export interface IPersonalizationSurvey { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index fc67a3634e..a2e1ae54d2 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -45,6 +45,8 @@ export class InternalHooksClass implements IInternalHooksClass { coding_skill: answers.codingSkill, work_area: answers.workArea, other_work_area: answers.otherWorkArea, + company_industry: answers.companyIndustry, + other_company_industry: answers.otherCompanyIndustry, }); } diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 5248c17bd9..a625f6c75b 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -473,7 +473,12 @@ export interface IVersionNotificationSettings { export type IPersonalizationSurveyKeys = 'codingSkill' | 'companyIndustry' | 'companySize' | 'otherCompanyIndustry' | 'otherWorkArea' | 'workArea'; export type IPersonalizationSurveyAnswers = { - [key in IPersonalizationSurveyKeys]: string | null + codingSkill: string | null; + companyIndustry: string[]; + companySize: string | null; + otherCompanyIndustry: string | null; + otherWorkArea: string | null; + workArea: string[] | string | null; }; export interface IPersonalizationSurvey { diff --git a/packages/editor-ui/src/components/PersonalizationModal.vue b/packages/editor-ui/src/components/PersonalizationModal.vue index 37727a4ccc..124e7ea04c 100644 --- a/packages/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/editor-ui/src/components/PersonalizationModal.vue @@ -10,7 +10,6 @@ :closeOnPressEscape="false" width="460px" @enter="save" - @input="onInput" >