diff --git a/frontend/app/(main)/proxy-routes/components/helpers.ts b/frontend/app/(main)/proxy-routes/components/helpers.ts index a7b031fb..5345dd00 100644 --- a/frontend/app/(main)/proxy-routes/components/helpers.ts +++ b/frontend/app/(main)/proxy-routes/components/helpers.ts @@ -16,7 +16,7 @@ export const proxyRouteConfigSections = [ { key: 'limits' as const, label: '流量限制', - description: '设置连接数和限速。', + description: '设置连接数和限速(可继承全局默认)。', }, { key: 'proxy' as const, @@ -263,18 +263,19 @@ export function customHeadersToText(headers: ProxyRouteCustomHeader[]) { export function validateLimitRate(value: string) { const normalized = value.trim(); - if (!normalized || normalized === '0') { + if (!normalized || normalized === '0' || normalized === '-1') { return null; } if (!limitRatePattern.test(normalized)) { - return '限速格式不合法,请使用 512k、1m 或纯数字'; + return '限速格式不合法,请使用 512k、1m、纯数字,或 -1 关闭'; } return null; } export function normalizeLimitRate(value: string) { const normalized = value.trim().toLowerCase(); - return normalized === '0' ? '' : normalized; + if (normalized === '0') return ''; + return normalized; } export function validateCacheRules( diff --git a/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx b/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx index 00c913bd..5bf0eceb 100644 --- a/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx +++ b/frontend/app/(main)/proxy-routes/detail/components/limits-section.tsx @@ -40,11 +40,11 @@ const rateLimitSchema = z if (!rawValue) { continue; } - if (!/^\d+$/.test(rawValue)) { + if (!/^-1$|^\d+$/.test(rawValue)) { context.addIssue({ code: z.ZodIssueCode.custom, path: [field], - message: '请输入大于等于 0 的整数', + message: '请输入 -1、0 或正整数', }); } } @@ -61,6 +61,21 @@ const rateLimitSchema = z type RateLimitValues = z.infer; +function formatConnValue(value: number | null | undefined) { + if (value === null || value === undefined || value === 0) { + return ''; + } + return String(value); +} + +function parseConnValue(value: string) { + const trimmed = value.trim(); + if (!trimmed) { + return 0; + } + return Number(trimmed); +} + interface LimitsSectionProps { route: ProxyRouteItem; onRouteUpdate: (route: ProxyRouteItem) => void; @@ -81,24 +96,16 @@ export function LimitsSection({ const form = useForm({ resolver: zodResolver(rateLimitSchema), defaultValues: { - limit_conn_per_server: route.limit_conn_per_server - ? String(route.limit_conn_per_server) - : '', - limit_conn_per_ip: route.limit_conn_per_ip - ? String(route.limit_conn_per_ip) - : '', + limit_conn_per_server: formatConnValue(route.limit_conn_per_server), + limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip), limit_rate: route.limit_rate || '', }, }); useEffect(() => { form.reset({ - limit_conn_per_server: route.limit_conn_per_server - ? String(route.limit_conn_per_server) - : '', - limit_conn_per_ip: route.limit_conn_per_ip - ? String(route.limit_conn_per_ip) - : '', + limit_conn_per_server: formatConnValue(route.limit_conn_per_server), + limit_conn_per_ip: formatConnValue(route.limit_conn_per_ip), limit_rate: route.limit_rate || '', }); }, [form, route]); @@ -106,7 +113,7 @@ export function LimitsSection({ return ( @@ -117,12 +124,10 @@ export function LimitsSection({ onSubmit={form.handleSubmit(async (values) => { await save( { - limit_conn_per_server: Number( - values.limit_conn_per_server.trim() || '0', - ), - limit_conn_per_ip: Number( - values.limit_conn_per_ip.trim() || '0', + limit_conn_per_server: parseConnValue( + values.limit_conn_per_server, ), + limit_conn_per_ip: parseConnValue(values.limit_conn_per_ip), limit_rate: normalizeLimitRate(values.limit_rate), }, '流量限制已保存', @@ -138,7 +143,9 @@ export function LimitsSection({ - 限制当前站点最大并发连接数。 + + 空或 0 继承全局默认;-1 关闭;大于 0 为自定义并发上限。 + )} @@ -153,7 +160,9 @@ export function LimitsSection({ - 限制单个 IP 的最大并发数。 + + 空或 0 继承全局默认;-1 关闭;大于 0 为单 IP 自定义上限。 + )} @@ -166,10 +175,10 @@ export function LimitsSection({ 限速 - + - 限制单请求带宽,例如 512k 或 1m。 + 空或 0 继承全局默认;-1 关闭;例如 512k、1m 为自定义带宽。 diff --git a/frontend/app/(main)/rate-limits/page.tsx b/frontend/app/(main)/rate-limits/page.tsx new file mode 100644 index 00000000..982f3356 --- /dev/null +++ b/frontend/app/(main)/rate-limits/page.tsx @@ -0,0 +1,300 @@ +'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 { 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'; + +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 default function RateLimitsPage() { + const { user, loading: authLoading } = useAuth(); + const queryClient = useQueryClient(); + const [fields, setFields] = useState(defaultFields); + const [saving, setSaving] = useState(false); + + const optionsQuery = useQuery({ + queryKey: optionsQueryKey, + queryFn: () => OptionService.list(), + enabled: !!user?.is_admin, + }); + + 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 (authLoading) { + return ( +
+ +
+ ); + } + + if (!user?.is_admin) { + return ( +
+ +
+ ); + } + + 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 +

+
+
+
+
+ ); +} diff --git a/frontend/lib/navigation/openflare-nav.ts b/frontend/lib/navigation/openflare-nav.ts index c8ab00f3..67cbcd77 100644 --- a/frontend/lib/navigation/openflare-nav.ts +++ b/frontend/lib/navigation/openflare-nav.ts @@ -42,6 +42,7 @@ export const openflareSecurityNavGroup: OpenFlareNavGroup = { items: [ { title: 'WAF', url: '/waf' }, { title: 'IP 组', url: '/ip-groups' }, + { title: '限流', url: '/rate-limits' }, ], }; diff --git a/frontend/lib/utils/search-data.ts b/frontend/lib/utils/search-data.ts index 90cd33aa..7b2b67a2 100644 --- a/frontend/lib/utils/search-data.ts +++ b/frontend/lib/utils/search-data.ts @@ -146,6 +146,21 @@ export const searchData: SearchItem[] = [ category: 'page', keywords: ['ip', 'ip group', 'ip组', '黑名单', '白名单', '访问控制'], }, + { + id: 'console-rate-limits', + title: '限流', + description: '配置边缘站点默认并发与带宽限流策略', + url: '/rate-limits', + category: 'page', + keywords: [ + '限流', + 'rate limit', + 'limit_conn', + 'limit_rate', + '并发', + '带宽', + ], + }, { id: 'console-pages', title: 'Pages 静态托管',