'use client'; import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { Loader2, ShieldPlus, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { RankChart } from '@/components/data/rank-chart'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from '@/components/ui/chart'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { AccessLogService, type DistributionItem, type WAFIPGroup, WafService, } from '@/lib/services/openflare'; import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers'; import { formatOverviewRangeHint, formatOverviewTrendLabel, OVERVIEW_RANGE_OPTIONS, type OverviewRangeHours, } from './access-log-utils'; const trendChartConfig = { requests: { label: '请求数', color: 'hsl(var(--primary))' }, } satisfies ChartConfig; function resolveBucketMinutes(hours: number) { if (hours <= 24) return 30; return 60; } function groupsContainingIp(groups: WAFIPGroup[], ip: string) { const target = ip.trim(); if (!target) return []; return groups.filter((group) => (group.ip_list ?? []).some((entry) => entry.trim() === target), ); } function toRankItems(items: DistributionItem[] | undefined) { return (items ?? []).map((item) => ({ label: item.key, value: item.value, })); } /** Clamp analysis/trend window to API limits (1–720 hours). */ function clampAnalysisHours(hours: number): number { if (!Number.isFinite(hours) || hours <= 0) return 24; return Math.min(720, Math.max(1, Math.round(hours))); } function isOverviewPreset(hours: number): hours is OverviewRangeHours { return hours === 24 || hours === 168 || hours === 360 || hours === 720; } function MetricCard({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); } function MiniRankCard({ title, items, color, }: { title: string; items: { label: string; value: number }[]; color: string; }) { return (

{title}

); } function AddToIPGroupPanel({ ip, open, onClose, }: { ip: string; open: boolean; onClose: () => void; }) { const queryClient = useQueryClient(); const [selectedGroupId, setSelectedGroupId] = useState(''); const groupsQuery = useQuery({ queryKey: ['openflare', 'waf', 'ip-groups'], queryFn: () => WafService.listIPGroups(), enabled: open, }); const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); const matchedGroups = useMemo( () => groupsContainingIp(groups, ip), [groups, ip], ); const manualGroups = useMemo( () => groups.filter( (group) => group.type === 'manual' && group.enabled !== false, ), [groups], ); const addableGroups = useMemo( () => manualGroups.filter( (group) => !(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()), ), [manualGroups, ip], ); const updateMutation = useMutation({ mutationFn: async ({ group, nextList, }: { group: WAFIPGroup; nextList: string[]; }) => WafService.updateIPGroup( group.id, buildIPGroupPayloadFromGroup(group, nextList), ), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'ip-groups'], }); }, }); const handleAdd = async () => { const groupId = Number.parseInt(selectedGroupId, 10); const group = addableGroups.find((item) => item.id === groupId); if (!group) { toast.error('请选择要加入的 IP 组'); return; } try { await updateMutation.mutateAsync({ group, nextList: [...(group.ip_list ?? []), ip.trim()], }); toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`); setSelectedGroupId(''); } catch (error) { toast.error(error instanceof Error ? error.message : '加入 IP 组失败'); } }; const handleRemove = async (group: WAFIPGroup) => { try { await updateMutation.mutateAsync({ group, nextList: (group.ip_list ?? []).filter( (entry) => entry.trim() !== ip.trim(), ), }); toast.success(`已从 IP 组「${group.name}」移除 ${ip}`); } catch (error) { toast.error(error instanceof Error ? error.message : '移除失败'); } }; return ( { if (!next) { setSelectedGroupId(''); onClose(); } }} > 将 IP 加入 IP 组 目标 IP: {ip} {groupsQuery.isLoading ? ( ) : groupsQuery.isError ? ( void groupsQuery.refetch()} /> ) : (
{matchedGroups.length > 0 ? (

该 IP 已存在于以下 IP 组

可选择删除,或继续添加到其他 IP 组。

{matchedGroups.map((group) => (

{group.name}

{group.type} · {group.ip_list?.length ?? 0} 条

{group.type === 'manual' ? ( ) : ( 不可手动删除 )}
))}
) : (

该 IP 尚未加入任何 IP 组。

)}

添加到其他 IP 组

{addableGroups.length === 0 ? (

没有可写入的手动 IP 组(或已全部包含该 IP)。

) : ( )}
)}
); } export function IpAnalysisPanel({ ip, enabled, initialHours = 24, }: { ip: string; enabled: boolean; /** * Exact analysis window in hours (1–720), aligned with list filter duration. * Remount with key={ip} when switching IPs so this re-initializes. */ initialHours?: number; }) { const [ipGroupOpen, setIpGroupOpen] = useState(false); const [rangeHours, setRangeHours] = useState(() => clampAnalysisHours(initialHours), ); const bucketMinutes = resolveBucketMinutes(rangeHours); const rangeHint = isOverviewPreset(rangeHours) ? formatOverviewRangeHint(rangeHours) : `近 ${rangeHours} 小时`; const trendQuery = useQuery({ queryKey: [ 'openflare', 'access-logs', 'ip-trend', ip, rangeHours, bucketMinutes, ], queryFn: () => AccessLogService.getIPTrend({ remote_addr: ip, hours: rangeHours, bucket_minutes: bucketMinutes, }), enabled: enabled && ip !== '', }); const analysisQuery = useQuery({ queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours], queryFn: () => AccessLogService.getIPAnalysis({ remote_addr: ip, hours: rangeHours, }), enabled: enabled && ip !== '', }); const trendChartData = useMemo(() => { return (trendQuery.data?.points ?? []).map((point) => ({ label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours), requests: point.request_count, })); }, [rangeHours, trendQuery.data?.points]); const analysis = analysisQuery.data; const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading; const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching; if (!ip) { return ; } return ( <>
{ if (!value) return; setRangeHours(clampAnalysisHours(Number.parseInt(value, 10))); }} variant='outline' size='sm' > {OVERVIEW_RANGE_OPTIONS.map((option) => ( {option.label} ))}
{isLoadingIP ? ( ) : (
{analysisQuery.isError ? ( void analysisQuery.refetch()} /> ) : analysis ? (
) : null}

IP 请求趋势

{ip} · {rangeHint} · {bucketMinutes} 分钟桶

{trendQuery.isError ? ( void trendQuery.refetch()} /> ) : trendChartData.every((point) => point.requests === 0) ? ( ) : ( formatCompactNumber(Number(value)) } /> } /> )}
{analysis ? (
) : null}
)}
setIpGroupOpen(false)} /> ); }