This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -40,7 +40,7 @@ export function CreateRuleDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form
className="flex flex-col gap-4"
className='flex flex-col gap-4'
onSubmit={async (event) => {
event.preventDefault();
if (!trimmedName || pending) return;
@@ -55,28 +55,28 @@ export function CreateRuleDialog({
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="waf-rule-name">规则名称</FieldLabel>
<FieldLabel htmlFor='waf-rule-name'>规则名称</FieldLabel>
<Input
id="waf-rule-name"
id='waf-rule-name'
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="例如:入口防护"
autoComplete="off"
placeholder='例如:入口防护'
autoComplete='off'
autoFocus
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
disabled={pending}
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={!trimmedName || pending}>
{pending ? <Spinner data-icon="inline-start" /> : null}
<Button type='submit' disabled={!trimmedName || pending}>
{pending ? <Spinner data-icon='inline-start' /> : null}
创建并编排
</Button>
</DialogFooter>
@@ -1,7 +1,4 @@
import type {
WAFIPGroup,
WAFIPGroupPayload,
} from '@/lib/services/openflare';
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '操作失败';
@@ -18,7 +15,6 @@ export function parseTextareaList(text: string) {
.filter(Boolean);
}
export function parseAutomaticConfig(text: string): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -83,7 +79,9 @@ export function formatIPGroupBanRemaining(
if (expireDate.getTime() <= now.getTime()) {
return '已过期';
}
const diffMins = Math.round((expireDate.getTime() - now.getTime()) / (60 * 1000));
const diffMins = Math.round(
(expireDate.getTime() - now.getTime()) / (60 * 1000),
);
if (diffMins < 60) {
return `${diffMins} 分钟后`;
}
@@ -1,12 +1,12 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {Plus} from 'lucide-react';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import {Button} from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -15,11 +15,25 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage,} from '@/components/ui/form';
import {Input} from '@/components/ui/input';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import {Textarea} from '@/components/ui/textarea';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import type {
WAFIPGroup,
WAFIPGroupPayload,
@@ -27,7 +41,12 @@ import type {
WAFIPGroupType,
} from '@/lib/services/openflare';
import {automaticPresetRules, listToText, parseAutomaticConfig, parseTextareaList,} from './helpers';
import {
automaticPresetRules,
listToText,
parseAutomaticConfig,
parseTextareaList,
} from './helpers';
const ipGroupSchema = z
.object({
@@ -126,7 +145,9 @@ function appendAutomaticPresetRule(
return JSON.stringify(
{
lookback_minutes:
typeof config.lookback_minutes === 'number' ? config.lookback_minutes : 60,
typeof config.lookback_minutes === 'number'
? config.lookback_minutes
: 60,
...config,
rules: nextRules,
},
@@ -175,20 +196,22 @@ export function IPGroupDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>{group ? `编辑 ${group.name}` : '新建 IP 组'}</DialogTitle>
<DialogTitle>
{group ? `编辑 ${group.name}` : '新建 IP 组'}
</DialogTitle>
<DialogDescription>
维护可被 WAF IP 黑白名单引用的手动、自动与订阅 IP 集合。
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<form onSubmit={handleSubmit} className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<FormField
control={form.control}
name="name"
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>IP 组名称</FormLabel>
@@ -201,7 +224,7 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="type"
name='type'
render={({ field }) => (
<FormItem>
<FormLabel>类型</FormLabel>
@@ -217,9 +240,9 @@ export function IPGroupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="manual">手动</SelectItem>
<SelectItem value="automatic">自动</SelectItem>
<SelectItem value="subscription">订阅</SelectItem>
<SelectItem value='manual'>手动</SelectItem>
<SelectItem value='automatic'>自动</SelectItem>
<SelectItem value='subscription'>订阅</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -228,17 +251,20 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="enabled"
name='enabled'
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-dashed p-4 md:col-span-2">
<div className="space-y-0.5">
<FormItem className='flex items-center justify-between rounded-lg border border-dashed p-4 md:col-span-2'>
<div className='space-y-0.5'>
<FormLabel>启用 IP 组</FormLabel>
<FormDescription>
关闭后保留配置,但发布时不会展开到 WAF 运行时名单。
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
@@ -246,15 +272,18 @@ export function IPGroupDialog({
</div>
{type === 'subscription' ? (
<div className="grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4">
<div className='grid gap-4 md:grid-cols-2 rounded-lg border border-dashed p-4'>
<FormField
control={form.control}
name="subscription_url"
name='subscription_url'
render={({ field }) => (
<FormItem>
<FormLabel>订阅 URL</FormLabel>
<FormControl>
<Input placeholder="https://example.com/ip-list.txt" {...field} />
<Input
placeholder='https://example.com/ip-list.txt'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -262,7 +291,7 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="subscription_format"
name='subscription_format'
render={({ field }) => (
<FormItem>
<FormLabel>订阅格式</FormLabel>
@@ -278,8 +307,8 @@ export function IPGroupDialog({
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="text">文本列表</SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value='text'>文本列表</SelectItem>
<SelectItem value='json'>JSON</SelectItem>
</SelectContent>
</Select>
<FormMessage />
@@ -288,13 +317,13 @@ export function IPGroupDialog({
/>
<FormField
control={form.control}
name="sync_interval_minutes"
name='sync_interval_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
type='number'
min={5}
value={field.value}
onChange={(event) =>
@@ -302,21 +331,25 @@ export function IPGroupDialog({
}
/>
</FormControl>
<FormDescription>最小 5 分钟,默认 1440 分钟。</FormDescription>
<FormDescription>
最小 5 分钟,默认 1440 分钟。
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="subscription_mapping_rule"
name='subscription_mapping_rule'
render={({ field }) => (
<FormItem>
<FormLabel>JSON 映射规则</FormLabel>
<FormControl>
<Input
disabled={form.watch('subscription_format') !== 'json'}
placeholder="留空表示根数组"
disabled={
form.watch('subscription_format') !== 'json'
}
placeholder='留空表示根数组'
{...field}
/>
</FormControl>
@@ -328,16 +361,16 @@ export function IPGroupDialog({
) : null}
{type === 'automatic' ? (
<div className="space-y-4 rounded-lg border border-dashed p-4">
<div className='space-y-4 rounded-lg border border-dashed p-4'>
<FormField
control={form.control}
name="sync_interval_minutes"
name='sync_interval_minutes'
render={({ field }) => (
<FormItem>
<FormLabel>同步间隔(分钟)</FormLabel>
<FormControl>
<Input
type="number"
type='number'
min={5}
value={field.value}
onChange={(event) =>
@@ -352,15 +385,15 @@ export function IPGroupDialog({
</FormItem>
)}
/>
<div className="space-y-2">
<div className='space-y-2'>
<FormLabel>预设规则</FormLabel>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
{automaticPresetRules.map((rule) => (
<Button
key={rule.expr}
type="button"
size="sm"
variant="outline"
type='button'
size='sm'
variant='outline'
onClick={() => {
const current = form.getValues('auto_config_text');
form.setValue(
@@ -369,7 +402,7 @@ export function IPGroupDialog({
);
}}
>
<Plus className="size-3.5 mr-1" />
<Plus className='size-3.5 mr-1' />
{rule.name}
</Button>
))}
@@ -377,12 +410,15 @@ export function IPGroupDialog({
</div>
<FormField
control={form.control}
name="auto_config_text"
name='auto_config_text'
render={({ field }) => (
<FormItem>
<FormLabel>自动配置 JSON</FormLabel>
<FormControl>
<Textarea className="min-h-48 font-mono text-xs" {...field} />
<Textarea
className='min-h-48 font-mono text-xs'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -394,13 +430,13 @@ export function IPGroupDialog({
{type !== 'automatic' ? (
<FormField
control={form.control}
name="ip_list_text"
name='ip_list_text'
render={({ field }) => (
<FormItem>
<FormLabel>IP / IP 段</FormLabel>
<FormControl>
<Textarea
className="min-h-48 font-mono text-xs"
className='min-h-48 font-mono text-xs'
placeholder={'203.0.113.10\n198.51.100.0/24'}
{...field}
/>
@@ -417,14 +453,20 @@ export function IPGroupDialog({
) : null}
{form.formState.errors.root ? (
<p className="text-sm text-destructive">{form.formState.errors.root.message}</p>
<p className='text-sm text-destructive'>
{form.formState.errors.root.message}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
取消
</Button>
<Button type="submit" disabled={submitting}>
<Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : '保存 IP 组'}
</Button>
</DialogFooter>
@@ -8,8 +8,8 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Button} from '@/components/ui/button';
import type {WAFIPGroupAutoTestResult} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import type { WAFIPGroupAutoTestResult } from '@/lib/services/openflare';
interface IPGroupTestDialogProps {
open: boolean;
@@ -26,7 +26,7 @@ export function IPGroupTestDialog({
}: IPGroupTestDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>自动规则测试结果</DialogTitle>
<DialogDescription>
@@ -35,32 +35,38 @@ export function IPGroupTestDialog({
</DialogHeader>
{loading ? (
<p className="text-sm text-muted-foreground py-6 text-center">测试中...</p>
<p className='text-sm text-muted-foreground py-6 text-center'>
测试中...
</p>
) : result ? (
<div className="space-y-4">
<div className="rounded-lg border border-dashed p-4 text-sm">
<div className='space-y-4'>
<div className='rounded-lg border border-dashed p-4 text-sm'>
<p>
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count} 条 · 命中{' '}
{result.matched_count} 个 IP
回看 {result.lookback_minutes} 分钟 · 规则 {result.rule_count}{' '}
条 · 命中 {result.matched_count} 个 IP
</p>
<p className="text-xs text-muted-foreground mt-1">
<p className='text-xs text-muted-foreground mt-1'>
测试时间:{new Date(result.tested_at).toLocaleString()}
</p>
</div>
{result.matched_count > 0 ? (
<pre className="max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap break-all">
<pre className='max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap break-all'>
{result.matched_ips.join('\n')}
</pre>
) : (
<p className="text-sm text-muted-foreground">当前没有匹配到任何 IP。</p>
<p className='text-sm text-muted-foreground'>
当前没有匹配到任何 IP。
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground py-6 text-center">暂无测试结果。</p>
<p className='text-sm text-muted-foreground py-6 text-center'>
暂无测试结果。
</p>
)}
<DialogFooter>
<Button type="button" onClick={() => onOpenChange(false)}>
<Button type='button' onClick={() => onOpenChange(false)}>
关闭
</Button>
</DialogFooter>
@@ -1,7 +1,7 @@
'use client';
import {Loader2, Trash2} from 'lucide-react';
import {useMemo, useState} from 'react';
import { Loader2, Trash2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import {
AlertDialog,
@@ -13,7 +13,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -22,12 +22,23 @@ import {
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 { 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';
import {
getIPGroupViewEntries,
ipGroupTypeLabels,
type IPGroupViewEntry,
} from './helpers';
interface IPGroupViewDialogProps {
open: boolean;
@@ -46,7 +57,9 @@ export function IPGroupViewDialog({
onOpenChange,
onRemoveIp,
}: IPGroupViewDialogProps) {
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(null);
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
null,
);
const entries = useMemo(
() => (group ? getIPGroupViewEntries(group) : []),
@@ -66,9 +79,11 @@ export function IPGroupViewDialog({
}
}}
>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>{group ? `查看 ${group.name}` : '查看 IP 组'}</DialogTitle>
<DialogTitle>
{group ? `查看 ${group.name}` : '查看 IP 组'}
</DialogTitle>
<DialogDescription>
{group
? `${ipGroupTypeLabels[group.type]} · 共 ${entries.length} 条 IP`
@@ -77,12 +92,14 @@ export function IPGroupViewDialog({
</DialogHeader>
{loading ? (
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<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>
<p className='py-8 text-center text-sm text-muted-foreground'>
未选择 IP 组。
</p>
) : entries.length === 0 ? (
<EmptyStateWithBorder
description={
@@ -92,13 +109,13 @@ export function IPGroupViewDialog({
}
/>
) : (
<div className="space-y-3">
<div className='space-y-3'>
{group.type === 'subscription' ? (
<p className="text-xs text-muted-foreground">
<p className='text-xs text-muted-foreground'>
订阅类型在下次同步时可能重新拉取已删除的 IP。
</p>
) : null}
<div className="rounded-lg border border-dashed">
<div className='rounded-lg border border-dashed'>
<Table>
<TableHeader>
<TableRow>
@@ -109,36 +126,44 @@ export function IPGroupViewDialog({
<TableHead>封禁剩余</TableHead>
</>
) : null}
<TableHead className="w-[80px] text-right">操作</TableHead>
<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>
<TableCell className='font-mono text-sm'>
{entry.ip}
</TableCell>
{showAutomaticMeta ? (
<>
<TableCell className="text-sm text-muted-foreground">
{entry.capturedAt ? formatDateTime(entry.capturedAt) : '—'}
<TableCell className='text-sm text-muted-foreground'>
{entry.capturedAt
? formatDateTime(entry.capturedAt)
: '—'}
</TableCell>
<TableCell className='text-sm'>
{entry.banRemaining ?? '—'}
</TableCell>
<TableCell className="text-sm">{entry.banRemaining ?? '—'}</TableCell>
</>
) : null}
<TableCell className="text-right">
<TableCell className='text-right'>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 text-destructive hover:text-destructive"
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" />
<Loader2 className='size-4 animate-spin' />
) : (
<Trash2 className="size-4" />
<Trash2 className='size-4' />
)}
<span className="sr-only">删除 {entry.ip}</span>
<span className='sr-only'>删除 {entry.ip}</span>
</Button>
</TableCell>
</TableRow>
@@ -150,7 +175,11 @@ export function IPGroupViewDialog({
)}
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
关闭
</Button>
</DialogFooter>
@@ -169,9 +198,11 @@ export function IPGroupViewDialog({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={Boolean(removingIp)}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={Boolean(removingIp)}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={Boolean(removingIp)}
onClick={async () => {
if (!deleteTarget) return;
@@ -186,4 +217,4 @@ export function IPGroupViewDialog({
</AlertDialog>
</>
);
}
}
@@ -1,9 +1,16 @@
'use client';
import {Download, Eye, MoreHorizontal, Pencil, Play, Trash2} from 'lucide-react';
import {
Download,
Eye,
MoreHorizontal,
Pencil,
Play,
Trash2,
} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -11,11 +18,18 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFIPGroup} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { WAFIPGroup } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import {ipGroupTypeLabels} from './helpers';
import { ipGroupTypeLabels } from './helpers';
interface IPGroupsTableProps {
groups: WAFIPGroup[];
@@ -47,15 +61,15 @@ export function IPGroupsTable({
<TableHead>引用次数</TableHead>
<TableHead>同步状态</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
<TableHead className='w-[80px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">{group.name}</TableCell>
<TableCell className='font-medium'>{group.name}</TableCell>
<TableCell>
<Badge variant="outline">{ipGroupTypeLabels[group.type]}</Badge>
<Badge variant='outline'>{ipGroupTypeLabels[group.type]}</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
@@ -64,42 +78,43 @@ export function IPGroupsTable({
</TableCell>
<TableCell>{group.ip_list.length}</TableCell>
<TableCell>{group.referenced_by_rule_count}</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[200px] truncate">
<TableCell className='text-sm text-muted-foreground max-w-[200px] truncate'>
{group.last_sync_status
? `${group.last_sync_status}: ${group.last_sync_message}`
: '尚无同步记录'}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className='text-muted-foreground text-sm'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<TableCell className='text-right'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreHorizontal className="size-4" />
<Button variant='ghost' size='icon' className='size-8'>
<MoreHorizontal className='size-4' />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => onView(group)}>
<Eye className="size-4 mr-2" />
<Eye className='size-4 mr-2' />
查看
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil className="size-4 mr-2" />
<Pencil className='size-4 mr-2' />
编辑
</DropdownMenuItem>
{group.type === 'automatic' ? (
<DropdownMenuItem onClick={() => onTest(group)}>
<Play className="size-4 mr-2" />
<Play className='size-4 mr-2' />
测试规则
</DropdownMenuItem>
) : null}
{group.type === 'subscription' || group.type === 'automatic' ? (
{group.type === 'subscription' ||
group.type === 'automatic' ? (
<DropdownMenuItem
disabled={syncingId === group.id}
onClick={() => onSync(group)}
>
<Download className="size-4 mr-2" />
<Download className='size-4 mr-2' />
{syncingId === group.id
? '同步中...'
: group.type === 'automatic'
@@ -109,10 +124,10 @@ export function IPGroupsTable({
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
className='text-destructive focus:text-destructive'
onClick={() => onDelete(group)}
>
<Trash2 className="size-4 mr-2" />
<Trash2 className='size-4 mr-2' />
删除
</DropdownMenuItem>
</DropdownMenuContent>
@@ -1,9 +1,15 @@
'use client';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2} from 'lucide-react';
import {
Globe2,
MoreHorizontal,
Pencil,
ShieldCheck,
Trash2,
} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -12,9 +18,16 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {WAFRule} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { WAFRule } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
interface RuleGroupsTableProps {
groups: WAFRule[];
@@ -37,24 +50,26 @@ export function RuleGroupsTable({
<TableHead>节点数</TableHead>
<TableHead>应用范围</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[80px] text-right">操作</TableHead>
<TableHead className='w-[80px] text-right'>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groups.map((group) => (
<TableRow key={group.id}>
<TableCell>
<div className="flex items-center gap-2">
<div className='flex items-center gap-2'>
{group.is_global ? (
<Globe2 className="size-4 text-primary shrink-0" />
<Globe2 className='size-4 text-primary shrink-0' />
) : (
<ShieldCheck className="size-4 text-muted-foreground shrink-0" />
<ShieldCheck className='size-4 text-muted-foreground shrink-0' />
)}
<span className="font-medium">{group.name}</span>
<span className='font-medium'>{group.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{group.is_global ? '全局' : '自定义'}</Badge>
<Badge variant='outline'>
{group.is_global ? '全局' : '自定义'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={group.enabled ? 'default' : 'secondary'}>
@@ -67,17 +82,17 @@ export function RuleGroupsTable({
? '全部网站'
: `${group.applied_site_count} 个网站`}
</TableCell>
<TableCell className="text-muted-foreground text-sm">
<TableCell className='text-muted-foreground text-sm'>
{group.updated_at ? formatDateTime(group.updated_at) : '—'}
</TableCell>
<TableCell className="text-right">
<TableCell className='text-right'>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<Button variant='ghost' size='icon' className='size-8'>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align='end'>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onEdit(group)}>
<Pencil />
@@ -89,7 +104,7 @@ export function RuleGroupsTable({
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
variant="destructive"
variant='destructive'
onClick={() => onDelete(group)}
>
<Trash2 />
+60 -41
View File
@@ -1,10 +1,10 @@
'use client';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {Loader2, Plus, RefreshCw, Shield} from 'lucide-react';
import {useRouter} from 'next/navigation';
import {useState} from 'react';
import {toast} from 'sonner';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Plus, RefreshCw, Shield } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -16,17 +16,23 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {WAFRule} from '@/lib/services/openflare';
import {WafService} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { WAFRule } from '@/lib/services/openflare';
import { WafService } from '@/lib/services/openflare';
import {CreateRuleDialog} from './components/create-rule-dialog';
import {getErrorMessage} from './components/helpers';
import {RuleGroupsTable} from './components/rule-groups-table';
import { CreateRuleDialog } from './components/create-rule-dialog';
import { getErrorMessage } from './components/helpers';
import { RuleGroupsTable } from './components/rule-groups-table';
const ruleGroupsQueryKey = ['openflare', 'waf', 'rule-groups'];
@@ -44,12 +50,14 @@ export default function WafPage() {
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'] }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions', 'diff'],
}),
]);
};
const createMutation = useMutation({
mutationFn: (name: string) => WafService.createRule({name}),
mutationFn: (name: string) => WafService.createRule({ name }),
onSuccess: (rule) => {
toast.success('规则已创建');
setCreateOpen(false);
@@ -81,40 +89,44 @@ export default function WafPage() {
const error = groupsQuery.error ?? null;
return (
<div className="w-full py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Shield className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">WAF</h1>
<div className='w-full py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Shield className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>WAF</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={() => setCreateOpen(true)}>
<Plus data-icon="inline-start" />
<div className='flex items-center gap-2'>
<Button
variant='secondary'
size='sm'
onClick={() => setCreateOpen(true)}
>
<Plus data-icon='inline-start' />
新建规则
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between gap-3'>
<div>
<CardTitle className="text-base font-semibold">规则组</CardTitle>
<CardTitle className='text-base font-semibold'>规则组</CardTitle>
<CardDescription>
使用可视化流程编排 WAF 防护规则;全局规则始终应用到所有网站。
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={handleRefresh}
disabled={groupsQuery.isFetching}
>
{groupsQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
<Loader2 className='size-3.5 mr-1 animate-spin' />
) : (
<RefreshCw className="size-3.5 mr-1" />
<RefreshCw className='size-3.5 mr-1' />
)}
刷新
</Button>
@@ -122,19 +134,22 @@ export default function WafPage() {
</CardHeader>
<CardContent>
{loading ? (
<LoadingStateWithBorder icon={Shield} description="加载 WAF 规则组中..." />
<LoadingStateWithBorder
icon={Shield}
description='加载 WAF 规则组中...'
/>
) : error ? (
<div className="p-8 border border-dashed rounded-lg">
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(error)}
onRetry={handleRefresh}
className="justify-center"
className='justify-center'
/>
</div>
) : groups.length === 0 ? (
<EmptyStateWithBorder
icon={Shield}
description="暂无规则组,系统通常会自动创建全局规则组。"
description='暂无规则组,系统通常会自动创建全局规则组。'
/>
) : (
<RuleGroupsTable
@@ -167,11 +182,15 @@ export default function WafPage() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
@@ -2,52 +2,136 @@ import {describe, expect, it} from 'vitest';
import type {WAFRuleGraph} from '@/lib/services/openflare';
import {acceptedNodeChanges, filterRemovableNodeIds, findGraphErrorTarget, getHistoryTransition, isConnectionAllowed, isPersistentEdgeChange, isPersistentNodeChange} from './editor-behavior';
import {
acceptedNodeChanges,
filterRemovableNodeIds,
findGraphErrorTarget,
getHistoryTransition,
isConnectionAllowed,
isPersistentEdgeChange,
isPersistentNodeChange,
} from './editor-behavior';
describe('React Flow persistence filtering', () => {
it('ignores dimensions and selection changes', () => {
expect(isPersistentNodeChange({type: 'dimensions', id: 'n', dimensions: {width: 10, height: 10}})).toBe(false);
expect(isPersistentNodeChange({type: 'select', id: 'n', selected: true})).toBe(false);
expect(isPersistentEdgeChange({type: 'select', id: 'e', selected: true})).toBe(false);
expect(
isPersistentNodeChange({
type: 'dimensions',
id: 'n',
dimensions: { width: 10, height: 10 },
}),
).toBe(false);
expect(
isPersistentNodeChange({ type: 'select', id: 'n', selected: true }),
).toBe(false);
expect(
isPersistentEdgeChange({ type: 'select', id: 'e', selected: true }),
).toBe(false);
});
it('persists completed position changes and removals', () => {
expect(isPersistentNodeChange({type: 'position', id: 'n', position: {x: 1, y: 2}, dragging: false})).toBe(true);
expect(isPersistentNodeChange({type: 'remove', id: 'n'})).toBe(true);
expect(isPersistentEdgeChange({type: 'remove', id: 'e'})).toBe(true);
expect(
isPersistentNodeChange({
type: 'position',
id: 'n',
position: { x: 1, y: 2 },
dragging: false,
}),
).toBe(true);
expect(isPersistentNodeChange({ type: 'remove', id: 'n' })).toBe(true);
expect(isPersistentEdgeChange({ type: 'remove', id: 'e' })).toBe(true);
});
it('forwards transient node changes to React Flow without persisting them', () => {
const changes = [
{type: 'dimensions' as const, id: 'match', dimensions: {width: 176, height: 64}},
{type: 'select' as const, id: 'match', selected: true},
{type: 'position' as const, id: 'match', position: {x: 24, y: 48}, dragging: true},
{
type: 'dimensions' as const,
id: 'match',
dimensions: { width: 176, height: 64 },
},
{ type: 'select' as const, id: 'match', selected: true },
{
type: 'position' as const,
id: 'match',
position: { x: 24, y: 48 },
dragging: true,
},
];
const nodes: WAFRuleGraph['nodes'] = [
{id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}},
{
id: 'match',
type: 'ip_match',
position: { x: 0, y: 0 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
},
];
expect(acceptedNodeChanges(nodes, changes)).toEqual({changes, persistent: false});
expect(acceptedNodeChanges(nodes, changes)).toEqual({
changes,
persistent: false,
});
});
});
describe('editor safety constraints', () => {
const graph: WAFRuleGraph = {schema_version: 1, nodes: [
{id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}},
{id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}},
{id: 'allow', type: 'allow', position: {x: 0, y: 0}, config: {}},
], edges: [{id: 'start-match', source: 'start', source_handle: 'next', target: 'match'}]};
const graph: WAFRuleGraph = {
schema_version: 1,
nodes: [
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, config: {} },
{
id: 'match',
type: 'ip_match',
position: { x: 0, y: 0 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
},
{ id: 'allow', type: 'allow', position: { x: 0, y: 0 }, config: {} },
],
edges: [
{
id: 'start-match',
source: 'start',
source_handle: 'next',
target: 'match',
},
],
};
it('protects start and allow from deletion', () => expect(filterRemovableNodeIds(graph.nodes, ['start', 'match', 'allow'])).toEqual(['match']));
it('protects start and allow from deletion', () =>
expect(
filterRemovableNodeIds(graph.nodes, ['start', 'match', 'allow']),
).toEqual(['match']));
it('does not persist a removal containing only protected nodes', () => {
expect(acceptedNodeChanges(graph.nodes, [{type: 'remove', id: 'start'}, {type: 'remove', id: 'allow'}])).toEqual({changes: [], persistent: false});
expect(
acceptedNodeChanges(graph.nodes, [
{ type: 'remove', id: 'start' },
{ type: 'remove', id: 'allow' },
]),
).toEqual({ changes: [], persistent: false });
});
it('rejects invalid and already-used source ports', () => {
expect(isConnectionAllowed(graph, {source: 'match', sourceHandle: 'next', target: 'allow'})).toBe(false);
expect(isConnectionAllowed(graph, {source: 'start', sourceHandle: 'next', target: 'allow'})).toBe(false);
expect(isConnectionAllowed(graph, {source: 'match', sourceHandle: 'true', target: 'allow'})).toBe(true);
expect(
isConnectionAllowed(graph, {
source: 'match',
sourceHandle: 'next',
target: 'allow',
}),
).toBe(false);
expect(
isConnectionAllowed(graph, {
source: 'start',
sourceHandle: 'next',
target: 'allow',
}),
).toBe(false);
expect(
isConnectionAllowed(graph, {
source: 'match',
sourceHandle: 'true',
target: 'allow',
}),
).toBe(true);
});
});
@@ -56,24 +140,73 @@ describe('server graph error targeting', () => {
const edges = ['start-match'];
it('uses explicit node and edge ids from nested payloads', () => {
expect(findGraphErrorTarget({details: {node_id: 'match-1'}}, nodes, edges)).toEqual({kind: 'node', id: 'match-1'});
expect(findGraphErrorTarget({details: {edgeId: 'start-match'}}, nodes, edges)).toEqual({kind: 'edge', id: 'start-match'});
expect(
findGraphErrorTarget({ details: { node_id: 'match-1' } }, nodes, edges),
).toEqual({ kind: 'node', id: 'match-1' });
expect(
findGraphErrorTarget(
{ details: { edgeId: 'start-match' } },
nodes,
edges,
),
).toEqual({ kind: 'edge', id: 'start-match' });
});
it('does not substring match unrelated error text', () => {
expect(findGraphErrorTarget({message: 'restart operation failed'}, nodes, edges)).toBeUndefined();
expect(
findGraphErrorTarget(
{ message: 'restart operation failed' },
nodes,
edges,
),
).toBeUndefined();
});
it('parses the real API envelope with strict ID boundaries', () => {
expect(findGraphErrorTarget({error_msg: '规则图无效: 节点 match-1 的 true 出口未连接', data: null}, nodes, edges)).toEqual({kind: 'node', id: 'match-1'});
expect(findGraphErrorTarget(new Error('规则图无效: 边 start-match 的目标节点不存在'), nodes, edges)).toEqual({kind: 'edge', id: 'start-match'});
expect(findGraphErrorTarget({error_msg: '节点 match-10 无效'}, nodes, edges)).toBeUndefined();
expect(findGraphErrorTarget({error_msg: '规则图无效: 边 ID start-match 重复'}, nodes, edges)).toEqual({kind: 'edge', id: 'start-match'});
expect(findGraphErrorTarget({error_msg: '边 ID start-matcher 重复'}, nodes, edges)).toBeUndefined();
expect(
findGraphErrorTarget(
{
error_msg: '规则图无效: 节点 match-1 的 true 出口未连接',
data: null,
},
nodes,
edges,
),
).toEqual({ kind: 'node', id: 'match-1' });
expect(
findGraphErrorTarget(
new Error('规则图无效: 边 start-match 的目标节点不存在'),
nodes,
edges,
),
).toEqual({ kind: 'edge', id: 'start-match' });
expect(
findGraphErrorTarget({ error_msg: '节点 match-10 无效' }, nodes, edges),
).toBeUndefined();
expect(
findGraphErrorTarget(
{ error_msg: '规则图无效: 边 ID start-match 重复' },
nodes,
edges,
),
).toEqual({ kind: 'edge', id: 'start-match' });
expect(
findGraphErrorTarget(
{ error_msg: '边 ID start-matcher 重复' },
nodes,
edges,
),
).toBeUndefined();
});
});
it('calculates deterministic Back and Forward restoration deltas', () => {
expect(getHistoryTransition(4, 3)).toEqual({direction: 'back', restoreDelta: 1});
expect(getHistoryTransition(4, 6)).toEqual({direction: 'forward', restoreDelta: -2});
expect(getHistoryTransition(4, 3)).toEqual({
direction: 'back',
restoreDelta: 1,
});
expect(getHistoryTransition(4, 6)).toEqual({
direction: 'forward',
restoreDelta: -2,
});
});
@@ -3,70 +3,155 @@ import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
import {wouldCreateCycle} from './graph-validation';
export type GraphErrorTarget = {kind: 'node' | 'edge'; id: string};
export type GraphErrorTarget = { kind: 'node' | 'edge'; id: string };
export function isPersistentNodeChange<NodeType extends Node = Node>(change: NodeChange<NodeType>): boolean {
return change.type === 'remove' || change.type === 'add' || change.type === 'replace' || (change.type === 'position' && change.dragging === false && Boolean(change.position));
export function isPersistentNodeChange<NodeType extends Node = Node>(
change: NodeChange<NodeType>,
): boolean {
return (
change.type === 'remove' ||
change.type === 'add' ||
change.type === 'replace' ||
(change.type === 'position' &&
change.dragging === false &&
Boolean(change.position))
);
}
export function isPersistentEdgeChange(change: EdgeChange): boolean {
return change.type === 'remove' || change.type === 'add' || change.type === 'replace';
return (
change.type === 'remove' ||
change.type === 'add' ||
change.type === 'replace'
);
}
export function filterRemovableNodeIds(nodes: WAFRuleNode[], ids: string[]): string[] {
return ids.filter((id) => !['start', 'allow'].includes(nodes.find((node) => node.id === id)?.type ?? ''));
export function filterRemovableNodeIds(
nodes: WAFRuleNode[],
ids: string[],
): string[] {
return ids.filter(
(id) =>
!['start', 'allow'].includes(
nodes.find((node) => node.id === id)?.type ?? '',
),
);
}
export function acceptedNodeChanges<NodeType extends Node = Node>(nodes: WAFRuleNode[], changes: NodeChange<NodeType>[]): {changes: NodeChange<NodeType>[]; persistent: boolean} {
const accepted = changes.filter((change) => change.type !== 'remove' || filterRemovableNodeIds(nodes, [change.id]).length === 1);
return {changes: accepted, persistent: accepted.some(isPersistentNodeChange)};
export function acceptedNodeChanges<NodeType extends Node = Node>(
nodes: WAFRuleNode[],
changes: NodeChange<NodeType>[],
): { changes: NodeChange<NodeType>[]; persistent: boolean } {
const accepted = changes.filter(
(change) =>
change.type !== 'remove' ||
filterRemovableNodeIds(nodes, [change.id]).length === 1,
);
return {
changes: accepted,
persistent: accepted.some(isPersistentNodeChange),
};
}
export function getHistoryTransition(current: number, target: number): {direction: 'back' | 'forward'; restoreDelta: number} {
return {direction: target < current ? 'back' : 'forward', restoreDelta: current - target};
export function getHistoryTransition(
current: number,
target: number,
): { direction: 'back' | 'forward'; restoreDelta: number } {
return {
direction: target < current ? 'back' : 'forward',
restoreDelta: current - target,
};
}
export function isConnectionAllowed(graph: WAFRuleGraph, connection: {source?: string | null; sourceHandle?: string | null; target?: string | null}): boolean {
if (!connection.source || !connection.target || !connection.sourceHandle) return false;
export function isConnectionAllowed(
graph: WAFRuleGraph,
connection: {
source?: string | null;
sourceHandle?: string | null;
target?: string | null;
},
): boolean {
if (!connection.source || !connection.target || !connection.sourceHandle)
return false;
const source = graph.nodes.find((node) => node.id === connection.source);
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next']};
return Boolean(source && (handles[source.type] ?? []).includes(connection.sourceHandle)) && !graph.edges.some((edge) => edge.source === connection.source && edge.source_handle === connection.sourceHandle) && !wouldCreateCycle(graph, connection.source, connection.target);
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
start: ['next'],
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
pow: ['next'],
};
return (
Boolean(
source && (handles[source.type] ?? []).includes(connection.sourceHandle),
) &&
!graph.edges.some(
(edge) =>
edge.source === connection.source &&
edge.source_handle === connection.sourceHandle,
) &&
!wouldCreateCycle(graph, connection.source, connection.target)
);
}
export function findGraphErrorTarget(payload: unknown, nodeIds: string[], edgeIds: string[]): GraphErrorTarget | undefined {
export function findGraphErrorTarget(
payload: unknown,
nodeIds: string[],
edgeIds: string[],
): GraphErrorTarget | undefined {
const found = collectIdFields(payload);
for (const {key, value} of found) {
if ((key === 'node_id' || key === 'nodeId') && nodeIds.includes(value)) return {kind: 'node', id: value};
if ((key === 'edge_id' || key === 'edgeId') && edgeIds.includes(value)) return {kind: 'edge', id: value};
for (const { key, value } of found) {
if ((key === 'node_id' || key === 'nodeId') && nodeIds.includes(value))
return { kind: 'node', id: value };
if ((key === 'edge_id' || key === 'edgeId') && edgeIds.includes(value))
return { kind: 'edge', id: value };
}
const messages = collectMessages(payload);
for (const message of messages) {
const nodeId = findMessageId(message, '节点', nodeIds);
if (nodeId) return {kind: 'node', id: nodeId};
if (nodeId) return { kind: 'node', id: nodeId };
const edgeId = findMessageId(message, '边', edgeIds);
if (edgeId) return {kind: 'edge', id: edgeId};
if (edgeId) return { kind: 'edge', id: edgeId };
}
return undefined;
}
function collectMessages(value: unknown): string[] {
if (value instanceof Error) return [value.message, ...collectMessages(value.cause)];
if (value instanceof Error)
return [value.message, ...collectMessages(value.cause)];
if (!value || typeof value !== 'object') return [];
return Object.entries(value).flatMap(([key, child]) => key === 'error_msg' || key === 'message' ? typeof child === 'string' ? [child] : [] : collectMessages(child));
return Object.entries(value).flatMap(([key, child]) =>
key === 'error_msg' || key === 'message'
? typeof child === 'string'
? [child]
: []
: collectMessages(child),
);
}
function findMessageId(message: string, prefix: '节点' | '边', ids: string[]): string | undefined {
function findMessageId(
message: string,
prefix: '节点' | '边',
ids: string[],
): string | undefined {
const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
return [...ids].sort((a, b) => b.length - a.length).find((id) => new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(message));
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
message,
),
);
}
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function collectIdFields(value: unknown): {key: string; value: string}[] {
function collectIdFields(value: unknown): { key: string; value: string }[] {
if (!value || typeof value !== 'object') return [];
const result: {key: string; value: string}[] = [];
const result: { key: string; value: string }[] = [];
for (const [key, child] of Object.entries(value)) {
if (typeof child === 'string') result.push({key, value: child});
if (typeof child === 'string') result.push({ key, value: child });
else result.push(...collectIdFields(child));
}
return result;
@@ -1,105 +1,265 @@
import {describe, expect, it} from 'vitest';
import { describe, expect, it } from 'vitest';
import type {WAFRuleGraph} from '@/lib/services/openflare';
import type { WAFRuleGraph } from '@/lib/services/openflare';
import {removeEdgeFromGraph, removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation';
import {
removeEdgeFromGraph,
removeNodeFromGraph,
validateGraph,
wouldCreateCycle,
} from './graph-validation';
const validGraph = (): WAFRuleGraph => ({
schema_version: 1,
nodes: [
{id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}},
{id: 'match', type: 'ip_match', position: {x: 240, y: 0}, config: {ips: ['127.0.0.1'], cidrs: [], ip_group_ids: []}},
{id: 'allow', type: 'allow', position: {x: 520, y: -80}, config: {}},
{id: 'block', type: 'block', position: {x: 520, y: 100}, config: {status_code: 403, response_body: ''}},
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, config: {} },
{
id: 'match',
type: 'ip_match',
position: { x: 240, y: 0 },
config: { ips: ['127.0.0.1'], cidrs: [], ip_group_ids: [] },
},
{ id: 'allow', type: 'allow', position: { x: 520, y: -80 }, config: {} },
{
id: 'block',
type: 'block',
position: { x: 520, y: 100 },
config: { status_code: 403, response_body: '' },
},
],
edges: [
{id: 'start-match', source: 'start', source_handle: 'next', target: 'match'},
{id: 'match-allow', source: 'match', source_handle: 'true', target: 'allow'},
{id: 'match-block', source: 'match', source_handle: 'false', target: 'block'},
{
id: 'start-match',
source: 'start',
source_handle: 'next',
target: 'match',
},
{
id: 'match-allow',
source: 'match',
source_handle: 'true',
target: 'allow',
},
{
id: 'match-block',
source: 'match',
source_handle: 'false',
target: 'block',
},
],
});
describe('validateGraph', () => {
it('accepts a complete terminating graph', () => expect(validateGraph(validGraph())).toEqual([]));
it('accepts a complete terminating graph', () =>
expect(validateGraph(validGraph())).toEqual([]));
it('requires exactly one start and allow node', () => {
const graph = validGraph();
graph.nodes = graph.nodes.filter((node) => node.type !== 'allow');
expect(validateGraph(graph).map((issue) => issue.code)).toContain('allow_count');
expect(validateGraph(graph).map((issue) => issue.code)).toContain(
'allow_count',
);
});
it('requires every source handle', () => {
const graph = validGraph();
graph.edges = graph.edges.filter((edge) => edge.source_handle !== 'false');
expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'missing_handle', nodeId: 'match'}));
expect(validateGraph(graph)).toContainEqual(
expect.objectContaining({ code: 'missing_handle', nodeId: 'match' }),
);
});
it('rejects cycles', () => {
const graph = validGraph();
graph.edges.push({id: 'cycle', source: 'block', source_handle: 'next', target: 'match'});
graph.edges.push({
id: 'cycle',
source: 'block',
source_handle: 'next',
target: 'match',
});
expect(validateGraph(graph).map((issue) => issue.code)).toContain('cycle');
});
it('reports unreachable nodes and paths without a terminal', () => {
const graph = validGraph();
graph.nodes.push({id: 'orphan', type: 'pow', position: {x: 0, y: 200}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'unreachable', nodeId: 'orphan'}),
expect.objectContaining({code: 'non_terminating', nodeId: 'orphan'}),
]));
graph.nodes.push({
id: 'orphan',
type: 'pow',
position: { x: 0, y: 200 },
config: {
algorithm: 'fast',
difficulty: 4,
session_ttl: 60,
challenge_ttl: 30,
},
});
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'unreachable', nodeId: 'orphan' }),
expect.objectContaining({ code: 'non_terminating', nodeId: 'orphan' }),
]),
);
});
it('rejects duplicate identifiers and start incoming edges', () => {
const graph = validGraph();
graph.nodes.push({...graph.nodes[1]});
graph.edges.push({...graph.edges[0]}, {id: 'into-start', source: 'match', source_handle: 'true', target: 'start'});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'duplicate_node_id', nodeId: 'match'}),
expect.objectContaining({code: 'duplicate_edge_id', edgeId: 'start-match'}),
expect.objectContaining({code: 'start_incoming', edgeId: 'into-start'}),
]));
graph.nodes.push({ ...graph.nodes[1] });
graph.edges.push(
{ ...graph.edges[0] },
{
id: 'into-start',
source: 'match',
source_handle: 'true',
target: 'start',
},
);
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'duplicate_node_id', nodeId: 'match' }),
expect.objectContaining({
code: 'duplicate_edge_id',
edgeId: 'start-match',
}),
expect.objectContaining({
code: 'start_incoming',
edgeId: 'into-start',
}),
]),
);
});
it('validates typed node configuration locally', () => {
const graph = validGraph();
graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['999.1.1.1'], cidrs: ['broken'], ip_group_ids: [-1]}} : node.type === 'block' ? {...node, config: {status_code: 200, response_body: 'x'.repeat(65_537)}} : node);
expect(validateGraph(graph).filter((issue) => issue.code === 'invalid_config').map((issue) => issue.nodeId)).toEqual(expect.arrayContaining(['match', 'block']));
graph.nodes = graph.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: {
ips: ['999.1.1.1'],
cidrs: ['broken'],
ip_group_ids: [-1],
},
}
: node.type === 'block'
? {
...node,
config: { status_code: 200, response_body: 'x'.repeat(65_537) },
}
: node,
);
expect(
validateGraph(graph)
.filter((issue) => issue.code === 'invalid_config')
.map((issue) => issue.nodeId),
).toEqual(expect.arrayContaining(['match', 'block']));
});
it('validates PoW bounds and geography codes', () => {
const graph = validGraph();
graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 0, session_ttl: 0, challenge_ttl: 0}});
graph.nodes.push({id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: ['china'], regions: ['']}});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}),
expect.objectContaining({code: 'invalid_config', nodeId: 'geo'}),
]));
graph.nodes.push({
id: 'pow',
type: 'pow',
position: { x: 0, y: 0 },
config: {
algorithm: 'fast',
difficulty: 0,
session_ttl: 0,
challenge_ttl: 0,
},
});
graph.nodes.push({
id: 'geo',
type: 'geo_match',
position: { x: 0, y: 0 },
config: { countries: ['china'], regions: [''] },
});
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_config', nodeId: 'pow' }),
expect.objectContaining({ code: 'invalid_config', nodeId: 'geo' }),
]),
);
});
it('rejects non-finite and fractional integer configuration', () => {
const graph = validGraph();
graph.nodes.push({id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4.5, session_ttl: Number.NaN, challenge_ttl: 30}});
graph.nodes.push({id: 'block-fraction', type: 'block', position: {x: 0, y: 0}, config: {status_code: 403.5, response_body: ''}});
expect(validateGraph(graph)).toEqual(expect.arrayContaining([
expect.objectContaining({code: 'invalid_config', nodeId: 'pow'}),
expect.objectContaining({code: 'invalid_config', nodeId: 'block-fraction'}),
]));
graph.nodes.push({
id: 'pow',
type: 'pow',
position: { x: 0, y: 0 },
config: {
algorithm: 'fast',
difficulty: 4.5,
session_ttl: Number.NaN,
challenge_ttl: 30,
},
});
graph.nodes.push({
id: 'block-fraction',
type: 'block',
position: { x: 0, y: 0 },
config: { status_code: 403.5, response_body: '' },
});
expect(validateGraph(graph)).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: 'invalid_config', nodeId: 'pow' }),
expect.objectContaining({
code: 'invalid_config',
nodeId: 'block-fraction',
}),
]),
);
});
it('matches server IP and prefix parsing semantics', () => {
const invalid = validGraph();
invalid.nodes = invalid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '::::'], cidrs: ['2001:db8::/32', '10.0.0.0/33'], ip_group_ids: []}} : node);
expect(validateGraph(invalid)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'}));
invalid.nodes = invalid.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: {
ips: ['2001:db8::1', '::::'],
cidrs: ['2001:db8::/32', '10.0.0.0/33'],
ip_group_ids: [],
},
}
: node,
);
expect(validateGraph(invalid)).toContainEqual(
expect.objectContaining({ code: 'invalid_config', nodeId: 'match' }),
);
const valid = validGraph();
valid.nodes = valid.nodes.map((node) => node.type === 'ip_match' ? {...node, config: {ips: ['2001:db8::1', '192.0.2.1'], cidrs: ['2001:db8::/32', '10.0.0.0/8'], ip_group_ids: []}} : node);
valid.nodes = valid.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: {
ips: ['2001:db8::1', '192.0.2.1'],
cidrs: ['2001:db8::/32', '10.0.0.0/8'],
ip_group_ids: [],
},
}
: node,
);
expect(validateGraph(valid)).toEqual([]);
});
it('requires exactly one CIDR slash and rejects scoped IPv6 addresses', () => {
for (const value of ['10.0.0.0/8/extra', 'fe80::1%en0', 'fe80::%en0/64']) {
const graph = validGraph();
graph.nodes = graph.nodes.map((node) => node.type === 'ip_match' ? {...node, config: value.includes('/') ? {ips: [], cidrs: [value], ip_group_ids: []} : {ips: [value], cidrs: [], ip_group_ids: []}} : node);
expect(validateGraph(graph)).toContainEqual(expect.objectContaining({code: 'invalid_config', nodeId: 'match'}));
graph.nodes = graph.nodes.map((node) =>
node.type === 'ip_match'
? {
...node,
config: value.includes('/')
? { ips: [], cidrs: [value], ip_group_ids: [] }
: { ips: [value], cidrs: [], ip_group_ids: [] },
}
: node,
);
expect(validateGraph(graph)).toContainEqual(
expect.objectContaining({ code: 'invalid_config', nodeId: 'match' }),
);
}
});
});
@@ -1,6 +1,21 @@
import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
export type GraphIssueCode = 'schema' | 'size_limit' | 'empty_id' | 'duplicate_node_id' | 'duplicate_edge_id' | 'start_count' | 'allow_count' | 'start_incoming' | 'missing_handle' | 'duplicate_handle' | 'invalid_edge' | 'invalid_config' | 'cycle' | 'unreachable' | 'non_terminating';
export type GraphIssueCode =
| 'schema'
| 'size_limit'
| 'empty_id'
| 'duplicate_node_id'
| 'duplicate_edge_id'
| 'start_count'
| 'allow_count'
| 'start_incoming'
| 'missing_handle'
| 'duplicate_handle'
| 'invalid_edge'
| 'invalid_config'
| 'cycle'
| 'unreachable'
| 'non_terminating';
export interface GraphIssue {
code: GraphIssueCode;
@@ -10,47 +25,117 @@ export interface GraphIssue {
}
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next'],
start: ['next'],
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
pow: ['next'],
};
export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
const issues: GraphIssue[] = [];
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
if (graph.schema_version !== 1) issues.push({code: 'schema', message: '规则图 schema_version 必须为 1'});
if (graph.nodes.length > 128 || graph.edges.length > 256 || new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024) issues.push({code: 'size_limit', message: '规则图超过大小限制'});
if (graph.schema_version !== 1)
issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' });
if (
graph.nodes.length > 128 ||
graph.edges.length > 256 ||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
)
issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
const nodeIds = new Set<string>();
for (const node of graph.nodes) {
if (!node.id.trim()) issues.push({code: 'empty_id', message: '节点 ID 不能为空', nodeId: node.id});
if (nodeIds.has(node.id)) issues.push({code: 'duplicate_node_id', message: `节点 ID ${node.id} 重复`, nodeId: node.id});
if (!node.id.trim())
issues.push({
code: 'empty_id',
message: '节点 ID 不能为空',
nodeId: node.id,
});
if (nodeIds.has(node.id))
issues.push({
code: 'duplicate_node_id',
message: `节点 ID ${node.id} 重复`,
nodeId: node.id,
});
nodeIds.add(node.id);
const configIssue = validateNodeConfig(node);
if (configIssue) issues.push({code: 'invalid_config', message: configIssue, nodeId: node.id});
if (configIssue)
issues.push({
code: 'invalid_config',
message: configIssue,
nodeId: node.id,
});
}
const edgeIds = new Set<string>();
for (const edge of graph.edges) {
if (!edge.id.trim()) issues.push({code: 'empty_id', message: '连线 ID 不能为空', edgeId: edge.id});
if (edgeIds.has(edge.id)) issues.push({code: 'duplicate_edge_id', message: `连线 ID ${edge.id} 重复`, edgeId: edge.id});
if (!edge.id.trim())
issues.push({
code: 'empty_id',
message: '连线 ID 不能为空',
edgeId: edge.id,
});
if (edgeIds.has(edge.id))
issues.push({
code: 'duplicate_edge_id',
message: `连线 ID ${edge.id} 重复`,
edgeId: edge.id,
});
edgeIds.add(edge.id);
if (nodeMap.get(edge.target)?.type === 'start') issues.push({code: 'start_incoming', message: '开始节点不能有入边', edgeId: edge.id, nodeId: edge.target});
if (nodeMap.get(edge.target)?.type === 'start')
issues.push({
code: 'start_incoming',
message: '开始节点不能有入边',
edgeId: edge.id,
nodeId: edge.target,
});
}
if (graph.nodes.filter((node) => node.type === 'start').length !== 1) issues.push({code: 'start_count', message: '规则图必须恰好有一个开始节点'});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1) issues.push({code: 'allow_count', message: '规则图必须恰好有一个通过节点'});
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
issues.push({
code: 'start_count',
message: '规则图必须恰好有一个开始节点',
});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
issues.push({
code: 'allow_count',
message: '规则图必须恰好有一个通过节点',
});
for (const edge of graph.edges) {
const source = nodeMap.get(edge.source);
if (!source || !nodeMap.has(edge.target) || !(handles[source.type] ?? []).includes(edge.source_handle)) {
issues.push({code: 'invalid_edge', message: `连线 ${edge.id} 的端点或出口无效`, edgeId: edge.id});
if (
!source ||
!nodeMap.has(edge.target) ||
!(handles[source.type] ?? []).includes(edge.source_handle)
) {
issues.push({
code: 'invalid_edge',
message: `连线 ${edge.id} 的端点或出口无效`,
edgeId: edge.id,
});
}
}
for (const node of graph.nodes) {
for (const handle of handles[node.type] ?? []) {
const outgoing = graph.edges.filter((edge) => edge.source === node.id && edge.source_handle === handle);
if (outgoing.length === 0) issues.push({code: 'missing_handle', message: `节点 ${node.id} 的 ${handle} 出口未连接`, nodeId: node.id});
if (outgoing.length > 1) issues.push({code: 'duplicate_handle', message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`, nodeId: node.id});
const outgoing = graph.edges.filter(
(edge) => edge.source === node.id && edge.source_handle === handle,
);
if (outgoing.length === 0)
issues.push({
code: 'missing_handle',
message: `节点 ${node.id} 的 ${handle} 出口未连接`,
nodeId: node.id,
});
if (outgoing.length > 1)
issues.push({
code: 'duplicate_handle',
message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
nodeId: node.id,
});
}
}
const adjacency = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
const adjacency = new Map(
graph.nodes.map((node) => [node.id, [] as string[]]),
);
const reverse = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
for (const edge of graph.edges) {
adjacency.get(edge.source)?.push(edge.target);
@@ -58,39 +143,110 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
}
const start = graph.nodes.find((node) => node.type === 'start');
const reachable = walk(start ? [start.id] : [], adjacency);
for (const node of graph.nodes) if (!reachable.has(node.id)) issues.push({code: 'unreachable', message: `节点 ${node.id} 无法从开始节点到达`, nodeId: node.id});
const terminals = graph.nodes.filter((node) => node.type === 'allow' || node.type === 'block').map((node) => node.id);
for (const node of graph.nodes)
if (!reachable.has(node.id))
issues.push({
code: 'unreachable',
message: `节点 ${node.id} 无法从开始节点到达`,
nodeId: node.id,
});
const terminals = graph.nodes
.filter((node) => node.type === 'allow' || node.type === 'block')
.map((node) => node.id);
const canTerminate = walk(terminals, reverse);
for (const node of graph.nodes) if (!canTerminate.has(node.id)) issues.push({code: 'non_terminating', message: `节点 ${node.id} 无法抵达终止节点`, nodeId: node.id});
if (hasCycle(graph)) issues.push({code: 'cycle', message: '规则图不能包含循环'});
for (const node of graph.nodes)
if (!canTerminate.has(node.id))
issues.push({
code: 'non_terminating',
message: `节点 ${node.id} 无法抵达终止节点`,
nodeId: node.id,
});
if (hasCycle(graph))
issues.push({ code: 'cycle', message: '规则图不能包含循环' });
return issues;
}
function validateNodeConfig(node: WAFRuleNode): string | undefined {
if (node.type === 'ip_match') {
if (node.config.ips.some((value) => !isIP(value))) return `节点 ${node.id} 包含无效 IP`;
if (node.config.cidrs.some((value) => { const parts = value.split('/'); if (parts.length !== 2) return true; const [ip, bits] = parts; return !isIP(ip) || !/^\d+$/.test(bits) || Number(bits) > (ip.includes(':') ? 128 : 32); })) return `节点 ${node.id} 包含无效 CIDR`;
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0)) return `节点 ${node.id} 包含无效 IP 组`;
if (node.config.ips.some((value) => !isIP(value)))
return `节点 ${node.id} 包含无效 IP`;
if (
node.config.cidrs.some((value) => {
const parts = value.split('/');
if (parts.length !== 2) return true;
const [ip, bits] = parts;
return (
!isIP(ip) ||
!/^\d+$/.test(bits) ||
Number(bits) > (ip.includes(':') ? 128 : 32)
);
})
)
return `节点 ${node.id} 包含无效 CIDR`;
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
return `节点 ${node.id} 包含无效 IP 组`;
}
if (node.type === 'geo_match' && (node.config.countries.some((code) => !/^[A-Z]{2}$/.test(code)) || node.config.regions.some((code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code)))) return `节点 ${node.id} 包含无效地域代码`;
if (node.type === 'pow' && (!['fast', 'slow'].includes(node.config.algorithm) || !isIntegerInRange(node.config.difficulty, 1, 16) || !isIntegerInRange(node.config.session_ttl, 60) || !isIntegerInRange(node.config.challenge_ttl, 30))) return `节点 ${node.id} 的 PoW 配置超出范围`;
if (node.type === 'block' && (!isIntegerInRange(node.config.status_code, 400, 599) || new TextEncoder().encode(node.config.response_body).length > 16 * 1024)) return `节点 ${node.id} 的阻止响应配置无效`;
if (
node.type === 'geo_match' &&
(node.config.countries.some((code) => !/^[A-Z]{2}$/.test(code)) ||
node.config.regions.some(
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
))
)
return `节点 ${node.id} 包含无效地域代码`;
if (
node.type === 'pow' &&
(!['fast', 'slow'].includes(node.config.algorithm) ||
!isIntegerInRange(node.config.difficulty, 1, 16) ||
!isIntegerInRange(node.config.session_ttl, 60) ||
!isIntegerInRange(node.config.challenge_ttl, 30))
)
return `节点 ${node.id} 的 PoW 配置超出范围`;
if (
node.type === 'block' &&
(!isIntegerInRange(node.config.status_code, 400, 599) ||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
return `节点 ${node.id} 的阻止响应配置无效`;
return undefined;
}
function isIntegerInRange(value: number, min: number, max = Number.MAX_SAFE_INTEGER): boolean { return Number.isFinite(value) && Number.isInteger(value) && value >= min && value <= max; }
function isIntegerInRange(
value: number,
min: number,
max = Number.MAX_SAFE_INTEGER,
): boolean {
return (
Number.isFinite(value) &&
Number.isInteger(value) &&
value >= min &&
value <= max
);
}
function isIP(value: string): boolean {
if (value.includes(':')) return isIPv6(value);
const parts = value.split('.');
return parts.length === 4 && parts.every((part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255);
return (
parts.length === 4 &&
parts.every(
(part) => /^(0|[1-9]\d{0,2})$/.test(part) && Number(part) <= 255,
)
);
}
function isIPv6(value: string): boolean {
if (!/^[0-9a-f:.]+$/i.test(value) || value.includes(':::') || value.split('::').length > 2) return false;
if (
!/^[0-9a-f:.]+$/i.test(value) ||
value.includes(':::') ||
value.split('::').length > 2
)
return false;
const compressed = value.includes('::');
const sections = value.split('::');
const groups = sections.flatMap((section) => section ? section.split(':') : []);
const groups = sections.flatMap((section) =>
section ? section.split(':') : [],
);
let units = 0;
for (let index = 0; index < groups.length; index++) {
const group = groups[index];
@@ -119,8 +275,12 @@ function walk(seeds: string[], links: Map<string, string[]>): Set<string> {
function hasCycle(graph: WAFRuleGraph): boolean {
const indegree = new Map(graph.nodes.map((node) => [node.id, 0]));
for (const edge of graph.edges) if (indegree.has(edge.target) && indegree.has(edge.source)) indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
const queue = [...indegree].filter(([, degree]) => degree === 0).map(([id]) => id);
for (const edge of graph.edges)
if (indegree.has(edge.target) && indegree.has(edge.source))
indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1);
const queue = [...indegree]
.filter(([, degree]) => degree === 0)
.map(([id]) => id);
let visited = 0;
while (queue.length) {
const id = queue.shift()!;
@@ -134,17 +294,35 @@ function hasCycle(graph: WAFRuleGraph): boolean {
return visited !== graph.nodes.length;
}
export function wouldCreateCycle(graph: WAFRuleGraph, source: string, target: string): boolean {
export function wouldCreateCycle(
graph: WAFRuleGraph,
source: string,
target: string,
): boolean {
if (source === target) return true;
const adjacency = new Map(graph.nodes.map((node) => [node.id, [] as string[]]));
const adjacency = new Map(
graph.nodes.map((node) => [node.id, [] as string[]]),
);
for (const edge of graph.edges) adjacency.get(edge.source)?.push(edge.target);
return walk([target], adjacency).has(source);
}
export function removeNodeFromGraph(graph: WAFRuleGraph, nodeId: string): WAFRuleGraph {
return {...graph, nodes: graph.nodes.filter((node) => node.id !== nodeId), edges: graph.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)};
export function removeNodeFromGraph(
graph: WAFRuleGraph,
nodeId: string,
): WAFRuleGraph {
return {
...graph,
nodes: graph.nodes.filter((node) => node.id !== nodeId),
edges: graph.edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId,
),
};
}
export function removeEdgeFromGraph(graph: WAFRuleGraph, edgeId: string): WAFRuleGraph {
return {...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId)};
export function removeEdgeFromGraph(
graph: WAFRuleGraph,
edgeId: string,
): WAFRuleGraph {
return { ...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId) };
}
@@ -1,14 +1,33 @@
import {Ban, Fingerprint, Globe2, Plus, ShieldCheck} from 'lucide-react';
import { Ban, Fingerprint, Globe2, Plus, ShieldCheck } from 'lucide-react';
import {Button} from '@/components/ui/button';
import type {WAFRuleNode} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import type { WAFRuleNode } from '@/lib/services/openflare';
type AddableType = Extract<WAFRuleNode['type'], 'ip_match' | 'geo_match' | 'pow' | 'block'>;
type AddableType = Extract<
WAFRuleNode['type'],
'ip_match' | 'geo_match' | 'pow' | 'block'
>;
const items = [
{type: 'ip_match', label: 'IP 匹配', icon: Fingerprint}, {type: 'geo_match', label: '地域匹配', icon: Globe2},
{type: 'pow', label: 'PoW 挑战', icon: ShieldCheck}, {type: 'block', label: '阻止', icon: Ban},
] satisfies {type: AddableType; label: string; icon: typeof Plus}[];
{ type: 'ip_match', label: 'IP 匹配', icon: Fingerprint },
{ type: 'geo_match', label: '地域匹配', icon: Globe2 },
{ type: 'pow', label: 'PoW 挑战', icon: ShieldCheck },
{ type: 'block', label: '阻止', icon: Ban },
] satisfies { type: AddableType; label: string; icon: typeof Plus }[];
export function NodeLibrary({onAdd}: {onAdd: (type: AddableType) => void}) {
return <div className="flex items-center gap-2">{items.map(({type, label, icon: Icon}) => <Button key={type} variant="outline" size="sm" onClick={() => onAdd(type)}><Icon data-icon="inline-start" />{label}</Button>)}</div>;
export function NodeLibrary({ onAdd }: { onAdd: (type: AddableType) => void }) {
return (
<div className='flex items-center gap-2'>
{items.map(({ type, label, icon: Icon }) => (
<Button
key={type}
variant='outline'
size='sm'
onClick={() => onAdd(type)}
>
<Icon data-icon='inline-start' />
{label}
</Button>
))}
</div>
);
}
@@ -1,34 +1,64 @@
import {fireEvent, render, screen} from '@testing-library/react';
import {expect, it, vi} from 'vitest';
import { fireEvent, render, screen } from '@testing-library/react';
import { expect, it, vi } from 'vitest';
import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare';
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
import {NodeProperties} from './node-properties';
import { NodeProperties } from './node-properties';
it('edits IP group config through a typed multi-select', async () => {
const node: WAFRuleNode = {id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}};
const group = {id: 7, name: '办公室出口'} as WAFIPGroup;
const node: WAFRuleNode = {
id: 'match',
type: 'ip_match',
position: { x: 0, y: 0 },
config: { ips: [], cidrs: [], ip_group_ids: [] },
};
const group = { id: 7, name: '办公室出口' } as WAFIPGroup;
const onChange = vi.fn();
render(<NodeProperties node={node} ipGroups={[group]} onChange={onChange}/>);
fireEvent.click(screen.getByRole('button', {name: 'IP 组'}));
render(<NodeProperties node={node} ipGroups={[group]} onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: 'IP 组' }));
fireEvent.click(await screen.findByText('办公室出口'));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({config: expect.objectContaining({ip_group_ids: [7]})}));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ ip_group_ids: [7] }),
}),
);
});
it('associates numeric property labels and constrains server ranges', () => {
const node: WAFRuleNode = {id: 'pow', type: 'pow', position: {x: 0, y: 0}, config: {algorithm: 'fast', difficulty: 4, session_ttl: 60, challenge_ttl: 30}};
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()}/>);
const node: WAFRuleNode = {
id: 'pow',
type: 'pow',
position: { x: 0, y: 0 },
config: {
algorithm: 'fast',
difficulty: 4,
session_ttl: 60,
challenge_ttl: 30,
},
};
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
expect(screen.getByLabelText('难度')).toHaveAttribute('min', '1');
expect(screen.getByLabelText('难度')).toHaveAttribute('max', '16');
expect(screen.getByLabelText('会话 TTL(秒)')).toHaveAttribute('min', '60');
});
it('creates any normalized valid geography code', async () => {
const node: WAFRuleNode = {id: 'geo', type: 'geo_match', position: {x: 0, y: 0}, config: {countries: [], regions: []}};
const node: WAFRuleNode = {
id: 'geo',
type: 'geo_match',
position: { x: 0, y: 0 },
config: { countries: [], regions: [] },
};
const onChange = vi.fn();
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange}/>);
fireEvent.click(screen.getByRole('button', {name: '国家代码'}));
fireEvent.change(await screen.findByPlaceholderText('输入代码并添加'), {target: {value: 'nz'}});
fireEvent.click(screen.getByRole('button', {name: '添加代码'}));
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({config: expect.objectContaining({countries: ['NZ']})}));
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
fireEvent.click(screen.getByRole('button', { name: '国家代码' }));
fireEvent.change(await screen.findByPlaceholderText('输入代码并添加'), {
target: { value: 'nz' },
});
fireEvent.click(screen.getByRole('button', { name: '添加代码' }));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ countries: ['NZ'] }),
}),
);
});
@@ -1,39 +1,387 @@
import {Settings2} from 'lucide-react';
import {useState} from 'react';
import { Settings2 } from 'lucide-react';
import { useState } from 'react';
import {Button} from '@/components/ui/button';
import {Checkbox} from '@/components/ui/checkbox';
import {Field, FieldDescription, FieldGroup, FieldLabel} from '@/components/ui/field';
import {Input} from '@/components/ui/input';
import {Popover, PopoverContent, PopoverTrigger} from '@/components/ui/popover';
import {ScrollArea} from '@/components/ui/scroll-area';
import {Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Separator} from '@/components/ui/separator';
import {Textarea} from '@/components/ui/textarea';
import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Field,
FieldDescription,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
const countries = ['CN', 'US', 'JP', 'SG', 'DE', 'FR', 'GB', 'CA', 'AU', 'BR', 'IN', 'KR'].map((value) => ({value, label: value}));
const regions = ['CN-BJ', 'CN-SH', 'CN-GD', 'CN-ZJ', 'US-CA', 'US-NY', 'US-TX', 'JP-13', 'DE-BE', 'GB-ENG'].map((value) => ({value, label: value}));
const countries = [
'CN',
'US',
'JP',
'SG',
'DE',
'FR',
'GB',
'CA',
'AU',
'BR',
'IN',
'KR',
].map((value) => ({ value, label: value }));
const regions = [
'CN-BJ',
'CN-SH',
'CN-GD',
'CN-ZJ',
'US-CA',
'US-NY',
'US-TX',
'JP-13',
'DE-BE',
'GB-ENG',
].map((value) => ({ value, label: value }));
export function NodeProperties({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) {
return <aside className="w-80 shrink-0 border-l bg-card"><ScrollArea className="h-full"><div className="flex flex-col gap-5 p-5"><div className="flex items-center gap-2"><Settings2 className="size-5 text-primary"/><div><h2 className="text-sm font-semibold">节点属性</h2><p className="text-xs text-muted-foreground">配置当前处理单元</p></div></div><Separator /><PropertyFields node={node} ipGroups={ipGroups} onChange={onChange}/></div></ScrollArea></aside>;
export function NodeProperties({
node,
ipGroups,
onChange,
}: {
node: WAFRuleNode;
ipGroups: WAFIPGroup[];
onChange: (node: WAFRuleNode) => void;
}) {
return (
<aside className='w-80 shrink-0 border-l bg-card'>
<ScrollArea className='h-full'>
<div className='flex flex-col gap-5 p-5'>
<div className='flex items-center gap-2'>
<Settings2 className='size-5 text-primary' />
<div>
<h2 className='text-sm font-semibold'>节点属性</h2>
<p className='text-xs text-muted-foreground'>配置当前处理单元</p>
</div>
</div>
<Separator />
<PropertyFields node={node} ipGroups={ipGroups} onChange={onChange} />
</div>
</ScrollArea>
</aside>
);
}
function PropertyFields({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) {
if (node.type === 'start' || node.type === 'allow') return <p className="text-sm text-muted-foreground">系统节点无需配置。</p>;
if (node.type === 'ip_match') return <FieldGroup><CsvField id={`${node.id}-ips`} label="IP 地址" value={node.config.ips} onChange={(ips) => onChange({...node, config: {...node.config, ips}})}/><CsvField id={`${node.id}-cidrs`} label="CIDR 网段" value={node.config.cidrs} onChange={(cidrs) => onChange({...node, config: {...node.config, cidrs}})}/><MultiSelect id={`${node.id}-groups`} label="IP 组" options={ipGroups.map((group) => ({value: String(group.id), label: group.name}))} value={node.config.ip_group_ids.map(String)} onChange={(values) => onChange({...node, config: {...node.config, ip_group_ids: values.map(Number)}})}/></FieldGroup>;
if (node.type === 'geo_match') return <FieldGroup><MultiSelect id={`${node.id}-countries`} label="国家代码" options={countries} value={node.config.countries} creatablePattern={/^[A-Z]{2}$/} onChange={(countries) => onChange({...node, config: {...node.config, countries}})}/><MultiSelect id={`${node.id}-regions`} label="地区代码" options={regions} value={node.config.regions} creatablePattern={/^[A-Z]{2}-[A-Z0-9]{1,3}$/} onChange={(regions) => onChange({...node, config: {...node.config, regions}})}/></FieldGroup>;
if (node.type === 'pow') return <FieldGroup><Field><FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel><Select value={node.config.algorithm} onValueChange={(algorithm: 'fast' | 'slow') => onChange({...node, config: {...node.config, algorithm}})}><SelectTrigger id={`${node.id}-algorithm`} className="w-full"><SelectValue/></SelectTrigger><SelectContent><SelectGroup><SelectItem value="fast">快速</SelectItem><SelectItem value="slow">稳健</SelectItem></SelectGroup></SelectContent></Select></Field>{(['difficulty', 'session_ttl', 'challenge_ttl'] as const).map((key) => <NumberField id={`${node.id}-${key}`} key={key} min={{difficulty: 1, session_ttl: 60, challenge_ttl: 30}[key]} max={key === 'difficulty' ? 16 : undefined} label={{difficulty: '难度', session_ttl: '会话 TTL(秒)', challenge_ttl: '挑战 TTL(秒)'}[key]} value={node.config[key]} onChange={(value) => onChange({...node, config: {...node.config, [key]: value}})}/>)}</FieldGroup>;
return <FieldGroup><NumberField id={`${node.id}-status`} min={400} max={599} label="HTTP 状态码" value={node.config.status_code} onChange={(status_code) => onChange({...node, config: {...node.config, status_code}})}/><Field><FieldLabel htmlFor={`${node.id}-body`}>HTML 响应体</FieldLabel><Textarea id={`${node.id}-body`} rows={9} value={node.config.response_body} onChange={(event) => onChange({...node, config: {...node.config, response_body: event.target.value}})}/><FieldDescription>{new TextEncoder().encode(node.config.response_body).length} / 16384 字节</FieldDescription></Field></FieldGroup>;
function PropertyFields({
node,
ipGroups,
onChange,
}: {
node: WAFRuleNode;
ipGroups: WAFIPGroup[];
onChange: (node: WAFRuleNode) => void;
}) {
if (node.type === 'start' || node.type === 'allow')
return <p className='text-sm text-muted-foreground'>系统节点无需配置。</p>;
if (node.type === 'ip_match')
return (
<FieldGroup>
<CsvField
id={`${node.id}-ips`}
label='IP 地址'
value={node.config.ips}
onChange={(ips) =>
onChange({ ...node, config: { ...node.config, ips } })
}
/>
<CsvField
id={`${node.id}-cidrs`}
label='CIDR 网段'
value={node.config.cidrs}
onChange={(cidrs) =>
onChange({ ...node, config: { ...node.config, cidrs } })
}
/>
<MultiSelect
id={`${node.id}-groups`}
label='IP 组'
options={ipGroups.map((group) => ({
value: String(group.id),
label: group.name,
}))}
value={node.config.ip_group_ids.map(String)}
onChange={(values) =>
onChange({
...node,
config: { ...node.config, ip_group_ids: values.map(Number) },
})
}
/>
</FieldGroup>
);
if (node.type === 'geo_match')
return (
<FieldGroup>
<MultiSelect
id={`${node.id}-countries`}
label='国家代码'
options={countries}
value={node.config.countries}
creatablePattern={/^[A-Z]{2}$/}
onChange={(countries) =>
onChange({ ...node, config: { ...node.config, countries } })
}
/>
<MultiSelect
id={`${node.id}-regions`}
label='地区代码'
options={regions}
value={node.config.regions}
creatablePattern={/^[A-Z]{2}-[A-Z0-9]{1,3}$/}
onChange={(regions) =>
onChange({ ...node, config: { ...node.config, regions } })
}
/>
</FieldGroup>
);
if (node.type === 'pow')
return (
<FieldGroup>
<Field>
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
<Select
value={node.config.algorithm}
onValueChange={(algorithm: 'fast' | 'slow') =>
onChange({ ...node, config: { ...node.config, algorithm } })
}
>
<SelectTrigger id={`${node.id}-algorithm`} className='w-full'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='fast'>快速</SelectItem>
<SelectItem value='slow'>稳健</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</Field>
{(['difficulty', 'session_ttl', 'challenge_ttl'] as const).map(
(key) => (
<NumberField
id={`${node.id}-${key}`}
key={key}
min={{ difficulty: 1, session_ttl: 60, challenge_ttl: 30 }[key]}
max={key === 'difficulty' ? 16 : undefined}
label={
{
difficulty: '难度',
session_ttl: '会话 TTL(秒)',
challenge_ttl: '挑战 TTL(秒)',
}[key]
}
value={node.config[key]}
onChange={(value) =>
onChange({ ...node, config: { ...node.config, [key]: value } })
}
/>
),
)}
</FieldGroup>
);
return (
<FieldGroup>
<NumberField
id={`${node.id}-status`}
min={400}
max={599}
label='HTTP 状态码'
value={node.config.status_code}
onChange={(status_code) =>
onChange({ ...node, config: { ...node.config, status_code } })
}
/>
<Field>
<FieldLabel htmlFor={`${node.id}-body`}>HTML 响应体</FieldLabel>
<Textarea
id={`${node.id}-body`}
rows={9}
value={node.config.response_body}
onChange={(event) =>
onChange({
...node,
config: { ...node.config, response_body: event.target.value },
})
}
/>
<FieldDescription>
{new TextEncoder().encode(node.config.response_body).length} / 16384
字节
</FieldDescription>
</Field>
</FieldGroup>
);
}
function CsvField({id, label, value, onChange}: {id: string; label: string; value: string[]; onChange: (value: string[]) => void}) { return <Field><FieldLabel htmlFor={id}>{label}</FieldLabel><Textarea id={id} value={value.join('\n')} onChange={(event) => onChange(event.target.value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))}/><FieldDescription>每行一个值</FieldDescription></Field>; }
function NumberField({id, label, value, min, max, onChange}: {id: string; label: string; value: number; min?: number; max?: number; onChange: (value: number) => void}) { return <Field><FieldLabel htmlFor={id}>{label}</FieldLabel><Input id={id} min={min} max={max} type="number" value={value} onChange={(event) => onChange(Number(event.target.value))}/></Field>; }
function CsvField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: string[];
onChange: (value: string[]) => void;
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Textarea
id={id}
value={value.join('\n')}
onChange={(event) =>
onChange(
event.target.value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean),
)
}
/>
<FieldDescription>每行一个值</FieldDescription>
</Field>
);
}
function NumberField({
id,
label,
value,
min,
max,
onChange,
}: {
id: string;
label: string;
value: number;
min?: number;
max?: number;
onChange: (value: number) => void;
}) {
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Input
id={id}
min={min}
max={max}
type='number'
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
</Field>
);
}
function MultiSelect({id, label, options, value, creatablePattern, onChange}: {id: string; label: string; options: {value: string; label: string}[]; value: string[]; creatablePattern?: RegExp; onChange: (value: string[]) => void}) {
function MultiSelect({
id,
label,
options,
value,
creatablePattern,
onChange,
}: {
id: string;
label: string;
options: { value: string; label: string }[];
value: string[];
creatablePattern?: RegExp;
onChange: (value: string[]) => void;
}) {
const [draft, setDraft] = useState('');
const normalized = draft.trim().toUpperCase();
const visible = [...options, ...value.filter((selected) => !options.some((option) => option.value === selected)).map((selected) => ({value: selected, label: selected}))];
const canCreate = Boolean(creatablePattern?.test(normalized) && !value.includes(normalized));
return <Field><FieldLabel htmlFor={id}>{label}</FieldLabel><Popover><PopoverTrigger asChild><Button id={id} variant="outline" className="w-full justify-start">{value.length ? `已选择 ${value.length} 项` : '请选择'}</Button></PopoverTrigger><PopoverContent align="start" className="flex max-h-64 flex-col gap-2 overflow-y-auto">{creatablePattern && <div className="flex gap-2"><Input aria-label={`新建${label}`} placeholder="输入代码并添加" value={draft} onChange={(event) => setDraft(event.target.value)}/><Button size="sm" disabled={!canCreate} onClick={() => { onChange([...value, normalized]); setDraft(''); }}>添加代码</Button></div>}{visible.length === 0 ? <p className="text-sm text-muted-foreground">暂无可选项</p> : visible.map((option) => <label key={option.value} className="flex cursor-pointer items-center gap-2 text-sm"><Checkbox checked={value.includes(option.value)} onCheckedChange={(checked) => onChange(checked ? [...value, option.value] : value.filter((item) => item !== option.value))}/><span>{option.label}</span><span className="ml-auto font-mono text-xs text-muted-foreground">{option.value}</span></label>)}</PopoverContent></Popover></Field>;
const visible = [
...options,
...value
.filter(
(selected) => !options.some((option) => option.value === selected),
)
.map((selected) => ({ value: selected, label: selected })),
];
const canCreate = Boolean(
creatablePattern?.test(normalized) && !value.includes(normalized),
);
return (
<Field>
<FieldLabel htmlFor={id}>{label}</FieldLabel>
<Popover>
<PopoverTrigger asChild>
<Button id={id} variant='outline' className='w-full justify-start'>
{value.length ? `已选择 ${value.length} 项` : '请选择'}
</Button>
</PopoverTrigger>
<PopoverContent
align='start'
className='flex max-h-64 flex-col gap-2 overflow-y-auto'
>
{creatablePattern && (
<div className='flex gap-2'>
<Input
aria-label={`新建${label}`}
placeholder='输入代码并添加'
value={draft}
onChange={(event) => setDraft(event.target.value)}
/>
<Button
size='sm'
disabled={!canCreate}
onClick={() => {
onChange([...value, normalized]);
setDraft('');
}}
>
添加代码
</Button>
</div>
)}
{visible.length === 0 ? (
<p className='text-sm text-muted-foreground'>暂无可选项</p>
) : (
visible.map((option) => (
<label
key={option.value}
className='flex cursor-pointer items-center gap-2 text-sm'
>
<Checkbox
checked={value.includes(option.value)}
onCheckedChange={(checked) =>
onChange(
checked
? [...value, option.value]
: value.filter((item) => item !== option.value),
)
}
/>
<span>{option.label}</span>
<span className='ml-auto font-mono text-xs text-muted-foreground'>
{option.value}
</span>
</label>
))
)}
</PopoverContent>
</Popover>
</Field>
);
}
@@ -1,6 +1,6 @@
'use client';
import {useCallback, useEffect, useRef} from 'react';
import { useCallback, useEffect, useRef } from 'react';
import {
addEdge,
applyEdgeChanges,
@@ -17,11 +17,15 @@ import {
type NodeChange,
type ReactFlowInstance,
} from '@xyflow/react';
import {Trash2} from 'lucide-react';
import { Trash2 } from 'lucide-react';
import '@xyflow/react/dist/style.css';
import {Button} from '@/components/ui/button';
import type {WAFRuleEdge, WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import type {
WAFRuleEdge,
WAFRuleGraph,
WAFRuleNode,
} from '@/lib/services/openflare';
import {
acceptedNodeChanges,
@@ -30,11 +34,15 @@ import {
isConnectionAllowed,
isPersistentEdgeChange,
} from './editor-behavior';
import {type GraphIssue, removeEdgeFromGraph, removeNodeFromGraph} from './graph-validation';
import {NodeLibrary} from './node-library';
import {RuleNode, type RuleFlowNodeData} from './rule-node';
import {
type GraphIssue,
removeEdgeFromGraph,
removeNodeFromGraph,
} from './graph-validation';
import { NodeLibrary } from './node-library';
import { RuleNode, type RuleFlowNodeData } from './rule-node';
const nodeTypes = {rule: RuleNode};
const nodeTypes = { rule: RuleNode };
type FlowNode = Node<RuleFlowNodeData>;
type FlowEdge = Edge;
@@ -61,119 +69,228 @@ export function RuleFlowCanvas({
onSelectEdge,
}: RuleFlowCanvasProps) {
const instance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
const [nodes, setNodes, applyFlowNodeChanges] = useNodesState<FlowNode>(buildFlowNodes(graph, issues, selectedId));
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(buildFlowEdges(graph, selectedId, selectedEdgeId));
const [nodes, setNodes, applyFlowNodeChanges] = useNodesState<FlowNode>(
buildFlowNodes(graph, issues, selectedId),
);
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(
buildFlowEdges(graph, selectedId, selectedEdgeId),
);
useEffect(() => {
setNodes((current) => buildFlowNodes(graph, issues, selectedId, current));
}, [graph, issues, selectedId, setNodes]);
useEffect(() => {
setEdges((current) => buildFlowEdges(graph, selectedId, selectedEdgeId, current));
setEdges((current) =>
buildFlowEdges(graph, selectedId, selectedEdgeId, current),
);
}, [graph, selectedEdgeId, selectedId, setEdges]);
useEffect(() => {
if (!focusTarget || !instance.current) return;
if (focusTarget.kind === 'node') {
void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4});
void instance.current.fitView({
nodes: [{ id: focusTarget.id }],
duration: 350,
maxZoom: 1.4,
});
return;
}
const edge = graph.edges.find((item) => item.id === focusTarget.id);
if (edge) void instance.current.fitView({nodes: [{id: edge.source}, {id: edge.target}], duration: 350, maxZoom: 1.4});
if (edge)
void instance.current.fitView({
nodes: [{ id: edge.source }, { id: edge.target }],
duration: 350,
maxZoom: 1.4,
});
}, [focusTarget, graph.edges]);
const onNodesChange = useCallback((changes: NodeChange<FlowNode>[]) => {
const accepted = acceptedNodeChanges(graph.nodes, changes);
if (accepted.changes.length === 0) return;
const onNodesChange = useCallback(
(changes: NodeChange<FlowNode>[]) => {
const accepted = acceptedNodeChanges(graph.nodes, changes);
if (accepted.changes.length === 0) return;
applyFlowNodeChanges(accepted.changes);
if (!accepted.persistent) return;
applyFlowNodeChanges(accepted.changes);
if (!accepted.persistent) return;
const removals = filterRemovableNodeIds(
graph.nodes,
accepted.changes.filter((change) => change.type === 'remove').map((change) => change.id),
);
let next = graph;
for (const id of removals) next = removeNodeFromGraph(next, id);
const removals = filterRemovableNodeIds(
graph.nodes,
accepted.changes
.filter((change) => change.type === 'remove')
.map((change) => change.id),
);
let next = graph;
for (const id of removals) next = removeNodeFromGraph(next, id);
const positions = new Map<string, {x: number; y: number}>();
for (const change of accepted.changes) {
if (change.type === 'position' && change.dragging === false && change.position) {
positions.set(change.id, change.position);
const positions = new Map<string, { x: number; y: number }>();
for (const change of accepted.changes) {
if (
change.type === 'position' &&
change.dragging === false &&
change.position
) {
positions.set(change.id, change.position);
}
}
}
next = {...next, nodes: next.nodes.map((node) => ({...node, position: positions.get(node.id) ?? node.position}))};
onGraphChange(next);
next = {
...next,
nodes: next.nodes.map((node) => ({
...node,
position: positions.get(node.id) ?? node.position,
})),
};
onGraphChange(next);
if (selectedId && removals.includes(selectedId)) onSelect(undefined);
}, [applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId]);
if (selectedId && removals.includes(selectedId)) onSelect(undefined);
},
[applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId],
);
const onEdgesChange = useCallback((changes: EdgeChange<FlowEdge>[]) => {
applyFlowEdgeChanges(changes);
const persistent = changes.filter(isPersistentEdgeChange);
if (persistent.length === 0) return;
const onEdgesChange = useCallback(
(changes: EdgeChange<FlowEdge>[]) => {
applyFlowEdgeChanges(changes);
const persistent = changes.filter(isPersistentEdgeChange);
if (persistent.length === 0) return;
const next = applyEdgeChanges(persistent, edges);
onGraphChange({...graph, edges: next.map(toRuleEdge)});
if (selectedEdgeId && persistent.some((change) => change.type === 'remove' && change.id === selectedEdgeId)) {
const next = applyEdgeChanges(persistent, edges);
onGraphChange({ ...graph, edges: next.map(toRuleEdge) });
if (
selectedEdgeId &&
persistent.some(
(change) => change.type === 'remove' && change.id === selectedEdgeId,
)
) {
onSelectEdge(undefined);
}
},
[
applyFlowEdgeChanges,
edges,
graph,
onGraphChange,
onSelectEdge,
selectedEdgeId,
],
);
const isValidConnection = useCallback(
(connection: Edge | Connection) => {
return isConnectionAllowed(graph, connection);
},
[graph],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!isValidConnection(connection)) return;
const next = addEdge(
{
...connection,
id: `${connection.source}-${connection.sourceHandle}-${connection.target}`,
},
edges,
);
setEdges(next);
onGraphChange({ ...graph, edges: next.map(toRuleEdge) });
},
[edges, graph, isValidConnection, onGraphChange, setEdges],
);
const addNode = useCallback(
(type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
const config =
type === 'ip_match'
? { ips: [], cidrs: [], ip_group_ids: [] }
: type === 'geo_match'
? { countries: [], regions: [] }
: type === 'pow'
? {
algorithm: 'fast' as const,
difficulty: 4,
session_ttl: 3600,
challenge_ttl: 300,
}
: { status_code: 403, response_body: '' };
onGraphChange({
...graph,
nodes: [
...graph.nodes,
{
id,
type,
position: { x: 240, y: 140 + graph.nodes.length * 24 },
config,
} as WAFRuleNode,
],
});
onSelectEdge(undefined);
}
}, [applyFlowEdgeChanges, edges, graph, onGraphChange, onSelectEdge, selectedEdgeId]);
const isValidConnection = useCallback((connection: Edge | Connection) => {
return isConnectionAllowed(graph, connection);
}, [graph]);
const onConnect = useCallback((connection: Connection) => {
if (!isValidConnection(connection)) return;
const next = addEdge({...connection, id: `${connection.source}-${connection.sourceHandle}-${connection.target}`}, edges);
setEdges(next);
onGraphChange({...graph, edges: next.map(toRuleEdge)});
}, [edges, graph, isValidConnection, onGraphChange, setEdges]);
const addNode = useCallback((type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
const config = type === 'ip_match'
? {ips: [], cidrs: [], ip_group_ids: []}
: type === 'geo_match'
? {countries: [], regions: []}
: type === 'pow'
? {algorithm: 'fast' as const, difficulty: 4, session_ttl: 3600, challenge_ttl: 300}
: {status_code: 403, response_body: ''};
onGraphChange({...graph, nodes: [...graph.nodes, {id, type, position: {x: 240, y: 140 + graph.nodes.length * 24}, config} as WAFRuleNode]});
onSelectEdge(undefined);
onSelect(id);
}, [graph, onGraphChange, onSelect, onSelectEdge]);
onSelect(id);
},
[graph, onGraphChange, onSelect, onSelectEdge],
);
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
const canDeleteNode = Boolean(selectedNode && !['start', 'allow'].includes(selectedNode.type));
const canDeleteEdge = Boolean(selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId));
const canDeleteNode = Boolean(
selectedNode && !['start', 'allow'].includes(selectedNode.type),
);
const canDeleteEdge = Boolean(
selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId),
);
const canDelete = canDeleteNode || canDeleteEdge;
const deleteLabel = canDeleteEdge ? '删除连线' : canDeleteNode ? '删除节点' : selectedNode ? '系统节点不可删除' : '请选择可删除项';
const deleteLabel = canDeleteEdge
? '删除连线'
: canDeleteNode
? '删除节点'
: selectedNode
? '系统节点不可删除'
: '请选择可删除项';
const deleteSelection = useCallback(() => {
if (canDeleteEdge && selectedEdgeId) {
setEdges((current) => current.filter((edge) => edge.id !== selectedEdgeId));
setEdges((current) =>
current.filter((edge) => edge.id !== selectedEdgeId),
);
onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId));
onSelectEdge(undefined);
return;
}
if (canDeleteNode && selectedId) {
setNodes((current) => current.filter((node) => node.id !== selectedId));
setEdges((current) => current.filter((edge) => edge.source !== selectedId && edge.target !== selectedId));
setEdges((current) =>
current.filter(
(edge) => edge.source !== selectedId && edge.target !== selectedId,
),
);
onGraphChange(removeNodeFromGraph(graph, selectedId));
onSelect(undefined);
}
}, [canDeleteEdge, canDeleteNode, graph, onGraphChange, onSelect, onSelectEdge, selectedEdgeId, selectedId, setEdges, setNodes]);
}, [
canDeleteEdge,
canDeleteNode,
graph,
onGraphChange,
onSelect,
onSelectEdge,
selectedEdgeId,
selectedId,
setEdges,
setNodes,
]);
return (
<section className="relative min-w-0 flex-1 bg-muted/20">
<div className="absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur">
<NodeLibrary onAdd={addNode}/>
<section className='relative min-w-0 flex-1 bg-muted/20'>
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
<NodeLibrary onAdd={addNode} />
</div>
<div className="absolute right-4 top-4 z-10">
<Button variant="destructive" size="sm" disabled={!canDelete} onClick={deleteSelection}>
<Trash2 data-icon="inline-start"/>
<div className='absolute right-4 top-4 z-10'>
<Button
variant='destructive'
size='sm'
disabled={!canDelete}
onClick={deleteSelection}
>
<Trash2 data-icon='inline-start' />
{deleteLabel}
</Button>
</div>
@@ -181,31 +298,52 @@ export function RuleFlowCanvas({
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={(value) => { instance.current = value; }}
onInit={(value) => {
instance.current = value;
}}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onBeforeDelete={async ({nodes: deletingNodes}) => !deletingNodes.some((node) => ['start', 'allow'].includes(node.data.rule.type))}
onNodeClick={(_, node) => { onSelectEdge(undefined); onSelect(node.id); }}
onEdgeClick={(_, edge) => { onSelect(undefined); onSelectEdge(edge.id); }}
onPaneClick={() => { onSelect(undefined); onSelectEdge(undefined); }}
onBeforeDelete={async ({ nodes: deletingNodes }) =>
!deletingNodes.some((node) =>
['start', 'allow'].includes(node.data.rule.type),
)
}
onNodeClick={(_, node) => {
onSelectEdge(undefined);
onSelect(node.id);
}}
onEdgeClick={(_, edge) => {
onSelect(undefined);
onSelectEdge(edge.id);
}}
onPaneClick={() => {
onSelect(undefined);
onSelectEdge(undefined);
}}
fitView
deleteKeyCode={['Backspace', 'Delete']}
>
<Background gap={20} size={1}/>
<MiniMap pannable zoomable/>
<Controls/>
<Background gap={20} size={1} />
<MiniMap pannable zoomable />
<Controls />
</ReactFlow>
</section>
);
}
function buildFlowNodes(graph: WAFRuleGraph, issues: GraphIssue[], selectedId?: string, current: FlowNode[] = []): FlowNode[] {
function buildFlowNodes(
graph: WAFRuleGraph,
issues: GraphIssue[],
selectedId?: string,
current: FlowNode[] = [],
): FlowNode[] {
const existing = new Map(current.map((node) => [node.id, node]));
const issueCounts = new Map<string, number>();
for (const issue of issues) {
if (issue.nodeId) issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1);
if (issue.nodeId)
issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1);
}
return graph.nodes.map((rule) => ({
...existing.get(rule.id),
@@ -213,11 +351,16 @@ function buildFlowNodes(graph: WAFRuleGraph, issues: GraphIssue[], selectedId?:
type: 'rule',
position: rule.position,
selected: rule.id === selectedId,
data: {rule, issues: issueCounts.get(rule.id) ?? 0},
data: { rule, issues: issueCounts.get(rule.id) ?? 0 },
}));
}
function buildFlowEdges(graph: WAFRuleGraph, selectedId?: string, selectedEdgeId?: string, current: FlowEdge[] = []): FlowEdge[] {
function buildFlowEdges(
graph: WAFRuleGraph,
selectedId?: string,
selectedEdgeId?: string,
current: FlowEdge[] = [],
): FlowEdge[] {
const existing = new Map(current.map((edge) => [edge.id, edge]));
return graph.edges.map((edge) => ({
...existing.get(edge.id),
@@ -231,5 +374,10 @@ function buildFlowEdges(graph: WAFRuleGraph, selectedId?: string, selectedEdgeId
}
function toRuleEdge(edge: Edge): WAFRuleEdge {
return {id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target};
return {
id: edge.id,
source: edge.source,
source_handle: edge.sourceHandle ?? '',
target: edge.target,
};
}
@@ -1,37 +1,74 @@
import {Handle, Position, type NodeProps} from '@xyflow/react';
import {Ban, Fingerprint, Flag, Globe2, Play, ShieldCheck} from 'lucide-react';
import { Handle, Position, type NodeProps } from '@xyflow/react';
import {
Ban,
Fingerprint,
Flag,
Globe2,
Play,
ShieldCheck,
} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
import type {WAFRuleNode} from '@/lib/services/openflare';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type { WAFRuleNode } from '@/lib/services/openflare';
export interface RuleFlowNodeData extends Record<string, unknown> { rule: WAFRuleNode; issues: number }
export interface RuleFlowNodeData extends Record<string, unknown> {
rule: WAFRuleNode;
issues: number;
}
const meta = {
start: {label: '开始', icon: Play}, ip_match: {label: 'IP 匹配', icon: Fingerprint}, geo_match: {label: '地域匹配', icon: Globe2},
pow: {label: 'PoW 挑战', icon: ShieldCheck}, allow: {label: '通过', icon: Flag}, block: {label: '阻止', icon: Ban},
start: { label: '开始', icon: Play },
ip_match: { label: 'IP 匹配', icon: Fingerprint },
geo_match: { label: '地域匹配', icon: Globe2 },
pow: { label: 'PoW 挑战', icon: ShieldCheck },
allow: { label: '通过', icon: Flag },
block: { label: '阻止', icon: Ban },
} as const;
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {start: ['next'], ip_match: ['true', 'false'], geo_match: ['true', 'false'], pow: ['next']};
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
start: ['next'],
ip_match: ['true', 'false'],
geo_match: ['true', 'false'],
pow: ['next'],
};
export function RuleNode({data, selected}: NodeProps) {
export function RuleNode({ data, selected }: NodeProps) {
const value = data as RuleFlowNodeData;
const {rule, issues} = value;
const {label, icon: Icon} = meta[rule.type];
const { rule, issues } = value;
const { label, icon: Icon } = meta[rule.type];
return (
<div className={cn('min-w-44 rounded-lg border bg-card shadow-sm transition-shadow', selected && 'ring-2 ring-ring', issues > 0 && 'border-destructive')}>
{rule.type !== 'start' && <Handle type="target" position={Position.Left} />}
<div className="flex items-center gap-3 px-4 py-3">
<Icon className="size-5 text-primary" />
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-sm font-medium">{label}</span>
<span className="font-mono text-[10px] text-muted-foreground">{rule.id}</span>
<div
className={cn(
'min-w-44 rounded-lg border bg-card shadow-sm transition-shadow',
selected && 'ring-2 ring-ring',
issues > 0 && 'border-destructive',
)}
>
{rule.type !== 'start' && (
<Handle type='target' position={Position.Left} />
)}
<div className='flex items-center gap-3 px-4 py-3'>
<Icon className='size-5 text-primary' />
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
<span className='text-sm font-medium'>{label}</span>
<span className='font-mono text-[10px] text-muted-foreground'>
{rule.id}
</span>
</div>
{issues > 0 && <Badge variant="destructive">{issues}</Badge>}
{issues > 0 && <Badge variant='destructive'>{issues}</Badge>}
</div>
{(outputHandles[rule.type] ?? []).map((handle, index, all) => (
<Handle key={handle} id={handle} type="source" position={Position.Right} style={{top: `${((index + 1) / (all.length + 1)) * 100}%`}}>
<span className="absolute right-3 -translate-y-1/2 text-[9px] text-muted-foreground">{handle}</span>
<Handle
key={handle}
id={handle}
type='source'
position={Position.Right}
style={{ top: `${((index + 1) / (all.length + 1)) * 100}%` }}
>
<span className='absolute right-3 -translate-y-1/2 text-[9px] text-muted-foreground'>
{handle}
</span>
</Handle>
))}
</div>
@@ -1,14 +1,23 @@
import {render} from '@testing-library/react';
import {afterEach, expect, it, vi} from 'vitest';
import { render } from '@testing-library/react';
import { afterEach, expect, it, vi } from 'vitest';
import {UnsavedChanges} from './unsaved-changes';
import { UnsavedChanges } from './unsaved-changes';
afterEach(() => vi.restoreAllMocks());
it('blocks same-origin application links when dirty and confirmation is declined', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const {container} = render(<><UnsavedChanges dirty/><a href="/waf">WAF</a></>);
const event = new MouseEvent('click', {bubbles: true, cancelable: true, button: 0});
const { container } = render(
<>
<UnsavedChanges dirty />
<a href='/waf'>WAF</a>
</>,
);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
});
container.querySelector('a')!.dispatchEvent(event);
expect(window.confirm).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(true);
@@ -16,8 +25,17 @@ it('blocks same-origin application links when dirty and confirmation is declined
it('does not block application links without changes', () => {
const confirm = vi.spyOn(window, 'confirm');
const {getByRole} = render(<><UnsavedChanges dirty={false}/><a href="/waf">WAF</a></>);
const event = new MouseEvent('click', {bubbles: true, cancelable: true, button: 0});
const { getByRole } = render(
<>
<UnsavedChanges dirty={false} />
<a href='/waf'>WAF</a>
</>,
);
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
});
event.preventDefault();
getByRole('link').dispatchEvent(event);
expect(confirm).not.toHaveBeenCalled();
@@ -26,21 +44,33 @@ it('does not block application links without changes', () => {
it('restores declined Back and Forward transitions by indexed delta', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const go = vi.spyOn(history, 'go').mockImplementation(() => undefined);
history.replaceState({__wafEditorIndex: 4}, '');
render(<UnsavedChanges dirty/>);
window.dispatchEvent(new PopStateEvent('popstate', {state: {__wafEditorIndex: 3}}));
history.replaceState({ __wafEditorIndex: 4 }, '');
render(<UnsavedChanges dirty />);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 3 } }),
);
expect(go).toHaveBeenLastCalledWith(1);
window.dispatchEvent(new PopStateEvent('popstate', {state: {__wafEditorIndex: 4}}));
window.dispatchEvent(new PopStateEvent('popstate', {state: {__wafEditorIndex: 6}}));
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 4 } }),
);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { __wafEditorIndex: 6 } }),
);
expect(go).toHaveBeenLastCalledWith(-2);
});
it('prompts and restores the current URL for an unknown unindexed history entry', () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
history.replaceState({__wafEditorIndex: 4}, '', '/waf/rules/editor?id=9');
history.replaceState({ __wafEditorIndex: 4 }, '', '/waf/rules/editor?id=9');
const push = vi.spyOn(history, 'pushState');
render(<UnsavedChanges dirty/>);
window.dispatchEvent(new PopStateEvent('popstate', {state: {legacy: true}}));
render(<UnsavedChanges dirty />);
window.dispatchEvent(
new PopStateEvent('popstate', { state: { legacy: true } }),
);
expect(window.confirm).toHaveBeenCalledOnce();
expect(push).toHaveBeenCalledWith(expect.objectContaining({__wafEditorIndex: 4}), '', '/waf/rules/editor?id=9');
expect(push).toHaveBeenCalledWith(
expect.objectContaining({ __wafEditorIndex: 4 }),
'',
'/waf/rules/editor?id=9',
);
});
@@ -1,42 +1,103 @@
'use client';
import {useEffect} from 'react';
import {getHistoryTransition} from './editor-behavior';
import { useEffect } from 'react';
import { getHistoryTransition } from './editor-behavior';
const historyIndexKey = '__wafEditorIndex';
export function UnsavedChanges({dirty}: {dirty: boolean}) {
export function UnsavedChanges({ dirty }: { dirty: boolean }) {
useEffect(() => {
if (!dirty) return;
const initialState = history.state && typeof history.state === 'object' ? history.state : {};
let currentIndex = Number.isInteger(initialState[historyIndexKey]) ? initialState[historyIndexKey] as number : 0;
const currentUrl = window.location.pathname + window.location.search + window.location.hash;
history.replaceState({...initialState, [historyIndexKey]: currentIndex}, '');
const initialState =
history.state && typeof history.state === 'object' ? history.state : {};
let currentIndex = Number.isInteger(initialState[historyIndexKey])
? (initialState[historyIndexKey] as number)
: 0;
const currentUrl =
window.location.pathname + window.location.search + window.location.hash;
history.replaceState(
{ ...initialState, [historyIndexKey]: currentIndex },
'',
);
const originalPushState = history.pushState.bind(history);
const originalReplaceState = history.replaceState.bind(history);
history.pushState = (data, unused, url) => { currentIndex++; originalPushState({...data, [historyIndexKey]: currentIndex}, unused, url); };
history.replaceState = (data, unused, url) => originalReplaceState({...data, [historyIndexKey]: currentIndex}, unused, url);
history.pushState = (data, unused, url) => {
currentIndex++;
originalPushState(
{ ...data, [historyIndexKey]: currentIndex },
unused,
url,
);
};
history.replaceState = (data, unused, url) =>
originalReplaceState(
{ ...data, [historyIndexKey]: currentIndex },
unused,
url,
);
let restoring = false;
const handler = (event: BeforeUnloadEvent) => { if (dirty) event.preventDefault(); };
const handler = (event: BeforeUnloadEvent) => {
if (dirty) event.preventDefault();
};
const clickHandler = (event: MouseEvent) => {
if (!dirty || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const link = (event.target as Element | null)?.closest('a[href]') as HTMLAnchorElement | null;
if (!link || link.target === '_blank' || new URL(link.href, window.location.href).origin !== window.location.origin) return;
if (!window.confirm('存在未保存的更改,确定离开吗?')) event.preventDefault();
if (
!dirty ||
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const link = (event.target as Element | null)?.closest(
'a[href]',
) as HTMLAnchorElement | null;
if (
!link ||
link.target === '_blank' ||
new URL(link.href, window.location.href).origin !==
window.location.origin
)
return;
if (!window.confirm('存在未保存的更改,确定离开吗?'))
event.preventDefault();
};
const popstateHandler = (event: PopStateEvent) => {
const hasTargetIndex = Number.isInteger(event.state?.[historyIndexKey]);
const targetIndex = hasTargetIndex ? event.state[historyIndexKey] as number : currentIndex;
if (restoring) { restoring = false; currentIndex = targetIndex; return; }
if (window.confirm('存在未保存的更改,确定离开吗?')) { currentIndex = targetIndex; return; }
if (!hasTargetIndex) { originalPushState({...initialState, [historyIndexKey]: currentIndex}, '', currentUrl); return; }
const targetIndex = hasTargetIndex
? (event.state[historyIndexKey] as number)
: currentIndex;
if (restoring) {
restoring = false;
currentIndex = targetIndex;
return;
}
if (window.confirm('存在未保存的更改,确定离开吗?')) {
currentIndex = targetIndex;
return;
}
if (!hasTargetIndex) {
originalPushState(
{ ...initialState, [historyIndexKey]: currentIndex },
'',
currentUrl,
);
return;
}
restoring = true;
history.go(getHistoryTransition(currentIndex, targetIndex).restoreDelta);
};
window.addEventListener('beforeunload', handler);
document.addEventListener('click', clickHandler, true);
window.addEventListener('popstate', popstateHandler);
return () => { history.pushState = originalPushState; history.replaceState = originalReplaceState; window.removeEventListener('beforeunload', handler); document.removeEventListener('click', clickHandler, true); window.removeEventListener('popstate', popstateHandler); };
return () => {
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
window.removeEventListener('beforeunload', handler);
document.removeEventListener('click', clickHandler, true);
window.removeEventListener('popstate', popstateHandler);
};
}, [dirty]);
return null;
}
@@ -1,38 +1,114 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import {act, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {AxiosError, type AxiosResponse} from 'axios';
import {beforeEach, expect, it, vi} from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { AxiosError, type AxiosResponse } from 'axios';
import { beforeEach, expect, it, vi } from 'vitest';
import type {WAFRule, WAFRuleGraph} from '@/lib/services';
import type { WAFRule, WAFRuleGraph } from '@/lib/services';
import WAFRuleEditorPage from './page';
const {getRule, saveRuleGraph, updateRuleMeta, listIPGroups, toastError} = vi.hoisted(() => ({
getRule: vi.fn(),
saveRuleGraph: vi.fn(),
updateRuleMeta: vi.fn(),
listIPGroups: vi.fn().mockResolvedValue([]),
toastError: vi.fn(),
}));
const { getRule, saveRuleGraph, updateRuleMeta, listIPGroups, toastError } =
vi.hoisted(() => ({
getRule: vi.fn(),
saveRuleGraph: vi.fn(),
updateRuleMeta: vi.fn(),
listIPGroups: vi.fn().mockResolvedValue([]),
toastError: vi.fn(),
}));
vi.mock('next/navigation', () => ({useRouter: () => ({push: vi.fn()}), useSearchParams: () => new URLSearchParams('id=9')}));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: toastError}}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => new URLSearchParams('id=9'),
}));
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: toastError } }));
vi.mock('@/lib/services', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services')>();
return {...actual, services: {...actual.services, openflareWaf: {getRule, saveRuleGraph, updateRuleMeta, listIPGroups}}};
return {
...actual,
services: {
...actual.services,
openflareWaf: { getRule, saveRuleGraph, updateRuleMeta, listIPGroups },
},
};
});
vi.mock('./components/rule-flow-canvas', () => ({RuleFlowCanvas: ({graph, focusTarget, onGraphChange}: {graph: WAFRuleGraph; focusTarget?: {kind: string; id: string}; onGraphChange: (graph: WAFRuleGraph) => void}) => <div><button onClick={() => onGraphChange({...graph, nodes: graph.nodes.map((node) => node.id === 'start' ? {...node, position: {x: 10, y: 0}} : node)})}>修改画布</button>{focusTarget && <span>focus:{focusTarget.kind}:{focusTarget.id}</span>}</div>}));
vi.mock('./components/node-properties', () => ({NodeProperties: () => null}));
vi.mock('./components/rule-flow-canvas', () => ({
RuleFlowCanvas: ({
graph,
focusTarget,
onGraphChange,
}: {
graph: WAFRuleGraph;
focusTarget?: { kind: string; id: string };
onGraphChange: (graph: WAFRuleGraph) => void;
}) => (
<div>
<button
onClick={() =>
onGraphChange({
...graph,
nodes: graph.nodes.map((node) =>
node.id === 'start'
? { ...node, position: { x: 10, y: 0 } }
: node,
),
})
}
>
修改画布
</button>
{focusTarget && (
<span>
focus:{focusTarget.kind}:{focusTarget.id}
</span>
)}
</div>
),
}));
vi.mock('./components/node-properties', () => ({ NodeProperties: () => null }));
const graph: WAFRuleGraph = {schema_version: 1, nodes: [
{id: 'start', type: 'start', position: {x: 0, y: 0}, config: {}},
{id: 'allow', type: 'allow', position: {x: 200, y: 0}, config: {}},
], edges: [{id: 'start-allow', source: 'start', source_handle: 'next', target: 'allow'}]};
const rule = {id: 9, name: '边缘防护', enabled: true, is_global: false, graph, revision: 1, applied_site_ids: [], applied_site_count: 0, created_at: '', updated_at: ''} satisfies WAFRule;
const graph: WAFRuleGraph = {
schema_version: 1,
nodes: [
{ id: 'start', type: 'start', position: { x: 0, y: 0 }, config: {} },
{ id: 'allow', type: 'allow', position: { x: 200, y: 0 }, config: {} },
],
edges: [
{
id: 'start-allow',
source: 'start',
source_handle: 'next',
target: 'allow',
},
],
};
const rule = {
id: 9,
name: '边缘防护',
enabled: true,
is_global: false,
graph,
revision: 1,
applied_site_ids: [],
applied_site_count: 0,
created_at: '',
updated_at: '',
} satisfies WAFRule;
function renderPage() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}, mutations: {retry: false}}});
return render(<QueryClientProvider client={client}><WAFRuleEditorPage/></QueryClientProvider>);
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<QueryClientProvider client={client}>
<WAFRuleEditorPage />
</QueryClientProvider>,
);
}
beforeEach(() => {
@@ -44,38 +120,58 @@ beforeEach(() => {
});
it('renders a query error with a working retry action', async () => {
getRule.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(rule);
getRule
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce(rule);
renderPage();
fireEvent.click(await screen.findByRole('button', {name: '重新加载'}));
expect(await screen.findByRole('heading', {name: '边缘防护'})).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: '重新加载' }));
expect(
await screen.findByRole('heading', { name: '边缘防护' }),
).toBeInTheDocument();
expect(getRule).toHaveBeenCalledTimes(2);
});
it('exposes conflict reload and maps typed server node errors to canvas focus', async () => {
getRule.mockResolvedValue(rule);
const conflict = new AxiosError('conflict');
conflict.response = {status: 409, data: {}, headers: {}, config: {headers: {}}} as AxiosResponse;
saveRuleGraph.mockRejectedValueOnce(conflict).mockRejectedValueOnce(new Error('规则图无效: 节点 start 的 next 出口未连接'));
conflict.response = {
status: 409,
data: {},
headers: {},
config: { headers: {} },
} as AxiosResponse;
saveRuleGraph
.mockRejectedValueOnce(conflict)
.mockRejectedValueOnce(
new Error('规则图无效: 节点 start 的 next 出口未连接'),
);
renderPage();
await screen.findByRole('heading', {name: '边缘防护'});
fireEvent.click(screen.getByRole('button', {name: '修改画布'}));
fireEvent.click(screen.getByRole('button', {name: '保存'}));
expect(await screen.findByRole('button', {name: '重新加载'})).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: '保存'}));
await waitFor(() => expect(screen.getByText('focus:node:start')).toBeInTheDocument());
await screen.findByRole('heading', { name: '边缘防护' });
fireEvent.click(screen.getByRole('button', { name: '修改画布' }));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(
await screen.findByRole('button', { name: '重新加载' }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() =>
expect(screen.getByText('focus:node:start')).toBeInTheDocument(),
);
});
it('optimistically enables a saved valid rule with its current name', async () => {
const disabledRule = {...rule, enabled: false};
const disabledRule = { ...rule, enabled: false };
let resolveUpdate: (value: WAFRule) => void = () => undefined;
updateRuleMeta.mockImplementation(() => new Promise<WAFRule>((resolve) => {
resolveUpdate = resolve;
}));
updateRuleMeta.mockImplementation(
() =>
new Promise<WAFRule>((resolve) => {
resolveUpdate = resolve;
}),
);
getRule.mockResolvedValue(disabledRule);
renderPage();
expect(await screen.findByText('已停用')).toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', {name: '启用规则'}));
fireEvent.click(screen.getByRole('switch', { name: '启用规则' }));
await waitFor(() => {
expect(updateRuleMeta).toHaveBeenCalledWith(9, {
@@ -85,20 +181,23 @@ it('optimistically enables a saved valid rule with its current name', async () =
expect(screen.getByText('已启用')).toBeInTheDocument();
});
await act(async () => resolveUpdate({...disabledRule, enabled: true}));
await act(async () => resolveUpdate({ ...disabledRule, enabled: true }));
});
it('rolls back an optimistic enabled change when metadata update fails', async () => {
const disabledRule = {...rule, enabled: false};
const disabledRule = { ...rule, enabled: false };
let rejectUpdate: (reason: Error) => void = () => undefined;
updateRuleMeta.mockImplementation(() => new Promise<WAFRule>((_resolve, reject) => {
rejectUpdate = reject;
}));
updateRuleMeta.mockImplementation(
() =>
new Promise<WAFRule>((_resolve, reject) => {
rejectUpdate = reject;
}),
);
getRule.mockResolvedValue(disabledRule);
renderPage();
expect(await screen.findByText('已停用')).toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', {name: '启用规则'}));
fireEvent.click(screen.getByRole('switch', { name: '启用规则' }));
await waitFor(() => expect(screen.getByText('已启用')).toBeInTheDocument());
await act(async () => rejectUpdate(new Error('网络不可用')));
@@ -108,17 +207,17 @@ it('rolls back an optimistic enabled change when metadata update fails', async (
});
it('requires graph changes to be saved before changing enabled state', async () => {
const disabledRule = {...rule, enabled: false};
const disabledRule = { ...rule, enabled: false };
getRule.mockResolvedValue(disabledRule);
saveRuleGraph.mockResolvedValue({...disabledRule, revision: 2});
saveRuleGraph.mockResolvedValue({ ...disabledRule, revision: 2 });
renderPage();
const enabledSwitch = await screen.findByRole('switch', {name: '启用规则'});
const enabledSwitch = await screen.findByRole('switch', { name: '启用规则' });
expect(enabledSwitch).toBeEnabled();
fireEvent.click(screen.getByRole('button', {name: '修改画布'}));
fireEvent.click(screen.getByRole('button', { name: '修改画布' }));
expect(enabledSwitch).toBeDisabled();
fireEvent.click(screen.getByRole('button', {name: '保存'}));
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => expect(enabledSwitch).toBeEnabled());
});
+210 -64
View File
@@ -1,28 +1,40 @@
'use client';
import {Suspense, useCallback, useEffect, useMemo, useState} from 'react';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import {ArrowLeft, GitBranch, Save} from 'lucide-react';
import {useRouter, useSearchParams} from 'next/navigation';
import {toast} from 'sonner';
import { ArrowLeft, GitBranch, Save } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { toast } from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Label} from '@/components/ui/label';
import {Skeleton} from '@/components/ui/skeleton';
import {Switch} from '@/components/ui/switch';
import {services, type WAFRule, type WAFRuleGraph, type WAFRuleNode} from '@/lib/services';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import {
services,
type WAFRule,
type WAFRuleGraph,
type WAFRuleNode,
} from '@/lib/services';
import {getErrorMessage} from '../../components/helpers';
import {validateGraph} from './components/graph-validation';
import {NodeProperties} from './components/node-properties';
import {RuleFlowCanvas} from './components/rule-flow-canvas';
import {UnsavedChanges} from './components/unsaved-changes';
import {findGraphErrorTarget, type GraphErrorTarget} from './components/editor-behavior';
import { getErrorMessage } from '../../components/helpers';
import { validateGraph } from './components/graph-validation';
import { NodeProperties } from './components/node-properties';
import { RuleFlowCanvas } from './components/rule-flow-canvas';
import { UnsavedChanges } from './components/unsaved-changes';
import {
findGraphErrorTarget,
type GraphErrorTarget,
} from './components/editor-behavior';
export default function WAFRuleEditorPage() {
return <Suspense fallback={<EditorSkeleton/>}><EditorContent/></Suspense>;
return (
<Suspense fallback={<EditorSkeleton />}>
<EditorContent />
</Suspense>
);
}
function EditorContent() {
@@ -39,38 +51,75 @@ function EditorContent() {
const [conflict, setConflict] = useState(false);
const ruleQueryKey = ['waf-rule', id] as const;
const ruleQuery = useQuery({queryKey: ruleQueryKey, queryFn: () => services.openflareWaf.getRule(id), enabled: Number.isFinite(id) && id > 0});
const ipGroupsQuery = useQuery({queryKey: ['waf-ip-groups'], queryFn: () => services.openflareWaf.listIPGroups()});
useEffect(() => { if (ruleQuery.data && !dirty) { setGraph(ruleQuery.data.graph); setRevision(ruleQuery.data.revision); } }, [dirty, ruleQuery.data]);
const issues = useMemo(() => graph ? validateGraph(graph) : [], [graph]);
const ruleQuery = useQuery({
queryKey: ruleQueryKey,
queryFn: () => services.openflareWaf.getRule(id),
enabled: Number.isFinite(id) && id > 0,
});
const ipGroupsQuery = useQuery({
queryKey: ['waf-ip-groups'],
queryFn: () => services.openflareWaf.listIPGroups(),
});
useEffect(() => {
if (ruleQuery.data && !dirty) {
setGraph(ruleQuery.data.graph);
setRevision(ruleQuery.data.revision);
}
}, [dirty, ruleQuery.data]);
const issues = useMemo(() => (graph ? validateGraph(graph) : []), [graph]);
const selected = graph?.nodes.find((node) => node.id === selectedId);
const saveMutation = useMutation({
mutationFn: () => services.openflareWaf.saveRuleGraph(id, {revision, graph: graph!}),
onSuccess: (rule) => { setGraph(rule.graph); setRevision(rule.revision); setDirty(false); setConflict(false); queryClient.setQueryData(['waf-rule', id], rule); toast.success('规则图已保存'); },
mutationFn: () =>
services.openflareWaf.saveRuleGraph(id, { revision, graph: graph! }),
onSuccess: (rule) => {
setGraph(rule.graph);
setRevision(rule.revision);
setDirty(false);
setConflict(false);
queryClient.setQueryData(['waf-rule', id], rule);
toast.success('规则图已保存');
},
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 409) { setConflict(true); toast.error('规则已在其他页面更新,请重新加载'); return; }
if (axios.isAxiosError(error) && error.response?.status === 409) {
setConflict(true);
toast.error('规则已在其他页面更新,请重新加载');
return;
}
const payload = axios.isAxiosError(error) ? error.response?.data : error;
const target = graph ? findGraphErrorTarget(payload, graph.nodes.map((node) => node.id), graph.edges.map((edge) => edge.id)) : undefined;
if (target?.kind === 'node') { setSelectedEdgeId(undefined); setSelectedId(target.id); }
if (target?.kind === 'edge') { setSelectedId(undefined); setSelectedEdgeId(target.id); }
setFocusTarget(target ? {...target} : undefined);
const target = graph
? findGraphErrorTarget(
payload,
graph.nodes.map((node) => node.id),
graph.edges.map((edge) => edge.id),
)
: undefined;
if (target?.kind === 'node') {
setSelectedEdgeId(undefined);
setSelectedId(target.id);
}
if (target?.kind === 'edge') {
setSelectedId(undefined);
setSelectedEdgeId(target.id);
}
setFocusTarget(target ? { ...target } : undefined);
toast.error('保存失败,请检查标记的节点和连线');
},
});
const metaMutation = useMutation({
mutationFn: (enabled: boolean) => services.openflareWaf.updateRuleMeta(id, {
name: ruleQuery.data!.name,
enabled,
}),
mutationFn: (enabled: boolean) =>
services.openflareWaf.updateRuleMeta(id, {
name: ruleQuery.data!.name,
enabled,
}),
onMutate: async (enabled) => {
await queryClient.cancelQueries({queryKey: ruleQueryKey});
await queryClient.cancelQueries({ queryKey: ruleQueryKey });
const previous = queryClient.getQueryData<WAFRule>(ruleQueryKey);
queryClient.setQueryData<WAFRule>(ruleQueryKey, (current) =>
current ? {...current, enabled} : current,
current ? { ...current, enabled } : current,
);
return {previous};
return { previous };
},
onError: (error, _enabled, context) => {
if (context?.previous) {
@@ -80,51 +129,148 @@ function EditorContent() {
},
onSuccess: (rule) => {
queryClient.setQueryData(ruleQueryKey, rule);
void queryClient.invalidateQueries({queryKey: ruleQueryKey, refetchType: 'none'});
void queryClient.invalidateQueries({queryKey: ['openflare', 'waf', 'rule-groups']});
void queryClient.invalidateQueries({queryKey: ['openflare', 'config-versions', 'diff']});
void queryClient.invalidateQueries({
queryKey: ruleQueryKey,
refetchType: 'none',
});
void queryClient.invalidateQueries({
queryKey: ['openflare', 'waf', 'rule-groups'],
});
void queryClient.invalidateQueries({
queryKey: ['openflare', 'config-versions', 'diff'],
});
toast.success(rule.enabled ? '规则已启用' : '规则已停用');
},
});
const changeGraph = useCallback((next: WAFRuleGraph, persistent = true) => { setGraph(next); if (persistent) { setDirty(true); setConflict(false); } }, []);
const changeNode = useCallback((next: WAFRuleNode) => { if (!graph) return; changeGraph({...graph, nodes: graph.nodes.map((node) => node.id === next.id ? next : node)}); }, [changeGraph, graph]);
const leave = () => { if (!dirty || window.confirm('存在未保存的更改,确定离开吗?')) router.push('/waf'); };
const changeGraph = useCallback((next: WAFRuleGraph, persistent = true) => {
setGraph(next);
if (persistent) {
setDirty(true);
setConflict(false);
}
}, []);
const changeNode = useCallback(
(next: WAFRuleNode) => {
if (!graph) return;
changeGraph({
...graph,
nodes: graph.nodes.map((node) => (node.id === next.id ? next : node)),
});
},
[changeGraph, graph],
);
const leave = () => {
if (!dirty || window.confirm('存在未保存的更改,确定离开吗?'))
router.push('/waf');
};
if (!Number.isFinite(id) || id <= 0) return <div className="w-full px-1 py-6"><p className="text-sm text-destructive">缺少有效的规则 ID。</p></div>;
if (ruleQuery.isError) return <div className="flex w-full flex-col items-start gap-3 px-1 py-6"><p className="text-sm text-destructive">规则加载失败,请重试。</p><Button variant="outline" onClick={() => void ruleQuery.refetch()}>重新加载</Button></div>;
if (ruleQuery.isLoading || !graph || !ruleQuery.data) return <EditorSkeleton/>;
if (!Number.isFinite(id) || id <= 0)
return (
<div className='w-full px-1 py-6'>
<p className='text-sm text-destructive'>缺少有效的规则 ID。</p>
</div>
);
if (ruleQuery.isError)
return (
<div className='flex w-full flex-col items-start gap-3 px-1 py-6'>
<p className='text-sm text-destructive'>规则加载失败,请重试。</p>
<Button variant='outline' onClick={() => void ruleQuery.refetch()}>
重新加载
</Button>
</div>
);
if (ruleQuery.isLoading || !graph || !ruleQuery.data)
return <EditorSkeleton />;
return (
<div className="flex h-[calc(100dvh-4rem)] w-full flex-col px-1 py-6">
<UnsavedChanges dirty={dirty}/>
<header className="mb-4 flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-2"><GitBranch className="size-5 text-primary"/>
<h1 className="text-2xl font-semibold tracking-tight">{ruleQuery.data.name}</h1>
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>{ruleQuery.data.enabled ? '已启用' : '已停用'}</Badge>
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}</Badge>
{dirty && <Badge variant="secondary">未保存</Badge>}
<div className='flex h-[calc(100dvh-4rem)] w-full flex-col px-1 py-6'>
<UnsavedChanges dirty={dirty} />
<header className='mb-4 flex items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<GitBranch className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{ruleQuery.data.name}
</h1>
<Badge variant={ruleQuery.data.enabled ? 'default' : 'secondary'}>
{ruleQuery.data.enabled ? '已启用' : '已停用'}
</Badge>
<Badge variant={issues.length === 0 ? 'outline' : 'destructive'}>
{issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`}
</Badge>
{dirty && <Badge variant='secondary'>未保存</Badge>}
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
<div className='flex shrink-0 items-center gap-2'>
<div className='flex items-center gap-2'>
<Switch
id="rule-enabled"
id='rule-enabled'
checked={ruleQuery.data.enabled}
disabled={dirty || issues.length > 0 || saveMutation.isPending || metaMutation.isPending}
disabled={
dirty ||
issues.length > 0 ||
saveMutation.isPending ||
metaMutation.isPending
}
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
/>
<Label htmlFor="rule-enabled">启用规则</Label>
<Label htmlFor='rule-enabled'>启用规则</Label>
</div>
<Button variant="outline" onClick={leave}><ArrowLeft data-icon="inline-start"/>返回</Button>
{conflict && <Button variant="outline" onClick={() => { setDirty(false); setConflict(false); void ruleQuery.refetch(); }}>重新加载</Button>}
<Button disabled={!dirty || issues.length > 0 || saveMutation.isPending} onClick={() => saveMutation.mutate()}><Save data-icon="inline-start"/>{saveMutation.isPending ? '保存中...' : '保存'}</Button>
<Button variant='outline' onClick={leave}>
<ArrowLeft data-icon='inline-start' />
返回
</Button>
{conflict && (
<Button
variant='outline'
onClick={() => {
setDirty(false);
setConflict(false);
void ruleQuery.refetch();
}}
>
重新加载
</Button>
)}
<Button
disabled={!dirty || issues.length > 0 || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
<Save data-icon='inline-start' />
{saveMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</header>
<div className="flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background shadow-sm">
<RuleFlowCanvas graph={graph} issues={issues} selectedId={selectedId} selectedEdgeId={selectedEdgeId} focusTarget={focusTarget} onGraphChange={changeGraph} onSelect={setSelectedId} onSelectEdge={setSelectedEdgeId}/>{selected && <NodeProperties node={selected} ipGroups={ipGroupsQuery.data ?? []} onChange={changeNode}/>}
<div className='flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background shadow-sm'>
<RuleFlowCanvas
graph={graph}
issues={issues}
selectedId={selectedId}
selectedEdgeId={selectedEdgeId}
focusTarget={focusTarget}
onGraphChange={changeGraph}
onSelect={setSelectedId}
onSelectEdge={setSelectedEdgeId}
/>
{selected && (
<NodeProperties
node={selected}
ipGroups={ipGroupsQuery.data ?? []}
onChange={changeNode}
/>
)}
</div>
</div>
);
}
function EditorSkeleton() { return <div className="flex w-full flex-col gap-4 px-1 py-6"><div className="flex items-center gap-2"><Skeleton className="size-5"/><Skeleton className="h-8 w-64"/></div><Skeleton className="h-[70dvh] w-full"/></div>; }
function EditorSkeleton() {
return (
<div className='flex w-full flex-col gap-4 px-1 py-6'>
<div className='flex items-center gap-2'>
<Skeleton className='size-5' />
<Skeleton className='h-8 w-64' />
</div>
<Skeleton className='h-[70dvh] w-full' />
</div>
);
}