diff --git a/openflare_server/controller/access_log.go b/openflare_server/controller/access_log.go index 7399e7f4..f2d10822 100644 --- a/openflare_server/controller/access_log.go +++ b/openflare_server/controller/access_log.go @@ -1,6 +1,7 @@ package controller import ( + "net/http" "openflare/service" "strconv" @@ -13,17 +14,149 @@ import ( // @Produce json // @Security BearerAuth // @Param node_id query string false "Node ID" +// @Param remote_addr query string false "Remote address" +// @Param host query string false "Host" +// @Param path query string false "Path" // @Param p query int false "Page index" // @Param page_size query int false "Page size" +// @Param sort_by query string false "Sort by" +// @Param sort_order query string false "Sort order" // @Success 200 {object} map[string]interface{} // @Router /api/access-logs/ [get] func GetAccessLogs(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("p", "0")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0")) - logs, err := service.ListAccessLogs(c.Query("node_id"), page, pageSize) + logs, err := service.ListAccessLogs(readAccessLogQuery(c)) if err != nil { respondFailure(c, err.Error()) return } respondSuccess(c, logs) } + +// GetFoldedAccessLogs godoc +// @Summary List folded access logs +// @Tags AccessLogs +// @Produce json +// @Security BearerAuth +// @Param node_id query string false "Node ID" +// @Param remote_addr query string false "Remote address" +// @Param host query string false "Host" +// @Param path query string false "Path" +// @Param p query int false "Page index" +// @Param page_size query int false "Page size" +// @Param sort_by query string false "Sort by" +// @Param sort_order query string false "Sort order" +// @Param fold_minutes query int false "Fold minutes" +// @Success 200 {object} map[string]interface{} +// @Router /api/access-logs/folds [get] +func GetFoldedAccessLogs(c *gin.Context) { + query := readAccessLogQuery(c) + query.FoldMinutes = readQueryInt(c, "fold_minutes") + logs, err := service.ListFoldedAccessLogs(query) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, logs) +} + +// GetAccessLogIPSummaries godoc +// @Summary List access log IP summaries +// @Tags AccessLogs +// @Produce json +// @Security BearerAuth +// @Param node_id query string false "Node ID" +// @Param remote_addr query string false "Remote address" +// @Param host query string false "Host" +// @Param p query int false "Page index" +// @Param page_size query int false "Page size" +// @Param sort_by query string false "Sort by" +// @Param sort_order query string false "Sort order" +// @Success 200 {object} map[string]interface{} +// @Router /api/access-logs/ip-summary [get] +func GetAccessLogIPSummaries(c *gin.Context) { + result, err := service.ListAccessLogIPSummaries(service.AccessLogIPSummaryQuery{ + NodeID: c.Query("node_id"), + RemoteAddr: c.Query("remote_addr"), + Host: c.Query("host"), + Page: readQueryInt(c, "p"), + PageSize: readQueryInt(c, "page_size"), + SortBy: c.Query("sort_by"), + SortOrder: c.Query("sort_order"), + }) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, result) +} + +// GetAccessLogIPTrend godoc +// @Summary Get access log IP trend +// @Tags AccessLogs +// @Produce json +// @Security BearerAuth +// @Param node_id query string false "Node ID" +// @Param remote_addr query string true "Remote address" +// @Param host query string false "Host" +// @Param hours query int false "Hours" +// @Param bucket_minutes query int false "Bucket minutes" +// @Success 200 {object} map[string]interface{} +// @Router /api/access-logs/ip-summary/trend [get] +func GetAccessLogIPTrend(c *gin.Context) { + result, err := service.GetAccessLogIPTrend(service.AccessLogIPTrendQuery{ + NodeID: c.Query("node_id"), + RemoteAddr: c.Query("remote_addr"), + Host: c.Query("host"), + Hours: readQueryInt(c, "hours"), + BucketMinutes: readQueryInt(c, "bucket_minutes"), + }) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, result) +} + +// CleanupAccessLogs godoc +// @Summary Cleanup access logs by retention days +// @Tags AccessLogs +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} +// @Router /api/access-logs/cleanup [post] +func CleanupAccessLogs(c *gin.Context) { + var input service.AccessLogCleanupInput + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "参数错误", + "error": err.Error(), + }) + return + } + result, err := service.CleanupAccessLogs(input) + if err != nil { + respondFailure(c, err.Error()) + return + } + respondSuccess(c, result) +} + +func readAccessLogQuery(c *gin.Context) service.AccessLogQuery { + return service.AccessLogQuery{ + NodeID: c.Query("node_id"), + RemoteAddr: c.Query("remote_addr"), + Host: c.Query("host"), + Path: c.Query("path"), + Page: readQueryInt(c, "p"), + PageSize: readQueryInt(c, "page_size"), + SortBy: c.Query("sort_by"), + SortOrder: c.Query("sort_order"), + } +} + +func readQueryInt(c *gin.Context, key string) int { + value, _ := strconv.Atoi(c.DefaultQuery(key, "0")) + return value +} diff --git a/openflare_server/model/node_access_log.go b/openflare_server/model/node_access_log.go index e5355092..a52ffac6 100644 --- a/openflare_server/model/node_access_log.go +++ b/openflare_server/model/node_access_log.go @@ -1,16 +1,22 @@ package model -import "time" +import ( + "fmt" + "strings" + "time" + + "gorm.io/gorm" +) type NodeAccessLog struct { ID uint `json:"id" gorm:"primaryKey"` - NodeID string `json:"node_id" gorm:"index;size:64;not null"` - LoggedAt time.Time `json:"logged_at" gorm:"index"` - RemoteAddr string `json:"remote_addr" gorm:"size:128"` + NodeID string `json:"node_id" gorm:"index:idx_node_access_logs_node_logged_at,priority:1;size:64;not null"` + LoggedAt time.Time `json:"logged_at" gorm:"index:idx_node_access_logs_logged_at;index:idx_node_access_logs_node_logged_at,priority:2"` + RemoteAddr string `json:"remote_addr" gorm:"index:idx_node_access_logs_remote_addr;size:128"` Region string `json:"region" gorm:"size:128"` - Host string `json:"host" gorm:"size:255"` + Host string `json:"host" gorm:"index:idx_node_access_logs_host;size:255"` Path string `json:"path" gorm:"size:2048"` - StatusCode int `json:"status_code"` + StatusCode int `json:"status_code" gorm:"index:idx_node_access_logs_status_code"` RawJSON string `json:"raw_json" gorm:"type:text"` CreatedAt time.Time `json:"created_at"` } @@ -20,39 +26,91 @@ type NodeAccessLogRegionCount struct { Count int64 `json:"count"` } -func ListNodeAccessLogs(nodeID string, since time.Time, offset int, limit int) (logs []*NodeAccessLog, err error) { - query := DB.Order("logged_at desc, id desc") - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("logged_at >= ?", since) - } - if offset > 0 { - query = query.Offset(offset) - } - if limit > 0 { - query = query.Limit(limit) - } - err = query.Find(&logs).Error +type NodeAccessLogQuery struct { + NodeID string + RemoteAddr string + Host string + Path string + Since time.Time + Page int + PageSize int + SortBy string + SortOrder string +} + +type NodeAccessLogBucketQuery struct { + NodeID string + RemoteAddr string + Host string + Path string + Since time.Time + Page int + PageSize int + SortBy string + SortOrder string + FoldMinutes int +} + +type NodeAccessLogBucketRow struct { + BucketEpoch int64 `json:"bucket_epoch"` + RequestCount int64 `json:"request_count"` + UniqueIPCount int64 `json:"unique_ip_count"` + UniqueHostCount int64 `json:"unique_host_count"` + SuccessCount int64 `json:"success_count"` + ClientErrorCount int64 `json:"client_error_count"` + ServerErrorCount int64 `json:"server_error_count"` +} + +type NodeAccessLogIPSummaryQuery struct { + NodeID string + RemoteAddr string + Host string + Since time.Time + Page int + PageSize int + SortBy string + SortOrder string +} + +type NodeAccessLogIPSummaryRow struct { + RemoteAddr string `json:"remote_addr"` + TotalRequests int64 `json:"total_requests"` + RecentRequests int64 `json:"recent_requests"` + LastSeenEpoch int64 `json:"last_seen_epoch"` +} + +type NodeAccessLogIPTrendQuery struct { + NodeID string + RemoteAddr string + Host string + Since time.Time + BucketMinutes int +} + +type NodeAccessLogTrendPointRow struct { + BucketEpoch int64 `json:"bucket_epoch"` + RequestCount int64 `json:"request_count"` +} + +func ListNodeAccessLogs(query NodeAccessLogQuery) (logs []*NodeAccessLog, err error) { + offset := query.Page * query.PageSize + db := buildNodeAccessLogQuery(DB, query). + Order(buildNodeAccessLogSortClause(query.SortBy, query.SortOrder)). + Limit(query.PageSize). + Offset(offset) + err = db.Find(&logs).Error return logs, err } -func CountNodeAccessLogs(nodeID string, since time.Time) (totalRecords int64, totalIPs int64, err error) { - query := DB.Model(&NodeAccessLog{}) - if nodeID != "" { - query = query.Where("node_id = ?", nodeID) - } - if !since.IsZero() { - query = query.Where("logged_at >= ?", since) - } - if err = query.Count(&totalRecords).Error; err != nil { +func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) { + base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query) + if err = base.Count(&totalRecords).Error; err != nil { return 0, 0, err } - if err = query. + distinctQuery := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query). Where("remote_addr <> ''"). - Distinct("remote_addr"). - Count(&totalIPs).Error; err != nil { + Distinct("remote_addr") + if err = distinctQuery.Count(&totalIPs).Error; err != nil { return 0, 0, err } return totalRecords, totalIPs, nil @@ -75,3 +133,203 @@ func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (i err = query.Scan(&items).Error return items, err } + +func ListNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (items []*NodeAccessLogBucketRow, err error) { + offset := query.Page * query.PageSize + bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes) + base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{ + NodeID: query.NodeID, + RemoteAddr: query.RemoteAddr, + Host: query.Host, + Path: query.Path, + Since: query.Since, + }) + err = base.Select(fmt.Sprintf( + "%s as bucket_epoch, count(*) as request_count, count(distinct remote_addr) as unique_ip_count, count(distinct host) as unique_host_count, sum(case when status_code < 400 then 1 else 0 end) as success_count, sum(case when status_code >= 400 and status_code < 500 then 1 else 0 end) as client_error_count, sum(case when status_code >= 500 then 1 else 0 end) as server_error_count", + bucketExpr, + )). + Group(bucketExpr). + Order(buildNodeAccessLogBucketSortClause(query.SortBy, query.SortOrder)). + Limit(query.PageSize). + Offset(offset). + Scan(&items).Error + return items, err +} + +func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err error) { + bucketExpr := accessLogBucketEpochExpr(query.FoldMinutes) + base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{ + NodeID: query.NodeID, + RemoteAddr: query.RemoteAddr, + Host: query.Host, + Path: query.Path, + Since: query.Since, + }) + rows := []struct { + BucketEpoch int64 `gorm:"column:bucket_epoch"` + }{} + err = base.Select(fmt.Sprintf("%s as bucket_epoch", bucketExpr)). + Group(bucketExpr). + Scan(&rows).Error + if err != nil { + return 0, err + } + return int64(len(rows)), nil +} + +func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) { + offset := query.Page * query.PageSize + base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{ + NodeID: query.NodeID, + RemoteAddr: query.RemoteAddr, + Host: query.Host, + Since: query.Since, + }).Where("remote_addr <> ''") + lastSeenExpr := accessLogEpochExpr("max(logged_at)") + err = base.Select( + "remote_addr as remote_addr, count(*) as total_requests, sum(case when logged_at >= ? then 1 else 0 end) as recent_requests, "+lastSeenExpr+" as last_seen_epoch", + recentSince, + ). + Group("remote_addr"). + Order(buildNodeAccessLogIPSummarySortClause(query.SortBy, query.SortOrder)). + Limit(query.PageSize). + Offset(offset). + Scan(&items).Error + return items, err +} + +func CountNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery) (total int64, err error) { + base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{ + NodeID: query.NodeID, + RemoteAddr: query.RemoteAddr, + Host: query.Host, + Since: query.Since, + }).Where("remote_addr <> ''") + rows := []struct { + RemoteAddr string `gorm:"column:remote_addr"` + }{} + err = base.Select("remote_addr"). + Group("remote_addr"). + Scan(&rows).Error + if err != nil { + return 0, err + } + return int64(len(rows)), nil +} + +func ListNodeAccessLogIPTrend(query NodeAccessLogIPTrendQuery) (items []*NodeAccessLogTrendPointRow, err error) { + bucketExpr := accessLogBucketEpochExpr(query.BucketMinutes) + base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), NodeAccessLogQuery{ + NodeID: query.NodeID, + RemoteAddr: query.RemoteAddr, + Host: query.Host, + Since: query.Since, + }).Where("remote_addr = ?", strings.TrimSpace(query.RemoteAddr)) + err = base.Select(fmt.Sprintf("%s as bucket_epoch, count(*) as request_count", bucketExpr)). + Group(bucketExpr). + Order("bucket_epoch asc"). + Scan(&items).Error + return items, err +} + +func DeleteNodeAccessLogsBefore(before time.Time) (deleted int64, err error) { + result := DB.Where("logged_at < ?", before).Delete(&NodeAccessLog{}) + return result.RowsAffected, result.Error +} + +func buildNodeAccessLogQuery(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB { + if db == nil { + db = DB.Model(&NodeAccessLog{}) + } + if db.Statement == nil || db.Statement.Model == nil { + db = db.Model(&NodeAccessLog{}) + } + if trimmed := strings.TrimSpace(query.NodeID); trimmed != "" { + db = db.Where("node_id LIKE ?", "%"+trimmed+"%") + } + if trimmed := strings.TrimSpace(query.RemoteAddr); trimmed != "" { + db = db.Where("remote_addr LIKE ?", "%"+trimmed+"%") + } + if trimmed := strings.TrimSpace(query.Host); trimmed != "" { + db = db.Where("host LIKE ?", "%"+trimmed+"%") + } + if trimmed := strings.TrimSpace(query.Path); trimmed != "" { + db = db.Where("path LIKE ?", "%"+trimmed+"%") + } + if !query.Since.IsZero() { + db = db.Where("logged_at >= ?", query.Since) + } + return db +} + +func buildNodeAccessLogSortClause(sortBy string, sortOrder string) string { + column := "logged_at" + switch strings.TrimSpace(sortBy) { + case "status_code": + column = "status_code" + case "remote_addr": + column = "remote_addr" + case "host": + column = "host" + case "path": + column = "path" + } + order := normalizeSortOrder(sortOrder) + if column == "logged_at" { + return fmt.Sprintf("%s %s, id %s", column, order, order) + } + return fmt.Sprintf("%s %s, logged_at desc, id desc", column, order) +} + +func buildNodeAccessLogBucketSortClause(sortBy string, sortOrder string) string { + order := normalizeSortOrder(sortOrder) + switch strings.TrimSpace(sortBy) { + case "request_count": + return fmt.Sprintf("request_count %s, bucket_epoch desc", order) + default: + return fmt.Sprintf("bucket_epoch %s", order) + } +} + +func buildNodeAccessLogIPSummarySortClause(sortBy string, sortOrder string) string { + order := normalizeSortOrder(sortOrder) + switch strings.TrimSpace(sortBy) { + case "recent_requests": + return fmt.Sprintf("recent_requests %s, last_seen_epoch desc, remote_addr asc", order) + case "last_seen_at": + return fmt.Sprintf("last_seen_epoch %s, total_requests desc, remote_addr asc", order) + case "remote_addr": + return fmt.Sprintf("remote_addr %s", order) + default: + return fmt.Sprintf("total_requests %s, last_seen_epoch desc, remote_addr asc", order) + } +} + +func accessLogBucketEpochExpr(bucketMinutes int) string { + bucketSeconds := bucketMinutes * 60 + if bucketSeconds <= 0 { + bucketSeconds = 180 + } + switch DB.Dialector.Name() { + case "postgres": + return fmt.Sprintf("CAST(floor(extract(epoch from logged_at) / %d) * %d AS BIGINT)", bucketSeconds, bucketSeconds) + default: + return fmt.Sprintf("CAST((strftime('%%s', logged_at) / %d) * %d AS INTEGER)", bucketSeconds, bucketSeconds) + } +} + +func accessLogEpochExpr(expression string) string { + switch DB.Dialector.Name() { + case "postgres": + return fmt.Sprintf("CAST(extract(epoch from %s) AS BIGINT)", expression) + default: + return fmt.Sprintf("CAST(strftime('%%s', %s) AS INTEGER)", expression) + } +} + +func normalizeSortOrder(sortOrder string) string { + if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") { + return "asc" + } + return "desc" +} diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 0b232cf6..7424b9c0 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -139,6 +139,10 @@ func SetApiRouter(router *gin.Engine) { accessLogRoute.Use(middleware.AdminAuth()) { accessLogRoute.GET("/", controller.GetAccessLogs) + accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs) + accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries) + accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend) + accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs) } agentRoute := apiRouter.Group("/agent") { diff --git a/openflare_server/service/access_log.go b/openflare_server/service/access_log.go index 696c9674..45808ab2 100644 --- a/openflare_server/service/access_log.go +++ b/openflare_server/service/access_log.go @@ -1,16 +1,36 @@ package service import ( + "errors" "openflare/model" "strings" "time" ) const ( - defaultAccessLogPageSize = 20 - maxAccessLogPageSize = 200 + defaultAccessLogPageSize = 20 + maxAccessLogPageSize = 200 + defaultAccessLogSortBy = "logged_at" + defaultAccessLogSortOrder = "desc" + defaultAccessLogFoldMinute = 3 + defaultIPTrendHours = 24 + defaultIPTrendBucketMinute = 30 + maxIPTrendHours = 168 + nodeAccessLogRetentionDays = 90 ) +type AccessLogQuery struct { + NodeID string `json:"node_id"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + Path string `json:"path"` + Page int `json:"page"` + PageSize int `json:"page_size"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` + FoldMinutes int `json:"fold_minutes"` +} + type AccessLogView struct { ID uint `json:"id"` NodeID string `json:"node_id"` @@ -32,52 +52,99 @@ type AccessLogList struct { TotalIP int64 `json:"total_ip"` } -func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, error) { - normalizedPage := normalizeAccessLogPage(page) - normalizedPageSize := normalizeAccessLogPageSize(pageSize) - offset := normalizedPage * normalizedPageSize - trimmedNodeID := strings.TrimSpace(nodeID) - since := time.Now().Add(-nodeAccessLogRetentionWindow) - logs, err := model.ListNodeAccessLogs( - trimmedNodeID, - since, - offset, - normalizedPageSize+1, - ) +type FoldedAccessLogView struct { + BucketStartedAt time.Time `json:"bucket_started_at"` + RequestCount int64 `json:"request_count"` + UniqueIPCount int64 `json:"unique_ip_count"` + UniqueHostCount int64 `json:"unique_host_count"` + SuccessCount int64 `json:"success_count"` + ClientErrorCount int64 `json:"client_error_count"` + ServerErrorCount int64 `json:"server_error_count"` +} + +type FoldedAccessLogList struct { + Items []FoldedAccessLogView `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` + TotalBucket int64 `json:"total_bucket"` + TotalRecord int64 `json:"total_record"` + TotalIP int64 `json:"total_ip"` + FoldMinutes int `json:"fold_minutes"` +} + +type AccessLogIPSummaryQuery struct { + NodeID string `json:"node_id"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + Page int `json:"page"` + PageSize int `json:"page_size"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` +} + +type AccessLogIPSummaryView struct { + RemoteAddr string `json:"remote_addr"` + TotalRequests int64 `json:"total_requests"` + RecentRequests int64 `json:"recent_requests"` + LastSeenAt time.Time `json:"last_seen_at"` +} + +type AccessLogIPSummaryList struct { + Items []AccessLogIPSummaryView `json:"items"` + Page int `json:"page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` + TotalIP int64 `json:"total_ip"` + SortBy string `json:"sort_by"` + SortOrder string `json:"sort_order"` +} + +type AccessLogIPTrendQuery struct { + NodeID string `json:"node_id"` + RemoteAddr string `json:"remote_addr"` + Host string `json:"host"` + Hours int `json:"hours"` + BucketMinutes int `json:"bucket_minutes"` +} + +type AccessLogIPTrendPoint struct { + BucketStartedAt time.Time `json:"bucket_started_at"` + RequestCount int64 `json:"request_count"` +} + +type AccessLogIPTrendView struct { + RemoteAddr string `json:"remote_addr"` + Hours int `json:"hours"` + BucketMinutes int `json:"bucket_minutes"` + Points []AccessLogIPTrendPoint `json:"points"` +} + +type AccessLogCleanupInput struct { + RetentionDays int `json:"retention_days"` +} + +type AccessLogCleanupResult struct { + RetentionDays int `json:"retention_days"` + DeletedCount int64 `json:"deleted_count"` + Cutoff time.Time `json:"cutoff"` +} + +func ListAccessLogs(input AccessLogQuery) (*AccessLogList, error) { + normalized := normalizeAccessLogQuery(input) + modelQuery := buildModelAccessLogQuery(normalized) + logs, err := model.ListNodeAccessLogs(modelQuery) if err != nil { return nil, err } - totalRecords, totalIPs, err := model.CountNodeAccessLogs(trimmedNodeID, since) + totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery) if err != nil { return nil, err } - nodeIDs := make([]string, 0, len(logs)) - seenNodeIDs := make(map[string]struct{}, len(logs)) - for _, item := range logs { - if item == nil { - continue - } - if _, exists := seenNodeIDs[item.NodeID]; exists { - continue - } - seenNodeIDs[item.NodeID] = struct{}{} - nodeIDs = append(nodeIDs, item.NodeID) - } - nodes, err := model.ListNodesByNodeIDs(nodeIDs) + nodeNames, err := listNodeNameMap(logs) if err != nil { return nil, err } - nodeNames := make(map[string]string, len(nodes)) - for _, node := range nodes { - if node == nil { - continue - } - nodeNames[node.NodeID] = node.Name - } - hasMore := len(logs) > normalizedPageSize - if hasMore { - logs = logs[:normalizedPageSize] - } views := make([]AccessLogView, 0, len(logs)) for _, item := range logs { if item == nil { @@ -97,14 +164,270 @@ func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, erro } return &AccessLogList{ Items: views, - Page: normalizedPage, - PageSize: normalizedPageSize, - HasMore: hasMore, + Page: normalized.Page, + PageSize: normalized.PageSize, + HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords, TotalRecord: totalRecords, TotalIP: totalIPs, }, nil } +func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) { + normalized := normalizeAccessLogQuery(input) + foldMinutes, err := normalizeFoldMinutes(normalized.FoldMinutes) + if err != nil { + return nil, err + } + modelQuery := buildModelAccessLogQuery(normalized) + bucketQuery := model.NodeAccessLogBucketQuery{ + NodeID: modelQuery.NodeID, + RemoteAddr: modelQuery.RemoteAddr, + Host: modelQuery.Host, + Path: modelQuery.Path, + Since: modelQuery.Since, + Page: normalized.Page, + PageSize: normalized.PageSize, + SortBy: normalizeFoldSortBy(normalized.SortBy), + SortOrder: normalized.SortOrder, + FoldMinutes: foldMinutes, + } + items, err := model.ListNodeAccessLogBuckets(bucketQuery) + if err != nil { + return nil, err + } + totalBuckets, err := model.CountNodeAccessLogBuckets(bucketQuery) + if err != nil { + return nil, err + } + totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery) + if err != nil { + return nil, err + } + views := make([]FoldedAccessLogView, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + views = append(views, FoldedAccessLogView{ + BucketStartedAt: time.Unix(item.BucketEpoch, 0).UTC(), + RequestCount: item.RequestCount, + UniqueIPCount: item.UniqueIPCount, + UniqueHostCount: item.UniqueHostCount, + SuccessCount: item.SuccessCount, + ClientErrorCount: item.ClientErrorCount, + ServerErrorCount: item.ServerErrorCount, + }) + } + return &FoldedAccessLogList{ + Items: views, + Page: normalized.Page, + PageSize: normalized.PageSize, + HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalBuckets, + TotalBucket: totalBuckets, + TotalRecord: totalRecords, + TotalIP: totalIPs, + FoldMinutes: foldMinutes, + }, nil +} + +func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) { + normalized := normalizeAccessLogIPSummaryQuery(input) + since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow) + recentSince := time.Now().UTC().Add(-3 * time.Hour) + query := model.NodeAccessLogIPSummaryQuery{ + NodeID: strings.TrimSpace(normalized.NodeID), + RemoteAddr: strings.TrimSpace(normalized.RemoteAddr), + Host: strings.TrimSpace(normalized.Host), + Since: since, + Page: normalized.Page, + PageSize: normalized.PageSize, + SortBy: normalized.SortBy, + SortOrder: normalized.SortOrder, + } + items, err := model.ListNodeAccessLogIPSummaries(query, recentSince) + if err != nil { + return nil, err + } + totalIP, err := model.CountNodeAccessLogIPSummaries(query) + if err != nil { + return nil, err + } + views := make([]AccessLogIPSummaryView, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + views = append(views, AccessLogIPSummaryView{ + RemoteAddr: item.RemoteAddr, + TotalRequests: item.TotalRequests, + RecentRequests: item.RecentRequests, + LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(), + }) + } + return &AccessLogIPSummaryList{ + Items: views, + Page: normalized.Page, + PageSize: normalized.PageSize, + HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP, + TotalIP: totalIP, + SortBy: normalized.SortBy, + SortOrder: normalized.SortOrder, + }, nil +} + +func GetAccessLogIPTrend(input AccessLogIPTrendQuery) (*AccessLogIPTrendView, error) { + normalized, err := normalizeAccessLogIPTrendQuery(input) + if err != nil { + return nil, err + } + points, err := model.ListNodeAccessLogIPTrend(model.NodeAccessLogIPTrendQuery{ + NodeID: strings.TrimSpace(normalized.NodeID), + RemoteAddr: strings.TrimSpace(normalized.RemoteAddr), + Host: strings.TrimSpace(normalized.Host), + Since: time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour), + BucketMinutes: normalized.BucketMinutes, + }) + if err != nil { + return nil, err + } + pointMap := make(map[int64]int64, len(points)) + for _, item := range points { + if item == nil { + continue + } + pointMap[item.BucketEpoch] = item.RequestCount + } + bucketDuration := time.Duration(normalized.BucketMinutes) * time.Minute + start := time.Now().UTC().Add(-time.Duration(normalized.Hours) * time.Hour).Truncate(bucketDuration) + end := time.Now().UTC().Truncate(bucketDuration) + views := make([]AccessLogIPTrendPoint, 0, int(end.Sub(start)/bucketDuration)+1) + for cursor := start; !cursor.After(end); cursor = cursor.Add(bucketDuration) { + views = append(views, AccessLogIPTrendPoint{ + BucketStartedAt: cursor, + RequestCount: pointMap[cursor.Unix()], + }) + } + return &AccessLogIPTrendView{ + RemoteAddr: normalized.RemoteAddr, + Hours: normalized.Hours, + BucketMinutes: normalized.BucketMinutes, + Points: views, + }, nil +} + +func CleanupAccessLogs(input AccessLogCleanupInput) (*AccessLogCleanupResult, error) { + if input.RetentionDays <= 0 || input.RetentionDays > nodeAccessLogRetentionDays { + return nil, errors.New("retention_days 必须在 1 到 90 之间") + } + cutoff := time.Now().UTC().Add(-time.Duration(input.RetentionDays) * 24 * time.Hour) + deleted, err := model.DeleteNodeAccessLogsBefore(cutoff) + if err != nil { + return nil, err + } + return &AccessLogCleanupResult{ + RetentionDays: input.RetentionDays, + DeletedCount: deleted, + Cutoff: cutoff, + }, nil +} + +func buildModelAccessLogQuery(input AccessLogQuery) model.NodeAccessLogQuery { + return model.NodeAccessLogQuery{ + NodeID: strings.TrimSpace(input.NodeID), + RemoteAddr: strings.TrimSpace(input.RemoteAddr), + Host: strings.TrimSpace(input.Host), + Path: strings.TrimSpace(input.Path), + Since: time.Now().UTC().Add(-nodeAccessLogRetentionWindow), + Page: input.Page, + PageSize: input.PageSize, + SortBy: input.SortBy, + SortOrder: input.SortOrder, + } +} + +func listNodeNameMap(logs []*model.NodeAccessLog) (map[string]string, error) { + nodeIDs := make([]string, 0, len(logs)) + seen := make(map[string]struct{}, len(logs)) + for _, item := range logs { + if item == nil || item.NodeID == "" { + continue + } + if _, exists := seen[item.NodeID]; exists { + continue + } + seen[item.NodeID] = struct{}{} + nodeIDs = append(nodeIDs, item.NodeID) + } + nodes, err := model.ListNodesByNodeIDs(nodeIDs) + if err != nil { + return nil, err + } + result := make(map[string]string, len(nodes)) + for _, node := range nodes { + if node == nil { + continue + } + result[node.NodeID] = node.Name + } + return result, nil +} + +func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery { + return AccessLogQuery{ + NodeID: strings.TrimSpace(input.NodeID), + RemoteAddr: strings.TrimSpace(input.RemoteAddr), + Host: strings.TrimSpace(input.Host), + Path: strings.TrimSpace(input.Path), + Page: normalizeAccessLogPage(input.Page), + PageSize: normalizeAccessLogPageSize(input.PageSize), + SortBy: normalizeAccessLogSortBy(input.SortBy), + SortOrder: normalizeAccessLogSortOrder(input.SortOrder), + FoldMinutes: input.FoldMinutes, + } +} + +func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIPSummaryQuery { + return AccessLogIPSummaryQuery{ + NodeID: strings.TrimSpace(input.NodeID), + RemoteAddr: strings.TrimSpace(input.RemoteAddr), + Host: strings.TrimSpace(input.Host), + Page: normalizeAccessLogPage(input.Page), + PageSize: normalizeAccessLogPageSize(input.PageSize), + SortBy: normalizeIPSummarySortBy(input.SortBy), + SortOrder: normalizeAccessLogSortOrder(input.SortOrder), + } +} + +func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) { + remoteAddr := strings.TrimSpace(input.RemoteAddr) + if remoteAddr == "" { + return AccessLogIPTrendQuery{}, errors.New("remote_addr 不能为空") + } + hours := input.Hours + if hours <= 0 { + hours = defaultIPTrendHours + } + if hours > maxIPTrendHours { + hours = maxIPTrendHours + } + bucketMinutes := input.BucketMinutes + if bucketMinutes <= 0 { + bucketMinutes = defaultIPTrendBucketMinute + } + switch bucketMinutes { + case 5, 10, 15, 30, 60: + default: + return AccessLogIPTrendQuery{}, errors.New("bucket_minutes 仅支持 5、10、15、30、60") + } + return AccessLogIPTrendQuery{ + NodeID: strings.TrimSpace(input.NodeID), + RemoteAddr: remoteAddr, + Host: strings.TrimSpace(input.Host), + Hours: hours, + BucketMinutes: bucketMinutes, + }, nil +} + func normalizeAccessLogPage(page int) int { if page < 0 { return 0 @@ -121,3 +444,49 @@ func normalizeAccessLogPageSize(pageSize int) int { } return pageSize } + +func normalizeAccessLogSortBy(sortBy string) string { + switch strings.TrimSpace(sortBy) { + case "status_code", "remote_addr", "host", "path": + return strings.TrimSpace(sortBy) + default: + return defaultAccessLogSortBy + } +} + +func normalizeAccessLogSortOrder(sortOrder string) string { + if strings.EqualFold(strings.TrimSpace(sortOrder), "asc") { + return "asc" + } + return defaultAccessLogSortOrder +} + +func normalizeFoldSortBy(sortBy string) string { + switch strings.TrimSpace(sortBy) { + case "request_count": + return "request_count" + default: + return "bucket_started_at" + } +} + +func normalizeIPSummarySortBy(sortBy string) string { + switch strings.TrimSpace(sortBy) { + case "recent_requests", "last_seen_at", "remote_addr": + return strings.TrimSpace(sortBy) + default: + return "total_requests" + } +} + +func normalizeFoldMinutes(value int) (int, error) { + if value <= 0 { + return defaultAccessLogFoldMinute, nil + } + switch value { + case 3, 5: + return value, nil + default: + return 0, errors.New("fold_minutes 仅支持 3 或 5") + } +} diff --git a/openflare_server/service/access_log_test.go b/openflare_server/service/access_log_test.go index 920f860b..7dc8d749 100644 --- a/openflare_server/service/access_log_test.go +++ b/openflare_server/service/access_log_test.go @@ -64,7 +64,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) { t.Fatalf("failed to seed access logs: %v", err) } - result, err := ListAccessLogs("", 0, 2) + result, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 2}) if err != nil { t.Fatalf("ListAccessLogs failed: %v", err) } @@ -84,7 +84,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) { t.Fatal("expected has_more to be true") } - filtered, err := ListAccessLogs("node-a", 0, 50) + filtered, err := ListAccessLogs(AccessLogQuery{NodeID: "node-a", Page: 0, PageSize: 50}) if err != nil { t.Fatalf("ListAccessLogs filtered failed: %v", err) } @@ -125,7 +125,7 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) { t.Fatalf("failed to seed access logs: %v", err) } - result, err := ListAccessLogs("", 0, 0) + result, err := ListAccessLogs(AccessLogQuery{}) if err != nil { t.Fatalf("ListAccessLogs failed: %v", err) } @@ -139,3 +139,122 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) { t.Fatal("expected has_more to be true") } } + +func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) { + setupServiceTestDB(t) + + now := time.Now().UTC() + if err := model.DB.Create(&model.Node{ + NodeID: "node-folded", + Name: "edge-folded", + }).Error; err != nil { + t.Fatalf("failed to seed node: %v", err) + } + logs := []*model.NodeAccessLog{ + { + NodeID: "node-folded", + LoggedAt: now.Add(-4 * time.Minute), + RemoteAddr: "203.0.113.1", + Host: "alpha.example.com", + Path: "/first", + StatusCode: 200, + }, + { + NodeID: "node-folded", + LoggedAt: now.Add(-3 * time.Minute), + RemoteAddr: "203.0.113.1", + Host: "alpha.example.com", + Path: "/second", + StatusCode: 502, + }, + { + NodeID: "node-folded", + LoggedAt: now.Add(-2 * time.Minute), + RemoteAddr: "203.0.113.2", + Host: "beta.example.com", + Path: "/third", + StatusCode: 404, + }, + } + if err := model.DB.Create(&logs).Error; err != nil { + t.Fatalf("failed to seed access logs: %v", err) + } + + folded, err := ListFoldedAccessLogs(AccessLogQuery{ + NodeID: "node-folded", + Page: 0, + PageSize: 10, + FoldMinutes: 5, + }) + if err != nil { + t.Fatalf("ListFoldedAccessLogs failed: %v", err) + } + if len(folded.Items) != 2 { + t.Fatalf("expected two folded buckets, got %+v", folded.Items) + } + if folded.TotalRecord != 3 || folded.TotalBucket != 2 { + t.Fatalf("unexpected folded totals: %+v", folded) + } + if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 { + t.Fatalf("unexpected folded request count sum: %+v", folded.Items) + } + + ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{ + NodeID: "node-folded", + Page: 0, + PageSize: 10, + SortBy: "total_requests", + SortOrder: "desc", + }) + if err != nil { + t.Fatalf("ListAccessLogIPSummaries failed: %v", err) + } + if len(ipSummaries.Items) != 2 { + t.Fatalf("expected two ip summary rows, got %+v", ipSummaries.Items) + } + if ipSummaries.Items[0].RemoteAddr != "203.0.113.1" || ipSummaries.Items[0].TotalRequests != 2 { + t.Fatalf("unexpected top ip summary row: %+v", ipSummaries.Items[0]) + } +} + +func TestCleanupAccessLogsDeletesExpiredData(t *testing.T) { + setupServiceTestDB(t) + + now := time.Now().UTC() + if err := model.DB.Create([]*model.NodeAccessLog{ + { + NodeID: "node-cleanup", + LoggedAt: now.Add(-10 * 24 * time.Hour), + RemoteAddr: "203.0.113.9", + Host: "cleanup.example.com", + Path: "/old", + StatusCode: 200, + }, + { + NodeID: "node-cleanup", + LoggedAt: now.Add(-2 * 24 * time.Hour), + RemoteAddr: "203.0.113.10", + Host: "cleanup.example.com", + Path: "/recent", + StatusCode: 200, + }, + }).Error; err != nil { + t.Fatalf("failed to seed cleanup logs: %v", err) + } + + result, err := CleanupAccessLogs(AccessLogCleanupInput{RetentionDays: 7}) + if err != nil { + t.Fatalf("CleanupAccessLogs failed: %v", err) + } + if result.DeletedCount != 1 { + t.Fatalf("expected 1 deleted record, got %+v", result) + } + + remaining, err := ListAccessLogs(AccessLogQuery{Page: 0, PageSize: 10, NodeID: "node-cleanup"}) + if err != nil { + t.Fatalf("ListAccessLogs failed after cleanup: %v", err) + } + if len(remaining.Items) != 1 || remaining.Items[0].Path != "/recent" { + t.Fatalf("unexpected remaining logs after cleanup: %+v", remaining.Items) + } +} diff --git a/openflare_server/service/node_update_test.go b/openflare_server/service/node_update_test.go index df7f855b..d0bae2e4 100644 --- a/openflare_server/service/node_update_test.go +++ b/openflare_server/service/node_update_test.go @@ -708,7 +708,12 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) { t.Fatalf("unexpected request reports: %+v", reports) } - accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10) + accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{ + NodeID: node.NodeID, + Since: time.Time{}, + Page: 0, + PageSize: 10, + }) if err != nil { t.Fatalf("expected node access logs query to succeed: %v", err) } @@ -822,7 +827,12 @@ func TestHeartbeatNodePersistsBufferedObservabilityPayload(t *testing.T) { t.Fatalf("expected current and buffered reports, got %+v", reports) } - accessLogs, err := model.ListNodeAccessLogs(node.NodeID, time.Time{}, 0, 10) + accessLogs, err := model.ListNodeAccessLogs(model.NodeAccessLogQuery{ + NodeID: node.NodeID, + Since: time.Time{}, + Page: 0, + PageSize: 10, + }) if err != nil { t.Fatalf("expected node access logs query to succeed: %v", err) } @@ -930,7 +940,11 @@ func TestListAccessLogsUsesPagination(t *testing.T) { t.Fatalf("failed to seed access logs: %v", err) } - pageOne, err := ListAccessLogs(node.NodeID, 0, 2) + pageOne, err := ListAccessLogs(AccessLogQuery{ + NodeID: node.NodeID, + Page: 0, + PageSize: 2, + }) if err != nil { t.Fatalf("ListAccessLogs page 1 failed: %v", err) } @@ -944,7 +958,11 @@ func TestListAccessLogsUsesPagination(t *testing.T) { t.Fatalf("expected paged access log region to be returned, got %+v", pageOne.Items[0]) } - pageTwo, err := ListAccessLogs(node.NodeID, 1, 2) + pageTwo, err := ListAccessLogs(AccessLogQuery{ + NodeID: node.NodeID, + Page: 1, + PageSize: 2, + }) if err != nil { t.Fatalf("ListAccessLogs page 2 failed: %v", err) } diff --git a/openflare_server/service/observability.go b/openflare_server/service/observability.go index 7aa28f33..211f268d 100644 --- a/openflare_server/service/observability.go +++ b/openflare_server/service/observability.go @@ -17,7 +17,7 @@ const ( NodeHealthSeverityInfo = "info" NodeHealthSeverityWarning = "warning" NodeHealthSeverityCritical = "critical" - nodeAccessLogRetentionWindow = 24 * time.Hour + nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour ) type AgentNodeSystemProfile struct { diff --git a/openflare_server/web/features/access-logs/api/access-logs.ts b/openflare_server/web/features/access-logs/api/access-logs.ts index 676e53f1..a3b0211b 100644 --- a/openflare_server/web/features/access-logs/api/access-logs.ts +++ b/openflare_server/web/features/access-logs/api/access-logs.ts @@ -1,15 +1,56 @@ import { apiRequest } from '@/lib/api/client'; -import type { AccessLogList } from '@/features/access-logs/types'; +import type { + AccessLogCleanupPayload, + AccessLogCleanupResult, + AccessLogFilters, + AccessLogIPSummaryFilters, + AccessLogIPSummaryList, + AccessLogIPTrend, + AccessLogIPTrendFilters, + AccessLogList, + FoldedAccessLogFilters, + FoldedAccessLogList, +} from '@/features/access-logs/types'; -export function getAccessLogs(page: number, nodeId?: string, pageSize = 20) { - const normalizedNodeId = nodeId?.trim(); - const searchParams = new URLSearchParams({ - p: String(Math.max(page, 0)), - page_size: String(pageSize), +function buildSearchParams(filters: object) { + const searchParams = new URLSearchParams(); + Object.entries(filters as Record).forEach(([key, value]) => { + if (value === undefined || value === null || value === '') { + return; + } + searchParams.set(key, String(value)); + }); + return searchParams.toString(); +} + +export function getAccessLogs(filters: AccessLogFilters) { + const query = buildSearchParams(filters); + return apiRequest(`/access-logs/${query ? `?${query}` : ''}`); +} + +export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) { + const query = buildSearchParams(filters); + return apiRequest(`/access-logs/folds${query ? `?${query}` : ''}`); +} + +export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) { + const query = buildSearchParams(filters); + return apiRequest( + `/access-logs/ip-summary${query ? `?${query}` : ''}`, + ); +} + +export function getAccessLogIPTrend(filters: AccessLogIPTrendFilters) { + const query = buildSearchParams(filters); + return apiRequest( + `/access-logs/ip-summary/trend${query ? `?${query}` : ''}`, + ); +} + +export function cleanupAccessLogs(payload: AccessLogCleanupPayload) { + return apiRequest('/access-logs/cleanup', { + method: 'POST', + body: JSON.stringify(payload), }); - if (normalizedNodeId) { - searchParams.set('node_id', normalizedNodeId); - } - return apiRequest(`/access-logs/?${searchParams.toString()}`); } diff --git a/openflare_server/web/features/access-logs/components/access-logs-page.tsx b/openflare_server/web/features/access-logs/components/access-logs-page.tsx index f54a7851..1f567fc0 100644 --- a/openflare_server/web/features/access-logs/components/access-logs-page.tsx +++ b/openflare_server/web/features/access-logs/components/access-logs-page.tsx @@ -1,94 +1,290 @@ 'use client'; -import Link from 'next/link'; import { useMemo, useState } from 'react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query'; +import { TrendChart } from '@/components/data/trend-chart'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { LoadingState } from '@/components/feedback/loading-state'; import { PageHeader } from '@/components/layout/page-header'; +import { AppModal } from '@/components/ui/app-modal'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; -import { getAccessLogs } from '@/features/access-logs/api/access-logs'; -import type { AccessLogItem } from '@/features/access-logs/types'; +import { + cleanupAccessLogs, + getAccessLogIPSummaries, + getAccessLogIPTrend, + getAccessLogs, + getFoldedAccessLogs, +} from '@/features/access-logs/api/access-logs'; +import type { + AccessLogCleanupPayload, + AccessLogIPSummaryItem, + AccessLogIPSummaryList, + AccessLogList, + FoldedAccessLogList, +} from '@/features/access-logs/types'; import { PrimaryButton, + ResourceField, ResourceInput, ResourceSelect, SecondaryButton, } from '@/features/shared/components/resource-primitives'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; +import { formatCompactNumber } from '@/lib/utils/metrics'; -const accessLogsQueryKey = (nodeId: string) => ['access-logs', nodeId] as const; -const defaultPageSize = 20; -const accessLogPageSizeOptions = [20, 50, 100, 200]; +type ActiveTab = 'detail' | 'ip'; + +type SearchDraft = { + nodeId: string; + remoteAddr: string; + host: string; + path: string; +}; + +type AppliedSearch = SearchDraft; + +const pageSizeOptions = [20, 50, 100, 200]; +const detailSortOptions = [ + { value: 'logged_at:desc', label: '时间从新到旧' }, + { value: 'logged_at:asc', label: '时间从旧到新' }, + { value: 'status_code:desc', label: '状态码从高到低' }, + { value: 'status_code:asc', label: '状态码从低到高' }, + { value: 'remote_addr:asc', label: 'IP 正序' }, + { value: 'remote_addr:desc', label: 'IP 倒序' }, + { value: 'host:asc', label: '域名正序' }, + { value: 'host:desc', label: '域名倒序' }, +]; +const foldedSortOptions = [ + { value: 'bucket_started_at:desc', label: '时间桶从新到旧' }, + { value: 'bucket_started_at:asc', label: '时间桶从旧到新' }, + { value: 'request_count:desc', label: '访问次数从高到低' }, + { value: 'request_count:asc', label: '访问次数从低到高' }, +]; +const ipSortOptions = [ + { value: 'total_requests:desc', label: '总访问次数从高到低' }, + { value: 'total_requests:asc', label: '总访问次数从低到高' }, + { value: 'recent_requests:desc', label: '3 小时访问次数从高到低' }, + { value: 'recent_requests:asc', label: '3 小时访问次数从低到高' }, + { value: 'last_seen_at:desc', label: '最后访问时间从新到旧' }, + { value: 'last_seen_at:asc', label: '最后访问时间从旧到新' }, +]; +const foldOptions = [ + { value: '0', label: '不折叠' }, + { value: '3', label: '按 3 分钟折叠' }, + { value: '5', label: '按 5 分钟折叠' }, +]; +const cleanupPresetOptions = [3, 7, 30]; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } -function buildSummary(totalRecord = 0, totalIP = 0) { +function parseSortValue(value: string) { + const [sortBy = 'logged_at', sortOrder = 'desc'] = value.split(':'); + return { + sortBy, + sortOrder: sortOrder === 'asc' ? 'asc' : 'desc', + } as const; +} + +function buildSummary(totalRecord = 0, totalIP = 0, activeTab: ActiveTab) { return [ - { label: '访问记录', value: totalRecord }, - { label: '来源 IP', value: totalIP }, + { label: '访问记录', value: formatCompactNumber(totalRecord) }, + { label: '来源 IP', value: formatCompactNumber(totalIP) }, + { label: '当前视图', value: activeTab === 'detail' ? '明细日志' : 'IP 维度' }, ]; } -function getStatusMeta(statusCode: number) { - if (statusCode >= 500) { - return { label: String(statusCode), variant: 'danger' as const }; - } - if (statusCode >= 400) { - return { label: String(statusCode), variant: 'warning' as const }; - } - return { label: String(statusCode), variant: 'success' as const }; +function buildTrendLabels(points: Array<{ bucket_started_at: string }>) { + return points.map((point) => { + const date = new Date(point.bucket_started_at); + if (Number.isNaN(date.getTime())) { + return '--'; + } + return `${String(date.getMonth() + 1).padStart(2, '0')}-${String( + date.getDate(), + ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String( + date.getMinutes(), + ).padStart(2, '0')}`; + }); } export function AccessLogsPage() { const queryClient = useQueryClient(); - const [nodeFilterInput, setNodeFilterInput] = useState(''); - const [nodeFilter, setNodeFilter] = useState(''); - const [page, setPage] = useState(0); - const [pageSize, setPageSize] = useState(defaultPageSize); + const [activeTab, setActiveTab] = useState('detail'); + const [draft, setDraft] = useState({ + nodeId: '', + remoteAddr: '', + host: '', + path: '', + }); + const [filters, setFilters] = useState({ + nodeId: '', + remoteAddr: '', + host: '', + path: '', + }); + const [detailPage, setDetailPage] = useState(0); + const [ipPage, setIPPage] = useState(0); + const [pageSize, setPageSize] = useState(20); + const [foldMinutes, setFoldMinutes] = useState<0 | 3 | 5>(0); + const [detailSort, setDetailSort] = useState('logged_at:desc'); + const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc'); + const [ipSort, setIPSort] = useState('total_requests:desc'); + const [selectedIP, setSelectedIP] = useState(null); + const [cleanupDays, setCleanupDays] = useState('7'); + const [customCleanupDays, setCustomCleanupDays] = useState('14'); + const [isCleanupModalOpen, setCleanupModalOpen] = useState(false); - const logsQuery = useQuery({ - queryKey: [...accessLogsQueryKey(nodeFilter), page, pageSize], - queryFn: () => getAccessLogs(page, nodeFilter, pageSize), - placeholderData: (previousData) => previousData, + const detailSortState = parseSortValue(detailSort); + const foldedSortState = parseSortValue(foldedSort); + const ipSortState = parseSortValue(ipSort); + + const detailQuery = useQuery({ + queryKey: [ + 'access-logs', + 'detail', + filters, + detailPage, + pageSize, + detailSort, + foldMinutes, + foldedSort, + ], + queryFn: () => { + if (foldMinutes > 0) { + return getFoldedAccessLogs({ + node_id: filters.nodeId || undefined, + remote_addr: filters.remoteAddr || undefined, + host: filters.host || undefined, + path: filters.path || undefined, + p: detailPage, + page_size: pageSize, + sort_by: foldedSortState.sortBy, + sort_order: foldedSortState.sortOrder, + fold_minutes: foldMinutes as 3 | 5, + }); + } + return getAccessLogs({ + node_id: filters.nodeId || undefined, + remote_addr: filters.remoteAddr || undefined, + host: filters.host || undefined, + path: filters.path || undefined, + p: detailPage, + page_size: pageSize, + sort_by: detailSortState.sortBy, + sort_order: detailSortState.sortOrder, + }); + }, + placeholderData: (previousData: AccessLogList | FoldedAccessLogList | undefined) => + previousData, }); - const logs = useMemo(() => logsQuery.data?.items ?? [], [logsQuery.data]); - const hasMore = logsQuery.data?.has_more ?? false; + const ipSummaryQuery = useQuery({ + queryKey: ['access-logs', 'ip-summary', filters, ipPage, pageSize, ipSort], + queryFn: () => + getAccessLogIPSummaries({ + node_id: filters.nodeId || undefined, + remote_addr: filters.remoteAddr || undefined, + host: filters.host || undefined, + p: ipPage, + page_size: pageSize, + sort_by: ipSortState.sortBy, + sort_order: ipSortState.sortOrder, + }), + placeholderData: (previousData: AccessLogIPSummaryList | undefined) => previousData, + }); + + const ipTrendQuery = useQuery({ + queryKey: ['access-logs', 'ip-trend', selectedIP?.remote_addr, filters.nodeId, filters.host], + queryFn: () => + getAccessLogIPTrend({ + node_id: filters.nodeId || undefined, + remote_addr: selectedIP?.remote_addr ?? '', + host: filters.host || undefined, + hours: 24, + bucket_minutes: 30, + }), + enabled: Boolean(selectedIP?.remote_addr), + }); + + const cleanupMutation = useMutation({ + mutationFn: (payload: AccessLogCleanupPayload) => cleanupAccessLogs(payload), + onSuccess: async () => { + setCleanupModalOpen(false); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }), + queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-summary'] }), + queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-trend'] }), + ]); + }, + }); + + const detailSummaryData = detailQuery.data as AccessLogList | FoldedAccessLogList | undefined; const summary = useMemo( () => buildSummary( - logsQuery.data?.total_record ?? 0, - logsQuery.data?.total_ip ?? 0, + detailSummaryData?.total_record ?? 0, + detailSummaryData?.total_ip ?? 0, + activeTab, ), - [logsQuery.data?.total_ip, logsQuery.data?.total_record], + [activeTab, detailSummaryData?.total_ip, detailSummaryData?.total_record], ); + const trendLabels = useMemo( + () => buildTrendLabels(ipTrendQuery.data?.points ?? []), + [ipTrendQuery.data?.points], + ); + const trendValues = useMemo( + () => (ipTrendQuery.data?.points ?? []).map((point) => point.request_count), + [ipTrendQuery.data?.points], + ); + + const handleSearch = () => { + setFilters({ + nodeId: draft.nodeId.trim(), + remoteAddr: draft.remoteAddr.trim(), + host: draft.host.trim(), + path: draft.path.trim(), + }); + setDetailPage(0); + setIPPage(0); + }; + + const handleReset = () => { + const empty = { nodeId: '', remoteAddr: '', host: '', path: '' }; + setDraft(empty); + setFilters(empty); + setDetailPage(0); + setIPPage(0); + setSelectedIP(null); + }; + + const handleCleanupConfirm = () => { + const retentionDays = + cleanupDays === 'custom' + ? Number.parseInt(customCleanupDays, 10) + : Number.parseInt(cleanupDays, 10); + cleanupMutation.mutate({ retention_days: retentionDays }); + }; + return (
- 返回节点页 - + setCleanupModalOpen(true)}> + 清理日志 + } /> - -
+ +
{summary.map((item) => (
void queryClient.invalidateQueries({ - queryKey: accessLogsQueryKey(nodeFilter), + queryKey: ['access-logs'], }) } > @@ -122,148 +318,598 @@ export function AccessLogsPage() { } >
-
- setNodeFilterInput(event.target.value)} - placeholder="输入 node_id 过滤访问日志" - className="lg:max-w-md" - /> - { - setPageSize(Number(event.target.value) || defaultPageSize); - setPage(0); - }} - className="lg:w-40" - aria-label="每页条数" - > - {accessLogPageSizeOptions.map((option) => ( - - ))} - -
- + {[ + { key: 'detail', label: '明细日志', description: '按请求明细查看与折叠聚合' }, + { key: 'ip', label: 'IP 维度', description: '查看来源 IP 汇总与趋势' }, + ].map((tab) => ( +
+

{tab.label}

+

{tab.description}

+ + ))}
- {logsQuery.isLoading ? ( - - ) : logsQuery.isError ? ( - - ) : logs.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {logs.map((item) => { - const statusMeta = getStatusMeta(item.status_code); - return ( - - - - - - - - - ); - })} - -
时间原 IP访问域名路径节点状态码
-
{formatDateTime(item.logged_at)}
-
- {formatRelativeTime(item.logged_at)} -
-
-
{item.remote_addr || '—'}
- {item.region ? ( -
- - {item.region} - -
- ) : null} -
- {item.host || '—'} - - {item.path || '—'} - -
{item.node_name || item.node_id}
-
- {item.node_id} -
-
- -
-
- )} -
-

- 第 {page + 1} 页,每页 {pageSize} 条。 -

-
- setPage((value) => Math.max(value - 1, 0))} +
+ + + setDraft((current) => ({ ...current, nodeId: event.target.value })) + } + placeholder="按 node_id 搜索" + /> + + + + setDraft((current) => ({ ...current, remoteAddr: event.target.value })) + } + placeholder="按 IP 搜索" + /> + + + + setDraft((current) => ({ ...current, host: event.target.value })) + } + placeholder="按域名搜索" + /> + + + + setDraft((current) => ({ ...current, path: event.target.value })) + } + placeholder="按路径搜索" + /> + +
+ +
+ + { + setPageSize(Number(event.target.value) || 20); + setDetailPage(0); + setIPPage(0); + }} > - 上一页 - - setPage((value) => value + 1)} + {pageSizeOptions.map((option) => ( + + ))} + + + + 0 ? '折叠排序' : '排序'}> + 0 + ? foldedSort + : activeTab === 'detail' + ? detailSort + : ipSort + } + onChange={(event) => { + if (activeTab === 'detail' && foldMinutes > 0) { + setFoldedSort(event.target.value); + setDetailPage(0); + return; + } + if (activeTab === 'detail') { + setDetailSort(event.target.value); + setDetailPage(0); + return; + } + setIPSort(event.target.value); + setIPPage(0); + }} > - 下一页 + {(activeTab === 'detail' && foldMinutes > 0 + ? foldedSortOptions + : activeTab === 'detail' + ? detailSortOptions + : ipSortOptions + ).map((option) => ( + + ))} + + + + {activeTab === 'detail' ? ( + + { + setFoldMinutes(Number(event.target.value) as 0 | 3 | 5); + setDetailPage(0); + }} + > + {foldOptions.map((option) => ( + + ))} + + + ) : ( +
+ )} + +
+ + 应用筛选 + + + 重置
+ + {activeTab === 'detail' ? ( + setDetailPage((value) => Math.max(value - 1, 0))} + onNextPage={() => setDetailPage((value) => value + 1)} + /> + ) : ( + setIPPage((value) => Math.max(value - 1, 0))} + onNextPage={() => setIPPage((value) => value + 1)} + onSelectIP={setSelectedIP} + /> + )} + + setSelectedIP(null)} + > + {ipTrendQuery.isLoading ? ( + + ) : ipTrendQuery.isError ? ( + + ) : ipTrendQuery.data ? ( + formatCompactNumber(value)} + /> + ) : ( + + )} + + + setCleanupModalOpen(false)} + footer={ +
+ setCleanupModalOpen(false)}> + 取消 + + + {cleanupMutation.isPending ? '清理中...' : '确认清理'} + +
+ } + > +
+
+ {cleanupPresetOptions.map((days) => ( + + ))} + +
+ + {cleanupDays === 'custom' ? ( + + setCustomCleanupDays(event.target.value)} + placeholder="输入保留天数" + type="number" + min={1} + max={90} + /> + + ) : null} + + {cleanupMutation.isError ? ( + + ) : null} +
+
+
+ ); +} + +function getStatusMeta(statusCode: number) { + if (statusCode >= 500) { + return { label: String(statusCode), variant: 'danger' as const }; + } + if (statusCode >= 400) { + return { label: String(statusCode), variant: 'warning' as const }; + } + return { label: String(statusCode), variant: 'success' as const }; +} + +function DetailTab({ + detailPage, + pageSize, + foldMinutes, + query, + onPrevPage, + onNextPage, +}: { + detailPage: number; + pageSize: number; + foldMinutes: 0 | 3 | 5; + query: { + isLoading: boolean; + isError: boolean; + isFetching: boolean; + error: unknown; + data?: AccessLogList | FoldedAccessLogList; + }; + onPrevPage: () => void; + onNextPage: () => void; +}) { + if (query.isLoading) { + return ( + + + + ); + } + + if (query.isError) { + return ( + + + + ); + } + + if (foldMinutes > 0) { + const data = query.data as FoldedAccessLogList; + return ( + +
+ {data.items.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {data.items.map((item) => ( + + + + + + + + + + ))} + +
时间桶总访问来源 IP域名数2xx4xx5xx
+
{formatDateTime(item.bucket_started_at)}
+
+ {formatRelativeTime(item.bucket_started_at)} +
+
+ {formatCompactNumber(item.request_count)} + + {formatCompactNumber(item.unique_ip_count)} + + {formatCompactNumber(item.unique_host_count)} + + {formatCompactNumber(item.success_count)} + + {formatCompactNumber(item.client_error_count)} + + {formatCompactNumber(item.server_error_count)} +
+
+ )} + +
+
+ ); + } + + const data = query.data as AccessLogList; + return ( + +
+ {data.items.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {data.items.map((item) => { + const statusMeta = getStatusMeta(item.status_code); + return ( + + + + + + + + + ); + })} + +
时间来源 IP访问域名路径节点状态码
+
{formatDateTime(item.logged_at)}
+
+ {formatRelativeTime(item.logged_at)} +
+
+
{item.remote_addr || '—'}
+ {item.region ? ( +
+ + {item.region} + +
+ ) : null} +
+ {item.host || '—'} + + {item.path || '—'} + +
{item.node_name || item.node_id}
+
+ {item.node_id} +
+
+ +
+
+ )} + +
+
+ ); +} + +function IPTab({ + pageSize, + ipPage, + query, + onPrevPage, + onNextPage, + onSelectIP, +}: { + pageSize: number; + ipPage: number; + query: { + isLoading: boolean; + isError: boolean; + isFetching: boolean; + error: unknown; + data?: { + items: AccessLogIPSummaryItem[]; + has_more: boolean; + }; + }; + onPrevPage: () => void; + onNextPage: () => void; + onSelectIP: (item: AccessLogIPSummaryItem) => void; +}) { + const items = query.data?.items ?? []; + const hasMore = query.data?.has_more ?? false; + + return ( + + {query.isLoading ? ( + + ) : query.isError ? ( + + ) : items.length === 0 ? ( + + ) : ( +
+
+ + + + + + + + + + + {items.map((item) => ( + onSelectIP(item)} + > + + + + + + ))} + +
IP总访问次数3 小时内访问次数最后访问时间
+ {item.remote_addr} + + {formatCompactNumber(item.total_requests)} + + {formatCompactNumber(item.recent_requests)} + +
{formatDateTime(item.last_seen_at)}
+
+ {formatRelativeTime(item.last_seen_at)} +
+
+
+ +
+ )} +
+ ); +} + +function Pager({ + page, + pageSize, + hasMore, + isFetching, + onPrev, + onNext, +}: { + page: number; + pageSize: number; + hasMore: boolean; + isFetching: boolean; + onPrev: () => void; + onNext: () => void; +}) { + return ( +
+

+ 第 {page + 1} 页,每页 {pageSize} 条。 +

+
+ + 上一页 + + + 下一页 + +
); } diff --git a/openflare_server/web/features/access-logs/types.ts b/openflare_server/web/features/access-logs/types.ts index f01cf530..13055335 100644 --- a/openflare_server/web/features/access-logs/types.ts +++ b/openflare_server/web/features/access-logs/types.ts @@ -1,3 +1,14 @@ +export interface AccessLogFilters { + node_id?: string; + remote_addr?: string; + host?: string; + path?: string; + p?: number; + page_size?: number; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + export interface AccessLogItem { id: number; node_id: string; @@ -18,3 +29,85 @@ export interface AccessLogList { total_record: number; total_ip: number; } + +export interface FoldedAccessLogFilters extends AccessLogFilters { + fold_minutes: 3 | 5; +} + +export interface FoldedAccessLogItem { + bucket_started_at: string; + request_count: number; + unique_ip_count: number; + unique_host_count: number; + success_count: number; + client_error_count: number; + server_error_count: number; +} + +export interface FoldedAccessLogList { + items: FoldedAccessLogItem[]; + page: number; + page_size: number; + has_more: boolean; + total_bucket: number; + total_record: number; + total_ip: number; + fold_minutes: number; +} + +export interface AccessLogIPSummaryFilters { + node_id?: string; + remote_addr?: string; + host?: string; + p?: number; + page_size?: number; + sort_by?: string; + sort_order?: 'asc' | 'desc'; +} + +export interface AccessLogIPSummaryItem { + remote_addr: string; + total_requests: number; + recent_requests: number; + last_seen_at: string; +} + +export interface AccessLogIPSummaryList { + items: AccessLogIPSummaryItem[]; + page: number; + page_size: number; + has_more: boolean; + total_ip: number; + sort_by: string; + sort_order: 'asc' | 'desc'; +} + +export interface AccessLogIPTrendFilters { + node_id?: string; + remote_addr: string; + host?: string; + hours?: number; + bucket_minutes?: number; +} + +export interface AccessLogIPTrendPoint { + bucket_started_at: string; + request_count: number; +} + +export interface AccessLogIPTrend { + remote_addr: string; + hours: number; + bucket_minutes: number; + points: AccessLogIPTrendPoint[]; +} + +export interface AccessLogCleanupPayload { + retention_days: number; +} + +export interface AccessLogCleanupResult { + retention_days: number; + deleted_count: number; + cutoff: string; +}