feat(access-logs): 重构访问日志为概览与明细双 Tab

新增访问日志概览 API 与前端页面:汇总请求量/访问量/带宽趋势与 Top 排行,
明细列表保留检索;排行榜改为紧凑列表样式。
This commit is contained in:
ryan
2026-07-18 20:58:03 +08:00
parent 4c0c389122
commit 177578ef4e
24 changed files with 1546 additions and 1419 deletions
@@ -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、主机与路径筛选,需要管理员权限
+1 -1
View File
@@ -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)