clickhouse 日志采集

This commit is contained in:
ryan
2026-06-09 10:01:03 +08:00
parent b41457553d
commit 61bc569bd8
14 changed files with 2102 additions and 403 deletions
@@ -0,0 +1,318 @@
/*
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 {AdminService} 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 AdminService.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: any) {
const errMsg = err?.message || ""
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
setClickhouseDisabled(true)
} else {
setError(err instanceof Error ? err : new Error("获取数据统计失败"))
}
} 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, index) => (
<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,467 @@
/*
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 {toast} from "sonner"
import {format} from "date-fns"
import {Activity, ChevronLeft, ChevronRight, Copy, Eye, RotateCcw, Search, XCircle} from "lucide-react"
import {AdminService} 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
}
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"
}
export function AccessLogs() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
const [logs, setLogs] = useState<AccessLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const pageSize = 15
// 搜索过滤条件
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 fetchAccessLogs = useCallback(async (currentPage: number = page) => {
try {
setLoading(true)
setError(null)
// 转换起止时间格式
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()
}
const data = await AdminService.getAccessLogs({
page: currentPage,
page_size: pageSize,
username: searchParams.username || undefined,
path: searchParams.path || undefined,
start_time: startISO || undefined,
end_time: endISO || undefined,
})
setLogs(data.list || [])
setTotal(data.total || 0)
setClickhouseDisabled(false)
} catch (err: any) {
const errMsg = err?.message || ""
if (errMsg.includes("ClickHouse") || errMsg.includes("未启用")) {
setClickhouseDisabled(true)
} else {
setError(err instanceof Error ? err : new Error("获取访问日志失败"))
}
} finally {
setLoading(false)
}
}, [page, searchParams])
useEffect(() => {
fetchAccessLogs()
}, [fetchAccessLogs])
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)
fetchAccessLogs(newPage)
}
const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text)
toast.success(`${subject}已复制到剪贴板`)
}
const totalPages = Math.max(1, Math.ceil(total / pageSize))
// 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={() => fetchAccessLogs(1)} />
}
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,335 @@
/*
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, useRef, useState} from "react"
import {toast} from "sonner"
import {ArrowDown, ChevronUp, Loader2, Pause, Play} from "lucide-react"
import {AdminService} from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
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
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)
const [connected, setConnected] = useState(false)
const [paused, setPaused] = 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 pausedRef = useRef(paused)
const autoScrollRef = useRef(autoScroll)
const isUserScrolling = useRef(false)
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
// ---- 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 AdminService.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 {
setLogs(prev => [...(data.lines || []), ...prev])
requestAnimationFrame(() => {
const el = containerRef.current
if (!el) return
const newCount = (data.lines || []).length
const lineH = 20
isUserScrolling.current = false
el.scrollTop = el.scrollTop + newCount * lineH
})
}
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.onopen = () => { setConnected(true) }
ws.onmessage = (event) => {
if (pausedRef.current) return
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 = () => { setConnected(false); wsRef.current = null }
ws.onerror = () => { setConnected(false) }
}, [])
// ---- 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 togglePause = useCallback(() => setPaused(p => !p), [])
const reconnect = useCallback(() => connectWs(), [connectWs])
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">
{/* Sub Header / Control Bar */}
<div className="flex items-center justify-between pb-3 border-b border-border/40 mb-3">
<div className="text-sm text-muted-foreground font-medium">
系统后台实时输出的运行日志 (最多缓存 2000 行)
</div>
<div className="flex items-center gap-2">
<Badge variant={connected ? "secondary" : "destructive"} className="h-6">
{connected ? "已连接" : "断开连接"}
</Badge>
{connected && (
<Button variant="outline" size="sm" onClick={togglePause} className="h-8">
{paused
? <><Play className="size-3.5 mr-1.5" />恢复</>
: <><Pause className="size-3.5 mr-1.5" />暂停</>
}
</Button>
)}
{!connected && (
<Button variant="outline" size="sm" onClick={reconnect} className="h-8">
重新连接
</Button>
)}
</div>
</div>
{/* 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-md border border-border/50 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>
)}
{/* Log lines */}
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="text-center text-gray-500 py-8">暂无日志</div>
) : (
logs.map((entry) => {
const level = parseLogLevel(entry.data)
const color = level === "error"
? "text-red-400"
: level === "warn"
? "text-yellow-400"
: level === "debug"
? "text-gray-500"
: "text-gray-300"
return (
<div
key={entry.index}
className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}
>
{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>
)
}
+53 -318
View File
@@ -1,334 +1,69 @@
/*
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, useRef, useState} from "react"
import {toast} from "sonner"
import {ArrowDown, ChevronUp, Loader2, Pause, Play, Terminal} from "lucide-react"
import {Activity, BarChart3, Terminal} from "lucide-react"
import {AdminService} from "@/lib/services"
import {ErrorInline} from "@/components/layout/error"
import {LoadingStateWithBorder} from "@/components/layout/loading"
import {Badge} from "@/components/ui/badge"
import {Button} from "@/components/ui/button"
interface LogEntry {
index: number
data: string
}
function getApiBaseUrl(): string {
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"
}
// Distance (px) from bottom to treat as "at bottom" — keeps auto-scroll
// active even when the user's last row is a few pixels shy of the edge.
const BOTTOM_THRESHOLD = 40
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {AppLogs} from "./app-logs"
import {AccessLogs} from "./access-logs"
import {AccessAnalytics} from "./access-analytics"
export function SystemLogs() {
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)
const [connected, setConnected] = useState(false)
const [paused, setPaused] = useState(false)
// autoScroll = true → new logs auto-scroll to bottom
// autoScroll = false → user is browsing history, lock scroll position
const [autoScroll, setAutoScroll] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const pausedRef = useRef(paused)
const autoScrollRef = useRef(autoScroll)
const isUserScrolling = useRef(false)
// Keep refs in sync for use inside callbacks without stale closures
useEffect(() => { pausedRef.current = paused }, [paused])
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
// ---- Scroll detection ------------------------------------------------
// We distinguish programmatic scrolls (triggered by our own auto-scroll or
// load-history offset fixup) from user-initiated scrolls by gating with
// the `isUserScrolling` flag. Only a user scroll can toggle `autoScroll`.
const handleScroll = useCallback(() => {
const el = containerRef.current
if (!el) return
// Ignore programmatic scrolls
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)
}
}, [])
// Mark user-initiated scrolls
const handleWheel = useCallback(() => { isUserScrolling.current = true }, [])
// Touch devices
const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, [])
// After the user stops scrolling, reset the flag so our programmatic
// scrolls won't accidentally toggle autoScroll.
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 AdminService.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 {
// Prepend older logs; keep the viewport showing the same content
// by restoring the scroll offset after the DOM update.
setLogs(prev => [...(data.lines || []), ...prev])
// Wait for React to render, then compensate scroll position
requestAnimationFrame(() => {
const el = containerRef.current
if (!el) return
// The number of new rows prepended × approximate line height
const newCount = (data.lines || []).length
const lineH = 20 // matches leading-5 ≈ 20px
isUserScrolling.current = false
el.scrollTop = el.scrollTop + newCount * lineH
})
}
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.onopen = () => { setConnected(true) }
ws.onmessage = (event) => {
if (pausedRef.current) return
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 = () => { setConnected(false); wsRef.current = null }
ws.onerror = () => { setConnected(false) }
}, [])
// ---- 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 togglePause = useCallback(() => setPaused(p => !p), [])
const reconnect = useCallback(() => connectWs(), [connectWs])
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">
<div className="flex flex-col h-full space-y-4 py-6">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b border-border/50">
<div className="flex items-center justify-between pb-4 border-b border-border/50 shrink-0">
<div className="flex items-center gap-2">
<Terminal className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">系统日志</h1>
</div>
<div className="flex items-center gap-2">
<Badge variant={connected ? "secondary" : "destructive"}>
{connected ? "已连接" : "未连接"}
</Badge>
{connected && (
<Button variant="outline" size="sm" onClick={togglePause}>
{paused
? <><Play className="size-3.5 mr-1.5" />恢复</>
: <><Pause className="size-3.5 mr-1.5" />暂停</>
}
</Button>
)}
{!connected && (
<Button variant="outline" size="sm" onClick={reconnect}>
重连
</Button>
)}
</div>
</div>
{/* Log viewer — fixed height, scrollable */}
<div
ref={containerRef}
onScroll={handleScroll}
onWheel={handleWheel}
onTouchStart={handleTouchStart}
className="mt-3 h-[calc(100vh-220px)] overflow-y-auto overflow-x-hidden rounded-md border border-border/50 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>
<h1 className="text-2xl font-semibold tracking-tight">系统日志</h1>
</div>
)}
{/* Log lines */}
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="text-center text-gray-500 py-8">暂无日志</div>
) : (
logs.map((entry) => {
const level = parseLogLevel(entry.data)
const color = level === "error"
? "text-red-400"
: level === "warn"
? "text-yellow-400"
: level === "debug"
? "text-gray-500"
: "text-gray-300"
return (
<div
key={entry.index}
className={`${color} whitespace-pre-wrap break-all hover:bg-white/5`}
>
{entry.data}
</div>
)
})
)}
</div>
</div>
{/* Floating "back to latest" button */}
{!autoScroll && (
<div className="absolute bottom-24 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"
>
<ArrowDown className="size-3.5 mr-1.5" />
回到最新
</Button>
</div>
)}
{/* Tabs Layout */}
<Tabs defaultValue="analytics" className="w-full flex-grow flex flex-col gap-2">
<TabsList className="mb-2 w-full sm:w-auto">
<TabsTrigger value="analytics" className="flex items-center gap-1.5 px-4">
<BarChart3 className="size-3.5" />
访问分析
</TabsTrigger>
<TabsTrigger value="access" className="flex items-center gap-1.5 px-4">
<Activity className="size-3.5" />
用户访问日志 (ClickHouse)
</TabsTrigger>
<TabsTrigger value="app" className="flex items-center gap-1.5 px-4">
<Terminal className="size-3.5" />
应用运行日志 (Console)
</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>
)
}