diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 3974c93f..33750dd5 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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 查询发生错误时静默吞掉报错的缺陷,引入错误日志输出,方便定位问题。 diff --git a/docs/docs.go b/docs/docs.go index 158cec27..b8e93d1e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index 8ddda97a..470cbd95 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a7db611e..456f9846 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 在时间范围内的访问趋势数据,需要管理员权限 diff --git a/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx new file mode 100644 index 00000000..5c1864b4 --- /dev/null +++ b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx @@ -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 ( +
+

+ {label} +

+
+ {value} +
+
+ ); +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} + +function MiniRankCard({ + title, + items, + color, +}: { + title: string; + items: { label: string; value: number }[]; + color: string; +}) { + return ( +
+

{title}

+ +
+ ); +} + +function AddToIPGroupPanel({ + ip, + open, + onClose, +}: { + ip: string; + open: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [selectedGroupId, setSelectedGroupId] = useState(''); + + 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 ( + { + if (!next) { + setSelectedGroupId(''); + onClose(); + } + }} + > + + + 将 IP 加入 IP 组 + + 目标 IP: + {ip} + + + + {groupsQuery.isLoading ? ( + + ) : groupsQuery.isError ? ( + void groupsQuery.refetch()} + /> + ) : ( +
+ {matchedGroups.length > 0 ? ( +
+

+ 该 IP 已存在于以下 IP 组 +

+

+ 可选择删除,或继续添加到其他 IP 组。 +

+
+ {matchedGroups.map((group) => ( +
+
+

+ {group.name} +

+

+ {group.type} · {group.ip_list?.length ?? 0} 条 +

+
+ {group.type === 'manual' ? ( + + ) : ( + + 不可手动删除 + + )} +
+ ))} +
+
+ ) : ( +

+ 该 IP 尚未加入任何 IP 组。 +

+ )} + +
+

添加到其他 IP 组

+ {addableGroups.length === 0 ? ( +

+ 没有可写入的手动 IP 组(或已全部包含该 IP)。 +

+ ) : ( + + )} +
+
+ )} + + + + + +
+
+ ); +} + +export function AccessLogDetailDialog({ + open, + item, + onOpenChange, +}: { + open: boolean; + item: AccessLogItem | null; + onOpenChange: (open: boolean) => void; +}) { + const [ipGroupOpen, setIpGroupOpen] = useState(false); + const [rangeHours, setRangeHours] = useState(24); + // Keep last selected item while the dialog closes to avoid empty-state flash. + const [displayItem, setDisplayItem] = useState(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 ( + <> + + + + 访问日志详情 + + 查看请求字段、User-Agent 与该 IP 的访问趋势及分析数据。 + + + + {activeItem && ( +
+
+ + + + + + + {activeItem.status_code} + + } + /> + + +
+ +
+ + { + if (!value) return; + setRangeHours( + Number.parseInt(value, 10) as OverviewRangeHours, + ); + }} + variant='outline' + size='sm' + > + {OVERVIEW_RANGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+ + {!ip ? ( + + ) : isLoadingIP ? ( + + ) : ( +
+ {analysisQuery.isError ? ( + void analysisQuery.refetch()} + /> + ) : analysis ? ( +
+ + + + + + +
+ ) : null} + +
+
+
+

IP 请求趋势

+

+ {ip} · {rangeHint} · {bucketMinutes} 分钟桶 +

+
+ +
+ + {trendQuery.isError ? ( + void trendQuery.refetch()} + /> + ) : trendChartData.every( + (point) => point.requests === 0, + ) ? ( + + ) : ( + + + + + + formatCompactNumber(Number(value)) + } + /> + } /> + + + + )} +
+ + {analysis ? ( +
+ + + + + + +
+ ) : null} +
+ )} +
+ )} +
+
+ + {ip ? ( + setIpGroupOpen(false)} + /> + ) : null} + + ); +} diff --git a/frontend/app/(main)/access-logs/components/detail-tab.tsx b/frontend/app/(main)/access-logs/components/detail-tab.tsx index 3eb6c741..d7600c8e 100644 --- a/frontend/app/(main)/access-logs/components/detail-tab.tsx +++ b/frontend/app/(main)/access-logs/components/detail-tab.tsx @@ -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(null); + const [detailOpen, setDetailOpen] = useState(false); + return ( -
-
-

日志明细

- -
- {error ? ( -
- + <> +
+
+

日志明细

+
- ) : loading ? ( - - ) : (data?.items ?? []).length === 0 ? ( - - ) : ( - - - - 时间 - 节点 - IP - 域名 - 路径 - User-Agent - 状态码 - - - - {(data?.items ?? []).map((item) => ( - - - {formatDateTime(item.logged_at)} - - - {item.node_name || item.node_id} - - - {item.remote_addr} - - {item.host} - - {item.path} - - - {item.user_agent || '—'} - - - - {item.status_code} - - + {error ? ( +
+ +
+ ) : loading ? ( + + ) : (data?.items ?? []).length === 0 ? ( + + ) : ( +
+ + + 时间 + 节点 + IP + 域名 + 路径 + 状态码 + - ))} - -
- )} - + + {(data?.items ?? []).map((item) => ( + + + {formatDateTime(item.logged_at)} + + + {item.node_name || item.node_id} + + + {item.remote_addr} + + {item.host} + + {item.path} + + + + {item.status_code} + + + + + + + + + 查看详情 + + + + + ))} + + + )} + +
+ + -
+ ); } diff --git a/frontend/app/(main)/access-logs/page.tsx b/frontend/app/(main)/access-logs/page.tsx index dfb5ae81..58e504a1 100644 --- a/frontend/app/(main)/access-logs/page.tsx +++ b/frontend/app/(main)/access-logs/page.tsx @@ -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('overview'); + const tab = resolveTab(searchParams.get('tab')); const [draft, setDraft] = useState(emptyDraft); const [filters, setFilters] = useState(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() {
- setTab(value as AccessLogTab)} - > + 概览 日志明细 @@ -207,3 +219,18 @@ export default function AccessLogsPage() { ); } + +export default function AccessLogsPage() { + return ( + + } + > + + + ); +} diff --git a/frontend/lib/services/openflare/access-log.service.ts b/frontend/lib/services/openflare/access-log.service.ts index 29cb9790..e8c3db0d 100644 --- a/frontend/lib/services/openflare/access-log.service.ts +++ b/frontend/lib/services/openflare/access-log.service.ts @@ -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 { + return this.get( + '/ip-summary/analysis', + buildSearchParams(filters), + ); + } + static cleanup( payload: AccessLogCleanupPayload, ): Promise { diff --git a/frontend/lib/services/openflare/index.ts b/frontend/lib/services/openflare/index.ts index c961cc12..4ef01046 100644 --- a/frontend/lib/services/openflare/index.ts +++ b/frontend/lib/services/openflare/index.ts @@ -82,6 +82,8 @@ export type { AccessLogCleanupPayload, AccessLogCleanupResult, AccessLogFilters, + AccessLogIPAnalysis, + AccessLogIPAnalysisFilters, AccessLogIPSummaryFilters, AccessLogIPSummaryItem, AccessLogIPSummaryList, diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index ebcd529b..fd20e6a7 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -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; } diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go index bc27af77..823c2289 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/internal/apps/openflare/observability/access_log_logics.go @@ -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 { diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index 7fbd0c9e..4e1a9ace 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -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 按保留天数清理过期访问日志记录,需要管理员权限 diff --git a/internal/router/v1/openflare/register_observability.go b/internal/router/v1/openflare/register_observability.go index 38507c21..ba47d318 100644 --- a/internal/router/v1/openflare/register_observability.go +++ b/internal/router/v1/openflare/register_observability.go @@ -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) } }