feat(access-logs): 新增 IP 明细 Tab 并完善日志详情字段

按时间窗聚合 IP 请求数/2xx 比例/入出站流量,支持排序与详情分析;
日志明细详情仅展示请求业务字段,IP 情报迁至独立详情弹窗。
This commit is contained in:
ryan
2026-07-19 00:42:59 +08:00
parent bfd9de69af
commit 920a530aa7
22 changed files with 2042 additions and 742 deletions
@@ -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 };
}
+105 -4
View File
@@ -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
+17 -1
View File
@@ -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';
}