mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
feat(access-logs): 明细详情支持 IP 分析与 URL Tab 记忆
- 新增单 IP 分析接口,趋势时间范围支持至 30 天 - 明细详情弹窗展示趋势、汇总与 Top 分布,可快捷管理 IP 组 - 访问日志页签改为 URL 参数记忆,筛选后保持当前 Tab
This commit is contained in:
@@ -26,6 +26,8 @@ sidebar: false
|
|||||||
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
|
- 优化访问日志概览及其他图表全局排行榜 (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 排行。
|
||||||
|
- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
|
||||||
|
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
|
||||||
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
|
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
|
||||||
- 修复访问日志概览在 ClickHouse 查询发生错误时静默吞掉报错的缺陷,引入错误日志输出,方便定位问题。
|
- 修复访问日志概览在 ClickHouse 查询发生错误时静默吞掉报错的缺陷,引入错误日志输出,方便定位问题。
|
||||||
|
|
||||||
|
|||||||
+163
@@ -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": {
|
"/api/v1/d/access-logs/ip-summary/trend": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"observability.AccessLogIPSummaryList": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/d/access-logs/ip-summary/trend": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"observability.AccessLogIPSummaryList": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -1872,6 +1872,56 @@ definitions:
|
|||||||
retention_days:
|
retention_days:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
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:
|
observability.AccessLogIPSummaryList:
|
||||||
properties:
|
properties:
|
||||||
has_more:
|
has_more:
|
||||||
@@ -7181,6 +7231,59 @@ paths:
|
|||||||
summary: 列出访问日志 IP 汇总
|
summary: 列出访问日志 IP 汇总
|
||||||
tags:
|
tags:
|
||||||
- openflare-observability
|
- 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:
|
/api/v1/d/access-logs/ip-summary/trend:
|
||||||
get:
|
get:
|
||||||
description: 返回指定 IP 在时间范围内的访问趋势数据,需要管理员权限
|
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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Eye } from 'lucide-react';
|
||||||
|
|
||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
@@ -20,9 +23,15 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} 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 { formatDateTime } from '@/lib/utils';
|
||||||
|
|
||||||
|
import { AccessLogDetailDialog } from './access-log-detail-dialog';
|
||||||
import { DETAIL_SORT_OPTIONS } from './access-log-utils';
|
import { DETAIL_SORT_OPTIONS } from './access-log-utils';
|
||||||
|
|
||||||
function PaginationBar({
|
function PaginationBar({
|
||||||
@@ -86,86 +95,111 @@ export function DetailTab({
|
|||||||
onNextPage: () => void;
|
onNextPage: () => void;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [selected, setSelected] = useState<AccessLogItem | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
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'>
|
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
|
||||||
<p className='text-sm font-medium'>日志明细</p>
|
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
|
||||||
<Select value={detailSort} onValueChange={onDetailSortChange}>
|
<p className='text-sm font-medium'>日志明细</p>
|
||||||
<SelectTrigger className='h-8 w-44 text-xs'>
|
<Select value={detailSort} onValueChange={onDetailSortChange}>
|
||||||
<SelectValue />
|
<SelectTrigger className='h-8 w-44 text-xs'>
|
||||||
</SelectTrigger>
|
<SelectValue />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
{DETAIL_SORT_OPTIONS.map((option) => (
|
<SelectContent>
|
||||||
<SelectItem key={option.value} value={option.value}>
|
{DETAIL_SORT_OPTIONS.map((option) => (
|
||||||
{option.label}
|
<SelectItem key={option.value} value={option.value}>
|
||||||
</SelectItem>
|
{option.label}
|
||||||
))}
|
</SelectItem>
|
||||||
</SelectContent>
|
))}
|
||||||
</Select>
|
</SelectContent>
|
||||||
</div>
|
</Select>
|
||||||
{error ? (
|
|
||||||
<div className='p-4'>
|
|
||||||
<ErrorInline
|
|
||||||
message={error.message || '加载失败'}
|
|
||||||
onRetry={onRetry}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
) : loading ? (
|
{error ? (
|
||||||
<LoadingStateWithBorder />
|
<div className='p-4'>
|
||||||
) : (data?.items ?? []).length === 0 ? (
|
<ErrorInline
|
||||||
<EmptyStateWithBorder title='暂无访问日志' />
|
message={error.message || '加载失败'}
|
||||||
) : (
|
onRetry={onRetry}
|
||||||
<Table>
|
/>
|
||||||
<TableHeader className='bg-muted/40'>
|
</div>
|
||||||
<TableRow className='border-dashed hover:bg-transparent'>
|
) : loading ? (
|
||||||
<TableHead className='text-xs'>时间</TableHead>
|
<LoadingStateWithBorder />
|
||||||
<TableHead className='text-xs'>节点</TableHead>
|
) : (data?.items ?? []).length === 0 ? (
|
||||||
<TableHead className='text-xs'>IP</TableHead>
|
<EmptyStateWithBorder title='暂无访问日志' />
|
||||||
<TableHead className='text-xs'>域名</TableHead>
|
) : (
|
||||||
<TableHead className='text-xs'>路径</TableHead>
|
<Table>
|
||||||
<TableHead className='text-xs'>User-Agent</TableHead>
|
<TableHeader className='bg-muted/40'>
|
||||||
<TableHead className='text-xs'>状态码</TableHead>
|
<TableRow className='border-dashed hover:bg-transparent'>
|
||||||
</TableRow>
|
<TableHead className='text-xs'>时间</TableHead>
|
||||||
</TableHeader>
|
<TableHead className='text-xs'>节点</TableHead>
|
||||||
<TableBody>
|
<TableHead className='text-xs'>IP</TableHead>
|
||||||
{(data?.items ?? []).map((item) => (
|
<TableHead className='text-xs'>域名</TableHead>
|
||||||
<TableRow key={item.id} className='border-dashed'>
|
<TableHead className='text-xs'>路径</TableHead>
|
||||||
<TableCell className='text-xs'>
|
<TableHead className='text-xs'>状态码</TableHead>
|
||||||
{formatDateTime(item.logged_at)}
|
<TableHead className='w-12 text-center text-xs' />
|
||||||
</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>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{(data?.items ?? []).map((item) => (
|
||||||
)}
|
<TableRow key={item.id} className='border-dashed'>
|
||||||
<PaginationBar
|
<TableCell className='text-xs'>
|
||||||
page={page}
|
{formatDateTime(item.logged_at)}
|
||||||
hasMore={data?.has_more ?? false}
|
</TableCell>
|
||||||
loading={isFetching}
|
<TableCell className='text-xs'>
|
||||||
onPrev={onPrevPage}
|
{item.node_name || item.node_id}
|
||||||
onNext={onNextPage}
|
</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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client';
|
'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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { AccessLogService } from '@/lib/services/openflare';
|
import { AccessLogService } from '@/lib/services/openflare';
|
||||||
@@ -27,9 +29,15 @@ const emptyDraft: SearchDraft = {
|
|||||||
path: '',
|
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 queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState<AccessLogTab>('overview');
|
const tab = resolveTab(searchParams.get('tab'));
|
||||||
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
|
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
|
||||||
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
|
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
@@ -40,6 +48,13 @@ export default function AccessLogsPage() {
|
|||||||
|
|
||||||
const detailSortState = parseSortValue(detailSort);
|
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({
|
const overviewQuery = useQuery({
|
||||||
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
|
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -150,10 +165,7 @@ export default function AccessLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||||
value={tab}
|
|
||||||
onValueChange={(value) => setTab(value as AccessLogTab)}
|
|
||||||
>
|
|
||||||
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
||||||
<TabsTrigger value='overview'>概览</TabsTrigger>
|
<TabsTrigger value='overview'>概览</TabsTrigger>
|
||||||
<TabsTrigger value='list'>日志明细</TabsTrigger>
|
<TabsTrigger value='list'>日志明细</TabsTrigger>
|
||||||
@@ -207,3 +219,18 @@ export default function AccessLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function AccessLogsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<LoadingStateWithBorder
|
||||||
|
title='加载访问日志'
|
||||||
|
description='正在准备页面...'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AccessLogsPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type {
|
|||||||
AccessLogCleanupPayload,
|
AccessLogCleanupPayload,
|
||||||
AccessLogCleanupResult,
|
AccessLogCleanupResult,
|
||||||
AccessLogFilters,
|
AccessLogFilters,
|
||||||
|
AccessLogIPAnalysis,
|
||||||
|
AccessLogIPAnalysisFilters,
|
||||||
AccessLogIPSummaryFilters,
|
AccessLogIPSummaryFilters,
|
||||||
AccessLogIPSummaryList,
|
AccessLogIPSummaryList,
|
||||||
AccessLogIPTrend,
|
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(
|
static cleanup(
|
||||||
payload: AccessLogCleanupPayload,
|
payload: AccessLogCleanupPayload,
|
||||||
): Promise<AccessLogCleanupResult> {
|
): Promise<AccessLogCleanupResult> {
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ export type {
|
|||||||
AccessLogCleanupPayload,
|
AccessLogCleanupPayload,
|
||||||
AccessLogCleanupResult,
|
AccessLogCleanupResult,
|
||||||
AccessLogFilters,
|
AccessLogFilters,
|
||||||
|
AccessLogIPAnalysis,
|
||||||
|
AccessLogIPAnalysisFilters,
|
||||||
AccessLogIPSummaryFilters,
|
AccessLogIPSummaryFilters,
|
||||||
AccessLogIPSummaryItem,
|
AccessLogIPSummaryItem,
|
||||||
AccessLogIPSummaryList,
|
AccessLogIPSummaryList,
|
||||||
|
|||||||
@@ -653,6 +653,33 @@ export interface AccessLogIPTrend {
|
|||||||
points: AccessLogIPTrendPoint[];
|
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 {
|
export interface AccessLogCleanupPayload {
|
||||||
retention_days: number;
|
retention_days: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const (
|
|||||||
defaultAccessLogFoldMinute = 3
|
defaultAccessLogFoldMinute = 3
|
||||||
defaultIPTrendHours = 24
|
defaultIPTrendHours = 24
|
||||||
defaultIPTrendBucketMinute = 30
|
defaultIPTrendBucketMinute = 30
|
||||||
maxIPTrendHours = 168
|
maxIPTrendHours = 24 * 30
|
||||||
nodeAccessLogRetentionDays = 90
|
nodeAccessLogRetentionDays = 90
|
||||||
defaultAccessLogQueryDays = 7
|
defaultAccessLogQueryDays = 7
|
||||||
accessLogFieldRemoteAddr = "remote_addr"
|
accessLogFieldRemoteAddr = "remote_addr"
|
||||||
@@ -181,6 +181,38 @@ type AccessLogIPTrendView struct {
|
|||||||
Points []AccessLogIPTrendPoint `json:"points"`
|
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.
|
// AccessLogOverviewQuery filters access log overview queries.
|
||||||
type AccessLogOverviewQuery struct {
|
type AccessLogOverviewQuery struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
@@ -665,6 +697,82 @@ func GetAccessLogIPTrend(ctx context.Context, input AccessLogIPTrendQuery) (*Acc
|
|||||||
}, nil
|
}, 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.
|
// CleanupAccessLogs removes access logs older than retention days.
|
||||||
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
|
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
|
||||||
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
|
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
|
||||||
|
|||||||
@@ -205,6 +205,35 @@ func GetAccessLogIPTrendHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response.OK(result))
|
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 清理过期访问日志。
|
// CleanupAccessLogsHandler 清理过期访问日志。
|
||||||
// @Summary 清理访问日志
|
// @Summary 清理访问日志
|
||||||
// @Description 按保留天数清理过期访问日志记录,需要管理员权限
|
// @Description 按保留天数清理过期访问日志记录,需要管理员权限
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func registerObservabilityRoutes(apiGroup *gin.RouterGroup) {
|
|||||||
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
|
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
|
||||||
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
|
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
|
||||||
accessLogRoute.GET("/ip-summary/trend", observability.GetAccessLogIPTrendHandler)
|
accessLogRoute.GET("/ip-summary/trend", observability.GetAccessLogIPTrendHandler)
|
||||||
|
accessLogRoute.GET("/ip-summary/analysis", observability.GetAccessLogIPAnalysisHandler)
|
||||||
accessLogRoute.POST("/cleanup", observability.CleanupAccessLogsHandler)
|
accessLogRoute.POST("/cleanup", observability.CleanupAccessLogsHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user