mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +08:00
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.
This commit is contained in:
@@ -30,6 +30,7 @@ sidebar: false
|
|||||||
|
|
||||||
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
|
- 站点流量限制语义调整为空或 `0` 继承全局默认、`-1` 关闭、大于 `0` 自定义;修改全局默认后需发布配置版本生效。
|
||||||
- Agent Docker 部署命令默认挂载命名卷 `openflare-agent-pages` 持久化 Pages 目录,重建容器时无需重新拉取静态站点包。
|
- Agent Docker 部署命令默认挂载命名卷 `openflare-agent-pages` 持久化 Pages 目录,重建容器时无需重新拉取静态站点包。
|
||||||
|
- 限流页新增「分析」视图:默认展示近 24 小时请求压力(RPS)与独立访客双轴趋势,支持域名过滤与时间预设,并按窗口平均 RPS 排行域名与 IP;原全局默认配置迁入「配置」页签。
|
||||||
|
|
||||||
## [v3.4.1] - 2026-07-19
|
## [v3.4.1] - 2026-07-19
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
import ReactECharts from 'echarts-for-react';
|
import ReactECharts from 'echarts-for-react';
|
||||||
import { ChevronDown, Filter, Loader2, X } from 'lucide-react';
|
|
||||||
import { Cell, Pie, PieChart } from 'recharts';
|
import { Cell, Pie, PieChart } from 'recharts';
|
||||||
|
|
||||||
import { RankCard } from '@/components/data/rank-card';
|
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 { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
import { ErrorInline } from '@/components/layout/error';
|
||||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -29,34 +25,18 @@ import {
|
|||||||
ChartTooltipContent,
|
ChartTooltipContent,
|
||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from '@/components/ui/chart';
|
} from '@/components/ui/chart';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import type {
|
||||||
import {
|
AccessLogOverview,
|
||||||
Collapsible,
|
DistributionItem,
|
||||||
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';
|
} from '@/lib/services/openflare';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatOverviewRangeHint,
|
formatOverviewRangeHint,
|
||||||
formatOverviewTrendLabel,
|
formatOverviewTrendLabel,
|
||||||
OVERVIEW_RANGE_OPTIONS,
|
|
||||||
type OverviewRangeHours,
|
type OverviewRangeHours,
|
||||||
} from './access-log-utils';
|
} from './access-log-utils';
|
||||||
|
import { OverviewToolbar } from './overview-toolbar';
|
||||||
|
|
||||||
const DEVICE_COLORS = [
|
const DEVICE_COLORS = [
|
||||||
'#38bdf8',
|
'#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<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 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)}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (!value) return;
|
|
||||||
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
|
|
||||||
}}
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
className='justify-end'
|
|
||||||
>
|
|
||||||
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
|
||||||
<ToggleGroupItem
|
|
||||||
key={option.value}
|
|
||||||
value={String(option.value)}
|
|
||||||
className='px-2.5 text-xs'
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
))}
|
|
||||||
</ToggleGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewTab({
|
export function OverviewTab({
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -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<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));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewToolbar({
|
||||||
|
hours,
|
||||||
|
hosts,
|
||||||
|
onHoursChange,
|
||||||
|
onHostsChange,
|
||||||
|
}: {
|
||||||
|
hours: OverviewRangeHours;
|
||||||
|
hosts: string[];
|
||||||
|
onHoursChange: (hours: OverviewRangeHours) => void;
|
||||||
|
onHostsChange: (hosts: string[]) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<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)}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (!value) return;
|
||||||
|
onHoursChange(Number.parseInt(value, 10) as OverviewRangeHours);
|
||||||
|
}}
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='justify-end'
|
||||||
|
>
|
||||||
|
{OVERVIEW_RANGE_OPTIONS.map((option) => (
|
||||||
|
<ToggleGroupItem
|
||||||
|
key={option.value}
|
||||||
|
value={String(option.value)}
|
||||||
|
className='px-2.5 text-xs'
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
))}
|
||||||
|
</ToggleGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<OverviewToolbar
|
||||||
|
hours={hours}
|
||||||
|
hosts={hosts}
|
||||||
|
onHoursChange={onHoursChange}
|
||||||
|
onHostsChange={onHostsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<LoadingStateWithBorder icon={Gauge} description='加载请求压力...' />
|
||||||
|
) : error ? (
|
||||||
|
<ErrorInline message={error.message || '加载失败'} onRetry={onRetry} />
|
||||||
|
) : !data ? (
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
icon={Gauge}
|
||||||
|
title='暂无数据'
|
||||||
|
description='当前时间范围内没有可用的访问日志。'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RatePressureChart data={data} hours={hours} />
|
||||||
|
<div className='grid gap-4 lg:grid-cols-2'>
|
||||||
|
<RankCard
|
||||||
|
title='平均 RPS 最高域名'
|
||||||
|
description={`${rangeHint}平均请求速率`}
|
||||||
|
items={hostItems}
|
||||||
|
color='#38bdf8'
|
||||||
|
valueFormatter={(value) => `${formatRps(value)} req/s`}
|
||||||
|
/>
|
||||||
|
<RankCard
|
||||||
|
title='平均 RPS 最高 IP'
|
||||||
|
description={`${rangeHint}平均请求速率`}
|
||||||
|
items={ipItems}
|
||||||
|
color='#a78bfa'
|
||||||
|
valueFormatter={(value) => `${formatRps(value)} req/s`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Record<string, string>>((acc, option) => {
|
||||||
|
acc[option.key] = option.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOptionsToFields(
|
||||||
|
optionMap: Record<string, string>,
|
||||||
|
): 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<RateLimitFields>(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 = <K extends keyof RateLimitFields>(
|
||||||
|
key: K,
|
||||||
|
value: RateLimitFields[K],
|
||||||
|
) => {
|
||||||
|
setFields((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (optionsQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<LoadingStateWithBorder icon={Gauge} description='加载限流参数...' />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsQuery.isError) {
|
||||||
|
return (
|
||||||
|
<ErrorInline
|
||||||
|
message={
|
||||||
|
optionsQuery.error instanceof Error
|
||||||
|
? optionsQuery.error.message
|
||||||
|
: '加载失败'
|
||||||
|
}
|
||||||
|
onRetry={() => void optionsQuery.refetch()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<Button variant='outline' size='sm' asChild>
|
||||||
|
<Link href='/config-versions'>
|
||||||
|
<ExternalLink className='size-3.5 mr-1' />
|
||||||
|
查看配置预览
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<CardTitle className='text-base'>全局默认限流</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
0
|
||||||
|
或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className='size-4 animate-spin mr-1' />
|
||||||
|
) : (
|
||||||
|
<Save className='size-3.5 mr-1' />
|
||||||
|
)}
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<Label className='text-xs text-muted-foreground'>
|
||||||
|
默认并发限制(每站点)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={0}
|
||||||
|
value={fields.openresty_default_limit_conn_per_server}
|
||||||
|
placeholder='0'
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(
|
||||||
|
'openresty_default_limit_conn_per_server',
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='h-9 text-xs'
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-muted-foreground'>
|
||||||
|
站点最大并发连接数默认值
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<Label className='text-xs text-muted-foreground'>
|
||||||
|
默认单 IP 并发
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
min={0}
|
||||||
|
value={fields.openresty_default_limit_conn_per_ip}
|
||||||
|
placeholder='0'
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField(
|
||||||
|
'openresty_default_limit_conn_per_ip',
|
||||||
|
e.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className='h-9 text-xs'
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-muted-foreground'>
|
||||||
|
单个 IP 最大并发连接数默认值
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<Label className='text-xs text-muted-foreground'>默认限速</Label>
|
||||||
|
<Input
|
||||||
|
value={fields.openresty_default_limit_rate}
|
||||||
|
placeholder='512k / 1m'
|
||||||
|
onChange={(e) =>
|
||||||
|
updateField('openresty_default_limit_rate', e.target.value)
|
||||||
|
}
|
||||||
|
className='h-9 text-xs'
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-muted-foreground'>
|
||||||
|
单请求带宽默认值,例如 512k 或 1m
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<EChartsOption>(() => {
|
||||||
|
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 [
|
||||||
|
'<span style="display:inline-flex;align-items:center;gap:8px;">',
|
||||||
|
`<span style="display:inline-block;width:8px;height:8px;border-radius:9999px;background:${row.color ?? '#94a3b8'};"></span>`,
|
||||||
|
`<span>${row.seriesName ?? ''}</span>`,
|
||||||
|
`<strong style="margin-left:8px;">${formatted}</strong>`,
|
||||||
|
'</span>',
|
||||||
|
].join('');
|
||||||
|
});
|
||||||
|
return [header, ...rows].join('<br/>');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 (
|
||||||
|
<Card className='border-dashed shadow-none'>
|
||||||
|
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between pb-2'>
|
||||||
|
<CardTitle className='text-base font-semibold tracking-tight'>
|
||||||
|
请求压力
|
||||||
|
</CardTitle>
|
||||||
|
<div className='flex items-center gap-1.5 text-xs text-muted-foreground font-mono'>
|
||||||
|
<Clock className='size-3.5 shrink-0' />
|
||||||
|
<span className='break-all'>{rangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='pt-0'>
|
||||||
|
{chartModel.labels.length === 0 ? (
|
||||||
|
<div className='flex h-[360px] items-center justify-center rounded-md border border-dashed bg-muted/20 text-sm text-muted-foreground'>
|
||||||
|
暂无请求压力数据
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ReactECharts
|
||||||
|
option={option}
|
||||||
|
notMerge
|
||||||
|
lazyUpdate
|
||||||
|
style={{ height: 360, width: '100%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,154 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import { Suspense, useState } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ExternalLink, Gauge, Loader2, Save } from 'lucide-react';
|
import { Gauge } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import { useAuth } from '@/components/providers/auth-provider';
|
import { useAuth } from '@/components/providers/auth-provider';
|
||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
import { ErrorInline } from '@/components/layout/error';
|
|
||||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import { AccessLogService } from '@/lib/services/openflare';
|
||||||
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;
|
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';
|
type RateLimitTab = 'analysis' | 'config';
|
||||||
const KEY_CONN_PER_IP = 'openresty_default_limit_conn_per_ip';
|
|
||||||
const KEY_LIMIT_RATE = 'openresty_default_limit_rate';
|
|
||||||
|
|
||||||
const limitRatePattern = /^\d+(?:[kKmM])?$/;
|
function resolveTab(value: string | null): RateLimitTab {
|
||||||
|
if (value === 'config') return 'config';
|
||||||
type RateLimitFields = {
|
return 'analysis';
|
||||||
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<Record<string, string>>((acc, option) => {
|
|
||||||
acc[option.key] = option.value;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapOptionsToFields(
|
function RateLimitsPageContent() {
|
||||||
optionMap: Record<string, string>,
|
|
||||||
): 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 { user, loading: authLoading } = useAuth();
|
||||||
const queryClient = useQueryClient();
|
const router = useRouter();
|
||||||
const [fields, setFields] = useState<RateLimitFields>(defaultFields);
|
const searchParams = useSearchParams();
|
||||||
const [saving, setSaving] = useState(false);
|
const tab = resolveTab(searchParams.get('tab'));
|
||||||
|
const [hours, setHours] = useState<OverviewRangeHours>(24);
|
||||||
|
const [hosts, setHosts] = useState<string[]>([]);
|
||||||
|
|
||||||
const optionsQuery = useQuery({
|
const overviewQuery = useQuery({
|
||||||
queryKey: optionsQueryKey,
|
queryKey: ['openflare', 'rate-limits', 'overview', hours, hosts],
|
||||||
queryFn: () => OptionService.list(),
|
queryFn: () =>
|
||||||
enabled: !!user?.is_admin,
|
AccessLogService.getOverview({
|
||||||
|
hours,
|
||||||
|
hosts: hosts.length > 0 ? hosts : undefined,
|
||||||
|
}),
|
||||||
|
enabled: !!user?.is_admin && tab === 'analysis',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const handleTabChange = (value: string) => {
|
||||||
if (!optionsQuery.data) return;
|
const next = resolveTab(value);
|
||||||
setFields(mapOptionsToFields(optionsToMap(optionsQuery.data)));
|
router.replace(
|
||||||
}, [optionsQuery.data]);
|
next === 'analysis' ? '/rate-limits' : '/rate-limits?tab=config',
|
||||||
|
);
|
||||||
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 = <K extends keyof RateLimitFields>(
|
|
||||||
key: K,
|
|
||||||
value: RateLimitFields[K],
|
|
||||||
) => {
|
|
||||||
setFields((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
@@ -171,130 +67,57 @@ export default function RateLimitsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (optionsQuery.isLoading) {
|
|
||||||
return (
|
|
||||||
<div className='py-6 px-1'>
|
|
||||||
<LoadingStateWithBorder icon={Gauge} description='加载限流参数...' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (optionsQuery.isError) {
|
|
||||||
return (
|
|
||||||
<div className='py-6 px-1'>
|
|
||||||
<ErrorInline
|
|
||||||
message={
|
|
||||||
optionsQuery.error instanceof Error
|
|
||||||
? optionsQuery.error.message
|
|
||||||
: '加载失败'
|
|
||||||
}
|
|
||||||
onRetry={() => void optionsQuery.refetch()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='py-6 px-1 space-y-6'>
|
<div className='py-6 px-1 space-y-6'>
|
||||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
<div className='flex items-center gap-2'>
|
||||||
<div className='flex items-center gap-2'>
|
<Gauge className='size-5 text-primary' />
|
||||||
<Gauge className='size-5 text-primary' />
|
<div>
|
||||||
<div>
|
<h1 className='text-2xl font-semibold tracking-tight'>限流</h1>
|
||||||
<h1 className='text-2xl font-semibold tracking-tight'>限流</h1>
|
<p className='text-sm text-muted-foreground'>
|
||||||
<p className='text-sm text-muted-foreground'>
|
查看边缘请求压力,并配置站点默认并发与带宽限流。
|
||||||
配置边缘站点默认并发与带宽限流。0
|
</p>
|
||||||
或空表示默认关闭;站点未单独配置时继承此处设置。修改后需在版本发布中生效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button variant='outline' size='sm' asChild>
|
|
||||||
<Link href='/config-versions'>
|
|
||||||
<ExternalLink className='size-3.5 mr-1' />
|
|
||||||
查看配置预览
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className='border-dashed shadow-none'>
|
<Tabs value={tab} onValueChange={handleTabChange}>
|
||||||
<CardHeader className='flex flex-row items-center justify-between'>
|
<TabsList className='grid w-full max-w-md grid-cols-2'>
|
||||||
<div>
|
<TabsTrigger value='analysis'>分析</TabsTrigger>
|
||||||
<CardTitle className='text-base'>全局默认限流</CardTitle>
|
<TabsTrigger value='config'>配置</TabsTrigger>
|
||||||
<CardDescription>
|
</TabsList>
|
||||||
0 或空表示默认关闭;站点未单独配置时继承此处设置。
|
|
||||||
</CardDescription>
|
<TabsContent value='analysis' className='mt-4'>
|
||||||
</div>
|
<AnalysisTab
|
||||||
<Button
|
data={overviewQuery.data}
|
||||||
size='sm'
|
loading={overviewQuery.isLoading}
|
||||||
disabled={saving}
|
error={
|
||||||
onClick={() => saveMutation.mutate()}
|
overviewQuery.error instanceof Error ? overviewQuery.error : null
|
||||||
>
|
}
|
||||||
{saving ? (
|
hours={hours}
|
||||||
<Loader2 className='size-4 animate-spin mr-1' />
|
hosts={hosts}
|
||||||
) : (
|
onHoursChange={setHours}
|
||||||
<Save className='size-3.5 mr-1' />
|
onHostsChange={setHosts}
|
||||||
)}
|
onRetry={() => void overviewQuery.refetch()}
|
||||||
保存
|
/>
|
||||||
</Button>
|
</TabsContent>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
<TabsContent value='config' className='mt-4'>
|
||||||
<div className='space-y-1.5'>
|
<ConfigTab />
|
||||||
<Label className='text-xs text-muted-foreground'>
|
</TabsContent>
|
||||||
默认并发限制(每站点)
|
</Tabs>
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
min={0}
|
|
||||||
value={fields.openresty_default_limit_conn_per_server}
|
|
||||||
placeholder='0'
|
|
||||||
onChange={(e) =>
|
|
||||||
updateField(
|
|
||||||
'openresty_default_limit_conn_per_server',
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className='h-9 text-xs'
|
|
||||||
/>
|
|
||||||
<p className='text-xs text-muted-foreground'>
|
|
||||||
站点最大并发连接数默认值
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-1.5'>
|
|
||||||
<Label className='text-xs text-muted-foreground'>
|
|
||||||
默认单 IP 并发
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
type='number'
|
|
||||||
min={0}
|
|
||||||
value={fields.openresty_default_limit_conn_per_ip}
|
|
||||||
placeholder='0'
|
|
||||||
onChange={(e) =>
|
|
||||||
updateField(
|
|
||||||
'openresty_default_limit_conn_per_ip',
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className='h-9 text-xs'
|
|
||||||
/>
|
|
||||||
<p className='text-xs text-muted-foreground'>
|
|
||||||
单个 IP 最大并发连接数默认值
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='space-y-1.5'>
|
|
||||||
<Label className='text-xs text-muted-foreground'>默认限速</Label>
|
|
||||||
<Input
|
|
||||||
value={fields.openresty_default_limit_rate}
|
|
||||||
placeholder='512k / 1m'
|
|
||||||
onChange={(e) =>
|
|
||||||
updateField('openresty_default_limit_rate', e.target.value)
|
|
||||||
}
|
|
||||||
className='h-9 text-xs'
|
|
||||||
/>
|
|
||||||
<p className='text-xs text-muted-foreground'>
|
|
||||||
单请求带宽默认值,例如 512k 或 1m
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function RateLimitsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className='py-6 px-1'>
|
||||||
|
<LoadingStateWithBorder icon={Gauge} description='加载限流页面...' />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RateLimitsPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ type RankCardProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
items: RankCardItem[];
|
items: RankCardItem[];
|
||||||
color?: string;
|
color?: string;
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RankCard({
|
export function RankCard({
|
||||||
@@ -25,6 +26,7 @@ export function RankCard({
|
|||||||
description,
|
description,
|
||||||
items,
|
items,
|
||||||
color = '#3b82f6',
|
color = '#3b82f6',
|
||||||
|
valueFormatter,
|
||||||
}: RankCardProps) {
|
}: RankCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className='border-dashed shadow-none'>
|
<Card className='border-dashed shadow-none'>
|
||||||
@@ -40,6 +42,7 @@ export function RankCard({
|
|||||||
<RankChart
|
<RankChart
|
||||||
items={items}
|
items={items}
|
||||||
color={color}
|
color={color}
|
||||||
|
valueFormatter={valueFormatter}
|
||||||
emptyMessage={`暂无 ${title} 数据`}
|
emptyMessage={`暂无 ${title} 数据`}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user