[功能] 添加访问日志折叠、IP汇总和趋势查询功能,并实现日志清理功能

This commit is contained in:
ryan
2026-03-18 10:33:35 +08:00
parent 2e875f583b
commit 29a6fedbe9
10 changed files with 1954 additions and 273 deletions
+136 -3
View File
@@ -1,6 +1,7 @@
package controller package controller
import ( import (
"net/http"
"openflare/service" "openflare/service"
"strconv" "strconv"
@@ -13,17 +14,149 @@ import (
// @Produce json // @Produce json
// @Security BearerAuth // @Security BearerAuth
// @Param node_id query string false "Node ID" // @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 p query int false "Page index"
// @Param page_size query int false "Page size" // @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{} // @Success 200 {object} map[string]interface{}
// @Router /api/access-logs/ [get] // @Router /api/access-logs/ [get]
func GetAccessLogs(c *gin.Context) { func GetAccessLogs(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("p", "0")) logs, err := service.ListAccessLogs(readAccessLogQuery(c))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "0"))
logs, err := service.ListAccessLogs(c.Query("node_id"), page, pageSize)
if err != nil { if err != nil {
respondFailure(c, err.Error()) respondFailure(c, err.Error())
return return
} }
respondSuccess(c, logs) 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
}
+287 -29
View File
@@ -1,16 +1,22 @@
package model package model
import "time" import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
)
type NodeAccessLog struct { type NodeAccessLog struct {
ID uint `json:"id" gorm:"primaryKey"` ID uint `json:"id" gorm:"primaryKey"`
NodeID string `json:"node_id" gorm:"index;size:64;not null"` 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"` 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:"size:128"` RemoteAddr string `json:"remote_addr" gorm:"index:idx_node_access_logs_remote_addr;size:128"`
Region string `json:"region" gorm:"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"` 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"` RawJSON string `json:"raw_json" gorm:"type:text"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
} }
@@ -20,39 +26,91 @@ type NodeAccessLogRegionCount struct {
Count int64 `json:"count"` Count int64 `json:"count"`
} }
func ListNodeAccessLogs(nodeID string, since time.Time, offset int, limit int) (logs []*NodeAccessLog, err error) { type NodeAccessLogQuery struct {
query := DB.Order("logged_at desc, id desc") NodeID string
if nodeID != "" { RemoteAddr string
query = query.Where("node_id = ?", nodeID) Host string
Path string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
} }
if !since.IsZero() {
query = query.Where("logged_at >= ?", since) type NodeAccessLogBucketQuery struct {
NodeID string
RemoteAddr string
Host string
Path string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
FoldMinutes int
} }
if offset > 0 {
query = query.Offset(offset) 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"`
} }
if limit > 0 {
query = query.Limit(limit) type NodeAccessLogIPSummaryQuery struct {
NodeID string
RemoteAddr string
Host string
Since time.Time
Page int
PageSize int
SortBy string
SortOrder string
} }
err = query.Find(&logs).Error
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 return logs, err
} }
func CountNodeAccessLogs(nodeID string, since time.Time) (totalRecords int64, totalIPs int64, err error) { func CountNodeAccessLogs(query NodeAccessLogQuery) (totalRecords int64, totalIPs int64, err error) {
query := DB.Model(&NodeAccessLog{}) base := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query)
if nodeID != "" { if err = base.Count(&totalRecords).Error; err != nil {
query = query.Where("node_id = ?", nodeID)
}
if !since.IsZero() {
query = query.Where("logged_at >= ?", since)
}
if err = query.Count(&totalRecords).Error; err != nil {
return 0, 0, err return 0, 0, err
} }
if err = query. distinctQuery := buildNodeAccessLogQuery(DB.Model(&NodeAccessLog{}), query).
Where("remote_addr <> ''"). Where("remote_addr <> ''").
Distinct("remote_addr"). Distinct("remote_addr")
Count(&totalIPs).Error; err != nil { if err = distinctQuery.Count(&totalIPs).Error; err != nil {
return 0, 0, err return 0, 0, err
} }
return totalRecords, totalIPs, nil return totalRecords, totalIPs, nil
@@ -75,3 +133,203 @@ func ListNodeAccessLogRegionCounts(nodeID string, since time.Time, limit int) (i
err = query.Scan(&items).Error err = query.Scan(&items).Error
return items, err 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"
}
+4
View File
@@ -139,6 +139,10 @@ func SetApiRouter(router *gin.Engine) {
accessLogRoute.Use(middleware.AdminAuth()) accessLogRoute.Use(middleware.AdminAuth())
{ {
accessLogRoute.GET("/", controller.GetAccessLogs) 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") agentRoute := apiRouter.Group("/agent")
{ {
+409 -40
View File
@@ -1,6 +1,7 @@
package service package service
import ( import (
"errors"
"openflare/model" "openflare/model"
"strings" "strings"
"time" "time"
@@ -9,8 +10,27 @@ import (
const ( const (
defaultAccessLogPageSize = 20 defaultAccessLogPageSize = 20
maxAccessLogPageSize = 200 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 { type AccessLogView struct {
ID uint `json:"id"` ID uint `json:"id"`
NodeID string `json:"node_id"` NodeID string `json:"node_id"`
@@ -32,52 +52,99 @@ type AccessLogList struct {
TotalIP int64 `json:"total_ip"` TotalIP int64 `json:"total_ip"`
} }
func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, error) { type FoldedAccessLogView struct {
normalizedPage := normalizeAccessLogPage(page) BucketStartedAt time.Time `json:"bucket_started_at"`
normalizedPageSize := normalizeAccessLogPageSize(pageSize) RequestCount int64 `json:"request_count"`
offset := normalizedPage * normalizedPageSize UniqueIPCount int64 `json:"unique_ip_count"`
trimmedNodeID := strings.TrimSpace(nodeID) UniqueHostCount int64 `json:"unique_host_count"`
since := time.Now().Add(-nodeAccessLogRetentionWindow) SuccessCount int64 `json:"success_count"`
logs, err := model.ListNodeAccessLogs( ClientErrorCount int64 `json:"client_error_count"`
trimmedNodeID, ServerErrorCount int64 `json:"server_error_count"`
since, }
offset,
normalizedPageSize+1, 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 { if err != nil {
return nil, err return nil, err
} }
totalRecords, totalIPs, err := model.CountNodeAccessLogs(trimmedNodeID, since) totalRecords, totalIPs, err := model.CountNodeAccessLogs(modelQuery)
if err != nil { if err != nil {
return nil, err return nil, err
} }
nodeIDs := make([]string, 0, len(logs)) nodeNames, err := listNodeNameMap(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)
if err != nil { if err != nil {
return nil, err 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)) views := make([]AccessLogView, 0, len(logs))
for _, item := range logs { for _, item := range logs {
if item == nil { if item == nil {
@@ -97,14 +164,270 @@ func ListAccessLogs(nodeID string, page int, pageSize int) (*AccessLogList, erro
} }
return &AccessLogList{ return &AccessLogList{
Items: views, Items: views,
Page: normalizedPage, Page: normalized.Page,
PageSize: normalizedPageSize, PageSize: normalized.PageSize,
HasMore: hasMore, HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalRecords,
TotalRecord: totalRecords, TotalRecord: totalRecords,
TotalIP: totalIPs, TotalIP: totalIPs,
}, nil }, 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 { func normalizeAccessLogPage(page int) int {
if page < 0 { if page < 0 {
return 0 return 0
@@ -121,3 +444,49 @@ func normalizeAccessLogPageSize(pageSize int) int {
} }
return pageSize 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")
}
}
+122 -3
View File
@@ -64,7 +64,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
t.Fatalf("failed to seed access logs: %v", err) 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 { if err != nil {
t.Fatalf("ListAccessLogs failed: %v", err) t.Fatalf("ListAccessLogs failed: %v", err)
} }
@@ -84,7 +84,7 @@ func TestListAccessLogsIncludesSummaryTotals(t *testing.T) {
t.Fatal("expected has_more to be true") 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 { if err != nil {
t.Fatalf("ListAccessLogs filtered failed: %v", err) 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) t.Fatalf("failed to seed access logs: %v", err)
} }
result, err := ListAccessLogs("", 0, 0) result, err := ListAccessLogs(AccessLogQuery{})
if err != nil { if err != nil {
t.Fatalf("ListAccessLogs failed: %v", err) t.Fatalf("ListAccessLogs failed: %v", err)
} }
@@ -139,3 +139,122 @@ func TestListAccessLogsUsesDefaultPageSize(t *testing.T) {
t.Fatal("expected has_more to be true") 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)
}
}
+22 -4
View File
@@ -708,7 +708,12 @@ func TestHeartbeatNodePersistsObservabilityPayload(t *testing.T) {
t.Fatalf("unexpected request reports: %+v", reports) 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 { if err != nil {
t.Fatalf("expected node access logs query to succeed: %v", err) 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) 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 { if err != nil {
t.Fatalf("expected node access logs query to succeed: %v", err) 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) 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 { if err != nil {
t.Fatalf("ListAccessLogs page 1 failed: %v", err) 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]) 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 { if err != nil {
t.Fatalf("ListAccessLogs page 2 failed: %v", err) t.Fatalf("ListAccessLogs page 2 failed: %v", err)
} }
+1 -1
View File
@@ -17,7 +17,7 @@ const (
NodeHealthSeverityInfo = "info" NodeHealthSeverityInfo = "info"
NodeHealthSeverityWarning = "warning" NodeHealthSeverityWarning = "warning"
NodeHealthSeverityCritical = "critical" NodeHealthSeverityCritical = "critical"
nodeAccessLogRetentionWindow = 24 * time.Hour nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
) )
type AgentNodeSystemProfile struct { type AgentNodeSystemProfile struct {
@@ -1,15 +1,56 @@
import { apiRequest } from '@/lib/api/client'; 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) { function buildSearchParams(filters: object) {
const normalizedNodeId = nodeId?.trim(); const searchParams = new URLSearchParams();
const searchParams = new URLSearchParams({ Object.entries(filters as Record<string, string | number | undefined>).forEach(([key, value]) => {
p: String(Math.max(page, 0)), if (value === undefined || value === null || value === '') {
page_size: String(pageSize), return;
}
searchParams.set(key, String(value));
}); });
if (normalizedNodeId) { return searchParams.toString();
searchParams.set('node_id', normalizedNodeId);
} }
return apiRequest<AccessLogList>(`/access-logs/?${searchParams.toString()}`);
export function getAccessLogs(filters: AccessLogFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogList>(`/access-logs/${query ? `?${query}` : ''}`);
}
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
const query = buildSearchParams(filters);
return apiRequest<FoldedAccessLogList>(`/access-logs/folds${query ? `?${query}` : ''}`);
}
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogIPSummaryList>(
`/access-logs/ip-summary${query ? `?${query}` : ''}`,
);
}
export function getAccessLogIPTrend(filters: AccessLogIPTrendFilters) {
const query = buildSearchParams(filters);
return apiRequest<AccessLogIPTrend>(
`/access-logs/ip-summary/trend${query ? `?${query}` : ''}`,
);
}
export function cleanupAccessLogs(payload: AccessLogCleanupPayload) {
return apiRequest<AccessLogCleanupResult>('/access-logs/cleanup', {
method: 'POST',
body: JSON.stringify(payload),
});
} }
@@ -1,94 +1,290 @@
'use client'; 'use client';
import Link from 'next/link';
import { useMemo, useState } from 'react'; 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 { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state'; import { ErrorState } from '@/components/feedback/error-state';
import { LoadingState } from '@/components/feedback/loading-state'; import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card'; import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge'; import { StatusBadge } from '@/components/ui/status-badge';
import { getAccessLogs } from '@/features/access-logs/api/access-logs'; import {
import type { AccessLogItem } from '@/features/access-logs/types'; 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 { import {
PrimaryButton, PrimaryButton,
ResourceField,
ResourceInput, ResourceInput,
ResourceSelect, ResourceSelect,
SecondaryButton, SecondaryButton,
} from '@/features/shared/components/resource-primitives'; } from '@/features/shared/components/resource-primitives';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import { formatCompactNumber } from '@/lib/utils/metrics';
const accessLogsQueryKey = (nodeId: string) => ['access-logs', nodeId] as const; type ActiveTab = 'detail' | 'ip';
const defaultPageSize = 20;
const accessLogPageSizeOptions = [20, 50, 100, 200]; 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) { function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。'; 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 [ return [
{ label: '访问记录', value: totalRecord }, { label: '访问记录', value: formatCompactNumber(totalRecord) },
{ label: '来源 IP', value: totalIP }, { label: '来源 IP', value: formatCompactNumber(totalIP) },
{ label: '当前视图', value: activeTab === 'detail' ? '明细日志' : 'IP 维度' },
]; ];
} }
function getStatusMeta(statusCode: number) { function buildTrendLabels(points: Array<{ bucket_started_at: string }>) {
if (statusCode >= 500) { return points.map((point) => {
return { label: String(statusCode), variant: 'danger' as const }; const date = new Date(point.bucket_started_at);
if (Number.isNaN(date.getTime())) {
return '--';
} }
if (statusCode >= 400) { return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(
return { label: String(statusCode), variant: 'warning' as const }; date.getDate(),
} ).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(
return { label: String(statusCode), variant: 'success' as const }; date.getMinutes(),
).padStart(2, '0')}`;
});
} }
export function AccessLogsPage() { export function AccessLogsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState(''); const [activeTab, setActiveTab] = useState<ActiveTab>('detail');
const [nodeFilter, setNodeFilter] = useState(''); const [draft, setDraft] = useState<SearchDraft>({
const [page, setPage] = useState(0); nodeId: '',
const [pageSize, setPageSize] = useState(defaultPageSize); remoteAddr: '',
host: '',
path: '',
});
const [filters, setFilters] = useState<AppliedSearch>({
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<AccessLogIPSummaryItem | null>(null);
const [cleanupDays, setCleanupDays] = useState<string>('7');
const [customCleanupDays, setCustomCleanupDays] = useState('14');
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
const logsQuery = useQuery({ const detailSortState = parseSortValue(detailSort);
queryKey: [...accessLogsQueryKey(nodeFilter), page, pageSize], const foldedSortState = parseSortValue(foldedSort);
queryFn: () => getAccessLogs(page, nodeFilter, pageSize), const ipSortState = parseSortValue(ipSort);
placeholderData: (previousData) => previousData,
const detailQuery = useQuery<AccessLogList | FoldedAccessLogList>({
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 ipSummaryQuery = useQuery<AccessLogIPSummaryList>({
const hasMore = logsQuery.data?.has_more ?? false; 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( const summary = useMemo(
() => () =>
buildSummary( buildSummary(
logsQuery.data?.total_record ?? 0, detailSummaryData?.total_record ?? 0,
logsQuery.data?.total_ip ?? 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="日志" title="日志"
description="查看最近时间窗口内的访问记录,包含来源 IP、归属地、访问域名、路径、命中节点与响应状态码。" description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。"
action={ action={
<Link <PrimaryButton type="button" onClick={() => setCleanupModalOpen(true)}>
href="/node" 清理日志
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]" </PrimaryButton>
>
返回节点页
</Link>
} }
/> />
<AppCard <AppCard title="日志摘要" description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。">
title="日志摘要" <div className="grid gap-4 md:grid-cols-3">
description="基于当前筛选条件汇总最近时间窗口内的访问记录与来源 IP 规模。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
{summary.map((item) => ( {summary.map((item) => (
<div <div
key={item.label} key={item.label}
@@ -106,14 +302,14 @@ export function AccessLogsPage() {
</AppCard> </AppCard>
<AppCard <AppCard
title="访问记录" title="筛选与视图"
description="默认展示全部节点的最近访问日志,可按 node_id 快速过滤。" description="支持 IP、访问域名、路径、分页大小、排序与时间折叠。"
action={ action={
<SecondaryButton <SecondaryButton
type="button" type="button"
onClick={() => onClick={() =>
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: accessLogsQueryKey(nodeFilter), queryKey: ['access-logs'],
}) })
} }
> >
@@ -122,70 +318,414 @@ export function AccessLogsPage() {
} }
> >
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center"> <div className="flex flex-wrap gap-2">
{[
{ key: 'detail', label: '明细日志', description: '按请求明细查看与折叠聚合' },
{ key: 'ip', label: 'IP 维度', description: '查看来源 IP 汇总与趋势' },
].map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key as ActiveTab)}
className={`min-w-[180px] rounded-2xl border px-4 py-3 text-left transition ${
activeTab === tab.key
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)] hover:border-[var(--border-strong)]'
}`}
>
<p className="text-sm font-semibold">{tab.label}</p>
<p className="mt-1 text-xs leading-5">{tab.description}</p>
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ResourceField label="节点 ID">
<ResourceInput <ResourceInput
value={nodeFilterInput} value={draft.nodeId}
onChange={(event) => setNodeFilterInput(event.target.value)} onChange={(event) =>
placeholder="输入 node_id 过滤访问日志" setDraft((current) => ({ ...current, nodeId: event.target.value }))
className="lg:max-w-md" }
placeholder="按 node_id 搜索"
/> />
</ResourceField>
<ResourceField label="来源 IP">
<ResourceInput
value={draft.remoteAddr}
onChange={(event) =>
setDraft((current) => ({ ...current, remoteAddr: event.target.value }))
}
placeholder="按 IP 搜索"
/>
</ResourceField>
<ResourceField label="访问域名">
<ResourceInput
value={draft.host}
onChange={(event) =>
setDraft((current) => ({ ...current, host: event.target.value }))
}
placeholder="按域名搜索"
/>
</ResourceField>
<ResourceField
label="请求路径"
hint={activeTab === 'ip' ? 'IP 维度页暂不使用路径过滤。' : '支持按路径模糊过滤。'}
>
<ResourceInput
value={draft.path}
disabled={activeTab === 'ip'}
onChange={(event) =>
setDraft((current) => ({ ...current, path: event.target.value }))
}
placeholder="按路径搜索"
/>
</ResourceField>
</div>
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ResourceField label="每页条数">
<ResourceSelect <ResourceSelect
value={String(pageSize)} value={String(pageSize)}
onChange={(event) => { onChange={(event) => {
setPageSize(Number(event.target.value) || defaultPageSize); setPageSize(Number(event.target.value) || 20);
setPage(0); setDetailPage(0);
setIPPage(0);
}} }}
className="lg:w-40"
aria-label="每页条数"
> >
{accessLogPageSizeOptions.map((option) => ( {pageSizeOptions.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
每页 {option} 条 每页 {option} 条
</option> </option>
))} ))}
</ResourceSelect> </ResourceSelect>
<div className="flex flex-wrap gap-2"> </ResourceField>
<PrimaryButton
type="button" <ResourceField label={activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'}>
onClick={() => { <ResourceSelect
setNodeFilter(nodeFilterInput.trim()); value={
setPage(0); activeTab === 'detail' && foldMinutes > 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) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
{activeTab === 'detail' ? (
<ResourceField label="时间折叠">
<ResourceSelect
value={String(foldMinutes)}
onChange={(event) => {
setFoldMinutes(Number(event.target.value) as 0 | 3 | 5);
setDetailPage(0);
}}
>
{foldOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</ResourceSelect>
</ResourceField>
) : (
<div />
)}
<div className="flex items-end gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
应用筛选
</PrimaryButton> </PrimaryButton>
<SecondaryButton <SecondaryButton type="button" onClick={handleReset}>
type="button" 重置
onClick={() => {
setNodeFilter('');
setNodeFilterInput('');
setPage(0);
}}
>
清空
</SecondaryButton> </SecondaryButton>
</div> </div>
</div> </div>
</div>
</AppCard>
{logsQuery.isLoading ? ( {activeTab === 'detail' ? (
<DetailTab
detailPage={detailPage}
pageSize={pageSize}
foldMinutes={foldMinutes}
query={detailQuery}
onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))}
onNextPage={() => setDetailPage((value) => value + 1)}
/>
) : (
<IPTab
pageSize={pageSize}
ipPage={ipPage}
query={ipSummaryQuery}
onPrevPage={() => setIPPage((value) => Math.max(value - 1, 0))}
onNextPage={() => setIPPage((value) => value + 1)}
onSelectIP={setSelectedIP}
/>
)}
<AppModal
isOpen={Boolean(selectedIP)}
title={selectedIP ? `IP 趋势 · ${selectedIP.remote_addr}` : 'IP 趋势'}
description="展示该来源 IP 最近 24 小时的访问次数曲线,帮助判断是否存在突增、持续轰击或间歇异常。"
size="xl"
onClose={() => setSelectedIP(null)}
>
{ipTrendQuery.isLoading ? (
<LoadingState /> <LoadingState />
) : logsQuery.isError ? ( ) : ipTrendQuery.isError ? (
<ErrorState title="IP 趋势加载失败" description={getErrorMessage(ipTrendQuery.error)} />
) : ipTrendQuery.data ? (
<TrendChart
labels={trendLabels}
series={[
{
label: '访问次数',
color: '#0f766e',
fillColor: 'rgba(15, 118, 110, 0.16)',
values: trendValues,
variant: 'area',
},
]}
yAxisValueFormatter={(value) => formatCompactNumber(value)}
/>
) : (
<EmptyState title="暂无趋势数据" description="当前 IP 在最近 24 小时内没有可展示的访问曲线。" />
)}
</AppModal>
<AppModal
isOpen={isCleanupModalOpen}
title="清理访问日志"
description="选择日志保留范围后,将删除更早的访问日志。该操作会影响当前日志检索与 IP 维度统计,请谨慎执行。"
onClose={() => setCleanupModalOpen(false)}
footer={
<div className="flex flex-wrap justify-end gap-2">
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}>
取消
</SecondaryButton>
<PrimaryButton
type="button"
disabled={cleanupMutation.isPending}
onClick={handleCleanupConfirm}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
</PrimaryButton>
</div>
}
>
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
{cleanupPresetOptions.map((days) => (
<button
key={days}
type="button"
onClick={() => setCleanupDays(String(days))}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupDays === String(days)
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
保留最近 {days} 天
</button>
))}
<button
type="button"
onClick={() => setCleanupDays('custom')}
className={`rounded-2xl border px-4 py-3 text-sm transition ${
cleanupDays === 'custom'
? 'border-[var(--brand-primary)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-elevated)] text-[var(--foreground-secondary)]'
}`}
>
自定义天数
</button>
</div>
{cleanupDays === 'custom' ? (
<ResourceField label="自定义保留天数" hint="当前支持 1 到 90 天。">
<ResourceInput
value={customCleanupDays}
onChange={(event) => setCustomCleanupDays(event.target.value)}
placeholder="输入保留天数"
type="number"
min={1}
max={90}
/>
</ResourceField>
) : null}
{cleanupMutation.isError ? (
<ErrorState <ErrorState
title="访问日志加载失败" title="日志清理失败"
description={getErrorMessage(logsQuery.error)} description={getErrorMessage(cleanupMutation.error)}
/> />
) : logs.length === 0 ? ( ) : null}
<EmptyState </div>
title="暂无访问日志" </AppModal>
description="当前时间窗口内还没有可展示的访问记录。" </div>
);
}
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 (
<AppCard title="访问日志" description="加载中...">
<LoadingState />
</AppCard>
);
}
if (query.isError) {
return (
<AppCard title="访问日志" description="日志查询失败。">
<ErrorState title="访问日志加载失败" description={getErrorMessage(query.error)} />
</AppCard>
);
}
if (foldMinutes > 0) {
const data = query.data as FoldedAccessLogList;
return (
<AppCard
title="折叠日志"
description={`当前按 ${foldMinutes} 分钟时间桶折叠,适合在高频刷新时快速定位异常波段。`}
>
<div className="space-y-4">
{data.items.length === 0 ? (
<EmptyState title="暂无折叠日志" description="当前筛选条件下没有可展示的时间桶。" />
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">时间桶</th>
<th className="px-3 py-3 font-medium">总访问</th>
<th className="px-3 py-3 font-medium">来源 IP</th>
<th className="px-3 py-3 font-medium">域名数</th>
<th className="px-3 py-3 font-medium">2xx</th>
<th className="px-3 py-3 font-medium">4xx</th>
<th className="px-3 py-3 font-medium">5xx</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{data.items.map((item) => (
<tr key={item.bucket_started_at} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{formatDateTime(item.bucket_started_at)}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(item.bucket_started_at)}
</div>
</td>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{formatCompactNumber(item.request_count)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.unique_ip_count)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.unique_host_count)}
</td>
<td className="px-3 py-4 text-emerald-600">
{formatCompactNumber(item.success_count)}
</td>
<td className="px-3 py-4 text-amber-600">
{formatCompactNumber(item.client_error_count)}
</td>
<td className="px-3 py-4 text-rose-600">
{formatCompactNumber(item.server_error_count)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pager
page={detailPage}
pageSize={pageSize}
hasMore={data.has_more}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/> />
</div>
</AppCard>
);
}
const data = query.data as AccessLogList;
return (
<AppCard
title="访问明细"
description="展示原始访问明细,支持按时间、状态码、IP 与域名排序。"
>
<div className="space-y-4">
{data.items.length === 0 ? (
<EmptyState title="暂无访问日志" description="当前筛选条件下没有可展示的访问明细。" />
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm"> <table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead> <thead>
<tr className="text-[var(--foreground-secondary)]"> <tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">时间</th> <th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">原 IP</th> <th className="px-3 py-3 font-medium">来源 IP</th>
<th className="px-3 py-3 font-medium">访问域名</th> <th className="px-3 py-3 font-medium">访问域名</th>
<th className="px-3 py-3 font-medium">路径</th> <th className="px-3 py-3 font-medium">路径</th>
<th className="px-3 py-3 font-medium">节点</th> <th className="px-3 py-3 font-medium">节点</th>
@@ -193,7 +733,7 @@ export function AccessLogsPage() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-[var(--border-default)]"> <tbody className="divide-y divide-[var(--border-default)]">
{logs.map((item) => { {data.items.map((item) => {
const statusMeta = getStatusMeta(item.status_code); const statusMeta = getStatusMeta(item.status_code);
return ( return (
<tr key={item.id} className="align-top"> <tr key={item.id} className="align-top">
@@ -229,10 +769,7 @@ export function AccessLogsPage() {
</div> </div>
</td> </td>
<td className="px-3 py-4"> <td className="px-3 py-4">
<StatusBadge <StatusBadge label={statusMeta.label} variant={statusMeta.variant} />
label={statusMeta.label}
variant={statusMeta.variant}
/>
</td> </td>
</tr> </tr>
); );
@@ -241,29 +778,138 @@ export function AccessLogsPage() {
</table> </table>
</div> </div>
)} )}
<Pager
page={detailPage}
pageSize={pageSize}
hasMore={data.has_more}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
</AppCard>
);
}
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 (
<AppCard
title="IP 维度日志"
description="聚合展示访问过系统的来源 IP,可按访问次数或最后访问时间排序,点击行查看 24 小时趋势曲线。"
>
{query.isLoading ? (
<LoadingState />
) : query.isError ? (
<ErrorState title="IP 汇总加载失败" description={getErrorMessage(query.error)} />
) : items.length === 0 ? (
<EmptyState title="暂无 IP 访问记录" description="当前筛选条件下没有可展示的来源 IP。" />
) : (
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">IP</th>
<th className="px-3 py-3 font-medium">总访问次数</th>
<th className="px-3 py-3 font-medium">3 小时内访问次数</th>
<th className="px-3 py-3 font-medium">最后访问时间</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr
key={item.remote_addr}
className="cursor-pointer align-top transition hover:bg-[var(--surface-elevated)]"
onClick={() => onSelectIP(item)}
>
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{item.remote_addr}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.total_requests)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatCompactNumber(item.recent_requests)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div>{formatDateTime(item.last_seen_at)}</div>
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
{formatRelativeTime(item.last_seen_at)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager
page={ipPage}
pageSize={pageSize}
hasMore={hasMore}
isFetching={query.isFetching}
onPrev={onPrevPage}
onNext={onNextPage}
/>
</div>
)}
</AppCard>
);
}
function Pager({
page,
pageSize,
hasMore,
isFetching,
onPrev,
onNext,
}: {
page: number;
pageSize: number;
hasMore: boolean;
isFetching: boolean;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 border-t border-[var(--border-default)] pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-[var(--foreground-secondary)]"> <p className="text-sm text-[var(--foreground-secondary)]">
第 {page + 1} 页,每页 {pageSize} 条。 第 {page + 1} 页,每页 {pageSize} 条。
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<SecondaryButton <SecondaryButton type="button" disabled={page === 0 || isFetching} onClick={onPrev}>
type="button"
disabled={page === 0 || logsQuery.isLoading}
onClick={() => setPage((value) => Math.max(value - 1, 0))}
>
上一页 上一页
</SecondaryButton> </SecondaryButton>
<SecondaryButton <SecondaryButton type="button" disabled={!hasMore || isFetching} onClick={onNext}>
type="button"
disabled={!hasMore || logsQuery.isLoading}
onClick={() => setPage((value) => value + 1)}
>
下一页 下一页
</SecondaryButton> </SecondaryButton>
</div> </div>
</div> </div>
</div>
</AppCard>
</div>
); );
} }
@@ -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 { export interface AccessLogItem {
id: number; id: number;
node_id: string; node_id: string;
@@ -18,3 +29,85 @@ export interface AccessLogList {
total_record: number; total_record: number;
total_ip: 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;
}