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:
parent
e2857d554f
commit
ea7e8303c0
5 changed files with 146 additions and 141 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 }) => (
|
||||
|
|
62
packages/console/src/hooks/use-page-search-parameters.ts
Normal file
62
packages/console/src/hooks/use-page-search-parameters.ts
Normal 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;
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue