feat(access-logs): 明细详情支持 IP 分析与 URL Tab 记忆

- 新增单 IP 分析接口,趋势时间范围支持至 30 天
- 明细详情弹窗展示趋势、汇总与 Top 分布,可快捷管理 IP 组
- 访问日志页签改为 URL 参数记忆,筛选后保持当前 Tab
This commit is contained in:
ryan
2026-07-18 22:40:21 +08:00
parent bf71bc540b
commit 9aec984bee
13 changed files with 1443 additions and 86 deletions
@@ -22,7 +22,7 @@ const (
defaultAccessLogFoldMinute = 3
defaultIPTrendHours = 24
defaultIPTrendBucketMinute = 30
maxIPTrendHours = 168
maxIPTrendHours = 24 * 30
nodeAccessLogRetentionDays = 90
defaultAccessLogQueryDays = 7
accessLogFieldRemoteAddr = "remote_addr"
@@ -181,6 +181,38 @@ type AccessLogIPTrendView struct {
Points []AccessLogIPTrendPoint `json:"points"`
}
// AccessLogIPAnalysisQuery filters per-IP analysis queries.
type AccessLogIPAnalysisQuery struct {
NodeID string `json:"node_id"`
RemoteAddr string `json:"remote_addr"`
Host string `json:"host"`
Hours int `json:"hours"`
}
// AccessLogIPAnalysisSummary is headline totals for one IP.
type AccessLogIPAnalysisSummary struct {
TotalRequests int64 `json:"total_requests"`
ErrorCount int64 `json:"error_count"`
BandwidthServed int64 `json:"bandwidth_served"`
BytesReceived int64 `json:"bytes_received"`
UniqueHosts int64 `json:"unique_hosts"`
UniquePaths int64 `json:"unique_paths"`
}
// AccessLogIPAnalysisView is per-IP analytics for the detail dialog.
type AccessLogIPAnalysisView struct {
RemoteAddr string `json:"remote_addr"`
Hours int `json:"hours"`
GeneratedAt time.Time `json:"generated_at"`
Summary AccessLogIPAnalysisSummary `json:"summary"`
TopPaths []DistributionItem `json:"top_paths"`
TopHosts []DistributionItem `json:"top_hosts"`
StatusCodes []DistributionItem `json:"status_codes"`
TopUserAgents []DistributionItem `json:"top_user_agents"`
DeviceTypes []DistributionItem `json:"device_types"`
TopBrowsers []DistributionItem `json:"top_browsers"`
}
// AccessLogOverviewQuery filters access log overview queries.
type AccessLogOverviewQuery struct {
NodeID string `json:"node_id"`
@@ -665,6 +697,82 @@ func GetAccessLogIPTrend(ctx context.Context, input AccessLogIPTrendQuery) (*Acc
}, nil
}
// GetAccessLogIPAnalysis returns per-IP summary and rankings.
func GetAccessLogIPAnalysis(ctx context.Context, input AccessLogIPAnalysisQuery) (*AccessLogIPAnalysisView, error) {
normalized, err := normalizeAccessLogIPAnalysisQuery(input)
if err != nil {
return nil, err
}
now := time.Now().UTC()
since := now.Add(-time.Duration(normalized.Hours) * time.Hour)
query := model.OpenFlareAccessLogQuery{
NodeID: normalized.NodeID,
RemoteAddr: normalized.RemoteAddr,
Host: normalized.Host,
Since: since,
Until: now,
}
summaryRow, err := model.TrafficSummaryOpenFlareAccessLogs(ctx, query)
if err != nil {
return nil, err
}
allPaths := valueCountDistribution(ctx, query, "path", 0)
allHosts := valueCountDistribution(ctx, query, "host", 0)
topPaths := limitDistributionItems(allPaths, accessLogOverviewTopLimit)
topHosts := limitDistributionItems(allHosts, accessLogOverviewTopLimit)
statusCodes := valueCountDistribution(ctx, query, "status_code", accessLogOverviewTopLimit)
topUserAgents := valueCountDistribution(ctx, query, "user_agent", accessLogOverviewTopLimit)
deviceTypes, topBrowsers, _, _ := buildAccessLogUADistributions(ctx, query)
return &AccessLogIPAnalysisView{
RemoteAddr: normalized.RemoteAddr,
Hours: normalized.Hours,
GeneratedAt: now,
Summary: AccessLogIPAnalysisSummary{
TotalRequests: summaryRow.RequestCount,
ErrorCount: summaryRow.ErrorCount,
BandwidthServed: summaryRow.BytesSent,
BytesReceived: summaryRow.RequestLength,
UniqueHosts: int64(len(allHosts)),
UniquePaths: int64(len(allPaths)),
},
TopPaths: topPaths,
TopHosts: topHosts,
StatusCodes: statusCodes,
TopUserAgents: topUserAgents,
DeviceTypes: deviceTypes,
TopBrowsers: topBrowsers,
}, nil
}
func limitDistributionItems(items []DistributionItem, limit int) []DistributionItem {
if limit <= 0 || len(items) <= limit {
return items
}
return items[:limit]
}
func normalizeAccessLogIPAnalysisQuery(input AccessLogIPAnalysisQuery) (AccessLogIPAnalysisQuery, error) {
remoteAddr := strings.TrimSpace(input.RemoteAddr)
if remoteAddr == "" {
return AccessLogIPAnalysisQuery{}, errors.New("remote_addr 不能为空")
}
hours := input.Hours
if hours <= 0 {
hours = defaultIPTrendHours
}
if hours > maxIPTrendHours {
hours = maxIPTrendHours
}
return AccessLogIPAnalysisQuery{
NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: remoteAddr,
Host: strings.TrimSpace(input.Host),
Hours: hours,
}, nil
}
// CleanupAccessLogs removes access logs older than retention days.
func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*AccessLogCleanupResult, error) {
if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays {
@@ -205,6 +205,35 @@ func GetAccessLogIPTrendHandler(c *gin.Context) {
c.JSON(http.StatusOK, response.OK(result))
}
// GetAccessLogIPAnalysisHandler 获取单 IP 访问分析。
// @Summary 获取访问日志 IP 分析
// @Description 返回指定 IP 的汇总指标与 Top 分布,需要管理员权限
// @Tags openflare-observability
// @Produce json
// @Security SessionCookie
// @Param node_id query string false "节点 ID"
// @Param remote_addr query string false "客户端 IP"
// @Param host query string false "请求 Host"
// @Param hours query int false "统计时间范围(小时)"
// @Success 200 {object} response.Any{data=observability.AccessLogIPAnalysisView} "IP 访问分析"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Failure 500 {object} response.Any "内部错误"
// @Router /api/v1/d/access-logs/ip-summary/analysis [get]
func GetAccessLogIPAnalysisHandler(c *gin.Context) {
result, err := GetAccessLogIPAnalysis(c.Request.Context(), AccessLogIPAnalysisQuery{
NodeID: c.Query("node_id"),
RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"),
Hours: readQueryInt(c, "hours"),
})
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(result))
}
// CleanupAccessLogsHandler 清理过期访问日志。
// @Summary 清理访问日志
// @Description 按保留天数清理过期访问日志记录,需要管理员权限