mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
feat(access-logs): 优化概览饼图布局并在查询出错时增加日志记录
- 将设备类型与状态码饼图的断点由 xl 降为 lg,在大屏/笔记本视口下保持双列展示 - 修复 valueCountDistribution 在 ClickHouse 查询出错时静默吞掉错误的缺陷,引入 logger.ErrorF 捕获 - 补充 unreleased 变更日志
This commit is contained in:
@@ -23,8 +23,11 @@ sidebar: false
|
|||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|
||||||
|
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
|
||||||
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
|
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
|
||||||
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
|
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
|
||||||
|
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
|
||||||
|
- 修复访问日志概览在 ClickHouse 查询发生错误时静默吞掉报错的缺陷,引入错误日志输出,方便定位问题。
|
||||||
|
|
||||||
## [v3.4.0] - 2026-07-18
|
## [v3.4.0] - 2026-07-18
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { EChartsOption } from 'echarts';
|
|||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { Cell, Pie, PieChart } from 'recharts';
|
import { Cell, Pie, PieChart } from 'recharts';
|
||||||
|
|
||||||
import { RankChart } from '@/components/data/rank-chart';
|
import { RankCard } from '@/components/data/rank-card';
|
||||||
import { TrendChart } from '@/components/data/trend-chart';
|
import { TrendChart } from '@/components/data/trend-chart';
|
||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
@@ -160,38 +160,6 @@ function toRankItems(items: DistributionItem[] | undefined) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function RankCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
items,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
items: { label: string; value: number }[];
|
|
||||||
color: string;
|
|
||||||
}) {
|
|
||||||
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'>
|
|
||||||
<RankChart
|
|
||||||
items={items}
|
|
||||||
color={color}
|
|
||||||
emptyMessage={`暂无 ${title} 数据`}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PieDistributionCard({
|
function PieDistributionCard({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -236,13 +204,13 @@ function PieDistributionCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className='pt-0'>
|
<CardContent className='pt-0'>
|
||||||
{chartData.length === 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'>
|
<div className='flex h-[300px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
config={chartConfig}
|
config={chartConfig}
|
||||||
className='mx-auto h-[450px] w-full'
|
className='mx-auto h-[300px] w-full'
|
||||||
>
|
>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
@@ -251,8 +219,8 @@ function PieDistributionCard({
|
|||||||
nameKey='name'
|
nameKey='name'
|
||||||
cx='50%'
|
cx='50%'
|
||||||
cy='46%'
|
cy='46%'
|
||||||
innerRadius={72}
|
innerRadius={50}
|
||||||
outerRadius={110}
|
outerRadius={80}
|
||||||
paddingAngle={2}
|
paddingAngle={2}
|
||||||
>
|
>
|
||||||
{chartData.map((entry) => (
|
{chartData.map((entry) => (
|
||||||
@@ -456,19 +424,16 @@ function OverviewContent({
|
|||||||
<RankCard
|
<RankCard
|
||||||
title='Top Paths'
|
title='Top Paths'
|
||||||
description='访问量最高的请求路径。'
|
description='访问量最高的请求路径。'
|
||||||
color='#a78bfa'
|
|
||||||
items={toRankItems(data.top_paths)}
|
items={toRankItems(data.top_paths)}
|
||||||
/>
|
/>
|
||||||
<RankCard
|
<RankCard
|
||||||
title='Top Hosts'
|
title='Top Hosts'
|
||||||
description='流量集中的访问域名。'
|
description='流量集中的访问域名。'
|
||||||
color='#34d399'
|
|
||||||
items={toRankItems(data.top_hosts)}
|
items={toRankItems(data.top_hosts)}
|
||||||
/>
|
/>
|
||||||
<RankCard
|
<RankCard
|
||||||
title='Top IPs'
|
title='Top IPs'
|
||||||
description='请求次数最多的来源 IP。'
|
description='请求次数最多的来源 IP。'
|
||||||
color='#38bdf8'
|
|
||||||
items={toRankItems(data.top_ips)}
|
items={toRankItems(data.top_ips)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -477,19 +442,16 @@ function OverviewContent({
|
|||||||
<RankCard
|
<RankCard
|
||||||
title='Top browsers'
|
title='Top browsers'
|
||||||
description='按浏览器聚合的请求排行。'
|
description='按浏览器聚合的请求排行。'
|
||||||
color='#f59e0b'
|
|
||||||
items={toRankItems(data.top_browsers)}
|
items={toRankItems(data.top_browsers)}
|
||||||
/>
|
/>
|
||||||
<RankCard
|
<RankCard
|
||||||
title='Top Operating System'
|
title='Top Operating System'
|
||||||
description='按操作系统聚合的请求排行。'
|
description='按操作系统聚合的请求排行。'
|
||||||
color='#22c55e'
|
|
||||||
items={toRankItems(data.top_operating_systems)}
|
items={toRankItems(data.top_operating_systems)}
|
||||||
/>
|
/>
|
||||||
<RankCard
|
<RankCard
|
||||||
title='Top User-Agent'
|
title='Top User-Agent'
|
||||||
description='原始 User-Agent 请求排行。'
|
description='原始 User-Agent 请求排行。'
|
||||||
color='#818cf8'
|
|
||||||
items={toRankItems(data.top_user_agents)}
|
items={toRankItems(data.top_user_agents)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from '@/components/ui/card';
|
||||||
|
|
||||||
|
import { RankChart } from './rank-chart';
|
||||||
|
|
||||||
|
type RankCardItem = {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RankCardProps = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
items: RankCardItem[];
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RankCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
items,
|
||||||
|
color = '#3b82f6',
|
||||||
|
}: RankCardProps) {
|
||||||
|
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'>
|
||||||
|
<RankChart
|
||||||
|
items={items}
|
||||||
|
color={color}
|
||||||
|
emptyMessage={`暂无 ${title} 数据`}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,7 +17,15 @@ type RankChartProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
|
const defaultFormatter = (value: number) => {
|
||||||
|
if (value >= 1000000) {
|
||||||
|
return `${Number((value / 1000000).toFixed(2))}M`;
|
||||||
|
}
|
||||||
|
if (value >= 1000) {
|
||||||
|
return `${Number((value / 1000).toFixed(2))}k`;
|
||||||
|
}
|
||||||
|
return value.toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
export function RankChart({
|
export function RankChart({
|
||||||
items,
|
items,
|
||||||
@@ -35,7 +43,7 @@ export function RankChart({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[450px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground',
|
'flex h-[320px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -45,31 +53,39 @@ export function RankChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('h-[450px] space-y-1.5 overflow-y-auto', className)}>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-[320px] space-y-0.5 overflow-y-auto pr-1 hide-scrollbar',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div key={item.label} className='group space-y-1'>
|
<div
|
||||||
<div className='flex items-center justify-between gap-3 text-[13px] leading-none'>
|
key={item.label}
|
||||||
<span
|
className='group flex items-center justify-between gap-3 py-1.5 text-sm'
|
||||||
className='min-w-0 truncate font-medium text-foreground'
|
>
|
||||||
title={item.label}
|
<span
|
||||||
>
|
className='min-w-0 flex-1 truncate text-[13px] font-normal text-foreground/90'
|
||||||
{item.label}
|
title={item.label}
|
||||||
</span>
|
>
|
||||||
<span className='shrink-0 tabular-nums text-foreground/80'>
|
{item.label}
|
||||||
|
</span>
|
||||||
|
<div className='flex items-center gap-3 shrink-0'>
|
||||||
|
<div className='h-1.5 w-24 sm:w-28 overflow-hidden rounded-full bg-muted/60'>
|
||||||
|
<div
|
||||||
|
className='h-full rounded-full transition-[width] duration-300'
|
||||||
|
style={{
|
||||||
|
width: `${ratio}%`,
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className='w-14 text-right tabular-nums text-foreground/80 text-[13px] font-medium'>
|
||||||
{valueFormatter(item.value)}
|
{valueFormatter(item.value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='h-1.5 overflow-hidden rounded-full bg-muted'>
|
|
||||||
<div
|
|
||||||
className='h-full rounded-full transition-[width] duration-300'
|
|
||||||
style={{
|
|
||||||
width: `${ratio}%`,
|
|
||||||
backgroundColor: color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics"
|
analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics"
|
||||||
|
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -236,10 +237,10 @@ type AccessLogCleanupResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultAccessLogOverviewHours = 24
|
defaultAccessLogOverviewHours = 24
|
||||||
maxAccessLogOverviewHours = 24 * 30
|
maxAccessLogOverviewHours = 24 * 30
|
||||||
accessLogOverviewTopLimit = 10
|
accessLogOverviewTopLimit = 10
|
||||||
accessLogOverviewUASampleLimit = 200
|
accessLogOverviewUASampleLimit = 200
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetAccessLogOverview returns summary metrics, trends, and top rankings.
|
// GetAccessLogOverview returns summary metrics, trends, and top rankings.
|
||||||
@@ -310,7 +311,11 @@ func valueCountDistribution(
|
|||||||
limit int,
|
limit int,
|
||||||
) []DistributionItem {
|
) []DistributionItem {
|
||||||
rows, err := model.ValueCountsOpenFlareAccessLogs(ctx, query, column, limit)
|
rows, err := model.ValueCountsOpenFlareAccessLogs(ctx, query, column, limit)
|
||||||
if err != nil || len(rows) == 0 {
|
if err != nil {
|
||||||
|
logger.ErrorF(ctx, "[AccessLog] ValueCountsOpenFlareAccessLogs failed for column %s: %v", column, err)
|
||||||
|
return []DistributionItem{}
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
return []DistributionItem{}
|
return []DistributionItem{}
|
||||||
}
|
}
|
||||||
items := make([]DistributionItem, 0, len(rows))
|
items := make([]DistributionItem, 0, len(rows))
|
||||||
|
|||||||
@@ -221,12 +221,16 @@ func ValueCountsNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter,
|
|||||||
}
|
}
|
||||||
clause, args := buildNodeAccessLogFilterClause(filter)
|
clause, args := buildNodeAccessLogFilterClause(filter)
|
||||||
tableName := nodeAccessLogTableName()
|
tableName := nodeAccessLogTableName()
|
||||||
|
filterExpr := valueExpr + " != ''"
|
||||||
|
if col == nodeAccessLogColumnStatusCode {
|
||||||
|
filterExpr = "status_code >= 0"
|
||||||
|
}
|
||||||
sql := fmt.Sprintf(`
|
sql := fmt.Sprintf(`
|
||||||
SELECT %s AS value, count() AS count
|
SELECT %s AS value, count() AS count
|
||||||
FROM %s
|
FROM %s
|
||||||
WHERE %s AND %s != ''
|
WHERE %s AND %s
|
||||||
GROUP BY value
|
GROUP BY value
|
||||||
ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, valueExpr)
|
ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, filterExpr)
|
||||||
if limit > 0 {
|
if limit > 0 {
|
||||||
sql += clickHouseLimitClause
|
sql += clickHouseLimitClause
|
||||||
args = append(args, limit)
|
args = append(args, limit)
|
||||||
|
|||||||
Reference in New Issue
Block a user