Compare commits

...

14 Commits

Author SHA1 Message Date
ryan 24862dcbed chore(release): v3.4.0
### 🛠 修复
- 修复了访问日志概览按域名筛选无效的问题,现已兼容 hosts 与 hosts[] 参数。
- 修复了 Agent 观测缓冲合并访问日志时忽略 cache_status 导致缓存状态被去重丢弃的问题。
- 修复了访问日志概览在 ClickHouse 查询失败时静默吞错的问题,现会输出错误日志便于排查。
- 修复了数据看板业务流量趋势与已提供数据口径不一致的问题,业务量统一由访问日志聚合。
- 修复了节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标的问题。

### ⚡️ 优化与改进
- 访问日志重构为概览、IP 明细与日志明细:支持时间窗聚合 IP 请求数/2xx 比例/入出站流量与详情分析,明细展示完整请求字段。
- 边缘访问日志支持 User-Agent 与 cache_status(命中/回源/未缓存),概览增加设备/浏览器/系统与状态码分布。
- 新建站点开启缓存时推荐仅缓存标准静态资源(不含 HTML);存量空策略与按 URL 行为保留为所有可缓存 GET。
- 边缘观测以访问日志为业务唯一真相;Agent 仅上报明细与主机读数,升级需重建或替换 Agent。
- Pages 支持更多压缩格式上传、URL 导入部署包,以及可配置的包大小与历史保留策略。

### 💄 其他/体验
- 优化了访问日志排行榜与饼图布局,页签状态支持 URL 参数记忆。
- 启用 cache_status 与边缘缓存策略变更后,需执行相关迁移并重新发布节点配置。
2026-07-19 10:45:40 +08:00
ryan 920a530aa7 feat(access-logs): 新增 IP 明细 Tab 并完善日志详情字段
按时间窗聚合 IP 请求数/2xx 比例/入出站流量,支持排序与详情分析;
日志明细详情仅展示请求业务字段,IP 情报迁至独立详情弹窗。
2026-07-19 00:42:59 +08:00
ryan bfd9de69af fix(access-logs): 修复概览域名筛选参数 hosts[] 被 Gin 忽略
Axios 默认序列化为 hosts[]=,Gin QueryArray("hosts") 读不到导致筛选失效;
后端兼容 hosts/hosts[],前端改为重复键序列化。
2026-07-19 00:28:37 +08:00
ryan 204f6d9a8b fix(cache): 存量空/url 策略规范为 all,避免静默收窄
评审修复:enabled 且 policy 为空或 url 时,写入/展示/快照/渲染均映射为 all,
保证旧站点宽缓存范围不变;新建 UI 仍显式提交 static 作为推荐默认。
2026-07-19 00:02:52 +08:00
ryan 04f029c705 feat(cache): 开启缓存默认仅缓存标准静态资源
路由缓存策略新增 static(内置扩展名,不含 HTML)与 all;
存量 url 规范为 all。OpenResty 渲染与代理路由 UI 同步。
2026-07-18 23:32:49 +08:00
ryan 7401f5d0b4 docs(design): 边缘缓存默认可缓存范围对标 Cloudflare
约定开启缓存默认 static 扩展名策略,存量 url 映射为 all,
并明确第一期不做 Edge TTL/Purge/Cache Rules。
2026-07-18 23:24:23 +08:00
ryan 0bb6830047 feat(access-logs): 概览支持 Zone/域名多选筛选
概览可按 Zone→Domain 层级多选域名并折叠展开;明细列表 IP 旁展示地区。
后端 overview 支持 hosts 多域名精确匹配。
2026-07-18 23:07:19 +08:00
ryan 6f221b042e fix(agent): 观测缓冲去重纳入 cache_status 并保留原始 -
避免同一请求不同缓存状态被合并丢弃;OpenResty 的 - 原样入库便于详情区分。
2026-07-18 22:56:10 +08:00
ryan fb5a4e5b59 feat(access-logs): 上报并展示边缘缓存状态 cache_status
OpenResty 日志输出 $upstream_cache_status;Agent/协议/ClickHouse 贯通入库。
明细列表与详情按 HIT/MISS 等推导命中、回源、未缓存三态标签。
2026-07-18 22:50:46 +08:00
ryan ee9d651c8a docs(obs): 约定访问日志 cache_status 与明细三态展示
仅上报 $upstream_cache_status,不上报回源地址;UI 由原始值推导
命中缓存 / 回源 / 未使用缓存。
2026-07-18 22:46:46 +08:00
ryan 9aec984bee feat(access-logs): 明细详情支持 IP 分析与 URL Tab 记忆
- 新增单 IP 分析接口,趋势时间范围支持至 30 天
- 明细详情弹窗展示趋势、汇总与 Top 分布,可快捷管理 IP 组
- 访问日志页签改为 URL 参数记忆,筛选后保持当前 Tab
2026-07-18 22:40:21 +08:00
ryan bf71bc540b feat(access-logs): 优化概览饼图布局并在查询出错时增加日志记录
- 将设备类型与状态码饼图的断点由 xl 降为 lg,在大屏/笔记本视口下保持双列展示
- 修复 valueCountDistribution 在 ClickHouse 查询出错时静默吞掉错误的缺陷,引入 logger.ErrorF 捕获
- 补充 unreleased 变更日志
2026-07-18 22:02:09 +08:00
ryan e49078ac3b feat(access-logs): 接入 User-Agent 与设备/浏览器/状态码分布
- Agent 访问日志上报 user_agent,OpenResty log_format 输出 http_user_agent
- of_node_access_logs 新增 user_agent 列并写入 ClickHouse
- 访问日志概览新增:设备类型饼图、状态码饼图、浏览器/OS/User-Agent 排行
- 日志明细列表增加 User-Agent 列
- 扩展 UA 解析工具(browser/os/device)并支持 CLI 识别
2026-07-18 21:18:59 +08:00
ryan 177578ef4e feat(access-logs): 重构访问日志为概览与明细双 Tab
新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行,
明细列表保留检索;排行榜改为紧凑列表样式。
2026-07-18 20:58:32 +08:00
73 changed files with 6055 additions and 1575 deletions
+13 -3
View File
@@ -21,10 +21,14 @@ sidebar: false
## [unreleased]
## [v3.4.0] - 2026-07-18
## [v3.4.0] - 2026-07-19
### 新增
- 访问日志重构为「概览」「IP 明细」与「日志明细」:概览含请求量/访问量/带宽趋势与 Top 排行;IP 明细可按时间窗查看请求数、2xx 比例、入出站流量并支持详情分析;日志明细展示完整请求字段。
- 边缘访问日志支持 User-Agent 与 `cache_status`(命中/回源/未缓存);概览新增设备类型、浏览器、操作系统与状态码分布。
- 访问日志概览支持按 Zone/域名多选筛选;明细列表在 IP 旁展示地区信息。
- 新建站点开启缓存时推荐「标准静态资源」(不含 HTML);原按 URL/空策略存量行为保留为「所有可缓存 GET」。
- Pages 现支持上传 zip、tar.gz、tar.xz、tar.bz2、tar、7z 等常用压缩格式的部署包。
- 管理员可在运维设置中配置 Pages 部署包大小上限与每个项目的历史部署保留数量。
- Pages 支持从 URL 导入部署包:填写下载链接后由控制面代为拉取并创建部署。
@@ -33,8 +37,11 @@ sidebar: false
### 修复
- 修复数据看板业务流量趋势与已提供数据口径不一致的问题:业务量统一由访问日志聚合,避免边缘预聚合窗口差分导致趋势偏低。
- 修复节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标(例如南美)的问题。
- 修复访问日志概览按域名筛选无效的问题,现已兼容 `hosts` / `hosts[]` 参数。
- 修复 Agent 观测缓冲合并访问日志时忽略 `cache_status` 导致缓存状态被去重丢弃的问题。
- 修复访问日志概览在 ClickHouse 查询失败时静默吞错的问题,现会输出错误日志。
- 修复数据看板业务流量趋势与已提供数据口径不一致的问题:业务量统一由访问日志聚合。
- 修复节点地图在缺少精确经纬度时,把香港/新加坡/台湾等地区错误标到占位坐标的问题。
### 变更
@@ -44,10 +51,13 @@ sidebar: false
- 不再采集或展示宿主机网卡入/出站;网络趋势仅保留访问日志已提供/接收数据。
- Pages 包大小与历史保留可配置,边缘按项目只保留最新激活部署;创建规则表单与详情一致支持直连/隧道/Pages 源站类型。
- 优化 Pages 部署包校验性能:不再为包内每个文件计算哈希,整包校验和保障完整性。
- 优化访问日志排行榜与饼图布局;页签状态支持 URL 参数记忆。
- 启用 `cache_status` 与边缘缓存策略变更需执行相关迁移并重新发布节点配置。
### 移除
- 移除请求预聚合表与 OpenResty 吞吐观测相关路径;管理端不再返回 `traffic_reports` 与 `openresty_rx|tx`。
- 访问日志已移除时间折叠视图;IP 情报从日志明细详情迁出至 IP 明细。
## [v3.3.0] - 2026-07-14
+1
View File
@@ -132,6 +132,7 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
{ text: 'WAF 设计', link: 'waf-design' },
{ text: 'WAF 可编排规则设计', link: 'waf-orchestration-design' },
{ text: 'Pages 静态托管设计', link: 'pages-design' },
{ text: '边缘缓存策略设计', link: 'edge-cache-design' },
{ text: '边缘可观测与业务流量统计', link: 'observability-design' },
{ text: '观测数据传输模型', link: 'observability-transport-model' },
{ text: '观测上报协议与表结构', link: 'observability-data-model' },
+1 -1
View File
@@ -95,7 +95,7 @@ OpenResty (Agent, TLS/WAF)
### 3. OpenResty (数据面)
接收访客流量并执行最终的业务落地:
* 流量入口,支持 HTTP/2、HTTP/3(QUIC)和 TLS 证书动态绑定。
* 嵌入 Lua 逻辑,在 `access_by_lua` 阶段高效过滤 WAF 规则、验证工作量证明 (PoW) 挑战,并在此之后执行连接数/速率限制及基础缓存。
* 嵌入 Lua 逻辑,在 `access_by_lua` 阶段高效过滤 WAF 规则、验证工作量证明 (PoW) 挑战,并在此之后执行连接数/速率限制及基础缓存(策略见 [边缘缓存策略设计](./edge-cache-design.md))。
* *详细设计请参阅:[WAF 设计文档](./waf-design.md) 与 [Pages 静态托管设计文档](./pages-design.md)*
### 4. Relay 与 OpenFlared (穿透组件)
+190
View File
@@ -0,0 +1,190 @@
# 边缘缓存策略设计(对标 Cloudflare 默认可缓存范围)
你会学到:OpenFlare 边缘 `proxy_cache` 的产品边界、默认可缓存范围如何对齐 Cloudflare「静态资源默认可缓存」、策略枚举与渲染规则、兼容迁移,以及本阶段明确不做的能力。
本设计是 [系统架构](./architecture.md) 中「基础缓存」的产品化专章;访问日志中的缓存结果见 [观测数据模型 §3.5.1](./observability-data-model.md)。
---
## 1. 目标与非目标
### 1.1 目标(第一期)
* **开箱接近 CF 默认**:路由开启缓存后,**默认只缓存静态扩展名**,不默认缓存 HTML/无扩展名动态路径。
* **行为可解释**:与现有安全旁路(非 GET、Authorization、会话 Cookie、请求 `Cache-Control`)叠加,不削弱安全。
* **可观测一致**:继续依赖 `$upstream_cache_status` → `cache_status` 明细三态。
* **兼容存量**:旧路由 `cache_policy=url`(近似「过旁路即可缓存」)迁移为显式策略 `all`,行为不变。
### 1.2 非目标(后续迭代)
* Cache Rules 表达式引擎
* Edge TTL / `proxy_cache_valid` / 忽略源站 `Cache-Control`
* 可配置 Cookie 旁路列表、Query 忽略列表
* Purge(按 URL/前缀/全站)
* 浏览器 TTL 改写、客户端 `CF-Cache-Status` 响应头
* 命中率看板
---
## 2. 现状摘要
| 层 | 现状 |
| --- | --- |
| 全局 | `proxy_cache_path` / key / lock / stale(Performance 部分字段) |
| 路由 | `cache_enabled` + `cache_policy`:`url` \| `suffix` \| `path_prefix` \| `path_exact` |
| 旁路 | 渲染器硬编码:非 GET、Authorization、会话 Cookie、请求 Cache-Control |
| TTL | **无** `proxy_cache_valid`;存多久主要看源站头 + `inactive` |
| 观测 | 已上报 `cache_status`,UI 三态:命中 / 回源 / 未缓存 |
问题:默认策略 `url` 对「过旁路的 GET」范围过宽,与 CF「默认主要缓存静态扩展名、默认不缓存 HTML」不一致。
---
## 3. 产品语义
### 3.1 双层开关(不变)
* **全局** `openresty_cache_enabled`:生成 `proxy_cache_path` 等;关闭则路由级缓存指令不生效。
* **路由** `cache_enabled`:是否在该站点 `location` 启用 `proxy_cache`。
两者均开启时才进入缓存逻辑。
### 3.2 策略枚举(第一期)
| `cache_policy` | 含义 | 新建默认 | 旧值兼容 |
| --- | --- | --- | --- |
| **`static`** | 仅 URI 匹配**标准静态扩展名**(内置表)才允许缓存 | **是** | — |
| **`all`** | 过安全旁路后,不限制路径/扩展名(等同今日 `url`) | 否 | 存量 `url` → `all` |
| **`suffix`** | 自定义扩展名列表(`cache_rules`) | 否 | 保持 |
| **`path_prefix`** | 自定义路径前缀 | 否 | 保持 |
| **`path_exact`** | 自定义精确路径 | 否 | 保持 |
> 渲染层:读到历史值 `url` 时按 `all` 处理,避免未迁移数据行为突变;API 校验与 UI 只暴露上表枚举(写入时可将 `url` 规范为 `all`)。
### 3.3 标准静态扩展名(内置,V1 硬编码)
对齐 Cloudflare 常见「默认可缓存静态」集合,**默认不包含** `html` / `htm`:
```text
css js mjs map json
ico cur gif jpg jpeg png webp avif svg svgz
ttf otf woff woff2 eot
mp3 mp4 webm ogg flac
wasm pdf
zip 7z gz tar
```
* 匹配对象:`$uri` 的扩展名(大小写不敏感),实现上与现有 `suffix` 策略相同:
`if ($uri !~* \.(?:css|js|…)$) { set $openflare_skip_cache 1; }`
* **V1.1(可选)**:全局配置项覆盖该列表;第一期不强制。
### 3.4 安全旁路(保持硬编码)
在策略匹配之前/之外,仍设置 `$openflare_skip_cache=1`:
1. `$request_method != GET`(含 HEAD,与现网一致)
2. `$http_authorization != ""`
3. 会话类 Cookie 正则(现网列表)
4. 请求 `$http_cache_control` 匹配 `no-cache|no-store|private`
`proxy_cache_bypass` / `proxy_no_cache` 均绑定 `$openflare_skip_cache`。
### 3.5 与源站头的关系(本阶段不改)
* 仍不输出 `proxy_cache_valid`。
* 对象**是否进入缓存流程**由策略 + 旁路决定;**存多久**继续依赖源站 `Cache-Control` / `Expires` 等及全局 `inactive`。
* Edge TTL / 强制忽略源站头 → 后续专项。
---
## 4. 渲染与数据流
```text
全局 cache_enabled?
│ no → 不生成 proxy_cache_*
▼ yes
路由 cache_enabled?
│ no → location 无 proxy_cache
▼ yes
set $openflare_skip_cache 0
→ 安全旁路 if → 置 1
→ 策略 if(static/all/suffix/…)→ 可置 1
proxy_cache openflare_cache
proxy_cache_methods GET
proxy_cache_bypass / proxy_no_cache $openflare_skip_cache
→
access.log cache_status=$upstream_cache_status
```
### 4.1 策略 → Nginx 条件
| 策略 | 额外条件 |
| --- | --- |
| `static` | `$uri` 不匹配内置扩展名表 → skip |
| `all` | 无额外路径条件 |
| `suffix` | 不匹配 `cache_rules` 扩展名 → skip |
| `path_prefix` / `path_exact` | 同现实现 |
### 4.2 涉及代码面(实现时)
| 区域 | 路径 |
| --- | --- |
| 渲染 | `pkg/render/openresty/render.go`(策略分支 + 内置扩展名常量) |
| 校验 | `internal/apps/openflare/proxy_route/helpers.go` |
| 模型/默认 | 创建路由默认 `cache_policy=static`;读写时 `url`→`all` |
| 快照 | `config_version/snapshot.go` |
| UI | `proxy-routes/detail/components/cache-section.tsx` |
| 测试 | `pkg/render/openresty/render_test.go`、proxy_route helpers 测试 |
---
## 5. 兼容与迁移
| 数据 | 处理 |
| --- | --- |
| DB 中 `cache_policy=''` 或 `url`(且已启用缓存) | 读取 / 快照 / 渲染均规范为 **`all`**,保证存量「宽缓存」不变 |
| API 写入时 `enabled` 且 policy 为空 | 规范为 **`all`**(兼容旧客户端);UI 新建开启时**显式提交** `static` |
| 新建路由 | 默认 `cache_enabled=false`;表单开启缓存时默认策略 **`static`** |
| 已开启且 `url` 的站点 | 显示与发布为 `all`,**缓存范围不变** |
| 期望「只缓存静态」的旧站点 | 用户在 UI 改为 `static` 或自定义 `suffix` |
**发布说明建议:** 说明默认策略变更仅影响**新配置**;存量 `url` 视为 `all`。
---
## 6. UI 文案要点(缓存 Tab)
* 开启缓存后默认:**标准静态资源**(列出扩展名摘要,并写明不含 HTML)。
* 选项:**标准静态资源** / **所有可缓存 GET(高级)** / 自定义后缀 / 路径前缀 / 精确路径。
* 固定说明:非 GET、带 Authorization、常见登录 Cookie、请求禁止缓存头时跳过缓存。
* 提示:全局 Performance 中缓存总开关须开启,否则站点开关无效。
---
## 7. 验证要点
* 渲染:`static` 生成扩展名 `if`;`all`/`url` 无路径限制;旁路四条仍在。
* 单测:内置表含 `css`/`js`/`woff2`,不含 `html`。
* 手动:开启 `static` 后请求 `/a.css` 可出现 HIT/MISS;`/index.html` 或 `/api` 多为未缓存/BYPASS。
* 观测:access log `cache_status` 与列表三态一致。
---
## 8. 后续路线图(非本设计交付)
1. **Edge TTL / 尊重源站开关**(`proxy_cache_valid`、`proxy_ignore_headers`)
2. **可配置旁路**(Cookie/Query)
3. **Purge API**
4. **Cache Rules**(有序规则 + 动作)
5. **全局默认可缓存扩展名配置**
---
## 9. 决策记录
| 决策 | 选择 | 原因 |
| --- | --- | --- |
| 默认可缓存范围 | 开启缓存默认 `static` 扩展名表 | 对标 CF 开箱行为,降低 HTML/API 被误缓存 |
| 旧 `url` | 映射为 `all` | 避免存量站点行为变化 |
| HTML | 默认不在白名单 | 对齐 CF 默认不缓存 HTML |
| 第一期不做 Edge TTL/Purge | 明确 Out of Scope | 先收敛「谁可以进缓存」再优化「存多久/怎么清」 |
+1
View File
@@ -22,6 +22,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
| 能力 | 说明 | 详细设计/使用指南 |
| --- | --- | --- |
| **反代配置管理** | 以网站规则(Proxy Route)为聚合边界,支持多域名与多上游负载均衡 | [新建反代配置](../guide/proxy-config.md) |
| **边缘缓存** | 单节点 OpenResty `proxy_cache`;开启后默认仅缓存标准静态扩展名(对标 CF 默认可缓存范围) | [边缘缓存策略设计](./edge-cache-design.md) |
| **Zone 与域名管理** | 以可注册根域为管理入口,聚合明确域名、域名证书与反代路由 | [Zone 与域名资源设计](./zone-design.md) |
| **配置版本控制** | 支持全局单一激活版本的预览、发布、不可变快照历史与秒级一键回滚 | [Agent 与发布模型](./agent-design.md) |
| **WAF 安全防护** | 支持可视化 DAG 编排规则、手动/自动/订阅型 IP 组、GeoIP 匹配与 PoW CC 防护 | [WAF 设计](./waf-design.md) / [WAF 可编排规则设计](./waf-orchestration-design.md) / [WAF 使用指南](../guide/waf-usage.md) |
+61 -9
View File
@@ -202,7 +202,9 @@ Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 p
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15
"request_time_ms": 15,
"user_agent": "Mozilla/5.0 ...",
"cache_status": "HIT"
}
```
@@ -216,6 +218,8 @@ Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 p
| `bytes_sent` | int64 | ✅ | **`$body_bytes_sent`** | **已提供数据**(响应体) |
| `request_length` | int64 | 建议 | `$request_length` | **接收数据** |
| `request_time_ms` | int64 | 可选 | `$request_time * 1000` | 耗时;缺省 0 |
| `user_agent` | string | 建议 | `$http_user_agent` | UA;可截断入库 |
| `cache_status` | string | 建议 | **`$upstream_cache_status`** | 边缘缓存结果(见 §3.5.1) |
**明确不由 Agent 上报(由 Server 写入):**
@@ -223,6 +227,45 @@ Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 p
* `id` / `created_at`:Server 生成
* `node_id`:取自 payload / 鉴权上下文
**明确不上报:**
* `upstream_addr` / 回源地址 / `origin_fetched`:不做回源端点追踪;「是否回源」仅由 `cache_status` 在控制面推导(§3.5.1)
### 3.5.1 `cache_status` — 缓存命中与回源(明细优先)
**目标(第一期):** 访问日志明细/详情能展示「是否命中缓存 / 是否回源 / 未使用缓存」。
**口径:** 只存 OpenResty `$upstream_cache_status` 原始值;**不上报** upstream 地址。
#### 原始值(入库)
| 值 | 含义(OpenResty) |
| --- | --- |
| `HIT` | 命中缓存 |
| `MISS` | 未命中,向 upstream 取内容 |
| `BYPASS` | 跳过缓存(如 method/cookie/策略导致 `$openflare_skip_cache`) |
| `EXPIRED` | 缓存过期后回源 |
| `STALE` | 提供陈旧缓存(stale) |
| `UPDATING` | 后台更新中,可能返回旧缓存 |
| `REVALIDATED` | 协商验证后仍用缓存 |
| `-` 或空 | 未经过 `proxy_cache`(如 Pages 本地静态、非代理 location) |
#### UI 三态推导(不落库)
控制面展示用派生枚举 `cache_outcome`,**不写 CH**:
| 三态 | 条件(`cache_status`) | 列表标签建议 |
| --- | --- | --- |
| **命中缓存** | `HIT` / `STALE` / `REVALIDATED` / `UPDATING` | 命中 |
| **回源** | `MISS` / `EXPIRED` | 回源 |
| **未使用缓存** | `BYPASS` / `-` / `""` | 未缓存 |
详情可同时显示三态 + 原始 `cache_status`。
#### 边界
* Pages 静态 / 无 `proxy_cache` 的 location:多为空或 `-` → **未使用缓存**,不得标成「命中」。
* 第一期只做明细可见;命中率看板、hourly 维度可后续用同一列聚合。
**单次心跳条数建议:**
* 软上限例如 2000 条/拍;超出进入 `buffered` 下一批,**禁止** 在 Agent 压成 TrafficReport。
@@ -309,6 +352,8 @@ type NodeAccessLog struct {
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
UserAgent string `json:"user_agent,omitempty"`
CacheStatus string `json:"cache_status,omitempty"` // $upstream_cache_status
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"` // body_bytes_sent,已提供数据
RequestLength int64 `json:"request_length"` // 接收数据
@@ -419,10 +464,12 @@ CREATE TABLE IF NOT EXISTS of_node_access_logs
region String, -- Server GeoIP 写入,Agent 不传
host String,
path String,
user_agent String DEFAULT '', -- $http_user_agent
cache_status String DEFAULT '', -- $upstream_cache_status
status_code Int32,
bytes_sent UInt64, -- 已提供数据(body)
request_length UInt64 DEFAULT 0, -- 接收数据;新增
request_time_ms UInt32 DEFAULT 0, -- 可选;新增
request_length UInt64 DEFAULT 0, -- 接收数据
request_time_ms UInt32 DEFAULT 0, -- 可选
created_at DateTime64(3, 'UTC')
)
ENGINE = MergeTree()
@@ -441,18 +488,19 @@ SETTINGS index_granularity = 8192;
| `region` | String | Server GeoIP |
| `host` | String | 上报 |
| `path` | String | 上报 |
| `user_agent` | String | 上报(可空) |
| `cache_status` | String | 上报(可空)→ **缓存状态** |
| `status_code` | Int32 | 上报 |
| `bytes_sent` | UInt64 | 上报 → **已提供数据** |
| `request_length` | UInt64 | 上报 → **接收数据** |
| `request_time_ms` | UInt32 | 上报可选 |
| `created_at` | DateTime64(3) | Server now |
**迁移:** 现表已有 `bytes_sent`;新增:
**迁移:** 现表已有 `bytes_sent` / `request_length` / `request_time_ms` / `user_agent`;缓存状态新增:
```sql
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS request_length UInt64 DEFAULT 0,
ADD COLUMN IF NOT EXISTS request_time_ms UInt32 DEFAULT 0;
ADD COLUMN IF NOT EXISTS cache_status String DEFAULT '';
```
### 5.2 L1 小时汇总(Server 侧 MV)
@@ -625,14 +673,16 @@ Relay 专用 `of_node_obs_frps` / `of_node_obs_frpc` **保留**(非本 Agent
## 7. OpenResty 日志格式(与明细对齐)
目标 `log_format`(与现网一致,保证 `bytes_sent` 键 = body):
目标 `log_format`(保证 `bytes_sent` 键 = body;含 UA 与缓存状态):
```nginx
log_format openflare_json escape=json
'{"ts":"$time_iso8601","host":"$host","path":"$request_uri",'
'"remote_addr":"$remote_addr","status":$status,'
'"request_time":$request_time,'
'"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
'"bytes_sent":$body_bytes_sent,"request_length":$request_length,'
'"user_agent":"$http_user_agent",'
'"cache_status":"$upstream_cache_status"}';
```
Agent 解析:
@@ -640,7 +690,9 @@ Agent 解析:
* `ts` → `logged_at_unix`
* `bytes_sent` → 协议 `bytes_sent`(已提供)
* `request_length` → 协议 `request_length`
* `request_time` → 可选 `request_time_ms = round(sec * 1000)`
* `request_time` → 可选 `request_time_ms = round(sec * 1000)`
* `user_agent` → 协议 `user_agent`
* `cache_status` → 协议 `cache_status`(原样透传,不做三态压缩)
---
+9 -2
View File
@@ -154,6 +154,8 @@ status ← $status
request_time ← $request_time
bytes_sent ← $body_bytes_sent 【已提供数据 = 响应体字节】
request_length← $request_length 【接收数据】
user_agent ← $http_user_agent
cache_status ← $upstream_cache_status 【缓存状态;UI 可推导命中/回源/未缓存】
```
观测端口请求 **不写** 业务 access.log(独立 server `access_log off`)。
@@ -170,7 +172,9 @@ request_length← $request_length 【接收数据】
"status_code": 200,
"bytes_sent": 1024,
"request_length": 128,
"request_time_ms": 15
"request_time_ms": 15,
"user_agent": "curl/8.0",
"cache_status": "MISS"
},
{
"logged_at_unix": 1721289602,
@@ -180,7 +184,9 @@ request_length← $request_length 【接收数据】
"status_code": 200,
"bytes_sent": 8192,
"request_length": 300,
"request_time_ms": 8
"request_time_ms": 8,
"user_agent": "Mozilla/5.0",
"cache_status": "HIT"
}
]
```
@@ -191,6 +197,7 @@ request_length← $request_length 【接收数据】
| `request_length` | **接收数据**(单请求) |
| `logged_at_unix` | 请求完成时间(业务时间轴) |
| `host` | 用于 Zone 域名过滤 |
| `cache_status` | `$upstream_cache_status` 原样;详情/列表可推导三态(命中/回源/未缓存);**不上报** upstream 地址 |
| 无 `region` | **Server 入库时** GeoIP 写入 |
### 4.3 Server 如何用(产品指标)
+425 -2
View File
@@ -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"
},
@@ -5315,6 +5333,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": [
@@ -5408,6 +5513,97 @@ const docTemplate = `{
}
}
},
"/api/v1/d/access-logs/overview": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-observability"
],
"summary": "获取访问日志概览",
"parameters": [
{
"type": "string",
"description": "节点 ID",
"name": "node_id",
"in": "query"
},
{
"type": "string",
"description": "请求 Host(单域名)",
"name": "host",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "请求 Host 列表(多域名精确匹配)",
"name": "hosts",
"in": "query"
},
{
"type": "integer",
"description": "统计时间范围(小时)",
"name": "hours",
"in": "query"
}
],
"responses": {
"200": {
"description": "访问日志概览",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/observability.AccessLogOverview"
}
}
}
]
}
},
"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/acme-accounts/default": {
"get": {
"security": [
@@ -14222,6 +14418,10 @@ const docTemplate = `{
"description": "body bytes = 已提供数据",
"type": "integer"
},
"cache_status": {
"description": "$upstream_cache_status",
"type": "string"
},
"host": {
"type": "string"
},
@@ -14244,6 +14444,9 @@ const docTemplate = `{
},
"status_code": {
"type": "integer"
},
"user_agent": {
"type": "string"
}
}
},
@@ -16045,12 +16248,91 @@ 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": {
"has_more": {
"type": "boolean"
},
"hours": {
"type": "integer"
},
"items": {
"type": "array",
"items": {
@@ -16063,6 +16345,9 @@ const docTemplate = `{
"page_size": {
"type": "integer"
},
"since": {
"type": "string"
},
"sort_by": {
"type": "string"
},
@@ -16071,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"
}
@@ -16148,9 +16452,125 @@ const docTemplate = `{
}
}
},
"observability.AccessLogOverview": {
"type": "object",
"properties": {
"device_types": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"generated_at": {
"type": "string"
},
"hours": {
"type": "integer"
},
"status_codes": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"summary": {
"$ref": "#/definitions/observability.AccessLogOverviewSummary"
},
"top_browsers": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_hosts": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_ips": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_operating_systems": {
"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"
}
},
"trends": {
"$ref": "#/definitions/observability.AccessLogOverviewTrends"
}
}
},
"observability.AccessLogOverviewMetricPoint": {
"type": "object",
"properties": {
"bucket_started_at": {
"type": "string"
},
"value": {
"type": "integer"
}
}
},
"observability.AccessLogOverviewSummary": {
"type": "object",
"properties": {
"bandwidth_served": {
"type": "integer"
},
"total_requests": {
"type": "integer"
},
"total_visits": {
"type": "integer"
}
}
},
"observability.AccessLogOverviewTrends": {
"type": "object",
"properties": {
"bandwidth": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
}
},
"requests": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
}
},
"visits": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
}
}
}
},
"observability.AccessLogView": {
"type": "object",
"properties": {
"cache_status": {
"type": "string"
},
"host": {
"type": "string"
},
@@ -16177,6 +16597,9 @@ const docTemplate = `{
},
"status_code": {
"type": "integer"
},
"user_agent": {
"type": "string"
}
}
},
@@ -0,0 +1,63 @@
# 访问日志 cache_status 明细可见 — 实现计划
说明:对应设计 [observability-data-model.md §3.5.1](../design/observability-data-model.md)。第一期只做明细可见,不上报 upstream 地址。
---
## 1. 目标与背景
* **需求背景**:访问日志无法判断请求是否命中边缘缓存、是否回源。
* **开发范围 (Scope)**:
* **必做**:OpenResty 日志输出 `$upstream_cache_status`;Agent 上报;CH 入库;列表/详情展示三态标签。
* **Out of Scope**:命中率看板、hourly 维度、`upstream_addr`。
---
## 2. 设计与决策
* **唯一字段**:`cache_status` string(原始值)。
* **UI 三态(不落库)**:
* 命中:`HIT` / `STALE` / `REVALIDATED` / `UPDATING`
* 回源:`MISS` / `EXPIRED`
* 未缓存:`BYPASS` / `-` / 空
* **数据流**:log_format → Agent parse → protocol → Server model → CH → API → 前端明细。
---
## 3. 修改清单
### 边缘 / 协议
* `pkg/render/openresty/types.go`、`internal/model/openflare_option.go`:`log_format` 增加 `cache_status`
* `internal/apps/agent/observability/traffic.go`:解析与映射
* `pkg/protocol/agent.go`:`NodeAccessLog.CacheStatus`
### Server / CH
* goose:`202607180005_access_log_cache_status.sql`
* `internal/model/analytics/node_access_log.go`、writer、list/scan、store 映射
* `internal/model/openflare_observability.go`、agent build records
* API `AccessLogView` + list 响应带 `cache_status`
### 前端
* types / 明细列表标签 / 详情字段
* 三态 helper:`resolveCacheOutcome(cache_status)`
---
## 4. 验证
* `go test ./internal/apps/agent/observability/ ./internal/repository/analytics/ ./internal/apps/openflare/agent/`
* `make swagger`(若 Handler 响应结构变更)
* `make code-check` / `make prettier`
---
## 5. 落地进度
* [x] log_format + protocol + agent parse
* [x] CH migration + 写入/读取
* [x] API + 前端明细展示
* [x] 缓冲去重 key 含 cache_status;保留 `-` 原始值
* [x] 测试与提交
@@ -0,0 +1,38 @@
# 边缘缓存默认 static 策略 — 实现计划
对应设计:[edge-cache-design.md](../design/edge-cache-design.md)
## 目标
路由开启缓存后,**新建推荐**仅缓存标准静态扩展名(`static`);存量 `url`/空策略映射为 `all`,不收窄缓存范围。
## 兼容规则(评审后定稿)
| 场景 | 行为 |
| --- | --- |
| 已启用 + `''` / `url` | 读 API / 快照 / 渲染 → **`all`** |
| 写入时 enabled 且 policy 为空 | 规范为 **`all`**(旧客户端兼容) |
| UI 新建/推荐默认 | **显式提交** `static` |
| 关闭缓存 | policy 存 `''`,rules 清空 |
## 修改清单
1. **渲染** `pkg/render/openresty/render.go`:`static` 内置扩展名;空/`url`/`all` 无路径限制
2. **校验/展示** `proxy_route/helpers.go`:`normalizeCachePolicy` + `displayCachePolicy`
3. **快照** `config_version/logics.go`:`normalizeSnapshotCachePolicy`
4. **前端** `cache-section.tsx` + helpers:存量 empty/url→`all`;关闭时提交 `''`;新建默认 `static`
5. **测试** render + proxy_route
6. **设计/changelog** 同步兼容说明
## 验证
```bash
go test ./pkg/render/openresty/ ./internal/apps/openflare/proxy_route/ ./internal/apps/openflare/config_version/
# 已通过(2026-07-18)
```
## 状态
- [x] 功能实现 + 评审修复(empty→all,禁止静默收窄)
- [ ] 提交 `fix(cache): ...`(待用户确认)
- [ ] 合并 / 发布后需重新发布节点配置
+161
View File
@@ -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 已更新)
- [ ] 提交合并
+3
View File
@@ -16,6 +16,9 @@
* [Zone 与域名资源重构](./20260712-zone-domain-refactor.md):以 Zone 和正规化 Zone 域名替代托管域名及反代路由中的域名/证书冗余字段。
* [WAF 可编排规则](./20260713-waf-orchestration.md):使用 React Flow 编辑 DAG 规则,发布时编译并由 OpenResty 纯内存执行。
* [边缘可观测与业务流量统计重构](./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 情报迁入独立详情;日志详情仅请求字段。
## 使用建议
+425 -2
View File
@@ -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"
},
@@ -5308,6 +5326,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": [
@@ -5401,6 +5506,97 @@
}
}
},
"/api/v1/d/access-logs/overview": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-observability"
],
"summary": "获取访问日志概览",
"parameters": [
{
"type": "string",
"description": "节点 ID",
"name": "node_id",
"in": "query"
},
{
"type": "string",
"description": "请求 Host(单域名)",
"name": "host",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "请求 Host 列表(多域名精确匹配)",
"name": "hosts",
"in": "query"
},
{
"type": "integer",
"description": "统计时间范围(小时)",
"name": "hours",
"in": "query"
}
],
"responses": {
"200": {
"description": "访问日志概览",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/observability.AccessLogOverview"
}
}
}
]
}
},
"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/acme-accounts/default": {
"get": {
"security": [
@@ -14215,6 +14411,10 @@
"description": "body bytes = 已提供数据",
"type": "integer"
},
"cache_status": {
"description": "$upstream_cache_status",
"type": "string"
},
"host": {
"type": "string"
},
@@ -14237,6 +14437,9 @@
},
"status_code": {
"type": "integer"
},
"user_agent": {
"type": "string"
}
}
},
@@ -16038,12 +16241,91 @@
}
}
},
"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": {
"has_more": {
"type": "boolean"
},
"hours": {
"type": "integer"
},
"items": {
"type": "array",
"items": {
@@ -16056,6 +16338,9 @@
"page_size": {
"type": "integer"
},
"since": {
"type": "string"
},
"sort_by": {
"type": "string"
},
@@ -16064,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"
}
@@ -16141,9 +16445,125 @@
}
}
},
"observability.AccessLogOverview": {
"type": "object",
"properties": {
"device_types": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"generated_at": {
"type": "string"
},
"hours": {
"type": "integer"
},
"status_codes": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"summary": {
"$ref": "#/definitions/observability.AccessLogOverviewSummary"
},
"top_browsers": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_hosts": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_ips": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.DistributionItem"
}
},
"top_operating_systems": {
"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"
}
},
"trends": {
"$ref": "#/definitions/observability.AccessLogOverviewTrends"
}
}
},
"observability.AccessLogOverviewMetricPoint": {
"type": "object",
"properties": {
"bucket_started_at": {
"type": "string"
},
"value": {
"type": "integer"
}
}
},
"observability.AccessLogOverviewSummary": {
"type": "object",
"properties": {
"bandwidth_served": {
"type": "integer"
},
"total_requests": {
"type": "integer"
},
"total_visits": {
"type": "integer"
}
}
},
"observability.AccessLogOverviewTrends": {
"type": "object",
"properties": {
"bandwidth": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
}
},
"requests": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
}
},
"visits": {
"type": "array",
"items": {
"$ref": "#/definitions/observability.AccessLogOverviewMetricPoint"
}
}
}
},
"observability.AccessLogView": {
"type": "object",
"properties": {
"cache_status": {
"type": "string"
},
"host": {
"type": "string"
},
@@ -16170,6 +16590,9 @@
},
"status_code": {
"type": "integer"
},
"user_agent": {
"type": "string"
}
}
},
+273 -2
View File
@@ -657,6 +657,9 @@ definitions:
bytes_sent:
description: body bytes = 已提供数据
type: integer
cache_status:
description: $upstream_cache_status
type: string
host:
type: string
logged_at_unix:
@@ -673,6 +676,8 @@ definitions:
type: integer
status_code:
type: integer
user_agent:
type: string
type: object
github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth:
properties:
@@ -1870,10 +1875,62 @@ 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:
type: boolean
hours:
type: integer
items:
items:
$ref: '#/definitions/observability.AccessLogIPSummaryView'
@@ -1882,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
@@ -1937,8 +2009,84 @@ definitions:
total_record:
type: integer
type: object
observability.AccessLogOverview:
properties:
device_types:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
generated_at:
type: string
hours:
type: integer
status_codes:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
summary:
$ref: '#/definitions/observability.AccessLogOverviewSummary'
top_browsers:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
top_hosts:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
top_ips:
items:
$ref: '#/definitions/observability.DistributionItem'
type: array
top_operating_systems:
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
trends:
$ref: '#/definitions/observability.AccessLogOverviewTrends'
type: object
observability.AccessLogOverviewMetricPoint:
properties:
bucket_started_at:
type: string
value:
type: integer
type: object
observability.AccessLogOverviewSummary:
properties:
bandwidth_served:
type: integer
total_requests:
type: integer
total_visits:
type: integer
type: object
observability.AccessLogOverviewTrends:
properties:
bandwidth:
items:
$ref: '#/definitions/observability.AccessLogOverviewMetricPoint'
type: array
requests:
items:
$ref: '#/definitions/observability.AccessLogOverviewMetricPoint'
type: array
visits:
items:
$ref: '#/definitions/observability.AccessLogOverviewMetricPoint'
type: array
type: object
observability.AccessLogView:
properties:
cache_status:
type: string
host:
type: string
id:
@@ -1957,6 +2105,8 @@ definitions:
type: string
status_code:
type: integer
user_agent:
type: string
type: object
observability.CapacityTrendPoint:
properties:
@@ -7040,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
@@ -7054,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
@@ -7062,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
@@ -7103,6 +7265,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 在时间范围内的访问趋势数据,需要管理员权限
@@ -7160,6 +7375,62 @@ paths:
summary: 获取访问日志 IP 趋势
tags:
- openflare-observability
/api/v1/d/access-logs/overview:
get:
description: 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限
parameters:
- description: 节点 ID
in: query
name: node_id
type: string
- description: 请求 Host(单域名)
in: query
name: host
type: string
- collectionFormat: csv
description: 请求 Host 列表(多域名精确匹配)
in: query
items:
type: string
name: hosts
type: array
- description: 统计时间范围(小时)
in: query
name: hours
type: integer
produces:
- application/json
responses:
"200":
description: 访问日志概览
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/observability.AccessLogOverview'
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: 获取访问日志概览
tags:
- openflare-observability
/api/v1/d/acme-accounts/default:
get:
description: 返回系统默认 ACME 账号配置,需要管理员权限
@@ -0,0 +1,163 @@
'use client';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { AccessLogItem } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { formatBytes } from '@/lib/utils/metrics';
import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils';
function DetailField({
label,
value,
mono,
full,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
full?: boolean;
}) {
return (
<div className={full ? 'sm:col-span-2 space-y-1' : 'space-y-1'}>
<p className='text-[11px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<div
className={`text-sm break-all ${mono ? 'font-mono text-xs' : 'text-foreground'}`}
>
{value}
</div>
</div>
);
}
function 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({
open,
item,
onOpenChange,
}: {
open: boolean;
item: AccessLogItem | null;
onOpenChange: (open: boolean) => void;
}) {
// Keep last selected item while the dialog closes to avoid empty-state flash.
const [displayItem, setDisplayItem] = useState<AccessLogItem | null>(item);
if (item && item !== displayItem) {
setDisplayItem(item);
}
const activeItem = item ?? displayItem;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-2xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>访问日志详情</DialogTitle>
<DialogDescription>
本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。
</DialogDescription>
</DialogHeader>
{activeItem ? (
<div className='grid gap-4 sm:grid-cols-2'>
<DetailField label='日志 ID' value={activeItem.id || '—'} mono />
<DetailField
label='请求时间'
value={formatDateTime(activeItem.logged_at)}
/>
<DetailField
label='入库时间'
value={
activeItem.created_at
? formatDateTime(activeItem.created_at)
: '—'
}
/>
<DetailField
label='节点'
value={activeItem.node_name || activeItem.node_id || '—'}
/>
<DetailField
label='节点 ID'
value={activeItem.node_id || '—'}
mono
/>
<DetailField
label='客户端 IP'
value={activeItem.remote_addr || '—'}
mono
/>
<DetailField label='地区' value={activeItem.region || '—'} />
<DetailField label='域名' value={activeItem.host || '—'} />
<DetailField
label='状态码'
value={
<Badge variant='outline' className='text-[10px]'>
{activeItem.status_code}
</Badge>
}
/>
<DetailField
label='缓存'
value={
<div className='flex flex-wrap items-center gap-2'>
<Badge variant='outline' className='text-[10px]'>
{cacheOutcomeLabel(
resolveCacheOutcome(activeItem.cache_status),
)}
</Badge>
<span className='font-mono text-xs text-muted-foreground'>
{activeItem.cache_status || '—'}
</span>
</div>
}
/>
<DetailField
label='出站流量'
value={formatBytes(activeItem.bytes_sent ?? 0)}
/>
<DetailField
label='入站流量'
value={formatBytes(activeItem.request_length ?? 0)}
/>
<DetailField
label='请求耗时'
value={formatRequestTimeMs(activeItem.request_time_ms)}
/>
<DetailField
label='路径'
value={activeItem.path || '—'}
full
mono
/>
<DetailField
label='User-Agent'
value={activeItem.user_agent || '—'}
full
mono
/>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -11,11 +11,10 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { AccessLogTab, SearchDraft } from './access-log-utils';
import type { SearchDraft } from './access-log-utils';
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
interface AccessLogFiltersProps {
tab: AccessLogTab;
draft: SearchDraft;
pageSize: number;
onDraftChange: (draft: SearchDraft) => void;
@@ -25,7 +24,6 @@ interface AccessLogFiltersProps {
}
export function AccessLogFilters({
tab,
draft,
pageSize,
onDraftChange,
@@ -33,8 +31,6 @@ export function AccessLogFilters({
onSearch,
onReset,
}: AccessLogFiltersProps) {
const showPath = tab === 'list' || tab === 'folds';
return (
<div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
@@ -81,24 +77,18 @@ export function AccessLogFilters({
className='h-9 text-xs'
/>
</div>
{showPath ? (
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>
请求路径
</p>
<Input
value={draft.path}
onChange={(e) =>
onDraftChange({ ...draft, path: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
className='h-9 text-xs'
/>
</div>
) : null}
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
<Input
value={draft.path}
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按路径搜索'
className='h-9 text-xs'
/>
</div>
</div>
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
@@ -1,4 +1,4 @@
export type AccessLogTab = 'list' | 'folds' | 'ip-summary' | 'ip-trend';
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
nodeId: string;
@@ -7,8 +7,20 @@ export type SearchDraft = {
path: string;
};
export type OverviewRangeHours = 24 | 168 | 360 | 720;
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
export const OVERVIEW_RANGE_OPTIONS: {
value: OverviewRangeHours;
label: string;
}[] = [
{ value: 24, label: '24 小时' },
{ value: 168, label: '7 天' },
{ value: 360, label: '15 天' },
{ value: 720, label: '30 天' },
];
export const DETAIL_SORT_OPTIONS = [
{ value: 'logged_at:desc', label: '时间从新到旧' },
{ value: 'logged_at:asc', label: '时间从旧到新' },
@@ -18,18 +30,17 @@ export const DETAIL_SORT_OPTIONS = [
{ value: 'remote_addr:desc', label: 'IP 倒序' },
];
export const FOLD_SORT_OPTIONS = [
{ value: 'bucket_started_at:desc', label: '时间桶从新到旧' },
{ value: 'bucket_started_at:asc', label: '时间桶从旧到新' },
{ value: 'request_count:desc', label: '访问次数从高到低' },
{ value: 'request_count:asc', label: '访问次数从低到高' },
];
export const IP_SORT_OPTIONS = [
{ value: 'total_requests:desc', label: '总访问次数从高到低' },
{ value: 'total_requests:asc', label: '总访问次数从低到高' },
{ value: 'recent_requests:desc', label: '3 小时访问次数从高到低' },
{ value: 'last_seen_at:desc', label: '最后访问时间从新到旧' },
{ 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) {
@@ -46,3 +57,54 @@ export function formatCompactNumber(value: number) {
maximumFractionDigits: 1,
}).format(value);
}
export function formatOverviewRangeHint(hours: number) {
if (hours <= 24) return '近 24 小时';
if (hours % 24 === 0) return `近 ${hours / 24} 天`;
return `近 ${hours} 小时`;
}
export function formatOverviewTrendLabel(value: string, hours: number) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '—';
}
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
const hour = `${date.getHours()}`.padStart(2, '0');
if (hours <= 24) {
return `${hour}:00`;
}
return `${month}/${day} ${hour}:00`;
}
export type CacheOutcome = 'hit' | 'origin' | 'uncached';
export function resolveCacheOutcome(
cacheStatus: string | undefined | null,
): CacheOutcome {
const status = (cacheStatus ?? '').trim().toUpperCase();
if (
status === 'HIT' ||
status === 'STALE' ||
status === 'REVALIDATED' ||
status === 'UPDATING'
) {
return 'hit';
}
if (status === 'MISS' || status === 'EXPIRED') {
return 'origin';
}
return 'uncached';
}
export function cacheOutcomeLabel(outcome: CacheOutcome) {
switch (outcome) {
case 'hit':
return '命中';
case 'origin':
return '回源';
default:
return '未缓存';
}
}
@@ -0,0 +1,243 @@
'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';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
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 {
cacheOutcomeLabel,
DETAIL_SORT_OPTIONS,
resolveCacheOutcome,
type CacheOutcome,
} from './access-log-utils';
function cacheOutcomeVariant(
outcome: CacheOutcome,
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (outcome) {
case 'hit':
return 'default';
case 'origin':
return 'secondary';
default:
return 'outline';
}
}
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
}
export function DetailTab({
data,
loading,
error,
page,
detailSort,
onDetailSortChange,
onRetry,
onPrevPage,
onNextPage,
isFetching,
}: {
data?: AccessLogList;
loading: boolean;
error: Error | null;
page: number;
detailSort: string;
onDetailSortChange: (value: string) => void;
onRetry: () => void;
onPrevPage: () => void;
onNextPage: () => void;
isFetching: boolean;
}) {
const [selected, setSelected] = useState<AccessLogItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
return (
<>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>日志明细</p>
<Select value={detailSort} onValueChange={onDetailSortChange}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>缓存</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => {
const outcome = resolveCacheOutcome(item.cache_status);
return (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs'>
<div className='flex flex-col gap-0.5'>
<span className='font-mono'>{item.remote_addr}</span>
{item.region ? (
<span className='text-[10px] text-muted-foreground'>
{item.region}
</span>
) : null}
</div>
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge
variant={cacheOutcomeVariant(outcome)}
className='text-[10px]'
title={item.cache_status || undefined}
>
{cacheOutcomeLabel(outcome)}
</Badge>
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看详情
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
<AccessLogDetailDialog
open={detailOpen}
item={selected}
onOpenChange={setDetailOpen}
/>
</>
);
}
@@ -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 (
<div className='rounded-lg border border-dashed px-3 py-2.5'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{label}
</p>
<p className='mt-1 text-lg font-semibold tracking-tight'>{value}</p>
</div>
);
}
function MiniRankCard({
title,
items,
color,
}: {
title: string;
items: { label: string; value: number }[];
color: string;
}) {
return (
<div className='rounded-lg border border-dashed p-3'>
<p className='mb-2 text-sm font-medium'>{title}</p>
<RankChart
items={items}
color={color}
className='!h-[220px]'
emptyMessage={`暂无 ${title} 数据`}
/>
</div>
);
}
function AddToIPGroupPanel({
ip,
open,
onClose,
}: {
ip: string;
open: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [selectedGroupId, setSelectedGroupId] = useState<string>('');
const groupsQuery = useQuery({
queryKey: ['openflare', 'waf', 'ip-groups'],
queryFn: () => WafService.listIPGroups(),
enabled: open,
});
const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]);
const matchedGroups = useMemo(
() => groupsContainingIp(groups, ip),
[groups, ip],
);
const manualGroups = useMemo(
() =>
groups.filter(
(group) => group.type === 'manual' && group.enabled !== false,
),
[groups],
);
const addableGroups = useMemo(
() =>
manualGroups.filter(
(group) =>
!(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()),
),
[manualGroups, ip],
);
const updateMutation = useMutation({
mutationFn: async ({
group,
nextList,
}: {
group: WAFIPGroup;
nextList: string[];
}) =>
WafService.updateIPGroup(
group.id,
buildIPGroupPayloadFromGroup(group, nextList),
),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'ip-groups'],
});
},
});
const handleAdd = async () => {
const groupId = Number.parseInt(selectedGroupId, 10);
const group = addableGroups.find((item) => item.id === groupId);
if (!group) {
toast.error('请选择要加入的 IP 组');
return;
}
try {
await updateMutation.mutateAsync({
group,
nextList: [...(group.ip_list ?? []), ip.trim()],
});
toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`);
setSelectedGroupId('');
} catch (error) {
toast.error(error instanceof Error ? error.message : '加入 IP 组失败');
}
};
const handleRemove = async (group: WAFIPGroup) => {
try {
await updateMutation.mutateAsync({
group,
nextList: (group.ip_list ?? []).filter(
(entry) => entry.trim() !== ip.trim(),
),
});
toast.success(`已从 IP 组「${group.name}」移除 ${ip}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : '移除失败');
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) {
setSelectedGroupId('');
onClose();
}
}}
>
<DialogContent className='max-w-lg'>
<DialogHeader>
<DialogTitle>将 IP 加入 IP 组</DialogTitle>
<DialogDescription>
目标 IP:
<span className='font-mono text-foreground'>{ip}</span>
</DialogDescription>
</DialogHeader>
{groupsQuery.isLoading ? (
<LoadingStateWithBorder title='加载 IP 组' />
) : groupsQuery.isError ? (
<ErrorInline
message={
groupsQuery.error instanceof Error
? groupsQuery.error.message
: '加载 IP 组失败'
}
onRetry={() => void groupsQuery.refetch()}
/>
) : (
<div className='space-y-4'>
{matchedGroups.length > 0 ? (
<div className='space-y-2 rounded-lg border border-dashed p-3'>
<p className='text-sm font-medium text-foreground'>
该 IP 已存在于以下 IP 组
</p>
<p className='text-xs text-muted-foreground'>
可选择删除,或继续添加到其他 IP 组。
</p>
<div className='space-y-2'>
{matchedGroups.map((group) => (
<div
key={group.id}
className='flex items-center justify-between gap-2 rounded-md border px-3 py-2'
>
<div className='min-w-0'>
<p className='truncate text-sm font-medium'>
{group.name}
</p>
<p className='text-[11px] text-muted-foreground'>
{group.type} · {group.ip_list?.length ?? 0} 条
</p>
</div>
{group.type === 'manual' ? (
<Button
variant='outline'
size='sm'
className='h-8 shrink-0 text-destructive'
disabled={updateMutation.isPending}
onClick={() => void handleRemove(group)}
>
{updateMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
<>
<Trash2 className='mr-1 size-3.5' />
删除
</>
)}
</Button>
) : (
<Badge variant='outline' className='text-[10px]'>
不可手动删除
</Badge>
)}
</div>
))}
</div>
</div>
) : (
<p className='text-sm text-muted-foreground'>
该 IP 尚未加入任何 IP 组。
</p>
)}
<div className='space-y-2'>
<p className='text-sm font-medium'>添加到其他 IP 组</p>
{addableGroups.length === 0 ? (
<p className='text-xs text-muted-foreground'>
没有可写入的手动 IP 组(或已全部包含该 IP)。
</p>
) : (
<Select
value={selectedGroupId}
onValueChange={setSelectedGroupId}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder='选择手动 IP 组' />
</SelectTrigger>
<SelectContent>
{addableGroups.map((group) => (
<SelectItem key={group.id} value={String(group.id)}>
{group.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={onClose}>
关闭
</Button>
<Button
onClick={() => void handleAdd()}
disabled={
!selectedGroupId ||
updateMutation.isPending ||
addableGroups.length === 0
}
>
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
处理中...
</>
) : (
'加入所选 IP 组'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function 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 <EmptyStateWithBorder description='没有有效 IP。' />;
}
return (
<>
<div className='space-y-4'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<Button
size='sm'
variant='outline'
onClick={() => setIpGroupOpen(true)}
>
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
</Button>
<ToggleGroup
type='single'
value={isOverviewPreset(rangeHours) ? String(rangeHours) : ''}
onValueChange={(value) => {
if (!value) return;
setRangeHours(clampAnalysisHours(Number.parseInt(value, 10)));
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
{isLoadingIP ? (
<LoadingStateWithBorder title='加载 IP 分析' />
) : (
<div className='space-y-4'>
{analysisQuery.isError ? (
<ErrorInline
message={
analysisQuery.error instanceof Error
? analysisQuery.error.message
: '加载 IP 分析失败'
}
onRetry={() => void analysisQuery.refetch()}
/>
) : analysis ? (
<div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
<MetricCard
label='总请求'
value={formatCompactNumber(analysis.summary.total_requests)}
/>
<MetricCard
label='错误数'
value={formatCompactNumber(analysis.summary.error_count)}
/>
<MetricCard
label='已提供带宽'
value={formatBytes(analysis.summary.bandwidth_served)}
/>
<MetricCard
label='接收数据'
value={formatBytes(analysis.summary.bytes_received)}
/>
<MetricCard
label='独立域名'
value={formatCompactNumber(analysis.summary.unique_hosts)}
/>
<MetricCard
label='独立路径'
value={formatCompactNumber(analysis.summary.unique_paths)}
/>
</div>
) : null}
<div className='space-y-3 rounded-lg border border-dashed p-4'>
<div className='flex items-center justify-between gap-2'>
<div>
<p className='text-sm font-medium'>IP 请求趋势</p>
<p className='text-xs text-muted-foreground'>
{ip} · {rangeHint} · {bucketMinutes} 分钟桶
</p>
</div>
<Button
size='sm'
variant='ghost'
disabled={isFetchingIP}
onClick={() => {
void trendQuery.refetch();
void analysisQuery.refetch();
}}
>
刷新
</Button>
</div>
{trendQuery.isError ? (
<ErrorInline
message={
trendQuery.error instanceof Error
? trendQuery.error.message
: '加载趋势失败'
}
onRetry={() => void trendQuery.refetch()}
/>
) : trendChartData.every((point) => point.requests === 0) ? (
<EmptyStateWithBorder
description={`该 IP 在${rangeHint}内没有访问记录。`}
/>
) : (
<ChartContainer
config={trendChartConfig}
className='h-56 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
tickFormatter={(value) =>
formatCompactNumber(Number(value))
}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
)}
</div>
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title='Top Paths'
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title='Top Hosts'
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title='Status Codes'
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title='Top User-Agents'
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title='Device Types'
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title='Top Browsers'
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
</div>
) : null}
</div>
)}
</div>
<AddToIPGroupPanel
ip={ip}
open={ipGroupOpen}
onClose={() => setIpGroupOpen(false)}
/>
</>
);
}
@@ -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<string | null>(remoteAddr);
const [displayRegion, setDisplayRegion] = useState(region);
if (remoteAddr && remoteAddr !== displayAddr) {
setDisplayAddr(remoteAddr);
setDisplayRegion(region);
}
const ip = remoteAddr ?? displayAddr ?? '';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] sm:max-w-6xl md:max-w-6xl overflow-y-auto hide-scrollbar'>
<DialogHeader>
<DialogTitle>IP 详情</DialogTitle>
<DialogDescription>
<span className='font-mono text-foreground'>{ip || '—'}</span>
{displayRegion ? (
<span className='text-muted-foreground'> · {displayRegion}</span>
) : null}
。查看该 IP 的访问趋势、分布与 WAF IP 组操作。
</DialogDescription>
</DialogHeader>
<IpAnalysisPanel
key={ip}
ip={ip}
enabled={open && ip !== ''}
initialHours={initialHours}
/>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>
当前第 {page + 1} 页
{typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''}
</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
}
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<AccessLogIPSummaryItem | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [draftSince, setDraftSince] = useState(customSince);
const [draftUntil, setDraftUntil] = useState(customUntil);
const [customError, setCustomError] = useState<string | null>(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 (
<>
<div className='space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4 space-y-3'>
<div className='flex flex-wrap items-center justify-between gap-3'>
<div className='space-y-1'>
<p className='text-sm font-medium'>时间范围</p>
<p className='text-xs text-muted-foreground'>
当前:{rangeHint}
{data?.total_ip != null
? ` · ${formatCompactNumber(data.total_ip)} 个 IP`
: ''}
</p>
</div>
<div className='flex flex-wrap items-center gap-2'>
<ToggleGroup
type='single'
value={timeMode === 'preset' ? String(hours) : ''}
onValueChange={(value) => {
if (!value) return;
onTimeModeChange('preset');
onHoursChange(
Number.parseInt(value, 10) as OverviewRangeHours,
);
}}
variant='outline'
size='sm'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
<Button
size='sm'
variant={timeMode === 'custom' ? 'default' : 'outline'}
className='h-8 text-xs'
onClick={() => onTimeModeChange('custom')}
>
自定义
</Button>
</div>
</div>
{timeMode === 'custom' ? (
<div className='space-y-2'>
<div className='flex flex-wrap items-end gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>开始</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
value={draftSince || defaultCustomRange.since}
onChange={(e) => {
setDraftSince(e.target.value);
setCustomError(null);
}}
/>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>结束</p>
<Input
type='datetime-local'
className='h-9 w-52 text-xs'
value={draftUntil || defaultCustomRange.until}
onChange={(e) => {
setDraftUntil(e.target.value);
setCustomError(null);
}}
/>
</div>
<Button
size='sm'
className='h-9 text-xs'
onClick={handleApplyCustom}
>
应用
</Button>
</div>
{customError ? (
<p className='text-xs text-destructive'>{customError}</p>
) : (
<p className='text-xs text-muted-foreground'>
修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。
</p>
)}
</div>
) : null}
<div className='flex flex-wrap items-center gap-3'>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>排序</p>
<Select value={sort} onValueChange={onSortChange}>
<SelectTrigger className='h-8 w-48 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className='space-y-1'>
<p className='text-xs text-muted-foreground'>每页</p>
<Select
value={String(pageSize)}
onValueChange={(value) =>
onPageSizeChange(Number.parseInt(value, 10))
}
>
<SelectTrigger className='h-8 w-24 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZE_OPTIONS.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>IP 明细</p>
</div>
{error ? (
<div className='p-4'>
<ErrorInline
message={error.message || '加载失败'}
onRetry={onRetry}
/>
</div>
) : loading ? (
<LoadingStateWithBorder />
) : (data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>地区</TableHead>
<TableHead className='text-xs text-right'>请求数</TableHead>
<TableHead className='text-xs text-right'>2xx 比例</TableHead>
<TableHead className='text-xs text-right'>入站</TableHead>
<TableHead className='text-xs text-right'>出站</TableHead>
<TableHead className='text-xs'>最后访问</TableHead>
<TableHead className='w-12 text-center text-xs' />
</TableRow>
</TableHeader>
<TableBody>
{(data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className='border-dashed'>
<TableCell className='font-mono text-xs'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs text-muted-foreground'>
{item.region || '—'}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatCompactNumber(item.total_requests)}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
<span
title={`${item.success_2xx_count}/${item.total_requests}`}
>
{formatRatio(item.success_ratio)}
</span>
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatBytes(item.bytes_received)}
</TableCell>
<TableCell className='text-xs text-right tabular-nums'>
{formatBytes(item.bytes_sent)}
</TableCell>
<TableCell className='text-xs'>
{formatDateTime(item.last_seen_at)}
</TableCell>
<TableCell className='text-center'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground hover:text-foreground'
onClick={() => {
setSelected(item);
setDetailOpen(true);
}}
>
<Eye className='size-3' />
</Button>
</TooltipTrigger>
<TooltipContent side='top' className='text-xs'>
查看 IP 详情
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={data?.has_more ?? false}
loading={isFetching}
totalIp={data?.total_ip}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
</div>
<IpDetailDialog
open={detailOpen}
remoteAddr={selected?.remote_addr ?? null}
region={selected?.region}
initialHours={analysisHours}
onOpenChange={setDetailOpen}
/>
</>
);
}
/** 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 };
}
@@ -0,0 +1,775 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { Cell, Pie, PieChart } from 'recharts';
import { RankCard } from '@/components/data/rank-card';
import { TrendChart } from '@/components/data/trend-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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/components/ui/chart';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
ZoneService,
zoneQueryKey,
type AccessLogOverview,
type DistributionItem,
} from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
import {
formatOverviewRangeHint,
formatOverviewTrendLabel,
OVERVIEW_RANGE_OPTIONS,
type OverviewRangeHours,
} from './access-log-utils';
const DEVICE_COLORS = [
'#38bdf8',
'#34d399',
'#f59e0b',
'#a78bfa',
'#f472b6',
'#94a3b8',
];
function SparklineMetricCard({
title,
value,
hint,
color,
fillColor,
labels,
values,
valueFormatter,
}: {
title: string;
value: string;
hint: string;
color: string;
fillColor: string;
labels: string[];
values: number[];
valueFormatter?: (value: number) => string;
}) {
const option = useMemo<EChartsOption>(
() => ({
animationDuration: 400,
grid: {
left: 0,
right: 0,
top: 8,
bottom: 0,
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false,
data: labels,
},
yAxis: {
type: 'value',
show: false,
min: 0,
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const items = Array.isArray(params) ? params : [];
const item = items[0] as
{ axisValueLabel?: string; value?: number } | undefined;
if (!item) return '';
const raw = typeof item.value === 'number' ? item.value : 0;
const formatted = valueFormatter
? valueFormatter(raw)
: formatCompactNumber(raw);
return `${item.axisValueLabel ?? ''}<br/>${formatted}`;
},
},
series: [
{
type: 'line',
data: values,
smooth: true,
showSymbol: false,
lineStyle: {
color,
width: 2,
},
areaStyle: {
color: fillColor,
},
},
],
}),
[color, fillColor, labels, valueFormatter, values],
);
return (
<Card className='border-dashed shadow-none'>
<CardContent className='p-4'>
<div className='flex items-start justify-between gap-3'>
<div className='min-w-0 space-y-1'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{title}
</p>
<p className='text-2xl font-semibold tracking-tight'>{value}</p>
<p className='text-[11px] text-muted-foreground'>{hint}</p>
</div>
<div className='h-16 w-28 shrink-0 sm:w-36'>
{labels.length > 0 ? (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: '100%', width: '100%' }}
/>
) : null}
</div>
</div>
</CardContent>
</Card>
);
}
function toRankItems(items: DistributionItem[] | undefined) {
return (items ?? []).map((item) => ({
label: item.key,
value: item.value,
}));
}
function PieDistributionCard({
title,
description,
items,
emptyMessage,
}: {
title: string;
description: string;
items: DistributionItem[];
emptyMessage: string;
}) {
const chartData = useMemo(
() =>
items.map((item, index) => ({
name: item.key,
value: item.value,
fill: DEVICE_COLORS[index % DEVICE_COLORS.length],
})),
[items],
);
const chartConfig = useMemo(() => {
const config: ChartConfig = {};
chartData.forEach((item) => {
config[item.name] = {
label: item.name,
color: item.fill,
};
});
return config;
}, [chartData]);
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
{chartData.length === 0 ? (
<div className='flex h-[300px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
{emptyMessage}
</div>
) : (
<ChartContainer
config={chartConfig}
className='mx-auto h-[300px] w-full'
>
<PieChart>
<Pie
data={chartData}
dataKey='value'
nameKey='name'
cx='50%'
cy='46%'
innerRadius={50}
outerRadius={80}
paddingAngle={2}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
</Pie>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
hideLabel
formatter={(value, name) => (
<>
<span className='text-muted-foreground'>{name}</span>
<span className='ml-auto font-mono font-medium tabular-nums text-foreground'>
{formatCompactNumber(Number(value ?? 0))}
</span>
</>
)}
/>
}
/>
<ChartLegend
content={<ChartLegendContent nameKey='name' />}
className='flex-wrap justify-center gap-x-4 gap-y-1 pt-2 text-[11px]'
/>
</PieChart>
</ChartContainer>
)}
</CardContent>
</Card>
);
}
type ManagedZoneDomains = {
zoneId: number;
zoneName: string;
domains: string[];
};
function useManagedZoneDomains(enabled: boolean) {
return useQuery({
queryKey: [...zoneQueryKey, 'zone-domain-tree'],
enabled,
staleTime: 60_000,
queryFn: async (): Promise<ManagedZoneDomains[]> => {
const zones = await ZoneService.list();
const overviews = await Promise.all(
zones.map((zone) => ZoneService.getOverview(zone.id)),
);
return overviews
.map((overview) => {
const domainSet = new Set<string>();
for (const item of overview.domains ?? []) {
const domain = item.domain?.trim();
if (domain) domainSet.add(domain);
}
return {
zoneId: overview.zone.id,
zoneName: overview.zone.domain || `Zone #${overview.zone.id}`,
domains: Array.from(domainSet).sort((a, b) => a.localeCompare(b)),
};
})
.filter((zone) => zone.domains.length > 0)
.sort((a, b) => a.zoneName.localeCompare(b.zoneName));
},
});
}
function OverviewHostFilter({
hosts,
onHostsChange,
}: {
hosts: string[];
onHostsChange: (hosts: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
{},
);
const zonesQuery = useManagedZoneDomains(open);
const zones = useMemo(() => zonesQuery.data ?? [], [zonesQuery.data]);
const selectedSet = useMemo(() => new Set(hosts), [hosts]);
const filteredZones = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return zones;
return zones
.map((zone) => {
const zoneMatched = zone.zoneName.toLowerCase().includes(q);
const domains = zoneMatched
? zone.domains
: zone.domains.filter((domain) => domain.toLowerCase().includes(q));
return { ...zone, domains };
})
.filter((zone) => zone.domains.length > 0);
}, [query, zones]);
useEffect(() => {
if (!open || zones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
let changed = false;
for (const zone of zones) {
if (next[zone.zoneId] === undefined) {
next[zone.zoneId] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [open, zones]);
useEffect(() => {
const q = query.trim();
if (!q || filteredZones.length === 0) return;
setExpandedZones((prev) => {
const next = { ...prev };
for (const zone of filteredZones) {
next[zone.zoneId] = true;
}
return next;
});
}, [filteredZones, query]);
const toggleHost = (domain: string, checked: boolean | 'indeterminate') => {
if (checked === true) {
if (selectedSet.has(domain)) return;
onHostsChange([...hosts, domain]);
return;
}
onHostsChange(hosts.filter((item) => item !== domain));
};
const toggleZone = (
zoneDomains: string[],
checked: boolean | 'indeterminate',
) => {
if (checked === true) {
const next = new Set(hosts);
for (const domain of zoneDomains) next.add(domain);
onHostsChange(Array.from(next));
return;
}
onHostsChange(hosts.filter((item) => !zoneDomains.includes(item)));
};
return (
<Popover
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery('');
}}
>
<PopoverTrigger asChild>
<Button
type='button'
variant='outline'
size='icon'
className={cn(
'size-8 shrink-0',
hosts.length > 0 ? 'border-primary text-primary' : undefined,
)}
title={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
}
aria-label={
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
}
>
<Filter className='size-3.5' />
</Button>
</PopoverTrigger>
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-sm font-medium'>筛选域名</p>
{hosts.length > 0 ? (
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-xs'
onClick={() => onHostsChange([])}
>
<X className='mr-1 size-3' />
清除
</Button>
) : null}
</div>
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder='搜索 Zone 或域名'
className='h-8 text-xs'
/>
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
{zonesQuery.isLoading ? (
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
<Loader2 className='size-3.5 animate-spin' />
加载域名…
</div>
) : zonesQuery.isError ? (
<div className='space-y-2 py-2'>
<p className='text-xs text-destructive'>加载域名失败</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => void zonesQuery.refetch()}
>
重试
</Button>
</div>
) : filteredZones.length === 0 ? (
<p className='py-6 text-center text-xs text-muted-foreground'>
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
</p>
) : (
filteredZones.map((zone) => {
const selectedCount = zone.domains.filter((domain) =>
selectedSet.has(domain),
).length;
const allSelected = selectedCount === zone.domains.length;
const partialSelected =
selectedCount > 0 && selectedCount < zone.domains.length;
const expanded = expandedZones[zone.zoneId] ?? true;
return (
<Collapsible
key={zone.zoneId}
open={expanded}
onOpenChange={(next) =>
setExpandedZones((prev) => ({
...prev,
[zone.zoneId]: next,
}))
}
className='rounded-md border border-dashed'
>
<div className='flex items-center gap-1 px-2 py-1.5'>
<Checkbox
checked={
allSelected
? true
: partialSelected
? 'indeterminate'
: false
}
onCheckedChange={(checked) =>
toggleZone(zone.domains, checked)
}
aria-label={`选择 Zone ${zone.zoneName}`}
/>
<CollapsibleTrigger asChild>
<button
type='button'
className='flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 py-0.5 text-left text-xs font-medium hover:bg-accent'
>
<ChevronDown
className={cn(
'size-3.5 shrink-0 text-muted-foreground transition-transform',
expanded ? 'rotate-0' : '-rotate-90',
)}
/>
<span className='min-w-0 flex-1 truncate'>
{zone.zoneName}
</span>
<span className='text-[10px] text-muted-foreground'>
{selectedCount}/{zone.domains.length}
</span>
</button>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className='space-y-0.5 border-t border-dashed px-2 py-1.5'>
{zone.domains.map((domain) => {
const selected = selectedSet.has(domain);
return (
<label
key={domain}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent',
selected ? 'bg-accent/50' : undefined,
)}
>
<Checkbox
checked={selected}
onCheckedChange={(checked) =>
toggleHost(domain, checked)
}
aria-label={`选择域名 ${domain}`}
/>
<span className='min-w-0 flex-1 truncate font-mono'>
{domain}
</span>
</label>
);
})}
</div>
</CollapsibleContent>
</Collapsible>
);
})
)}
</div>
</PopoverContent>
</Popover>
);
}
function OverviewToolbar({
hours,
hosts,
onHoursChange,
onHostsChange,
}: {
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
}) {
return (
<div className='flex flex-wrap items-center justify-end gap-2'>
{hosts.length > 0 ? (
<Badge variant='secondary' className='max-w-[260px] truncate'>
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
</Badge>
) : null}
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
<ToggleGroup
type='single'
value={String(hours)}
onValueChange={(value) => {
if (!value) return;
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
}}
variant='outline'
size='sm'
className='justify-end'
>
{OVERVIEW_RANGE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={String(option.value)}
className='px-2.5 text-xs'
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
);
}
export function OverviewTab({
data,
loading,
error,
hours,
hosts,
onHoursChange,
onHostsChange,
onRetry,
}: {
data?: AccessLogOverview;
loading: boolean;
error: Error | null;
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
return (
<div className='space-y-6'>
<OverviewToolbar
hours={hours}
hosts={hosts}
onHoursChange={onHoursChange}
onHostsChange={onHostsChange}
/>
{loading ? (
<LoadingStateWithBorder
title='加载访问概览'
description='正在聚合请求量、访问量与带宽趋势...'
/>
) : error ? (
<ErrorInline
message={error.message || '加载访问概览失败'}
onRetry={onRetry}
/>
) : !data ? (
<EmptyStateWithBorder
title='暂无概览数据'
description='当前时间范围内没有可展示的访问统计。'
/>
) : (
<OverviewContent data={data} hours={hours} />
)}
</div>
);
}
function OverviewContent({
data,
hours,
}: {
data: AccessLogOverview;
hours: number;
}) {
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
);
const requestValues = data.trends.requests.map((point) => point.value);
const visitValues = data.trends.visits.map((point) => point.value);
const bandwidthValues = data.trends.bandwidth.map((point) => point.value);
const hint = formatOverviewRangeHint(hours);
return (
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title='Total Requests'
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
fillColor='rgba(245, 158, 11, 0.18)'
labels={requestLabels}
values={requestValues}
/>
<SparklineMetricCard
title='Total Visits'
value={formatCompactNumber(data.summary.total_visits)}
hint={`${hint} · 独立访客`}
color='#38bdf8'
fillColor='rgba(56, 189, 248, 0.16)'
labels={requestLabels}
values={visitValues}
/>
<SparklineMetricCard
title='Bandwidth Served'
value={formatBytes(data.summary.bandwidth_served)}
hint={`${hint} · 已提供数据`}
color='#34d399'
fillColor='rgba(52, 211, 153, 0.16)'
labels={requestLabels}
values={bandwidthValues}
valueFormatter={formatBytes}
/>
</div>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
Requests over time
</CardTitle>
<CardDescription className='text-xs'>
观察请求量是否出现异常抬升或回落。
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={requestLabels}
height={280}
showSummary={false}
series={[
{
label: '请求量',
color: '#f59e0b',
fillColor: 'rgba(245, 158, 11, 0.18)',
variant: 'area',
values: requestValues,
},
]}
/>
</CardContent>
</Card>
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
description='按设备类型统计请求占比。'
items={data.device_types ?? []}
emptyMessage='暂无设备类型数据'
/>
<PieDistributionCard
title='Status code'
description='按 HTTP 状态码统计请求占比。'
items={data.status_codes ?? []}
emptyMessage='暂无状态码分布数据'
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
description='访问量最高的请求路径。'
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
description='流量集中的访问域名。'
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
description='请求次数最多的来源 IP。'
items={toRankItems(data.top_ips)}
/>
</div>
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
description='按浏览器聚合的请求排行。'
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
description='按操作系统聚合的请求排行。'
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
description='原始 User-Agent 请求排行。'
items={toRankItems(data.top_user_agents)}
/>
</div>
</>
);
}
+174 -543
View File
@@ -1,56 +1,32 @@
'use client';
import { useCallback, useEffect, useMemo, 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 { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
import { RefreshCw, ScrollText, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { AccessLogService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { AccessLogFilters } from './components/access-log-filters';
import {
type AccessLogTab,
DETAIL_SORT_OPTIONS,
FOLD_SORT_OPTIONS,
formatCompactNumber,
IP_SORT_OPTIONS,
type OverviewRangeHours,
parseSortValue,
type SearchDraft,
} from './components/access-log-utils';
import { CleanupDialog } from './components/cleanup-dialog';
const trendChartConfig = {
requests: { label: '请求数', color: 'hsl(var(--primary))' },
} satisfies ChartConfig;
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 = {
nodeId: '',
@@ -59,62 +35,65 @@ const emptyDraft: SearchDraft = {
path: '',
};
function PaginationBar({
page,
hasMore,
loading,
onPrev,
onNext,
}: {
page: number;
hasMore: boolean;
loading: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className='flex items-center justify-between px-4 py-3 border-t border-dashed'>
<p className='text-xs text-muted-foreground'>当前第 {page + 1} 页</p>
<div className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={loading || page <= 0}
onClick={onPrev}
>
上一页
</Button>
<Button
variant='outline'
size='sm'
disabled={loading || !hasMore}
onClick={onNext}
>
下一页
</Button>
</div>
</div>
);
function resolveTab(value: string | null): AccessLogTab {
if (value === 'list') return 'list';
if (value === 'ips') return 'ips';
return 'overview';
}
export default function AccessLogsPage() {
function AccessLogsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [tab, setTab] = useState<AccessLogTab>('list');
const tab = resolveTab(searchParams.get('tab'));
const [draft, setDraft] = useState<SearchDraft>(emptyDraft);
const [filters, setFilters] = useState<SearchDraft>(emptyDraft);
const [pageSize, setPageSize] = useState(20);
const [page, setPage] = useState(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
const [foldSort, setFoldSort] = useState('bucket_started_at:desc');
const [ipSort, setIpSort] = useState('total_requests:desc');
const [foldMinutes, setFoldMinutes] = useState<3 | 5>(3);
const [trendIp, setTrendIp] = useState('');
const [appliedTrendIp, setAppliedTrendIp] = useState('');
const [ipPageSize, setIpPageSize] = useState(20);
const [ipPage, setIpPage] = useState(0);
const [ipHours, setIpHours] = useState<OverviewRangeHours>(168);
const [ipTimeMode, setIpTimeMode] = useState<IpTabTimeMode>('preset');
const [ipCustomSince, setIpCustomSince] = useState('');
const [ipCustomUntil, setIpCustomUntil] = useState('');
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
const foldSortState = parseSortValue(foldSort);
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);
router.replace(
next === 'overview' ? '/access-logs' : `/access-logs?tab=${next}`,
);
};
const overviewQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'overview',
overviewHours,
overviewHosts,
],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
}),
enabled: tab === 'overview',
});
const listQuery = useQuery({
queryKey: [
@@ -140,66 +119,25 @@ export default function AccessLogsPage() {
enabled: tab === 'list',
});
const foldsQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'folds',
filters,
page,
pageSize,
foldSort,
foldMinutes,
],
queryFn: () =>
AccessLogService.listFolds({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
p: page,
page_size: pageSize,
sort_by: foldSortState.sortBy,
sort_order: foldSortState.sortOrder,
fold_minutes: foldMinutes,
}),
enabled: tab === 'folds',
});
const ipSummaryQuery = useQuery({
const ipQuery = useQuery({
queryKey: [
'openflare',
'access-logs',
'ip-summary',
filters,
page,
pageSize,
ipTimeParams,
ipPage,
ipPageSize,
ipSort,
],
queryFn: () =>
AccessLogService.listIPSummaries({
node_id: filters.nodeId || undefined,
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
p: page,
page_size: pageSize,
...(ipTimeParams as NonNullable<typeof ipTimeParams>),
p: ipPage,
page_size: ipPageSize,
sort_by: ipSortState.sortBy,
sort_order: ipSortState.sortOrder,
}),
enabled: tab === 'ip-summary',
});
const ipTrendQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'ip-trend', filters, appliedTrendIp],
queryFn: () =>
AccessLogService.getIPTrend({
node_id: filters.nodeId || undefined,
remote_addr: appliedTrendIp,
host: filters.host || undefined,
hours: 24,
bucket_minutes: 30,
}),
enabled: tab === 'ip-trend' && appliedTrendIp !== '',
enabled: ipQueryEnabled,
});
const cleanupMutation = useMutation({
@@ -217,35 +155,6 @@ export default function AccessLogsPage() {
},
});
const activeSummary = useMemo(() => {
if (tab === 'list' && listQuery.data) {
return {
totalRecord: listQuery.data.total_record,
totalIp: listQuery.data.total_ip,
};
}
if (tab === 'folds' && foldsQuery.data) {
return {
totalRecord: foldsQuery.data.total_record,
totalIp: foldsQuery.data.total_ip,
};
}
if (tab === 'ip-summary' && ipSummaryQuery.data) {
return {
totalRecord: 0,
totalIp: ipSummaryQuery.data.total_ip,
};
}
return { totalRecord: 0, totalIp: 0 };
}, [tab, listQuery.data, foldsQuery.data, ipSummaryQuery.data]);
const trendChartData = useMemo(() => {
return (ipTrendQuery.data?.points ?? []).map((point) => ({
label: formatDateTime(point.bucket_started_at).slice(5),
requests: point.request_count,
}));
}, [ipTrendQuery.data?.points]);
const handleSearch = useCallback(() => {
setFilters({
nodeId: draft.nodeId.trim(),
@@ -260,26 +169,32 @@ export default function AccessLogsPage() {
setDraft(emptyDraft);
setFilters(emptyDraft);
setPage(0);
setTrendIp('');
setAppliedTrendIp('');
};
useEffect(() => {
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 === 'folds') void foldsQuery.refetch();
if (tab === 'ip-summary') void ipSummaryQuery.refetch();
if (tab === 'ip-trend') void ipTrendQuery.refetch();
if (tab === 'ips') void ipQuery.refetch();
};
const isFetching =
listQuery.isFetching ||
foldsQuery.isFetching ||
ipSummaryQuery.isFetching ||
ipTrendQuery.isFetching;
overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
return (
<div className='py-6 px-1 space-y-6'>
@@ -289,7 +204,7 @@ export default function AccessLogsPage() {
<div>
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
<p className='text-sm text-muted-foreground'>
按节点、IP、域名与路径检索,支持时间折叠、IP 汇总与趋势分析。
查看访问概览、IP 明细与请求日志。
</p>
</div>
</div>
@@ -316,392 +231,93 @@ export default function AccessLogsPage() {
</div>
</div>
<div className='grid gap-3 sm:grid-cols-3'>
{[
{
label: '访问记录',
value: formatCompactNumber(activeSummary.totalRecord),
},
{
label: '来源 IP',
value: formatCompactNumber(activeSummary.totalIp),
},
{
label: '当前视图',
value:
tab === 'list'
? '明细日志'
: tab === 'folds'
? '时间折叠'
: tab === 'ip-summary'
? 'IP 汇总'
: 'IP 趋势',
},
].map((item) => (
<div
key={item.label}
className='rounded-lg border border-dashed px-4 py-3'
>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
{item.label}
</p>
<p className='mt-1 text-lg font-semibold'>{item.value}</p>
</div>
))}
</div>
<Tabs
value={tab}
onValueChange={(value) => setTab(value as AccessLogTab)}
>
<TabsList className='grid w-full grid-cols-2 lg:grid-cols-4'>
<TabsTrigger value='list'>明细列表</TabsTrigger>
<TabsTrigger value='folds'>时间折叠</TabsTrigger>
<TabsTrigger value='ip-summary'>IP 汇总</TabsTrigger>
<TabsTrigger value='ip-trend'>IP 趋势</TabsTrigger>
<Tabs value={tab} onValueChange={handleTabChange}>
<TabsList className='grid w-full max-w-lg grid-cols-3'>
<TabsTrigger value='overview'>概览</TabsTrigger>
<TabsTrigger value='ips'>IP 明细</TabsTrigger>
<TabsTrigger value='list'>日志明细</TabsTrigger>
</TabsList>
<div className='mt-4 rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
tab={tab}
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
<TabsContent value='overview' className='mt-4'>
<OverviewTab
data={overviewQuery.data}
loading={overviewQuery.isLoading}
error={
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
hosts={overviewHosts}
onHoursChange={setOverviewHours}
onHostsChange={setOverviewHosts}
onRetry={() => void overviewQuery.refetch()}
/>
</div>
<TabsContent value='list' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>明细日志</p>
<Select value={detailSort} onValueChange={setDetailSort}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DETAIL_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{listQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
listQuery.error instanceof Error
? listQuery.error.message
: '加载失败'
}
onRetry={() => void listQuery.refetch()}
/>
</div>
) : listQuery.isLoading ? (
<LoadingStateWithBorder />
) : (listQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无访问日志' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间</TableHead>
<TableHead className='text-xs'>节点</TableHead>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>域名</TableHead>
<TableHead className='text-xs'>路径</TableHead>
<TableHead className='text-xs'>状态码</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(listQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id} className='border-dashed'>
<TableCell className='text-xs'>
{formatDateTime(item.logged_at)}
</TableCell>
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
{item.path}
</TableCell>
<TableCell>
<Badge variant='outline' className='text-[10px]'>
{item.status_code}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={listQuery.data?.has_more ?? false}
loading={listQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
</TabsContent>
<TabsContent value='folds' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex flex-wrap items-center justify-between gap-2 px-4 py-3 border-b border-dashed'>
<div className='flex items-center gap-2'>
<p className='text-sm font-medium'>时间折叠</p>
<Select
value={String(foldMinutes)}
onValueChange={(value) =>
setFoldMinutes(Number(value) as 3 | 5)
}
>
<SelectTrigger className='h-8 w-36 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='3'>3 分钟桶</SelectItem>
<SelectItem value='5'>5 分钟桶</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={foldSort} onValueChange={setFoldSort}>
<SelectTrigger className='h-8 w-44 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{FOLD_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{foldsQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
foldsQuery.error instanceof Error
? foldsQuery.error.message
: '加载失败'
}
onRetry={() => void foldsQuery.refetch()}
/>
</div>
) : foldsQuery.isLoading ? (
<LoadingStateWithBorder />
) : (foldsQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无折叠数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>时间桶</TableHead>
<TableHead className='text-xs'>请求数</TableHead>
<TableHead className='text-xs'>独立 IP</TableHead>
<TableHead className='text-xs'>独立域名</TableHead>
<TableHead className='text-xs'>2xx</TableHead>
<TableHead className='text-xs'>4xx</TableHead>
<TableHead className='text-xs'>5xx</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(foldsQuery.data?.items ?? []).map((item) => (
<TableRow
key={item.bucket_started_at}
className='border-dashed'
>
<TableCell className='text-xs'>
{formatDateTime(item.bucket_started_at)}
</TableCell>
<TableCell className='text-xs'>
{item.request_count}
</TableCell>
<TableCell className='text-xs'>
{item.unique_ip_count}
</TableCell>
<TableCell className='text-xs'>
{item.unique_host_count}
</TableCell>
<TableCell className='text-xs'>
{item.success_count}
</TableCell>
<TableCell className='text-xs'>
{item.client_error_count}
</TableCell>
<TableCell className='text-xs'>
{item.server_error_count}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={foldsQuery.data?.has_more ?? false}
loading={foldsQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
/>
</div>
<TabsContent value='ips' className='mt-4'>
<IpTab
data={ipQuery.data}
loading={ipQueryEnabled && ipQuery.isLoading}
error={ipQuery.error instanceof Error ? ipQuery.error : null}
page={ipPage}
pageSize={ipPageSize}
sort={ipSort}
hours={ipHours}
timeMode={ipTimeMode}
customSince={ipCustomSince}
customUntil={ipCustomUntil}
onHoursChange={(next) => {
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}
/>
</TabsContent>
<TabsContent value='ip-summary' className='mt-4'>
<div className='rounded-lg border border-dashed overflow-hidden bg-background'>
<div className='flex items-center justify-between px-4 py-3 border-b border-dashed'>
<p className='text-sm font-medium'>IP 汇总</p>
<Select value={ipSort} onValueChange={setIpSort}>
<SelectTrigger className='h-8 w-52 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IP_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{ipSummaryQuery.isError ? (
<div className='p-4'>
<ErrorInline
message={
ipSummaryQuery.error instanceof Error
? ipSummaryQuery.error.message
: '加载失败'
}
onRetry={() => void ipSummaryQuery.refetch()}
/>
</div>
) : ipSummaryQuery.isLoading ? (
<LoadingStateWithBorder />
) : (ipSummaryQuery.data?.items ?? []).length === 0 ? (
<EmptyStateWithBorder title='暂无 IP 汇总数据' />
) : (
<Table>
<TableHeader className='bg-muted/40'>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='text-xs'>IP</TableHead>
<TableHead className='text-xs'>总请求数</TableHead>
<TableHead className='text-xs'>近 3 小时</TableHead>
<TableHead className='text-xs'>最后访问</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(ipSummaryQuery.data?.items ?? []).map((item) => (
<TableRow key={item.remote_addr} className='border-dashed'>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
</TableCell>
<TableCell className='text-xs'>
{item.total_requests}
</TableCell>
<TableCell className='text-xs'>
{item.recent_requests}
</TableCell>
<TableCell className='text-xs'>
{formatDateTime(item.last_seen_at)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<PaginationBar
page={page}
hasMore={ipSummaryQuery.data?.has_more ?? false}
loading={ipSummaryQuery.isFetching}
onPrev={() => setPage((p) => Math.max(0, p - 1))}
onNext={() => setPage((p) => p + 1)}
<TabsContent value='list' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4'>
<AccessLogFilters
draft={draft}
pageSize={pageSize}
onDraftChange={setDraft}
onPageSizeChange={setPageSize}
onSearch={handleSearch}
onReset={handleReset}
/>
</div>
</TabsContent>
<TabsContent value='ip-trend' className='mt-4 space-y-4'>
<div className='rounded-lg border border-dashed bg-background p-4 flex flex-col gap-3 sm:flex-row sm:items-end'>
<div className='space-y-1.5 flex-1'>
<p className='text-xs font-medium text-muted-foreground'>
趋势 IP
</p>
<input
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 text-xs'
value={trendIp}
onChange={(e) => setTrendIp(e.target.value)}
placeholder='输入要分析趋势的 IP 地址'
/>
</div>
<Button
size='sm'
onClick={() => setAppliedTrendIp(trendIp.trim())}
disabled={!trendIp.trim()}
>
查看趋势
</Button>
</div>
<div className='rounded-lg border border-dashed overflow-hidden bg-background p-4'>
{!appliedTrendIp ? (
<EmptyStateWithBorder description='请输入 IP 地址后查看 24 小时访问趋势。' />
) : ipTrendQuery.isLoading ? (
<LoadingStateWithBorder />
) : ipTrendQuery.isError ? (
<ErrorInline
message={
ipTrendQuery.error instanceof Error
? ipTrendQuery.error.message
: '加载失败'
}
onRetry={() => void ipTrendQuery.refetch()}
/>
) : trendChartData.length === 0 ? (
<EmptyStateWithBorder description='该 IP 在选定时间范围内没有访问记录。' />
) : (
<div className='space-y-3'>
<p className='text-sm font-medium'>
{appliedTrendIp} · 近 {ipTrendQuery.data?.hours ?? 24} 小时
</p>
<ChartContainer
config={trendChartConfig}
className='h-64 w-full'
>
<AreaChart data={trendChartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey='label'
tickLine={false}
axisLine={false}
fontSize={10}
/>
<YAxis
tickLine={false}
axisLine={false}
fontSize={10}
width={40}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Area
type='monotone'
dataKey='requests'
stroke='var(--color-requests)'
fill='var(--color-requests)'
fillOpacity={0.2}
/>
</AreaChart>
</ChartContainer>
</div>
)}
</div>
<DetailTab
data={listQuery.data}
loading={listQuery.isLoading}
error={listQuery.error instanceof Error ? listQuery.error : null}
page={page}
detailSort={detailSort}
onDetailSortChange={setDetailSort}
onRetry={() => void listQuery.refetch()}
onPrevPage={() => setPage((p) => Math.max(0, p - 1))}
onNextPage={() => setPage((p) => p + 1)}
isFetching={listQuery.isFetching}
/>
</TabsContent>
</Tabs>
@@ -714,3 +330,18 @@ export default function AccessLogsPage() {
</div>
);
}
export default function AccessLogsPage() {
return (
<Suspense
fallback={
<LoadingStateWithBorder
title='加载访问日志'
description='正在准备页面...'
/>
}
>
<AccessLogsPageContent />
</Suspense>
);
}
@@ -1,702 +1,177 @@
{
"Afghanistan": [
65.99254367483304,
33.83349410470984
],
"Albania": [
20.035803636476235,
41.179283471608585
],
"Algeria": [
2.6175172555729325,
28.158202786113698
],
"Angola": [
17.563198329790602,
-12.334557680834592
],
"Argentina": [
-65.15670576062132,
-35.178771590782524
],
"Armenia": [
44.929379894609674,
40.29884592895426
],
"Australia": [
134.361180723562,
-25.574955774991995
],
"Austria": [
14.10165807418182,
47.57042831528493
],
"Azerbaijan": [
47.636777782137415,
40.3311508093757
],
"Bangladesh": [
90.21009840315206,
23.88793565572049
],
"Belarus": [
28.04895281233843,
53.53432933090482
],
"Belgium": [
4.628129604331132,
50.646494813466056
],
"Belize": [
-88.72096138346826,
17.17073148225863
],
"Benin": [
2.323507643298733,
9.63048313950481
],
"Bhutan": [
90.40562361681495,
27.40133623508629
],
"Bolivia": [
-64.69104972918655,
-16.70141507507877
],
"Bosnia and Herzegovina": [
17.762461846176542,
44.16398853722456
],
"Botswana": [
23.791788508036337,
-22.185333985163894
],
"Brazil": [
-53.113858151998855,
-10.827995867833263
],
"Brunei": [
114.54229268997476,
4.451218476864121
],
"Bulgaria": [
25.225364036830744,
42.77785706066581
],
"Burkina Faso": [
-1.7757430610210276,
12.260370131848955
],
"Burundi": [
29.873065896828848,
-3.3802825292327188
],
"Cambodia": [
104.90702345739547,
12.717932969523966
],
"Cameroon": [
12.739868035578002,
5.692692453534944
],
"Canada": [
-102.31777616484295,
57.94457487676815
],
"Central African Republic": [
20.490781591297914,
6.563625165826992
],
"Chad": [
18.645815630757973,
15.32729896930035
],
"Chile": [
-71.34891661948937,
-35.98028631773855
],
"China": [
103.79804331240364,
36.61094167680319
],
"Colombia": [
-73.07794094917405,
3.9235618261252663
],
"Costa Rica": [
-84.21742467558424,
9.986463158070535
],
"Croatia": [
16.458685366615978,
45.18567759026696
],
"Cuba": [
-78.8954025482625,
21.62136808001903
],
"Cyprus": [
33.02409208254226,
34.91304424540028
],
"Czech Republic": [
15.330767057694615,
49.70160772219921
],
"Democratic Republic of the Congo": [
23.64811688254194,
-2.8775533309965056
],
"Denmark": [
-41.29088868109839,
74.72535220460091
],
"Djibouti": [
42.56788631653962,
11.745988995518626
],
"Dominican Republic": [
-70.48595892120278,
18.896879062003393
],
"East Timor": [
125.78222644041995,
-8.858373836074785
],
"Ecuador": [
-78.38384779532852,
-1.4454571869341688
],
"Egypt": [
29.86739912685152,
26.49286512797635
],
"El Salvador": [
-88.86593554283397,
13.734573052971413
],
"Equatorial Guinea": [
10.481670786028316,
1.5660796013647418
],
"Eritrea": [
38.835037148217566,
15.359809600209143
],
"Estonia": [
25.811350172874032,
58.69303019372469
],
"Ethiopia": [
39.60372599651433,
8.621595995236198
],
"Fiji": [
177.98415179869738,
-17.827004078184217
],
"Finland": [
26.28958351526606,
64.52279699210293
],
"France": [
165.43817082439585,
-21.285375782391018
],
"Gabon": [
11.774048593462979,
-0.5859798536474902
],
"Gambia": [
-16.21304964555284,
13.37062695465985
],
"Georgia": [
43.48650319000057,
42.175027555818694
],
"Germany": [
10.380496796525602,
51.083853487429664
],
"Ghana": [
-1.2150159067556041,
7.94078880601944
],
"Greece": [
22.53435219348475,
39.517734832602365
],
"Guatemala": [
-90.35344030476544,
15.679044168855697
],
"Guinea": [
-10.943568403941383,
10.44078010915303
],
"Guinea Bissau": [
-14.929646023453557,
12.05088889829685
],
"Guyana": [
-58.978019347627985,
4.800329726037504
],
"Haiti": [
-72.67866559866681,
18.916195586860297
],
"Honduras": [
-86.65651796671759,
14.821029518594349
],
"Hong Kong": [
114.1747,
22.2783
],
"Hungary": [
19.410867145831855,
47.168542742918525
],
"Iceland": [
-18.509670948264166,
65.03034972428229
],
"India": [
79.58636900833257,
22.901586798523514
],
"Indonesia": [
101.52237404492608,
-0.44681277438152994
],
"Iran": [
54.266246557433305,
32.57616445788996
],
"Iraq": [
43.74006501790808,
33.0341456153028
],
"Ireland": [
-8.117695195030898,
53.2341315203433
],
"Israel": [
35.241573878810335,
31.94758544099519
],
"Italy": [
12.16233842748749,
43.51281131035235
],
"Ivory Coast": [
-5.569771898429842,
7.626172775954684
],
"Jamaica": [
-77.32850498837172,
18.172957893241634
],
"Japan": [
137.97352453982398,
36.589925009528365
],
"Jordan": [
36.76131486689134,
31.237145277490367
],
"Kashmir": [
77.08733723957188,
35.415738932286466
],
"Kazakhstan": [
67.27815427190085,
48.155989088140196
],
"Kenya": [
37.795078751163835,
0.5967194222965451
],
"Kosovo": [
20.851519165302378,
42.51783231278167
],
"Kuwait": [
47.566177395781736,
29.316145203902064
],
"Kyrgyzstan": [
74.58224715420873,
41.48363475100716
],
"Laos": [
103.75263811609112,
18.494354885305256
],
"Latvia": [
24.89496154477673,
56.853143698564296
],
"Lebanon": [
35.88502277707876,
33.91528991779158
],
"Lesotho": [
28.24303757633595,
-29.57351232727891
],
"Liberia": [
-9.325867879201954,
6.453996218817175
],
"Libya": [
18.008785486000946,
27.031457363755713
],
"Lithuania": [
23.872793668340726,
55.3142460584835
],
"Luxembourg": [
6.070340274408408,
49.76746140228473
],
"Macao": [
113.5439,
22.1987
],
"Macau": [
113.5439,
22.1987
],
"Macedonia": [
21.704642152886493,
41.60373573782414
],
"Madagascar": [
46.70172536870331,
-19.374776628372288
],
"Malawi": [
34.288911351438394,
-13.200363393826267
],
"Malaysia": [
114.69201508943004,
3.57724156754415
],
"Mali": [
-3.5321833143642736,
17.347810147640345
],
"Mauritania": [
-10.34307896458181,
20.259793585678146
],
"Mexico": [
-102.51849775988345,
23.943713432682014
],
"Moldova": [
28.446472517363826,
47.2017924696845
],
"Mongolia": [
103.06423533091326,
46.82560864032323
],
"Montenegro": [
19.233365413523355,
42.78395456212118
],
"Morocco": [
-8.459192831832421,
29.83932361976915
],
"Mozambique": [
35.53173663088568,
-17.27297477254694
],
"Myanmar": [
96.49588816533908,
21.22201469728619
],
"Namibia": [
17.208945346875193,
-22.132302890947404
],
"Nepal": [
83.9234099472594,
28.243335814044734
],
"Netherlands": [
5.646194278986089,
52.27734132368374
],
"New Zealand": [
170.54047059084724,
-43.955381757717255
],
"Nicaragua": [
-85.03229930746807,
12.835548864379284
],
"Niger": [
9.384787308614277,
17.41758190683491
],
"Nigeria": [
8.074235448092892,
9.58958974174772
],
"North Korea": [
127.21551783512186,
40.144856985239926
],
"Northern Cyprus": [
33.39079188058263,
35.225753269439686
],
"Norway": [
13.994247649210363,
64.32249798565987
],
"Oman": [
56.08271927344639,
20.573738446088598
],
"Pakistan": [
69.32988114530586,
29.951585862562073
],
"Panama": [
-80.05970573973497,
8.521692369504816
],
"Papua New Guinea": [
144.22896759838426,
-6.607045399379529
],
"Paraguay": [
-58.39900641582761,
-23.223895588708068
],
"Peru": [
-74.38850244798846,
-9.156316252308201
],
"Philippines": [
121.45754828266669,
15.948824272939182
],
"Poland": [
19.379892383012464,
52.11963086628078
],
"Portugal": [
-7.977838817691485,
39.6854900148663
],
"Qatar": [
51.170816022848,
25.32876608728452
],
"Republic of Serbia": [
20.766249889622852,
44.19998417744864
],
"Republic of the Congo": [
15.21207418503586,
-0.8564004333096924
],
"Romania": [
24.971632366248883,
45.84560772065747
],
"Russia": [
99.05228241954488,
61.677043847105494
],
"Rwanda": [
29.91765145538139,
-1.9953628964377972
],
"Saudi Arabia": [
44.5342871199886,
24.122854227726673
],
"Senegal": [
-14.472411019597182,
14.338613732351318
],
"Sierra Leone": [
-11.82013204896783,
8.575093438325089
],
"Singapore": [
103.8198,
1.3521
],
"Slovakia": [
19.47296461136286,
48.71540683451534
],
"Slovenia": [
14.771193461021065,
46.10807043333343
],
"Solomon Islands": [
160.156396875585,
-9.628589831637283
],
"Somalia": [
45.706539856671256,
4.747960517798881
],
"Somaliland": [
46.25513292885051,
9.729995248781385
],
"South Africa": [
25.160630055943862,
-29.008774283210798
],
"South Korea": [
127.87280447602005,
36.475157799464604
],
"South Sudan": [
30.253827921202955,
7.307277152400154
],
"Spain": [
-3.5708365129134876,
40.391564705240064
],
"Sri Lanka": [
80.70055059693914,
7.649883097905021
],
"Sudan": [
29.946243665370346,
15.99205876641883
],
"Suriname": [
-55.91029765824739,
4.126766010517478
],
"Swaziland": [
31.47664514492297,
-26.563545981108437
],
"Sweden": [
16.752551082675275,
62.8354879894394
],
"Switzerland": [
8.205812860319684,
46.805544047201984
],
"Syria": [
38.50449031631095,
35.01769370592341
],
"Taiwan": [
120.9605,
23.6978
],
"Tajikistan": [
70.98712700685866,
38.55204366339685
],
"Thailand": [
101.01060253224972,
15.132319216387051
],
"The Bahamas": [
-78.02733865351972,
24.719920319716515
],
"Togo": [
0.949513544830056,
8.576560980195756
],
"Trinidad and Tobago": [
-61.270458833911896,
10.339930045317969
],
"Tunisia": [
9.546832905099203,
34.12673069029241
],
"Turkey": [
35.424986274948736,
38.998407240706534
],
"Turkmenistan": [
59.39292286662992,
39.1098173228857
],
"Uganda": [
32.36575488492427,
1.2757695499320103
],
"Ukraine": [
31.381193375918794,
49.01345298489402
],
"United Arab Emirates": [
54.30345828396012,
23.905698042805522
],
"United Kingdom": [
-36.367082569942795,
-54.468348288665524
],
"United Republic of Tanzania": [
34.79490109621088,
-6.276146393386636
],
"United States of America": [
-99.14669730710926,
39.52689381707972
],
"Uruguay": [
-56.01665573850212,
-32.80651677440885
],
"Uzbekistan": [
63.148826727968626,
41.7502616897204
],
"Vanuatu": [
166.84228659210697,
-15.266627310669035
],
"Venezuela": [
-66.1882174671549,
7.118299933056809
],
"Vietnam": [
106.29939096235873,
16.63088189612042
],
"Western Sahara": [
-12.218085234851758,
24.221135708939354
],
"Yemen": [
47.546351590294876,
15.935825615412996
],
"Zambia": [
27.775321721593084,
-13.460954386993254
],
"Zimbabwe": [
29.850564118889274,
-19.00677451616593
]
"Afghanistan": [65.99254367483304, 33.83349410470984],
"Albania": [20.035803636476235, 41.179283471608585],
"Algeria": [2.6175172555729325, 28.158202786113698],
"Angola": [17.563198329790602, -12.334557680834592],
"Argentina": [-65.15670576062132, -35.178771590782524],
"Armenia": [44.929379894609674, 40.29884592895426],
"Australia": [134.361180723562, -25.574955774991995],
"Austria": [14.10165807418182, 47.57042831528493],
"Azerbaijan": [47.636777782137415, 40.3311508093757],
"Bangladesh": [90.21009840315206, 23.88793565572049],
"Belarus": [28.04895281233843, 53.53432933090482],
"Belgium": [4.628129604331132, 50.646494813466056],
"Belize": [-88.72096138346826, 17.17073148225863],
"Benin": [2.323507643298733, 9.63048313950481],
"Bhutan": [90.40562361681495, 27.40133623508629],
"Bolivia": [-64.69104972918655, -16.70141507507877],
"Bosnia and Herzegovina": [17.762461846176542, 44.16398853722456],
"Botswana": [23.791788508036337, -22.185333985163894],
"Brazil": [-53.113858151998855, -10.827995867833263],
"Brunei": [114.54229268997476, 4.451218476864121],
"Bulgaria": [25.225364036830744, 42.77785706066581],
"Burkina Faso": [-1.7757430610210276, 12.260370131848955],
"Burundi": [29.873065896828848, -3.3802825292327188],
"Cambodia": [104.90702345739547, 12.717932969523966],
"Cameroon": [12.739868035578002, 5.692692453534944],
"Canada": [-102.31777616484295, 57.94457487676815],
"Central African Republic": [20.490781591297914, 6.563625165826992],
"Chad": [18.645815630757973, 15.32729896930035],
"Chile": [-71.34891661948937, -35.98028631773855],
"China": [103.79804331240364, 36.61094167680319],
"Colombia": [-73.07794094917405, 3.9235618261252663],
"Costa Rica": [-84.21742467558424, 9.986463158070535],
"Croatia": [16.458685366615978, 45.18567759026696],
"Cuba": [-78.8954025482625, 21.62136808001903],
"Cyprus": [33.02409208254226, 34.91304424540028],
"Czech Republic": [15.330767057694615, 49.70160772219921],
"Democratic Republic of the Congo": [23.64811688254194, -2.8775533309965056],
"Denmark": [-41.29088868109839, 74.72535220460091],
"Djibouti": [42.56788631653962, 11.745988995518626],
"Dominican Republic": [-70.48595892120278, 18.896879062003393],
"East Timor": [125.78222644041995, -8.858373836074785],
"Ecuador": [-78.38384779532852, -1.4454571869341688],
"Egypt": [29.86739912685152, 26.49286512797635],
"El Salvador": [-88.86593554283397, 13.734573052971413],
"Equatorial Guinea": [10.481670786028316, 1.5660796013647418],
"Eritrea": [38.835037148217566, 15.359809600209143],
"Estonia": [25.811350172874032, 58.69303019372469],
"Ethiopia": [39.60372599651433, 8.621595995236198],
"Fiji": [177.98415179869738, -17.827004078184217],
"Finland": [26.28958351526606, 64.52279699210293],
"France": [165.43817082439585, -21.285375782391018],
"Gabon": [11.774048593462979, -0.5859798536474902],
"Gambia": [-16.21304964555284, 13.37062695465985],
"Georgia": [43.48650319000057, 42.175027555818694],
"Germany": [10.380496796525602, 51.083853487429664],
"Ghana": [-1.2150159067556041, 7.94078880601944],
"Greece": [22.53435219348475, 39.517734832602365],
"Guatemala": [-90.35344030476544, 15.679044168855697],
"Guinea": [-10.943568403941383, 10.44078010915303],
"Guinea Bissau": [-14.929646023453557, 12.05088889829685],
"Guyana": [-58.978019347627985, 4.800329726037504],
"Haiti": [-72.67866559866681, 18.916195586860297],
"Honduras": [-86.65651796671759, 14.821029518594349],
"Hong Kong": [114.1747, 22.2783],
"Hungary": [19.410867145831855, 47.168542742918525],
"Iceland": [-18.509670948264166, 65.03034972428229],
"India": [79.58636900833257, 22.901586798523514],
"Indonesia": [101.52237404492608, -0.44681277438152994],
"Iran": [54.266246557433305, 32.57616445788996],
"Iraq": [43.74006501790808, 33.0341456153028],
"Ireland": [-8.117695195030898, 53.2341315203433],
"Israel": [35.241573878810335, 31.94758544099519],
"Italy": [12.16233842748749, 43.51281131035235],
"Ivory Coast": [-5.569771898429842, 7.626172775954684],
"Jamaica": [-77.32850498837172, 18.172957893241634],
"Japan": [137.97352453982398, 36.589925009528365],
"Jordan": [36.76131486689134, 31.237145277490367],
"Kashmir": [77.08733723957188, 35.415738932286466],
"Kazakhstan": [67.27815427190085, 48.155989088140196],
"Kenya": [37.795078751163835, 0.5967194222965451],
"Kosovo": [20.851519165302378, 42.51783231278167],
"Kuwait": [47.566177395781736, 29.316145203902064],
"Kyrgyzstan": [74.58224715420873, 41.48363475100716],
"Laos": [103.75263811609112, 18.494354885305256],
"Latvia": [24.89496154477673, 56.853143698564296],
"Lebanon": [35.88502277707876, 33.91528991779158],
"Lesotho": [28.24303757633595, -29.57351232727891],
"Liberia": [-9.325867879201954, 6.453996218817175],
"Libya": [18.008785486000946, 27.031457363755713],
"Lithuania": [23.872793668340726, 55.3142460584835],
"Luxembourg": [6.070340274408408, 49.76746140228473],
"Macao": [113.5439, 22.1987],
"Macau": [113.5439, 22.1987],
"Macedonia": [21.704642152886493, 41.60373573782414],
"Madagascar": [46.70172536870331, -19.374776628372288],
"Malawi": [34.288911351438394, -13.200363393826267],
"Malaysia": [114.69201508943004, 3.57724156754415],
"Mali": [-3.5321833143642736, 17.347810147640345],
"Mauritania": [-10.34307896458181, 20.259793585678146],
"Mexico": [-102.51849775988345, 23.943713432682014],
"Moldova": [28.446472517363826, 47.2017924696845],
"Mongolia": [103.06423533091326, 46.82560864032323],
"Montenegro": [19.233365413523355, 42.78395456212118],
"Morocco": [-8.459192831832421, 29.83932361976915],
"Mozambique": [35.53173663088568, -17.27297477254694],
"Myanmar": [96.49588816533908, 21.22201469728619],
"Namibia": [17.208945346875193, -22.132302890947404],
"Nepal": [83.9234099472594, 28.243335814044734],
"Netherlands": [5.646194278986089, 52.27734132368374],
"New Zealand": [170.54047059084724, -43.955381757717255],
"Nicaragua": [-85.03229930746807, 12.835548864379284],
"Niger": [9.384787308614277, 17.41758190683491],
"Nigeria": [8.074235448092892, 9.58958974174772],
"North Korea": [127.21551783512186, 40.144856985239926],
"Northern Cyprus": [33.39079188058263, 35.225753269439686],
"Norway": [13.994247649210363, 64.32249798565987],
"Oman": [56.08271927344639, 20.573738446088598],
"Pakistan": [69.32988114530586, 29.951585862562073],
"Panama": [-80.05970573973497, 8.521692369504816],
"Papua New Guinea": [144.22896759838426, -6.607045399379529],
"Paraguay": [-58.39900641582761, -23.223895588708068],
"Peru": [-74.38850244798846, -9.156316252308201],
"Philippines": [121.45754828266669, 15.948824272939182],
"Poland": [19.379892383012464, 52.11963086628078],
"Portugal": [-7.977838817691485, 39.6854900148663],
"Qatar": [51.170816022848, 25.32876608728452],
"Republic of Serbia": [20.766249889622852, 44.19998417744864],
"Republic of the Congo": [15.21207418503586, -0.8564004333096924],
"Romania": [24.971632366248883, 45.84560772065747],
"Russia": [99.05228241954488, 61.677043847105494],
"Rwanda": [29.91765145538139, -1.9953628964377972],
"Saudi Arabia": [44.5342871199886, 24.122854227726673],
"Senegal": [-14.472411019597182, 14.338613732351318],
"Sierra Leone": [-11.82013204896783, 8.575093438325089],
"Singapore": [103.8198, 1.3521],
"Slovakia": [19.47296461136286, 48.71540683451534],
"Slovenia": [14.771193461021065, 46.10807043333343],
"Solomon Islands": [160.156396875585, -9.628589831637283],
"Somalia": [45.706539856671256, 4.747960517798881],
"Somaliland": [46.25513292885051, 9.729995248781385],
"South Africa": [25.160630055943862, -29.008774283210798],
"South Korea": [127.87280447602005, 36.475157799464604],
"South Sudan": [30.253827921202955, 7.307277152400154],
"Spain": [-3.5708365129134876, 40.391564705240064],
"Sri Lanka": [80.70055059693914, 7.649883097905021],
"Sudan": [29.946243665370346, 15.99205876641883],
"Suriname": [-55.91029765824739, 4.126766010517478],
"Swaziland": [31.47664514492297, -26.563545981108437],
"Sweden": [16.752551082675275, 62.8354879894394],
"Switzerland": [8.205812860319684, 46.805544047201984],
"Syria": [38.50449031631095, 35.01769370592341],
"Taiwan": [120.9605, 23.6978],
"Tajikistan": [70.98712700685866, 38.55204366339685],
"Thailand": [101.01060253224972, 15.132319216387051],
"The Bahamas": [-78.02733865351972, 24.719920319716515],
"Togo": [0.949513544830056, 8.576560980195756],
"Trinidad and Tobago": [-61.270458833911896, 10.339930045317969],
"Tunisia": [9.546832905099203, 34.12673069029241],
"Turkey": [35.424986274948736, 38.998407240706534],
"Turkmenistan": [59.39292286662992, 39.1098173228857],
"Uganda": [32.36575488492427, 1.2757695499320103],
"Ukraine": [31.381193375918794, 49.01345298489402],
"United Arab Emirates": [54.30345828396012, 23.905698042805522],
"United Kingdom": [-36.367082569942795, -54.468348288665524],
"United Republic of Tanzania": [34.79490109621088, -6.276146393386636],
"United States of America": [-99.14669730710926, 39.52689381707972],
"Uruguay": [-56.01665573850212, -32.80651677440885],
"Uzbekistan": [63.148826727968626, 41.7502616897204],
"Vanuatu": [166.84228659210697, -15.266627310669035],
"Venezuela": [-66.1882174671549, 7.118299933056809],
"Vietnam": [106.29939096235873, 16.63088189612042],
"Western Sahara": [-12.218085234851758, 24.221135708939354],
"Yemen": [47.546351590294876, 15.935825615412996],
"Zambia": [27.775321721593084, -13.460954386993254],
"Zimbabwe": [29.850564118889274, -19.00677451616593]
}
@@ -278,10 +278,10 @@ export function normalizeLimitRate(value: string) {
}
export function validateCacheRules(
policy: 'url' | 'suffix' | 'path_prefix' | 'path_exact',
policy: 'static' | 'all' | 'url' | 'suffix' | 'path_prefix' | 'path_exact',
rules: string[],
) {
if (policy === 'url') {
if (policy === 'static' || policy === 'all' || policy === 'url') {
return null;
}
@@ -335,7 +335,17 @@ export function buildPayloadFromRoute(
limit_conn_per_ip: route.limit_conn_per_ip,
limit_rate: route.limit_rate,
cache_enabled: route.cache_enabled,
cache_policy: route.cache_policy || 'url',
cache_policy: (() => {
if (!route.cache_enabled) {
return 'static';
}
const policy = (route.cache_policy || '').trim();
// Legacy empty/url → all (same as backend displayCachePolicy).
if (!policy || policy === 'url' || policy === 'all') {
return 'all';
}
return policy;
})(),
cache_rules: route.cache_rule_list ?? [],
custom_headers: route.custom_header_list ?? [],
basic_auth_enabled: route.basic_auth_enabled,
@@ -229,7 +229,7 @@ export function ProxyRouteCreateSheet({
limit_conn_per_ip: 0,
limit_rate: '',
cache_enabled: false,
cache_policy: 'url',
cache_policy: 'static',
cache_rules: [],
custom_headers: [],
basic_auth_enabled: false,
@@ -36,7 +36,13 @@ import { SectionShell } from './section-shell';
const cacheSchema = z
.object({
cache_enabled: z.boolean(),
cache_policy: z.enum(['url', 'suffix', 'path_prefix', 'path_exact']),
cache_policy: z.enum([
'static',
'all',
'suffix',
'path_prefix',
'path_exact',
]),
cache_rules_text: z.string(),
})
.superRefine((value, context) => {
@@ -63,6 +69,29 @@ interface CacheSectionProps {
onSavingChange?: (saving: boolean) => void;
}
/** Map API/DB values for the form. Legacy empty/url → all (compat). */
function normalizeCachePolicyValue(
policy: string | undefined | null,
enabled = true,
) {
if (!enabled) {
return 'static';
}
const value = (policy || '').trim();
if (!value || value === 'url' || value === 'all') return 'all';
if (value === 'static') return 'static';
if (value === 'suffix' || value === 'path_prefix' || value === 'path_exact') {
return value;
}
return 'static';
}
function needsRulesForPolicy(policy: string) {
return (
policy === 'suffix' || policy === 'path_prefix' || policy === 'path_exact'
);
}
export function CacheSection({
route,
onRouteUpdate,
@@ -78,8 +107,10 @@ export function CacheSection({
resolver: zodResolver(cacheSchema),
defaultValues: {
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
route.cache_enabled,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
},
});
@@ -87,14 +118,20 @@ export function CacheSection({
useEffect(() => {
form.reset({
cache_enabled: route.cache_enabled,
cache_policy: (route.cache_policy ||
'url') as CacheValues['cache_policy'],
cache_policy: normalizeCachePolicyValue(
route.cache_policy,
route.cache_enabled,
) as CacheValues['cache_policy'],
cache_rules_text: route.cache_rule_list.join('\n'),
});
}, [form, route]);
const watchedEnabled = form.watch('cache_enabled');
const watchedPolicy = form.watch('cache_policy');
const needsRules =
watchedPolicy === 'suffix' ||
watchedPolicy === 'path_prefix' ||
watchedPolicy === 'path_exact';
const rulesHint =
watchedPolicy === 'suffix'
@@ -103,7 +140,9 @@ export function CacheSection({
? '每行一个路径前缀,例如 /assets、/static。'
: watchedPolicy === 'path_exact'
? '每行一个精确路径,例如 /robots.txt。'
: '按 URL 缓存时无需额外规则。';
: watchedPolicy === 'static'
? '标准静态资源使用内置扩展名列表(不含 HTML),无需填写规则。'
: '所有可缓存 GET 无需额外规则(仍会绕过登录态与 Authorization)。';
const rulesPlaceholder =
watchedPolicy === 'suffix'
@@ -112,7 +151,7 @@ export function CacheSection({
? '/assets\n/static'
: watchedPolicy === 'path_exact'
? '/robots.txt\n/manifest.json'
: '按 URL 缓存时无需额外规则';
: '当前策略无需额外规则';
return (
<SectionShell
@@ -130,11 +169,10 @@ export function CacheSection({
await save(
{
cache_enabled: values.cache_enabled,
cache_policy: values.cache_enabled
? values.cache_policy
: 'url',
cache_policy: values.cache_enabled ? values.cache_policy : '',
cache_rules:
values.cache_enabled && values.cache_policy !== 'url'
values.cache_enabled &&
needsRulesForPolicy(values.cache_policy)
? rules
: [],
},
@@ -150,8 +188,9 @@ export function CacheSection({
<div className='space-y-0.5'>
<FormLabel>启用站点缓存</FormLabel>
<FormDescription>
系统仍会自动绕过非 GET、带 Authorization 或常见登录态 Cookie
的请求。
新建推荐「标准静态资源」(不含
HTML)。须同时开启性能设置中的全局 OpenResty
缓存。仍会绕过非 GET、Authorization 与常见登录 Cookie。
</FormDescription>
</div>
<FormControl>
@@ -181,12 +220,17 @@ export function CacheSection({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='url'>按 URL 缓存</SelectItem>
<SelectItem value='suffix'>按后缀缓存</SelectItem>
<SelectItem value='path_prefix'>按路径前缀缓存</SelectItem>
<SelectItem value='path_exact'>按精确路径缓存</SelectItem>
<SelectItem value='static'>标准静态资源(推荐)</SelectItem>
<SelectItem value='all'>所有可缓存 GET(高级)</SelectItem>
<SelectItem value='suffix'>自定义后缀</SelectItem>
<SelectItem value='path_prefix'>路径前缀</SelectItem>
<SelectItem value='path_exact'>精确路径</SelectItem>
</SelectContent>
</Select>
<FormDescription>
标准静态资源含 css/js/图片/字体/媒体等,默认不缓存 HTML
与接口路径。
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -201,7 +245,7 @@ export function CacheSection({
<FormControl>
<Textarea
className='min-h-32'
disabled={!watchedEnabled || watchedPolicy === 'url'}
disabled={!watchedEnabled || !needsRules}
placeholder={rulesPlaceholder}
{...field}
/>
+48
View File
@@ -0,0 +1,48 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { RankChart } from './rank-chart';
type RankCardItem = {
label: string;
value: number;
};
type RankCardProps = {
title: string;
description: string;
items: RankCardItem[];
color?: string;
};
export function RankCard({
title,
description,
items,
color = '#3b82f6',
}: RankCardProps) {
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-sm font-semibold text-foreground'>
{title}
</CardTitle>
<CardDescription className='text-xs text-muted-foreground'>
{description}
</CardDescription>
</CardHeader>
<CardContent className='pt-0'>
<RankChart
items={items}
color={color}
emptyMessage={`暂无 ${title} 数据`}
/>
</CardContent>
</Card>
);
}
+57 -95
View File
@@ -1,8 +1,8 @@
'use client';
import { useMemo } from 'react';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { cn } from '@/lib/utils';
type RankChartItem = {
label: string;
@@ -14,119 +14,81 @@ type RankChartProps = {
color: string;
valueFormatter?: (value: number) => string;
emptyMessage?: string;
className?: string;
};
const defaultFormatter = (value: number) => value.toLocaleString('zh-CN');
function getChartValue(params: unknown) {
if (typeof params !== 'object' || params === null || !('value' in params)) {
return 0;
const defaultFormatter = (value: number) => {
if (value >= 1000000) {
return `${Number((value / 1000000).toFixed(2))}M`;
}
const rawValue = (params as { value?: unknown }).value;
if (Array.isArray(rawValue)) {
const candidate = rawValue[0];
return typeof candidate === 'number' ? candidate : 0;
if (value >= 1000) {
return `${Number((value / 1000).toFixed(2))}k`;
}
return typeof rawValue === 'number' ? rawValue : 0;
}
return value.toLocaleString('zh-CN');
};
export function RankChart({
items,
color,
valueFormatter = defaultFormatter,
emptyMessage = '暂无分布数据',
className,
}: RankChartProps) {
const option = useMemo<EChartsOption>(
() => ({
animationDuration: 400,
grid: {
left: 16,
right: 24,
top: 12,
bottom: 12,
containLabel: true,
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
backgroundColor: 'rgba(15, 23, 42, 0.92)',
borderWidth: 0,
textStyle: {
color: '#e2e8f0',
fontSize: 12,
},
formatter: (params: unknown) => {
const item = Array.isArray(params) ? params[0] : params;
const data = item as { name?: string; value?: number };
return `${data.name ?? ''}<br/>${valueFormatter(data.value ?? 0)}`;
},
},
xAxis: {
type: 'value',
axisLabel: {
color: '#94a3b8',
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.16)',
type: 'dashed',
},
},
},
yAxis: {
type: 'category',
data: items.map((item) => item.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: {
color: '#cbd5e1',
width: 120,
overflow: 'truncate',
},
},
series: [
{
type: 'bar',
data: items.map((item) => item.value),
barWidth: 12,
showBackground: true,
backgroundStyle: {
color: 'rgba(148, 163, 184, 0.12)',
borderRadius: 999,
},
itemStyle: {
color,
borderRadius: 999,
},
label: {
show: true,
position: 'right',
color: '#e2e8f0',
formatter: (params: unknown) =>
valueFormatter(getChartValue(params)),
},
},
],
}),
[color, items, valueFormatter],
const maxValue = useMemo(
() => Math.max(0, ...items.map((item) => item.value)),
[items],
);
if (items.length === 0) {
return (
<div className='flex h-[220px] items-center justify-center rounded-3xl border border-dashed bg-muted/30 text-sm text-muted-foreground'>
<div
className={cn(
'flex h-[320px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground',
className,
)}
>
{emptyMessage}
</div>
);
}
return (
<ReactECharts
option={option}
notMerge
lazyUpdate
style={{ height: Math.max(220, items.length * 44), width: '100%' }}
/>
<div
className={cn(
'h-[320px] space-y-0.5 overflow-y-auto pr-1 hide-scrollbar',
className,
)}
>
{items.map((item) => {
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
return (
<div
key={item.label}
className='group flex items-center justify-between gap-3 py-1.5 text-sm'
>
<span
className='min-w-0 flex-1 truncate text-[13px] font-normal text-foreground/90'
title={item.label}
>
{item.label}
</span>
<div className='flex items-center gap-3 shrink-0'>
<div className='h-1.5 w-24 sm:w-28 overflow-hidden rounded-full bg-muted/60'>
<div
className='h-full rounded-full transition-[width] duration-300'
style={{
width: `${ratio}%`,
backgroundColor: color,
}}
/>
</div>
<span className='w-14 text-right tabular-nums text-foreground/80 text-[13px] font-medium'>
{valueFormatter(item.value)}
</span>
</div>
</div>
);
})}
</div>
);
}
+4
View File
@@ -28,6 +28,10 @@ const apiClient = axios.create({
withCredentials: apiConfig.withCredentials,
// Fail fast on slash redirect loops between Next dev proxy and Gin legacy routes.
maxRedirects: 5,
// Gin QueryArray("hosts") expects hosts=a&hosts=b, not hosts[]=a.
paramsSerializer: {
indexes: null,
},
headers: {
'Content-Type': 'application/json',
},
@@ -0,0 +1,22 @@
/**
* Serialize query params for Gin-compatible repeated array keys.
* Axios default uses hosts[]= which Gin QueryArray("hosts") ignores.
*/
export function serializeSearchParams(
params?: Record<string, unknown>,
): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item === undefined || item === null || item === '') continue;
search.append(key, String(item));
}
continue;
}
search.append(key, String(value));
}
return search.toString();
}
+1
View File
@@ -254,6 +254,7 @@ export type {
OriginItem,
OriginDetail,
AccessLogList,
AccessLogOverview,
OptionItem,
GeoIPLookupResult,
DatabaseCleanupResult,
@@ -3,11 +3,15 @@ import type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPAnalysis,
AccessLogIPAnalysisFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryList,
AccessLogIPTrend,
AccessLogIPTrendFilters,
AccessLogList,
AccessLogOverview,
AccessLogOverviewFilters,
FoldedAccessLogFilters,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
@@ -16,10 +20,16 @@ import type {
function buildSearchParams(filters: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
Object.entries(
filters as Record<string, string | number | undefined>,
).forEach(([key, value]) => {
Object.entries(filters as Record<string, unknown>).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
if (Array.isArray(value)) {
const items = value
.map((item) => String(item).trim())
.filter((item) => item !== '');
if (items.length === 0) return;
params[key] = items;
return;
}
params[key] = value;
});
return params;
@@ -32,6 +42,12 @@ export class AccessLogService extends OpenFlareBaseService {
return this.get<AccessLogList>('/', buildSearchParams(filters));
}
static getOverview(
filters: AccessLogOverviewFilters = {},
): Promise<AccessLogOverview> {
return this.get<AccessLogOverview>('/overview', buildSearchParams(filters));
}
static listFolds(
filters: FoldedAccessLogFilters,
): Promise<FoldedAccessLogList> {
@@ -65,6 +81,15 @@ export class AccessLogService extends OpenFlareBaseService {
);
}
static getIPAnalysis(
filters: AccessLogIPAnalysisFilters,
): Promise<AccessLogIPAnalysis> {
return this.get<AccessLogIPAnalysis>(
'/ip-summary/analysis',
buildSearchParams(filters),
);
}
static cleanup(
payload: AccessLogCleanupPayload,
): Promise<AccessLogCleanupResult> {
+5
View File
@@ -82,6 +82,8 @@ export type {
AccessLogCleanupPayload,
AccessLogCleanupResult,
AccessLogFilters,
AccessLogIPAnalysis,
AccessLogIPAnalysisFilters,
AccessLogIPSummaryFilters,
AccessLogIPSummaryItem,
AccessLogIPSummaryList,
@@ -89,6 +91,9 @@ export type {
AccessLogIPTrendFilters,
AccessLogItem,
AccessLogList,
AccessLogOverview,
AccessLogOverviewFilters,
AccessLogOverviewMetricPoint,
FoldedAccessLogFilters,
FoldedAccessLogIPFilters,
FoldedAccessLogIPList,
+81 -1
View File
@@ -500,6 +500,41 @@ export interface AccessLogFilters {
sort_order?: 'asc' | 'desc';
}
export interface AccessLogOverviewFilters {
node_id?: string;
host?: string;
hosts?: string[];
hours?: number;
}
export interface AccessLogOverviewMetricPoint {
bucket_started_at: string;
value: number;
}
export interface AccessLogOverview {
generated_at: string;
hours: number;
summary: {
total_requests: number;
total_visits: number;
bandwidth_served: number;
};
trends: {
requests: AccessLogOverviewMetricPoint[];
visits: AccessLogOverviewMetricPoint[];
bandwidth: AccessLogOverviewMetricPoint[];
};
top_paths: DistributionItem[];
top_hosts: DistributionItem[];
top_ips: DistributionItem[];
device_types: DistributionItem[];
top_browsers: DistributionItem[];
top_operating_systems: DistributionItem[];
top_user_agents: DistributionItem[];
status_codes: DistributionItem[];
}
export interface AccessLogItem {
id: string;
node_id: string;
@@ -509,7 +544,13 @@ export interface AccessLogItem {
region: string;
host: string;
path: string;
user_agent: string;
cache_status: string;
status_code: number;
bytes_sent: number;
request_length: number;
request_time_ms: number;
created_at: string;
}
export interface AccessLogList {
@@ -575,6 +616,9 @@ export interface AccessLogIPSummaryFilters {
node_id?: string;
remote_addr?: string;
host?: string;
hours?: number;
since?: string;
until?: string;
p?: number;
page_size?: number;
sort_by?: string;
@@ -583,8 +627,14 @@ export interface AccessLogIPSummaryFilters {
export interface AccessLogIPSummaryItem {
remote_addr: string;
region?: string;
total_requests: number;
recent_requests: number;
success_2xx_count: number;
success_ratio: number;
bytes_received: number;
bytes_sent: number;
/** @deprecated always 0 */
recent_requests?: number;
last_seen_at: string;
}
@@ -594,6 +644,9 @@ export interface AccessLogIPSummaryList {
page_size: number;
has_more: boolean;
total_ip: number;
hours: number;
since: string;
until?: string;
sort_by: string;
sort_order: 'asc' | 'desc';
}
@@ -618,6 +671,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;
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { serializeSearchParams } from '@/lib/services/core/search-params';
describe('serializeSearchParams', () => {
it('serializes hosts arrays as repeated keys for Gin QueryArray', () => {
const query = serializeSearchParams({
hours: 168,
hosts: ['gist.arctel.de'],
});
expect(query).toBe('hours=168&hosts=gist.arctel.de');
});
it('serializes multiple hosts without bracket notation', () => {
const query = serializeSearchParams({
hosts: ['a.example', 'b.example'],
});
expect(query).toBe('hosts=a.example&hosts=b.example');
expect(query).not.toContain('hosts[]');
expect(query).not.toContain('hosts%5B%5D');
});
});
@@ -22,6 +22,8 @@ type accessLogRecord struct {
Host string `json:"host"`
RemoteAddr string `json:"remote_addr"`
Path string `json:"path"`
UserAgent string `json:"user_agent"`
CacheStatus string `json:"cache_status"`
Status int `json:"status"`
BytesSent int64 `json:"bytes_sent"`
RequestLength int64 `json:"request_length"`
@@ -145,6 +147,8 @@ func (aggregate *trafficAggregate) consume(line []byte) {
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
Host: strings.TrimSpace(record.Host),
Path: normalizeAccessLogPath(record.Path),
UserAgent: strings.TrimSpace(record.UserAgent),
CacheStatus: normalizeCacheStatus(record.CacheStatus),
StatusCode: record.Status,
BytesSent: record.BytesSent,
RequestLength: record.RequestLength,
@@ -157,6 +161,8 @@ type parsedAccessLogRecord struct {
Host string
RemoteAddr string
Path string
UserAgent string
CacheStatus string
Status int
BytesSent int64
RequestLength int64
@@ -189,6 +195,8 @@ func parseJSONAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
Host: strings.TrimSpace(record.Host),
RemoteAddr: strings.TrimSpace(record.RemoteAddr),
Path: normalizeAccessLogPath(record.Path),
UserAgent: strings.TrimSpace(record.UserAgent),
CacheStatus: normalizeCacheStatus(record.CacheStatus),
Status: record.Status,
BytesSent: record.BytesSent,
RequestLength: record.RequestLength,
@@ -196,6 +204,11 @@ func parseJSONAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
}, true
}
func normalizeCacheStatus(value string) string {
// Keep OpenResty "-" as-is so details can distinguish it from a missing field.
return strings.TrimSpace(value)
}
func parseCombinedAccessLogRecord(raw string) (parsedAccessLogRecord, bool) {
matches := combinedAccessLogPattern.FindStringSubmatch(raw)
if len(matches) != combinedAccessLogMatchGroupCount {
@@ -14,8 +14,8 @@ func TestCollectAccessLogsReturnsFactsOnly(t *testing.T) {
tempDir := t.TempDir()
logPath := filepath.Join(tempDir, "openflare_access.log")
content := []byte(
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"/login\",\"remote_addr\":\"10.0.0.1\",\"status\":200,\"request_length\":128,\"bytes_sent\":512,\"request_time\":0.015}\n" +
"{\"ts\":\"2026-03-14T08:00:05Z\",\"host\":\"api.example.com\",\"path\":\"/v1/ping\",\"remote_addr\":\"10.0.0.2\",\"status\":502,\"request_length\":64,\"bytes_sent\":256,\"request_time\":0.008}\n",
"{\"ts\":\"2026-03-14T08:00:00Z\",\"host\":\"app.example.com\",\"path\":\"/login\",\"remote_addr\":\"10.0.0.1\",\"status\":200,\"request_length\":128,\"bytes_sent\":512,\"request_time\":0.015,\"user_agent\":\"Mozilla/5.0\",\"cache_status\":\"HIT\"}\n" +
"{\"ts\":\"2026-03-14T08:00:05Z\",\"host\":\"api.example.com\",\"path\":\"/v1/ping\",\"remote_addr\":\"10.0.0.2\",\"status\":502,\"request_length\":64,\"bytes_sent\":256,\"request_time\":0.008,\"user_agent\":\"curl/8.0\",\"cache_status\":\"MISS\"}\n",
)
if err := os.WriteFile(logPath, content, 0o644); err != nil {
t.Fatalf("WriteFile failed: %v", err)
@@ -35,6 +35,12 @@ func TestCollectAccessLogsReturnsFactsOnly(t *testing.T) {
if accessLogs[0].Path != "/login" || accessLogs[1].Path != "/v1/ping" {
t.Fatalf("unexpected access log paths: %+v", accessLogs)
}
if accessLogs[0].UserAgent != "Mozilla/5.0" || accessLogs[1].UserAgent != "curl/8.0" {
t.Fatalf("unexpected user agents: %+v", accessLogs)
}
if accessLogs[0].CacheStatus != "HIT" || accessLogs[1].CacheStatus != "MISS" {
t.Fatalf("unexpected cache status: %+v", accessLogs)
}
snapshot, err := stateStore.Load()
if err != nil {
@@ -50,6 +56,18 @@ func TestCollectAccessLogsReturnsFactsOnly(t *testing.T) {
}
}
func TestNormalizeCacheStatusKeepsDash(t *testing.T) {
if got := normalizeCacheStatus(" - "); got != "-" {
t.Fatalf("normalizeCacheStatus dash = %q, want -", got)
}
if got := normalizeCacheStatus(" HIT "); got != "HIT" {
t.Fatalf("normalizeCacheStatus hit = %q, want HIT", got)
}
if got := normalizeCacheStatus(""); got != "" {
t.Fatalf("normalizeCacheStatus empty = %q, want empty", got)
}
}
func TestCollectAccessLogsResetsOffsetAfterTruncate(t *testing.T) {
tempDir := t.TempDir()
logPath := filepath.Join(tempDir, "openflare_access.log")
@@ -117,7 +117,7 @@ func mergeAccessLogs(existing []protocol.NodeAccessLog, incoming []protocol.Node
}
func accessLogKey(item protocol.NodeAccessLog) string {
return strconv.FormatInt(item.LoggedAtUnix, 10) + "|" + item.RemoteAddr + "|" + item.Host + "|" + item.Path + "|" + strconv.Itoa(item.StatusCode)
return strconv.FormatInt(item.LoggedAtUnix, 10) + "|" + item.RemoteAddr + "|" + item.Host + "|" + item.Path + "|" + item.UserAgent + "|" + item.CacheStatus + "|" + strconv.Itoa(item.StatusCode)
}
// Replayable returns buffered records from windows before currentWindowStartedAtUnix.
@@ -65,7 +65,7 @@ func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
if err := store.Upsert(ObservabilityBufferRecord{
WindowStartedAtUnix: 1710403200,
AccessLogs: []protocol.NodeAccessLog{
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200},
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200, CacheStatus: "HIT"},
},
}, 1710403000); err != nil {
t.Fatalf("first upsert failed: %v", err)
@@ -73,7 +73,9 @@ func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
if err := store.Upsert(ObservabilityBufferRecord{
WindowStartedAtUnix: 1710403200,
AccessLogs: []protocol.NodeAccessLog{
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200},
// Same identity except cache status — must not collapse HIT/MISS.
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200, CacheStatus: "HIT"},
{LoggedAtUnix: 1710403201, RemoteAddr: "10.0.0.1", Host: "app.example.com", Path: "/a", StatusCode: 200, CacheStatus: "MISS"},
{LoggedAtUnix: 1710403205, RemoteAddr: "10.0.0.2", Host: "app.example.com", Path: "/b", StatusCode: 502},
},
}, 1710403000); err != nil {
@@ -84,8 +86,8 @@ func TestObservabilityBufferStoreMergesAccessLogsWithinWindow(t *testing.T) {
if err != nil {
t.Fatalf("Replayable failed: %v", err)
}
if len(records) != 1 || len(records[0].AccessLogs) != 2 {
t.Fatalf("expected merged access logs, got %+v", records)
if len(records) != 1 || len(records[0].AccessLogs) != 3 {
t.Fatalf("expected merged access logs with distinct cache_status, got %+v", records)
}
}
@@ -24,6 +24,8 @@ const (
healthSeverityWarning = "warning"
healthSeverityCritical = "critical"
accessLogPathMaxLength = 100
accessLogUserAgentMaxLength = 512
accessLogCacheStatusMaxLength = 32
healthEventMessageMaxLength = 4096
)
@@ -208,6 +210,8 @@ func buildNodeAccessLogRecords(nodeID string, direct []NodeAccessLog, buffered [
Region: "",
Host: strings.TrimSpace(item.Host),
Path: truncateForDatabase(strings.TrimSpace(item.Path), accessLogPathMaxLength),
UserAgent: truncateForDatabase(strings.TrimSpace(item.UserAgent), accessLogUserAgentMaxLength),
CacheStatus: truncateForDatabase(strings.TrimSpace(item.CacheStatus), accessLogCacheStatusMaxLength),
StatusCode: item.StatusCode,
BytesSent: bytesSent,
RequestLength: requestLength,
@@ -347,10 +347,35 @@ func normalizeSnapshotRoutes(routes []snapshotRoute) []snapshotRoute {
routes[index].BasicAuthPassword = ""
}
routes[index].UpstreamType = normalizeUpstreamType(routes[index].UpstreamType)
routes[index].CachePolicy = normalizeSnapshotCachePolicy(
routes[index].CacheEnabled,
routes[index].CachePolicy,
)
if !routes[index].CacheEnabled {
routes[index].CacheRules = nil
}
}
return routes
}
// normalizeSnapshotCachePolicy aligns published policy with edge-cache-design:
// legacy empty/url → all; disabled → empty; static/suffix/... kept.
func normalizeSnapshotCachePolicy(enabled bool, raw string) string {
if !enabled {
return ""
}
policy := strings.TrimSpace(strings.ToLower(raw))
switch policy {
case "", "url", "all":
return "all"
case "static", "suffix", "path_prefix", "path_exact":
return policy
default:
// Unknown: prefer static over caching everything.
return "static"
}
}
func flattenSnapshotRoutesBySite(routes []snapshotRoute) map[string]snapshotRoute {
siteMap := make(map[string]snapshotRoute)
for _, route := range normalizeSnapshotRoutes(routes) {
@@ -0,0 +1,82 @@
package observability
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResolveAccessLogIPSummaryWindowHours(t *testing.T) {
t.Parallel()
since, until, hours, err := resolveAccessLogIPSummaryWindow("", "", 0)
require.NoError(t, err)
assert.Equal(t, defaultAccessLogQueryDays*24, hours)
assert.WithinDuration(t, time.Now().UTC(), until, 2*time.Second)
assert.WithinDuration(t, until.Add(-time.Duration(hours)*time.Hour), since, time.Second)
_, _, hours, err = resolveAccessLogIPSummaryWindow("", "", 24)
require.NoError(t, err)
assert.Equal(t, 24, hours)
_, _, hours, err = resolveAccessLogIPSummaryWindow("", "", 9999)
require.NoError(t, err)
assert.Equal(t, maxAccessLogOverviewHours, hours)
}
func TestResolveAccessLogIPSummaryWindowCustomRange(t *testing.T) {
t.Parallel()
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(72 * time.Hour)
since, until, hours, err := resolveAccessLogIPSummaryWindow(
start.Format(time.RFC3339),
end.Format(time.RFC3339),
24,
)
require.NoError(t, err)
assert.True(t, since.Equal(start))
assert.True(t, until.Equal(end))
assert.Equal(t, 72, hours)
}
func TestResolveAccessLogIPSummaryWindowErrors(t *testing.T) {
t.Parallel()
_, _, _, err := resolveAccessLogIPSummaryWindow("2026-07-01T00:00:00Z", "", 24)
require.Error(t, err)
_, _, _, err = resolveAccessLogIPSummaryWindow("bad", "2026-07-02T00:00:00Z", 24)
require.Error(t, err)
start := time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC)
end := start.Add(-time.Hour)
_, _, _, err = resolveAccessLogIPSummaryWindow(
start.Format(time.RFC3339),
end.Format(time.RFC3339),
24,
)
require.Error(t, err)
start = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end = start.Add(40 * 24 * time.Hour)
_, _, _, err = resolveAccessLogIPSummaryWindow(
start.Format(time.RFC3339),
end.Format(time.RFC3339),
24,
)
require.Error(t, err)
}
func TestNormalizeIPSummarySortBy(t *testing.T) {
t.Parallel()
assert.Equal(t, "total_requests", normalizeIPSummarySortBy(""))
assert.Equal(t, "request_length", normalizeIPSummarySortBy("bytes_received"))
assert.Equal(t, "request_length", normalizeIPSummarySortBy("request_length"))
assert.Equal(t, "bytes_sent", normalizeIPSummarySortBy("bytes_sent"))
assert.Equal(t, "success_ratio", normalizeIPSummarySortBy("success_ratio"))
assert.Equal(t, "last_seen_at", normalizeIPSummarySortBy("last_seen_at"))
assert.Equal(t, "remote_addr", normalizeIPSummarySortBy("remote_addr"))
}
@@ -9,6 +9,8 @@ import (
"time"
"github.com/Rain-kl/Wavelet/internal/model"
analyticsrepo "github.com/Rain-kl/Wavelet/internal/repository/analytics"
"github.com/Rain-kl/Wavelet/pkg/logger"
)
const (
@@ -20,7 +22,7 @@ const (
defaultAccessLogFoldMinute = 3
defaultIPTrendHours = 24
defaultIPTrendBucketMinute = 30
maxIPTrendHours = 168
maxIPTrendHours = 24 * 30
nodeAccessLogRetentionDays = 90
defaultAccessLogQueryDays = 7
accessLogFieldRemoteAddr = "remote_addr"
@@ -42,17 +44,23 @@ type AccessLogQuery struct {
FoldMinutes int `json:"fold_minutes"`
}
// AccessLogView is a single access log row.
// AccessLogView is a single access log row (all business fields from of_node_access_logs).
type AccessLogView struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
LoggedAt time.Time `json:"logged_at"`
RemoteAddr string `json:"remote_addr"`
Region string `json:"region"`
Host string `json:"host"`
Path string `json:"path"`
StatusCode int `json:"status_code"`
ID string `json:"id"`
NodeID string `json:"node_id"`
NodeName string `json:"node_name"`
LoggedAt time.Time `json:"logged_at"`
RemoteAddr string `json:"remote_addr"`
Region string `json:"region"`
Host string `json:"host"`
Path string `json:"path"`
UserAgent string `json:"user_agent"`
CacheStatus string `json:"cache_status"`
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"`
RequestLength int64 `json:"request_length"`
RequestTimeMs int64 `json:"request_time_ms"`
CreatedAt time.Time `json:"created_at"`
}
// AccessLogList is a paginated access log response.
@@ -130,6 +138,9 @@ type AccessLogIPSummaryQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Hours int `json:"hours"`
Since string `json:"since"`
Until string `json:"until"`
Page int `json:"page"`
PageSize int `json:"page_size"`
SortBy string `json:"sort_by"`
@@ -138,8 +149,14 @@ type AccessLogIPSummaryQuery struct {
// AccessLogIPSummaryView is an IP summary row.
type AccessLogIPSummaryView struct {
RemoteAddr string `json:"remote_addr"`
TotalRequests int64 `json:"total_requests"`
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"`
LastSeenAt time.Time `json:"last_seen_at"`
}
@@ -151,6 +168,9 @@ type AccessLogIPSummaryList struct {
PageSize int `json:"page_size"`
HasMore bool `json:"has_more"`
TotalIP int64 `json:"total_ip"`
Hours int `json:"hours"`
Since time.Time `json:"since"`
Until time.Time `json:"until,omitempty"`
SortBy string `json:"sort_by"`
SortOrder string `json:"sort_order"`
}
@@ -178,6 +198,82 @@ 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"`
Host string `json:"host"`
Hosts []string `json:"hosts"`
Hours int `json:"hours"`
}
// AccessLogOverviewMetricPoint is a single overview trend bucket.
type AccessLogOverviewMetricPoint struct {
BucketStartedAt time.Time `json:"bucket_started_at"`
Value int64 `json:"value"`
}
// AccessLogOverviewSummary is the headline totals for the overview window.
type AccessLogOverviewSummary struct {
TotalRequests int64 `json:"total_requests"`
TotalVisits int64 `json:"total_visits"`
BandwidthServed int64 `json:"bandwidth_served"`
}
// AccessLogOverviewTrends groups sparkline/series data for the overview.
type AccessLogOverviewTrends struct {
Requests []AccessLogOverviewMetricPoint `json:"requests"`
Visits []AccessLogOverviewMetricPoint `json:"visits"`
Bandwidth []AccessLogOverviewMetricPoint `json:"bandwidth"`
}
// AccessLogOverview is the access-log analytics overview payload.
type AccessLogOverview struct {
GeneratedAt time.Time `json:"generated_at"`
Hours int `json:"hours"`
Summary AccessLogOverviewSummary `json:"summary"`
Trends AccessLogOverviewTrends `json:"trends"`
TopPaths []DistributionItem `json:"top_paths"`
TopHosts []DistributionItem `json:"top_hosts"`
TopIPs []DistributionItem `json:"top_ips"`
DeviceTypes []DistributionItem `json:"device_types"`
TopBrowsers []DistributionItem `json:"top_browsers"`
TopOperatingSystems []DistributionItem `json:"top_operating_systems"`
TopUserAgents []DistributionItem `json:"top_user_agents"`
StatusCodes []DistributionItem `json:"status_codes"`
}
// AccessLogCleanupInput is the cleanup request payload.
type AccessLogCleanupInput struct {
RetentionDays int `json:"retention_days"`
@@ -190,6 +286,226 @@ type AccessLogCleanupResult struct {
Cutoff time.Time `json:"cutoff"`
}
const (
defaultAccessLogOverviewHours = 24
maxAccessLogOverviewHours = 24 * 30
accessLogOverviewTopLimit = 10
accessLogOverviewUASampleLimit = 200
)
// GetAccessLogOverview returns summary metrics, trends, and top rankings.
func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*AccessLogOverview, error) {
normalized := normalizeAccessLogOverviewQuery(input)
now := time.Now().UTC()
since := now.Add(-time.Duration(normalized.Hours) * time.Hour)
query := model.OpenFlareAccessLogQuery{
NodeID: normalized.NodeID,
Host: normalized.Host,
Hosts: normalized.Hosts,
Since: since,
Until: now,
}
summaryRow, err := model.TrafficSummaryOpenFlareAccessLogs(ctx, query)
if err != nil {
return nil, err
}
requestPoints, visitPoints, bandwidthPoints := buildAccessLogOverviewTrends(
ctx, now, normalized.Hours, query,
)
deviceTypes, topBrowsers, topOSes, topUserAgents := buildAccessLogUADistributions(ctx, query)
return &AccessLogOverview{
GeneratedAt: now,
Hours: normalized.Hours,
Summary: AccessLogOverviewSummary{
TotalRequests: summaryRow.RequestCount,
TotalVisits: summaryRow.UniqueIPCount,
BandwidthServed: summaryRow.BytesSent,
},
Trends: AccessLogOverviewTrends{
Requests: requestPoints,
Visits: visitPoints,
Bandwidth: bandwidthPoints,
},
TopPaths: valueCountDistribution(ctx, query, "path", accessLogOverviewTopLimit),
TopHosts: valueCountDistribution(ctx, query, "host", accessLogOverviewTopLimit),
TopIPs: valueCountDistribution(ctx, query, "remote_addr", accessLogOverviewTopLimit),
DeviceTypes: deviceTypes,
TopBrowsers: topBrowsers,
TopOperatingSystems: topOSes,
TopUserAgents: topUserAgents,
StatusCodes: valueCountDistribution(ctx, query, "status_code", accessLogOverviewTopLimit),
}, nil
}
func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOverviewQuery {
hours := input.Hours
if hours <= 0 {
hours = defaultAccessLogOverviewHours
}
if hours > maxAccessLogOverviewHours {
hours = maxAccessLogOverviewHours
}
hosts := normalizeAccessLogOverviewHosts(input.Hosts)
host := strings.TrimSpace(input.Host)
if len(hosts) == 0 && host != "" {
// Single host query uses Hosts exact-match for consistency with multi-select.
hosts = []string{host}
host = ""
}
if len(hosts) > 0 {
host = ""
}
return AccessLogOverviewQuery{
NodeID: strings.TrimSpace(input.NodeID),
Host: host,
Hosts: hosts,
Hours: hours,
}
}
func normalizeAccessLogOverviewHosts(hosts []string) []string {
if len(hosts) == 0 {
return nil
}
seen := make(map[string]struct{}, len(hosts))
result := make([]string, 0, len(hosts))
for _, host := range hosts {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, trimmed)
}
return result
}
func valueCountDistribution(
ctx context.Context,
query model.OpenFlareAccessLogQuery,
column string,
limit int,
) []DistributionItem {
rows, err := model.ValueCountsOpenFlareAccessLogs(ctx, query, column, limit)
if err != nil {
logger.ErrorF(ctx, "[AccessLog] ValueCountsOpenFlareAccessLogs failed for column %s: %v", column, err)
return []DistributionItem{}
}
if len(rows) == 0 {
return []DistributionItem{}
}
items := make([]DistributionItem, 0, len(rows))
for _, row := range rows {
if strings.TrimSpace(row.Value) == "" || row.Count <= 0 {
continue
}
items = append(items, DistributionItem{Key: row.Value, Value: row.Count})
}
return items
}
func buildAccessLogUADistributions(
ctx context.Context,
query model.OpenFlareAccessLogQuery,
) (
deviceTypes []DistributionItem,
topBrowsers []DistributionItem,
topOSes []DistributionItem,
topUserAgents []DistributionItem,
) {
uaRows := valueCountDistribution(ctx, query, "user_agent", accessLogOverviewUASampleLimit)
if len(uaRows) == 0 {
return []DistributionItem{}, []DistributionItem{}, []DistributionItem{}, []DistributionItem{}
}
deviceAcc := make(distributionAccumulator)
browserAcc := make(distributionAccumulator)
osAcc := make(distributionAccumulator)
for _, row := range uaRows {
ua := row.Key
count := row.Value
deviceAcc[analyticsrepo.ParseDeviceType(ua)] += count
browserAcc[analyticsrepo.ParseBrowserName(ua)] += count
osAcc[analyticsrepo.ParseOSName(ua)] += count
}
topUserAgents = make([]DistributionItem, 0, accessLogOverviewTopLimit)
for _, row := range uaRows {
if len(topUserAgents) >= accessLogOverviewTopLimit {
break
}
topUserAgents = append(topUserAgents, row)
}
return toDistributionItems(deviceAcc, 0),
toDistributionItems(browserAcc, accessLogOverviewTopLimit),
toDistributionItems(osAcc, accessLogOverviewTopLimit),
topUserAgents
}
func buildAccessLogOverviewTrends(
ctx context.Context,
now time.Time,
hours int,
query model.OpenFlareAccessLogQuery,
) (
requests []AccessLogOverviewMetricPoint,
visits []AccessLogOverviewMetricPoint,
bandwidth []AccessLogOverviewMetricPoint,
) {
if hours <= 0 {
hours = defaultAccessLogOverviewHours
}
start := now.Truncate(time.Hour).Add(-time.Duration(hours-1) * time.Hour)
requests = make([]AccessLogOverviewMetricPoint, hours)
visits = make([]AccessLogOverviewMetricPoint, hours)
bandwidth = make([]AccessLogOverviewMetricPoint, hours)
for index := range requests {
bucketAt := start.Add(time.Duration(index) * time.Hour)
requests[index].BucketStartedAt = bucketAt
visits[index].BucketStartedAt = bucketAt
bandwidth[index].BucketStartedAt = bucketAt
}
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
NodeID: query.NodeID,
Host: query.Host,
Hosts: query.Hosts,
Since: query.Since,
Until: query.Until,
FoldMinutes: 60,
SortBy: defaultAccessLogSortBy,
SortOrder: sortOrderAsc,
})
if err != nil || len(buckets) == 0 {
return requests, visits, bandwidth
}
byEpoch := make(map[int64]*model.OpenFlareAccessLogBucketRow, len(buckets))
for _, row := range buckets {
if row == nil {
continue
}
byEpoch[row.BucketEpoch] = row
}
for index := range requests {
row, ok := byEpoch[requests[index].BucketStartedAt.Unix()]
if !ok {
continue
}
requests[index].Value = row.RequestCount
visits[index].Value = row.UniqueIPCount
bandwidth[index].Value = row.BytesSent
}
return requests, visits, bandwidth
}
// ListAccessLogs returns paginated access logs.
func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) {
normalized := normalizeAccessLogQuery(input)
@@ -212,15 +528,21 @@ func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList,
continue
}
views = append(views, AccessLogView{
ID: formatAccessLogID(item.ID),
NodeID: item.NodeID,
NodeName: nodeNames[item.NodeID],
LoggedAt: item.LoggedAt,
RemoteAddr: item.RemoteAddr,
Region: item.Region,
Host: item.Host,
Path: item.Path,
StatusCode: item.StatusCode,
ID: formatAccessLogID(item.ID),
NodeID: item.NodeID,
NodeName: nodeNames[item.NodeID],
LoggedAt: item.LoggedAt,
RemoteAddr: item.RemoteAddr,
Region: item.Region,
Host: item.Host,
Path: item.Path,
UserAgent: item.UserAgent,
CacheStatus: item.CacheStatus,
StatusCode: item.StatusCode,
BytesSent: item.BytesSent,
RequestLength: item.RequestLength,
RequestTimeMs: item.RequestTimeMs,
CreatedAt: item.CreatedAt,
})
}
return &AccessLogList{
@@ -347,20 +669,22 @@ func ListFoldedAccessLogIPs(ctx context.Context, input FoldedAccessLogIPQuery) (
// ListAccessLogIPSummaries returns paginated IP summaries.
func ListAccessLogIPSummaries(ctx context.Context, input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
normalized := normalizeAccessLogIPSummaryQuery(input)
since := defaultAccessLogSince()
recentSince := time.Now().UTC().Add(-3 * time.Hour)
normalized, since, until, err := normalizeAccessLogIPSummaryQuery(input)
if err != nil {
return nil, err
}
query := model.OpenFlareAccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(normalized.NodeID),
RemoteAddr: strings.TrimSpace(normalized.RemoteAddr),
Host: strings.TrimSpace(normalized.Host),
Since: since,
Until: until,
Page: normalized.Page,
PageSize: normalized.PageSize,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}
items, err := model.ListOpenFlareAccessLogIPSummaries(ctx, query, recentSince)
items, err := model.ListOpenFlareAccessLogIPSummaries(ctx, query, time.Time{})
if err != nil {
return nil, err
}
@@ -374,10 +698,15 @@ func ListAccessLogIPSummaries(ctx context.Context, input AccessLogIPSummaryQuery
continue
}
views = append(views, AccessLogIPSummaryView{
RemoteAddr: item.RemoteAddr,
TotalRequests: item.TotalRequests,
RecentRequests: item.RecentRequests,
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
RemoteAddr: item.RemoteAddr,
Region: item.Region,
TotalRequests: item.TotalRequests,
Success2xxCount: item.Success2xxCount,
SuccessRatio: item.SuccessRatio,
BytesReceived: item.BytesReceived,
BytesSent: item.BytesSent,
RecentRequests: 0,
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
})
}
return &AccessLogIPSummaryList{
@@ -386,6 +715,9 @@ func ListAccessLogIPSummaries(ctx context.Context, input AccessLogIPSummaryQuery
PageSize: normalized.PageSize,
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
TotalIP: totalIP,
Hours: normalized.Hours,
Since: since,
Until: until,
SortBy: normalized.SortBy,
SortOrder: normalized.SortOrder,
}, nil
@@ -432,6 +764,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 {
@@ -508,16 +916,63 @@ func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
}
}
func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery {
func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) (AccessLogIPSummaryQuery, time.Time, time.Time, error) {
sinceRaw := strings.TrimSpace(input.Since)
untilRaw := strings.TrimSpace(input.Until)
since, until, hours, err := resolveAccessLogIPSummaryWindow(sinceRaw, untilRaw, input.Hours)
if err != nil {
return AccessLogIPSummaryQuery{}, time.Time{}, time.Time{}, err
}
return AccessLogIPSummaryQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host),
Hours: hours,
Since: sinceRaw,
Until: untilRaw,
Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeIPSummarySortBy(input.SortBy),
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
}, since, until, nil
}
func resolveAccessLogIPSummaryWindow(sinceRaw, untilRaw string, hours int) (time.Time, time.Time, int, error) {
if sinceRaw == "" && untilRaw == "" {
if hours <= 0 {
hours = defaultAccessLogQueryDays * 24
}
if hours > 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) {
@@ -623,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"
@@ -130,7 +130,6 @@ type diskCounterState struct {
seen bool
}
func buildTrafficWindowSummaryFromAccessLogs(
ctx context.Context,
nodeID string,
@@ -333,7 +332,7 @@ func BuildTrafficTrendPointsFromAccessLogs(ctx context.Context, now time.Time, n
Since: since,
Until: now,
FoldMinutes: 60,
SortBy: "logged_at",
SortBy: defaultAccessLogSortBy,
SortOrder: sortOrderAsc,
})
if err != nil || len(buckets) == 0 {
@@ -379,7 +378,7 @@ func applyAccessLogBytesToNetworkTrend(ctx context.Context, now time.Time, nodeI
Since: since,
Until: now,
FoldMinutes: 60,
SortBy: "logged_at",
SortBy: defaultAccessLogSortBy,
SortOrder: sortOrderAsc,
})
if err != nil || len(buckets) == 0 {
@@ -0,0 +1,57 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package observability
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestReadQueryStringArrayAcceptsHostsBracketForm(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
url string
want []string
}{
{
name: "axios brackets form",
url: "/overview?hours=168&hosts%5B%5D=gist.arctel.de",
want: []string{"gist.arctel.de"},
},
{
name: "repeated hosts keys",
url: "/overview?hosts=a.example&hosts=b.example",
want: []string{"a.example", "b.example"},
},
{
name: "single hosts key",
url: "/overview?hosts=gist.arctel.de",
want: []string{"gist.arctel.de"},
},
{
name: "empty",
url: "/overview?hours=24",
want: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, err := http.NewRequest(http.MethodGet, tc.url, nil)
require.NoError(t, err)
c.Request = req
got := readQueryStringArray(c, "hosts")
require.Equal(t, tc.want, got)
})
}
}
@@ -12,6 +12,35 @@ import (
"github.com/gin-gonic/gin"
)
// GetAccessLogOverviewHandler 获取访问日志概览。
// @Summary 获取访问日志概览
// @Description 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param host query string false "请求 Host(单域名)"
// @Param hosts query []string false "请求 Host 列表(多域名精确匹配)"
// @Param hours query int false "统计时间范围(小时)"
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
// @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/overview [get]
func GetAccessLogOverviewHandler(c *gin.Context) {
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
NodeID: c.Query("node_id"),
Host: c.Query("host"),
Hosts: readQueryStringArray(c, "hosts"),
Hours: readQueryInt(c, "hours"),
})
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
}
// GetAccessLogsHandler 分页列出访问日志。
// @Summary 列出访问日志
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
@@ -114,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 "参数错误"
@@ -136,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"),
@@ -178,6 +213,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 按保留天数清理过期访问日志记录,需要管理员权限
@@ -221,3 +285,15 @@ func readQueryInt(c *gin.Context, key string) int {
value, _ := strconv.Atoi(c.DefaultQuery(key, "0"))
return value
}
// readQueryStringArray reads repeated query values for key, and also accepts
// the Axios/jQuery bracket form key[] / key%5B%5D which Gin does not map to key.
func readQueryStringArray(c *gin.Context, key string) []string {
if values := c.QueryArray(key); len(values) > 0 {
return values
}
if values := c.QueryArray(key + "[]"); len(values) > 0 {
return values
}
return nil
}
+26 -6
View File
@@ -25,7 +25,9 @@ var proxyHeaderKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
var proxyRouteLimitRatePattern = regexp.MustCompile(`^\d+[kKmM]?$`)
const (
proxyRouteCachePolicyURL = "url"
proxyRouteCachePolicyStatic = "static"
proxyRouteCachePolicyAll = "all"
proxyRouteCachePolicyURL = "url" // legacy alias of all
proxyRouteCachePolicySuffix = "suffix"
proxyRouteCachePolicyPathPrefix = "path_prefix"
proxyRouteCachePolicyPathExact = "path_exact"
@@ -434,15 +436,33 @@ func decodeStoredCustomHeaders(raw string) ([]CustomHeaderInput, error) {
return normalizeCustomHeaders(headers)
}
// normalizeCachePolicy stores API write values.
// When enabling with empty/url policy, keep legacy "all" semantics so old rows
// and clients that omit policy do not silently narrow cache to static extensions.
// New UI should send policy=static explicitly when choosing the recommended default.
func normalizeCachePolicy(enabled bool, raw string) string {
if !enabled {
return ""
}
policy := strings.TrimSpace(raw)
if policy == "" {
return proxyRouteCachePolicyURL
policy := strings.TrimSpace(strings.ToLower(raw))
switch policy {
case "", proxyRouteCachePolicyURL, proxyRouteCachePolicyAll:
return proxyRouteCachePolicyAll
case proxyRouteCachePolicyStatic:
return proxyRouteCachePolicyStatic
case proxyRouteCachePolicySuffix, proxyRouteCachePolicyPathPrefix, proxyRouteCachePolicyPathExact:
return policy
default:
return policy
}
return policy
}
// displayCachePolicy normalizes values for API list/get (and UI).
func displayCachePolicy(enabled bool, raw string) string {
if !enabled {
return ""
}
return normalizeCachePolicy(true, raw)
}
func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]string, error) {
@@ -451,7 +471,7 @@ func normalizeCacheRules(enabled bool, rawPolicy string, rules []string) ([]stri
}
policy := normalizeCachePolicy(enabled, rawPolicy)
switch policy {
case proxyRouteCachePolicyURL:
case proxyRouteCachePolicyStatic, proxyRouteCachePolicyAll, proxyRouteCachePolicyURL:
return []string{}, nil
case proxyRouteCachePolicySuffix:
return normalizeCacheSuffixRules(rules)
@@ -309,7 +309,7 @@ func buildProxyRouteView(ctx context.Context, route *model.ProxyRoute) (*View, e
LimitConnPerIP: route.LimitConnPerIP,
LimitRate: route.LimitRate,
CacheEnabled: route.CacheEnabled,
CachePolicy: route.CachePolicy,
CachePolicy: displayCachePolicy(route.CacheEnabled, route.CachePolicy),
CacheRules: route.CacheRules,
CacheRuleList: cacheRules,
CustomHeaders: route.CustomHeaders,
@@ -80,3 +80,29 @@ func TestCreateProxyRouteHTTPSRequiresCoveringCertificate(t *testing.T) {
_, err := CreateProxyRoute(ctx, Input{SiteName: "api", ZoneDomainIDs: []uint{domain.ID}, OriginURL: "http://origin.example.com:8080", EnableHTTPS: true})
require.EqualError(t, err, errProxyRouteCertRequired)
}
func TestNormalizeCachePolicyDefaultsAndLegacy(t *testing.T) {
assert.Equal(t, "", normalizeCachePolicy(false, "static"))
// Empty/url on write = legacy all (compat); UI sends static explicitly for new default.
assert.Equal(t, proxyRouteCachePolicyAll, normalizeCachePolicy(true, ""))
assert.Equal(t, proxyRouteCachePolicyStatic, normalizeCachePolicy(true, "static"))
assert.Equal(t, proxyRouteCachePolicyAll, normalizeCachePolicy(true, "url"))
assert.Equal(t, proxyRouteCachePolicyAll, normalizeCachePolicy(true, "all"))
assert.Equal(t, proxyRouteCachePolicySuffix, normalizeCachePolicy(true, "suffix"))
assert.Equal(t, "", displayCachePolicy(false, "all"))
assert.Equal(t, proxyRouteCachePolicyAll, displayCachePolicy(true, ""))
assert.Equal(t, proxyRouteCachePolicyAll, displayCachePolicy(true, "url"))
assert.Equal(t, proxyRouteCachePolicyStatic, displayCachePolicy(true, "static"))
rules, err := normalizeCacheRules(true, "url", []string{"css"})
require.NoError(t, err)
assert.Empty(t, rules)
rules, err = normalizeCacheRules(true, "static", nil)
require.NoError(t, err)
assert.Empty(t, rules)
_, err = normalizeCacheRules(true, "suffix", nil)
require.Error(t, err)
}
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS user_agent String DEFAULT '';
-- +goose Down
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS user_agent;
@@ -0,0 +1,6 @@
-- +goose Up
ALTER TABLE of_node_access_logs
ADD COLUMN IF NOT EXISTS cache_status String DEFAULT '';
-- +goose Down
ALTER TABLE of_node_access_logs DROP COLUMN IF EXISTS cache_status;
+3 -1
View File
@@ -10,7 +10,7 @@ import (
const (
nodeAccessLogTableName = "of_node_access_logs"
nodeAccessLogInsertColumns = "id, node_id, logged_at, remote_addr, region, host, path, status_code, bytes_sent, request_length, request_time_ms, created_at"
nodeAccessLogInsertColumns = "id, node_id, logged_at, remote_addr, region, host, path, user_agent, cache_status, status_code, bytes_sent, request_length, request_time_ms, created_at"
)
// NodeAccessLog stores OpenFlare edge node access records in ClickHouse.
@@ -22,6 +22,8 @@ type NodeAccessLog struct {
Region string `gorm:"column:region"`
Host string `gorm:"column:host"`
Path string `gorm:"column:path"`
UserAgent string `gorm:"column:user_agent"`
CacheStatus string `gorm:"column:cache_status"`
StatusCode int32 `gorm:"column:status_code"`
BytesSent uint64 `gorm:"column:bytes_sent"`
RequestLength uint64 `gorm:"column:request_length"`
@@ -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.
+27 -7
View File
@@ -77,7 +77,7 @@ func TrafficSummaryOpenFlareAccessLogs(ctx context.Context, query OpenFlareAcces
return currentAccessLogStore().TrafficSummary(ctx, query)
}
// ValueCountsOpenFlareAccessLogs groups logs by status_code or host.
// ValueCountsOpenFlareAccessLogs groups logs by status_code, host, path, remote_addr, or user_agent.
func ValueCountsOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery, column string, limit int) ([]OpenFlareAccessLogValueCount, error) {
return currentAccessLogStore().ValueCounts(ctx, query, column, limit)
}
@@ -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
@@ -284,6 +284,8 @@ func toAnalyticsNodeAccessLog(record *OpenFlareAccessLog) analyticsmodel.NodeAcc
Region: record.Region,
Host: record.Host,
Path: record.Path,
UserAgent: record.UserAgent,
CacheStatus: record.CacheStatus,
StatusCode: openFlareAccessLogStatusCodeToInt32(record.StatusCode),
BytesSent: bytesSent,
RequestLength: requestLength,
@@ -315,6 +317,8 @@ func fromAnalyticsNodeAccessLogs(rows []analyticsmodel.NodeAccessLog) []*OpenFla
Region: row.Region,
Host: row.Host,
Path: row.Path,
UserAgent: row.UserAgent,
CacheStatus: row.CacheStatus,
StatusCode: int(row.StatusCode),
BytesSent: bytesSent,
RequestLength: requestLength,
@@ -20,6 +20,9 @@ import (
const (
accessLogColumnStatusCode = "status_code"
accessLogColumnHost = "host"
accessLogColumnPath = "path"
accessLogColumnRemoteAddr = "remote_addr"
accessLogColumnUserAgent = "user_agent"
)
type memoryAccessLogStore struct {
@@ -271,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 == "" {
@@ -283,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)
@@ -312,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
@@ -473,17 +506,26 @@ func (s *memoryAccessLogStore) ValueCounts(_ context.Context, filter OpenFlareAc
s.mu.RLock()
defer s.mu.RUnlock()
col := strings.TrimSpace(strings.ToLower(column))
if col != accessLogColumnStatusCode && col != accessLogColumnHost {
switch col {
case accessLogColumnStatusCode, accessLogColumnHost, accessLogColumnPath, accessLogColumnRemoteAddr, accessLogColumnUserAgent:
default:
return nil, nil
}
rows := s.filterRecords(filter)
counts := make(map[string]int64)
for _, row := range rows {
var value string
if col == accessLogColumnStatusCode {
switch col {
case accessLogColumnStatusCode:
value = strconv.Itoa(row.StatusCode)
} else {
case accessLogColumnHost:
value = strings.TrimSpace(row.Host)
case accessLogColumnPath:
value = strings.TrimSpace(row.Path)
case accessLogColumnRemoteAddr:
value = strings.TrimSpace(row.RemoteAddr)
case accessLogColumnUserAgent:
value = strings.TrimSpace(row.UserAgent)
}
if value == "" {
continue
+13 -4
View File
@@ -47,6 +47,8 @@ type OpenFlareAccessLog struct {
Region string `json:"region" gorm:"size:128"`
Host string `json:"host" gorm:"index;size:255"`
Path string `json:"path" gorm:"size:2048"`
UserAgent string `json:"user_agent" gorm:"column:user_agent;size:512"`
CacheStatus string `json:"cache_status" gorm:"column:cache_status;size:32"`
StatusCode int `json:"status_code" gorm:"index"`
BytesSent int64 `json:"bytes_sent" gorm:"column:bytes_sent;not null;default:0"`
RequestLength int64 `json:"request_length" gorm:"column:request_length;not null;default:0"`
@@ -235,6 +237,7 @@ type OpenFlareAccessLogIPSummaryQuery struct {
RemoteAddr string
Host string
Since time.Time
Until time.Time
Page int
PageSize int
SortBy string
@@ -243,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).
+1 -1
View File
@@ -38,7 +38,7 @@ http {
fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp;
uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp;
scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp;
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length,"user_agent":"$http_user_agent","cache_status":"$upstream_cache_status"}';
access_log {{OpenRestyAccessLogPath}} openflare_json;
sendfile on;
tcp_nopush on;
@@ -28,7 +28,8 @@ func TestParseBrowserName(t *testing.T) {
{name: "edge", ua: "Mozilla/5.0 Edg/120.0.0.0", want: "Edge"},
{name: "wechat", ua: "MicroMessenger/8.0", want: "WeChat"},
{name: "postman", ua: "PostmanRuntime/7.36.0", want: "Postman"},
{name: "other", ua: "curl/8.0", want: "Other"},
{name: "cli", ua: "curl/8.0", want: "CLI"},
{name: "other", ua: "CustomClient/1.0", want: "Other"},
}
for _, tt := range tests {
+110 -20
View File
@@ -5,26 +5,116 @@ package analytics
import "strings"
const (
uaLabelUnknown = "Unknown"
uaLabelBot = "Bot"
uaLabelOther = "Other"
uaTokenBot = "bot"
uaTokenAndroid = "android"
uaTokenSpider = "spider"
uaTokenCrawler = "crawler"
)
type uaMatchRule struct {
label string
contains []string
allOf []string
noneOf []string
}
func matchUARules(uaLower string, rules []uaMatchRule, fallback string) string {
if uaLower == "" {
return uaLabelUnknown
}
for _, rule := range rules {
matched := false
for _, token := range rule.contains {
if strings.Contains(uaLower, token) {
matched = true
break
}
}
if !matched && len(rule.allOf) > 0 {
matched = true
for _, token := range rule.allOf {
if !strings.Contains(uaLower, token) {
matched = false
break
}
}
}
if !matched {
continue
}
excluded := false
for _, token := range rule.noneOf {
if strings.Contains(uaLower, token) {
excluded = true
break
}
}
if excluded {
continue
}
return rule.label
}
return fallback
}
var browserRules = []uaMatchRule{
{label: "WeChat", contains: []string{"micromessenger"}},
{label: "Postman", contains: []string{"postman"}},
{label: "CLI", contains: []string{"curl/", "wget/"}},
{label: "Edge", contains: []string{"edg/", "edgios/", "edga/"}},
{label: "Opera", contains: []string{"opr/", "opera"}},
{label: "Firefox", contains: []string{"firefox", "fxios"}},
{label: "Chrome", contains: []string{"crios", "chrome"}, noneOf: []string{"chromium"}},
{label: "Chromium", contains: []string{"chromium"}},
{label: "Safari", contains: []string{"safari"}},
{label: uaLabelBot, contains: []string{uaTokenBot, uaTokenSpider, uaTokenCrawler, "slurp"}},
}
var osRules = []uaMatchRule{
{label: "Android", contains: []string{uaTokenAndroid}},
{label: "iOS", contains: []string{"iphone", "ipad", "ipod", "ios"}},
{label: "Windows", contains: []string{"windows"}},
{label: "macOS", contains: []string{"mac os x", "macintosh", "macos"}},
{label: "Chrome OS", contains: []string{"cros"}},
{label: "Linux", contains: []string{"linux"}},
{label: uaLabelBot, contains: []string{uaTokenBot, uaTokenSpider, uaTokenCrawler}},
}
var deviceRules = []uaMatchRule{
{
label: uaLabelBot,
contains: []string{uaTokenBot, uaTokenSpider, uaTokenCrawler, "slurp", "curl/", "wget/", "python-requests", "go-http-client", "postman"},
},
{
label: "Tablet",
contains: []string{"ipad", "tablet"},
},
{
label: "Tablet",
allOf: []string{uaTokenAndroid},
noneOf: []string{"mobile"},
},
{
label: "Mobile",
contains: []string{"mobi", "iphone", "ipod", uaTokenAndroid},
},
}
// ParseBrowserName performs lightweight User-Agent browser identification.
func ParseBrowserName(ua string) string {
uaLower := strings.ToLower(ua)
if strings.Contains(uaLower, "micromessenger") {
return "WeChat"
}
if strings.Contains(uaLower, "postman") {
return "Postman"
}
if strings.Contains(uaLower, "edg/") || strings.Contains(uaLower, "edge") {
return "Edge"
}
if strings.Contains(uaLower, "firefox") {
return "Firefox"
}
if strings.Contains(uaLower, "chrome") {
return "Chrome"
}
if strings.Contains(uaLower, "safari") {
return "Safari"
}
return "Other"
return matchUARules(strings.ToLower(ua), browserRules, uaLabelOther)
}
// ParseOSName performs lightweight User-Agent OS identification.
func ParseOSName(ua string) string {
return matchUARules(strings.ToLower(ua), osRules, uaLabelOther)
}
// ParseDeviceType performs lightweight User-Agent device type identification.
func ParseDeviceType(ua string) string {
return matchUARules(strings.ToLower(ua), deviceRules, "Desktop")
}
@@ -36,7 +36,7 @@ func ListNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]anal
clause, args := buildNodeAccessLogFilterClause(filter)
tableName := nodeAccessLogTableName()
sql := fmt.Sprintf(`
SELECT id, node_id, logged_at, remote_addr, region, host, path, status_code, bytes_sent, request_length, request_time_ms, created_at
SELECT id, node_id, logged_at, remote_addr, region, host, path, user_agent, cache_status, status_code, bytes_sent, request_length, request_time_ms, created_at
FROM %s
WHERE %s
ORDER BY %s`, tableName, clause, nodeAccessLogOrderClause(filter.SortBy, filter.SortOrder))
@@ -55,6 +55,7 @@ ORDER BY %s`, tableName, clause, nodeAccessLogOrderClause(filter.SortBy, filter.
return scanNodeAccessLogRows(rows)
}
//nolint:dupl // scan shapes differ by model fields; shared helper would obscure CH column mapping
func scanNodeAccessLogRows(rows driver.Rows) ([]analyticsmodel.NodeAccessLog, error) {
var result []analyticsmodel.NodeAccessLog
for rows.Next() {
@@ -67,6 +68,8 @@ func scanNodeAccessLogRows(rows driver.Rows) ([]analyticsmodel.NodeAccessLog, er
&item.Region,
&item.Host,
&item.Path,
&item.UserAgent,
&item.CacheStatus,
&item.StatusCode,
&item.BytesSent,
&item.RequestLength,
@@ -206,33 +209,29 @@ WHERE %s`, tableName, clause)
}
// ValueCountsNodeAccessLogs groups logs by a single dimension column.
// Allowed columns: status_code, host.
// Allowed columns: status_code, host, path, remote_addr, user_agent.
func ValueCountsNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, column string, limit int) ([]NodeAccessLogValueCount, error) {
conn, err := nodeAccessLogConn()
if err != nil {
return nil, err
}
col := strings.TrimSpace(strings.ToLower(column))
switch col {
case nodeAccessLogColumnStatusCode, nodeAccessLogColumnHost:
default:
valueExpr, ok := nodeAccessLogValueCountExpr(col)
if !ok {
return nil, fmt.Errorf("unsupported value count column: %s", column)
}
clause, args := buildNodeAccessLogFilterClause(filter)
tableName := nodeAccessLogTableName()
// status_code is numeric; cast to string for a uniform Value field.
var valueExpr string
filterExpr := valueExpr + " != ''"
if col == nodeAccessLogColumnStatusCode {
valueExpr = "toString(" + nodeAccessLogColumnStatusCode + ")"
} else {
valueExpr = "trim(" + nodeAccessLogColumnHost + ")"
filterExpr = "status_code >= 0"
}
sql := fmt.Sprintf(`
SELECT %s AS value, count() AS count
FROM %s
WHERE %s AND %s != ''
WHERE %s AND %s
GROUP BY value
ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, valueExpr)
ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, filterExpr)
if limit > 0 {
sql += clickHouseLimitClause
args = append(args, limit)
@@ -259,6 +258,23 @@ ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, valueExpr)
return result, nil
}
func nodeAccessLogValueCountExpr(column string) (string, bool) {
switch column {
case nodeAccessLogColumnStatusCode:
return "toString(" + nodeAccessLogColumnStatusCode + ")", true
case nodeAccessLogColumnHost:
return "trim(" + nodeAccessLogColumnHost + ")", true
case nodeAccessLogColumnPath:
return "trim(" + nodeAccessLogColumnPath + ")", true
case nodeAccessLogColumnRemoteAddr:
return "trim(" + nodeAccessLogColumnRemoteAddr + ")", true
case nodeAccessLogColumnUserAgent:
return "trim(" + nodeAccessLogColumnUserAgent + ")", true
default:
return "", false
}
}
// NodeAggregatesNodeAccessLogs returns per-node request/error/UV aggregates.
func NodeAggregatesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]NodeAccessLogNodeAggregate, error) {
conn, err := nodeAccessLogConn()
@@ -20,6 +20,7 @@ const (
nodeAccessLogColumnStatusCode = "status_code"
nodeAccessLogColumnHost = "host"
nodeAccessLogColumnPath = "path"
nodeAccessLogColumnUserAgent = "user_agent"
nodeAccessLogColumnLoggedAt = "logged_at"
)
@@ -175,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
}
@@ -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,
&region,
&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
@@ -49,8 +49,10 @@ func TestBatchInsertNodeAccessLogs_UsesModelBatchSQL(t *testing.T) {
assert.True(t, mockBatch.sendCalled)
require.Len(t, mockBatch.rows, 1)
assert.Equal(t, "node-a", mockBatch.rows[0][1])
require.Len(t, mockBatch.rows[0], 12)
assert.Equal(t, uint64(2048), mockBatch.rows[0][8]) // bytes_sent
assert.Equal(t, uint64(0), mockBatch.rows[0][9]) // request_length
assert.Equal(t, uint32(0), mockBatch.rows[0][10]) // request_time_ms
require.Len(t, mockBatch.rows[0], 14)
assert.Equal(t, "", mockBatch.rows[0][7]) // user_agent
assert.Equal(t, "", mockBatch.rows[0][8]) // cache_status
assert.Equal(t, uint64(2048), mockBatch.rows[0][10]) // bytes_sent
assert.Equal(t, uint64(0), mockBatch.rows[0][11]) // request_length
assert.Equal(t, uint32(0), mockBatch.rows[0][12]) // request_time_ms
}
@@ -46,6 +46,8 @@ func BatchInsertNodeAccessLogs(ctx context.Context, logs []analyticsmodel.NodeAc
logItem.Region,
logItem.Host,
logItem.Path,
strings.TrimSpace(logItem.UserAgent),
strings.TrimSpace(logItem.CacheStatus),
logItem.StatusCode,
logItem.BytesSent,
logItem.RequestLength,
@@ -159,6 +159,7 @@ ORDER BY %s`, tableName, clause, nodeObservabilityCapturedAtOrderClause())
return scanNodeObsFrpcRows(rows)
}
//nolint:dupl // scan shapes differ by model fields; shared helper would obscure CH column mapping
func scanNodeMetricSnapshotRows(rows driver.Rows) ([]analyticsmodel.NodeMetricSnapshot, error) {
var result []analyticsmodel.NodeMetricSnapshot
for rows.Next() {
@@ -14,10 +14,12 @@ func registerObservabilityRoutes(apiGroup *gin.RouterGroup) {
accessLogRoute.Use(apiutil.AdminMiddlewares()...)
{
apiutil.RegisterCollection(accessLogRoute, "GET", observability.GetAccessLogsHandler)
accessLogRoute.GET("/overview", observability.GetAccessLogOverviewHandler)
accessLogRoute.GET("/folds", observability.GetFoldedAccessLogsHandler)
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)
}
}
+6 -6
View File
@@ -14,12 +14,12 @@ func TestCountryCentroidGermany(t *testing.T) {
func TestCountryCentroidHongKongSingaporeTaiwan(t *testing.T) {
cases := []struct {
iso string
name string
minLat float64
maxLat float64
minLon float64
maxLon float64
iso string
name string
minLat float64
maxLat float64
minLon float64
maxLon float64
}{
{iso: "HK", name: "Hong Kong", minLat: 22, maxLat: 23, minLon: 113, maxLon: 115},
{iso: "SG", name: "Singapore", minLat: 1, maxLat: 2, minLon: 103, maxLon: 104},
+4 -2
View File
@@ -128,8 +128,8 @@ type NodeMetricSnapshot struct {
MemoryTotalBytes int64 `json:"memory_total_bytes"`
StorageUsedBytes int64 `json:"storage_used_bytes"`
StorageTotalBytes int64 `json:"storage_total_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
DiskReadBytes int64 `json:"disk_read_bytes"`
DiskWriteBytes int64 `json:"disk_write_bytes"`
}
// NodeAccessLog is an access log entry from agent (L1 business fact).
@@ -138,6 +138,8 @@ type NodeAccessLog struct {
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Path string `json:"path"`
UserAgent string `json:"user_agent,omitempty"`
CacheStatus string `json:"cache_status,omitempty"` // $upstream_cache_status
StatusCode int `json:"status_code"`
BytesSent int64 `json:"bytes_sent"` // body bytes = 已提供数据
RequestLength int64 `json:"request_length"` // 接收数据
+25 -2
View File
@@ -479,15 +479,38 @@ func renderRouteLimitBlock(limitConfig routeLimitConfig) string {
}
func renderRouteCachePolicyCondition(cacheConfig routeCacheConfig) string {
switch cacheConfig.Policy {
policy := normalizeRenderCachePolicy(cacheConfig.Policy)
switch policy {
case cachePolicyStatic:
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(DefaultStaticCacheExtensions)))
case cachePolicySuffix:
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(cacheConfig.Rules)))
case cachePolicyPathPrefix:
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathPrefixMatchPattern(cacheConfig.Rules)))
case cachePolicyPathExact:
return fmt.Sprintf(" if ($uri !~ %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildPathExactMatchPattern(cacheConfig.Rules)))
default:
case cachePolicyAll, cachePolicyURL:
return ""
default:
// Unknown policy: treat as static for safety (do not cache everything).
return fmt.Sprintf(" if ($uri !~* %s) {\n set $openflare_skip_cache 1;\n }\n", quoteNginxStringLiteral(buildSuffixMatchPattern(DefaultStaticCacheExtensions)))
}
}
// normalizeRenderCachePolicy maps stored policy for OpenResty generation.
// Legacy empty and "url" mean "all GETs after security bypass" (pre-static default).
// Explicit "static" uses the built-in extension allowlist. Unknown policies fall back to static.
func normalizeRenderCachePolicy(raw string) string {
policy := strings.TrimSpace(strings.ToLower(raw))
switch policy {
case "", cachePolicyURL, cachePolicyAll:
return cachePolicyAll
case cachePolicyStatic:
return cachePolicyStatic
case cachePolicySuffix, cachePolicyPathPrefix, cachePolicyPathExact:
return policy
default:
return policy
}
}
+44
View File
@@ -398,3 +398,47 @@ func TestRenderRouteConfigPagesWithSPAFallbackServesRoot(t *testing.T) {
t.Fatalf("expected spa fallback try_files in location /, got:\n%s", routeConfig)
}
}
func TestRenderRouteCachePolicyConditionStaticDefault(t *testing.T) {
staticBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "static"})
if staticBlock == "" {
t.Fatal("static policy should emit a path condition")
}
if !strings.Contains(staticBlock, "css") || !strings.Contains(staticBlock, "woff2") {
t.Fatalf("static policy should include default extensions, got:\n%s", staticBlock)
}
if strings.Contains(staticBlock, "html") {
t.Fatalf("static policy must not include html, got:\n%s", staticBlock)
}
// Legacy empty/url = all (wide cache after security bypass).
emptyPolicy := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: ""})
if emptyPolicy != "" {
t.Fatalf("empty policy should map to all (no path filter), got %q", emptyPolicy)
}
allBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "all"})
if allBlock != "" {
t.Fatalf("all policy should not add path condition, got %q", allBlock)
}
urlBlock := renderRouteCachePolicyCondition(routeCacheConfig{Enabled: true, Policy: "url"})
if urlBlock != "" {
t.Fatalf("legacy url policy should map to all, got %q", urlBlock)
}
}
func TestRenderRouteCacheBlockIncludesStaticWhenEnabled(t *testing.T) {
block := renderRouteCacheBlock(
routeCacheConfig{Enabled: true, Policy: "static"},
ConfigSnapshot{CacheEnabled: true},
)
if !strings.Contains(block, "proxy_cache openflare_cache") {
t.Fatalf("expected proxy_cache, got:\n%s", block)
}
if !strings.Contains(block, "\\.(?:") {
t.Fatalf("expected static suffix pattern, got:\n%s", block)
}
if !strings.Contains(block, "request_method != GET") {
t.Fatalf("expected security bypass for non-GET, got:\n%s", block)
}
}
+15 -1
View File
@@ -25,6 +25,9 @@ const (
)
const (
cachePolicyStatic = "static"
cachePolicyAll = "all"
cachePolicyURL = "url" // legacy alias of all
cachePolicySuffix = "suffix"
cachePolicyPathPrefix = "path_prefix"
cachePolicyPathExact = "path_exact"
@@ -33,6 +36,17 @@ const (
anubisAPIPrefix = "/.within.website/x/cmd/anubis/api/"
)
// DefaultStaticCacheExtensions is the built-in suffix allowlist for cache_policy=static.
// HTML is intentionally excluded (Cloudflare-like default).
var DefaultStaticCacheExtensions = []string{
"css", "js", "mjs", "map", "json",
"ico", "cur", "gif", "jpg", "jpeg", "png", "webp", "avif", "svg", "svgz",
"ttf", "otf", "woff", "woff2", "eot",
"mp3", "mp4", "webm", "ogg", "flac",
"wasm", "pdf",
"zip", "7z", "gz", "tar",
}
// OpenFlareRuntimeUser is the dedicated service account shared by the agent
// process and OpenResty worker processes.
const OpenFlareRuntimeUser = "openflare"
@@ -60,7 +74,7 @@ http {
fastcgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/fastcgi_temp;
uwsgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/uwsgi_temp;
scgi_temp_path __OPENFLARE_NGINX_CACHE_DIR__/scgi_temp;
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length}';
{{OpenRestyConnectionUpgradeMap}}{{OpenRestyDefaultServerBlock}} log_format openflare_json escape=json '{"ts":"$time_iso8601","host":"$host","path":"$request_uri","remote_addr":"$remote_addr","status":$status,"request_time":$request_time,"bytes_sent":$body_bytes_sent,"request_length":$request_length,"user_agent":"$http_user_agent","cache_status":"$upstream_cache_status"}';
access_log {{OpenRestyAccessLogPath}} openflare_json;
sendfile on;
tcp_nopush on;