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 { Logger, LogProvider } from '../logger';
|
||||||
import { IApplicationQuery } from '../types/query';
|
import { IApplicationQuery } from '../types/query';
|
||||||
import { Db } from './db';
|
import { Db } from './db';
|
||||||
|
import { IApplicationOverview } from '../features/metrics/instance/models';
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
'app_name',
|
'app_name',
|
||||||
@ -249,4 +250,83 @@ export default class ClientApplicationsStore
|
|||||||
|
|
||||||
return mapRow(row);
|
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 { APPLICATION_CREATED, CLIENT_REGISTER } from '../../../types/events';
|
||||||
import { IApplication } from './models';
|
import { IApplication, IApplicationOverview } from './models';
|
||||||
import { IUnleashStores } from '../../../types/stores';
|
import { IUnleashStores } from '../../../types/stores';
|
||||||
import { IUnleashConfig } from '../../../types/option';
|
import { IUnleashConfig } from '../../../types/option';
|
||||||
import { IEventStore } from '../../../types/stores/event-store';
|
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> {
|
async deleteApplication(appName: string): Promise<void> {
|
||||||
await this.clientInstanceStore.deleteForApplication(appName);
|
await this.clientInstanceStore.deleteForApplication(appName);
|
||||||
await this.clientApplicationsStore.delete(appName);
|
await this.clientApplicationsStore.delete(appName);
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { IClientInstance } from '../../../types/stores/client-instance-store';
|
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 {
|
export interface IYesNoCount {
|
||||||
yes: number;
|
yes: number;
|
||||||
@ -29,3 +31,17 @@ export interface IApplication {
|
|||||||
environment?: string;
|
environment?: string;
|
||||||
links?: Record<string, 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: {
|
components: {
|
||||||
|
schemas: {
|
||||||
applicationOverviewEnvironmentSchema,
|
applicationOverviewEnvironmentSchema,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type ApplicationOverviewSchema = FromSchema<
|
export type ApplicationOverviewSchema = FromSchema<
|
||||||
|
@ -16,12 +16,23 @@ import {
|
|||||||
import { CreateApplicationSchema } from '../../openapi/spec/create-application-schema';
|
import { CreateApplicationSchema } from '../../openapi/spec/create-application-schema';
|
||||||
import { IAuthRequest } from '../unleash-types';
|
import { IAuthRequest } from '../unleash-types';
|
||||||
import { extractUserIdFromUser } from '../../util';
|
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 {
|
class MetricsController extends Controller {
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
private clientInstanceService: ClientInstanceService;
|
private clientInstanceService: ClientInstanceService;
|
||||||
|
|
||||||
|
private flagResolver: IFlagResolver;
|
||||||
|
|
||||||
|
private openApiService: OpenApiService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
config: IUnleashConfig,
|
config: IUnleashConfig,
|
||||||
{
|
{
|
||||||
@ -33,6 +44,8 @@ class MetricsController extends Controller {
|
|||||||
this.logger = config.getLogger('/admin-api/metrics.ts');
|
this.logger = config.getLogger('/admin-api/metrics.ts');
|
||||||
|
|
||||||
this.clientInstanceService = clientInstanceService;
|
this.clientInstanceService = clientInstanceService;
|
||||||
|
this.openApiService = openApiService;
|
||||||
|
this.flagResolver = config.flagResolver;
|
||||||
|
|
||||||
// deprecated routes
|
// deprecated routes
|
||||||
this.get('/seen-toggles', this.deprecated);
|
this.get('/seen-toggles', this.deprecated);
|
||||||
@ -195,9 +208,21 @@ class MetricsController extends Controller {
|
|||||||
}
|
}
|
||||||
async getApplicationOverview(
|
async getApplicationOverview(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response<ApplicationSchema>,
|
res: Response<ApplicationOverviewSchema>,
|
||||||
): Promise<void> {
|
): 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;
|
export default MetricsController;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Store } from './store';
|
import { Store } from './store';
|
||||||
import { IApplicationQuery } from '../query';
|
import { IApplicationQuery } from '../query';
|
||||||
|
import { IApplicationOverview } from '../../features/metrics/instance/models';
|
||||||
|
|
||||||
export interface IClientApplicationUsage {
|
export interface IClientApplicationUsage {
|
||||||
project: string;
|
project: string;
|
||||||
@ -28,4 +29,5 @@ export interface IClientApplicationsStore
|
|||||||
getAppsForStrategy(query: IApplicationQuery): Promise<IClientApplication[]>;
|
getAppsForStrategy(query: IApplicationQuery): Promise<IClientApplication[]>;
|
||||||
getUnannounced(): Promise<IClientApplication[]>;
|
getUnannounced(): Promise<IClientApplication[]>;
|
||||||
setUnannouncedToAnnounced(): 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';
|
} from '../../lib/types/stores/client-applications-store';
|
||||||
import NotFoundError from '../../lib/error/notfound-error';
|
import NotFoundError from '../../lib/error/notfound-error';
|
||||||
import { IApplicationQuery } from '../../lib/types/query';
|
import { IApplicationQuery } from '../../lib/types/query';
|
||||||
|
import { IApplicationOverview } from '../../lib/features/metrics/instance/models';
|
||||||
|
|
||||||
export default class FakeClientApplicationsStore
|
export default class FakeClientApplicationsStore
|
||||||
implements IClientApplicationsStore
|
implements IClientApplicationsStore
|
||||||
@ -78,4 +79,8 @@ export default class FakeClientApplicationsStore
|
|||||||
await this.delete(details.appName);
|
await this.delete(details.appName);
|
||||||
return this.bulkUpsert([details]);
|
return this.bulkUpsert([details]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getApplicationOverview(appName: string): Promise<IApplicationOverview> {
|
||||||
|
throw new Error('Method not implemented.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user