diff --git a/src/lib/db/client-applications-store.ts b/src/lib/db/client-applications-store.ts index 917d62b8f1..48b0571d02 100644 --- a/src/lib/db/client-applications-store.ts +++ b/src/lib/db/client-applications-store.ts @@ -7,6 +7,7 @@ import { import { Logger, LogProvider } from '../logger'; import { IApplicationQuery } from '../types/query'; import { Db } from './db'; +import { IFlagResolver } from '../types'; const COLUMNS = [ 'app_name', @@ -35,8 +36,45 @@ const mapRow: (any) => IClientApplication = (row) => ({ icon: row.icon, lastSeen: row.last_seen, announced: row.announced, + project: row.project, + environment: row.environment, }); +const reduceRows = (rows: any[]): IClientApplication[] => { + const appsObj = rows.reduce((acc, row) => { + // extracting project and environment from usage table + const { project, environment } = row; + const existingApp = acc[row.app_name]; + + if (existingApp) { + const existingProject = existingApp.usage.find( + (usage) => usage.project === project, + ); + + if (existingProject) { + existingProject.environments.push(environment); + } else { + existingApp.usage.push({ + project: project, + environments: [environment], + }); + } + } else { + acc[row.app_name] = { + ...mapRow(row), + usage: + project && environment + ? [{ project, environments: [environment] }] + : [], + }; + } + + return acc; + }, {}); + + return Object.values(appsObj); +}; + const remapRow = (input) => { const temp = { app_name: input.appName, @@ -72,10 +110,18 @@ export default class ClientApplicationsStore { private db: Db; + private flagResolver: IFlagResolver; + private logger: Logger; - constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) { + constructor( + db: Db, + eventBus: EventEmitter, + getLogger: LogProvider, + flagResolver: IFlagResolver, + ) { this.db = db; + this.flagResolver = flagResolver; this.logger = getLogger('client-applications-store.ts'); } @@ -147,15 +193,38 @@ export default class ClientApplicationsStore async getAppsForStrategy( query: IApplicationQuery, ): Promise { - const rows = await this.db.select(COLUMNS).from(TABLE); - const apps = rows.map(mapRow); + if (this.flagResolver.isEnabled('newApplicationList')) { + const rows = await this.db + .select([ + ...COLUMNS.map((column) => `${TABLE}.${column}`), + 'project', + 'environment', + ]) + .from(TABLE) + .leftJoin( + TABLE_USAGE, + `${TABLE_USAGE}.app_name`, + `${TABLE}.app_name`, + ); + const apps = reduceRows(rows); - if (query.strategyName) { - return apps.filter((app) => - app.strategies.includes(query.strategyName), - ); + if (query.strategyName) { + return apps.filter((app) => + app.strategies.includes(query.strategyName), + ); + } + return apps; + } else { + const rows = await this.db.select(COLUMNS).from(TABLE); + const apps = rows.map(mapRow); + + if (query.strategyName) { + return apps.filter((app) => + app.strategies.includes(query.strategyName), + ); + } + return apps; } - return apps; } async getUnannounced(): Promise { diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 6b8bbff505..fddc5a5e3e 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -54,6 +54,7 @@ export const createStores = ( db, eventBus, getLogger, + config.flagResolver, ), clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger), clientMetricsStoreV2: new ClientMetricsStoreV2( diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index cbdb86c41a..bb1c366aad 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -12,6 +12,7 @@ import { apiTokenSchema, apiTokensSchema, applicationSchema, + applicationUsageSchema, applicationsSchema, batchFeaturesSchema, bulkToggleFeaturesSchema, @@ -215,6 +216,7 @@ export const schemas: UnleashSchemas = { apiTokenSchema, apiTokensSchema, applicationSchema, + applicationUsageSchema, applicationsSchema, batchFeaturesSchema, batchStaleSchema, diff --git a/src/lib/openapi/spec/application-schema.ts b/src/lib/openapi/spec/application-schema.ts index b58343a565..0ad18a7f8b 100644 --- a/src/lib/openapi/spec/application-schema.ts +++ b/src/lib/openapi/spec/application-schema.ts @@ -1,4 +1,5 @@ import { FromSchema } from 'json-schema-to-ts'; +import { applicationUsageSchema } from './application-usage-schema'; export const applicationSchema = { $id: '#/components/schemas/applicationSchema', @@ -50,8 +51,17 @@ export const applicationSchema = { type: 'string', example: 'https://github.com/favicon.ico', }, + usage: { + description: 'The list of projects the application has been using.', + type: 'array', + items: { + $ref: '#/components/schemas/applicationUsageSchema', + }, + }, + }, + components: { + applicationUsageSchema, }, - components: {}, } as const; export type ApplicationSchema = FromSchema; diff --git a/src/lib/openapi/spec/application-usage-schema.ts b/src/lib/openapi/spec/application-usage-schema.ts new file mode 100644 index 0000000000..0340ff9d3d --- /dev/null +++ b/src/lib/openapi/spec/application-usage-schema.ts @@ -0,0 +1,28 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const applicationUsageSchema = { + $id: '#/components/schemas/applicationUsageSchema', + type: 'object', + description: 'Data about an project that have been used by applications.', + additionalProperties: false, + required: ['project', 'environments'], + properties: { + project: { + description: 'Name of the project', + type: 'string', + example: 'main-project', + }, + environments: { + description: + 'Which environments have been accessed in this project.', + type: 'array', + items: { + type: 'string', + }, + example: ['development', 'production'], + }, + }, + components: {}, +} as const; + +export type ApplicationUsageSchema = FromSchema; diff --git a/src/lib/openapi/spec/applications-schema.ts b/src/lib/openapi/spec/applications-schema.ts index 21d293bc32..5841fc5377 100644 --- a/src/lib/openapi/spec/applications-schema.ts +++ b/src/lib/openapi/spec/applications-schema.ts @@ -1,5 +1,6 @@ import { applicationSchema } from './application-schema'; import { FromSchema } from 'json-schema-to-ts'; +import { applicationUsageSchema } from './application-usage-schema'; export const applicationsSchema = { $id: '#/components/schemas/applicationsSchema', @@ -20,6 +21,7 @@ export const applicationsSchema = { components: { schemas: { applicationSchema, + applicationUsageSchema, }, }, } as const; diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 711a632ba3..3d02e293da 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -153,3 +153,4 @@ export * from './strategy-variant-schema'; export * from './client-segment-schema'; export * from './update-feature-type-lifetime-schema'; export * from './create-group-schema'; +export * from './application-usage-schema'; diff --git a/src/lib/services/client-metrics/instance-service.ts b/src/lib/services/client-metrics/instance-service.ts index b3373a2c2e..b6f7f96008 100644 --- a/src/lib/services/client-metrics/instance-service.ts +++ b/src/lib/services/client-metrics/instance-service.ts @@ -1,4 +1,3 @@ -import { applicationSchema } from './schema'; import { APPLICATION_CREATED, CLIENT_REGISTER } from '../../types/events'; import { IApplication } from './models'; import { IUnleashStores } from '../../types/stores'; @@ -205,8 +204,7 @@ export default class ClientInstanceService { } async createApplication(input: IApplication): Promise { - const applicationData = await applicationSchema.validateAsync(input); - await this.clientApplicationsStore.upsert(applicationData); + await this.clientApplicationsStore.upsert(input); } async removeInstancesOlderThanTwoDays(): Promise { diff --git a/src/lib/services/client-metrics/models.ts b/src/lib/services/client-metrics/models.ts index 854a05e71e..5fc793c932 100644 --- a/src/lib/services/client-metrics/models.ts +++ b/src/lib/services/client-metrics/models.ts @@ -25,5 +25,7 @@ export interface IApplication { createdAt?: Date; instances?: IClientInstance[]; seenToggles?: Record; + project?: string; + environment?: string; links?: Record; } diff --git a/src/lib/types/stores/client-applications-store.ts b/src/lib/types/stores/client-applications-store.ts index 8548143ffc..16783ad050 100644 --- a/src/lib/types/stores/client-applications-store.ts +++ b/src/lib/types/stores/client-applications-store.ts @@ -1,6 +1,10 @@ import { Store } from './store'; import { IApplicationQuery } from '../query'; +export interface IClientApplicationUsage { + project: string; + environments: string[]; +} export interface IClientApplication { appName: string; updatedAt: Date; @@ -13,6 +17,7 @@ export interface IClientApplication { color: string; icon: string; strategies: string[]; + usage?: IClientApplicationUsage[]; } export interface IClientApplicationsStore diff --git a/src/test/e2e/api/admin/metrics.e2e.test.ts b/src/test/e2e/api/admin/metrics.e2e.test.ts index 7d859101f1..5051e4c234 100644 --- a/src/test/e2e/api/admin/metrics.e2e.test.ts +++ b/src/test/e2e/api/admin/metrics.e2e.test.ts @@ -1,13 +1,24 @@ import dbInit, { ITestDb } from '../../helpers/database-init'; -import { IUnleashTest, setupApp } from '../../helpers/test-helper'; +import { + IUnleashTest, + setupAppWithCustomConfig, +} from '../../helpers/test-helper'; import getLogger from '../../../fixtures/no-logger'; let app: IUnleashTest; let db: ITestDb; beforeAll(async () => { - db = await dbInit('metrics_serial', getLogger); - app = await setupApp(db.stores); + db = await dbInit('metrics_serial', getLogger, { + experimental: { flags: { newApplicationList: true } }, + }); + app = await setupAppWithCustomConfig(db.stores, { + experimental: { + flags: { + newApplicationList: true, + }, + }, + }); }); beforeEach(async () => { @@ -44,6 +55,14 @@ beforeEach(async () => { appName: 'deletable-app', instanceId: 'inst-1', }); + + await app.services.clientInstanceService.createApplication({ + appName: 'usage-app', + strategies: ['default'], + description: 'Some desc', + project: 'default', + environment: 'dev', + }); }); afterAll(async () => { @@ -74,7 +93,7 @@ test('should get list of applications', async () => { .expect('Content-Type', /json/) .expect(200) .expect((res) => { - expect(res.body.applications).toHaveLength(3); + expect(res.body.applications).toHaveLength(4); }); }); @@ -89,7 +108,7 @@ test('should delete application', async () => { .get('/api/admin/metrics/applications') .expect('Content-Type', /json/) .expect((res) => { - expect(res.body.applications).toHaveLength(2); + expect(res.body.applications).toHaveLength(3); }); }); @@ -101,3 +120,17 @@ test('deleting an application should be idempotent, so expect 200', async () => expect(res.status).toBe(200); }); }); + +test('should get list of application usage', async () => { + const { body } = await app.request + .get('/api/admin/metrics/applications') + .expect('Content-Type', /json/) + .expect(200); + const application = body.applications.find( + (selectableApp) => selectableApp.appName === 'usage-app', + ); + expect(application).toMatchObject({ + appName: 'usage-app', + usage: [{ project: 'default', environments: ['dev'] }], + }); +});