'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 (
);
}
function MiniRankCard({
title,
items,
color,
}: {
title: string;
items: { label: string; value: number }[];
color: string;
}) {
const t = useTranslations('accessLogs.analysis');
return (
);
}
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 (
);
}
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)}
/>
>
);
}