Files
insar-management-system-v2/frontend/src/AuditLogPanel.jsx
T

271 lines
8.8 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useCallback, useEffect, useMemo, useState } from 'react';
const LIMIT_OPTIONS = [100, 200, 500, 1000];
const ACTION_LABELS = {
write_access_granted: '写操作放行',
write_blocked_readonly: '只读拦截',
write_auth_required: '未登录拦截',
login_success: '登录成功',
login_failed: '登录失败',
logout: '退出登录',
user_created: '创建用户',
user_updated: '更新用户',
license_uploaded: '上传授权文件',
license_upload_failed: '上传授权失败',
license_refreshed: '刷新授权状态',
task_queued: '任务入队',
batch_created: '创建任务批次',
batch_marked_complete: '批次标记完成',
batch_item_updated: '更新批次条目',
dinsar_label_updated: '更新D-InSAR标签',
};
const getActionLabel = (action) => ACTION_LABELS[action] || action || '未知动作';
const getOutcome = (action) => {
const normalized = (action || '').toLowerCase();
if (normalized.includes('failed') || normalized.includes('blocked') || normalized.includes('required')) {
return 'blocked';
}
return 'ok';
};
const formatTime = (value) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString();
};
const formatDetail = (detail) => {
if (detail === null || detail === undefined || detail === '') {
return '-';
}
if (typeof detail === 'string') {
return detail;
}
try {
return JSON.stringify(detail);
} catch {
return '[detail 解析失败]';
}
};
const AuditLogPanel = ({ apiClient }) => {
const [logs, setLogs] = useState([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [limit, setLimit] = useState(200);
const [keyword, setKeyword] = useState('');
const [actionFilter, setActionFilter] = useState('all');
const [outcomeFilter, setOutcomeFilter] = useState('all');
const [autoRefresh, setAutoRefresh] = useState(false);
const [lastUpdated, setLastUpdated] = useState(null);
const loadLogs = useCallback(async ({ silent = false } = {}) => {
if (!silent) {
setLoading(true);
setMessage('');
}
try {
const response = await apiClient.get('/auth/audit-logs', { params: { limit } });
const data = Array.isArray(response.data) ? response.data : [];
setLogs(data);
setLastUpdated(new Date());
} catch (error) {
setMessage(error.response?.data?.detail || '加载审计日志失败。');
} finally {
if (!silent) {
setLoading(false);
}
}
}, [apiClient, limit]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
useEffect(() => {
if (!autoRefresh) return undefined;
const timer = setInterval(() => {
loadLogs({ silent: true });
}, 15000);
return () => clearInterval(timer);
}, [autoRefresh, loadLogs]);
const actionOptions = useMemo(() => {
return Array.from(new Set(logs.map((item) => item.action).filter(Boolean))).sort();
}, [logs]);
const filteredLogs = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return logs.filter((item) => {
if (actionFilter !== 'all' && item.action !== actionFilter) {
return false;
}
const outcome = getOutcome(item.action);
if (outcomeFilter !== 'all' && outcome !== outcomeFilter) {
return false;
}
if (!normalizedKeyword) {
return true;
}
const detailText = formatDetail(item.detail).toLowerCase();
const searchText = [
item.username || '',
item.action || '',
item.resource || '',
item.ip_address || '',
detailText,
].join(' ').toLowerCase();
return searchText.includes(normalizedKeyword);
});
}, [logs, actionFilter, outcomeFilter, keyword]);
const summary = useMemo(() => {
const total = filteredLogs.length;
const blocked = filteredLogs.filter((item) => getOutcome(item.action) === 'blocked').length;
const passed = total - blocked;
return { total, blocked, passed };
}, [filteredLogs]);
return (
<div className="audit-panel">
<div className="panel-card audit-panel-header">
<div className="audit-header-title">
<div className="panel-card-title">审计日志</div>
<p className="panel-card-desc">记录关键操作权限拦截和鉴权行为便于排查问题</p>
</div>
<div className="audit-header-actions">
<label className="audit-switch">
<input
type="checkbox"
checked={autoRefresh}
onChange={(event) => setAutoRefresh(event.target.checked)}
/>
自动刷新15s
</label>
<button
type="button"
className="secondary-btn"
onClick={() => loadLogs()}
disabled={loading}
>
{loading ? '刷新中...' : '立即刷新'}
</button>
</div>
</div>
<div className="panel-card audit-summary-card">
<div className="audit-summary-grid">
<div className="audit-summary-item">
<span>当前显示</span>
<strong>{summary.total}</strong>
</div>
<div className="audit-summary-item ok">
<span>通过</span>
<strong>{summary.passed}</strong>
</div>
<div className="audit-summary-item blocked">
<span>拦截/失败</span>
<strong>{summary.blocked}</strong>
</div>
<div className="audit-summary-item">
<span>更新时间</span>
<strong>{lastUpdated ? lastUpdated.toLocaleTimeString() : '-'}</strong>
</div>
</div>
</div>
<div className="panel-card audit-filter-card">
<div className="audit-filter-grid">
<label className="audit-filter-field">
<span>关键字</span>
<input
type="text"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="用户名 / 动作 / 资源 / 详情"
/>
</label>
<label className="audit-filter-field">
<span>动作类型</span>
<select value={actionFilter} onChange={(event) => setActionFilter(event.target.value)}>
<option value="all">全部动作</option>
{actionOptions.map((action) => (
<option key={action} value={action}>
{getActionLabel(action)}
</option>
))}
</select>
</label>
<label className="audit-filter-field">
<span>结果</span>
<select value={outcomeFilter} onChange={(event) => setOutcomeFilter(event.target.value)}>
<option value="all">全部结果</option>
<option value="ok">通过</option>
<option value="blocked">拦截/失败</option>
</select>
</label>
<label className="audit-filter-field">
<span>返回条数</span>
<select value={limit} onChange={(event) => setLimit(Number(event.target.value) || 200)}>
{LIMIT_OPTIONS.map((value) => (
<option key={value} value={value}>
最近 {value}
</option>
))}
</select>
</label>
</div>
</div>
<div className="panel-card audit-list-card">
<div className="audit-list">
{filteredLogs.map((item) => {
const outcome = getOutcome(item.action);
return (
<div key={item.id} className="audit-log-item">
<div className="audit-log-top">
<div className="audit-log-main">
<span className={`audit-outcome ${outcome}`}>{outcome === 'ok' ? '通过' : '拦截/失败'}</span>
<strong>{getActionLabel(item.action)}</strong>
<span className="audit-operator">{item.username || '匿名/未登录'}</span>
</div>
<span className="audit-time">{formatTime(item.created_at)}</span>
</div>
<div className="audit-log-meta">
<span>资源{item.resource || '-'}</span>
<span>IP{item.ip_address || '-'}</span>
</div>
<div className="audit-log-detail">详情{formatDetail(item.detail)}</div>
</div>
);
})}
{!loading && filteredLogs.length === 0 && (
<div className="empty-state">当前筛选条件下暂无日志</div>
)}
</div>
</div>
{message && (
<div className="panel-inline-message error">
{message}
</div>
)}
</div>
);
};
export default AuditLogPanel;