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 (
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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 (
+ <>
+
+
+ {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)
}
}