mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 01:36:37 +08:00
refactor(frontend): colocate admin components and split service layer
Move route-specific admin components from components/common/admin into app/(main)/admin/<feature>/components/. Split AdminService god object into domain services, fix UploadService to use BaseService, add AdminUploadService, consolidate DB export into DbManageService, and migrate consumers to the unified services entry.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {useCallback, useEffect, useState} from "react"
|
||||
import {BarChart3, Globe, RefreshCw, TrendingUp, Users, XCircle} from "lucide-react"
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from "recharts"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
import {ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent} from "@/components/ui/chart"
|
||||
import {Spinner} from "@/components/ui/spinner"
|
||||
|
||||
interface TrendData {
|
||||
date: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface BrowserData {
|
||||
browser: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface TopUserData {
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
count: number
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
count: {
|
||||
label: "请求量",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
} satisfies ChartConfig
|
||||
|
||||
// Format YYYY-MM-DD to MM/DD
|
||||
function formatDateLabel(dateStr: string) {
|
||||
if (!dateStr || dateStr.length < 10) return dateStr
|
||||
const parts = dateStr.split("-")
|
||||
if (parts.length === 3) {
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
|
||||
export function AccessAnalytics() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
|
||||
|
||||
const [trend, setTrend] = useState<TrendData[]>([])
|
||||
const [browsers, setBrowsers] = useState<BrowserData[]>([])
|
||||
const [topUsers, setTopUsers] = useState<TopUserData[]>([])
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const data = await services.adminLog.getLogsAnalytics()
|
||||
|
||||
// Formats the date labels for the X-axis representation
|
||||
const formattedTrend = (data.trend || []).map(item => ({
|
||||
...item,
|
||||
formattedDate: formatDateLabel(item.date)
|
||||
}))
|
||||
|
||||
setTrend(formattedTrend)
|
||||
setBrowsers(data.browsers || [])
|
||||
setTopUsers(data.top_users || [])
|
||||
setClickhouseDisabled(false)
|
||||
} catch (err) {
|
||||
const errorInstance = err instanceof Error ? err : new Error("获取数据统计失败")
|
||||
const errMsg = errorInstance.message || ""
|
||||
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
|
||||
setClickhouseDisabled(true)
|
||||
} else {
|
||||
setError(errorInstance)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics()
|
||||
}, [fetchAnalytics])
|
||||
|
||||
const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0)
|
||||
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]">
|
||||
<XCircle className="size-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold">ClickHouse 未启用</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
|
||||
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={fetchAnalytics} />
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingStateWithBorder icon={BarChart3} description="加载访问统计指标中..." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Overview Cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card className="bg-card/25 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">近 7 天总请求数</CardTitle>
|
||||
<TrendingUp className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{totalTrendRequests.toLocaleString()}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">系统记录的所有成功认证的访问总频次</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/25 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">活跃终端分类数</CardTitle>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{browsers.length}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">在一周内发起请求的浏览器代理大类统计</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-card/25 border-border/40 sm:col-span-2 lg:col-span-1">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2 space-y-0">
|
||||
<CardTitle className="text-sm font-semibold tracking-tight">活跃独立用户数</CardTitle>
|
||||
<Users className="size-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-mono">{topUsers.length}</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">一周内累计发起高频请求的注册账户总量</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Access Trend Chart */}
|
||||
<Card className="bg-card/20 border-border/40">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-bold">一周访问量趋势</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
展现系统最近 7 天内的每日 API 请求曲线
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={fetchAnalytics} disabled={loading}>
|
||||
{loading ? <Spinner className="size-4" /> : <RefreshCw className="size-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2 pr-4 pt-2">
|
||||
{trend.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={TrendingUp} description="暂无趋势数据" />
|
||||
) : (
|
||||
<div className="h-[280px] w-full">
|
||||
<ChartContainer config={chartConfig} className="w-full h-full aspect-auto">
|
||||
<AreaChart
|
||||
data={trend}
|
||||
margin={{ top: 10, right: 10, left: -20, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="colorCount" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-count)" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="var(--color-count)" stopOpacity={0.01} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="formattedDate"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dy={10}
|
||||
className="font-mono"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
dx={-10}
|
||||
className="font-mono"
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="var(--color-count)"
|
||||
strokeWidth={2}
|
||||
fillOpacity={1}
|
||||
fill="url(#colorCount)"
|
||||
name="请求次数"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Two Columns for Ranking Statistics */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Browser Rankings */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-bold flex items-center gap-1.5">
|
||||
<Globe className="size-4.5 text-muted-foreground" />
|
||||
使用的浏览器排行
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
基于请求头 User-Agent 智能分类的一周占比统计
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{browsers.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Globe} description="暂无浏览器排行数据" />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{browsers.map((item, index) => {
|
||||
const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0
|
||||
return (
|
||||
<div key={item.browser} className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="px-1.5 py-0 h-5 font-mono text-[10px] select-none">
|
||||
{index + 1}
|
||||
</Badge>
|
||||
<span className="font-semibold">{item.browser}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Users */}
|
||||
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-bold flex items-center gap-1.5">
|
||||
<Users className="size-4.5 text-muted-foreground" />
|
||||
最活跃的用户排行 (Top 10)
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
统计最近一周发起接口访问请求数量最多的账户
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
{topUsers.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Users} description="暂无活跃用户数据" />
|
||||
) : (
|
||||
<div className="divide-y divide-border/40">
|
||||
{topUsers.map((user) => (
|
||||
<div key={user.user_id} className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs select-none">
|
||||
{(user.username || "U").slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate max-w-[180px] sm:max-w-xs">
|
||||
<div className="text-xs font-bold leading-tight truncate">
|
||||
{user.username || "未知"}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground leading-normal truncate">
|
||||
{user.nickname ? `(${user.nickname})` : "(无昵称)"} | ID: {user.user_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-xs font-bold font-mono">{user.count.toLocaleString()}</div>
|
||||
<div className="text-[9px] text-muted-foreground uppercase font-bold tracking-wider">次请求</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {useMemo, useState} from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {toast} from "sonner"
|
||||
import {format} from "date-fns"
|
||||
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Label} from "@/components/ui/label"
|
||||
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle} from "@/components/ui/sheet"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||
|
||||
interface AccessLog {
|
||||
id: string
|
||||
user_id: string
|
||||
username: string
|
||||
nickname: string
|
||||
path: string
|
||||
method: string
|
||||
ip: string
|
||||
user_agent: string
|
||||
headers: string
|
||||
status: number
|
||||
latency: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 15
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return "-"
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return format(date, "yyyy-MM-dd HH:mm:ss")
|
||||
}
|
||||
|
||||
function formatLatency(ms: number) {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function statusVariant(status: number) {
|
||||
if (status >= 500) return "destructive"
|
||||
if (status >= 400) return "destructive"
|
||||
if (status >= 300) return "outline"
|
||||
return "secondary"
|
||||
}
|
||||
|
||||
function isClickhouseDisabledError(error: Error) {
|
||||
const errMsg = error.message || ""
|
||||
return errMsg.includes("ClickHouse") || errMsg.includes("未启用")
|
||||
}
|
||||
|
||||
export function AccessLogs() {
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// 搜索过滤条件
|
||||
const [usernameFilter, setUsernameFilter] = useState("")
|
||||
const [pathFilter, setPathFilter] = useState("")
|
||||
const [startTimeFilter, setStartTimeFilter] = useState("")
|
||||
const [endTimeFilter, setEndTimeFilter] = useState("")
|
||||
|
||||
// 实际提交的搜索条件
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
username: "",
|
||||
path: "",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
})
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ["admin", "access-logs", page, searchParams],
|
||||
queryFn: async () => {
|
||||
let startISO = ""
|
||||
if (searchParams.start_time) {
|
||||
startISO = new Date(searchParams.start_time).toISOString()
|
||||
}
|
||||
let endISO = ""
|
||||
if (searchParams.end_time) {
|
||||
endISO = new Date(searchParams.end_time).toISOString()
|
||||
}
|
||||
|
||||
return services.adminLog.getAccessLogs({
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
username: searchParams.username || undefined,
|
||||
path: searchParams.path || undefined,
|
||||
start_time: startISO || undefined,
|
||||
end_time: endISO || undefined,
|
||||
})
|
||||
},
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const logs = logsQuery.data?.list ?? []
|
||||
const total = logsQuery.data?.total ?? 0
|
||||
const loading = logsQuery.isPending || logsQuery.isFetching
|
||||
const clickhouseDisabled = logsQuery.isError && isClickhouseDisabledError(logsQuery.error)
|
||||
const error = logsQuery.isError && !clickhouseDisabled ? logsQuery.error : null
|
||||
|
||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(total / PAGE_SIZE)), [total])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
setSearchParams({
|
||||
username: usernameFilter.trim(),
|
||||
path: pathFilter.trim(),
|
||||
start_time: startTimeFilter,
|
||||
end_time: endTimeFilter,
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setUsernameFilter("")
|
||||
setPathFilter("")
|
||||
setStartTimeFilter("")
|
||||
setEndTimeFilter("")
|
||||
setPage(1)
|
||||
setSearchParams({
|
||||
username: "",
|
||||
path: "",
|
||||
start_time: "",
|
||||
end_time: "",
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string, subject: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(`${subject}已复制到剪贴板`)
|
||||
}
|
||||
|
||||
// Prettify Headers JSON string
|
||||
const getPrettyHeaders = (headersRaw?: string) => {
|
||||
if (!headersRaw) return "暂无头部数据"
|
||||
try {
|
||||
const parsed = JSON.parse(headersRaw)
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
} catch {
|
||||
return headersRaw
|
||||
}
|
||||
}
|
||||
|
||||
if (clickhouseDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-lg bg-card text-center my-6 min-h-[300px]">
|
||||
<XCircle className="size-10 text-muted-foreground mb-3" />
|
||||
<h3 className="text-base font-semibold">ClickHouse 未启用</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1 max-w-[400px] mb-4">
|
||||
当前系统配置未启用 ClickHouse 存储,系统不会收集用户访问日志。如需使用此功能,请在后端 `config.yaml` 配置文件中配置并启用 ClickHouse。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorInline error={error} onRetry={() => void logsQuery.refetch()} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
<form onSubmit={handleSearch} className="grid gap-3 p-4 border rounded-lg bg-card/30 md:grid-cols-5 items-end">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-user" className="text-xs">用户名</Label>
|
||||
<Input
|
||||
id="search-user"
|
||||
placeholder="输入用户名搜索..."
|
||||
value={usernameFilter}
|
||||
onChange={(e) => setUsernameFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-path" className="text-xs">接口路径</Label>
|
||||
<Input
|
||||
id="search-path"
|
||||
placeholder="模糊匹配路径..."
|
||||
value={pathFilter}
|
||||
onChange={(e) => setPathFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-start" className="text-xs">起始时间</Label>
|
||||
<Input
|
||||
id="search-start"
|
||||
type="datetime-local"
|
||||
value={startTimeFilter}
|
||||
onChange={(e) => setStartTimeFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="search-end" className="text-xs">结束时间</Label>
|
||||
<Input
|
||||
id="search-end"
|
||||
type="datetime-local"
|
||||
value={endTimeFilter}
|
||||
onChange={(e) => setEndTimeFilter(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="h-8 flex-1">
|
||||
<Search className="size-3.5 mr-1" />
|
||||
搜索
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={handleReset} className="h-8 flex-1">
|
||||
<RotateCcw className="size-3.5 mr-1" />
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Loading Table */}
|
||||
{loading && logs.length === 0 ? (
|
||||
<LoadingStateWithBorder icon={Activity} description="加载访问日志中..." />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Activity} description="未查询到任何访问日志数据" />
|
||||
) : (
|
||||
<div className="rounded-lg border bg-card overflow-hidden">
|
||||
<Table className="min-w-[900px]">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[100px]">请求方法</TableHead>
|
||||
<TableHead className="min-w-[200px]">路径</TableHead>
|
||||
<TableHead className="w-[150px]">用户</TableHead>
|
||||
<TableHead className="w-[120px]">IP</TableHead>
|
||||
<TableHead className="w-[90px]">状态</TableHead>
|
||||
<TableHead className="w-[100px]">耗时</TableHead>
|
||||
<TableHead className="w-[170px]">请求时间</TableHead>
|
||||
<TableHead className="w-[80px] text-center">详情</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs.map((log) => (
|
||||
<TableRow key={log.id} className="hover:bg-muted/30">
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="font-mono font-semibold uppercase text-xs">
|
||||
{log.method}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs break-all max-w-[280px]">
|
||||
{log.path}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-medium">{log.username || "未知用户"}</span>
|
||||
{log.nickname && (
|
||||
<span className="text-[10px] text-muted-foreground">({log.nickname})</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{log.ip}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(log.status)}>
|
||||
{log.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatLatency(log.latency)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground">
|
||||
{formatDateTime(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setSelectedLog(log)
|
||||
setDetailOpen(true)
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{logs.length > 0 && (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
共 {total} 条记录,当前第 {page}/{totalPages} 页
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
<ChevronLeft className="size-3.5 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(Math.min(totalPages, page + 1))}
|
||||
disabled={page >= totalPages || loading}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="size-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<Sheet open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<SheetContent className="w-full p-0 sm:max-w-[640px] flex flex-col h-full bg-background border-l">
|
||||
<SheetHeader className="border-b px-5 py-4 shrink-0">
|
||||
<SheetTitle className="text-lg font-bold">访问日志详情</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
访问 ID: {selectedLog?.id}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{selectedLog ? (
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{/* Summary Details */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">请求方法</div>
|
||||
<div className="mt-1 font-mono text-sm font-bold uppercase">{selectedLog.method}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">响应状态</div>
|
||||
<div className="mt-1">
|
||||
<Badge variant={statusVariant(selectedLog.status)}>
|
||||
{selectedLog.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">耗时</div>
|
||||
<div className="mt-1 font-mono text-sm">{formatLatency(selectedLog.latency)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10 col-span-2 sm:col-span-1">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">IP 地址</div>
|
||||
<div className="mt-1 font-mono text-sm">{selectedLog.ip}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 bg-muted/10 col-span-2">
|
||||
<div className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">用户</div>
|
||||
<div className="mt-1 text-sm font-medium">
|
||||
{selectedLog.username ? `${selectedLog.username} (${selectedLog.nickname || '无昵称'})` : "未知/游客"}
|
||||
<span className="block font-mono text-[10px] text-muted-foreground mt-0.5">ID: {selectedLog.user_id}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Path */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-bold text-muted-foreground">请求路径</Label>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 font-mono text-xs break-all">
|
||||
{selectedLog.path}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Time */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs font-bold text-muted-foreground">请求时间</Label>
|
||||
<div className="font-mono text-xs">{formatDateTime(selectedLog.created_at)}</div>
|
||||
</div>
|
||||
|
||||
{/* User Agent */}
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-bold text-muted-foreground">User-Agent</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(selectedLog.user_agent, "User-Agent")}
|
||||
title="复制 User-Agent"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 px-3 py-2 text-xs font-mono break-all max-h-24 overflow-y-auto leading-relaxed">
|
||||
{selectedLog.user_agent}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div className="grid gap-2 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-bold text-muted-foreground">Request Headers</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(getPrettyHeaders(selectedLog.headers), "Headers")}
|
||||
title="复制 Headers"
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="overflow-auto rounded-md border bg-[#0d1117] p-3 text-xs leading-relaxed text-gray-300 font-mono max-h-[300px]">
|
||||
{getPrettyHeaders(selectedLog.headers)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-grow flex items-center justify-center">
|
||||
<EmptyStateWithBorder icon={Activity} description="未选中日志明细" />
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
Copyright 2026 Arctel.net
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import {memo, useCallback, useEffect, useRef, useState} from "react"
|
||||
import {useVirtualizer} from "@tanstack/react-virtual"
|
||||
import {toast} from "sonner"
|
||||
import {ArrowDown, ChevronUp, Loader2} from "lucide-react"
|
||||
|
||||
import services from "@/lib/services"
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {Button} from "@/components/ui/button"
|
||||
|
||||
interface LogEntry {
|
||||
index: number
|
||||
data: string
|
||||
}
|
||||
|
||||
// Distance (px) from bottom to treat as "at bottom"
|
||||
const BOTTOM_THRESHOLD = 40
|
||||
const LOG_LINE_ESTIMATE_PX = 20
|
||||
const LOG_VIRTUAL_OVERSCAN = 24
|
||||
|
||||
const LogLine = memo(function LogLine({data}: {data: string}) {
|
||||
const level = parseLogLevel(data)
|
||||
const color = level === "error"
|
||||
? "text-red-400"
|
||||
: level === "warn"
|
||||
? "text-yellow-400"
|
||||
: level === "debug"
|
||||
? "text-gray-500"
|
||||
: "text-gray-300"
|
||||
|
||||
return (
|
||||
<div className={`${color} whitespace-pre-wrap break-all px-2 py-0.5 rounded hover:bg-white/5`}>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
// If NEXT_PUBLIC_WAVELET_BACKEND_URL is set, use it. Otherwise, use origin.
|
||||
const base = process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
if (base.startsWith("http")) return base
|
||||
|
||||
// Relative URL fallback
|
||||
const proto = window.location.protocol
|
||||
const host = window.location.host
|
||||
return `${proto}//${host}${base}`
|
||||
}
|
||||
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
|
||||
}
|
||||
|
||||
function buildWsUrl(): string {
|
||||
const base = getApiBaseUrl()
|
||||
const wsBase = base.replace(/^http/, "ws")
|
||||
return `${wsBase}/api/v1/admin/logs/ws`
|
||||
}
|
||||
|
||||
function parseLogLevel(line: string): "debug" | "info" | "warn" | "error" | "unknown" {
|
||||
const lower = line.toLowerCase()
|
||||
if (lower.includes("\"level\":\"error\"") || lower.includes("level=error")) return "error"
|
||||
if (lower.includes("\"level\":\"warn\"") || lower.includes("level=warn")) return "warn"
|
||||
if (lower.includes("\"level\":\"debug\"") || lower.includes("level=debug")) return "debug"
|
||||
if (lower.includes("\"level\":\"info\"") || lower.includes("level=info")) return "info"
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
export function AppLogs() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [nextCursor, setNextCursor] = useState(0)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
// autoScroll = true → new logs auto-scroll to bottom
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const autoScrollRef = useRef(autoScroll)
|
||||
const isUserScrolling = useRef(false)
|
||||
|
||||
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: logs.length,
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => LOG_LINE_ESTIMATE_PX,
|
||||
overscan: LOG_VIRTUAL_OVERSCAN,
|
||||
measureElement: (element) => element.getBoundingClientRect().height,
|
||||
})
|
||||
|
||||
// ---- Scroll detection ------------------------------------------------
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
if (!isUserScrolling.current) return
|
||||
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
|
||||
if (atBottom && !autoScrollRef.current) {
|
||||
setAutoScroll(true)
|
||||
} else if (!atBottom && autoScrollRef.current) {
|
||||
setAutoScroll(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleWheel = useCallback(() => { isUserScrolling.current = true }, [])
|
||||
const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const onScrollEnd = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => { isUserScrolling.current = false }, 150)
|
||||
}
|
||||
el.addEventListener("scroll", onScrollEnd, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener("scroll", onScrollEnd)
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ---- Auto-scroll to bottom when new logs arrive ----------------------
|
||||
useEffect(() => {
|
||||
if (!autoScroll || !containerRef.current) return
|
||||
isUserScrolling.current = false
|
||||
const el = containerRef.current
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}, [logs, autoScroll])
|
||||
|
||||
// ---- Data fetching ---------------------------------------------------
|
||||
const fetchLogs = useCallback(async (cursor: number = 0) => {
|
||||
try {
|
||||
return await services.adminLog.getLogs(cursor)
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error("获取日志失败")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadHistory = useCallback(async (cursor: number = 0) => {
|
||||
const isInitial = cursor === 0
|
||||
if (isInitial) {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setLoadingMore(true)
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchLogs(cursor)
|
||||
if (isInitial) {
|
||||
setLogs(data.lines || [])
|
||||
} else {
|
||||
const el = containerRef.current
|
||||
const previousScrollHeight = el?.scrollHeight ?? 0
|
||||
setLogs(prev => [...(data.lines || []), ...prev])
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return
|
||||
isUserScrolling.current = false
|
||||
el.scrollTop = el.scrollHeight - previousScrollHeight
|
||||
})
|
||||
}
|
||||
setHasMore(data.has_more)
|
||||
setNextCursor(data.next_cursor)
|
||||
} catch (err) {
|
||||
if (isInitial) {
|
||||
setError(err instanceof Error ? err : new Error("获取日志失败"))
|
||||
} else {
|
||||
toast.error("加载更早日志失败")
|
||||
}
|
||||
} finally {
|
||||
if (isInitial) setLoading(false)
|
||||
else setLoadingMore(false)
|
||||
}
|
||||
}, [fetchLogs])
|
||||
|
||||
// ---- WebSocket -------------------------------------------------------
|
||||
const connectWs = useCallback(() => {
|
||||
if (wsRef.current) wsRef.current.close()
|
||||
|
||||
const ws = new WebSocket(buildWsUrl())
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === "log" && msg.data) {
|
||||
const entry: LogEntry = msg.data
|
||||
setLogs(prev => {
|
||||
const next = [...prev, entry]
|
||||
return next.length > 2000 ? next.slice(-2000) : next
|
||||
})
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
ws.onclose = () => { wsRef.current = null }
|
||||
}, [])
|
||||
|
||||
// ---- Initialize ------------------------------------------------------
|
||||
useEffect(() => {
|
||||
loadHistory(0).then(() => connectWs())
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ---- Actions ---------------------------------------------------------
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setAutoScroll(true)
|
||||
requestAnimationFrame(() => {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (nextCursor > 0) loadHistory(nextCursor)
|
||||
}, [nextCursor, loadHistory])
|
||||
|
||||
// ---- Render ----------------------------------------------------------
|
||||
if (loading) return <LoadingStateWithBorder />
|
||||
if (error) return <ErrorInline error={error} onRetry={() => loadHistory(0)} />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full relative">
|
||||
{/* Log viewer — fixed height, scrollable */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
className="h-[calc(100vh-270px)] overflow-y-auto overflow-x-hidden rounded-lg border border-border/40 bg-[#0d1117] font-mono text-[13px] leading-5 relative"
|
||||
>
|
||||
{/* Load older logs */}
|
||||
{hasMore && (
|
||||
<div className="sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="text-muted-foreground hover:text-foreground h-7 text-xs"
|
||||
>
|
||||
{loadingMore
|
||||
? <><Loader2 className="size-3 mr-1.5 animate-spin" />加载中...</>
|
||||
: <><ChevronUp className="size-3 mr-1.5" />加载更早日志</>
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-gray-500">暂无日志</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative px-3 py-3"
|
||||
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const entry = logs[virtualRow.index]
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.index}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
style={{transform: `translateY(${virtualRow.start}px)`}}
|
||||
>
|
||||
<LogLine data={entry.data} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Floating "back to latest" button */}
|
||||
{!autoScroll && (
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={scrollToBottom}
|
||||
className="shadow-lg bg-background/80 backdrop-blur-sm border-border/50"
|
||||
>
|
||||
<ArrowDown className="size-3.5 mr-1.5" />
|
||||
回到最新
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,17 +10,17 @@ const tabFallback = (
|
||||
)
|
||||
|
||||
const AccessAnalytics = dynamic(
|
||||
() => import("@/components/common/admin/access-analytics").then((mod) => mod.AccessAnalytics),
|
||||
() => import("./components/access-analytics").then((mod) => mod.AccessAnalytics),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const AccessLogs = dynamic(
|
||||
() => import("@/components/common/admin/access-logs").then((mod) => mod.AccessLogs),
|
||||
() => import("./components/access-logs").then((mod) => mod.AccessLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const AppLogs = dynamic(
|
||||
() => import("@/components/common/admin/app-logs").then((mod) => mod.AppLogs),
|
||||
() => import("./components/app-logs").then((mod) => mod.AppLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user