mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 15:06:36 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -76,6 +76,7 @@ export type {
|
||||
WAFRuleGraph,
|
||||
WAFRuleNode,
|
||||
WAFSaveRuleGraphPayload,
|
||||
WAFUpdateRuleMetaPayload,
|
||||
WAFRuleGroup,
|
||||
WAFRuleGroupPayload,
|
||||
WAFSiteRuleGroups,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user