0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

refactor(console): audit logs table (#2951)

This commit is contained in:
Xiao Yijun 2023-01-16 18:25:02 +08:00 committed by GitHub
parent e2857d554f
commit ea7e8303c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 146 additions and 141 deletions

View file

@ -1,19 +1,9 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
flex-direction: column;
height: 100%;
}
.filter {
display: flex;
justify-content: flex-end;
align-items: center;
padding: _.unit(3);
border-bottom: 1px solid var(--color-divider);
background-color: var(--color-layer-1);
border-radius: 12px 12px 0 0;
.title {
color: var(--color-text-secondary);
@ -30,24 +20,3 @@
margin-left: _.unit(2);
}
}
.tableLayout {
display: flex;
flex-direction: column;
max-height: 100%;
overflow-y: auto;
flex: 1;
.tableContainer {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
.pagination {
margin-top: _.unit(4);
}
.eventName {
width: 360px;
}

View file

@ -1,45 +1,41 @@
import type { Log } from '@logto/schemas';
import { LogResult } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import useSWR from 'swr';
import ApplicationName from '@/components/ApplicationName';
import Pagination from '@/components/Pagination';
import TableEmpty from '@/components/Table/TableEmpty';
import TableError from '@/components/Table/TableError';
import TableLoading from '@/components/Table/TableLoading';
import UserName from '@/components/UserName';
import { defaultPageSize } from '@/consts';
import type { RequestError } from '@/hooks/use-api';
import * as tableStyles from '@/scss/table.module.scss';
import usePageSearchParameters from '@/hooks/use-page-search-parameters';
import { buildUrl } from '@/utilities/url';
import Table from '../Table';
import type { Column } from '../Table/types';
import ApplicationSelector from './components/ApplicationSelector';
import EventName from './components/EventName';
import EventSelector from './components/EventSelector';
import * as styles from './index.module.scss';
const pageSize = 20;
type Props = {
userId?: string;
className?: string;
};
const defaultTableColumn = 4;
const AuditLogTable = ({ userId, className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { pathname } = useLocation();
const [query, setQuery] = useSearchParams();
const pageIndex = Number(query.get('page') ?? '1');
const event = query.get('event');
const applicationId = query.get('applicationId');
const pageSize = defaultPageSize;
const [{ page, event, applicationId }, updatePageSearchParameters] = usePageSearchParameters({
page: 1,
event: '',
applicationId: '',
});
const url = buildUrl('/api/logs', {
page: String(pageIndex),
page: String(page),
page_size: String(pageSize),
...conditional(event && { logType: event }),
...conditional(applicationId && { applicationId }),
@ -50,109 +46,89 @@ const AuditLogTable = ({ userId, className }: Props) => {
const isLoading = !data && !error;
const navigate = useNavigate();
const [logs, totalCount] = data ?? [];
const showUserColumn = !userId;
const tableColumnCount = showUserColumn ? defaultTableColumn : defaultTableColumn - 1;
const isUserColumnVisible = !userId;
const updateQuery = (key: string, value: string) => {
const queries: Record<string, string> = {};
for (const [key, value] of query.entries()) {
// eslint-disable-next-line @silverhand/fp/no-mutation
queries[key] = value;
}
setQuery({
...queries,
[key]: value,
});
const eventColumn: Column<Log> = {
title: t('logs.event'),
dataIndex: 'event',
colSpan: isUserColumnVisible ? 5 : 6,
render: ({ key, payload: { result } }) => (
<EventName eventKey={key} isSuccess={result === LogResult.Success} />
),
};
const userColumn: Column<Log> = {
title: t('logs.user'),
dataIndex: 'user',
colSpan: 5,
render: ({ payload: { userId } }) => (userId ? <UserName userId={userId} /> : <div>-</div>),
};
const applicationColumn: Column<Log> = {
title: t('logs.application'),
dataIndex: 'application',
colSpan: isUserColumnVisible ? 3 : 5,
render: ({ payload: { applicationId } }) =>
applicationId ? <ApplicationName applicationId={applicationId} /> : <div>-</div>,
};
const timeColumn: Column<Log> = {
title: t('logs.time'),
dataIndex: 'time',
colSpan: isUserColumnVisible ? 3 : 5,
render: ({ createdAt }) => new Date(createdAt).toLocaleString(),
};
const columns: Array<Column<Log>> = [
eventColumn,
conditional(isUserColumnVisible && userColumn),
applicationColumn,
timeColumn,
// eslint-disable-next-line unicorn/prefer-native-coercion-functions
].filter((column): column is Column<Log> => Boolean(column));
return (
<div className={classNames(styles.container, className)}>
<div className={styles.tableLayout}>
<Table
className={className}
rowGroups={[{ key: 'logs', data: logs }]}
rowIndexKey="id"
columns={columns}
rowClickHandler={({ id }) => {
navigate(`${pathname}/${id}`);
}}
filter={
<div className={styles.filter}>
<div className={styles.title}>{t('logs.filter_by')}</div>
<div className={styles.eventSelector}>
<EventSelector
value={event ?? undefined}
onChange={(value) => {
updateQuery('event', value ?? '');
value={event}
onChange={(event) => {
updatePageSearchParameters({ event, page: undefined });
}}
/>
</div>
<div className={styles.applicationSelector}>
<ApplicationSelector
value={applicationId ?? undefined}
onChange={(value) => {
updateQuery('applicationId', value ?? '');
value={applicationId}
onChange={(applicationId) => {
updatePageSearchParameters({ applicationId, page: undefined });
}}
/>
</div>
</div>
<div className={classNames(tableStyles.scrollable, styles.tableContainer)}>
<table className={conditional(logs?.length === 0 && tableStyles.empty)}>
<colgroup>
<col className={styles.eventName} />
{showUserColumn && <col />}
<col />
<col />
</colgroup>
<thead>
<tr>
<th>{t('logs.event')}</th>
{showUserColumn && <th>{t('logs.user')}</th>}
<th>{t('logs.application')}</th>
<th>{t('logs.time')}</th>
</tr>
</thead>
<tbody>
{!data && error && (
<TableError
columns={tableColumnCount}
content={error.body?.message ?? error.message}
onRetry={async () => mutate(undefined, true)}
/>
)}
{isLoading && <TableLoading columns={tableColumnCount} />}
{logs?.length === 0 && <TableEmpty columns={tableColumnCount} />}
{logs?.map(({ key, payload, createdAt, id }) => (
<tr
key={id}
className={tableStyles.clickable}
onClick={() => {
navigate(`${pathname}/${id}`);
}}
>
<td>
<EventName eventKey={key} isSuccess={payload.result === LogResult.Success} />
</td>
{showUserColumn && (
<td>{payload.userId ? <UserName userId={payload.userId} /> : '-'}</td>
)}
<td>
{payload.applicationId ? (
<ApplicationName applicationId={payload.applicationId} />
) : (
'-'
)}
</td>
<td>{new Date(createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Pagination
pageIndex={pageIndex}
totalCount={totalCount}
pageSize={pageSize}
className={styles.pagination}
onChange={(page) => {
updateQuery('page', String(page));
}}
/>
</div>
}
pagination={{
pageIndex: Number(page),
totalCount,
pageSize,
onChange: (page) => {
updatePageSearchParameters({ page });
},
}}
isLoading={isLoading}
errorMessage={error?.body?.message ?? error?.message}
onRetry={async () => mutate(undefined, true)}
/>
);
};

View file

@ -99,14 +99,14 @@ const Table = <
{!hasData && errorMessage && (
<TableError columns={columns.length} content={errorMessage} onRetry={onRetry} />
)}
{!isLoading && !hasData && placeholder && (
{!isLoading && !hasData && (
<TableEmpty
columns={columns.length}
title={placeholder.title}
description={placeholder.description}
image={placeholder.image}
title={placeholder?.title}
description={placeholder?.description}
image={placeholder?.image}
>
{placeholder.content}
{placeholder?.content}
</TableEmpty>
)}
{rowGroups.map(({ key, label, labelClassName, data }) => (

View file

@ -0,0 +1,62 @@
import { conditional } from '@silverhand/essentials';
import { useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
type Parameters = Record<string, string | number>;
type UsePageSearchParametersReturn<T extends Parameters = Parameters> = [
{
[K in keyof T]: T[K];
},
(parameters: Partial<T>) => void
];
/**
* Manage page search parameters
*
* @param config Define search parameter keys and their default value. E.g., `{ page: 1, keyword: '' }`
* @returns [pageSearchParams, updatePageSearchParams]
*/
const usePageSearchParameters = <T extends Parameters>(
config: T
): UsePageSearchParametersReturn<T> => {
const [searchParameters, setSearchParameters] = useSearchParams();
const updatePageSearchParameters = useCallback(
(parameters: Partial<T>) => {
const baseParameters = new URLSearchParams(searchParameters);
for (const key of Object.keys(parameters)) {
// eslint-disable-next-line no-restricted-syntax
const value = parameters[key as keyof Partial<T>];
if (value === undefined) {
baseParameters.delete(key);
} else {
baseParameters.set(key, String(value));
}
}
setSearchParameters(baseParameters);
},
[searchParameters, setSearchParameters]
);
return [
// eslint-disable-next-line no-restricted-syntax
Object.fromEntries(
Object.entries(config).map(([parameterKey, defaultValue]) => {
const locationParameterValue = searchParameters.get(parameterKey);
const parameterValue =
typeof defaultValue === 'string'
? locationParameterValue ?? defaultValue
: conditional(locationParameterValue && Number(locationParameterValue)) ?? defaultValue;
return [parameterKey, parameterValue];
})
) as UsePageSearchParametersReturn<T>[0],
updatePageSearchParameters,
];
};
export default usePageSearchParameters;

View file

@ -8,9 +8,7 @@ const AuditLogs = () => {
<div className={resourcesStyles.headline}>
<CardTitle title="logs.title" subtitle="logs.subtitle" />
</div>
<div className={resourcesStyles.table}>
<AuditLogTable />
</div>
<AuditLogTable className={resourcesStyles.table} />
</div>
);
};