Add filtering support for activity logs

This commit is contained in:
DaneEveritt 2022-06-05 19:23:25 -04:00
parent c6e8b893c8
commit 0bfba306bf
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
3 changed files with 99 additions and 48 deletions

View file

@ -2,18 +2,19 @@ import useUserSWRContentKey from '@/plugins/useUserSWRContentKey';
import useSWR, { ConfigInterface, responseInterface } from 'swr'; import useSWR, { ConfigInterface, responseInterface } from 'swr';
import { ActivityLog, Transformers } from '@definitions/user'; import { ActivityLog, Transformers } from '@definitions/user';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import http, { PaginatedResult } from '@/api/http'; import http, { PaginatedResult, QueryBuilderParams, withQueryBuilderParams } from '@/api/http';
import { toPaginatedSet } from '@definitions/helpers'; import { toPaginatedSet } from '@definitions/helpers';
const useActivityLogs = (page = 1, config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => { export type ActivityLogFilters = QueryBuilderParams<'ip' | 'event', 'timestamp'>;
const key = useUserSWRContentKey([ 'account', 'activity', page.toString() ]);
const useActivityLogs = (filters?: ActivityLogFilters, config?: ConfigInterface<PaginatedResult<ActivityLog>, AxiosError>): responseInterface<PaginatedResult<ActivityLog>, AxiosError> => {
const key = useUserSWRContentKey([ 'account', 'activity', JSON.stringify(filters) ]);
return useSWR<PaginatedResult<ActivityLog>>(key, async () => { return useSWR<PaginatedResult<ActivityLog>>(key, async () => {
const { data } = await http.get('/api/client/account/activity', { const { data } = await http.get('/api/client/account/activity', {
params: { params: {
...withQueryBuilderParams(filters),
include: [ 'actor' ], include: [ 'actor' ],
sort: '-timestamp',
page: page,
}, },
}); });

View file

@ -117,6 +117,7 @@ export function getPaginationSet (data: any): PaginationDataSet {
type QueryBuilderFilterValue = string | number | boolean | null; type QueryBuilderFilterValue = string | number | boolean | null;
export interface QueryBuilderParams<FilterKeys extends string = string, SortKeys extends string = string> { export interface QueryBuilderParams<FilterKeys extends string = string, SortKeys extends string = string> {
page?: number;
filters?: { filters?: {
[K in FilterKeys]?: QueryBuilderFilterValue | Readonly<QueryBuilderFilterValue[]>; [K in FilterKeys]?: QueryBuilderFilterValue | Readonly<QueryBuilderFilterValue[]>;
}; };
@ -150,6 +151,7 @@ export const withQueryBuilderParams = (data?: QueryBuilderParams): Record<string
return { return {
...filters, ...filters,
sorts: !sorts.length ? undefined : sorts.join(','), sort: !sorts.length ? undefined : sorts.join(','),
page: data.page,
}; };
}; };

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useActivityLogs } from '@/api/account/activity'; import { ActivityLogFilters, useActivityLogs } from '@/api/account/activity';
import { useFlashKey } from '@/plugins/useFlash'; import { useFlashKey } from '@/plugins/useFlash';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import FlashMessageRender from '@/components/FlashMessageRender'; import FlashMessageRender from '@/components/FlashMessageRender';
@ -8,23 +8,57 @@ import { Link } from 'react-router-dom';
import PaginationFooter from '@/components/elements/table/PaginationFooter'; import PaginationFooter from '@/components/elements/table/PaginationFooter';
import { UserIcon } from '@heroicons/react/outline'; import { UserIcon } from '@heroicons/react/outline';
import Tooltip from '@/components/elements/tooltip/Tooltip'; import Tooltip from '@/components/elements/tooltip/Tooltip';
import { DesktopComputerIcon } from '@heroicons/react/solid'; import { DesktopComputerIcon, XCircleIcon } from '@heroicons/react/solid';
import { useLocation } from 'react-router';
import Spinner from '@/components/elements/Spinner';
import { styles as btnStyles } from '@/components/elements/button/index';
import classNames from 'classnames';
export default () => { export default () => {
const location = useLocation();
const { clearAndAddHttpError } = useFlashKey('account'); const { clearAndAddHttpError } = useFlashKey('account');
const [ page, setPage ] = useState(1); const [ filters, setFilters ] = useState<ActivityLogFilters>({ page: 1, sorts: { timestamp: -1 } });
const { data, isValidating: _, error } = useActivityLogs(page, { const { data, isValidating, error } = useActivityLogs(filters, {
revalidateOnMount: true, revalidateOnMount: true,
revalidateOnFocus: false, revalidateOnFocus: false,
}); });
useEffect(() => {
const parsed = new URLSearchParams(location.search);
setFilters(value => ({ ...value, filters: { ip: parsed.get('ip'), event: parsed.get('event') } }));
}, [ location.search ]);
useEffect(() => { useEffect(() => {
clearAndAddHttpError(error); clearAndAddHttpError(error);
}, [ error ]); }, [ error ]);
const queryTo = (params: Record<string, string>): string => {
const current = new URLSearchParams(location.search);
Object.keys(params).forEach(key => {
current.set(key, params[key]);
});
return current.toString();
};
return ( return (
<PageContentBlock title={'Account Activity Log'}> <PageContentBlock title={'Account Activity Log'}>
<FlashMessageRender byKey={'account'}/> <FlashMessageRender byKey={'account'}/>
{(filters.filters?.event || filters.filters?.ip) &&
<div className={'flex justify-end mb-2'}>
<Link
to={'#'}
className={classNames(btnStyles.button, btnStyles.text)}
onClick={() => setFilters(value => ({ ...value, filters: {} }))}
>
Clear Filters <XCircleIcon className={'w-4 h-4 ml-2'}/>
</Link>
</div>
}
{!data && isValidating ?
<Spinner centered/>
:
<div className={'bg-gray-700'}> <div className={'bg-gray-700'}>
{data?.items.map((activity) => ( {data?.items.map((activity) => (
<div <div
@ -44,7 +78,10 @@ export default () => {
<div className={'flex items-center text-gray-50'}> <div className={'flex items-center text-gray-50'}>
{activity.relationships.actor?.username || 'system'} {activity.relationships.actor?.username || 'system'}
<span className={'text-gray-400'}>&nbsp;&mdash;&nbsp;</span> <span className={'text-gray-400'}>&nbsp;&mdash;&nbsp;</span>
<Link to={`#event=${activity.event}`}> <Link
to={`?${queryTo({ event: activity.event })}`}
className={'transition-colors duration-75 hover:text-cyan-400'}
>
{activity.event} {activity.event}
</Link> </Link>
{typeof activity.properties.useragent === 'string' && {typeof activity.properties.useragent === 'string' &&
@ -53,11 +90,18 @@ export default () => {
</Tooltip> </Tooltip>
} }
</div> </div>
{/* <p className={'mt-1'}>{activity.description || JSON.stringify(activity.properties)}</p> */}
<div className={'mt-1 flex items-center text-sm'}> <div className={'mt-1 flex items-center text-sm'}>
<Link to={`#ip=${activity.ip}`}>{activity.ip}</Link> <Link
to={`?${queryTo({ ip: activity.ip })}`}
className={'transition-colors duration-75 hover:text-cyan-400'}
>
{activity.ip}
</Link>
<span className={'text-gray-400'}>&nbsp;|&nbsp;</span> <span className={'text-gray-400'}>&nbsp;|&nbsp;</span>
<Tooltip placement={'right'} content={format(activity.timestamp, 'MMM do, yyyy h:mma')}> <Tooltip
placement={'right'}
content={format(activity.timestamp, 'MMM do, yyyy h:mma')}
>
<span> <span>
{formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })} {formatDistanceToNowStrict(activity.timestamp, { addSuffix: true })}
</span> </span>
@ -67,7 +111,11 @@ export default () => {
</div> </div>
))} ))}
</div> </div>
{data && <PaginationFooter pagination={data.pagination} onPageSelect={setPage}/>} }
{data && <PaginationFooter
pagination={data.pagination}
onPageSelect={page => setFilters(value => ({ ...value, page }))}
/>}
</PageContentBlock> </PageContentBlock>
); );
}; };