feat(waf): complete composable rule orchestration

Add the React Flow rule editor, ordered graph APIs and runtime DAG execution.\n\nPublish rules only on OpenResty reload and reconcile checksum-driven IP group snapshots in bounded shared memory.
This commit is contained in:
ryan
2026-07-13 14:16:55 +08:00
parent d36409fbf9
commit a1a997bcda
72 changed files with 5897 additions and 3080 deletions
@@ -1,6 +1,6 @@
'use client';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2, Users} from 'lucide-react';
import {Globe2, MoreHorizontal, Pencil, ShieldCheck, Trash2} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -20,14 +20,12 @@ interface RuleGroupsTableProps {
groups: WAFRule[];
onEdit: (group: WAFRule) => void;
onDelete: (group: WAFRule) => void;
onBindSites: (group: WAFRule) => void;
}
export function RuleGroupsTable({
groups,
onEdit,
onDelete,
onBindSites,
}: RuleGroupsTableProps) {
return (
<Table>
@@ -85,12 +83,6 @@ export function RuleGroupsTable({
<Pencil />
编排
</DropdownMenuItem>
{!group.is_global ? (
<DropdownMenuItem onClick={() => onBindSites(group)}>
<Users />
绑定网站
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
{!group.is_global ? (
<>
@@ -1,139 +0,0 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Check, Search} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle,} from '@/components/ui/sheet';
import {cn} from '@/lib/utils';
import type {ProxyRouteItem, WAFRule} from '@/lib/services/openflare';
interface SiteBindingSheetProps {
group: WAFRule | null;
routes: ProxyRouteItem[];
open: boolean;
pending: boolean;
onOpenChange: (open: boolean) => void;
onSave: (ids: number[]) => void;
}
export function SiteBindingSheet({
group,
routes,
open,
pending,
onOpenChange,
onSave,
}: SiteBindingSheetProps) {
const [keyword, setKeyword] = useState('');
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
useEffect(() => {
setSelectedIDs(group?.applied_site_ids ?? []);
setKeyword('');
}, [group, open]);
const filteredRoutes = useMemo(() => {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return routes;
return routes.filter((route) =>
[
route.site_name,
...(route.zone_domains ?? []).map((item) => item.domain),
]
.join(' ')
.toLowerCase()
.includes(normalized),
);
}, [keyword, routes]);
const selectedSet = useMemo(() => new Set(selectedIDs), [selectedIDs]);
const toggleID = (id: number) => {
setSelectedIDs((current) =>
current.includes(id)
? current.filter((item) => item !== id)
: [...current, id],
);
};
const selectFiltered = () => {
const next = new Set(selectedIDs);
filteredRoutes.forEach((route) => next.add(route.id));
setSelectedIDs([...next]);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>{group ? `绑定 ${group.name}` : '绑定规则组'}</SheetTitle>
<SheetDescription>
选择这个自定义规则组要叠加到哪些网站。
</SheetDescription>
</SheetHeader>
<div className="space-y-4 px-4 pb-4">
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Search className="size-4 text-muted-foreground" />
<Input
value={keyword}
placeholder="搜索网站或域名"
className="border-0 shadow-none focus-visible:ring-0"
onChange={(event) => setKeyword(event.target.value)}
/>
<Button type="button" variant="ghost" size="sm" onClick={selectFiltered}>
全选当前
</Button>
</div>
<div className="space-y-2">
{filteredRoutes.map((route) => (
<button
key={route.id}
type="button"
onClick={() => toggleID(route.id)}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left transition',
selectedSet.has(route.id) && 'border-primary bg-muted/50',
)}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded border',
selectedSet.has(route.id) && 'border-primary bg-primary text-primary-foreground',
)}
>
{selectedSet.has(route.id) ? <Check className="size-3" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{route.site_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{(route.zone_domains ?? []).map((item) => item.domain).join(', ') ||
'未绑定域名'}
</span>
</span>
</button>
))}
</div>
</div>
<SheetFooter className="px-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button
type="button"
disabled={!group || pending}
onClick={() => onSave(selectedIDs)}
>
{pending ? '保存中...' : '保存应用范围'}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+3 -38
View File
@@ -22,33 +22,25 @@ 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 {ProxyRouteService, WafService} 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 {SiteBindingSheet} from './components/site-binding-sheet';
const ruleGroupsQueryKey = ['openflare', 'waf', 'rule-groups'];
const routesQueryKey = ['openflare', 'proxy-routes'];
export default function WafPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<WAFRule | null>(null);
const [bindingGroup, setBindingGroup] = useState<WAFRule | null>(null);
const groupsQuery = useQuery({
queryKey: ruleGroupsQueryKey,
queryFn: () => WafService.listRuleGroups(),
});
const routesQuery = useQuery({
queryKey: routesQueryKey,
queryFn: () => ProxyRouteService.list(),
});
const invalidate = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey }),
@@ -80,26 +72,13 @@ export default function WafPage() {
},
});
const bindMutation = useMutation({
mutationFn: ({ id, ids }: { id: number; ids: number[] }) =>
WafService.updateRuleGroupSites(id, ids),
onSuccess: async () => {
toast.success('规则组应用范围已更新');
setBindingGroup(null);
await invalidate();
},
onError: (error) => {
toast.error(getErrorMessage(error));
},
});
const handleRefresh = () => {
void queryClient.invalidateQueries({ queryKey: ruleGroupsQueryKey });
};
const groups = groupsQuery.data ?? [];
const loading = groupsQuery.isLoading || routesQuery.isLoading;
const error = groupsQuery.error ?? routesQuery.error ?? null;
const loading = groupsQuery.isLoading;
const error = groupsQuery.error ?? null;
return (
<div className="w-full py-6 px-1 space-y-6">
@@ -162,7 +141,6 @@ export default function WafPage() {
groups={groups}
onEdit={(rule) => router.push(`/waf/rules/editor?id=${rule.id}`)}
onDelete={setDeleteTarget}
onBindSites={setBindingGroup}
/>
)}
</CardContent>
@@ -177,19 +155,6 @@ export default function WafPage() {
}}
/>
<SiteBindingSheet
group={bindingGroup}
routes={routesQuery.data ?? []}
open={Boolean(bindingGroup)}
pending={bindMutation.isPending}
onOpenChange={(open) => !open && setBindingGroup(null)}
onSave={(ids) => {
if (bindingGroup) {
bindMutation.mutate({ id: bindingGroup.id, ids });
}
}}
/>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
@@ -0,0 +1,66 @@
import {describe, expect, it} from 'vitest';
import type {WAFRuleGraph} from '@/lib/services/openflare';
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);
});
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);
});
});
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'}]};
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});
});
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);
});
});
describe('server graph error targeting', () => {
const nodes = ['start', 'match-1'];
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'});
});
it('does not substring match unrelated error text', () => {
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();
});
});
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});
});
@@ -0,0 +1,73 @@
import type {EdgeChange, NodeChange} from '@xyflow/react';
import type {WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
import {wouldCreateCycle} from './graph-validation';
export type GraphErrorTarget = {kind: 'node' | 'edge'; id: string};
export function isPersistentNodeChange(change: NodeChange): 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';
}
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(nodes: WAFRuleNode[], changes: NodeChange[]): {changes: NodeChange[]; persistent: boolean} {
const accepted = changes.filter((change) => change.type === 'position' || (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 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);
}
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};
}
const messages = collectMessages(payload);
for (const message of messages) {
const nodeId = findMessageId(message, '节点', nodeIds);
if (nodeId) return {kind: 'node', id: nodeId};
const edgeId = findMessageId(message, '边', edgeIds);
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 || typeof value !== 'object') return [];
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 {
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));
}
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function collectIdFields(value: unknown): {key: string; value: string}[] {
if (!value || typeof value !== 'object') return [];
const result: {key: string; value: string}[] = [];
for (const [key, child] of Object.entries(value)) {
if (typeof child === 'string') result.push({key, value: child});
else result.push(...collectIdFields(child));
}
return result;
}
@@ -0,0 +1,116 @@
import {describe, expect, it} from 'vitest';
import type {WAFRuleGraph} from '@/lib/services/openflare';
import {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: ''}},
],
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'},
],
});
describe('validateGraph', () => {
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');
});
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'}));
});
it('rejects cycles', () => {
const graph = validGraph();
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'}),
]));
});
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'}),
]));
});
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']));
});
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'}),
]));
});
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'}),
]));
});
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'}));
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);
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'}));
}
});
});
it('removes incident edges when deleting a node', () => {
const next = removeNodeFromGraph(validGraph(), 'match');
expect(next.nodes.some((node) => node.id === 'match')).toBe(false);
expect(next.edges).toEqual([]);
});
it('detects whether a new connection creates a cycle', () => {
expect(wouldCreateCycle(validGraph(), 'allow', 'start')).toBe(true);
expect(wouldCreateCycle(validGraph(), 'start', 'block')).toBe(false);
});
@@ -0,0 +1,146 @@
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 interface GraphIssue {
code: GraphIssueCode;
message: string;
nodeId?: string;
edgeId?: string;
}
const handles: Partial<Record<WAFRuleNode['type'], string[]>> = {
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: '规则图超过大小限制'});
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});
nodeIds.add(node.id);
const configIssue = validateNodeConfig(node);
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});
edgeIds.add(edge.id);
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: '规则图必须恰好有一个通过节点'});
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});
}
}
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 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);
reverse.get(edge.target)?.push(edge.source);
}
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);
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: '规则图不能包含循环'});
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.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 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);
}
function isIPv6(value: string): boolean {
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(':') : []);
let units = 0;
for (let index = 0; index < groups.length; index++) {
const group = groups[index];
if (group.includes('.')) {
if (index !== groups.length - 1 || !isIP(group)) return false;
units += 2;
} else {
if (!/^[0-9a-f]{1,4}$/i.test(group)) return false;
units++;
}
}
return compressed ? units < 8 : units === 8;
}
function walk(seeds: string[], links: Map<string, string[]>): Set<string> {
const seen = new Set<string>();
const stack = [...seeds];
while (stack.length) {
const id = stack.pop()!;
if (seen.has(id)) continue;
seen.add(id);
stack.push(...(links.get(id) ?? []));
}
return seen;
}
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);
let visited = 0;
while (queue.length) {
const id = queue.shift()!;
visited++;
for (const edge of graph.edges.filter((item) => item.source === id)) {
const next = (indegree.get(edge.target) ?? 0) - 1;
indegree.set(edge.target, next);
if (next === 0) queue.push(edge.target);
}
}
return visited !== graph.nodes.length;
}
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[]]));
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)};
}
@@ -0,0 +1,14 @@
import {Ban, Fingerprint, Globe2, Plus, ShieldCheck} from 'lucide-react';
import {Button} from '@/components/ui/button';
import type {WAFRuleNode} from '@/lib/services/openflare';
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}[];
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>;
}
@@ -0,0 +1,34 @@
import {fireEvent, render, screen} from '@testing-library/react';
import {expect, it, vi} from 'vitest';
import type {WAFIPGroup, WAFRuleNode} from '@/lib/services/openflare';
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 onChange = vi.fn();
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]})}));
});
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()}/>);
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 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']})}));
});
@@ -0,0 +1,39 @@
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';
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 />{!node ? <p className="text-sm text-muted-foreground">选择画布中的节点以查看配置。</p> : <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 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}) {
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>;
}
@@ -0,0 +1,68 @@
'use client';
import {useCallback, useEffect, useMemo, useRef} from 'react';
import {addEdge, Background, Controls, MiniMap, ReactFlow, type Connection, type Edge, type Node, type NodeChange, applyNodeChanges, type EdgeChange, applyEdgeChanges, type ReactFlowInstance} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WAFRuleEdge, WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
import {type GraphIssue, removeNodeFromGraph} from './graph-validation';
import {acceptedNodeChanges, filterRemovableNodeIds, type GraphErrorTarget, isConnectionAllowed, isPersistentEdgeChange} from './editor-behavior';
import {NodeLibrary} from './node-library';
import {RuleNode, type RuleFlowNodeData} from './rule-node';
const nodeTypes = {rule: RuleNode};
export function RuleFlowCanvas({graph, issues, selectedId, selectedEdgeId, focusTarget, onGraphChange, onSelect, onSelectEdge}: {graph: WAFRuleGraph; issues: GraphIssue[]; selectedId?: string; selectedEdgeId?: string; focusTarget?: GraphErrorTarget; onGraphChange: (graph: WAFRuleGraph, persistent?: boolean) => void; onSelect: (id?: string) => void; onSelectEdge: (id?: string) => void}) {
const instance = useRef<ReactFlowInstance<Node<RuleFlowNodeData>, Edge> | null>(null);
const nodes = useMemo<Node<RuleFlowNodeData>[]>(() => graph.nodes.map((rule) => ({id: rule.id, type: 'rule', position: rule.position, selected: rule.id === selectedId, data: {rule, issues: issues.filter((issue) => issue.nodeId === rule.id).length}})), [graph.nodes, issues, selectedId]);
const edges = useMemo<Edge[]>(() => graph.edges.map((edge) => ({id: edge.id, source: edge.source, sourceHandle: edge.source_handle, target: edge.target, selected: edge.id === selectedEdgeId, animated: selectedId === edge.source || selectedId === edge.target})), [graph.edges, selectedEdgeId, selectedId]);
useEffect(() => {
if (!focusTarget || !instance.current) return;
if (focusTarget.kind === 'node') void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4});
else {
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});
}
}, [focusTarget, graph.edges]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
const accepted = acceptedNodeChanges(graph.nodes, changes);
if (accepted.changes.length === 0) 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 positioned = applyNodeChanges(accepted.changes.filter((change) => change.type !== 'remove'), nodes);
const positions = new Map(positioned.map((node) => [node.id, node.position]));
onGraphChange({...next, nodes: next.nodes.map((node) => ({...node, position: positions.get(node.id) ?? node.position}))}, accepted.persistent);
}, [graph, nodes, onGraphChange]);
const onEdgesChange = useCallback((changes: EdgeChange[]) => {
const persistent = changes.filter(isPersistentEdgeChange);
if (persistent.length === 0) return;
const next = applyEdgeChanges(persistent, edges);
onGraphChange({...graph, edges: next.map(toRuleEdge)});
}, [edges, graph, onGraphChange]);
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);
onGraphChange({...graph, edges: next.map(toRuleEdge)});
}, [edges, graph, isValidConnection, onGraphChange]);
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]});
onSelect(id);
}, [graph, onGraphChange, onSelect]);
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}/></div><ReactFlow nodes={nodes} edges={edges} nodeTypes={nodeTypes} onInit={(value) => { instance.current = value; }} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} isValidConnection={isValidConnection} 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/></ReactFlow></section>;
}
function toRuleEdge(edge: Edge): WAFRuleEdge { return {id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target}; }
@@ -0,0 +1,39 @@
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';
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},
} as const;
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) {
const value = data as RuleFlowNodeData;
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>
{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>
))}
</div>
);
}
@@ -0,0 +1,46 @@
import {render} from '@testing-library/react';
import {afterEach, expect, it, vi} from 'vitest';
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});
container.querySelector('a')!.dispatchEvent(event);
expect(window.confirm).toHaveBeenCalledOnce();
expect(event.defaultPrevented).toBe(true);
});
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});
event.preventDefault();
getByRole('link').dispatchEvent(event);
expect(confirm).not.toHaveBeenCalled();
});
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}}));
expect(go).toHaveBeenLastCalledWith(1);
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');
const push = vi.spyOn(history, 'pushState');
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');
});
@@ -0,0 +1,42 @@
'use client';
import {useEffect} from 'react';
import {getHistoryTransition} from './editor-behavior';
const historyIndexKey = '__wafEditorIndex';
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 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);
let restoring = false;
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();
};
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; }
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); };
}, [dirty]);
return null;
}
@@ -0,0 +1,124 @@
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 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(),
}));
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}}};
});
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;
function renderPage() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}, mutations: {retry: false}}});
return render(<QueryClientProvider client={client}><WAFRuleEditorPage/></QueryClientProvider>);
}
beforeEach(() => {
getRule.mockReset();
saveRuleGraph.mockReset();
updateRuleMeta.mockReset();
listIPGroups.mockClear();
toastError.mockReset();
});
it('renders a query error with a working retry action', async () => {
getRule.mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce(rule);
renderPage();
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 出口未连接'));
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());
});
it('optimistically enables a saved valid rule with its current name', async () => {
const disabledRule = {...rule, enabled: false};
let resolveUpdate: (value: WAFRule) => void = () => undefined;
updateRuleMeta.mockImplementation(() => new Promise<WAFRule>((resolve) => {
resolveUpdate = resolve;
}));
getRule.mockResolvedValue(disabledRule);
renderPage();
expect(await screen.findByText('已停用')).toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', {name: '启用规则'}));
await waitFor(() => {
expect(updateRuleMeta).toHaveBeenCalledWith(9, {
name: '边缘防护',
enabled: true,
});
expect(screen.getByText('已启用')).toBeInTheDocument();
});
await act(async () => resolveUpdate({...disabledRule, enabled: true}));
});
it('rolls back an optimistic enabled change when metadata update fails', async () => {
const disabledRule = {...rule, enabled: false};
let rejectUpdate: (reason: Error) => void = () => undefined;
updateRuleMeta.mockImplementation(() => new Promise<WAFRule>((_resolve, reject) => {
rejectUpdate = reject;
}));
getRule.mockResolvedValue(disabledRule);
renderPage();
expect(await screen.findByText('已停用')).toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', {name: '启用规则'}));
await waitFor(() => expect(screen.getByText('已启用')).toBeInTheDocument());
await act(async () => rejectUpdate(new Error('网络不可用')));
await waitFor(() => expect(screen.getByText('已停用')).toBeInTheDocument());
expect(toastError).toHaveBeenCalledWith('网络不可用');
});
it('requires graph changes to be saved before changing enabled state', async () => {
const disabledRule = {...rule, enabled: false};
getRule.mockResolvedValue(disabledRule);
saveRuleGraph.mockResolvedValue({...disabledRule, revision: 2});
renderPage();
const enabledSwitch = await screen.findByRole('switch', {name: '启用规则'});
expect(enabledSwitch).toBeEnabled();
fireEvent.click(screen.getByRole('button', {name: '修改画布'}));
expect(enabledSwitch).toBeDisabled();
fireEvent.click(screen.getByRole('button', {name: '保存'}));
await waitFor(() => expect(enabledSwitch).toBeEnabled());
});
@@ -0,0 +1,123 @@
'use client';
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 {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';
export default function WAFRuleEditorPage() {
return <Suspense fallback={<EditorSkeleton/>}><EditorContent/></Suspense>;
}
function EditorContent() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const id = Number(searchParams.get('id'));
const [graph, setGraph] = useState<WAFRuleGraph>();
const [revision, setRevision] = useState(0);
const [selectedId, setSelectedId] = useState<string>();
const [selectedEdgeId, setSelectedEdgeId] = useState<string>();
const [focusTarget, setFocusTarget] = useState<GraphErrorTarget>();
const [dirty, setDirty] = useState(false);
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 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('规则图已保存'); },
onError: (error) => {
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);
toast.error('保存失败,请检查标记的节点和连线');
},
});
const metaMutation = useMutation({
mutationFn: (enabled: boolean) => services.openflareWaf.updateRuleMeta(id, {
name: ruleQuery.data!.name,
enabled,
}),
onMutate: async (enabled) => {
await queryClient.cancelQueries({queryKey: ruleQueryKey});
const previous = queryClient.getQueryData<WAFRule>(ruleQueryKey);
queryClient.setQueryData<WAFRule>(ruleQueryKey, (current) =>
current ? {...current, enabled} : current,
);
return {previous};
},
onError: (error, _enabled, context) => {
if (context?.previous) {
queryClient.setQueryData(ruleQueryKey, context.previous);
}
toast.error(getErrorMessage(error));
},
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']});
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'); };
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>
<div className="flex shrink-0 items-center gap-2">
<div className="flex items-center gap-2">
<Switch
id="rule-enabled"
checked={ruleQuery.data.enabled}
disabled={dirty || issues.length > 0 || saveMutation.isPending || metaMutation.isPending}
onCheckedChange={(enabled) => metaMutation.mutate(enabled)}
/>
<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>
</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}/><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>; }
+1
View File
@@ -76,6 +76,7 @@ export type {
WAFRuleGraph,
WAFRuleNode,
WAFSaveRuleGraphPayload,
WAFUpdateRuleMetaPayload,
WAFRuleGroup,
WAFRuleGroupPayload,
WAFSiteRuleGroups,
+5
View File
@@ -773,6 +773,11 @@ export interface WAFSaveRuleGraphPayload {
graph: WAFRuleGraph;
}
export interface WAFUpdateRuleMetaPayload {
name: string;
enabled: boolean;
}
export interface WAFRuleGroupPayload {
name: string;
enabled: boolean;
@@ -9,6 +9,7 @@ import type {
WAFRule,
WAFSaveRuleGraphPayload,
WAFSiteRuleGroups,
WAFUpdateRuleMetaPayload,
} from './types';
export class WafService extends OpenFlareBaseService {
@@ -33,12 +34,15 @@ export class WafService extends OpenFlareBaseService {
return this.post<WAFRule>(`/rule-groups/${id}/graph`, payload);
}
static async deleteRuleGroup(id: number): Promise<void> {
return this.post<void>(`/rule-groups/${id}/delete`);
static async updateRuleMeta(
id: number,
payload: WAFUpdateRuleMetaPayload,
): Promise<WAFRule> {
return this.post<WAFRule>(`/rule-groups/${id}/meta`, payload);
}
static async updateRuleGroupSites(id: number, ids: number[]): Promise<WAFRule> {
return this.post<WAFRule>(`/rule-groups/${id}/sites`, { ids });
static async deleteRuleGroup(id: number): Promise<void> {
return this.post<void>(`/rule-groups/${id}/delete`);
}
static async listSiteRuleGroups(routeId: number): Promise<WAFSiteRuleGroups> {
+22 -8
View File
@@ -6,7 +6,7 @@ import {beforeEach, describe, expect, it, vi} from 'vitest';
import WafPage from '@/app/(main)/waf/page';
import apiClient from '@/lib/services/core/api-client';
import {ProxyRouteService, WafService} from '@/lib/services/openflare';
import {WafService} from '@/lib/services/openflare';
import type {WAFSiteRuleGroups} from '@/lib/services/openflare';
import {WafService as DirectWafService} from '@/lib/services/openflare/waf.service';
@@ -32,11 +32,6 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
listIPGroups: vi.fn(),
createRule: vi.fn(),
deleteRuleGroup: vi.fn(),
updateRuleGroupSites: vi.fn(),
},
ProxyRouteService: {
...actual.ProxyRouteService,
list: vi.fn(),
},
};
});
@@ -109,6 +104,27 @@ describe('WafService rule graph API', () => {
undefined,
);
});
it('updates rule metadata with the current name and enabled state', async () => {
vi.mocked(apiClient.post).mockResolvedValue({
data: {error_msg: '', data: {...ruleSummary, enabled: true}},
} as AxiosResponse);
await DirectWafService.updateRuleMeta(17, {
name: '入口防护',
enabled: true,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/waf/rule-groups/17/meta',
{name: '入口防护', enabled: true},
undefined,
);
});
it('does not expose the retired rule-to-sites binding service', () => {
expect(DirectWafService).not.toHaveProperty('updateRuleGroupSites');
});
});
describe('WAF rule creation flow', () => {
@@ -118,8 +134,6 @@ describe('WAF rule creation flow', () => {
vi.mocked(WafService.listRuleGroups).mockResolvedValue([]);
vi.mocked(WafService.listIPGroups).mockReset();
vi.mocked(WafService.listIPGroups).mockResolvedValue([]);
vi.mocked(ProxyRouteService.list).mockReset();
vi.mocked(ProxyRouteService.list).mockResolvedValue([]);
vi.mocked(WafService.createRule).mockReset();
vi.mocked(WafService.createRule).mockResolvedValue(ruleSummary);
});