1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: project applications paging backend (#6312)

This commit is contained in:
Jaanus Sellin 2024-02-22 15:35:16 +02:00 committed by GitHub
parent adb6f61015
commit fb63f21d8a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 212 additions and 98 deletions

View File

@ -1,6 +1,4 @@
import React, { FC, useContext } from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useChangeRequest } from 'hooks/api/getters/useChangeRequest/useChangeRequest';
import React, { FC } from 'react';
import {
ClickAwayListener,

View File

@ -2,12 +2,14 @@ import EventEmitter from 'events';
import NotFoundError from '../error/notfound-error';
import {
IClientApplication,
IClientApplications,
IClientApplicationsSearchParams,
IClientApplicationsStore,
} from '../types/stores/client-applications-store';
import { Logger, LogProvider } from '../logger';
import { IApplicationQuery } from '../types/query';
import { Db } from './db';
import { IApplicationOverview } from '../features/metrics/instance/models';
import { applySearchFilters } from '../features/feature-search/search-utils';
const COLUMNS = [
'app_name',
@ -64,7 +66,12 @@ const reduceRows = (rows: any[]): IClientApplication[] => {
...mapRow(row),
usage:
project && environment
? [{ project, environments: [environment] }]
? [
{
project,
environments: [environment],
},
]
: [],
};
}
@ -173,38 +180,62 @@ export default class ClientApplicationsStore
return this.db(TABLE).where('app_name', appName).del();
}
/**
* Could also be done in SQL:
* (not sure if it is faster though)
*
* SELECT app_name from (
* SELECT app_name, json_array_elements(strategies)::text as strategyName from client_strategies
* ) as foo
* WHERE foo.strategyName = '"other"';
*/
async getAppsForStrategy(
query: IApplicationQuery,
): Promise<IClientApplication[]> {
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);
async getApplications(
params: IClientApplicationsSearchParams,
): Promise<IClientApplications> {
const { limit, offset, sortOrder = 'asc', searchParams } = params;
const validatedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
if (query.strategyName) {
return apps.filter((app) =>
app.strategies.includes(query.strategyName),
);
const query = this.db
.with('applications', (qb) => {
applySearchFilters(qb, searchParams, [
'client_applications.app_name',
]);
qb.select([
...COLUMNS.map((column) => `${TABLE}.${column}`),
'project',
'environment',
this.db.raw(
`DENSE_RANK() OVER (ORDER BY client_applications.app_name ${validatedSortOrder}) AS rank`,
),
])
.from(TABLE)
.leftJoin(
TABLE_USAGE,
`${TABLE_USAGE}.app_name`,
`${TABLE}.app_name`,
);
})
.with(
'final_ranks',
this.db.raw(
'select row_number() over (order by min(rank)) as final_rank from applications group by app_name',
),
)
.with(
'total',
this.db.raw('select count(*) as total from final_ranks'),
)
.select('*')
.from('applications')
.joinRaw('CROSS JOIN total')
.whereBetween('rank', [offset + 1, offset + limit]);
const rows = await query;
if (rows.length !== 0) {
const applications = reduceRows(rows);
return {
applications,
total: Number(rows[0].total) || 0,
};
}
return apps;
return {
applications: [],
total: 0,
};
}
async getUnannounced(): Promise<IClientApplication[]> {

View File

@ -19,6 +19,7 @@ import {
FeatureSearchQueryParameters,
featureSearchQueryParameters,
} from '../../openapi/spec/feature-search-query-parameters';
import { normalizeQueryParams } from './search-utils';
const PATH = '/features';
@ -83,17 +84,21 @@ export default class FeatureSearchController extends Controller {
createdAt,
state,
status,
offset,
limit = '50',
sortOrder,
sortBy,
favoritesFirst,
} = req.query;
const userId = req.user.id;
const normalizedQuery = query
?.split(',')
.map((query) => query.trim())
.filter((query) => query);
const {
normalizedQuery,
normalizedSortBy,
normalizedSortOrder,
normalizedOffset,
normalizedLimit,
} = normalizeQueryParams(req.query, {
limitDefault: 50,
maxLimit: 100,
sortByDefault: 'createdAt',
});
const normalizedStatus = status
?.map((tag) => tag.split(':'))
.filter(
@ -101,12 +106,6 @@ export default class FeatureSearchController extends Controller {
tag.length === 2 &&
['enabled', 'disabled'].includes(tag[1]),
);
const normalizedLimit =
Number(limit) > 0 && Number(limit) <= 100 ? Number(limit) : 25;
const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
const normalizedSortBy: string = sortBy ? sortBy : 'createdAt';
const normalizedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const normalizedFavoritesFirst = favoritesFirst === 'true';
const { features, total } = await this.featureSearchService.search({
searchParams: normalizedQuery,

View File

@ -1,6 +1,13 @@
import { Knex } from 'knex';
import { IQueryParam } from '../feature-toggle/types/feature-toggle-strategies-store-type';
export interface NormalizeParamsDefaults {
limitDefault: number;
maxLimit?: number; // Optional because you might not always want to enforce a max limit
sortByDefault: string;
typeDefault?: string; // Optional field for type, not required for every call
}
export const applySearchFilters = (
qb: Knex.QueryBuilder,
searchParams: string[] | undefined,
@ -45,3 +52,41 @@ export const applyGenericQueryParams = (
}
});
};
export const normalizeQueryParams = (
params,
defaults: NormalizeParamsDefaults,
) => {
const {
query,
offset,
limit = defaults.limitDefault,
sortOrder,
sortBy = defaults.sortByDefault,
} = params;
const normalizedQuery = query
?.split(',')
.map((query) => query.trim())
.filter((query) => query);
const maxLimit = defaults.maxLimit || 1000;
const normalizedLimit =
Number(limit) > 0 && Number(limit) <= maxLimit
? Number(limit)
: defaults.limitDefault;
const normalizedOffset = Number(offset) > 0 ? Number(offset) : 0;
const normalizedSortBy = sortBy;
const normalizedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
return {
normalizedQuery,
normalizedLimit,
normalizedOffset,
normalizedSortBy,
normalizedSortOrder,
};
};

View File

@ -5,12 +5,13 @@ import { IUnleashConfig } from '../../../types/option';
import { IEventStore } from '../../../types/stores/event-store';
import {
IClientApplication,
IClientApplications,
IClientApplicationsSearchParams,
IClientApplicationsStore,
} from '../../../types/stores/client-applications-store';
import { IFeatureToggleStore } from '../../feature-toggle/types/feature-toggle-store-type';
import { IStrategyStore } from '../../../types/stores/strategy-store';
import { IClientInstanceStore } from '../../../types/stores/client-instance-store';
import { IApplicationQuery } from '../../../types/query';
import { IClientApp } from '../../../types/model';
import { clientRegisterSchema } from '../shared/schema';
@ -155,28 +156,31 @@ export default class ClientInstanceService {
}
async getApplications(
query: IApplicationQuery,
query: IClientApplicationsSearchParams,
userId: number,
): Promise<IClientApplication[]> {
): Promise<IClientApplications> {
const applications =
await this.clientApplicationsStore.getAppsForStrategy(query);
await this.clientApplicationsStore.getApplications(query);
const accessibleProjects =
await this.privateProjectChecker.getUserAccessibleProjects(userId);
if (accessibleProjects.mode === 'all') {
return applications;
} else {
return applications.map((application) => {
return {
...application,
usage: application.usage?.filter(
(usageItem) =>
usageItem.project === ALL_PROJECTS ||
accessibleProjects.projects.includes(
usageItem.project,
),
),
};
});
return {
applications: applications.applications.map((application) => {
return {
...application,
usage: application.usage?.filter(
(usageItem) =>
usageItem.project === ALL_PROJECTS ||
accessibleProjects.projects.includes(
usageItem.project,
),
),
};
}),
total: applications.total,
};
}
}

View File

@ -39,6 +39,7 @@ import {
} from '../../openapi/spec/project-applications-schema';
import { NotFoundError } from '../../error';
import { projectApplicationsQueryParameters } from '../../openapi/spec/project-applications-query-parameters';
import { normalizeQueryParams } from '../feature-search/search-utils';
export default class ProjectController extends Controller {
private projectService: ProjectService;
@ -272,21 +273,19 @@ export default class ProjectController extends Controller {
throw new NotFoundError();
}
const { query, offset, limit = '50', sortOrder, sortBy } = req.query;
const { projectId } = req.params;
const normalizedQuery = query
?.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 {
normalizedQuery,
normalizedSortBy,
normalizedSortOrder,
normalizedOffset,
normalizedLimit,
} = normalizeQueryParams(req.query, {
limitDefault: 50,
maxLimit: 100,
sortByDefault: 'appName',
});
const applications = await this.projectService.getApplications({
searchParams: normalizedQuery,

View File

@ -24,6 +24,7 @@ import {
} from '../../openapi/spec/application-overview-schema';
import { OpenApiService } from '../../services';
import { applicationsQueryParameters } from '../../openapi/spec/applications-query-parameters';
import { normalizeQueryParams } from '../../features/feature-search/search-utils';
class MetricsController extends Controller {
private logger: Logger;
@ -162,7 +163,9 @@ class MetricsController extends Controller {
}
async deleteApplication(
req: Request<{ appName: string }>,
req: Request<{
appName: string;
}>,
res: Response,
): Promise<void> {
const { appName } = req.params;
@ -172,7 +175,13 @@ class MetricsController extends Controller {
}
async createApplication(
req: Request<{ appName: string }, unknown, CreateApplicationSchema>,
req: Request<
{
appName: string;
},
unknown,
CreateApplicationSchema
>,
res: Response,
): Promise<void> {
const input = {
@ -188,15 +197,29 @@ class MetricsController extends Controller {
res: Response<ApplicationsSchema>,
): Promise<void> {
const { user } = req;
const query = req.query.strategyName
? { strategyName: req.query.strategyName as string }
: {};
const {
normalizedQuery,
normalizedSortBy,
normalizedSortOrder,
normalizedOffset,
normalizedLimit,
} = normalizeQueryParams(req.query, {
limitDefault: 1000,
maxLimit: 1000,
sortByDefault: 'appName',
});
const applications = await this.clientInstanceService.getApplications(
query,
{
searchParams: normalizedQuery,
offset: normalizedOffset,
limit: normalizedLimit,
sortBy: normalizedSortBy,
sortOrder: normalizedSortOrder,
},
extractUserIdFromUser(user),
);
// todo: change to total with pagination later
res.json({ applications, total: applications.length });
res.json(applications);
}
async getApplication(
@ -209,6 +232,7 @@ class MetricsController extends Controller {
await this.clientInstanceService.getApplication(appName);
res.json(appDetails);
}
async getApplicationOverview(
req: Request,
res: Response<ApplicationOverviewSchema>,
@ -228,4 +252,5 @@ class MetricsController extends Controller {
);
}
}
export default MetricsController;

View File

@ -1,5 +1,4 @@
import { Store } from './store';
import { IApplicationQuery } from '../query';
import { IApplicationOverview } from '../../features/metrics/instance/models';
export interface IClientApplicationUsage {
@ -22,11 +21,26 @@ export interface IClientApplication {
usage?: IClientApplicationUsage[];
}
export interface IClientApplications {
applications: IClientApplication[];
total: number;
}
export interface IClientApplicationsSearchParams {
searchParams?: string[];
offset: number;
limit: number;
sortBy: string;
sortOrder: 'asc' | 'desc';
}
export interface IClientApplicationsStore
extends Store<IClientApplication, string> {
upsert(details: Partial<IClientApplication>): Promise<void>;
bulkUpsert(details: Partial<IClientApplication>[]): Promise<void>;
getAppsForStrategy(query: IApplicationQuery): Promise<IClientApplication[]>;
getApplications(
params: IClientApplicationsSearchParams,
): Promise<IClientApplications>;
getUnannounced(): Promise<IClientApplication[]>;
setUnannouncedToAnnounced(): Promise<IClientApplication[]>;
getApplicationOverview(appName: string): Promise<IApplicationOverview>;

View File

@ -1,9 +1,10 @@
import {
IClientApplication,
IClientApplications,
IClientApplicationsSearchParams,
IClientApplicationsStore,
} 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
@ -55,15 +56,13 @@ export default class FakeClientApplicationsStore
return this.get(appName);
}
async getAppsForStrategy(
query: IApplicationQuery,
): Promise<IClientApplication[]> {
if (query.strategyName) {
return this.apps.filter((a) =>
a.strategies.includes(query.strategyName),
);
}
return this.apps;
async getApplications(
query: IClientApplicationsSearchParams,
): Promise<IClientApplications> {
return {
applications: this.apps,
total: this.apps.length,
};
}
async getUnannounced(): Promise<IClientApplication[]> {