"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}
/>
)
}