mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 13:46:38 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24862dcbed | |||
| 920a530aa7 | |||
| bfd9de69af | |||
| 204f6d9a8b | |||
| 04f029c705 | |||
| 7401f5d0b4 | |||
| 0bb6830047 | |||
| 6f221b042e | |||
| fb5a4e5b59 | |||
| ee9d651c8a | |||
| 9aec984bee | |||
| bf71bc540b | |||
| e49078ac3b | |||
| 177578ef4e |
+13
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 (穿透组件)
|
||||
|
||||
@@ -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 | 先收敛「谁可以进缓存」再优化「存多久/怎么清」 |
|
||||
@@ -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) |
|
||||
|
||||
@@ -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`(原样透传,不做三态压缩)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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): ...`(待用户确认)
|
||||
- [ ] 合并 / 发布后需重新发布节点配置
|
||||
@@ -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 已更新)
|
||||
- [ ] 提交合并
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
®ion,
|
||||
&totalRequests,
|
||||
&success2xx,
|
||||
&successRatio,
|
||||
&bytesReceived,
|
||||
&bytes,
|
||||
&lastSeenEpoch,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan ip summary row: %w", err)
|
||||
}
|
||||
result = append(result, NodeAccessLogIPSummary{
|
||||
RemoteAddr: remoteAddr,
|
||||
TotalRequests: safeInt64Count(totalRequests),
|
||||
RecentRequests: safeInt64Count(recentRequests),
|
||||
LastSeenEpoch: lastSeenEpoch,
|
||||
RemoteAddr: remoteAddr,
|
||||
Region: region,
|
||||
TotalRequests: safeInt64Count(totalRequests),
|
||||
Success2xxCount: safeInt64Count(success2xx),
|
||||
SuccessRatio: successRatio,
|
||||
BytesReceived: safeInt64Count(bytesReceived),
|
||||
BytesSent: safeInt64Count(bytes),
|
||||
RecentRequests: 0,
|
||||
LastSeenEpoch: lastSeenEpoch,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"` // 接收数据
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user