feat(editor): Add usage and plan pages (#4819)

* feat(editor): Usage and plan page (#4793)

feat(editor): usage and plan page

* feat(editor): Update Usage and plan page (#4842)

* feat(editor): usage and plan store

* feat(editor): usage and plan page updates

* feat(editor): usage and plan add buttons and alert

* tes(editor): usage and plan store

* tes(editor): usage remove refresh button and add link to view plans

* tes(editor): usage use info tip

* tes(editor): usage info style

* feat(editor): Get quotas data (#4866)

feat(editor): get quotas data

* feat(editor): In-app experience (#4875)

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

* fix(editor): Usage change mounted lifecycle logic

* fix(editor): Usage return after successful activation in mounted

* fix: remove console log

* test: fix tests related to settings (#4979)

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>
Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com>
This commit is contained in:
Csaba Tuncsik
2022-12-20 10:52:01 +01:00
committed by GitHub
parent 96296e1724
commit 0da338f9b5
47 changed files with 1310 additions and 74 deletions

View File

@@ -32,6 +32,7 @@ import {
WorkflowExecuteMode,
LoggerProxy as Logger,
ErrorReporterProxy as ErrorReporter,
INodeType,
} from 'n8n-workflow';
import express from 'express';
@@ -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}"`);
@@ -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

View File

@@ -501,6 +501,9 @@ export interface IN8nUISettings {
workflowSharing: boolean;
};
hideUsagePage: boolean;
license: {
environment: 'production' | 'staging';
};
}
export interface IPersonalizationSurveyAnswers {
@@ -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;
}

View File

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

View File

@@ -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';
@@ -70,17 +70,7 @@ 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() {
@@ -88,13 +78,7 @@ export class License {
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 {
@@ -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;

View File

@@ -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);
@@ -358,6 +359,9 @@ class App {
workflowSharing: false,
},
hideUsagePage: config.getEnv('hideUsagePage'),
license: {
environment: config.getEnv('license.tenantId') === 1 ? 'production' : 'staging',
},
};
}
@@ -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);
}
}
}
@@ -792,6 +800,11 @@ class App {
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/workflows`, workflowsController);
// ----------------------------------------
// License
// ----------------------------------------
this.app.use(`/${this.restEndpoint}/license`, licenseController);
// ----------------------------------------
// Workflow Statistics
// ----------------------------------------

View File

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

View File

@@ -99,6 +99,9 @@ export class WorkflowEntity extends AbstractEntity implements IWorkflowDb {
@Column({ length: 36 })
versionId: string;
@Column({ default: 0 })
triggerCount: number;
}
/**

View File

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

View File

@@ -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,
@@ -54,4 +55,5 @@ export const mysqlMigrations = [
RemoveCredentialUsageTable1665754637026,
AddWorkflowVersionIdColumn1669739707125,
WorkflowStatistics1664196174002,
AddTriggerCountColumn1669823906994,
];

View File

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

View File

@@ -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,
@@ -50,4 +51,5 @@ export const postgresMigrations = [
RemoveCredentialUsageTable1665754637025,
AddWorkflowVersionIdColumn1669739707126,
WorkflowStatistics1664196174001,
AddTriggerCountColumn1669823906995,
];

View File

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

View File

@@ -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,
@@ -47,6 +48,7 @@ const sqliteMigrations = [
CreateCredentialUsageTable1665484192211,
RemoveCredentialUsageTable1665754637024,
AddWorkflowVersionIdColumn1669739707124,
AddTriggerCountColumn1669823906993,
WorkflowStatistics1664196174000,
];

View File

@@ -1,4 +1,4 @@
import { INode, IRun, IWorkflowBase } from 'n8n-workflow';
import { INode, IRun, IWorkflowBase, LoggerProxy } from 'n8n-workflow';
import { Db, InternalHooksManager } from '..';
import { StatisticsNames } from '../databases/entities/WorkflowStatistics';
import { getWorkflowOwner } from '../UserManagement/UserManagementHelper';
@@ -26,7 +26,7 @@ export async function workflowExecutionCompleted(
workflowId = parseInt(workflowData.id as string, 10);
if (isNaN(workflowId)) throw new Error('not a number');
} catch (error) {
console.error(`Error "${error as string}" when casting workflow ID to a number`);
LoggerProxy.error(`Error "${error as string}" when casting workflow ID to a number`);
return;
}
@@ -67,7 +67,7 @@ export async function nodeFetchedData(workflowId: string, node: INode): Promise<
id = parseInt(workflowId, 10);
if (isNaN(id)) throw new Error('not a number');
} catch (error) {
console.error(`Error ${error as string} when casting workflow ID to a number`);
LoggerProxy.error(`Error ${error as string} when casting workflow ID to a number`);
return;
}

View File

@@ -0,0 +1,36 @@
import { getLicense } from '@/License';
import { Db, ILicenseReadResponse } from '..';
export class LicenseService {
static async getActiveTriggerCount(): Promise<number> {
const qb = Db.collections.Workflow.createQueryBuilder('workflow')
.select('SUM(workflow.triggerCount)', 'triggerCount')
.where('workflow.active = :active', { active: true });
const results: { triggerCount: number } | undefined = await qb.getRawOne();
if (!results) {
throw new Error('Could not get active trigger count');
}
return results.triggerCount ?? 0;
}
// Helper for getting the basic license data that we want to return
static async getLicenseData(): Promise<ILicenseReadResponse> {
const triggerCount = await LicenseService.getActiveTriggerCount();
const license = getLicense();
const mainPlan = license.getMainPlan();
return {
usage: {
executions: {
value: triggerCount,
limit: license.getTriggerLimit(),
warningThreshold: 0.8,
},
},
license: {
planId: mainPlan?.productId ?? '',
planName: license.getPlanName(),
},
};
}
}

View File

@@ -0,0 +1,136 @@
/* eslint-disable no-param-reassign */
import express from 'express';
import { LoggerProxy } from 'n8n-workflow';
import { getLogger } from '@/Logger';
import {
ILicensePostResponse,
ILicenseReadResponse,
InternalHooksManager,
ResponseHelper,
} from '..';
import { LicenseService } from './License.service';
import { getLicense } from '@/License';
import { AuthenticatedRequest, LicenseRequest } from '@/requests';
import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service';
export const licenseController = express.Router();
const OWNER_ROUTES = ['/activate', '/renew'];
/**
* Initialize Logger if needed
*/
licenseController.use((req, res, next) => {
try {
LoggerProxy.getInstance();
} catch (error) {
LoggerProxy.init(getLogger());
}
next();
});
/**
* Owner checking
*/
licenseController.use((req: AuthenticatedRequest, res, next) => {
if (OWNER_ROUTES.includes(req.path) && req.user) {
if (!isInstanceOwner(req.user)) {
LoggerProxy.info('Non-owner attempted to activate or renew a license', {
userId: req.user.id,
});
ResponseHelper.sendErrorResponse(
res,
new ResponseHelper.UnauthorizedError(
'Only an instance owner may activate or renew a license',
),
);
return;
}
}
next();
});
/**
* GET /license
* Get the license data, usable by everyone
*/
licenseController.get(
'/',
ResponseHelper.send(async (): Promise<ILicenseReadResponse> => {
return LicenseService.getLicenseData();
}),
);
/**
* POST /license/activate
* Only usable by the instance owner, activates a license.
*/
licenseController.post(
'/activate',
ResponseHelper.send(async (req: LicenseRequest.Activate): Promise<ILicensePostResponse> => {
// Call the license manager activate function and tell it to throw an error
const license = getLicense();
try {
await license.activate(req.body.activationKey);
} catch (e) {
const error = e as Error & { errorId?: string };
switch (error.errorId ?? 'UNSPECIFIED') {
case 'SCHEMA_VALIDATION':
error.message = 'Activation key is in the wrong format';
break;
case 'RESERVATION_EXHAUSTED':
error.message =
'Activation key has been used too many times. Please contact sales@n8n.io if you would like to extend it';
break;
case 'RESERVATION_EXPIRED':
error.message = 'Activation key has expired';
break;
case 'NOT_FOUND':
case 'RESERVATION_CONFLICT':
error.message = 'Activation key not found';
break;
}
throw new ResponseHelper.BadRequestError((e as Error).message);
}
// Return the read data, plus the management JWT
return {
managementToken: license.getManagementJwt(),
...(await LicenseService.getLicenseData()),
};
}),
);
/**
* POST /license/renew
* Only usable by instance owner, renews a license
*/
licenseController.post(
'/renew',
ResponseHelper.send(async (): Promise<ILicensePostResponse> => {
// Call the license manager activate function and tell it to throw an error
const license = getLicense();
try {
await license.renew();
} catch (e) {
// not awaiting so as not to make the endpoint hang
void InternalHooksManager.getInstance().onLicenseRenewAttempt({ success: false });
if (e instanceof Error) {
throw new ResponseHelper.BadRequestError(e.message);
}
}
// not awaiting so as not to make the endpoint hang
void InternalHooksManager.getInstance().onLicenseRenewAttempt({ success: true });
// Return the read data, plus the management JWT
return {
managementToken: license.getManagementJwt(),
...(await LicenseService.getLicenseData()),
};
}),
);

View File

@@ -340,3 +340,11 @@ export declare namespace NodeRequest {
export declare namespace CurlHelper {
type ToJson = AuthenticatedRequest<{}, {}, { curlCommand?: string }>;
}
// ----------------------------------
// /license
// ----------------------------------
export declare namespace LicenseRequest {
type Activate = AuthenticatedRequest<{}, {}, { activationKey: string }, {}>;
}

View File

@@ -6,6 +6,8 @@ import { ITelemetryTrackProperties, LoggerProxy } from 'n8n-workflow';
import config from '@/config';
import { IExecutionTrackProperties } from '@/Interfaces';
import { getLogger } from '@/Logger';
import { getLicense } from '@/License';
import { LicenseService } from '@/license/License.service';
type ExecutionTrackDataKey = 'manual_error' | 'manual_success' | 'prod_error' | 'prod_success';
@@ -95,7 +97,14 @@ export class Telemetry {
});
this.executionCountsBuffer = {};
allPromises.push(this.track('pulse'));
// License info
const pulsePacket = {
plan_name_current: getLicense().getPlanName(),
quota: getLicense().getTriggerLimit(),
usage: await LicenseService.getActiveTriggerCount(),
};
allPromises.push(this.track('pulse', pulsePacket));
return Promise.all(allPromises);
}