mirror of
https://github.com/Unleash/unleash.git
synced 2025-02-04 00:18:01 +01:00
feat: applicaton usage endpoint (#4548)
This commit is contained in:
parent
0e162362e6
commit
1fbd8b6ef8
@ -7,6 +7,7 @@ import {
|
|||||||
import { Logger, LogProvider } from '../logger';
|
import { Logger, LogProvider } from '../logger';
|
||||||
import { IApplicationQuery } from '../types/query';
|
import { IApplicationQuery } from '../types/query';
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
import { IFlagResolver } from '../types';
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
'app_name',
|
'app_name',
|
||||||
@ -35,8 +36,45 @@ const mapRow: (any) => IClientApplication = (row) => ({
|
|||||||
icon: row.icon,
|
icon: row.icon,
|
||||||
lastSeen: row.last_seen,
|
lastSeen: row.last_seen,
|
||||||
announced: row.announced,
|
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 remapRow = (input) => {
|
||||||
const temp = {
|
const temp = {
|
||||||
app_name: input.appName,
|
app_name: input.appName,
|
||||||
@ -72,10 +110,18 @@ export default class ClientApplicationsStore
|
|||||||
{
|
{
|
||||||
private db: Db;
|
private db: Db;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
|
constructor(
|
||||||
|
db: Db,
|
||||||
|
eventBus: EventEmitter,
|
||||||
|
getLogger: LogProvider,
|
||||||
|
flagResolver: IFlagResolver,
|
||||||
|
) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
|
this.flagResolver = flagResolver;
|
||||||
this.logger = getLogger('client-applications-store.ts');
|
this.logger = getLogger('client-applications-store.ts');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -147,15 +193,38 @@ export default class ClientApplicationsStore
|
|||||||
async getAppsForStrategy(
|
async getAppsForStrategy(
|
||||||
query: IApplicationQuery,
|
query: IApplicationQuery,
|
||||||
): Promise<IClientApplication[]> {
|
): Promise<IClientApplication[]> {
|
||||||
const rows = await this.db.select(COLUMNS).from(TABLE);
|
if (this.flagResolver.isEnabled('newApplicationList')) {
|
||||||
const apps = rows.map(mapRow);
|
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) {
|
if (query.strategyName) {
|
||||||
return apps.filter((app) =>
|
return apps.filter((app) =>
|
||||||
app.strategies.includes(query.strategyName),
|
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<IClientApplication[]> {
|
async getUnannounced(): Promise<IClientApplication[]> {
|
||||||
|
@ -54,6 +54,7 @@ export const createStores = (
|
|||||||
db,
|
db,
|
||||||
eventBus,
|
eventBus,
|
||||||
getLogger,
|
getLogger,
|
||||||
|
config.flagResolver,
|
||||||
),
|
),
|
||||||
clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger),
|
clientInstanceStore: new ClientInstanceStore(db, eventBus, getLogger),
|
||||||
clientMetricsStoreV2: new ClientMetricsStoreV2(
|
clientMetricsStoreV2: new ClientMetricsStoreV2(
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
apiTokenSchema,
|
apiTokenSchema,
|
||||||
apiTokensSchema,
|
apiTokensSchema,
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
|
applicationUsageSchema,
|
||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
batchFeaturesSchema,
|
batchFeaturesSchema,
|
||||||
bulkToggleFeaturesSchema,
|
bulkToggleFeaturesSchema,
|
||||||
@ -215,6 +216,7 @@ export const schemas: UnleashSchemas = {
|
|||||||
apiTokenSchema,
|
apiTokenSchema,
|
||||||
apiTokensSchema,
|
apiTokensSchema,
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
|
applicationUsageSchema,
|
||||||
applicationsSchema,
|
applicationsSchema,
|
||||||
batchFeaturesSchema,
|
batchFeaturesSchema,
|
||||||
batchStaleSchema,
|
batchStaleSchema,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { applicationUsageSchema } from './application-usage-schema';
|
||||||
|
|
||||||
export const applicationSchema = {
|
export const applicationSchema = {
|
||||||
$id: '#/components/schemas/applicationSchema',
|
$id: '#/components/schemas/applicationSchema',
|
||||||
@ -50,8 +51,17 @@ export const applicationSchema = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
example: 'https://github.com/favicon.ico',
|
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;
|
} as const;
|
||||||
|
|
||||||
export type ApplicationSchema = FromSchema<typeof applicationSchema>;
|
export type ApplicationSchema = FromSchema<typeof applicationSchema>;
|
||||||
|
28
src/lib/openapi/spec/application-usage-schema.ts
Normal file
28
src/lib/openapi/spec/application-usage-schema.ts
Normal file
@ -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<typeof applicationUsageSchema>;
|
@ -1,5 +1,6 @@
|
|||||||
import { applicationSchema } from './application-schema';
|
import { applicationSchema } from './application-schema';
|
||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { applicationUsageSchema } from './application-usage-schema';
|
||||||
|
|
||||||
export const applicationsSchema = {
|
export const applicationsSchema = {
|
||||||
$id: '#/components/schemas/applicationsSchema',
|
$id: '#/components/schemas/applicationsSchema',
|
||||||
@ -20,6 +21,7 @@ export const applicationsSchema = {
|
|||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
applicationSchema,
|
applicationSchema,
|
||||||
|
applicationUsageSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -153,3 +153,4 @@ export * from './strategy-variant-schema';
|
|||||||
export * from './client-segment-schema';
|
export * from './client-segment-schema';
|
||||||
export * from './update-feature-type-lifetime-schema';
|
export * from './update-feature-type-lifetime-schema';
|
||||||
export * from './create-group-schema';
|
export * from './create-group-schema';
|
||||||
|
export * from './application-usage-schema';
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { applicationSchema } from './schema';
|
|
||||||
import { APPLICATION_CREATED, CLIENT_REGISTER } from '../../types/events';
|
import { APPLICATION_CREATED, CLIENT_REGISTER } from '../../types/events';
|
||||||
import { IApplication } from './models';
|
import { IApplication } from './models';
|
||||||
import { IUnleashStores } from '../../types/stores';
|
import { IUnleashStores } from '../../types/stores';
|
||||||
@ -205,8 +204,7 @@ export default class ClientInstanceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createApplication(input: IApplication): Promise<void> {
|
async createApplication(input: IApplication): Promise<void> {
|
||||||
const applicationData = await applicationSchema.validateAsync(input);
|
await this.clientApplicationsStore.upsert(input);
|
||||||
await this.clientApplicationsStore.upsert(applicationData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeInstancesOlderThanTwoDays(): Promise<void> {
|
async removeInstancesOlderThanTwoDays(): Promise<void> {
|
||||||
|
@ -25,5 +25,7 @@ export interface IApplication {
|
|||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
instances?: IClientInstance[];
|
instances?: IClientInstance[];
|
||||||
seenToggles?: Record<string, any>;
|
seenToggles?: Record<string, any>;
|
||||||
|
project?: string;
|
||||||
|
environment?: string;
|
||||||
links?: Record<string, string>;
|
links?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
import { IApplicationQuery } from '../query';
|
import { IApplicationQuery } from '../query';
|
||||||
|
|
||||||
|
export interface IClientApplicationUsage {
|
||||||
|
project: string;
|
||||||
|
environments: string[];
|
||||||
|
}
|
||||||
export interface IClientApplication {
|
export interface IClientApplication {
|
||||||
appName: string;
|
appName: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
@ -13,6 +17,7 @@ export interface IClientApplication {
|
|||||||
color: string;
|
color: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
strategies: string[];
|
strategies: string[];
|
||||||
|
usage?: IClientApplicationUsage[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IClientApplicationsStore
|
export interface IClientApplicationsStore
|
||||||
|
@ -1,13 +1,24 @@
|
|||||||
import dbInit, { ITestDb } from '../../helpers/database-init';
|
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';
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
|
|
||||||
let app: IUnleashTest;
|
let app: IUnleashTest;
|
||||||
let db: ITestDb;
|
let db: ITestDb;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('metrics_serial', getLogger);
|
db = await dbInit('metrics_serial', getLogger, {
|
||||||
app = await setupApp(db.stores);
|
experimental: { flags: { newApplicationList: true } },
|
||||||
|
});
|
||||||
|
app = await setupAppWithCustomConfig(db.stores, {
|
||||||
|
experimental: {
|
||||||
|
flags: {
|
||||||
|
newApplicationList: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@ -44,6 +55,14 @@ beforeEach(async () => {
|
|||||||
appName: 'deletable-app',
|
appName: 'deletable-app',
|
||||||
instanceId: 'inst-1',
|
instanceId: 'inst-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await app.services.clientInstanceService.createApplication({
|
||||||
|
appName: 'usage-app',
|
||||||
|
strategies: ['default'],
|
||||||
|
description: 'Some desc',
|
||||||
|
project: 'default',
|
||||||
|
environment: 'dev',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -74,7 +93,7 @@ test('should get list of applications', async () => {
|
|||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.expect((res) => {
|
.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')
|
.get('/api/admin/metrics/applications')
|
||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect((res) => {
|
.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);
|
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'] }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user