diff --git a/docs/changelog/index.md b/docs/changelog/index.md index dbf09f84..c7318c30 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -23,11 +23,12 @@ sidebar: false ### 变更 +- 访问日志新增「IP 明细」标签页:可按 24 小时 / 7 天 / 15 天 / 30 天或自定义时间区间查看每个 IP 的请求数、2xx 比例、入站/出站流量与最后访问时间,并支持排序与详情分析;日志明细详情仅展示单条请求字段,IP 情报改在 IP 明细中查看。 - 边缘缓存默认策略调整为「标准静态资源」:新建开启缓存时推荐仅缓存 css/js/图片/字体等扩展名(不含 HTML);原「按 URL」与空策略存量行为保留为「所有可缓存 GET」。重新发布节点配置后生效。 - 优化访问日志概览及其他图表全局排行榜 (RankChart) 的样式布局:将每一项改为单行横向排布(左侧标签、中间进度条、右侧数值),数值支持自动格式化为 Compact 形式(如 39.57k、1.2M),同时调整默认高度为 320px 并隐藏滚动条。 -- 访问日志页面重构为「概览」与「日志明细」两个标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠与 IP 汇总视图。 +- 访问日志页面重构为「概览」「IP 明细」与「日志明细」标签页:概览展示请求量、访问量、带宽趋势与 Top Paths/Hosts/IPs,明细列表保留检索能力;已移除时间折叠视图。 - 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。 -- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。 +- 访问日志明细支持查看单条请求详情(含 User-Agent、缓存状态等);IP 访问趋势、分析与加入 IP 组能力集中在 IP 明细详情中。 - 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。 - 访问日志概览支持按 Zone/域名多选筛选,并可折叠展开层级选择。 - 访问日志明细列表在 IP 旁展示对应国家/地区信息。 diff --git a/docs/docs.go b/docs/docs.go index 19a57034..6e2473b2 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -5217,7 +5217,7 @@ const docTemplate = `{ "SessionCookie": [] } ], - "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限", + "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限", "produces": [ "application/json" ], @@ -5244,6 +5244,24 @@ const docTemplate = `{ "name": "host", "in": "query" }, + { + "type": "integer", + "description": "统计时间范围(小时,1-720,默认 168)", + "name": "hours", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339", + "name": "until", + "in": "query" + }, { "type": "integer", "description": "页码", @@ -5258,7 +5276,7 @@ const docTemplate = `{ }, { "type": "string", - "description": "排序字段", + "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr", "name": "sort_by", "in": "query" }, @@ -16312,6 +16330,9 @@ const docTemplate = `{ "has_more": { "type": "boolean" }, + "hours": { + "type": "integer" + }, "items": { "type": "array", "items": { @@ -16324,6 +16345,9 @@ const docTemplate = `{ "page_size": { "type": "integer" }, + "since": { + "type": "string" + }, "sort_by": { "type": "string" }, @@ -16332,21 +16356,40 @@ const docTemplate = `{ }, "total_ip": { "type": "integer" + }, + "until": { + "type": "string" } } }, "observability.AccessLogIPSummaryView": { "type": "object", "properties": { + "bytes_received": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, "last_seen_at": { "type": "string" }, "recent_requests": { + "description": "RecentRequests is deprecated and always 0.", "type": "integer" }, + "region": { + "type": "string" + }, "remote_addr": { "type": "string" }, + "success_2xx_count": { + "type": "integer" + }, + "success_ratio": { + "type": "number" + }, "total_requests": { "type": "integer" } diff --git a/docs/plan/20260719-access-log-ip-tab.md b/docs/plan/20260719-access-log-ip-tab.md new file mode 100644 index 00000000..8a3354ce --- /dev/null +++ b/docs/plan/20260719-access-log-ip-tab.md @@ -0,0 +1,161 @@ +# 访问日志 IP 明细 Tab — 实现计划 + +## 1. 目标与背景 (Goal & Context) + +* **需求背景**:运维需要按 IP 维度快速查看时间窗内的访问量与流量,并下钻单 IP 情报;原先 IP 分析嵌在「单条访问日志详情」中,入口弱、列表能力缺失。 +* **开发范围 (Scope) V1**: + * 访问日志页新增第三 Tab **「IP 明细」**(`?tab=ips`)。 + * IP 列表:时间筛选(快捷 24h/7d/15d/30d + 自定义 since/until)、分页、按请求数 / 入站 / 出站 / 最后访问 / 2xx 比例排序。 + * 列表列:IP、地区、请求数、2xx 比例(2xx 数 / 总请求)、入站流量、出站流量、最后访问。 + * 行详情:弹窗展示完整 **IP 情报**(分析 + 趋势 + Top 分布 + 加入 WAF IP 组)。 + * **日志明细详情弹窗仅展示单条请求字段**,不再内嵌 IP 情报;如需分析请到 IP 明细。 +* **Out of Scope(V1 不做)**: + * 独立 `/access-logs/ip` 子路由全页。 + * IP 列表 UI 暴露节点 / host 筛选(后端可保留兼容参数,前端首版不放)。 + * 入/出站带宽时间序列(趋势图仍为请求数)。 + * 实时 GeoIP 二次查询(沿用入库 `region`)。 + +## 2. 设计与决策 (Design & Decisions) + +### 2.1 页面与交互 + +| Tab | URL | 内容 | +| --- | --- | --- | +| 概览 | `/access-logs` | 不变 | +| IP 明细 | `/access-logs?tab=ips` | 新 | +| 日志明细 | `/access-logs?tab=list` | 不变;详情弹窗瘦身 | + +* **时间筛选(IP 明细)**: + * 快捷:`hours` ∈ {24, 168, 360, 720},默认 168(7d)。 + * 自定义:`since` + `until`(RFC3339);**同时提供时覆盖 hours**。 +* **详情形态**:留在列表页的 Dialog(非独立子页)。 +* **日志详情瘦身**:`access-log-detail-dialog` 只渲染请求字段(时间、节点、IP、地区、host、path、UA、cache、status 等)及必要操作;删除 analysis/trend/WAF 组内嵌区块。WAF「按 IP 加入组」仅保留在 IP 详情弹窗。 + +### 2.2 API 设计(扩展现有端点,不新建) + +**`GET /api/v1/d/access-logs/ip-summary`** + +| 参数 | 说明 | +| --- | --- | +| `hours` | 1–720;默认 168;在无 since/until 时生效 | +| `since` / `until` | 可选 RFC3339;同时有效时优先于 hours | +| `sort_by` | `total_requests`(默认)\| `request_length`(入站)\| `bytes_sent`(出站)\| `last_seen_at` \| `success_ratio` | +| `sort_order` | `asc` \| `desc` | +| `p` / `page_size` | 分页,page_size 上限 200 | +| `remote_addr` / `node_id` / `host` | 兼容保留;V1 UI 可不暴露 | + +**响应行字段(扩展)** + +```text +remote_addr string +region string // 窗内 argMax(region, logged_at) 或等价 +total_requests uint64 +success_2xx_count uint64 // status_code 200–299 +success_ratio float64 // success_2xx_count / total_requests;total=0 时为 0 +bytes_received uint64 // sum(request_length) 入站 +bytes_sent uint64 // sum(bytes_sent) 出站 +last_seen_at time +``` + +* `recent_requests`:可停止计算或固定返回 0;**UI 不展示**。避免与可配置时间窗语义冲突。 +* 详情下钻仍用现有: + * `GET .../ip-summary/analysis?remote_addr=&hours=`(或 since/until,若后续扩展;V1 将列表当前窗映射为 hours 或 since/until 与后端约定一致) + * `GET .../ip-summary/trend?remote_addr=&hours=&bucket_minutes=` + +**分析/趋势时间窗对齐**:打开 IP 详情时,将列表当前时间窗传入 analysis/trend(优先 since/until;仅有 hours 则传 hours)。 + +### 2.3 数据层(ClickHouse) + +* 表:`of_node_access_logs`(已有 `bytes_sent`、`request_length`、`status_code`、`region`)。 +* 聚合:`GROUP BY remote_addr`,在 `NodeAccessLogFilter.Since/Until` 上过滤。 +* 2xx:`countIf(status_code >= 200 AND status_code < 300)`。 +* region:`argMax(region, logged_at)`。 +* 排序:服务端 ORDER BY 对应表达式;`success_ratio` 注意除零(`if(total=0,0,ratio)`)。 + +### 2.4 设计决策权衡 + +| 选项 | 结论 | +| --- | --- | +| 扩展 `/ip-summary` vs 新 `/ip-list` | **扩展现有**,前端 service 已有 `listIPSummaries` | +| 详情弹窗 vs 子页 | **弹窗**,与现有明细交互一致 | +| IP 情报放日志详情 vs 独立 IP 详情 | **仅 IP 明细详情**;日志详情只展示请求信息 | +| 时间:仅快捷 vs 仅自定义 | **两者都要**,自定义优先 | + +### 2.5 数据流(示意) + +```mermaid +flowchart LR + UI_IP[IP 明细 Tab] --> API_List[GET /ip-summary] + API_List --> CH[(of_node_access_logs)] + UI_IP --> UI_Dlg[IP 详情 Dialog] + UI_Dlg --> API_A[GET /ip-summary/analysis] + UI_Dlg --> API_T[GET /ip-summary/trend] + API_A --> CH + API_T --> CH + UI_List[日志明细 Tab] --> API_Logs[GET /access-logs] + UI_List --> UI_LogDlg[日志详情 Dialog] + UI_LogDlg -.->|不请求 IP 分析| X[仅请求字段] +``` + +## 3. 具体修改文件清单 (Proposed Changes) + +### 后端 Server + +* #### [MODIFY] `internal/repository/analytics/node_access_log_stats.go`(及 filter 如有) + * `IPSummariesNodeAccessLogs`:时间窗、sum 入/出、2xx count、ratio、region、扩展 sort。 +* #### [MODIFY] `internal/apps/openflare/observability/access_log_logics.go` + * Query/View 类型扩展;解析 hours/since/until;去掉或忽略 recent 3h 硬编码。 +* #### [MODIFY] `internal/apps/openflare/observability/routers.go` / handler + * 绑定新 query;Swagger 注释。 +* #### [MODIFY] 相关单元测试(logics / repository 若有) + +### 前端 Web + +* #### [MODIFY] `frontend/app/(main)/access-logs/page.tsx` + * 第三 Tab `ips`;`resolveTab` / `handleTabChange`。 +* #### [NEW] `frontend/app/(main)/access-logs/components/ip-tab.tsx` + * 列表、时间筛选、排序、分页、打开详情。 +* #### [NEW] `frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx` + * IP 入口详情壳。 +* #### [NEW] `frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx` + * 从现有 `access-log-detail-dialog` **迁出** 分析/趋势/排行/WAF IP 组逻辑。 +* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx` + * **删除** IP 情报相关 UI 与 `getIPAnalysis` / `getIPTrend` 请求;仅请求日志字段展示。 +* #### [MODIFY] `frontend/app/(main)/access-logs/components/access-log-utils.ts` + * tab 类型、IP 排序选项、时间筛选辅助。 +* #### [MODIFY] `frontend/lib/services/openflare/access-log.service.ts` + `types.ts` + * `listIPSummaries` 参数与 `AccessLogIPSummaryItem` 字段同步。 + +### 文档 + +* #### [MODIFY] `docs/changelog/index.md` — `[Unreleased]` 用户可见说明 +* #### [MODIFY] `docs/design/observability-design.md` 或 data-model(如有访问日志 UI 约定)— 补充 IP 明细 Tab 与 API 字段(中文) +* #### [MODIFY] `docs/plan/index.md` — 挂上本计划链接 + +## 4. 验证计划 (Verification Plan) + +### 自动化 + +```bash +go test ./internal/apps/openflare/observability/ ./internal/repository/analytics/ +# 前端:相关 tsc / 页面无类型错误 +make code-check # 完成后按项目门禁 +make prettier +make swagger # API 注释变更后 +``` + +### 手动 + +1. `/access-logs?tab=ips` 默认 7d 列表有数据;切换 24h/自定义区间结果变化。 +2. 分别按请求数、入站、出站、2xx 比例、最后访问排序正确。 +3. 2xx 比例 = 2xx/总数;0 请求不出现 NaN/Infinity。 +4. 点 IP 打开详情:指标/趋势/Top/WAF 组可用;时间窗与列表一致。 +5. 日志明细 → 详情:仅请求信息,**无** IP 分析/趋势区块。 +6. 概览 Tab 行为无回归。 + +## 5. 状态 + +- [x] 需求澄清与方案确认 +- [x] 实现(后端 ip-summary 扩展 + 前端 IP 明细 Tab + 日志详情瘦身) +- [x] 测试与 changelog(`go test` 相关包通过;changelog 已更新) +- [ ] 提交合并 diff --git a/docs/plan/index.md b/docs/plan/index.md index 3066f7b7..974203a5 100644 --- a/docs/plan/index.md +++ b/docs/plan/index.md @@ -18,6 +18,7 @@ * [边缘可观测与业务流量统计重构](./20260717-observability-redesign.md):访问日志为业务唯一真相;Agent 只上报明细与主机读数;收敛「出站/已提供」双字段。 * [访问日志 cache_status 明细可见](./20260718-access-log-cache-status.md):上报 `$upstream_cache_status`,明细展示命中/回源/未缓存三态。 * [边缘缓存默认 static 策略](./20260718-edge-cache-static-default.md):开启缓存默认仅静态扩展名;存量 url→all。 +* [访问日志 IP 明细 Tab](./20260719-access-log-ip-tab.md):第三 Tab 按 IP 聚合列表(时间窗/流量/2xx 比例);IP 情报迁入独立详情;日志详情仅请求字段。 ## 使用建议 diff --git a/docs/swagger.json b/docs/swagger.json index a6a01155..7355be74 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -5210,7 +5210,7 @@ "SessionCookie": [] } ], - "description": "按 IP 聚合访问日志统计并分页返回,需要管理员权限", + "description": "按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限", "produces": [ "application/json" ], @@ -5237,6 +5237,24 @@ "name": "host", "in": "query" }, + { + "type": "integer", + "description": "统计时间范围(小时,1-720,默认 168)", + "name": "hours", + "in": "query" + }, + { + "type": "string", + "description": "开始时间 RFC3339(与 until 同时提供时优先于 hours)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间 RFC3339", + "name": "until", + "in": "query" + }, { "type": "integer", "description": "页码", @@ -5251,7 +5269,7 @@ }, { "type": "string", - "description": "排序字段", + "description": "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr", "name": "sort_by", "in": "query" }, @@ -16305,6 +16323,9 @@ "has_more": { "type": "boolean" }, + "hours": { + "type": "integer" + }, "items": { "type": "array", "items": { @@ -16317,6 +16338,9 @@ "page_size": { "type": "integer" }, + "since": { + "type": "string" + }, "sort_by": { "type": "string" }, @@ -16325,21 +16349,40 @@ }, "total_ip": { "type": "integer" + }, + "until": { + "type": "string" } } }, "observability.AccessLogIPSummaryView": { "type": "object", "properties": { + "bytes_received": { + "type": "integer" + }, + "bytes_sent": { + "type": "integer" + }, "last_seen_at": { "type": "string" }, "recent_requests": { + "description": "RecentRequests is deprecated and always 0.", "type": "integer" }, + "region": { + "type": "string" + }, "remote_addr": { "type": "string" }, + "success_2xx_count": { + "type": "integer" + }, + "success_ratio": { + "type": "number" + }, "total_requests": { "type": "integer" } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c72e7c91..6bbf8815 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1929,6 +1929,8 @@ definitions: properties: has_more: type: boolean + hours: + type: integer items: items: $ref: '#/definitions/observability.AccessLogIPSummaryView' @@ -1937,21 +1939,36 @@ definitions: type: integer page_size: type: integer + since: + type: string sort_by: type: string sort_order: type: string total_ip: type: integer + until: + type: string type: object observability.AccessLogIPSummaryView: properties: + bytes_received: + type: integer + bytes_sent: + type: integer last_seen_at: type: string recent_requests: + description: RecentRequests is deprecated and always 0. type: integer + region: + type: string remote_addr: type: string + success_2xx_count: + type: integer + success_ratio: + type: number total_requests: type: integer type: object @@ -7173,7 +7190,7 @@ paths: - openflare-observability /api/v1/d/access-logs/ip-summary: get: - description: 按 IP 聚合访问日志统计并分页返回,需要管理员权限 + description: 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限 parameters: - description: 节点 ID in: query @@ -7187,6 +7204,18 @@ paths: in: query name: host type: string + - description: 统计时间范围(小时,1-720,默认 168) + in: query + name: hours + type: integer + - description: 开始时间 RFC3339(与 until 同时提供时优先于 hours) + in: query + name: since + type: string + - description: 结束时间 RFC3339 + in: query + name: until + type: string - description: 页码 in: query name: p @@ -7195,7 +7224,7 @@ paths: in: query name: page_size type: integer - - description: 排序字段 + - description: 排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr in: query name: sort_by type: string diff --git a/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx index b50b59ef..7b95d01c 100644 --- a/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx +++ b/frontend/app/(main)/access-logs/components/access-log-detail-dialog.tsx @@ -1,83 +1,20 @@ 'use client'; -import { useMemo, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; -import { Loader2, ShieldPlus, Trash2 } from 'lucide-react'; -import { toast } from 'sonner'; +import { useState } from 'react'; -import { RankChart } from '@/components/data/rank-chart'; -import { EmptyStateWithBorder } from '@/components/layout/empty'; -import { ErrorInline } from '@/components/layout/error'; -import { LoadingStateWithBorder } from '@/components/layout/loading'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, -} from '@/components/ui/chart'; import { Dialog, DialogContent, DialogDescription, - DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; -import { - AccessLogService, - type AccessLogItem, - type DistributionItem, - type WAFIPGroup, - WafService, -} from '@/lib/services/openflare'; +import type { AccessLogItem } from '@/lib/services/openflare'; import { formatDateTime } from '@/lib/utils'; -import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; +import { formatBytes } from '@/lib/utils/metrics'; -import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers'; -import { - cacheOutcomeLabel, - formatOverviewRangeHint, - formatOverviewTrendLabel, - OVERVIEW_RANGE_OPTIONS, - resolveCacheOutcome, - type OverviewRangeHours, -} from './access-log-utils'; - -const trendChartConfig = { - requests: { label: '请求数', color: 'hsl(var(--primary))' }, -} satisfies ChartConfig; - -function resolveBucketMinutes(hours: OverviewRangeHours) { - if (hours <= 24) return 30; - if (hours <= 168) return 60; - return 60; -} - -function groupsContainingIp(groups: WAFIPGroup[], ip: string) { - const target = ip.trim(); - if (!target) return []; - return groups.filter((group) => - (group.ip_list ?? []).some((entry) => entry.trim() === target), - ); -} - -function toRankItems(items: DistributionItem[] | undefined) { - return (items ?? []).map((item) => ({ - label: item.key, - value: item.value, - })); -} +import { cacheOutcomeLabel, resolveCacheOutcome } from './access-log-utils'; function DetailField({ label, @@ -104,268 +41,14 @@ function DetailField({ ); } -function MetricCard({ label, value }: { label: string; value: string }) { - return ( -
-

- {label} -

-

{value}

-
- ); -} - -function MiniRankCard({ - title, - items, - color, -}: { - title: string; - items: { label: string; value: number }[]; - color: string; -}) { - return ( -
-

{title}

- -
- ); -} - -function AddToIPGroupPanel({ - ip, - open, - onClose, -}: { - ip: string; - open: boolean; - onClose: () => void; -}) { - const queryClient = useQueryClient(); - const [selectedGroupId, setSelectedGroupId] = useState(''); - - const groupsQuery = useQuery({ - queryKey: ['openflare', 'waf', 'ip-groups'], - queryFn: () => WafService.listIPGroups(), - enabled: open, - }); - - const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); - const matchedGroups = useMemo( - () => groupsContainingIp(groups, ip), - [groups, ip], - ); - const manualGroups = useMemo( - () => - groups.filter( - (group) => group.type === 'manual' && group.enabled !== false, - ), - [groups], - ); - const addableGroups = useMemo( - () => - manualGroups.filter( - (group) => - !(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()), - ), - [manualGroups, ip], - ); - - const updateMutation = useMutation({ - mutationFn: async ({ - group, - nextList, - }: { - group: WAFIPGroup; - nextList: string[]; - }) => - WafService.updateIPGroup( - group.id, - buildIPGroupPayloadFromGroup(group, nextList), - ), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: ['openflare', 'waf', 'ip-groups'], - }); - }, - }); - - const handleAdd = async () => { - const groupId = Number.parseInt(selectedGroupId, 10); - const group = addableGroups.find((item) => item.id === groupId); - if (!group) { - toast.error('请选择要加入的 IP 组'); - return; - } - try { - await updateMutation.mutateAsync({ - group, - nextList: [...(group.ip_list ?? []), ip.trim()], - }); - toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`); - setSelectedGroupId(''); - } catch (error) { - toast.error(error instanceof Error ? error.message : '加入 IP 组失败'); - } - }; - - const handleRemove = async (group: WAFIPGroup) => { - try { - await updateMutation.mutateAsync({ - group, - nextList: (group.ip_list ?? []).filter( - (entry) => entry.trim() !== ip.trim(), - ), - }); - toast.success(`已从 IP 组「${group.name}」移除 ${ip}`); - } catch (error) { - toast.error(error instanceof Error ? error.message : '移除失败'); - } - }; - - return ( - { - if (!next) { - setSelectedGroupId(''); - onClose(); - } - }} - > - - - 将 IP 加入 IP 组 - - 目标 IP: - {ip} - - - - {groupsQuery.isLoading ? ( - - ) : groupsQuery.isError ? ( - void groupsQuery.refetch()} - /> - ) : ( -
- {matchedGroups.length > 0 ? ( -
-

- 该 IP 已存在于以下 IP 组 -

-

- 可选择删除,或继续添加到其他 IP 组。 -

-
- {matchedGroups.map((group) => ( -
-
-

- {group.name} -

-

- {group.type} · {group.ip_list?.length ?? 0} 条 -

-
- {group.type === 'manual' ? ( - - ) : ( - - 不可手动删除 - - )} -
- ))} -
-
- ) : ( -

- 该 IP 尚未加入任何 IP 组。 -

- )} - -
-

添加到其他 IP 组

- {addableGroups.length === 0 ? ( -

- 没有可写入的手动 IP 组(或已全部包含该 IP)。 -

- ) : ( - - )} -
-
- )} - - - - - -
-
- ); +function formatRequestTimeMs(value: number | undefined | null) { + if (value == null || !Number.isFinite(value) || value < 0) { + return '—'; + } + if (value < 1000) { + return `${Math.round(value)} ms`; + } + return `${(value / 1000).toFixed(2)} s`; } export function AccessLogDetailDialog({ @@ -377,328 +60,104 @@ export function AccessLogDetailDialog({ item: AccessLogItem | null; onOpenChange: (open: boolean) => void; }) { - const [ipGroupOpen, setIpGroupOpen] = useState(false); - const [rangeHours, setRangeHours] = useState(24); // Keep last selected item while the dialog closes to avoid empty-state flash. const [displayItem, setDisplayItem] = useState(item); if (item && item !== displayItem) { setDisplayItem(item); } const activeItem = item ?? displayItem; - const ip = activeItem?.remote_addr?.trim() ?? ''; - const bucketMinutes = resolveBucketMinutes(rangeHours); - const rangeHint = formatOverviewRangeHint(rangeHours); - - const trendQuery = useQuery({ - queryKey: [ - 'openflare', - 'access-logs', - 'ip-trend', - ip, - rangeHours, - bucketMinutes, - ], - queryFn: () => - AccessLogService.getIPTrend({ - remote_addr: ip, - hours: rangeHours, - bucket_minutes: bucketMinutes, - }), - enabled: open && ip !== '', - }); - - const analysisQuery = useQuery({ - queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours], - queryFn: () => - AccessLogService.getIPAnalysis({ - remote_addr: ip, - hours: rangeHours, - }), - enabled: open && ip !== '', - }); - - const trendChartData = useMemo(() => { - return (trendQuery.data?.points ?? []).map((point) => ({ - label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours), - requests: point.request_count, - })); - }, [rangeHours, trendQuery.data?.points]); - - const analysis = analysisQuery.data; - const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading; - const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching; return ( - <> - - - - 访问日志详情 - - 查看请求字段、User-Agent 与该 IP 的访问趋势及分析数据。 - - + + + + 访问日志详情 + + 本条请求的全部业务字段。IP 访问分析请前往「IP 明细」。 + + - {activeItem && ( -
-
- - - - - - - {activeItem.status_code} - - } - /> - - - {cacheOutcomeLabel( - resolveCacheOutcome(activeItem.cache_status), - )} - - - {activeItem.cache_status || '—'} - -
- } - /> - - -
- -
- - { - if (!value) return; - setRangeHours( - Number.parseInt(value, 10) as OverviewRangeHours, - ); - }} - variant='outline' - size='sm' - > - {OVERVIEW_RANGE_OPTIONS.map((option) => ( - - {option.label} - - ))} - -
- - {!ip ? ( - - ) : isLoadingIP ? ( - - ) : ( -
- {analysisQuery.isError ? ( - void analysisQuery.refetch()} - /> - ) : analysis ? ( -
- - - - - - -
- ) : null} - -
-
-
-

IP 请求趋势

-

- {ip} · {rangeHint} · {bucketMinutes} 分钟桶 -

-
- -
- - {trendQuery.isError ? ( - void trendQuery.refetch()} - /> - ) : trendChartData.every( - (point) => point.requests === 0, - ) ? ( - - ) : ( - - - - - - formatCompactNumber(Number(value)) - } - /> - } /> - - - + {activeItem ? ( +
+ + + + + + + + + + {activeItem.status_code} + + } + /> + + + {cacheOutcomeLabel( + resolveCacheOutcome(activeItem.cache_status), )} -
- - {analysis ? ( -
- - - - - - -
- ) : null} + + + {activeItem.cache_status || '—'} +
- )} -
- )} -
-
- - {ip ? ( - setIpGroupOpen(false)} - /> - ) : null} - + } + /> + + + + + + + ) : null} +
+
); } diff --git a/frontend/app/(main)/access-logs/components/access-log-utils.ts b/frontend/app/(main)/access-logs/components/access-log-utils.ts index bc0be48c..829bf527 100644 --- a/frontend/app/(main)/access-logs/components/access-log-utils.ts +++ b/frontend/app/(main)/access-logs/components/access-log-utils.ts @@ -1,4 +1,4 @@ -export type AccessLogTab = 'overview' | 'list'; +export type AccessLogTab = 'overview' | 'ips' | 'list'; export type SearchDraft = { nodeId: string; @@ -30,6 +30,19 @@ export const DETAIL_SORT_OPTIONS = [ { value: 'remote_addr:desc', label: 'IP 倒序' }, ]; +export const IP_SORT_OPTIONS = [ + { value: 'total_requests:desc', label: '请求数从高到低' }, + { value: 'total_requests:asc', label: '请求数从低到高' }, + { value: 'request_length:desc', label: '入站从高到低' }, + { value: 'request_length:asc', label: '入站从低到高' }, + { value: 'bytes_sent:desc', label: '出站从高到低' }, + { value: 'bytes_sent:asc', label: '出站从低到高' }, + { value: 'success_ratio:desc', label: '2xx 比例从高到低' }, + { value: 'success_ratio:asc', label: '2xx 比例从低到高' }, + { value: 'last_seen_at:desc', label: '最后访问从新到旧' }, + { value: 'last_seen_at:asc', label: '最后访问从旧到新' }, +]; + export function parseSortValue(value: string) { const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':'); return { diff --git a/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx b/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx new file mode 100644 index 00000000..ae6f2a92 --- /dev/null +++ b/frontend/app/(main)/access-logs/components/ip-analysis-panel.tsx @@ -0,0 +1,607 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { Loader2, ShieldPlus, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; + +import { RankChart } from '@/components/data/rank-chart'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { + AccessLogService, + type DistributionItem, + type WAFIPGroup, + WafService, +} from '@/lib/services/openflare'; +import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; + +import { buildIPGroupPayloadFromGroup } from '../../waf/components/helpers'; +import { + formatOverviewRangeHint, + formatOverviewTrendLabel, + OVERVIEW_RANGE_OPTIONS, + type OverviewRangeHours, +} from './access-log-utils'; + +const trendChartConfig = { + requests: { label: '请求数', color: 'hsl(var(--primary))' }, +} satisfies ChartConfig; + +function resolveBucketMinutes(hours: number) { + if (hours <= 24) return 30; + return 60; +} + +function groupsContainingIp(groups: WAFIPGroup[], ip: string) { + const target = ip.trim(); + if (!target) return []; + return groups.filter((group) => + (group.ip_list ?? []).some((entry) => entry.trim() === target), + ); +} + +function toRankItems(items: DistributionItem[] | undefined) { + return (items ?? []).map((item) => ({ + label: item.key, + value: item.value, + })); +} + +/** Clamp analysis/trend window to API limits (1–720 hours). */ +function clampAnalysisHours(hours: number): number { + if (!Number.isFinite(hours) || hours <= 0) return 24; + return Math.min(720, Math.max(1, Math.round(hours))); +} + +function isOverviewPreset(hours: number): hours is OverviewRangeHours { + return hours === 24 || hours === 168 || hours === 360 || hours === 720; +} + +function MetricCard({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

{value}

+
+ ); +} + +function MiniRankCard({ + title, + items, + color, +}: { + title: string; + items: { label: string; value: number }[]; + color: string; +}) { + return ( +
+

{title}

+ +
+ ); +} + +function AddToIPGroupPanel({ + ip, + open, + onClose, +}: { + ip: string; + open: boolean; + onClose: () => void; +}) { + const queryClient = useQueryClient(); + const [selectedGroupId, setSelectedGroupId] = useState(''); + + const groupsQuery = useQuery({ + queryKey: ['openflare', 'waf', 'ip-groups'], + queryFn: () => WafService.listIPGroups(), + enabled: open, + }); + + const groups = useMemo(() => groupsQuery.data ?? [], [groupsQuery.data]); + const matchedGroups = useMemo( + () => groupsContainingIp(groups, ip), + [groups, ip], + ); + const manualGroups = useMemo( + () => + groups.filter( + (group) => group.type === 'manual' && group.enabled !== false, + ), + [groups], + ); + const addableGroups = useMemo( + () => + manualGroups.filter( + (group) => + !(group.ip_list ?? []).some((entry) => entry.trim() === ip.trim()), + ), + [manualGroups, ip], + ); + + const updateMutation = useMutation({ + mutationFn: async ({ + group, + nextList, + }: { + group: WAFIPGroup; + nextList: string[]; + }) => + WafService.updateIPGroup( + group.id, + buildIPGroupPayloadFromGroup(group, nextList), + ), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: ['openflare', 'waf', 'ip-groups'], + }); + }, + }); + + const handleAdd = async () => { + const groupId = Number.parseInt(selectedGroupId, 10); + const group = addableGroups.find((item) => item.id === groupId); + if (!group) { + toast.error('请选择要加入的 IP 组'); + return; + } + try { + await updateMutation.mutateAsync({ + group, + nextList: [...(group.ip_list ?? []), ip.trim()], + }); + toast.success(`已将 ${ip} 加入 IP 组「${group.name}」`); + setSelectedGroupId(''); + } catch (error) { + toast.error(error instanceof Error ? error.message : '加入 IP 组失败'); + } + }; + + const handleRemove = async (group: WAFIPGroup) => { + try { + await updateMutation.mutateAsync({ + group, + nextList: (group.ip_list ?? []).filter( + (entry) => entry.trim() !== ip.trim(), + ), + }); + toast.success(`已从 IP 组「${group.name}」移除 ${ip}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : '移除失败'); + } + }; + + return ( + { + if (!next) { + setSelectedGroupId(''); + onClose(); + } + }} + > + + + 将 IP 加入 IP 组 + + 目标 IP: + {ip} + + + + {groupsQuery.isLoading ? ( + + ) : groupsQuery.isError ? ( + void groupsQuery.refetch()} + /> + ) : ( +
+ {matchedGroups.length > 0 ? ( +
+

+ 该 IP 已存在于以下 IP 组 +

+

+ 可选择删除,或继续添加到其他 IP 组。 +

+
+ {matchedGroups.map((group) => ( +
+
+

+ {group.name} +

+

+ {group.type} · {group.ip_list?.length ?? 0} 条 +

+
+ {group.type === 'manual' ? ( + + ) : ( + + 不可手动删除 + + )} +
+ ))} +
+
+ ) : ( +

+ 该 IP 尚未加入任何 IP 组。 +

+ )} + +
+

添加到其他 IP 组

+ {addableGroups.length === 0 ? ( +

+ 没有可写入的手动 IP 组(或已全部包含该 IP)。 +

+ ) : ( + + )} +
+
+ )} + + + + + +
+
+ ); +} + +export function IpAnalysisPanel({ + ip, + enabled, + initialHours = 24, +}: { + ip: string; + enabled: boolean; + /** + * Exact analysis window in hours (1–720), aligned with list filter duration. + * Remount with key={ip} when switching IPs so this re-initializes. + */ + initialHours?: number; +}) { + const [ipGroupOpen, setIpGroupOpen] = useState(false); + const [rangeHours, setRangeHours] = useState(() => + clampAnalysisHours(initialHours), + ); + + const bucketMinutes = resolveBucketMinutes(rangeHours); + const rangeHint = isOverviewPreset(rangeHours) + ? formatOverviewRangeHint(rangeHours) + : `近 ${rangeHours} 小时`; + + const trendQuery = useQuery({ + queryKey: [ + 'openflare', + 'access-logs', + 'ip-trend', + ip, + rangeHours, + bucketMinutes, + ], + queryFn: () => + AccessLogService.getIPTrend({ + remote_addr: ip, + hours: rangeHours, + bucket_minutes: bucketMinutes, + }), + enabled: enabled && ip !== '', + }); + + const analysisQuery = useQuery({ + queryKey: ['openflare', 'access-logs', 'ip-analysis', ip, rangeHours], + queryFn: () => + AccessLogService.getIPAnalysis({ + remote_addr: ip, + hours: rangeHours, + }), + enabled: enabled && ip !== '', + }); + + const trendChartData = useMemo(() => { + return (trendQuery.data?.points ?? []).map((point) => ({ + label: formatOverviewTrendLabel(point.bucket_started_at, rangeHours), + requests: point.request_count, + })); + }, [rangeHours, trendQuery.data?.points]); + + const analysis = analysisQuery.data; + const isLoadingIP = trendQuery.isLoading || analysisQuery.isLoading; + const isFetchingIP = trendQuery.isFetching || analysisQuery.isFetching; + + if (!ip) { + return ; + } + + return ( + <> +
+
+ + { + if (!value) return; + setRangeHours(clampAnalysisHours(Number.parseInt(value, 10))); + }} + variant='outline' + size='sm' + > + {OVERVIEW_RANGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + +
+ + {isLoadingIP ? ( + + ) : ( +
+ {analysisQuery.isError ? ( + void analysisQuery.refetch()} + /> + ) : analysis ? ( +
+ + + + + + +
+ ) : null} + +
+
+
+

IP 请求趋势

+

+ {ip} · {rangeHint} · {bucketMinutes} 分钟桶 +

+
+ +
+ + {trendQuery.isError ? ( + void trendQuery.refetch()} + /> + ) : trendChartData.every((point) => point.requests === 0) ? ( + + ) : ( + + + + + + formatCompactNumber(Number(value)) + } + /> + } /> + + + + )} +
+ + {analysis ? ( +
+ + + + + + +
+ ) : null} +
+ )} +
+ + setIpGroupOpen(false)} + /> + + ); +} diff --git a/frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx b/frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx new file mode 100644 index 00000000..d8051ff6 --- /dev/null +++ b/frontend/app/(main)/access-logs/components/ip-detail-dialog.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState } from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +import { IpAnalysisPanel } from './ip-analysis-panel'; + +export function IpDetailDialog({ + open, + remoteAddr, + region, + initialHours, + onOpenChange, +}: { + open: boolean; + remoteAddr: string | null; + region?: string; + initialHours?: number; + onOpenChange: (open: boolean) => void; +}) { + const [displayAddr, setDisplayAddr] = useState(remoteAddr); + const [displayRegion, setDisplayRegion] = useState(region); + if (remoteAddr && remoteAddr !== displayAddr) { + setDisplayAddr(remoteAddr); + setDisplayRegion(region); + } + const ip = remoteAddr ?? displayAddr ?? ''; + + return ( + + + + IP 详情 + + {ip || '—'} + {displayRegion ? ( + · {displayRegion} + ) : null} + 。查看该 IP 的访问趋势、分布与 WAF IP 组操作。 + + + + + + ); +} diff --git a/frontend/app/(main)/access-logs/components/ip-tab.tsx b/frontend/app/(main)/access-logs/components/ip-tab.tsx new file mode 100644 index 00000000..0d7b7faa --- /dev/null +++ b/frontend/app/(main)/access-logs/components/ip-tab.tsx @@ -0,0 +1,506 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Eye } from 'lucide-react'; + +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { + AccessLogIPSummaryItem, + AccessLogIPSummaryList, +} from '@/lib/services/openflare'; +import { formatDateTime } from '@/lib/utils'; +import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; + +import { + formatOverviewRangeHint, + IP_SORT_OPTIONS, + OVERVIEW_RANGE_OPTIONS, + PAGE_SIZE_OPTIONS, + type OverviewRangeHours, +} from './access-log-utils'; +import { IpDetailDialog } from './ip-detail-dialog'; + +export function toLocalInputValue(date: Date) { + const pad = (n: number) => `${n}`.padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** Default local range ending now, spanning `hours`. */ +export function defaultLocalRangeForHours(hours: number) { + const until = new Date(); + const since = new Date(until.getTime() - hours * 3_600_000); + return { + since: toLocalInputValue(since), + until: toLocalInputValue(until), + }; +} + +function localInputToRFC3339(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toISOString(); +} + +const MAX_CUSTOM_RANGE_MS = 30 * 24 * 3_600_000; + +/** Validate custom local datetime-local range; returns error message or null. */ +export function validateCustomTimeRange( + sinceLocal: string, + untilLocal: string, +): string | null { + if (!sinceLocal.trim() || !untilLocal.trim()) { + return '请填写开始与结束时间'; + } + const sinceMs = new Date(sinceLocal).getTime(); + const untilMs = new Date(untilLocal).getTime(); + if (Number.isNaN(sinceMs) || Number.isNaN(untilMs)) { + return '时间格式无效'; + } + if (untilMs <= sinceMs) { + return '结束时间必须晚于开始时间'; + } + if (untilMs - sinceMs > MAX_CUSTOM_RANGE_MS) { + return '时间范围不能超过 30 天'; + } + return null; +} + +function formatRatio(ratio: number) { + if (!Number.isFinite(ratio) || ratio < 0) return '0%'; + return `${(ratio * 100).toFixed(1)}%`; +} + +/** Exact hours for analysis API (1–720), matching list window duration. */ +export function resolveAnalysisHours(input: { + timeMode: IpTabTimeMode; + hours: OverviewRangeHours; + customSince: string; + customUntil: string; +}): number { + if (input.timeMode === 'custom' && input.customSince && input.customUntil) { + const ms = + new Date(input.customUntil).getTime() - + new Date(input.customSince).getTime(); + if (Number.isFinite(ms) && ms > 0) { + return Math.min(720, Math.max(1, Math.ceil(ms / 3_600_000))); + } + } + return input.hours; +} + +function PaginationBar({ + page, + hasMore, + loading, + totalIp, + onPrev, + onNext, +}: { + page: number; + hasMore: boolean; + loading: boolean; + totalIp?: number; + onPrev: () => void; + onNext: () => void; +}) { + return ( +
+

+ 当前第 {page + 1} 页 + {typeof totalIp === 'number' ? ` · 共 ${totalIp} 个 IP` : ''} +

+
+ + +
+
+ ); +} + +export type IpTabTimeMode = 'preset' | 'custom'; + +export function IpTab({ + data, + loading, + error, + page, + pageSize, + sort, + hours, + timeMode, + customSince, + customUntil, + onHoursChange, + onTimeModeChange, + onApplyCustomRange, + onPageSizeChange, + onSortChange, + onRetry, + onPrevPage, + onNextPage, + isFetching, +}: { + data?: AccessLogIPSummaryList; + loading: boolean; + error: Error | null; + page: number; + pageSize: number; + sort: string; + hours: OverviewRangeHours; + timeMode: IpTabTimeMode; + /** Applied custom range (local datetime-local strings). */ + customSince: string; + customUntil: string; + onHoursChange: (hours: OverviewRangeHours) => void; + onTimeModeChange: (mode: IpTabTimeMode) => void; + /** Commit validated custom range for list query. */ + onApplyCustomRange: (since: string, until: string) => void; + onPageSizeChange: (size: number) => void; + onSortChange: (value: string) => void; + onRetry: () => void; + onPrevPage: () => void; + onNextPage: () => void; + isFetching: boolean; +}) { + const [selected, setSelected] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [draftSince, setDraftSince] = useState(customSince); + const [draftUntil, setDraftUntil] = useState(customUntil); + const [customError, setCustomError] = useState(null); + + const defaultCustomRange = useMemo( + () => defaultLocalRangeForHours(hours), + [hours], + ); + + useEffect(() => { + if (timeMode !== 'custom') { + setCustomError(null); + return; + } + // Sync draft from applied range when entering custom or after apply. + setDraftSince(customSince || defaultCustomRange.since); + setDraftUntil(customUntil || defaultCustomRange.until); + setCustomError(null); + }, [timeMode, customSince, customUntil, defaultCustomRange]); + + const analysisHours = resolveAnalysisHours({ + timeMode, + hours, + customSince, + customUntil, + }); + + const rangeHint = + timeMode === 'custom' && customSince && customUntil + ? '自定义区间' + : timeMode === 'custom' + ? '自定义(未应用)' + : formatOverviewRangeHint(hours); + + const handleApplyCustom = () => { + const message = validateCustomTimeRange(draftSince, draftUntil); + if (message) { + setCustomError(message); + return; + } + setCustomError(null); + onApplyCustomRange(draftSince, draftUntil); + }; + + return ( + <> +
+
+
+
+

时间范围

+

+ 当前:{rangeHint} + {data?.total_ip != null + ? ` · ${formatCompactNumber(data.total_ip)} 个 IP` + : ''} +

+
+
+ { + if (!value) return; + onTimeModeChange('preset'); + onHoursChange( + Number.parseInt(value, 10) as OverviewRangeHours, + ); + }} + variant='outline' + size='sm' + > + {OVERVIEW_RANGE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
+
+ + {timeMode === 'custom' ? ( +
+
+
+

开始

+ { + setDraftSince(e.target.value); + setCustomError(null); + }} + /> +
+
+

结束

+ { + setDraftUntil(e.target.value); + setCustomError(null); + }} + /> +
+ +
+ {customError ? ( +

{customError}

+ ) : ( +

+ 修改时间后点击「应用」再查询;详情分析窗口与列表时长对齐。 +

+ )} +
+ ) : null} + +
+
+

排序

+ +
+
+

每页

+ +
+
+
+ +
+
+

IP 明细

+
+ {error ? ( +
+ +
+ ) : loading ? ( + + ) : (data?.items ?? []).length === 0 ? ( + + ) : ( + + + + IP + 地区 + 请求数 + 2xx 比例 + 入站 + 出站 + 最后访问 + + + + + {(data?.items ?? []).map((item) => ( + + + {item.remote_addr} + + + {item.region || '—'} + + + {formatCompactNumber(item.total_requests)} + + + + {formatRatio(item.success_ratio)} + + + + {formatBytes(item.bytes_received)} + + + {formatBytes(item.bytes_sent)} + + + {formatDateTime(item.last_seen_at)} + + + + + + + + 查看 IP 详情 + + + + + ))} + +
+ )} + +
+
+ + + + ); +} + +/** Build list API time params from IP tab state (applied values only). */ +export function buildIpSummaryTimeParams(input: { + timeMode: IpTabTimeMode; + hours: OverviewRangeHours; + customSince: string; + customUntil: string; +}): { hours?: number; since?: string; until?: string } | null { + if (input.timeMode === 'custom') { + if (validateCustomTimeRange(input.customSince, input.customUntil)) { + return null; + } + const since = localInputToRFC3339(input.customSince); + const until = localInputToRFC3339(input.customUntil); + if (!since || !until) { + return null; + } + return { since, until }; + } + return { hours: input.hours }; +} diff --git a/frontend/app/(main)/access-logs/page.tsx b/frontend/app/(main)/access-logs/page.tsx index ef91ccfd..bac541a7 100644 --- a/frontend/app/(main)/access-logs/page.tsx +++ b/frontend/app/(main)/access-logs/page.tsx @@ -20,6 +20,12 @@ import { } from './components/access-log-utils'; import { CleanupDialog } from './components/cleanup-dialog'; import { DetailTab } from './components/detail-tab'; +import { + buildIpSummaryTimeParams, + defaultLocalRangeForHours, + IpTab, + type IpTabTimeMode, +} from './components/ip-tab'; import { OverviewTab } from './components/overview-tab'; const emptyDraft: SearchDraft = { @@ -30,7 +36,9 @@ const emptyDraft: SearchDraft = { }; function resolveTab(value: string | null): AccessLogTab { - return value === 'list' ? 'list' : 'overview'; + if (value === 'list') return 'list'; + if (value === 'ips') return 'ips'; + return 'overview'; } function AccessLogsPageContent() { @@ -43,11 +51,26 @@ function AccessLogsPageContent() { const [pageSize, setPageSize] = useState(20); const [page, setPage] = useState(0); const [detailSort, setDetailSort] = useState('logged_at:desc'); + const [ipSort, setIpSort] = useState('total_requests:desc'); + const [ipPageSize, setIpPageSize] = useState(20); + const [ipPage, setIpPage] = useState(0); + const [ipHours, setIpHours] = useState(168); + const [ipTimeMode, setIpTimeMode] = useState('preset'); + const [ipCustomSince, setIpCustomSince] = useState(''); + const [ipCustomUntil, setIpCustomUntil] = useState(''); const [overviewHours, setOverviewHours] = useState(24); const [overviewHosts, setOverviewHosts] = useState([]); const [cleanupOpen, setCleanupOpen] = useState(false); const detailSortState = parseSortValue(detailSort); + const ipSortState = parseSortValue(ipSort); + const ipTimeParams = buildIpSummaryTimeParams({ + timeMode: ipTimeMode, + hours: ipHours, + customSince: ipCustomSince, + customUntil: ipCustomUntil, + }); + const ipQueryEnabled = tab === 'ips' && ipTimeParams != null; const handleTabChange = (value: string) => { const next = resolveTab(value); @@ -96,6 +119,27 @@ function AccessLogsPageContent() { enabled: tab === 'list', }); + const ipQuery = useQuery({ + queryKey: [ + 'openflare', + 'access-logs', + 'ip-summary', + ipTimeParams, + ipPage, + ipPageSize, + ipSort, + ], + queryFn: () => + AccessLogService.listIPSummaries({ + ...(ipTimeParams as NonNullable), + p: ipPage, + page_size: ipPageSize, + sort_by: ipSortState.sortBy, + sort_order: ipSortState.sortOrder, + }), + enabled: ipQueryEnabled, + }); + const cleanupMutation = useMutation({ mutationFn: (retentionDays: number) => AccessLogService.cleanup({ retention_days: retentionDays }), @@ -131,12 +175,26 @@ function AccessLogsPageContent() { setPage(0); }, [tab, pageSize]); + useEffect(() => { + setIpPage(0); + }, [ + tab, + ipPageSize, + ipHours, + ipTimeMode, + ipCustomSince, + ipCustomUntil, + ipSort, + ]); + const refreshActive = () => { if (tab === 'overview') void overviewQuery.refetch(); if (tab === 'list') void listQuery.refetch(); + if (tab === 'ips') void ipQuery.refetch(); }; - const isFetching = overviewQuery.isFetching || listQuery.isFetching; + const isFetching = + overviewQuery.isFetching || listQuery.isFetching || ipQuery.isFetching; return (
@@ -146,7 +204,7 @@ function AccessLogsPageContent() {

访问日志

- 查看访问概览、排行榜与明细日志。 + 查看访问概览、IP 明细与请求日志。

@@ -174,8 +232,9 @@ function AccessLogsPageContent() { - + 概览 + IP 明细 日志明细 @@ -194,6 +253,48 @@ function AccessLogsPageContent() { /> + + { + setIpTimeMode('preset'); + setIpHours(next); + }} + onTimeModeChange={(mode) => { + setIpTimeMode(mode); + if ( + mode === 'custom' && + (!ipCustomSince.trim() || !ipCustomUntil.trim()) + ) { + const range = defaultLocalRangeForHours(ipHours); + setIpCustomSince(range.since); + setIpCustomUntil(range.until); + } + }} + onApplyCustomRange={(since, until) => { + setIpCustomSince(since); + setIpCustomUntil(until); + setIpTimeMode('custom'); + setIpPage(0); + }} + onPageSizeChange={setIpPageSize} + onSortChange={setIpSort} + onRetry={() => void ipQuery.refetch()} + onPrevPage={() => setIpPage((p) => Math.max(0, p - 1))} + onNextPage={() => setIpPage((p) => p + 1)} + isFetching={ipQuery.isFetching} + /> + +
maxAccessLogOverviewHours { + hours = maxAccessLogOverviewHours + } + until := time.Now().UTC() + return until.Add(-time.Duration(hours) * time.Hour), until, hours, nil } + if sinceRaw == "" || untilRaw == "" { + return time.Time{}, time.Time{}, 0, errors.New("since 与 until 需同时提供") + } + parsedSince, err := time.Parse(time.RFC3339, sinceRaw) + if err != nil { + return time.Time{}, time.Time{}, 0, errors.New("since 必须为 RFC3339 时间") + } + parsedUntil, err := time.Parse(time.RFC3339, untilRaw) + if err != nil { + return time.Time{}, time.Time{}, 0, errors.New("until 必须为 RFC3339 时间") + } + since := parsedSince.UTC() + until := parsedUntil.UTC() + if !until.After(since) { + return time.Time{}, time.Time{}, 0, errors.New("until 必须晚于 since") + } + if until.Sub(since) > time.Duration(maxAccessLogOverviewHours)*time.Hour { + return time.Time{}, time.Time{}, 0, errors.New("时间范围不能超过 30 天") + } + hours = int(until.Sub(since).Hours()) + if hours <= 0 { + hours = 1 + } + return since, until, hours, nil } func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccessLogIPQuery, time.Time, error) { @@ -1001,7 +1078,9 @@ func normalizeFoldSortBy(sortBy string) string { func normalizeIPSummarySortBy(sortBy string) string { switch strings.TrimSpace(sortBy) { - case "recent_requests", "last_seen_at", accessLogFieldRemoteAddr: + case "request_length", "bytes_received": + return "request_length" + case "bytes_sent", "success_ratio", "last_seen_at", accessLogFieldRemoteAddr: return strings.TrimSpace(sortBy) default: return "total_requests" diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index 1c1b25f7..78bbceed 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -143,16 +143,19 @@ func GetFoldedAccessLogIPsHandler(c *gin.Context) { // GetAccessLogIPSummariesHandler 列出访问日志 IP 汇总。 // @Summary 列出访问日志 IP 汇总 -// @Description 按 IP 聚合访问日志统计并分页返回,需要管理员权限 +// @Description 按 IP 聚合访问日志统计并分页返回;支持 hours 或 since/until 时间窗,需要管理员权限 // @Tags openflare-observability // @Produce json // @Security SessionCookie // @Param node_id query string false "节点 ID" // @Param remote_addr query string false "客户端 IP" // @Param host query string false "请求 Host" +// @Param hours query int false "统计时间范围(小时,1-720,默认 168)" +// @Param since query string false "开始时间 RFC3339(与 until 同时提供时优先于 hours)" +// @Param until query string false "结束时间 RFC3339" // @Param p query int false "页码" // @Param page_size query int false "每页条数" -// @Param sort_by query string false "排序字段" +// @Param sort_by query string false "排序字段 total_requests|request_length|bytes_sent|success_ratio|last_seen_at|remote_addr" // @Param sort_order query string false "排序方向" // @Success 200 {object} response.Any{data=observability.AccessLogIPSummaryList} "IP 汇总列表" // @Failure 400 {object} response.Any "参数错误" @@ -165,6 +168,9 @@ func GetAccessLogIPSummariesHandler(c *gin.Context) { NodeID: c.Query("node_id"), RemoteAddr: c.Query("remote_addr"), Host: c.Query("host"), + Hours: readQueryInt(c, "hours"), + Since: c.Query("since"), + Until: c.Query("until"), Page: readQueryInt(c, "p"), PageSize: readQueryInt(c, "page_size"), SortBy: c.Query("sort_by"), diff --git a/internal/model/analytics/node_access_log_stats.go b/internal/model/analytics/node_access_log_stats.go index 0ee797a3..ad98ac29 100644 --- a/internal/model/analytics/node_access_log_stats.go +++ b/internal/model/analytics/node_access_log_stats.go @@ -46,10 +46,16 @@ type NodeAccessLogIPAggregate struct { // NodeAccessLogIPSummary is an IP summary row. type NodeAccessLogIPSummary struct { - RemoteAddr string `gorm:"column:remote_addr"` - TotalRequests int64 `gorm:"column:total_requests"` - RecentRequests int64 `gorm:"column:recent_requests"` - LastSeenEpoch int64 `gorm:"column:last_seen_epoch"` + RemoteAddr string `gorm:"column:remote_addr"` + Region string `gorm:"column:region"` + TotalRequests int64 `gorm:"column:total_requests"` + Success2xxCount int64 `gorm:"column:success_2xx_count"` + SuccessRatio float64 `gorm:"column:success_ratio"` + BytesReceived int64 `gorm:"column:request_length"` + BytesSent int64 `gorm:"column:bytes_sent"` + // RecentRequests is deprecated (always 0); kept for wire compatibility. + RecentRequests int64 `gorm:"column:recent_requests"` + LastSeenEpoch int64 `gorm:"column:last_seen_epoch"` } // NodeAccessLogIPTrend is an IP trend bucket row. diff --git a/internal/model/openflare_access_log.go b/internal/model/openflare_access_log.go index 9ac1ab96..363f9a40 100644 --- a/internal/model/openflare_access_log.go +++ b/internal/model/openflare_access_log.go @@ -251,10 +251,15 @@ func buildOpenFlareAccessLogIPSummaryRows(ctx context.Context, query OpenFlareAc continue } rows = append(rows, &OpenFlareAccessLogIPSummaryRow{ - RemoteAddr: remoteAddr, - TotalRequests: partial.TotalRequests, - RecentRequests: partial.RecentRequests, - LastSeenEpoch: partial.LastSeenEpoch, + RemoteAddr: remoteAddr, + Region: strings.TrimSpace(partial.Region), + TotalRequests: partial.TotalRequests, + Success2xxCount: partial.Success2xxCount, + SuccessRatio: partial.SuccessRatio, + BytesReceived: partial.BytesReceived, + BytesSent: partial.BytesSent, + RecentRequests: 0, + LastSeenEpoch: partial.LastSeenEpoch, }) } return rows, nil @@ -305,6 +310,7 @@ func openFlareAccessLogQueryFromIPSummary(query OpenFlareAccessLogIPSummaryQuery RemoteAddr: query.RemoteAddr, Host: query.Host, Since: query.Since, + Until: query.Until, Page: query.Page, PageSize: query.PageSize, SortBy: query.SortBy, @@ -377,8 +383,12 @@ func sortOpenFlareAccessLogIPSummaryRows(items []*OpenFlareAccessLogIPSummaryRow } var compare int switch strings.TrimSpace(sortBy) { - case "recent_requests": - compare = openFlareAccessLogCompareInt64(left.RecentRequests, right.RecentRequests) + case "request_length", "bytes_received": + compare = openFlareAccessLogCompareInt64(left.BytesReceived, right.BytesReceived) + case "bytes_sent": + compare = openFlareAccessLogCompareInt64(left.BytesSent, right.BytesSent) + case "success_ratio": + compare = openFlareAccessLogCompareFloat64(left.SuccessRatio, right.SuccessRatio) case "last_seen_at": compare = openFlareAccessLogCompareInt64(left.LastSeenEpoch, right.LastSeenEpoch) case "remote_addr": @@ -399,6 +409,16 @@ func sortOpenFlareAccessLogIPSummaryRows(items []*OpenFlareAccessLogIPSummaryRow }) } +func openFlareAccessLogCompareFloat64(left, right float64) int { + if left < right { + return -1 + } + if left > right { + return 1 + } + return 0 +} + func openFlareAccessLogPaginateBounds(total int, page int, pageSize int) (int, int) { if page < 0 { page = 0 diff --git a/internal/model/openflare_access_log_store_memory.go b/internal/model/openflare_access_log_store_memory.go index 5af2de57..d74c8a86 100644 --- a/internal/model/openflare_access_log_store_memory.go +++ b/internal/model/openflare_access_log_store_memory.go @@ -274,11 +274,21 @@ func (s *memoryAccessLogStore) IPAggregates(_ context.Context, filter OpenFlareA return result, nil } -func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAccessLogQuery, recentSince time.Time) ([]openFlareAccessLogIPSummaryRow, error) { +func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAccessLogQuery, _ time.Time) ([]openFlareAccessLogIPSummaryRow, error) { s.mu.RLock() defer s.mu.RUnlock() rows := s.filterRecords(filter) - aggregates := make(map[string]*openFlareAccessLogIPSummaryRow) + type aggregate struct { + RemoteAddr string + Region string + RegionEpoch int64 + TotalRequests int64 + Success2xxCount int64 + BytesReceived int64 + BytesSent int64 + LastSeenEpoch int64 + } + aggregates := make(map[string]*aggregate) for _, row := range rows { remoteAddr := strings.TrimSpace(row.RemoteAddr) if remoteAddr == "" { @@ -286,25 +296,40 @@ func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAc } item := aggregates[remoteAddr] if item == nil { - item = &openFlareAccessLogIPSummaryRow{RemoteAddr: remoteAddr} + item = &aggregate{RemoteAddr: remoteAddr} aggregates[remoteAddr] = item } item.TotalRequests++ - if !recentSince.IsZero() && !row.LoggedAt.Before(recentSince) { - item.RecentRequests++ + if row.StatusCode >= 200 && row.StatusCode < 300 { + item.Success2xxCount++ } + item.BytesReceived += row.RequestLength + item.BytesSent += row.BytesSent epoch := row.LoggedAt.UTC().Unix() if epoch > item.LastSeenEpoch { item.LastSeenEpoch = epoch } + if epoch >= item.RegionEpoch { + item.RegionEpoch = epoch + item.Region = strings.TrimSpace(row.Region) + } } summaryRows := make([]*OpenFlareAccessLogIPSummaryRow, 0, len(aggregates)) for _, item := range aggregates { + ratio := 0.0 + if item.TotalRequests > 0 { + ratio = float64(item.Success2xxCount) / float64(item.TotalRequests) + } summaryRows = append(summaryRows, &OpenFlareAccessLogIPSummaryRow{ - RemoteAddr: item.RemoteAddr, - TotalRequests: item.TotalRequests, - RecentRequests: item.RecentRequests, - LastSeenEpoch: item.LastSeenEpoch, + RemoteAddr: item.RemoteAddr, + Region: item.Region, + TotalRequests: item.TotalRequests, + Success2xxCount: item.Success2xxCount, + SuccessRatio: ratio, + BytesReceived: item.BytesReceived, + BytesSent: item.BytesSent, + RecentRequests: 0, + LastSeenEpoch: item.LastSeenEpoch, }) } sortOpenFlareAccessLogIPSummaryRows(summaryRows, filter.SortBy, filter.SortOrder) @@ -315,10 +340,15 @@ func (s *memoryAccessLogStore) IPSummaries(_ context.Context, filter OpenFlareAc result := make([]openFlareAccessLogIPSummaryRow, len(summaryRows)) for index, item := range summaryRows { result[index] = openFlareAccessLogIPSummaryRow{ - RemoteAddr: item.RemoteAddr, - TotalRequests: item.TotalRequests, - RecentRequests: item.RecentRequests, - LastSeenEpoch: item.LastSeenEpoch, + RemoteAddr: item.RemoteAddr, + Region: item.Region, + TotalRequests: item.TotalRequests, + Success2xxCount: item.Success2xxCount, + SuccessRatio: item.SuccessRatio, + BytesReceived: item.BytesReceived, + BytesSent: item.BytesSent, + RecentRequests: 0, + LastSeenEpoch: item.LastSeenEpoch, } } return result, nil diff --git a/internal/model/openflare_observability.go b/internal/model/openflare_observability.go index eb94b69b..95702c7c 100644 --- a/internal/model/openflare_observability.go +++ b/internal/model/openflare_observability.go @@ -237,6 +237,7 @@ type OpenFlareAccessLogIPSummaryQuery struct { RemoteAddr string Host string Since time.Time + Until time.Time Page int PageSize int SortBy string @@ -245,10 +246,16 @@ type OpenFlareAccessLogIPSummaryQuery struct { // OpenFlareAccessLogIPSummaryRow is an IP summary row (v1 stub). type OpenFlareAccessLogIPSummaryRow struct { - RemoteAddr string `json:"remote_addr"` - TotalRequests int64 `json:"total_requests"` - RecentRequests int64 `json:"recent_requests"` - LastSeenEpoch int64 `json:"last_seen_epoch"` + RemoteAddr string `json:"remote_addr"` + Region string `json:"region"` + TotalRequests int64 `json:"total_requests"` + Success2xxCount int64 `json:"success_2xx_count"` + SuccessRatio float64 `json:"success_ratio"` + BytesReceived int64 `json:"bytes_received"` + BytesSent int64 `json:"bytes_sent"` + // RecentRequests is deprecated and always 0. + RecentRequests int64 `json:"recent_requests"` + LastSeenEpoch int64 `json:"last_seen_epoch"` } // OpenFlareAccessLogIPTrendQuery filters IP trend queries (v1 stub). diff --git a/internal/repository/analytics/node_access_log_filter.go b/internal/repository/analytics/node_access_log_filter.go index 173555b7..465660be 100644 --- a/internal/repository/analytics/node_access_log_filter.go +++ b/internal/repository/analytics/node_access_log_filter.go @@ -176,10 +176,17 @@ func nodeAccessLogIPSummaryOrderClause(sortBy string, sortOrder string) string { } column := "total_requests" switch strings.TrimSpace(sortBy) { - case "recent_requests": - column = "recent_requests" + case "request_length", "bytes_received": + column = "request_length" + case "bytes_sent": + column = "bytes_sent" + case "success_ratio": + column = "success_ratio" case "last_seen_at": column = "last_seen_epoch" + case "recent_requests": + // Deprecated sort key; fall back to total_requests. + column = "total_requests" case nodeAccessLogColumnRemoteAddr: column = nodeAccessLogColumnRemoteAddr } diff --git a/internal/repository/analytics/node_access_log_stats.go b/internal/repository/analytics/node_access_log_stats.go index 7bbcdb87..82ba8c7d 100644 --- a/internal/repository/analytics/node_access_log_stats.go +++ b/internal/repository/analytics/node_access_log_stats.go @@ -205,32 +205,42 @@ GROUP BY remote_addr`, lastSeenExpr, tableName, queryClause) return result, nil } -// IPSummariesNodeAccessLogs returns paginated IP summary rows. -func IPSummariesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, recentSince time.Time) ([]NodeAccessLogIPSummary, error) { +// IPSummariesNodeAccessLogs returns paginated IP summary rows for the filter window. +// recentSince is ignored (kept for call-site compatibility); recent_requests is always 0. +func IPSummariesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, _ time.Time) ([]NodeAccessLogIPSummary, error) { conn, err := nodeAccessLogConn() if err != nil { return nil, err } clause, args := buildNodeAccessLogFilterClause(filter) lastSeenExpr := nodeAccessLogEpochExpr() - recentClause := "0" - queryArgs := make([]any, 0, len(args)+1) - if !recentSince.IsZero() { - recentClause = "if(logged_at >= ?, 1, 0)" - queryArgs = append(queryArgs, recentSince) - } - queryArgs = append(queryArgs, args...) + queryArgs := append([]any{}, args...) tableName := nodeAccessLogTableName() - sql := fmt.Sprintf(` + // Outer query allows ORDER BY success_ratio without repeating countIf. + innerSQL := fmt.Sprintf(` SELECT remote_addr, + argMax(region, logged_at) AS region, count() AS total_requests, - sum(%s) AS recent_requests, + countIf(status_code >= 200 AND status_code < 300) AS success_2xx_count, + sum(request_length) AS request_length, + sum(bytes_sent) AS bytes_sent, max(%s) AS last_seen_epoch FROM %s WHERE %s AND remote_addr != '' -GROUP BY remote_addr -ORDER BY %s`, recentClause, lastSeenExpr, tableName, clause, nodeAccessLogIPSummaryOrderClause(filter.SortBy, filter.SortOrder)) +GROUP BY remote_addr`, lastSeenExpr, tableName, clause) + sql := fmt.Sprintf(` +SELECT + remote_addr, + region, + total_requests, + success_2xx_count, + if(total_requests = 0, 0., toFloat64(success_2xx_count) / toFloat64(total_requests)) AS success_ratio, + request_length, + bytes_sent, + last_seen_epoch +FROM (%s) +ORDER BY %s`, innerSQL, nodeAccessLogIPSummaryOrderClause(filter.SortBy, filter.SortOrder)) if filter.PageSize > 0 { if filter.Page < 0 { filter.Page = 0 @@ -247,18 +257,33 @@ ORDER BY %s`, recentClause, lastSeenExpr, tableName, clause, nodeAccessLogIPSumm var result []NodeAccessLogIPSummary for rows.Next() { var ( - remoteAddr string - lastSeenEpoch int64 - totalRequests, recentRequests uint64 + remoteAddr, region string + lastSeenEpoch int64 + successRatio float64 + totalRequests, success2xx, bytesReceived, bytes uint64 ) - if err := rows.Scan(&remoteAddr, &totalRequests, &recentRequests, &lastSeenEpoch); err != nil { + if err := rows.Scan( + &remoteAddr, + ®ion, + &totalRequests, + &success2xx, + &successRatio, + &bytesReceived, + &bytes, + &lastSeenEpoch, + ); err != nil { return nil, fmt.Errorf("scan ip summary row: %w", err) } result = append(result, NodeAccessLogIPSummary{ - RemoteAddr: remoteAddr, - TotalRequests: safeInt64Count(totalRequests), - RecentRequests: safeInt64Count(recentRequests), - LastSeenEpoch: lastSeenEpoch, + RemoteAddr: remoteAddr, + Region: region, + TotalRequests: safeInt64Count(totalRequests), + Success2xxCount: safeInt64Count(success2xx), + SuccessRatio: successRatio, + BytesReceived: safeInt64Count(bytesReceived), + BytesSent: safeInt64Count(bytes), + RecentRequests: 0, + LastSeenEpoch: lastSeenEpoch, }) } return result, nil