feat(access-logs): 状态码支持手动输入,新增时间范围筛选

- 状态码筛选支持预设快捷选项 + 手动输入任意 100-599 状态码(数字校验)
- 新增时间范围筛选:shadcn 日期+时间选择器(Popover+Calendar+时分 Select),
  起止时间以 RFC3339 成对传入,后端校验格式与先后关系,非法值返回 400
- 默认显示来源 IP/访问域名/状态码,节点 ID/请求路径/时间范围折叠进「更多筛选」
This commit is contained in:
ryan
2026-08-13 10:27:40 +08:00
parent 75d1fcf345
commit a9e4237bbf
11 changed files with 309 additions and 36 deletions
+2 -1
View File
@@ -19,7 +19,8 @@ sidebar: false
## [Unreleased] ## [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 语义一致),并同步过滤空白归属地。 - 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据:聚合查询对空节点 ID 误拼 `node_id = ''` 恒空条件,现改为空节点 ID 表示全节点聚合(与 ClickHouse 语义一致),并同步过滤空白归属地。
+12
View File
@@ -4848,6 +4848,18 @@ const docTemplate = `{
"name": "status_code", "name": "status_code",
"in": "query" "in": "query"
}, },
{
"type": "string",
"description": "起始时间(RFC3339,需与 until 成对提供)",
"name": "since",
"in": "query"
},
{
"type": "string",
"description": "结束时间(RFC3339,需与 since 成对提供)",
"name": "until",
"in": "query"
},
{ {
"type": "integer", "type": "integer",
"description": "页码", "description": "页码",
+12
View File
@@ -4841,6 +4841,18 @@
"name": "status_code", "name": "status_code",
"in": "query" "in": "query"
}, },
{
"type": "string",
"description": "起始时间(RFC3339,需与 until 成对提供)",
"name": "since",
"in": "query"
},
{
"type": "string",
"description": "结束时间(RFC3339,需与 since 成对提供)",
"name": "until",
"in": "query"
},
{ {
"type": "integer", "type": "integer",
"description": "页码", "description": "页码",
+8
View File
@@ -7216,6 +7216,14 @@ paths:
in: query in: query
name: status_code name: status_code
type: integer type: integer
- description: 起始时间(RFC3339,需与 until 成对提供)
in: query
name: since
type: string
- description: 结束时间(RFC3339,需与 since 成对提供)
in: query
name: until
type: string
- description: 页码 - description: 页码
in: query in: query
name: p name: p
@@ -1,15 +1,22 @@
'use client'; 'use client';
import { useState } from 'react'; 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 { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from '@/components/ui/collapsible'; } from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -29,6 +36,14 @@ interface AccessLogFiltersProps {
onReset: () => void; 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({ function FilterField({
label, label,
children, children,
@@ -44,6 +59,97 @@ function FilterField({
); );
} }
function TimeSelect({
value,
options,
onValueChange,
}: {
value: string;
options: string[];
onValueChange: (value: string) => void;
}) {
return (
<Select value={value} onValueChange={onValueChange}>
<SelectTrigger className='h-8 w-18 text-xs'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
/** 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant='outline'
className='h-9 w-full justify-start gap-2 px-3 text-xs font-normal'
>
<CalendarIcon className='size-3.5 text-muted-foreground' />
{current ? (
format(current, 'yyyy-MM-dd HH:mm')
) : (
<span className='text-muted-foreground'>选择时间</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
mode='single'
selected={current}
onSelect={applyDate}
/>
<div className='flex items-center gap-1.5 border-t p-2'>
<TimeSelect
value={hour}
options={HOUR_OPTIONS}
onValueChange={(h) => applyTime(h, minute)}
/>
<span className='text-xs text-muted-foreground'>:</span>
<TimeSelect
value={minute}
options={MINUTE_OPTIONS}
onValueChange={(m) => applyTime(hour, m)}
/>
</div>
</PopoverContent>
</Popover>
);
}
export function AccessLogFilters({ export function AccessLogFilters({
draft, draft,
pageSize, pageSize,
@@ -53,6 +159,15 @@ export function AccessLogFilters({
onReset, onReset,
}: AccessLogFiltersProps) { }: AccessLogFiltersProps) {
const [moreOpen, setMoreOpen] = useState(false); 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 ( return (
<div className='space-y-3'> <div className='space-y-3'>
@@ -70,24 +185,64 @@ export function AccessLogFilters({
className='h-9 text-xs' className='h-9 text-xs'
/> />
</FilterField> </FilterField>
<FilterField label='状态码'> <FilterField label='访问域名'>
<Select <Input
value={draft.statusCode} value={draft.host}
onValueChange={(value) => onChange={(e) =>
onDraftChange({ ...draft, statusCode: value }) onDraftChange({ ...draft, host: e.target.value })
} }
> onKeyDown={(e) => {
<SelectTrigger className='h-9 text-xs'> if (e.key === 'Enter') onSearch();
<SelectValue placeholder='全部状态码' /> }}
</SelectTrigger> placeholder='按域名搜索'
<SelectContent> className='h-9 text-xs'
{STATUS_CODE_OPTIONS.map((option) => ( />
<SelectItem key={option.value} value={option.value}> </FilterField>
{option.label} <FilterField label='状态码'>
</SelectItem> <div className='flex flex-col gap-1.5'>
))} <Select
</SelectContent> value={statusSelectValue}
</Select> onValueChange={(value) => {
if (value === CUSTOM_STATUS_CODE) {
onDraftChange({
...draft,
statusCode: isPresetStatus ? '' : draft.statusCode,
});
} else {
onDraftChange({ ...draft, statusCode: value });
}
}}
>
<SelectTrigger className='h-9 text-xs'>
<SelectValue placeholder='全部状态码' />
</SelectTrigger>
<SelectContent>
{STATUS_CODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
<SelectItem value={CUSTOM_STATUS_CODE}>自定义…</SelectItem>
</SelectContent>
</Select>
{statusSelectValue === CUSTOM_STATUS_CODE ? (
<Input
value={draft.statusCode}
onChange={(e) =>
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}
</div>
</FilterField> </FilterField>
</div> </div>
@@ -124,19 +279,6 @@ export function AccessLogFilters({
/> />
</div> </div>
</FilterField> </FilterField>
<FilterField label='访问域名'>
<Input
value={draft.host}
onChange={(e) =>
onDraftChange({ ...draft, host: e.target.value })
}
onKeyDown={(e) => {
if (e.key === 'Enter') onSearch();
}}
placeholder='按域名搜索'
className='h-9 text-xs'
/>
</FilterField>
<FilterField label='请求路径'> <FilterField label='请求路径'>
<Input <Input
value={draft.path} value={draft.path}
@@ -150,6 +292,22 @@ export function AccessLogFilters({
className='h-9 text-xs' className='h-9 text-xs'
/> />
</FilterField> </FilterField>
<FilterField label='时间范围'>
<div className='grid grid-cols-2 gap-2'>
<DateTimePicker
value={draft.since}
onChange={(value) =>
onDraftChange({ ...draft, since: value })
}
/>
<DateTimePicker
value={draft.until}
onChange={(value) =>
onDraftChange({ ...draft, until: value })
}
/>
</div>
</FilterField>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
@@ -6,6 +6,8 @@ export type SearchDraft = {
host: string; host: string;
path: string; path: string;
statusCode: string; statusCode: string;
since: string;
until: string;
}; };
export type OverviewRangeHours = 24 | 168 | 360 | 720; export type OverviewRangeHours = 24 | 168 | 360 | 720;
+6
View File
@@ -34,6 +34,8 @@ const emptyDraft: SearchDraft = {
host: '', host: '',
path: '', path: '',
statusCode: '', statusCode: '',
since: '',
until: '',
}; };
function resolveTab(value: string | null): AccessLogTab { function resolveTab(value: string | null): AccessLogTab {
@@ -115,6 +117,8 @@ function AccessLogsPageContent() {
status_code: filters.statusCode status_code: filters.statusCode
? Number.parseInt(filters.statusCode, 10) ? Number.parseInt(filters.statusCode, 10)
: undefined, : undefined,
since: filters.since || undefined,
until: filters.until || undefined,
p: page, p: page,
page_size: pageSize, page_size: pageSize,
sort_by: detailSortState.sortBy, sort_by: detailSortState.sortBy,
@@ -166,6 +170,8 @@ function AccessLogsPageContent() {
host: draft.host.trim(), host: draft.host.trim(),
path: draft.path.trim(), path: draft.path.trim(),
statusCode: draft.statusCode.trim(), statusCode: draft.statusCode.trim(),
since: draft.since.trim(),
until: draft.until.trim(),
}); });
setPage(0); setPage(0);
}, [draft]); }, [draft]);
+2
View File
@@ -631,6 +631,8 @@ export interface AccessLogFilters {
host?: string; host?: string;
path?: string; path?: string;
status_code?: number; status_code?: number;
since?: string;
until?: string;
p?: number; p?: number;
page_size?: number; page_size?: number;
sort_by?: string; sort_by?: string;
@@ -40,6 +40,8 @@ type AccessLogQuery struct {
Host string `json:"host"` Host string `json:"host"`
Path string `json:"path"` Path string `json:"path"`
StatusCode int `json:"status_code"` StatusCode int `json:"status_code"`
Since string `json:"since"`
Until string `json:"until"`
Page int `json:"page"` Page int `json:"page"`
PageSize int `json:"page_size"` PageSize int `json:"page_size"`
SortBy string `json:"sort_by"` SortBy string `json:"sort_by"`
@@ -534,7 +536,10 @@ func buildAccessLogOverviewTrends(
// ListAccessLogs returns paginated access logs. // ListAccessLogs returns paginated access logs.
func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) { func ListAccessLogs(ctx context.Context, input AccessLogQuery) (*AccessLogList, error) {
normalized := normalizeAccessLogQuery(input) normalized := normalizeAccessLogQuery(input)
modelQuery := buildModelAccessLogQuery(normalized) modelQuery, err := buildModelAccessLogQuery(normalized)
if err != nil {
return nil, err
}
logs, err := repository.ListOpenFlareAccessLogs(ctx, modelQuery) logs, err := repository.ListOpenFlareAccessLogs(ctx, modelQuery)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -587,13 +592,17 @@ func ListFoldedAccessLogs(ctx context.Context, input AccessLogQuery) (*FoldedAcc
if err != nil { if err != nil {
return nil, err return nil, err
} }
modelQuery := buildModelAccessLogQuery(normalized) modelQuery, err := buildModelAccessLogQuery(normalized)
if err != nil {
return nil, err
}
bucketQuery := model.OpenFlareAccessLogBucketQuery{ bucketQuery := model.OpenFlareAccessLogBucketQuery{
NodeID: modelQuery.NodeID, NodeID: modelQuery.NodeID,
RemoteAddr: modelQuery.RemoteAddr, RemoteAddr: modelQuery.RemoteAddr,
Host: modelQuery.Host, Host: modelQuery.Host,
Path: modelQuery.Path, Path: modelQuery.Path,
Since: modelQuery.Since, Since: modelQuery.Since,
Until: modelQuery.Until,
Page: normalized.Page, Page: normalized.Page,
PageSize: normalized.PageSize, PageSize: normalized.PageSize,
SortBy: normalizeFoldSortBy(input.SortBy), SortBy: normalizeFoldSortBy(input.SortBy),
@@ -882,19 +891,49 @@ func CleanupAccessLogs(ctx context.Context, input AccessLogCleanupInput) (*Acces
}, nil }, 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{ return model.OpenFlareAccessLogQuery{
NodeID: strings.TrimSpace(input.NodeID), NodeID: strings.TrimSpace(input.NodeID),
RemoteAddr: strings.TrimSpace(input.RemoteAddr), RemoteAddr: strings.TrimSpace(input.RemoteAddr),
Host: strings.TrimSpace(input.Host), Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path), Path: strings.TrimSpace(input.Path),
StatusCode: input.StatusCode, StatusCode: input.StatusCode,
Since: defaultAccessLogSince(), Since: since,
Until: until,
Page: input.Page, Page: input.Page,
PageSize: input.PageSize, PageSize: input.PageSize,
SortBy: input.SortBy, SortBy: input.SortBy,
SortOrder: input.SortOrder, 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 { func defaultAccessLogSince() time.Time {
@@ -935,6 +974,8 @@ func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
Host: strings.TrimSpace(input.Host), Host: strings.TrimSpace(input.Host),
Path: strings.TrimSpace(input.Path), Path: strings.TrimSpace(input.Path),
StatusCode: input.StatusCode, StatusCode: input.StatusCode,
Since: strings.TrimSpace(input.Since),
Until: strings.TrimSpace(input.Until),
Page: normalizeAccessLogPage(input.Page), Page: normalizeAccessLogPage(input.Page),
PageSize: normalizeAccessLogPageSize(input.PageSize), PageSize: normalizeAccessLogPageSize(input.PageSize),
SortBy: normalizeAccessLogSortBy(input.SortBy), SortBy: normalizeAccessLogSortBy(input.SortBy),
@@ -101,3 +101,30 @@ func TestReadAccessLogQueryRejectsInvalidStatusCode(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, 0, got.StatusCode) 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)
})
}
}
@@ -54,6 +54,8 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
// @Param host query string false "请求 Host" // @Param host query string false "请求 Host"
// @Param path query string false "请求路径" // @Param path query string false "请求路径"
// @Param status_code query int false "HTTP 状态码(100-599)" // @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 p query int false "页码"
// @Param page_size query int false "每页条数" // @Param page_size query int false "每页条数"
// @Param sort_by query string false "排序字段" // @Param sort_by query string false "排序字段"
@@ -284,6 +286,8 @@ func readAccessLogQuery(c *gin.Context) (AccessLogQuery, error) {
RemoteAddr: c.Query("remote_addr"), RemoteAddr: c.Query("remote_addr"),
Host: c.Query("host"), Host: c.Query("host"),
Path: c.Query("path"), Path: c.Query("path"),
Since: c.Query("since"),
Until: c.Query("until"),
Page: readQueryInt(c, "p"), Page: readQueryInt(c, "p"),
PageSize: readQueryInt(c, "page_size"), PageSize: readQueryInt(c, "page_size"),
SortBy: c.Query("sort_by"), SortBy: c.Query("sort_by"),