mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat(access-logs): 新增 IP 明细 Tab 并完善日志详情字段
按时间窗聚合 IP 请求数/2xx 比例/入出站流量,支持排序与详情分析; 日志明细详情仅展示请求业务字段,IP 情报迁至独立详情弹窗。
This commit is contained in:
@@ -1,83 +1,20 @@
|
||||
'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 { useState } from 'react';
|
||||
|
||||
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 AccessLogItem,
|
||||
type DistributionItem,
|
||||
type WAFIPGroup,
|
||||
WafService,
|
||||
} from '@/lib/services/openflare';
|
||||
import type { AccessLogItem } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
import { formatBytes } from '@/lib/utils/metrics';
|
||||
|
||||
import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers';
|
||||
import {
|
||||
cacheOutcomeLabel,
|
||||
formatOverviewRangeHint,
|
||||
formatOverviewTrendLabel,
|
||||
OVERVIEW_RANGE_OPTIONS,
|
||||
resolveCacheOutcome,
|
||||
type OverviewRangeHours,
|
||||
} from './access-log-utils';
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function resolveBucketMinutes(hours: OverviewRangeHours) {
|
||||
if (hours <= 24) return 30;
|
||||
if (hours <= 168) return 60;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils';
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
@@ -104,268 +41,14 @@ function DetailField({
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed px-3 py-2.5'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
{label}
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold tracking-tight'>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniRankCard({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
items: { label: string; value: number }[];
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='mb-2 text-sm font-medium'>{title}</p>
|
||||
<RankChart
|
||||
items={items}
|
||||
color={color}
|
||||
className='!h-[220px]'
|
||||
emptyMessage={`暂无 ${title} 数据`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToIPGroupPanel({
|
||||
ip,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
ip: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setSelectedGroupId('');
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className='max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
|
||||
<DialogDescription>
|
||||
目标 IP:
|
||||
<span className='font-mono text-foreground'>{ip}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder title='加载 IP 组' />
|
||||
) : groupsQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
groupsQuery.error instanceof Error
|
||||
? groupsQuery.error.message
|
||||
: '加载 IP 组失败'
|
||||
}
|
||||
onRetry={() => void groupsQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{matchedGroups.length > 0 ? (
|
||||
<div className='space-y-2 rounded-lg border border-dashed p-3'>
|
||||
<p className='text-sm font-medium text-foreground'>
|
||||
该 IP 已存在于以下 IP 组
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
可选择删除,或继续添加到其他 IP 组。
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{matchedGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className='flex items-center justify-between gap-2 rounded-md border px-3 py-2'
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>
|
||||
{group.name}
|
||||
</p>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
{group.type} · {group.ip_list?.length ?? 0} 条
|
||||
</p>
|
||||
</div>
|
||||
{group.type === 'manual' ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 shrink-0 text-destructive'
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => void handleRemove(group)}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className='mr-1 size-3.5' />
|
||||
删除
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
不可手动删除
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
该 IP 尚未加入任何 IP 组。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>添加到其他 IP 组</p>
|
||||
{addableGroups.length === 0 ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
没有可写入的手动 IP 组(或已全部包含该 IP)。
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId}
|
||||
onValueChange={setSelectedGroupId}
|
||||
>
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue placeholder='选择手动 IP 组' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{addableGroups.map((group) => (
|
||||
<SelectItem key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={
|
||||
!selectedGroupId ||
|
||||
updateMutation.isPending ||
|
||||
addableGroups.length === 0
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
'加入所选 IP 组'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
function formatRequestTimeMs(value: number | undefined | null) {
|
||||
if (value == null || !Number.isFinite(value) || value < 0) {
|
||||
return '—';
|
||||
}
|
||||
if (value < 1000) {
|
||||
return `${Math.round(value)} ms`;
|
||||
}
|
||||
return `${(value / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
export function AccessLogDetailDialog({
|
||||
@@ -377,328 +60,104 @@ export function AccessLogDetailDialog({
|
||||
item: AccessLogItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [ipGroupOpen, setIpGroupOpen] = useState(false);
|
||||
const [rangeHours, setRangeHours] = useState<OverviewRangeHours>(24);
|
||||
// Keep last selected item while the dialog closes to avoid empty-state flash.
|
||||
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
|
||||
if (item && item !== displayItem) {
|
||||
setDisplayItem(item);
|
||||
}
|
||||
const activeItem = item ?? displayItem;
|
||||
const ip = activeItem?.remote_addr?.trim() ?? '';
|
||||
const bucketMinutes = resolveBucketMinutes(rangeHours);
|
||||
const rangeHint = formatOverviewRangeHint(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: open && ip !== '',
|
||||
});
|
||||
|
||||
const analysisQuery = useQuery({
|
||||
queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours],
|
||||
queryFn: () =>
|
||||
AccessLogService.getIPAnalysis({
|
||||
remote_addr: ip,
|
||||
hours: rangeHours,
|
||||
}),
|
||||
enabled: open && 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>访问日志详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看请求字段、User-Agent 与该 IP 的访问趋势及分析数据。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>访问日志详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{activeItem && (
|
||||
<div className='space-y-5'>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<DetailField
|
||||
label='时间'
|
||||
value={formatDateTime(activeItem.logged_at)}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点'
|
||||
value={activeItem.node_name || activeItem.node_id || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label='IP'
|
||||
value={activeItem.remote_addr || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField label='地区' value={activeItem.region || '—'} />
|
||||
<DetailField label='域名' value={activeItem.host || '—'} />
|
||||
<DetailField
|
||||
label='状态码'
|
||||
value={
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{activeItem.status_code}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='缓存'
|
||||
value={
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{cacheOutcomeLabel(
|
||||
resolveCacheOutcome(activeItem.cache_status),
|
||||
)}
|
||||
</Badge>
|
||||
<span className='font-mono text-xs text-muted-foreground'>
|
||||
{activeItem.cache_status || '—'}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='路径'
|
||||
value={activeItem.path || '—'}
|
||||
full
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label='User-Agent'
|
||||
value={activeItem.user_agent || '—'}
|
||||
full
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!ip}
|
||||
onClick={() => setIpGroupOpen(true)}
|
||||
>
|
||||
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={String(rangeHours)}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setRangeHours(
|
||||
Number.parseInt(value, 10) as OverviewRangeHours,
|
||||
);
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{!ip ? (
|
||||
<EmptyStateWithBorder description='该日志没有有效 IP。' />
|
||||
) : isLoadingIP ? (
|
||||
<LoadingStateWithBorder title='加载 IP 分析' />
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{analysisQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
analysisQuery.error instanceof Error
|
||||
? analysisQuery.error.message
|
||||
: '加载 IP 分析失败'
|
||||
}
|
||||
onRetry={() => void analysisQuery.refetch()}
|
||||
/>
|
||||
) : analysis ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<MetricCard
|
||||
label='总请求'
|
||||
value={formatCompactNumber(
|
||||
analysis.summary.total_requests,
|
||||
)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='错误数'
|
||||
value={formatCompactNumber(
|
||||
analysis.summary.error_count,
|
||||
)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='已提供带宽'
|
||||
value={formatBytes(analysis.summary.bandwidth_served)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='接收数据'
|
||||
value={formatBytes(analysis.summary.bytes_received)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立域名'
|
||||
value={formatCompactNumber(
|
||||
analysis.summary.unique_hosts,
|
||||
)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立路径'
|
||||
value={formatCompactNumber(
|
||||
analysis.summary.unique_paths,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='space-y-3 rounded-lg border border-dashed p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>IP 请求趋势</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
disabled={isFetchingIP}
|
||||
onClick={() => {
|
||||
void trendQuery.refetch();
|
||||
void analysisQuery.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{trendQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
trendQuery.error instanceof Error
|
||||
? trendQuery.error.message
|
||||
: '加载趋势失败'
|
||||
}
|
||||
onRetry={() => void trendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.every(
|
||||
(point) => point.requests === 0,
|
||||
) ? (
|
||||
<EmptyStateWithBorder
|
||||
description={`该 IP 在${rangeHint}内没有访问记录。`}
|
||||
/>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={trendChartConfig}
|
||||
className='h-56 w-full'
|
||||
>
|
||||
<AreaChart data={trendChartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='label'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={10}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
tickFormatter={(value) =>
|
||||
formatCompactNumber(Number(value))
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type='monotone'
|
||||
dataKey='requests'
|
||||
stroke='var(--color-requests)'
|
||||
fill='var(--color-requests)'
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
{activeItem ? (
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
|
||||
<DetailField
|
||||
label='请求时间'
|
||||
value={formatDateTime(activeItem.logged_at)}
|
||||
/>
|
||||
<DetailField
|
||||
label='入库时间'
|
||||
value={
|
||||
activeItem.created_at
|
||||
? formatDateTime(activeItem.created_at)
|
||||
: '—'
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点'
|
||||
value={activeItem.node_name || activeItem.node_id || '—'}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点 ID'
|
||||
value={activeItem.node_id || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label='客户端 IP'
|
||||
value={activeItem.remote_addr || '—'}
|
||||
mono
|
||||
/>
|
||||
<DetailField label='地区' value={activeItem.region || '—'} />
|
||||
<DetailField label='域名' value={activeItem.host || '—'} />
|
||||
<DetailField
|
||||
label='状态码'
|
||||
value={
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{activeItem.status_code}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='缓存'
|
||||
value={
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{cacheOutcomeLabel(
|
||||
resolveCacheOutcome(activeItem.cache_status),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysis ? (
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<MiniRankCard
|
||||
title='Top Paths'
|
||||
color='#a78bfa'
|
||||
items={toRankItems(analysis.top_paths)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top Hosts'
|
||||
color='#34d399'
|
||||
items={toRankItems(analysis.top_hosts)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Status Codes'
|
||||
color='#f59e0b'
|
||||
items={toRankItems(analysis.status_codes)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top User-Agents'
|
||||
color='#818cf8'
|
||||
items={toRankItems(analysis.top_user_agents)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Device Types'
|
||||
color='#38bdf8'
|
||||
items={toRankItems(analysis.device_types)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top Browsers'
|
||||
color='#22c55e'
|
||||
items={toRankItems(analysis.top_browsers)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Badge>
|
||||
<span className='font-mono text-xs text-muted-foreground'>
|
||||
{activeItem.cache_status || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ip ? (
|
||||
<AddToIPGroupPanel
|
||||
ip={ip}
|
||||
open={ipGroupOpen}
|
||||
onClose={() => setIpGroupOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label='出站流量'
|
||||
value={formatBytes(activeItem.bytes_sent ?? 0)}
|
||||
/>
|
||||
<DetailField
|
||||
label='入站流量'
|
||||
value={formatBytes(activeItem.request_length ?? 0)}
|
||||
/>
|
||||
<DetailField
|
||||
label='请求耗时'
|
||||
value={formatRequestTimeMs(activeItem.request_time_ms)}
|
||||
/>
|
||||
<DetailField
|
||||
label='路径'
|
||||
value={activeItem.path || '—'}
|
||||
full
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label='User-Agent'
|
||||
value={activeItem.user_agent || '—'}
|
||||
full
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AccessLogTab = 'overview' | 'list';
|
||||
export type AccessLogTab = 'overview' | 'ips' | 'list';
|
||||
|
||||
export type SearchDraft = {
|
||||
nodeId: string;
|
||||
@@ -30,6 +30,19 @@ export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||
];
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: 'total_requests:desc', label: '请求数从高到低' },
|
||||
{ value: 'total_requests:asc', label: '请求数从低到高' },
|
||||
{ value: 'request_length:desc', label: '入站从高到低' },
|
||||
{ value: 'request_length:asc', label: '入站从低到高' },
|
||||
{ value: 'bytes_sent:desc', label: '出站从高到低' },
|
||||
{ value: 'bytes_sent:asc', label: '出站从低到高' },
|
||||
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' },
|
||||
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' },
|
||||
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' },
|
||||
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' },
|
||||
];
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
'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 (
|
||||
<div className='rounded-lg border border-dashed px-3 py-2.5'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
{label}
|
||||
</p>
|
||||
<p className='mt-1 text-lg font-semibold tracking-tight'>{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniRankCard({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
items: { label: string; value: number }[];
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className='rounded-lg border border-dashed p-3'>
|
||||
<p className='mb-2 text-sm font-medium'>{title}</p>
|
||||
<RankChart
|
||||
items={items}
|
||||
color={color}
|
||||
className='!h-[220px]'
|
||||
emptyMessage={`暂无 ${title} 数据`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddToIPGroupPanel({
|
||||
ip,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
ip: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
|
||||
|
||||
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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setSelectedGroupId('');
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className='max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
|
||||
<DialogDescription>
|
||||
目标 IP:
|
||||
<span className='font-mono text-foreground'>{ip}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{groupsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder title='加载 IP 组' />
|
||||
) : groupsQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
groupsQuery.error instanceof Error
|
||||
? groupsQuery.error.message
|
||||
: '加载 IP 组失败'
|
||||
}
|
||||
onRetry={() => void groupsQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{matchedGroups.length > 0 ? (
|
||||
<div className='space-y-2 rounded-lg border border-dashed p-3'>
|
||||
<p className='text-sm font-medium text-foreground'>
|
||||
该 IP 已存在于以下 IP 组
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
可选择删除,或继续添加到其他 IP 组。
|
||||
</p>
|
||||
<div className='space-y-2'>
|
||||
{matchedGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className='flex items-center justify-between gap-2 rounded-md border px-3 py-2'
|
||||
>
|
||||
<div className='min-w-0'>
|
||||
<p className='truncate text-sm font-medium'>
|
||||
{group.name}
|
||||
</p>
|
||||
<p className='text-[11px] text-muted-foreground'>
|
||||
{group.type} · {group.ip_list?.length ?? 0} 条
|
||||
</p>
|
||||
</div>
|
||||
{group.type === 'manual' ? (
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-8 shrink-0 text-destructive'
|
||||
disabled={updateMutation.isPending}
|
||||
onClick={() => void handleRemove(group)}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className='size-3.5 animate-spin' />
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className='mr-1 size-3.5' />
|
||||
删除
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
不可手动删除
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
该 IP 尚未加入任何 IP 组。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='space-y-2'>
|
||||
<p className='text-sm font-medium'>添加到其他 IP 组</p>
|
||||
{addableGroups.length === 0 ? (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
没有可写入的手动 IP 组(或已全部包含该 IP)。
|
||||
</p>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId}
|
||||
onValueChange={setSelectedGroupId}
|
||||
>
|
||||
<SelectTrigger className='h-9 text-xs'>
|
||||
<SelectValue placeholder='选择手动 IP 组' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{addableGroups.map((group) => (
|
||||
<SelectItem key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleAdd()}
|
||||
disabled={
|
||||
!selectedGroupId ||
|
||||
updateMutation.isPending ||
|
||||
addableGroups.length === 0
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
处理中...
|
||||
</>
|
||||
) : (
|
||||
'加入所选 IP 组'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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 <EmptyStateWithBorder description='没有有效 IP。' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
onClick={() => setIpGroupOpen(true)}
|
||||
>
|
||||
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={isOverviewPreset(rangeHours) ? String(rangeHours) : ''}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setRangeHours(clampAnalysisHours(Number.parseInt(value, 10)));
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
{isLoadingIP ? (
|
||||
<LoadingStateWithBorder title='加载 IP 分析' />
|
||||
) : (
|
||||
<div className='space-y-4'>
|
||||
{analysisQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
analysisQuery.error instanceof Error
|
||||
? analysisQuery.error.message
|
||||
: '加载 IP 分析失败'
|
||||
}
|
||||
onRetry={() => void analysisQuery.refetch()}
|
||||
/>
|
||||
) : analysis ? (
|
||||
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
||||
<MetricCard
|
||||
label='总请求'
|
||||
value={formatCompactNumber(analysis.summary.total_requests)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='错误数'
|
||||
value={formatCompactNumber(analysis.summary.error_count)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='已提供带宽'
|
||||
value={formatBytes(analysis.summary.bandwidth_served)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='接收数据'
|
||||
value={formatBytes(analysis.summary.bytes_received)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立域名'
|
||||
value={formatCompactNumber(analysis.summary.unique_hosts)}
|
||||
/>
|
||||
<MetricCard
|
||||
label='独立路径'
|
||||
value={formatCompactNumber(analysis.summary.unique_paths)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='space-y-3 rounded-lg border border-dashed p-4'>
|
||||
<div className='flex items-center justify-between gap-2'>
|
||||
<div>
|
||||
<p className='text-sm font-medium'>IP 请求趋势</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
disabled={isFetchingIP}
|
||||
onClick={() => {
|
||||
void trendQuery.refetch();
|
||||
void analysisQuery.refetch();
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{trendQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
trendQuery.error instanceof Error
|
||||
? trendQuery.error.message
|
||||
: '加载趋势失败'
|
||||
}
|
||||
onRetry={() => void trendQuery.refetch()}
|
||||
/>
|
||||
) : trendChartData.every((point) => point.requests === 0) ? (
|
||||
<EmptyStateWithBorder
|
||||
description={`该 IP 在${rangeHint}内没有访问记录。`}
|
||||
/>
|
||||
) : (
|
||||
<ChartContainer
|
||||
config={trendChartConfig}
|
||||
className='h-56 w-full'
|
||||
>
|
||||
<AreaChart data={trendChartData}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey='label'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={10}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
fontSize={10}
|
||||
width={40}
|
||||
tickFormatter={(value) =>
|
||||
formatCompactNumber(Number(value))
|
||||
}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type='monotone'
|
||||
dataKey='requests'
|
||||
stroke='var(--color-requests)'
|
||||
fill='var(--color-requests)'
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{analysis ? (
|
||||
<div className='grid gap-3 md:grid-cols-2'>
|
||||
<MiniRankCard
|
||||
title='Top Paths'
|
||||
color='#a78bfa'
|
||||
items={toRankItems(analysis.top_paths)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top Hosts'
|
||||
color='#34d399'
|
||||
items={toRankItems(analysis.top_hosts)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Status Codes'
|
||||
color='#f59e0b'
|
||||
items={toRankItems(analysis.status_codes)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top User-Agents'
|
||||
color='#818cf8'
|
||||
items={toRankItems(analysis.top_user_agents)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Device Types'
|
||||
color='#38bdf8'
|
||||
items={toRankItems(analysis.device_types)}
|
||||
/>
|
||||
<MiniRankCard
|
||||
title='Top Browsers'
|
||||
color='#22c55e'
|
||||
items={toRankItems(analysis.top_browsers)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddToIPGroupPanel
|
||||
ip={ip}
|
||||
open={ipGroupOpen}
|
||||
onClose={() => setIpGroupOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { IpAnalysisPanel } from './ip-analysis-panel';
|
||||
|
||||
export function IpDetailDialog({
|
||||
open,
|
||||
remoteAddr,
|
||||
region,
|
||||
initialHours,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
remoteAddr: string | null;
|
||||
region?: string;
|
||||
initialHours?: number;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [displayAddr, setDisplayAddr] = useState<string | null>(remoteAddr);
|
||||
const [displayRegion, setDisplayRegion] = useState(region);
|
||||
if (remoteAddr && remoteAddr !== displayAddr) {
|
||||
setDisplayAddr(remoteAddr);
|
||||
setDisplayRegion(region);
|
||||
}
|
||||
const ip = remoteAddr ?? displayAddr ?? '';
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>IP 详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
<span className='font-mono text-foreground'>{ip || '—'}</span>
|
||||
{displayRegion ? (
|
||||
<span className='text-muted-foreground'> · {displayRegion}</span>
|
||||
) : null}
|
||||
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<IpAnalysisPanel
|
||||
key={ip}
|
||||
ip={ip}
|
||||
enabled={open && ip !== ''}
|
||||
initialHours={initialHours}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Eye } from 'lucide-react';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type {
|
||||
AccessLogIPSummaryItem,
|
||||
AccessLogIPSummaryList,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
|
||||
import {
|
||||
formatOverviewRangeHint,
|
||||
IP_SORT_OPTIONS,
|
||||
OVERVIEW_RANGE_OPTIONS,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
type OverviewRangeHours,
|
||||
} from './access-log-utils';
|
||||
import { IpDetailDialog } from './ip-detail-dialog';
|
||||
|
||||
export function toLocalInputValue(date: Date) {
|
||||
const pad = (n: number) => `${n}`.padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Default local range ending now, spanning `hours`. */
|
||||
export function defaultLocalRangeForHours(hours: number) {
|
||||
const until = new Date();
|
||||
const since = new Date(until.getTime() - hours * 3_600_000);
|
||||
return {
|
||||
since: toLocalInputValue(since),
|
||||
until: toLocalInputValue(until),
|
||||
};
|
||||
}
|
||||
|
||||
function localInputToRFC3339(value: string) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
|
||||
|
||||
/** Validate custom local datetime-local range; returns error message or null. */
|
||||
export function validateCustomTimeRange(
|
||||
sinceLocal: string,
|
||||
untilLocal: string,
|
||||
): string | null {
|
||||
if (!sinceLocal.trim() || !untilLocal.trim()) {
|
||||
return '请填写开始与结束时间';
|
||||
}
|
||||
const sinceMs = new Date(sinceLocal).getTime();
|
||||
const untilMs = new Date(untilLocal).getTime();
|
||||
if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
|
||||
return '时间格式无效';
|
||||
}
|
||||
if (untilMs <= sinceMs) {
|
||||
return '结束时间必须晚于开始时间';
|
||||
}
|
||||
if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
|
||||
return '时间范围不能超过 30 天';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatRatio(ratio: number) {
|
||||
if (!Number.isFinite(ratio) || ratio < 0) return '0%';
|
||||
return `${(ratio * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/** Exact hours for analysis API (1–720), matching list window duration. */
|
||||
export function resolveAnalysisHours(input: {
|
||||
timeMode: IpTabTimeMode;
|
||||
hours: OverviewRangeHours;
|
||||
customSince: string;
|
||||
customUntil: string;
|
||||
}): number {
|
||||
if (input.timeMode === 'custom' && input.customSince && input.customUntil) {
|
||||
const ms =
|
||||
new Date(input.customUntil).getTime() -
|
||||
new Date(input.customSince).getTime();
|
||||
if (Number.isFinite(ms) && ms > 0) {
|
||||
return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000)));
|
||||
}
|
||||
}
|
||||
return input.hours;
|
||||
}
|
||||
|
||||
function PaginationBar({
|
||||
page,
|
||||
hasMore,
|
||||
loading,
|
||||
totalIp,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
loading: boolean;
|
||||
totalIp?: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前第 {page + 1} 页
|
||||
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
|
||||
</p>
|
||||
<div className='flex gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loading || page <= 0}
|
||||
onClick={onPrev}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={loading || !hasMore}
|
||||
onClick={onNext}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type IpTabTimeMode = 'preset' | 'custom';
|
||||
|
||||
export function IpTab({
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
page,
|
||||
pageSize,
|
||||
sort,
|
||||
hours,
|
||||
timeMode,
|
||||
customSince,
|
||||
customUntil,
|
||||
onHoursChange,
|
||||
onTimeModeChange,
|
||||
onApplyCustomRange,
|
||||
onPageSizeChange,
|
||||
onSortChange,
|
||||
onRetry,
|
||||
onPrevPage,
|
||||
onNextPage,
|
||||
isFetching,
|
||||
}: {
|
||||
data?: AccessLogIPSummaryList;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sort: string;
|
||||
hours: OverviewRangeHours;
|
||||
timeMode: IpTabTimeMode;
|
||||
/** Applied custom range (local datetime-local strings). */
|
||||
customSince: string;
|
||||
customUntil: string;
|
||||
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||
onTimeModeChange: (mode: IpTabTimeMode) => void;
|
||||
/** Commit validated custom range for list query. */
|
||||
onApplyCustomRange: (since: string, until: string) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onSortChange: (value: string) => void;
|
||||
onRetry: () => void;
|
||||
onPrevPage: () => void;
|
||||
onNextPage: () => void;
|
||||
isFetching: boolean;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<AccessLogIPSummaryItem | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [draftSince, setDraftSince] = useState(customSince);
|
||||
const [draftUntil, setDraftUntil] = useState(customUntil);
|
||||
const [customError, setCustomError] = useState<string | null>(null);
|
||||
|
||||
const defaultCustomRange = useMemo(
|
||||
() => defaultLocalRangeForHours(hours),
|
||||
[hours],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeMode !== 'custom') {
|
||||
setCustomError(null);
|
||||
return;
|
||||
}
|
||||
// Sync draft from applied range when entering custom or after apply.
|
||||
setDraftSince(customSince || defaultCustomRange.since);
|
||||
setDraftUntil(customUntil || defaultCustomRange.until);
|
||||
setCustomError(null);
|
||||
}, [timeMode, customSince, customUntil, defaultCustomRange]);
|
||||
|
||||
const analysisHours = resolveAnalysisHours({
|
||||
timeMode,
|
||||
hours,
|
||||
customSince,
|
||||
customUntil,
|
||||
});
|
||||
|
||||
const rangeHint =
|
||||
timeMode === 'custom' && customSince && customUntil
|
||||
? '自定义区间'
|
||||
: timeMode === 'custom'
|
||||
? '自定义(未应用)'
|
||||
: formatOverviewRangeHint(hours);
|
||||
|
||||
const handleApplyCustom = () => {
|
||||
const message = validateCustomTimeRange(draftSince, draftUntil);
|
||||
if (message) {
|
||||
setCustomError(message);
|
||||
return;
|
||||
}
|
||||
setCustomError(null);
|
||||
onApplyCustomRange(draftSince, draftUntil);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
|
||||
<div className='flex flex-wrap items-center justify-between gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-sm font-medium'>时间范围</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
当前:{rangeHint}
|
||||
{data?.total_ip != null
|
||||
? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={timeMode === 'preset' ? String(hours) : ''}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
onTimeModeChange('preset');
|
||||
onHoursChange(
|
||||
Number.parseInt(value, 10) as OverviewRangeHours,
|
||||
);
|
||||
}}
|
||||
variant='outline'
|
||||
size='sm'
|
||||
>
|
||||
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem
|
||||
key={option.value}
|
||||
value={String(option.value)}
|
||||
className='px-2.5 text-xs'
|
||||
>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={timeMode === 'custom' ? 'default' : 'outline'}
|
||||
className='h-8 text-xs'
|
||||
onClick={() => onTimeModeChange('custom')}
|
||||
>
|
||||
自定义
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{timeMode === 'custom' ? (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex flex-wrap items-end gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>开始</p>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
className='h-9 w-52 text-xs'
|
||||
value={draftSince || defaultCustomRange.since}
|
||||
onChange={(e) => {
|
||||
setDraftSince(e.target.value);
|
||||
setCustomError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>结束</p>
|
||||
<Input
|
||||
type='datetime-local'
|
||||
className='h-9 w-52 text-xs'
|
||||
value={draftUntil || defaultCustomRange.until}
|
||||
onChange={(e) => {
|
||||
setDraftUntil(e.target.value);
|
||||
setCustomError(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size='sm'
|
||||
className='h-9 text-xs'
|
||||
onClick={handleApplyCustom}
|
||||
>
|
||||
应用
|
||||
</Button>
|
||||
</div>
|
||||
{customError ? (
|
||||
<p className='text-xs text-destructive'>{customError}</p>
|
||||
) : (
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>排序</p>
|
||||
<Select value={sort} onValueChange={onSortChange}>
|
||||
<SelectTrigger className='h-8 w-48 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IP_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<p className='text-xs text-muted-foreground'>每页</p>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) =>
|
||||
onPageSizeChange(Number.parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-24 text-xs'>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<SelectItem key={size} value={String(size)}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||
<p className='text-sm font-medium'>IP 明细</p>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={error.message || '加载失败'}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : (data?.items ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder title='暂无 IP 数据' />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className='bg-muted/40'>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>IP</TableHead>
|
||||
<TableHead className='text-xs'>地区</TableHead>
|
||||
<TableHead className='text-xs text-right'>请求数</TableHead>
|
||||
<TableHead className='text-xs text-right'>2xx 比例</TableHead>
|
||||
<TableHead className='text-xs text-right'>入站</TableHead>
|
||||
<TableHead className='text-xs text-right'>出站</TableHead>
|
||||
<TableHead className='text-xs'>最后访问</TableHead>
|
||||
<TableHead className='w-12 text-center text-xs' />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.remote_addr} className='border-dashed'>
|
||||
<TableCell className='font-mono text-xs'>
|
||||
{item.remote_addr}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-muted-foreground'>
|
||||
{item.region || '—'}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-right tabular-nums'>
|
||||
{formatCompactNumber(item.total_requests)}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-right tabular-nums'>
|
||||
<span
|
||||
title={`${item.success_2xx_count}/${item.total_requests}`}
|
||||
>
|
||||
{formatRatio(item.success_ratio)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-right tabular-nums'>
|
||||
{formatBytes(item.bytes_received)}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs text-right tabular-nums'>
|
||||
{formatBytes(item.bytes_sent)}
|
||||
</TableCell>
|
||||
<TableCell className='text-xs'>
|
||||
{formatDateTime(item.last_seen_at)}
|
||||
</TableCell>
|
||||
<TableCell className='text-center'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-foreground'
|
||||
onClick={() => {
|
||||
setSelected(item);
|
||||
setDetailOpen(true);
|
||||
}}
|
||||
>
|
||||
<Eye className='size-3' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看 IP 详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<PaginationBar
|
||||
page={page}
|
||||
hasMore={data?.has_more ?? false}
|
||||
loading={isFetching}
|
||||
totalIp={data?.total_ip}
|
||||
onPrev={onPrevPage}
|
||||
onNext={onNextPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IpDetailDialog
|
||||
open={detailOpen}
|
||||
remoteAddr={selected?.remote_addr ?? null}
|
||||
region={selected?.region}
|
||||
initialHours={analysisHours}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Build list API time params from IP tab state (applied values only). */
|
||||
export function buildIpSummaryTimeParams(input: {
|
||||
timeMode: IpTabTimeMode;
|
||||
hours: OverviewRangeHours;
|
||||
customSince: string;
|
||||
customUntil: string;
|
||||
}): { hours?: number; since?: string; until?: string } | null {
|
||||
if (input.timeMode === 'custom') {
|
||||
if (validateCustomTimeRange(input.customSince, input.customUntil)) {
|
||||
return null;
|
||||
}
|
||||
const since = localInputToRFC3339(input.customSince);
|
||||
const until = localInputToRFC3339(input.customUntil);
|
||||
if (!since || !until) {
|
||||
return null;
|
||||
}
|
||||
return { since, until };
|
||||
}
|
||||
return { hours: input.hours };
|
||||
}
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from './components/access-log-utils';
|
||||
import { CleanupDialog } from './components/cleanup-dialog';
|
||||
import { DetailTab } from './components/detail-tab';
|
||||
import {
|
||||
buildIpSummaryTimeParams,
|
||||
defaultLocalRangeForHours,
|
||||
IpTab,
|
||||
type IpTabTimeMode,
|
||||
} from './components/ip-tab';
|
||||
import { OverviewTab } from './components/overview-tab';
|
||||
|
||||
const emptyDraft: SearchDraft = {
|
||||
@@ -30,7 +36,9 @@ const emptyDraft: SearchDraft = {
|
||||
};
|
||||
|
||||
function resolveTab(value: string | null): AccessLogTab {
|
||||
return value === 'list' ? 'list' : 'overview';
|
||||
if (value === 'list') return 'list';
|
||||
if (value === 'ips') return 'ips';
|
||||
return 'overview';
|
||||
}
|
||||
|
||||
function AccessLogsPageContent() {
|
||||
@@ -43,11 +51,26 @@ function AccessLogsPageContent() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [page, setPage] = useState(0);
|
||||
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
||||
const [ipSort, setIpSort] = useState('total_requests:desc');
|
||||
const [ipPageSize, setIpPageSize] = useState(20);
|
||||
const [ipPage, setIpPage] = useState(0);
|
||||
const [ipHours, setIpHours] = useState<OverviewRangeHours>(168);
|
||||
const [ipTimeMode, setIpTimeMode] = useState<IpTabTimeMode>('preset');
|
||||
const [ipCustomSince, setIpCustomSince] = useState('');
|
||||
const [ipCustomUntil, setIpCustomUntil] = useState('');
|
||||
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
|
||||
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
|
||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||
|
||||
const detailSortState = parseSortValue(detailSort);
|
||||
const ipSortState = parseSortValue(ipSort);
|
||||
const ipTimeParams = buildIpSummaryTimeParams({
|
||||
timeMode: ipTimeMode,
|
||||
hours: ipHours,
|
||||
customSince: ipCustomSince,
|
||||
customUntil: ipCustomUntil,
|
||||
});
|
||||
const ipQueryEnabled = tab === 'ips' && ipTimeParams != null;
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const next = resolveTab(value);
|
||||
@@ -96,6 +119,27 @@ function AccessLogsPageContent() {
|
||||
enabled: tab === 'list',
|
||||
});
|
||||
|
||||
const ipQuery = useQuery({
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'ip-summary',
|
||||
ipTimeParams,
|
||||
ipPage,
|
||||
ipPageSize,
|
||||
ipSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.listIPSummaries({
|
||||
...(ipTimeParams as NonNullable<typeof ipTimeParams>),
|
||||
p: ipPage,
|
||||
page_size: ipPageSize,
|
||||
sort_by: ipSortState.sortBy,
|
||||
sort_order: ipSortState.sortOrder,
|
||||
}),
|
||||
enabled: ipQueryEnabled,
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
@@ -131,12 +175,26 @@ function AccessLogsPageContent() {
|
||||
setPage(0);
|
||||
}, [tab, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setIpPage(0);
|
||||
}, [
|
||||
tab,
|
||||
ipPageSize,
|
||||
ipHours,
|
||||
ipTimeMode,
|
||||
ipCustomSince,
|
||||
ipCustomUntil,
|
||||
ipSort,
|
||||
]);
|
||||
|
||||
const refreshActive = () => {
|
||||
if (tab === 'overview') void overviewQuery.refetch();
|
||||
if (tab === 'list') void listQuery.refetch();
|
||||
if (tab === 'ips') void ipQuery.refetch();
|
||||
};
|
||||
|
||||
const isFetching = overviewQuery.isFetching || listQuery.isFetching;
|
||||
const isFetching =
|
||||
overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
@@ -146,7 +204,7 @@ function AccessLogsPageContent() {
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看访问概览、排行榜与明细日志。
|
||||
查看访问概览、IP 明细与请求日志。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,8 +232,9 @@ function AccessLogsPageContent() {
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
||||
<TabsList className='grid w-full max-w-lg grid-cols-3'>
|
||||
<TabsTrigger value='overview'>概览</TabsTrigger>
|
||||
<TabsTrigger value='ips'>IP 明细</TabsTrigger>
|
||||
<TabsTrigger value='list'>日志明细</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -194,6 +253,48 @@ function AccessLogsPageContent() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='ips' className='mt-4'>
|
||||
<IpTab
|
||||
data={ipQuery.data}
|
||||
loading={ipQueryEnabled && ipQuery.isLoading}
|
||||
error={ipQuery.error instanceof Error ? ipQuery.error : null}
|
||||
page={ipPage}
|
||||
pageSize={ipPageSize}
|
||||
sort={ipSort}
|
||||
hours={ipHours}
|
||||
timeMode={ipTimeMode}
|
||||
customSince={ipCustomSince}
|
||||
customUntil={ipCustomUntil}
|
||||
onHoursChange={(next) => {
|
||||
setIpTimeMode('preset');
|
||||
setIpHours(next);
|
||||
}}
|
||||
onTimeModeChange={(mode) => {
|
||||
setIpTimeMode(mode);
|
||||
if (
|
||||
mode === 'custom' &&
|
||||
(!ipCustomSince.trim() || !ipCustomUntil.trim())
|
||||
) {
|
||||
const range = defaultLocalRangeForHours(ipHours);
|
||||
setIpCustomSince(range.since);
|
||||
setIpCustomUntil(range.until);
|
||||
}
|
||||
}}
|
||||
onApplyCustomRange={(since, until) => {
|
||||
setIpCustomSince(since);
|
||||
setIpCustomUntil(until);
|
||||
setIpTimeMode('custom');
|
||||
setIpPage(0);
|
||||
}}
|
||||
onPageSizeChange={setIpPageSize}
|
||||
onSortChange={setIpSort}
|
||||
onRetry={() => void ipQuery.refetch()}
|
||||
onPrevPage={() => setIpPage((p) => Math.max(0, p - 1))}
|
||||
onNextPage={() => setIpPage((p) => p + 1)}
|
||||
isFetching={ipQuery.isFetching}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='list' className='mt-4 space-y-4'>
|
||||
<div className='rounded-lg border border-dashed bg-background p-4'>
|
||||
<AccessLogFilters
|
||||
|
||||
@@ -547,6 +547,10 @@ export interface AccessLogItem {
|
||||
user_agent: string;
|
||||
cache_status: string;
|
||||
status_code: number;
|
||||
bytes_sent: number;
|
||||
request_length: number;
|
||||
request_time_ms: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AccessLogList {
|
||||
@@ -612,6 +616,9 @@ export interface AccessLogIPSummaryFilters {
|
||||
node_id?: string;
|
||||
remote_addr?: string;
|
||||
host?: string;
|
||||
hours?: number;
|
||||
since?: string;
|
||||
until?: string;
|
||||
p?: number;
|
||||
page_size?: number;
|
||||
sort_by?: string;
|
||||
@@ -620,8 +627,14 @@ export interface AccessLogIPSummaryFilters {
|
||||
|
||||
export interface AccessLogIPSummaryItem {
|
||||
remote_addr: string;
|
||||
region?: string;
|
||||
total_requests: number;
|
||||
recent_requests: number;
|
||||
success_2xx_count: number;
|
||||
success_ratio: number;
|
||||
bytes_received: number;
|
||||
bytes_sent: number;
|
||||
/** @deprecated always 0 */
|
||||
recent_requests?: number;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
@@ -631,6 +644,9 @@ export interface AccessLogIPSummaryList {
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
total_ip: number;
|
||||
hours: number;
|
||||
since: string;
|
||||
until?: string;
|
||||
sort_by: string;
|
||||
sort_order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user