From a9e4237bbff21833a51ba92862f088935f29a966 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 13 Aug 2026 10:27:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(access-logs):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=A0=81=E6=94=AF=E6=8C=81=E6=89=8B=E5=8A=A8=E8=BE=93=E5=85=A5?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=97=B6=E9=97=B4=E8=8C=83=E5=9B=B4?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 状态码筛选支持预设快捷选项 + 手动输入任意 100-599 状态码(数字校验) - 新增时间范围筛选:shadcn 日期+时间选择器(Popover+Calendar+时分 Select), 起止时间以 RFC3339 成对传入,后端校验格式与先后关系,非法值返回 400 - 默认显示来源 IP/访问域名/状态码,节点 ID/请求路径/时间范围折叠进「更多筛选」 --- docs/changelog/index.md | 3 +- docs/docs.go | 12 + docs/swagger.json | 12 + docs/swagger.yaml | 8 + .../components/access-log-filters.tsx | 220 +++++++++++++++--- .../components/access-log-utils.ts | 2 + frontend/app/(main)/access-logs/page.tsx | 6 + frontend/lib/services/openflare/types.ts | 2 + .../observability/access_log_logics.go | 49 +++- .../observability/query_params_test.go | 27 +++ .../apps/openflare/observability/routers.go | 4 + 11 files changed, 309 insertions(+), 36 deletions(-) diff --git a/docs/changelog/index.md b/docs/changelog/index.md index b0081784..0f32ead8 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -19,7 +19,8 @@ sidebar: false ## [Unreleased] ### 新增 -- 访问日志「日志明细」支持按 HTTP 状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 快捷选项,筛选条件贯穿 ClickHouse 与 PostgreSQL/SQLite 日志库。 +- 访问日志「日志明细」支持按 HTTP 状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 快捷选项,也可手动输入任意状态码,筛选条件贯穿 ClickHouse 与 PostgreSQL/SQLite 日志库。 +- 访问日志「日志明细」支持自定义时间范围筛选:内置 shadcn 日期+时间选择器,可指定起止时间(RFC3339,需成对提供)。 ### 🛠 修复 - 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据:聚合查询对空节点 ID 误拼 `node_id = ''` 恒空条件,现改为空节点 ID 表示全节点聚合(与 ClickHouse 语义一致),并同步过滤空白归属地。 diff --git a/docs/docs.go b/docs/docs.go index a765b1b1..cc850e20 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -4848,6 +4848,18 @@ const docTemplate = `{ "name": "status_code", "in": "query" }, + { + "type": "string", + "description": "起始时间(RFC3339,需与 until 成对提供)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339,需与 since 成对提供)", + "name": "until", + "in": "query" + }, { "type": "integer", "description": "页码", diff --git a/docs/swagger.json b/docs/swagger.json index 8c4a062d..578b1059 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4841,6 +4841,18 @@ "name": "status_code", "in": "query" }, + { + "type": "string", + "description": "起始时间(RFC3339,需与 until 成对提供)", + "name": "since", + "in": "query" + }, + { + "type": "string", + "description": "结束时间(RFC3339,需与 since 成对提供)", + "name": "until", + "in": "query" + }, { "type": "integer", "description": "页码", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6385ee1d..5f025236 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -7216,6 +7216,14 @@ paths: in: query name: status_code type: integer + - description: 起始时间(RFC3339,需与 until 成对提供) + in: query + name: since + type: string + - description: 结束时间(RFC3339,需与 since 成对提供) + in: query + name: until + type: string - description: 页码 in: query name: p diff --git a/frontend/app/(main)/access-logs/components/access-log-filters.tsx b/frontend/app/(main)/access-logs/components/access-log-filters.tsx index 0d4b7a05..82b47437 100644 --- a/frontend/app/(main)/access-logs/components/access-log-filters.tsx +++ b/frontend/app/(main)/access-logs/components/access-log-filters.tsx @@ -1,15 +1,22 @@ 'use client'; import { useState } from 'react'; -import { ChevronDown, Search } from 'lucide-react'; +import { format } from 'date-fns'; +import { CalendarIcon, ChevronDown, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible'; import { Input } from '@/components/ui/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; import { Select, SelectContent, @@ -29,6 +36,14 @@ interface AccessLogFiltersProps { onReset: () => void; } +const CUSTOM_STATUS_CODE = '__custom__'; +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => + String(i).padStart(2, '0'), +); +const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, i) => + String(i).padStart(2, '0'), +); + function FilterField({ label, children, @@ -44,6 +59,97 @@ function FilterField({ ); } +function TimeSelect({ + value, + options, + onValueChange, +}: { + value: string; + options: string[]; + onValueChange: (value: string) => void; +}) { + return ( + + ); +} + +/** shadcn 日期 + 时间选择器,value 为 ISO 字符串。 */ +function DateTimePicker({ + value, + onChange, +}: { + value: string; + onChange: (value: string) => void; +}) { + const [open, setOpen] = useState(false); + const current = value ? new Date(value) : undefined; + + const applyDate = (date: Date | undefined) => { + if (!date) return; + const next = value ? new Date(value) : new Date(); + next.setFullYear(date.getFullYear(), date.getMonth(), date.getDate()); + onChange(next.toISOString()); + }; + + const applyTime = (hh: string, mm: string) => { + const next = value ? new Date(value) : new Date(); + next.setHours(Number(hh), Number(mm), 0, 0); + onChange(next.toISOString()); + }; + + const hour = current ? String(current.getHours()).padStart(2, '0') : '00'; + const minute = current ? String(current.getMinutes()).padStart(2, '0') : '00'; + + return ( + + + + + + +
+ applyTime(h, minute)} + /> + : + applyTime(hour, m)} + /> +
+
+
+ ); +} + export function AccessLogFilters({ draft, pageSize, @@ -53,6 +159,15 @@ export function AccessLogFilters({ onReset, }: AccessLogFiltersProps) { const [moreOpen, setMoreOpen] = useState(false); + const isPresetStatus = STATUS_CODE_OPTIONS.some( + (option) => option.value === draft.statusCode, + ); + const statusSelectValue = + draft.statusCode === '' + ? '' + : isPresetStatus + ? draft.statusCode + : CUSTOM_STATUS_CODE; return (
@@ -70,24 +185,64 @@ export function AccessLogFilters({ className='h-9 text-xs' /> - - + onDraftChange({ ...draft, host: e.target.value }) } - > - - - - - {STATUS_CODE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - + onKeyDown={(e) => { + if (e.key === 'Enter') onSearch(); + }} + placeholder='按域名搜索' + className='h-9 text-xs' + /> + + +
+ + {statusSelectValue === CUSTOM_STATUS_CODE ? ( + + onDraftChange({ + ...draft, + statusCode: e.target.value.replace(/\D/g, '').slice(0, 3), + }) + } + onKeyDown={(e) => { + if (e.key === 'Enter') onSearch(); + }} + placeholder='输入状态码,如 201' + autoFocus + className='h-9 text-xs' + /> + ) : null} +
@@ -124,19 +279,6 @@ export function AccessLogFilters({ /> - - - onDraftChange({ ...draft, host: e.target.value }) - } - onKeyDown={(e) => { - if (e.key === 'Enter') onSearch(); - }} - placeholder='按域名搜索' - className='h-9 text-xs' - /> - + +
+ + onDraftChange({ ...draft, since: value }) + } + /> + + onDraftChange({ ...draft, until: value }) + } + /> +
+
diff --git a/frontend/app/(main)/access-logs/components/access-log-utils.ts b/frontend/app/(main)/access-logs/components/access-log-utils.ts index f01fd797..fc85dc10 100644 --- a/frontend/app/(main)/access-logs/components/access-log-utils.ts +++ b/frontend/app/(main)/access-logs/components/access-log-utils.ts @@ -6,6 +6,8 @@ export type SearchDraft = { host: string; path: string; statusCode: string; + since: string; + until: string; }; export type OverviewRangeHours = 24 | 168 | 360 | 720; diff --git a/frontend/app/(main)/access-logs/page.tsx b/frontend/app/(main)/access-logs/page.tsx index bf158196..00cff898 100644 --- a/frontend/app/(main)/access-logs/page.tsx +++ b/frontend/app/(main)/access-logs/page.tsx @@ -34,6 +34,8 @@ const emptyDraft: SearchDraft = { host: '', path: '', statusCode: '', + since: '', + until: '', }; function resolveTab(value: string | null): AccessLogTab { @@ -115,6 +117,8 @@ function AccessLogsPageContent() { status_code: filters.statusCode ? Number.parseInt(filters.statusCode, 10) : undefined, + since: filters.since || undefined, + until: filters.until || undefined, p: page, page_size: pageSize, sort_by: detailSortState.sortBy, @@ -166,6 +170,8 @@ function AccessLogsPageContent() { host: draft.host.trim(), path: draft.path.trim(), statusCode: draft.statusCode.trim(), + since: draft.since.trim(), + until: draft.until.trim(), }); setPage(0); }, [draft]); diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index 118e1c7d..960b0c48 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -631,6 +631,8 @@ export interface AccessLogFilters { host?: string; path?: string; status_code?: number; + since?: string; + until?: string; p?: number; page_size?: number; sort_by?: string; diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go index ef6b1add..2d2aa0e6 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/internal/apps/openflare/observability/access_log_logics.go @@ -40,6 +40,8 @@ type AccessLogQuery struct { Host string `json:"host"` Path string `json:"path"` StatusCode int `json:"status_code"` + Since string `json:"since"` + Until string `json:"until"` Page int `json:"page"` PageSize int `json:"page_size"` SortBy string `json:"sort_by"` @@ -534,7 +536,10 @@ func buildAccessLogOverviewTrends( // ListAccessLogs returns paginated access logs. func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) { normalized := normalizeAccessLogQuery(input) - modelQuery := buildModelAccessLogQuery(normalized) + modelQuery, err := buildModelAccessLogQuery(normalized) + if err != nil { + return nil, err + } logs, err := repository.ListOpenFlareAccessLogs(ctx, modelQuery) if err != nil { return nil, err @@ -587,13 +592,17 @@ func ListFoldedAccessLogs(ctx context.Context, input AccessLogQuery) (*FoldedAcc if err != nil { return nil, err } - modelQuery := buildModelAccessLogQuery(normalized) + modelQuery, err := buildModelAccessLogQuery(normalized) + if err != nil { + return nil, err + } bucketQuery := model.OpenFlareAccessLogBucketQuery{ NodeID: modelQuery.NodeID, RemoteAddr: modelQuery.RemoteAddr, Host: modelQuery.Host, Path: modelQuery.Path, Since: modelQuery.Since, + Until: modelQuery.Until, Page: normalized.Page, PageSize: normalized.PageSize, SortBy: normalizeFoldSortBy(input.SortBy), @@ -882,19 +891,49 @@ func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*Acces }, nil } -func buildModelAccessLogQuery(input AccessLogQuery) model.OpenFlareAccessLogQuery { +func buildModelAccessLogQuery(input AccessLogQuery) (model.OpenFlareAccessLogQuery, error) { + since := defaultAccessLogSince() + until := time.Now().UTC() + if input.Since != "" || input.Until != "" { + parsedSince, parsedUntil, err := resolveAccessLogWindow(input.Since, input.Until) + if err != nil { + return model.OpenFlareAccessLogQuery{}, err + } + since, until = parsedSince, parsedUntil + } return model.OpenFlareAccessLogQuery{ NodeID: strings.TrimSpace(input.NodeID), RemoteAddr: strings.TrimSpace(input.RemoteAddr), Host: strings.TrimSpace(input.Host), Path: strings.TrimSpace(input.Path), StatusCode: input.StatusCode, - Since: defaultAccessLogSince(), + Since: since, + Until: until, Page: input.Page, PageSize: input.PageSize, SortBy: input.SortBy, SortOrder: input.SortOrder, + }, nil +} + +// resolveAccessLogWindow 校验并解析 RFC3339 时间窗;since/until 必须成对提供, +// 且 until 需晚于 since。 +func resolveAccessLogWindow(sinceRaw, untilRaw string) (time.Time, time.Time, error) { + if sinceRaw == "" || untilRaw == "" { + return time.Time{}, time.Time{}, errors.New("since 与 until 需同时提供") } + parsedSince, err := time.Parse(time.RFC3339, sinceRaw) + if err != nil { + return time.Time{}, time.Time{}, errors.New("since 时间格式无效,需为 RFC3339") + } + parsedUntil, err := time.Parse(time.RFC3339, untilRaw) + if err != nil { + return time.Time{}, time.Time{}, errors.New("until 时间格式无效,需为 RFC3339") + } + if !parsedUntil.After(parsedSince) { + return time.Time{}, time.Time{}, errors.New("until 必须晚于 since") + } + return parsedSince, parsedUntil, nil } func defaultAccessLogSince() time.Time { @@ -935,6 +974,8 @@ func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery { Host: strings.TrimSpace(input.Host), Path: strings.TrimSpace(input.Path), StatusCode: input.StatusCode, + Since: strings.TrimSpace(input.Since), + Until: strings.TrimSpace(input.Until), Page: normalizeAccessLogPage(input.Page), PageSize: normalizeAccessLogPageSize(input.PageSize), SortBy: normalizeAccessLogSortBy(input.SortBy), diff --git a/internal/apps/openflare/observability/query_params_test.go b/internal/apps/openflare/observability/query_params_test.go index 6a16fbe6..56e1c1e7 100644 --- a/internal/apps/openflare/observability/query_params_test.go +++ b/internal/apps/openflare/observability/query_params_test.go @@ -101,3 +101,30 @@ func TestReadAccessLogQueryRejectsInvalidStatusCode(t *testing.T) { require.NoError(t, err) require.Equal(t, 0, got.StatusCode) } + +func TestResolveAccessLogWindow(t *testing.T) { + since, until, err := resolveAccessLogWindow( + "2026-08-01T00:00:00Z", + "2026-08-02T00:00:00Z", + ) + require.NoError(t, err) + require.True(t, until.After(since)) + + for _, tc := range []struct { + name string + since string + until string + }{ + {name: "missing both", since: "", until: ""}, + {name: "only since", since: "2026-08-01T00:00:00Z", until: ""}, + {name: "only until", since: "", until: "2026-08-02T00:00:00Z"}, + {name: "bad since", since: "not-a-time", until: "2026-08-02T00:00:00Z"}, + {name: "bad until", since: "2026-08-01T00:00:00Z", until: "not-a-time"}, + {name: "reversed", since: "2026-08-02T00:00:00Z", until: "2026-08-01T00:00:00Z"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, _, err := resolveAccessLogWindow(tc.since, tc.until) + require.Error(t, err) + }) + } +} diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index 6467b950..af1f61de 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -54,6 +54,8 @@ func GetAccessLogOverviewHandler(c *gin.Context) { // @Param host query string false "请求 Host" // @Param path query string false "请求路径" // @Param status_code query int false "HTTP 状态码(100-599)" +// @Param since query string false "起始时间(RFC3339,需与 until 成对提供)" +// @Param until query string false "结束时间(RFC3339,需与 since 成对提供)" // @Param p query int false "页码" // @Param page_size query int false "每页条数" // @Param sort_by query string false "排序字段" @@ -284,6 +286,8 @@ func readAccessLogQuery(c *gin.Context) (AccessLogQuery, error) { RemoteAddr: c.Query("remote_addr"), Host: c.Query("host"), Path: c.Query("path"), + Since: c.Query("since"), + Until: c.Query("until"), Page: readQueryInt(c, "p"), PageSize: readQueryInt(c, "page_size"), SortBy: c.Query("sort_by"),