From c9df6c370fb6de9e8af7d999e6127f7c3c1af820 Mon Sep 17 00:00:00 2001 From: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com> Date: Thu, 31 Jul 2025 21:28:07 +0200 Subject: [PATCH] feat: add last seen filter to feature and project searches --- .../FeatureToggleFilters.tsx | 7 +++ .../useGlobalFeatureSearch.ts | 1 + .../ProjectOverviewFilters.tsx | 7 +++ .../useProjectFeatureSearch.ts | 1 + .../openapi/models/searchFeaturesParams.ts | 4 ++ .../feature-search-controller.ts | 2 + .../feature-search/feature-search-service.ts | 8 +++ .../feature-search/feature-search-store.ts | 27 +++++++++- .../feature-search/feature.search.e2e.test.ts | 50 +++++++++++++++++++ .../feature-toggle-strategies-store-type.ts | 1 + .../spec/feature-search-query-parameters.ts | 11 ++++ 11 files changed, 118 insertions(+), 1 deletion(-) diff --git a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx index f605182b83..4ad248a2e9 100644 --- a/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx +++ b/frontend/src/component/feature/FeatureToggleList/FeatureToggleFilters/FeatureToggleFilters.tsx @@ -105,6 +105,13 @@ export const FeatureToggleFilters: VFC = ({ filterKey: 'createdAt', 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', icon: 'flag', diff --git a/frontend/src/component/feature/FeatureToggleList/useGlobalFeatureSearch.ts b/frontend/src/component/feature/FeatureToggleList/useGlobalFeatureSearch.ts index 32b5a7cab7..01c9a6721d 100644 --- a/frontend/src/component/feature/FeatureToggleList/useGlobalFeatureSearch.ts +++ b/frontend/src/component/feature/FeatureToggleList/useGlobalFeatureSearch.ts @@ -31,6 +31,7 @@ export const useGlobalFeatureSearch = (pageLimit = DEFAULT_PAGE_LIMIT) => { state: FilterItemParam, segment: FilterItemParam, createdAt: FilterItemParam, + lastSeenAt: FilterItemParam, type: FilterItemParam, lifecycle: FilterItemParam, createdBy: FilterItemParam, diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx index 017ca188cc..2a909d0201 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/ProjectOverviewFilters.tsx @@ -81,6 +81,13 @@ export const ProjectOverviewFilters: VFC = ({ filterKey: 'createdAt', 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', icon: 'flag', diff --git a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts index c1cfb6e969..78c43401e0 100644 --- a/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts +++ b/frontend/src/component/project/Project/PaginatedProjectFeatureToggles/useProjectFeatureSearch.ts @@ -38,6 +38,7 @@ export const useProjectFeatureSearch = ( tag: FilterItemParam, state: FilterItemParam, createdAt: FilterItemParam, + lastSeenAt: FilterItemParam, type: FilterItemParam, createdBy: FilterItemParam, archived: FilterItemParam, diff --git a/frontend/src/openapi/models/searchFeaturesParams.ts b/frontend/src/openapi/models/searchFeaturesParams.ts index e9d09741bb..6ce961eab2 100644 --- a/frontend/src/openapi/models/searchFeaturesParams.ts +++ b/frontend/src/openapi/models/searchFeaturesParams.ts @@ -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. */ 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; }; diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index c6c99e5825..4a83125213 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -108,6 +108,7 @@ export default class FeatureSearchController extends Controller { favoritesFirst, archived, sortBy, + lastSeenAt, } = req.query; const userId = req.user.id; const { @@ -149,6 +150,7 @@ export default class FeatureSearchController extends Controller { createdBy, sortBy, lifecycle, + lastSeenAt, status: normalizedStatus, offset: normalizedOffset, limit: normalizedLimit, diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 8525dbffbe..605a666c6b 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -73,6 +73,14 @@ export class FeatureSearchService { if (parsed) queryParams.push(parsed); } + if (params.lastSeenAt) { + const parsed = parseSearchOperatorValue( + 'lastSeenAt', + params.lastSeenAt, + ); + if (parsed) queryParams.push(parsed); + } + ['tag', 'segment', 'project'].forEach((field) => { if (params[field]) { const parsed = parseSearchOperatorValue(field, params[field]); diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts index eb42f70e23..9c05f1183c 100644 --- a/src/lib/features/feature-search/feature-search-store.ts +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -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 = ( query: Knex.QueryBuilder, queryParams: IQueryParam[], @@ -782,12 +802,17 @@ const applyQueryParams = ( const segmentConditions = queryParams.filter( (param) => param.field === 'segment', ); + const lastSeenAtConditions = queryParams.filter( + (param) => param.field === 'lastSeenAt', + ); const genericConditions = queryParams.filter( - (param) => !['tag', 'stale'].includes(param.field), + (param) => + !['tag', 'stale', 'segment', 'lastSeenAt'].includes(param.field), ); applyGenericQueryParams(query, genericConditions); applyStaleConditions(query, staleConditions); + applyLastSeenAtConditions(query, lastSeenAtConditions); applyMultiQueryParams( query, diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 4ad2150650..4bd0353f9e 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -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 () => { await app.createFeature({ name: 'my_feature_b', diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index 1887187c0c..10b6588595 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -31,6 +31,7 @@ export interface IFeatureSearchParams { type?: string; tag?: string; lifecycle?: string; + lastSeenAt?: string; status?: string[][]; offset: number; favoritesFirst?: boolean; diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 5fff558f02..c4cfc7512b 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -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.', 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; export type FeatureSearchQueryParameters = Partial<