mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
feat(waf): 规则编辑器一键格式化节点布局
按从开始节点出发的层次从左到右整理坐标,并 fitView 到画布。
This commit is contained in:
@@ -30,6 +30,7 @@ sidebar: false
|
|||||||
|
|
||||||
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||||
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
|
- WAF 规则画布支持右键删除节点或连线,并屏蔽浏览器默认右键菜单。
|
||||||
|
- WAF 规则编辑器支持一键格式化布局,按流程层次自动整理节点位置。
|
||||||
|
|
||||||
## [v3.4.0] - 2026-07-19
|
## [v3.4.0] - 2026-07-19
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string[]>();
|
||||||
|
const indegree = new Map<string, number>();
|
||||||
|
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<string, number>();
|
||||||
|
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<number, WAFRuleNode[]>();
|
||||||
|
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<string, { x: number; y: number }>();
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -25,9 +25,10 @@ import {
|
|||||||
type NodeChange,
|
type NodeChange,
|
||||||
type ReactFlowInstance,
|
type ReactFlowInstance,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { AlignHorizontalSpaceAround, Trash2 } from 'lucide-react';
|
||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare';
|
import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
isConnectionAllowed,
|
isConnectionAllowed,
|
||||||
isPersistentEdgeChange,
|
isPersistentEdgeChange,
|
||||||
} from './editor-behavior';
|
} from './editor-behavior';
|
||||||
|
import { layoutRuleGraph } from './graph-layout';
|
||||||
import {
|
import {
|
||||||
type GraphIssue,
|
type GraphIssue,
|
||||||
removeEdgeFromGraph,
|
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 =
|
const contextNode =
|
||||||
contextMenu?.kind === 'node'
|
contextMenu?.kind === 'node'
|
||||||
? graph.nodes.find((node) => node.id === contextMenu.id)
|
? 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'
|
className='relative min-w-0 flex-1 bg-muted/20'
|
||||||
onContextMenu={suppressBrowserContextMenu}
|
onContextMenu={suppressBrowserContextMenu}
|
||||||
>
|
>
|
||||||
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
|
<div className='absolute left-4 top-4 z-10 flex flex-wrap items-center gap-2 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
|
||||||
<NodeLibrary />
|
<NodeLibrary />
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
title='自动整理节点与连线布局'
|
||||||
|
onClick={formatLayout}
|
||||||
|
>
|
||||||
|
<AlignHorizontalSpaceAround data-icon='inline-start' />
|
||||||
|
格式化布局
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
|||||||
Reference in New Issue
Block a user