feat(access-logs): 概览支持 Zone/域名多选筛选

概览可按 Zone→Domain 层级多选域名并折叠展开;明细列表 IP 旁展示地区。
后端 overview 支持 hosts 多域名精确匹配。
This commit is contained in:
ryan
2026-07-18 23:07:19 +08:00
parent 6f221b042e
commit 0bb6830047
11 changed files with 426 additions and 21 deletions
+2
View File
@@ -28,6 +28,8 @@ sidebar: false
- 边缘访问日志现支持上报并存储 User-Agent;概览新增设备类型饼图,以及浏览器、操作系统、User-Agent 排行。
- 访问日志明细支持查看单条请求详情,可查看 User-Agent、IP 访问趋势与分析,并快捷将 IP 加入或移出 IP 组。
- 访问日志页签状态改为 URL 参数记忆,筛选后不会再跳回概览。
- 访问日志概览支持按 Zone/域名多选筛选,并可折叠展开层级选择。
- 访问日志明细列表在 IP 旁展示对应国家/地区信息。
- 访问日志支持边缘缓存状态(`cache_status`):明细列表与详情可查看命中 / 回源 / 未缓存。启用需执行 ClickHouse 迁移并重新发布节点配置使新日志格式生效。
- 修复 Agent 观测缓冲在合并访问日志时忽略 `cache_status` 导致 HIT/MISS 被去重丢弃的问题。
- 优化访问日志概览的设备类型与状态码饼图布局,使设备类型与状态码饼图在 1024px(lg)以上屏幕保持双列并排展示,提升页面紧凑度。
+11 -1
View File
@@ -5519,10 +5519,20 @@ const docTemplate = `{
},
{
"type": "string",
"description": "请求 Host",
"description": "请求 Host(单域名)",
"name": "host",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "请求 Host 列表(多域名精确匹配)",
"name": "hosts",
"in": "query"
},
{
"type": "integer",
"description": "统计时间范围(小时)",
+11 -1
View File
@@ -5512,10 +5512,20 @@
},
{
"type": "string",
"description": "请求 Host",
"description": "请求 Host(单域名)",
"name": "host",
"in": "query"
},
{
"type": "array",
"items": {
"type": "string"
},
"collectionFormat": "csv",
"description": "请求 Host 列表(多域名精确匹配)",
"name": "hosts",
"in": "query"
},
{
"type": "integer",
"description": "统计时间范围(小时)",
+8 -1
View File
@@ -7354,10 +7354,17 @@ paths:
in: query
name: node_id
type: string
- description: 请求 Host
- description: 请求 Host(单域名)
in: query
name: host
type: string
- collectionFormat: csv
description: 请求 Host 列表(多域名精确匹配)
in: query
items:
type: string
name: hosts
type: array
- description: 统计时间范围(小时)
in: query
name: hours
@@ -170,8 +170,15 @@ export function DetailTab({
<TableCell className='text-xs'>
{item.node_name || item.node_id}
</TableCell>
<TableCell className='text-xs font-mono'>
{item.remote_addr}
<TableCell className='text-xs'>
<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 className='text-xs'>{item.host}</TableCell>
<TableCell className='text-xs max-w-48 truncate'>
@@ -1,8 +1,10 @@
'use client';
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { EChartsOption } from 'echarts';
import ReactECharts from 'echarts-for-react';
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
import { Cell, Pie, PieChart } from 'recharts';
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 { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
@@ -25,11 +29,26 @@ import {
ChartTooltipContent,
type ChartConfig,
} 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 type {
AccessLogOverview,
DistributionItem,
import {
ZoneService,
zoneQueryKey,
type AccessLogOverview,
type DistributionItem,
} from '@/lib/services/openflare';
import { cn } from '@/lib/utils';
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
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,
hosts,
onHoursChange,
onHostsChange,
}: {
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
}) {
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
type='single'
value={String(hours)}
@@ -294,19 +600,28 @@ export function OverviewTab({
loading,
error,
hours,
hosts,
onHoursChange,
onHostsChange,
onRetry,
}: {
data?: AccessLogOverview;
loading: boolean;
error: Error | null;
hours: OverviewRangeHours;
hosts: string[];
onHoursChange: (hours: OverviewRangeHours) => void;
onHostsChange: (hosts: string[]) => void;
onRetry: () => void;
}) {
return (
<div className='space-y-6'>
<OverviewRangeSwitch hours={hours} onHoursChange={onHoursChange} />
<OverviewToolbar
hours={hours}
hosts={hosts}
onHoursChange={onHoursChange}
onHostsChange={onHostsChange}
/>
{loading ? (
<LoadingStateWithBorder
+11 -1
View File
@@ -44,6 +44,7 @@ function AccessLogsPageContent() {
const [page, setPage] = useState(0);
const [detailSort, setDetailSort] = useState('logged_at:desc');
const [overviewHours, setOverviewHours] = useState<OverviewRangeHours>(24);
const [overviewHosts, setOverviewHosts] = useState<string[]>([]);
const [cleanupOpen, setCleanupOpen] = useState(false);
const detailSortState = parseSortValue(detailSort);
@@ -56,10 +57,17 @@ function AccessLogsPageContent() {
};
const overviewQuery = useQuery({
queryKey: ['openflare', 'access-logs', 'overview', overviewHours],
queryKey: [
'openflare',
'access-logs',
'overview',
overviewHours,
overviewHosts,
],
queryFn: () =>
AccessLogService.getOverview({
hours: overviewHours,
hosts: overviewHosts.length > 0 ? overviewHosts : undefined,
}),
enabled: tab === 'overview',
});
@@ -179,7 +187,9 @@ function AccessLogsPageContent() {
overviewQuery.error instanceof Error ? overviewQuery.error : null
}
hours={overviewHours}
hosts={overviewHosts}
onHoursChange={setOverviewHours}
onHostsChange={setOverviewHosts}
onRetry={() => void overviewQuery.refetch()}
/>
</TabsContent>
@@ -20,10 +20,16 @@ import type {
function buildSearchParams(filters: object): Record<string, unknown> {
const params: Record<string, unknown> = {};
Object.entries(
filters as Record<string, string | number | undefined>,
).forEach(([key, value]) => {
Object.entries(filters as Record<string, unknown>).forEach(([key, value]) => {
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;
});
return params;
+1
View File
@@ -503,6 +503,7 @@ export interface AccessLogFilters {
export interface AccessLogOverviewFilters {
node_id?: string;
host?: string;
hosts?: string[];
hours?: number;
}
@@ -216,9 +216,10 @@ type AccessLogIPAnalysisView struct {
// AccessLogOverviewQuery filters access log overview queries.
type AccessLogOverviewQuery struct {
NodeID string `json:"node_id"`
Host string `json:"host"`
Hours int `json:"hours"`
NodeID string `json:"node_id"`
Host string `json:"host"`
Hosts []string `json:"hosts"`
Hours int `json:"hours"`
}
// AccessLogOverviewMetricPoint is a single overview trend bucket.
@@ -284,6 +285,7 @@ func GetAccessLogOverview(ctx context.Context, input AccessLogOverviewQuery) (*A
query := model.OpenFlareAccessLogQuery{
NodeID: normalized.NodeID,
Host: normalized.Host,
Hosts: normalized.Hosts,
Since: since,
Until: now,
}
@@ -330,13 +332,45 @@ func normalizeAccessLogOverviewQuery(input AccessLogOverviewQuery) AccessLogOver
if 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{
NodeID: strings.TrimSpace(input.NodeID),
Host: strings.TrimSpace(input.Host),
Host: host,
Hosts: hosts,
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(
ctx context.Context,
query model.OpenFlareAccessLogQuery,
@@ -427,6 +461,7 @@ func buildAccessLogOverviewTrends(
buckets, err := model.ListOpenFlareAccessLogBuckets(ctx, model.OpenFlareAccessLogBucketQuery{
NodeID: query.NodeID,
Host: query.Host,
Hosts: query.Hosts,
Since: query.Since,
Until: query.Until,
FoldMinutes: 60,
@@ -19,7 +19,8 @@ import (
// @Produce json
// @Security SessionCookie
// @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 "统计时间范围(小时)"
// @Success 200 {object} response.Any{data=observability.AccessLogOverview} "访问日志概览"
// @Failure 400 {object} response.Any "参数错误"
@@ -31,6 +32,7 @@ func GetAccessLogOverviewHandler(c *gin.Context) {
result, err := GetAccessLogOverview(c.Request.Context(), AccessLogOverviewQuery{
NodeID: c.Query("node_id"),
Host: c.Query("host"),
Hosts: c.QueryArray("hosts"),
Hours: readQueryInt(c, "hours"),
})
if apiutil.AbortBadRequestOnError(c, err) {