feat(access-logs): 接入 User-Agent 与设备/浏览器/状态码分布

- Agent 访问日志上报 user_agent,OpenResty log_format 输出 http_user_agent
- of_node_access_logs 新增 user_agent 列并写入 ClickHouse
- 访问日志概览新增:设备类型饼图、状态码饼图、浏览器/OS/User-Agent 排行
- 日志明细列表增加 User-Agent 列
- 扩展 UA 解析工具(browser/os/device)并支持 CLI 识别
This commit is contained in:
ryan
2026-07-18 21:18:59 +08:00
parent 177578ef4e
commit e49078ac3b
28 changed files with 498 additions and 62 deletions
@@ -123,6 +123,7 @@ export function DetailTab({
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>User-Agent</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
</TableRow>
</TableHeader>
@@ -139,9 +140,15 @@ export function DetailTab({
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
<TableCell className='text-xs max-w-40 truncate'>
{item.path}
</TableCell>
<TableCell
className='text-xs max-w-56 truncate'
title={item.user_agent || undefined}
>
{item.user_agent || '—'}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
@@ -3,6 +3,7 @@
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { Cell, Pie, PieChart } from 'recharts';
import { RankChart } from '@/components/data/rank-chart';
import { TrendChart } from '@/components/data/trend-chart';
@@ -16,8 +17,19 @@ import {
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { AccessLogOverview } from '@/lib/services/openflare';
import type {
AccessLogOverview,
DistributionItem,
} from '@/lib/services/openflare';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
@@ -27,6 +39,15 @@ import {
type OverviewRangeHours,
} from './access-log-utils';
const DEVICE_COLORS = [
'#38bdf8',
'#34d399',
'#f59e0b',
'#a78bfa',
'#f472b6',
'#94a3b8',
];
function SparklineMetricCard({
title,
value,
@@ -132,6 +153,13 @@ function SparklineMetricCard({
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
function RankCard({
title,
description,
@@ -164,6 +192,101 @@ function RankCard({
);
}
function PieDistributionCard({
title,
description,
items,
emptyMessage,
}: {
title: string;
description: string;
items: DistributionItem[];
emptyMessage: string;
}) {
const chartData = useMemo(
() =>
items.map((item, index) => ({
name: item.key,
value: item.value,
fill: DEVICE_COLORS[index % DEVICE_COLORS.length],
})),
[items],
);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
chartData.forEach((item) => {
config[item.name] = {
label: item.name,
color: item.fill,
};
});
return config;
}, [chartData]);
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
{chartData.length === 0 ? (
<div className='flex h-[450px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
{emptyMessage}
</div>
) : (
<ChartContainer
config={chartConfig}
className='mx-auto h-[450px] w-full'
>
<PieChart>
<Pie
data={chartData}
dataKey='value'
nameKey='name'
cx='50%'
cy='46%'
innerRadius={72}
outerRadius={110}
paddingAngle={2}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => (
<>
<span className='text-muted-foreground'>{name}</span>
<span className='ml-auto font-mono font-medium tabular-nums text-foreground'>
{formatCompactNumber(Number(value ?? 0))}
</span>
</>
)}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 pt-2 text-[11px]'
/>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}
function OverviewRangeSwitch({
hours,
onHoursChange,
@@ -314,33 +437,60 @@ function OverviewContent({
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
description='按设备类型统计请求占比。'
items={data.device_types ?? []}
emptyMessage='暂无设备类型数据'
/>
<PieDistributionCard
title='Status code'
description='按 HTTP 状态码统计请求占比。'
items={data.status_codes ?? []}
emptyMessage='暂无状态码分布数据'
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
description='访问量最高的请求路径。'
color='#a78bfa'
items={data.top_paths.map((item) => ({
label: item.key,
value: item.value,
}))}
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
description='流量集中的访问域名。'
color='#34d399'
items={data.top_hosts.map((item) => ({
label: item.key,
value: item.value,
}))}
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
description='请求次数最多的来源 IP。'
color='#38bdf8'
items={data.top_ips.map((item) => ({
label: item.key,
value: item.value,
}))}
items={toRankItems(data.top_ips)}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
description='按浏览器聚合的请求排行。'
color='#f59e0b'
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
description='按操作系统聚合的请求排行。'
color='#22c55e'
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
description='原始 User-Agent 请求排行。'
color='#818cf8'
items={toRankItems(data.top_user_agents)}
/>
</div>
</>
+6
View File
@@ -527,6 +527,11 @@ export interface AccessLogOverview {
top_paths: DistributionItem[];
top_hosts: DistributionItem[];
top_ips: DistributionItem[];
device_types: DistributionItem[];
top_browsers: DistributionItem[];
top_operating_systems: DistributionItem[];
top_user_agents: DistributionItem[];
status_codes: DistributionItem[];
}
export interface AccessLogItem {
@@ -538,6 +543,7 @@ export interface AccessLogItem {
region: string;
host: string;
path: string;
user_agent: string;
status_code: number;
}