'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; import { RefreshCw, ScrollText, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { AccessLogService } from '@/lib/services/openflare'; import { formatDateTime } from '@/lib/utils'; import { AccessLogFilters } from './components/access-log-filters'; import { type AccessLogTab, DETAIL_SORT_OPTIONS, FOLD_SORT_OPTIONS, formatCompactNumber, IP_SORT_OPTIONS, parseSortValue, type SearchDraft, } from './components/access-log-utils'; import { CleanupDialog } from './components/cleanup-dialog'; const trendChartConfig = { requests: { label: '请求数', color: 'hsl(var(--primary))' }, } satisfies ChartConfig; const emptyDraft: SearchDraft = { nodeId: '', remoteAddr: '', host: '', path: '', }; function PaginationBar({ page, hasMore, loading, onPrev, onNext, }: { page: number; hasMore: boolean; loading: boolean; onPrev: () => void; onNext: () => void; }) { return (

当前第 {page + 1} 页

); } export default function AccessLogsPage() { const queryClient = useQueryClient(); const [tab, setTab] = useState('list'); const [draft, setDraft] = useState(emptyDraft); const [filters, setFilters] = useState(emptyDraft); const [pageSize, setPageSize] = useState(20); const [page, setPage] = useState(0); const [detailSort, setDetailSort] = useState('logged_at:desc'); const [foldSort, setFoldSort] = useState('bucket_started_at:desc'); const [ipSort, setIpSort] = useState('total_requests:desc'); const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3); const [trendIp, setTrendIp] = useState(''); const [appliedTrendIp, setAppliedTrendIp] = useState(''); const [cleanupOpen, setCleanupOpen] = useState(false); const detailSortState = parseSortValue(detailSort); const foldSortState = parseSortValue(foldSort); const ipSortState = parseSortValue(ipSort); const listQuery = useQuery({ queryKey: [ 'openflare', 'access-logs', 'list', filters, page, pageSize, detailSort, ], queryFn: () => AccessLogService.list({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, path: filters.path || undefined, p: page, page_size: pageSize, sort_by: detailSortState.sortBy, sort_order: detailSortState.sortOrder, }), enabled: tab === 'list', }); const foldsQuery = useQuery({ queryKey: [ 'openflare', 'access-logs', 'folds', filters, page, pageSize, foldSort, foldMinutes, ], queryFn: () => AccessLogService.listFolds({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, path: filters.path || undefined, p: page, page_size: pageSize, sort_by: foldSortState.sortBy, sort_order: foldSortState.sortOrder, fold_minutes: foldMinutes, }), enabled: tab === 'folds', }); const ipSummaryQuery = useQuery({ queryKey: [ 'openflare', 'access-logs', 'ip-summary', filters, page, pageSize, ipSort, ], queryFn: () => AccessLogService.listIPSummaries({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, p: page, page_size: pageSize, sort_by: ipSortState.sortBy, sort_order: ipSortState.sortOrder, }), enabled: tab === 'ip-summary', }); const ipTrendQuery = useQuery({ queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp], queryFn: () => AccessLogService.getIPTrend({ node_id: filters.nodeId || undefined, remote_addr: appliedTrendIp, host: filters.host || undefined, hours: 24, bucket_minutes: 30, }), enabled: tab === 'ip-trend' && appliedTrendIp !== '', }); const cleanupMutation = useMutation({ mutationFn: (retentionDays: number) => AccessLogService.cleanup({ retention_days: retentionDays }), onSuccess: async (result) => { toast.success(`已清理 ${result.deleted_count} 条日志`); setCleanupOpen(false); await queryClient.invalidateQueries({ queryKey: ['openflare', 'access-logs'], }); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '清理失败'); }, }); const activeSummary = useMemo(() => { if (tab === 'list' && listQuery.data) { return { totalRecord: listQuery.data.total_record, totalIp: listQuery.data.total_ip, }; } if (tab === 'folds' && foldsQuery.data) { return { totalRecord: foldsQuery.data.total_record, totalIp: foldsQuery.data.total_ip, }; } if (tab === 'ip-summary' && ipSummaryQuery.data) { return { totalRecord: 0, totalIp: ipSummaryQuery.data.total_ip, }; } return { totalRecord: 0, totalIp: 0 }; }, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data]); const trendChartData = useMemo(() => { return (ipTrendQuery.data?.points ?? []).map((point) => ({ label: formatDateTime(point.bucket_started_at).slice(5), requests: point.request_count, })); }, [ipTrendQuery.data?.points]); const handleSearch = useCallback(() => { setFilters({ nodeId: draft.nodeId.trim(), remoteAddr: draft.remoteAddr.trim(), host: draft.host.trim(), path: draft.path.trim(), }); setPage(0); }, [draft]); const handleReset = () => { setDraft(emptyDraft); setFilters(emptyDraft); setPage(0); setTrendIp(''); setAppliedTrendIp(''); }; useEffect(() => { setPage(0); }, [tab, pageSize]); const refreshActive = () => { if (tab === 'list') void listQuery.refetch(); if (tab === 'folds') void foldsQuery.refetch(); if (tab === 'ip-summary') void ipSummaryQuery.refetch(); if (tab === 'ip-trend') void ipTrendQuery.refetch(); }; const isFetching = listQuery.isFetching || foldsQuery.isFetching || ipSummaryQuery.isFetching || ipTrendQuery.isFetching; return (

访问日志

按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。

{[ { label: '访问记录', value: formatCompactNumber(activeSummary.totalRecord), }, { label: '来源 IP', value: formatCompactNumber(activeSummary.totalIp), }, { label: '当前视图', value: tab === 'list' ? '明细日志' : tab === 'folds' ? '时间折叠' : tab === 'ip-summary' ? 'IP 汇总' : 'IP 趋势', }, ].map((item) => (

{item.label}

{item.value}

))}
setTab(value as AccessLogTab)} > 明细列表 时间折叠 IP 汇总 IP 趋势

明细日志

{listQuery.isError ? (
void listQuery.refetch()} />
) : listQuery.isLoading ? ( ) : (listQuery.data?.items ?? []).length === 0 ? ( ) : ( 时间 节点 IP 域名 路径 状态码 {(listQuery.data?.items ?? []).map((item) => ( {formatDateTime(item.logged_at)} {item.node_name || item.node_id} {item.remote_addr} {item.host} {item.path} {item.status_code} ))}
)} setPage((p) => Math.max(0, p - 1))} onNext={() => setPage((p) => p + 1)} />

时间折叠

{foldsQuery.isError ? (
void foldsQuery.refetch()} />
) : foldsQuery.isLoading ? ( ) : (foldsQuery.data?.items ?? []).length === 0 ? ( ) : ( 时间桶 请求数 独立 IP 独立域名 2xx 4xx 5xx {(foldsQuery.data?.items ?? []).map((item) => ( {formatDateTime(item.bucket_started_at)} {item.request_count} {item.unique_ip_count} {item.unique_host_count} {item.success_count} {item.client_error_count} {item.server_error_count} ))}
)} setPage((p) => Math.max(0, p - 1))} onNext={() => setPage((p) => p + 1)} />

IP 汇总

{ipSummaryQuery.isError ? (
void ipSummaryQuery.refetch()} />
) : ipSummaryQuery.isLoading ? ( ) : (ipSummaryQuery.data?.items ?? []).length === 0 ? ( ) : ( IP 总请求数 近 3 小时 最后访问 {(ipSummaryQuery.data?.items ?? []).map((item) => ( {item.remote_addr} {item.total_requests} {item.recent_requests} {formatDateTime(item.last_seen_at)} ))}
)} setPage((p) => Math.max(0, p - 1))} onNext={() => setPage((p) => p + 1)} />

趋势 IP

setTrendIp(e.target.value)} placeholder='输入要分析趋势的 IP 地址' />
{!appliedTrendIp ? ( ) : ipTrendQuery.isLoading ? ( ) : ipTrendQuery.isError ? ( void ipTrendQuery.refetch()} /> ) : trendChartData.length === 0 ? ( ) : (

{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时

} />
)}
cleanupMutation.mutate(days)} loading={cleanupMutation.isPending} />
); }