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'
/>
-
-
+ 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"),