From 6ba8e0bebe0de1cc527d1324400366fc56fafed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Ovejero?= Date: Wed, 18 Jun 2025 10:00:02 +0200 Subject: [PATCH] refactor(core): Decouple module settings from frontend service (#16324) Co-authored-by: Danny Martini --- .../@n8n/api-types/src/frontend-settings.ts | 24 +++-- packages/@n8n/api-types/src/index.ts | 1 - .../api-types/src/schemas/insights.schema.ts | 11 +-- .../@n8n/backend-common/src/license-state.ts | 3 +- .../src/module/__tests__/module.test.ts | 94 ++++++------------- packages/@n8n/decorators/src/module/index.ts | 2 +- .../decorators/src/module/module-metadata.ts | 6 +- packages/@n8n/decorators/src/module/module.ts | 9 +- .../external-secrets.module.ts | 2 +- .../__tests__/insights.service.test.ts | 21 +++-- .../src/modules/insights/insights-helpers.ts | 31 ------ .../modules/insights/insights.constants.ts | 19 ++++ .../src/modules/insights/insights.module.ts | 7 +- .../src/modules/insights/insights.service.ts | 40 +++++++- packages/cli/src/modules/module-registry.ts | 18 +++- packages/cli/src/server.ts | 6 ++ packages/cli/src/services/frontend.service.ts | 21 ++--- .../cli/test/integration/shared/test-db.ts | 3 - .../integration/shared/utils/test-server.ts | 2 + .../@n8n/rest-api-client/src/api/index.ts | 1 + .../src/api/module-settings.ts | 8 ++ .../editor-ui/src/__tests__/defaults.ts | 15 +-- .../editor-ui/src/components/MainSidebar.vue | 2 +- .../components/InsightsDashboard.test.ts | 18 +++- .../src/features/insights/insights.store.ts | 11 ++- .../editor-ui/src/stores/settings.store.ts | 18 +++- .../editor-ui/src/views/SigninView.test.ts | 1 + .../editor-ui/src/views/SigninView.vue | 5 + 28 files changed, 227 insertions(+), 172 deletions(-) delete mode 100644 packages/cli/src/modules/insights/insights-helpers.ts create mode 100644 packages/cli/src/modules/insights/insights.constants.ts create mode 100644 packages/frontend/@n8n/rest-api-client/src/api/module-settings.ts diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index 9d9153659e..bbb98e5569 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -192,13 +192,25 @@ export interface FrontendSettings { partialExecution: { version: 1 | 2; }; - insights: { - enabled: boolean; + evaluation: { + quota: number; + }; + + /** Backend modules that were loaded during startup based on user configuration and pre-init check. */ + loadedModules: string[]; +} + +export type FrontendModuleSettings = { + /** + * Client settings for [insights](https://docs.n8n.io/insights/) module. + * + * - `summary`: Whether the summary banner should be shown. + * - `dashboard`: Whether the full dashboard should be shown. + * - `dateRanges`: Date range filters available to select. + */ + insights?: { summary: boolean; dashboard: boolean; dateRanges: InsightsDateRange[]; }; - evaluation: { - quota: number; - }; -} +}; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 59f4dc9501..161041444f 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -36,7 +36,6 @@ export { type InsightsByWorkflow, type InsightsByTime, type InsightsDateRange, - INSIGHTS_DATE_RANGE_KEYS, } from './schemas/insights.schema'; export { diff --git a/packages/@n8n/api-types/src/schemas/insights.schema.ts b/packages/@n8n/api-types/src/schemas/insights.schema.ts index 311d08775d..334a9f5ada 100644 --- a/packages/@n8n/api-types/src/schemas/insights.schema.ts +++ b/packages/@n8n/api-types/src/schemas/insights.schema.ts @@ -85,18 +85,9 @@ export const insightsByTimeDataSchemas = { export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict(); export type InsightsByTime = z.infer; -export const INSIGHTS_DATE_RANGE_KEYS = [ - 'day', - 'week', - '2weeks', - 'month', - 'quarter', - '6months', - 'year', -] as const; export const insightsDateRangeSchema = z .object({ - key: z.enum(INSIGHTS_DATE_RANGE_KEYS), + key: z.enum(['day', 'week', '2weeks', 'month', 'quarter', '6months', 'year']), licensed: z.boolean(), granularity: z.enum(['hour', 'day', 'week']), }) diff --git a/packages/@n8n/backend-common/src/license-state.ts b/packages/@n8n/backend-common/src/license-state.ts index b891b7af85..939739893f 100644 --- a/packages/@n8n/backend-common/src/license-state.ts +++ b/packages/@n8n/backend-common/src/license-state.ts @@ -1,4 +1,5 @@ -import { UNLIMITED_LICENSE_QUOTA, type BooleanLicenseFeature } from '@n8n/constants'; +import type { BooleanLicenseFeature } from '@n8n/constants'; +import { UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import { Service } from '@n8n/di'; import { UnexpectedError } from 'n8n-workflow'; diff --git a/packages/@n8n/decorators/src/module/__tests__/module.test.ts b/packages/@n8n/decorators/src/module/__tests__/module.test.ts index f7bca69854..66e3a27d71 100644 --- a/packages/@n8n/decorators/src/module/__tests__/module.test.ts +++ b/packages/@n8n/decorators/src/module/__tests__/module.test.ts @@ -1,5 +1,6 @@ import { Container } from '@n8n/di'; +import type { ModuleInterface } from '../module'; import { BackendModule } from '../module'; import { ModuleMetadata } from '../module-metadata'; @@ -14,34 +15,26 @@ describe('@BackendModule decorator', () => { }); it('should register module in ModuleMetadata', () => { - @BackendModule() - class TestModule { - initialize() {} - } + @BackendModule({ name: 'test' }) + class TestModule implements ModuleInterface {} - const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); + const registeredModules = moduleMetadata.getClasses(); expect(registeredModules).toContain(TestModule); expect(registeredModules).toHaveLength(1); }); it('should register multiple modules', () => { - @BackendModule() - class FirstModule { - initialize() {} - } + @BackendModule({ name: 'test-1' }) + class FirstModule implements ModuleInterface {} - @BackendModule() - class SecondModule { - initialize() {} - } + @BackendModule({ name: 'test-2' }) + class SecondModule implements ModuleInterface {} - @BackendModule() - class ThirdModule { - initialize() {} - } + @BackendModule({ name: 'test-3' }) + class ThirdModule implements ModuleInterface {} - const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); + const registeredModules = moduleMetadata.getClasses(); expect(registeredModules).toContain(FirstModule); expect(registeredModules).toContain(SecondModule); @@ -49,55 +42,26 @@ describe('@BackendModule decorator', () => { expect(registeredModules).toHaveLength(3); }); - it('should work with modules without initialize method', () => { - @BackendModule() - class TestModule {} - - const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); - - expect(registeredModules).toContain(TestModule); - expect(registeredModules).toHaveLength(1); - }); - - it('should support async initialize method', async () => { - const mockInitialize = jest.fn(); - - @BackendModule() - class TestModule { - async initialize() { - mockInitialize(); - } - } - - const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); - - expect(registeredModules).toContain(TestModule); - - const moduleInstance = new TestModule(); - await moduleInstance.initialize(); - - expect(mockInitialize).toHaveBeenCalled(); - }); - - describe('ModuleMetadata', () => { - it('should allow retrieving and checking registered modules', () => { - @BackendModule() - class FirstModule {} - - @BackendModule() - class SecondModule {} - - const registeredModules = moduleMetadata.getEntries().map((entry) => entry.class); - - expect(registeredModules).toContain(FirstModule); - expect(registeredModules).toContain(SecondModule); - }); - }); - it('should apply Service decorator', () => { - @BackendModule() - class TestModule {} + @BackendModule({ name: 'test' }) + class TestModule implements ModuleInterface {} expect(Container.has(TestModule)).toBe(true); }); + + it('stores the test name and licenseFlag flag in the metadata', () => { + const name = 'test'; + const licenseFlag = 'feat:ldap'; + + @BackendModule({ name, licenseFlag }) + class TestModule implements ModuleInterface {} + + const registeredModules = moduleMetadata.getEntries(); + + expect(registeredModules).toHaveLength(1); + const [moduleName, options] = registeredModules[0]; + expect(moduleName).toBe(name); + expect(options.licenseFlag).toBe(licenseFlag); + expect(options.class).toBe(TestModule); + }); }); diff --git a/packages/@n8n/decorators/src/module/index.ts b/packages/@n8n/decorators/src/module/index.ts index ae9ba9165d..f8dc040d81 100644 --- a/packages/@n8n/decorators/src/module/index.ts +++ b/packages/@n8n/decorators/src/module/index.ts @@ -1,2 +1,2 @@ -export { ModuleInterface, BackendModule, EntityClass } from './module'; +export { ModuleInterface, BackendModule, EntityClass, ModuleSettings } from './module'; export { ModuleMetadata } from './module-metadata'; diff --git a/packages/@n8n/decorators/src/module/module-metadata.ts b/packages/@n8n/decorators/src/module/module-metadata.ts index baebd2453a..e76c16d39f 100644 --- a/packages/@n8n/decorators/src/module/module-metadata.ts +++ b/packages/@n8n/decorators/src/module/module-metadata.ts @@ -16,6 +16,10 @@ export class ModuleMetadata { } getEntries() { - return [...this.modules.values()]; + return [...this.modules.entries()]; + } + + getClasses() { + return [...this.modules.values()].map((entry) => entry.class); } } diff --git a/packages/@n8n/decorators/src/module/module.ts b/packages/@n8n/decorators/src/module/module.ts index 988e8b8a70..0327ae2994 100644 --- a/packages/@n8n/decorators/src/module/module.ts +++ b/packages/@n8n/decorators/src/module/module.ts @@ -18,9 +18,12 @@ export interface BaseEntity { export type EntityClass = new () => BaseEntity; +export type ModuleSettings = Record; + export interface ModuleInterface { - init?(): void | Promise; + init?(): Promise; entities?(): EntityClass[]; + settings?(): Promise; } export type ModuleClass = Constructable; @@ -28,9 +31,9 @@ export type ModuleClass = Constructable; export type LicenseFlag = (typeof LICENSE_FEATURES)[keyof typeof LICENSE_FEATURES]; export const BackendModule = - (opts?: { licenseFlag: LicenseFlag }): ClassDecorator => + (opts: { name: string; licenseFlag?: LicenseFlag }): ClassDecorator => (target) => { - Container.get(ModuleMetadata).register(target.name, { + Container.get(ModuleMetadata).register(opts.name, { class: target as unknown as ModuleClass, licenseFlag: opts?.licenseFlag, }); diff --git a/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts b/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts index 30256ede17..542cbc1ca8 100644 --- a/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts +++ b/packages/cli/src/modules/external-secrets.ee/external-secrets.module.ts @@ -2,7 +2,7 @@ import type { ModuleInterface } from '@n8n/decorators'; import { BackendModule } from '@n8n/decorators'; import { Container } from '@n8n/di'; -@BackendModule({ licenseFlag: 'feat:externalSecrets' }) +@BackendModule({ name: 'external-secrets', licenseFlag: 'feat:externalSecrets' }) export class ExternalSecretsModule implements ModuleInterface { async init() { await import('./external-secrets.controller.ee'); diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts index 21a22a5906..72e5ba5510 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.test.ts @@ -26,7 +26,6 @@ import type { InsightsRaw } from '../database/entities/insights-raw'; import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; import { InsightsCollectionService } from '../insights-collection.service'; import { InsightsCompactionService } from '../insights-compaction.service'; -import { getAvailableDateRanges } from '../insights-helpers'; import type { InsightsPruningService } from '../insights-pruning.service'; import { InsightsConfig } from '../insights.config'; import { InsightsService } from '../insights.service'; @@ -584,16 +583,26 @@ describe('getInsightsByTime', () => { describe('getAvailableDateRanges', () => { let licenseMock: jest.Mocked; + let insightsService: InsightsService; beforeAll(() => { licenseMock = mock(); + insightsService = new InsightsService( + mock(), + mock(), + mock(), + mock(), + licenseMock, + mock(), + mockLogger(), + ); }); test('returns correct ranges when hourly data is enabled and max history is unlimited', () => { licenseMock.getInsightsMaxHistory.mockReturnValue(-1); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); - const result = getAvailableDateRanges(licenseMock); + const result = insightsService.getAvailableDateRanges(); expect(result).toEqual([ { key: 'day', licensed: true, granularity: 'hour' }, @@ -610,7 +619,7 @@ describe('getAvailableDateRanges', () => { licenseMock.getInsightsMaxHistory.mockReturnValue(365); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); - const result = getAvailableDateRanges(licenseMock); + const result = insightsService.getAvailableDateRanges(); expect(result).toEqual([ { key: 'day', licensed: true, granularity: 'hour' }, @@ -627,7 +636,7 @@ describe('getAvailableDateRanges', () => { licenseMock.getInsightsMaxHistory.mockReturnValue(30); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); - const result = getAvailableDateRanges(licenseMock); + const result = insightsService.getAvailableDateRanges(); expect(result).toEqual([ { key: 'day', licensed: false, granularity: 'hour' }, @@ -644,7 +653,7 @@ describe('getAvailableDateRanges', () => { licenseMock.getInsightsMaxHistory.mockReturnValue(5); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); - const result = getAvailableDateRanges(licenseMock); + const result = insightsService.getAvailableDateRanges(); expect(result).toEqual([ { key: 'day', licensed: false, granularity: 'hour' }, @@ -661,7 +670,7 @@ describe('getAvailableDateRanges', () => { licenseMock.getInsightsMaxHistory.mockReturnValue(90); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); - const result = getAvailableDateRanges(licenseMock); + const result = insightsService.getAvailableDateRanges(); expect(result).toEqual([ { key: 'day', licensed: true, granularity: 'hour' }, diff --git a/packages/cli/src/modules/insights/insights-helpers.ts b/packages/cli/src/modules/insights/insights-helpers.ts deleted file mode 100644 index 16a1df91f8..0000000000 --- a/packages/cli/src/modules/insights/insights-helpers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type InsightsDateRange, INSIGHTS_DATE_RANGE_KEYS } from '@n8n/api-types'; -import type { LicenseState } from '@n8n/backend-common'; - -export const keyRangeToDays: Record = { - day: 1, - week: 7, - '2weeks': 14, - month: 30, - quarter: 90, - '6months': 180, - year: 365, -}; - -/** - * Returns the available date ranges with their license authorization and time granularity - * when grouped by time. - */ -export function getAvailableDateRanges(licenseState: LicenseState): InsightsDateRange[] { - const maxHistoryInDays = - licenseState.getInsightsMaxHistory() === -1 - ? Number.MAX_SAFE_INTEGER - : licenseState.getInsightsMaxHistory(); - const isHourlyDateLicensed = licenseState.isInsightsHourlyDataLicensed(); - - return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({ - key, - licensed: - key === 'day' ? (isHourlyDateLicensed ?? false) : maxHistoryInDays >= keyRangeToDays[key], - granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week', - })); -} diff --git a/packages/cli/src/modules/insights/insights.constants.ts b/packages/cli/src/modules/insights/insights.constants.ts new file mode 100644 index 0000000000..ef902ddb5a --- /dev/null +++ b/packages/cli/src/modules/insights/insights.constants.ts @@ -0,0 +1,19 @@ +export const INSIGHTS_DATE_RANGE_KEYS = [ + 'day', + 'week', + '2weeks', + 'month', + 'quarter', + '6months', + 'year', +] as const; + +export const keyRangeToDays: Record<(typeof INSIGHTS_DATE_RANGE_KEYS)[number], number> = { + day: 1, + week: 7, + '2weeks': 14, + month: 30, + quarter: 90, + '6months': 180, + year: 365, +}; diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index 4ea7b18cb0..43a1878184 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -8,7 +8,7 @@ import { InsightsByPeriod } from './database/entities/insights-by-period'; import { InsightsMetadata } from './database/entities/insights-metadata'; import { InsightsRaw } from './database/entities/insights-raw'; -@BackendModule() +@BackendModule({ name: 'insights' }) export class InsightsModule implements ModuleInterface { async init() { const { instanceType } = Container.get(InstanceSettings); @@ -28,4 +28,9 @@ export class InsightsModule implements ModuleInterface { entities() { return [InsightsByPeriod, InsightsMetadata, InsightsRaw]; } + + async settings() { + const { InsightsService } = await import('./insights.service'); + return Container.get(InsightsService).settings(); + } } diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index de60f5994d..c6987e6218 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -10,8 +10,8 @@ import { NumberToType } from './database/entities/insights-shared'; import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository'; import { InsightsCollectionService } from './insights-collection.service'; import { InsightsCompactionService } from './insights-compaction.service'; -import { getAvailableDateRanges, keyRangeToDays } from './insights-helpers'; import { InsightsPruningService } from './insights-pruning.service'; +import { INSIGHTS_DATE_RANGE_KEYS, keyRangeToDays } from './insights.constants'; @Service() export class InsightsService { @@ -27,6 +27,14 @@ export class InsightsService { this.logger = this.logger.scoped('insights'); } + settings() { + return { + summary: this.licenseState.isInsightsSummaryLicensed(), + dashboard: this.licenseState.isInsightsDashboardLicensed(), + dateRanges: this.getAvailableDateRanges(), + }; + } + startTimers() { this.collectionService.startFlushingTimer(); @@ -191,9 +199,8 @@ export class InsightsService { getMaxAgeInDaysAndGranularity( dateRangeKey: InsightsDateRange['key'], ): InsightsDateRange & { maxAgeInDays: number } { - const dateRange = getAvailableDateRanges(this.licenseState).find( - (range) => range.key === dateRangeKey, - ); + const dateRange = this.getAvailableDateRanges().find((range) => range.key === dateRangeKey); + if (!dateRange) { // Not supposed to happen if we trust the dateRangeKey type throw new UserError('The selected date range is not available'); @@ -207,4 +214,29 @@ export class InsightsService { return { ...dateRange, maxAgeInDays: keyRangeToDays[dateRangeKey] }; } + + /** + * Returns the available date ranges with their license authorization and time granularity + * when grouped by time. + */ + getAvailableDateRanges(): DateRange[] { + const maxHistoryInDays = + this.licenseState.getInsightsMaxHistory() === -1 + ? Number.MAX_SAFE_INTEGER + : this.licenseState.getInsightsMaxHistory(); + const isHourlyDateLicensed = this.licenseState.isInsightsHourlyDataLicensed(); + + return INSIGHTS_DATE_RANGE_KEYS.map((key) => ({ + key, + licensed: + key === 'day' ? (isHourlyDateLicensed ?? false) : maxHistoryInDays >= keyRangeToDays[key], + granularity: key === 'day' ? 'hour' : keyRangeToDays[key] <= 30 ? 'day' : 'week', + })); + } } + +type DateRange = { + key: 'day' | 'week' | '2weeks' | 'month' | 'quarter' | '6months' | 'year'; + licensed: boolean; + granularity: 'hour' | 'day' | 'week'; +}; diff --git a/packages/cli/src/modules/module-registry.ts b/packages/cli/src/modules/module-registry.ts index bdb7dc5abb..3609f3a6d5 100644 --- a/packages/cli/src/modules/module-registry.ts +++ b/packages/cli/src/modules/module-registry.ts @@ -1,6 +1,6 @@ import { LicenseState, Logger } from '@n8n/backend-common'; import { LifecycleMetadata, ModuleMetadata } from '@n8n/decorators'; -import type { LifecycleContext, EntityClass } from '@n8n/decorators'; +import type { LifecycleContext, EntityClass, ModuleSettings } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import type { ExecutionLifecycleHooks } from 'n8n-core'; import type { @@ -17,6 +17,8 @@ import type { export class ModuleRegistry { readonly entities: EntityClass[] = []; + readonly settings: Map = new Map(); + constructor( private readonly moduleMetadata: ModuleMetadata, private readonly lifecycleMetadata: LifecycleMetadata, @@ -25,17 +27,25 @@ export class ModuleRegistry { ) {} async initModules() { - for (const { class: ModuleClass, licenseFlag } of this.moduleMetadata.getEntries()) { + for (const [moduleName, moduleEntry] of this.moduleMetadata.getEntries()) { + const { licenseFlag, class: ModuleClass } = moduleEntry; + if (licenseFlag && !this.licenseState.isLicensed(licenseFlag)) { - this.logger.debug(`Skipped init for unlicensed module "${ModuleClass.name}"`); + this.logger.debug(`Skipped init for unlicensed module "${moduleName}"`); continue; } await Container.get(ModuleClass).init?.(); + + const moduleSettings = await Container.get(ModuleClass).settings?.(); + + if (!moduleSettings) continue; + + this.settings.set(moduleName, moduleSettings); } } addEntities() { - for (const { class: ModuleClass } of this.moduleMetadata.getEntries()) { + for (const ModuleClass of this.moduleMetadata.getClasses()) { const entities = Container.get(ModuleClass).entities?.(); if (!entities || entities.length === 0) continue; diff --git a/packages/cli/src/server.ts b/packages/cli/src/server.ts index e4ac66b602..95dee5870b 100644 --- a/packages/cli/src/server.ts +++ b/packages/cli/src/server.ts @@ -271,6 +271,12 @@ export class Server extends AbstractServer { ResponseHelper.send(async () => frontendService.getSettings()), ); + // Returns settings for all loaded modules + this.app.get( + `/${this.restEndpoint}/module-settings`, + ResponseHelper.send(async () => frontendService.getModuleSettings()), + ); + // Return Sentry config as a static file this.app.get(`/${this.restEndpoint}/sentry.js`, (_, res) => { res.type('js'); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index e68bf11255..38be3a8e67 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -17,7 +17,7 @@ import { CredentialsOverwrites } from '@/credentials-overwrites'; import { getLdapLoginLabel } from '@/ldap.ee/helpers.ee'; import { License } from '@/license'; import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; -import { getAvailableDateRanges as getInsightsAvailableDateRanges } from '@/modules/insights/insights-helpers'; +import { ModuleRegistry } from '@/modules/module-registry'; import { ModulesConfig } from '@/modules/modules.config'; import { isApiEnabled } from '@/public-api'; import { PushConfig } from '@/push/push.config'; @@ -53,6 +53,7 @@ export class FrontendService { private readonly pushConfig: PushConfig, private readonly binaryDataConfig: BinaryDataConfig, private readonly licenseState: LicenseState, + private readonly moduleRegistry: ModuleRegistry, ) { loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes()); void this.generateTypes(); @@ -252,15 +253,10 @@ export class FrontendService { folders: { enabled: false, }, - insights: { - enabled: this.modulesConfig.modules.includes('insights'), - summary: true, - dashboard: false, - dateRanges: [], - }, evaluation: { quota: this.licenseState.getMaxWorkflowsWithEvaluations(), }, + loadedModules: this.modulesConfig.modules, }; } @@ -388,13 +384,6 @@ export class FrontendService { this.settings.aiCredits.credits = this.license.getAiCredits(); } - Object.assign(this.settings.insights, { - enabled: this.modulesConfig.loadedModules.has('insights'), - summary: this.licenseState.isInsightsSummaryLicensed(), - dashboard: this.licenseState.isInsightsDashboardLicensed(), - dateRanges: getInsightsAvailableDateRanges(this.licenseState), - }); - this.settings.mfa.enabled = this.globalConfig.mfa.enabled; this.settings.executionMode = config.getEnv('executions.mode'); @@ -411,6 +400,10 @@ export class FrontendService { return this.settings; } + getModuleSettings() { + return Object.fromEntries(this.moduleRegistry.settings); + } + private writeStaticJSON(name: string, data: INodeTypeBaseDescription[] | ICredentialType[]) { const { staticCacheDir } = this.instanceSettings; const filePath = path.join(staticCacheDir, `types/${name}.json`); diff --git a/packages/cli/test/integration/shared/test-db.ts b/packages/cli/test/integration/shared/test-db.ts index eb7299d9c6..4845a475ff 100644 --- a/packages/cli/test/integration/shared/test-db.ts +++ b/packages/cli/test/integration/shared/test-db.ts @@ -7,7 +7,6 @@ import { randomString } from 'n8n-workflow'; import { DbConnection } from '@/databases/db-connection'; import { DbConnectionOptions } from '@/databases/db-connection-options'; -import { ModuleRegistry } from '@/modules/module-registry'; export const testDbPrefix = 'n8n_test_'; @@ -38,8 +37,6 @@ export async function init() { const dbConnection = Container.get(DbConnection); await dbConnection.init(); await dbConnection.migrate(); - - await Container.get(ModuleRegistry).initModules(); } export function isReady() { diff --git a/packages/cli/test/integration/shared/utils/test-server.ts b/packages/cli/test/integration/shared/utils/test-server.ts index ba9610aaba..6e07991348 100644 --- a/packages/cli/test/integration/shared/utils/test-server.ts +++ b/packages/cli/test/integration/shared/utils/test-server.ts @@ -14,6 +14,7 @@ import { AUTH_COOKIE_NAME } from '@/constants'; import { ControllerRegistry } from '@/controller.registry'; import { License } from '@/license'; import { rawBodyReader, bodyParser } from '@/middlewares'; +import { ModuleRegistry } from '@/modules/module-registry'; import { PostHogClient } from '@/posthog'; import { Push } from '@/push'; import type { APIRequest } from '@/requests'; @@ -300,6 +301,7 @@ export const setupTestServer = ({ } } + await Container.get(ModuleRegistry).initModules(); Container.get(ControllerRegistry).activate(app); } }); diff --git a/packages/frontend/@n8n/rest-api-client/src/api/index.ts b/packages/frontend/@n8n/rest-api-client/src/api/index.ts index 171f8c9d80..f78c5640b9 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/index.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/index.ts @@ -12,6 +12,7 @@ export * from './orchestration'; export * from './prompts'; export * from './roles'; export * from './settings'; +export * from './module-settings'; export * from './sso'; export * from './ui'; export * from './versions'; diff --git a/packages/frontend/@n8n/rest-api-client/src/api/module-settings.ts b/packages/frontend/@n8n/rest-api-client/src/api/module-settings.ts new file mode 100644 index 0000000000..5b53712054 --- /dev/null +++ b/packages/frontend/@n8n/rest-api-client/src/api/module-settings.ts @@ -0,0 +1,8 @@ +import type { FrontendModuleSettings } from '@n8n/api-types'; + +import type { IRestApiContext } from '../types'; +import { makeRestApiRequest } from '../utils'; + +export async function getModuleSettings(context: IRestApiContext): Promise { + return await makeRestApiRequest(context, 'GET', '/module-settings'); +} diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index a1cc98eec9..e8398f4071 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -145,21 +145,8 @@ export const defaultSettings: FrontendSettings = { folders: { enabled: false, }, - insights: { - enabled: false, - summary: true, - dashboard: false, - dateRanges: [ - { key: 'day', licensed: true, granularity: 'hour' }, - { key: 'week', licensed: true, granularity: 'day' }, - { key: '2weeks', licensed: true, granularity: 'day' }, - { key: 'month', licensed: false, granularity: 'day' }, - { key: 'quarter', licensed: false, granularity: 'week' }, - { key: '6months', licensed: false, granularity: 'week' }, - { key: 'year', licensed: false, granularity: 'week' }, - ], - }, evaluation: { quota: 0, }, + loadedModules: [], }; diff --git a/packages/frontend/editor-ui/src/components/MainSidebar.vue b/packages/frontend/editor-ui/src/components/MainSidebar.vue index 40f157b58c..156663bed0 100644 --- a/packages/frontend/editor-ui/src/components/MainSidebar.vue +++ b/packages/frontend/editor-ui/src/components/MainSidebar.vue @@ -112,7 +112,7 @@ const mainMenuItems = computed(() => [ position: 'bottom', route: { to: { name: VIEWS.INSIGHTS } }, available: - settingsStore.settings.insights.enabled && + settingsStore.settings.loadedModules.includes('insights') && hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }), }, { diff --git a/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.test.ts b/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.test.ts index 60b097a09b..d2e42a48ae 100644 --- a/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.test.ts +++ b/packages/frontend/editor-ui/src/features/insights/components/InsightsDashboard.test.ts @@ -13,9 +13,25 @@ const renderComponent = createComponentRenderer(InsightsDashboard, { }, }); +const moduleSettings = { + insights: { + summary: true, + dashboard: true, + dateRanges: [ + { + key: 'day', + licensed: true, + granularity: 'hour', + }, + ], + }, +}; + describe('InsightsDashboard', () => { beforeEach(() => { - createTestingPinia({ initialState: { settings: { settings: defaultSettings } } }); + createTestingPinia({ + initialState: { settings: { settings: defaultSettings, moduleSettings } }, + }); }); it('should render without error', () => { diff --git a/packages/frontend/editor-ui/src/features/insights/insights.store.ts b/packages/frontend/editor-ui/src/features/insights/insights.store.ts index 740960a0ff..bc6150164a 100644 --- a/packages/frontend/editor-ui/src/features/insights/insights.store.ts +++ b/packages/frontend/editor-ui/src/features/insights/insights.store.ts @@ -18,8 +18,13 @@ export const useInsightsStore = defineStore('insights', () => { () => getResourcePermissions(usersStore.currentUser?.globalScopes).insights, ); - const isInsightsEnabled = computed(() => settingsStore.settings.insights.enabled); - const isDashboardEnabled = computed(() => settingsStore.settings.insights.dashboard); + const isInsightsEnabled = computed(() => + settingsStore.settings.loadedModules.includes('insights'), + ); + + const isDashboardEnabled = computed( + () => settingsStore.moduleSettings.insights?.dashboard ?? false, + ); const isSummaryEnabled = computed( () => globalInsightsPermissions.value.list && isInsightsEnabled.value, @@ -64,7 +69,7 @@ export const useInsightsStore = defineStore('insights', () => { { immediate: false, resetOnExecute: false }, ); - const dateRanges = computed(() => settingsStore.settings.insights.dateRanges); + const dateRanges = computed(() => settingsStore.moduleSettings.insights?.dateRanges ?? []); return { globalInsightsPermissions, diff --git a/packages/frontend/editor-ui/src/stores/settings.store.ts b/packages/frontend/editor-ui/src/stores/settings.store.ts index 3b45c0edf8..919eb7bc8e 100644 --- a/packages/frontend/editor-ui/src/stores/settings.store.ts +++ b/packages/frontend/editor-ui/src/stores/settings.store.ts @@ -1,9 +1,14 @@ import { computed, ref } from 'vue'; import Bowser from 'bowser'; -import type { IUserManagementSettings, FrontendSettings } from '@n8n/api-types'; +import type { + IUserManagementSettings, + FrontendSettings, + FrontendModuleSettings, +} from '@n8n/api-types'; import * as eventsApi from '@n8n/rest-api-client/api/events'; import * as settingsApi from '@n8n/rest-api-client/api/settings'; +import * as moduleSettingsApi from '@n8n/rest-api-client/api/module-settings'; import * as promptsApi from '@n8n/rest-api-client/api/prompts'; import { testHealthEndpoint } from '@/api/templates'; import { @@ -26,6 +31,7 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const i18n = useI18n(); const initialized = ref(false); const settings = ref({} as FrontendSettings); + const moduleSettings = ref({}); const userManagement = ref({ quota: -1, showSetupOnFirstLoad: false, @@ -178,6 +184,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { const isDevRelease = computed(() => settings.value.releaseChannel === 'dev'); + const loadedModules = computed(() => settings.value.loadedModules); + const setSettings = (newSettings: FrontendSettings) => { settings.value = newSettings; userManagement.value = newSettings.userManagement; @@ -325,6 +333,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { settings.value = {} as FrontendSettings; }; + const getModuleSettings = async () => { + const fetched = await moduleSettingsApi.getModuleSettings(useRootStore().restApiContext); + moduleSettings.value = fetched; + }; + /** * (Experimental) Minimum zoom level of the canvas to render node settings in place of nodes, without opening NDV */ @@ -402,5 +415,8 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => { getSettings, setSettings, initialize, + loadedModules, + getModuleSettings, + moduleSettings, }; }); diff --git a/packages/frontend/editor-ui/src/views/SigninView.test.ts b/packages/frontend/editor-ui/src/views/SigninView.test.ts index 28ae6a8488..fada4fea21 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.test.ts +++ b/packages/frontend/editor-ui/src/views/SigninView.test.ts @@ -46,6 +46,7 @@ let telemetry: ReturnType; describe('SigninView', () => { const signInWithValidUser = async () => { settingsStore.isCloudDeployment = false; + settingsStore.loadedModules = []; usersStore.loginWithCreds.mockResolvedValueOnce(); const { getByRole, queryByTestId, container } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/views/SigninView.vue b/packages/frontend/editor-ui/src/views/SigninView.vue index 9b4d7919bd..2d037e8fe8 100644 --- a/packages/frontend/editor-ui/src/views/SigninView.vue +++ b/packages/frontend/editor-ui/src/views/SigninView.vue @@ -144,6 +144,11 @@ const login = async (form: LoginRequestDto) => { } } await settingsStore.getSettings(); + + if (settingsStore.loadedModules.length > 0) { + await settingsStore.getModuleSettings(); + } + toast.clearAllStickyNotifications(); telemetry.track('User attempted to login', {