466 lines
14 KiB
TypeScript
466 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Table,
|
|
Card,
|
|
Typography,
|
|
Space,
|
|
Tag,
|
|
DatePicker,
|
|
Select,
|
|
Input,
|
|
Button,
|
|
Row,
|
|
Col,
|
|
Alert,
|
|
Timeline,
|
|
} from 'antd';
|
|
import {
|
|
AuditOutlined,
|
|
SearchOutlined,
|
|
FilterOutlined,
|
|
UserOutlined,
|
|
AppstoreOutlined,
|
|
KeyOutlined,
|
|
ClockCircleOutlined,
|
|
CheckCircleOutlined,
|
|
ExclamationCircleOutlined,
|
|
DeleteOutlined,
|
|
} from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
import { apiService, AuditEvent, AuditQueryParams } from '../services/apiService';
|
|
|
|
dayjs.extend(relativeTime);
|
|
|
|
const { Title, Text } = Typography;
|
|
const { RangePicker } = DatePicker;
|
|
const { Option } = Select;
|
|
|
|
const Audit: React.FC = () => {
|
|
const [auditData, setAuditData] = useState<AuditEvent[]>([]);
|
|
const [filteredData, setFilteredData] = useState<AuditEvent[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [filters, setFilters] = useState({
|
|
dateRange: null as any,
|
|
action: '',
|
|
status: '',
|
|
user: '',
|
|
resourceType: '',
|
|
});
|
|
|
|
// Load audit data on component mount
|
|
useEffect(() => {
|
|
loadAuditData();
|
|
}, []);
|
|
|
|
const loadAuditData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await apiService.getAuditEvents({
|
|
limit: 100,
|
|
order_by: 'timestamp',
|
|
order_desc: true,
|
|
});
|
|
setAuditData(response.events);
|
|
setFilteredData(response.events);
|
|
} catch (error) {
|
|
console.error('Failed to load audit data:', error);
|
|
// Keep empty arrays on error
|
|
setAuditData([]);
|
|
setFilteredData([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const applyFilters = async () => {
|
|
// For real-time filtering, we'll use the API with filters
|
|
try {
|
|
setLoading(true);
|
|
const params: AuditQueryParams = {
|
|
limit: 100,
|
|
order_by: 'timestamp',
|
|
order_desc: true,
|
|
};
|
|
|
|
if (filters.dateRange && filters.dateRange.length === 2) {
|
|
const [start, end] = filters.dateRange;
|
|
params.start_time = start.toISOString();
|
|
params.end_time = end.toISOString();
|
|
}
|
|
|
|
if (filters.action) {
|
|
params.event_types = [filters.action];
|
|
}
|
|
|
|
if (filters.status) {
|
|
params.statuses = [filters.status];
|
|
}
|
|
|
|
if (filters.user) {
|
|
params.actor_id = filters.user;
|
|
}
|
|
|
|
if (filters.resourceType) {
|
|
params.resource_type = filters.resourceType;
|
|
}
|
|
|
|
const response = await apiService.getAuditEvents(params);
|
|
setFilteredData(response.events);
|
|
} catch (error) {
|
|
console.error('Failed to apply filters:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
dateRange: null,
|
|
action: '',
|
|
status: '',
|
|
user: '',
|
|
resourceType: '',
|
|
});
|
|
loadAuditData(); // Reload original data
|
|
};
|
|
|
|
const getStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'success':
|
|
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
|
|
case 'failure':
|
|
return <ExclamationCircleOutlined style={{ color: '#ff4d4f' }} />;
|
|
case 'warning':
|
|
return <ExclamationCircleOutlined style={{ color: '#faad14' }} />;
|
|
default:
|
|
return <ClockCircleOutlined style={{ color: '#1890ff' }} />;
|
|
}
|
|
};
|
|
|
|
const getActionIcon = (action: string) => {
|
|
if (action.includes('APPLICATION')) return <AppstoreOutlined />;
|
|
if (action.includes('TOKEN')) return <KeyOutlined />;
|
|
if (action.includes('USER')) return <UserOutlined />;
|
|
return <AuditOutlined />;
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: 'Timestamp',
|
|
dataIndex: 'timestamp',
|
|
key: 'timestamp',
|
|
render: (timestamp: string) => (
|
|
<div>
|
|
<div>{dayjs(timestamp).format('MMM DD, YYYY')}</div>
|
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
|
{dayjs(timestamp).format('HH:mm:ss')}
|
|
</Text>
|
|
</div>
|
|
),
|
|
sorter: (a: AuditEvent, b: AuditEvent) =>
|
|
dayjs(a.timestamp).unix() - dayjs(b.timestamp).unix(),
|
|
defaultSortOrder: 'descend' as const,
|
|
},
|
|
{
|
|
title: 'User',
|
|
dataIndex: 'actor_id',
|
|
key: 'actor_id',
|
|
render: (actorId: string) => (
|
|
<div>
|
|
<UserOutlined style={{ marginRight: '8px' }} />
|
|
{actorId || 'System'}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Action',
|
|
dataIndex: 'type',
|
|
key: 'type',
|
|
render: (type: string) => (
|
|
<div>
|
|
{getActionIcon(type)}
|
|
<span style={{ marginLeft: '8px' }}>{type.replace(/_/g, ' ').replace(/\./g, ' ')}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Resource',
|
|
key: 'resource',
|
|
render: (_: any, record: AuditEvent) => (
|
|
<div>
|
|
<div>
|
|
<Tag color="blue">{record.resource_type?.toUpperCase() || 'N/A'}</Tag>
|
|
</div>
|
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
|
{record.resource_id || 'N/A'}
|
|
</Text>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: 'Status',
|
|
dataIndex: 'status',
|
|
key: 'status',
|
|
render: (status: string) => (
|
|
<Tag
|
|
color={status === 'success' ? 'green' : status === 'failure' ? 'red' : 'orange'}
|
|
icon={getStatusIcon(status)}
|
|
>
|
|
{status.toUpperCase()}
|
|
</Tag>
|
|
),
|
|
},
|
|
{
|
|
title: 'IP Address',
|
|
dataIndex: 'actor_ip',
|
|
key: 'actor_ip',
|
|
render: (ip: string) => <Text code>{ip || 'N/A'}</Text>,
|
|
},
|
|
];
|
|
|
|
const expandedRowRender = (record: AuditEvent) => (
|
|
<Card size="small" title="Event Details">
|
|
<Row gutter={16}>
|
|
<Col span={12}>
|
|
<Space direction="vertical" size="small">
|
|
<div>
|
|
<Text strong>User Agent:</Text>
|
|
<div style={{ wordBreak: 'break-all' }}>
|
|
<Text type="secondary">{record.user_agent}</Text>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Text strong>Event ID:</Text>
|
|
<div><Text code>{record.id}</Text></div>
|
|
</div>
|
|
</Space>
|
|
</Col>
|
|
<Col span={12}>
|
|
<div>
|
|
<Text strong>Additional Details:</Text>
|
|
<pre style={{
|
|
background: '#f5f5f5',
|
|
padding: '8px',
|
|
borderRadius: '4px',
|
|
fontSize: '12px',
|
|
marginTop: '8px'
|
|
}}>
|
|
{JSON.stringify(record.details, null, 2)}
|
|
</pre>
|
|
</div>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
|
<div>
|
|
<Title level={2}>Audit Log</Title>
|
|
<Text type="secondary">
|
|
Monitor and track all system activities and security events
|
|
</Text>
|
|
</div>
|
|
|
|
{/* Statistics Cards */}
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<Card>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<AuditOutlined style={{ fontSize: '32px', color: '#1890ff', marginBottom: '8px' }} />
|
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>{filteredData.length}</div>
|
|
<div>Total Events</div>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<CheckCircleOutlined style={{ fontSize: '32px', color: '#52c41a', marginBottom: '8px' }} />
|
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
|
{filteredData.filter(e => e.status === 'success').length}
|
|
</div>
|
|
<div>Successful</div>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<ExclamationCircleOutlined style={{ fontSize: '32px', color: '#ff4d4f', marginBottom: '8px' }} />
|
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
|
{filteredData.filter(e => e.status === 'failure').length}
|
|
</div>
|
|
<div>Failed</div>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
<Col span={6}>
|
|
<Card>
|
|
<div style={{ textAlign: 'center' }}>
|
|
<UserOutlined style={{ fontSize: '32px', color: '#722ed1', marginBottom: '8px' }} />
|
|
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
|
{new Set(filteredData.map(e => e.actor_id).filter(id => id)).size}
|
|
</div>
|
|
<div>Unique Users</div>
|
|
</div>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
|
|
{/* Filters */}
|
|
<Card title="Filters" extra={
|
|
<Space>
|
|
<Button onClick={applyFilters} type="primary" icon={<SearchOutlined />}>
|
|
Apply Filters
|
|
</Button>
|
|
<Button onClick={clearFilters} icon={<DeleteOutlined />}>
|
|
Clear
|
|
</Button>
|
|
</Space>
|
|
}>
|
|
<Row gutter={16}>
|
|
<Col span={6}>
|
|
<div style={{ marginBottom: '8px' }}>
|
|
<Text strong>Date Range:</Text>
|
|
</div>
|
|
<RangePicker
|
|
style={{ width: '100%' }}
|
|
value={filters.dateRange}
|
|
onChange={(dates) => setFilters({ ...filters, dateRange: dates })}
|
|
/>
|
|
</Col>
|
|
<Col span={4}>
|
|
<div style={{ marginBottom: '8px' }}>
|
|
<Text strong>Action:</Text>
|
|
</div>
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
placeholder="All actions"
|
|
value={filters.action}
|
|
onChange={(value) => setFilters({ ...filters, action: value })}
|
|
allowClear
|
|
>
|
|
<Option value="app.created">Application Created</Option>
|
|
<Option value="app.updated">Application Updated</Option>
|
|
<Option value="app.deleted">Application Deleted</Option>
|
|
<Option value="auth.token_created">Token Created</Option>
|
|
<Option value="auth.token_revoked">Token Revoked</Option>
|
|
<Option value="auth.token_validated">Token Validated</Option>
|
|
<Option value="auth.login">Login</Option>
|
|
<Option value="auth.login_failed">Login Failed</Option>
|
|
</Select>
|
|
</Col>
|
|
<Col span={4}>
|
|
<div style={{ marginBottom: '8px' }}>
|
|
<Text strong>Status:</Text>
|
|
</div>
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
placeholder="All statuses"
|
|
value={filters.status}
|
|
onChange={(value) => setFilters({ ...filters, status: value })}
|
|
allowClear
|
|
>
|
|
<Option value="success">Success</Option>
|
|
<Option value="failure">Failure</Option>
|
|
<Option value="warning">Warning</Option>
|
|
</Select>
|
|
</Col>
|
|
<Col span={5}>
|
|
<div style={{ marginBottom: '8px' }}>
|
|
<Text strong>User:</Text>
|
|
</div>
|
|
<Input
|
|
placeholder="Search by user"
|
|
value={filters.user}
|
|
onChange={(e) => setFilters({ ...filters, user: e.target.value })}
|
|
allowClear
|
|
/>
|
|
</Col>
|
|
<Col span={5}>
|
|
<div style={{ marginBottom: '8px' }}>
|
|
<Text strong>Resource Type:</Text>
|
|
</div>
|
|
<Select
|
|
style={{ width: '100%' }}
|
|
placeholder="All types"
|
|
value={filters.resourceType}
|
|
onChange={(value) => setFilters({ ...filters, resourceType: value })}
|
|
allowClear
|
|
>
|
|
<Option value="application">Application</Option>
|
|
<Option value="token">Token</Option>
|
|
<Option value="user">User</Option>
|
|
</Select>
|
|
</Col>
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* Recent Activity Timeline */}
|
|
<Card title="Recent Activity">
|
|
<Timeline>
|
|
{filteredData.slice(0, 5).map((entry) => (
|
|
<Timeline.Item
|
|
key={entry.id}
|
|
dot={getStatusIcon(entry.status)}
|
|
color={entry.status === 'success' ? 'green' : entry.status === 'failure' ? 'red' : 'orange'}
|
|
>
|
|
<div>
|
|
<Text strong>{entry.type.replace(/_/g, ' ').replace(/\./g, ' ')}</Text>
|
|
<div>
|
|
<Text type="secondary">
|
|
{entry.actor_id || 'System'} • {dayjs(entry.timestamp).fromNow()}
|
|
</Text>
|
|
</div>
|
|
<div>
|
|
<Tag>{entry.resource_type || 'N/A'}</Tag>
|
|
<Text type="secondary" style={{ marginLeft: '8px' }}>
|
|
{entry.resource_id || 'N/A'}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
</Timeline.Item>
|
|
))}
|
|
</Timeline>
|
|
</Card>
|
|
|
|
{/* Audit Log Table */}
|
|
<Card title="Audit Log Entries">
|
|
{filteredData.length === 0 && !loading && (
|
|
<Alert
|
|
message="No Audit Events Found"
|
|
description="No audit events match your current filters. Try adjusting the filters or check if any events have been logged to the system."
|
|
type="info"
|
|
showIcon
|
|
style={{ marginBottom: '16px' }}
|
|
/>
|
|
)}
|
|
|
|
<Table
|
|
columns={columns}
|
|
dataSource={filteredData}
|
|
rowKey="id"
|
|
loading={loading}
|
|
expandable={{
|
|
expandedRowRender,
|
|
expandRowByClick: true,
|
|
}}
|
|
pagination={{
|
|
pageSize: 10,
|
|
showSizeChanger: true,
|
|
showQuickJumper: true,
|
|
showTotal: (total, range) =>
|
|
`${range[0]}-${range[1]} of ${total} audit entries`,
|
|
}}
|
|
/>
|
|
</Card>
|
|
</Space>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Audit;
|