1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-13 13:48:59 +02:00

Feat: filter flags by "last seen at" (#10449)

This lets users filter features by when they were last reported in metrics.
This commit is contained in:
Tymoteusz Czech 2025-08-04 14:50:21 +02:00 committed by GitHub
parent bd5a8539c0
commit e1b6979627
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 259 additions and 53 deletions

View File

@ -96,7 +96,7 @@ test('Filter table by project', async () => {
await screen.findByPlaceholderText(/Search/); await screen.findByPlaceholderText(/Search/);
await screen.getByRole('button', { await screen.getByRole('button', {
name: /Filter/i, name: 'Filter',
}); });
await Promise.all( await Promise.all(

View File

@ -38,17 +38,15 @@ const StyledIcon = styled(Icon)(({ theme }) => ({
interface IAddFilterButtonProps { interface IAddFilterButtonProps {
visibleOptions: string[]; visibleOptions: string[];
setVisibleOptions: (filters: string[]) => void;
hiddenOptions: string[]; hiddenOptions: string[];
setHiddenOptions: (filters: string[]) => void; onSelectedOptionsChange: (filters: string[]) => void;
availableFilters: IFilterItem[]; availableFilters: IFilterItem[];
} }
export const AddFilterButton = ({ export const AddFilterButton = ({
visibleOptions, visibleOptions,
setVisibleOptions,
hiddenOptions, hiddenOptions,
setHiddenOptions, onSelectedOptionsChange,
availableFilters, availableFilters,
}: IAddFilterButtonProps) => { }: IAddFilterButtonProps) => {
const projectId = useOptionalPathParam('projectId'); const projectId = useOptionalPathParam('projectId');
@ -69,11 +67,7 @@ export const AddFilterButton = ({
}; };
const onSelect = (label: string) => { const onSelect = (label: string) => {
const newVisibleOptions = visibleOptions.filter((f) => f !== label); onSelectedOptionsChange([...hiddenOptions, label]);
const newHiddenOptions = [...hiddenOptions, label];
setHiddenOptions(newHiddenOptions);
setVisibleOptions(newVisibleOptions);
handleClose(); handleClose();
}; };

View File

@ -1,6 +1,5 @@
import { type FC, useEffect, useState } from 'react'; import { type FC, useEffect, useMemo, useState } from 'react';
import { Box, Icon, styled } from '@mui/material'; import { Box, Icon, styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { AddFilterButton } from '../AddFilterButton.tsx'; import { AddFilterButton } from '../AddFilterButton.tsx';
import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem'; import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem';
import { import {
@ -165,6 +164,22 @@ const SingleFilter: FC<SingleFilterProps> = ({
); );
}; };
const mergeArraysKeepingOrder = (
firstArray: string[],
secondArray: string[],
): string[] => {
const resultArray: string[] = [...firstArray];
const elementsSet = new Set(firstArray);
secondArray.forEach((element) => {
if (!elementsSet.has(element)) {
resultArray.push(element);
}
});
return resultArray;
};
type MultiFilterProps = IFilterProps & { type MultiFilterProps = IFilterProps & {
rangeChangeHandler: RangeChangeHandler; rangeChangeHandler: RangeChangeHandler;
}; };
@ -176,31 +191,12 @@ const MultiFilter: FC<MultiFilterProps> = ({
rangeChangeHandler, rangeChangeHandler,
className, className,
}) => { }) => {
const [unselectedFilters, setUnselectedFilters] = useState<string[]>([]);
const [selectedFilters, setSelectedFilters] = useState<string[]>([]); const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const deselectFilter = (label: string) => { const deselectFilter = (label: string) => {
const newSelectedFilters = selectedFilters.filter((f) => f !== label); const newSelectedFilters = selectedFilters.filter((f) => f !== label);
const newUnselectedFilters = [...unselectedFilters, label].sort();
setSelectedFilters(newSelectedFilters); setSelectedFilters(newSelectedFilters);
setUnselectedFilters(newUnselectedFilters);
};
const mergeArraysKeepingOrder = (
firstArray: string[],
secondArray: string[],
): string[] => {
const resultArray: string[] = [...firstArray];
const elementsSet = new Set(firstArray);
secondArray.forEach((element) => {
if (!elementsSet.has(element)) {
resultArray.push(element);
}
});
return resultArray;
}; };
useEffect(() => { useEffect(() => {
@ -219,15 +215,16 @@ const MultiFilter: FC<MultiFilterProps> = ({
newSelectedFilters, newSelectedFilters,
); );
setSelectedFilters(allSelectedFilters); setSelectedFilters(allSelectedFilters);
const newUnselectedFilters = availableFilters
.filter((item) => !allSelectedFilters.includes(item.label))
.map((field) => field.label)
.sort();
setUnselectedFilters(newUnselectedFilters);
}, [JSON.stringify(state), JSON.stringify(availableFilters)]); }, [JSON.stringify(state), JSON.stringify(availableFilters)]);
const hasAvailableFilters = unselectedFilters.length > 0; const unselectedFilters = useMemo(
() =>
availableFilters
.filter((item) => !selectedFilters.includes(item.label))
.map((field) => field.label)
.sort(),
[availableFilters, selectedFilters],
);
return ( return (
<StyledBox className={className}> <StyledBox className={className}>
@ -251,19 +248,14 @@ const MultiFilter: FC<MultiFilterProps> = ({
/> />
); );
})} })}
{unselectedFilters.length > 0 ? (
<ConditionallyRender <AddFilterButton
condition={hasAvailableFilters} availableFilters={availableFilters}
show={ visibleOptions={unselectedFilters}
<AddFilterButton hiddenOptions={selectedFilters}
availableFilters={availableFilters} onSelectedOptionsChange={setSelectedFilters}
visibleOptions={unselectedFilters} />
setVisibleOptions={setUnselectedFilters} ) : null}
hiddenOptions={selectedFilters}
setHiddenOptions={setSelectedFilters}
/>
}
/>
</StyledBox> </StyledBox>
); );
}; };

View File

@ -114,6 +114,7 @@ export const ProjectFeatureToggles = ({
createdBy: tableState.createdBy, createdBy: tableState.createdBy,
archived: tableState.archived, archived: tableState.archived,
lifecycle: tableState.lifecycle, lifecycle: tableState.lifecycle,
lastSeenAt: tableState.lastSeenAt,
}; };
const { favorite, unfavorite } = useFavoriteFeaturesApi(); const { favorite, unfavorite } = useFavoriteFeaturesApi();

View File

@ -7,6 +7,7 @@ import {
} from 'component/filter/Filters/Filters'; } from 'component/filter/Filters/Filters';
import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators'; import { useProjectFlagCreators } from 'hooks/api/getters/useProjectFlagCreators/useProjectFlagCreators';
import { formatTag } from 'utils/format-tag'; import { formatTag } from 'utils/format-tag';
import { useUiFlag } from 'hooks/useUiFlag';
interface IProjectOverviewFilters { interface IProjectOverviewFilters {
state: FilterItemParamHolder; state: FilterItemParamHolder;
@ -21,6 +22,7 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
}) => { }) => {
const { tags } = useAllTags(); const { tags } = useAllTags();
const { flagCreators } = useProjectFlagCreators(project); const { flagCreators } = useProjectFlagCreators(project);
const filterFlagsToArchiveEnabled = useUiFlag('filterFlagsToArchive');
const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]); const [availableFilters, setAvailableFilters] = useState<IFilterItem[]>([]);
useEffect(() => { useEffect(() => {
@ -81,6 +83,17 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
filterKey: 'createdAt', filterKey: 'createdAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'], dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
}, },
...(filterFlagsToArchiveEnabled
? [
{
label: 'Last seen',
icon: 'monitor_heart',
options: [],
filterKey: 'lastSeenAt',
dateOperators: ['IS_ON_OR_AFTER', 'IS_BEFORE'],
} as IFilterItem,
]
: []),
{ {
label: 'Flag type', label: 'Flag type',
icon: 'flag', icon: 'flag',
@ -127,7 +140,11 @@ export const ProjectOverviewFilters: VFC<IProjectOverviewFilters> = ({
]; ];
setAvailableFilters(availableFilters); setAvailableFilters(availableFilters);
}, [JSON.stringify(tags), JSON.stringify(flagCreators)]); }, [
JSON.stringify(tags),
JSON.stringify(flagCreators),
filterFlagsToArchiveEnabled,
]);
return ( return (
<Filters <Filters

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

@ -95,6 +95,7 @@ export type UiFlags = {
reportUnknownFlags?: boolean; reportUnknownFlags?: boolean;
lifecycleGraphs?: boolean; lifecycleGraphs?: boolean;
addConfiguration?: boolean; addConfiguration?: boolean;
filterFlagsToArchive?: boolean;
projectListViewToggle?: boolean; projectListViewToggle?: boolean;
}; };

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

@ -1,3 +1,4 @@
import { subDays } from 'date-fns';
import dbInit, { import dbInit, {
type ITestDb, type ITestDb,
} from '../../../test/e2e/helpers/database-init.js'; } from '../../../test/e2e/helpers/database-init.js';
@ -1085,6 +1086,149 @@ 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',
});
const currentDate = new Date();
await insertLastSeenAt(
'recently_seen_feature',
db.rawDatabase,
DEFAULT_ENV,
currentDate.toISOString(),
);
await insertLastSeenAt(
'old_seen_feature',
db.rawDatabase,
DEFAULT_ENV,
subDays(currentDate, 10).toISOString(),
);
const sevenDaysAgo = subDays(currentDate, 7);
const { body: recentFeatures } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${sevenDaysAgo.toISOString().split('T')[0]}`,
)
.expect(200);
expect(recentFeatures.features).toHaveLength(1);
expect(recentFeatures.features[0].name).toBe('recently_seen_feature');
const { body: oldFeatures } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_BEFORE:${sevenDaysAgo.toISOString().split('T')[0]}`,
)
.expect(200);
expect(oldFeatures.features).toHaveLength(1);
expect(oldFeatures.features[0].name).toBe('old_seen_feature');
const { body: allFeatures } = await app.request
.get('/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:2000-01-01')
.expect(200);
expect(allFeatures.features).toHaveLength(2);
});
test('should filter by last seen even if in different environment', async () => {
await app.createFeature({
name: 'feature_in_production',
});
await app.createFeature({
name: 'feature_in_development',
});
const currentDate = new Date();
await insertLastSeenAt(
'feature_in_production',
db.rawDatabase,
'production',
subDays(currentDate, 2).toISOString(),
);
await insertLastSeenAt(
'feature_in_development',
db.rawDatabase,
DEFAULT_ENV,
subDays(currentDate, 5).toISOString(),
);
const threeDaysAgo = subDays(currentDate, 3);
const { body: recentFeatures } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${threeDaysAgo.toISOString().split('T')[0]}`,
)
.expect(200);
expect(recentFeatures.features).toHaveLength(1);
expect(recentFeatures.features[0].name).toBe('feature_in_production');
const sixDaysAgo = subDays(currentDate, 6);
const { body: olderFeatures } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${sixDaysAgo.toISOString().split('T')[0]}`,
)
.expect(200);
expect(olderFeatures.features).toHaveLength(2);
expect(olderFeatures.features.map((f) => f.name)).toContain(
'feature_in_production',
);
expect(olderFeatures.features.map((f) => f.name)).toContain(
'feature_in_development',
);
});
test('should not return features with no last seen when filtering by lastSeenAt', async () => {
await app.createFeature({
name: 'feature_with_last_seen',
});
await app.createFeature({
name: 'feature_without_last_seen',
});
const currentDate = new Date();
await insertLastSeenAt(
'feature_with_last_seen',
db.rawDatabase,
DEFAULT_ENV,
subDays(currentDate, 1).toISOString(),
);
const twoDaysAgo = subDays(currentDate, 2);
const { body: featuresWithLastSeen } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_ON_OR_AFTER:${twoDaysAgo.toISOString().split('T')[0]}`,
)
.expect(200);
expect(featuresWithLastSeen.features).toHaveLength(1);
expect(featuresWithLastSeen.features[0].name).toBe(
'feature_with_last_seen',
);
const currentDateFormatted = currentDate.toISOString().split('T')[0];
const { body: featuresBeforeToday } = await app.request
.get(
`/api/admin/search/features?lastSeenAt=IS_BEFORE:${currentDateFormatted}`,
)
.expect(200);
expect(featuresBeforeToday.features).toHaveLength(1);
expect(featuresBeforeToday.features[0].name).toBe('feature_with_last_seen');
});
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<

View File

@ -66,6 +66,7 @@ export type IFlagKey =
| 'lifecycleGraphs' | 'lifecycleGraphs'
| 'githubAuth' | 'githubAuth'
| 'addConfiguration' | 'addConfiguration'
| 'filterFlagsToArchive'
| 'projectListViewToggle'; | 'projectListViewToggle';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -306,6 +307,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_ADD_CONFIGURATION, process.env.UNLEASH_EXPERIMENTAL_ADD_CONFIGURATION,
false, false,
), ),
filterFlagsToArchive: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FILTER_FLAGS_TO_ARCHIVE,
false,
),
projectListViewToggle: parseEnvVarBoolean( projectListViewToggle: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_PROJECT_LIST_VIEW_TOGGLE, process.env.UNLEASH_EXPERIMENTAL_PROJECT_LIST_VIEW_TOGGLE,
false, false,