From fd9348b7bdef5e601e4dbcbfe75baa5392f9808d Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 19 Jul 2026 12:49:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(waf):=20=E8=A7=84=E5=88=99=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E4=B8=80=E9=94=AE=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=B8=83=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按从开始节点出发的层次从左到右整理坐标,并 fitView 到画布。 --- docs/changelog/index.md | 1 + .../editor/components/graph-layout.test.ts | 76 +++++++++++++ .../rules/editor/components/graph-layout.ts | 105 ++++++++++++++++++ .../editor/components/rule-flow-canvas.tsx | 28 ++++- 4 files changed, 208 insertions(+), 2 deletions(-) create mode 100644 frontend/app/(main)/waf/rules/editor/components/graph-layout.test.ts create mode 100644 frontend/app/(main)/waf/rules/editor/components/graph-layout.ts diff --git a/docs/changelog/index.md b/docs/changelog/index.md index 4e2a4a9f..e2e6b7f5 100644 --- a/docs/changelog/index.md +++ b/docs/changelog/index.md @@ -30,6 +30,7 @@ sidebar: false - WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。 - WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。 +- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。 ## [v3.4.0] - 2026-07-19 diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-layout.test.ts b/frontend/app/(main)/waf/rules/editor/components/graph-layout.test.ts new file mode 100644 index 00000000..9939b102 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/graph-layout.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; + +import type { WAFRuleGraph } from '@/lib/services/openflare'; + +import { layoutRuleGraph } from './graph-layout'; + +const sampleGraph: WAFRuleGraph = { + schema_version: 1, + nodes: [ + { + id: 'start', + type: 'start', + position: { x: 500, y: 400 }, + config: {}, + }, + { + id: 'match', + type: 'ip_match', + position: { x: 10, y: 10 }, + config: { ips: [], cidrs: [], ip_group_ids: [] }, + }, + { + id: 'allow', + type: 'allow', + position: { x: 0, y: 0 }, + config: {}, + }, + { + id: 'block', + type: 'block', + position: { x: 99, y: 99 }, + config: { status_code: 403, response_body: '' }, + }, + ], + edges: [ + { + id: 'e1', + source: 'start', + source_handle: 'next', + target: 'match', + }, + { + id: 'e2', + source: 'match', + source_handle: 'true', + target: 'allow', + }, + { + id: 'e3', + source: 'match', + source_handle: 'false', + target: 'block', + }, + ], +}; + +describe('layoutRuleGraph', () => { + it('places start left of match and match left of terminals', () => { + const laid = layoutRuleGraph(sampleGraph); + const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n])); + expect(byId.start.position.x).toBeLessThan(byId.match.position.x); + expect(byId.match.position.x).toBeLessThan(byId.allow.position.x); + expect(byId.match.position.x).toBeLessThan(byId.block.position.x); + }); + + it('keeps edges unchanged', () => { + const laid = layoutRuleGraph(sampleGraph); + expect(laid.edges).toEqual(sampleGraph.edges); + }); + + it('separates sibling terminals on y axis', () => { + const laid = layoutRuleGraph(sampleGraph); + const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n])); + expect(byId.allow.position.y).not.toBe(byId.block.position.y); + }); +}); diff --git a/frontend/app/(main)/waf/rules/editor/components/graph-layout.ts b/frontend/app/(main)/waf/rules/editor/components/graph-layout.ts new file mode 100644 index 00000000..86ac96c4 --- /dev/null +++ b/frontend/app/(main)/waf/rules/editor/components/graph-layout.ts @@ -0,0 +1,105 @@ +import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare'; + +const NODE_WIDTH = 220; +const NODE_HEIGHT = 72; +const GAP_X = 120; +const GAP_Y = 48; +const ORIGIN_X = 48; +const ORIGIN_Y = 48; + +/** Left-to-right layered layout for the WAF rule DAG. Edges are unchanged. */ +export function layoutRuleGraph(graph: WAFRuleGraph): WAFRuleGraph { + if (graph.nodes.length === 0) return graph; + + const children = new Map(); + const indegree = new Map(); + for (const node of graph.nodes) { + children.set(node.id, []); + indegree.set(node.id, 0); + } + for (const edge of graph.edges) { + if (!children.has(edge.source) || !indegree.has(edge.target)) continue; + children.get(edge.source)!.push(edge.target); + indegree.set(edge.target, (indegree.get(edge.target) ?? 0) + 1); + } + + const start = + graph.nodes.find((node) => node.type === 'start') ?? graph.nodes[0]; + const depth = new Map(); + const queue: string[] = [start.id]; + depth.set(start.id, 0); + + while (queue.length > 0) { + const id = queue.shift()!; + const d = depth.get(id) ?? 0; + for (const child of children.get(id) ?? []) { + const next = d + 1; + const prev = depth.get(child); + if (prev === undefined || next > prev) { + depth.set(child, next); + queue.push(child); + } + } + } + + // Unreachable nodes (no path from start) sit after the main layers. + let maxDepth = 0; + for (const value of depth.values()) maxDepth = Math.max(maxDepth, value); + let orphanColumn = maxDepth + 1; + for (const node of graph.nodes) { + if (!depth.has(node.id)) { + depth.set(node.id, orphanColumn); + orphanColumn += 1; + } + } + + const columns = new Map(); + for (const node of graph.nodes) { + const col = depth.get(node.id) ?? 0; + const list = columns.get(col) ?? []; + list.push(node); + columns.set(col, list); + } + + for (const [, list] of columns) { + list.sort((a, b) => { + const rank = (node: WAFRuleNode) => { + if (node.type === 'start') return 0; + if (node.type === 'allow') return 2; + if (node.type === 'block') return 3; + return 1; + }; + const diff = rank(a) - rank(b); + if (diff !== 0) return diff; + return a.id.localeCompare(b.id); + }); + } + + let maxRows = 1; + for (const list of columns.values()) maxRows = Math.max(maxRows, list.length); + + const positions = new Map(); + const sortedCols = [...columns.keys()].sort((a, b) => a - b); + for (const col of sortedCols) { + const list = columns.get(col) ?? []; + const blockHeight = + list.length * NODE_HEIGHT + Math.max(0, list.length - 1) * GAP_Y; + const totalHeight = + maxRows * NODE_HEIGHT + Math.max(0, maxRows - 1) * GAP_Y; + const offsetY = ORIGIN_Y + (totalHeight - blockHeight) / 2; + list.forEach((node, index) => { + positions.set(node.id, { + x: ORIGIN_X + col * (NODE_WIDTH + GAP_X), + y: offsetY + index * (NODE_HEIGHT + GAP_Y), + }); + }); + } + + return { + ...graph, + nodes: graph.nodes.map((node) => ({ + ...node, + position: positions.get(node.id) ?? node.position, + })), + }; +} diff --git a/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx b/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx index 07ba89b9..fd2e0fd9 100644 --- a/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx +++ b/frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx @@ -25,9 +25,10 @@ import { type NodeChange, type ReactFlowInstance, } from '@xyflow/react'; -import { Trash2 } from 'lucide-react'; +import { AlignHorizontalSpaceAround, Trash2 } from 'lucide-react'; import '@xyflow/react/dist/style.css'; +import { Button } from '@/components/ui/button'; import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare'; import { cn } from '@/lib/utils'; @@ -38,6 +39,7 @@ import { isConnectionAllowed, isPersistentEdgeChange, } from './editor-behavior'; +import { layoutRuleGraph } from './graph-layout'; import { type GraphIssue, removeEdgeFromGraph, @@ -326,6 +328,18 @@ export function RuleFlowCanvas({ [], ); + const formatLayout = useCallback(() => { + const next = layoutRuleGraph(graph); + onGraphChange(next); + setContextMenu(null); + requestAnimationFrame(() => { + void instance.current?.fitView({ + ...initialFitViewOptions, + duration: 280, + }); + }); + }, [graph, onGraphChange]); + const contextNode = contextMenu?.kind === 'node' ? graph.nodes.find((node) => node.id === contextMenu.id) @@ -343,8 +357,18 @@ export function RuleFlowCanvas({ className='relative min-w-0 flex-1 bg-muted/20' onContextMenu={suppressBrowserContextMenu} > -
+
+