mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	feat: application overview backend (#6303)
This commit is contained in:
		
							parent
							
								
									6246459926
								
							
						
					
					
						commit
						3c4457af00
					
				| @ -7,6 +7,7 @@ import { | ||||
| import { Logger, LogProvider } from '../logger'; | ||||
| import { IApplicationQuery } from '../types/query'; | ||||
| import { Db } from './db'; | ||||
| import { IApplicationOverview } from '../features/metrics/instance/models'; | ||||
| 
 | ||||
| const COLUMNS = [ | ||||
|     'app_name', | ||||
| @ -249,4 +250,83 @@ export default class ClientApplicationsStore | ||||
| 
 | ||||
|         return mapRow(row); | ||||
|     } | ||||
| 
 | ||||
|     async getApplicationOverview( | ||||
|         appName: string, | ||||
|     ): Promise<IApplicationOverview> { | ||||
|         const query = this.db | ||||
|             .select([ | ||||
|                 'f.project', | ||||
|                 'cme.environment', | ||||
|                 'cme.feature_name', | ||||
|                 'ci.instance_id', | ||||
|                 'ci.sdk_version', | ||||
|                 'ci.last_seen', | ||||
|             ]) | ||||
|             .from({ a: 'client_applications' }) | ||||
|             .leftJoin('client_metrics_env as cme', 'cme.app_name', 'a.app_name') | ||||
|             .leftJoin('features as f', 'cme.feature_name', 'f.name') | ||||
|             .leftJoin('client_instances as ci', function () { | ||||
|                 this.on('ci.app_name', '=', 'cme.app_name').andOn( | ||||
|                     'ci.environment', | ||||
|                     '=', | ||||
|                     'cme.environment', | ||||
|                 ); | ||||
|             }) | ||||
|             .where('a.app_name', appName); | ||||
| 
 | ||||
|         const rows = await query; | ||||
|         if (!rows.length) { | ||||
|             throw new NotFoundError(`Could not find appName=${appName}`); | ||||
|         } | ||||
| 
 | ||||
|         return this.mapApplicationOverviewData(rows); | ||||
|     } | ||||
| 
 | ||||
|     mapApplicationOverviewData(rows: any[]): IApplicationOverview { | ||||
|         const featureCount = new Set(rows.map((row) => row.feature_name)).size; | ||||
| 
 | ||||
|         const environments = rows.reduce((acc, row) => { | ||||
|             const { environment, instance_id, sdk_version, last_seen } = row; | ||||
|             let env = acc.find((e) => e.name === environment); | ||||
|             if (!env) { | ||||
|                 env = { | ||||
|                     name: environment, | ||||
|                     instanceCount: 1, | ||||
|                     sdks: sdk_version ? [sdk_version] : [], | ||||
|                     lastSeen: last_seen, | ||||
|                     uniqueInstanceIds: new Set([instance_id]), | ||||
|                 }; | ||||
|                 acc.push(env); | ||||
|             } else { | ||||
|                 env.uniqueInstanceIds.add(instance_id); | ||||
|                 env.instanceCount = env.uniqueInstanceIds.size; | ||||
|                 if (sdk_version && !env.sdks.includes(sdk_version)) { | ||||
|                     env.sdks.push(sdk_version); | ||||
|                 } | ||||
|                 if (new Date(last_seen) > new Date(env.lastSeen)) { | ||||
|                     env.lastSeen = last_seen; | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             return acc; | ||||
|         }, []); | ||||
| 
 | ||||
|         environments.forEach((env) => { | ||||
|             delete env.uniqueInstanceIds; | ||||
|             env.sdks.sort(); | ||||
|         }); | ||||
| 
 | ||||
|         return { | ||||
|             projects: [ | ||||
|                 ...new Set( | ||||
|                     rows | ||||
|                         .filter((row) => row.project != null) | ||||
|                         .map((row) => row.project), | ||||
|                 ), | ||||
|             ], | ||||
|             featureCount, | ||||
|             environments, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| import { APPLICATION_CREATED, CLIENT_REGISTER } from '../../../types/events'; | ||||
| import { IApplication } from './models'; | ||||
| import { IApplication, IApplicationOverview } from './models'; | ||||
| import { IUnleashStores } from '../../../types/stores'; | ||||
| import { IUnleashConfig } from '../../../types/option'; | ||||
| import { IEventStore } from '../../../types/stores/event-store'; | ||||
| @ -212,6 +212,12 @@ export default class ClientInstanceService { | ||||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     async getApplicationOverview( | ||||
|         appName: string, | ||||
|     ): Promise<IApplicationOverview> { | ||||
|         return this.clientApplicationsStore.getApplicationOverview(appName); | ||||
|     } | ||||
| 
 | ||||
|     async deleteApplication(appName: string): Promise<void> { | ||||
|         await this.clientInstanceStore.deleteForApplication(appName); | ||||
|         await this.clientApplicationsStore.delete(appName); | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| import { IClientInstance } from '../../../types/stores/client-instance-store'; | ||||
| import { ApplicationOverviewSchema } from '../../../openapi/spec/application-overview-schema'; | ||||
| import { ApplicationOverviewEnvironmentSchema } from '../../../openapi/spec/application-overview-environment-schema'; | ||||
| 
 | ||||
| export interface IYesNoCount { | ||||
|     yes: number; | ||||
| @ -29,3 +31,17 @@ export interface IApplication { | ||||
|     environment?: string; | ||||
|     links?: Record<string, string>; | ||||
| } | ||||
| 
 | ||||
| type IApplicationOverviewEnvironment = Omit< | ||||
|     ApplicationOverviewEnvironmentSchema, | ||||
|     'lastSeen' | ||||
| > & { | ||||
|     lastSeen: Date; | ||||
| }; | ||||
| 
 | ||||
| export type IApplicationOverview = Omit< | ||||
|     ApplicationOverviewSchema, | ||||
|     'environments' | ||||
| > & { | ||||
|     environments: IApplicationOverviewEnvironment[]; | ||||
| }; | ||||
|  | ||||
| @ -33,8 +33,10 @@ export const applicationOverviewSchema = { | ||||
|         }, | ||||
|     }, | ||||
|     components: { | ||||
|         schemas: { | ||||
|             applicationOverviewEnvironmentSchema, | ||||
|         }, | ||||
|     }, | ||||
| } as const; | ||||
| 
 | ||||
| export type ApplicationOverviewSchema = FromSchema< | ||||
|  | ||||
| @ -16,12 +16,23 @@ import { | ||||
| import { CreateApplicationSchema } from '../../openapi/spec/create-application-schema'; | ||||
| import { IAuthRequest } from '../unleash-types'; | ||||
| import { extractUserIdFromUser } from '../../util'; | ||||
| import { IFlagResolver, serializeDates } from '../../types'; | ||||
| import { NotFoundError } from '../../error'; | ||||
| import { | ||||
|     ApplicationOverviewSchema, | ||||
|     applicationOverviewSchema, | ||||
| } from '../../openapi/spec/application-overview-schema'; | ||||
| import { OpenApiService } from '../../services'; | ||||
| 
 | ||||
| class MetricsController extends Controller { | ||||
|     private logger: Logger; | ||||
| 
 | ||||
|     private clientInstanceService: ClientInstanceService; | ||||
| 
 | ||||
|     private flagResolver: IFlagResolver; | ||||
| 
 | ||||
|     private openApiService: OpenApiService; | ||||
| 
 | ||||
|     constructor( | ||||
|         config: IUnleashConfig, | ||||
|         { | ||||
| @ -33,6 +44,8 @@ class MetricsController extends Controller { | ||||
|         this.logger = config.getLogger('/admin-api/metrics.ts'); | ||||
| 
 | ||||
|         this.clientInstanceService = clientInstanceService; | ||||
|         this.openApiService = openApiService; | ||||
|         this.flagResolver = config.flagResolver; | ||||
| 
 | ||||
|         // deprecated routes
 | ||||
|         this.get('/seen-toggles', this.deprecated); | ||||
| @ -195,9 +208,21 @@ class MetricsController extends Controller { | ||||
|     } | ||||
|     async getApplicationOverview( | ||||
|         req: Request, | ||||
|         res: Response<ApplicationSchema>, | ||||
|         res: Response<ApplicationOverviewSchema>, | ||||
|     ): Promise<void> { | ||||
|         throw new Error('Not implemented'); | ||||
|         if (!this.flagResolver.isEnabled('sdkReporting')) { | ||||
|             throw new NotFoundError(); | ||||
|         } | ||||
|         const { appName } = req.params; | ||||
|         const overview = | ||||
|             await this.clientInstanceService.getApplicationOverview(appName); | ||||
| 
 | ||||
|         this.openApiService.respondWithValidation( | ||||
|             200, | ||||
|             res, | ||||
|             applicationOverviewSchema.$id, | ||||
|             serializeDates(overview), | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| export default MetricsController; | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| import { Store } from './store'; | ||||
| import { IApplicationQuery } from '../query'; | ||||
| import { IApplicationOverview } from '../../features/metrics/instance/models'; | ||||
| 
 | ||||
| export interface IClientApplicationUsage { | ||||
|     project: string; | ||||
| @ -28,4 +29,5 @@ export interface IClientApplicationsStore | ||||
|     getAppsForStrategy(query: IApplicationQuery): Promise<IClientApplication[]>; | ||||
|     getUnannounced(): Promise<IClientApplication[]>; | ||||
|     setUnannouncedToAnnounced(): Promise<IClientApplication[]>; | ||||
|     getApplicationOverview(appName: string): Promise<IApplicationOverview>; | ||||
| } | ||||
|  | ||||
							
								
								
									
										136
									
								
								src/test/e2e/api/admin/applications.e2e.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								src/test/e2e/api/admin/applications.e2e.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,136 @@ | ||||
| import dbInit, { ITestDb } from '../../helpers/database-init'; | ||||
| import { | ||||
|     IUnleashTest, | ||||
|     setupAppWithCustomConfig, | ||||
| } from '../../helpers/test-helper'; | ||||
| import getLogger from '../../../fixtures/no-logger'; | ||||
| import { | ||||
|     ApiTokenType, | ||||
|     IApiToken, | ||||
| } from '../../../../lib/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, | ||||
|                 }, | ||||
|             }, | ||||
|             'toggle-name-2': { | ||||
|                 yes: 123, | ||||
|                 no: 321, | ||||
|                 variants: { | ||||
|                     'variant-1': 123, | ||||
|                     'variant-2': 321, | ||||
|                 }, | ||||
|             }, | ||||
|             'toggle-name-3': { | ||||
|                 yes: 123, | ||||
|                 no: 321, | ||||
|                 variants: { | ||||
|                     'variant-1': 123, | ||||
|                     'variant-2': 321, | ||||
|                 }, | ||||
|             }, | ||||
|         }, | ||||
|     }, | ||||
| }; | ||||
| 
 | ||||
| beforeAll(async () => { | ||||
|     db = await dbInit('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 Promise.all([ | ||||
|         db.stores.clientMetricsStoreV2.deleteAll(), | ||||
|         db.stores.clientInstanceStore.deleteAll(), | ||||
|         db.stores.featureToggleStore.deleteAll(), | ||||
|     ]); | ||||
| }); | ||||
| 
 | ||||
| afterAll(async () => { | ||||
|     await app.destroy(); | ||||
|     await db.destroy(); | ||||
| }); | ||||
| 
 | ||||
| test('should show correct number of total', async () => { | ||||
|     await Promise.all([ | ||||
|         app.createFeature('toggle-name-1'), | ||||
|         app.createFeature('toggle-name-2'), | ||||
|         app.createFeature('toggle-name-3'), | ||||
|         app.request.post('/api/client/register').send({ | ||||
|             appName: metrics.appName, | ||||
|             instanceId: metrics.instanceId, | ||||
|             strategies: ['default'], | ||||
|             sdkVersion: 'unleash-client-test', | ||||
|             started: Date.now(), | ||||
|             interval: 10, | ||||
|         }), | ||||
|         app.request.post('/api/client/register').send({ | ||||
|             appName: metrics.appName, | ||||
|             instanceId: 'another-instance', | ||||
|             strategies: ['default'], | ||||
|             sdkVersion: 'unleash-client-test2', | ||||
|             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/metrics/applications/${metrics.appName}/overview`) | ||||
|         .expect(200); | ||||
| 
 | ||||
|     const expected = { | ||||
|         projects: ['default'], | ||||
|         environments: [ | ||||
|             { | ||||
|                 instanceCount: 2, | ||||
|                 name: 'default', | ||||
|                 sdks: ['unleash-client-test', 'unleash-client-test2'], | ||||
|             }, | ||||
|         ], | ||||
|         featureCount: 3, | ||||
|     }; | ||||
| 
 | ||||
|     expect(body).toMatchObject(expected); | ||||
| }); | ||||
| @ -4,6 +4,7 @@ import { | ||||
| } from '../../lib/types/stores/client-applications-store'; | ||||
| import NotFoundError from '../../lib/error/notfound-error'; | ||||
| import { IApplicationQuery } from '../../lib/types/query'; | ||||
| import { IApplicationOverview } from '../../lib/features/metrics/instance/models'; | ||||
| 
 | ||||
| export default class FakeClientApplicationsStore | ||||
|     implements IClientApplicationsStore | ||||
| @ -78,4 +79,8 @@ export default class FakeClientApplicationsStore | ||||
|         await this.delete(details.appName); | ||||
|         return this.bulkUpsert([details]); | ||||
|     } | ||||
| 
 | ||||
|     getApplicationOverview(appName: string): Promise<IApplicationOverview> { | ||||
|         throw new Error('Method not implemented.'); | ||||
|     } | ||||
| } | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user