refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
@@ -0,0 +1,124 @@
"use client"
import {Search} from "lucide-react"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
import type {AccessLogTab, SearchDraft} from "./access-log-utils"
import {PAGE_SIZE_OPTIONS} from "./access-log-utils"
interface AccessLogFiltersProps {
tab: AccessLogTab
draft: SearchDraft
pageSize: number
onDraftChange: (draft: SearchDraft) => void
onPageSizeChange: (pageSize: number) => void
onSearch: () => void
onReset: () => void
}
export function AccessLogFilters({
tab,
draft,
pageSize,
onDraftChange,
onPageSizeChange,
onSearch,
onReset,
}: AccessLogFiltersProps) {
const showPath = tab === "list" || tab === "folds"
return (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">节点 ID</p>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input
value={draft.nodeId}
onChange={(e) =>
onDraftChange({ ...draft, nodeId: e.target.value })
}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按 node_id 搜索"
className="pl-8 h-9 text-xs"
/>
</div>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">来源 IP</p>
<Input
value={draft.remoteAddr}
onChange={(e) =>
onDraftChange({ ...draft, remoteAddr: e.target.value })
}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按 IP 搜索"
className="h-9 text-xs"
/>
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">访问域名</p>
<Input
value={draft.host}
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按域名搜索"
className="h-9 text-xs"
/>
</div>
{showPath ? (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">请求路径</p>
<Input
value={draft.path}
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") onSearch()
}}
placeholder="按路径搜索"
className="h-9 text-xs"
/>
</div>
) : null}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="space-y-1.5 w-full sm:max-w-[180px]">
<p className="text-xs font-medium text-muted-foreground">每页条数</p>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number.parseInt(value, 10))}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option} 条
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<Button size="sm" onClick={onSearch}>
筛选
</Button>
<Button variant="outline" size="sm" onClick={onReset}>
清空
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,48 @@
export type AccessLogTab = "list" | "folds" | "ip-summary" | "ip-trend"
export type SearchDraft = {
nodeId: string
remoteAddr: string
host: string
path: string
}
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]
export const DETAIL_SORT_OPTIONS = [
{ 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 倒序" },
]
export const FOLD_SORT_OPTIONS = [
{ value: "bucket_started_at:desc", label: "时间桶从新到旧" },
{ value: "bucket_started_at:asc", label: "时间桶从旧到新" },
{ value: "request_count:desc", label: "访问次数从高到低" },
{ value: "request_count:asc", label: "访问次数从低到高" },
]
export const IP_SORT_OPTIONS = [
{ value: "total_requests:desc", label: "总访问次数从高到低" },
{ value: "total_requests:asc", label: "总访问次数从低到高" },
{ value: "recent_requests:desc", label: "3 小时访问次数从高到低" },
{ value: "last_seen_at:desc", label: "最后访问时间从新到旧" },
]
export function parseSortValue(value: string) {
const [sortBy = "logged_at", sortOrder = "desc"] = value.split(":")
return {
sortBy,
sortOrder: sortOrder === "asc" ? ("asc" as const) : ("desc" as const),
}
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat("zh-CN", {
notation: value >= 10000 ? "compact" : "standard",
maximumFractionDigits: 1,
}).format(value)
}
@@ -0,0 +1,115 @@
"use client"
import {useState} from "react"
import {Loader2} from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {Button} from "@/components/ui/button"
import {Input} from "@/components/ui/input"
import {Label} from "@/components/ui/label"
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
const CLEANUP_PRESETS = [3, 7, 30]
interface CleanupDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: (retentionDays: number) => void
loading: boolean
}
export function CleanupDialog({
open,
onOpenChange,
onConfirm,
loading,
}: CleanupDialogProps) {
const [mode, setMode] = useState<string>("7")
const [customDays, setCustomDays] = useState("14")
const [error, setError] = useState<string | null>(null)
const handleConfirm = () => {
const retentionDays =
mode === "custom"
? Number.parseInt(customDays, 10)
: Number.parseInt(mode, 10)
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
setError("保留天数必须大于 0")
return
}
setError(null)
onConfirm(retentionDays)
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理访问日志</AlertDialogTitle>
<AlertDialogDescription>
删除早于指定保留天数的访问日志记录,操作不可恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label>保留策略</Label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLEANUP_PRESETS.map((days) => (
<SelectItem key={days} value={String(days)}>
保留最近 {days} 天
</SelectItem>
))}
<SelectItem value="custom">自定义天数</SelectItem>
</SelectContent>
</Select>
</div>
{mode === "custom" ? (
<div className="space-y-1.5">
<Label htmlFor="customDays">自定义保留天数</Label>
<Input
id="customDays"
type="number"
min={1}
value={customDays}
onChange={(e) => setCustomDays(e.target.value)}
disabled={loading}
/>
</div>
) : null}
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<Button variant="destructive" onClick={handleConfirm} disabled={loading}>
{loading ? (
<>
<Loader2 className="size-4 animate-spin mr-1" />
清理中...
</>
) : (
"确认清理"
)}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
+588
View File
@@ -0,0 +1,588 @@
"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 (
<div className="flex items-center justify-between px-4 py-3 border-t border-dashed">
<p className="text-xs text-muted-foreground">当前第 {page + 1} 页</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 default function AccessLogsPage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState<AccessLogTab>("list")
const [draft, setDraft] = useState<SearchDraft>(emptyDraft)
const [filters, setFilters] = useState<SearchDraft>(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 (
<div className="py-6 px-1 space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2">
<ScrollText className="size-5 text-primary" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">访问日志</h1>
<p className="text-sm text-muted-foreground">
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={refreshActive} disabled={isFetching}>
<RefreshCw className={`size-3.5 mr-1 ${isFetching ? "animate-spin" : ""}`} />
刷新
</Button>
<Button variant="destructive" size="sm" onClick={() => setCleanupOpen(true)}>
<Trash2 className="size-3.5 mr-1" />
清理日志
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
{[
{ label: "访问记录", value: formatCompactNumber(activeSummary.totalRecord) },
{ label: "来源 IP", value: formatCompactNumber(activeSummary.totalIp) },
{
label: "当前视图",
value:
tab === "list"
? "明细日志"
: tab === "folds"
? "时间折叠"
: tab === "ip-summary"
? "IP 汇总"
: "IP 趋势",
},
].map((item) => (
<div key={item.label} className="rounded-lg border border-dashed px-4 py-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{item.label}
</p>
<p className="mt-1 text-lg font-semibold">{item.value}</p>
</div>
))}
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as AccessLogTab)}>
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4">
<TabsTrigger value="list">明细列表</TabsTrigger>
<TabsTrigger value="folds">时间折叠</TabsTrigger>
<TabsTrigger value="ip-summary">IP 汇总</TabsTrigger>
<TabsTrigger value="ip-trend">IP 趋势</TabsTrigger>
</TabsList>
<div className="mt-4 rounded-lg border border-dashed bg-background p-4">
<AccessLogFilters
tab={tab}
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
/>
</div>
<TabsContent value="list" className="mt-4">
<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">明细日志</p>
<Select value={detailSort} onValueChange={setDetailSort}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{listQuery.isError ? (
<div className="p-4">
<ErrorInline
message={listQuery.error instanceof Error ? listQuery.error.message : "加载失败"}
onRetry={() => void listQuery.refetch()}
/>
</div>
) : listQuery.isLoading ? (
<LoadingStateWithBorder />
) : (listQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title="暂无访问日志" />
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs">时间</TableHead>
<TableHead className="text-xs">节点</TableHead>
<TableHead className="text-xs">IP</TableHead>
<TableHead className="text-xs">域名</TableHead>
<TableHead className="text-xs">路径</TableHead>
<TableHead className="text-xs">状态码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id} className="border-dashed">
<TableCell className="text-xs">{formatDateTime(item.logged_at)}</TableCell>
<TableCell className="text-xs">{item.node_name || item.node_id}</TableCell>
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
<TableCell className="text-xs">{item.host}</TableCell>
<TableCell className="text-xs max-w-48 truncate">{item.path}</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{item.status_code}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={listQuery.data?.has_more ?? false}
loading={listQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value="folds" className="mt-4">
<div className="rounded-lg border border-dashed overflow-hidden bg-background">
<div className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">时间折叠</p>
<Select
value={String(foldMinutes)}
onValueChange={(value) => setFoldMinutes(Number(value) as 3 | 5)}
>
<SelectTrigger className="h-8 w-36 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3 分钟桶</SelectItem>
<SelectItem value="5">5 分钟桶</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={foldSort} onValueChange={setFoldSort}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FOLD_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{foldsQuery.isError ? (
<div className="p-4">
<ErrorInline
message={
foldsQuery.error instanceof Error
? foldsQuery.error.message
: "加载失败"
}
onRetry={() => void foldsQuery.refetch()}
/>
</div>
) : foldsQuery.isLoading ? (
<LoadingStateWithBorder />
) : (foldsQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title="暂无折叠数据" />
) : (
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs">时间桶</TableHead>
<TableHead className="text-xs">请求数</TableHead>
<TableHead className="text-xs">独立 IP</TableHead>
<TableHead className="text-xs">独立域名</TableHead>
<TableHead className="text-xs">2xx</TableHead>
<TableHead className="text-xs">4xx</TableHead>
<TableHead className="text-xs">5xx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(foldsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.bucket_started_at} className="border-dashed">
<TableCell className="text-xs">
{formatDateTime(item.bucket_started_at)}
</TableCell>
<TableCell className="text-xs">{item.request_count}</TableCell>
<TableCell className="text-xs">{item.unique_ip_count}</TableCell>
<TableCell className="text-xs">{item.unique_host_count}</TableCell>
<TableCell className="text-xs">{item.success_count}</TableCell>
<TableCell className="text-xs">{item.client_error_count}</TableCell>
<TableCell className="text-xs">{item.server_error_count}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={foldsQuery.data?.has_more ?? false}
loading={foldsQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value="ip-summary" className="mt-4">
<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>
<Select value={ipSort} onValueChange={setIpSort}>
<SelectTrigger className="h-8 w-52 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{ipSummaryQuery.isError ? (
<div className="p-4">
<ErrorInline
message={
ipSummaryQuery.error instanceof Error
? ipSummaryQuery.error.message
: "加载失败"
}
onRetry={() => void ipSummaryQuery.refetch()}
/>
</div>
) : ipSummaryQuery.isLoading ? (
<LoadingStateWithBorder />
) : (ipSummaryQuery.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">近 3 小时</TableHead>
<TableHead className="text-xs">最后访问</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(ipSummaryQuery.data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className="border-dashed">
<TableCell className="text-xs font-mono">{item.remote_addr}</TableCell>
<TableCell className="text-xs">{item.total_requests}</TableCell>
<TableCell className="text-xs">{item.recent_requests}</TableCell>
<TableCell className="text-xs">
{formatDateTime(item.last_seen_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={ipSummaryQuery.data?.has_more ?? false}
loading={ipSummaryQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value="ip-trend" className="mt-4 space-y-4">
<div className="rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="space-y-1.5 flex-1">
<p className="text-xs font-medium text-muted-foreground">趋势 IP</p>
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs"
value={trendIp}
onChange={(e) => setTrendIp(e.target.value)}
placeholder="输入要分析趋势的 IP 地址"
/>
</div>
<Button
size="sm"
onClick={() => setAppliedTrendIp(trendIp.trim())}
disabled={!trendIp.trim()}
>
查看趋势
</Button>
</div>
<div className="rounded-lg border border-dashed overflow-hidden bg-background p-4">
{!appliedTrendIp ? (
<EmptyStateWithBorder description="请输入 IP 地址后查看 24 小时访问趋势。" />
) : ipTrendQuery.isLoading ? (
<LoadingStateWithBorder />
) : ipTrendQuery.isError ? (
<ErrorInline
message={
ipTrendQuery.error instanceof Error
? ipTrendQuery.error.message
: "加载失败"
}
onRetry={() => void ipTrendQuery.refetch()}
/>
) : trendChartData.length === 0 ? (
<EmptyStateWithBorder description="该 IP 在选定时间范围内没有访问记录。" />
) : (
<div className="space-y-3">
<p className="text-sm font-medium">
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
</p>
<ChartContainer config={trendChartConfig} className="h-64 w-full">
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis dataKey="label" tickLine={false} axisLine={false} fontSize={10} />
<YAxis tickLine={false} axisLine={false} fontSize={10} width={40} />
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="requests"
stroke="var(--color-requests)"
fill="var(--color-requests)"
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
</div>
)}
</div>
</TabsContent>
</Tabs>
<CleanupDialog
open={cleanupOpen}
onOpenChange={setCleanupOpen}
onConfirm={(days) => cleanupMutation.mutate(days)}
loading={cleanupMutation.isPending}
/>
</div>
)
}