mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-11 00:08:30 +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,7 +33,9 @@ export const applicationOverviewSchema = {
|
||||
},
|
||||
},
|
||||
components: {
|
||||
applicationOverviewEnvironmentSchema,
|
||||
schemas: {
|
||||
applicationOverviewEnvironmentSchema,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -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