mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
[功能] 添加访问日志折叠、IP汇总和趋势查询功能,并实现日志清理功能
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"openflare/model"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -9,8 +10,27 @@ import (
|
||||
const (
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const (
|
||||
NodeHealthSeverityInfo = "info"
|
||||
NodeHealthSeverityWarning = "warning"
|
||||
NodeHealthSeverityCritical = "critical"
|
||||
nodeAccessLogRetentionWindow = 24 * time.Hour
|
||||
nodeAccessLogRetentionWindow = nodeAccessLogRetentionDays * 24 * time.Hour
|
||||
)
|
||||
|
||||
type AgentNodeSystemProfile struct {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
if (normalizedNodeId) {
|
||||
searchParams.set('node_id', normalizedNodeId);
|
||||
function buildSearchParams(filters: object) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(filters as Record<string, string | number | undefined>).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
return apiRequest<AccessLogList>(`/access-logs/?${searchParams.toString()}`);
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
return 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';
|
||||
|
||||
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 };
|
||||
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 '--';
|
||||
}
|
||||
if (statusCode >= 400) {
|
||||
return { label: String(statusCode), variant: 'warning' as const };
|
||||
}
|
||||
return { label: String(statusCode), variant: 'success' as const };
|
||||
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<ActiveTab>('detail');
|
||||
const [draft, setDraft] = useState<SearchDraft>({
|
||||
nodeId: '',
|
||||
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({
|
||||
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<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 hasMore = logsQuery.data?.has_more ?? false;
|
||||
const ipSummaryQuery = useQuery<AccessLogIPSummaryList>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="日志"
|
||||
description="查看最近时间窗口内的访问记录,包含来源 IP、归属地、访问域名、路径、命中节点与响应状态码。"
|
||||
description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。"
|
||||
action={
|
||||
<Link
|
||||
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)]"
|
||||
>
|
||||
返回节点页
|
||||
</Link>
|
||||
<PrimaryButton type="button" onClick={() => setCleanupModalOpen(true)}>
|
||||
清理日志
|
||||
</PrimaryButton>
|
||||
}
|
||||
/>
|
||||
|
||||
<AppCard
|
||||
title="日志摘要"
|
||||
description="基于当前筛选条件汇总最近时间窗口内的访问记录与来源 IP 规模。"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-2">
|
||||
<AppCard title="日志摘要" description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{summary.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
@@ -106,14 +302,14 @@ export function AccessLogsPage() {
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="访问记录"
|
||||
description="默认展示全部节点的最近访问日志,可按 node_id 快速过滤。"
|
||||
title="筛选与视图"
|
||||
description="支持 IP、访问域名、路径、分页大小、排序与时间折叠。"
|
||||
action={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: accessLogsQueryKey(nodeFilter),
|
||||
queryKey: ['access-logs'],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -122,70 +318,414 @@ export function AccessLogsPage() {
|
||||
}
|
||||
>
|
||||
<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
|
||||
value={nodeFilterInput}
|
||||
onChange={(event) => setNodeFilterInput(event.target.value)}
|
||||
placeholder="输入 node_id 过滤访问日志"
|
||||
className="lg:max-w-md"
|
||||
value={draft.nodeId}
|
||||
onChange={(event) =>
|
||||
setDraft((current) => ({ ...current, nodeId: event.target.value }))
|
||||
}
|
||||
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
|
||||
value={String(pageSize)}
|
||||
onChange={(event) => {
|
||||
setPageSize(Number(event.target.value) || defaultPageSize);
|
||||
setPage(0);
|
||||
setPageSize(Number(event.target.value) || 20);
|
||||
setDetailPage(0);
|
||||
setIPPage(0);
|
||||
}}
|
||||
className="lg:w-40"
|
||||
aria-label="每页条数"
|
||||
>
|
||||
{accessLogPageSizeOptions.map((option) => (
|
||||
{pageSizeOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
每页 {option} 条
|
||||
</option>
|
||||
))}
|
||||
</ResourceSelect>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNodeFilter(nodeFilterInput.trim());
|
||||
setPage(0);
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label={activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'}>
|
||||
<ResourceSelect
|
||||
value={
|
||||
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>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNodeFilter('');
|
||||
setNodeFilterInput('');
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
清空
|
||||
<SecondaryButton type="button" onClick={handleReset}>
|
||||
重置
|
||||
</SecondaryButton>
|
||||
</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 />
|
||||
) : 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
|
||||
title="访问日志加载失败"
|
||||
description={getErrorMessage(logsQuery.error)}
|
||||
title="日志清理失败"
|
||||
description={getErrorMessage(cleanupMutation.error)}
|
||||
/>
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无访问日志"
|
||||
description="当前时间窗口内还没有可展示的访问记录。"
|
||||
) : null}
|
||||
</div>
|
||||
</AppModal>
|
||||
</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">
|
||||
<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">原 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>
|
||||
@@ -193,7 +733,7 @@ export function AccessLogsPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{logs.map((item) => {
|
||||
{data.items.map((item) => {
|
||||
const statusMeta = getStatusMeta(item.status_code);
|
||||
return (
|
||||
<tr key={item.id} className="align-top">
|
||||
@@ -229,10 +769,7 @@ export function AccessLogsPage() {
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-4">
|
||||
<StatusBadge
|
||||
label={statusMeta.label}
|
||||
variant={statusMeta.variant}
|
||||
/>
|
||||
<StatusBadge label={statusMeta.label} variant={statusMeta.variant} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -241,29 +778,138 @@ export function AccessLogsPage() {
|
||||
</table>
|
||||
</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">
|
||||
<p className="text-sm text-[var(--foreground-secondary)]">
|
||||
第 {page + 1} 页,每页 {pageSize} 条。
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={page === 0 || logsQuery.isLoading}
|
||||
onClick={() => setPage((value) => Math.max(value - 1, 0))}
|
||||
>
|
||||
<SecondaryButton type="button" disabled={page === 0 || isFetching} onClick={onPrev}>
|
||||
上一页
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
disabled={!hasMore || logsQuery.isLoading}
|
||||
onClick={() => setPage((value) => value + 1)}
|
||||
>
|
||||
<SecondaryButton type="button" disabled={!hasMore || isFetching} onClick={onNext}>
|
||||
下一页
|
||||
</SecondaryButton>
|
||||
</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 {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user