"use client"; import { Activity, AlertTriangle, Ban, Clock3, Globe2, LogIn, Plus, RefreshCw, Search, ShieldAlert, Unlock, UserCheck, } from "lucide-react"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import useSWR from "swr"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { cn, fetcher } from "@/lib/utils"; type Severity = "info" | "watch" | "high" | "critical"; type AccessLogEvent = { id: string; eventType: string; ip: string; userEmail: string | null; userRole: string | null; method: string | null; path: string | null; routeGroup: string | null; statusCode: number | null; outcome: string; severity: Severity; reasons: string[] | null; userAgent: string | null; createdAt: string; }; type AccessLogTopIp = { ip: string; total: number; uniqueUsers: number; failureCount: number; rateLimitedCount: number; authFailureCount: number; loginSuccessCount: number; suspiciousCount: number; firstSeen: string; lastSeen: string; severity: Severity; failureRate: number; reasons: string[]; }; type AccessLogResponse = { overview: { totalEvents: number; uniqueIps: number; abnormalEvents: number; abnormalIps: number; rateLimitedEvents: number; manualBlockedEvents: number; loginSuccessEvents: number; unauthorizedEvents: number; }; topIps: AccessLogTopIp[]; hourlyStats: Array<{ hour: string; count: number; abnormalCount: number; }>; pathStats: Array<{ path: string | null; routeGroup: string | null; count: number; abnormalCount: number; }>; recentEvents: AccessLogEvent[]; rules: string[]; }; type AccessBlocklistEntry = { id: string; ip: string; reason: string | null; isActive: boolean; expiresAt: string | null; createdAt: string; updatedAt: string; createdByEmail: string | null; }; const severityLabels: Record = { info: "正常", watch: "关注", high: "异常", critical: "高危", }; const severityClassNames: Record = { info: "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-300", watch: "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800/70 dark:bg-amber-950/45 dark:text-amber-300", high: "border-orange-200 bg-orange-50 text-orange-700 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-300", critical: "border-red-200 bg-red-50 text-red-700 dark:border-red-800/70 dark:bg-red-950/45 dark:text-red-300", }; function formatNumber(value: number | null | undefined) { return new Intl.NumberFormat("zh-CN").format(Number(value || 0)); } function formatPercent(value: number | null | undefined) { return `${Math.round(Number(value || 0) * 100)}%`; } function formatTime(value: string | null | undefined) { if (!value) return "--"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "--"; return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }).format(date); } function getStatusClassName(statusCode: number | null) { if (!statusCode) return "text-muted-foreground"; if (statusCode >= 500) return "text-red-700 dark:text-red-300"; if (statusCode >= 400) return "text-orange-700 dark:text-orange-300"; if (statusCode >= 300) return "text-sky-700 dark:text-sky-300"; return "text-emerald-700 dark:text-emerald-300"; } function AccessSeverityBadge({ severity }: { severity: Severity }) { return ( {severityLabels[severity]} ); } export function AccessSecurityDashboard() { const [hours, setHours] = useState("24"); const [severity, setSeverity] = useState("all"); const [query, setQuery] = useState(""); const [blockIp, setBlockIp] = useState(""); const [blockReason, setBlockReason] = useState(""); const [isSavingBlock, setIsSavingBlock] = useState(false); const accessLogUrl = useMemo(() => { const params = new URLSearchParams({ hours, severity, limit: "160", }); if (query.trim()) params.set("q", query.trim()); return `/api/admin/access-logs?${params.toString()}`; }, [hours, query, severity]); const { data, isLoading, mutate } = useSWR( accessLogUrl, fetcher, { refreshInterval: 30_000 } ); const { data: blocklistData, mutate: mutateBlocklist } = useSWR<{ entries: AccessBlocklistEntry[]; }>("/api/admin/access-blocklist", fetcher); const activeBlocklistEntries = useMemo( () => (blocklistData?.entries || []).filter((entry) => entry.isActive), [blocklistData?.entries] ); const blockedRules = useMemo( () => new Set(activeBlocklistEntries.map((entry) => entry.ip.toLowerCase())), [activeBlocklistEntries] ); const saveBlockEntry = async (targetIp?: string, targetReason?: string) => { const ip = String(targetIp || blockIp).trim(); const reason = String(targetReason ?? blockReason).trim(); if (!ip) { toast.error("请输入要拉黑的 IP"); return; } setIsSavingBlock(true); try { const response = await fetch("/api/admin/access-blocklist", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ ip, reason }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload?.message || payload?.error || "拉黑失败"); } toast.success(`${ip} 已加入手动黑名单`); setBlockIp(""); setBlockReason(""); await Promise.all([mutateBlocklist(), mutate()]); } catch (error) { toast.error(error instanceof Error ? error.message : "拉黑失败"); } finally { setIsSavingBlock(false); } }; const updateBlockEntry = async (entry: AccessBlocklistEntry, isActive: boolean) => { try { const response = await fetch("/api/admin/access-blocklist", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: entry.id, isActive, reason: entry.reason || "", expiresAt: entry.expiresAt || null, }), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(payload?.message || payload?.error || "更新失败"); } toast.success(isActive ? `${entry.ip} 已重新拉黑` : `${entry.ip} 已解除拉黑`); await Promise.all([mutateBlocklist(), mutate()]); } catch (error) { toast.error(error instanceof Error ? error.message : "更新失败"); } }; const overviewCards = [ { key: "events", label: "访问记录", value: formatNumber(data?.overview.totalEvents), note: `${hours} 小时窗口`, icon: Activity, className: "border-slate-200 bg-slate-50 text-slate-900 dark:border-slate-700/70 dark:bg-slate-900/70 dark:text-slate-100", }, { key: "ips", label: "独立 IP", value: formatNumber(data?.overview.uniqueIps), note: `${formatNumber(data?.overview.abnormalIps)} 个异常标注`, icon: Globe2, className: "border-sky-200 bg-sky-50 text-sky-900 dark:border-sky-800/70 dark:bg-sky-950/45 dark:text-sky-100", }, { key: "abnormal", label: "异常事件", value: formatNumber(data?.overview.abnormalEvents), note: `${formatNumber(data?.overview.manualBlockedEvents)} 次手动阻断`, icon: ShieldAlert, className: "border-orange-200 bg-orange-50 text-orange-900 dark:border-orange-800/70 dark:bg-orange-950/45 dark:text-orange-100", }, { key: "login", label: "成功登录", value: formatNumber(data?.overview.loginSuccessEvents), note: `${formatNumber(data?.overview.unauthorizedEvents)} 次未授权`, icon: LogIn, className: "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/70 dark:bg-emerald-950/45 dark:text-emerald-100", }, ]; const maxHourlyCount = Math.max( 1, ...(data?.hourlyStats || []).map((item) => item.count) ); const maxPathCount = Math.max( 1, ...(data?.pathStats || []).map((item) => item.count) ); return (
访问安全分析 IP 访问记录、登录来源、异常标注与请求趋势
setQuery(event.target.value)} placeholder="搜索 IP、路径、用户" value={query} />
{overviewCards.map((item) => { const Icon = item.icon; return (
{item.label}
{item.value}
{item.note}
); })}
小时趋势
橙色表示异常事件占比
{(data?.hourlyStats || []).map((item) => { const height = Math.max(4, (item.count / maxHourlyCount) * 100); const abnormalHeight = item.count > 0 ? Math.max(0, (item.abnormalCount / item.count) * height) : 0; return (
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "} {formatNumber(item.abnormalCount)}
); })} {(!data?.hourlyStats || data.hourlyStats.length === 0) && (
暂无访问记录
)}
手动 IP 黑名单 管理员手动加入后,命中 IP 会被阻断并写入访问日志
setBlockIp(event.target.value)} placeholder="IP 或 IPv4 CIDR" value={blockIp} /> setBlockReason(event.target.value)} placeholder="拉黑原因" value={blockReason} />
{activeBlocklistEntries.map((entry) => (
{entry.ip}
{entry.reason || "未填写原因"}
{formatTime(entry.updatedAt)}
))} {activeBlocklistEntries.length === 0 ? (
暂无手动拉黑 IP
) : null}
异常 IP 标注 按限流、失败率、认证异常、敏感路径和请求量综合排序
{(data?.topIps || []).map((item) => (
{item.ip}
{formatNumber(item.total)} 次访问 {formatNumber(item.uniqueUsers)} 个用户 失败率 {formatPercent(item.failureRate)} 最近 {formatTime(item.lastSeen)}
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map( (reason) => ( {reason} ) )}
))} {(!data?.topIps || data.topIps.length === 0) && (
暂无 IP 聚合数据
)}
访问路径 按请求数量排序
{(data?.pathStats || []).map((item) => { const abnormalRate = item.count > 0 ? item.abnormalCount / item.count : 0; return (
{item.path || "/"} {formatNumber(item.count)}
); })} {(!data?.pathStats || data.pathStats.length === 0) && (
暂无路径统计
)}
访问日志列表 最近 {formatNumber(data?.recentEvents.length)} 条
{(data?.rules || []).map((rule) => ( {rule} ))}
时间
IP
状态
路径
用户
标注
{(data?.recentEvents || []).map((event) => (
{formatTime(event.createdAt)}
{event.ip}
{event.statusCode || "--"}
{event.method || "GET"} {event.path || "/"}
{event.reasons?.join("、") || event.outcome}
{event.userEmail || "匿名"}
{event.userRole || "unknown"}
{event.severity === "high" || event.severity === "critical" ? ( ) : null}
))} {(!data?.recentEvents || data.recentEvents.length === 0) && (
暂无访问日志
)}
); }