diff --git a/frontend/src/component/layout/Error/Error.tsx b/frontend/src/component/layout/Error/Error.tsx index a26338789b..d86a757ca4 100644 --- a/frontend/src/component/layout/Error/Error.tsx +++ b/frontend/src/component/layout/Error/Error.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, VFC } from 'react'; +import { useEffect, VFC } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, Button } from '@mui/material'; import { Dialogue } from 'component/common/Dialogue/Dialogue'; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index c3897b9af2..120d29d030 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -21,7 +21,6 @@ import { PlausibleProvider } from 'component/providers/PlausibleProvider/Plausib import { Error as LayoutError } from './component/layout/Error/Error'; import { ErrorBoundary } from 'react-error-boundary'; import { useRecordUIErrorApi } from 'hooks/api/actions/useRecordUIErrorApi/useRecordUiErrorApi'; -import { useEffect } from 'react'; window.global ||= window; diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 7889c0930b..ca10ebbb8b 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -133,6 +133,7 @@ exports[`should create default config 1`] = ` "proPlanAutoCharge": false, "responseTimeWithAppNameKillSwitch": false, "scheduledConfigurationChanges": false, + "sdkReporting": false, "showInactiveUsers": false, "strictSchemaValidation": false, "stripClientHeadersOn304": false, diff --git a/src/lib/features/project/project-applications.e2e.test.ts b/src/lib/features/project/project-applications.e2e.test.ts new file mode 100644 index 0000000000..2678cd157e --- /dev/null +++ b/src/lib/features/project/project-applications.e2e.test.ts @@ -0,0 +1,179 @@ +import dbInit, { ITestDb } from '../../../test/e2e/helpers/database-init'; +import { + IUnleashTest, + setupAppWithCustomConfig, +} from '../../../test/e2e/helpers/test-helper'; +import getLogger from '../../../test/fixtures/no-logger'; + +import { ApiTokenType, IApiToken } from '../../types/models/api-token'; + +let app: IUnleashTest; +let db: ITestDb; +let defaultToken: IApiToken; + +const metrics = { + appName: 'appName', + instanceId: 'instanceId', + bucket: { + start: '2016-11-03T07:16:43.572Z', + stop: '2016-11-03T07:16:53.572Z', + toggles: { + 'toggle-name-1': { + yes: 123, + no: 321, + variants: { + 'variant-1': 123, + 'variant-2': 321, + }, + }, + }, + }, +}; + +beforeAll(async () => { + db = await dbInit('projects_applications_serial', getLogger); + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + sdkReporting: true, + }, + }, + }, + db.rawDatabase, + ); + defaultToken = + await app.services.apiTokenService.createApiTokenWithProjects({ + type: ApiTokenType.CLIENT, + projects: ['default'], + environment: 'default', + tokenName: 'tester', + }); +}); + +afterEach(async () => { + await db.stores.clientMetricsStoreV2.deleteAll(); + await db.stores.clientInstanceStore.deleteAll(); + await db.stores.featureToggleStore.deleteAll(); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +test('should return applications', async () => { + await app.createFeature('toggle-name-1'); + + await app.request.post('/api/client/register').send({ + appName: metrics.appName, + instanceId: metrics.instanceId, + strategies: ['default'], + sdkVersion: 'unleash-client-test:1.2', + started: Date.now(), + interval: 10, + }); + await app.services.clientInstanceService.bulkAdd(); + await app.request + .post('/api/client/metrics') + .set('Authorization', defaultToken.secret) + .send(metrics) + .expect(202); + + await app.services.clientMetricsServiceV2.bulkAdd(); + + const { body } = await app.request + .get('/api/admin/projects/default/applications') + .expect('Content-Type', /json/) + .expect(200); + + expect(body).toMatchObject([ + { + environments: ['default'], + instances: ['instanceId'], + name: 'appName', + sdks: [ + { + name: 'unleash-client-test', + versions: ['1.2'], + }, + ], + }, + ]); +}); + +test('should return applications if sdk was not in database', async () => { + await app.createFeature('toggle-name-1'); + + await app.request.post('/api/client/register').send({ + appName: metrics.appName, + instanceId: metrics.instanceId, + strategies: ['default'], + started: Date.now(), + interval: 10, + }); + await app.services.clientInstanceService.bulkAdd(); + await app.request + .post('/api/client/metrics') + .set('Authorization', defaultToken.secret) + .send(metrics) + .expect(202); + + await app.services.clientMetricsServiceV2.bulkAdd(); + + const { body } = await app.request + .get('/api/admin/projects/default/applications') + .expect('Content-Type', /json/) + .expect(200); + + expect(body).toMatchObject([ + { + environments: ['default'], + instances: ['instanceId'], + name: 'appName', + sdks: [], + }, + ]); +}); + +test('should return application without version if sdk has just name', async () => { + await app.createFeature('toggle-name-1'); + + await app.request.post('/api/client/register').send({ + appName: metrics.appName, + instanceId: metrics.instanceId, + strategies: ['default'], + sdkVersion: 'unleash-client-test', + started: Date.now(), + interval: 10, + }); + await app.services.clientInstanceService.bulkAdd(); + await app.request + .post('/api/client/metrics') + .set('Authorization', defaultToken.secret) + .send(metrics) + .expect(202); + + await app.services.clientMetricsServiceV2.bulkAdd(); + + const { body } = await app.request + .get('/api/admin/projects/default/applications') + .expect('Content-Type', /json/) + .expect(200); + + expect(body).toMatchObject([ + { + environments: ['default'], + instances: ['instanceId'], + name: 'appName', + sdks: [ + { + name: 'unleash-client-test', + versions: [], + }, + ], + }, + ]); +}); diff --git a/src/lib/features/project/project-controller.ts b/src/lib/features/project/project-controller.ts index d008f4bd95..88445891a3 100644 --- a/src/lib/features/project/project-controller.ts +++ b/src/lib/features/project/project-controller.ts @@ -2,6 +2,7 @@ import { Response } from 'express'; import Controller from '../../routes/controller'; import { IArchivedQuery, + IFlagResolver, IProjectParam, IUnleashConfig, IUnleashServices, @@ -36,6 +37,7 @@ import { projectApplicationsSchema, ProjectApplicationsSchema, } from '../../openapi/spec/project-applications-schema'; +import { NotFoundError } from '../../error'; export default class ProjectController extends Controller { private projectService: ProjectService; @@ -44,11 +46,14 @@ export default class ProjectController extends Controller { private openApiService: OpenApiService; + private flagResolver: IFlagResolver; + constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { super(config); this.projectService = services.projectService; this.openApiService = services.openApiService; this.settingService = services.settingService; + this.flagResolver = config.flagResolver; this.route({ path: '', @@ -258,6 +263,10 @@ export default class ProjectController extends Controller { req: IAuthRequest, res: Response, ): Promise { + if (!this.flagResolver.isEnabled('sdkReporting')) { + throw new NotFoundError(); + } + const { projectId } = req.params; const applications = diff --git a/src/lib/features/project/project-store.ts b/src/lib/features/project/project-store.ts index 36b00271ed..02395dab13 100644 --- a/src/lib/features/project/project-store.ts +++ b/src/lib/features/project/project-store.ts @@ -7,7 +7,6 @@ import { IFlagResolver, IProject, IProjectApplication, - IProjectApplicationSdk, IProjectUpdate, IProjectWithCount, ProjectMode, @@ -721,40 +720,48 @@ class ProjectStore implements IProjectStore { } getAggregatedApplicationsData(rows): IProjectApplication[] { - const entriesMap: Map = new Map(); - const orderedEntries: IProjectApplication[] = []; - - const getSdk = (sdkParts: string[]): IProjectApplicationSdk => { - return { - name: sdkParts[0], - versions: [sdkParts[1]], - }; - }; + const entriesMap = new Map(); rows.forEach((row) => { - let entry = entriesMap.get(row.app_name); - const sdkParts = row.sdk_version.split(':'); + const { app_name, environment, instance_id, sdk_version } = row; + let entry = entriesMap.get(app_name); if (!entry) { entry = { - name: row.app_name, - environments: [row.environment], - instances: [row.instance_id], - sdks: [getSdk(sdkParts)], + name: app_name, + environments: [], + instances: [], + sdks: [], }; - entriesMap.set(row.feature_name, entry); - orderedEntries.push(entry); + entriesMap.set(app_name, entry); } - const sdk = entry.sdks.find((sdk) => sdk.name === sdkParts[0]); - if (!sdk) { - entry.sdks.push(getSdk(sdkParts)); - } else { - sdk.versions.push(sdkParts[1]); + if (!entry.environments.includes(environment)) { + entry.environments.push(environment); + } + + if (!entry.instances.includes(instance_id)) { + entry.instances.push(instance_id); + } + + if (sdk_version) { + const sdkParts = sdk_version.split(':'); + const sdkName = sdkParts[0]; + const sdkVersion = sdkParts[1] || ''; + let sdk = entry.sdks.find((sdk) => sdk.name === sdkName); + + if (!sdk) { + sdk = { name: sdkName, versions: [] }; + entry.sdks.push(sdk); + } + + if (sdkVersion && !sdk.versions.includes(sdkVersion)) { + sdk.versions.push(sdkVersion); + } } }); - return orderedEntries; + return Array.from(entriesMap.values()); } } diff --git a/src/lib/features/project/projects.e2e.test.ts b/src/lib/features/project/projects.e2e.test.ts index 1903625ea7..e64bd64cb9 100644 --- a/src/lib/features/project/projects.e2e.test.ts +++ b/src/lib/features/project/projects.e2e.test.ts @@ -24,6 +24,7 @@ beforeAll(async () => { experimental: { flags: { strictSchemaValidation: true, + sdkReporting: true, }, }, }, @@ -286,12 +287,3 @@ test('response should include last seen at per environment for multiple environm expect(body.features[1].lastSeenAt).toBe('2023-10-01T12:34:56.000Z'); }); - -test('should return empty list of applications', async () => { - const { body } = await app.request - .get('/api/admin/projects/default/applications') - .expect('Content-Type', /json/) - .expect(200); - - expect(body).toMatchObject([]); -}); diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index e98e2e2b0c..4f8cb4ee11 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -48,7 +48,8 @@ export type IFlagKey = | 'showInactiveUsers' | 'inMemoryScheduledChangeRequests' | 'collectTrafficDataUsage' - | 'useMemoizedActiveTokens'; + | 'useMemoizedActiveTokens' + | 'sdkReporting'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -204,6 +205,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_EXECUTIVE_DASHBOARD, false, ), + sdkReporting: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_SDK_REPORTING, + false, + ), feedbackComments: { name: 'feedbackComments', enabled: parseEnvVarBoolean( diff --git a/src/server-dev.ts b/src/server-dev.ts index 309d0e50c5..d11b3ea212 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -49,6 +49,7 @@ process.nextTick(async () => { featureSearchFeedbackPosting: true, extendedUsageMetricsUI: true, executiveDashboard: true, + sdkReporting: true, }, }, authentication: {