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:
ryan
2026-08-13 09:59:32 +08:00
parent f1577bf092
commit 75d1fcf345
18 changed files with 322 additions and 75 deletions
@@ -1,8 +1,14 @@
'use client';
import { Search } from 'lucide-react';
import { useState } from 'react';
import { ChevronDown, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -12,7 +18,7 @@ import {
SelectValue,
} from '@/components/ui/select';
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 {
draft: SearchDraft;
@@ -23,6 +29,21 @@ interface AccessLogFiltersProps {
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({
draft,
pageSize,
@@ -31,28 +52,12 @@ export function AccessLogFilters({
onSearch,
onReset,
}: AccessLogFiltersProps) {
const [moreOpen, setMoreOpen] = useState(false);
return (
<div className='space-y-3'>
<div className='grid gap-3 md:grid-cols-2 xl:grid-cols-4'>
<div className='space-y-1.5'>
<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>
<FilterField label='来源 IP'>
<Input
value={draft.remoteAddr}
onChange={(e) =>
@@ -64,33 +69,91 @@ export function AccessLogFilters({
placeholder='按 IP 搜索'
className='h-9 text-xs'
/>
</div>
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>访问域名</p>
<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'
/>
</div>
<div className='space-y-1.5'>
<p className='text-xs font-medium text-muted-foreground'>请求路径</p>
<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'
/>
</div>
</FilterField>
<FilterField label='状态码'>
<Select
value={draft.statusCode}
onValueChange={(value) =>
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>
))}
</SelectContent>
</Select>
</FilterField>
</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='space-y-1.5 w-full sm:max-w-[180px]'>
<p className='text-xs font-medium text-muted-foreground'>每页条数</p>
@@ -5,6 +5,7 @@ export type SearchDraft = {
remoteAddr: string;
host: string;
path: string;
statusCode: string;
};
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];
/** 状态码快捷筛选项(空值表示全部,由 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: {
value: OverviewRangeHours;
label: string;
+5
View File
@@ -33,6 +33,7 @@ const emptyDraft: SearchDraft = {
remoteAddr: '',
host: '',
path: '',
statusCode: '',
};
function resolveTab(value: string | null): AccessLogTab {
@@ -111,6 +112,9 @@ function AccessLogsPageContent() {
remote_addr: filters.remoteAddr || undefined,
host: filters.host || undefined,
path: filters.path || undefined,
status_code: filters.statusCode
? Number.parseInt(filters.statusCode, 10)
: undefined,
p: page,
page_size: pageSize,
sort_by: detailSortState.sortBy,
@@ -161,6 +165,7 @@ function AccessLogsPageContent() {
remoteAddr: draft.remoteAddr.trim(),
host: draft.host.trim(),
path: draft.path.trim(),
statusCode: draft.statusCode.trim(),
});
setPage(0);
}, [draft]);
+1
View File
@@ -630,6 +630,7 @@ export interface AccessLogFilters {
remote_addr?: string;
host?: string;
path?: string;
status_code?: number;
p?: number;
page_size?: number;
sort_by?: string;