1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +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.getByRole('button', {
name: /Filter/i,
name: 'Filter',
});
await Promise.all(

View File

@ -38,17 +38,15 @@ const StyledIcon = styled(Icon)(({ theme }) => ({
interface IAddFilterButtonProps {
visibleOptions: string[];
setVisibleOptions: (filters: string[]) => void;
hiddenOptions: string[];
setHiddenOptions: (filters: string[]) => void;
onSelectedOptionsChange: (filters: string[]) => void;
availableFilters: IFilterItem[];
}
export const AddFilterButton = ({
visibleOptions,
setVisibleOptions,
hiddenOptions,
setHiddenOptions,
onSelectedOptionsChange,
availableFilters,
}: IAddFilterButtonProps) => {
const projectId = useOptionalPathParam('projectId');
@ -69,11 +67,7 @@ export const AddFilterButton = ({
};
const onSelect = (label: string) => {
const newVisibleOptions = visibleOptions.filter((f) => f !== label);
const newHiddenOptions = [...hiddenOptions, label];
setHiddenOptions(newHiddenOptions);
setVisibleOptions(newVisibleOptions);
onSelectedOptionsChange([...hiddenOptions, label]);
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { AddFilterButton } from '../AddFilterButton.tsx';
import { FilterDateItem } from 'component/common/FilterDateItem/FilterDateItem';
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 & {
rangeChangeHandler: RangeChangeHandler;
};
@ -176,31 +191,12 @@ const MultiFilter: FC<MultiFilterProps> = ({
rangeChangeHandler,
className,
}) => {
const [unselectedFilters, setUnselectedFilters] = useState<string[]>([]);
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
const deselectFilter = (label: string) => {
const newSelectedFilters = selectedFilters.filter((f) => f !== label);
const newUnselectedFilters = [...unselectedFilters, label].sort();
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(() => {
@ -219,15 +215,16 @@ const MultiFilter: FC<MultiFilterProps> = ({
newSelectedFilters,
);
setSelectedFilters(allSelectedFilters);
const newUnselectedFilters = availableFilters
.filter((item) => !allSelectedFilters.includes(item.label))
.map((field) => field.label)
.sort();
setUnselectedFilters(newUnselectedFilters);
}, [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 (
<StyledBox className={className}>
@ -251,19 +248,14 @@ const MultiFilter: FC<MultiFilterProps> = ({
/>
);
})}
<ConditionallyRender
condition={hasAvailableFilters}
show={
<AddFilterButton
availableFilters={availableFilters}
visibleOptions={unselectedFilters}
setVisibleOptions={setUnselectedFilters}
hiddenOptions={selectedFilters}
setHiddenOptions={setSelectedFilters}
/>
}
/>
{unselectedFilters.length > 0 ? (
<AddFilterButton
availableFilters={availableFilters}
visibleOptions={unselectedFilters}
hiddenOptions={selectedFilters}
onSelectedOptionsChange={setSelectedFilters}
/>
) : null}
</StyledBox>
);
};

View File

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

View File

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

View File

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

View File

@ -95,6 +95,7 @@ export type UiFlags = {
reportUnknownFlags?: boolean;
lifecycleGraphs?: boolean;
addConfiguration?: boolean;
filterFlagsToArchive?: 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.
*/
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,
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,

View File

@ -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]);

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 = (
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,

View File

@ -1,3 +1,4 @@
import { subDays } from 'date-fns';
import dbInit, {
type ITestDb,
} 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 () => {
await app.createFeature({
name: 'my_feature_b',

View File

@ -31,6 +31,7 @@ export interface IFeatureSearchParams {
type?: string;
tag?: string;
lifecycle?: string;
lastSeenAt?: string;
status?: string[][];
offset: number;
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.',
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<

View File

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