mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor(core): Decouple module settings from frontend service (#16324)
Co-authored-by: Danny Martini <danny@n8n.io>
This commit is contained in:
@@ -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;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,7 +36,6 @@ export {
|
||||
type InsightsByWorkflow,
|
||||
type InsightsByTime,
|
||||
type InsightsDateRange,
|
||||
INSIGHTS_DATE_RANGE_KEYS,
|
||||
} from './schemas/insights.schema';
|
||||
|
||||
export {
|
||||
|
||||
@@ -85,18 +85,9 @@ export const insightsByTimeDataSchemas = {
|
||||
export const insightsByTimeSchema = z.object(insightsByTimeDataSchemas).strict();
|
||||
export type InsightsByTime = z.infer<typeof insightsByTimeSchema>;
|
||||
|
||||
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']),
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { ModuleInterface, BackendModule, EntityClass } from './module';
|
||||
export { ModuleInterface, BackendModule, EntityClass, ModuleSettings } from './module';
|
||||
export { ModuleMetadata } from './module-metadata';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,12 @@ export interface BaseEntity {
|
||||
|
||||
export type EntityClass = new () => BaseEntity;
|
||||
|
||||
export type ModuleSettings = Record<string, unknown>;
|
||||
|
||||
export interface ModuleInterface {
|
||||
init?(): void | Promise<void>;
|
||||
init?(): Promise<void>;
|
||||
entities?(): EntityClass[];
|
||||
settings?(): Promise<ModuleSettings>;
|
||||
}
|
||||
|
||||
export type ModuleClass = Constructable<ModuleInterface>;
|
||||
@@ -28,9 +31,9 @@ export type ModuleClass = Constructable<ModuleInterface>;
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<LicenseState>;
|
||||
let insightsService: InsightsService;
|
||||
|
||||
beforeAll(() => {
|
||||
licenseMock = mock<LicenseState>();
|
||||
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' },
|
||||
|
||||
@@ -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<InsightsDateRange['key'], number> = {
|
||||
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',
|
||||
}));
|
||||
}
|
||||
19
packages/cli/src/modules/insights/insights.constants.ts
Normal file
19
packages/cli/src/modules/insights/insights.constants.ts
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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<string, ModuleSettings> = 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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<FrontendModuleSettings> {
|
||||
return await makeRestApiRequest(context, 'GET', '/module-settings');
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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' } }),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<FrontendSettings>({} as FrontendSettings);
|
||||
const moduleSettings = ref<FrontendModuleSettings>({});
|
||||
const userManagement = ref<IUserManagementSettings>({
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ let telemetry: ReturnType<typeof useTelemetry>;
|
||||
describe('SigninView', () => {
|
||||
const signInWithValidUser = async () => {
|
||||
settingsStore.isCloudDeployment = false;
|
||||
settingsStore.loadedModules = [];
|
||||
usersStore.loginWithCreds.mockResolvedValueOnce();
|
||||
|
||||
const { getByRole, queryByTestId, container } = renderComponent();
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user