mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 07:06:36 +08:00
feat(access-logs): 重构访问日志为概览与明细双 Tab
新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行, 明细列表保留检索;排行榜改为紧凑列表样式。
This commit is contained in:
@@ -178,6 +178,44 @@ type AccessLogIPTrendView struct {
|
||||
Points []AccessLogIPTrendPoint `json:"points"`
|
||||
}
|
||||
|
||||
// AccessLogOverviewQuery filters access log overview queries.
|
||||
type AccessLogOverviewQuery struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Host string `json:"host"`
|
||||
Hours int `json:"hours"`
|
||||
}
|
||||
|
||||
// AccessLogOverviewMetricPoint is a single overview trend bucket.
|
||||
type AccessLogOverviewMetricPoint struct {
|
||||
BucketStartedAt time.Time `json:"bucket_started_at"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
// AccessLogOverviewSummary is the headline totals for the overview window.
|
||||
type AccessLogOverviewSummary struct {
|
||||
TotalRequests int64 `json:"total_requests"`
|
||||
TotalVisits int64 `json:"total_visits"`
|
||||
BandwidthServed int64 `json:"bandwidth_served"`
|
||||
}
|
||||
|
||||
// AccessLogOverviewTrends groups sparkline/series data for the overview.
|
||||
type AccessLogOverviewTrends struct {
|
||||
Requests []AccessLogOverviewMetricPoint `json:"requests"`
|
||||
Visits []AccessLogOverviewMetricPoint `json:"visits"`
|
||||
Bandwidth []AccessLogOverviewMetricPoint `json:"bandwidth"`
|
||||
}
|
||||
|
||||
// AccessLogOverview is the access-log analytics overview payload.
|
||||
type AccessLogOverview struct {
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
Hours int `json:"hours"`
|
||||
Summary AccessLogOverviewSummary `json:"summary"`
|
||||
Trends AccessLogOverviewTrends `json:"trends"`
|
||||
TopPaths []DistributionItem `json:"top_paths"`
|
||||
TopHosts []DistributionItem `json:"top_hosts"`
|
||||
TopIPs []DistributionItem `json:"top_ips"`
|
||||
}
|
||||
|
||||
// AccessLogCleanupInput is the cleanup request payload.
|
||||
type AccessLogCleanupInput struct {
|
||||
RetentionDays int `json:"retention_days"`
|
||||
@@ -190,6 +228,142 @@ type AccessLogCleanupResult struct {
|
||||
Cutoff time.Time `json:"cutoff"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultAccessLogOverviewHours = 24
|
||||
maxAccessLogOverviewHours = 24 * 30
|
||||
accessLogOverviewTopLimit = 10
|
||||
)
|
||||
|
||||
// GetAccessLogOverview returns summary metrics, trends, and top rankings.
|
||||
func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*AccessLogOverview, error) {
|
||||
normalized := normalizeAccessLogOverviewQuery(input)
|
||||
now := time.Now().UTC()
|
||||
since := now.Add(-time.Duration(normalized.Hours) * time.Hour)
|
||||
query := model.OpenFlareAccessLogQuery{
|
||||
NodeID: normalized.NodeID,
|
||||
Host: normalized.Host,
|
||||
Since: since,
|
||||
Until: now,
|
||||
}
|
||||
|
||||
summaryRow, err := model.TrafficSummaryOpenFlareAccessLogs(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestPoints, visitPoints, bandwidthPoints := buildAccessLogOverviewTrends(
|
||||
ctx, now, normalized.Hours, query,
|
||||
)
|
||||
|
||||
return &AccessLogOverview{
|
||||
GeneratedAt: now,
|
||||
Hours: normalized.Hours,
|
||||
Summary: AccessLogOverviewSummary{
|
||||
TotalRequests: summaryRow.RequestCount,
|
||||
TotalVisits: summaryRow.UniqueIPCount,
|
||||
BandwidthServed: summaryRow.BytesSent,
|
||||
},
|
||||
Trends: AccessLogOverviewTrends{
|
||||
Requests: requestPoints,
|
||||
Visits: visitPoints,
|
||||
Bandwidth: bandwidthPoints,
|
||||
},
|
||||
TopPaths: valueCountDistribution(ctx, query, "path", accessLogOverviewTopLimit),
|
||||
TopHosts: valueCountDistribution(ctx, query, "host", accessLogOverviewTopLimit),
|
||||
TopIPs: valueCountDistribution(ctx, query, "remote_addr", accessLogOverviewTopLimit),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOverviewQuery {
|
||||
hours := input.Hours
|
||||
if hours <= 0 {
|
||||
hours = defaultAccessLogOverviewHours
|
||||
}
|
||||
if hours > maxAccessLogOverviewHours {
|
||||
hours = maxAccessLogOverviewHours
|
||||
}
|
||||
return AccessLogOverviewQuery{
|
||||
NodeID: strings.TrimSpace(input.NodeID),
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Hours: hours,
|
||||
}
|
||||
}
|
||||
|
||||
func valueCountDistribution(
|
||||
ctx context.Context,
|
||||
query model.OpenFlareAccessLogQuery,
|
||||
column string,
|
||||
limit int,
|
||||
) []DistributionItem {
|
||||
rows, err := model.ValueCountsOpenFlareAccessLogs(ctx, query, column, limit)
|
||||
if err != nil || len(rows) == 0 {
|
||||
return []DistributionItem{}
|
||||
}
|
||||
items := make([]DistributionItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(row.Value) == "" || row.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, DistributionItem{Key: row.Value, Value: row.Count})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func buildAccessLogOverviewTrends(
|
||||
ctx context.Context,
|
||||
now time.Time,
|
||||
hours int,
|
||||
query model.OpenFlareAccessLogQuery,
|
||||
) (
|
||||
requests []AccessLogOverviewMetricPoint,
|
||||
visits []AccessLogOverviewMetricPoint,
|
||||
bandwidth []AccessLogOverviewMetricPoint,
|
||||
) {
|
||||
if hours <= 0 {
|
||||
hours = defaultAccessLogOverviewHours
|
||||
}
|
||||
start := now.Truncate(time.Hour).Add(-time.Duration(hours-1) * time.Hour)
|
||||
requests = make([]AccessLogOverviewMetricPoint, hours)
|
||||
visits = make([]AccessLogOverviewMetricPoint, hours)
|
||||
bandwidth = make([]AccessLogOverviewMetricPoint, hours)
|
||||
for index := range requests {
|
||||
bucketAt := start.Add(time.Duration(index) * time.Hour)
|
||||
requests[index].BucketStartedAt = bucketAt
|
||||
visits[index].BucketStartedAt = bucketAt
|
||||
bandwidth[index].BucketStartedAt = bucketAt
|
||||
}
|
||||
|
||||
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
||||
NodeID: query.NodeID,
|
||||
Host: query.Host,
|
||||
Since: query.Since,
|
||||
Until: query.Until,
|
||||
FoldMinutes: 60,
|
||||
SortBy: defaultAccessLogSortBy,
|
||||
SortOrder: sortOrderAsc,
|
||||
})
|
||||
if err != nil || len(buckets) == 0 {
|
||||
return requests, visits, bandwidth
|
||||
}
|
||||
byEpoch := make(map[int64]*model.OpenFlareAccessLogBucketRow, len(buckets))
|
||||
for _, row := range buckets {
|
||||
if row == nil {
|
||||
continue
|
||||
}
|
||||
byEpoch[row.BucketEpoch] = row
|
||||
}
|
||||
for index := range requests {
|
||||
row, ok := byEpoch[requests[index].BucketStartedAt.Unix()]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
requests[index].Value = row.RequestCount
|
||||
visits[index].Value = row.UniqueIPCount
|
||||
bandwidth[index].Value = row.BytesSent
|
||||
}
|
||||
return requests, visits, bandwidth
|
||||
}
|
||||
|
||||
// ListAccessLogs returns paginated access logs.
|
||||
func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) {
|
||||
normalized := normalizeAccessLogQuery(input)
|
||||
|
||||
@@ -130,7 +130,6 @@ type diskCounterState struct {
|
||||
seen bool
|
||||
}
|
||||
|
||||
|
||||
func buildTrafficWindowSummaryFromAccessLogs(
|
||||
ctx context.Context,
|
||||
nodeID string,
|
||||
@@ -333,7 +332,7 @@ func BuildTrafficTrendPointsFromAccessLogs(ctx context.Context, now time.Time, n
|
||||
Since: since,
|
||||
Until: now,
|
||||
FoldMinutes: 60,
|
||||
SortBy: "logged_at",
|
||||
SortBy: defaultAccessLogSortBy,
|
||||
SortOrder: sortOrderAsc,
|
||||
})
|
||||
if err != nil || len(buckets) == 0 {
|
||||
@@ -379,7 +378,7 @@ func applyAccessLogBytesToNetworkTrend(ctx context.Context, now time.Time, nodeI
|
||||
Since: since,
|
||||
Until: now,
|
||||
FoldMinutes: 60,
|
||||
SortBy: "logged_at",
|
||||
SortBy: defaultAccessLogSortBy,
|
||||
SortOrder: sortOrderAsc,
|
||||
})
|
||||
if err != nil || len(buckets) == 0 {
|
||||
|
||||
@@ -12,6 +12,33 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetAccessLogOverviewHandler 获取访问日志概览。
|
||||
// @Summary 获取访问日志概览
|
||||
// @Description 返回访问日志汇总指标、趋势与 Top 排行,需要管理员权限
|
||||
// @Tags openflare-observability
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Param node_id query string false "节点 ID"
|
||||
// @Param host query string false "请求 Host"
|
||||
// @Param hours query int false "统计时间范围(小时)"
|
||||
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
|
||||
// @Failure 400 {object} response.Any "参数错误"
|
||||
// @Failure 401 {object} response.Any "未登录"
|
||||
// @Failure 404 {object} response.Any "无权限或不存在"
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/access-logs/overview [get]
|
||||
func GetAccessLogOverviewHandler(c *gin.Context) {
|
||||
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
|
||||
NodeID: c.Query("node_id"),
|
||||
Host: c.Query("host"),
|
||||
Hours: readQueryInt(c, "hours"),
|
||||
})
|
||||
if apiutil.AbortBadRequestOnError(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response.OK(result))
|
||||
}
|
||||
|
||||
// GetAccessLogsHandler 分页列出访问日志。
|
||||
// @Summary 列出访问日志
|
||||
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
|
||||
|
||||
@@ -77,7 +77,7 @@ func TrafficSummaryOpenFlareAccessLogs(ctx context.Context, query OpenFlareAcces
|
||||
return currentAccessLogStore().TrafficSummary(ctx, query)
|
||||
}
|
||||
|
||||
// ValueCountsOpenFlareAccessLogs groups logs by status_code or host.
|
||||
// ValueCountsOpenFlareAccessLogs groups logs by status_code, host, path, or remote_addr.
|
||||
func ValueCountsOpenFlareAccessLogs(ctx context.Context, query OpenFlareAccessLogQuery, column string, limit int) ([]OpenFlareAccessLogValueCount, error) {
|
||||
return currentAccessLogStore().ValueCounts(ctx, query, column, limit)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
const (
|
||||
accessLogColumnStatusCode = "status_code"
|
||||
accessLogColumnHost = "host"
|
||||
accessLogColumnPath = "path"
|
||||
accessLogColumnRemoteAddr = "remote_addr"
|
||||
)
|
||||
|
||||
type memoryAccessLogStore struct {
|
||||
@@ -473,17 +475,24 @@ func (s *memoryAccessLogStore) ValueCounts(_ context.Context, filter OpenFlareAc
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
col := strings.TrimSpace(strings.ToLower(column))
|
||||
if col != accessLogColumnStatusCode && col != accessLogColumnHost {
|
||||
switch col {
|
||||
case accessLogColumnStatusCode, accessLogColumnHost, accessLogColumnPath, accessLogColumnRemoteAddr:
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
rows := s.filterRecords(filter)
|
||||
counts := make(map[string]int64)
|
||||
for _, row := range rows {
|
||||
var value string
|
||||
if col == accessLogColumnStatusCode {
|
||||
switch col {
|
||||
case accessLogColumnStatusCode:
|
||||
value = strconv.Itoa(row.StatusCode)
|
||||
} else {
|
||||
case accessLogColumnHost:
|
||||
value = strings.TrimSpace(row.Host)
|
||||
case accessLogColumnPath:
|
||||
value = strings.TrimSpace(row.Path)
|
||||
case accessLogColumnRemoteAddr:
|
||||
value = strings.TrimSpace(row.RemoteAddr)
|
||||
}
|
||||
if value == "" {
|
||||
continue
|
||||
|
||||
@@ -206,27 +206,19 @@ WHERE %s`, tableName, clause)
|
||||
}
|
||||
|
||||
// ValueCountsNodeAccessLogs groups logs by a single dimension column.
|
||||
// Allowed columns: status_code, host.
|
||||
// Allowed columns: status_code, host, path, remote_addr.
|
||||
func ValueCountsNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter, column string, limit int) ([]NodeAccessLogValueCount, error) {
|
||||
conn, err := nodeAccessLogConn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col := strings.TrimSpace(strings.ToLower(column))
|
||||
switch col {
|
||||
case nodeAccessLogColumnStatusCode, nodeAccessLogColumnHost:
|
||||
default:
|
||||
valueExpr, ok := nodeAccessLogValueCountExpr(col)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unsupported value count column: %s", column)
|
||||
}
|
||||
clause, args := buildNodeAccessLogFilterClause(filter)
|
||||
tableName := nodeAccessLogTableName()
|
||||
// status_code is numeric; cast to string for a uniform Value field.
|
||||
var valueExpr string
|
||||
if col == nodeAccessLogColumnStatusCode {
|
||||
valueExpr = "toString(" + nodeAccessLogColumnStatusCode + ")"
|
||||
} else {
|
||||
valueExpr = "trim(" + nodeAccessLogColumnHost + ")"
|
||||
}
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT %s AS value, count() AS count
|
||||
FROM %s
|
||||
@@ -259,6 +251,21 @@ ORDER BY count DESC, value ASC`, valueExpr, tableName, clause, valueExpr)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func nodeAccessLogValueCountExpr(column string) (string, bool) {
|
||||
switch column {
|
||||
case nodeAccessLogColumnStatusCode:
|
||||
return "toString(" + nodeAccessLogColumnStatusCode + ")", true
|
||||
case nodeAccessLogColumnHost:
|
||||
return "trim(" + nodeAccessLogColumnHost + ")", true
|
||||
case nodeAccessLogColumnPath:
|
||||
return "trim(" + nodeAccessLogColumnPath + ")", true
|
||||
case nodeAccessLogColumnRemoteAddr:
|
||||
return "trim(" + nodeAccessLogColumnRemoteAddr + ")", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// NodeAggregatesNodeAccessLogs returns per-node request/error/UV aggregates.
|
||||
func NodeAggregatesNodeAccessLogs(ctx context.Context, filter NodeAccessLogFilter) ([]NodeAccessLogNodeAggregate, error) {
|
||||
conn, err := nodeAccessLogConn()
|
||||
|
||||
@@ -14,6 +14,7 @@ func registerObservabilityRoutes(apiGroup *gin.RouterGroup) {
|
||||
accessLogRoute.Use(apiutil.AdminMiddlewares()...)
|
||||
{
|
||||
apiutil.RegisterCollection(accessLogRoute, "GET", observability.GetAccessLogsHandler)
|
||||
accessLogRoute.GET("/overview", observability.GetAccessLogOverviewHandler)
|
||||
accessLogRoute.GET("/folds", observability.GetFoldedAccessLogsHandler)
|
||||
accessLogRoute.GET("/folds/ip-summary", observability.GetFoldedAccessLogIPsHandler)
|
||||
accessLogRoute.GET("/ip-summary", observability.GetAccessLogIPSummariesHandler)
|
||||
|
||||
Reference in New Issue
Block a user