'use client'; 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'; 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, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, 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 { ZoneService, zoneQueryKey, type AccessLogOverview, type DistributionItem, } from '@/lib/services/openflare'; import { cn } from '@/lib/utils'; import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics'; import { formatOverviewRangeHint, formatOverviewTrendLabel, OVERVIEW_RANGE_OPTIONS, type OverviewRangeHours, } from './access-log-utils'; const DEVICE_COLORS = [ '#38bdf8', '#34d399', '#f59e0b', '#a78bfa', '#f472b6', '#94a3b8', ]; function SparklineMetricCard({ title, value, hint, color, fillColor, labels, values, valueFormatter, }: { title: string; value: string; hint: string; color: string; fillColor: string; labels: string[]; values: number[]; valueFormatter?: (value: number) => string; }) { const option = useMemo( () => ({ animationDuration: 400, grid: { left: 0, right: 0, top: 8, bottom: 0, }, xAxis: { type: 'category', show: false, boundaryGap: false, data: labels, }, yAxis: { type: 'value', show: false, min: 0, }, tooltip: { trigger: 'axis', backgroundColor: 'rgba(15, 23, 42, 0.92)', borderWidth: 0, textStyle: { color: '#e2e8f0', fontSize: 12, }, formatter: (params: unknown) => { const items = Array.isArray(params) ? params : []; const item = items[0] as { axisValueLabel?: string; value?: number } | undefined; if (!item) return ''; const raw = typeof item.value === 'number' ? item.value : 0; const formatted = valueFormatter ? valueFormatter(raw) : formatCompactNumber(raw); return `${item.axisValueLabel ?? ''}
${formatted}`; }, }, series: [ { type: 'line', data: values, smooth: true, showSymbol: false, lineStyle: { color, width: 2, }, areaStyle: { color: fillColor, }, }, ], }), [color, fillColor, labels, valueFormatter, values], ); return (

{title}

{value}

{hint}

{labels.length > 0 ? ( ) : null}
); } function toRankItems(items: DistributionItem[] | undefined) { return (items ?? []).map((item) => ({ label: item.key, value: item.value, })); } function PieDistributionCard({ title, description, items, emptyMessage, }: { title: string; description: string; items: DistributionItem[]; emptyMessage: string; }) { const chartData = useMemo( () => items.map((item, index) => ({ name: item.key, value: item.value, fill: DEVICE_COLORS[index % DEVICE_COLORS.length], })), [items], ); const chartConfig = useMemo(() => { const config: ChartConfig = {}; chartData.forEach((item) => { config[item.name] = { label: item.name, color: item.fill, }; }); return config; }, [chartData]); return ( {title} {description} {chartData.length === 0 ? (
{emptyMessage}
) : ( {chartData.map((entry) => ( ))} ( <> {name} {formatCompactNumber(Number(value ?? 0))} )} /> } /> } className='flex-wrap justify-center gap-x-4 gap-y-1 pt-2 text-[11px]' /> )}
); } 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} { if (!value) return; onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours); }} variant='outline' size='sm' className='justify-end' > {OVERVIEW_RANGE_OPTIONS.map((option) => ( {option.label} ))}
); } export function OverviewTab({ data, 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 (
{loading ? ( ) : error ? ( ) : !data ? ( ) : ( )}
); } function OverviewContent({ data, hours, }: { data: AccessLogOverview; hours: number; }) { const requestLabels = data.trends.requests.map((point) => formatOverviewTrendLabel(point.bucket_started_at, hours), ); const requestValues = data.trends.requests.map((point) => point.value); const visitValues = data.trends.visits.map((point) => point.value); const bandwidthValues = data.trends.bandwidth.map((point) => point.value); const hint = formatOverviewRangeHint(hours); return ( <>
Requests over time 观察请求量是否出现异常抬升或回落。
); }