707 lines
26 KiB
TypeScript
707 lines
26 KiB
TypeScript
"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<Severity, string> = {
|
||
info: "正常",
|
||
watch: "关注",
|
||
high: "异常",
|
||
critical: "高危",
|
||
};
|
||
|
||
const severityClassNames: Record<Severity, string> = {
|
||
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 (
|
||
<Badge
|
||
className={cn("shrink-0 border px-2 py-0.5", severityClassNames[severity])}
|
||
variant="outline"
|
||
>
|
||
{severityLabels[severity]}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
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<AccessLogResponse>(
|
||
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 (
|
||
<div className="space-y-4">
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div>
|
||
<CardTitle className="text-xl">访问安全分析</CardTitle>
|
||
<CardDescription>
|
||
IP 访问记录、登录来源、异常标注与请求趋势
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||
<div className="relative sm:w-[260px]">
|
||
<Search className="-translate-y-1/2 absolute left-3 top-1/2 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
className="h-9 pl-9"
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
placeholder="搜索 IP、路径、用户"
|
||
value={query}
|
||
/>
|
||
</div>
|
||
<Select onValueChange={setHours} value={hours}>
|
||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1">近 1 小时</SelectItem>
|
||
<SelectItem value="6">近 6 小时</SelectItem>
|
||
<SelectItem value="24">近 24 小时</SelectItem>
|
||
<SelectItem value="72">近 3 天</SelectItem>
|
||
<SelectItem value="168">近 7 天</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select onValueChange={setSeverity} value={severity}>
|
||
<SelectTrigger className="h-9 sm:w-[132px]">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部级别</SelectItem>
|
||
<SelectItem value="abnormal">仅异常</SelectItem>
|
||
<SelectItem value="watch">关注</SelectItem>
|
||
<SelectItem value="high">异常</SelectItem>
|
||
<SelectItem value="critical">高危</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
className="h-9 gap-2"
|
||
onClick={() => mutate()}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||
刷新
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
{overviewCards.map((item) => {
|
||
const Icon = item.icon;
|
||
return (
|
||
<div
|
||
className={cn("rounded-lg border p-4", item.className)}
|
||
key={item.key}
|
||
>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-sm text-current/70">{item.label}</div>
|
||
<Icon className="h-4 w-4" />
|
||
</div>
|
||
<div className="mt-3 font-semibold text-3xl tracking-tight">
|
||
{item.value}
|
||
</div>
|
||
<div className="mt-1 text-current/65 text-xs">{item.note}</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="rounded-lg border bg-background p-4">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<div className="flex items-center gap-2 font-medium text-sm">
|
||
<Clock3 className="h-4 w-4" />
|
||
小时趋势
|
||
</div>
|
||
<div className="text-muted-foreground text-xs">
|
||
橙色表示异常事件占比
|
||
</div>
|
||
</div>
|
||
<div className="flex h-[180px] items-end gap-1">
|
||
{(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 (
|
||
<div
|
||
className="group relative flex min-w-[10px] flex-1 items-end overflow-hidden rounded bg-slate-200 dark:bg-slate-800"
|
||
key={item.hour}
|
||
style={{ height: `${height}%` }}
|
||
>
|
||
<div
|
||
className="w-full bg-orange-400"
|
||
style={{ height: `${abnormalHeight}%` }}
|
||
/>
|
||
<div className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-10 mb-2 hidden whitespace-nowrap rounded-md bg-zinc-950 px-2 py-1 text-white text-xs shadow-lg group-hover:block">
|
||
{item.hour} · {formatNumber(item.count)} 次 · 异常{" "}
|
||
{formatNumber(item.abnormalCount)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{(!data?.hourlyStats || data.hourlyStats.length === 0) && (
|
||
<div className="flex h-full w-full items-center justify-center rounded-lg border border-dashed text-muted-foreground text-sm">
|
||
暂无访问记录
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<CardTitle className="text-lg">手动 IP 黑名单</CardTitle>
|
||
<CardDescription>
|
||
管理员手动加入后,命中 IP 会被阻断并写入访问日志
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid gap-3 lg:grid-cols-[220px_minmax(240px,1fr)_auto]">
|
||
<Input
|
||
className="h-9"
|
||
onChange={(event) => setBlockIp(event.target.value)}
|
||
placeholder="IP 或 IPv4 CIDR"
|
||
value={blockIp}
|
||
/>
|
||
<Input
|
||
className="h-9"
|
||
onChange={(event) => setBlockReason(event.target.value)}
|
||
placeholder="拉黑原因"
|
||
value={blockReason}
|
||
/>
|
||
<Button
|
||
className="h-9 gap-2"
|
||
disabled={isSavingBlock}
|
||
onClick={() => saveBlockEntry()}
|
||
size="sm"
|
||
>
|
||
<Plus className="h-4 w-4" />
|
||
加入黑名单
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||
{activeBlocklistEntries.map((entry) => (
|
||
<div
|
||
className="rounded-lg border border-red-100 bg-red-50/60 px-3 py-3 dark:border-red-900/60 dark:bg-red-950/30"
|
||
key={entry.id}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-2">
|
||
<Ban className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||
<span className="truncate font-mono font-semibold text-sm text-red-900 dark:text-red-200">
|
||
{entry.ip}
|
||
</span>
|
||
</div>
|
||
<div className="mt-1 line-clamp-2 text-red-800/75 text-xs dark:text-red-300/75">
|
||
{entry.reason || "未填写原因"}
|
||
</div>
|
||
<div className="mt-2 text-red-700/70 text-xs dark:text-red-400/70">
|
||
{formatTime(entry.updatedAt)}
|
||
</div>
|
||
</div>
|
||
<Button
|
||
className="h-8 shrink-0 gap-1 border-red-200 bg-white text-red-700 hover:bg-red-50 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300 dark:hover:bg-red-950/60"
|
||
onClick={() => updateBlockEntry(entry, false)}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Unlock className="h-3.5 w-3.5" />
|
||
解除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{activeBlocklistEntries.length === 0 ? (
|
||
<div className="rounded-lg border border-dashed py-8 text-center text-muted-foreground text-sm md:col-span-2 xl:col-span-3">
|
||
暂无手动拉黑 IP
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(320px,0.7fr)]">
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<CardTitle className="text-lg">异常 IP 标注</CardTitle>
|
||
<CardDescription>
|
||
按限流、失败率、认证异常、敏感路径和请求量综合排序
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-2">
|
||
{(data?.topIps || []).map((item) => (
|
||
<div
|
||
className="rounded-lg border bg-background px-4 py-3"
|
||
key={item.ip}
|
||
>
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-mono font-semibold text-sm">
|
||
{item.ip}
|
||
</span>
|
||
<AccessSeverityBadge severity={item.severity} />
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap gap-2 text-muted-foreground text-xs">
|
||
<span>{formatNumber(item.total)} 次访问</span>
|
||
<span>{formatNumber(item.uniqueUsers)} 个用户</span>
|
||
<span>失败率 {formatPercent(item.failureRate)}</span>
|
||
<span>最近 {formatTime(item.lastSeen)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-1 md:justify-end">
|
||
{(item.reasons.length > 0 ? item.reasons : ["正常访问"]).map(
|
||
(reason) => (
|
||
<Badge
|
||
className="border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||
key={reason}
|
||
variant="outline"
|
||
>
|
||
{reason}
|
||
</Badge>
|
||
)
|
||
)}
|
||
<Button
|
||
className="h-7 gap-1 px-2 text-xs"
|
||
disabled={blockedRules.has(item.ip.toLowerCase()) || isSavingBlock}
|
||
onClick={() =>
|
||
saveBlockEntry(
|
||
item.ip,
|
||
item.reasons.join("、") || "管理员从异常 IP 排行手动拉黑"
|
||
)
|
||
}
|
||
size="sm"
|
||
variant="outline"
|
||
>
|
||
<Ban className="h-3.5 w-3.5" />
|
||
{blockedRules.has(item.ip.toLowerCase()) ? "已拉黑" : "拉黑"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
{(!data?.topIps || data.topIps.length === 0) && (
|
||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||
暂无 IP 聚合数据
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<CardTitle className="text-lg">访问路径</CardTitle>
|
||
<CardDescription>按请求数量排序</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{(data?.pathStats || []).map((item) => {
|
||
const abnormalRate =
|
||
item.count > 0 ? item.abnormalCount / item.count : 0;
|
||
return (
|
||
<div key={`${item.routeGroup || "page"}-${item.path || "/"}`}>
|
||
<div className="flex items-center justify-between gap-3 text-sm">
|
||
<span className="min-w-0 truncate font-medium">
|
||
{item.path || "/"}
|
||
</span>
|
||
<span className="shrink-0 text-muted-foreground text-xs">
|
||
{formatNumber(item.count)}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||
<div
|
||
className="h-full rounded-full bg-zinc-900 dark:bg-zinc-200"
|
||
style={{
|
||
width: `${Math.max(4, (item.count / maxPathCount) * 100)}%`,
|
||
}}
|
||
/>
|
||
<div
|
||
className="-mt-2 h-full rounded-full bg-orange-400"
|
||
style={{
|
||
width: `${Math.round(abnormalRate * 100)}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
{(!data?.pathStats || data.pathStats.length === 0) && (
|
||
<div className="rounded-lg border border-dashed py-10 text-center text-muted-foreground text-sm">
|
||
暂无路径统计
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<CardTitle className="text-lg">访问日志列表</CardTitle>
|
||
<CardDescription>最近 {formatNumber(data?.recentEvents.length)} 条</CardDescription>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(data?.rules || []).map((rule) => (
|
||
<Badge
|
||
className="border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||
key={rule}
|
||
variant="outline"
|
||
>
|
||
{rule}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="overflow-x-auto rounded-lg border">
|
||
<div className="min-w-[980px]">
|
||
<div className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] border-b bg-zinc-50 px-4 py-2 font-medium text-muted-foreground text-xs dark:bg-zinc-900/70">
|
||
<div>时间</div>
|
||
<div>IP</div>
|
||
<div>状态</div>
|
||
<div>路径</div>
|
||
<div>用户</div>
|
||
<div>标注</div>
|
||
</div>
|
||
{(data?.recentEvents || []).map((event) => (
|
||
<div
|
||
className="grid grid-cols-[138px_132px_86px_minmax(220px,1fr)_140px_120px] items-center border-b px-4 py-3 text-sm last:border-b-0"
|
||
key={event.id}
|
||
>
|
||
<div className="text-muted-foreground text-xs">
|
||
{formatTime(event.createdAt)}
|
||
</div>
|
||
<div className="font-mono text-xs">{event.ip}</div>
|
||
<div
|
||
className={cn(
|
||
"font-semibold text-xs",
|
||
getStatusClassName(event.statusCode)
|
||
)}
|
||
>
|
||
{event.statusCode || "--"}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium">
|
||
{event.method || "GET"} {event.path || "/"}
|
||
</div>
|
||
<div className="mt-1 truncate text-muted-foreground text-xs">
|
||
{event.reasons?.join("、") || event.outcome}
|
||
</div>
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="truncate text-xs">
|
||
{event.userEmail || "匿名"}
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-1 text-muted-foreground text-xs">
|
||
<UserCheck className="h-3 w-3" />
|
||
{event.userRole || "unknown"}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{event.severity === "high" || event.severity === "critical" ? (
|
||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||
) : null}
|
||
<AccessSeverityBadge severity={event.severity} />
|
||
</div>
|
||
</div>
|
||
))}
|
||
{(!data?.recentEvents || data.recentEvents.length === 0) && (
|
||
<div className="py-10 text-center text-muted-foreground text-sm">
|
||
暂无访问日志
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|