mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
refactor(repo): replace legacy openflare-server with Wavelet rename
Delete the old monolithic openflare-server implementation and rename Wavelet/ to openflare-server/ to complete the migration consolidation. Update CI workflows, agent Dockerfiles, and deployment docs for the new layout (frontend/, docker/Dockerfile).
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import dynamic from "next/dynamic"
|
||||
import {Activity, BarChart3, Terminal} from "lucide-react"
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
|
||||
|
||||
const tabFallback = (
|
||||
<div className="h-64 animate-pulse rounded-lg border border-border/40 bg-muted/20" />
|
||||
)
|
||||
|
||||
const AccessAnalytics = dynamic(
|
||||
() => import("./components/access-analytics").then((mod) => mod.AccessAnalytics),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const AccessLogs = dynamic(
|
||||
() => import("./components/access-logs").then((mod) => mod.AccessLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
const AppLogs = dynamic(
|
||||
() => import("./components/app-logs").then((mod) => mod.AppLogs),
|
||||
{ loading: () => tabFallback },
|
||||
)
|
||||
|
||||
export function LogsPageClient() {
|
||||
return (
|
||||
<div className="flex flex-col h-full space-y-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">系统日志</h1>
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs Layout */}
|
||||
<Tabs defaultValue="analytics" className="w-full">
|
||||
<TabsList variant="line" className="w-fit inline-flex gap-8 mb-6">
|
||||
<TabsTrigger value="analytics" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
访问分析
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="access" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Activity className="size-3.5" />
|
||||
用户访问日志
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="app" className="px-0 pb-2 text-xs font-semibold flex items-center gap-1.5">
|
||||
<Terminal className="size-3.5" />
|
||||
应用运行日志
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analytics" className="mt-0 outline-none flex-1">
|
||||
<AccessAnalytics />
|
||||
</TabsContent>
|
||||
<TabsContent value="access" className="mt-0 outline-none flex-1">
|
||||
<AccessLogs />
|
||||
</TabsContent>
|
||||
<TabsContent value="app" className="mt-0 outline-none flex-1">
|
||||
<AppLogs />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
import {LogsPageClient} from "./page-client"
|
||||
|
||||
export default function LogsPage() {
|
||||
return <LogsPageClient />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {BaseService} from '@/lib/services/core';
|
||||
import type {AuthSource, AuthSourceRequest, ToggleAuthSourceRequest} from './types';
|
||||
|
||||
export class AdminAuthSourceService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin';
|
||||
|
||||
static async listAuthSources(): Promise<AuthSource[]> {
|
||||
return this.get<AuthSource[]>('/auth-sources');
|
||||
}
|
||||
|
||||
static async createAuthSource(request: AuthSourceRequest): Promise<AuthSource> {
|
||||
return this.post<AuthSource>('/auth-sources', request);
|
||||
}
|
||||
|
||||
static async updateAuthSource(id: string, request: AuthSourceRequest): Promise<AuthSource> {
|
||||
return this.put<AuthSource>(`/auth-sources/${id}`, request);
|
||||
}
|
||||
|
||||
static async toggleAuthSource(id: string, request: ToggleAuthSourceRequest): Promise<void> {
|
||||
return this.put<void>(`/auth-sources/${id}/toggle`, request);
|
||||
}
|
||||
|
||||
static async deleteAuthSource(id: string): Promise<void> {
|
||||
return this.delete<void>(`/auth-sources/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type {InternalAxiosRequestConfig} from 'axios';
|
||||
|
||||
import {BaseService} from '../core/base.service';
|
||||
import type {FileStatsResponse, ListUploadsResponse} from './types';
|
||||
|
||||
export class AdminUploadService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/admin/uploads';
|
||||
|
||||
static async listUploads(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
keyword?: string,
|
||||
type?: string,
|
||||
extension?: string,
|
||||
): Promise<ListUploadsResponse> {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (type) params.type = type;
|
||||
if (extension) params.extension = extension;
|
||||
return this.get<ListUploadsResponse>('', params);
|
||||
}
|
||||
|
||||
static async getFileStats(): Promise<FileStatsResponse> {
|
||||
return this.get<FileStatsResponse>('/stats');
|
||||
}
|
||||
|
||||
static async deleteFile(id: string): Promise<void> {
|
||||
return this.delete<void>(`/${id}`);
|
||||
}
|
||||
|
||||
static getDownloadUrl(id: string): string {
|
||||
return `${this.basePath}/download/${id}`;
|
||||
}
|
||||
|
||||
static async batchDownload(ids: string[]): Promise<Blob> {
|
||||
return this.post<Blob>('/download/batch', { ids }, {
|
||||
responseType: 'blob',
|
||||
} as InternalAxiosRequestConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { UploadService } from './upload.service';
|
||||
export { AdminUploadService } from './admin-upload.service';
|
||||
export { formatFileSize, getFileUrl } from './utils';
|
||||
export type { ImageQuality } from './utils';
|
||||
export type { UploadImageResponse, Upload, ListUploadsResponse, FileStatsResponse } from './types';
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 文件上传元数据
|
||||
*/
|
||||
export interface UploadMetadata {
|
||||
width?: number
|
||||
height?: number
|
||||
duration?: number
|
||||
original_mime?: string
|
||||
user_agent?: string
|
||||
client_ip?: string
|
||||
bucket?: string
|
||||
extra?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传记录
|
||||
*/
|
||||
export interface Upload {
|
||||
id: string
|
||||
user_id: string
|
||||
file_name: string
|
||||
file_path: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
extension: string
|
||||
hash: string
|
||||
type: string
|
||||
status: string
|
||||
access_mode: number
|
||||
metadata: UploadMetadata
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传接口响应(原 UploadImageResponse 兼容)
|
||||
*/
|
||||
export interface UploadImageResponse {
|
||||
/** 上传记录 ID */
|
||||
id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表查询参数
|
||||
*/
|
||||
export interface ListUploadsQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
type?: string
|
||||
extension?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表分页响应
|
||||
*/
|
||||
export interface ListUploadsResponse {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
items: Upload[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 近 7 天新增文件趋势项
|
||||
*/
|
||||
export interface TrendItem {
|
||||
date: string
|
||||
count: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件分类/类型统计项
|
||||
*/
|
||||
export interface DistributionItem {
|
||||
name: string
|
||||
count: number
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件统计信息响应
|
||||
*/
|
||||
export interface FileStatsResponse {
|
||||
total_count: number
|
||||
total_size: number
|
||||
trend: TrendItem[]
|
||||
categories: DistributionItem[]
|
||||
types: DistributionItem[]
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type {InternalAxiosRequestConfig} from 'axios';
|
||||
|
||||
import {BaseService} from '../core/base.service';
|
||||
import type {ListUploadsResponse, Upload, UploadImageResponse} from './types';
|
||||
|
||||
export class UploadService extends BaseService {
|
||||
protected static readonly basePath = '/api/v1/upload';
|
||||
|
||||
static async uploadFile(
|
||||
file: File,
|
||||
type: string = 'generic',
|
||||
metadata?: Record<string, unknown>,
|
||||
accessMode?: number,
|
||||
): Promise<Upload> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', type);
|
||||
if (metadata) {
|
||||
formData.append('metadata', JSON.stringify(metadata));
|
||||
}
|
||||
if (accessMode !== undefined) {
|
||||
formData.append('access_mode', String(accessMode));
|
||||
}
|
||||
|
||||
return this.post<Upload>('', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
} as InternalAxiosRequestConfig);
|
||||
}
|
||||
|
||||
static async uploadBase64Image(
|
||||
base64: string,
|
||||
type: string = 'generic',
|
||||
filename: string = 'image.png',
|
||||
accessMode?: number,
|
||||
): Promise<UploadImageResponse> {
|
||||
const response = await fetch(base64);
|
||||
const blob = await response.blob();
|
||||
const mimeType = base64.match(/data:([^;]+);/)?.[1] || 'image/png';
|
||||
const file = new File([blob], filename, { type: mimeType });
|
||||
const result = await this.uploadFile(file, type, undefined, accessMode);
|
||||
return { id: result.id };
|
||||
}
|
||||
|
||||
static async listMyUploads(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
keyword?: string,
|
||||
type?: string,
|
||||
extension?: string,
|
||||
): Promise<ListUploadsResponse> {
|
||||
const params: Record<string, string | number> = { page, page_size: pageSize };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (type) params.type = type;
|
||||
if (extension) params.extension = extension;
|
||||
return this.get<ListUploadsResponse>('/my', params);
|
||||
}
|
||||
|
||||
static async deleteMyFile(id: string): Promise<void> {
|
||||
return this.delete<void>(`/${id}`);
|
||||
}
|
||||
|
||||
static async updateMyFile(id: string, fileName: string, accessMode?: number): Promise<Upload> {
|
||||
return this.put<Upload>(`/${id}`, {
|
||||
file_name: fileName,
|
||||
access_mode: accessMode,
|
||||
});
|
||||
}
|
||||
|
||||
static async batchDownloadMyFiles(ids: string[]): Promise<Blob> {
|
||||
return this.post<Blob>('/download/batch', { ids }, {
|
||||
responseType: 'blob',
|
||||
} as InternalAxiosRequestConfig);
|
||||
}
|
||||
|
||||
static getDownloadUrl(id: string): string {
|
||||
return `${this.basePath}/download/${id}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type ImageQuality = 'low' | 'medium' | 'high' | 'origin';
|
||||
|
||||
export function getFileUrl(
|
||||
id: string | number | null | undefined,
|
||||
quality: ImageQuality = 'origin',
|
||||
): string | null {
|
||||
if (!id) return null;
|
||||
if (quality === 'origin') return `/f/${id}`;
|
||||
return `/f/${id}?quality=${quality}`;
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
Reference in New Issue
Block a user