feat: add published workflow count to dynamic banners

This commit is contained in:
Romeo Balta
2026-06-18 22:12:22 +01:00
parent 2dac96d6ed
commit d49f71a59b
9 changed files with 168 additions and 2 deletions
@@ -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[];
@@ -99,6 +99,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';
@@ -42,6 +43,10 @@ describe('FrontendService', () => {
whatsNewEndpoint: '',
infoUrl: '',
},
dynamicBanners: {
endpoint: 'https://api.n8n.io/api/banners',
enabled: true,
},
personalization: { enabled: false },
defaultLocale: 'en',
auth: { cookie: { secure: false } },
@@ -178,6 +183,10 @@ describe('FrontendService', () => {
getAiUsageSettings: jest.fn().mockResolvedValue(true),
});
const workflowRepository = mock<WorkflowRepository>({
getPublishedCount: jest.fn().mockResolvedValue(7),
});
const createMockService = () => {
Container.set(
CommunityPackagesConfig,
@@ -205,6 +214,7 @@ describe('FrontendService', () => {
mfaService,
ownershipService,
aiUsageService,
workflowRepository,
),
license,
};
@@ -213,6 +223,7 @@ describe('FrontendService', () => {
beforeEach(() => {
originalEnv = process.env;
jest.clearAllMocks();
globalConfig.diagnostics.enabled = false;
});
afterEach(() => {
@@ -231,6 +242,43 @@ describe('FrontendService', () => {
);
});
it('should include dynamic banner filters', async () => {
globalConfig.diagnostics.enabled = true;
globalConfig.diagnostics.frontendConfig = 'key;http://localhost';
const { service } = createMockService();
const settings = await service.getSettings();
expect(settings.dynamicBanners.filters).toEqual({
publishedWorkflowCount: 7,
});
expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(1);
const refreshedSettings = await service.getSettings();
expect(refreshedSettings.dynamicBanners.filters).toEqual({
publishedWorkflowCount: 7,
});
expect(workflowRepository.getPublishedCount).toHaveBeenCalledTimes(1);
});
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 surface logStreaming.managedByEnv from instanceSettingsLoader config', async () => {
globalConfig.instanceSettingsLoader = {
logStreamingManagedByEnv: true,
+42 -1
View File
@@ -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 = 5 * Time.minutes.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,
@@ -436,6 +447,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, {
@@ -591,6 +604,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
@@ -20,6 +20,7 @@ type DynamicBannerFilters = {
userCreatedAt?: string;
isOwner?: boolean;
role?: Role;
publishedWorkflowCount?: number;
};
export async function getDynamicBanners(
@@ -185,5 +185,8 @@ export const defaultSettings: FrontendSettings = {
dynamicBanners: {
endpoint: 'https://api.n8n.io/api/banners',
enabled: true,
filters: {
publishedWorkflowCount: 0,
},
},
};
@@ -17,6 +17,9 @@ describe('Banners store', () => {
dynamicBanners: {
endpoint: 'https://test.endpoint.com',
enabled: false,
filters: {
publishedWorkflowCount: 0,
},
},
banners: {
dismissed: [],
@@ -24,6 +27,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 +90,9 @@ describe('Banners store', () => {
dynamicBanners: {
endpoint: 'https://test.endpoint.com',
enabled: true,
filters: {
publishedWorkflowCount: 2,
},
},
banners: {
dismissed: ['dynamic-banner-2'],
@@ -100,4 +110,40 @@ 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;
await bannersStore.loadDynamicBanners();
expect(getDynamicBannersSpy).toHaveBeenCalledWith(
'https://test.endpoint.com',
expect.objectContaining({
version: '1.2.3',
deploymentType: 'cloud',
instanceId: 'instance-id',
planName: 'Pro',
publishedWorkflowCount: 4,
}),
);
});
});
@@ -51,6 +51,8 @@ export const useBannersStore = defineStore(STORES.BANNERS, () => {
userCreatedAt: usersStore.currentUser?.createdAt,
isOwner: usersStore.currentUser?.isOwner,
role: usersStore.currentUser?.role,
publishedWorkflowCount:
settingsStore.settings.dynamicBanners.filters.publishedWorkflowCount,
})
).map((item) => ({
...item,