mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +08:00
[优化] 添加获取折叠访问日志 IP 概要的 API 和前端支持
This commit is contained in:
@@ -59,6 +59,43 @@ func GetFoldedAccessLogs(c *gin.Context) {
|
|||||||
respondSuccess(c, logs)
|
respondSuccess(c, logs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFoldedAccessLogIPs godoc
|
||||||
|
// @Summary List folded 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 path query string false "Path"
|
||||||
|
// @Param bucket_started_at query string true "Bucket started at"
|
||||||
|
// @Param fold_minutes query int true "Fold minutes"
|
||||||
|
// @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/folds/ip-summary [get]
|
||||||
|
func GetFoldedAccessLogIPs(c *gin.Context) {
|
||||||
|
result, err := service.ListFoldedAccessLogIPs(service.FoldedAccessLogIPQuery{
|
||||||
|
NodeID: c.Query("node_id"),
|
||||||
|
RemoteAddr: c.Query("remote_addr"),
|
||||||
|
Host: c.Query("host"),
|
||||||
|
Path: c.Query("path"),
|
||||||
|
BucketStartedAt: c.Query("bucket_started_at"),
|
||||||
|
FoldMinutes: readQueryInt(c, "fold_minutes"),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// GetAccessLogIPSummaries godoc
|
// GetAccessLogIPSummaries godoc
|
||||||
// @Summary List access log IP summaries
|
// @Summary List access log IP summaries
|
||||||
// @Tags AccessLogs
|
// @Tags AccessLogs
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type NodeAccessLogQuery struct {
|
|||||||
Host string
|
Host string
|
||||||
Path string
|
Path string
|
||||||
Since time.Time
|
Since time.Time
|
||||||
|
Until time.Time
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
SortBy string
|
SortBy string
|
||||||
@@ -61,6 +62,28 @@ type NodeAccessLogBucketRow struct {
|
|||||||
ServerErrorCount int64 `json:"server_error_count"`
|
ServerErrorCount int64 `json:"server_error_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NodeAccessLogBucketIPQuery struct {
|
||||||
|
NodeID string
|
||||||
|
RemoteAddr string
|
||||||
|
Host string
|
||||||
|
Path string
|
||||||
|
BucketStartedAt time.Time
|
||||||
|
FoldMinutes int
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NodeAccessLogBucketIPRow struct {
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
SuccessCount int64 `json:"success_count"`
|
||||||
|
ClientErrorCount int64 `json:"client_error_count"`
|
||||||
|
ServerErrorCount int64 `json:"server_error_count"`
|
||||||
|
LastSeenEpoch int64 `json:"last_seen_epoch"`
|
||||||
|
}
|
||||||
|
|
||||||
type NodeAccessLogIPSummaryQuery struct {
|
type NodeAccessLogIPSummaryQuery struct {
|
||||||
NodeID string
|
NodeID string
|
||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
@@ -184,6 +207,26 @@ func CountNodeAccessLogBuckets(query NodeAccessLogBucketQuery) (total int64, err
|
|||||||
return int64(len(rows)), nil
|
return int64(len(rows)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListNodeAccessLogBucketIPs(query NodeAccessLogBucketIPQuery) (items []*NodeAccessLogBucketIPRow, err error) {
|
||||||
|
rows, err := buildNodeAccessLogBucketIPRows(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
start, end := paginateBounds(len(rows), query.Page, query.PageSize)
|
||||||
|
if start >= len(rows) {
|
||||||
|
return []*NodeAccessLogBucketIPRow{}, nil
|
||||||
|
}
|
||||||
|
return rows[start:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CountNodeAccessLogBucketIPs(query NodeAccessLogBucketIPQuery) (total int64, err error) {
|
||||||
|
rows, err := buildNodeAccessLogBucketIPRows(query)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return int64(len(rows)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
|
func ListNodeAccessLogIPSummaries(query NodeAccessLogIPSummaryQuery, recentSince time.Time) (items []*NodeAccessLogIPSummaryRow, err error) {
|
||||||
rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince)
|
rows, err := buildNodeAccessLogIPSummaryRows(query, recentSince)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -309,6 +352,9 @@ func applyNodeAccessLogFilters(db *gorm.DB, query NodeAccessLogQuery) *gorm.DB {
|
|||||||
if !query.Since.IsZero() {
|
if !query.Since.IsZero() {
|
||||||
db = db.Where("logged_at >= ?", query.Since)
|
db = db.Where("logged_at >= ?", query.Since)
|
||||||
}
|
}
|
||||||
|
if !query.Until.IsZero() {
|
||||||
|
db = db.Where("logged_at < ?", query.Until)
|
||||||
|
}
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,6 +438,75 @@ func buildNodeAccessLogBucketRows(query NodeAccessLogBucketQuery) ([]*NodeAccess
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildNodeAccessLogBucketIPRows(query NodeAccessLogBucketIPQuery) ([]*NodeAccessLogBucketIPRow, error) {
|
||||||
|
if query.BucketStartedAt.IsZero() {
|
||||||
|
return []*NodeAccessLogBucketIPRow{}, nil
|
||||||
|
}
|
||||||
|
foldMinutes := query.FoldMinutes
|
||||||
|
if foldMinutes <= 0 {
|
||||||
|
foldMinutes = 3
|
||||||
|
}
|
||||||
|
bucketStartedAt := query.BucketStartedAt.UTC()
|
||||||
|
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
|
||||||
|
NodeID: query.NodeID,
|
||||||
|
RemoteAddr: query.RemoteAddr,
|
||||||
|
Host: query.Host,
|
||||||
|
Path: query.Path,
|
||||||
|
Since: bucketStartedAt,
|
||||||
|
Until: bucketStartedAt.Add(time.Duration(foldMinutes) * time.Minute),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
type accumulator struct {
|
||||||
|
requestCount int64
|
||||||
|
successCount int64
|
||||||
|
clientErrorCount int64
|
||||||
|
serverErrorCount int64
|
||||||
|
lastSeenAt time.Time
|
||||||
|
}
|
||||||
|
accumulators := make(map[string]*accumulator)
|
||||||
|
for _, item := range logs {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteAddr := strings.TrimSpace(item.RemoteAddr)
|
||||||
|
if remoteAddr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc := accumulators[remoteAddr]
|
||||||
|
if acc == nil {
|
||||||
|
acc = &accumulator{}
|
||||||
|
accumulators[remoteAddr] = acc
|
||||||
|
}
|
||||||
|
acc.requestCount++
|
||||||
|
switch {
|
||||||
|
case item.StatusCode < 400:
|
||||||
|
acc.successCount++
|
||||||
|
case item.StatusCode < 500:
|
||||||
|
acc.clientErrorCount++
|
||||||
|
default:
|
||||||
|
acc.serverErrorCount++
|
||||||
|
}
|
||||||
|
if item.LoggedAt.After(acc.lastSeenAt) {
|
||||||
|
acc.lastSeenAt = item.LoggedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows := make([]*NodeAccessLogBucketIPRow, 0, len(accumulators))
|
||||||
|
for remoteAddr, acc := range accumulators {
|
||||||
|
rows = append(rows, &NodeAccessLogBucketIPRow{
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
RequestCount: acc.requestCount,
|
||||||
|
SuccessCount: acc.successCount,
|
||||||
|
ClientErrorCount: acc.clientErrorCount,
|
||||||
|
ServerErrorCount: acc.serverErrorCount,
|
||||||
|
LastSeenEpoch: acc.lastSeenAt.Unix(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sortNodeAccessLogBucketIPRows(rows, query.SortBy, query.SortOrder)
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) {
|
func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSince time.Time) ([]*NodeAccessLogIPSummaryRow, error) {
|
||||||
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
|
logs, err := listNodeAccessLogsAcrossShards(NodeAccessLogQuery{
|
||||||
NodeID: query.NodeID,
|
NodeID: query.NodeID,
|
||||||
@@ -442,6 +557,36 @@ func buildNodeAccessLogIPSummaryRows(query NodeAccessLogIPSummaryQuery, recentSi
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortNodeAccessLogBucketIPRows(items []*NodeAccessLogBucketIPRow, sortBy string, sortOrder string) {
|
||||||
|
desc := normalizeSortOrder(sortOrder) != "asc"
|
||||||
|
sort.Slice(items, func(i int, j int) bool {
|
||||||
|
left := items[i]
|
||||||
|
right := items[j]
|
||||||
|
if left == nil || right == nil {
|
||||||
|
return left != nil
|
||||||
|
}
|
||||||
|
var compare int
|
||||||
|
switch strings.TrimSpace(sortBy) {
|
||||||
|
case "last_seen_at":
|
||||||
|
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
|
||||||
|
case "remote_addr":
|
||||||
|
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
|
||||||
|
default:
|
||||||
|
compare = compareInt64(left.RequestCount, right.RequestCount)
|
||||||
|
}
|
||||||
|
if compare == 0 {
|
||||||
|
compare = compareInt64(left.LastSeenEpoch, right.LastSeenEpoch)
|
||||||
|
}
|
||||||
|
if compare == 0 {
|
||||||
|
compare = strings.Compare(left.RemoteAddr, right.RemoteAddr)
|
||||||
|
}
|
||||||
|
if desc {
|
||||||
|
return compare > 0
|
||||||
|
}
|
||||||
|
return compare < 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) {
|
func sortNodeAccessLogs(items []*NodeAccessLog, sortBy string, sortOrder string) {
|
||||||
desc := normalizeSortOrder(sortOrder) != "asc"
|
desc := normalizeSortOrder(sortOrder) != "asc"
|
||||||
sort.Slice(items, func(i int, j int) bool {
|
sort.Slice(items, func(i int, j int) bool {
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
{
|
{
|
||||||
accessLogRoute.GET("/", controller.GetAccessLogs)
|
accessLogRoute.GET("/", controller.GetAccessLogs)
|
||||||
accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs)
|
accessLogRoute.GET("/folds", controller.GetFoldedAccessLogs)
|
||||||
|
accessLogRoute.GET("/folds/ip-summary", controller.GetFoldedAccessLogIPs)
|
||||||
accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries)
|
accessLogRoute.GET("/ip-summary", controller.GetAccessLogIPSummaries)
|
||||||
accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend)
|
accessLogRoute.GET("/ip-summary/trend", controller.GetAccessLogIPTrend)
|
||||||
accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs)
|
accessLogRoute.POST("/cleanup", controller.CleanupAccessLogs)
|
||||||
|
|||||||
@@ -73,6 +73,40 @@ type FoldedAccessLogList struct {
|
|||||||
FoldMinutes int `json:"fold_minutes"`
|
FoldMinutes int `json:"fold_minutes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FoldedAccessLogIPQuery struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
BucketStartedAt string `json:"bucket_started_at"`
|
||||||
|
FoldMinutes int `json:"fold_minutes"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
SortBy string `json:"sort_by"`
|
||||||
|
SortOrder string `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FoldedAccessLogIPView struct {
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
RequestCount int64 `json:"request_count"`
|
||||||
|
SuccessCount int64 `json:"success_count"`
|
||||||
|
ClientErrorCount int64 `json:"client_error_count"`
|
||||||
|
ServerErrorCount int64 `json:"server_error_count"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FoldedAccessLogIPList struct {
|
||||||
|
Items []FoldedAccessLogIPView `json:"items"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
TotalIP int64 `json:"total_ip"`
|
||||||
|
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||||
|
FoldMinutes int `json:"fold_minutes"`
|
||||||
|
SortBy string `json:"sort_by"`
|
||||||
|
SortOrder string `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
type AccessLogIPSummaryQuery struct {
|
type AccessLogIPSummaryQuery struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
RemoteAddr string `json:"remote_addr"`
|
RemoteAddr string `json:"remote_addr"`
|
||||||
@@ -187,7 +221,7 @@ func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
|
|||||||
Since: modelQuery.Since,
|
Since: modelQuery.Since,
|
||||||
Page: normalized.Page,
|
Page: normalized.Page,
|
||||||
PageSize: normalized.PageSize,
|
PageSize: normalized.PageSize,
|
||||||
SortBy: normalizeFoldSortBy(normalized.SortBy),
|
SortBy: normalizeFoldSortBy(input.SortBy),
|
||||||
SortOrder: normalized.SortOrder,
|
SortOrder: normalized.SortOrder,
|
||||||
FoldMinutes: foldMinutes,
|
FoldMinutes: foldMinutes,
|
||||||
}
|
}
|
||||||
@@ -230,6 +264,58 @@ func ListFoldedAccessLogs(input AccessLogQuery) (*FoldedAccessLogList, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ListFoldedAccessLogIPs(input FoldedAccessLogIPQuery) (*FoldedAccessLogIPList, error) {
|
||||||
|
normalized, bucketStartedAt, err := normalizeFoldedAccessLogIPQuery(input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
modelQuery := model.NodeAccessLogBucketIPQuery{
|
||||||
|
NodeID: normalized.NodeID,
|
||||||
|
RemoteAddr: normalized.RemoteAddr,
|
||||||
|
Host: normalized.Host,
|
||||||
|
Path: normalized.Path,
|
||||||
|
BucketStartedAt: bucketStartedAt,
|
||||||
|
FoldMinutes: normalized.FoldMinutes,
|
||||||
|
Page: normalized.Page,
|
||||||
|
PageSize: normalized.PageSize,
|
||||||
|
SortBy: normalized.SortBy,
|
||||||
|
SortOrder: normalized.SortOrder,
|
||||||
|
}
|
||||||
|
items, err := model.ListNodeAccessLogBucketIPs(modelQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
totalIP, err := model.CountNodeAccessLogBucketIPs(modelQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
views := make([]FoldedAccessLogIPView, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
views = append(views, FoldedAccessLogIPView{
|
||||||
|
RemoteAddr: item.RemoteAddr,
|
||||||
|
RequestCount: item.RequestCount,
|
||||||
|
SuccessCount: item.SuccessCount,
|
||||||
|
ClientErrorCount: item.ClientErrorCount,
|
||||||
|
ServerErrorCount: item.ServerErrorCount,
|
||||||
|
LastSeenAt: time.Unix(item.LastSeenEpoch, 0).UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &FoldedAccessLogIPList{
|
||||||
|
Items: views,
|
||||||
|
Page: normalized.Page,
|
||||||
|
PageSize: normalized.PageSize,
|
||||||
|
HasMore: int64((normalized.Page+1)*normalized.PageSize) < totalIP,
|
||||||
|
TotalIP: totalIP,
|
||||||
|
BucketStartedAt: bucketStartedAt,
|
||||||
|
FoldMinutes: normalized.FoldMinutes,
|
||||||
|
SortBy: normalized.SortBy,
|
||||||
|
SortOrder: normalized.SortOrder,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
|
func ListAccessLogIPSummaries(input AccessLogIPSummaryQuery) (*AccessLogIPSummaryList, error) {
|
||||||
normalized := normalizeAccessLogIPSummaryQuery(input)
|
normalized := normalizeAccessLogIPSummaryQuery(input)
|
||||||
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow)
|
since := time.Now().UTC().Add(-nodeAccessLogRetentionWindow)
|
||||||
@@ -398,6 +484,35 @@ func normalizeAccessLogIPSummaryQuery(input AccessLogIPSummaryQuery) AccessLogIP
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeFoldedAccessLogIPQuery(input FoldedAccessLogIPQuery) (FoldedAccessLogIPQuery, time.Time, error) {
|
||||||
|
foldMinutes, err := normalizeFoldMinutes(input.FoldMinutes)
|
||||||
|
if err != nil {
|
||||||
|
return FoldedAccessLogIPQuery{}, time.Time{}, err
|
||||||
|
}
|
||||||
|
bucketStartedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(input.BucketStartedAt))
|
||||||
|
if err != nil {
|
||||||
|
return FoldedAccessLogIPQuery{}, time.Time{}, errors.New("bucket_started_at 必须为 RFC3339 时间")
|
||||||
|
}
|
||||||
|
normalizedSortBy := strings.TrimSpace(input.SortBy)
|
||||||
|
switch normalizedSortBy {
|
||||||
|
case "last_seen_at", "remote_addr":
|
||||||
|
default:
|
||||||
|
normalizedSortBy = "request_count"
|
||||||
|
}
|
||||||
|
return FoldedAccessLogIPQuery{
|
||||||
|
NodeID: strings.TrimSpace(input.NodeID),
|
||||||
|
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
|
||||||
|
Host: strings.TrimSpace(input.Host),
|
||||||
|
Path: strings.TrimSpace(input.Path),
|
||||||
|
BucketStartedAt: strings.TrimSpace(input.BucketStartedAt),
|
||||||
|
FoldMinutes: foldMinutes,
|
||||||
|
Page: normalizeAccessLogPage(input.Page),
|
||||||
|
PageSize: normalizeAccessLogPageSize(input.PageSize),
|
||||||
|
SortBy: normalizedSortBy,
|
||||||
|
SortOrder: normalizeAccessLogSortOrder(input.SortOrder),
|
||||||
|
}, bucketStartedAt.UTC(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
|
func normalizeAccessLogIPTrendQuery(input AccessLogIPTrendQuery) (AccessLogIPTrendQuery, error) {
|
||||||
remoteAddr := strings.TrimSpace(input.RemoteAddr)
|
remoteAddr := strings.TrimSpace(input.RemoteAddr)
|
||||||
if remoteAddr == "" {
|
if remoteAddr == "" {
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
|
|||||||
NodeID: "node-folded",
|
NodeID: "node-folded",
|
||||||
Page: 0,
|
Page: 0,
|
||||||
PageSize: 10,
|
PageSize: 10,
|
||||||
|
SortBy: "request_count",
|
||||||
|
SortOrder: "desc",
|
||||||
FoldMinutes: 5,
|
FoldMinutes: 5,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -193,6 +195,28 @@ func TestListFoldedAccessLogsAndIPSummaries(t *testing.T) {
|
|||||||
if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 {
|
if folded.Items[0].RequestCount+folded.Items[1].RequestCount != 3 {
|
||||||
t.Fatalf("unexpected folded request count sum: %+v", folded.Items)
|
t.Fatalf("unexpected folded request count sum: %+v", folded.Items)
|
||||||
}
|
}
|
||||||
|
if folded.Items[0].RequestCount != 2 {
|
||||||
|
t.Fatalf("expected folded buckets to sort by request_count desc, got %+v", folded.Items)
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketIPs, err := ListFoldedAccessLogIPs(FoldedAccessLogIPQuery{
|
||||||
|
NodeID: "node-folded",
|
||||||
|
BucketStartedAt: folded.Items[0].BucketStartedAt.Format(time.RFC3339),
|
||||||
|
FoldMinutes: 5,
|
||||||
|
Page: 0,
|
||||||
|
PageSize: 10,
|
||||||
|
SortBy: "request_count",
|
||||||
|
SortOrder: "desc",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFoldedAccessLogIPs failed: %v", err)
|
||||||
|
}
|
||||||
|
if bucketIPs.TotalIP != 1 || len(bucketIPs.Items) != 1 {
|
||||||
|
t.Fatalf("expected one folded bucket IP row, got %+v", bucketIPs)
|
||||||
|
}
|
||||||
|
if bucketIPs.Items[0].RemoteAddr != "203.0.113.1" || bucketIPs.Items[0].RequestCount != 2 {
|
||||||
|
t.Fatalf("unexpected top folded bucket IP row: %+v", bucketIPs.Items[0])
|
||||||
|
}
|
||||||
|
|
||||||
ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{
|
ipSummaries, err := ListAccessLogIPSummaries(AccessLogIPSummaryQuery{
|
||||||
NodeID: "node-folded",
|
NodeID: "node-folded",
|
||||||
|
|||||||
@@ -9,13 +9,17 @@ import type {
|
|||||||
AccessLogIPTrend,
|
AccessLogIPTrend,
|
||||||
AccessLogIPTrendFilters,
|
AccessLogIPTrendFilters,
|
||||||
AccessLogList,
|
AccessLogList,
|
||||||
|
FoldedAccessLogIPFilters,
|
||||||
|
FoldedAccessLogIPList,
|
||||||
FoldedAccessLogFilters,
|
FoldedAccessLogFilters,
|
||||||
FoldedAccessLogList,
|
FoldedAccessLogList,
|
||||||
} from '@/features/access-logs/types';
|
} from '@/features/access-logs/types';
|
||||||
|
|
||||||
function buildSearchParams(filters: object) {
|
function buildSearchParams(filters: object) {
|
||||||
const searchParams = new URLSearchParams();
|
const searchParams = new URLSearchParams();
|
||||||
Object.entries(filters as Record<string, string | number | undefined>).forEach(([key, value]) => {
|
Object.entries(
|
||||||
|
filters as Record<string, string | number | undefined>,
|
||||||
|
).forEach(([key, value]) => {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -31,7 +35,16 @@ export function getAccessLogs(filters: AccessLogFilters) {
|
|||||||
|
|
||||||
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
|
export function getFoldedAccessLogs(filters: FoldedAccessLogFilters) {
|
||||||
const query = buildSearchParams(filters);
|
const query = buildSearchParams(filters);
|
||||||
return apiRequest<FoldedAccessLogList>(`/access-logs/folds${query ? `?${query}` : ''}`);
|
return apiRequest<FoldedAccessLogList>(
|
||||||
|
`/access-logs/folds${query ? `?${query}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFoldedAccessLogIPs(filters: FoldedAccessLogIPFilters) {
|
||||||
|
const query = buildSearchParams(filters);
|
||||||
|
return apiRequest<FoldedAccessLogIPList>(
|
||||||
|
`/access-logs/folds/ip-summary${query ? `?${query}` : ''}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
|
export function getAccessLogIPSummaries(filters: AccessLogIPSummaryFilters) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo, useState } from 'react';
|
import { Fragment, useMemo, useState } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { TrendChart } from '@/components/data/trend-chart';
|
import { TrendChart } from '@/components/data/trend-chart';
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getAccessLogIPSummaries,
|
getAccessLogIPSummaries,
|
||||||
getAccessLogIPTrend,
|
getAccessLogIPTrend,
|
||||||
getAccessLogs,
|
getAccessLogs,
|
||||||
|
getFoldedAccessLogIPs,
|
||||||
getFoldedAccessLogs,
|
getFoldedAccessLogs,
|
||||||
} from '@/features/access-logs/api/access-logs';
|
} from '@/features/access-logs/api/access-logs';
|
||||||
import type {
|
import type {
|
||||||
@@ -23,6 +24,7 @@ import type {
|
|||||||
AccessLogIPSummaryItem,
|
AccessLogIPSummaryItem,
|
||||||
AccessLogIPSummaryList,
|
AccessLogIPSummaryList,
|
||||||
AccessLogList,
|
AccessLogList,
|
||||||
|
FoldedAccessLogIPList,
|
||||||
FoldedAccessLogList,
|
FoldedAccessLogList,
|
||||||
} from '@/features/access-logs/types';
|
} from '@/features/access-logs/types';
|
||||||
import {
|
import {
|
||||||
@@ -94,7 +96,10 @@ function buildSummary(totalRecord = 0, totalIP = 0, activeTab: ActiveTab) {
|
|||||||
return [
|
return [
|
||||||
{ label: '访问记录', value: formatCompactNumber(totalRecord) },
|
{ label: '访问记录', value: formatCompactNumber(totalRecord) },
|
||||||
{ label: '来源 IP', value: formatCompactNumber(totalIP) },
|
{ label: '来源 IP', value: formatCompactNumber(totalIP) },
|
||||||
{ label: '当前视图', value: activeTab === 'detail' ? '明细日志' : 'IP 维度' },
|
{
|
||||||
|
label: '当前视图',
|
||||||
|
value: activeTab === 'detail' ? '明细日志' : 'IP 维度',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +117,36 @@ function buildTrendLabels(points: Array<{ bucket_started_at: string }>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFoldedAccessLogList(
|
||||||
|
value: AccessLogList | FoldedAccessLogList | undefined,
|
||||||
|
): value is FoldedAccessLogList {
|
||||||
|
if (!value || !Array.isArray(value.items)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.items.every((item) => {
|
||||||
|
const candidate = item as Partial<FoldedAccessLogList['items'][number]>;
|
||||||
|
return (
|
||||||
|
typeof candidate.bucket_started_at === 'string' &&
|
||||||
|
typeof candidate.request_count === 'number'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessLogList(
|
||||||
|
value: AccessLogList | FoldedAccessLogList | undefined,
|
||||||
|
): value is AccessLogList {
|
||||||
|
if (!value || !Array.isArray(value.items)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return value.items.every((item) => {
|
||||||
|
const candidate = item as Partial<AccessLogList['items'][number]>;
|
||||||
|
return (
|
||||||
|
typeof candidate.logged_at === 'string' &&
|
||||||
|
typeof candidate.status_code === 'number'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function AccessLogsPage() {
|
export function AccessLogsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('detail');
|
const [activeTab, setActiveTab] = useState<ActiveTab>('detail');
|
||||||
@@ -134,7 +169,13 @@ export function AccessLogsPage() {
|
|||||||
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
||||||
const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc');
|
const [foldedSort, setFoldedSort] = useState('bucket_started_at:desc');
|
||||||
const [ipSort, setIPSort] = useState('total_requests:desc');
|
const [ipSort, setIPSort] = useState('total_requests:desc');
|
||||||
const [selectedIP, setSelectedIP] = useState<AccessLogIPSummaryItem | null>(null);
|
const [expandedBucket, setExpandedBucket] = useState<string | null>(null);
|
||||||
|
const [bucketIPPages, setBucketIPPages] = useState<Record<string, number>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const [selectedIP, setSelectedIP] = useState<AccessLogIPSummaryItem | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [cleanupDays, setCleanupDays] = useState<string>('7');
|
const [cleanupDays, setCleanupDays] = useState<string>('7');
|
||||||
const [customCleanupDays, setCustomCleanupDays] = useState('14');
|
const [customCleanupDays, setCustomCleanupDays] = useState('14');
|
||||||
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
|
const [isCleanupModalOpen, setCleanupModalOpen] = useState(false);
|
||||||
@@ -179,8 +220,14 @@ export function AccessLogsPage() {
|
|||||||
sort_order: detailSortState.sortOrder,
|
sort_order: detailSortState.sortOrder,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
placeholderData: (previousData: AccessLogList | FoldedAccessLogList | undefined) =>
|
placeholderData: (
|
||||||
previousData,
|
previousData: AccessLogList | FoldedAccessLogList | undefined,
|
||||||
|
) => {
|
||||||
|
if (foldMinutes > 0) {
|
||||||
|
return isFoldedAccessLogList(previousData) ? previousData : undefined;
|
||||||
|
}
|
||||||
|
return isAccessLogList(previousData) ? previousData : undefined;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ipSummaryQuery = useQuery<AccessLogIPSummaryList>({
|
const ipSummaryQuery = useQuery<AccessLogIPSummaryList>({
|
||||||
@@ -195,11 +242,18 @@ export function AccessLogsPage() {
|
|||||||
sort_by: ipSortState.sortBy,
|
sort_by: ipSortState.sortBy,
|
||||||
sort_order: ipSortState.sortOrder,
|
sort_order: ipSortState.sortOrder,
|
||||||
}),
|
}),
|
||||||
placeholderData: (previousData: AccessLogIPSummaryList | undefined) => previousData,
|
placeholderData: (previousData: AccessLogIPSummaryList | undefined) =>
|
||||||
|
previousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ipTrendQuery = useQuery({
|
const ipTrendQuery = useQuery({
|
||||||
queryKey: ['access-logs', 'ip-trend', selectedIP?.remote_addr, filters.nodeId, filters.host],
|
queryKey: [
|
||||||
|
'access-logs',
|
||||||
|
'ip-trend',
|
||||||
|
selectedIP?.remote_addr,
|
||||||
|
filters.nodeId,
|
||||||
|
filters.host,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getAccessLogIPTrend({
|
getAccessLogIPTrend({
|
||||||
node_id: filters.nodeId || undefined,
|
node_id: filters.nodeId || undefined,
|
||||||
@@ -212,18 +266,26 @@ export function AccessLogsPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cleanupMutation = useMutation({
|
const cleanupMutation = useMutation({
|
||||||
mutationFn: (payload: AccessLogCleanupPayload) => cleanupAccessLogs(payload),
|
mutationFn: (payload: AccessLogCleanupPayload) =>
|
||||||
|
cleanupAccessLogs(payload),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setCleanupModalOpen(false);
|
setCleanupModalOpen(false);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }),
|
queryClient.invalidateQueries({ queryKey: ['access-logs', 'detail'] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-summary'] }),
|
queryClient.invalidateQueries({
|
||||||
queryClient.invalidateQueries({ queryKey: ['access-logs', 'ip-trend'] }),
|
queryKey: ['access-logs', 'ip-summary'],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ['access-logs', 'ip-trend'],
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const detailSummaryData = detailQuery.data as AccessLogList | FoldedAccessLogList | undefined;
|
const detailSummaryData = detailQuery.data as
|
||||||
|
| AccessLogList
|
||||||
|
| FoldedAccessLogList
|
||||||
|
| undefined;
|
||||||
const summary = useMemo(
|
const summary = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildSummary(
|
buildSummary(
|
||||||
@@ -252,6 +314,8 @@ export function AccessLogsPage() {
|
|||||||
});
|
});
|
||||||
setDetailPage(0);
|
setDetailPage(0);
|
||||||
setIPPage(0);
|
setIPPage(0);
|
||||||
|
setExpandedBucket(null);
|
||||||
|
setBucketIPPages({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -261,6 +325,8 @@ export function AccessLogsPage() {
|
|||||||
setDetailPage(0);
|
setDetailPage(0);
|
||||||
setIPPage(0);
|
setIPPage(0);
|
||||||
setSelectedIP(null);
|
setSelectedIP(null);
|
||||||
|
setExpandedBucket(null);
|
||||||
|
setBucketIPPages({});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCleanupConfirm = () => {
|
const handleCleanupConfirm = () => {
|
||||||
@@ -277,13 +343,19 @@ export function AccessLogsPage() {
|
|||||||
title="日志"
|
title="日志"
|
||||||
description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。"
|
description="升级后的日志中心支持按节点、IP、域名与路径检索,支持时间折叠、IP 维度聚合与按保留天数清理旧日志。"
|
||||||
action={
|
action={
|
||||||
<PrimaryButton type="button" onClick={() => setCleanupModalOpen(true)}>
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCleanupModalOpen(true)}
|
||||||
|
>
|
||||||
清理日志
|
清理日志
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AppCard title="日志摘要" description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。">
|
<AppCard
|
||||||
|
title="日志摘要"
|
||||||
|
description="所有汇总、排序、折叠与分页都由后端计算,前端仅展示当前页结果。"
|
||||||
|
>
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
{summary.map((item) => (
|
{summary.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -320,8 +392,16 @@ export function AccessLogsPage() {
|
|||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
{ key: 'detail', label: '明细日志', description: '按请求明细查看与折叠聚合' },
|
{
|
||||||
{ key: 'ip', label: 'IP 维度', description: '查看来源 IP 汇总与趋势' },
|
key: 'detail',
|
||||||
|
label: '明细日志',
|
||||||
|
description: '按请求明细查看与折叠聚合',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ip',
|
||||||
|
label: 'IP 维度',
|
||||||
|
description: '查看来源 IP 汇总与趋势',
|
||||||
|
},
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
@@ -344,7 +424,10 @@ export function AccessLogsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={draft.nodeId}
|
value={draft.nodeId}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({ ...current, nodeId: event.target.value }))
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
nodeId: event.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder="按 node_id 搜索"
|
placeholder="按 node_id 搜索"
|
||||||
/>
|
/>
|
||||||
@@ -353,7 +436,10 @@ export function AccessLogsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={draft.remoteAddr}
|
value={draft.remoteAddr}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({ ...current, remoteAddr: event.target.value }))
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
remoteAddr: event.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder="按 IP 搜索"
|
placeholder="按 IP 搜索"
|
||||||
/>
|
/>
|
||||||
@@ -362,20 +448,30 @@ export function AccessLogsPage() {
|
|||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={draft.host}
|
value={draft.host}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({ ...current, host: event.target.value }))
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
host: event.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder="按域名搜索"
|
placeholder="按域名搜索"
|
||||||
/>
|
/>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ResourceField
|
<ResourceField
|
||||||
label="请求路径"
|
label="请求路径"
|
||||||
hint={activeTab === 'ip' ? 'IP 维度页暂不使用路径过滤。' : '支持按路径模糊过滤。'}
|
hint={
|
||||||
|
activeTab === 'ip'
|
||||||
|
? 'IP 维度页暂不使用路径过滤。'
|
||||||
|
: '支持按路径模糊过滤。'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ResourceInput
|
<ResourceInput
|
||||||
value={draft.path}
|
value={draft.path}
|
||||||
disabled={activeTab === 'ip'}
|
disabled={activeTab === 'ip'}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setDraft((current) => ({ ...current, path: event.target.value }))
|
setDraft((current) => ({
|
||||||
|
...current,
|
||||||
|
path: event.target.value,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
placeholder="按路径搜索"
|
placeholder="按路径搜索"
|
||||||
/>
|
/>
|
||||||
@@ -400,7 +496,11 @@ export function AccessLogsPage() {
|
|||||||
</ResourceSelect>
|
</ResourceSelect>
|
||||||
</ResourceField>
|
</ResourceField>
|
||||||
|
|
||||||
<ResourceField label={activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'}>
|
<ResourceField
|
||||||
|
label={
|
||||||
|
activeTab === 'detail' && foldMinutes > 0 ? '折叠排序' : '排序'
|
||||||
|
}
|
||||||
|
>
|
||||||
<ResourceSelect
|
<ResourceSelect
|
||||||
value={
|
value={
|
||||||
activeTab === 'detail' && foldMinutes > 0
|
activeTab === 'detail' && foldMinutes > 0
|
||||||
@@ -444,6 +544,8 @@ export function AccessLogsPage() {
|
|||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setFoldMinutes(Number(event.target.value) as 0 | 3 | 5);
|
setFoldMinutes(Number(event.target.value) as 0 | 3 | 5);
|
||||||
setDetailPage(0);
|
setDetailPage(0);
|
||||||
|
setExpandedBucket(null);
|
||||||
|
setBucketIPPages({});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{foldOptions.map((option) => (
|
{foldOptions.map((option) => (
|
||||||
@@ -474,7 +576,19 @@ export function AccessLogsPage() {
|
|||||||
detailPage={detailPage}
|
detailPage={detailPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
foldMinutes={foldMinutes}
|
foldMinutes={foldMinutes}
|
||||||
|
filters={filters}
|
||||||
query={detailQuery}
|
query={detailQuery}
|
||||||
|
expandedBucket={expandedBucket}
|
||||||
|
bucketIPPages={bucketIPPages}
|
||||||
|
onToggleBucket={(bucket) =>
|
||||||
|
setExpandedBucket((current) => (current === bucket ? null : bucket))
|
||||||
|
}
|
||||||
|
onBucketIPPageChange={(bucket, nextPage) =>
|
||||||
|
setBucketIPPages((current) => ({
|
||||||
|
...current,
|
||||||
|
[bucket]: Math.max(nextPage, 0),
|
||||||
|
}))
|
||||||
|
}
|
||||||
onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))}
|
onPrevPage={() => setDetailPage((value) => Math.max(value - 1, 0))}
|
||||||
onNextPage={() => setDetailPage((value) => value + 1)}
|
onNextPage={() => setDetailPage((value) => value + 1)}
|
||||||
/>
|
/>
|
||||||
@@ -499,7 +613,10 @@ export function AccessLogsPage() {
|
|||||||
{ipTrendQuery.isLoading ? (
|
{ipTrendQuery.isLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : ipTrendQuery.isError ? (
|
) : ipTrendQuery.isError ? (
|
||||||
<ErrorState title="IP 趋势加载失败" description={getErrorMessage(ipTrendQuery.error)} />
|
<ErrorState
|
||||||
|
title="IP 趋势加载失败"
|
||||||
|
description={getErrorMessage(ipTrendQuery.error)}
|
||||||
|
/>
|
||||||
) : ipTrendQuery.data ? (
|
) : ipTrendQuery.data ? (
|
||||||
<TrendChart
|
<TrendChart
|
||||||
labels={trendLabels}
|
labels={trendLabels}
|
||||||
@@ -515,7 +632,10 @@ export function AccessLogsPage() {
|
|||||||
yAxisValueFormatter={(value) => formatCompactNumber(value)}
|
yAxisValueFormatter={(value) => formatCompactNumber(value)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="暂无趋势数据" description="当前 IP 在最近 24 小时内没有可展示的访问曲线。" />
|
<EmptyState
|
||||||
|
title="暂无趋势数据"
|
||||||
|
description="当前 IP 在最近 24 小时内没有可展示的访问曲线。"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</AppModal>
|
</AppModal>
|
||||||
|
|
||||||
@@ -526,7 +646,10 @@ export function AccessLogsPage() {
|
|||||||
onClose={() => setCleanupModalOpen(false)}
|
onClose={() => setCleanupModalOpen(false)}
|
||||||
footer={
|
footer={
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<SecondaryButton type="button" onClick={() => setCleanupModalOpen(false)}>
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCleanupModalOpen(false)}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
@@ -607,13 +730,19 @@ function DetailTab({
|
|||||||
detailPage,
|
detailPage,
|
||||||
pageSize,
|
pageSize,
|
||||||
foldMinutes,
|
foldMinutes,
|
||||||
|
filters,
|
||||||
query,
|
query,
|
||||||
|
expandedBucket,
|
||||||
|
bucketIPPages,
|
||||||
|
onToggleBucket,
|
||||||
|
onBucketIPPageChange,
|
||||||
onPrevPage,
|
onPrevPage,
|
||||||
onNextPage,
|
onNextPage,
|
||||||
}: {
|
}: {
|
||||||
detailPage: number;
|
detailPage: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
foldMinutes: 0 | 3 | 5;
|
foldMinutes: 0 | 3 | 5;
|
||||||
|
filters: AppliedSearch;
|
||||||
query: {
|
query: {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isError: boolean;
|
isError: boolean;
|
||||||
@@ -621,6 +750,10 @@ function DetailTab({
|
|||||||
error: unknown;
|
error: unknown;
|
||||||
data?: AccessLogList | FoldedAccessLogList;
|
data?: AccessLogList | FoldedAccessLogList;
|
||||||
};
|
};
|
||||||
|
expandedBucket: string | null;
|
||||||
|
bucketIPPages: Record<string, number>;
|
||||||
|
onToggleBucket: (bucket: string) => void;
|
||||||
|
onBucketIPPageChange: (bucket: string, page: number) => void;
|
||||||
onPrevPage: () => void;
|
onPrevPage: () => void;
|
||||||
onNextPage: () => void;
|
onNextPage: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -635,13 +768,26 @@ function DetailTab({
|
|||||||
if (query.isError) {
|
if (query.isError) {
|
||||||
return (
|
return (
|
||||||
<AppCard title="访问日志" description="日志查询失败。">
|
<AppCard title="访问日志" description="日志查询失败。">
|
||||||
<ErrorState title="访问日志加载失败" description={getErrorMessage(query.error)} />
|
<ErrorState
|
||||||
|
title="访问日志加载失败"
|
||||||
|
description={getErrorMessage(query.error)}
|
||||||
|
/>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foldMinutes > 0) {
|
if (foldMinutes > 0) {
|
||||||
const data = query.data as FoldedAccessLogList;
|
if (!isFoldedAccessLogList(query.data)) {
|
||||||
|
return (
|
||||||
|
<AppCard
|
||||||
|
title="折叠日志"
|
||||||
|
description={`当前按 ${foldMinutes} 分钟时间桶折叠,适合在高频刷新时快速定位异常波段。`}
|
||||||
|
>
|
||||||
|
<LoadingState />
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const data = query.data;
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
title="折叠日志"
|
title="折叠日志"
|
||||||
@@ -649,7 +795,10 @@ function DetailTab({
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<EmptyState title="暂无折叠日志" description="当前筛选条件下没有可展示的时间桶。" />
|
<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">
|
||||||
@@ -665,34 +814,83 @@ function DetailTab({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-[var(--border-default)]">
|
<tbody className="divide-y divide-[var(--border-default)]">
|
||||||
{data.items.map((item) => (
|
{data.items.map((item) => {
|
||||||
<tr key={item.bucket_started_at} className="align-top">
|
const isExpanded =
|
||||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
expandedBucket === item.bucket_started_at;
|
||||||
<div>{formatDateTime(item.bucket_started_at)}</div>
|
const bucketPage =
|
||||||
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
|
bucketIPPages[item.bucket_started_at] ?? 0;
|
||||||
{formatRelativeTime(item.bucket_started_at)}
|
return (
|
||||||
</div>
|
<Fragment key={item.bucket_started_at}>
|
||||||
</td>
|
<tr
|
||||||
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
|
key={item.bucket_started_at}
|
||||||
{formatCompactNumber(item.request_count)}
|
className="cursor-pointer align-top transition hover:bg-[var(--surface-elevated)]"
|
||||||
</td>
|
onClick={() => onToggleBucket(item.bucket_started_at)}
|
||||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
>
|
||||||
{formatCompactNumber(item.unique_ip_count)}
|
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
||||||
</td>
|
<button
|
||||||
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
|
type="button"
|
||||||
{formatCompactNumber(item.unique_host_count)}
|
onClick={(event) => {
|
||||||
</td>
|
event.stopPropagation();
|
||||||
<td className="px-3 py-4 text-emerald-600">
|
onToggleBucket(item.bucket_started_at);
|
||||||
{formatCompactNumber(item.success_count)}
|
}}
|
||||||
</td>
|
className="text-left font-medium text-[var(--foreground-primary)] underline-offset-4 hover:underline"
|
||||||
<td className="px-3 py-4 text-amber-600">
|
>
|
||||||
{formatCompactNumber(item.client_error_count)}
|
{formatDateTime(item.bucket_started_at)}
|
||||||
</td>
|
</button>
|
||||||
<td className="px-3 py-4 text-rose-600">
|
<div className="mt-1 text-xs text-[var(--foreground-muted)]">
|
||||||
{formatCompactNumber(item.server_error_count)}
|
{formatRelativeTime(item.bucket_started_at)}
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</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>
|
||||||
|
{isExpanded ? (
|
||||||
|
<tr key={`${item.bucket_started_at}-detail`}>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="bg-[var(--surface-elevated)] px-3 py-4"
|
||||||
|
>
|
||||||
|
<FoldedBucketIPDetails
|
||||||
|
bucketStartedAt={item.bucket_started_at}
|
||||||
|
foldMinutes={foldMinutes as 3 | 5}
|
||||||
|
filters={filters}
|
||||||
|
page={bucketPage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPrevPage={() =>
|
||||||
|
onBucketIPPageChange(
|
||||||
|
item.bucket_started_at,
|
||||||
|
bucketPage - 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onNextPage={() =>
|
||||||
|
onBucketIPPageChange(
|
||||||
|
item.bucket_started_at,
|
||||||
|
bucketPage + 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,6 +899,7 @@ function DetailTab({
|
|||||||
page={detailPage}
|
page={detailPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
hasMore={data.has_more}
|
hasMore={data.has_more}
|
||||||
|
itemCount={data.items.length}
|
||||||
isFetching={query.isFetching}
|
isFetching={query.isFetching}
|
||||||
onPrev={onPrevPage}
|
onPrev={onPrevPage}
|
||||||
onNext={onNextPage}
|
onNext={onNextPage}
|
||||||
@@ -710,7 +909,18 @@ function DetailTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = query.data as AccessLogList;
|
if (!isAccessLogList(query.data)) {
|
||||||
|
return (
|
||||||
|
<AppCard
|
||||||
|
title="访问明细"
|
||||||
|
description="展示原始访问明细,支持按时间、状态码、IP 与域名排序。"
|
||||||
|
>
|
||||||
|
<LoadingState />
|
||||||
|
</AppCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = query.data;
|
||||||
return (
|
return (
|
||||||
<AppCard
|
<AppCard
|
||||||
title="访问明细"
|
title="访问明细"
|
||||||
@@ -718,7 +928,10 @@ function DetailTab({
|
|||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<EmptyState title="暂无访问日志" description="当前筛选条件下没有可展示的访问明细。" />
|
<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">
|
||||||
@@ -769,7 +982,10 @@ function DetailTab({
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4">
|
<td className="px-3 py-4">
|
||||||
<StatusBadge label={statusMeta.label} variant={statusMeta.variant} />
|
<StatusBadge
|
||||||
|
label={statusMeta.label}
|
||||||
|
variant={statusMeta.variant}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -782,6 +998,7 @@ function DetailTab({
|
|||||||
page={detailPage}
|
page={detailPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
hasMore={data.has_more}
|
hasMore={data.has_more}
|
||||||
|
itemCount={data.items.length}
|
||||||
isFetching={query.isFetching}
|
isFetching={query.isFetching}
|
||||||
onPrev={onPrevPage}
|
onPrev={onPrevPage}
|
||||||
onNext={onNextPage}
|
onNext={onNextPage}
|
||||||
@@ -826,9 +1043,15 @@ function IPTab({
|
|||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
) : query.isError ? (
|
) : query.isError ? (
|
||||||
<ErrorState title="IP 汇总加载失败" description={getErrorMessage(query.error)} />
|
<ErrorState
|
||||||
|
title="IP 汇总加载失败"
|
||||||
|
description={getErrorMessage(query.error)}
|
||||||
|
/>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<EmptyState title="暂无 IP 访问记录" description="当前筛选条件下没有可展示的来源 IP。" />
|
<EmptyState
|
||||||
|
title="暂无 IP 访问记录"
|
||||||
|
description="当前筛选条件下没有可展示的来源 IP。"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -872,6 +1095,7 @@ function IPTab({
|
|||||||
page={ipPage}
|
page={ipPage}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
itemCount={items.length}
|
||||||
isFetching={query.isFetching}
|
isFetching={query.isFetching}
|
||||||
onPrev={onPrevPage}
|
onPrev={onPrevPage}
|
||||||
onNext={onNextPage}
|
onNext={onNextPage}
|
||||||
@@ -882,10 +1106,137 @@ function IPTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FoldedBucketIPDetails({
|
||||||
|
bucketStartedAt,
|
||||||
|
foldMinutes,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPrevPage,
|
||||||
|
onNextPage,
|
||||||
|
}: {
|
||||||
|
bucketStartedAt: string;
|
||||||
|
foldMinutes: 3 | 5;
|
||||||
|
filters: AppliedSearch;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPrevPage: () => void;
|
||||||
|
onNextPage: () => void;
|
||||||
|
}) {
|
||||||
|
const query = useQuery<FoldedAccessLogIPList>({
|
||||||
|
queryKey: [
|
||||||
|
'access-logs',
|
||||||
|
'fold-bucket-ip',
|
||||||
|
filters,
|
||||||
|
bucketStartedAt,
|
||||||
|
foldMinutes,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getFoldedAccessLogIPs({
|
||||||
|
node_id: filters.nodeId || undefined,
|
||||||
|
remote_addr: filters.remoteAddr || undefined,
|
||||||
|
host: filters.host || undefined,
|
||||||
|
path: filters.path || undefined,
|
||||||
|
bucket_started_at: bucketStartedAt,
|
||||||
|
fold_minutes: foldMinutes,
|
||||||
|
p: page,
|
||||||
|
page_size: pageSize,
|
||||||
|
sort_by: 'request_count',
|
||||||
|
sort_order: 'desc',
|
||||||
|
}),
|
||||||
|
placeholderData: (previousData: FoldedAccessLogIPList | undefined) =>
|
||||||
|
previousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (query.isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
if (query.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="时间桶 IP 明细加载失败"
|
||||||
|
description={getErrorMessage(query.error)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = query.data?.items ?? [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无 IP 明细"
|
||||||
|
description="该时间段内没有可展示的来源 IP。"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||||
|
时间段内 IP 访问情况
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--foreground-muted)]">
|
||||||
|
共 {formatCompactNumber(query.data?.total_ip ?? 0)} 个
|
||||||
|
IP,按访问次数从高到低排序
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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">2xx</th>
|
||||||
|
<th className="px-3 py-3 font-medium">4xx</th>
|
||||||
|
<th className="px-3 py-3 font-medium">5xx</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}>
|
||||||
|
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
|
||||||
|
{item.remote_addr}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||||
|
{formatCompactNumber(item.request_count)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-emerald-600">
|
||||||
|
{formatCompactNumber(item.success_count)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-amber-600">
|
||||||
|
{formatCompactNumber(item.client_error_count)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-rose-600">
|
||||||
|
{formatCompactNumber(item.server_error_count)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
|
||||||
|
{formatDateTime(item.last_seen_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<Pager
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
hasMore={query.data?.has_more ?? false}
|
||||||
|
itemCount={items.length}
|
||||||
|
isFetching={query.isFetching}
|
||||||
|
onPrev={onPrevPage}
|
||||||
|
onNext={onNextPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Pager({
|
function Pager({
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
hasMore,
|
hasMore,
|
||||||
|
itemCount,
|
||||||
isFetching,
|
isFetching,
|
||||||
onPrev,
|
onPrev,
|
||||||
onNext,
|
onNext,
|
||||||
@@ -893,20 +1244,30 @@ function Pager({
|
|||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
|
itemCount: number;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
onPrev: () => void;
|
onPrev: () => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const canTryNext = hasMore || itemCount >= pageSize;
|
||||||
return (
|
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 type="button" disabled={page === 0 || isFetching} onClick={onPrev}>
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={page === 0 || isFetching}
|
||||||
|
onClick={onPrev}
|
||||||
|
>
|
||||||
上一页
|
上一页
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<SecondaryButton type="button" disabled={!hasMore || isFetching} onClick={onNext}>
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={!canTryNext || isFetching}
|
||||||
|
onClick={onNext}
|
||||||
|
>
|
||||||
下一页
|
下一页
|
||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -55,6 +55,31 @@ export interface FoldedAccessLogList {
|
|||||||
fold_minutes: number;
|
fold_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FoldedAccessLogIPFilters extends FoldedAccessLogFilters {
|
||||||
|
bucket_started_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoldedAccessLogIPItem {
|
||||||
|
remote_addr: string;
|
||||||
|
request_count: number;
|
||||||
|
success_count: number;
|
||||||
|
client_error_count: number;
|
||||||
|
server_error_count: number;
|
||||||
|
last_seen_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FoldedAccessLogIPList {
|
||||||
|
items: FoldedAccessLogIPItem[];
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
has_more: boolean;
|
||||||
|
total_ip: number;
|
||||||
|
bucket_started_at: string;
|
||||||
|
fold_minutes: number;
|
||||||
|
sort_by: string;
|
||||||
|
sort_order: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccessLogIPSummaryFilters {
|
export interface AccessLogIPSummaryFilters {
|
||||||
node_id?: string;
|
node_id?: string;
|
||||||
remote_addr?: string;
|
remote_addr?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user