diff --git a/docs/changelog/index.md b/docs/changelog/index.md index c48221d9..f7f77ef4 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -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)以上屏幕保持双列并排展示,提升页面紧凑度。 diff --git a/docs/docs.go b/docs/docs.go index c2a86b40..19a57034 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": "统计时间范围(小时)", diff --git a/docs/swagger.json b/docs/swagger.json index 4e5ef29d..a6a01155 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": "统计时间范围(小时)", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 07fb3166..c72e7c91 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/frontend/app/(main)/access-logs/components/detail-tab.tsx b/frontend/app/(main)/access-logs/components/detail-tab.tsx index e0c3bc06..0399dd22 100644 --- a/frontend/app/(main)/access-logs/components/detail-tab.tsx +++ b/frontend/app/(main)/access-logs/components/detail-tab.tsx @@ -170,8 +170,15 @@ export function DetailTab({ {item.node_name || item.node_id} - - {item.remote_addr} + +
+ {item.remote_addr} + {item.region ? ( + + {item.region} + + ) : null} +
{item.host} diff --git a/frontend/app/(main)/access-logs/components/overview-tab.tsx b/frontend/app/(main)/access-logs/components/overview-tab.tsx index e1ede172..a4698881 100644 --- a/frontend/app/(main)/access-logs/components/overview-tab.tsx +++ b/frontend/app/(main)/access-logs/components/overview-tab.tsx @@ -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 => { + 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(); + 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>( + {}, + ); + 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 ( + { + setOpen(next); + if (!next) setQuery(''); + }} + > + + + + +
+

筛选域名

+ {hosts.length > 0 ? ( + + ) : null} +
+ setQuery(event.target.value)} + placeholder='搜索 Zone 或域名' + className='h-8 text-xs' + /> +
+ {zonesQuery.isLoading ? ( +
+ + 加载域名… +
+ ) : zonesQuery.isError ? ( +
+

加载域名失败

+ +
+ ) : filteredZones.length === 0 ? ( +

+ {zones.length === 0 ? '暂无已登记域名' : '没有匹配的域名'} +

+ ) : ( + 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 ( + + setExpandedZones((prev) => ({ + ...prev, + [zone.zoneId]: next, + })) + } + className='rounded-md border border-dashed' + > +
+ + toggleZone(zone.domains, checked) + } + aria-label={`选择 Zone ${zone.zoneName}`} + /> + + + +
+ +
+ {zone.domains.map((domain) => { + const selected = selectedSet.has(domain); + return ( + + ); + })} +
+
+
+ ); + }) + )} +
+
+
+ ); +} + +function OverviewToolbar({ hours, + hosts, onHoursChange, + onHostsChange, }: { hours: OverviewRangeHours; + hosts: string[]; onHoursChange: (hours: OverviewRangeHours) => void; + onHostsChange: (hosts: string[]) => void; }) { return ( -
+
+ {hosts.length > 0 ? ( + + {hosts.length === 1 ? hosts[0] : `已选 ${hosts.length} 个域名`} + + ) : null} + void; + onHostsChange: (hosts: string[]) => void; onRetry: () => void; }) { return (
- + {loading ? ( (24); + const [overviewHosts, setOverviewHosts] = useState([]); 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()} /> diff --git a/frontend/lib/services/openflare/access-log.service.ts b/frontend/lib/services/openflare/access-log.service.ts index e8c3db0d..236ecfdb 100644 --- a/frontend/lib/services/openflare/access-log.service.ts +++ b/frontend/lib/services/openflare/access-log.service.ts @@ -20,10 +20,16 @@ import type { function buildSearchParams(filters: object): Record { const params: Record = {}; - Object.entries( - filters as Record, - ).forEach(([key, value]) => { + Object.entries(filters as Record).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; diff --git a/frontend/lib/services/openflare/types.ts b/frontend/lib/services/openflare/types.ts index f023eabd..ae9f0f01 100644 --- a/frontend/lib/services/openflare/types.ts +++ b/frontend/lib/services/openflare/types.ts @@ -503,6 +503,7 @@ export interface AccessLogFilters { export interface AccessLogOverviewFilters { node_id?: string; host?: string; + hosts?: string[]; hours?: number; } diff --git a/internal/apps/openflare/observability/access_log_logics.go b/internal/apps/openflare/observability/access_log_logics.go index 8a89ace8..73386cd6 100644 --- a/internal/apps/openflare/observability/access_log_logics.go +++ b/internal/apps/openflare/observability/access_log_logics.go @@ -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, diff --git a/internal/apps/openflare/observability/routers.go b/internal/apps/openflare/observability/routers.go index 4e1a9ace..1ad41c6a 100644 --- a/internal/apps/openflare/observability/routers.go +++ b/internal/apps/openflare/observability/routers.go @@ -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) {