From bfd9de69afd28ba7af97636595fe9aea6285c347 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 00:28:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(access-logs):=20=E4=BF=AE=E5=A4=8D=E6=A6=82?= =?UTF-8?q?=E8=A7=88=E5=9F=9F=E5=90=8D=E7=AD=9B=E9=80=89=E5=8F=82=E6=95=B0?= =?UTF-8?q?=20hosts[]=20=E8=A2=AB=20Gin=20=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Axios 默认序列化为 hosts[]=,Gin QueryArray("hosts") 读不到导致筛选失效; 后端兼容 hosts/hosts[],前端改为重复键序列化。 --- docs/changelog/index.md | 1 + frontend/lib/services/core/api-client.ts | 4 ++ frontend/lib/services/core/search-params.ts | 22 +++++++ .../unit/access-log-overview-params.test.ts | 22 +++++++ .../observability/query_params_test.go | 57 +++++++++++++++++++ .../apps/openflare/observability/routers.go | 14 ++++- 6 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 frontend/lib/services/core/search-params.ts create mode 100644 frontend/tests/unit/access-log-overview-params.test.ts create mode 100644 internal/apps/openflare/observability/query_params_test.go diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 0a483cfa..dbf09f84 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -35,6 +35,7 @@ sidebar: false - 修复 Agent 观测缓冲在合并访问日志时忽略 `cache_status` 导致 HIT/MISS 被去重丢弃的问题。 - 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。 - 修复访问日志概览在 ClickHouse 查询发生错误时静默吞掉报错的缺陷,引入错误日志输出,方便定位问题。 +- 修复访问日志概览按域名筛选无效的问题:前端 Axios 默认发送 `hosts[]=`,后端 Gin 无法识别,现已兼容 `hosts` / `hosts[]` 两种参数形式。 ## [v3.4.0] - 2026-07-18 diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index 6ffdc9e0..19aa4b3d 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -28,6 +28,10 @@ const apiClient = axios.create({ withCredentials: apiConfig.withCredentials, // Fail fast on slash redirect loops between Next dev proxy and Gin legacy routes. maxRedirects: 5, + // Gin QueryArray("hosts") expects hosts=a&hosts=b, not hosts[]=a. + paramsSerializer: { + indexes: null, + }, headers: { 'Content-Type': 'application/json', }, diff --git a/frontend/lib/services/core/search-params.ts b/frontend/lib/services/core/search-params.ts new file mode 100644 index 00000000..2c2e2488 --- /dev/null +++ b/frontend/lib/services/core/search-params.ts @@ -0,0 +1,22 @@ +/** + * Serialize query params for Gin-compatible repeated array keys. + * Axios default uses hosts[]= which Gin QueryArray("hosts") ignores. + */ +export function serializeSearchParams( + params?: Record, +): string { + if (!params) return ''; + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === '') continue; + if (Array.isArray(value)) { + for (const item of value) { + if (item === undefined || item === null || item === '') continue; + search.append(key, String(item)); + } + continue; + } + search.append(key, String(value)); + } + return search.toString(); +} diff --git a/frontend/tests/unit/access-log-overview-params.test.ts b/frontend/tests/unit/access-log-overview-params.test.ts new file mode 100644 index 00000000..2890080e --- /dev/null +++ b/frontend/tests/unit/access-log-overview-params.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { serializeSearchParams } from '@/lib/services/core/search-params'; + +describe('serializeSearchParams', () => { + it('serializes hosts arrays as repeated keys for Gin QueryArray', () => { + const query = serializeSearchParams({ + hours: 168, + hosts: ['gist.arctel.de'], + }); + expect(query).toBe('hours=168&hosts=gist.arctel.de'); + }); + + it('serializes multiple hosts without bracket notation', () => { + const query = serializeSearchParams({ + hosts: ['a.example', 'b.example'], + }); + expect(query).toBe('hosts=a.example&hosts=b.example'); + expect(query).not.toContain('hosts[]'); + expect(query).not.toContain('hosts%5B%5D'); + }); +}); diff --git a/internal/apps/openflare/observability/query_params_test.go b/internal/apps/openflare/observability/query_params_test.go new file mode 100644 index 00000000..ffe91c33 --- /dev/null +++ b/internal/apps/openflare/observability/query_params_test.go @@ -0,0 +1,57 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package observability + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestReadQueryStringArrayAcceptsHostsBracketForm(t *testing.T) { + gin.SetMode(gin.TestMode) + + cases := []struct { + name string + url string + want []string + }{ + { + name: "axios brackets form", + url: "/overview?hours=168&hosts%5B%5D=gist.arctel.de", + want: []string{"gist.arctel.de"}, + }, + { + name: "repeated hosts keys", + url: "/overview?hosts=a.example&hosts=b.example", + want: []string{"a.example", "b.example"}, + }, + { + name: "single hosts key", + url: "/overview?hosts=gist.arctel.de", + want: []string{"gist.arctel.de"}, + }, + { + name: "empty", + url: "/overview?hours=24", + want: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, err := http.NewRequest(http.MethodGet, tc.url, nil) + require.NoError(t, err) + c.Request = req + + got := readQueryStringArray(c, "hosts") + require.Equal(t, tc.want, got) + }) + } +} diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index 1ad41c6a..1c1b25f7 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -32,7 +32,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) { result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{ NodeID: c.Query("node_id"), Host: c.Query("host"), - Hosts: c.QueryArray("hosts"), + Hosts: readQueryStringArray(c, "hosts"), Hours: readQueryInt(c, "hours"), }) if apiutil.AbortBadRequestOnError(c, err) { @@ -279,3 +279,15 @@ func readQueryInt(c *gin.Context, key string) int { value, _ := strconv.Atoi(c.DefaultQuery(key, "0")) return value } + +// readQueryStringArray reads repeated query values for key, and also accepts +// the Axios/jQuery bracket form key[] / key%5B%5D which Gin does not map to key. +func readQueryStringArray(c *gin.Context, key string) []string { + if values := c.QueryArray(key); len(values) > 0 { + return values + } + if values := c.QueryArray(key + "[]"); len(values) > 0 { + return values + } + return nil +}