From 86d2d6b0ada4d3e54a9bef23683811678ed654c5 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 19:09:01 +0800 Subject: [PATCH] feat(rate-limits): add RPS analysis tab with dual-axis chart Split rate-limits into analysis/config tabs; reuse access-log overview filters; chart hourly RPS vs visits with dataZoom; rank top hosts/IPs by window-average RPS. --- docs/changelog/index.md | 1 + .../access-logs/components/overview-tab.tsx | 351 +----------------- .../components/overview-toolbar.tsx | 349 +++++++++++++++++ .../rate-limits/components/analysis-tab.tsx | 105 ++++++ .../rate-limits/components/config-tab.tsx | 263 +++++++++++++ .../components/rate-pressure-chart.tsx | 337 +++++++++++++++++ frontend/app/(main)/rate-limits/page.tsx | 333 ++++------------- frontend/components/data/rank-card.tsx | 3 + 8 files changed, 1141 insertions(+), 601 deletions(-) create mode 100644 frontend/app/(main)/access-logs/components/overview-toolbar.tsx create mode 100644 frontend/app/(main)/rate-limits/components/analysis-tab.tsx create mode 100644 frontend/app/(main)/rate-limits/components/config-tab.tsx create mode 100644 frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 2cb07d64..2c93fb17 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -30,6 +30,7 @@ sidebar: false - 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。 - Agent Docker 部署命令默认挂载命名卷 `openflare-agent-pages` 持久化 Pages 目录,重建容器时无需重新拉取静态站点包。 +- 限流页新增「分析」视图:默认展示近 24 小时请求压力(RPS)与独立访客双轴趋势,支持域名过滤与时间预设,并按窗口平均 RPS 排行域名与 IP;原全局默认配置迁入「配置」页签。 ## [v3.4.1] - 2026-07-19 diff --git a/frontend/app/(main)/access-logs/components/overview-tab.tsx b/frontend/app/(main)/access-logs/components/overview-tab.tsx index a4698881..5e77ad7f 100644 --- a/frontend/app/(main)/access-logs/components/overview-tab.tsx +++ b/frontend/app/(main)/access-logs/components/overview-tab.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; 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'; @@ -12,8 +10,6 @@ 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, @@ -29,34 +25,18 @@ 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 { - ZoneService, - zoneQueryKey, - type AccessLogOverview, - type DistributionItem, +import type { + AccessLogOverview, + 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'; +import { OverviewToolbar } from './overview-toolbar'; const DEVICE_COLORS = [ '#38bdf8', @@ -274,327 +254,6 @@ function PieDistributionCard({ ); } -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, diff --git a/frontend/app/(main)/access-logs/components/overview-toolbar.tsx b/frontend/app/(main)/access-logs/components/overview-toolbar.tsx new file mode 100644 index 00000000..7722ac36 --- /dev/null +++ b/frontend/app/(main)/access-logs/components/overview-toolbar.tsx @@ -0,0 +1,349 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { ChevronDown, Filter, Loader2, X } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +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 } from '@/lib/services/openflare'; +import { cn } from '@/lib/utils'; + +import { + OVERVIEW_RANGE_OPTIONS, + type OverviewRangeHours, +} from './access-log-utils'; + +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)); + }, + }); +} + +export 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 ( + + ); + })} +
+
+
+ ); + }) + )} +
+
+
+ ); +} + +export 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} + + ))} + +
+ ); +} diff --git a/frontend/app/(main)/rate-limits/components/analysis-tab.tsx b/frontend/app/(main)/rate-limits/components/analysis-tab.tsx new file mode 100644 index 00000000..8106e1ed --- /dev/null +++ b/frontend/app/(main)/rate-limits/components/analysis-tab.tsx @@ -0,0 +1,105 @@ +'use client'; + +import { Gauge } from 'lucide-react'; + +import { RankCard } from '@/components/data/rank-card'; +import { EmptyStateWithBorder } from '@/components/layout/empty'; +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import type { + AccessLogOverview, + DistributionItem, +} from '@/lib/services/openflare'; + +import { + formatOverviewRangeHint, + type OverviewRangeHours, +} from '../../access-logs/components/access-log-utils'; +import { OverviewToolbar } from '../../access-logs/components/overview-toolbar'; +import { RatePressureChart } from './rate-pressure-chart'; + +function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) { + const windowSeconds = Math.max(hours, 1) * 3600; + return (items ?? []).map((item) => ({ + label: item.key, + value: item.value / windowSeconds, + })); +} + +function formatRps(value: number) { + if (!Number.isFinite(value)) return '—'; + if (value >= 100) { + return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 }); + } + return value.toLocaleString('zh-CN', { + maximumFractionDigits: 3, + minimumFractionDigits: 0, + }); +} + +export function AnalysisTab({ + 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; +}) { + const rangeHint = formatOverviewRangeHint(hours); + const hostItems = toAvgRpsItems(data?.top_hosts, hours); + const ipItems = toAvgRpsItems(data?.top_ips, hours); + + return ( +
+ + + {loading ? ( + + ) : error ? ( + + ) : !data ? ( + + ) : ( + <> + +
+ `${formatRps(value)} req/s`} + /> + `${formatRps(value)} req/s`} + /> +
+ + )} +
+ ); +} diff --git a/frontend/app/(main)/rate-limits/components/config-tab.tsx b/frontend/app/(main)/rate-limits/components/config-tab.tsx new file mode 100644 index 00000000..231f8ad2 --- /dev/null +++ b/frontend/app/(main)/rate-limits/components/config-tab.tsx @@ -0,0 +1,263 @@ +'use client'; + +import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react'; +import { toast } from 'sonner'; + +import { ErrorInline } from '@/components/layout/error'; +import { LoadingStateWithBorder } from '@/components/layout/loading'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { OptionService } from '@/lib/services/openflare'; + +const optionsQueryKey = ['openflare', 'options'] as const; + +const KEY_CONN_PER_SERVER = 'openresty_default_limit_conn_per_server'; +const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip'; +const KEY_LIMIT_RATE = 'openresty_default_limit_rate'; + +const limitRatePattern = /^\d+(?:[kKmM])?$/; + +type RateLimitFields = { + openresty_default_limit_conn_per_server: string; + openresty_default_limit_conn_per_ip: string; + openresty_default_limit_rate: string; +}; + +const defaultFields: RateLimitFields = { + openresty_default_limit_conn_per_server: '0', + openresty_default_limit_conn_per_ip: '0', + openresty_default_limit_rate: '', +}; + +function optionsToMap(options: Array<{ key: string; value: string }>) { + return options.reduce>((acc, option) => { + acc[option.key] = option.value; + return acc; + }, {}); +} + +function mapOptionsToFields( + optionMap: Record, +): RateLimitFields { + return { + openresty_default_limit_conn_per_server: + optionMap[KEY_CONN_PER_SERVER] ?? '0', + openresty_default_limit_conn_per_ip: optionMap[KEY_CONN_PER_IP] ?? '0', + openresty_default_limit_rate: optionMap[KEY_LIMIT_RATE] ?? '', + }; +} + +function validateFields(fields: RateLimitFields) { + for (const key of [KEY_CONN_PER_SERVER, KEY_CONN_PER_IP] as const) { + const raw = fields[key].trim(); + if (!raw) continue; + if (!/^\d+$/.test(raw)) { + throw new Error('并发限制请输入非负整数,或留空表示关闭'); + } + } + + const rate = fields.openresty_default_limit_rate.trim(); + if (rate && rate !== '0' && !limitRatePattern.test(rate)) { + throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭'); + } +} + +function normalizeConnValue(value: string) { + const trimmed = value.trim(); + if (!trimmed) return '0'; + return trimmed; +} + +function normalizeRateValue(value: string) { + const normalized = value.trim().toLowerCase(); + if (!normalized || normalized === '0') return ''; + return normalized; +} + +export function ConfigTab() { + const queryClient = useQueryClient(); + const [fields, setFields] = useState(defaultFields); + const [saving, setSaving] = useState(false); + + const optionsQuery = useQuery({ + queryKey: optionsQueryKey, + queryFn: () => OptionService.list(), + }); + + useEffect(() => { + if (!optionsQuery.data) return; + setFields(mapOptionsToFields(optionsToMap(optionsQuery.data))); + }, [optionsQuery.data]); + + const saveMutation = useMutation({ + mutationFn: async () => { + validateFields(fields); + setSaving(true); + await OptionService.updateBatch([ + { + key: KEY_CONN_PER_SERVER, + value: normalizeConnValue( + fields.openresty_default_limit_conn_per_server, + ), + }, + { + key: KEY_CONN_PER_IP, + value: normalizeConnValue(fields.openresty_default_limit_conn_per_ip), + }, + { + key: KEY_LIMIT_RATE, + value: normalizeRateValue(fields.openresty_default_limit_rate), + }, + ]); + }, + onSuccess: async () => { + toast.success('限流参数已保存'); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: optionsQueryKey }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'config-preview'], + }), + queryClient.invalidateQueries({ + queryKey: ['openflare', 'config-versions'], + }), + ]); + setSaving(false); + }, + onError: (error) => { + setSaving(false); + toast.error(error instanceof Error ? error.message : '保存失败'); + }, + }); + + const updateField = ( + key: K, + value: RateLimitFields[K], + ) => { + setFields((prev) => ({ ...prev, [key]: value })); + }; + + if (optionsQuery.isLoading) { + return ( + + ); + } + + if (optionsQuery.isError) { + return ( + void optionsQuery.refetch()} + /> + ); + } + + return ( +
+
+ +
+ + + +
+ 全局默认限流 + + 0 + 或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。 + +
+ +
+ +
+ + + updateField( + 'openresty_default_limit_conn_per_server', + e.target.value, + ) + } + className='h-9 text-xs' + /> +

+ 站点最大并发连接数默认值 +

+
+
+ + + updateField( + 'openresty_default_limit_conn_per_ip', + e.target.value, + ) + } + className='h-9 text-xs' + /> +

+ 单个 IP 最大并发连接数默认值 +

+
+
+ + + updateField('openresty_default_limit_rate', e.target.value) + } + className='h-9 text-xs' + /> +

+ 单请求带宽默认值,例如 512k 或 1m +

+
+
+
+
+ ); +} diff --git a/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx b/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx new file mode 100644 index 00000000..13e45b5a --- /dev/null +++ b/frontend/app/(main)/rate-limits/components/rate-pressure-chart.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { useMemo } from 'react'; +import type { EChartsOption } from 'echarts'; +import ReactECharts from 'echarts-for-react'; +import { Clock } from 'lucide-react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import type { AccessLogOverview } from '@/lib/services/openflare'; +import { calculateNiceAxisMax, formatCompactNumber } from '@/lib/utils/metrics'; + +import { + formatOverviewTrendLabel, + type OverviewRangeHours, +} from '../../access-logs/components/access-log-utils'; + +const BUCKET_SECONDS = 3600; +const RPS_COLOR = '#38bdf8'; +const VISITS_COLOR = '#a78bfa'; + +function formatDateTime(date: Date) { + const y = date.getFullYear(); + const m = `${date.getMonth() + 1}`.padStart(2, '0'); + const d = `${date.getDate()}`.padStart(2, '0'); + const h = `${date.getHours()}`.padStart(2, '0'); + const min = `${date.getMinutes()}`.padStart(2, '0'); + const s = `${date.getSeconds()}`.padStart(2, '0'); + return `${y}-${m}-${d} ${h}:${min}:${s}`; +} + +function formatAxisTime(value: string, hours: number) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return '—\n—'; + } + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + const hour = `${date.getHours()}`.padStart(2, '0'); + const minute = `${date.getMinutes()}`.padStart(2, '0'); + if (hours <= 24) { + return `${hour}:${minute}\n`; + } + return `${month}-${day}\n${hour}:${minute}`; +} + +function formatRps(value: number) { + if (!Number.isFinite(value)) return '—'; + if (value >= 100) return formatCompactNumber(value); + return value.toLocaleString('zh-CN', { + maximumFractionDigits: 2, + minimumFractionDigits: 0, + }); +} + +type RatePressureChartProps = { + data?: AccessLogOverview; + hours: OverviewRangeHours; +}; + +export function RatePressureChart({ data, hours }: RatePressureChartProps) { + const rangeLabel = useMemo(() => { + const end = data?.generated_at ? new Date(data.generated_at) : new Date(); + const start = new Date(end.getTime() - hours * 3600 * 1000); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return '—'; + } + return `${formatDateTime(start)} — ${formatDateTime(end)}`; + }, [data?.generated_at, hours]); + + const chartModel = useMemo(() => { + const requests = data?.trends.requests ?? []; + const visits = data?.trends.visits ?? []; + const length = Math.max(requests.length, visits.length); + const labels: string[] = []; + const rpsValues: number[] = []; + const visitValues: number[] = []; + const rawTimes: string[] = []; + + for (let i = 0; i < length; i += 1) { + const req = requests[i]; + const visit = visits[i]; + const time = req?.bucket_started_at ?? visit?.bucket_started_at ?? ''; + rawTimes.push(time); + labels.push(formatAxisTime(time, hours)); + rpsValues.push((req?.value ?? 0) / BUCKET_SECONDS); + visitValues.push(visit?.value ?? 0); + } + + return { labels, rpsValues, visitValues, rawTimes }; + }, [data?.trends.requests, data?.trends.visits, hours]); + + const option = useMemo(() => { + const rpsMax = calculateNiceAxisMax(chartModel.rpsValues); + const visitMax = calculateNiceAxisMax(chartModel.visitValues); + + return { + animationDuration: 500, + animationEasing: 'cubicOut', + grid: { + left: 16, + right: 16, + top: 48, + bottom: 72, + containLabel: true, + }, + 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 : []; + if (items.length === 0) return ''; + const first = items[0] as { + dataIndex?: number; + axisValueLabel?: string; + }; + const index = + typeof first.dataIndex === 'number' ? first.dataIndex : 0; + const rawTime = chartModel.rawTimes[index] ?? ''; + const header = rawTime + ? formatOverviewTrendLabel(rawTime, hours) + : (first.axisValueLabel ?? ''); + const rows = items.map((item) => { + const row = item as { + seriesName?: string; + color?: string; + value?: number | string; + }; + const numeric = + typeof row.value === 'number' + ? row.value + : Number(row.value ?? 0); + const isRps = row.seriesName === '请求速率'; + const formatted = isRps + ? `${formatRps(numeric)} req/s` + : formatCompactNumber(numeric); + return [ + '', + ``, + `${row.seriesName ?? ''}`, + `${formatted}`, + '', + ].join(''); + }); + return [header, ...rows].join('
'); + }, + }, + legend: { + show: true, + top: 8, + right: 80, + itemWidth: 10, + itemHeight: 10, + icon: 'circle', + textStyle: { + color: '#94a3b8', + fontSize: 12, + }, + data: ['请求速率', '独立访客'], + }, + graphic: [ + { + type: 'text', + left: 16, + top: 12, + style: { + text: 'RPS', + fill: '#94a3b8', + fontSize: 12, + }, + }, + { + type: 'text', + right: 16, + top: 12, + style: { + text: '访客 / 桶', + fill: '#94a3b8', + fontSize: 12, + }, + }, + ], + xAxis: { + type: 'category', + boundaryGap: false, + data: chartModel.labels, + axisLine: { + lineStyle: { + color: 'rgba(148, 163, 184, 0.24)', + }, + }, + axisTick: { + show: false, + }, + axisLabel: { + color: '#94a3b8', + margin: 14, + lineHeight: 16, + }, + }, + yAxis: [ + { + type: 'value', + min: 0, + max: rpsMax, + splitNumber: 4, + axisLabel: { + color: '#94a3b8', + formatter: (value: number) => formatRps(value), + }, + splitLine: { + lineStyle: { + color: 'rgba(148, 163, 184, 0.16)', + type: 'dashed', + }, + }, + }, + { + type: 'value', + min: 0, + max: visitMax, + splitNumber: 4, + axisLabel: { + color: '#94a3b8', + formatter: (value: number) => formatCompactNumber(value), + }, + splitLine: { + show: false, + }, + }, + ], + dataZoom: [ + { + type: 'slider', + height: 28, + bottom: 8, + borderColor: 'rgba(148, 163, 184, 0.2)', + backgroundColor: 'rgba(148, 163, 184, 0.06)', + fillerColor: 'rgba(56, 189, 248, 0.12)', + handleStyle: { + color: '#94a3b8', + }, + textStyle: { + color: '#94a3b8', + fontSize: 10, + }, + dataBackground: { + lineStyle: { + color: RPS_COLOR, + width: 1, + }, + areaStyle: { + color: `${RPS_COLOR}33`, + }, + }, + }, + { + type: 'inside', + }, + ], + series: [ + { + name: '请求速率', + type: 'line', + yAxisIndex: 0, + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 8, + lineStyle: { + color: RPS_COLOR, + width: 2.5, + }, + itemStyle: { + color: RPS_COLOR, + }, + areaStyle: { + color: `${RPS_COLOR}33`, + }, + data: chartModel.rpsValues, + }, + { + name: '独立访客', + type: 'line', + yAxisIndex: 1, + smooth: true, + showSymbol: false, + symbol: 'circle', + symbolSize: 8, + lineStyle: { + color: VISITS_COLOR, + width: 2, + }, + itemStyle: { + color: VISITS_COLOR, + }, + areaStyle: { + color: `${VISITS_COLOR}22`, + }, + data: chartModel.visitValues, + }, + ], + }; + }, [chartModel, hours]); + + return ( + + + + 请求压力 + +
+ + {rangeLabel} +
+
+ + {chartModel.labels.length === 0 ? ( +
+ 暂无请求压力数据 +
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/app/(main)/rate-limits/page.tsx b/frontend/app/(main)/rate-limits/page.tsx index 982f3356..cee42455 100644 --- a/frontend/app/(main)/rate-limits/page.tsx +++ b/frontend/app/(main)/rate-limits/page.tsx @@ -1,154 +1,50 @@ 'use client'; -import Link from 'next/link'; -import { useEffect, useState } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react'; -import { toast } from 'sonner'; +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Gauge } from 'lucide-react'; import { useAuth } from '@/components/providers/auth-provider'; import { EmptyStateWithBorder } from '@/components/layout/empty'; -import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; -import { Button } from '@/components/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { OptionService } from '@/lib/services/openflare'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { AccessLogService } from '@/lib/services/openflare'; -const optionsQueryKey = ['openflare', 'options'] as const; +import type { OverviewRangeHours } from '../access-logs/components/access-log-utils'; +import { AnalysisTab } from './components/analysis-tab'; +import { ConfigTab } from './components/config-tab'; -const KEY_CONN_PER_SERVER = 'openresty_default_limit_conn_per_server'; -const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip'; -const KEY_LIMIT_RATE = 'openresty_default_limit_rate'; +type RateLimitTab = 'analysis' | 'config'; -const limitRatePattern = /^\d+(?:[kKmM])?$/; - -type RateLimitFields = { - openresty_default_limit_conn_per_server: string; - openresty_default_limit_conn_per_ip: string; - openresty_default_limit_rate: string; -}; - -const defaultFields: RateLimitFields = { - openresty_default_limit_conn_per_server: '0', - openresty_default_limit_conn_per_ip: '0', - openresty_default_limit_rate: '', -}; - -function optionsToMap(options: Array<{ key: string; value: string }>) { - return options.reduce>((acc, option) => { - acc[option.key] = option.value; - return acc; - }, {}); +function resolveTab(value: string | null): RateLimitTab { + if (value === 'config') return 'config'; + return 'analysis'; } -function mapOptionsToFields( - optionMap: Record, -): RateLimitFields { - return { - openresty_default_limit_conn_per_server: - optionMap[KEY_CONN_PER_SERVER] ?? '0', - openresty_default_limit_conn_per_ip: optionMap[KEY_CONN_PER_IP] ?? '0', - openresty_default_limit_rate: optionMap[KEY_LIMIT_RATE] ?? '', - }; -} - -function validateFields(fields: RateLimitFields) { - for (const key of [KEY_CONN_PER_SERVER, KEY_CONN_PER_IP] as const) { - const raw = fields[key].trim(); - if (!raw) continue; - if (!/^\d+$/.test(raw)) { - throw new Error('并发限制请输入非负整数,或留空表示关闭'); - } - } - - const rate = fields.openresty_default_limit_rate.trim(); - if (rate && rate !== '0' && !limitRatePattern.test(rate)) { - throw new Error('限速格式不合法,请使用 512k、1m、纯数字,或留空关闭'); - } -} - -function normalizeConnValue(value: string) { - const trimmed = value.trim(); - if (!trimmed) return '0'; - return trimmed; -} - -function normalizeRateValue(value: string) { - const normalized = value.trim().toLowerCase(); - if (!normalized || normalized === '0') return ''; - return normalized; -} - -export default function RateLimitsPage() { +function RateLimitsPageContent() { const { user, loading: authLoading } = useAuth(); - const queryClient = useQueryClient(); - const [fields, setFields] = useState(defaultFields); - const [saving, setSaving] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + const tab = resolveTab(searchParams.get('tab')); + const [hours, setHours] = useState(24); + const [hosts, setHosts] = useState([]); - const optionsQuery = useQuery({ - queryKey: optionsQueryKey, - queryFn: () => OptionService.list(), - enabled: !!user?.is_admin, + const overviewQuery = useQuery({ + queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts], + queryFn: () => + AccessLogService.getOverview({ + hours, + hosts: hosts.length > 0 ? hosts : undefined, + }), + enabled: !!user?.is_admin && tab === 'analysis', }); - useEffect(() => { - if (!optionsQuery.data) return; - setFields(mapOptionsToFields(optionsToMap(optionsQuery.data))); - }, [optionsQuery.data]); - - const saveMutation = useMutation({ - mutationFn: async () => { - validateFields(fields); - setSaving(true); - await OptionService.updateBatch([ - { - key: KEY_CONN_PER_SERVER, - value: normalizeConnValue( - fields.openresty_default_limit_conn_per_server, - ), - }, - { - key: KEY_CONN_PER_IP, - value: normalizeConnValue(fields.openresty_default_limit_conn_per_ip), - }, - { - key: KEY_LIMIT_RATE, - value: normalizeRateValue(fields.openresty_default_limit_rate), - }, - ]); - }, - onSuccess: async () => { - toast.success('限流参数已保存'); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: optionsQueryKey }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'config-preview'], - }), - queryClient.invalidateQueries({ - queryKey: ['openflare', 'config-versions'], - }), - ]); - setSaving(false); - }, - onError: (error) => { - setSaving(false); - toast.error(error instanceof Error ? error.message : '保存失败'); - }, - }); - - const updateField = ( - key: K, - value: RateLimitFields[K], - ) => { - setFields((prev) => ({ ...prev, [key]: value })); + const handleTabChange = (value: string) => { + const next = resolveTab(value); + router.replace( + next === 'analysis' ? '/rate-limits' : '/rate-limits?tab=config', + ); }; if (authLoading) { @@ -171,130 +67,57 @@ export default function RateLimitsPage() { ); } - if (optionsQuery.isLoading) { - return ( -
- -
- ); - } - - if (optionsQuery.isError) { - return ( -
- void optionsQuery.refetch()} - /> -
- ); - } - return (
-
-
- -
-

限流

-

- 配置边缘站点默认并发与带宽限流。0 - 或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。 -

-
+
+ +
+

限流

+

+ 查看边缘请求压力,并配置站点默认并发与带宽限流。 +

-
- - -
- 全局默认限流 - - 0 或空表示默认关闭;站点未单独配置时继承此处设置。 - -
- -
- -
- - - updateField( - 'openresty_default_limit_conn_per_server', - e.target.value, - ) - } - className='h-9 text-xs' - /> -

- 站点最大并发连接数默认值 -

-
-
- - - updateField( - 'openresty_default_limit_conn_per_ip', - e.target.value, - ) - } - className='h-9 text-xs' - /> -

- 单个 IP 最大并发连接数默认值 -

-
-
- - - updateField('openresty_default_limit_rate', e.target.value) - } - className='h-9 text-xs' - /> -

- 单请求带宽默认值,例如 512k 或 1m -

-
-
-
+ + + 分析 + 配置 + + + + void overviewQuery.refetch()} + /> + + + + + +
); } + +export default function RateLimitsPage() { + return ( + + +
+ } + > + + + ); +} diff --git a/frontend/components/data/rank-card.tsx b/frontend/components/data/rank-card.tsx index dde35466..8820fa7e 100644 --- a/frontend/components/data/rank-card.tsx +++ b/frontend/components/data/rank-card.tsx @@ -18,6 +18,7 @@ type RankCardProps = { description: string; items: RankCardItem[]; color?: string; + valueFormatter?: (value: number) => string; }; export function RankCard({ @@ -25,6 +26,7 @@ export function RankCard({ description, items, color = '#3b82f6', + valueFormatter, }: RankCardProps) { return ( @@ -40,6 +42,7 @@ export function RankCard({