1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +02:00

feat: add last seen filter to feature and project searches

This commit is contained in:
Tymoteusz Czech 2025-07-31 21:28:07 +02:00
parent 2629705501
commit c9df6c370f
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
11 changed files with 118 additions and 1 deletions

View File

@ -105,6 +105,13 @@ export const FeatureToggleFilters: VFC<IFeatureToggleFiltersProps> = ({
filterKey: 'createdAt', filterKey: 'createdAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'], dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
}, },
{
label: 'Last seen',
icon: 'visibility',
options: [],
filterKey: 'lastSeenAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
},
{ {
label: 'Flag type', label: 'Flag type',
icon: 'flag', icon: 'flag',

View File

@ -31,6 +31,7 @@ export const useGlobalFeatureSearch = (pageLimit = DEFAULT_PAGE_LIMIT) => {
state: FilterItemParam, state: FilterItemParam,
segment: FilterItemParam, segment: FilterItemParam,
createdAt: FilterItemParam, createdAt: FilterItemParam,
lastSeenAt: FilterItemParam,
type: FilterItemParam, type: FilterItemParam,
lifecycle: FilterItemParam, lifecycle: FilterItemParam,
createdBy: FilterItemParam, createdBy: FilterItemParam,

View File

@ -81,6 +81,13 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
filterKey: 'createdAt', filterKey: 'createdAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'], dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
}, },
{
label: 'Last seen',
icon: 'visibility',
options: [],
filterKey: 'lastSeenAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
},
{ {
label: 'Flag type', label: 'Flag type',
icon: 'flag', icon: 'flag',

View File

@ -38,6 +38,7 @@ export const useProjectFeatureSearch = (
tag: FilterItemParam, tag: FilterItemParam,
state: FilterItemParam, state: FilterItemParam,
createdAt: FilterItemParam, createdAt: FilterItemParam,
lastSeenAt: FilterItemParam,
type: FilterItemParam, type: FilterItemParam,
createdBy: FilterItemParam, createdBy: FilterItemParam,
archived: FilterItemParam, archived: FilterItemParam,

View File

@ -70,4 +70,8 @@ export type SearchFeaturesParams = {
* The date the feature was created. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER. * The date the feature was created. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.
*/ */
createdAt?: string; createdAt?: string;
/**
* The date the feature was last seen (either from metrics or manual report). The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.
*/
lastSeenAt?: string;
}; };

View File

@ -108,6 +108,7 @@ export default class FeatureSearchController extends Controller {
favoritesFirst, favoritesFirst,
archived, archived,
sortBy, sortBy,
lastSeenAt,
} = req.query; } = req.query;
const userId = req.user.id; const userId = req.user.id;
const { const {
@ -149,6 +150,7 @@ export default class FeatureSearchController extends Controller {
createdBy, createdBy,
sortBy, sortBy,
lifecycle, lifecycle,
lastSeenAt,
status: normalizedStatus, status: normalizedStatus,
offset: normalizedOffset, offset: normalizedOffset,
limit: normalizedLimit, limit: normalizedLimit,

View File

@ -73,6 +73,14 @@ export class FeatureSearchService {
if (parsed) queryParams.push(parsed); if (parsed) queryParams.push(parsed);
} }
if (params.lastSeenAt) {
const parsed = parseSearchOperatorValue(
'lastSeenAt',
params.lastSeenAt,
);
if (parsed) queryParams.push(parsed);
}
['tag', 'segment', 'project'].forEach((field) => { ['tag', 'segment', 'project'].forEach((field) => {
if (params[field]) { if (params[field]) {
const parsed = parseSearchOperatorValue(field, params[field]); const parsed = parseSearchOperatorValue(field, params[field]);

View File

@ -771,6 +771,26 @@ const applyStaleConditions = (
} }
} }
}; };
const applyLastSeenAtConditions = (
query: Knex.QueryBuilder,
lastSeenAtConditions: IQueryParam[],
): void => {
lastSeenAtConditions.forEach((param) => {
const lastSeenAtExpression = query.client.raw(
'coalesce(last_seen_at_metrics.last_seen_at, features.last_seen_at)',
);
switch (param.operator) {
case 'IS_BEFORE':
query.where(lastSeenAtExpression, '<', param.values[0]);
break;
case 'IS_ON_OR_AFTER':
query.where(lastSeenAtExpression, '>=', param.values[0]);
break;
}
});
};
const applyQueryParams = ( const applyQueryParams = (
query: Knex.QueryBuilder, query: Knex.QueryBuilder,
queryParams: IQueryParam[], queryParams: IQueryParam[],
@ -782,12 +802,17 @@ const applyQueryParams = (
const segmentConditions = queryParams.filter( const segmentConditions = queryParams.filter(
(param) => param.field === 'segment', (param) => param.field === 'segment',
); );
const lastSeenAtConditions = queryParams.filter(
(param) => param.field === 'lastSeenAt',
);
const genericConditions = queryParams.filter( const genericConditions = queryParams.filter(
(param) => !['tag', 'stale'].includes(param.field), (param) =>
!['tag', 'stale', 'segment', 'lastSeenAt'].includes(param.field),
); );
applyGenericQueryParams(query, genericConditions); applyGenericQueryParams(query, genericConditions);
applyStaleConditions(query, staleConditions); applyStaleConditions(query, staleConditions);
applyLastSeenAtConditions(query, lastSeenAtConditions);
applyMultiQueryParams( applyMultiQueryParams(
query, query,

View File

@ -1085,6 +1085,56 @@ test('should filter features by combined operators', async () => {
}); });
}); });
test('should filter features by lastSeenAt', async () => {
await app.createFeature({
name: 'recently_seen_feature',
});
await app.createFeature({
name: 'old_seen_feature',
});
// Insert lastSeenAt data for both features
const recentDate = new Date();
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 10); // 10 days ago
await insertLastSeenAt(
'recently_seen_feature',
db.rawDatabase,
DEFAULT_ENV,
recentDate.toISOString(),
);
await insertLastSeenAt(
'old_seen_feature',
db.rawDatabase,
DEFAULT_ENV,
oldDate.toISOString(),
);
// Filter for features seen in the last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
const { body: recentFeatures } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${sevenDaysAgo.toISOString()}`,
)
.expect(200);
expect(recentFeatures.features).toHaveLength(1);
expect(recentFeatures.features[0].name).toBe('recently_seen_feature');
// Filter for features seen before 7 days ago
const { body: oldFeatures } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_BEFORE:${sevenDaysAgo.toISOString()}`,
)
.expect(200);
expect(oldFeatures.features).toHaveLength(1);
expect(oldFeatures.features[0].name).toBe('old_seen_feature');
});
test('should return environment usage metrics and lifecycle', async () => { test('should return environment usage metrics and lifecycle', async () => {
await app.createFeature({ await app.createFeature({
name: 'my_feature_b', name: 'my_feature_b',

View File

@ -31,6 +31,7 @@ export interface IFeatureSearchParams {
type?: string; type?: string;
tag?: string; tag?: string;
lifecycle?: string; lifecycle?: string;
lastSeenAt?: string;
status?: string[][]; status?: string[][];
offset: number; offset: number;
favoritesFirst?: boolean; favoritesFirst?: boolean;

View File

@ -179,6 +179,17 @@ export const featureSearchQueryParameters = [
'The date the feature was created. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.', 'The date the feature was created. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.',
in: 'query', in: 'query',
}, },
{
name: 'lastSeenAt',
schema: {
type: 'string',
example: 'IS_ON_OR_AFTER:2023-01-28',
pattern: '^(IS_BEFORE|IS_ON_OR_AFTER):\\d{4}-\\d{2}-\\d{2}$',
},
description:
'The date the feature was last seen from metrics. The date can be specified with an operator. The supported operators are IS_BEFORE, IS_ON_OR_AFTER.',
in: 'query',
},
] as const; ] as const;
export type FeatureSearchQueryParameters = Partial< export type FeatureSearchQueryParameters = Partial<