Skip to content

Commit

Permalink
feat(editor): In-app experience (#4875)
Browse files Browse the repository at this point in the history
* feat: Add license quotas endpoint

* feat: Add trigger count to workflow activation process

* refactor: Get quotas from db

* feat: Add license information

* ✨ - finalised GET /license endpoint

* 🔨 - getActiveTriggerCount return 0 instead of null

* 🐛 - ignore manualTrigger when counting active triggers

* ✨ - add activation endpoint

* ✨ - added renew endpoint

* 🔨 - added return type interfaces

* 🔨 - handle license errors where methods are called

* 🔨 - rename function to match name from lib

* feat(editor): usage add plans buttons logic

* 🚨 - testing new License methods

* feat(editor): usage add more business logic

* chore(editor): code formatting

* 🚨 - added license api tests

* fix(editor): usage store

* fix(editor): usage update translations

* feat(editor): usage add license activation modal

* feat(editor): usage change subscription app url

* feat(editor): usage add contact us link

* feat(editor): usage fix modal width

* ✨ - Add renewal tracking metric

* ✨ - add license data to pulse event

* 🔨 - set default triggercount on entity model

* ✨ - add db migrations for mysql and postgres

* fix(editor): Usage api call data processing and error handling

* fix(editor): Usage fix activation query key

* 🚨 - add initDb to telemetry tests

* 🔨 - move getlicensedata to licenseservice

* 🔨 - return 403 instead of 404 to non owners

* 🔨 - move owner checking to middleware

* 🐛 - fixed incorrectly returned error from middleware

* 🐛 - using mock instead of test db for pulse tests

* fix(editor): Usage fix activation and add success messages

* fix(editor): Usage should not renew activation right after activation

* 🚨 - skipping failing pulse tests for now

* fix(editor): Usage add telemetry calls and apply design review outcomes

* feat(editor): Hide usage page according to BE flag

* feat(editor): Usage modify key activation flow

* feat(editor): Usage change subscription app url

* feat(editor): Usage add telemetry for manage plan

* feat(editor): Usage extend link url query params

* feat(editor): Usage add line chart if there is a workflow limit

* feat(editor): Usage remove query after key activation redirection

* fix(editor): Usage handle limit exceeded workflow chart, add focus to input when modal opened

* fix(editor): Usage activation can return router promise when removing query

* fix(editor): Usage and plan design review

* 🐛 - fix renew endpoint hanging issue

* 🐛 - fix license activation bug

* fix(editor): Usage proper translation for plans and/or editions

* fix(editor): Usage apply David's review results

* fix(editor): Usage page set as default and first under Settings

* fix(editor): Usage open subscription app in new tab

* fix(editor): Usage page having key query param a plan links

* test: Fix broken test

* fix(editor): Usage page address review

* 🧪 Flush promises on telemetry tests

* ⚡ Extract helper with `setImmediate`

* 🔥 Remove leftovers

* ⚡ Use Adi's helper

* refactor: Comment broken tests

* refactor: add Tenant id to settings

* feat: add environment to license endpoints

* refactor: Move license environment to general settings

* fix: fix routing bug

* fix(editor): Usage page some code review changes and formatting

* fix(editor): Usage page remove direct usage of reusable translation keys

* fix(editor): Usage page async await instead of then

* fix(editor): Usage page show some content only if network requests in component mounted were successful

* chore(editor): code formatting

* fix(editor): Usage checking license environment

* feat(editor): Improve license activation error messages (no-changelog) (#4958)

* fix(editor): Usage changing activation error title

* remove unnecessary import

* fix(editor): Usage refactor notification showing

* fix(editor): Usage using notification directly in store actions

Co-authored-by: Omar Ajoue <krynble@gmail.com>
Co-authored-by: freyamade <freya@n8n.io>
Co-authored-by: Iván Ovejero <ivov.src@gmail.com>
Co-authored-by: Mutasem <mutdmour@gmail.com>
Co-authored-by: Cornelius Suermann <cornelius@n8n.io>
  • Loading branch information
6 people authored Dec 19, 2022
1 parent 5854dd8 commit 7b84f9e
Show file tree
Hide file tree
Showing 39 changed files with 1,131 additions and 187 deletions.
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
"tsconfig-paths": "^3.14.1"
},
"dependencies": {
"@n8n_io/license-sdk": "^1.6.1",
"@n8n_io/license-sdk": "^1.7.0",
"@oclif/command": "^1.8.16",
"@oclif/core": "^1.16.4",
"@oclif/errors": "^1.3.6",
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/ActiveWorkflowRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
WorkflowExecuteMode,
LoggerProxy as Logger,
ErrorReporterProxy as ErrorReporter,
INodeType,
} from 'n8n-workflow';

import express from 'express';
Expand Down Expand Up @@ -800,6 +801,7 @@ export class ActiveWorkflowRunner {

const canBeActivated = workflowInstance.checkIfWorkflowCanBeActivated([
'n8n-nodes-base.start',
'n8n-nodes-base.manualTrigger',
]);
if (!canBeActivated) {
Logger.error(`Unable to activate workflow "${workflowData.name}"`);
Expand Down Expand Up @@ -854,6 +856,18 @@ export class ActiveWorkflowRunner {
// If there were activation errors delete them
delete this.activationErrors[workflowId];
}

if (workflowInstance.id) {
// Sum all triggers in the workflow, EXCLUDING the manual trigger
const triggerFilter = (nodeType: INodeType) =>
!!nodeType.trigger && !nodeType.description.name.includes('manualTrigger');
const triggerCount =
workflowInstance.queryNodes(triggerFilter).length +
workflowInstance.getPollNodes().length +
WebhookHelpers.getWorkflowWebhooks(workflowInstance, additionalData, undefined, true)
.length;
await Db.collections.Workflow.update(workflowInstance.id, { triggerCount });
}
} catch (error) {
// There was a problem activating the workflow

Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/Interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,9 @@ export interface IN8nUISettings {
workflowSharing: boolean;
};
hideUsagePage: boolean;
license: {
environment: 'production' | 'staging';
};
}

export interface IPersonalizationSurveyAnswers {
Expand Down Expand Up @@ -751,3 +754,25 @@ export interface IExecutionTrackProperties extends ITelemetryTrackProperties {
error_node_type?: string;
is_manual: boolean;
}

// ----------------------------------
// license
// ----------------------------------

export interface ILicenseReadResponse {
usage: {
executions: {
limit: number;
value: number;
warningThreshold: number;
};
};
license: {
planId: string;
planName: string;
};
}

export interface ILicensePostResponse extends ILicenseReadResponse {
managementToken: string;
}
7 changes: 7 additions & 0 deletions packages/cli/src/InternalHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,4 +498,11 @@ export class InternalHooksClass implements IInternalHooksClass {
}): Promise<void> {
return this.telemetry.track('Workflow first data fetched', data, { withPostHog: true });
}

/**
* License
*/
async onLicenseRenewAttempt(data: { success: boolean }): Promise<void> {
await this.telemetry.track('Instance attempted to refresh license', data);
}
}
72 changes: 53 additions & 19 deletions packages/cli/src/License.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LicenseManager, TLicenseContainerStr } from '@n8n_io/license-sdk';
import { LicenseManager, TEntitlement, TLicenseContainerStr } from '@n8n_io/license-sdk';
import { ILogger } from 'n8n-workflow';
import { getLogger } from './Logger';
import config from '@/config';
Expand Down Expand Up @@ -70,31 +70,15 @@ export class License {
return;
}

if (this.manager.isValid()) {
return;
}

try {
await this.manager.activate(activationKey);
} catch (e) {
if (e instanceof Error) {
this.logger.error('Could not activate license', e);
}
}
await this.manager.activate(activationKey);
}

async renew() {
if (!this.manager) {
return;
}

try {
await this.manager.renew();
} catch (e) {
if (e instanceof Error) {
this.logger.error('Could not renew license', e);
}
}
await this.manager.renew();
}

isFeatureEnabled(feature: string): boolean {
Expand All @@ -108,6 +92,56 @@ export class License {
isSharingEnabled() {
return this.isFeatureEnabled(LICENSE_FEATURES.SHARING);
}

getCurrentEntitlements() {
return this.manager?.getCurrentEntitlements() ?? [];
}

getFeatureValue(
feature: string,
requireValidCert?: boolean,
): undefined | boolean | number | string {
if (!this.manager) {
return undefined;
}

return this.manager.getFeatureValue(feature, requireValidCert);
}

getManagementJwt(): string {
if (!this.manager) {
return '';
}
return this.manager.getManagementJwt();
}

/**
* Helper function to get the main plan for a license
*/
getMainPlan(): TEntitlement | undefined {
if (!this.manager) {
return undefined;
}

const entitlements = this.getCurrentEntitlements();
if (!entitlements.length) {
return undefined;
}

return entitlements.find(
(entitlement) =>
(entitlement.productMetadata.terms as unknown as { isMainPlan: boolean }).isMainPlan,
);
}

// Helper functions for computed data
getTriggerLimit(): number {
return (this.getFeatureValue('quota:activeWorkflows') ?? -1) as number;
}

getPlanName(): string {
return (this.getFeatureValue('planName') ?? 'Community') as string;
}
}

let licenseInstance: License | undefined;
Expand Down
15 changes: 14 additions & 1 deletion packages/cli/src/Server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'
import { toHttpNodeParameters } from '@/CurlConverterHelper';
import { setupErrorMiddleware } from '@/ErrorReporting';
import { getLicense } from '@/License';
import { licenseController } from './license/license.controller';
import { corsMiddleware } from './middlewares/cors';

require('body-parser-xml')(bodyParser);
Expand Down Expand Up @@ -358,6 +359,9 @@ class App {
workflowSharing: false,
},
hideUsagePage: config.getEnv('hideUsagePage'),
license: {
environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging',
},
};
}

Expand Down Expand Up @@ -401,7 +405,11 @@ class App {

const activationKey = config.getEnv('license.activationKey');
if (activationKey) {
await license.activate(activationKey);
try {
await license.activate(activationKey);
} catch (e) {
LoggerProxy.error('Could not activate license', e);
}
}
}

Expand Down Expand Up @@ -792,6 +800,11 @@ class App {
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/workflows`, workflowsController);

// ----------------------------------------
// License
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/license`, licenseController);

// ----------------------------------------
// Workflow Statistics
// ----------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/WebhookHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper';
export const WEBHOOK_METHODS = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT'];

/**
* Returns all the webhooks which should be created for the give workflow
* Returns all the webhooks which should be created for the given workflow
*
*/
export function getWorkflowWebhooks(
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/databases/entities/WorkflowEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb {

@Column({ length: 36 })
versionId: string;

@Column({ default: 0 })
triggerCount: number;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config';

export class AddTriggerCountColumn1669823906994 implements MigrationInterface {
name = 'AddTriggerCountColumn1669823906994';

async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);

const tablePrefix = config.getEnv('database.tablePrefix');

await queryRunner.query(
`ALTER TABLE ${tablePrefix}workflow_entity ADD COLUMN triggerCount integer NOT NULL DEFAULT 0`,
);
// Table will be populated by n8n startup - see ActiveWorkflowRunner.ts

logMigrationEnd(this.name);
}

async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');

await queryRunner.query(
`ALTER TABLE ${tablePrefix}workflow_entity DROP COLUMN triggerCount`,
);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/migrations/mysqldb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { CreateWorkflowsEditorRole1663755770894 } from './1663755770894-CreateWo
import { CreateCredentialUsageTable1665484192213 } from './1665484192213-CreateCredentialUsageTable';
import { RemoveCredentialUsageTable1665754637026 } from './1665754637026-RemoveCredentialUsageTable';
import { AddWorkflowVersionIdColumn1669739707125 } from './1669739707125-AddWorkflowVersionIdColumn';
import { AddTriggerCountColumn1669823906994 } from './1669823906994-AddTriggerCountColumn';

export const mysqlMigrations = [
InitialMigration1588157391238,
Expand Down Expand Up @@ -54,4 +55,5 @@ export const mysqlMigrations = [
RemoveCredentialUsageTable1665754637026,
AddWorkflowVersionIdColumn1669739707125,
WorkflowStatistics1664196174002,
AddTriggerCountColumn1669823906994,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config';

export class AddTriggerCountColumn1669823906995 implements MigrationInterface {
name = 'AddTriggerCountColumn1669823906995';

async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);

const tablePrefix = getTablePrefix();

await queryRunner.query(
`ALTER TABLE ${tablePrefix}workflow_entity ADD COLUMN "triggerCount" integer NOT NULL DEFAULT 0`,
);
// Table will be populated by n8n startup - see ActiveWorkflowRunner.ts

logMigrationEnd(this.name);
}

async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = getTablePrefix();

await queryRunner.query(
`ALTER TABLE ${tablePrefix}workflow_entity DROP COLUMN "triggerCount"`,
);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/migrations/postgresdb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { CreateWorkflowsEditorRole1663755770893 } from './1663755770893-CreateWo
import { CreateCredentialUsageTable1665484192212 } from './1665484192212-CreateCredentialUsageTable';
import { RemoveCredentialUsageTable1665754637025 } from './1665754637025-RemoveCredentialUsageTable';
import { AddWorkflowVersionIdColumn1669739707126 } from './1669739707126-AddWorkflowVersionIdColumn';
import { AddTriggerCountColumn1669823906995 } from './1669823906995-AddTriggerCountColumn';

export const postgresMigrations = [
InitialMigration1587669153312,
Expand Down Expand Up @@ -50,4 +51,5 @@ export const postgresMigrations = [
RemoveCredentialUsageTable1665754637025,
AddWorkflowVersionIdColumn1669739707126,
WorkflowStatistics1664196174001,
AddTriggerCountColumn1669823906995,
];
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers';
import config from '@/config';

export class AddTriggerCountColumn1669823906993 implements MigrationInterface {
name = 'AddTriggerCountColumn1669823906993';

async up(queryRunner: QueryRunner): Promise<void> {
logMigrationStart(this.name);

const tablePrefix = config.getEnv('database.tablePrefix');

await queryRunner.query(
`ALTER TABLE \`${tablePrefix}workflow_entity\` ADD COLUMN "triggerCount" integer NOT NULL DEFAULT 0`,
);
// Table will be populated by n8n startup - see ActiveWorkflowRunner.ts

logMigrationEnd(this.name);
}

async down(queryRunner: QueryRunner): Promise<void> {
const tablePrefix = config.getEnv('database.tablePrefix');

await queryRunner.query(
`ALTER TABLE \`${tablePrefix}workflow_entity\` DROP COLUMN "triggerCount"`,
);
}
}
2 changes: 2 additions & 0 deletions packages/cli/src/databases/migrations/sqlite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { CreateWorkflowsEditorRole1663755770892 } from './1663755770892-CreateWo
import { CreateCredentialUsageTable1665484192211 } from './1665484192211-CreateCredentialUsageTable';
import { RemoveCredentialUsageTable1665754637024 } from './1665754637024-RemoveCredentialUsageTable';
import { AddWorkflowVersionIdColumn1669739707124 } from './1669739707124-AddWorkflowVersionIdColumn';
import { AddTriggerCountColumn1669823906993 } from './1669823906993-AddTriggerCountColumn';

const sqliteMigrations = [
InitialMigration1588102412422,
Expand All @@ -47,6 +48,7 @@ const sqliteMigrations = [
CreateCredentialUsageTable1665484192211,
RemoveCredentialUsageTable1665754637024,
AddWorkflowVersionIdColumn1669739707124,
AddTriggerCountColumn1669823906993,
WorkflowStatistics1664196174000,
];

Expand Down
Loading

0 comments on commit 7b84f9e

Please sign in to comment.