'use client'; import { Fragment, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { TrendChart } from '@/components/data/trend-chart'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; import { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { cleanupAccessLogs, getAccessLogIPSummaries, getAccessLogIPTrend, getAccessLogs, getFoldedAccessLogIPs, getFoldedAccessLogs, } from '@/features/access-logs/api/access-logs'; import type { AccessLogCleanupPayload, AccessLogIPSummaryItem, AccessLogIPSummaryList, AccessLogList, FoldedAccessLogIPList, FoldedAccessLogList, } from '@/features/access-logs/types'; import { PrimaryButton, ResourceField, ResourceInput, ResourceSelect, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatCompactNumber } from '@/lib/utils/metrics'; type ActiveTab = 'detail' | 'ip'; type SearchDraft = { nodeId: string; remoteAddr: string; host: string; path: string; }; type AppliedSearch = SearchDraft; const pageSizeOptions = [20, 50, 100, 200]; const detailSortOptions = [ { value: 'logged_at:desc', label: '时间从新到旧' }, { value: 'logged_at:asc', label: '时间从旧到新' }, { value: 'status_code:desc', label: '状态码从高到低' }, { value: 'status_code:asc', label: '状态码从低到高' }, { value: 'remote_addr:asc', label: 'IP 正序' }, { value: 'remote_addr:desc', label: 'IP 倒序' }, { value: 'host:asc', label: '域名正序' }, { value: 'host:desc', label: '域名倒序' }, ]; const foldedSortOptions = [ { value: 'bucket_started_at:desc', label: '时间桶从新到旧' }, { value: 'bucket_started_at:asc', label: '时间桶从旧到新' }, { value: 'request_count:desc', label: '访问次数从高到低' }, { value: 'request_count:asc', label: '访问次数从低到高' }, ]; const ipSortOptions = [ { value: 'total_requests:desc', label: '总访问次数从高到低' }, { value: 'total_requests:asc', label: '总访问次数从低到高' }, { value: 'recent_requests:desc', label: '3 小时访问次数从高到低' }, { value: 'recent_requests:asc', label: '3 小时访问次数从低到高' }, { value: 'last_seen_at:desc', label: '最后访问时间从新到旧' }, { value: 'last_seen_at:asc', label: '最后访问时间从旧到新' }, ]; const foldOptions = [ { value: '0', label: '不折叠' }, { value: '3', label: '按 3 分钟折叠' }, { value: '5', label: '按 5 分钟折叠' }, ]; const cleanupPresetOptions = [3, 7, 30]; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function parseSortValue(value: string) { const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':'); return { sortBy, sortOrder: sortOrder === 'asc' ? 'asc' : 'desc', } as const; } function buildSummary(totalRecord = 0, totalIP = 0, activeTab: ActiveTab) { return [ { label: '访问记录', value: formatCompactNumber(totalRecord) }, { label: '来源 IP', value: formatCompactNumber(totalIP) }, { label: '当前视图', value: activeTab === 'detail' ? '明细日志' : 'IP 维度', }, ]; } function buildTrendLabels(points: Array<{ bucket_started_at: string }>) { return points.map((point) => { const date = new Date(point.bucket_started_at); if (Number.isNaN(date.getTime())) { return '--'; } return `${String(date.getMonth() + 1).padStart(2, '0')}-${String( date.getDate(), ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String( date.getMinutes(), ).padStart(2, '0')}`; }); } function isFoldedAccessLogList( value: AccessLogList | FoldedAccessLogList | undefined, ): value is FoldedAccessLogList { if (!value || !Array.isArray(value.items)) { return false; } return value.items.every((item) => { const candidate = item as Partial; return ( typeof candidate.bucket_started_at === 'string' && typeof candidate.request_count === 'number' ); }); } function isAccessLogList( value: AccessLogList | FoldedAccessLogList | undefined, ): value is AccessLogList { if (!value || !Array.isArray(value.items)) { return false; } return value.items.every((item) => { const candidate = item as Partial; return ( typeof candidate.logged_at === 'string' && typeof candidate.status_code === 'number' ); }); } export function AccessLogsPage() { const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('detail'); const [draft, setDraft] = useState({ nodeId: '', remoteAddr: '', host: '', path: '', }); const [filters, setFilters] = useState({ nodeId: '', remoteAddr: '', host: '', path: '', }); const [detailPage, setDetailPage] = useState(0); const [ipPage, setIPPage] = useState(0); const [pageSize, setPageSize] = useState(20); const [foldMinutes, setFoldMinutes] = useState<0 | 3 | 5>(0); const [detailSort, setDetailSort] = useState('logged_at:desc'); const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc'); const [ipSort, setIPSort] = useState('total_requests:desc'); const [expandedBucket, setExpandedBucket] = useState(null); const [bucketIPPages, setBucketIPPages] = useState>( {}, ); const [selectedIP, setSelectedIP] = useState( null, ); const [cleanupDays, setCleanupDays] = useState('7'); const [customCleanupDays, setCustomCleanupDays] = useState('14'); const [isCleanupModalOpen, setCleanupModalOpen] = useState(false); const detailSortState = parseSortValue(detailSort); const foldedSortState = parseSortValue(foldedSort); const ipSortState = parseSortValue(ipSort); const detailQuery = useQuery({ queryKey: [ 'access-logs', 'detail', filters, detailPage, pageSize, detailSort, foldMinutes, foldedSort, ], queryFn: () => { if (foldMinutes > 0) { return getFoldedAccessLogs({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, path: filters.path || undefined, p: detailPage, page_size: pageSize, sort_by: foldedSortState.sortBy, sort_order: foldedSortState.sortOrder, fold_minutes: foldMinutes as 3 | 5, }); } return getAccessLogs({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, path: filters.path || undefined, p: detailPage, page_size: pageSize, sort_by: detailSortState.sortBy, sort_order: detailSortState.sortOrder, }); }, placeholderData: ( previousData: AccessLogList | FoldedAccessLogList | undefined, ) => { if (foldMinutes > 0) { return isFoldedAccessLogList(previousData) ? previousData : undefined; } return isAccessLogList(previousData) ? previousData : undefined; }, }); const ipSummaryQuery = useQuery({ queryKey: ['access-logs', 'ip-summary', filters, ipPage, pageSize, ipSort], queryFn: () => getAccessLogIPSummaries({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, p: ipPage, page_size: pageSize, sort_by: ipSortState.sortBy, sort_order: ipSortState.sortOrder, }), placeholderData: (previousData: AccessLogIPSummaryList | undefined) => previousData, }); const ipTrendQuery = useQuery({ queryKey: [ 'access-logs', 'ip-trend', selectedIP?.remote_addr, filters.nodeId, filters.host, ], queryFn: () => getAccessLogIPTrend({ node_id: filters.nodeId || undefined, remote_addr: selectedIP?.remote_addr ?? '', host: filters.host || undefined, hours: 24, bucket_minutes: 30, }), enabled: Boolean(selectedIP?.remote_addr), }); const cleanupMutation = useMutation({ mutationFn: (payload: AccessLogCleanupPayload) => cleanupAccessLogs(payload), onSuccess: async () => { setCleanupModalOpen(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }), queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-summary'], }), queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-trend'], }), ]); }, }); const detailSummaryData = detailQuery.data as | AccessLogList | FoldedAccessLogList | undefined; const summary = useMemo( () => buildSummary( detailSummaryData?.total_record ?? 0, detailSummaryData?.total_ip ?? 0, activeTab, ), [activeTab, detailSummaryData?.total_ip, detailSummaryData?.total_record], ); const trendLabels = useMemo( () => buildTrendLabels(ipTrendQuery.data?.points ?? []), [ipTrendQuery.data?.points], ); const trendValues = useMemo( () => (ipTrendQuery.data?.points ?? []).map((point) => point.request_count), [ipTrendQuery.data?.points], ); const handleSearch = () => { setFilters({ nodeId: draft.nodeId.trim(), remoteAddr: draft.remoteAddr.trim(), host: draft.host.trim(), path: draft.path.trim(), }); setDetailPage(0); setIPPage(0); setExpandedBucket(null); setBucketIPPages({}); }; const handleReset = () => { const empty = { nodeId: '', remoteAddr: '', host: '', path: '' }; setDraft(empty); setFilters(empty); setDetailPage(0); setIPPage(0); setSelectedIP(null); setExpandedBucket(null); setBucketIPPages({}); }; const handleCleanupConfirm = () => { const retentionDays = cleanupDays === 'custom' ? Number.parseInt(customCleanupDays, 10) : Number.parseInt(cleanupDays, 10); cleanupMutation.mutate({ retention_days: retentionDays }); }; return (
setCleanupModalOpen(true)} > 清理日志 } />
{summary.map((item) => (

{item.label}

{item.value}

))}
void queryClient.invalidateQueries({ queryKey: ['access-logs'], }) } > 刷新 } >
{[ { key: 'detail', label: '明细日志', description: '按请求明细查看与折叠聚合', }, { key: 'ip', label: 'IP 维度', description: '查看来源 IP 汇总与趋势', }, ].map((tab) => ( ))}
setDraft((current) => ({ ...current, nodeId: event.target.value, })) } placeholder="按 node_id 搜索" /> setDraft((current) => ({ ...current, remoteAddr: event.target.value, })) } placeholder="按 IP 搜索" /> setDraft((current) => ({ ...current, host: event.target.value, })) } placeholder="按域名搜索" /> setDraft((current) => ({ ...current, path: event.target.value, })) } placeholder="按路径搜索" />
{ setPageSize(Number(event.target.value) || 20); setDetailPage(0); setIPPage(0); }} > {pageSizeOptions.map((option) => ( ))} 0 ? '折叠排序' : '排序' } > 0 ? foldedSort : activeTab === 'detail' ? detailSort : ipSort } onChange={(event) => { if (activeTab === 'detail' && foldMinutes > 0) { setFoldedSort(event.target.value); setDetailPage(0); return; } if (activeTab === 'detail') { setDetailSort(event.target.value); setDetailPage(0); return; } setIPSort(event.target.value); setIPPage(0); }} > {(activeTab === 'detail' && foldMinutes > 0 ? foldedSortOptions : activeTab === 'detail' ? detailSortOptions : ipSortOptions ).map((option) => ( ))} {activeTab === 'detail' ? ( { setFoldMinutes(Number(event.target.value) as 0 | 3 | 5); setDetailPage(0); setExpandedBucket(null); setBucketIPPages({}); }} > {foldOptions.map((option) => ( ))} ) : (
)}
应用筛选 重置
{activeTab === 'detail' ? ( setExpandedBucket((current) => (current === bucket ? null : bucket)) } onBucketIPPageChange={(bucket, nextPage) => setBucketIPPages((current) => ({ ...current, [bucket]: Math.max(nextPage, 0), })) } onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))} onNextPage={() => setDetailPage((value) => value + 1)} /> ) : ( setIPPage((value) => Math.max(value - 1, 0))} onNextPage={() => setIPPage((value) => value + 1)} onSelectIP={setSelectedIP} /> )} setSelectedIP(null)} > {ipTrendQuery.isLoading ? ( ) : ipTrendQuery.isError ? ( ) : ipTrendQuery.data ? ( formatCompactNumber(value)} /> ) : ( )} setCleanupModalOpen(false)} footer={
setCleanupModalOpen(false)} > 取消 {cleanupMutation.isPending ? '清理中...' : '确认清理'}
} >
{cleanupPresetOptions.map((days) => ( ))}
{cleanupDays === 'custom' ? ( setCustomCleanupDays(event.target.value)} placeholder="输入保留天数" type="number" min={1} max={90} /> ) : null} {cleanupMutation.isError ? ( ) : null}
); } function getStatusMeta(statusCode: number) { if (statusCode >= 500) { return { label: String(statusCode), variant: 'danger' as const }; } if (statusCode >= 400) { return { label: String(statusCode), variant: 'warning' as const }; } return { label: String(statusCode), variant: 'success' as const }; } function DetailTab({ detailPage, pageSize, foldMinutes, filters, query, expandedBucket, bucketIPPages, onToggleBucket, onBucketIPPageChange, onPrevPage, onNextPage, }: { detailPage: number; pageSize: number; foldMinutes: 0 | 3 | 5; filters: AppliedSearch; query: { isLoading: boolean; isError: boolean; isFetching: boolean; error: unknown; data?: AccessLogList | FoldedAccessLogList; }; expandedBucket: string | null; bucketIPPages: Record; onToggleBucket: (bucket: string) => void; onBucketIPPageChange: (bucket: string, page: number) => void; onPrevPage: () => void; onNextPage: () => void; }) { if (query.isLoading) { return ( ); } if (query.isError) { return ( ); } if (foldMinutes > 0) { if (!isFoldedAccessLogList(query.data)) { return ( ); } const data = query.data; return (
{data.items.length === 0 ? ( ) : (
{data.items.map((item) => { const isExpanded = expandedBucket === item.bucket_started_at; const bucketPage = bucketIPPages[item.bucket_started_at] ?? 0; return ( onToggleBucket(item.bucket_started_at)} > {isExpanded ? ( ) : null} ); })}
时间桶 总访问 来源 IP 域名数 2xx 4xx 5xx
{formatRelativeTime(item.bucket_started_at)}
{formatCompactNumber(item.request_count)} {formatCompactNumber(item.unique_ip_count)} {formatCompactNumber(item.unique_host_count)} {formatCompactNumber(item.success_count)} {formatCompactNumber(item.client_error_count)} {formatCompactNumber(item.server_error_count)}
onBucketIPPageChange( item.bucket_started_at, bucketPage - 1, ) } onNextPage={() => onBucketIPPageChange( item.bucket_started_at, bucketPage + 1, ) } />
)}
); } if (!isAccessLogList(query.data)) { return ( ); } const data = query.data; return (
{data.items.length === 0 ? ( ) : (
{data.items.map((item) => { const statusMeta = getStatusMeta(item.status_code); return ( ); })}
时间 来源 IP 访问域名 路径 节点 状态码
{formatDateTime(item.logged_at)}
{formatRelativeTime(item.logged_at)}
{item.remote_addr || '—'}
{item.region ? (
{item.region}
) : null}
{item.host || '—'} {item.path || '—'}
{item.node_name || item.node_id}
{item.node_id}
)}
); } function IPTab({ pageSize, ipPage, query, onPrevPage, onNextPage, onSelectIP, }: { pageSize: number; ipPage: number; query: { isLoading: boolean; isError: boolean; isFetching: boolean; error: unknown; data?: { items: AccessLogIPSummaryItem[]; has_more: boolean; }; }; onPrevPage: () => void; onNextPage: () => void; onSelectIP: (item: AccessLogIPSummaryItem) => void; }) { const items = query.data?.items ?? []; const hasMore = query.data?.has_more ?? false; return ( {query.isLoading ? ( ) : query.isError ? ( ) : items.length === 0 ? ( ) : (
{items.map((item) => ( onSelectIP(item)} > ))}
IP 总访问次数 3 小时内访问次数 最后访问时间
{item.remote_addr} {formatCompactNumber(item.total_requests)} {formatCompactNumber(item.recent_requests)}
{formatDateTime(item.last_seen_at)}
{formatRelativeTime(item.last_seen_at)}
)}
); } function FoldedBucketIPDetails({ bucketStartedAt, foldMinutes, filters, page, pageSize, onPrevPage, onNextPage, }: { bucketStartedAt: string; foldMinutes: 3 | 5; filters: AppliedSearch; page: number; pageSize: number; onPrevPage: () => void; onNextPage: () => void; }) { const query = useQuery({ queryKey: [ 'access-logs', 'fold-bucket-ip', filters, bucketStartedAt, foldMinutes, page, pageSize, ], queryFn: () => getFoldedAccessLogIPs({ node_id: filters.nodeId || undefined, remote_addr: filters.remoteAddr || undefined, host: filters.host || undefined, path: filters.path || undefined, bucket_started_at: bucketStartedAt, fold_minutes: foldMinutes, p: page, page_size: pageSize, sort_by: 'request_count', sort_order: 'desc', }), placeholderData: (previousData: FoldedAccessLogIPList | undefined) => previousData, }); if (query.isLoading) { return ; } if (query.isError) { return ( ); } const items = query.data?.items ?? []; if (items.length === 0) { return ( ); } return (

时间段内 IP 访问情况

共 {formatCompactNumber(query.data?.total_ip ?? 0)} 个 IP,按访问次数从高到低排序

{items.map((item) => ( ))}
来源 IP 访问次数 2xx 4xx 5xx 最后访问
{item.remote_addr} {formatCompactNumber(item.request_count)} {formatCompactNumber(item.success_count)} {formatCompactNumber(item.client_error_count)} {formatCompactNumber(item.server_error_count)} {formatDateTime(item.last_seen_at)}
); } function Pager({ page, pageSize, hasMore, itemCount, isFetching, onPrev, onNext, }: { page: number; pageSize: number; hasMore: boolean; itemCount: number; isFetching: boolean; onPrev: () => void; onNext: () => void; }) { const canTryNext = hasMore || itemCount >= pageSize; return (

第 {page + 1} 页,每页 {pageSize} 条。

上一页 下一页
); }