mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 16:46:37 +08:00
前端优化
This commit is contained in:
@@ -1,4 +1,10 @@
|
|||||||
import type {ProxyRoutePoWConfig, WAFRuleGroup, WAFRuleGroupPayload,} from '@/lib/services/openflare';
|
import type {
|
||||||
|
ProxyRoutePoWConfig,
|
||||||
|
WAFIPGroup,
|
||||||
|
WAFIPGroupPayload,
|
||||||
|
WAFRuleGroup,
|
||||||
|
WAFRuleGroupPayload,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
export type WAFTab = 'basic' | 'lists' | 'pow' | 'block';
|
||||||
export type RuleListType = 'whitelist' | 'blacklist';
|
export type RuleListType = 'whitelist' | 'blacklist';
|
||||||
@@ -266,3 +272,63 @@ export const automaticPresetRules = [
|
|||||||
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
|
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export type IPGroupViewEntry = {
|
||||||
|
ip: string;
|
||||||
|
capturedAt?: string;
|
||||||
|
banRemaining?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildIPGroupPayloadFromGroup(
|
||||||
|
group: WAFIPGroup,
|
||||||
|
ipList: string[],
|
||||||
|
): WAFIPGroupPayload {
|
||||||
|
return {
|
||||||
|
name: group.name,
|
||||||
|
type: group.type,
|
||||||
|
enabled: group.enabled,
|
||||||
|
ip_list: ipList,
|
||||||
|
auto_config: group.auto_config ?? {},
|
||||||
|
subscription_url: group.subscription_url ?? '',
|
||||||
|
subscription_format: group.subscription_format ?? 'text',
|
||||||
|
subscription_mapping_rule: group.subscription_mapping_rule ?? '',
|
||||||
|
sync_interval_minutes: group.sync_interval_minutes || 1440,
|
||||||
|
remark: group.remark ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIPGroupBanRemaining(
|
||||||
|
capturedAt: string,
|
||||||
|
ttlSeconds: number,
|
||||||
|
now = new Date(),
|
||||||
|
): string {
|
||||||
|
if (ttlSeconds <= 0) {
|
||||||
|
return '永久';
|
||||||
|
}
|
||||||
|
const capturedDate = new Date(capturedAt);
|
||||||
|
if (Number.isNaN(capturedDate.getTime())) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
|
||||||
|
if (expireDate.getTime() <= now.getTime()) {
|
||||||
|
return '已过期';
|
||||||
|
}
|
||||||
|
const diffMins = Math.round((expireDate.getTime() - now.getTime()) / (60 * 1000));
|
||||||
|
if (diffMins < 60) {
|
||||||
|
return `${diffMins} 分钟后`;
|
||||||
|
}
|
||||||
|
return `${Math.round(diffMins / 60)} 小时后`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIPGroupViewEntries(group: WAFIPGroup): IPGroupViewEntry[] {
|
||||||
|
if (group.type === 'automatic' && group.ext_ips && group.ext_ips.length > 0) {
|
||||||
|
const ttl =
|
||||||
|
typeof group.auto_config?.ttl === 'number' ? group.auto_config.ttl : -1;
|
||||||
|
return group.ext_ips.map((item) => ({
|
||||||
|
ip: item.ip,
|
||||||
|
capturedAt: item.captured_at,
|
||||||
|
banRemaining: formatIPGroupBanRemaining(item.captured_at, ttl),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return (group.ip_list ?? []).map((ip) => ({ ip }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Loader2, Trash2} from 'lucide-react';
|
||||||
|
import {useMemo, useState} from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||||
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||||
|
import type {WAFIPGroup} from '@/lib/services/openflare';
|
||||||
|
import {formatDateTime} from '@/lib/utils';
|
||||||
|
|
||||||
|
import {getIPGroupViewEntries, ipGroupTypeLabels, type IPGroupViewEntry} from './helpers';
|
||||||
|
|
||||||
|
interface IPGroupViewDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
group: WAFIPGroup | null;
|
||||||
|
loading: boolean;
|
||||||
|
removingIp: string | null;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onRemoveIp: (ip: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IPGroupViewDialog({
|
||||||
|
open,
|
||||||
|
group,
|
||||||
|
loading,
|
||||||
|
removingIp,
|
||||||
|
onOpenChange,
|
||||||
|
onRemoveIp,
|
||||||
|
}: IPGroupViewDialogProps) {
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(null);
|
||||||
|
|
||||||
|
const entries = useMemo(
|
||||||
|
() => (group ? getIPGroupViewEntries(group) : []),
|
||||||
|
[group],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showAutomaticMeta = group?.type === 'automatic';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
if (!nextOpen) {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{group ? `查看 ${group.name}` : '查看 IP 组'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{group
|
||||||
|
? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP`
|
||||||
|
: '查看当前 IP 组中的 IP 列表,并可移除不需要的条目。'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
加载 IP 列表...
|
||||||
|
</div>
|
||||||
|
) : !group ? (
|
||||||
|
<p className="py-8 text-center text-sm text-muted-foreground">未选择 IP 组。</p>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
description={
|
||||||
|
group.type === 'automatic'
|
||||||
|
? '该自动规则暂未抓取任何 IP,可点击「立即执行」手动触发抓取。'
|
||||||
|
: '当前 IP 组暂无 IP 条目。'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.type === 'subscription' ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
订阅类型在下次同步时可能重新拉取已删除的 IP。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<div className="rounded-lg border border-dashed">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>IP 地址</TableHead>
|
||||||
|
{showAutomaticMeta ? (
|
||||||
|
<>
|
||||||
|
<TableHead>抓取时间</TableHead>
|
||||||
|
<TableHead>封禁剩余</TableHead>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<TableHead className="w-[80px] text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<TableRow key={entry.ip}>
|
||||||
|
<TableCell className="font-mono text-sm">{entry.ip}</TableCell>
|
||||||
|
{showAutomaticMeta ? (
|
||||||
|
<>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{entry.capturedAt ? formatDateTime(entry.capturedAt) : '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">{entry.banRemaining ?? '—'}</TableCell>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8 text-destructive hover:text-destructive"
|
||||||
|
disabled={removingIp === entry.ip}
|
||||||
|
onClick={() => setDeleteTarget(entry)}
|
||||||
|
>
|
||||||
|
{removingIp === entry.ip ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">删除 {entry.ip}</span>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
onOpenChange={(nextOpen) => !nextOpen && setDeleteTarget(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除 IP</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确认从 IP 组「{group?.name}」中移除 {deleteTarget?.ip} 吗?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={Boolean(removingIp)}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
disabled={Boolean(removingIp)}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
await onRemoveIp(deleteTarget.ip);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{removingIp ? '删除中...' : '确认删除'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {Download, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
|
import {Download, Eye, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
@@ -20,6 +20,7 @@ import {ipGroupTypeLabels} from './helpers';
|
|||||||
interface IPGroupsTableProps {
|
interface IPGroupsTableProps {
|
||||||
groups: WAFIPGroup[];
|
groups: WAFIPGroup[];
|
||||||
syncingId: number | null;
|
syncingId: number | null;
|
||||||
|
onView: (group: WAFIPGroup) => void;
|
||||||
onEdit: (group: WAFIPGroup) => void;
|
onEdit: (group: WAFIPGroup) => void;
|
||||||
onDelete: (group: WAFIPGroup) => void;
|
onDelete: (group: WAFIPGroup) => void;
|
||||||
onSync: (group: WAFIPGroup) => void;
|
onSync: (group: WAFIPGroup) => void;
|
||||||
@@ -29,6 +30,7 @@ interface IPGroupsTableProps {
|
|||||||
export function IPGroupsTable({
|
export function IPGroupsTable({
|
||||||
groups,
|
groups,
|
||||||
syncingId,
|
syncingId,
|
||||||
|
onView,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onSync,
|
onSync,
|
||||||
@@ -78,6 +80,10 @@ export function IPGroupsTable({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => onView(group)}>
|
||||||
|
<Eye className="size-4 mr-2" />
|
||||||
|
查看
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => onEdit(group)}>
|
<DropdownMenuItem onClick={() => onEdit(group)}>
|
||||||
<Pencil className="size-4 mr-2" />
|
<Pencil className="size-4 mr-2" />
|
||||||
编辑
|
编辑
|
||||||
|
|||||||
@@ -200,21 +200,6 @@ export function RuleGroupDialog({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enabled"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<FormLabel>启用规则组</FormLabel>
|
|
||||||
<FormDescription>关闭后保留配置,但不会参与匹配。</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="remark"
|
name="remark"
|
||||||
@@ -229,6 +214,20 @@ export function RuleGroupDialog({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<FormLabel>启用规则组</FormLabel>
|
||||||
|
</div>
|
||||||
|
<FormControl>
|
||||||
|
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="lists" className="space-y-4 mt-4">
|
<TabsContent value="lists" className="space-y-4 mt-4">
|
||||||
|
|||||||
@@ -23,9 +23,10 @@ import {LoadingStateWithBorder} from '@/components/layout/loading';
|
|||||||
import type {WAFIPGroup, WAFIPGroupAutoTestResult, WAFIPGroupPayload,} from '@/lib/services/openflare';
|
import type {WAFIPGroup, WAFIPGroupAutoTestResult, WAFIPGroupPayload,} from '@/lib/services/openflare';
|
||||||
import {WafService} from '@/lib/services/openflare';
|
import {WafService} from '@/lib/services/openflare';
|
||||||
|
|
||||||
import {getErrorMessage, parseAutomaticConfig} from '../components/helpers';
|
import {buildIPGroupPayloadFromGroup, getErrorMessage, parseAutomaticConfig} from '../components/helpers';
|
||||||
import {IPGroupDialog} from '../components/ip-group-dialog';
|
import {IPGroupDialog} from '../components/ip-group-dialog';
|
||||||
import {IPGroupTestDialog} from '../components/ip-group-test-dialog';
|
import {IPGroupTestDialog} from '../components/ip-group-test-dialog';
|
||||||
|
import {IPGroupViewDialog} from '../components/ip-group-view-dialog';
|
||||||
import {IPGroupsTable} from '../components/ip-groups-table';
|
import {IPGroupsTable} from '../components/ip-groups-table';
|
||||||
|
|
||||||
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
|
const ipGroupsQueryKey = ['openflare', 'waf', 'ip-groups'];
|
||||||
@@ -38,6 +39,9 @@ export default function WafIPGroupsPage() {
|
|||||||
const [testOpen, setTestOpen] = useState(false);
|
const [testOpen, setTestOpen] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<WAFIPGroupAutoTestResult | null>(null);
|
const [testResult, setTestResult] = useState<WAFIPGroupAutoTestResult | null>(null);
|
||||||
const [syncingId, setSyncingId] = useState<number | null>(null);
|
const [syncingId, setSyncingId] = useState<number | null>(null);
|
||||||
|
const [viewOpen, setViewOpen] = useState(false);
|
||||||
|
const [viewingGroup, setViewingGroup] = useState<WAFIPGroup | null>(null);
|
||||||
|
const [removingIp, setRemovingIp] = useState<string | null>(null);
|
||||||
|
|
||||||
const groupsQuery = useQuery({
|
const groupsQuery = useQuery({
|
||||||
queryKey: ipGroupsQueryKey,
|
queryKey: ipGroupsQueryKey,
|
||||||
@@ -105,6 +109,33 @@ export default function WafIPGroupsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const viewGroupQuery = useQuery({
|
||||||
|
queryKey: ['openflare', 'waf', 'ip-groups', viewingGroup?.id],
|
||||||
|
queryFn: () => WafService.getIPGroup(viewingGroup!.id),
|
||||||
|
enabled: viewOpen && viewingGroup !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeIpMutation = useMutation({
|
||||||
|
mutationFn: async ({ group, ip }: { group: WAFIPGroup; ip: string }) => {
|
||||||
|
const nextIpList = group.ip_list.filter((item) => item !== ip);
|
||||||
|
return WafService.updateIPGroup(group.id, buildIPGroupPayloadFromGroup(group, nextIpList));
|
||||||
|
},
|
||||||
|
onMutate: ({ ip }) => {
|
||||||
|
setRemovingIp(ip);
|
||||||
|
},
|
||||||
|
onSuccess: async (updatedGroup) => {
|
||||||
|
toast.success('IP 已移除');
|
||||||
|
setViewingGroup(updatedGroup);
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(getErrorMessage(error));
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setRemovingIp(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const testMutation = useMutation({
|
const testMutation = useMutation({
|
||||||
mutationFn: (group: WAFIPGroup) =>
|
mutationFn: (group: WAFIPGroup) =>
|
||||||
WafService.testIPGroup({
|
WafService.testIPGroup({
|
||||||
@@ -146,7 +177,19 @@ export default function WafIPGroupsPage() {
|
|||||||
testMutation.mutate(group);
|
testMutation.mutate(group);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleView = (group: WAFIPGroup) => {
|
||||||
|
setViewingGroup(group);
|
||||||
|
setViewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveIp = async (ip: string) => {
|
||||||
|
const group = viewGroupQuery.data ?? viewingGroup;
|
||||||
|
if (!group) return;
|
||||||
|
await removeIpMutation.mutateAsync({ group, ip });
|
||||||
|
};
|
||||||
|
|
||||||
const groups = groupsQuery.data ?? [];
|
const groups = groupsQuery.data ?? [];
|
||||||
|
const viewGroup = viewGroupQuery.data ?? viewingGroup;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-1 space-y-6">
|
<div className="py-6 px-1 space-y-6">
|
||||||
@@ -205,6 +248,7 @@ export default function WafIPGroupsPage() {
|
|||||||
<IPGroupsTable
|
<IPGroupsTable
|
||||||
groups={groups}
|
groups={groups}
|
||||||
syncingId={syncingId}
|
syncingId={syncingId}
|
||||||
|
onView={handleView}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={setDeleteTarget}
|
onDelete={setDeleteTarget}
|
||||||
onSync={(group) => syncMutation.mutate(group.id)}
|
onSync={(group) => syncMutation.mutate(group.id)}
|
||||||
@@ -234,6 +278,20 @@ export default function WafIPGroupsPage() {
|
|||||||
onOpenChange={setTestOpen}
|
onOpenChange={setTestOpen}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<IPGroupViewDialog
|
||||||
|
open={viewOpen}
|
||||||
|
group={viewGroup}
|
||||||
|
loading={viewGroupQuery.isFetching && !viewGroupQuery.data}
|
||||||
|
removingIp={removingIp}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setViewOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setViewingGroup(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRemoveIp={handleRemoveIp}
|
||||||
|
/>
|
||||||
|
|
||||||
<AlertDialog
|
<AlertDialog
|
||||||
open={Boolean(deleteTarget)}
|
open={Boolean(deleteTarget)}
|
||||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ export const openflareWebsiteNavGroup: OpenFlareNavGroup = {
|
|||||||
title: '网站管理',
|
title: '网站管理',
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
items: [
|
items: [
|
||||||
{title: '网站', url: '/websites', childUrls: ['/websites/detail']},
|
{title: '域名列表', url: '/websites', childUrls: ['/websites/detail']},
|
||||||
{title: '证书', url: '/websites/certificates'},
|
{title: 'TLS证书', url: '/websites/certificates'},
|
||||||
{title: 'DNS', url: '/websites/dns-accounts'},
|
{title: 'DNS账号', url: '/websites/dns-accounts'},
|
||||||
{title: '源站', url: '/origins', childUrls: ['/origins/detail']},
|
{title: '源站地址', url: '/origins', childUrls: ['/origins/detail']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -695,6 +695,11 @@ func buildIPGroup(group *model.OpenFlareWAFIPGroup, input IPGroupInput) (*model.
|
|||||||
group.Type = groupType
|
group.Type = groupType
|
||||||
group.Enabled = input.Enabled
|
group.Enabled = input.Enabled
|
||||||
group.IPList = string(ipListJSON)
|
group.IPList = string(ipListJSON)
|
||||||
|
if groupType == wafIPGroupTypeAutomatic {
|
||||||
|
if err := pruneIPGroupExtIPs(group, normalizedIPs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
group.AutoConfig = autoConfig
|
group.AutoConfig = autoConfig
|
||||||
group.SubscriptionURL = subscriptionURL
|
group.SubscriptionURL = subscriptionURL
|
||||||
group.SubscriptionFormat = subscriptionFormat
|
group.SubscriptionFormat = subscriptionFormat
|
||||||
@@ -785,6 +790,34 @@ func loadIPGroupReferenceCounts(ctx context.Context) (map[uint]int, error) {
|
|||||||
return counts, nil
|
return counts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pruneIPGroupExtIPs(group *model.OpenFlareWAFIPGroup, ipList []string) error {
|
||||||
|
if group == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
allowed := make(map[string]struct{}, len(ipList))
|
||||||
|
for _, ip := range ipList {
|
||||||
|
allowed[ip] = struct{}{}
|
||||||
|
}
|
||||||
|
var extIPs []ipGroupExtIP
|
||||||
|
if group.ExtIPs != "" && group.ExtIPs != "[]" {
|
||||||
|
if err := json.Unmarshal([]byte(group.ExtIPs), &extIPs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pruned := make([]ipGroupExtIP, 0, len(extIPs))
|
||||||
|
for _, extIP := range extIPs {
|
||||||
|
if _, ok := allowed[extIP.IP]; ok {
|
||||||
|
pruned = append(pruned, extIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
extIPsJSON, err := json.Marshal(pruned)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
group.ExtIPs = string(extIPsJSON)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeIPList(items []string) ([]string, error) {
|
func normalizeIPList(items []string) ([]string, error) {
|
||||||
normalized := make([]string, 0, len(items))
|
normalized := make([]string, 0, len(items))
|
||||||
for _, raw := range items {
|
for _, raw := range items {
|
||||||
|
|||||||
@@ -65,3 +65,46 @@ func TestCreateRuleGroup(t *testing.T) {
|
|||||||
})
|
})
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPruneIPGroupExtIPs(t *testing.T) {
|
||||||
|
group := &model.OpenFlareWAFIPGroup{
|
||||||
|
ExtIPs: `[{"ip":"203.0.113.10","captured_at":"2026-06-18T10:00:00Z"},{"ip":"203.0.113.11","captured_at":"2026-06-18T11:00:00Z"}]`,
|
||||||
|
}
|
||||||
|
err := pruneIPGroupExtIPs(group, []string{"203.0.113.10"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.JSONEq(t, `[{"ip":"203.0.113.10","captured_at":"2026-06-18T10:00:00Z"}]`, group.ExtIPs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateIPGroupPrunesAutomaticExtIPs(t *testing.T) {
|
||||||
|
cleanup := setupWAFTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
created, err := CreateIPGroup(ctx, IPGroupInput{
|
||||||
|
Name: "auto group",
|
||||||
|
Type: wafIPGroupTypeAutomatic,
|
||||||
|
Enabled: true,
|
||||||
|
AutoConfig: []byte(`{"lookback_minutes":60,"ttl":-1,"rules":[{"name":"scan","expr":"request_count > 1"}]}`),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
group, err := model.GetOpenFlareWAFIPGroupByID(ctx, created.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
group.IPList = `["203.0.113.10","203.0.113.11"]`
|
||||||
|
group.ExtIPs = `[{"ip":"203.0.113.10","captured_at":"2026-06-18T10:00:00Z"},{"ip":"203.0.113.11","captured_at":"2026-06-18T11:00:00Z"}]`
|
||||||
|
require.NoError(t, model.UpdateOpenFlareWAFIPGroup(ctx, group))
|
||||||
|
|
||||||
|
updated, err := UpdateIPGroup(ctx, created.ID, IPGroupInput{
|
||||||
|
Name: created.Name,
|
||||||
|
Type: created.Type,
|
||||||
|
Enabled: created.Enabled,
|
||||||
|
IPList: []string{"203.0.113.10"},
|
||||||
|
AutoConfig: created.AutoConfig,
|
||||||
|
Remark: created.Remark,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, updated.IPList, 1)
|
||||||
|
assert.Equal(t, "203.0.113.10", updated.IPList[0])
|
||||||
|
require.Len(t, updated.ExtIPs, 1)
|
||||||
|
assert.Equal(t, "203.0.113.10", updated.ExtIPs[0].IP)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ sidebar: false
|
|||||||
|
|
||||||
- 移除 Wavelet 顶栏 OpenFlare 服务端版本入口按钮;版本升级能力保留在 Admin 设置 OpenFlare 运维 Tab。
|
- 移除 Wavelet 顶栏 OpenFlare 服务端版本入口按钮;版本升级能力保留在 Admin 设置 OpenFlare 运维 Tab。
|
||||||
- 将 Wavelet 默认上游仓库调整为 `Rain-kl/OpenFlare`,站点名称、邮件模板、前端默认标题与页脚品牌统一初始化为 OpenFlare;新增 goose 迁移回填既有环境的旧 Wavelet 默认值。
|
- 将 Wavelet 默认上游仓库调整为 `Rain-kl/OpenFlare`,站点名称、邮件模板、前端默认标题与页脚品牌统一初始化为 OpenFlare;新增 goose 迁移回填既有环境的旧 Wavelet 默认值。
|
||||||
|
- Wavelet WAF IP 组列表新增「查看」操作,支持在查看弹窗中浏览当前 IP 并移除单条 IP;自动类型同步裁剪 `ext_ips` 以与 `ip_list` 保持一致。
|
||||||
|
|
||||||
## [v2.3.4] - 2026-06-17
|
## [v2.3.4] - 2026-06-17
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||||
import type { ReactNode } from 'react';
|
import type {ReactNode} from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import {useRouter} from 'next/navigation';
|
||||||
import { Globe2, Network, Plus, Save, ShieldCheck, Trash2 } from 'lucide-react';
|
import {Globe2, Network, Plus, Save, ShieldCheck, Trash2} from 'lucide-react';
|
||||||
|
|
||||||
import { EmptyState } from '@/components/feedback/empty-state';
|
import {EmptyState} from '@/components/feedback/empty-state';
|
||||||
import { ErrorState } from '@/components/feedback/error-state';
|
import {ErrorState} from '@/components/feedback/error-state';
|
||||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
import {InlineMessage} from '@/components/feedback/inline-message';
|
||||||
import { LoadingState } from '@/components/feedback/loading-state';
|
import {LoadingState} from '@/components/feedback/loading-state';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import {PageHeader} from '@/components/layout/page-header';
|
||||||
import { AppCard } from '@/components/ui/app-card';
|
import {AppCard} from '@/components/ui/app-card';
|
||||||
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
import {getProxyRoutes} from '@/features/proxy-routes/api/proxy-routes';
|
||||||
import {
|
import {
|
||||||
DangerButton,
|
DangerButton,
|
||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
@@ -30,18 +30,14 @@ import {
|
|||||||
replaceWAFRuleGroupSites,
|
replaceWAFRuleGroupSites,
|
||||||
updateWAFRuleGroup,
|
updateWAFRuleGroup,
|
||||||
} from '@/features/waf/api/waf';
|
} from '@/features/waf/api/waf';
|
||||||
import type {
|
import type {WAFIPGroup, WAFRuleGroup, WAFRuleGroupPayload,} from '@/features/waf/types';
|
||||||
WAFIPGroup,
|
import {cn} from '@/lib/utils/cn';
|
||||||
WAFRuleGroup,
|
|
||||||
WAFRuleGroupPayload,
|
|
||||||
} from '@/features/waf/types';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
|
||||||
|
|
||||||
import { RuleEntryModal } from './rule-entry-modal';
|
import {RuleEntryModal} from './rule-entry-modal';
|
||||||
import { SiteApplyDrawer } from './site-apply-drawer';
|
import {SiteApplyDrawer} from './site-apply-drawer';
|
||||||
import { PowTabPanel } from './pow-tab-panel';
|
import {PowTabPanel} from './pow-tab-panel';
|
||||||
import { RuleListSection } from './rule-list-section';
|
import {RuleListSection} from './rule-list-section';
|
||||||
import { TabButton } from './tab-button';
|
import {TabButton} from './tab-button';
|
||||||
import {
|
import {
|
||||||
buildCountryOptions,
|
buildCountryOptions,
|
||||||
buildDraft,
|
buildDraft,
|
||||||
@@ -56,12 +52,7 @@ import {
|
|||||||
textToList,
|
textToList,
|
||||||
updateDraftList,
|
updateDraftList,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
import type {
|
import type {FeedbackState, ListFieldKey, RuleModalState, WAFTab,} from './types';
|
||||||
FeedbackState,
|
|
||||||
ListFieldKey,
|
|
||||||
RuleModalState,
|
|
||||||
WAFTab,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export function WAFPage() {
|
export function WAFPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -444,7 +435,6 @@ export function WAFPage() {
|
|||||||
</ResourceField>
|
</ResourceField>
|
||||||
<ToggleField
|
<ToggleField
|
||||||
label="启用规则组"
|
label="启用规则组"
|
||||||
description="关闭后保留配置,但不会参与匹配。"
|
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onChange={(checked) =>
|
onChange={(checked) =>
|
||||||
setDraft((current) => ({
|
setDraft((current) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user