diff --git a/frontend/src/component/banners/internalBanners/InternalBanners.tsx b/frontend/src/component/banners/internalBanners/InternalBanners.tsx index 1574cb2eef24..f9c479fe930a 100644 --- a/frontend/src/component/banners/internalBanners/InternalBanners.tsx +++ b/frontend/src/component/banners/internalBanners/InternalBanners.tsx @@ -1,8 +1,19 @@ import { Banner } from 'component/banners/Banner/Banner'; import { useBanners } from 'hooks/api/getters/useBanners/useBanners'; +import { useUiFlag } from '../../../hooks/useUiFlag'; +import { ConditionallyRender } from '../../common/ConditionallyRender/ConditionallyRender'; +import { IBanner } from '../../../interfaces/banner'; export const InternalBanners = () => { const { banners } = useBanners(); + const displayUpgradeEdgeBanner = useUiFlag('displayUpgradeEdgeBanner'); + + const upgradeEdgeBanner: IBanner = { + message: `We noticed that you're using an outdated Unleash Edge. To ensure you continue to receive metrics, we recommend upgrading to v17.0.0 or later.`, + link: 'https://github.com/Unleash/unleash-edge', + linkText: 'Get latest', + variant: 'warning', + }; return ( <> @@ -11,6 +22,10 @@ export const InternalBanners = () => { .map((banner) => ( ))} + } + /> ); }; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 64805b24b726..3562790a0d6a 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -79,6 +79,7 @@ export type UiFlags = { executiveDashboard?: boolean; changeRequestConflictHandling?: boolean; feedbackComments?: Variant; + displayUpgradeEdgeBanner?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/db/client-instance-store.ts b/src/lib/db/client-instance-store.ts index 00fb718b93fe..b1d7847b0c94 100644 --- a/src/lib/db/client-instance-store.ts +++ b/src/lib/db/client-instance-store.ts @@ -166,6 +166,15 @@ export default class ClientInstanceStore implements IClientInstanceStore { return rows.map(mapRow); } + async getBySdkName(sdkName: string): Promise { + const rows = await this.db + .select() + .from(TABLE) + .whereLike('sdk_version', `${sdkName}%`) + .orderBy('last_seen', 'desc'); + return rows.map(mapRow); + } + async getDistinctApplications(): Promise { const rows = await this.db .distinct('app_name') diff --git a/src/lib/features/metrics/instance/instance-service.ts b/src/lib/features/metrics/instance/instance-service.ts index 309de68a288c..0ee1d039a4ad 100644 --- a/src/lib/features/metrics/instance/instance-service.ts +++ b/src/lib/features/metrics/instance/instance-service.ts @@ -21,6 +21,7 @@ import { IPrivateProjectChecker } from '../../private-project/privateProjectChec import { IFlagResolver, SYSTEM_USER } from '../../../types'; import { ALL_PROJECTS } from '../../../util'; import { Logger } from '../../../logger'; +import { SemVer } from 'semver'; export default class ClientInstanceService { apps = {}; @@ -224,4 +225,20 @@ export default class ClientInstanceService { async removeInstancesOlderThanTwoDays(): Promise { return this.clientInstanceStore.removeInstancesOlderThanTwoDays(); } + + async usesSdkOlderThan( + sdkName: string, + sdkVersion: string, + ): Promise { + const semver = new SemVer(sdkVersion); + const instancesOfSdk = + await this.clientInstanceStore.getBySdkName(sdkName); + return instancesOfSdk.some((instance) => { + if (instance.sdkVersion) { + const [_sdkName, sdkVersion] = instance.sdkVersion.split(':'); + const instanceUsedSemver = new SemVer(sdkVersion); + return instanceUsedSemver < semver; + } + }); + } } diff --git a/src/lib/routes/admin-api/config.test.ts b/src/lib/routes/admin-api/config.test.ts index 562de5eba1dc..44e86be8c6b9 100644 --- a/src/lib/routes/admin-api/config.test.ts +++ b/src/lib/routes/admin-api/config.test.ts @@ -9,6 +9,7 @@ import { DEFAULT_STRATEGY_SEGMENTS_LIMIT, } from '../../util/segments'; import TestAgent from 'supertest/lib/agent'; +import { IUnleashStores } from '../../types'; const uiConfig = { headerBackground: 'red', @@ -28,17 +29,20 @@ async function getSetup() { return { base, + stores, request: supertest(app), }; } let request: TestAgent; let base: string; +let stores: IUnleashStores; beforeEach(async () => { const setup = await getSetup(); request = setup.request; base = setup.base; + stores = setup.stores; }); test('should get ui config', async () => { @@ -52,3 +56,45 @@ test('should get ui config', async () => { expect(body.segmentValuesLimit).toEqual(DEFAULT_SEGMENT_VALUES_LIMIT); expect(body.strategySegmentsLimit).toEqual(DEFAULT_STRATEGY_SEGMENTS_LIMIT); }); + +describe('displayUpgradeEdgeBanner', () => { + test('ui config should have displayUpgradeEdgeBanner to be set if an instance using edge has been seen', async () => { + await stores.clientInstanceStore.insert({ + appName: 'my-app', + instanceId: 'some-instance', + sdkVersion: 'unleash-edge:16.0.0', + }); + const { body } = await request + .get(`${base}/api/admin/ui-config`) + .expect('Content-Type', /json/) + .expect(200); + expect(body.flags).toBeTruthy(); + expect(body.flags.displayUpgradeEdgeBanner).toBeTruthy(); + }); + test('ui config should not get displayUpgradeEdgeBanner flag if edge >= 17.0.0 has been seen', async () => { + await stores.clientInstanceStore.insert({ + appName: 'my-app', + instanceId: 'some-instance', + sdkVersion: 'unleash-edge:17.1.0', + }); + const { body } = await request + .get(`${base}/api/admin/ui-config`) + .expect('Content-Type', /json/) + .expect(200); + expect(body.flags).toBeTruthy(); + expect(body.flags.displayUpgradeEdgeBanner).toEqual(false); + }); + test('ui config should not get displayUpgradeEdgeBanner flag if java-client has been seen', async () => { + await stores.clientInstanceStore.insert({ + appName: 'my-app', + instanceId: 'some-instance', + sdkVersion: 'unleash-client-java:9.1.0', + }); + const { body } = await request + .get(`${base}/api/admin/ui-config`) + .expect('Content-Type', /json/) + .expect(200); + expect(body.flags).toBeTruthy(); + expect(body.flags.displayUpgradeEdgeBanner).toEqual(false); + }); +}); diff --git a/src/lib/routes/admin-api/config.ts b/src/lib/routes/admin-api/config.ts index 9f8cf0ee53a2..837451e44c72 100644 --- a/src/lib/routes/admin-api/config.ts +++ b/src/lib/routes/admin-api/config.ts @@ -26,6 +26,9 @@ import { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema'; import { createRequestSchema } from '../../openapi/util/create-request-schema'; import { ProxyService } from '../../services'; import MaintenanceService from '../../features/maintenance/maintenance-service'; +import memoizee from 'memoizee'; +import { minutesToMilliseconds } from 'date-fns'; +import ClientInstanceService from '../../features/metrics/instance/instance-service'; class ConfigController extends Controller { private versionService: VersionService; @@ -36,8 +39,12 @@ class ConfigController extends Controller { private emailService: EmailService; + private clientInstanceService: ClientInstanceService; + private maintenanceService: MaintenanceService; + private usesOldEdgeFunction: () => Promise; + private readonly openApiService: OpenApiService; constructor( @@ -49,6 +56,7 @@ class ConfigController extends Controller { openApiService, proxyService, maintenanceService, + clientInstanceService, }: Pick< IUnleashServices, | 'versionService' @@ -57,6 +65,7 @@ class ConfigController extends Controller { | 'openApiService' | 'proxyService' | 'maintenanceService' + | 'clientInstanceService' >, ) { super(config); @@ -66,6 +75,18 @@ class ConfigController extends Controller { this.openApiService = openApiService; this.proxyService = proxyService; this.maintenanceService = maintenanceService; + this.clientInstanceService = clientInstanceService; + this.usesOldEdgeFunction = memoizee( + async () => + this.clientInstanceService.usesSdkOlderThan( + 'unleash-edge', + '17.0.0', + ), + { + promise: true, + maxAge: minutesToMilliseconds(10), + }, + ); this.route({ method: 'get', @@ -109,14 +130,17 @@ class ConfigController extends Controller { req: AuthedRequest, res: Response, ): Promise { - const [frontendSettings, simpleAuthSettings, maintenanceMode] = - await Promise.all([ - this.proxyService.getFrontendSettings(false), - this.settingService.get( - simpleAuthSettingsKey, - ), - this.maintenanceService.isMaintenanceMode(), - ]); + const [ + frontendSettings, + simpleAuthSettings, + maintenanceMode, + usesOldEdge, + ] = await Promise.all([ + this.proxyService.getFrontendSettings(false), + this.settingService.get(simpleAuthSettingsKey), + this.maintenanceService.isMaintenanceMode(), + this.usesOldEdgeFunction(), + ]); const disablePasswordAuth = simpleAuthSettings?.disabled || @@ -126,7 +150,11 @@ class ConfigController extends Controller { email: req.user.email, }); - const flags = { ...this.config.ui.flags, ...expFlags }; + const flags = { + ...this.config.ui.flags, + ...expFlags, + displayUpgradeEdgeBanner: usesOldEdge, + }; const response: UiConfigSchema = { ...this.config.ui, diff --git a/src/lib/types/stores/client-instance-store.ts b/src/lib/types/stores/client-instance-store.ts index bae30dc46ff0..eb4857da2415 100644 --- a/src/lib/types/stores/client-instance-store.ts +++ b/src/lib/types/stores/client-instance-store.ts @@ -21,6 +21,7 @@ export interface IClientInstanceStore setLastSeen(INewClientInstance): Promise; insert(details: INewClientInstance): Promise; getByAppName(appName: string): Promise; + getBySdkName(sdkName: string): Promise; getDistinctApplications(): Promise; getDistinctApplicationsCount(daysBefore?: number): Promise; deleteForApplication(appName: string): Promise; diff --git a/src/test/fixtures/fake-client-instance-store.ts b/src/test/fixtures/fake-client-instance-store.ts index 6d7a0cd29667..531375d6e225 100644 --- a/src/test/fixtures/fake-client-instance-store.ts +++ b/src/test/fixtures/fake-client-instance-store.ts @@ -31,6 +31,14 @@ export default class FakeClientInstanceStore implements IClientInstanceStore { return; } + async getBySdkName(sdkName: string): Promise { + return Promise.resolve( + this.instances.filter((instance) => + instance.sdkVersion?.startsWith(sdkName), + ), + ); + } + async deleteAll(): Promise { this.instances = []; }