mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-10 17:26:38 +08:00
feat(access-logs): 概览支持 Zone/域名多选筛选
概览可按 Zone→Domain 层级多选域名并折叠展开;明细列表 IP 旁展示地区。 后端 overview 支持 hosts 多域名精确匹配。
This commit is contained in:
@@ -28,6 +28,8 @@ sidebar: false
|
|||||||
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
|
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
|
||||||
- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
|
- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
|
||||||
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
|
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
|
||||||
|
- 访问日志概览支持按 Zone/域名多选筛选,并可折叠展开层级选择。
|
||||||
|
- 访问日志明细列表在 IP 旁展示对应国家/地区信息。
|
||||||
- 访问日志支持边缘缓存状态(`cache_status`):明细列表与详情可查看命中 / 回源 / 未缓存。启用需执行 ClickHouse 迁移并重新发布节点配置使新日志格式生效。
|
- 访问日志支持边缘缓存状态(`cache_status`):明细列表与详情可查看命中 / 回源 / 未缓存。启用需执行 ClickHouse 迁移并重新发布节点配置使新日志格式生效。
|
||||||
- 修复 Agent 观测缓冲在合并访问日志时忽略 `cache_status` 导致 HIT/MISS 被去重丢弃的问题。
|
- 修复 Agent 观测缓冲在合并访问日志时忽略 `cache_status` 导致 HIT/MISS 被去重丢弃的问题。
|
||||||
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
|
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
|
||||||
|
|||||||
+11
-1
@@ -5519,10 +5519,20 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "请求 Host",
|
"description": "请求 Host(单域名)",
|
||||||
"name": "host",
|
"name": "host",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collectionFormat": "csv",
|
||||||
|
"description": "请求 Host 列表(多域名精确匹配)",
|
||||||
|
"name": "hosts",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "统计时间范围(小时)",
|
"description": "统计时间范围(小时)",
|
||||||
|
|||||||
+11
-1
@@ -5512,10 +5512,20 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "请求 Host",
|
"description": "请求 Host(单域名)",
|
||||||
"name": "host",
|
"name": "host",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"collectionFormat": "csv",
|
||||||
|
"description": "请求 Host 列表(多域名精确匹配)",
|
||||||
|
"name": "hosts",
|
||||||
|
"in": "query"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "统计时间范围(小时)",
|
"description": "统计时间范围(小时)",
|
||||||
|
|||||||
+8
-1
@@ -7354,10 +7354,17 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
name: node_id
|
name: node_id
|
||||||
type: string
|
type: string
|
||||||
- description: 请求 Host
|
- description: 请求 Host(单域名)
|
||||||
in: query
|
in: query
|
||||||
name: host
|
name: host
|
||||||
type: string
|
type: string
|
||||||
|
- collectionFormat: csv
|
||||||
|
description: 请求 Host 列表(多域名精确匹配)
|
||||||
|
in: query
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
name: hosts
|
||||||
|
type: array
|
||||||
- description: 统计时间范围(小时)
|
- description: 统计时间范围(小时)
|
||||||
in: query
|
in: query
|
||||||
name: hours
|
name: hours
|
||||||
|
|||||||
@@ -170,8 +170,15 @@ export function DetailTab({
|
|||||||
<TableCell className='text-xs'>
|
<TableCell className='text-xs'>
|
||||||
{item.node_name || item.node_id}
|
{item.node_name || item.node_id}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='text-xs font-mono'>
|
<TableCell className='text-xs'>
|
||||||
{item.remote_addr}
|
<div className='flex flex-col gap-0.5'>
|
||||||
|
<span className='font-mono'>{item.remote_addr}</span>
|
||||||
|
{item.region ? (
|
||||||
|
<span className='text-[10px] text-muted-foreground'>
|
||||||
|
{item.region}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className='text-xs'>{item.host}</TableCell>
|
<TableCell className='text-xs'>{item.host}</TableCell>
|
||||||
<TableCell className='text-xs max-w-48 truncate'>
|
<TableCell className='text-xs max-w-48 truncate'>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
|
||||||
import { Cell, Pie, PieChart } from 'recharts';
|
import { Cell, Pie, PieChart } from 'recharts';
|
||||||
|
|
||||||
import { RankCard } from '@/components/data/rank-card';
|
import { RankCard } from '@/components/data/rank-card';
|
||||||
@@ -10,6 +12,8 @@ import { TrendChart } from '@/components/data/trend-chart';
|
|||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -25,11 +29,26 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@/components/ui/chart';
|
} from '@/components/ui/chart';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import type {
|
import {
|
||||||
AccessLogOverview,
|
ZoneService,
|
||||||
DistributionItem,
|
zoneQueryKey,
|
||||||
|
type AccessLogOverview,
|
||||||
|
type DistributionItem,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -255,15 +274,302 @@ function PieDistributionCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function OverviewRangeSwitch({
|
type ManagedZoneDomains = {
|
||||||
|
zoneId: number;
|
||||||
|
zoneName: string;
|
||||||
|
domains: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function useManagedZoneDomains(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [...zoneQueryKey, 'zone-domain-tree'],
|
||||||
|
enabled,
|
||||||
|
staleTime: 60_000,
|
||||||
|
queryFn: async (): Promise<ManagedZoneDomains[]> => {
|
||||||
|
const zones = await ZoneService.list();
|
||||||
|
const overviews = await Promise.all(
|
||||||
|
zones.map((zone) => ZoneService.getOverview(zone.id)),
|
||||||
|
);
|
||||||
|
return overviews
|
||||||
|
.map((overview) => {
|
||||||
|
const domainSet = new Set<string>();
|
||||||
|
for (const item of overview.domains ?? []) {
|
||||||
|
const domain = item.domain?.trim();
|
||||||
|
if (domain) domainSet.add(domain);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
zoneId: overview.zone.id,
|
||||||
|
zoneName: overview.zone.domain || `Zone #${overview.zone.id}`,
|
||||||
|
domains: Array.from(domainSet).sort((a, b) => a.localeCompare(b)),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((zone) => zone.domains.length > 0)
|
||||||
|
.sort((a, b) => a.zoneName.localeCompare(b.zoneName));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewHostFilter({
|
||||||
|
hosts,
|
||||||
|
onHostsChange,
|
||||||
|
}: {
|
||||||
|
hosts: string[];
|
||||||
|
onHostsChange: (hosts: string[]) => void;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [expandedZones, setExpandedZones] = useState<Record<number, boolean>>(
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const zonesQuery = useManagedZoneDomains(open);
|
||||||
|
const zones = useMemo(() => zonesQuery.data ?? [], [zonesQuery.data]);
|
||||||
|
const selectedSet = useMemo(() => new Set(hosts), [hosts]);
|
||||||
|
const filteredZones = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return zones;
|
||||||
|
return zones
|
||||||
|
.map((zone) => {
|
||||||
|
const zoneMatched = zone.zoneName.toLowerCase().includes(q);
|
||||||
|
const domains = zoneMatched
|
||||||
|
? zone.domains
|
||||||
|
: zone.domains.filter((domain) => domain.toLowerCase().includes(q));
|
||||||
|
return { ...zone, domains };
|
||||||
|
})
|
||||||
|
.filter((zone) => zone.domains.length > 0);
|
||||||
|
}, [query, zones]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || zones.length === 0) return;
|
||||||
|
setExpandedZones((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
let changed = false;
|
||||||
|
for (const zone of zones) {
|
||||||
|
if (next[zone.zoneId] === undefined) {
|
||||||
|
next[zone.zoneId] = true;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [open, zones]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = query.trim();
|
||||||
|
if (!q || filteredZones.length === 0) return;
|
||||||
|
setExpandedZones((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const zone of filteredZones) {
|
||||||
|
next[zone.zoneId] = true;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [filteredZones, query]);
|
||||||
|
|
||||||
|
const toggleHost = (domain: string, checked: boolean | 'indeterminate') => {
|
||||||
|
if (checked === true) {
|
||||||
|
if (selectedSet.has(domain)) return;
|
||||||
|
onHostsChange([...hosts, domain]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onHostsChange(hosts.filter((item) => item !== domain));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleZone = (
|
||||||
|
zoneDomains: string[],
|
||||||
|
checked: boolean | 'indeterminate',
|
||||||
|
) => {
|
||||||
|
if (checked === true) {
|
||||||
|
const next = new Set(hosts);
|
||||||
|
for (const domain of zoneDomains) next.add(domain);
|
||||||
|
onHostsChange(Array.from(next));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onHostsChange(hosts.filter((item) => !zoneDomains.includes(item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) setQuery('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
className={cn(
|
||||||
|
'size-8 shrink-0',
|
||||||
|
hosts.length > 0 ? 'border-primary text-primary' : undefined,
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
|
||||||
|
}
|
||||||
|
aria-label={
|
||||||
|
hosts.length > 0 ? `已筛选 ${hosts.length} 个域名` : '按域名筛选'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Filter className='size-3.5' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align='end' className='w-96 space-y-3 p-3'>
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<p className='text-sm font-medium'>筛选域名</p>
|
||||||
|
{hosts.length > 0 ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='h-7 px-2 text-xs'
|
||||||
|
onClick={() => onHostsChange([])}
|
||||||
|
>
|
||||||
|
<X className='mr-1 size-3' />
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
placeholder='搜索 Zone 或域名'
|
||||||
|
className='h-8 text-xs'
|
||||||
|
/>
|
||||||
|
<div className='max-h-72 space-y-2 overflow-y-auto hide-scrollbar'>
|
||||||
|
{zonesQuery.isLoading ? (
|
||||||
|
<div className='flex items-center justify-center gap-2 py-6 text-xs text-muted-foreground'>
|
||||||
|
<Loader2 className='size-3.5 animate-spin' />
|
||||||
|
加载域名…
|
||||||
|
</div>
|
||||||
|
) : zonesQuery.isError ? (
|
||||||
|
<div className='space-y-2 py-2'>
|
||||||
|
<p className='text-xs text-destructive'>加载域名失败</p>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='h-7 text-xs'
|
||||||
|
onClick={() => void zonesQuery.refetch()}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : filteredZones.length === 0 ? (
|
||||||
|
<p className='py-6 text-center text-xs text-muted-foreground'>
|
||||||
|
{zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredZones.map((zone) => {
|
||||||
|
const selectedCount = zone.domains.filter((domain) =>
|
||||||
|
selectedSet.has(domain),
|
||||||
|
).length;
|
||||||
|
const allSelected = selectedCount === zone.domains.length;
|
||||||
|
const partialSelected =
|
||||||
|
selectedCount > 0 && selectedCount < zone.domains.length;
|
||||||
|
const expanded = expandedZones[zone.zoneId] ?? true;
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
key={zone.zoneId}
|
||||||
|
open={expanded}
|
||||||
|
onOpenChange={(next) =>
|
||||||
|
setExpandedZones((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[zone.zoneId]: next,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className='rounded-md border border-dashed'
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-1 px-2 py-1.5'>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
allSelected
|
||||||
|
? true
|
||||||
|
: partialSelected
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleZone(zone.domains, checked)
|
||||||
|
}
|
||||||
|
aria-label={`选择 Zone ${zone.zoneName}`}
|
||||||
|
/>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 py-0.5 text-left text-xs font-medium hover:bg-accent'
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'size-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||||
|
expanded ? 'rotate-0' : '-rotate-90',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className='min-w-0 flex-1 truncate'>
|
||||||
|
{zone.zoneName}
|
||||||
|
</span>
|
||||||
|
<span className='text-[10px] text-muted-foreground'>
|
||||||
|
{selectedCount}/{zone.domains.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className='space-y-0.5 border-t border-dashed px-2 py-1.5'>
|
||||||
|
{zone.domains.map((domain) => {
|
||||||
|
const selected = selectedSet.has(domain);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={domain}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs hover:bg-accent',
|
||||||
|
selected ? 'bg-accent/50' : undefined,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleHost(domain, checked)
|
||||||
|
}
|
||||||
|
aria-label={`选择域名 ${domain}`}
|
||||||
|
/>
|
||||||
|
<span className='min-w-0 flex-1 truncate font-mono'>
|
||||||
|
{domain}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverviewToolbar({
|
||||||
hours,
|
hours,
|
||||||
|
hosts,
|
||||||
onHoursChange,
|
onHoursChange,
|
||||||
|
onHostsChange,
|
||||||
}: {
|
}: {
|
||||||
hours: OverviewRangeHours;
|
hours: OverviewRangeHours;
|
||||||
|
hosts: string[];
|
||||||
onHoursChange: (hours: OverviewRangeHours) => void;
|
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||||
|
onHostsChange: (hosts: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className='flex justify-end'>
|
<div className='flex flex-wrap items-center justify-end gap-2'>
|
||||||
|
{hosts.length > 0 ? (
|
||||||
|
<Badge variant='secondary' className='max-w-[260px] truncate'>
|
||||||
|
{hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
<OverviewHostFilter hosts={hosts} onHostsChange={onHostsChange} />
|
||||||
<ToggleGroup
|
<ToggleGroup
|
||||||
type='single'
|
type='single'
|
||||||
value={String(hours)}
|
value={String(hours)}
|
||||||
@@ -294,19 +600,28 @@ export function OverviewTab({
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
hours,
|
hours,
|
||||||
|
hosts,
|
||||||
onHoursChange,
|
onHoursChange,
|
||||||
|
onHostsChange,
|
||||||
onRetry,
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
data?: AccessLogOverview;
|
data?: AccessLogOverview;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
hours: OverviewRangeHours;
|
hours: OverviewRangeHours;
|
||||||
|
hosts: string[];
|
||||||
onHoursChange: (hours: OverviewRangeHours) => void;
|
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||||
|
onHostsChange: (hosts: string[]) => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<OverviewRangeSwitch hours={hours} onHoursChange={onHoursChange} />
|
<OverviewToolbar
|
||||||
|
hours={hours}
|
||||||
|
hosts={hosts}
|
||||||
|
onHoursChange={onHoursChange}
|
||||||
|
onHostsChange={onHostsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<LoadingStateWithBorder
|
<LoadingStateWithBorder
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function AccessLogsPageContent() {
|
|||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
const [detailSort, setDetailSort] = useState('logged_at:desc');
|
||||||
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
|
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
|
||||||
|
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
|
||||||
const [cleanupOpen, setCleanupOpen] = useState(false);
|
const [cleanupOpen, setCleanupOpen] = useState(false);
|
||||||
|
|
||||||
const detailSortState = parseSortValue(detailSort);
|
const detailSortState = parseSortValue(detailSort);
|
||||||
@@ -56,10 +57,17 @@ function AccessLogsPageContent() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const overviewQuery = useQuery({
|
const overviewQuery = useQuery({
|
||||||
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
|
queryKey: [
|
||||||
|
'openflare',
|
||||||
|
'access-logs',
|
||||||
|
'overview',
|
||||||
|
overviewHours,
|
||||||
|
overviewHosts,
|
||||||
|
],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
AccessLogService.getOverview({
|
AccessLogService.getOverview({
|
||||||
hours: overviewHours,
|
hours: overviewHours,
|
||||||
|
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
|
||||||
}),
|
}),
|
||||||
enabled: tab === 'overview',
|
enabled: tab === 'overview',
|
||||||
});
|
});
|
||||||
@@ -179,7 +187,9 @@ function AccessLogsPageContent() {
|
|||||||
overviewQuery.error instanceof Error ? overviewQuery.error : null
|
overviewQuery.error instanceof Error ? overviewQuery.error : null
|
||||||
}
|
}
|
||||||
hours={overviewHours}
|
hours={overviewHours}
|
||||||
|
hosts={overviewHosts}
|
||||||
onHoursChange={setOverviewHours}
|
onHoursChange={setOverviewHours}
|
||||||
|
onHostsChange={setOverviewHosts}
|
||||||
onRetry={() => void overviewQuery.refetch()}
|
onRetry={() => void overviewQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -20,10 +20,16 @@ import type {
|
|||||||
|
|
||||||
function buildSearchParams(filters: object): Record<string, unknown> {
|
function buildSearchParams(filters: object): Record<string, unknown> {
|
||||||
const params: Record<string, unknown> = {};
|
const params: Record<string, unknown> = {};
|
||||||
Object.entries(
|
Object.entries(filters as Record<string, unknown>).forEach(([key, value]) => {
|
||||||
filters as Record<string, string | number | undefined>,
|
|
||||||
).forEach(([key, value]) => {
|
|
||||||
if (value === undefined || value === null || value === '') return;
|
if (value === undefined || value === null || value === '') return;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
const items = value
|
||||||
|
.map((item) => String(item).trim())
|
||||||
|
.filter((item) => item !== '');
|
||||||
|
if (items.length === 0) return;
|
||||||
|
params[key] = items;
|
||||||
|
return;
|
||||||
|
}
|
||||||
params[key] = value;
|
params[key] = value;
|
||||||
});
|
});
|
||||||
return params;
|
return params;
|
||||||
|
|||||||
@@ -503,6 +503,7 @@ export interface AccessLogFilters {
|
|||||||
export interface AccessLogOverviewFilters {
|
export interface AccessLogOverviewFilters {
|
||||||
node_id?: string;
|
node_id?: string;
|
||||||
host?: string;
|
host?: string;
|
||||||
|
hosts?: string[];
|
||||||
hours?: number;
|
hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -216,9 +216,10 @@ type AccessLogIPAnalysisView struct {
|
|||||||
|
|
||||||
// AccessLogOverviewQuery filters access log overview queries.
|
// AccessLogOverviewQuery filters access log overview queries.
|
||||||
type AccessLogOverviewQuery struct {
|
type AccessLogOverviewQuery struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
Hours int `json:"hours"`
|
Hosts []string `json:"hosts"`
|
||||||
|
Hours int `json:"hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessLogOverviewMetricPoint is a single overview trend bucket.
|
// AccessLogOverviewMetricPoint is a single overview trend bucket.
|
||||||
@@ -284,6 +285,7 @@ func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*A
|
|||||||
query := model.OpenFlareAccessLogQuery{
|
query := model.OpenFlareAccessLogQuery{
|
||||||
NodeID: normalized.NodeID,
|
NodeID: normalized.NodeID,
|
||||||
Host: normalized.Host,
|
Host: normalized.Host,
|
||||||
|
Hosts: normalized.Hosts,
|
||||||
Since: since,
|
Since: since,
|
||||||
Until: now,
|
Until: now,
|
||||||
}
|
}
|
||||||
@@ -330,13 +332,45 @@ func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOver
|
|||||||
if hours > maxAccessLogOverviewHours {
|
if hours > maxAccessLogOverviewHours {
|
||||||
hours = maxAccessLogOverviewHours
|
hours = maxAccessLogOverviewHours
|
||||||
}
|
}
|
||||||
|
hosts := normalizeAccessLogOverviewHosts(input.Hosts)
|
||||||
|
host := strings.TrimSpace(input.Host)
|
||||||
|
if len(hosts) == 0 && host != "" {
|
||||||
|
// Single host query uses Hosts exact-match for consistency with multi-select.
|
||||||
|
hosts = []string{host}
|
||||||
|
host = ""
|
||||||
|
}
|
||||||
|
if len(hosts) > 0 {
|
||||||
|
host = ""
|
||||||
|
}
|
||||||
return AccessLogOverviewQuery{
|
return AccessLogOverviewQuery{
|
||||||
NodeID: strings.TrimSpace(input.NodeID),
|
NodeID: strings.TrimSpace(input.NodeID),
|
||||||
Host: strings.TrimSpace(input.Host),
|
Host: host,
|
||||||
|
Hosts: hosts,
|
||||||
Hours: hours,
|
Hours: hours,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAccessLogOverviewHosts(hosts []string) []string {
|
||||||
|
if len(hosts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(hosts))
|
||||||
|
result := make([]string, 0, len(hosts))
|
||||||
|
for _, host := range hosts {
|
||||||
|
trimmed := strings.TrimSpace(host)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(trimmed)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
result = append(result, trimmed)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func valueCountDistribution(
|
func valueCountDistribution(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
query model.OpenFlareAccessLogQuery,
|
query model.OpenFlareAccessLogQuery,
|
||||||
@@ -427,6 +461,7 @@ func buildAccessLogOverviewTrends(
|
|||||||
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
|
||||||
NodeID: query.NodeID,
|
NodeID: query.NodeID,
|
||||||
Host: query.Host,
|
Host: query.Host,
|
||||||
|
Hosts: query.Hosts,
|
||||||
Since: query.Since,
|
Since: query.Since,
|
||||||
Until: query.Until,
|
Until: query.Until,
|
||||||
FoldMinutes: 60,
|
FoldMinutes: 60,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import (
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security SessionCookie
|
// @Security SessionCookie
|
||||||
// @Param node_id query string false "节点 ID"
|
// @Param node_id query string false "节点 ID"
|
||||||
// @Param host query string false "请求 Host"
|
// @Param host query string false "请求 Host(单域名)"
|
||||||
|
// @Param hosts query []string false "请求 Host 列表(多域名精确匹配)"
|
||||||
// @Param hours query int false "统计时间范围(小时)"
|
// @Param hours query int false "统计时间范围(小时)"
|
||||||
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
|
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
|
||||||
// @Failure 400 {object} response.Any "参数错误"
|
// @Failure 400 {object} response.Any "参数错误"
|
||||||
@@ -31,6 +32,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
|
|||||||
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
|
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
|
||||||
NodeID: c.Query("node_id"),
|
NodeID: c.Query("node_id"),
|
||||||
Host: c.Query("host"),
|
Host: c.Query("host"),
|
||||||
|
Hosts: c.QueryArray("hosts"),
|
||||||
Hours: readQueryInt(c, "hours"),
|
Hours: readQueryInt(c, "hours"),
|
||||||
})
|
})
|
||||||
if apiutil.AbortBadRequestOnError(c, err) {
|
if apiutil.AbortBadRequestOnError(c, err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user