mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
feat(editor): Add published workflow count to dynamic banners (no-changelog) (#32619)
This commit is contained in:
parent
38dae742b5
commit
de9f3ef2db
|
|
@ -116,6 +116,9 @@ export interface FrontendSettings {
|
|||
dynamicBanners: {
|
||||
endpoint: string;
|
||||
enabled: boolean;
|
||||
filters: {
|
||||
publishedWorkflowCount: number;
|
||||
};
|
||||
};
|
||||
instanceId: string;
|
||||
telemetry: ITelemetrySettings;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { In, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { In, IsNull, Not, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
|
|
@ -576,6 +576,22 @@ describe('WorkflowRepository', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getPublishedCount', () => {
|
||||
it('should count non-archived workflows with an active version', async () => {
|
||||
const countSpy = vi.spyOn(workflowRepository, 'count').mockResolvedValue(7);
|
||||
|
||||
const result = await workflowRepository.getPublishedCount();
|
||||
|
||||
expect(result).toBe(7);
|
||||
expect(countSpy).toHaveBeenCalledWith({
|
||||
where: {
|
||||
activeVersionId: Not(IsNull()),
|
||||
isArchived: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByCredentialResolverId', () => {
|
||||
it('should use PostgreSQL JSON operator for postgresdb', async () => {
|
||||
const workflows = [{ id: 'wf-1', name: 'Workflow 1' }] as WorkflowEntity[];
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||
});
|
||||
}
|
||||
|
||||
async getPublishedCount() {
|
||||
return await this.count({
|
||||
where: { activeVersionId: Not(IsNull()), isArchived: false },
|
||||
});
|
||||
}
|
||||
|
||||
async getPublishedPersonalWorkflowsCount(): Promise<number> {
|
||||
return await this.createQueryBuilder('workflow')
|
||||
.innerJoin('workflow.shared', 'shared')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import type { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import type { WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { BinaryDataConfig, InstanceSettings } from 'n8n-core';
|
||||
|
|
@ -43,6 +44,10 @@ describe('FrontendService', () => {
|
|||
whatsNewEndpoint: '',
|
||||
infoUrl: '',
|
||||
},
|
||||
dynamicBanners: {
|
||||
endpoint: 'https://api.n8n.io/api/banners',
|
||||
enabled: true,
|
||||
},
|
||||
personalization: { enabled: false },
|
||||
defaultLocale: 'en',
|
||||
auth: { cookie: { secure: false } },
|
||||
|
|
@ -179,6 +184,10 @@ describe('FrontendService', () => {
|
|||
getAiUsageSettings: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
|
||||
const workflowRepository = mock<WorkflowRepository>({
|
||||
getPublishedCount: jest.fn().mockResolvedValue(7),
|
||||
});
|
||||
|
||||
const createMockService = () => {
|
||||
Container.set(
|
||||
CommunityPackagesConfig,
|
||||
|
|
@ -206,6 +215,7 @@ describe('FrontendService', () => {
|
|||
mfaService,
|
||||
ownershipService,
|
||||
aiUsageService,
|
||||
workflowRepository,
|
||||
),
|
||||
license,
|
||||
};
|
||||
|
|
@ -214,9 +224,11 @@ describe('FrontendService', () => {
|
|||
beforeEach(() => {
|
||||
originalEnv = process.env;
|
||||
jest.clearAllMocks();
|
||||
globalConfig.diagnostics.enabled = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
|
|
@ -232,6 +244,78 @@ describe('FrontendService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should cache dynamic banner filters for 30 seconds', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-01-01T00:00:00.000Z') });
|
||||
globalConfig.diagnostics.enabled = true;
|
||||
globalConfig.diagnostics.frontendConfig = 'key;http://localhost';
|
||||
workflowRepository.getPublishedCount.mockResolvedValueOnce(7).mockResolvedValueOnce(8);
|
||||
|
||||
const { service } = createMockService();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings.dynamicBanners.filters).toEqual({
|
||||
publishedWorkflowCount: 7,
|
||||
});
|
||||
expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(29_999);
|
||||
const cachedSettings = await service.getSettings();
|
||||
|
||||
expect(cachedSettings.dynamicBanners.filters).toEqual({
|
||||
publishedWorkflowCount: 7,
|
||||
});
|
||||
expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(1);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
const refreshedSettings = await service.getSettings();
|
||||
|
||||
expect(refreshedSettings.dynamicBanners.filters).toEqual({
|
||||
publishedWorkflowCount: 8,
|
||||
});
|
||||
expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fall back when dynamic banner filters cannot be loaded', async () => {
|
||||
globalConfig.diagnostics.enabled = true;
|
||||
globalConfig.diagnostics.frontendConfig = 'key;http://localhost';
|
||||
workflowRepository.getPublishedCount.mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
const { service } = createMockService();
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings.dynamicBanners.filters).toEqual({
|
||||
publishedWorkflowCount: 0,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to fetch published workflow count for dynamic banners',
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to the last published workflow count when refresh fails', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-01-01T00:00:00.000Z') });
|
||||
globalConfig.diagnostics.enabled = true;
|
||||
globalConfig.diagnostics.frontendConfig = 'key;http://localhost';
|
||||
workflowRepository.getPublishedCount
|
||||
.mockResolvedValueOnce(7)
|
||||
.mockRejectedValueOnce(new Error('database unavailable'));
|
||||
|
||||
const { service } = createMockService();
|
||||
await service.getSettings();
|
||||
jest.advanceTimersByTime(30_000);
|
||||
|
||||
const settings = await service.getSettings();
|
||||
|
||||
expect(settings.dynamicBanners.filters).toEqual({
|
||||
publishedWorkflowCount: 7,
|
||||
});
|
||||
expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(2);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to fetch published workflow count for dynamic banners',
|
||||
expect.objectContaining({ error: expect.any(Error) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should surface logStreaming.managedByEnv from instanceSettingsLoader config', async () => {
|
||||
globalConfig.instanceSettingsLoader = {
|
||||
logStreamingManagedByEnv: true,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types';
|
||||
import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { GlobalConfig, SecurityConfig } from '@n8n/config';
|
||||
import { LICENSE_FEATURES, LICENSE_QUOTAS } from '@n8n/constants';
|
||||
import { LICENSE_FEATURES, LICENSE_QUOTAS, Time } from '@n8n/constants';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
|
|
@ -33,6 +34,8 @@ import {
|
|||
import { AiUsageService } from './ai-usage.service';
|
||||
import { UrlService } from './url.service';
|
||||
|
||||
const DYNAMIC_BANNER_FILTERS_CACHE_TTL = 30 * Time.seconds.toMilliseconds;
|
||||
|
||||
/**
|
||||
* IMPORTANT: Only add settings that are absolutely necessary for non-authenticated pages
|
||||
*/
|
||||
|
|
@ -111,6 +114,10 @@ export class FrontendService {
|
|||
|
||||
private communityPackagesService?: CommunityPackagesService;
|
||||
|
||||
private publishedWorkflowCountCache?: { value: number; expiresAt: number };
|
||||
|
||||
private publishedWorkflowCountRequest?: Promise<number>;
|
||||
|
||||
constructor(
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly logger: Logger,
|
||||
|
|
@ -129,6 +136,7 @@ export class FrontendService {
|
|||
private readonly mfaService: MfaService,
|
||||
private readonly ownershipService: OwnershipService,
|
||||
private readonly aiUsageService: AiUsageService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
) {
|
||||
loadNodesAndCredentials.addPostProcessor(async () => await this.generateTypes());
|
||||
void this.generateTypes();
|
||||
|
|
@ -238,6 +246,9 @@ export class FrontendService {
|
|||
dynamicBanners: {
|
||||
endpoint: this.globalConfig.dynamicBanners.endpoint,
|
||||
enabled: this.globalConfig.dynamicBanners.enabled && this.globalConfig.diagnostics.enabled,
|
||||
filters: {
|
||||
publishedWorkflowCount: 0,
|
||||
},
|
||||
},
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
telemetry: telemetrySettings,
|
||||
|
|
@ -439,6 +450,8 @@ export class FrontendService {
|
|||
oauth2: `${instanceBaseUrl}/${restEndpoint}/oauth2-credential/callback`,
|
||||
};
|
||||
this.settings.jwksUri = `${instanceBaseUrl}/${restEndpoint}/.well-known/jwks.json`;
|
||||
this.settings.dynamicBanners.filters.publishedWorkflowCount =
|
||||
await this.getPublishedWorkflowCountForDynamicBanners();
|
||||
|
||||
// refresh user management status
|
||||
Object.assign(this.settings.userManagement, {
|
||||
|
|
@ -594,6 +607,34 @@ export class FrontendService {
|
|||
return this.settings;
|
||||
}
|
||||
|
||||
private async getPublishedWorkflowCountForDynamicBanners(): Promise<number> {
|
||||
if (!this.settings.dynamicBanners.enabled) return 0;
|
||||
|
||||
const now = Date.now();
|
||||
if (this.publishedWorkflowCountCache && this.publishedWorkflowCountCache.expiresAt > now) {
|
||||
return this.publishedWorkflowCountCache.value;
|
||||
}
|
||||
|
||||
try {
|
||||
this.publishedWorkflowCountRequest ??= this.workflowRepository
|
||||
.getPublishedCount()
|
||||
.finally(() => {
|
||||
this.publishedWorkflowCountRequest = undefined;
|
||||
});
|
||||
|
||||
const value = await this.publishedWorkflowCountRequest;
|
||||
this.publishedWorkflowCountCache = {
|
||||
value,
|
||||
expiresAt: Date.now() + DYNAMIC_BANNER_FILTERS_CACHE_TTL,
|
||||
};
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to fetch published workflow count for dynamic banners', { error });
|
||||
return this.publishedWorkflowCountCache?.value ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Only add settings that are absolutely necessary for non-authenticated pages
|
||||
* @returns Public settings for unauthenticated users
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ type DynamicBannerFilters = {
|
|||
deploymentType: string;
|
||||
planName?: string;
|
||||
instanceId: string;
|
||||
userId?: string;
|
||||
userCreatedAt?: string;
|
||||
isOwner?: boolean;
|
||||
role?: Role;
|
||||
publishedWorkflowCount?: number;
|
||||
};
|
||||
|
||||
export async function getDynamicBanners(
|
||||
|
|
|
|||
|
|
@ -188,5 +188,8 @@ export const defaultSettings: FrontendSettings = {
|
|||
dynamicBanners: {
|
||||
endpoint: 'https://api.n8n.io/api/banners',
|
||||
enabled: true,
|
||||
filters: {
|
||||
publishedWorkflowCount: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,21 +2,27 @@ import { createPinia, setActivePinia } from 'pinia';
|
|||
import { useBannersStore } from '@/features/shared/banners/banners.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import * as dynamicBannersApi from '@n8n/rest-api-client/api/dynamic-banners';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
|
||||
let bannersStore: ReturnType<typeof useBannersStore>;
|
||||
let settingsStore: ReturnType<typeof useSettingsStore>;
|
||||
let usersStore: ReturnType<typeof useUsersStore>;
|
||||
|
||||
describe('Banners store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
bannersStore = useBannersStore();
|
||||
settingsStore = useSettingsStore();
|
||||
usersStore = useUsersStore();
|
||||
|
||||
// Set up settings store with required configuration
|
||||
settingsStore.settings = {
|
||||
dynamicBanners: {
|
||||
endpoint: 'https://test.endpoint.com',
|
||||
enabled: false,
|
||||
filters: {
|
||||
publishedWorkflowCount: 0,
|
||||
},
|
||||
},
|
||||
banners: {
|
||||
dismissed: [],
|
||||
|
|
@ -24,6 +30,10 @@ describe('Banners store', () => {
|
|||
} as unknown as typeof settingsStore.settings;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should add non-production license banner to stack based on enterprise settings', () => {
|
||||
bannersStore.loadStaticBanners({
|
||||
banners: ['NON_PRODUCTION_LICENSE'],
|
||||
|
|
@ -83,6 +93,9 @@ describe('Banners store', () => {
|
|||
dynamicBanners: {
|
||||
endpoint: 'https://test.endpoint.com',
|
||||
enabled: true,
|
||||
filters: {
|
||||
publishedWorkflowCount: 2,
|
||||
},
|
||||
},
|
||||
banners: {
|
||||
dismissed: ['dynamic-banner-2'],
|
||||
|
|
@ -100,4 +113,43 @@ describe('Banners store', () => {
|
|||
|
||||
expect(freshBannersStore.bannerStack).not.toContain('dynamic-banner-2');
|
||||
});
|
||||
|
||||
it('should send dynamic banner filters as flat query params', async () => {
|
||||
const getDynamicBannersSpy = vi
|
||||
.spyOn(dynamicBannersApi, 'getDynamicBanners')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
settingsStore.settings = {
|
||||
versionCli: '1.2.3',
|
||||
deployment: { type: 'cloud' },
|
||||
instanceId: 'instance-id',
|
||||
license: { planName: 'Pro' },
|
||||
dynamicBanners: {
|
||||
endpoint: 'https://test.endpoint.com',
|
||||
enabled: true,
|
||||
filters: {
|
||||
publishedWorkflowCount: 4,
|
||||
},
|
||||
},
|
||||
banners: {
|
||||
dismissed: [],
|
||||
},
|
||||
} as unknown as typeof settingsStore.settings;
|
||||
usersStore.addUsers([{ id: 'current-user-id' }]);
|
||||
usersStore.currentUserId = 'current-user-id';
|
||||
|
||||
await bannersStore.loadDynamicBanners();
|
||||
|
||||
expect(getDynamicBannersSpy).toHaveBeenCalledWith(
|
||||
'https://test.endpoint.com',
|
||||
expect.objectContaining({
|
||||
version: '1.2.3',
|
||||
deploymentType: 'cloud',
|
||||
instanceId: 'instance-id',
|
||||
planName: 'Pro',
|
||||
userId: 'current-user-id',
|
||||
publishedWorkflowCount: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,9 +48,12 @@ export const useBannersStore = defineStore(STORES.BANNERS, () => {
|
|||
deploymentType,
|
||||
instanceId: settingsStore.settings.instanceId,
|
||||
planName: settingsStore.settings.license?.planName,
|
||||
userId: usersStore.currentUser?.id,
|
||||
userCreatedAt: usersStore.currentUser?.createdAt,
|
||||
isOwner: usersStore.currentUser?.isOwner,
|
||||
role: usersStore.currentUser?.role,
|
||||
publishedWorkflowCount:
|
||||
settingsStore.settings.dynamicBanners.filters.publishedWorkflowCount,
|
||||
})
|
||||
).map((item) => ({
|
||||
...item,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user