From 421dd72224bf563a7bff6de7c4028c8e52811bc8 Mon Sep 17 00:00:00 2001 From: Ahsan Virani Date: Tue, 19 Oct 2021 05:57:49 +0200 Subject: [PATCH 01/11] :sparkles: Introduce telemetry (#2099) * introduce analytics * add user survey backend * add user survey backend * set answers on survey submit Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> * change name to personalization * lint Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> * N8n 2495 add personalization modal (#2280) * update modals * add onboarding modal * implement questions * introduce analytics * simplify impl * implement survey handling * add personalized cateogry * update modal behavior * add thank you view * handle empty cases * rename modal * standarize modal names * update image, add tags to headings * remove unused file * remove unused interfaces * clean up footer spacing * introduce analytics * refactor to fix bug * update endpoint * set min height * update stories * update naming from questions to survey * remove spacing after core categories * fix bug in logic * sort nodes * rename types * merge with be * rename userSurvey * clean up rest api * use constants for keys * use survey keys * clean up types * move personalization to its own file Co-authored-by: ahsan-virani * Survey new options (#2300) * split up options * fix quotes * remove unused import * add user created workflow event (#2301) * simplify env vars * fix versionCli on FE * update personalization env * fix event User opened Credentials panel * fix select modal spacing * fix nodes panel event * fix workflow id in workflow execute event * improve telemetry error logging * fix config and stop process events * add flush call on n8n stop * ready for release * improve telemetry process exit * fix merge * improve n8n stop events Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> Co-authored-by: Mutasem Co-authored-by: Jan Oberhauser --- .eslintrc.js | 7 + packages/cli/commands/start.ts | 14 +- packages/cli/config/index.ts | 40 +++ packages/cli/package.json | 1 + packages/cli/src/Interfaces.ts | 49 ++++ packages/cli/src/InternalHooks.ts | 105 ++++++++ packages/cli/src/InternalHooksManager.ts | 23 ++ packages/cli/src/PersonalizationSurvey.ts | 63 +++++ packages/cli/src/ResponseHelper.ts | 4 +- packages/cli/src/Server.ts | 129 +++++++-- .../cli/src/WorkflowExecuteAdditionalData.ts | 2 + packages/cli/src/WorkflowRunner.ts | 18 +- packages/cli/src/WorkflowRunnerProcess.ts | 7 +- packages/cli/src/index.ts | 1 + packages/cli/src/telemetry/index.ts | 151 +++++++++++ packages/core/src/Interfaces.ts | 1 + packages/core/src/UserSettings.ts | 52 +++- .../components/N8nHeading/Heading.stories.js | 30 +++ .../src/components/N8nHeading/Heading.vue | 128 +++++++++ .../src/components/N8nHeading/index.js | 3 + .../components/N8nIconButton/IconButton.vue | 8 +- .../src/components/N8nInfoTip/InfoTip.vue | 9 +- .../components/N8nInputLabel/InputLabel.vue | 36 ++- .../components/N8nSelect/Select.stories.js | 5 + .../src/components/N8nText/Text.stories.js | 30 +++ .../src/components/N8nText/Text.vue | 102 +++++++ .../src/components/N8nText/index.js | 3 + .../design-system/src/components/index.js | 4 + .../src/styleguide/TextClasses.vue | 49 ---- .../src/styleguide/spacing.stories.mdx | 1 - .../src/styleguide/text.stories.mdx | 38 --- packages/design-system/theme/src/_tokens.scss | 1 + .../design-system/theme/src/collapse.scss | 2 +- .../theme/src/common/typography.scss | 66 ----- .../design-system/theme/src/common/var.scss | 4 - packages/design-system/theme/src/dialog.scss | 5 +- .../design-system/theme/src/message-box.scss | 2 + packages/design-system/theme/src/reset.scss | 2 +- packages/editor-ui/.eslintrc.js | 2 +- packages/editor-ui/public/suggestednodes.png | Bin 0 -> 90624 bytes packages/editor-ui/src/App.vue | 6 + packages/editor-ui/src/Interface.ts | 20 +- packages/editor-ui/src/api/settings.ts | 12 + .../CredentialEdit/CredentialConfig.vue | 12 +- .../CredentialEdit/CredentialEdit.vue | 2 + .../src/components/CredentialsList.vue | 53 ++-- .../src/components/CredentialsSelectModal.vue | 16 +- .../editor-ui/src/components/DataDisplay.vue | 1 + .../components/DuplicateWorkflowDialog.vue | 48 ++-- .../src/components/ExecutionsList.vue | 1 + .../src/components/ExpressionEdit.vue | 6 +- .../components/MainHeader/WorkflowDetails.vue | 9 +- .../editor-ui/src/components/MainSidebar.vue | 33 +-- .../MainSidebarMenuItemsIterator.vue | 5 + packages/editor-ui/src/components/Modal.vue | 65 ++++- packages/editor-ui/src/components/Modals.vue | 103 ++++---- packages/editor-ui/src/components/Node.vue | 3 + .../components/NodeCreator/ItemIterator.vue | 1 - .../src/components/NodeCreator/MainPanel.vue | 16 +- .../src/components/NodeCreator/NoResults.vue | 6 +- .../components/NodeCreator/NodeCreator.vue | 4 +- .../src/components/NodeCreator/helpers.ts | 101 ++++--- .../src/components/NodeCredentials.vue | 8 +- .../editor-ui/src/components/NodeSettings.vue | 9 +- .../editor-ui/src/components/NodeWebhooks.vue | 6 +- .../src/components/ParameterInput.vue | 18 ++ .../src/components/ParameterInputExpanded.vue | 9 +- .../src/components/PersonalizationModal.vue | 249 ++++++++++++++++++ packages/editor-ui/src/components/RunData.vue | 4 + .../editor-ui/src/components/TagsDropdown.vue | 4 +- .../src/components/TagsManager/NoTagsView.vue | 1 - .../components/TagsManager/TagsManager.vue | 12 +- .../editor-ui/src/components/Telemetry.vue | 23 ++ .../editor-ui/src/components/UpdatesPanel.vue | 9 +- .../src/components/WorkflowActivator.vue | 1 + .../editor-ui/src/components/WorkflowOpen.vue | 17 +- .../src/components/WorkflowSettings.vue | 20 +- .../src/components/mixins/newVersions.ts | 3 +- .../src/components/mixins/nodeBase.ts | 4 +- .../src/components/mixins/pushConnection.ts | 3 +- .../src/components/mixins/restApi.ts | 4 - .../src/components/mixins/showMessage.ts | 9 +- .../src/components/mixins/workflowHelpers.ts | 9 +- .../src/components/mixins/workflowRun.ts | 9 +- packages/editor-ui/src/constants.ts | 64 ++++- packages/editor-ui/src/main.ts | 4 +- packages/editor-ui/src/modules/helper.ts | 82 ++++++ packages/editor-ui/src/modules/settings.ts | 75 ++++++ packages/editor-ui/src/modules/ui.ts | 35 +-- .../editor-ui/src/n8n-theme-variables.scss | 3 - packages/editor-ui/src/n8n-theme.scss | 4 - packages/editor-ui/src/plugins/components.ts | 6 + .../editor-ui/src/plugins/telemetry/index.ts | 171 ++++++++++++ .../src/plugins/telemetry/rudder-sdk.d.ts | 1 + packages/editor-ui/src/store.ts | 16 +- packages/editor-ui/src/views/NodeView.vue | 59 +++-- packages/editor-ui/tsconfig.json | 2 +- packages/workflow/src/Interfaces.ts | 38 +++ packages/workflow/src/TelemetryHelpers.ts | 61 +++++ packages/workflow/src/index.ts | 1 + 100 files changed, 2223 insertions(+), 550 deletions(-) create mode 100644 packages/cli/src/InternalHooks.ts create mode 100644 packages/cli/src/InternalHooksManager.ts create mode 100644 packages/cli/src/PersonalizationSurvey.ts create mode 100644 packages/cli/src/telemetry/index.ts create mode 100644 packages/design-system/src/components/N8nHeading/Heading.stories.js create mode 100644 packages/design-system/src/components/N8nHeading/Heading.vue create mode 100644 packages/design-system/src/components/N8nHeading/index.js create mode 100644 packages/design-system/src/components/N8nText/Text.stories.js create mode 100644 packages/design-system/src/components/N8nText/Text.vue create mode 100644 packages/design-system/src/components/N8nText/index.js delete mode 100644 packages/design-system/src/styleguide/TextClasses.vue delete mode 100644 packages/design-system/src/styleguide/text.stories.mdx delete mode 100644 packages/design-system/theme/src/common/typography.scss create mode 100644 packages/editor-ui/public/suggestednodes.png create mode 100644 packages/editor-ui/src/api/settings.ts create mode 100644 packages/editor-ui/src/components/PersonalizationModal.vue create mode 100644 packages/editor-ui/src/components/Telemetry.vue create mode 100644 packages/editor-ui/src/modules/helper.ts create mode 100644 packages/editor-ui/src/modules/settings.ts create mode 100644 packages/editor-ui/src/plugins/telemetry/index.ts create mode 100644 packages/editor-ui/src/plugins/telemetry/rudder-sdk.d.ts create mode 100644 packages/workflow/src/TelemetryHelpers.ts diff --git a/.eslintrc.js b/.eslintrc.js index 17ee2a4cb0..550426054b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -122,6 +122,8 @@ module.exports = { 'undefined', ], + 'no-void': ['error', { 'allowAsStatement': true }], + // ---------------------------------- // @typescript-eslint // ---------------------------------- @@ -250,6 +252,11 @@ module.exports = { */ '@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }], + /** + * https://github.com/typescript-eslint/typescript-eslint/blob/v4.30.0/packages/eslint-plugin/docs/rules/no-floating-promises.md + */ + '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], + /** * https://eslint.org/docs/1.0.0/rules/no-throw-literal */ diff --git a/packages/cli/commands/start.ts b/packages/cli/commands/start.ts index 33f83cac7a..39e2786c02 100644 --- a/packages/cli/commands/start.ts +++ b/packages/cli/commands/start.ts @@ -24,6 +24,7 @@ import { GenericHelpers, // eslint-disable-next-line @typescript-eslint/no-unused-vars IExecutionsCurrentSummary, + InternalHooksManager, LoadNodesAndCredentials, NodeTypes, Server, @@ -92,9 +93,12 @@ export class Start extends Command { setTimeout(() => { // In case that something goes wrong with shutdown we // kill after max. 30 seconds no matter what + console.log(`process exited after 30s`); process.exit(processExitCode); }, 30000); + await InternalHooksManager.getInstance().onN8nStop(); + const skipWebhookDeregistration = config.get( 'endpoints.skipWebhoooksDeregistrationOnShutdown', ) as boolean; @@ -151,9 +155,15 @@ export class Start extends Command { LoggerProxy.init(logger); logger.info('Initializing n8n process'); - // todo remove a few versions after release logger.info( - '\nn8n now checks for new versions and security updates. You can turn this off using the environment variable N8N_VERSION_NOTIFICATIONS_ENABLED to "false"\nFor more information, please refer to https://docs.n8n.io/getting-started/installation/advanced/configuration.html\n', + '\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 diff --git a/packages/cli/config/index.ts b/packages/cli/config/index.ts index 40cd13093a..c4b822ffa8 100644 --- a/packages/cli/config/index.ts +++ b/packages/cli/config/index.ts @@ -649,6 +649,46 @@ const config = convict({ env: 'N8N_VERSION_NOTIFICATIONS_INFO_URL', }, }, + + deployment: { + type: { + format: String, + default: 'default', + env: 'N8N_DEPLOYMENT_TYPE', + }, + }, + + personalization: { + enabled: { + doc: 'Whether personalization is enabled.', + format: Boolean, + default: true, + env: 'N8N_PERSONALIZATION_ENABLED', + }, + }, + + diagnostics: { + enabled: { + doc: 'Whether diagnostic mode is enabled.', + format: Boolean, + default: true, + env: 'N8N_DIAGNOSTICS_ENABLED', + }, + config: { + frontend: { + doc: 'Diagnostics config for frontend.', + format: String, + default: '1zPn9bgWPzlQc0p8Gj1uiK6DOTn;https://telemetry.n8n.io', + env: 'N8N_DIAGNOSTICS_CONFIG_FRONTEND', + }, + backend: { + doc: 'Diagnostics config for backend.', + format: String, + default: '1zPn7YoGC3ZXE9zLeTKLuQCB4F6;https://telemetry.n8n.io/v1/batch', + env: 'N8N_DIAGNOSTICS_CONFIG_BACKEND', + }, + }, + }, }); // Overwrite default configuration with settings which got defined in diff --git a/packages/cli/package.json b/packages/cli/package.json index 5f152755dd..e9f52041e3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -83,6 +83,7 @@ "dependencies": { "@oclif/command": "^1.5.18", "@oclif/errors": "^1.2.2", + "@rudderstack/rudder-sdk-node": "^1.0.2", "@types/json-diff": "^0.5.1", "@types/jsonwebtoken": "^8.5.2", "basic-auth": "^2.0.1", diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index 13948d6079..273f68dad0 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -11,6 +11,7 @@ import { IRunData, IRunExecutionData, ITaskData, + ITelemetrySettings, IWorkflowBase as IWorkflowBaseWorkflow, // eslint-disable-next-line @typescript-eslint/no-unused-vars IWorkflowCredentials, @@ -281,6 +282,40 @@ export interface IExternalHooksClass { run(hookName: string, hookParameters?: any[]): Promise; } +export interface IDiagnosticInfo { + versionCli: string; + databaseType: DatabaseType; + notificationsEnabled: boolean; + disableProductionWebhooksOnMainProcess: boolean; + basicAuthActive: boolean; + systemInfo: { + os: { + type?: string; + version?: string; + }; + memory?: number; + cpus: { + count?: number; + model?: string; + speed?: number; + }; + }; + executionVariables: { + [key: string]: string | number | undefined; + }; + deploymentType: string; +} + +export interface IInternalHooksClass { + onN8nStop(): Promise; + onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise; + onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise; + onWorkflowCreated(workflow: IWorkflowBase): Promise; + onWorkflowDeleted(workflowId: string): Promise; + onWorkflowSaved(workflow: IWorkflowBase): Promise; + onWorkflowPostExecute(workflow: IWorkflowBase, runData?: IRun): Promise; +} + export interface IN8nConfig { database: IN8nConfigDatabase; endpoints: IN8nConfigEndpoints; @@ -357,6 +392,20 @@ export interface IN8nUISettings { }; versionNotifications: IVersionNotificationSettings; instanceId: string; + telemetry: ITelemetrySettings; + personalizationSurvey: IPersonalizationSurvey; +} + +export interface IPersonalizationSurveyAnswers { + companySize: string | null; + codingSkill: string | null; + workArea: string | null; + otherWorkArea: string | null; +} + +export interface IPersonalizationSurvey { + answers?: IPersonalizationSurveyAnswers; + shouldShow: boolean; } export interface IPackageVersions { diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts new file mode 100644 index 0000000000..a92eec6160 --- /dev/null +++ b/packages/cli/src/InternalHooks.ts @@ -0,0 +1,105 @@ +/* eslint-disable import/no-cycle */ +import { IDataObject, IRun, TelemetryHelpers } from 'n8n-workflow'; +import { + IDiagnosticInfo, + IInternalHooksClass, + IPersonalizationSurveyAnswers, + IWorkflowBase, +} from '.'; +import { Telemetry } from './telemetry'; + +export class InternalHooksClass implements IInternalHooksClass { + constructor(private telemetry: Telemetry) {} + + async onServerStarted(diagnosticInfo: IDiagnosticInfo): Promise { + const info = { + version_cli: diagnosticInfo.versionCli, + db_type: diagnosticInfo.databaseType, + n8n_version_notifications_enabled: diagnosticInfo.notificationsEnabled, + n8n_disable_production_main_process: diagnosticInfo.disableProductionWebhooksOnMainProcess, + n8n_basic_auth_active: diagnosticInfo.basicAuthActive, + system_info: diagnosticInfo.systemInfo, + execution_variables: diagnosticInfo.executionVariables, + n8n_deployment_type: diagnosticInfo.deploymentType, + }; + await this.telemetry.identify(info); + await this.telemetry.track('Instance started', info); + } + + async onPersonalizationSurveySubmitted(answers: IPersonalizationSurveyAnswers): Promise { + await this.telemetry.track('User responded to personalization questions', { + company_size: answers.companySize, + coding_skill: answers.codingSkill, + work_area: answers.workArea, + other_work_area: answers.otherWorkArea, + }); + } + + async onWorkflowCreated(workflow: IWorkflowBase): Promise { + await this.telemetry.track('User created workflow', { + workflow_id: workflow.id, + node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, + }); + } + + async onWorkflowDeleted(workflowId: string): Promise { + await this.telemetry.track('User deleted workflow', { + workflow_id: workflowId, + }); + } + + async onWorkflowSaved(workflow: IWorkflowBase): Promise { + await this.telemetry.track('User saved workflow', { + workflow_id: workflow.id, + node_graph: TelemetryHelpers.generateNodesGraph(workflow).nodeGraph, + }); + } + + async onWorkflowPostExecute(workflow: IWorkflowBase, runData?: IRun): Promise { + const properties: IDataObject = { + workflow_id: workflow.id, + is_manual: false, + }; + + if (runData !== undefined) { + properties.execution_mode = runData.mode; + if (runData.mode === 'manual') { + properties.is_manual = true; + } + + properties.success = !!runData.finished; + + if (!properties.success && runData?.data.resultData.error) { + properties.error_message = runData?.data.resultData.error.message; + let errorNodeName = runData?.data.resultData.error.node?.name; + properties.error_node_type = runData?.data.resultData.error.node?.type; + + if (runData.data.resultData.lastNodeExecuted) { + const lastNode = TelemetryHelpers.getNodeTypeForName( + workflow, + runData.data.resultData.lastNodeExecuted, + ); + + if (lastNode !== undefined) { + properties.error_node_type = lastNode.type; + errorNodeName = lastNode.name; + } + } + + if (properties.is_manual) { + const nodeGraphResult = TelemetryHelpers.generateNodesGraph(workflow); + properties.node_graph = nodeGraphResult.nodeGraph; + if (errorNodeName) { + properties.error_node_id = nodeGraphResult.nameIndices[errorNodeName]; + } + } + } + } + + void this.telemetry.trackWorkflowExecution(properties); + } + + async onN8nStop(): Promise { + await this.telemetry.trackN8nStop(); + } +} diff --git a/packages/cli/src/InternalHooksManager.ts b/packages/cli/src/InternalHooksManager.ts new file mode 100644 index 0000000000..28087b3702 --- /dev/null +++ b/packages/cli/src/InternalHooksManager.ts @@ -0,0 +1,23 @@ +/* eslint-disable import/no-cycle */ +import { InternalHooksClass } from './InternalHooks'; +import { Telemetry } from './telemetry'; + +export class InternalHooksManager { + private static internalHooksInstance: InternalHooksClass; + + static getInstance(): InternalHooksClass { + if (this.internalHooksInstance) { + return this.internalHooksInstance; + } + + throw new Error('InternalHooks not initialized'); + } + + static init(instanceId: string): InternalHooksClass { + if (!this.internalHooksInstance) { + this.internalHooksInstance = new InternalHooksClass(new Telemetry(instanceId)); + } + + return this.internalHooksInstance; + } +} diff --git a/packages/cli/src/PersonalizationSurvey.ts b/packages/cli/src/PersonalizationSurvey.ts new file mode 100644 index 0000000000..b384b4894c --- /dev/null +++ b/packages/cli/src/PersonalizationSurvey.ts @@ -0,0 +1,63 @@ +import { readFileSync, writeFile } from 'fs'; +import { promisify } from 'util'; +import { UserSettings } from 'n8n-core'; + +import * as config from '../config'; +// eslint-disable-next-line import/no-cycle +import { Db, IPersonalizationSurvey, IPersonalizationSurveyAnswers } from '.'; + +const fsWriteFile = promisify(writeFile); + +const PERSONALIZATION_SURVEY_FILENAME = 'personalizationSurvey.json'; + +function loadSurveyFromDisk(): IPersonalizationSurveyAnswers | undefined { + const userSettingsPath = UserSettings.getUserN8nFolderPath(); + try { + const surveyFile = readFileSync( + `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`, + 'utf-8', + ); + return JSON.parse(surveyFile) as IPersonalizationSurveyAnswers; + } catch (error) { + return undefined; + } +} + +export async function writeSurveyToDisk( + surveyAnswers: IPersonalizationSurveyAnswers, +): Promise { + const userSettingsPath = UserSettings.getUserN8nFolderPath(); + await fsWriteFile( + `${userSettingsPath}/${PERSONALIZATION_SURVEY_FILENAME}`, + JSON.stringify(surveyAnswers, null, '\t'), + ); +} + +export async function preparePersonalizationSurvey(): Promise { + const survey: IPersonalizationSurvey = { + shouldShow: false, + }; + + survey.answers = loadSurveyFromDisk(); + + if (survey.answers) { + return survey; + } + + const enabled = + (config.get('personalization.enabled') as boolean) && + (config.get('diagnostics.enabled') as boolean); + + if (!enabled) { + return survey; + } + + const workflowsExist = !!(await Db.collections.Workflow?.findOne()); + + if (workflowsExist) { + return survey; + } + + survey.shouldShow = true; + return survey; +} diff --git a/packages/cli/src/ResponseHelper.ts b/packages/cli/src/ResponseHelper.ts index 02c431b64b..f6deb551be 100644 --- a/packages/cli/src/ResponseHelper.ts +++ b/packages/cli/src/ResponseHelper.ts @@ -90,13 +90,13 @@ export function sendSuccessResponse( } } -export function sendErrorResponse(res: Response, error: ResponseError) { +export function sendErrorResponse(res: Response, error: ResponseError, shouldLog = true) { let httpStatusCode = 500; if (error.httpStatusCode) { httpStatusCode = error.httpStatusCode; } - if (process.env.NODE_ENV !== 'production') { + if (process.env.NODE_ENV !== 'production' && shouldLog) { console.error('ERROR RESPONSE'); console.error(error); } diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index f19c6a922f..aead6ad68f 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -28,17 +28,18 @@ import * as express from 'express'; import { readFileSync } from 'fs'; import { dirname as pathDirname, join as pathJoin, resolve as pathResolve } from 'path'; import { - getConnectionManager, - In, - Like, FindManyOptions, FindOneOptions, + getConnectionManager, + In, IsNull, LessThanOrEqual, + Like, Not, } from 'typeorm'; import * as bodyParser from 'body-parser'; import * as history from 'connect-history-api-fallback'; +import * as os from 'os'; // eslint-disable-next-line import/no-extraneous-dependencies import * as _ from 'lodash'; import * as clientOAuth2 from 'client-oauth2'; @@ -74,6 +75,8 @@ import { INodeTypeNameVersion, IRunData, INodeVersionedType, + ITelemetryClientConfig, + ITelemetrySettings, IWorkflowBase, IWorkflowCredentials, LoggerProxy, @@ -124,11 +127,13 @@ import { IExecutionsStopData, IExecutionsSummary, IExternalHooksClass, + IDiagnosticInfo, IN8nUISettings, IPackageVersions, ITagWithCountDb, IWorkflowExecutionDataProcess, IWorkflowResponse, + IPersonalizationSurveyAnswers, LoadNodesAndCredentials, NodeTypes, Push, @@ -142,9 +147,13 @@ import { WorkflowHelpers, WorkflowRunner, } from '.'; + import * as config from '../config'; import * as TagHelpers from './TagHelpers'; +import * as PersonalizationSurvey from './PersonalizationSurvey'; + +import { InternalHooksManager } from './InternalHooksManager'; import { TagEntity } from './databases/entities/TagEntity'; import { WorkflowEntity } from './databases/entities/WorkflowEntity'; import { NameRequest } from './WorkflowHelpers'; @@ -243,6 +252,22 @@ class App { const urlBaseWebhook = WebhookHelpers.getWebhookBaseUrl(); + const telemetrySettings: ITelemetrySettings = { + enabled: config.get('diagnostics.enabled') as boolean, + }; + + if (telemetrySettings.enabled) { + const conf = config.get('diagnostics.config.frontend') as string; + const [key, url] = conf.split(';'); + + if (!key || !url) { + LoggerProxy.warn('Diagnostics frontend config is invalid'); + telemetrySettings.enabled = false; + } + + telemetrySettings.config = { key, url }; + } + this.frontendSettings = { endpointWebhook: this.endpointWebhook, endpointWebhookTest: this.endpointWebhookTest, @@ -264,6 +289,10 @@ class App { infoUrl: config.get('versionNotifications.infoUrl'), }, instanceId: '', + telemetry: telemetrySettings, + personalizationSurvey: { + shouldShow: false, + }, }; } @@ -290,7 +319,13 @@ class App { this.versions = await GenericHelpers.getVersions(); this.frontendSettings.versionCli = this.versions.cli; - this.frontendSettings.instanceId = (await generateInstanceId()) as string; + + this.frontendSettings.instanceId = await UserSettings.getInstanceId(); + + this.frontendSettings.personalizationSurvey = + await PersonalizationSurvey.preparePersonalizationSurvey(); + + InternalHooksManager.init(this.frontendSettings.instanceId); await this.externalHooks.run('frontend.settings', [this.frontendSettings]); @@ -458,10 +493,13 @@ class App { }; jwt.verify(token, getKey, jwtVerifyOptions, (err: jwt.VerifyErrors, decoded: object) => { - if (err) ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token'); - else if (!isTenantAllowed(decoded)) + if (err) { + ResponseHelper.jwtAuthAuthorizationError(res, 'Invalid token'); + } else if (!isTenantAllowed(decoded)) { ResponseHelper.jwtAuthAuthorizationError(res, 'Tenant not allowed'); - else next(); + } else { + next(); + } }); }); } @@ -656,6 +694,7 @@ class App { // @ts-ignore savedWorkflow.id = savedWorkflow.id.toString(); + void InternalHooksManager.getInstance().onWorkflowCreated(newWorkflow as IWorkflowBase); return savedWorkflow; }, ), @@ -858,12 +897,12 @@ class App { } await this.externalHooks.run('workflow.afterUpdate', [workflow]); + void InternalHooksManager.getInstance().onWorkflowSaved(workflow as IWorkflowBase); if (workflow.active) { // When the workflow is supposed to be active add it again try { await this.externalHooks.run('workflow.activate', [workflow]); - await this.activeWorkflowRunner.add(id, isActive ? 'update' : 'activate'); } catch (error) { // If workflow could not be activated set it again to inactive @@ -901,6 +940,7 @@ class App { } await Db.collections.Workflow!.delete(id); + void InternalHooksManager.getInstance().onWorkflowDeleted(id); await this.externalHooks.run('workflow.afterDelete', [id]); return true; @@ -2601,6 +2641,31 @@ class App { ), ); + // ---------------------------------------- + // User Survey + // ---------------------------------------- + + // Process personalization survey responses + this.app.post( + `/${this.restEndpoint}/user-survey`, + async (req: express.Request, res: express.Response) => { + if (!this.frontendSettings.personalizationSurvey.shouldShow) { + ResponseHelper.sendErrorResponse( + res, + new ResponseHelper.ResponseError('User survey already submitted', undefined, 400), + false, + ); + } + + const answers = req.body as IPersonalizationSurveyAnswers; + await PersonalizationSurvey.writeSurveyToDisk(answers); + this.frontendSettings.personalizationSurvey.shouldShow = false; + this.frontendSettings.personalizationSurvey.answers = answers; + ResponseHelper.sendSuccessResponse(res, undefined, true, 200); + void InternalHooksManager.getInstance().onPersonalizationSurveySubmitted(answers); + }, + ); + // ---------------------------------------- // Webhooks // ---------------------------------------- @@ -2810,6 +2875,43 @@ export async function start(): Promise { console.log(`Version: ${versions.cli}`); await app.externalHooks.run('n8n.ready', [app]); + const cpus = os.cpus(); + const diagnosticInfo: IDiagnosticInfo = { + basicAuthActive: config.get('security.basicAuth.active') as boolean, + databaseType: (await GenericHelpers.getConfigValue('database.type')) as DatabaseType, + disableProductionWebhooksOnMainProcess: + config.get('endpoints.disableProductionWebhooksOnMainProcess') === true, + notificationsEnabled: config.get('versionNotifications.enabled') === true, + versionCli: versions.cli, + systemInfo: { + os: { + type: os.type(), + version: os.version(), + }, + memory: os.totalmem() / 1024, + cpus: { + count: cpus.length, + model: cpus[0].model, + speed: cpus[0].speed, + }, + }, + executionVariables: { + executions_process: config.get('executions.process'), + executions_mode: config.get('executions.mode'), + executions_timeout: config.get('executions.timeout'), + executions_timeout_max: config.get('executions.maxTimeout'), + executions_data_save_on_error: config.get('executions.saveDataOnError'), + executions_data_save_on_success: config.get('executions.saveDataOnSuccess'), + executions_data_save_on_progress: config.get('executions.saveExecutionProgress'), + executions_data_save_manual_executions: config.get('executions.saveDataManualExecutions'), + executions_data_prune: config.get('executions.pruneData'), + executions_data_max_age: config.get('executions.pruneDataMaxAge'), + executions_data_prune_timeout: config.get('executions.pruneDataTimeout'), + }, + deploymentType: config.get('deployment.type'), + }; + + void InternalHooksManager.getInstance().onServerStarted(diagnosticInfo); }); } @@ -2848,14 +2950,3 @@ async function getExecutionsCount( const count = await Db.collections.Execution!.count(countFilter); return { count, estimate: false }; } - -async function generateInstanceId() { - const encryptionKey = await UserSettings.getEncryptionKey(); - const hash = encryptionKey - ? createHash('sha256') - .update(encryptionKey.slice(Math.round(encryptionKey.length / 2))) - .digest('hex') - : undefined; - - return hash; -} diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 58f983fc8a..6ec88655fd 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -48,6 +48,7 @@ import { IExecutionDb, IExecutionFlattedDb, IExecutionResponse, + InternalHooksManager, IPushDataExecutionFinished, IWorkflowBase, IWorkflowExecuteProcess, @@ -903,6 +904,7 @@ export async function executeWorkflow( } await externalHooks.run('workflow.postExecute', [data, workflowData]); + void InternalHooksManager.getInstance().onWorkflowPostExecute(workflowData, data); if (data.finished === true) { // Workflow did finish successfully diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 1784c2ce6e..8984384aaa 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -16,7 +16,6 @@ import { IProcessMessage, WorkflowExecute } from 'n8n-core'; import { ExecutionError, IRun, - IWorkflowBase, LoggerProxy as Logger, Workflow, WorkflowExecuteMode, @@ -56,6 +55,7 @@ import { WorkflowHelpers, } from '.'; import * as Queue from './Queue'; +import { InternalHooksManager } from './InternalHooksManager'; export class WorkflowRunner { activeExecutions: ActiveExecutions.ActiveExecutions; @@ -160,10 +160,22 @@ export class WorkflowRunner { executionId = await this.runSubprocess(data, loadStaticData, executionId); } + const postExecutePromise = this.activeExecutions.getPostExecutePromise(executionId); + const externalHooks = ExternalHooks(); + postExecutePromise + .then(async (executionData) => { + void InternalHooksManager.getInstance().onWorkflowPostExecute( + data.workflowData, + executionData, + ); + }) + .catch((error) => { + console.error('There was a problem running internal hook "onWorkflowPostExecute"', error); + }); + if (externalHooks.exists('workflow.postExecute')) { - this.activeExecutions - .getPostExecutePromise(executionId) + postExecutePromise .then(async (executionData) => { await externalHooks.run('workflow.postExecute', [executionData, data.workflowData]); }) diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index bf45881cae..d7039d69af 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -5,7 +5,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable @typescript-eslint/unbound-method */ -import { IProcessMessage, WorkflowExecute } from 'n8n-core'; +import { IProcessMessage, UserSettings, WorkflowExecute } from 'n8n-core'; import { ExecutionError, @@ -40,6 +40,7 @@ import { import { getLogger } from './Logger'; import * as config from '../config'; +import { InternalHooksManager } from './InternalHooksManager'; export class WorkflowRunnerProcess { data: IWorkflowExecutionDataProcessWithExecution | undefined; @@ -133,6 +134,9 @@ export class WorkflowRunnerProcess { const externalHooks = ExternalHooks(); await externalHooks.init(); + const instanceId = (await UserSettings.prepareUserSettings()).instanceId ?? ''; + InternalHooksManager.init(instanceId); + // Credentials should now be loaded from database. // We check if any node uses credentials. If it does, then // init database. @@ -243,6 +247,7 @@ export class WorkflowRunnerProcess { const { workflow } = executeWorkflowFunctionOutput; result = await workflowExecute.processRunExecutionData(workflow); await externalHooks.run('workflow.postExecute', [result, workflowData]); + void InternalHooksManager.getInstance().onWorkflowPostExecute(workflowData, result); await sendToParentProcess('finishExecution', { executionId, result }); delete this.childExecutions[executionId]; } catch (e) { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index abca07fb29..c98040e648 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ export * from './CredentialTypes'; export * from './CredentialsOverwrites'; export * from './ExternalHooks'; export * from './Interfaces'; +export * from './InternalHooksManager'; export * from './LoadNodesAndCredentials'; export * from './NodeTypes'; export * from './WaitTracker'; diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts new file mode 100644 index 0000000000..fb38ed257d --- /dev/null +++ b/packages/cli/src/telemetry/index.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import TelemetryClient = require('@rudderstack/rudder-sdk-node'); +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; +} + +interface IExecutionCountsBuffer { + [workflowId: string]: IExecutionCountsBufferItem; +} + +export class Telemetry { + private client?: TelemetryClient; + + private instanceId: string; + + private pulseIntervalReference: NodeJS.Timeout; + + private executionCountsBuffer: IExecutionCountsBuffer = {}; + + constructor(instanceId: string) { + this.instanceId = instanceId; + + const enabled = config.get('diagnostics.enabled') as boolean; + if (enabled) { + const conf = config.get('diagnostics.config.backend') as string; + const [key, url] = conf.split(';'); + + if (!key || !url) { + const logger = getLogger(); + LoggerProxy.init(logger); + logger.warn('Diagnostics backend config is invalid'); + return; + } + + this.client = new TelemetryClient(key, url); + + this.pulseIntervalReference = setInterval(async () => { + void this.pulse(); + }, 6 * 60 * 60 * 1000); // every 6 hours + } + } + + private async pulse(): Promise { + if (!this.client) { + return Promise.resolve(); + } + + const allPromises = Object.keys(this.executionCountsBuffer).map(async (workflowId) => { + const promise = this.track('Workflow execution count', { + workflow_id: workflowId, + ...this.executionCountsBuffer[workflowId], + }); + 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; + + return promise; + }); + + allPromises.push(this.track('pulse')); + 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] ?? { + manual_error_count: 0, + manual_success_count: 0, + prod_error_count: 0, + prod_success_count: 0, + }; + + if ( + properties.success === false && + properties.error_node_type && + (properties.error_node_type as string).startsWith('n8n-nodes-base') + ) { + // errored exec + void this.track('Workflow execution errored', properties); + + if (properties.is_manual) { + this.executionCountsBuffer[workflowId].manual_error_count++; + } else { + this.executionCountsBuffer[workflowId].prod_error_count++; + } + } else if (properties.is_manual) { + this.executionCountsBuffer[workflowId].manual_success_count++; + } else { + this.executionCountsBuffer[workflowId].prod_success_count++; + } + } + } + + async trackN8nStop(): Promise { + clearInterval(this.pulseIntervalReference); + void this.track('User instance stopped'); + return new Promise((resolve) => { + if (this.client) { + this.client.flush(resolve); + } else { + resolve(); + } + }); + } + + async identify(traits?: IDataObject): Promise { + return new Promise((resolve) => { + if (this.client) { + this.client.identify( + { + userId: this.instanceId, + traits: { + ...traits, + instanceId: this.instanceId, + }, + }, + resolve, + ); + } else { + resolve(); + } + }); + } + + async track(eventName: string, properties?: IDataObject): Promise { + return new Promise((resolve) => { + if (this.client) { + this.client.track( + { + userId: this.instanceId, + event: eventName, + properties, + }, + resolve, + ); + } else { + resolve(); + } + }); + } +} diff --git a/packages/core/src/Interfaces.ts b/packages/core/src/Interfaces.ts index 94f940ed0a..c5f0f41271 100644 --- a/packages/core/src/Interfaces.ts +++ b/packages/core/src/Interfaces.ts @@ -145,6 +145,7 @@ export interface ITriggerTime { export interface IUserSettings { encryptionKey?: string; tunnelSubdomain?: string; + instanceId?: string; } export interface ILoadOptionsFunctions extends ILoadOptionsFunctionsBase { diff --git a/packages/core/src/UserSettings.ts b/packages/core/src/UserSettings.ts index f3afc2b242..e6620ccc4d 100644 --- a/packages/core/src/UserSettings.ts +++ b/packages/core/src/UserSettings.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import * as fs from 'fs'; import * as path from 'path'; -import { randomBytes } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; // eslint-disable-next-line import/no-cycle import { ENCRYPTION_KEY_ENV_OVERWRITE, @@ -37,7 +37,12 @@ export async function prepareUserSettings(): Promise { if (userSettings !== undefined) { // Settings already exist, check if they contain the encryptionKey if (userSettings.encryptionKey !== undefined) { - // Key already exists so return + // Key already exists + if (userSettings.instanceId === undefined) { + userSettings.instanceId = await generateInstanceId(userSettings.encryptionKey); + settingsCache = userSettings; + } + return userSettings; } } else { @@ -52,6 +57,8 @@ export async function prepareUserSettings(): Promise { userSettings.encryptionKey = randomBytes(24).toString('base64'); } + userSettings.instanceId = await generateInstanceId(userSettings.encryptionKey); + // eslint-disable-next-line no-console console.log(`UserSettings were generated and saved to: ${settingsPath}`); @@ -65,8 +72,8 @@ export async function prepareUserSettings(): Promise { * @export * @returns */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export async function getEncryptionKey() { + +export async function getEncryptionKey(): Promise { if (process.env[ENCRYPTION_KEY_ENV_OVERWRITE] !== undefined) { return process.env[ENCRYPTION_KEY_ENV_OVERWRITE]; } @@ -84,6 +91,36 @@ export async function getEncryptionKey() { return userSettings.encryptionKey; } +/** + * Returns the instance ID + * + * @export + * @returns + */ +export async function getInstanceId(): Promise { + const userSettings = await getUserSettings(); + + if (userSettings === undefined) { + return ''; + } + + if (userSettings.instanceId === undefined) { + return ''; + } + + return userSettings.instanceId; +} + +async function generateInstanceId(key?: string) { + const hash = key + ? createHash('sha256') + .update(key.slice(Math.round(key.length / 2))) + .digest('hex') + : undefined; + + return hash; +} + /** * Adds/Overwrite the given settings in the currently * saved user settings @@ -141,7 +178,12 @@ export async function writeUserSettings( await fsMkdir(path.dirname(settingsPath)); } - await fsWriteFile(settingsPath, JSON.stringify(userSettings, null, '\t')); + const settingsToWrite = { ...userSettings }; + if (settingsToWrite.instanceId !== undefined) { + delete settingsToWrite.instanceId; + } + + await fsWriteFile(settingsPath, JSON.stringify(settingsToWrite, null, '\t')); settingsCache = JSON.parse(JSON.stringify(userSettings)); return userSettings; diff --git a/packages/design-system/src/components/N8nHeading/Heading.stories.js b/packages/design-system/src/components/N8nHeading/Heading.stories.js new file mode 100644 index 0000000000..4a1ac65e84 --- /dev/null +++ b/packages/design-system/src/components/N8nHeading/Heading.stories.js @@ -0,0 +1,30 @@ +import N8nHeading from './Heading.vue'; + +export default { + title: 'Atoms/Heading', + component: N8nHeading, + argTypes: { + size: { + control: { + type: 'select', + options: ['2xlarge', 'xlarge', 'large', 'medium', 'small'], + }, + }, + color: { + control: { + type: 'select', + options: ['primary', 'text-dark', 'text-base', 'text-light'], + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nHeading, + }, + template: 'hello world', +}); + +export const Heading = Template.bind({}); diff --git a/packages/design-system/src/components/N8nHeading/Heading.vue b/packages/design-system/src/components/N8nHeading/Heading.vue new file mode 100644 index 0000000000..e4cd0bca07 --- /dev/null +++ b/packages/design-system/src/components/N8nHeading/Heading.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/packages/design-system/src/components/N8nHeading/index.js b/packages/design-system/src/components/N8nHeading/index.js new file mode 100644 index 0000000000..32d93055a6 --- /dev/null +++ b/packages/design-system/src/components/N8nHeading/index.js @@ -0,0 +1,3 @@ +import N8nHeading from './Heading.vue'; + +export default N8nHeading; diff --git a/packages/design-system/src/components/N8nIconButton/IconButton.vue b/packages/design-system/src/components/N8nIconButton/IconButton.vue index 0ace9defb8..6064ca4a53 100644 --- a/packages/design-system/src/components/N8nIconButton/IconButton.vue +++ b/packages/design-system/src/components/N8nIconButton/IconButton.vue @@ -1,5 +1,5 @@ diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 70ccf59ee6..02fe67ec4f 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -18,6 +18,7 @@ import { IRun, IRunData, ITaskData, + ITelemetrySettings, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -129,7 +130,6 @@ export interface IRestApi { getPastExecutions(filter: object, limit: number, lastId?: string | number, firstId?: string | number): Promise; stopCurrentExecution(executionId: string): Promise; makeRestApiRequest(method: string, endpoint: string, data?: any): Promise; // tslint:disable-line:no-any - getSettings(): Promise; getNodeTypes(onlyLatest?: boolean): Promise; getNodesInformation(nodeInfos: INodeTypeNameVersion[]): Promise; getNodeParameterOptions(nodeTypeAndVersion: INodeTypeNameVersion, path: string, methodName: string, currentNodeParameters: INodeParameters, credentials?: INodeCredentials): Promise; @@ -437,6 +437,17 @@ export interface IVersionNotificationSettings { infoUrl: string; } +export type IPersonalizationSurveyKeys = 'companySize' | 'codingSkill' | 'workArea' | 'otherWorkArea'; + +export type IPersonalizationSurveyAnswers = { + [key in IPersonalizationSurveyKeys]: string | null +}; + +export interface IPersonalizationSurvey { + answers?: IPersonalizationSurveyAnswers; + shouldShow: boolean; +} + export interface IN8nUISettings { endpointWebhook: string; endpointWebhookTest: string; @@ -457,6 +468,8 @@ export interface IN8nUISettings { }; versionNotifications: IVersionNotificationSettings; instanceId: string; + personalizationSurvey?: IPersonalizationSurvey; + telemetry: ITelemetrySettings; } export interface IWorkflowSettings extends IWorkflowSettingsWorkflow { @@ -599,6 +612,7 @@ export interface IRootState { workflow: IWorkflowDb; sidebarMenuItems: IMenuItem[]; instanceId: string; + telemetry: ITelemetrySettings | null; } export interface ICredentialTypeMap { @@ -636,6 +650,10 @@ export interface IUiState { isPageLoading: boolean; } +export interface ISettingsState { + settings: IN8nUISettings; +} + export interface IVersionsState { versionNotificationSettings: IVersionNotificationSettings; nextVersions: IVersion[]; diff --git a/packages/editor-ui/src/api/settings.ts b/packages/editor-ui/src/api/settings.ts new file mode 100644 index 0000000000..44302f8168 --- /dev/null +++ b/packages/editor-ui/src/api/settings.ts @@ -0,0 +1,12 @@ +import { IDataObject } from 'n8n-workflow'; +import { IRestApiContext, IN8nUISettings, IPersonalizationSurveyAnswers } from '../Interface'; +import { makeRestApiRequest } from './helpers'; + +export async function getSettings(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/settings'); +} + +export async function submitPersonalizationSurvey(context: IRestApiContext, params: IPersonalizationSurveyAnswers): Promise { + await makeRestApiRequest(context, 'POST', '/user-survey', params as unknown as IDataObject); +} + diff --git a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue index 3ad1712676..25b002cc88 100644 --- a/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue +++ b/packages/editor-ui/src/components/CredentialEdit/CredentialConfig.vue @@ -40,7 +40,7 @@ Need help filling out these fields? - Open docs + Open docs -
- -
- Your saved credentials: -
- + + + + diff --git a/packages/editor-ui/src/components/Modals.vue b/packages/editor-ui/src/components/Modals.vue index 12de097bb8..b7819e3b6c 100644 --- a/packages/editor-ui/src/components/Modals.vue +++ b/packages/editor-ui/src/components/Modals.vue @@ -1,45 +1,5 @@