'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 (
);
}
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)}
/>
{!appliedTrendIp ? (
) : ipTrendQuery.isLoading ? (
) : ipTrendQuery.isError ? (
void ipTrendQuery.refetch()}
/>
) : trendChartData.length === 0 ? (
) : (
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
} />
)}
cleanupMutation.mutate(days)}
loading={cleanupMutation.isPending}
/>
);
}