feat(access-logs): 明细详情支持 IP 分析与 URL Tab 记忆

- 新增单 IP 分析接口,趋势时间范围支持至 30 天
- 明细详情弹窗展示趋势、汇总与 Top 分布,可快捷管理 IP 组
- 访问日志页签改为 URL 参数记忆,筛选后保持当前 Tab
This commit is contained in:
ryan
2026-07-18 22:40:21 +08:00
parent bf71bc540b
commit 9aec984bee
13 changed files with 1443 additions and 86 deletions
+2
View File
@@ -26,6 +26,8 @@ sidebar: false
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
- 修复访问日志概览在 ClickHouse 查询发生错误时静默吞掉报错的缺陷,引入错误日志输出,方便定位问题。
+163
View File
@@ -5315,6 +5315,93 @@ const docTemplate = `{
}
}
},
"/api/v1/d/access-logs/ip-summary/analysis": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定 IP 的汇总指标与 Top 分布,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-observability"
],
"summary": "获取访问日志 IP 分析",
"parameters": [
{
"type": "string",
"description": "节点 ID",
"name": "node_id",
"in": "query"
},
{
"type": "string",
"description": "客户端 IP",
"name": "remote_addr",
"in": "query"
},
{
"type": "string",
"description": "请求 Host",
"name": "host",
"in": "query"
},
{
"type": "integer",
"description": "统计时间范围(小时)",
"name": "hours",
"in": "query"
}
],
"responses": {
"200": {
"description": "IP 访问分析",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/observability.AccessLogIPAnalysisView"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/access-logs/ip-summary/trend": {
"get": {
"security": [
@@ -16129,6 +16216,82 @@ const docTemplate = `{
}
}
},
"observability.AccessLogIPAnalysisSummary": {
"type": "object",
"properties": {
"bandwidth_served": {
"type": "integer"
},
"bytes_received": {
"type": "integer"
},
"error_count": {
"type": "integer"
},
"total_requests": {
"type": "integer"
},
"unique_hosts": {
"type": "integer"
},
"unique_paths": {
"type": "integer"
}
}
},
"observability.AccessLogIPAnalysisView": {
"type": "object",
"properties": {
"device_types": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"generated_at": {
"type": "string"
},
"hours": {
"type": "integer"
},
"remote_addr": {
"type": "string"
},
"status_codes": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"summary": {
"$ref": "#/definitions/observability.AccessLogIPAnalysisSummary"
},
"top_browsers": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_hosts": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_paths": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_user_agents": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
}
}
},
"observability.AccessLogIPSummaryList": {
"type": "object",
"properties": {
+163
View File
@@ -5308,6 +5308,93 @@
}
}
},
"/api/v1/d/access-logs/ip-summary/analysis": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定 IP 的汇总指标与 Top 分布,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-observability"
],
"summary": "获取访问日志 IP 分析",
"parameters": [
{
"type": "string",
"description": "节点 ID",
"name": "node_id",
"in": "query"
},
{
"type": "string",
"description": "客户端 IP",
"name": "remote_addr",
"in": "query"
},
{
"type": "string",
"description": "请求 Host",
"name": "host",
"in": "query"
},
{
"type": "integer",
"description": "统计时间范围(小时)",
"name": "hours",
"in": "query"
}
],
"responses": {
"200": {
"description": "IP 访问分析",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/observability.AccessLogIPAnalysisView"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "内部错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/access-logs/ip-summary/trend": {
"get": {
"security": [
@@ -16122,6 +16209,82 @@
}
}
},
"observability.AccessLogIPAnalysisSummary": {
"type": "object",
"properties": {
"bandwidth_served": {
"type": "integer"
},
"bytes_received": {
"type": "integer"
},
"error_count": {
"type": "integer"
},
"total_requests": {
"type": "integer"
},
"unique_hosts": {
"type": "integer"
},
"unique_paths": {
"type": "integer"
}
}
},
"observability.AccessLogIPAnalysisView": {
"type": "object",
"properties": {
"device_types": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"generated_at": {
"type": "string"
},
"hours": {
"type": "integer"
},
"remote_addr": {
"type": "string"
},
"status_codes": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"summary": {
"$ref": "#/definitions/observability.AccessLogIPAnalysisSummary"
},
"top_browsers": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_hosts": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_paths": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_user_agents": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
}
}
},
"observability.AccessLogIPSummaryList": {
"type": "object",
"properties": {
+103
View File
@@ -1872,6 +1872,56 @@ definitions:
retention_days:
type: integer
type: object
observability.AccessLogIPAnalysisSummary:
properties:
bandwidth_served:
type: integer
bytes_received:
type: integer
error_count:
type: integer
total_requests:
type: integer
unique_hosts:
type: integer
unique_paths:
type: integer
type: object
observability.AccessLogIPAnalysisView:
properties:
device_types:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
generated_at:
type: string
hours:
type: integer
remote_addr:
type: string
status_codes:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
summary:
$ref: '#/definitions/observability.AccessLogIPAnalysisSummary'
top_browsers:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
top_hosts:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
top_paths:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
top_user_agents:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
type: object
observability.AccessLogIPSummaryList:
properties:
has_more:
@@ -7181,6 +7231,59 @@ paths:
summary: 列出访问日志 IP 汇总
tags:
- openflare-observability
/api/v1/d/access-logs/ip-summary/analysis:
get:
description: 返回指定 IP 的汇总指标与 Top 分布,需要管理员权限
parameters:
- description: 节点 ID
in: query
name: node_id
type: string
- description: 客户端 IP
in: query
name: remote_addr
type: string
- description: 请求 Host
in: query
name: host
type: string
- description: 统计时间范围(小时)
in: query
name: hours
type: integer
produces:
- application/json
responses:
"200":
description: IP 访问分析
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/observability.AccessLogIPAnalysisView'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 内部错误
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取访问日志 IP 分析
tags:
- openflare-observability
/api/v1/d/access-logs/ip-summary/trend:
get:
description: 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限
@@ -0,0 +1,687 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { RankChart } from '@/components/data/rank-chart';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
AccessLogService,
type AccessLogItem,
type DistributionItem,
type WAFIPGroup,
WafService,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
OVERVIEW_RANGE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
const trendChartConfig = {
requests: { label: '请求数', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
function resolveBucketMinutes(hours: OverviewRangeHours) {
if (hours <= 24) return 30;
if (hours <= 168) return 60;
return 60;
}
function groupsContainingIp(groups: WAFIPGroup[], ip: string) {
const target = ip.trim();
if (!target) return [];
return groups.filter((group) =>
(group.ip_list ?? []).some((entry) => entry.trim() === target),
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
function DetailField({
label,
value,
mono,
full,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
full?: boolean;
}) {
return (
<div className={full ? 'sm:col-span-2 space-y-1' : 'space-y-1'}>
<p className='text-[11px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<div
className={`text-sm break-all ${mono ? 'font-mono text-xs' : 'text-foreground'}`}
>
{value}
</div>
</div>
);
}
function MetricCard({ label, value }: { label: string; value: string }) {
return (
<div className='rounded-lg border border-dashed px-3 py-2.5'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<p className='mt-1 text-lg font-semibold tracking-tight'>{value}</p>
</div>
);
}
function MiniRankCard({
title,
items,
color,
}: {
title: string;
items: { label: string; value: number }[];
color: string;
}) {
return (
<div className='rounded-lg border border-dashed p-3'>
<p className='mb-2 text-sm font-medium'>{title}</p>
<RankChart
items={items}
color={color}
className='!h-[220px]'
emptyMessage={`暂无 ${title} 数据`}
/>
</div>
);
}
function AddToIPGroupPanel({
ip,
open,
onClose,
}: {
ip: string;
open: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const groupsQuery = useQuery({
queryKey: ['openflare', 'waf', 'ip-groups'],
queryFn: () => WafService.listIPGroups(),
enabled: open,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const matchedGroups = useMemo(
() => groupsContainingIp(groups, ip),
[groups, ip],
);
const manualGroups = useMemo(
() =>
groups.filter(
(group) => group.type === 'manual' && group.enabled !== false,
),
[groups],
);
const addableGroups = useMemo(
() =>
manualGroups.filter(
(group) =>
!(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()),
),
[manualGroups, ip],
);
const updateMutation = useMutation({
mutationFn: async ({
group,
nextList,
}: {
group: WAFIPGroup;
nextList: string[];
}) =>
WafService.updateIPGroup(
group.id,
buildIPGroupPayloadFromGroup(group, nextList),
),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'ip-groups'],
});
},
});
const handleAdd = async () => {
const groupId = Number.parseInt(selectedGroupId, 10);
const group = addableGroups.find((item) => item.id === groupId);
if (!group) {
toast.error('请选择要加入的 IP 组');
return;
}
try {
await updateMutation.mutateAsync({
group,
nextList: [...(group.ip_list ?? []), ip.trim()],
});
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`);
setSelectedGroupId('');
} catch (error) {
toast.error(error instanceof Error ? error.message : '加入 IP 组失败');
}
};
const handleRemove = async (group: WAFIPGroup) => {
try {
await updateMutation.mutateAsync({
group,
nextList: (group.ip_list ?? []).filter(
(entry) => entry.trim() !== ip.trim(),
),
});
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : '移除失败');
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
setSelectedGroupId('');
onClose();
}
}}
>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
<DialogDescription>
目标 IP:
<span className='font-mono text-foreground'>{ip}</span>
</DialogDescription>
</DialogHeader>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder title='加载 IP 组' />
) : groupsQuery.isError ? (
<ErrorInline
message={
groupsQuery.error instanceof Error
? groupsQuery.error.message
: '加载 IP 组失败'
}
onRetry={() => void groupsQuery.refetch()}
/>
) : (
<div className='space-y-4'>
{matchedGroups.length > 0 ? (
<div className='space-y-2 rounded-lg border border-dashed p-3'>
<p className='text-sm font-medium text-foreground'>
该 IP 已存在于以下 IP 组
</p>
<p className='text-xs text-muted-foreground'>
可选择删除,或继续添加到其他 IP 组。
</p>
<div className='space-y-2'>
{matchedGroups.map((group) => (
<div
key={group.id}
className='flex items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{group.name}
</p>
<p className='text-[11px] text-muted-foreground'>
{group.type} · {group.ip_list?.length ?? 0} 条
</p>
</div>
{group.type === 'manual' ? (
<Button
variant='outline'
size='sm'
className='h-8 shrink-0 text-destructive'
disabled={updateMutation.isPending}
onClick={() => void handleRemove(group)}
>
{updateMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<>
<Trash2 className='mr-1 size-3.5' />
删除
</>
)}
</Button>
) : (
<Badge variant='outline' className='text-[10px]'>
不可手动删除
</Badge>
)}
</div>
))}
</div>
</div>
) : (
<p className='text-sm text-muted-foreground'>
该 IP 尚未加入任何 IP 组。
</p>
)}
<div className='space-y-2'>
<p className='text-sm font-medium'>添加到其他 IP 组</p>
{addableGroups.length === 0 ? (
<p className='text-xs text-muted-foreground'>
没有可写入的手动 IP 组(或已全部包含该 IP)。
</p>
) : (
<Select
value={selectedGroupId}
onValueChange={setSelectedGroupId}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder='选择手动 IP 组' />
</SelectTrigger>
<SelectContent>
{addableGroups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={onClose}>
关闭
</Button>
<Button
onClick={() => void handleAdd()}
disabled={
!selectedGroupId ||
updateMutation.isPending ||
addableGroups.length === 0
}
>
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
处理中...
</>
) : (
'加入所选 IP 组'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function AccessLogDetailDialog({
open,
item,
onOpenChange,
}: {
open: boolean;
item: AccessLogItem | null;
onOpenChange: (open: boolean) => void;
}) {
const [ipGroupOpen, setIpGroupOpen] = useState(false);
const [rangeHours, setRangeHours] = useState<OverviewRangeHours>(24);
// Keep last selected item while the dialog closes to avoid empty-state flash.
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
if (item && item !== displayItem) {
setDisplayItem(item);
}
const activeItem = item ?? displayItem;
const ip = activeItem?.remote_addr?.trim() ?? '';
const bucketMinutes = resolveBucketMinutes(rangeHours);
const rangeHint = formatOverviewRangeHint(rangeHours);
const trendQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-trend',
ip,
rangeHours,
bucketMinutes,
],
queryFn: () =>
AccessLogService.getIPTrend({
remote_addr: ip,
hours: rangeHours,
bucket_minutes: bucketMinutes,
}),
enabled: open && ip !== '',
});
const analysisQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours],
queryFn: () =>
AccessLogService.getIPAnalysis({
remote_addr: ip,
hours: rangeHours,
}),
enabled: open && ip !== '',
});
const trendChartData = useMemo(() => {
return (trendQuery.data?.points ?? []).map((point) => ({
label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours),
requests: point.request_count,
}));
}, [rangeHours, trendQuery.data?.points]);
const analysis = analysisQuery.data;
const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading;
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>访问日志详情</DialogTitle>
<DialogDescription>
查看请求字段、User-Agent 与该 IP 的访问趋势及分析数据。
</DialogDescription>
</DialogHeader>
{activeItem && (
<div className='space-y-5'>
<div className='grid gap-4 sm:grid-cols-2'>
<DetailField
label='时间'
value={formatDateTime(activeItem.logged_at)}
/>
<DetailField
label='节点'
value={activeItem.node_name || activeItem.node_id || '—'}
/>
<DetailField
label='IP'
value={activeItem.remote_addr || '—'}
mono
/>
<DetailField label='地区' value={activeItem.region || '—'} />
<DetailField label='域名' value={activeItem.host || '—'} />
<DetailField
label='状态码'
value={
<Badge variant='outline' className='text-[10px]'>
{activeItem.status_code}
</Badge>
}
/>
<DetailField
label='路径'
value={activeItem.path || '—'}
full
mono
/>
<DetailField
label='User-Agent'
value={activeItem.user_agent || '—'}
full
mono
/>
</div>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Button
size='sm'
variant='outline'
disabled={!ip}
onClick={() => setIpGroupOpen(true)}
>
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
</Button>
<ToggleGroup
type='single'
value={String(rangeHours)}
onValueChange={(value) => {
if (!value) return;
setRangeHours(
Number.parseInt(value, 10) as OverviewRangeHours,
);
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{!ip ? (
<EmptyStateWithBorder description='该日志没有有效 IP。' />
) : isLoadingIP ? (
<LoadingStateWithBorder title='加载 IP 分析' />
) : (
<div className='space-y-4'>
{analysisQuery.isError ? (
<ErrorInline
message={
analysisQuery.error instanceof Error
? analysisQuery.error.message
: '加载 IP 分析失败'
}
onRetry={() => void analysisQuery.refetch()}
/>
) : analysis ? (
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<MetricCard
label='总请求'
value={formatCompactNumber(
analysis.summary.total_requests,
)}
/>
<MetricCard
label='错误数'
value={formatCompactNumber(
analysis.summary.error_count,
)}
/>
<MetricCard
label='已提供带宽'
value={formatBytes(analysis.summary.bandwidth_served)}
/>
<MetricCard
label='接收数据'
value={formatBytes(analysis.summary.bytes_received)}
/>
<MetricCard
label='独立域名'
value={formatCompactNumber(
analysis.summary.unique_hosts,
)}
/>
<MetricCard
label='独立路径'
value={formatCompactNumber(
analysis.summary.unique_paths,
)}
/>
</div>
) : null}
<div className='space-y-3 rounded-lg border border-dashed p-4'>
<div className='flex items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>IP 请求趋势</p>
<p className='text-xs text-muted-foreground'>
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
</p>
</div>
<Button
size='sm'
variant='ghost'
disabled={isFetchingIP}
onClick={() => {
void trendQuery.refetch();
void analysisQuery.refetch();
}}
>
刷新
</Button>
</div>
{trendQuery.isError ? (
<ErrorInline
message={
trendQuery.error instanceof Error
? trendQuery.error.message
: '加载趋势失败'
}
onRetry={() => void trendQuery.refetch()}
/>
) : trendChartData.every(
(point) => point.requests === 0,
) ? (
<EmptyStateWithBorder
description={`该 IP 在${rangeHint}内没有访问记录。`}
/>
) : (
<ChartContainer
config={trendChartConfig}
className='h-56 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
tickFormatter={(value) =>
formatCompactNumber(Number(value))
}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</div>
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title='Top Paths'
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title='Top Hosts'
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title='Status Codes'
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title='Top User-Agents'
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title='Device Types'
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title='Top Browsers'
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
</div>
) : null}
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
{ip ? (
<AddToIPGroupPanel
ip={ip}
open={ipGroupOpen}
onClose={() => setIpGroupOpen(false)}
/>
) : null}
</>
);
}
@@ -1,5 +1,8 @@
'use client';
import { useState } from 'react';
import { Eye } from 'lucide-react';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
@@ -20,9 +23,15 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { AccessLogList } from '@/lib/services/openflare';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { AccessLogItem, AccessLogList } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogDetailDialog } from './access-log-detail-dialog';
import { DETAIL_SORT_OPTIONS } from './access-log-utils';
function PaginationBar({
@@ -86,86 +95,111 @@ export function DetailTab({
onNextPage: () => void;
isFetching: boolean;
}) {
const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
return (
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>日志明细</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
<>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>日志明细</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<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>
<TableBody>
{(data?.items ?? []).map((item) => (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<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}
</Badge>
</TableCell>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
<AccessLogDetailDialog
open={detailOpen}
item={selected}
onOpenChange={setDetailOpen}
/>
</div>
</>
);
}
+34 -7
View File
@@ -1,10 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
@@ -27,9 +29,15 @@ const emptyDraft: SearchDraft = {
path: '',
};
export default function AccessLogsPage() {
function resolveTab(value: string | null): AccessLogTab {
return value === 'list' ? 'list' : 'overview';
}
function AccessLogsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [tab, setTab] = useState<AccessLogTab>('overview');
const tab = resolveTab(searchParams.get('tab'));
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
const [pageSize, setPageSize] = useState(20);
@@ -40,6 +48,13 @@ export default function AccessLogsPage() {
const detailSortState = parseSortValue(detailSort);
const handleTabChange = (value: string) => {
const next = resolveTab(value);
router.replace(
next === 'overview' ? '/access-logs' : `/access-logs?tab=${next}`,
);
};
const overviewQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
queryFn: () =>
@@ -150,10 +165,7 @@ export default function AccessLogsPage() {
</div>
</div>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as AccessLogTab)}
>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-md grid-cols-2'>
<TabsTrigger value='overview'>概览</TabsTrigger>
<TabsTrigger value='list'>日志明细</TabsTrigger>
@@ -207,3 +219,18 @@ export default function AccessLogsPage() {
</div>
);
}
export default function AccessLogsPage() {
return (
<Suspense
fallback={
<LoadingStateWithBorder
title='加载访问日志'
description='正在准备页面...'
/>
}
>
<AccessLogsPageContent />
</Suspense>
);
}
@@ -3,6 +3,8 @@ import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPAnalysis,
AccessLogIPAnalysisFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryList,
AccessLogIPTrend,
@@ -73,6 +75,15 @@ export class AccessLogService extends OpenFlareBaseService {
);
}
static getIPAnalysis(
filters: AccessLogIPAnalysisFilters,
): Promise<AccessLogIPAnalysis> {
return this.get<AccessLogIPAnalysis>(
'/ip-summary/analysis',
buildSearchParams(filters),
);
}
static cleanup(
payload: AccessLogCleanupPayload,
): Promise<AccessLogCleanupResult> {
+2
View File
@@ -82,6 +82,8 @@ export type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPAnalysis,
AccessLogIPAnalysisFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
+27
View File
@@ -653,6 +653,33 @@ export interface AccessLogIPTrend {
points: AccessLogIPTrendPoint[];
}
export interface AccessLogIPAnalysisFilters {
node_id?: string;
remote_addr: string;
host?: string;
hours?: number;
}
export interface AccessLogIPAnalysis {
remote_addr: string;
hours: number;
generated_at: string;
summary: {
total_requests: number;
error_count: number;
bandwidth_served: number;
bytes_received: number;
unique_hosts: number;
unique_paths: number;
};
top_paths: DistributionItem[];
top_hosts: DistributionItem[];
status_codes: DistributionItem[];
top_user_agents: DistributionItem[];
device_types: DistributionItem[];
top_browsers: DistributionItem[];
}
export interface AccessLogCleanupPayload {
retention_days: number;
}
@@ -22,7 +22,7 @@ const (
defaultAccessLogFoldMinute = 3
defaultIPTrendHours = 24
defaultIPTrendBucketMinute = 30
maxIPTrendHours = 168
maxIPTrendHours = 24 * 30
nodeAccessLogRetentionDays = 90
defaultAccessLogQueryDays = 7
accessLogFieldRemoteAddr = "remote_addr"
@@ -181,6 +181,38 @@ type AccessLogIPTrendView struct {
Points []AccessLogIPTrendPoint `json:"points"`
}
// AccessLogIPAnalysisQuery filters per-IP analysis queries.
type AccessLogIPAnalysisQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Hours int `json:"hours"`
}
// AccessLogIPAnalysisSummary is headline totals for one IP.
type AccessLogIPAnalysisSummary struct {
TotalRequests int64 `json:"total_requests"`
ErrorCount int64 `json:"error_count"`
BandwidthServed int64 `json:"bandwidth_served"`
BytesReceived int64 `json:"bytes_received"`
UniqueHosts int64 `json:"unique_hosts"`
UniquePaths int64 `json:"unique_paths"`
}
// AccessLogIPAnalysisView is per-IP analytics for the detail dialog.
type AccessLogIPAnalysisView struct {
RemoteAddr string `json:"remote_addr"`
Hours int `json:"hours"`
GeneratedAt time.Time `json:"generated_at"`
Summary AccessLogIPAnalysisSummary `json:"summary"`
TopPaths []DistributionItem `json:"top_paths"`
TopHosts []DistributionItem `json:"top_hosts"`
StatusCodes []DistributionItem `json:"status_codes"`
TopUserAgents []DistributionItem `json:"top_user_agents"`
DeviceTypes []DistributionItem `json:"device_types"`
TopBrowsers []DistributionItem `json:"top_browsers"`
}
// AccessLogOverviewQuery filters access log overview queries.
type AccessLogOverviewQuery struct {
NodeID string `json:"node_id"`
@@ -665,6 +697,82 @@ func GetAccessLogIPTrend(ctx context.Context, input AccessLogIPTrendQuery) (*Acc
}, nil
}
// GetAccessLogIPAnalysis returns per-IP summary and rankings.
func GetAccessLogIPAnalysis(ctx context.Context, input AccessLogIPAnalysisQuery) (*AccessLogIPAnalysisView, error) {
normalized, err := normalizeAccessLogIPAnalysisQuery(input)
if err != nil {
return nil, err
}
now := time.Now().UTC()
since := now.Add(-time.Duration(normalized.Hours) * time.Hour)
query := model.OpenFlareAccessLogQuery{
NodeID: normalized.NodeID,
RemoteAddr: normalized.RemoteAddr,
Host: normalized.Host,
Since: since,
Until: now,
}
summaryRow, err := model.TrafficSummaryOpenFlareAccessLogs(ctx, query)
if err != nil {
return nil, err
}
allPaths := valueCountDistribution(ctx, query, "path", 0)
allHosts := valueCountDistribution(ctx, query, "host", 0)
topPaths := limitDistributionItems(allPaths, accessLogOverviewTopLimit)
topHosts := limitDistributionItems(allHosts, accessLogOverviewTopLimit)
statusCodes := valueCountDistribution(ctx, query, "status_code", accessLogOverviewTopLimit)
topUserAgents := valueCountDistribution(ctx, query, "user_agent", accessLogOverviewTopLimit)
deviceTypes, topBrowsers, _, _ := buildAccessLogUADistributions(ctx, query)
return &AccessLogIPAnalysisView{
RemoteAddr: normalized.RemoteAddr,
Hours: normalized.Hours,
GeneratedAt: now,
Summary: AccessLogIPAnalysisSummary{
TotalRequests: summaryRow.RequestCount,
ErrorCount: summaryRow.ErrorCount,
BandwidthServed: summaryRow.BytesSent,
BytesReceived: summaryRow.RequestLength,
UniqueHosts: int64(len(allHosts)),
UniquePaths: int64(len(allPaths)),
},
TopPaths: topPaths,
TopHosts: topHosts,
StatusCodes: statusCodes,
TopUserAgents: topUserAgents,
DeviceTypes: deviceTypes,
TopBrowsers: topBrowsers,
}, nil
}
func limitDistributionItems(items []DistributionItem, limit int) []DistributionItem {
if limit <= 0 || len(items) <= limit {
return items
}
return items[:limit]
}
func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLogIPAnalysisQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPAnalysisQuery{}, errors.New("remote_addr 不能为空")
}
hours := input.Hours
if hours <= 0 {
hours = defaultIPTrendHours
}
if hours > maxIPTrendHours {
hours = maxIPTrendHours
}
return AccessLogIPAnalysisQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: remoteAddr,
Host: strings.TrimSpace(input.Host),
Hours: hours,
}, nil
}
// CleanupAccessLogs removes access logs older than retention days.
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
@@ -205,6 +205,35 @@ func GetAccessLogIPTrendHandler(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(result))
}
// GetAccessLogIPAnalysisHandler 获取单 IP 访问分析。
// @Summary 获取访问日志 IP 分析
// @Description 返回指定 IP 的汇总指标与 Top 分布,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param hours query int false "统计时间范围(小时)"
// @Success 200 {object} response.Any{data=observability.AccessLogIPAnalysisView} "IP 访问分析"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/access-logs/ip-summary/analysis [get]
func GetAccessLogIPAnalysisHandler(c *gin.Context) {
result, err := GetAccessLogIPAnalysis(c.Request.Context(), AccessLogIPAnalysisQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
})
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
}
// CleanupAccessLogsHandler 清理过期访问日志。
// @Summary 清理访问日志
// @Description 按保留天数清理过期访问日志记录,需要管理员权限
@@ -19,6 +19,7 @@ func registerObservabilityRoutes(apiGroup *gin.RouterGroup) {
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
accessLogRoute.GET("/ip-summary/trend", observability.GetAccessLogIPTrendHandler)
accessLogRoute.GET("/ip-summary/analysis", observability.GetAccessLogIPAnalysisHandler)
accessLogRoute.POST("/cleanup", observability.CleanupAccessLogsHandler)
}
}