mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
feat(access-logs): 状态码支持手动输入,新增时间范围筛选
- 状态码筛选支持预设快捷选项 + 手动输入任意 100-599 状态码(数字校验) - 新增时间范围筛选:shadcn 日期+时间选择器(Popover+Calendar+时分 Select), 起止时间以 RFC3339 成对传入,后端校验格式与先后关系,非法值返回 400 - 默认显示来源 IP/访问域名/状态码,节点 ID/请求路径/时间范围折叠进「更多筛选」
This commit is contained in:
@@ -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 语义一致),并同步过滤空白归属地。
|
||||||
|
|||||||
@@ -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": "页码",
|
||||||
|
|||||||
@@ -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": "页码",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
Reference in New Issue
Block a user