mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 10:16:37 +08:00
[功能] 添加访问日志折叠、IP汇总和趋势查询功能,并实现日志清理功能
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
if !since.IsZero() {
|
Since time.Time
|
||||||
query = query.Where("logged_at >= ?", since)
|
Page int
|
||||||
}
|
PageSize int
|
||||||
if offset > 0 {
|
SortBy string
|
||||||
query = query.Offset(offset)
|
SortOrder string
|
||||||
}
|
}
|
||||||
if limit > 0 {
|
|
||||||
query = query.Limit(limit)
|
type NodeAccessLogBucketQuery struct {
|
||||||
}
|
NodeID string
|
||||||
err = query.Find(&logs).Error
|
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
|
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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
|
||||||
if (normalizedNodeId) {
|
|
||||||
searchParams.set('node_id', normalizedNodeId);
|
|
||||||
}
|
}
|
||||||
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';
|
'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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user