mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-02 23:06:36 +08:00
feat(access-logs): 日志明细支持按状态码筛选并折叠次要搜索项,修复首页来源分布无数据
- 修复 PostgreSQL/SQLite 日志库下首页「来源分布」卡片无数据:RegionCounts 对空 节点 ID 误拼 node_id = '' 恒空条件,改为空节点 ID 表示全节点聚合(对齐 CH 语义), 并过滤空白归属地 - /access-logs?tab=list 新增状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 选项, 校验 100-599,非法值返回 400;ClickHouse 与 PostgreSQL/SQLite 日志库均支持 - 搜索框折叠:默认仅显示来源 IP 与状态码,节点 ID/访问域名/请求路径折叠进 「更多筛选」
This commit is contained in:
@@ -39,6 +39,7 @@ type AccessLogQuery struct {
|
||||
RemoteAddr string `json:"remote_addr"`
|
||||
Host string `json:"host"`
|
||||
Path string `json:"path"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
SortBy string `json:"sort_by"`
|
||||
@@ -887,6 +888,7 @@ func buildModelAccessLogQuery(input AccessLogQuery) model.OpenFlareAccessLogQuer
|
||||
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Path: strings.TrimSpace(input.Path),
|
||||
StatusCode: input.StatusCode,
|
||||
Since: defaultAccessLogSince(),
|
||||
Page: input.Page,
|
||||
PageSize: input.PageSize,
|
||||
@@ -932,6 +934,7 @@ func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
|
||||
RemoteAddr: strings.TrimSpace(input.RemoteAddr),
|
||||
Host: strings.TrimSpace(input.Host),
|
||||
Path: strings.TrimSpace(input.Path),
|
||||
StatusCode: input.StatusCode,
|
||||
Page: normalizeAccessLogPage(input.Page),
|
||||
PageSize: normalizeAccessLogPageSize(input.PageSize),
|
||||
SortBy: normalizeAccessLogSortBy(input.SortBy),
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package observability defines shared error messages for observability operations.
|
||||
package observability
|
||||
|
||||
const (
|
||||
errInvalidStatusCode = "status_code 必须为 100-599 之间的整数"
|
||||
)
|
||||
@@ -55,3 +55,49 @@ func TestReadQueryStringArrayAcceptsHostsBracketForm(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAccessLogQueryIncludesStatusCode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, err := http.NewRequest(
|
||||
http.MethodGet,
|
||||
"/?node_id=n1&remote_addr=1.2.3.4&host=a.example&path=/api&status_code=404&p=2&page_size=50",
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
c.Request = req
|
||||
|
||||
got, err := readAccessLogQuery(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "n1", got.NodeID)
|
||||
require.Equal(t, "1.2.3.4", got.RemoteAddr)
|
||||
require.Equal(t, "a.example", got.Host)
|
||||
require.Equal(t, "/api", got.Path)
|
||||
require.Equal(t, 404, got.StatusCode)
|
||||
require.Equal(t, 2, got.Page)
|
||||
require.Equal(t, 50, got.PageSize)
|
||||
}
|
||||
|
||||
func TestReadAccessLogQueryRejectsInvalidStatusCode(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, raw := range []string{"abc", "99", "600", "-1"} {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, err := http.NewRequest(http.MethodGet, "/?status_code="+raw, nil)
|
||||
require.NoError(t, err)
|
||||
c.Request = req
|
||||
|
||||
_, err = readAccessLogQuery(c)
|
||||
require.Error(t, err, "status_code=%s should be rejected", raw)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||
require.NoError(t, err)
|
||||
c.Request = req
|
||||
got, err := readAccessLogQuery(c)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, got.StatusCode)
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"errors"
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
||||
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// GetAccessLogOverviewHandler 获取访问日志概览。
|
||||
@@ -45,7 +45,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
||||
|
||||
// GetAccessLogsHandler 分页列出访问日志。
|
||||
// @Summary 列出访问日志
|
||||
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
|
||||
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限
|
||||
// @Tags openflare-observability
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
@@ -53,6 +53,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
||||
// @Param remote_addr query string false "客户端 IP"
|
||||
// @Param host query string false "请求 Host"
|
||||
// @Param path query string false "请求路径"
|
||||
// @Param status_code query int false "HTTP 状态码(100-599)"
|
||||
// @Param p query int false "页码"
|
||||
// @Param page_size query int false "每页条数"
|
||||
// @Param sort_by query string false "排序字段"
|
||||
@@ -64,7 +65,11 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/access-logs [get]
|
||||
func GetAccessLogsHandler(c *gin.Context) {
|
||||
logs, err := ListAccessLogs(c.Request.Context(), readAccessLogQuery(c))
|
||||
query, err := readAccessLogQuery(c)
|
||||
if apiutil.AbortBadRequestOnError(c, err) {
|
||||
return
|
||||
}
|
||||
logs, err := ListAccessLogs(c.Request.Context(), query)
|
||||
if apiutil.AbortBadRequestOnError(c, err) {
|
||||
return
|
||||
}
|
||||
@@ -93,7 +98,10 @@ func GetAccessLogsHandler(c *gin.Context) {
|
||||
// @Failure 500 {object} response.Any "内部错误"
|
||||
// @Router /api/v1/d/access-logs/folds [get]
|
||||
func GetFoldedAccessLogsHandler(c *gin.Context) {
|
||||
query := readAccessLogQuery(c)
|
||||
query, err := readAccessLogQuery(c)
|
||||
if apiutil.AbortBadRequestOnError(c, err) {
|
||||
return
|
||||
}
|
||||
query.FoldMinutes = readQueryInt(c, "fold_minutes")
|
||||
logs, err := ListFoldedAccessLogs(c.Request.Context(), query)
|
||||
if apiutil.AbortBadRequestOnError(c, err) {
|
||||
@@ -270,8 +278,8 @@ func CleanupAccessLogsHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response.OK(result))
|
||||
}
|
||||
|
||||
func readAccessLogQuery(c *gin.Context) AccessLogQuery {
|
||||
return AccessLogQuery{
|
||||
func readAccessLogQuery(c *gin.Context) (AccessLogQuery, error) {
|
||||
query := AccessLogQuery{
|
||||
NodeID: c.Query("node_id"),
|
||||
RemoteAddr: c.Query("remote_addr"),
|
||||
Host: c.Query("host"),
|
||||
@@ -281,6 +289,14 @@ func readAccessLogQuery(c *gin.Context) AccessLogQuery {
|
||||
SortBy: c.Query("sort_by"),
|
||||
SortOrder: c.Query("sort_order"),
|
||||
}
|
||||
if raw := c.Query("status_code"); raw != "" {
|
||||
code, err := strconv.Atoi(raw)
|
||||
if err != nil || code < 100 || code > 599 {
|
||||
return AccessLogQuery{}, errors.New(errInvalidStatusCode)
|
||||
}
|
||||
query.StatusCode = code
|
||||
}
|
||||
return query, nil
|
||||
}
|
||||
|
||||
func readQueryInt(c *gin.Context, key string) int {
|
||||
|
||||
@@ -25,14 +25,16 @@ type NodeAccessLogFilter struct {
|
||||
RemoteAddr string
|
||||
Host string
|
||||
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||
Hosts []string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Hosts []string
|
||||
Path string
|
||||
// StatusCode filters by exact HTTP status code when > 0.
|
||||
StatusCode int
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
// NodeObservabilityFilter scopes ClickHouse node observability queries.
|
||||
|
||||
@@ -161,14 +161,16 @@ type OpenFlareAccessLogQuery struct {
|
||||
RemoteAddr string
|
||||
Host string
|
||||
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||
Hosts []string
|
||||
Path string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
Hosts []string
|
||||
Path string
|
||||
// StatusCode filters by exact HTTP status code when > 0.
|
||||
StatusCode int
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
Page int
|
||||
PageSize int
|
||||
SortBy string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
|
||||
|
||||
@@ -45,6 +45,16 @@ func TestBuildUserAccessLogFilterClause_EmptyUserIDs(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestBuildNodeAccessLogFilterClause_StatusCode(t *testing.T) {
|
||||
clause, args := buildNodeAccessLogFilterClause(NodeAccessLogFilter{StatusCode: 404})
|
||||
assert.Equal(t, "status_code = ?", clause)
|
||||
assert.Equal(t, []any{404}, args)
|
||||
|
||||
clause, args = buildNodeAccessLogFilterClause(NodeAccessLogFilter{})
|
||||
assert.Equal(t, "1", clause)
|
||||
assert.Nil(t, args)
|
||||
}
|
||||
|
||||
func TestCountAccessLogs_EmptyUserIDs(t *testing.T) {
|
||||
count, err := CountAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
nodeAccessLogFilterClauseCapacity = 6
|
||||
nodeAccessLogFilterClauseCapacity = 7
|
||||
|
||||
nodeAccessLogSortDesc = "DESC"
|
||||
nodeAccessLogSortAsc = "ASC"
|
||||
@@ -55,6 +55,10 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
|
||||
parts = append(parts, "path LIKE ?")
|
||||
args = append(args, trimmed+"%")
|
||||
}
|
||||
if filter.StatusCode > 0 {
|
||||
parts = append(parts, "status_code = ?")
|
||||
args = append(args, filter.StatusCode)
|
||||
}
|
||||
if !filter.Since.IsZero() {
|
||||
parts = append(parts, "logged_at >= ?")
|
||||
args = append(args, filter.Since.UTC())
|
||||
|
||||
@@ -282,7 +282,12 @@ func (s *gormLogStore) RegionCounts(ctx context.Context, nodeID string, since ti
|
||||
var rows []row
|
||||
q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}).
|
||||
Select("region, COUNT(*) AS count").
|
||||
Where("node_id = ? AND region <> '' AND logged_at >= ?", nodeID, since)
|
||||
Where("trim(region) <> '' AND logged_at >= ?", since)
|
||||
// 空 nodeID 表示全节点聚合(对齐 CH 语义),仅非空时追加 node_id 过滤,
|
||||
// 避免 `node_id = ''` 恒空导致首页来源分布无数据。
|
||||
if nodeID = strings.TrimSpace(nodeID); nodeID != "" {
|
||||
q = q.Where("node_id = ?", nodeID)
|
||||
}
|
||||
if err := q.Group("region").Order("count DESC").Limit(limitOr(limit, defaultTopN)).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -788,6 +793,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N
|
||||
Host: query.Host,
|
||||
Hosts: query.Hosts,
|
||||
Path: query.Path,
|
||||
StatusCode: query.StatusCode,
|
||||
Since: query.Since,
|
||||
Until: query.Until,
|
||||
Page: query.Page,
|
||||
@@ -823,6 +829,10 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
|
||||
parts = append(parts, "path LIKE ?")
|
||||
args = append(args, path+"%")
|
||||
}
|
||||
if f.StatusCode > 0 {
|
||||
parts = append(parts, "status_code = ?")
|
||||
args = append(args, f.StatusCode)
|
||||
}
|
||||
if !f.Since.IsZero() {
|
||||
parts = append(parts, "logged_at >= ?")
|
||||
args = append(args, f.Since)
|
||||
|
||||
@@ -231,6 +231,44 @@ func TestGormNodeAggregatesExcludeEmptyNodeID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGormRegionCountsEmptyNodeIDAggregatesAll 回归测试:首页「来源分布」以空 node_id
|
||||
// 表示全节点聚合,RegionCounts 不得拼出 `node_id = ”` 恒空条件(对齐 CH 语义)。
|
||||
func TestGormRegionCountsEmptyNodeIDAggregatesAll(t *testing.T) {
|
||||
ResetForTest()
|
||||
SetConfigReader(func(_ context.Context, _ string) (string, error) { return "", nil })
|
||||
s := newTestGormStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
rows := []analyticsmodel.NodeAccessLog{
|
||||
{ID: 1, NodeID: "n1", LoggedAt: now, RemoteAddr: "1.1.1.1", Region: "CN"},
|
||||
{ID: 2, NodeID: "n2", LoggedAt: now, RemoteAddr: "2.2.2.2", Region: "CN"},
|
||||
{ID: 3, NodeID: "n3", LoggedAt: now, RemoteAddr: "3.3.3.3", Region: "US"},
|
||||
{ID: 4, NodeID: "n4", LoggedAt: now, RemoteAddr: "4.4.4.4", Region: " "},
|
||||
}
|
||||
if err := s.BatchInsertNodeAccessLogs(ctx, rows); err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
|
||||
all, err := s.RegionCounts(ctx, "", now.Add(-time.Hour), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("region counts (all nodes): %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("all-nodes region counts want 2 regions (empty region excluded), got %+v", all)
|
||||
}
|
||||
if all[0].Region != "CN" || all[0].Count != 2 || all[1].Region != "US" || all[1].Count != 1 {
|
||||
t.Fatalf("all-nodes region counts got %+v, want CN=2 US=1", all)
|
||||
}
|
||||
|
||||
cnOnly, err := s.RegionCounts(ctx, "n1", now.Add(-time.Hour), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("region counts (node): %v", err)
|
||||
}
|
||||
if len(cnOnly) != 1 || cnOnly[0].Region != "CN" || cnOnly[0].Count != 1 {
|
||||
t.Fatalf("node-scoped region counts got %+v, want CN=1", cnOnly)
|
||||
}
|
||||
}
|
||||
|
||||
// testGormStoreSeq 保证每个测试获得独立的共享内存库(cache=shared 下同名 DSN 会复用同一库,
|
||||
// 导致跨测试 id 冲突)。
|
||||
var testGormStoreSeq int64
|
||||
|
||||
Reference in New Issue
Block a user