This commit is contained in:
ryan
2026-07-13 15:17:45 +08:00
parent 08e61ce833
commit e0eb4e5975
334 changed files with 17212 additions and 12321 deletions
@@ -14,209 +14,282 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
'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 { 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"
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
date: string;
count: number;
}
interface BrowserData {
browser: string
count: number
browser: string;
count: number;
}
interface TopUserData {
user_id: string
username: string
nickname: string
count: number
user_id: string;
username: string;
nickname: string;
count: number;
}
const chartConfig = {
count: {
label: "请求量",
color: "hsl(var(--primary))",
label: '请求量',
color: 'hsl(var(--primary))',
},
} satisfies ChartConfig
} 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 (!dateStr || dateStr.length < 10) return dateStr;
const parts = dateStr.split('-');
if (parts.length === 3) {
return `${parts[1]}/${parts[2]}`
return `${parts[1]}/${parts[2]}`;
}
return dateStr
return dateStr;
}
export function AccessAnalytics() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [clickhouseDisabled, setClickhouseDisabled] = useState(false)
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 [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()
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 => ({
const formattedTrend = (data.trend || []).map((item) => ({
...item,
formattedDate: formatDateLabel(item.date)
}))
formattedDate: formatDateLabel(item.date),
}));
setTrend(formattedTrend)
setBrowsers(data.browsers || [])
setTopUsers(data.top_users || [])
setClickhouseDisabled(false)
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)
const errorInstance =
err instanceof Error ? err : new Error('获取数据统计失败');
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
setClickhouseDisabled(true);
} else {
setError(errorInstance)
setError(errorInstance);
}
} finally {
setLoading(false)
setLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchAnalytics()
}, [fetchAnalytics])
fetchAnalytics();
}, [fetchAnalytics]);
const totalBrowserRequests = browsers.reduce((sum, item) => sum + item.count, 0)
const totalTrendRequests = trend.reduce((sum, item) => sum + item.count, 0)
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。
<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} />
return <ErrorInline error={error} onRetry={fetchAnalytics} />;
}
if (loading) {
return <LoadingStateWithBorder icon={BarChart3} description="加载访问统计指标中..." />
return (
<LoadingStateWithBorder
icon={BarChart3}
description='加载访问统计指标中...'
/>
);
}
return (
<div className="space-y-6">
<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" />
<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>
<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" />
<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>
<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" />
<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>
<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">
<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">
<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
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">
<CardContent className='pl-2 pr-4 pt-2'>
{trend.length === 0 ? (
<EmptyStateWithBorder icon={TrendingUp} description="暂无趋势数据" />
<EmptyStateWithBorder
icon={TrendingUp}
description='暂无趋势数据'
/>
) : (
<div className="h-[280px] w-full">
<ChartContainer config={chartConfig} className="w-full h-full aspect-auto">
<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 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} />
<CartesianGrid strokeDasharray='3 3' vertical={false} />
<XAxis
dataKey="formattedDate"
dataKey='formattedDate'
tickLine={false}
axisLine={false}
dy={10}
className="font-mono"
className='font-mono'
/>
<YAxis
tickLine={false}
axisLine={false}
dx={-10}
className="font-mono"
className='font-mono'
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type="monotone"
dataKey="count"
stroke="var(--color-count)"
type='monotone'
dataKey='count'
stroke='var(--color-count)'
strokeWidth={2}
fillOpacity={1}
fill="url(#colorCount)"
name="请求次数"
fill='url(#colorCount)'
name='请求次数'
/>
</AreaChart>
</ChartContainer>
@@ -226,46 +299,56 @@ export function AccessAnalytics() {
</Card>
{/* Two Columns for Ranking Statistics */}
<div className="grid gap-6 md:grid-cols-2">
<div className='grid gap-6 md:grid-cols-2'>
{/* Browser Rankings */}
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
<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 className='text-base font-bold flex items-center gap-1.5'>
<Globe className='size-4.5 text-muted-foreground' />
使用的浏览器排行
</CardTitle>
<CardDescription className="text-xs">
<CardDescription className='text-xs'>
基于请求头 User-Agent 智能分类的一周占比统计
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<CardContent className='flex-1'>
{browsers.length === 0 ? (
<EmptyStateWithBorder icon={Globe} description="暂无浏览器排行数据" />
<EmptyStateWithBorder
icon={Globe}
description='暂无浏览器排行数据'
/>
) : (
<div className="space-y-4">
<div className='space-y-4'>
{browsers.map((item, index) => {
const percent = totalBrowserRequests > 0 ? (item.count / totalBrowserRequests) * 100 : 0
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">
<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 className='font-semibold'>{item.browser}</span>
</span>
<span className="text-muted-foreground font-mono text-xs">
{item.count.toLocaleString()} 次 ({percent.toFixed(1)}%)
<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-2 w-full rounded-full bg-muted overflow-hidden'>
<div
className="h-full bg-primary rounded-full transition-all duration-500"
className='h-full bg-primary rounded-full transition-all duration-500'
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
);
})}
</div>
)}
@@ -273,39 +356,50 @@ export function AccessAnalytics() {
</Card>
{/* Top Users */}
<Card className="bg-card/20 border-border/40 flex flex-col h-full">
<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" />
<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 className='text-xs'>
统计最近一周发起接口访问请求数量最多的账户
</CardDescription>
</CardHeader>
<CardContent className="flex-1">
<CardContent className='flex-1'>
{topUsers.length === 0 ? (
<EmptyStateWithBorder icon={Users} description="暂无活跃用户数据" />
<EmptyStateWithBorder
icon={Users}
description='暂无活跃用户数据'
/>
) : (
<div className="divide-y divide-border/40">
<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
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 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 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 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>
))}
@@ -315,5 +409,5 @@ export function AccessAnalytics() {
</Card>
</div>
</div>
)
);
}
@@ -14,441 +14,540 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
'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 { 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"
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
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
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")
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`
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"
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("未启用")
const errMsg = error.message || '';
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
}
export function AccessLogs() {
const [page, setPage] = useState(1)
const [page, setPage] = useState(1);
// 搜索过滤条件
const [usernameFilter, setUsernameFilter] = useState("")
const [pathFilter, setPathFilter] = useState("")
const [startTimeFilter, setStartTimeFilter] = useState("")
const [endTimeFilter, setEndTimeFilter] = useState("")
// 搜索过滤条件
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 [searchParams, setSearchParams] = useState({
username: '',
path: '',
start_time: '',
end_time: '',
});
const [selectedLog, setSelectedLog] = useState<AccessLog | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
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()
}
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,
})
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 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 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 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 handleReset = () => {
setUsernameFilter('');
setPathFilter('');
setStartTimeFilter('');
setEndTimeFilter('');
setPage(1);
setSearchParams({
username: '',
path: '',
start_time: '',
end_time: '',
});
};
const handlePageChange = (newPage: number) => {
setPage(newPage)
}
const handlePageChange = (newPage: number) => {
setPage(newPage);
};
const copyToClipboard = (text: string, subject: string) => {
navigator.clipboard.writeText(text)
toast.success(`${subject}已复制到剪贴板`)
}
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
}
}
// 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 (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()} />
}
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>
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>
)}
{/* 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>
)}
{/* 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>
{/* 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>
{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>
{/* 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>
{/* 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>
{/* 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>
)
}
{/* 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>
);
}
@@ -14,92 +14,103 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
"use client"
'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 { 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"
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
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 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"
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`}>
<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 (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
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}`
const proto = window.location.protocol;
const host = window.location.host;
return `${proto}//${host}${base}`;
}
return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ""
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`
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"
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 [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 [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 [autoScroll, setAutoScroll] = useState(true);
const containerRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const autoScrollRef = useRef(autoScroll)
const isUserScrolling = useRef(false)
const containerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const autoScrollRef = useRef(autoScroll);
const isUserScrolling = useRef(false);
useEffect(() => { autoScrollRef.current = autoScroll }, [autoScroll])
useEffect(() => {
autoScrollRef.current = autoScroll;
}, [autoScroll]);
const rowVirtualizer = useVirtualizer({
count: logs.length,
@@ -107,200 +118,222 @@ export function AppLogs() {
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
const el = containerRef.current;
if (!el) return;
if (!isUserScrolling.current) return
if (!isUserScrolling.current) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD
const atBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < BOTTOM_THRESHOLD;
if (atBottom && !autoScrollRef.current) {
setAutoScroll(true)
setAutoScroll(true);
} else if (!atBottom && autoScrollRef.current) {
setAutoScroll(false)
setAutoScroll(false);
}
}, [])
}, []);
const handleWheel = useCallback(() => { isUserScrolling.current = true }, [])
const handleTouchStart = useCallback(() => { isUserScrolling.current = true }, [])
const handleWheel = useCallback(() => {
isUserScrolling.current = true;
}, []);
const handleTouchStart = useCallback(() => {
isUserScrolling.current = true;
}, []);
useEffect(() => {
const el = containerRef.current
if (!el) return
const el = containerRef.current;
if (!el) return;
let timer: ReturnType<typeof setTimeout>
let timer: ReturnType<typeof setTimeout>;
const onScrollEnd = () => {
clearTimeout(timer)
timer = setTimeout(() => { isUserScrolling.current = false }, 150)
}
el.addEventListener("scroll", onScrollEnd, { passive: true })
clearTimeout(timer);
timer = setTimeout(() => {
isUserScrolling.current = false;
}, 150);
};
el.addEventListener('scroll', onScrollEnd, { passive: true });
return () => {
el.removeEventListener("scroll", onScrollEnd)
clearTimeout(timer)
}
}, [])
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
if (!autoScroll || !containerRef.current) return;
isUserScrolling.current = false;
const el = containerRef.current;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight
})
}, [logs, autoScroll])
el.scrollTop = el.scrollHeight;
});
}, [logs, autoScroll]);
// ---- Data fetching ---------------------------------------------------
const fetchLogs = useCallback(async (cursor: number = 0) => {
try {
return await services.adminLog.getLogs(cursor)
return await services.adminLog.getLogs(cursor);
} catch (err) {
throw err instanceof Error ? err : new Error("获取日志失败")
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)
const loadHistory = useCallback(
async (cursor: number = 0) => {
const isInitial = cursor === 0;
if (isInitial) {
setLogs(data.lines || [])
setLoading(true);
setError(null);
} else {
const el = containerRef.current
const previousScrollHeight = el?.scrollHeight ?? 0
setLogs(prev => [...(data.lines || []), ...prev])
setLoadingMore(true);
}
requestAnimationFrame(() => {
if (!el) return
isUserScrolling.current = false
el.scrollTop = el.scrollHeight - previousScrollHeight
})
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);
}
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])
},
[fetchLogs],
);
// ---- WebSocket -------------------------------------------------------
const connectWs = useCallback(() => {
if (wsRef.current) wsRef.current.close()
if (wsRef.current) wsRef.current.close();
const ws = new WebSocket(buildWsUrl())
wsRef.current = ws
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
})
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 */ }
}
} catch {
/* ignore */
}
};
ws.onclose = () => { wsRef.current = null }
}, [])
ws.onclose = () => {
wsRef.current = null;
};
}, []);
// ---- Initialize ------------------------------------------------------
useEffect(() => {
loadHistory(0).then(() => connectWs())
loadHistory(0).then(() => connectWs());
return () => {
wsRef.current?.close()
wsRef.current = null
}
wsRef.current?.close();
wsRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, []);
// ---- Actions ---------------------------------------------------------
const scrollToBottom = useCallback(() => {
setAutoScroll(true)
setAutoScroll(true);
requestAnimationFrame(() => {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
})
}, [])
});
}, []);
const handleLoadMore = useCallback(() => {
if (nextCursor > 0) loadHistory(nextCursor)
}, [nextCursor, loadHistory])
if (nextCursor > 0) loadHistory(nextCursor);
}, [nextCursor, loadHistory]);
// ---- Render ----------------------------------------------------------
if (loading) return <LoadingStateWithBorder />
if (error) return <ErrorInline error={error} onRetry={() => loadHistory(0)} />
if (loading) return <LoadingStateWithBorder />;
if (error)
return <ErrorInline error={error} onRetry={() => loadHistory(0)} />;
return (
<div className="flex flex-col h-full relative">
<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"
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">
<div className='sticky top-0 z-10 flex justify-center py-1.5 bg-[#0d1117]/90 backdrop-blur-sm'>
<Button
variant="ghost"
size="sm"
variant='ghost'
size='sm'
onClick={handleLoadMore}
disabled={loadingMore}
className="text-muted-foreground hover:text-foreground h-7 text-xs"
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" />加载更早日志</>
}
{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='px-4 py-8 text-center text-gray-500'>暂无日志</div>
) : (
<div
className="relative px-3 py-3"
style={{height: `${rowVirtualizer.getTotalSize()}px`}}
className='relative px-3 py-3'
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const entry = logs[virtualRow.index]
if (!entry) return null
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)`}}
className='absolute top-0 left-0 w-full'
style={{ transform: `translateY(${virtualRow.start}px)` }}
>
<LogLine data={entry.data} />
</div>
)
);
})}
</div>
)}
@@ -308,18 +341,18 @@ export function AppLogs() {
{/* Floating "back to latest" button */}
{!autoScroll && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20">
<div className='absolute bottom-6 left-1/2 -translate-x-1/2 z-20'>
<Button
variant="outline"
size="sm"
variant='outline'
size='sm'
onClick={scrollToBottom}
className="shadow-lg bg-background/80 backdrop-blur-sm border-border/50"
className='shadow-lg bg-background/80 backdrop-blur-sm border-border/50'
>
<ArrowDown className="size-3.5 mr-1.5" />
<ArrowDown className='size-3.5 mr-1.5' />
回到最新
</Button>
</div>
)}
</div>
)
);
}