mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat(access-logs): 日志明细支持按状态码筛选并折叠次要搜索项,修复首页来源分布无数据
- 修复 PostgreSQL/SQLite 日志库下首页「来源分布」卡片无数据:RegionCounts 对空 节点 ID 误拼 node_id = '' 恒空条件,改为空节点 ID 表示全节点聚合(对齐 CH 语义), 并过滤空白归属地 - /access-logs?tab=list 新增状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 选项, 校验 100-599,非法值返回 400;ClickHouse 与 PostgreSQL/SQLite 日志库均支持 - 搜索框折叠:默认仅显示来源 IP 与状态码,节点 ID/访问域名/请求路径折叠进 「更多筛选」
This commit is contained in:
@@ -18,7 +18,11 @@ sidebar: false
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- 访问日志「日志明细」支持按 HTTP 状态码筛选:状态码下拉含常用 2xx/3xx/4xx/5xx 快捷选项,筛选条件贯穿 ClickHouse 与 PostgreSQL/SQLite 日志库。
|
||||||
|
|
||||||
### 🛠 修复
|
### 🛠 修复
|
||||||
|
- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据:聚合查询对空节点 ID 误拼 `node_id = ''` 恒空条件,现改为空节点 ID 表示全节点聚合(与 ClickHouse 语义一致),并同步过滤空白归属地。
|
||||||
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应:`proxy_intercept_errors` 会在 Lua 判断前丢弃源站错误响应体,POST/PUT 等请求收到 503 时被 OpenResty 自带错误页覆盖原始报错数据;现改为在代理层用 Lua 过滤器(`header_filter`/`body_filter`)仅对 GET 请求替换错误页,非 GET 请求完整透传源站原始状态码与响应体;非仅 GET 模式继续使用命名 location(`@__openflare_origin_error`)承载错误页,保留原始请求方法与错误状态码。
|
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应:`proxy_intercept_errors` 会在 Lua 判断前丢弃源站错误响应体,POST/PUT 等请求收到 503 时被 OpenResty 自带错误页覆盖原始报错数据;现改为在代理层用 Lua 过滤器(`header_filter`/`body_filter`)仅对 GET 请求替换错误页,非 GET 请求完整透传源站原始状态码与响应体;非仅 GET 模式继续使用命名 location(`@__openflare_origin_error`)承载错误页,保留原始请求方法与错误状态码。
|
||||||
- 修复 PostgreSQL 作为日志库时节点访问日志/可观测指标/用户访问日志批量写入失败:GORM 对零值 `uint64` 主键会省略 `id` 列,而 PG 日志表 `id` 无默认值,导致持续报「null value in column id violates not-null constraint」;现于落库前为零 ID 行生成雪花 ID(与 ClickHouse 写入路径一致),并新增回归测试覆盖六张日志表。
|
- 修复 PostgreSQL 作为日志库时节点访问日志/可观测指标/用户访问日志批量写入失败:GORM 对零值 `uint64` 主键会省略 `id` 列,而 PG 日志表 `id` 无默认值,导致持续报「null value in column id violates not-null constraint」;现于落库前为零 ID 行生成雪花 ID(与 ClickHouse 写入路径一致),并新增回归测试覆盖六张日志表。
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -4809,7 +4809,7 @@ const docTemplate = `{
|
|||||||
"SessionCookie": []
|
"SessionCookie": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限",
|
"description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -4842,6 +4842,12 @@ const docTemplate = `{
|
|||||||
"name": "path",
|
"name": "path",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "HTTP 状态码(100-599)",
|
||||||
|
"name": "status_code",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "页码",
|
"description": "页码",
|
||||||
|
|||||||
+7
-1
@@ -4802,7 +4802,7 @@
|
|||||||
"SessionCookie": []
|
"SessionCookie": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限",
|
"description": "分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限",
|
||||||
"produces": [
|
"produces": [
|
||||||
"application/json"
|
"application/json"
|
||||||
],
|
],
|
||||||
@@ -4835,6 +4835,12 @@
|
|||||||
"name": "path",
|
"name": "path",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "HTTP 状态码(100-599)",
|
||||||
|
"name": "status_code",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "页码",
|
"description": "页码",
|
||||||
|
|||||||
+5
-1
@@ -7194,7 +7194,7 @@ paths:
|
|||||||
- config
|
- config
|
||||||
/api/v1/d/access-logs:
|
/api/v1/d/access-logs:
|
||||||
get:
|
get:
|
||||||
description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
|
description: 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限
|
||||||
parameters:
|
parameters:
|
||||||
- description: 节点 ID
|
- description: 节点 ID
|
||||||
in: query
|
in: query
|
||||||
@@ -7212,6 +7212,10 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: path
|
name: path
|
||||||
type: string
|
type: string
|
||||||
|
- description: HTTP 状态码(100-599)
|
||||||
|
in: query
|
||||||
|
name: status_code
|
||||||
|
type: integer
|
||||||
- description: 页码
|
- description: 页码
|
||||||
in: query
|
in: query
|
||||||
name: p
|
name: p
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Search } from 'lucide-react';
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -12,7 +18,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import type { SearchDraft } from './access-log-utils';
|
import type { SearchDraft } from './access-log-utils';
|
||||||
import { PAGE_SIZE_OPTIONS } from './access-log-utils';
|
import { PAGE_SIZE_OPTIONS, STATUS_CODE_OPTIONS } from './access-log-utils';
|
||||||
|
|
||||||
interface AccessLogFiltersProps {
|
interface AccessLogFiltersProps {
|
||||||
draft: SearchDraft;
|
draft: SearchDraft;
|
||||||
@@ -23,6 +29,21 @@ interface AccessLogFiltersProps {
|
|||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FilterField({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<p className='text-xs font-medium text-muted-foreground'>{label}</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccessLogFilters({
|
export function AccessLogFilters({
|
||||||
draft,
|
draft,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -31,28 +52,12 @@ export function AccessLogFilters({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onReset,
|
onReset,
|
||||||
}: AccessLogFiltersProps) {
|
}: AccessLogFiltersProps) {
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-3'>
|
<div className='space-y-3'>
|
||||||
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
<div className='space-y-1.5'>
|
<FilterField label='来源 IP'>
|
||||||
<p className='text-xs font-medium text-muted-foreground'>节点 ID</p>
|
|
||||||
<div className='relative'>
|
|
||||||
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
|
||||||
<Input
|
|
||||||
value={draft.nodeId}
|
|
||||||
onChange={(e) =>
|
|
||||||
onDraftChange({ ...draft, nodeId: e.target.value })
|
|
||||||
}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') onSearch();
|
|
||||||
}}
|
|
||||||
placeholder='按 node_id 搜索'
|
|
||||||
className='pl-8 h-9 text-xs'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-1.5'>
|
|
||||||
<p className='text-xs font-medium text-muted-foreground'>来源 IP</p>
|
|
||||||
<Input
|
<Input
|
||||||
value={draft.remoteAddr}
|
value={draft.remoteAddr}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -64,33 +69,91 @@ export function AccessLogFilters({
|
|||||||
placeholder='按 IP 搜索'
|
placeholder='按 IP 搜索'
|
||||||
className='h-9 text-xs'
|
className='h-9 text-xs'
|
||||||
/>
|
/>
|
||||||
</div>
|
</FilterField>
|
||||||
<div className='space-y-1.5'>
|
<FilterField label='状态码'>
|
||||||
<p className='text-xs font-medium text-muted-foreground'>访问域名</p>
|
<Select
|
||||||
<Input
|
value={draft.statusCode}
|
||||||
value={draft.host}
|
onValueChange={(value) =>
|
||||||
onChange={(e) => onDraftChange({ ...draft, host: e.target.value })}
|
onDraftChange({ ...draft, statusCode: value })
|
||||||
onKeyDown={(e) => {
|
}
|
||||||
if (e.key === 'Enter') onSearch();
|
>
|
||||||
}}
|
<SelectTrigger className='h-9 text-xs'>
|
||||||
placeholder='按域名搜索'
|
<SelectValue placeholder='全部状态码' />
|
||||||
className='h-9 text-xs'
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
</div>
|
{STATUS_CODE_OPTIONS.map((option) => (
|
||||||
<div className='space-y-1.5'>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
|
{option.label}
|
||||||
<Input
|
</SelectItem>
|
||||||
value={draft.path}
|
))}
|
||||||
onChange={(e) => onDraftChange({ ...draft, path: e.target.value })}
|
</SelectContent>
|
||||||
onKeyDown={(e) => {
|
</Select>
|
||||||
if (e.key === 'Enter') onSearch();
|
</FilterField>
|
||||||
}}
|
|
||||||
placeholder='按路径搜索'
|
|
||||||
className='h-9 text-xs'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Collapsible open={moreOpen} onOpenChange={setMoreOpen}>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='-ml-1 h-8 gap-1 px-1 text-xs text-muted-foreground'
|
||||||
|
>
|
||||||
|
更多筛选
|
||||||
|
<ChevronDown
|
||||||
|
className={`size-3.5 transition-transform ${
|
||||||
|
moreOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className='grid gap-3 pt-3 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
<FilterField label='节点 ID'>
|
||||||
|
<div className='relative'>
|
||||||
|
<Search className='absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||||
|
<Input
|
||||||
|
value={draft.nodeId}
|
||||||
|
onChange={(e) =>
|
||||||
|
onDraftChange({ ...draft, nodeId: e.target.value })
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') onSearch();
|
||||||
|
}}
|
||||||
|
placeholder='按 node_id 搜索'
|
||||||
|
className='pl-8 h-9 text-xs'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</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='请求路径'>
|
||||||
|
<Input
|
||||||
|
value={draft.path}
|
||||||
|
onChange={(e) =>
|
||||||
|
onDraftChange({ ...draft, path: e.target.value })
|
||||||
|
}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') onSearch();
|
||||||
|
}}
|
||||||
|
placeholder='按路径搜索'
|
||||||
|
className='h-9 text-xs'
|
||||||
|
/>
|
||||||
|
</FilterField>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between'>
|
||||||
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
|
<div className='space-y-1.5 w-full sm:max-w-[180px]'>
|
||||||
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
|
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type SearchDraft = {
|
|||||||
remoteAddr: string;
|
remoteAddr: string;
|
||||||
host: string;
|
host: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
statusCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OverviewRangeHours = 24 | 168 | 360 | 720;
|
export type OverviewRangeHours = 24 | 168 | 360 | 720;
|
||||||
@@ -14,6 +15,23 @@ export type RateLimitRangeHours = 24 | 72;
|
|||||||
|
|
||||||
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200];
|
||||||
|
|
||||||
|
/** 状态码快捷筛选项(空值表示全部,由 Select placeholder 兜底)。 */
|
||||||
|
export const STATUS_CODE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: '200', label: '200 OK' },
|
||||||
|
{ value: '301', label: '301 永久重定向' },
|
||||||
|
{ value: '302', label: '302 临时重定向' },
|
||||||
|
{ value: '304', label: '304 未修改' },
|
||||||
|
{ value: '400', label: '400 请求错误' },
|
||||||
|
{ value: '401', label: '401 未授权' },
|
||||||
|
{ value: '403', label: '403 禁止访问' },
|
||||||
|
{ value: '404', label: '404 未找到' },
|
||||||
|
{ value: '429', label: '429 请求过多' },
|
||||||
|
{ value: '500', label: '500 服务器错误' },
|
||||||
|
{ value: '502', label: '502 网关错误' },
|
||||||
|
{ value: '503', label: '503 服务不可用' },
|
||||||
|
{ value: '504', label: '504 网关超时' },
|
||||||
|
];
|
||||||
|
|
||||||
export const OVERVIEW_RANGE_OPTIONS: {
|
export const OVERVIEW_RANGE_OPTIONS: {
|
||||||
value: OverviewRangeHours;
|
value: OverviewRangeHours;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const emptyDraft: SearchDraft = {
|
|||||||
remoteAddr: '',
|
remoteAddr: '',
|
||||||
host: '',
|
host: '',
|
||||||
path: '',
|
path: '',
|
||||||
|
statusCode: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveTab(value: string | null): AccessLogTab {
|
function resolveTab(value: string | null): AccessLogTab {
|
||||||
@@ -111,6 +112,9 @@ function AccessLogsPageContent() {
|
|||||||
remote_addr: filters.remoteAddr || undefined,
|
remote_addr: filters.remoteAddr || undefined,
|
||||||
host: filters.host || undefined,
|
host: filters.host || undefined,
|
||||||
path: filters.path || undefined,
|
path: filters.path || undefined,
|
||||||
|
status_code: filters.statusCode
|
||||||
|
? Number.parseInt(filters.statusCode, 10)
|
||||||
|
: undefined,
|
||||||
p: page,
|
p: page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
sort_by: detailSortState.sortBy,
|
sort_by: detailSortState.sortBy,
|
||||||
@@ -161,6 +165,7 @@ function AccessLogsPageContent() {
|
|||||||
remoteAddr: draft.remoteAddr.trim(),
|
remoteAddr: draft.remoteAddr.trim(),
|
||||||
host: draft.host.trim(),
|
host: draft.host.trim(),
|
||||||
path: draft.path.trim(),
|
path: draft.path.trim(),
|
||||||
|
statusCode: draft.statusCode.trim(),
|
||||||
});
|
});
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|||||||
@@ -630,6 +630,7 @@ export interface AccessLogFilters {
|
|||||||
remote_addr?: string;
|
remote_addr?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
path?: string;
|
path?: string;
|
||||||
|
status_code?: number;
|
||||||
p?: number;
|
p?: number;
|
||||||
page_size?: number;
|
page_size?: number;
|
||||||
sort_by?: string;
|
sort_by?: string;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type AccessLogQuery struct {
|
|||||||
RemoteAddr string `json:"remote_addr"`
|
RemoteAddr string `json:"remote_addr"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
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"`
|
||||||
@@ -887,6 +888,7 @@ func buildModelAccessLogQuery(input AccessLogQuery) model.OpenFlareAccessLogQuer
|
|||||||
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,
|
||||||
Since: defaultAccessLogSince(),
|
Since: defaultAccessLogSince(),
|
||||||
Page: input.Page,
|
Page: input.Page,
|
||||||
PageSize: input.PageSize,
|
PageSize: input.PageSize,
|
||||||
@@ -932,6 +934,7 @@ func normalizeAccessLogQuery(input AccessLogQuery) AccessLogQuery {
|
|||||||
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,
|
||||||
Page: normalizeAccessLogPage(input.Page),
|
Page: normalizeAccessLogPage(input.Page),
|
||||||
PageSize: normalizeAccessLogPageSize(input.PageSize),
|
PageSize: normalizeAccessLogPageSize(input.PageSize),
|
||||||
SortBy: normalizeAccessLogSortBy(input.SortBy),
|
SortBy: normalizeAccessLogSortBy(input.SortBy),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Package observability defines shared error messages for observability operations.
|
||||||
|
package observability
|
||||||
|
|
||||||
|
const (
|
||||||
|
errInvalidStatusCode = "status_code 必须为 100-599 之间的整数"
|
||||||
|
)
|
||||||
@@ -55,3 +55,49 @@ func TestReadQueryStringArrayAcceptsHostsBracketForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReadAccessLogQueryIncludesStatusCode(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
req, err := http.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/?node_id=n1&remote_addr=1.2.3.4&host=a.example&path=/api&status_code=404&p=2&page_size=50",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
got, err := readAccessLogQuery(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "n1", got.NodeID)
|
||||||
|
require.Equal(t, "1.2.3.4", got.RemoteAddr)
|
||||||
|
require.Equal(t, "a.example", got.Host)
|
||||||
|
require.Equal(t, "/api", got.Path)
|
||||||
|
require.Equal(t, 404, got.StatusCode)
|
||||||
|
require.Equal(t, 2, got.Page)
|
||||||
|
require.Equal(t, 50, got.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAccessLogQueryRejectsInvalidStatusCode(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
for _, raw := range []string{"abc", "99", "600", "-1"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "/?status_code="+raw, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
_, err = readAccessLogQuery(c)
|
||||||
|
require.Error(t, err, "status_code=%s should be rejected", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
c.Request = req
|
||||||
|
got, err := readAccessLogQuery(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 0, got.StatusCode)
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
package observability
|
package observability
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"errors"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
|
||||||
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetAccessLogOverviewHandler 获取访问日志概览。
|
// GetAccessLogOverviewHandler 获取访问日志概览。
|
||||||
@@ -45,7 +45,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
|||||||
|
|
||||||
// GetAccessLogsHandler 分页列出访问日志。
|
// GetAccessLogsHandler 分页列出访问日志。
|
||||||
// @Summary 列出访问日志
|
// @Summary 列出访问日志
|
||||||
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机与路径筛选,需要管理员权限
|
// @Description 分页返回 OpenFlare 访问日志,支持按节点、IP、主机、路径与状态码筛选,需要管理员权限
|
||||||
// @Tags openflare-observability
|
// @Tags openflare-observability
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security SessionCookie
|
// @Security SessionCookie
|
||||||
@@ -53,6 +53,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
|||||||
// @Param remote_addr query string false "客户端 IP"
|
// @Param remote_addr query string false "客户端 IP"
|
||||||
// @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 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 "排序字段"
|
||||||
@@ -64,7 +65,11 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
|||||||
// @Failure 500 {object} response.Any "内部错误"
|
// @Failure 500 {object} response.Any "内部错误"
|
||||||
// @Router /api/v1/d/access-logs [get]
|
// @Router /api/v1/d/access-logs [get]
|
||||||
func GetAccessLogsHandler(c *gin.Context) {
|
func GetAccessLogsHandler(c *gin.Context) {
|
||||||
logs, err := ListAccessLogs(c.Request.Context(), readAccessLogQuery(c))
|
query, err := readAccessLogQuery(c)
|
||||||
|
if apiutil.AbortBadRequestOnError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logs, err := ListAccessLogs(c.Request.Context(), query)
|
||||||
if apiutil.AbortBadRequestOnError(c, err) {
|
if apiutil.AbortBadRequestOnError(c, err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,7 +98,10 @@ func GetAccessLogsHandler(c *gin.Context) {
|
|||||||
// @Failure 500 {object} response.Any "内部错误"
|
// @Failure 500 {object} response.Any "内部错误"
|
||||||
// @Router /api/v1/d/access-logs/folds [get]
|
// @Router /api/v1/d/access-logs/folds [get]
|
||||||
func GetFoldedAccessLogsHandler(c *gin.Context) {
|
func GetFoldedAccessLogsHandler(c *gin.Context) {
|
||||||
query := readAccessLogQuery(c)
|
query, err := readAccessLogQuery(c)
|
||||||
|
if apiutil.AbortBadRequestOnError(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
query.FoldMinutes = readQueryInt(c, "fold_minutes")
|
query.FoldMinutes = readQueryInt(c, "fold_minutes")
|
||||||
logs, err := ListFoldedAccessLogs(c.Request.Context(), query)
|
logs, err := ListFoldedAccessLogs(c.Request.Context(), query)
|
||||||
if apiutil.AbortBadRequestOnError(c, err) {
|
if apiutil.AbortBadRequestOnError(c, err) {
|
||||||
@@ -270,8 +278,8 @@ func CleanupAccessLogsHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response.OK(result))
|
c.JSON(http.StatusOK, response.OK(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAccessLogQuery(c *gin.Context) AccessLogQuery {
|
func readAccessLogQuery(c *gin.Context) (AccessLogQuery, error) {
|
||||||
return AccessLogQuery{
|
query := AccessLogQuery{
|
||||||
NodeID: c.Query("node_id"),
|
NodeID: c.Query("node_id"),
|
||||||
RemoteAddr: c.Query("remote_addr"),
|
RemoteAddr: c.Query("remote_addr"),
|
||||||
Host: c.Query("host"),
|
Host: c.Query("host"),
|
||||||
@@ -281,6 +289,14 @@ func readAccessLogQuery(c *gin.Context) AccessLogQuery {
|
|||||||
SortBy: c.Query("sort_by"),
|
SortBy: c.Query("sort_by"),
|
||||||
SortOrder: c.Query("sort_order"),
|
SortOrder: c.Query("sort_order"),
|
||||||
}
|
}
|
||||||
|
if raw := c.Query("status_code"); raw != "" {
|
||||||
|
code, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || code < 100 || code > 599 {
|
||||||
|
return AccessLogQuery{}, errors.New(errInvalidStatusCode)
|
||||||
|
}
|
||||||
|
query.StatusCode = code
|
||||||
|
}
|
||||||
|
return query, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readQueryInt(c *gin.Context, key string) int {
|
func readQueryInt(c *gin.Context, key string) int {
|
||||||
|
|||||||
@@ -25,14 +25,16 @@ type NodeAccessLogFilter struct {
|
|||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
Host string
|
Host string
|
||||||
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||||
Hosts []string
|
Hosts []string
|
||||||
Path string
|
Path string
|
||||||
Since time.Time
|
// StatusCode filters by exact HTTP status code when > 0.
|
||||||
Until time.Time
|
StatusCode int
|
||||||
Page int
|
Since time.Time
|
||||||
PageSize int
|
Until time.Time
|
||||||
SortBy string
|
Page int
|
||||||
SortOrder string
|
PageSize int
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeObservabilityFilter scopes ClickHouse node observability queries.
|
// NodeObservabilityFilter scopes ClickHouse node observability queries.
|
||||||
|
|||||||
@@ -161,14 +161,16 @@ type OpenFlareAccessLogQuery struct {
|
|||||||
RemoteAddr string
|
RemoteAddr string
|
||||||
Host string
|
Host string
|
||||||
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
// Hosts exact-matches any host (case-insensitive). Prefer over Host for multi-domain scopes.
|
||||||
Hosts []string
|
Hosts []string
|
||||||
Path string
|
Path string
|
||||||
Since time.Time
|
// StatusCode filters by exact HTTP status code when > 0.
|
||||||
Until time.Time
|
StatusCode int
|
||||||
Page int
|
Since time.Time
|
||||||
PageSize int
|
Until time.Time
|
||||||
SortBy string
|
Page int
|
||||||
SortOrder string
|
PageSize int
|
||||||
|
SortBy string
|
||||||
|
SortOrder string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
|
// OpenFlareAccessLogBucketQuery filters folded access log queries (v1 stub).
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ func TestBuildUserAccessLogFilterClause_EmptyUserIDs(t *testing.T) {
|
|||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildNodeAccessLogFilterClause_StatusCode(t *testing.T) {
|
||||||
|
clause, args := buildNodeAccessLogFilterClause(NodeAccessLogFilter{StatusCode: 404})
|
||||||
|
assert.Equal(t, "status_code = ?", clause)
|
||||||
|
assert.Equal(t, []any{404}, args)
|
||||||
|
|
||||||
|
clause, args = buildNodeAccessLogFilterClause(NodeAccessLogFilter{})
|
||||||
|
assert.Equal(t, "1", clause)
|
||||||
|
assert.Nil(t, args)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCountAccessLogs_EmptyUserIDs(t *testing.T) {
|
func TestCountAccessLogs_EmptyUserIDs(t *testing.T) {
|
||||||
count, err := CountAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}})
|
count, err := CountAccessLogs(context.Background(), AccessLogFilter{UserIDs: []uint64{}})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
nodeAccessLogFilterClauseCapacity = 6
|
nodeAccessLogFilterClauseCapacity = 7
|
||||||
|
|
||||||
nodeAccessLogSortDesc = "DESC"
|
nodeAccessLogSortDesc = "DESC"
|
||||||
nodeAccessLogSortAsc = "ASC"
|
nodeAccessLogSortAsc = "ASC"
|
||||||
@@ -55,6 +55,10 @@ func buildNodeAccessLogFilterClause(filter NodeAccessLogFilter) (string, []any)
|
|||||||
parts = append(parts, "path LIKE ?")
|
parts = append(parts, "path LIKE ?")
|
||||||
args = append(args, trimmed+"%")
|
args = append(args, trimmed+"%")
|
||||||
}
|
}
|
||||||
|
if filter.StatusCode > 0 {
|
||||||
|
parts = append(parts, "status_code = ?")
|
||||||
|
args = append(args, filter.StatusCode)
|
||||||
|
}
|
||||||
if !filter.Since.IsZero() {
|
if !filter.Since.IsZero() {
|
||||||
parts = append(parts, "logged_at >= ?")
|
parts = append(parts, "logged_at >= ?")
|
||||||
args = append(args, filter.Since.UTC())
|
args = append(args, filter.Since.UTC())
|
||||||
|
|||||||
@@ -282,7 +282,12 @@ func (s *gormLogStore) RegionCounts(ctx context.Context, nodeID string, since ti
|
|||||||
var rows []row
|
var rows []row
|
||||||
q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}).
|
q := s.db.WithContext(ctx).Model(&analyticsmodel.NodeAccessLog{}).
|
||||||
Select("region, COUNT(*) AS count").
|
Select("region, COUNT(*) AS count").
|
||||||
Where("node_id = ? AND region <> '' AND logged_at >= ?", nodeID, since)
|
Where("trim(region) <> '' AND logged_at >= ?", since)
|
||||||
|
// 空 nodeID 表示全节点聚合(对齐 CH 语义),仅非空时追加 node_id 过滤,
|
||||||
|
// 避免 `node_id = ''` 恒空导致首页来源分布无数据。
|
||||||
|
if nodeID = strings.TrimSpace(nodeID); nodeID != "" {
|
||||||
|
q = q.Where("node_id = ?", nodeID)
|
||||||
|
}
|
||||||
if err := q.Group("region").Order("count DESC").Limit(limitOr(limit, defaultTopN)).Scan(&rows).Error; err != nil {
|
if err := q.Group("region").Order("count DESC").Limit(limitOr(limit, defaultTopN)).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -788,6 +793,7 @@ func toNodeAccessLogFilter(query model.OpenFlareAccessLogQuery) analyticsmodel.N
|
|||||||
Host: query.Host,
|
Host: query.Host,
|
||||||
Hosts: query.Hosts,
|
Hosts: query.Hosts,
|
||||||
Path: query.Path,
|
Path: query.Path,
|
||||||
|
StatusCode: query.StatusCode,
|
||||||
Since: query.Since,
|
Since: query.Since,
|
||||||
Until: query.Until,
|
Until: query.Until,
|
||||||
Page: query.Page,
|
Page: query.Page,
|
||||||
@@ -823,6 +829,10 @@ func buildNodeAccessLogFilterParts(f analyticsmodel.NodeAccessLogFilter) (string
|
|||||||
parts = append(parts, "path LIKE ?")
|
parts = append(parts, "path LIKE ?")
|
||||||
args = append(args, path+"%")
|
args = append(args, path+"%")
|
||||||
}
|
}
|
||||||
|
if f.StatusCode > 0 {
|
||||||
|
parts = append(parts, "status_code = ?")
|
||||||
|
args = append(args, f.StatusCode)
|
||||||
|
}
|
||||||
if !f.Since.IsZero() {
|
if !f.Since.IsZero() {
|
||||||
parts = append(parts, "logged_at >= ?")
|
parts = append(parts, "logged_at >= ?")
|
||||||
args = append(args, f.Since)
|
args = append(args, f.Since)
|
||||||
|
|||||||
@@ -231,6 +231,44 @@ func TestGormNodeAggregatesExcludeEmptyNodeID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGormRegionCountsEmptyNodeIDAggregatesAll 回归测试:首页「来源分布」以空 node_id
|
||||||
|
// 表示全节点聚合,RegionCounts 不得拼出 `node_id = ”` 恒空条件(对齐 CH 语义)。
|
||||||
|
func TestGormRegionCountsEmptyNodeIDAggregatesAll(t *testing.T) {
|
||||||
|
ResetForTest()
|
||||||
|
SetConfigReader(func(_ context.Context, _ string) (string, error) { return "", nil })
|
||||||
|
s := newTestGormStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now()
|
||||||
|
rows := []analyticsmodel.NodeAccessLog{
|
||||||
|
{ID: 1, NodeID: "n1", LoggedAt: now, RemoteAddr: "1.1.1.1", Region: "CN"},
|
||||||
|
{ID: 2, NodeID: "n2", LoggedAt: now, RemoteAddr: "2.2.2.2", Region: "CN"},
|
||||||
|
{ID: 3, NodeID: "n3", LoggedAt: now, RemoteAddr: "3.3.3.3", Region: "US"},
|
||||||
|
{ID: 4, NodeID: "n4", LoggedAt: now, RemoteAddr: "4.4.4.4", Region: " "},
|
||||||
|
}
|
||||||
|
if err := s.BatchInsertNodeAccessLogs(ctx, rows); err != nil {
|
||||||
|
t.Fatalf("insert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := s.RegionCounts(ctx, "", now.Add(-time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("region counts (all nodes): %v", err)
|
||||||
|
}
|
||||||
|
if len(all) != 2 {
|
||||||
|
t.Fatalf("all-nodes region counts want 2 regions (empty region excluded), got %+v", all)
|
||||||
|
}
|
||||||
|
if all[0].Region != "CN" || all[0].Count != 2 || all[1].Region != "US" || all[1].Count != 1 {
|
||||||
|
t.Fatalf("all-nodes region counts got %+v, want CN=2 US=1", all)
|
||||||
|
}
|
||||||
|
|
||||||
|
cnOnly, err := s.RegionCounts(ctx, "n1", now.Add(-time.Hour), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("region counts (node): %v", err)
|
||||||
|
}
|
||||||
|
if len(cnOnly) != 1 || cnOnly[0].Region != "CN" || cnOnly[0].Count != 1 {
|
||||||
|
t.Fatalf("node-scoped region counts got %+v, want CN=1", cnOnly)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// testGormStoreSeq 保证每个测试获得独立的共享内存库(cache=shared 下同名 DSN 会复用同一库,
|
// testGormStoreSeq 保证每个测试获得独立的共享内存库(cache=shared 下同名 DSN 会复用同一库,
|
||||||
// 导致跨测试 id 冲突)。
|
// 导致跨测试 id 冲突)。
|
||||||
var testGormStoreSeq int64
|
var testGormStoreSeq int64
|
||||||
|
|||||||
Reference in New Issue
Block a user