From 17d826ddf46a6490fa4c1c859759dc40b0aa5cdf Mon Sep 17 00:00:00 2001 From: Christopher Kolstad Date: Wed, 24 Jan 2024 15:22:48 +0100 Subject: [PATCH] task: Add banner encouraging edge upgrade (#6018) Only triggers if there is any rows in client instances that have sdk_version: unleash-edge with version < 17.0.0 The function that checks this memoizes the check for 10 minutes to avoid scanning the client instances table too often. --- frontend/src/component/App.tsx | 2 + .../EdgeUpgradeBanner/EdgeUpgradeBanner.tsx | 22 +++++++++ frontend/src/interfaces/uiConfig.ts | 1 + src/lib/db/client-instance-store.ts | 9 ++++ .../metrics/instance/instance-service.ts | 22 ++++++++- src/lib/routes/admin-api/config.test.ts | 46 +++++++++++++++++++ src/lib/routes/admin-api/config.ts | 46 +++++++++++++++---- src/lib/types/stores/client-instance-store.ts | 1 + .../fixtures/fake-client-instance-store.ts | 8 ++++ 9 files changed, 147 insertions(+), 10 deletions(-) create mode 100644 frontend/src/component/banners/EdgeUpgradeBanner/EdgeUpgradeBanner.tsx diff --git a/frontend/src/component/App.tsx b/frontend/src/component/App.tsx index 4b3de13696..5ff7d0ab1a 100644 --- a/frontend/src/component/App.tsx +++ b/frontend/src/component/App.tsx @@ -21,6 +21,7 @@ import { styled } from '@mui/material'; import { InitialRedirect } from './InitialRedirect'; import { InternalBanners } from './banners/internalBanners/InternalBanners'; import { ExternalBanners } from './banners/externalBanners/ExternalBanners'; +import { EdgeUpgradeBanner } from './banners/EdgeUpgradeBanner/EdgeUpgradeBanner'; import { LicenseBanner } from './banners/internalBanners/LicenseBanner'; import { Demo } from './demo/Demo'; @@ -68,6 +69,7 @@ export const App = () => { + diff --git a/frontend/src/component/banners/EdgeUpgradeBanner/EdgeUpgradeBanner.tsx b/frontend/src/component/banners/EdgeUpgradeBanner/EdgeUpgradeBanner.tsx new file mode 100644 index 0000000000..5c4e33f2e8 --- /dev/null +++ b/frontend/src/component/banners/EdgeUpgradeBanner/EdgeUpgradeBanner.tsx @@ -0,0 +1,22 @@ +import { useUiFlag } from '../../../hooks/useUiFlag'; +import { ConditionallyRender } from '../../common/ConditionallyRender/ConditionallyRender'; +import { Banner } from '../Banner/Banner'; +import { IBanner } from '../../../interfaces/banner'; + +export const EdgeUpgradeBanner = () => { + 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 ( + <> + } + /> + + ); +}; diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 64805b24b7..3562790a0d 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 00fb718b93..ccdcf388d8 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) + .whereRaw(`sdk_version LIKE '??%'`, [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 309de68a28..56339bc325 100644 --- a/src/lib/features/metrics/instance/instance-service.ts +++ b/src/lib/features/metrics/instance/instance-service.ts @@ -19,7 +19,7 @@ import { clientMetricsSchema } from '../shared/schema'; import { PartialSome } from '../../../types/partial'; import { IPrivateProjectChecker } from '../../private-project/privateProjectCheckerType'; import { IFlagResolver, SYSTEM_USER } from '../../../types'; -import { ALL_PROJECTS } from '../../../util'; +import { ALL_PROJECTS, parseStrictSemVer } from '../../../util'; import { Logger } from '../../../logger'; export default class ClientInstanceService { @@ -224,4 +224,24 @@ export default class ClientInstanceService { async removeInstancesOlderThanTwoDays(): Promise { return this.clientInstanceStore.removeInstancesOlderThanTwoDays(); } + + async usesSdkOlderThan( + sdkName: string, + sdkVersion: string, + ): Promise { + const semver = parseStrictSemVer(sdkVersion); + const instancesOfSdk = + await this.clientInstanceStore.getBySdkName(sdkName); + return instancesOfSdk.some((instance) => { + if (instance.sdkVersion) { + const [_sdkName, sdkVersion] = instance.sdkVersion.split(':'); + const instanceUsedSemver = parseStrictSemVer(sdkVersion); + return ( + instanceUsedSemver !== null && + semver !== null && + instanceUsedSemver < semver + ); + } + }); + } } diff --git a/src/lib/routes/admin-api/config.test.ts b/src/lib/routes/admin-api/config.test.ts index 562de5eba1..44e86be8c6 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 9f8cf0ee53..837451e44c 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 bae30dc46f..eb4857da24 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 6d7a0cd296..531375d6e2 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 = []; }