From cd7c312fbd172b5d3c8bbeaf775f7b5bb4611aa5 Mon Sep 17 00:00:00 2001 From: Ricardo Espinoza Date: Mon, 15 May 2023 17:16:13 -0400 Subject: [PATCH] feat(editor): Add cloud ExecutionsUsage and API blocking using licenses (#6159) * Add ExecutionsUsage component * set $sidebar-expanded-width back to 200px * add days using interpolation * Rename PlanData type to CloudPlanData * Rename Metadata type to PlanMetadata * Make prop block in the update button * Use variable in line-height * Remove progressBarSection class * fix trial expiration calculation * mock expirationDate and fix issue with days left * Remove unnecesary property from class .container * inject component data via props * Check for plan data during app mounting and keep data in the store * Remove mounted hook * redirect when upgrade plan is clicked * Remove computed properties * Remove instance property as it's not needed anymore * Flatten plan object * remove console.log * Add all cloud types within its own namespace * keep redirection inside component * get computed properties back * Improve polling logic * Move cloudData to its own store * Remove commented interfaces * remove cloudPlan from user store * fix imports * update logic for userIsTrialing method * centralize userIsTrialing method * redirect to production change plan page always * Call staging or production cloud api depending on base URL * remove setting store form ExecutionUsage.vue * fix linting issue * Add trial group to PlanMetadata group * Move helpers into the store * make staging url check more specific * make cloud state nullable * fix linting issue * swap mockup date for endpoint * Make getCurrentPlan async * asas * Improvements * small improvements * chore: resolve conflicts * make sure there is data before calculating trial expiration * Fix issue with component not loading on first page load * type safety improvements * apply component ui feedback * fix linting issue * chore: clean up unnecessary change from merge conflict * feat: Block api feature using licenses, show notice page for trial cloud users (#6187) * rename planSpec to plan * Remove instance property as it's not needed anymore * Flatten plan object * remove console.log * feat: disable api using license * feat: add api page * chore: resolve conflicts * chore: resolve conflicts * feat: update and refactor a bit * fix: update endpoints * fix: update endpoints * fix: use host * feat: update copy * fix linting issues --------- Co-authored-by: ricardo * add pluralization to days left text --------- Co-authored-by: Mutasem Co-authored-by: Mutasem Aldmour <4711238+mutdmour@users.noreply.github.com> --- packages/cli/src/License.ts | 4 + packages/cli/src/PublicApi/index.ts | 10 + packages/cli/src/Server.ts | 8 +- packages/cli/src/audit/risks/instance.risk.ts | 3 +- packages/cli/src/constants.ts | 1 + .../src/components/N8nButton/Button.vue | 13 +- .../src/components/N8nMenu/Menu.vue | 1 + packages/editor-ui/src/App.vue | 26 ++- packages/editor-ui/src/Interface.ts | 39 ++++ packages/editor-ui/src/api/cloudPlans.ts | 13 ++ .../src/components/ExecutionsUsage.vue | 203 ++++++++++++++++++ .../editor-ui/src/components/MainSidebar.vue | 25 ++- packages/editor-ui/src/constants.ts | 10 + .../src/plugins/i18n/locales/en.json | 14 +- packages/editor-ui/src/plugins/icons/index.ts | 2 + .../editor-ui/src/stores/cloudPlan.store.ts | 83 +++++++ packages/editor-ui/src/stores/index.ts | 1 + .../editor-ui/src/stores/n8nRoot.store.ts | 11 +- packages/editor-ui/src/utils/apiUtils.ts | 6 +- .../editor-ui/src/views/SettingsApiView.vue | 22 +- 20 files changed, 480 insertions(+), 15 deletions(-) create mode 100644 packages/editor-ui/src/api/cloudPlans.ts create mode 100644 packages/editor-ui/src/components/ExecutionsUsage.vue create mode 100644 packages/editor-ui/src/stores/cloudPlan.store.ts diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index c00e1b1835..9a4597f92a 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -132,6 +132,10 @@ export class License { return this.isFeatureEnabled(LICENSE_FEATURES.VERSION_CONTROL); } + isAPIDisabled() { + return this.isFeatureEnabled(LICENSE_FEATURES.API_DISABLED); + } + getCurrentEntitlements() { return this.manager?.getCurrentEntitlements() ?? []; } diff --git a/packages/cli/src/PublicApi/index.ts b/packages/cli/src/PublicApi/index.ts index fa27212db4..4ffa4d7fe5 100644 --- a/packages/cli/src/PublicApi/index.ts +++ b/packages/cli/src/PublicApi/index.ts @@ -16,6 +16,7 @@ import * as Db from '@/Db'; import { getInstanceBaseUrl } from '@/UserManagement/UserManagementHelper'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; +import { License } from '@/License'; async function createApiRouter( version: string, @@ -151,3 +152,12 @@ export const loadPublicApiVersions = async ( apiLatestVersion: Number(versions.pop()?.charAt(1)) ?? 1, }; }; + +function isApiEnabledByLicense(): boolean { + const license = Container.get(License); + return !license.isAPIDisabled(); +} + +export function isApiEnabled(): boolean { + return !config.get('publicApi.disabled') && isApiEnabledByLicense(); +} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index f5406d2b2d..8739fe9ed1 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -97,7 +97,7 @@ import { import { executionsController } from '@/executions/executions.controller'; import { workflowStatsController } from '@/api/workflowStats.api'; -import { loadPublicApiVersions } from '@/PublicApi'; +import { isApiEnabled, loadPublicApiVersions } from '@/PublicApi'; import { getInstanceBaseUrl, isEmailSetUp, @@ -277,7 +277,7 @@ export class Server extends AbstractServer { }, }, publicApi: { - enabled: !config.getEnv('publicApi.disabled'), + enabled: isApiEnabled(), latestVersion: 1, path: config.getEnv('publicApi.path'), swaggerUi: { @@ -538,7 +538,7 @@ export class Server extends AbstractServer { this.endpointWebhook, this.endpointWebhookTest, this.endpointPresetCredentials, - config.getEnv('publicApi.disabled') ? publicApiEndpoint : '', + isApiEnabled() ? '' : publicApiEndpoint, ...excludeEndpoints.split(':'), ].filter((u) => !!u); @@ -564,7 +564,7 @@ export class Server extends AbstractServer { // Public API // ---------------------------------------- - if (!config.getEnv('publicApi.disabled')) { + if (isApiEnabled()) { const { apiRouters, apiLatestVersion } = await loadPublicApiVersions(publicApiEndpoint); this.app.use(...apiRouters); this.frontendSettings.publicApi.latestVersion = apiLatestVersion; diff --git a/packages/cli/src/audit/risks/instance.risk.ts b/packages/cli/src/audit/risks/instance.risk.ts index 0bd0c18fb3..6d75acbd98 100644 --- a/packages/cli/src/audit/risks/instance.risk.ts +++ b/packages/cli/src/audit/risks/instance.risk.ts @@ -13,6 +13,7 @@ import { import { getN8nPackageJson, inDevelopment } from '@/constants'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { Risk, n8n } from '@/audit/types'; +import { isApiEnabled } from '@/PublicApi'; function getSecuritySettings() { if (config.getEnv('deployment.type') === 'cloud') return null; @@ -34,7 +35,7 @@ function getSecuritySettings() { communityPackagesEnabled: config.getEnv('nodes.communityPackages.enabled'), versionNotificationsEnabled: config.getEnv('versionNotifications.enabled'), templatesEnabled: config.getEnv('templates.enabled'), - publicApiEnabled: !config.getEnv('publicApi.disabled'), + publicApiEnabled: isApiEnabled(), userManagementEnabled, }; diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 818364bc5c..049f103cc9 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -78,6 +78,7 @@ export const enum LICENSE_FEATURES { ADVANCED_EXECUTION_FILTERS = 'feat:advancedExecutionFilters', VARIABLES = 'feat:variables', VERSION_CONTROL = 'feat:versionControl', + API_DISABLED = 'feat:apiDisabled', } export const enum LICENSE_QUOTAS { diff --git a/packages/design-system/src/components/N8nButton/Button.vue b/packages/design-system/src/components/N8nButton/Button.vue index 127a1abd5f..ad899a10c6 100644 --- a/packages/design-system/src/components/N8nButton/Button.vue +++ b/packages/design-system/src/components/N8nButton/Button.vue @@ -38,7 +38,7 @@ export default defineComponent({ type: String, default: 'medium', validator: (value: string): boolean => - ['mini', 'small', 'medium', 'large', 'xlarge'].includes(value), + ['xmini', 'mini', 'small', 'medium', 'large', 'xlarge'].includes(value), }, loading: { type: Boolean, @@ -278,6 +278,17 @@ $loading-overlay-background-color: rgba(255, 255, 255, 0); * Sizes */ +.xmini { + --button-padding-vertical: var(--spacing-4xs); + --button-padding-horizontal: var(--spacing-3xs); + --button-font-size: var(--font-size-3xs); + + &.square { + height: 22px; + width: 22px; + } +} + .mini { --button-padding-vertical: var(--spacing-4xs); --button-padding-horizontal: var(--spacing-2xs); diff --git a/packages/design-system/src/components/N8nMenu/Menu.vue b/packages/design-system/src/components/N8nMenu/Menu.vue index 66e9d61bb4..6dafe805fc 100644 --- a/packages/design-system/src/components/N8nMenu/Menu.vue +++ b/packages/design-system/src/components/N8nMenu/Menu.vue @@ -28,6 +28,7 @@
+ { + try { + await this.cloudPlanStore.getOwnerCurrentPLan(); + if (!this.cloudPlanStore.userIsTrialing) return; + await this.cloudPlanStore.getInstanceCurrentUsage(); + this.startPollingInstanceUsageData(); + } catch {} + }, + startPollingInstanceUsageData() { + const interval = setInterval(async () => { + try { + await this.cloudPlanStore.getInstanceCurrentUsage(); + if (this.cloudPlanStore.trialExpired || this.cloudPlanStore.allExecutionsUsed) { + clearTimeout(interval); + return; + } + } catch {} + }, CLOUD_TRIAL_CHECK_INTERVAL); + }, }, async mounted() { this.setTheme(); @@ -193,6 +216,7 @@ export default defineComponent({ this.authenticate(); this.redirectIfNecessary(); void this.checkForNewVersions(); + void this.checkForCloudPlanData(); this.loading = false; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index ee2365b8b8..3b9546127a 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1450,3 +1450,42 @@ export type VersionControlPreferences = { branchColor: string; publicKey?: string; }; + +export declare namespace Cloud { + export interface PlanData { + planId: number; + monthlyExecutionsLimit: number; + activeWorkflowsLimit: number; + credentialsLimit: number; + isActive: boolean; + displayName: string; + expirationDate: string; + metadata: PlanMetadata; + } + + export interface PlanMetadata { + version: 'v1'; + group: 'opt-out' | 'opt-in' | 'trial'; + slug: 'pro-1' | 'pro-2' | 'starter' | 'trial-1'; + trial?: Trial; + } + + interface Trial { + length: number; + gracePeriod: number; + } +} + +export interface CloudPlanState { + data: Cloud.PlanData | null; + usage: InstanceUsage | null; + loadingPlan: boolean; +} + +export interface InstanceUsage { + timeframe?: string; + executions: number; + activeWorkflows: number; +} + +export type CloudPlanAndUsageData = Cloud.PlanData & { usage: InstanceUsage }; diff --git a/packages/editor-ui/src/api/cloudPlans.ts b/packages/editor-ui/src/api/cloudPlans.ts new file mode 100644 index 0000000000..eb39bd42d9 --- /dev/null +++ b/packages/editor-ui/src/api/cloudPlans.ts @@ -0,0 +1,13 @@ +import type { Cloud, IRestApiContext, InstanceUsage } from '@/Interface'; +import { get } from '@/utils'; + +export async function getCurrentPlan( + context: IRestApiContext, + cloudUserId: string, +): Promise { + return get(context.baseUrl, `/user/${cloudUserId}/plan`); +} + +export async function getCurrentUsage(context: IRestApiContext): Promise { + return get(context.baseUrl, '/limits'); +} diff --git a/packages/editor-ui/src/components/ExecutionsUsage.vue b/packages/editor-ui/src/components/ExecutionsUsage.vue new file mode 100644 index 0000000000..791a82a669 --- /dev/null +++ b/packages/editor-ui/src/components/ExecutionsUsage.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/packages/editor-ui/src/components/MainSidebar.vue b/packages/editor-ui/src/components/MainSidebar.vue index cc8ccb4f56..37ee67b81d 100644 --- a/packages/editor-ui/src/components/MainSidebar.vue +++ b/packages/editor-ui/src/components/MainSidebar.vue @@ -25,6 +25,12 @@ />
+ +