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:
ryan
2026-08-13 09:59:32 +08:00
parent f1577bf092
commit 75d1fcf345
18 changed files with 322 additions and 75 deletions
@@ -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())
+11 -1
View File
@@ -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