diff --git a/docs/changelog/index.md b/docs/changelog/index.md
index dbf09f84..c7318c30 100644
--- a/docs/changelog/index.md
+++ b/docs/changelog/index.md
@@ -23,11 +23,12 @@ sidebar: false
### 变更
+- 访问日志新增「IP 明细」标签页:可按 24 小时 / 7 天 / 15 天 / 30 天或自定义时间区间查看每个 IP 的请求数、2xx 比例、入站/出站流量与最后访问时间,并支持排序与详情分析;日志明细详情仅展示单条请求字段,IP 情报改在 IP 明细中查看。
- 边缘缓存默认策略调整为「标准静态资源」:新建开启缓存时推荐仅缓存 css/js/图片/字体等扩展名(不含 HTML);原「按 URL」与空策略存量行为保留为「所有可缓存 GET」。重新发布节点配置后生效。
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
-- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
+- 访问日志页面重构为「概览」「IP 明细」与「日志明细」标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠视图。
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
-- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
+- 访问日志明细支持查看单条请求详情(含 User-Agent、缓存状态等);IP 访问趋势、分析与加入 IP 组能力集中在 IP 明细详情中。
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
- 访问日志概览支持按 Zone/域名多选筛选,并可折叠展开层级选择。
- 访问日志明细列表在 IP 旁展示对应国家/地区信息。
diff --git a/docs/docs.go b/docs/docs.go
index 19a57034..6e2473b2 100644
--- a/docs/docs.go
+++ b/docs/docs.go
@@ -5217,7 +5217,7 @@ const docTemplate = `{
"SessionCookie": []
}
],
- "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限",
+ "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限",
"produces": [
"application/json"
],
@@ -5244,6 +5244,24 @@ const docTemplate = `{
"name": "host",
"in": "query"
},
+ {
+ "type": "integer",
+ "description": "统计时间范围(小时,1-720,默认 168)",
+ "name": "hours",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)",
+ "name": "since",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "结束时间 RFC3339",
+ "name": "until",
+ "in": "query"
+ },
{
"type": "integer",
"description": "页码",
@@ -5258,7 +5276,7 @@ const docTemplate = `{
},
{
"type": "string",
- "description": "排序字段",
+ "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr",
"name": "sort_by",
"in": "query"
},
@@ -16312,6 +16330,9 @@ const docTemplate = `{
"has_more": {
"type": "boolean"
},
+ "hours": {
+ "type": "integer"
+ },
"items": {
"type": "array",
"items": {
@@ -16324,6 +16345,9 @@ const docTemplate = `{
"page_size": {
"type": "integer"
},
+ "since": {
+ "type": "string"
+ },
"sort_by": {
"type": "string"
},
@@ -16332,21 +16356,40 @@ const docTemplate = `{
},
"total_ip": {
"type": "integer"
+ },
+ "until": {
+ "type": "string"
}
}
},
"observability.AccessLogIPSummaryView": {
"type": "object",
"properties": {
+ "bytes_received": {
+ "type": "integer"
+ },
+ "bytes_sent": {
+ "type": "integer"
+ },
"last_seen_at": {
"type": "string"
},
"recent_requests": {
+ "description": "RecentRequests is deprecated and always 0.",
"type": "integer"
},
+ "region": {
+ "type": "string"
+ },
"remote_addr": {
"type": "string"
},
+ "success_2xx_count": {
+ "type": "integer"
+ },
+ "success_ratio": {
+ "type": "number"
+ },
"total_requests": {
"type": "integer"
}
diff --git a/docs/plan/20260719-access-log-ip-tab.md b/docs/plan/20260719-access-log-ip-tab.md
new file mode 100644
index 00000000..8a3354ce
--- /dev/null
+++ b/docs/plan/20260719-access-log-ip-tab.md
@@ -0,0 +1,161 @@
+# 访问日志 IP 明细 Tab — 实现计划
+
+## 1. 目标与背景 (Goal & Context)
+
+* **需求背景**:运维需要按 IP 维度快速查看时间窗内的访问量与流量,并下钻单 IP 情报;原先 IP 分析嵌在「单条访问日志详情」中,入口弱、列表能力缺失。
+* **开发范围 (Scope) V1**:
+ * 访问日志页新增第三 Tab **「IP 明细」**(`?tab=ips`)。
+ * IP 列表:时间筛选(快捷 24h/7d/15d/30d + 自定义 since/until)、分页、按请求数 / 入站 / 出站 / 最后访问 / 2xx 比例排序。
+ * 列表列:IP、地区、请求数、2xx 比例(2xx 数 / 总请求)、入站流量、出站流量、最后访问。
+ * 行详情:弹窗展示完整 **IP 情报**(分析 + 趋势 + Top 分布 + 加入 WAF IP 组)。
+ * **日志明细详情弹窗仅展示单条请求字段**,不再内嵌 IP 情报;如需分析请到 IP 明细。
+* **Out of Scope(V1 不做)**:
+ * 独立 `/access-logs/ip` 子路由全页。
+ * IP 列表 UI 暴露节点 / host 筛选(后端可保留兼容参数,前端首版不放)。
+ * 入/出站带宽时间序列(趋势图仍为请求数)。
+ * 实时 GeoIP 二次查询(沿用入库 `region`)。
+
+## 2. 设计与决策 (Design & Decisions)
+
+### 2.1 页面与交互
+
+| Tab | URL | 内容 |
+| --- | --- | --- |
+| 概览 | `/access-logs` | 不变 |
+| IP 明细 | `/access-logs?tab=ips` | 新 |
+| 日志明细 | `/access-logs?tab=list` | 不变;详情弹窗瘦身 |
+
+* **时间筛选(IP 明细)**:
+ * 快捷:`hours` ∈ {24, 168, 360, 720},默认 168(7d)。
+ * 自定义:`since` + `until`(RFC3339);**同时提供时覆盖 hours**。
+* **详情形态**:留在列表页的 Dialog(非独立子页)。
+* **日志详情瘦身**:`access-log-detail-dialog` 只渲染请求字段(时间、节点、IP、地区、host、path、UA、cache、status 等)及必要操作;删除 analysis/trend/WAF 组内嵌区块。WAF「按 IP 加入组」仅保留在 IP 详情弹窗。
+
+### 2.2 API 设计(扩展现有端点,不新建)
+
+**`GET /api/v1/d/access-logs/ip-summary`**
+
+| 参数 | 说明 |
+| --- | --- |
+| `hours` | 1–720;默认 168;在无 since/until 时生效 |
+| `since` / `until` | 可选 RFC3339;同时有效时优先于 hours |
+| `sort_by` | `total_requests`(默认)\| `request_length`(入站)\| `bytes_sent`(出站)\| `last_seen_at` \| `success_ratio` |
+| `sort_order` | `asc` \| `desc` |
+| `p` / `page_size` | 分页,page_size 上限 200 |
+| `remote_addr` / `node_id` / `host` | 兼容保留;V1 UI 可不暴露 |
+
+**响应行字段(扩展)**
+
+```text
+remote_addr string
+region string // 窗内 argMax(region, logged_at) 或等价
+total_requests uint64
+success_2xx_count uint64 // status_code 200–299
+success_ratio float64 // success_2xx_count / total_requests;total=0 时为 0
+bytes_received uint64 // sum(request_length) 入站
+bytes_sent uint64 // sum(bytes_sent) 出站
+last_seen_at time
+```
+
+* `recent_requests`:可停止计算或固定返回 0;**UI 不展示**。避免与可配置时间窗语义冲突。
+* 详情下钻仍用现有:
+ * `GET .../ip-summary/analysis?remote_addr=&hours=`(或 since/until,若后续扩展;V1 将列表当前窗映射为 hours 或 since/until 与后端约定一致)
+ * `GET .../ip-summary/trend?remote_addr=&hours=&bucket_minutes=`
+
+**分析/趋势时间窗对齐**:打开 IP 详情时,将列表当前时间窗传入 analysis/trend(优先 since/until;仅有 hours 则传 hours)。
+
+### 2.3 数据层(ClickHouse)
+
+* 表:`of_node_access_logs`(已有 `bytes_sent`、`request_length`、`status_code`、`region`)。
+* 聚合:`GROUP BY remote_addr`,在 `NodeAccessLogFilter.Since/Until` 上过滤。
+* 2xx:`countIf(status_code >= 200 AND status_code < 300)`。
+* region:`argMax(region, logged_at)`。
+* 排序:服务端 ORDER BY 对应表达式;`success_ratio` 注意除零(`if(total=0,0,ratio)`)。
+
+### 2.4 设计决策权衡
+
+| 选项 | 结论 |
+| --- | --- |
+| 扩展 `/ip-summary` vs 新 `/ip-list` | **扩展现有**,前端 service 已有 `listIPSummaries` |
+| 详情弹窗 vs 子页 | **弹窗**,与现有明细交互一致 |
+| IP 情报放日志详情 vs 独立 IP 详情 | **仅 IP 明细详情**;日志详情只展示请求信息 |
+| 时间:仅快捷 vs 仅自定义 | **两者都要**,自定义优先 |
+
+### 2.5 数据流(示意)
+
+```mermaid
+flowchart LR
+ UI_IP[IP 明细 Tab] --> API_List[GET /ip-summary]
+ API_List --> CH[(of_node_access_logs)]
+ UI_IP --> UI_Dlg[IP 详情 Dialog]
+ UI_Dlg --> API_A[GET /ip-summary/analysis]
+ UI_Dlg --> API_T[GET /ip-summary/trend]
+ API_A --> CH
+ API_T --> CH
+ UI_List[日志明细 Tab] --> API_Logs[GET /access-logs]
+ UI_List --> UI_LogDlg[日志详情 Dialog]
+ UI_LogDlg -.->|不请求 IP 分析| X[仅请求字段]
+```
+
+## 3. 具体修改文件清单 (Proposed Changes)
+
+### 后端 Server
+
+* #### [MODIFY] `internal/repository/analytics/node_access_log_stats.go`(及 filter 如有)
+ * `IPSummariesNodeAccessLogs`:时间窗、sum 入/出、2xx count、ratio、region、扩展 sort。
+* #### [MODIFY] `internal/apps/openflare/observability/access_log_logics.go`
+ * Query/View 类型扩展;解析 hours/since/until;去掉或忽略 recent 3h 硬编码。
+* #### [MODIFY] `internal/apps/openflare/observability/routers.go` / handler
+ * 绑定新 query;Swagger 注释。
+* #### [MODIFY] 相关单元测试(logics / repository 若有)
+
+### 前端 Web
+
+* #### [MODIFY] `frontend/app/(main)/access-logs/page.tsx`
+ * 第三 Tab `ips`;`resolveTab` / `handleTabChange`。
+* #### [NEW] `frontend/app/(main)/access-logs/components/ip-tab.tsx`
+ * 列表、时间筛选、排序、分页、打开详情。
+* #### [NEW] `frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx`
+ * IP 入口详情壳。
+* #### [NEW] `frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx`
+ * 从现有 `access-log-detail-dialog` **迁出** 分析/趋势/排行/WAF IP 组逻辑。
+* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx`
+ * **删除** IP 情报相关 UI 与 `getIPAnalysis` / `getIPTrend` 请求;仅请求日志字段展示。
+* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-utils.ts`
+ * tab 类型、IP 排序选项、时间筛选辅助。
+* #### [MODIFY] `frontend/lib/services/openflare/access-log.service.ts` + `types.ts`
+ * `listIPSummaries` 参数与 `AccessLogIPSummaryItem` 字段同步。
+
+### 文档
+
+* #### [MODIFY] `docs/changelog/index.md` — `[Unreleased]` 用户可见说明
+* #### [MODIFY] `docs/design/observability-design.md` 或 data-model(如有访问日志 UI 约定)— 补充 IP 明细 Tab 与 API 字段(中文)
+* #### [MODIFY] `docs/plan/index.md` — 挂上本计划链接
+
+## 4. 验证计划 (Verification Plan)
+
+### 自动化
+
+```bash
+go test ./internal/apps/openflare/observability/ ./internal/repository/analytics/
+# 前端:相关 tsc / 页面无类型错误
+make code-check # 完成后按项目门禁
+make prettier
+make swagger # API 注释变更后
+```
+
+### 手动
+
+1. `/access-logs?tab=ips` 默认 7d 列表有数据;切换 24h/自定义区间结果变化。
+2. 分别按请求数、入站、出站、2xx 比例、最后访问排序正确。
+3. 2xx 比例 = 2xx/总数;0 请求不出现 NaN/Infinity。
+4. 点 IP 打开详情:指标/趋势/Top/WAF 组可用;时间窗与列表一致。
+5. 日志明细 → 详情:仅请求信息,**无** IP 分析/趋势区块。
+6. 概览 Tab 行为无回归。
+
+## 5. 状态
+
+- [x] 需求澄清与方案确认
+- [x] 实现(后端 ip-summary 扩展 + 前端 IP 明细 Tab + 日志详情瘦身)
+- [x] 测试与 changelog(`go test` 相关包通过;changelog 已更新)
+- [ ] 提交合并
diff --git a/docs/plan/index.md b/docs/plan/index.md
index 3066f7b7..974203a5 100644
--- a/docs/plan/index.md
+++ b/docs/plan/index.md
@@ -18,6 +18,7 @@
* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。
* [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。
* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。
+* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。
## 使用建议
diff --git a/docs/swagger.json b/docs/swagger.json
index a6a01155..7355be74 100644
--- a/docs/swagger.json
+++ b/docs/swagger.json
@@ -5210,7 +5210,7 @@
"SessionCookie": []
}
],
- "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限",
+ "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限",
"produces": [
"application/json"
],
@@ -5237,6 +5237,24 @@
"name": "host",
"in": "query"
},
+ {
+ "type": "integer",
+ "description": "统计时间范围(小时,1-720,默认 168)",
+ "name": "hours",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)",
+ "name": "since",
+ "in": "query"
+ },
+ {
+ "type": "string",
+ "description": "结束时间 RFC3339",
+ "name": "until",
+ "in": "query"
+ },
{
"type": "integer",
"description": "页码",
@@ -5251,7 +5269,7 @@
},
{
"type": "string",
- "description": "排序字段",
+ "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr",
"name": "sort_by",
"in": "query"
},
@@ -16305,6 +16323,9 @@
"has_more": {
"type": "boolean"
},
+ "hours": {
+ "type": "integer"
+ },
"items": {
"type": "array",
"items": {
@@ -16317,6 +16338,9 @@
"page_size": {
"type": "integer"
},
+ "since": {
+ "type": "string"
+ },
"sort_by": {
"type": "string"
},
@@ -16325,21 +16349,40 @@
},
"total_ip": {
"type": "integer"
+ },
+ "until": {
+ "type": "string"
}
}
},
"observability.AccessLogIPSummaryView": {
"type": "object",
"properties": {
+ "bytes_received": {
+ "type": "integer"
+ },
+ "bytes_sent": {
+ "type": "integer"
+ },
"last_seen_at": {
"type": "string"
},
"recent_requests": {
+ "description": "RecentRequests is deprecated and always 0.",
"type": "integer"
},
+ "region": {
+ "type": "string"
+ },
"remote_addr": {
"type": "string"
},
+ "success_2xx_count": {
+ "type": "integer"
+ },
+ "success_ratio": {
+ "type": "number"
+ },
"total_requests": {
"type": "integer"
}
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
index c72e7c91..6bbf8815 100644
--- a/docs/swagger.yaml
+++ b/docs/swagger.yaml
@@ -1929,6 +1929,8 @@ definitions:
properties:
has_more:
type: boolean
+ hours:
+ type: integer
items:
items:
$ref: '#/definitions/observability.AccessLogIPSummaryView'
@@ -1937,21 +1939,36 @@ definitions:
type: integer
page_size:
type: integer
+ since:
+ type: string
sort_by:
type: string
sort_order:
type: string
total_ip:
type: integer
+ until:
+ type: string
type: object
observability.AccessLogIPSummaryView:
properties:
+ bytes_received:
+ type: integer
+ bytes_sent:
+ type: integer
last_seen_at:
type: string
recent_requests:
+ description: RecentRequests is deprecated and always 0.
type: integer
+ region:
+ type: string
remote_addr:
type: string
+ success_2xx_count:
+ type: integer
+ success_ratio:
+ type: number
total_requests:
type: integer
type: object
@@ -7173,7 +7190,7 @@ paths:
- openflare-observability
/api/v1/d/access-logs/ip-summary:
get:
- description: 按 IP 聚合访问日志统计并分页返回,需要管理员权限
+ description: 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限
parameters:
- description: 节点 ID
in: query
@@ -7187,6 +7204,18 @@ paths:
in: query
name: host
type: string
+ - description: 统计时间范围(小时,1-720,默认 168)
+ in: query
+ name: hours
+ type: integer
+ - description: 开始时间 RFC3339(与 until 同时提供时优先于 hours)
+ in: query
+ name: since
+ type: string
+ - description: 结束时间 RFC3339
+ in: query
+ name: until
+ type: string
- description: 页码
in: query
name: p
@@ -7195,7 +7224,7 @@ paths:
in: query
name: page_size
type: integer
- - description: 排序字段
+ - description: 排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr
in: query
name: sort_by
type: string
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
index b50b59ef..7b95d01c 100644
--- a/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx
+++ b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx
@@ -1,83 +1,20 @@
'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 { useState } from 'react';
-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 type { AccessLogItem } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
-import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
+import { formatBytes } from '@/lib/utils/metrics';
-import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers';
-import {
- cacheOutcomeLabel,
- formatOverviewRangeHint,
- formatOverviewTrendLabel,
- OVERVIEW_RANGE_OPTIONS,
- resolveCacheOutcome,
- 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,
- }));
-}
+import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils';
function DetailField({
label,
@@ -104,268 +41,14 @@ function DetailField({
);
}
-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 (
-
- );
+function formatRequestTimeMs(value: number | undefined | null) {
+ if (value == null || !Number.isFinite(value) || value < 0) {
+ return '—';
+ }
+ if (value < 1000) {
+ return `${Math.round(value)} ms`;
+ }
+ return `${(value / 1000).toFixed(2)} s`;
}
export function AccessLogDetailDialog({
@@ -377,328 +60,104 @@ export function AccessLogDetailDialog({
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 (
- <>
-
);
}
diff --git a/frontend/app/(main)/access-logs/components/access-log-utils.ts b/frontend/app/(main)/access-logs/components/access-log-utils.ts
index bc0be48c..829bf527 100644
--- a/frontend/app/(main)/access-logs/components/access-log-utils.ts
+++ b/frontend/app/(main)/access-logs/components/access-log-utils.ts
@@ -1,4 +1,4 @@
-export type AccessLogTab = 'overview' | 'list';
+export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
nodeId: string;
@@ -30,6 +30,19 @@ export const DETAIL_SORT_OPTIONS = [
{ value: 'remote_addr:desc', label: 'IP 倒序' },
];
+export const IP_SORT_OPTIONS = [
+ { value: 'total_requests:desc', label: '请求数从高到低' },
+ { value: 'total_requests:asc', label: '请求数从低到高' },
+ { value: 'request_length:desc', label: '入站从高到低' },
+ { value: 'request_length:asc', label: '入站从低到高' },
+ { value: 'bytes_sent:desc', label: '出站从高到低' },
+ { value: 'bytes_sent:asc', label: '出站从低到高' },
+ { value: 'success_ratio:desc', label: '2xx 比例从高到低' },
+ { value: 'success_ratio:asc', label: '2xx 比例从低到高' },
+ { value: 'last_seen_at:desc', label: '最后访问从新到旧' },
+ { value: 'last_seen_at:asc', label: '最后访问从旧到新' },
+];
+
export function parseSortValue(value: string) {
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
return {
diff --git a/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx b/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx
new file mode 100644
index 00000000..ae6f2a92
--- /dev/null
+++ b/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx
@@ -0,0 +1,607 @@
+'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 DistributionItem,
+ type WAFIPGroup,
+ WafService,
+} from '@/lib/services/openflare';
+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: number) {
+ if (hours <= 24) return 30;
+ 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,
+ }));
+}
+
+/** Clamp analysis/trend window to API limits (1–720 hours). */
+function clampAnalysisHours(hours: number): number {
+ if (!Number.isFinite(hours) || hours <= 0) return 24;
+ return Math.min(720, Math.max(1, Math.round(hours)));
+}
+
+function isOverviewPreset(hours: number): hours is OverviewRangeHours {
+ return hours === 24 || hours === 168 || hours === 360 || hours === 720;
+}
+
+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 (
+ {
+ 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 IpAnalysisPanel({
+ ip,
+ enabled,
+ initialHours = 24,
+}: {
+ ip: string;
+ enabled: boolean;
+ /**
+ * Exact analysis window in hours (1–720), aligned with list filter duration.
+ * Remount with key={ip} when switching IPs so this re-initializes.
+ */
+ initialHours?: number;
+}) {
+ const [ipGroupOpen, setIpGroupOpen] = useState(false);
+ const [rangeHours, setRangeHours] = useState(() =>
+ clampAnalysisHours(initialHours),
+ );
+
+ const bucketMinutes = resolveBucketMinutes(rangeHours);
+ const rangeHint = isOverviewPreset(rangeHours)
+ ? formatOverviewRangeHint(rangeHours)
+ : `近 ${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: enabled && ip !== '',
+ });
+
+ const analysisQuery = useQuery({
+ queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours],
+ queryFn: () =>
+ AccessLogService.getIPAnalysis({
+ remote_addr: ip,
+ hours: rangeHours,
+ }),
+ enabled: enabled && 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;
+
+ if (!ip) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+ {
+ if (!value) return;
+ setRangeHours(clampAnalysisHours(Number.parseInt(value, 10)));
+ }}
+ variant='outline'
+ size='sm'
+ >
+ {OVERVIEW_RANGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ {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}
+
+ )}
+
+
+ setIpGroupOpen(false)}
+ />
+ >
+ );
+}
diff --git a/frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx b/frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx
new file mode 100644
index 00000000..d8051ff6
--- /dev/null
+++ b/frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import { useState } from 'react';
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+
+import { IpAnalysisPanel } from './ip-analysis-panel';
+
+export function IpDetailDialog({
+ open,
+ remoteAddr,
+ region,
+ initialHours,
+ onOpenChange,
+}: {
+ open: boolean;
+ remoteAddr: string | null;
+ region?: string;
+ initialHours?: number;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const [displayAddr, setDisplayAddr] = useState(remoteAddr);
+ const [displayRegion, setDisplayRegion] = useState(region);
+ if (remoteAddr && remoteAddr !== displayAddr) {
+ setDisplayAddr(remoteAddr);
+ setDisplayRegion(region);
+ }
+ const ip = remoteAddr ?? displayAddr ?? '';
+
+ return (
+
+
+
+ IP 详情
+
+ {ip || '—'}
+ {displayRegion ? (
+ · {displayRegion}
+ ) : null}
+ 。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/access-logs/components/ip-tab.tsx b/frontend/app/(main)/access-logs/components/ip-tab.tsx
new file mode 100644
index 00000000..0d7b7faa
--- /dev/null
+++ b/frontend/app/(main)/access-logs/components/ip-tab.tsx
@@ -0,0 +1,506 @@
+'use client';
+
+import { useEffect, useMemo, 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';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import type {
+ AccessLogIPSummaryItem,
+ AccessLogIPSummaryList,
+} from '@/lib/services/openflare';
+import { formatDateTime } from '@/lib/utils';
+import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
+
+import {
+ formatOverviewRangeHint,
+ IP_SORT_OPTIONS,
+ OVERVIEW_RANGE_OPTIONS,
+ PAGE_SIZE_OPTIONS,
+ type OverviewRangeHours,
+} from './access-log-utils';
+import { IpDetailDialog } from './ip-detail-dialog';
+
+export function toLocalInputValue(date: Date) {
+ const pad = (n: number) => `${n}`.padStart(2, '0');
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
+}
+
+/** Default local range ending now, spanning `hours`. */
+export function defaultLocalRangeForHours(hours: number) {
+ const until = new Date();
+ const since = new Date(until.getTime() - hours * 3_600_000);
+ return {
+ since: toLocalInputValue(since),
+ until: toLocalInputValue(until),
+ };
+}
+
+function localInputToRFC3339(value: string) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ return date.toISOString();
+}
+
+const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000;
+
+/** Validate custom local datetime-local range; returns error message or null. */
+export function validateCustomTimeRange(
+ sinceLocal: string,
+ untilLocal: string,
+): string | null {
+ if (!sinceLocal.trim() || !untilLocal.trim()) {
+ return '请填写开始与结束时间';
+ }
+ const sinceMs = new Date(sinceLocal).getTime();
+ const untilMs = new Date(untilLocal).getTime();
+ if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) {
+ return '时间格式无效';
+ }
+ if (untilMs <= sinceMs) {
+ return '结束时间必须晚于开始时间';
+ }
+ if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) {
+ return '时间范围不能超过 30 天';
+ }
+ return null;
+}
+
+function formatRatio(ratio: number) {
+ if (!Number.isFinite(ratio) || ratio < 0) return '0%';
+ return `${(ratio * 100).toFixed(1)}%`;
+}
+
+/** Exact hours for analysis API (1–720), matching list window duration. */
+export function resolveAnalysisHours(input: {
+ timeMode: IpTabTimeMode;
+ hours: OverviewRangeHours;
+ customSince: string;
+ customUntil: string;
+}): number {
+ if (input.timeMode === 'custom' && input.customSince && input.customUntil) {
+ const ms =
+ new Date(input.customUntil).getTime() -
+ new Date(input.customSince).getTime();
+ if (Number.isFinite(ms) && ms > 0) {
+ return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000)));
+ }
+ }
+ return input.hours;
+}
+
+function PaginationBar({
+ page,
+ hasMore,
+ loading,
+ totalIp,
+ onPrev,
+ onNext,
+}: {
+ page: number;
+ hasMore: boolean;
+ loading: boolean;
+ totalIp?: number;
+ onPrev: () => void;
+ onNext: () => void;
+}) {
+ return (
+
+
+ 当前第 {page + 1} 页
+ {typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
+
+
+
+
+
+
+ );
+}
+
+export type IpTabTimeMode = 'preset' | 'custom';
+
+export function IpTab({
+ data,
+ loading,
+ error,
+ page,
+ pageSize,
+ sort,
+ hours,
+ timeMode,
+ customSince,
+ customUntil,
+ onHoursChange,
+ onTimeModeChange,
+ onApplyCustomRange,
+ onPageSizeChange,
+ onSortChange,
+ onRetry,
+ onPrevPage,
+ onNextPage,
+ isFetching,
+}: {
+ data?: AccessLogIPSummaryList;
+ loading: boolean;
+ error: Error | null;
+ page: number;
+ pageSize: number;
+ sort: string;
+ hours: OverviewRangeHours;
+ timeMode: IpTabTimeMode;
+ /** Applied custom range (local datetime-local strings). */
+ customSince: string;
+ customUntil: string;
+ onHoursChange: (hours: OverviewRangeHours) => void;
+ onTimeModeChange: (mode: IpTabTimeMode) => void;
+ /** Commit validated custom range for list query. */
+ onApplyCustomRange: (since: string, until: string) => void;
+ onPageSizeChange: (size: number) => void;
+ onSortChange: (value: string) => void;
+ onRetry: () => void;
+ onPrevPage: () => void;
+ onNextPage: () => void;
+ isFetching: boolean;
+}) {
+ const [selected, setSelected] = useState(null);
+ const [detailOpen, setDetailOpen] = useState(false);
+ const [draftSince, setDraftSince] = useState(customSince);
+ const [draftUntil, setDraftUntil] = useState(customUntil);
+ const [customError, setCustomError] = useState(null);
+
+ const defaultCustomRange = useMemo(
+ () => defaultLocalRangeForHours(hours),
+ [hours],
+ );
+
+ useEffect(() => {
+ if (timeMode !== 'custom') {
+ setCustomError(null);
+ return;
+ }
+ // Sync draft from applied range when entering custom or after apply.
+ setDraftSince(customSince || defaultCustomRange.since);
+ setDraftUntil(customUntil || defaultCustomRange.until);
+ setCustomError(null);
+ }, [timeMode, customSince, customUntil, defaultCustomRange]);
+
+ const analysisHours = resolveAnalysisHours({
+ timeMode,
+ hours,
+ customSince,
+ customUntil,
+ });
+
+ const rangeHint =
+ timeMode === 'custom' && customSince && customUntil
+ ? '自定义区间'
+ : timeMode === 'custom'
+ ? '自定义(未应用)'
+ : formatOverviewRangeHint(hours);
+
+ const handleApplyCustom = () => {
+ const message = validateCustomTimeRange(draftSince, draftUntil);
+ if (message) {
+ setCustomError(message);
+ return;
+ }
+ setCustomError(null);
+ onApplyCustomRange(draftSince, draftUntil);
+ };
+
+ return (
+ <>
+
+
+
+
+
时间范围
+
+ 当前:{rangeHint}
+ {data?.total_ip != null
+ ? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
+ : ''}
+
+
+
+ {
+ if (!value) return;
+ onTimeModeChange('preset');
+ onHoursChange(
+ Number.parseInt(value, 10) as OverviewRangeHours,
+ );
+ }}
+ variant='outline'
+ size='sm'
+ >
+ {OVERVIEW_RANGE_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+
+ {timeMode === 'custom' ? (
+
+
+
+
开始
+
{
+ setDraftSince(e.target.value);
+ setCustomError(null);
+ }}
+ />
+
+
+
结束
+
{
+ setDraftUntil(e.target.value);
+ setCustomError(null);
+ }}
+ />
+
+
+
+ {customError ? (
+
{customError}
+ ) : (
+
+ 修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
+
+ )}
+
+ ) : null}
+
+
+
+
排序
+
+
+
+
每页
+
+
+
+
+
+
+
+ {error ? (
+
+
+
+ ) : loading ? (
+
+ ) : (data?.items ?? []).length === 0 ? (
+
+ ) : (
+
+
+
+ IP
+ 地区
+ 请求数
+ 2xx 比例
+ 入站
+ 出站
+ 最后访问
+
+
+
+
+ {(data?.items ?? []).map((item) => (
+
+
+ {item.remote_addr}
+
+
+ {item.region || '—'}
+
+
+ {formatCompactNumber(item.total_requests)}
+
+
+
+ {formatRatio(item.success_ratio)}
+
+
+
+ {formatBytes(item.bytes_received)}
+
+
+ {formatBytes(item.bytes_sent)}
+
+
+ {formatDateTime(item.last_seen_at)}
+
+
+
+
+
+
+
+ 查看 IP 详情
+
+
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ >
+ );
+}
+
+/** Build list API time params from IP tab state (applied values only). */
+export function buildIpSummaryTimeParams(input: {
+ timeMode: IpTabTimeMode;
+ hours: OverviewRangeHours;
+ customSince: string;
+ customUntil: string;
+}): { hours?: number; since?: string; until?: string } | null {
+ if (input.timeMode === 'custom') {
+ if (validateCustomTimeRange(input.customSince, input.customUntil)) {
+ return null;
+ }
+ const since = localInputToRFC3339(input.customSince);
+ const until = localInputToRFC3339(input.customUntil);
+ if (!since || !until) {
+ return null;
+ }
+ return { since, until };
+ }
+ return { hours: input.hours };
+}
diff --git a/frontend/app/(main)/access-logs/page.tsx b/frontend/app/(main)/access-logs/page.tsx
index ef91ccfd..bac541a7 100644
--- a/frontend/app/(main)/access-logs/page.tsx
+++ b/frontend/app/(main)/access-logs/page.tsx
@@ -20,6 +20,12 @@ import {
} from './components/access-log-utils';
import { CleanupDialog } from './components/cleanup-dialog';
import { DetailTab } from './components/detail-tab';
+import {
+ buildIpSummaryTimeParams,
+ defaultLocalRangeForHours,
+ IpTab,
+ type IpTabTimeMode,
+} from './components/ip-tab';
import { OverviewTab } from './components/overview-tab';
const emptyDraft: SearchDraft = {
@@ -30,7 +36,9 @@ const emptyDraft: SearchDraft = {
};
function resolveTab(value: string | null): AccessLogTab {
- return value === 'list' ? 'list' : 'overview';
+ if (value === 'list') return 'list';
+ if (value === 'ips') return 'ips';
+ return 'overview';
}
function AccessLogsPageContent() {
@@ -43,11 +51,26 @@ function AccessLogsPageContent() {
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
+ const [ipSort, setIpSort] = useState('total_requests:desc');
+ const [ipPageSize, setIpPageSize] = useState(20);
+ const [ipPage, setIpPage] = useState(0);
+ const [ipHours, setIpHours] = useState(168);
+ const [ipTimeMode, setIpTimeMode] = useState('preset');
+ const [ipCustomSince, setIpCustomSince] = useState('');
+ const [ipCustomUntil, setIpCustomUntil] = useState('');
const [overviewHours, setOverviewHours] = useState(24);
const [overviewHosts, setOverviewHosts] = useState([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
+ const ipSortState = parseSortValue(ipSort);
+ const ipTimeParams = buildIpSummaryTimeParams({
+ timeMode: ipTimeMode,
+ hours: ipHours,
+ customSince: ipCustomSince,
+ customUntil: ipCustomUntil,
+ });
+ const ipQueryEnabled = tab === 'ips' && ipTimeParams != null;
const handleTabChange = (value: string) => {
const next = resolveTab(value);
@@ -96,6 +119,27 @@ function AccessLogsPageContent() {
enabled: tab === 'list',
});
+ const ipQuery = useQuery({
+ queryKey: [
+ 'openflare',
+ 'access-logs',
+ 'ip-summary',
+ ipTimeParams,
+ ipPage,
+ ipPageSize,
+ ipSort,
+ ],
+ queryFn: () =>
+ AccessLogService.listIPSummaries({
+ ...(ipTimeParams as NonNullable),
+ p: ipPage,
+ page_size: ipPageSize,
+ sort_by: ipSortState.sortBy,
+ sort_order: ipSortState.sortOrder,
+ }),
+ enabled: ipQueryEnabled,
+ });
+
const cleanupMutation = useMutation({
mutationFn: (retentionDays: number) =>
AccessLogService.cleanup({ retention_days: retentionDays }),
@@ -131,12 +175,26 @@ function AccessLogsPageContent() {
setPage(0);
}, [tab, pageSize]);
+ useEffect(() => {
+ setIpPage(0);
+ }, [
+ tab,
+ ipPageSize,
+ ipHours,
+ ipTimeMode,
+ ipCustomSince,
+ ipCustomUntil,
+ ipSort,
+ ]);
+
const refreshActive = () => {
if (tab === 'overview') void overviewQuery.refetch();
if (tab === 'list') void listQuery.refetch();
+ if (tab === 'ips') void ipQuery.refetch();
};
- const isFetching = overviewQuery.isFetching || listQuery.isFetching;
+ const isFetching =
+ overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
return (
@@ -146,7 +204,7 @@ function AccessLogsPageContent() {
访问日志
- 查看访问概览、排行榜与明细日志。
+ 查看访问概览、IP 明细与请求日志。
@@ -174,8 +232,9 @@ function AccessLogsPageContent() {
-
+
概览
+ IP 明细
日志明细
@@ -194,6 +253,48 @@ function AccessLogsPageContent() {
/>
+
+ {
+ setIpTimeMode('preset');
+ setIpHours(next);
+ }}
+ onTimeModeChange={(mode) => {
+ setIpTimeMode(mode);
+ if (
+ mode === 'custom' &&
+ (!ipCustomSince.trim() || !ipCustomUntil.trim())
+ ) {
+ const range = defaultLocalRangeForHours(ipHours);
+ setIpCustomSince(range.since);
+ setIpCustomUntil(range.until);
+ }
+ }}
+ onApplyCustomRange={(since, until) => {
+ setIpCustomSince(since);
+ setIpCustomUntil(until);
+ setIpTimeMode('custom');
+ setIpPage(0);
+ }}
+ onPageSizeChange={setIpPageSize}
+ onSortChange={setIpSort}
+ onRetry={() => void ipQuery.refetch()}
+ onPrevPage={() => setIpPage((p) => Math.max(0, p - 1))}
+ onNextPage={() => setIpPage((p) => p + 1)}
+ isFetching={ipQuery.isFetching}
+ />
+
+
maxAccessLogOverviewHours {
+ hours = maxAccessLogOverviewHours
+ }
+ until := time.Now().UTC()
+ return until.Add(-time.Duration(hours) * time.Hour), until, hours, nil
}
+ if sinceRaw == "" || untilRaw == "" {
+ return time.Time{}, time.Time{}, 0, errors.New("since 与 until 需同时提供")
+ }
+ parsedSince, err := time.Parse(time.RFC3339, sinceRaw)
+ if err != nil {
+ return time.Time{}, time.Time{}, 0, errors.New("since 必须为 RFC3339 时间")
+ }
+ parsedUntil, err := time.Parse(time.RFC3339, untilRaw)
+ if err != nil {
+ return time.Time{}, time.Time{}, 0, errors.New("until 必须为 RFC3339 时间")
+ }
+ since := parsedSince.UTC()
+ until := parsedUntil.UTC()
+ if !until.After(since) {
+ return time.Time{}, time.Time{}, 0, errors.New("until 必须晚于 since")
+ }
+ if until.Sub(since) > time.Duration(maxAccessLogOverviewHours)*time.Hour {
+ return time.Time{}, time.Time{}, 0, errors.New("时间范围不能超过 30 天")
+ }
+ hours = int(until.Sub(since).Hours())
+ if hours <= 0 {
+ hours = 1
+ }
+ return since, until, hours, nil
}
func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccessLogIPQuery, time.Time, error) {
@@ -1001,7 +1078,9 @@ func normalizeFoldSortBy(sortBy string) string {
func normalizeIPSummarySortBy(sortBy string) string {
switch strings.TrimSpace(sortBy) {
- case "recent_requests", "last_seen_at", accessLogFieldRemoteAddr:
+ case "request_length", "bytes_received":
+ return "request_length"
+ case "bytes_sent", "success_ratio", "last_seen_at", accessLogFieldRemoteAddr:
return strings.TrimSpace(sortBy)
default:
return "total_requests"
diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go
index 1c1b25f7..78bbceed 100644
--- a/internal/apps/openflare/observability/routers.go
+++ b/internal/apps/openflare/observability/routers.go
@@ -143,16 +143,19 @@ func GetFoldedAccessLogIPsHandler(c *gin.Context) {
// GetAccessLogIPSummariesHandler 列出访问日志 IP 汇总。
// @Summary 列出访问日志 IP 汇总
-// @Description 按 IP 聚合访问日志统计并分页返回,需要管理员权限
+// @Description 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限
// @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 "统计时间范围(小时,1-720,默认 168)"
+// @Param since query string false "开始时间 RFC3339(与 until 同时提供时优先于 hours)"
+// @Param until query string false "结束时间 RFC3339"
// @Param p query int false "页码"
// @Param page_size query int false "每页条数"
-// @Param sort_by query string false "排序字段"
+// @Param sort_by query string false "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr"
// @Param sort_order query string false "排序方向"
// @Success 200 {object} response.Any{data=observability.AccessLogIPSummaryList} "IP 汇总列表"
// @Failure 400 {object} response.Any "参数错误"
@@ -165,6 +168,9 @@ func GetAccessLogIPSummariesHandler(c *gin.Context) {
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
+ Hours: readQueryInt(c, "hours"),
+ Since: c.Query("since"),
+ Until: c.Query("until"),
Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"),
diff --git a/internal/model/analytics/node_access_log_stats.go b/internal/model/analytics/node_access_log_stats.go
index 0ee797a3..ad98ac29 100644
--- a/internal/model/analytics/node_access_log_stats.go
+++ b/internal/model/analytics/node_access_log_stats.go
@@ -46,10 +46,16 @@ type NodeAccessLogIPAggregate struct {
// NodeAccessLogIPSummary is an IP summary row.
type NodeAccessLogIPSummary struct {
- RemoteAddr string `gorm:"column:remote_addr"`
- TotalRequests int64 `gorm:"column:total_requests"`
- RecentRequests int64 `gorm:"column:recent_requests"`
- LastSeenEpoch int64 `gorm:"column:last_seen_epoch"`
+ RemoteAddr string `gorm:"column:remote_addr"`
+ Region string `gorm:"column:region"`
+ TotalRequests int64 `gorm:"column:total_requests"`
+ Success2xxCount int64 `gorm:"column:success_2xx_count"`
+ SuccessRatio float64 `gorm:"column:success_ratio"`
+ BytesReceived int64 `gorm:"column:request_length"`
+ BytesSent int64 `gorm:"column:bytes_sent"`
+ // RecentRequests is deprecated (always 0); kept for wire compatibility.
+ RecentRequests int64 `gorm:"column:recent_requests"`
+ LastSeenEpoch int64 `gorm:"column:last_seen_epoch"`
}
// NodeAccessLogIPTrend is an IP trend bucket row.
diff --git a/internal/model/openflare_access_log.go b/internal/model/openflare_access_log.go
index 9ac1ab96..363f9a40 100644
--- a/internal/model/openflare_access_log.go
+++ b/internal/model/openflare_access_log.go
@@ -251,10 +251,15 @@ func buildOpenFlareAccessLogIPSummaryRows(ctx context.Context, query OpenFlareAc
continue
}
rows = append(rows, &OpenFlareAccessLogIPSummaryRow{
- RemoteAddr: remoteAddr,
- TotalRequests: partial.TotalRequests,
- RecentRequests: partial.RecentRequests,
- LastSeenEpoch: partial.LastSeenEpoch,
+ RemoteAddr: remoteAddr,
+ Region: strings.TrimSpace(partial.Region),
+ TotalRequests: partial.TotalRequests,
+ Success2xxCount: partial.Success2xxCount,
+ SuccessRatio: partial.SuccessRatio,
+ BytesReceived: partial.BytesReceived,
+ BytesSent: partial.BytesSent,
+ RecentRequests: 0,
+ LastSeenEpoch: partial.LastSeenEpoch,
})
}
return rows, nil
@@ -305,6 +310,7 @@ func openFlareAccessLogQueryFromIPSummary(query OpenFlareAccessLogIPSummaryQuery
RemoteAddr: query.RemoteAddr,
Host: query.Host,
Since: query.Since,
+ Until: query.Until,
Page: query.Page,
PageSize: query.PageSize,
SortBy: query.SortBy,
@@ -377,8 +383,12 @@ func sortOpenFlareAccessLogIPSummaryRows(items []*OpenFlareAccessLogIPSummaryRow
}
var compare int
switch strings.TrimSpace(sortBy) {
- case "recent_requests":
- compare = openFlareAccessLogCompareInt64(left.RecentRequests, right.RecentRequests)
+ case "request_length", "bytes_received":
+ compare = openFlareAccessLogCompareInt64(left.BytesReceived, right.BytesReceived)
+ case "bytes_sent":
+ compare = openFlareAccessLogCompareInt64(left.BytesSent, right.BytesSent)
+ case "success_ratio":
+ compare = openFlareAccessLogCompareFloat64(left.SuccessRatio, right.SuccessRatio)
case "last_seen_at":
compare = openFlareAccessLogCompareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
case "remote_addr":
@@ -399,6 +409,16 @@ func sortOpenFlareAccessLogIPSummaryRows(items []*OpenFlareAccessLogIPSummaryRow
})
}
+func openFlareAccessLogCompareFloat64(left, right float64) int {
+ if left < right {
+ return -1
+ }
+ if left > right {
+ return 1
+ }
+ return 0
+}
+
func openFlareAccessLogPaginateBounds(total int, page int, pageSize int) (int, int) {
if page < 0 {
page = 0
diff --git a/internal/model/openflare_access_log_store_memory.go b/internal/model/openflare_access_log_store_memory.go
index 5af2de57..d74c8a86 100644
--- a/internal/model/openflare_access_log_store_memory.go
+++ b/internal/model/openflare_access_log_store_memory.go
@@ -274,11 +274,21 @@ func (s *memoryAccessLogStore) IPAggregates(_ context.Context, filter OpenFlareA
return result, nil
}
-func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAccessLogQuery, recentSince time.Time) ([]openFlareAccessLogIPSummaryRow, error) {
+func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAccessLogQuery, _ time.Time) ([]openFlareAccessLogIPSummaryRow, error) {
s.mu.RLock()
defer s.mu.RUnlock()
rows := s.filterRecords(filter)
- aggregates := make(map[string]*openFlareAccessLogIPSummaryRow)
+ type aggregate struct {
+ RemoteAddr string
+ Region string
+ RegionEpoch int64
+ TotalRequests int64
+ Success2xxCount int64
+ BytesReceived int64
+ BytesSent int64
+ LastSeenEpoch int64
+ }
+ aggregates := make(map[string]*aggregate)
for _, row := range rows {
remoteAddr := strings.TrimSpace(row.RemoteAddr)
if remoteAddr == "" {
@@ -286,25 +296,40 @@ func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAc
}
item := aggregates[remoteAddr]
if item == nil {
- item = &openFlareAccessLogIPSummaryRow{RemoteAddr: remoteAddr}
+ item = &aggregate{RemoteAddr: remoteAddr}
aggregates[remoteAddr] = item
}
item.TotalRequests++
- if !recentSince.IsZero() && !row.LoggedAt.Before(recentSince) {
- item.RecentRequests++
+ if row.StatusCode >= 200 && row.StatusCode < 300 {
+ item.Success2xxCount++
}
+ item.BytesReceived += row.RequestLength
+ item.BytesSent += row.BytesSent
epoch := row.LoggedAt.UTC().Unix()
if epoch > item.LastSeenEpoch {
item.LastSeenEpoch = epoch
}
+ if epoch >= item.RegionEpoch {
+ item.RegionEpoch = epoch
+ item.Region = strings.TrimSpace(row.Region)
+ }
}
summaryRows := make([]*OpenFlareAccessLogIPSummaryRow, 0, len(aggregates))
for _, item := range aggregates {
+ ratio := 0.0
+ if item.TotalRequests > 0 {
+ ratio = float64(item.Success2xxCount) / float64(item.TotalRequests)
+ }
summaryRows = append(summaryRows, &OpenFlareAccessLogIPSummaryRow{
- RemoteAddr: item.RemoteAddr,
- TotalRequests: item.TotalRequests,
- RecentRequests: item.RecentRequests,
- LastSeenEpoch: item.LastSeenEpoch,
+ RemoteAddr: item.RemoteAddr,
+ Region: item.Region,
+ TotalRequests: item.TotalRequests,
+ Success2xxCount: item.Success2xxCount,
+ SuccessRatio: ratio,
+ BytesReceived: item.BytesReceived,
+ BytesSent: item.BytesSent,
+ RecentRequests: 0,
+ LastSeenEpoch: item.LastSeenEpoch,
})
}
sortOpenFlareAccessLogIPSummaryRows(summaryRows, filter.SortBy, filter.SortOrder)
@@ -315,10 +340,15 @@ func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAc
result := make([]openFlareAccessLogIPSummaryRow, len(summaryRows))
for index, item := range summaryRows {
result[index] = openFlareAccessLogIPSummaryRow{
- RemoteAddr: item.RemoteAddr,
- TotalRequests: item.TotalRequests,
- RecentRequests: item.RecentRequests,
- LastSeenEpoch: item.LastSeenEpoch,
+ RemoteAddr: item.RemoteAddr,
+ Region: item.Region,
+ TotalRequests: item.TotalRequests,
+ Success2xxCount: item.Success2xxCount,
+ SuccessRatio: item.SuccessRatio,
+ BytesReceived: item.BytesReceived,
+ BytesSent: item.BytesSent,
+ RecentRequests: 0,
+ LastSeenEpoch: item.LastSeenEpoch,
}
}
return result, nil
diff --git a/internal/model/openflare_observability.go b/internal/model/openflare_observability.go
index eb94b69b..95702c7c 100644
--- a/internal/model/openflare_observability.go
+++ b/internal/model/openflare_observability.go
@@ -237,6 +237,7 @@ type OpenFlareAccessLogIPSummaryQuery struct {
RemoteAddr string
Host string
Since time.Time
+ Until time.Time
Page int
PageSize int
SortBy string
@@ -245,10 +246,16 @@ type OpenFlareAccessLogIPSummaryQuery struct {
// OpenFlareAccessLogIPSummaryRow is an IP summary row (v1 stub).
type OpenFlareAccessLogIPSummaryRow struct {
- RemoteAddr string `json:"remote_addr"`
- TotalRequests int64 `json:"total_requests"`
- RecentRequests int64 `json:"recent_requests"`
- LastSeenEpoch int64 `json:"last_seen_epoch"`
+ RemoteAddr string `json:"remote_addr"`
+ Region string `json:"region"`
+ TotalRequests int64 `json:"total_requests"`
+ Success2xxCount int64 `json:"success_2xx_count"`
+ SuccessRatio float64 `json:"success_ratio"`
+ BytesReceived int64 `json:"bytes_received"`
+ BytesSent int64 `json:"bytes_sent"`
+ // RecentRequests is deprecated and always 0.
+ RecentRequests int64 `json:"recent_requests"`
+ LastSeenEpoch int64 `json:"last_seen_epoch"`
}
// OpenFlareAccessLogIPTrendQuery filters IP trend queries (v1 stub).
diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go
index 173555b7..465660be 100644
--- a/internal/repository/analytics/node_access_log_filter.go
+++ b/internal/repository/analytics/node_access_log_filter.go
@@ -176,10 +176,17 @@ func nodeAccessLogIPSummaryOrderClause(sortBy string, sortOrder string) string {
}
column := "total_requests"
switch strings.TrimSpace(sortBy) {
- case "recent_requests":
- column = "recent_requests"
+ case "request_length", "bytes_received":
+ column = "request_length"
+ case "bytes_sent":
+ column = "bytes_sent"
+ case "success_ratio":
+ column = "success_ratio"
case "last_seen_at":
column = "last_seen_epoch"
+ case "recent_requests":
+ // Deprecated sort key; fall back to total_requests.
+ column = "total_requests"
case nodeAccessLogColumnRemoteAddr:
column = nodeAccessLogColumnRemoteAddr
}
diff --git a/internal/repository/analytics/node_access_log_stats.go b/internal/repository/analytics/node_access_log_stats.go
index 7bbcdb87..82ba8c7d 100644
--- a/internal/repository/analytics/node_access_log_stats.go
+++ b/internal/repository/analytics/node_access_log_stats.go
@@ -205,32 +205,42 @@ GROUP BY remote_addr`, lastSeenExpr, tableName, queryClause)
return result, nil
}
-// IPSummariesNodeAccessLogs returns paginated IP summary rows.
-func IPSummariesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, recentSince time.Time) ([]NodeAccessLogIPSummary, error) {
+// IPSummariesNodeAccessLogs returns paginated IP summary rows for the filter window.
+// recentSince is ignored (kept for call-site compatibility); recent_requests is always 0.
+func IPSummariesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, _ time.Time) ([]NodeAccessLogIPSummary, error) {
conn, err := nodeAccessLogConn()
if err != nil {
return nil, err
}
clause, args := buildNodeAccessLogFilterClause(filter)
lastSeenExpr := nodeAccessLogEpochExpr()
- recentClause := "0"
- queryArgs := make([]any, 0, len(args)+1)
- if !recentSince.IsZero() {
- recentClause = "if(logged_at >= ?, 1, 0)"
- queryArgs = append(queryArgs, recentSince)
- }
- queryArgs = append(queryArgs, args...)
+ queryArgs := append([]any{}, args...)
tableName := nodeAccessLogTableName()
- sql := fmt.Sprintf(`
+ // Outer query allows ORDER BY success_ratio without repeating countIf.
+ innerSQL := fmt.Sprintf(`
SELECT
remote_addr,
+ argMax(region, logged_at) AS region,
count() AS total_requests,
- sum(%s) AS recent_requests,
+ countIf(status_code >= 200 AND status_code < 300) AS success_2xx_count,
+ sum(request_length) AS request_length,
+ sum(bytes_sent) AS bytes_sent,
max(%s) AS last_seen_epoch
FROM %s
WHERE %s AND remote_addr != ''
-GROUP BY remote_addr
-ORDER BY %s`, recentClause, lastSeenExpr, tableName, clause, nodeAccessLogIPSummaryOrderClause(filter.SortBy, filter.SortOrder))
+GROUP BY remote_addr`, lastSeenExpr, tableName, clause)
+ sql := fmt.Sprintf(`
+SELECT
+ remote_addr,
+ region,
+ total_requests,
+ success_2xx_count,
+ if(total_requests = 0, 0., toFloat64(success_2xx_count) / toFloat64(total_requests)) AS success_ratio,
+ request_length,
+ bytes_sent,
+ last_seen_epoch
+FROM (%s)
+ORDER BY %s`, innerSQL, nodeAccessLogIPSummaryOrderClause(filter.SortBy, filter.SortOrder))
if filter.PageSize > 0 {
if filter.Page < 0 {
filter.Page = 0
@@ -247,18 +257,33 @@ ORDER BY %s`, recentClause, lastSeenExpr, tableName, clause, nodeAccessLogIPSumm
var result []NodeAccessLogIPSummary
for rows.Next() {
var (
- remoteAddr string
- lastSeenEpoch int64
- totalRequests, recentRequests uint64
+ remoteAddr, region string
+ lastSeenEpoch int64
+ successRatio float64
+ totalRequests, success2xx, bytesReceived, bytes uint64
)
- if err := rows.Scan(&remoteAddr, &totalRequests, &recentRequests, &lastSeenEpoch); err != nil {
+ if err := rows.Scan(
+ &remoteAddr,
+ ®ion,
+ &totalRequests,
+ &success2xx,
+ &successRatio,
+ &bytesReceived,
+ &bytes,
+ &lastSeenEpoch,
+ ); err != nil {
return nil, fmt.Errorf("scan ip summary row: %w", err)
}
result = append(result, NodeAccessLogIPSummary{
- RemoteAddr: remoteAddr,
- TotalRequests: safeInt64Count(totalRequests),
- RecentRequests: safeInt64Count(recentRequests),
- LastSeenEpoch: lastSeenEpoch,
+ RemoteAddr: remoteAddr,
+ Region: region,
+ TotalRequests: safeInt64Count(totalRequests),
+ Success2xxCount: safeInt64Count(success2xx),
+ SuccessRatio: successRatio,
+ BytesReceived: safeInt64Count(bytesReceived),
+ BytesSent: safeInt64Count(bytes),
+ RecentRequests: 0,
+ LastSeenEpoch: lastSeenEpoch,
})
}
return result, nil