mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 15:26:36 +08:00
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:
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user