mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01: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:
		
							parent
							
								
									bd5a8539c0
								
							
						
					
					
						commit
						e1b6979627
					
				| @ -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( | ||||
|  | ||||
| @ -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(); | ||||
|     }; | ||||
| 
 | ||||
|  | ||||
| @ -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> | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| @ -114,6 +114,7 @@ export const ProjectFeatureToggles = ({ | ||||
|         createdBy: tableState.createdBy, | ||||
|         archived: tableState.archived, | ||||
|         lifecycle: tableState.lifecycle, | ||||
|         lastSeenAt: tableState.lastSeenAt, | ||||
|     }; | ||||
| 
 | ||||
|     const { favorite, unfavorite } = useFavoriteFeaturesApi(); | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -38,6 +38,7 @@ export const useProjectFeatureSearch = ( | ||||
|         tag: FilterItemParam, | ||||
|         state: FilterItemParam, | ||||
|         createdAt: FilterItemParam, | ||||
|         lastSeenAt: FilterItemParam, | ||||
|         type: FilterItemParam, | ||||
|         createdBy: FilterItemParam, | ||||
|         archived: FilterItemParam, | ||||
|  | ||||
| @ -95,6 +95,7 @@ export type UiFlags = { | ||||
|     reportUnknownFlags?: boolean; | ||||
|     lifecycleGraphs?: boolean; | ||||
|     addConfiguration?: boolean; | ||||
|     filterFlagsToArchive?: boolean; | ||||
|     projectListViewToggle?: boolean; | ||||
| }; | ||||
| 
 | ||||
|  | ||||
| @ -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; | ||||
| }; | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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]); | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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', | ||||
|  | ||||
| @ -31,6 +31,7 @@ export interface IFeatureSearchParams { | ||||
|     type?: string; | ||||
|     tag?: string; | ||||
|     lifecycle?: string; | ||||
|     lastSeenAt?: string; | ||||
|     status?: string[][]; | ||||
|     offset: number; | ||||
|     favoritesFirst?: boolean; | ||||
|  | ||||
| @ -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< | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user