mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-06-08 17:09:59 +08:00
332 lines
11 KiB
TypeScript
332 lines
11 KiB
TypeScript
import { memo, useState, useEffect, useCallback } from 'react';
|
|
import { Table, message, Tag, Input, Select, Button, Space, Modal, DatePicker, Descriptions, Divider, Typography } from 'antd';
|
|
import PageCard from '../components/PageCard';
|
|
import { auditApi, type AuditLogItem, type PaginatedAuditLogs } from '../api/audit';
|
|
import { useI18n } from '../i18n';
|
|
import useResponsive from '../hooks/useResponsive';
|
|
import { format, formatISO } from 'date-fns';
|
|
|
|
const { RangePicker } = DatePicker;
|
|
|
|
const ACTION_OPTIONS = [
|
|
'login',
|
|
'logout',
|
|
'register',
|
|
'read',
|
|
'create',
|
|
'update',
|
|
'delete',
|
|
'reset_password',
|
|
'share',
|
|
'download',
|
|
'upload',
|
|
'other'
|
|
];
|
|
|
|
const HTTP_METHOD_COLOR_MAP: Record<string, string> = {
|
|
GET: 'green',
|
|
POST: 'blue',
|
|
PUT: 'orange',
|
|
PATCH: 'gold',
|
|
DELETE: 'red',
|
|
HEAD: 'cyan',
|
|
OPTIONS: 'purple',
|
|
};
|
|
|
|
const renderHttpMethodTag = (method: string) => {
|
|
const upper = method.toUpperCase();
|
|
const color = HTTP_METHOD_COLOR_MAP[upper] || 'default';
|
|
return (
|
|
<Tag
|
|
bordered={false}
|
|
color={color}
|
|
style={{ margin: 0, paddingInline: 8, minWidth: 56, textAlign: 'center', fontWeight: 500 }}
|
|
>
|
|
{upper}
|
|
</Tag>
|
|
);
|
|
};
|
|
|
|
const AuditLogsPage = memo(function AuditLogsPage() {
|
|
const { isMobile } = useResponsive();
|
|
const [loading, setLoading] = useState(false);
|
|
const [data, setData] = useState<PaginatedAuditLogs | null>(null);
|
|
const [filters, setFilters] = useState<{
|
|
page: number;
|
|
page_size: number;
|
|
action: string;
|
|
success: '' | boolean;
|
|
username: string;
|
|
path: string;
|
|
start_time: string;
|
|
end_time: string;
|
|
}>({
|
|
page: 1,
|
|
page_size: 20,
|
|
action: '',
|
|
success: '',
|
|
username: '',
|
|
path: '',
|
|
start_time: '',
|
|
end_time: '',
|
|
});
|
|
const [selectedLog, setSelectedLog] = useState<AuditLogItem | null>(null);
|
|
const { t } = useI18n();
|
|
|
|
const fetchList = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const params: any = { ...filters };
|
|
if (!params.action) delete params.action;
|
|
if (params.success === '') delete params.success;
|
|
if (!params.username) delete params.username;
|
|
if (!params.path) delete params.path;
|
|
if (!params.start_time) delete params.start_time;
|
|
if (!params.end_time) delete params.end_time;
|
|
const res = await auditApi.list(params);
|
|
setData(res);
|
|
} catch (e: any) {
|
|
message.error(e.message || t('Load failed'));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [filters, t]);
|
|
|
|
useEffect(() => {
|
|
fetchList();
|
|
}, [fetchList]);
|
|
|
|
const handleClearLogs = () => {
|
|
const hasRange = !!(filters.start_time || filters.end_time);
|
|
Modal.confirm({
|
|
title: t('Confirm clear logs?'),
|
|
content: hasRange
|
|
? t('This will delete logs in selected range irreversibly.')
|
|
: t('This will delete all logs irreversibly.'),
|
|
onOk: async () => {
|
|
try {
|
|
const params: any = {};
|
|
if (filters.start_time) params.start_time = filters.start_time;
|
|
if (filters.end_time) params.end_time = filters.end_time;
|
|
const res = await auditApi.clear(params);
|
|
message.success(t('Cleared {count} logs', { count: String(res.deleted_count) }));
|
|
fetchList();
|
|
} catch (e: any) {
|
|
message.error(e.message || t('Clear failed'));
|
|
}
|
|
},
|
|
});
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: t('Time'),
|
|
dataIndex: 'created_at',
|
|
width: 180,
|
|
render: (ts: string) => format(new Date(ts), 'yyyy-MM-dd HH:mm:ss'),
|
|
},
|
|
{
|
|
title: t('Action'),
|
|
dataIndex: 'action',
|
|
width: 100,
|
|
render: (action: string) => <Tag color="blue">{action}</Tag>,
|
|
},
|
|
{
|
|
title: t('User'),
|
|
dataIndex: 'username',
|
|
width: 100,
|
|
render: (_: any, rec: AuditLogItem) => rec.username || rec.user_id || '-',
|
|
},
|
|
{
|
|
title: t('Path'),
|
|
dataIndex: 'path',
|
|
width: 350,
|
|
ellipsis: true,
|
|
render: (path: string, rec: AuditLogItem) => (
|
|
<Space size={4}>
|
|
{renderHttpMethodTag(rec.method)}
|
|
<span style={{ maxWidth: 280, display: 'inline-block' }}>{path}</span>
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: t('Status Code'),
|
|
dataIndex: 'status_code',
|
|
width: 80,
|
|
align: 'center' as const,
|
|
},
|
|
{
|
|
title: t('Duration (ms)'),
|
|
dataIndex: 'duration_ms',
|
|
width: 110,
|
|
align: 'right' as const,
|
|
render: (ms?: number | null) => (ms !== null && ms !== undefined ? ms : '-'),
|
|
},
|
|
{
|
|
title: t('Client IP'),
|
|
dataIndex: 'client_ip',
|
|
width: 140,
|
|
render: (ip?: string | null) => ip || '-',
|
|
},
|
|
{
|
|
title: t('Result'),
|
|
dataIndex: 'success',
|
|
width: 80,
|
|
align: 'center' as const,
|
|
render: (success: boolean) => (
|
|
<Tag color={success ? 'green' : 'red'}>
|
|
{success ? t('Success') : t('Failure')}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: t('Actions'),
|
|
width: 80,
|
|
align: 'center' as const,
|
|
render: (_: any, rec: AuditLogItem) => (
|
|
<Button size="small" onClick={() => setSelectedLog(rec)}>{t('Details')}</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<PageCard
|
|
title={t('Audit Logs')}
|
|
extra={
|
|
<Space align="center" wrap size={[8, 8]}>
|
|
<RangePicker
|
|
showTime
|
|
size="small"
|
|
onChange={dates => {
|
|
setFilters(f => ({
|
|
...f,
|
|
start_time: dates?.[0] ? formatISO(dates[0].toDate()) : '',
|
|
end_time: dates?.[1] ? formatISO(dates[1].toDate()) : '',
|
|
page: 1,
|
|
}));
|
|
}}
|
|
/>
|
|
<Select
|
|
style={{ width: 120 }}
|
|
placeholder={t('Action')}
|
|
allowClear
|
|
size="small"
|
|
value={filters.action || undefined}
|
|
onChange={action => setFilters(f => ({ ...f, action: action || '', page: 1 }))}
|
|
options={ACTION_OPTIONS.map(a => ({ value: a, label: a }))}
|
|
/>
|
|
<Select
|
|
style={{ width: 120 }}
|
|
placeholder={t('Result')}
|
|
allowClear
|
|
size="small"
|
|
value={filters.success === '' ? undefined : filters.success}
|
|
onChange={value => setFilters(f => ({ ...f, success: (value as boolean) ?? '', page: 1 }))}
|
|
options={[
|
|
{ value: true, label: t('Success') },
|
|
{ value: false, label: t('Failure') },
|
|
]}
|
|
/>
|
|
<Input.Search
|
|
style={{ width: 120 }}
|
|
placeholder={t('Username')}
|
|
size="small"
|
|
allowClear
|
|
value={filters.username}
|
|
onChange={e => setFilters(f => ({ ...f, username: e.target.value }))}
|
|
onSearch={username => setFilters(f => ({ ...f, username, page: 1 }))}
|
|
/>
|
|
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
|
|
<Button danger onClick={handleClearLogs}>{t('Clear')}</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<Table
|
|
rowKey="id"
|
|
dataSource={data?.items}
|
|
columns={columns}
|
|
loading={loading}
|
|
pagination={{
|
|
current: filters.page,
|
|
pageSize: filters.page_size,
|
|
total: data?.total,
|
|
showSizeChanger: true,
|
|
onChange: (page, pageSize) => setFilters(f => ({ ...f, page, page_size: pageSize })),
|
|
}}
|
|
scroll={{ x: 900 }}
|
|
/>
|
|
<Modal
|
|
title={t('Audit Log Details')}
|
|
open={!!selectedLog}
|
|
onCancel={() => setSelectedLog(null)}
|
|
footer={null}
|
|
width={900}
|
|
styles={{ body: { maxHeight: '70vh', overflowY: 'auto' } }}
|
|
>
|
|
{selectedLog && (
|
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
|
<Descriptions
|
|
column={isMobile ? 1 : 2}
|
|
bordered
|
|
size="small"
|
|
labelStyle={{ minWidth: 120, whiteSpace: 'nowrap', fontWeight: 500 }}
|
|
contentStyle={{ wordBreak: 'break-all', whiteSpace: 'pre-wrap' }}
|
|
>
|
|
<Descriptions.Item label={t('Time')}>
|
|
{format(new Date(selectedLog.created_at), 'yyyy-MM-dd HH:mm:ss')}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Action')}>
|
|
<Tag color="blue">{selectedLog.action}</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('User')}>
|
|
{selectedLog.username || selectedLog.user_id || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Client IP')}>
|
|
{selectedLog.client_ip || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Path')} span={2}>
|
|
<Space size={6} wrap style={{ wordBreak: 'break-all' }}>
|
|
{renderHttpMethodTag(selectedLog.method)}
|
|
<Typography.Text copyable style={{ wordBreak: 'break-all' }}>{selectedLog.path}</Typography.Text>
|
|
</Space>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Status Code')}>
|
|
{selectedLog.status_code}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Duration (ms)')}>
|
|
{selectedLog.duration_ms ?? '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Result')}>
|
|
<Tag color={selectedLog.success ? 'green' : 'red'}>
|
|
{selectedLog.success ? t('Success') : t('Failure')}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Description')} span={2}>
|
|
{selectedLog.description || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label={t('Error')} span={2}>
|
|
{selectedLog.error || '-'}
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
<Divider style={{ margin: '12px 0 0' }} />
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
{t('Request Params')}
|
|
</Typography.Title>
|
|
<pre style={{ maxHeight: 200, overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
|
{selectedLog.request_params ? JSON.stringify(selectedLog.request_params, null, 2) : '-'}
|
|
</pre>
|
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
{t('Request Body')}
|
|
</Typography.Title>
|
|
<pre style={{ maxHeight: 200, overflow: 'auto', background: 'var(--ant-color-fill-tertiary, #f5f5f5)', padding: 12 }}>
|
|
{selectedLog.request_body ? JSON.stringify(selectedLog.request_body, null, 2) : '-'}
|
|
</pre>
|
|
</Space>
|
|
)}
|
|
</Modal>
|
|
</PageCard>
|
|
);
|
|
});
|
|
|
|
export default AuditLogsPage;
|