mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 22:46:38 +08:00
feat(access-logs): 新增 IP 明细 Tab 并完善日志详情字段
按时间窗聚合 IP 请求数/2xx 比例/入出站流量,支持排序与详情分析; 日志明细详情仅展示请求业务字段,IP 情报迁至独立详情弹窗。
This commit is contained in:
@@ -23,11 +23,12 @@ sidebar: false
|
||||
|
||||
### 变更
|
||||
|
||||
- 访问日志新增「IP 明细」标签页:可按 24 小时 / 7 天 / 15 天 / 30 天或自定义时间区间查看每个 IP 的请求数、2xx 比例、入站/出站流量与最后访问时间,并支持排序与详情分析;日志明细详情仅展示单条请求字段,IP 情报改在 IP 明细中查看。
|
||||
- 边缘缓存默认策略调整为「标准静态资源」:新建开启缓存时推荐仅缓存 css/js/图片/字体等扩展名(不含 HTML);原「按 URL」与空策略存量行为保留为「所有可缓存 GET」。重新发布节点配置后生效。
|
||||
- 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。
|
||||
- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。
|
||||
- 访问日志页面重构为「概览」「IP 明细」与「日志明细」标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠视图。
|
||||
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
|
||||
- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
|
||||
- 访问日志明细支持查看单条请求详情(含 User-Agent、缓存状态等);IP 访问趋势、分析与加入 IP 组能力集中在 IP 明细详情中。
|
||||
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
|
||||
- 访问日志概览支持按 Zone/域名多选筛选,并可折叠展开层级选择。
|
||||
- 访问日志明细列表在 IP 旁展示对应国家/地区信息。
|
||||
|
||||
+45
-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"
|
||||
},
|
||||
@@ -16312,6 +16330,9 @@ const docTemplate = `{
|
||||
"has_more": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hours": {
|
||||
"type": "integer"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -16324,6 +16345,9 @@ const docTemplate = `{
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"since": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_by": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16332,21 +16356,40 @@ const docTemplate = `{
|
||||
},
|
||||
"total_ip": {
|
||||
"type": "integer"
|
||||
},
|
||||
"until": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"observability.AccessLogIPSummaryView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bytes_received": {
|
||||
"type": "integer"
|
||||
},
|
||||
"bytes_sent": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent_requests": {
|
||||
"description": "RecentRequests is deprecated and always 0.",
|
||||
"type": "integer"
|
||||
},
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"success_2xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"success_ratio": {
|
||||
"type": "number"
|
||||
},
|
||||
"total_requests": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
@@ -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 已更新)
|
||||
- [ ] 提交合并
|
||||
@@ -18,6 +18,7 @@
|
||||
* [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。
|
||||
* [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。
|
||||
* [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。
|
||||
* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。
|
||||
|
||||
## 使用建议
|
||||
|
||||
|
||||
+45
-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"
|
||||
},
|
||||
@@ -16305,6 +16323,9 @@
|
||||
"has_more": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hours": {
|
||||
"type": "integer"
|
||||
},
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -16317,6 +16338,9 @@
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"since": {
|
||||
"type": "string"
|
||||
},
|
||||
"sort_by": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16325,21 +16349,40 @@
|
||||
},
|
||||
"total_ip": {
|
||||
"type": "integer"
|
||||
},
|
||||
"until": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"observability.AccessLogIPSummaryView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bytes_received": {
|
||||
"type": "integer"
|
||||
},
|
||||
"bytes_sent": {
|
||||
"type": "integer"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent_requests": {
|
||||
"description": "RecentRequests is deprecated and always 0.",
|
||||
"type": "integer"
|
||||
},
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"remote_addr": {
|
||||
"type": "string"
|
||||
},
|
||||
"success_2xx_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"success_ratio": {
|
||||
"type": "number"
|
||||
},
|
||||
"total_requests": {
|
||||
"type": "integer"
|
||||
}
|
||||
|
||||
+31
-2
@@ -1929,6 +1929,8 @@ definitions:
|
||||
properties:
|
||||
has_more:
|
||||
type: boolean
|
||||
hours:
|
||||
type: integer
|
||||
items:
|
||||
items:
|
||||
$ref: '#/definitions/observability.AccessLogIPSummaryView'
|
||||
@@ -1937,21 +1939,36 @@ definitions:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
since:
|
||||
type: string
|
||||
sort_by:
|
||||
type: string
|
||||
sort_order:
|
||||
type: string
|
||||
total_ip:
|
||||
type: integer
|
||||
until:
|
||||
type: string
|
||||
type: object
|
||||
observability.AccessLogIPSummaryView:
|
||||
properties:
|
||||
bytes_received:
|
||||
type: integer
|
||||
bytes_sent:
|
||||
type: integer
|
||||
last_seen_at:
|
||||
type: string
|
||||
recent_requests:
|
||||
description: RecentRequests is deprecated and always 0.
|
||||
type: integer
|
||||
region:
|
||||
type: string
|
||||
remote_addr:
|
||||
type: string
|
||||
success_2xx_count:
|
||||
type: integer
|
||||
success_ratio:
|
||||
type: number
|
||||
total_requests:
|
||||
type: integer
|
||||
type: object
|
||||
@@ -7173,7 +7190,7 @@ paths:
|
||||
- openflare-observability
|
||||
/api/v1/d/access-logs/ip-summary:
|
||||
get:
|
||||
description: 按 IP 聚合访问日志统计并分页返回,需要管理员权限
|
||||
description: 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限
|
||||
parameters:
|
||||
- description: 节点 ID
|
||||
in: query
|
||||
@@ -7187,6 +7204,18 @@ paths:
|
||||
in: query
|
||||
name: host
|
||||
type: string
|
||||
- description: 统计时间范围(小时,1-720,默认 168)
|
||||
in: query
|
||||
name: hours
|
||||
type: integer
|
||||
- description: 开始时间 RFC3339(与 until 同时提供时优先于 hours)
|
||||
in: query
|
||||
name: since
|
||||
type: string
|
||||
- description: 结束时间 RFC3339
|
||||
in: query
|
||||
name: until
|
||||
type: string
|
||||
- description: 页码
|
||||
in: query
|
||||
name: p
|
||||
@@ -7195,7 +7224,7 @@ paths:
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: 排序字段
|
||||
- description: 排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr
|
||||
in: query
|
||||
name: sort_by
|
||||
type: string
|
||||
|
||||
@@ -1,83 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
import { Loader2, ShieldPlus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { RankChart } from '@/components/data/rank-chart';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from '@/components/ui/chart';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
AccessLogService,
|
||||
type AccessLogItem,
|
||||
type DistributionItem,
|
||||
type WAFIPGroup,
|
||||
WafService,
|
||||
} from '@/lib/services/openflare';
|
||||
import type { AccessLogItem } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
import { formatBytes } from '@/lib/utils/metrics';
|
||||
|
||||
import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers';
|
||||
import {
|
||||
cacheOutcomeLabel,
|
||||
formatOverviewRangeHint,
|
||||
formatOverviewTrendLabel,
|
||||
OVERVIEW_RANGE_OPTIONS,
|
||||
resolveCacheOutcome,
|
||||
type OverviewRangeHours,
|
||||
} from './access-log-utils';
|
||||
|
||||
const trendChartConfig = {
|
||||
requests: { label: '请求数', color: 'hsl(var(--primary))' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function resolveBucketMinutes(hours: OverviewRangeHours) {
|
||||
if (hours <= 24) return 30;
|
||||
if (hours <= 168) return 60;
|
||||
return 60;
|
||||
}
|
||||
|
||||
function groupsContainingIp(groups: WAFIPGroup[], ip: string) {
|
||||
const target = ip.trim();
|
||||
if (!target) return [];
|
||||
return groups.filter((group) =>
|
||||
(group.ip_list ?? []).some((entry) => entry.trim() === target),
|
||||
);
|
||||
}
|
||||
|
||||
function toRankItems(items: DistributionItem[] | undefined) {
|
||||
return (items ?? []).map((item) => ({
|
||||
label: item.key,
|
||||
value: item.value,
|
||||
}));
|
||||
}
|
||||
import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils';
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
@@ -104,268 +41,14 @@ function DetailField({
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
function formatRequestTimeMs(value: number | undefined | null) {
|
||||
if (value == null || !Number.isFinite(value) || value < 0) {
|
||||
return '—';
|
||||
}
|
||||
if (value < 1000) {
|
||||
return `${Math.round(value)} ms`;
|
||||
}
|
||||
return `${(value / 1000).toFixed(2)} s`;
|
||||
}
|
||||
|
||||
export function AccessLogDetailDialog({
|
||||
@@ -377,328 +60,104 @@ export function AccessLogDetailDialog({
|
||||
item: AccessLogItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const [ipGroupOpen, setIpGroupOpen] = useState(false);
|
||||
const [rangeHours, setRangeHours] = useState<OverviewRangeHours>(24);
|
||||
// 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;
|
||||
const ip = activeItem?.remote_addr?.trim() ?? '';
|
||||
const bucketMinutes = resolveBucketMinutes(rangeHours);
|
||||
const rangeHint = formatOverviewRangeHint(rangeHours);
|
||||
|
||||
const trendQuery = useQuery({
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'ip-trend',
|
||||
ip,
|
||||
rangeHours,
|
||||
bucketMinutes,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.getIPTrend({
|
||||
remote_addr: ip,
|
||||
hours: rangeHours,
|
||||
bucket_minutes: bucketMinutes,
|
||||
}),
|
||||
enabled: open && ip !== '',
|
||||
});
|
||||
|
||||
const analysisQuery = useQuery({
|
||||
queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours],
|
||||
queryFn: () =>
|
||||
AccessLogService.getIPAnalysis({
|
||||
remote_addr: ip,
|
||||
hours: rangeHours,
|
||||
}),
|
||||
enabled: open && ip !== '',
|
||||
});
|
||||
|
||||
const trendChartData = useMemo(() => {
|
||||
return (trendQuery.data?.points ?? []).map((point) => ({
|
||||
label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours),
|
||||
requests: point.request_count,
|
||||
}));
|
||||
}, [rangeHours, trendQuery.data?.points]);
|
||||
|
||||
const analysis = analysisQuery.data;
|
||||
const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading;
|
||||
const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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>访问日志详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
查看请求字段、User-Agent 与该 IP 的访问趋势及分析数据。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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='space-y-5'>
|
||||
<div className='grid gap-4 sm:grid-cols-2'>
|
||||
<DetailField
|
||||
label='时间'
|
||||
value={formatDateTime(activeItem.logged_at)}
|
||||
/>
|
||||
<DetailField
|
||||
label='节点'
|
||||
value={activeItem.node_name || activeItem.node_id || '—'}
|
||||
/>
|
||||
<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={activeItem.path || '—'}
|
||||
full
|
||||
mono
|
||||
/>
|
||||
<DetailField
|
||||
label='User-Agent'
|
||||
value={activeItem.user_agent || '—'}
|
||||
full
|
||||
mono
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-wrap items-center justify-between gap-2'>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outline'
|
||||
disabled={!ip}
|
||||
onClick={() => setIpGroupOpen(true)}
|
||||
>
|
||||
<ShieldPlus className='mr-1 size-3.5' />将 IP 加入到 IP 组
|
||||
</Button>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
value={String(rangeHours)}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
setRangeHours(
|
||||
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>
|
||||
</div>
|
||||
|
||||
{!ip ? (
|
||||
<EmptyStateWithBorder description='该日志没有有效 IP。' />
|
||||
) : 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>
|
||||
{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),
|
||||
)}
|
||||
</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}
|
||||
</Badge>
|
||||
<span className='font-mono text-xs text-muted-foreground'>
|
||||
{activeItem.cache_status || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{ip ? (
|
||||
<AddToIPGroupPanel
|
||||
ip={ip}
|
||||
open={ipGroupOpen}
|
||||
onClose={() => setIpGroupOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AccessLogTab = 'overview' | 'list';
|
||||
export type AccessLogTab = 'overview' | 'ips' | 'list';
|
||||
|
||||
export type SearchDraft = {
|
||||
nodeId: string;
|
||||
@@ -30,6 +30,19 @@ export const DETAIL_SORT_OPTIONS = [
|
||||
{ value: 'remote_addr:desc', label: 'IP 倒序' },
|
||||
];
|
||||
|
||||
export const IP_SORT_OPTIONS = [
|
||||
{ value: 'total_requests:desc', label: '请求数从高到低' },
|
||||
{ value: 'total_requests:asc', label: '请求数从低到高' },
|
||||
{ value: 'request_length:desc', label: '入站从高到低' },
|
||||
{ value: 'request_length:asc', label: '入站从低到高' },
|
||||
{ value: 'bytes_sent:desc', label: '出站从高到低' },
|
||||
{ value: 'bytes_sent:asc', label: '出站从低到高' },
|
||||
{ value: 'success_ratio:desc', label: '2xx 比例从高到低' },
|
||||
{ value: 'success_ratio:asc', label: '2xx 比例从低到高' },
|
||||
{ value: 'last_seen_at:desc', label: '最后访问从新到旧' },
|
||||
{ value: 'last_seen_at:asc', label: '最后访问从旧到新' },
|
||||
];
|
||||
|
||||
export function parseSortValue(value: string) {
|
||||
const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':');
|
||||
return {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
} from './components/access-log-utils';
|
||||
import { CleanupDialog } from './components/cleanup-dialog';
|
||||
import { DetailTab } from './components/detail-tab';
|
||||
import {
|
||||
buildIpSummaryTimeParams,
|
||||
defaultLocalRangeForHours,
|
||||
IpTab,
|
||||
type IpTabTimeMode,
|
||||
} from './components/ip-tab';
|
||||
import { OverviewTab } from './components/overview-tab';
|
||||
|
||||
const emptyDraft: SearchDraft = {
|
||||
@@ -30,7 +36,9 @@ const emptyDraft: SearchDraft = {
|
||||
};
|
||||
|
||||
function resolveTab(value: string | null): AccessLogTab {
|
||||
return value === 'list' ? 'list' : 'overview';
|
||||
if (value === 'list') return 'list';
|
||||
if (value === 'ips') return 'ips';
|
||||
return 'overview';
|
||||
}
|
||||
|
||||
function AccessLogsPageContent() {
|
||||
@@ -43,11 +51,26 @@ function AccessLogsPageContent() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [page, setPage] = useState(0);
|
||||
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
||||
const [ipSort, setIpSort] = useState('total_requests:desc');
|
||||
const [ipPageSize, setIpPageSize] = useState(20);
|
||||
const [ipPage, setIpPage] = useState(0);
|
||||
const [ipHours, setIpHours] = useState<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 ipSortState = parseSortValue(ipSort);
|
||||
const ipTimeParams = buildIpSummaryTimeParams({
|
||||
timeMode: ipTimeMode,
|
||||
hours: ipHours,
|
||||
customSince: ipCustomSince,
|
||||
customUntil: ipCustomUntil,
|
||||
});
|
||||
const ipQueryEnabled = tab === 'ips' && ipTimeParams != null;
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
const next = resolveTab(value);
|
||||
@@ -96,6 +119,27 @@ function AccessLogsPageContent() {
|
||||
enabled: tab === 'list',
|
||||
});
|
||||
|
||||
const ipQuery = useQuery({
|
||||
queryKey: [
|
||||
'openflare',
|
||||
'access-logs',
|
||||
'ip-summary',
|
||||
ipTimeParams,
|
||||
ipPage,
|
||||
ipPageSize,
|
||||
ipSort,
|
||||
],
|
||||
queryFn: () =>
|
||||
AccessLogService.listIPSummaries({
|
||||
...(ipTimeParams as NonNullable<typeof ipTimeParams>),
|
||||
p: ipPage,
|
||||
page_size: ipPageSize,
|
||||
sort_by: ipSortState.sortBy,
|
||||
sort_order: ipSortState.sortOrder,
|
||||
}),
|
||||
enabled: ipQueryEnabled,
|
||||
});
|
||||
|
||||
const cleanupMutation = useMutation({
|
||||
mutationFn: (retentionDays: number) =>
|
||||
AccessLogService.cleanup({ retention_days: retentionDays }),
|
||||
@@ -131,12 +175,26 @@ function AccessLogsPageContent() {
|
||||
setPage(0);
|
||||
}, [tab, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setIpPage(0);
|
||||
}, [
|
||||
tab,
|
||||
ipPageSize,
|
||||
ipHours,
|
||||
ipTimeMode,
|
||||
ipCustomSince,
|
||||
ipCustomUntil,
|
||||
ipSort,
|
||||
]);
|
||||
|
||||
const refreshActive = () => {
|
||||
if (tab === 'overview') void overviewQuery.refetch();
|
||||
if (tab === 'list') void listQuery.refetch();
|
||||
if (tab === 'ips') void ipQuery.refetch();
|
||||
};
|
||||
|
||||
const isFetching = overviewQuery.isFetching || listQuery.isFetching;
|
||||
const isFetching =
|
||||
overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
@@ -146,7 +204,7 @@ function AccessLogsPageContent() {
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>访问日志</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
查看访问概览、排行榜与明细日志。
|
||||
查看访问概览、IP 明细与请求日志。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,8 +232,9 @@ function AccessLogsPageContent() {
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
||||
<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>
|
||||
|
||||
@@ -194,6 +253,48 @@ function AccessLogsPageContent() {
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<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='list' className='mt-4 space-y-4'>
|
||||
<div className='rounded-lg border border-dashed bg-background p-4'>
|
||||
<AccessLogFilters
|
||||
|
||||
@@ -547,6 +547,10 @@ export interface AccessLogItem {
|
||||
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 {
|
||||
@@ -612,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;
|
||||
@@ -620,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;
|
||||
}
|
||||
|
||||
@@ -631,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';
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -44,19 +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"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CacheStatus string `json:"cache_status"`
|
||||
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.
|
||||
@@ -134,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"`
|
||||
@@ -142,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"`
|
||||
}
|
||||
@@ -155,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"`
|
||||
}
|
||||
@@ -512,17 +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,
|
||||
UserAgent: item.UserAgent,
|
||||
CacheStatus: item.CacheStatus,
|
||||
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{
|
||||
@@ -649,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
|
||||
}
|
||||
@@ -676,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{
|
||||
@@ -688,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
|
||||
@@ -886,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) {
|
||||
@@ -1001,7 +1078,9 @@ func normalizeFoldSortBy(sortBy string) string {
|
||||
|
||||
func normalizeIPSummarySortBy(sortBy string) string {
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "recent_requests", "last_seen_at", accessLogFieldRemoteAddr:
|
||||
case "request_length", "bytes_received":
|
||||
return "request_length"
|
||||
case "bytes_sent", "success_ratio", "last_seen_at", accessLogFieldRemoteAddr:
|
||||
return strings.TrimSpace(sortBy)
|
||||
default:
|
||||
return "total_requests"
|
||||
|
||||
@@ -143,16 +143,19 @@ func GetFoldedAccessLogIPsHandler(c *gin.Context) {
|
||||
|
||||
// GetAccessLogIPSummariesHandler 列出访问日志 IP 汇总。
|
||||
// @Summary 列出访问日志 IP 汇总
|
||||
// @Description 按 IP 聚合访问日志统计并分页返回,需要管理员权限
|
||||
// @Description 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限
|
||||
// @Tags openflare-observability
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param node_id query string false "节点 ID"
|
||||
// @Param remote_addr query string false "客户端 IP"
|
||||
// @Param host query string false "请求 Host"
|
||||
// @Param hours query int false "统计时间范围(小时,1-720,默认 168)"
|
||||
// @Param since query string false "开始时间 RFC3339(与 until 同时提供时优先于 hours)"
|
||||
// @Param until query string false "结束时间 RFC3339"
|
||||
// @Param p query int false "页码"
|
||||
// @Param page_size query int false "每页条数"
|
||||
// @Param sort_by query string false "排序字段"
|
||||
// @Param sort_by query string false "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr"
|
||||
// @Param sort_order query string false "排序方向"
|
||||
// @Success 200 {object} response.Any{data=observability.AccessLogIPSummaryList} "IP 汇总列表"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
@@ -165,6 +168,9 @@ func GetAccessLogIPSummariesHandler(c *gin.Context) {
|
||||
NodeID: c.Query("node_id"),
|
||||
RemoteAddr: c.Query("remote_addr"),
|
||||
Host: c.Query("host"),
|
||||
Hours: readQueryInt(c, "hours"),
|
||||
Since: c.Query("since"),
|
||||
Until: c.Query("until"),
|
||||
Page: readQueryInt(c, "p"),
|
||||
PageSize: readQueryInt(c, "page_size"),
|
||||
SortBy: c.Query("sort_by"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -274,11 +274,21 @@ func (s *memoryAccessLogStore) IPAggregates(_ context.Context, filter OpenFlareA
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAccessLogQuery, recentSince time.Time) ([]openFlareAccessLogIPSummaryRow, error) {
|
||||
func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAccessLogQuery, _ time.Time) ([]openFlareAccessLogIPSummaryRow, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
rows := s.filterRecords(filter)
|
||||
aggregates := make(map[string]*openFlareAccessLogIPSummaryRow)
|
||||
type aggregate struct {
|
||||
RemoteAddr string
|
||||
Region string
|
||||
RegionEpoch int64
|
||||
TotalRequests int64
|
||||
Success2xxCount int64
|
||||
BytesReceived int64
|
||||
BytesSent int64
|
||||
LastSeenEpoch int64
|
||||
}
|
||||
aggregates := make(map[string]*aggregate)
|
||||
for _, row := range rows {
|
||||
remoteAddr := strings.TrimSpace(row.RemoteAddr)
|
||||
if remoteAddr == "" {
|
||||
@@ -286,25 +296,40 @@ func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAc
|
||||
}
|
||||
item := aggregates[remoteAddr]
|
||||
if item == nil {
|
||||
item = &openFlareAccessLogIPSummaryRow{RemoteAddr: remoteAddr}
|
||||
item = &aggregate{RemoteAddr: remoteAddr}
|
||||
aggregates[remoteAddr] = item
|
||||
}
|
||||
item.TotalRequests++
|
||||
if !recentSince.IsZero() && !row.LoggedAt.Before(recentSince) {
|
||||
item.RecentRequests++
|
||||
if row.StatusCode >= 200 && row.StatusCode < 300 {
|
||||
item.Success2xxCount++
|
||||
}
|
||||
item.BytesReceived += row.RequestLength
|
||||
item.BytesSent += row.BytesSent
|
||||
epoch := row.LoggedAt.UTC().Unix()
|
||||
if epoch > item.LastSeenEpoch {
|
||||
item.LastSeenEpoch = epoch
|
||||
}
|
||||
if epoch >= item.RegionEpoch {
|
||||
item.RegionEpoch = epoch
|
||||
item.Region = strings.TrimSpace(row.Region)
|
||||
}
|
||||
}
|
||||
summaryRows := make([]*OpenFlareAccessLogIPSummaryRow, 0, len(aggregates))
|
||||
for _, item := range aggregates {
|
||||
ratio := 0.0
|
||||
if item.TotalRequests > 0 {
|
||||
ratio = float64(item.Success2xxCount) / float64(item.TotalRequests)
|
||||
}
|
||||
summaryRows = append(summaryRows, &OpenFlareAccessLogIPSummaryRow{
|
||||
RemoteAddr: item.RemoteAddr,
|
||||
TotalRequests: item.TotalRequests,
|
||||
RecentRequests: item.RecentRequests,
|
||||
LastSeenEpoch: item.LastSeenEpoch,
|
||||
RemoteAddr: item.RemoteAddr,
|
||||
Region: item.Region,
|
||||
TotalRequests: item.TotalRequests,
|
||||
Success2xxCount: item.Success2xxCount,
|
||||
SuccessRatio: ratio,
|
||||
BytesReceived: item.BytesReceived,
|
||||
BytesSent: item.BytesSent,
|
||||
RecentRequests: 0,
|
||||
LastSeenEpoch: item.LastSeenEpoch,
|
||||
})
|
||||
}
|
||||
sortOpenFlareAccessLogIPSummaryRows(summaryRows, filter.SortBy, filter.SortOrder)
|
||||
@@ -315,10 +340,15 @@ func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAc
|
||||
result := make([]openFlareAccessLogIPSummaryRow, len(summaryRows))
|
||||
for index, item := range summaryRows {
|
||||
result[index] = openFlareAccessLogIPSummaryRow{
|
||||
RemoteAddr: item.RemoteAddr,
|
||||
TotalRequests: item.TotalRequests,
|
||||
RecentRequests: item.RecentRequests,
|
||||
LastSeenEpoch: item.LastSeenEpoch,
|
||||
RemoteAddr: item.RemoteAddr,
|
||||
Region: item.Region,
|
||||
TotalRequests: item.TotalRequests,
|
||||
Success2xxCount: item.Success2xxCount,
|
||||
SuccessRatio: item.SuccessRatio,
|
||||
BytesReceived: item.BytesReceived,
|
||||
BytesSent: item.BytesSent,
|
||||
RecentRequests: 0,
|
||||
LastSeenEpoch: item.LastSeenEpoch,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
|
||||
@@ -237,6 +237,7 @@ type OpenFlareAccessLogIPSummaryQuery struct {
|
||||
RemoteAddr string
|
||||
Host string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
@@ -245,10 +246,16 @@ type OpenFlareAccessLogIPSummaryQuery struct {
|
||||
|
||||
// OpenFlareAccessLogIPSummaryRow is an IP summary row (v1 stub).
|
||||
type OpenFlareAccessLogIPSummaryRow struct {
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
RecentRequests int64 `json:"recent_requests"`
|
||||
LastSeenEpoch int64 `json:"last_seen_epoch"`
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Region string `json:"region"`
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
Success2xxCount int64 `json:"success_2xx_count"`
|
||||
SuccessRatio float64 `json:"success_ratio"`
|
||||
BytesReceived int64 `json:"bytes_received"`
|
||||
BytesSent int64 `json:"bytes_sent"`
|
||||
// RecentRequests is deprecated and always 0.
|
||||
RecentRequests int64 `json:"recent_requests"`
|
||||
LastSeenEpoch int64 `json:"last_seen_epoch"`
|
||||
}
|
||||
|
||||
// OpenFlareAccessLogIPTrendQuery filters IP trend queries (v1 stub).
|
||||
|
||||
@@ -176,10 +176,17 @@ func nodeAccessLogIPSummaryOrderClause(sortBy string, sortOrder string) string {
|
||||
}
|
||||
column := "total_requests"
|
||||
switch strings.TrimSpace(sortBy) {
|
||||
case "recent_requests":
|
||||
column = "recent_requests"
|
||||
case "request_length", "bytes_received":
|
||||
column = "request_length"
|
||||
case "bytes_sent":
|
||||
column = "bytes_sent"
|
||||
case "success_ratio":
|
||||
column = "success_ratio"
|
||||
case "last_seen_at":
|
||||
column = "last_seen_epoch"
|
||||
case "recent_requests":
|
||||
// Deprecated sort key; fall back to total_requests.
|
||||
column = "total_requests"
|
||||
case nodeAccessLogColumnRemoteAddr:
|
||||
column = nodeAccessLogColumnRemoteAddr
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user