All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m13s
- IP138 在线 API + ip2region 离线库双通道归属地解析,60 秒熔断降级 - 5 条异常检测规则:地区不对/不可能旅行/频繁登录/团队遍地开花/海外IP太杂 - 飞书 interactive 卡片告警(红色严重/橙色警告),含辅助指标 - R2 自动封禁用户、R4 自动封禁团队,封禁即踢下线 - 系统设置页全局配置 + 团队详情页独立阈值覆盖 - 安全日志页面 + 管理员修改密码入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
225 lines
8.3 KiB
TypeScript
225 lines
8.3 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react';
|
|
import { adminApi } from '../lib/api';
|
|
import type { LoginAnomaly, Team } from '../types';
|
|
import { showToast } from '../components/Toast';
|
|
import { DatePicker } from '../components/DatePicker';
|
|
import { Select } from '../components/Select';
|
|
import styles from './AuditLogsPage.module.css';
|
|
|
|
const RULE_OPTIONS = [
|
|
{ label: '全部规则', value: '' },
|
|
{ label: 'R1 登录地区不对', value: 'region_mismatch' },
|
|
{ label: 'R2 不可能的旅行', value: 'impossible_travel' },
|
|
{ label: 'R3 登录太频繁', value: 'login_frequency' },
|
|
{ label: 'R4 团队遍地开花', value: 'multi_city' },
|
|
{ label: 'R5 海外IP太杂', value: 'overseas_ip_diversity' },
|
|
];
|
|
|
|
const LEVEL_OPTIONS = [
|
|
{ label: '全部级别', value: '' },
|
|
{ label: '警告', value: 'warning' },
|
|
{ label: '严重', value: 'critical' },
|
|
];
|
|
|
|
const RULE_LABELS: Record<string, string> = {
|
|
region_mismatch: 'R1 登录地区不对',
|
|
impossible_travel: 'R2 不可能的旅行',
|
|
login_frequency: 'R3 登录太频繁',
|
|
multi_city: 'R4 团队遍地开花',
|
|
overseas_ip_diversity: 'R5 海外IP太杂',
|
|
};
|
|
|
|
export function AnomalyLogPage() {
|
|
const [anomalies, setAnomalies] = useState<LoginAnomaly[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [ruleFilter, setRuleFilter] = useState('');
|
|
const [levelFilter, setLevelFilter] = useState('');
|
|
const [teamFilter, setTeamFilter] = useState('');
|
|
const [startDate, setStartDate] = useState('');
|
|
const [endDate, setEndDate] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [teams, setTeams] = useState<Team[]>([]);
|
|
const pageSize = 20;
|
|
|
|
useEffect(() => {
|
|
adminApi.getTeams().then(({ data }) => setTeams(data.results)).catch(() => {});
|
|
}, []);
|
|
|
|
const fetchAnomalies = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await adminApi.getLoginAnomalies({
|
|
page, page_size: pageSize,
|
|
rule: ruleFilter || undefined,
|
|
level: levelFilter || undefined,
|
|
team_id: teamFilter ? Number(teamFilter) : undefined,
|
|
start_date: startDate || undefined,
|
|
end_date: endDate || undefined,
|
|
});
|
|
setAnomalies(data.results);
|
|
setTotal(data.total);
|
|
} catch {
|
|
showToast('加载安全日志失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, ruleFilter, levelFilter, teamFilter, startDate, endDate]);
|
|
|
|
useEffect(() => { fetchAnomalies(); }, [fetchAnomalies]);
|
|
|
|
const handleSearch = () => {
|
|
setPage(1);
|
|
fetchAnomalies();
|
|
};
|
|
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
|
|
const teamOptions = [
|
|
{ label: '全部团队', value: '' },
|
|
...teams.map((t) => ({ label: t.name, value: String(t.id) })),
|
|
];
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<h1 className={styles.title}>安全日志</h1>
|
|
|
|
<div className={styles.filters}>
|
|
<Select
|
|
value={ruleFilter}
|
|
onChange={(v) => { setRuleFilter(v); setPage(1); }}
|
|
placeholder="全部规则"
|
|
options={RULE_OPTIONS}
|
|
/>
|
|
<Select
|
|
value={levelFilter}
|
|
onChange={(v) => { setLevelFilter(v); setPage(1); }}
|
|
placeholder="全部级别"
|
|
options={LEVEL_OPTIONS}
|
|
/>
|
|
<Select
|
|
value={teamFilter}
|
|
onChange={(v) => { setTeamFilter(v); setPage(1); }}
|
|
placeholder="全部团队"
|
|
options={teamOptions}
|
|
/>
|
|
<DatePicker value={startDate} onChange={setStartDate} placeholder="开始日期" />
|
|
<span className={styles.dateSep}>~</span>
|
|
<DatePicker value={endDate} onChange={setEndDate} placeholder="结束日期" />
|
|
<button className={styles.searchBtn} onClick={handleSearch}>查询</button>
|
|
<button className={styles.refreshBtn} onClick={fetchAnomalies}>刷新</button>
|
|
</div>
|
|
|
|
<div className={styles.tableWrapper}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>时间</th>
|
|
<th>团队</th>
|
|
<th>用户</th>
|
|
<th>级别</th>
|
|
<th>规则</th>
|
|
<th>IP / 归属地</th>
|
|
<th>详情</th>
|
|
<th>处理</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
Array.from({ length: 5 }).map((_, i) => (
|
|
<tr key={i}>
|
|
{Array.from({ length: 8 }).map((_, j) => (
|
|
<td key={j}><div className={styles.skeletonCell} /></td>
|
|
))}
|
|
</tr>
|
|
))
|
|
) : anomalies.length === 0 ? (
|
|
<tr><td colSpan={8} className={styles.empty}>暂无异常记录</td></tr>
|
|
) : (
|
|
anomalies.map((a) => (
|
|
<tr key={a.id}>
|
|
<td className={styles.timeCell}>{new Date(a.created_at).toLocaleString('zh-CN')}</td>
|
|
<td>{a.team_name}</td>
|
|
<td>{a.username}</td>
|
|
<td>
|
|
<span style={{
|
|
padding: '2px 8px', borderRadius: 4, fontSize: 12, whiteSpace: 'nowrap',
|
|
background: a.level === 'critical' ? 'rgba(255, 77, 79, 0.15)' : 'rgba(250, 173, 20, 0.15)',
|
|
color: a.level === 'critical' ? '#ff4d4f' : '#faad14',
|
|
}}>
|
|
{a.level === 'critical' ? '严重' : '警告'}
|
|
</span>
|
|
</td>
|
|
<td><span className={styles.actionBadge}>{RULE_LABELS[a.rule] || a.rule}</span></td>
|
|
<td className={styles.ipCell}>
|
|
{a.ip_address}
|
|
{(a.geo_country || a.geo_city) && (
|
|
<div style={{ fontSize: 11, color: 'var(--color-text-secondary)' }}>
|
|
{[a.geo_country, a.geo_province, a.geo_city].filter(Boolean).join(' ')}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td style={{ fontSize: 12, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
{renderDetail(a)}
|
|
</td>
|
|
<td>
|
|
{a.auto_disabled ? (
|
|
<span style={{ fontSize: 12, color: '#ff4d4f' }}>
|
|
已封禁{a.disabled_target === 'team' ? '团队' : '用户'}
|
|
</span>
|
|
) : a.alerted ? (
|
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>已告警</span>
|
|
) : (
|
|
<span style={{ fontSize: 12, color: 'var(--color-text-secondary)' }}>-</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className={styles.pagination}>
|
|
<span className={styles.pageInfo}>共 {total} 条</span>
|
|
<div className={styles.pageButtons}>
|
|
<button disabled={page <= 1} onClick={() => setPage(page - 1)}><</button>
|
|
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
|
let p: number;
|
|
if (totalPages <= 5) p = i + 1;
|
|
else if (page <= 3) p = i + 1;
|
|
else if (page >= totalPages - 2) p = totalPages - 4 + i;
|
|
else p = page - 2 + i;
|
|
return (
|
|
<button key={p} className={page === p ? styles.activePage : ''} onClick={() => setPage(p)}>
|
|
{p}
|
|
</button>
|
|
);
|
|
})}
|
|
<button disabled={page >= totalPages} onClick={() => setPage(page + 1)}>></button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function renderDetail(a: LoginAnomaly): string {
|
|
const d = a.detail;
|
|
switch (a.rule) {
|
|
case 'region_mismatch':
|
|
return `预期: ${(d.expected as string[] || []).join(',')} | 实际: ${d.city || ''}`;
|
|
case 'impossible_travel':
|
|
return `${d.previous_city || ''} → ${d.current_city || ''}`;
|
|
case 'login_frequency':
|
|
return `${d.count || 0} 次 / ${d.window_seconds || 0}s`;
|
|
case 'multi_city':
|
|
return `预期外城市: ${(d.unexpected_cities as string[] || []).join(',')}`;
|
|
case 'overseas_ip_diversity':
|
|
return `海外国家: ${(d.countries as string[] || []).join(',')}`;
|
|
default:
|
|
return JSON.stringify(d);
|
|
}
|
|
}
|