fix(frontend): stabilize WAF rule canvas

Use React Flow controlled node state, support deleting nodes and edges, and show node properties only after selection.
This commit is contained in:
ryan
2026-07-13 14:58:57 +08:00
parent 74ddf97b36
commit 85d5c8568c
12 changed files with 245 additions and 38 deletions
+1
View File
@@ -33,6 +33,7 @@ sidebar: false
### 修复
- WAF 规则编辑器支持通过按钮或键盘删除普通节点和连线,节点属性栏改为选中节点后按需显示,并修复拖动节点时因 React Flow 初始化状态丢失导致的画面闪烁与 error 015。
- WAF 规则编辑器新增启用/停用控制,并移除已废弃的规则组侧站点绑定入口;站点规则顺序统一在反代路由详情中管理。
- 修复 Agent 心跳无法从活动 WAF 运行图发现 IP 组引用的问题,并对发布与同步的完整 IP 组快照增加 20 MiB 聚合容量保护。
- 修复 Agent 增量同步长期保留已取消引用的 WAF IP 组、最终阻塞合法热更新的问题;配置同步现按活动引用集合权威收敛,实时广播仅更新本地现存组。
+3 -2
View File
@@ -85,9 +85,10 @@ Agent 必须先原子替换 IP 组 JSON,最后原子更新 checksum,使 Work
React Flow 编辑页采用全宽画布和固定右侧属性栏:
* 顶部提供返回、规则名称、启用状态、校验状态和保存操作。
* 画布支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力。
* 画布支持缩放、平移、框选、删除、自动布局和 MiniMap/Controls 等必要导航能力;节点拖动由 React Flow 本地受控状态实时处理,拖动结束后才把坐标写回编辑图。
* “添加处理单元”提供 IP 匹配、地域匹配、PoW 和阻止;开始与通过由默认图提供且不可删除或重复添加。
* 选中节点后在右侧属性栏编辑配置,画布与上下文始终可见。
* 选中普通节点或连线后可使用画布删除按钮或 Delete/Backspace 删除;删除节点时同步移除关联连线。
* 右侧属性栏默认隐藏,选中节点后才显示并用于编辑配置;点击连线或画布空白处时收起。
* 离开存在未保存变更的页面前必须提示;保存冲突和 Server 校验错误应定位到相关节点或边。
WAF 列表展示规则名称、启用状态、节点数量、应用路由数量和更新时间。新建规则的对话框只有名称字段,成功后立即导航到编排页面。
+4
View File
@@ -13,6 +13,10 @@ OpenFlare WAF 使用可视化有向无环图编排规则。新建规则时只填
服务端会拒绝循环、悬空出口、不可达节点、重复端口连接和无效配置。保存时携带页面加载得到的 `revision`;发生 409 冲突时应重新加载,避免覆盖他人修改。
选中普通节点或连线后,可点击画布右上角的删除按钮,或按 Delete/Backspace 删除。删除节点会同时删除关联连线;唯一的“开始”和“通过”节点不可删除。拖动节点只在松开时记录最终坐标,不会在移动过程中反复重建画布状态。
右侧节点属性栏默认隐藏,点击节点后显示;点击连线或画布空白区域后自动收起。
## 绑定与生效
启用的全局规则固定最先执行;路由绑定的自定义规则严格按列表顺序执行。调整顺序后需要发布配置版本,规则拓扑才会随 OpenResty reload 生效。
+2 -2
View File
@@ -10,9 +10,9 @@
## 实现状态(2026-07-13)
Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。
Tasks 1–11 已实现,包含三段数据库迁移、图模型与编译器、规则 API、发布快照、OpenResty 内存执行器、IP 组协调刷新、React Flow 编辑器、有序绑定、GeoLite2 City/Country 支持以及中文文档与 Swagger 更新。React Flow 画布使用本地受控节点状态处理拖动,并支持显式或键盘删除普通节点与连线。Country 与 City MMDB 均随 Agent 内嵌,缺失文件在启动时从程序内初始化,网络仅用于后续周期更新。
当前工作区已完成 `go test ./...`、前端全量 Vitest(54 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。
当前工作区已完成 `go test ./...`、前端全量 Vitest(56 项)、`make swagger`、`make code-check` 与 `git diff --check` 验证。Next.js 生产构建在本机持续停留于 Turbopack 的 `Creating an optimized production build ...`,未返回编译错误或成功状态,故不计为通过。
## Global Constraints
@@ -16,6 +16,19 @@ describe('React Flow persistence filtering', () => {
expect(isPersistentNodeChange({type: 'remove', id: 'n'})).toBe(true);
expect(isPersistentEdgeChange({type: 'remove', id: 'e'})).toBe(true);
});
it('forwards transient node changes to React Flow without persisting them', () => {
const changes = [
{type: 'dimensions' as const, id: 'match', dimensions: {width: 176, height: 64}},
{type: 'select' as const, id: 'match', selected: true},
{type: 'position' as const, id: 'match', position: {x: 24, y: 48}, dragging: true},
];
const nodes: WAFRuleGraph['nodes'] = [
{id: 'match', type: 'ip_match', position: {x: 0, y: 0}, config: {ips: [], cidrs: [], ip_group_ids: []}},
];
expect(acceptedNodeChanges(nodes, changes)).toEqual({changes, persistent: false});
});
});
describe('editor safety constraints', () => {
@@ -1,11 +1,11 @@
import type {EdgeChange, NodeChange} from '@xyflow/react';
import type {EdgeChange, Node, 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 {
export function isPersistentNodeChange<NodeType extends Node = Node>(change: NodeChange<NodeType>): boolean {
return change.type === 'remove' || change.type === 'add' || change.type === 'replace' || (change.type === 'position' && change.dragging === false && Boolean(change.position));
}
@@ -17,8 +17,8 @@ export function filterRemovableNodeIds(nodes: WAFRuleNode[], ids: string[]): str
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));
export function acceptedNodeChanges<NodeType extends Node = Node>(nodes: WAFRuleNode[], changes: NodeChange<NodeType>[]): {changes: NodeChange<NodeType>[]; persistent: boolean} {
const accepted = changes.filter((change) => change.type !== 'remove' || filterRemovableNodeIds(nodes, [change.id]).length === 1);
return {changes: accepted, persistent: accepted.some(isPersistentNodeChange)};
}
@@ -2,7 +2,7 @@ import {describe, expect, it} from 'vitest';
import type {WAFRuleGraph} from '@/lib/services/openflare';
import {removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation';
import {removeEdgeFromGraph, removeNodeFromGraph, validateGraph, wouldCreateCycle} from './graph-validation';
const validGraph = (): WAFRuleGraph => ({
schema_version: 1,
@@ -110,6 +110,15 @@ it('removes incident edges when deleting a node', () => {
expect(next.edges).toEqual([]);
});
it('removes a selected connection without changing nodes', () => {
const graph = validGraph();
const edgeId = graph.edges[0].id;
const next = removeEdgeFromGraph(graph, edgeId);
expect(next.nodes).toEqual(graph.nodes);
expect(next.edges).toHaveLength(graph.edges.length - 1);
expect(next.edges.some((edge) => edge.id === edgeId)).toBe(false);
});
it('detects whether a new connection creates a cycle', () => {
expect(wouldCreateCycle(validGraph(), 'allow', 'start')).toBe(true);
expect(wouldCreateCycle(validGraph(), 'start', 'block')).toBe(false);
@@ -144,3 +144,7 @@ export function wouldCreateCycle(graph: WAFRuleGraph, source: string, target: st
export function removeNodeFromGraph(graph: WAFRuleGraph, nodeId: string): WAFRuleGraph {
return {...graph, nodes: graph.nodes.filter((node) => node.id !== nodeId), edges: graph.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)};
}
export function removeEdgeFromGraph(graph: WAFRuleGraph, edgeId: string): WAFRuleGraph {
return {...graph, edges: graph.edges.filter((edge) => edge.id !== edgeId)};
}
@@ -15,8 +15,8 @@ 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>;
export function NodeProperties({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) {
return <aside className="w-80 shrink-0 border-l bg-card"><ScrollArea className="h-full"><div className="flex flex-col gap-5 p-5"><div className="flex items-center gap-2"><Settings2 className="size-5 text-primary"/><div><h2 className="text-sm font-semibold">节点属性</h2><p className="text-xs text-muted-foreground">配置当前处理单元</p></div></div><Separator /><PropertyFields node={node} ipGroups={ipGroups} onChange={onChange}/></div></ScrollArea></aside>;
}
function PropertyFields({node, ipGroups, onChange}: {node: WAFRuleNode; ipGroups: WAFIPGroup[]; onChange: (node: WAFRuleNode) => void}) {
@@ -1,49 +1,124 @@
'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 {useCallback, useEffect, useRef} from 'react';
import {
addEdge,
applyEdgeChanges,
Background,
Controls,
MiniMap,
ReactFlow,
useEdgesState,
useNodesState,
type Connection,
type Edge,
type EdgeChange,
type Node,
type NodeChange,
type ReactFlowInstance,
} from '@xyflow/react';
import {Trash2} from 'lucide-react';
import '@xyflow/react/dist/style.css';
import {Button} from '@/components/ui/button';
import type {WAFRuleEdge, WAFRuleGraph, WAFRuleNode} from '@/lib/services/openflare';
import {type GraphIssue, removeNodeFromGraph} from './graph-validation';
import {acceptedNodeChanges, filterRemovableNodeIds, type GraphErrorTarget, isConnectionAllowed, isPersistentEdgeChange} from './editor-behavior';
import {
acceptedNodeChanges,
filterRemovableNodeIds,
type GraphErrorTarget,
isConnectionAllowed,
isPersistentEdgeChange,
} from './editor-behavior';
import {type GraphIssue, removeEdgeFromGraph, removeNodeFromGraph} from './graph-validation';
import {NodeLibrary} from './node-library';
import {RuleNode, type RuleFlowNodeData} from './rule-node';
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]);
type FlowNode = Node<RuleFlowNodeData>;
type FlowEdge = Edge;
interface RuleFlowCanvasProps {
graph: WAFRuleGraph;
issues: GraphIssue[];
selectedId?: string;
selectedEdgeId?: string;
focusTarget?: GraphErrorTarget;
onGraphChange: (graph: WAFRuleGraph, persistent?: boolean) => void;
onSelect: (id?: string) => void;
onSelectEdge: (id?: string) => void;
}
export function RuleFlowCanvas({
graph,
issues,
selectedId,
selectedEdgeId,
focusTarget,
onGraphChange,
onSelect,
onSelectEdge,
}: RuleFlowCanvasProps) {
const instance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
const [nodes, setNodes, applyFlowNodeChanges] = useNodesState<FlowNode>(buildFlowNodes(graph, issues, selectedId));
const [edges, setEdges, applyFlowEdgeChanges] = useEdgesState<FlowEdge>(buildFlowEdges(graph, selectedId, selectedEdgeId));
useEffect(() => {
setNodes((current) => buildFlowNodes(graph, issues, selectedId, current));
}, [graph, issues, selectedId, setNodes]);
useEffect(() => {
setEdges((current) => buildFlowEdges(graph, selectedId, selectedEdgeId, current));
}, [graph, selectedEdgeId, selectedId, setEdges]);
useEffect(() => {
if (!focusTarget || !instance.current) return;
if (focusTarget.kind === 'node') void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4});
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});
if (focusTarget.kind === 'node') {
void instance.current.fitView({nodes: [{id: focusTarget.id}], duration: 350, maxZoom: 1.4});
return;
}
const edge = graph.edges.find((item) => item.id === focusTarget.id);
if (edge) void instance.current.fitView({nodes: [{id: edge.source}, {id: edge.target}], duration: 350, maxZoom: 1.4});
}, [focusTarget, graph.edges]);
const onNodesChange = useCallback((changes: NodeChange[]) => {
const onNodesChange = useCallback((changes: NodeChange<FlowNode>[]) => {
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));
applyFlowNodeChanges(accepted.changes);
if (!accepted.persistent) return;
const removals = filterRemovableNodeIds(
graph.nodes,
accepted.changes.filter((change) => change.type === 'remove').map((change) => change.id),
);
let next = graph;
for (const id of removals) next = removeNodeFromGraph(next, id);
const 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 positions = new Map<string, {x: number; y: number}>();
for (const change of accepted.changes) {
if (change.type === 'position' && change.dragging === false && change.position) {
positions.set(change.id, change.position);
}
}
next = {...next, nodes: next.nodes.map((node) => ({...node, position: positions.get(node.id) ?? node.position}))};
onGraphChange(next);
if (selectedId && removals.includes(selectedId)) onSelect(undefined);
}, [applyFlowNodeChanges, graph, onGraphChange, onSelect, selectedId]);
const onEdgesChange = useCallback((changes: EdgeChange<FlowEdge>[]) => {
applyFlowEdgeChanges(changes);
const persistent = changes.filter(isPersistentEdgeChange);
if (persistent.length === 0) return;
const next = applyEdgeChanges(persistent, edges);
onGraphChange({...graph, edges: next.map(toRuleEdge)});
}, [edges, graph, onGraphChange]);
if (selectedEdgeId && persistent.some((change) => change.type === 'remove' && change.id === selectedEdgeId)) {
onSelectEdge(undefined);
}
}, [applyFlowEdgeChanges, edges, graph, onGraphChange, onSelectEdge, selectedEdgeId]);
const isValidConnection = useCallback((connection: Edge | Connection) => {
return isConnectionAllowed(graph, connection);
@@ -52,17 +127,109 @@ export function RuleFlowCanvas({graph, issues, selectedId, selectedEdgeId, focus
const onConnect = useCallback((connection: Connection) => {
if (!isValidConnection(connection)) return;
const next = addEdge({...connection, id: `${connection.source}-${connection.sourceHandle}-${connection.target}`}, edges);
setEdges(next);
onGraphChange({...graph, edges: next.map(toRuleEdge)});
}, [edges, graph, isValidConnection, onGraphChange]);
}, [edges, graph, isValidConnection, onGraphChange, setEdges]);
const addNode = useCallback((type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
const config = type === 'ip_match' ? {ips: [], cidrs: [], ip_group_ids: []} : type === 'geo_match' ? {countries: [], regions: []} : type === 'pow' ? {algorithm: 'fast' as const, difficulty: 4, session_ttl: 3600, challenge_ttl: 300} : {status_code: 403, response_body: ''};
const config = type === 'ip_match'
? {ips: [], cidrs: [], ip_group_ids: []}
: type === 'geo_match'
? {countries: [], regions: []}
: type === 'pow'
? {algorithm: 'fast' as const, difficulty: 4, session_ttl: 3600, challenge_ttl: 300}
: {status_code: 403, response_body: ''};
onGraphChange({...graph, nodes: [...graph.nodes, {id, type, position: {x: 240, y: 140 + graph.nodes.length * 24}, config} as WAFRuleNode]});
onSelectEdge(undefined);
onSelect(id);
}, [graph, onGraphChange, onSelect]);
}, [graph, onGraphChange, onSelect, onSelectEdge]);
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>;
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
const canDeleteNode = Boolean(selectedNode && !['start', 'allow'].includes(selectedNode.type));
const canDeleteEdge = Boolean(selectedEdgeId && graph.edges.some((edge) => edge.id === selectedEdgeId));
const canDelete = canDeleteNode || canDeleteEdge;
const deleteLabel = canDeleteEdge ? '删除连线' : canDeleteNode ? '删除节点' : selectedNode ? '系统节点不可删除' : '请选择可删除项';
const deleteSelection = useCallback(() => {
if (canDeleteEdge && selectedEdgeId) {
setEdges((current) => current.filter((edge) => edge.id !== selectedEdgeId));
onGraphChange(removeEdgeFromGraph(graph, selectedEdgeId));
onSelectEdge(undefined);
return;
}
if (canDeleteNode && selectedId) {
setNodes((current) => current.filter((node) => node.id !== selectedId));
setEdges((current) => current.filter((edge) => edge.source !== selectedId && edge.target !== selectedId));
onGraphChange(removeNodeFromGraph(graph, selectedId));
onSelect(undefined);
}
}, [canDeleteEdge, canDeleteNode, graph, onGraphChange, onSelect, onSelectEdge, selectedEdgeId, selectedId, setEdges, setNodes]);
return (
<section className="relative min-w-0 flex-1 bg-muted/20">
<div className="absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur">
<NodeLibrary onAdd={addNode}/>
</div>
<div className="absolute right-4 top-4 z-10">
<Button variant="destructive" size="sm" disabled={!canDelete} onClick={deleteSelection}>
<Trash2 data-icon="inline-start"/>
{deleteLabel}
</Button>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onInit={(value) => { instance.current = value; }}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onBeforeDelete={async ({nodes: deletingNodes}) => !deletingNodes.some((node) => ['start', 'allow'].includes(node.data.rule.type))}
onNodeClick={(_, node) => { onSelectEdge(undefined); onSelect(node.id); }}
onEdgeClick={(_, edge) => { onSelect(undefined); onSelectEdge(edge.id); }}
onPaneClick={() => { onSelect(undefined); onSelectEdge(undefined); }}
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}; }
function buildFlowNodes(graph: WAFRuleGraph, issues: GraphIssue[], selectedId?: string, current: FlowNode[] = []): FlowNode[] {
const existing = new Map(current.map((node) => [node.id, node]));
const issueCounts = new Map<string, number>();
for (const issue of issues) {
if (issue.nodeId) issueCounts.set(issue.nodeId, (issueCounts.get(issue.nodeId) ?? 0) + 1);
}
return graph.nodes.map((rule) => ({
...existing.get(rule.id),
id: rule.id,
type: 'rule',
position: rule.position,
selected: rule.id === selectedId,
data: {rule, issues: issueCounts.get(rule.id) ?? 0},
}));
}
function buildFlowEdges(graph: WAFRuleGraph, selectedId?: string, selectedEdgeId?: string, current: FlowEdge[] = []): FlowEdge[] {
const existing = new Map(current.map((edge) => [edge.id, edge]));
return graph.edges.map((edge) => ({
...existing.get(edge.id),
id: edge.id,
source: edge.source,
sourceHandle: edge.source_handle,
target: edge.target,
selected: edge.id === selectedEdgeId,
animated: selectedId === edge.source || selectedId === edge.target,
}));
}
function toRuleEdge(edge: Edge): WAFRuleEdge {
return {id: edge.id, source: edge.source, source_handle: edge.sourceHandle ?? '', target: edge.target};
}
@@ -99,7 +99,12 @@ function EditorContent() {
<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 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
@@ -115,7 +120,9 @@ function EditorContent() {
<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 className="flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background shadow-sm">
<RuleFlowCanvas graph={graph} issues={issues} selectedId={selectedId} selectedEdgeId={selectedEdgeId} focusTarget={focusTarget} onGraphChange={changeGraph} onSelect={setSelectedId} onSelectEdge={setSelectedEdgeId}/>{selected && <NodeProperties node={selected} ipGroups={ipGroupsQuery.data ?? []} onChange={changeNode}/>}
</div>
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@ const nextConfig: NextConfig = {
// Prevent 308 redirects on /api/* trailing slashes; dev rewrites proxy legacy APIs as-is.
skipTrailingSlashRedirect: true,
experimental: {
turbopackFileSystemCacheForBuild: true,
},
...(isExport ? { output: 'export' } : {
async redirects() {