mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01:00
feat: project applications server side paging and sorting and filtering (#6236)
Uses exactly same pattern as search-store. Nothing too crazy here. Most code is in tests.
This commit is contained in:
parent
6a8f903bcf
commit
3d77825493
@ -7,7 +7,6 @@ import {
|
|||||||
LineChart,
|
LineChart,
|
||||||
NotEnoughData,
|
NotEnoughData,
|
||||||
} from '../LineChart/LineChart';
|
} from '../LineChart/LineChart';
|
||||||
import { type ScriptableContext } from 'chart.js';
|
|
||||||
|
|
||||||
interface IUsersChartProps {
|
interface IUsersChartProps {
|
||||||
userTrends: ExecutiveSummarySchema['userTrends'];
|
userTrends: ExecutiveSummarySchema['userTrends'];
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
IFeatureSearchParams,
|
IFeatureSearchParams,
|
||||||
IQueryParam,
|
IQueryParam,
|
||||||
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
|
import { applyGenericQueryParams, applySearchFilters } from './search-utils';
|
||||||
|
|
||||||
const sortEnvironments = (overview: IFeatureOverview[]) => {
|
const sortEnvironments = (overview: IFeatureOverview[]) => {
|
||||||
return overview.map((data: IFeatureOverview) => ({
|
return overview.map((data: IFeatureOverview) => ({
|
||||||
@ -87,28 +88,10 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
query.from('features');
|
query.from('features');
|
||||||
|
|
||||||
applyQueryParams(query, queryParams);
|
applyQueryParams(query, queryParams);
|
||||||
|
applySearchFilters(query, searchParams, [
|
||||||
const hasSearchParams = searchParams?.length;
|
'features.name',
|
||||||
if (hasSearchParams) {
|
'features.description',
|
||||||
const sqlParameters = searchParams.map(
|
]);
|
||||||
(item) => `%${item}%`,
|
|
||||||
);
|
|
||||||
const sqlQueryParameters = sqlParameters
|
|
||||||
.map(() => '?')
|
|
||||||
.join(',');
|
|
||||||
|
|
||||||
query.where((builder) => {
|
|
||||||
builder
|
|
||||||
.orWhereRaw(
|
|
||||||
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
|
|
||||||
['features.name', ...sqlParameters],
|
|
||||||
)
|
|
||||||
.orWhereRaw(
|
|
||||||
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
|
|
||||||
['features.description', ...sqlParameters],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type) {
|
if (type) {
|
||||||
query.whereIn('features.type', type);
|
query.whereIn('features.type', type);
|
||||||
@ -282,7 +265,6 @@ class FeatureSearchStore implements IFeatureSearchStore {
|
|||||||
)
|
)
|
||||||
.joinRaw('CROSS JOIN total_features')
|
.joinRaw('CROSS JOIN total_features')
|
||||||
.whereBetween('final_rank', [offset + 1, offset + limit]);
|
.whereBetween('final_rank', [offset + 1, offset + limit]);
|
||||||
|
|
||||||
const rows = await finalQuery;
|
const rows = await finalQuery;
|
||||||
stopTimer();
|
stopTimer();
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
@ -417,30 +399,6 @@ const applyQueryParams = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyGenericQueryParams = (
|
|
||||||
query: Knex.QueryBuilder,
|
|
||||||
queryParams: IQueryParam[],
|
|
||||||
): void => {
|
|
||||||
queryParams.forEach((param) => {
|
|
||||||
switch (param.operator) {
|
|
||||||
case 'IS':
|
|
||||||
case 'IS_ANY_OF':
|
|
||||||
query.whereIn(param.field, param.values);
|
|
||||||
break;
|
|
||||||
case 'IS_NOT':
|
|
||||||
case 'IS_NONE_OF':
|
|
||||||
query.whereNotIn(param.field, param.values);
|
|
||||||
break;
|
|
||||||
case 'IS_BEFORE':
|
|
||||||
query.where(param.field, '<', param.values[0]);
|
|
||||||
break;
|
|
||||||
case 'IS_ON_OR_AFTER':
|
|
||||||
query.where(param.field, '>=', param.values[0]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyMultiQueryParams = (
|
const applyMultiQueryParams = (
|
||||||
query: Knex.QueryBuilder,
|
query: Knex.QueryBuilder,
|
||||||
queryParams: IQueryParam[],
|
queryParams: IQueryParam[],
|
||||||
|
47
src/lib/features/feature-search/search-utils.ts
Normal file
47
src/lib/features/feature-search/search-utils.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type';
|
||||||
|
|
||||||
|
export const applySearchFilters = (
|
||||||
|
qb: Knex.QueryBuilder,
|
||||||
|
searchParams: string[] | undefined,
|
||||||
|
columns: string[],
|
||||||
|
): void => {
|
||||||
|
const hasSearchParams = searchParams?.length;
|
||||||
|
if (hasSearchParams) {
|
||||||
|
const sqlParameters = searchParams.map((item) => `%${item}%`);
|
||||||
|
const sqlQueryParameters = sqlParameters.map(() => '?').join(',');
|
||||||
|
|
||||||
|
qb.where((builder) => {
|
||||||
|
columns.forEach((column) => {
|
||||||
|
builder.orWhereRaw(
|
||||||
|
`(${column}) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
|
||||||
|
sqlParameters,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyGenericQueryParams = (
|
||||||
|
query: Knex.QueryBuilder,
|
||||||
|
queryParams: IQueryParam[],
|
||||||
|
): void => {
|
||||||
|
queryParams.forEach((param) => {
|
||||||
|
switch (param.operator) {
|
||||||
|
case 'IS':
|
||||||
|
case 'IS_ANY_OF':
|
||||||
|
query.whereIn(param.field, param.values);
|
||||||
|
break;
|
||||||
|
case 'IS_NOT':
|
||||||
|
case 'IS_NONE_OF':
|
||||||
|
query.whereNotIn(param.field, param.values);
|
||||||
|
break;
|
||||||
|
case 'IS_BEFORE':
|
||||||
|
query.where(param.field, '<', param.values[0]);
|
||||||
|
break;
|
||||||
|
case 'IS_ON_OR_AFTER':
|
||||||
|
query.where(param.field, '>=', param.values[0]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -89,19 +89,22 @@ test('should return applications', async () => {
|
|||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(body).toMatchObject([
|
expect(body).toMatchObject({
|
||||||
{
|
applications: [
|
||||||
environments: ['default'],
|
{
|
||||||
instances: ['instanceId'],
|
environments: ['default'],
|
||||||
name: 'appName',
|
instances: ['instanceId'],
|
||||||
sdks: [
|
name: 'appName',
|
||||||
{
|
sdks: [
|
||||||
name: 'unleash-client-test',
|
{
|
||||||
versions: ['1.2'],
|
name: 'unleash-client-test',
|
||||||
},
|
versions: ['1.2'],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
]);
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return applications if sdk was not in database', async () => {
|
test('should return applications if sdk was not in database', async () => {
|
||||||
@ -128,14 +131,17 @@ test('should return applications if sdk was not in database', async () => {
|
|||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(body).toMatchObject([
|
expect(body).toMatchObject({
|
||||||
{
|
applications: [
|
||||||
environments: ['default'],
|
{
|
||||||
instances: ['instanceId'],
|
environments: ['default'],
|
||||||
name: 'appName',
|
instances: ['instanceId'],
|
||||||
sdks: [],
|
name: 'appName',
|
||||||
},
|
sdks: [],
|
||||||
]);
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return application without version if sdk has just name', async () => {
|
test('should return application without version if sdk has just name', async () => {
|
||||||
@ -163,17 +169,222 @@ test('should return application without version if sdk has just name', async ()
|
|||||||
.expect('Content-Type', /json/)
|
.expect('Content-Type', /json/)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
|
|
||||||
expect(body).toMatchObject([
|
expect(body).toMatchObject({
|
||||||
{
|
applications: [
|
||||||
environments: ['default'],
|
{
|
||||||
instances: ['instanceId'],
|
environments: ['default'],
|
||||||
name: 'appName',
|
instances: ['instanceId'],
|
||||||
sdks: [
|
name: 'appName',
|
||||||
{
|
sdks: [
|
||||||
name: 'unleash-client-test',
|
{
|
||||||
versions: [],
|
name: 'unleash-client-test',
|
||||||
},
|
versions: [],
|
||||||
],
|
},
|
||||||
},
|
],
|
||||||
]);
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by appName descending', async () => {
|
||||||
|
await app.createFeature('toggle-name-1');
|
||||||
|
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: metrics.appName,
|
||||||
|
instanceId: metrics.instanceId,
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-client-test',
|
||||||
|
started: Date.now(),
|
||||||
|
interval: 10,
|
||||||
|
});
|
||||||
|
const secondApp = 'second-app';
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: secondApp,
|
||||||
|
instanceId: metrics.instanceId,
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-client-test',
|
||||||
|
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.request
|
||||||
|
.post('/api/client/metrics')
|
||||||
|
.set('Authorization', defaultToken.secret)
|
||||||
|
.send({
|
||||||
|
...metrics,
|
||||||
|
appName: secondApp,
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
await app.services.clientMetricsServiceV2.bulkAdd();
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get('/api/admin/projects/default/applications?sortOrder=desc')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
applications: [
|
||||||
|
{
|
||||||
|
environments: ['default'],
|
||||||
|
instances: ['instanceId'],
|
||||||
|
name: 'second-app',
|
||||||
|
sdks: [
|
||||||
|
{
|
||||||
|
name: 'unleash-client-test',
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
environments: ['default'],
|
||||||
|
instances: ['instanceId'],
|
||||||
|
name: 'appName',
|
||||||
|
sdks: [
|
||||||
|
{
|
||||||
|
name: 'unleash-client-test',
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by sdk', async () => {
|
||||||
|
await app.createFeature('toggle-name-1');
|
||||||
|
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: metrics.appName,
|
||||||
|
instanceId: metrics.instanceId,
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-java-test',
|
||||||
|
started: Date.now(),
|
||||||
|
interval: 10,
|
||||||
|
});
|
||||||
|
const secondApp = 'second-app';
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: secondApp,
|
||||||
|
instanceId: metrics.instanceId,
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-client-test',
|
||||||
|
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.request
|
||||||
|
.post('/api/client/metrics')
|
||||||
|
.set('Authorization', defaultToken.secret)
|
||||||
|
.send({
|
||||||
|
...metrics,
|
||||||
|
appName: secondApp,
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
await app.services.clientMetricsServiceV2.bulkAdd();
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get('/api/admin/projects/default/applications?&query=java')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
applications: [
|
||||||
|
{
|
||||||
|
environments: ['default'],
|
||||||
|
instances: ['instanceId'],
|
||||||
|
name: 'appName',
|
||||||
|
sdks: [
|
||||||
|
{
|
||||||
|
name: 'unleash-java-test',
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show correct number of total', async () => {
|
||||||
|
await app.createFeature('toggle-name-1');
|
||||||
|
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: metrics.appName,
|
||||||
|
instanceId: metrics.instanceId,
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-client-test',
|
||||||
|
started: Date.now(),
|
||||||
|
interval: 10,
|
||||||
|
});
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: metrics.appName,
|
||||||
|
instanceId: 'another-instance',
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-client-test',
|
||||||
|
started: Date.now(),
|
||||||
|
interval: 10,
|
||||||
|
});
|
||||||
|
const secondApp = 'second-app';
|
||||||
|
await app.request.post('/api/client/register').send({
|
||||||
|
appName: secondApp,
|
||||||
|
instanceId: metrics.instanceId,
|
||||||
|
strategies: ['default'],
|
||||||
|
sdkVersion: 'unleash-client-test',
|
||||||
|
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.request
|
||||||
|
.post('/api/client/metrics')
|
||||||
|
.set('Authorization', defaultToken.secret)
|
||||||
|
.send({
|
||||||
|
...metrics,
|
||||||
|
appName: secondApp,
|
||||||
|
})
|
||||||
|
.expect(202);
|
||||||
|
|
||||||
|
await app.services.clientMetricsServiceV2.bulkAdd();
|
||||||
|
|
||||||
|
const { body } = await app.request
|
||||||
|
.get('/api/admin/projects/default/applications?sortOrder=desc&limit=1')
|
||||||
|
.expect('Content-Type', /json/)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
applications: [
|
||||||
|
{
|
||||||
|
environments: ['default'],
|
||||||
|
instances: ['instanceId'],
|
||||||
|
name: 'second-app',
|
||||||
|
sdks: [
|
||||||
|
{
|
||||||
|
name: 'unleash-client-test',
|
||||||
|
versions: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -267,10 +267,30 @@ export default class ProjectController extends Controller {
|
|||||||
throw new NotFoundError();
|
throw new NotFoundError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { query, offset, limit = '50', sortOrder, sortBy } = req.query;
|
||||||
|
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
|
|
||||||
const applications =
|
const normalizedQuery = query
|
||||||
await this.projectService.getApplications(projectId);
|
?.split(',')
|
||||||
|
.map((query) => query.trim())
|
||||||
|
.filter((query) => query);
|
||||||
|
|
||||||
|
const normalizedLimit =
|
||||||
|
Number(limit) > 0 && Number(limit) <= 100 ? Number(limit) : 25;
|
||||||
|
const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
|
||||||
|
const normalizedSortBy: string = sortBy ? sortBy : 'appName';
|
||||||
|
const normalizedSortOrder =
|
||||||
|
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
|
||||||
|
|
||||||
|
const applications = await this.projectService.getApplications({
|
||||||
|
searchParams: normalizedQuery,
|
||||||
|
project: projectId,
|
||||||
|
offset: normalizedOffset,
|
||||||
|
limit: normalizedLimit,
|
||||||
|
sortBy: normalizedSortBy,
|
||||||
|
sortOrder: normalizedSortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
this.openApiService.respondWithValidation(
|
this.openApiService.respondWithValidation(
|
||||||
200,
|
200,
|
||||||
|
@ -42,8 +42,8 @@ import {
|
|||||||
IProjectUpdate,
|
IProjectUpdate,
|
||||||
IProjectHealth,
|
IProjectHealth,
|
||||||
SYSTEM_USER,
|
SYSTEM_USER,
|
||||||
IProjectApplication,
|
|
||||||
IProjectStore,
|
IProjectStore,
|
||||||
|
IProjectApplications,
|
||||||
} from '../../types';
|
} from '../../types';
|
||||||
import {
|
import {
|
||||||
IProjectAccessModel,
|
IProjectAccessModel,
|
||||||
@ -65,6 +65,7 @@ import { checkFeatureNamingData } from '../feature-naming-pattern/feature-naming
|
|||||||
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
|
import { IPrivateProjectChecker } from '../private-project/privateProjectCheckerType';
|
||||||
import EventService from '../events/event-service';
|
import EventService from '../events/event-service';
|
||||||
import {
|
import {
|
||||||
|
IProjectApplicationsSearchParams,
|
||||||
IProjectEnterpriseSettingsUpdate,
|
IProjectEnterpriseSettingsUpdate,
|
||||||
IProjectQuery,
|
IProjectQuery,
|
||||||
} from './project-store-type';
|
} from './project-store-type';
|
||||||
@ -901,9 +902,11 @@ export default class ProjectService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getApplications(projectId: string): Promise<IProjectApplication[]> {
|
async getApplications(
|
||||||
|
searchParams: IProjectApplicationsSearchParams,
|
||||||
|
): Promise<IProjectApplications> {
|
||||||
const applications =
|
const applications =
|
||||||
await this.projectStore.getApplicationsByProject(projectId);
|
await this.projectStore.getApplicationsByProject(searchParams);
|
||||||
return applications;
|
return applications;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
IEnvironment,
|
IEnvironment,
|
||||||
IFeatureNaming,
|
IFeatureNaming,
|
||||||
IProject,
|
IProject,
|
||||||
IProjectApplication,
|
IProjectApplications,
|
||||||
IProjectWithCount,
|
IProjectWithCount,
|
||||||
ProjectMode,
|
ProjectMode,
|
||||||
} from '../../types/model';
|
} from '../../types/model';
|
||||||
@ -55,6 +55,15 @@ export type ProjectEnvironment = {
|
|||||||
defaultStrategy?: CreateFeatureStrategySchema;
|
defaultStrategy?: CreateFeatureStrategySchema;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface IProjectApplicationsSearchParams {
|
||||||
|
searchParams?: string[];
|
||||||
|
project?: string;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
sortBy: string;
|
||||||
|
sortOrder: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
export interface IProjectStore extends Store<IProject, string> {
|
export interface IProjectStore extends Store<IProject, string> {
|
||||||
hasProject(id: string): Promise<boolean>;
|
hasProject(id: string): Promise<boolean>;
|
||||||
|
|
||||||
@ -122,5 +131,7 @@ export interface IProjectStore extends Store<IProject, string> {
|
|||||||
isFeatureLimitReached(id: string): Promise<boolean>;
|
isFeatureLimitReached(id: string): Promise<boolean>;
|
||||||
|
|
||||||
getProjectModeCounts(): Promise<ProjectModeCount[]>;
|
getProjectModeCounts(): Promise<ProjectModeCount[]>;
|
||||||
getApplicationsByProject(projectId: string): Promise<IProjectApplication[]>;
|
getApplicationsByProject(
|
||||||
|
searchParams: IProjectApplicationsSearchParams,
|
||||||
|
): Promise<IProjectApplications>;
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import {
|
|||||||
IFlagResolver,
|
IFlagResolver,
|
||||||
IProject,
|
IProject,
|
||||||
IProjectApplication,
|
IProjectApplication,
|
||||||
|
IProjectApplications,
|
||||||
IProjectUpdate,
|
IProjectUpdate,
|
||||||
IProjectWithCount,
|
IProjectWithCount,
|
||||||
ProjectMode,
|
ProjectMode,
|
||||||
@ -19,6 +20,7 @@ import {
|
|||||||
IProjectEnterpriseSettingsUpdate,
|
IProjectEnterpriseSettingsUpdate,
|
||||||
IProjectStore,
|
IProjectStore,
|
||||||
ProjectEnvironment,
|
ProjectEnvironment,
|
||||||
|
IProjectApplicationsSearchParams,
|
||||||
} from '../../features/project/project-store-type';
|
} from '../../features/project/project-store-type';
|
||||||
import { DEFAULT_ENV } from '../../util';
|
import { DEFAULT_ENV } from '../../util';
|
||||||
import metricsHelper from '../../util/metrics-helper';
|
import metricsHelper from '../../util/metrics-helper';
|
||||||
@ -27,6 +29,7 @@ import EventEmitter from 'events';
|
|||||||
import { Db } from '../../db/db';
|
import { Db } from '../../db/db';
|
||||||
import Raw = Knex.Raw;
|
import Raw = Knex.Raw;
|
||||||
import { CreateFeatureStrategySchema } from '../../openapi';
|
import { CreateFeatureStrategySchema } from '../../openapi';
|
||||||
|
import { applySearchFilters } from '../feature-search/search-utils';
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
'id',
|
'id',
|
||||||
@ -579,32 +582,66 @@ class ProjectStore implements IProjectStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getApplicationsByProject(
|
async getApplicationsByProject(
|
||||||
projectId: string,
|
params: IProjectApplicationsSearchParams,
|
||||||
): Promise<IProjectApplication[]> {
|
): Promise<IProjectApplications> {
|
||||||
|
const { project, limit, sortOrder, sortBy, searchParams, offset } =
|
||||||
|
params;
|
||||||
|
const validatedSortOrder =
|
||||||
|
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
|
||||||
const query = this.db
|
const query = this.db
|
||||||
.with('applications', (qb) => {
|
.with('applications', (qb) => {
|
||||||
qb.select('project', 'app_name', 'environment')
|
qb.select('project', 'app_name', 'environment')
|
||||||
.distinct()
|
.distinct()
|
||||||
.from('client_metrics_env as cme')
|
.from('client_metrics_env as cme')
|
||||||
.leftJoin('features as f', 'cme.feature_name', 'f.name')
|
.leftJoin('features as f', 'cme.feature_name', 'f.name')
|
||||||
.where('project', projectId);
|
.where('project', project);
|
||||||
})
|
})
|
||||||
.select(
|
.with('ranked', (qb) => {
|
||||||
'a.app_name',
|
applySearchFilters(qb, searchParams, [
|
||||||
'a.environment',
|
'a.app_name',
|
||||||
'ci.instance_id',
|
|
||||||
'ci.sdk_version',
|
|
||||||
)
|
|
||||||
.from('applications as a')
|
|
||||||
.leftJoin('client_instances as ci', function () {
|
|
||||||
this.on('ci.app_name', 'a.app_name').andOn(
|
|
||||||
'ci.environment',
|
|
||||||
'a.environment',
|
'a.environment',
|
||||||
);
|
'ci.instance_id',
|
||||||
});
|
'ci.sdk_version',
|
||||||
|
]);
|
||||||
|
|
||||||
|
qb.select(
|
||||||
|
'a.app_name',
|
||||||
|
'a.environment',
|
||||||
|
'ci.instance_id',
|
||||||
|
'ci.sdk_version',
|
||||||
|
this.db.raw(
|
||||||
|
`DENSE_RANK() OVER (ORDER BY a.app_name ${validatedSortOrder}) AS rank`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.from('applications as a')
|
||||||
|
.leftJoin('client_instances as ci', function () {
|
||||||
|
this.on('ci.app_name', '=', 'a.app_name').andOn(
|
||||||
|
'ci.environment',
|
||||||
|
'=',
|
||||||
|
'a.environment',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.with(
|
||||||
|
'final_ranks',
|
||||||
|
this.db.raw(
|
||||||
|
'select row_number() over (order by min(rank)) as final_rank from ranked group by app_name',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
'total',
|
||||||
|
this.db.raw('select count(*) as total from final_ranks'),
|
||||||
|
)
|
||||||
|
.select('*')
|
||||||
|
.from('ranked')
|
||||||
|
.joinRaw('CROSS JOIN total')
|
||||||
|
.whereBetween('rank', [offset + 1, offset + limit]);
|
||||||
const rows = await query;
|
const rows = await query;
|
||||||
const applications = this.getAggregatedApplicationsData(rows);
|
const applications = this.getAggregatedApplicationsData(rows);
|
||||||
return applications;
|
return {
|
||||||
|
applications,
|
||||||
|
total: Number(rows[0].total) || 0,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDefaultStrategy(
|
async getDefaultStrategy(
|
||||||
@ -751,7 +788,10 @@ class ProjectStore implements IProjectStore {
|
|||||||
let sdk = entry.sdks.find((sdk) => sdk.name === sdkName);
|
let sdk = entry.sdks.find((sdk) => sdk.name === sdkName);
|
||||||
|
|
||||||
if (!sdk) {
|
if (!sdk) {
|
||||||
sdk = { name: sdkName, versions: [] };
|
sdk = {
|
||||||
|
name: sdkName,
|
||||||
|
versions: [],
|
||||||
|
};
|
||||||
entry.sdks.push(sdk);
|
entry.sdks.push(sdk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2,19 +2,22 @@ import { validateSchema } from '../validate';
|
|||||||
import { ProjectApplicationsSchema } from './project-applications-schema';
|
import { ProjectApplicationsSchema } from './project-applications-schema';
|
||||||
|
|
||||||
test('projectApplicationsSchema', () => {
|
test('projectApplicationsSchema', () => {
|
||||||
const data: ProjectApplicationsSchema = [
|
const data: ProjectApplicationsSchema = {
|
||||||
{
|
total: 55,
|
||||||
name: 'my-weba-app',
|
applications: [
|
||||||
environments: ['development', 'production'],
|
{
|
||||||
instances: ['instance-414122'],
|
name: 'my-weba-app',
|
||||||
sdks: [
|
environments: ['development', 'production'],
|
||||||
{
|
instances: ['instance-414122'],
|
||||||
name: 'unleash-client-node',
|
sdks: [
|
||||||
versions: ['4.1.1'],
|
{
|
||||||
},
|
name: 'unleash-client-node',
|
||||||
],
|
versions: ['4.1.1'],
|
||||||
},
|
},
|
||||||
];
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
validateSchema('#/components/schemas/projectApplicationsSchema', data),
|
validateSchema('#/components/schemas/projectApplicationsSchema', data),
|
||||||
|
@ -4,10 +4,22 @@ import { projectApplicationSdkSchema } from './project-application-sdk-schema';
|
|||||||
|
|
||||||
export const projectApplicationsSchema = {
|
export const projectApplicationsSchema = {
|
||||||
$id: '#/components/schemas/projectApplicationsSchema',
|
$id: '#/components/schemas/projectApplicationsSchema',
|
||||||
type: 'array',
|
type: 'object',
|
||||||
description: 'A list of project applications',
|
description: 'A list of project applications',
|
||||||
items: {
|
required: ['total', 'applications'],
|
||||||
$ref: '#/components/schemas/projectApplicationSchema',
|
properties: {
|
||||||
|
total: {
|
||||||
|
type: 'integer',
|
||||||
|
example: 50,
|
||||||
|
description: 'The total number of project applications.',
|
||||||
|
},
|
||||||
|
applications: {
|
||||||
|
type: 'array',
|
||||||
|
items: {
|
||||||
|
$ref: '#/components/schemas/projectApplicationSchema',
|
||||||
|
},
|
||||||
|
description: 'All applications defined for a specific project.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
@ -478,6 +478,11 @@ export interface IProject {
|
|||||||
featureNaming?: IFeatureNaming;
|
featureNaming?: IFeatureNaming;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IProjectApplications {
|
||||||
|
applications: IProjectApplication[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IProjectApplication {
|
export interface IProjectApplication {
|
||||||
name: string;
|
name: string;
|
||||||
environments: string[];
|
environments: string[];
|
||||||
|
7
src/test/fixtures/fake-project-store.ts
vendored
7
src/test/fixtures/fake-project-store.ts
vendored
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IEnvironment,
|
IEnvironment,
|
||||||
IProject,
|
IProject,
|
||||||
IProjectApplication,
|
IProjectApplications,
|
||||||
IProjectStore,
|
IProjectStore,
|
||||||
IProjectWithCount,
|
IProjectWithCount,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from '../../lib/features/project/project-store';
|
} from '../../lib/features/project/project-store';
|
||||||
import { CreateFeatureStrategySchema } from '../../lib/openapi';
|
import { CreateFeatureStrategySchema } from '../../lib/openapi';
|
||||||
import {
|
import {
|
||||||
|
IProjectApplicationsSearchParams,
|
||||||
IProjectHealthUpdate,
|
IProjectHealthUpdate,
|
||||||
IProjectInsert,
|
IProjectInsert,
|
||||||
ProjectEnvironment,
|
ProjectEnvironment,
|
||||||
@ -209,8 +210,8 @@ export default class FakeProjectStore implements IProjectStore {
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
getApplicationsByProject(
|
getApplicationsByProject(
|
||||||
projectId: string,
|
searchParams: IProjectApplicationsSearchParams,
|
||||||
): Promise<IProjectApplication[]> {
|
): Promise<IProjectApplications> {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user