'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 { useTranslations } from 'next-intl'; 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'; function useTrendChartConfig() { const t = useTranslations('accessLogs'); return { requests: { label: t('ip.requests'), 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; }) { const t = useTranslations('accessLogs.analysis'); return (

{title}

); } function AddToIPGroupPanel({ ip, open, onClose, }: { ip: string; open: boolean; onClose: () => void; }) { const t = useTranslations('accessLogs.analysis'); 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(t('selectGroup')); return; } try { await updateMutation.mutateAsync({ group, nextList: [...(group.ip_list ?? []), ip.trim()], }); toast.success(t('added', { ip, name: group.name })); setSelectedGroupId(''); } catch (error) { toast.error(error instanceof Error ? error.message : t('addFailed')); } }; const handleRemove = async (group: WAFIPGroup) => { try { await updateMutation.mutateAsync({ group, nextList: (group.ip_list ?? []).filter( (entry) => entry.trim() !== ip.trim(), ), }); toast.success(t('removed', { name: group.name, ip })); } catch (error) { toast.error(error instanceof Error ? error.message : t('removeFailed')); } }; return ( { if (!next) { setSelectedGroupId(''); onClose(); } }} > {t('joinTitle')} {t('targetIp')} {ip} {groupsQuery.isLoading ? ( ) : groupsQuery.isError ? ( void groupsQuery.refetch()} /> ) : (
{matchedGroups.length > 0 ? (

{t('alreadyIn')}

{t('alreadyInHint')}

{matchedGroups.map((group) => (

{group.name}

{t('entryCount', { type: group.type, count: group.ip_list?.length ?? 0, })}

{group.type === 'manual' ? ( ) : ( {t('cannotDelete')} )}
))}
) : (

{t('notInAny')}

)}

{t('addToOther')}

{addableGroups.length === 0 ? (

{t('noWritable')}

) : ( )}
)}
); } 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 t = useTranslations('accessLogs'); const trendChartConfig = useTrendChartConfig(); const [ipGroupOpen, setIpGroupOpen] = useState(false); const [rangeHours, setRangeHours] = useState(() => clampAnalysisHours(initialHours), ); const bucketMinutes = resolveBucketMinutes(rangeHours); const rangeHint = isOverviewPreset(rangeHours) ? formatOverviewRangeHint(rangeHours, (key, values) => t(key, values)) : t('analysis.recentHours', { hours: 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) => ( {t(option.labelKey)} ))}
{isLoadingIP ? ( ) : (
{analysisQuery.isError ? ( void analysisQuery.refetch()} /> ) : analysis ? (
) : null}

{t('analysis.trend')}

{t('analysis.trendMeta', { ip, range: rangeHint, minutes: bucketMinutes, })}

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