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:
ryan
2026-06-19 11:37:34 +08:00
parent aa11c0f52a
commit d78449cbc9
50 changed files with 7965 additions and 5 deletions
@@ -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]}`;
}