mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 10:16:37 +08:00
feat(waf): 规则编辑器节点自定义命名与拖放添加
对齐后端 label 字段;属性栏可编辑显示名称;节点库改为拖到画布落点创建。
This commit is contained in:
@@ -21,6 +21,10 @@ sidebar: false
|
|||||||
|
|
||||||
## [unreleased]
|
## [unreleased]
|
||||||
|
|
||||||
|
### 改进
|
||||||
|
|
||||||
|
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||||
|
|
||||||
## [v3.4.0] - 2026-07-19
|
## [v3.4.0] - 2026-07-19
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# WAF Editor Node Label + Drag-Add Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let users rename WAF rule nodes via optional `label`, and add nodes by dragging from the library onto the canvas drop position (no click-to-fixed-offset).
|
||||||
|
|
||||||
|
**Architecture:** Frontend-only. Align TS `WAFRuleNode` with backend `label`. Pure helpers for display name and default node factory. Node library is drag source; React Flow pane handles drop with `screenToFlowPosition`. Properties panel edits `label` for non-system nodes.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js App Router, React, TypeScript, `@xyflow/react`, Vitest + Testing Library, shadcn/ui.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-07-19-waf-editor-node-label-drag-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- No backend / schema_version / note field changes.
|
||||||
|
- System nodes `start` / `allow`: no rename UI.
|
||||||
|
- New nodes: no default `label` (type name shown).
|
||||||
|
- Drag-only add; remove click-add.
|
||||||
|
- After code: relevant vitest pass; run `make prettier` / `make code-check` if touching repo gates.
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/lib/services/openflare/types.ts` | Add `label?: string` to all `WAFRuleNode` variants |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/node-factory.ts` | `NODE_TYPE_LABELS`, `displayNodeTitle`, `createRuleNode`, drag MIME constant |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts` | Unit tests for title + factory |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx` | Use `displayNodeTitle` |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx` | 「显示名称」Input |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx` | Label edit + system node |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/node-library.tsx` | Draggable items, no onClick |
|
||||||
|
| `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx` | Drop handler + position-aware create |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Types + pure helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/lib/services/openflare/types.ts`
|
||||||
|
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.ts`
|
||||||
|
- Create: `frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `WAF_NODE_DRAG_MIME`, `AddableNodeType`, `NODE_TYPE_LABELS`, `displayNodeTitle(node)`, `createRuleNode(type, position)`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `label?: string` to every `WAFRuleNode` union member** in `types.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write failing tests** in `node-factory.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
createRuleNode,
|
||||||
|
displayNodeTitle,
|
||||||
|
NODE_TYPE_LABELS,
|
||||||
|
} from './node-factory';
|
||||||
|
|
||||||
|
describe('displayNodeTitle', () => {
|
||||||
|
it('uses trimmed label when present', () => {
|
||||||
|
expect(
|
||||||
|
displayNodeTitle({
|
||||||
|
id: 'x',
|
||||||
|
type: 'ip_match',
|
||||||
|
label: ' 办公室 ',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||||
|
}),
|
||||||
|
).toBe('办公室');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to type default when label empty', () => {
|
||||||
|
expect(
|
||||||
|
displayNodeTitle({
|
||||||
|
id: 'x',
|
||||||
|
type: 'block',
|
||||||
|
label: ' ',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: { status_code: 403, response_body: '' },
|
||||||
|
}),
|
||||||
|
).toBe(NODE_TYPE_LABELS.block);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRuleNode', () => {
|
||||||
|
it('creates typed node at position without label', () => {
|
||||||
|
const node = createRuleNode('pow', { x: 12, y: 34 });
|
||||||
|
expect(node.type).toBe('pow');
|
||||||
|
expect(node.position).toEqual({ x: 12, y: 34 });
|
||||||
|
expect(node.label).toBeUndefined();
|
||||||
|
expect(node.id.startsWith('pow-')).toBe(true);
|
||||||
|
if (node.type === 'pow') {
|
||||||
|
expect(node.config).toEqual({
|
||||||
|
algorithm: 'fast',
|
||||||
|
difficulty: 4,
|
||||||
|
session_ttl: 3600,
|
||||||
|
challenge_ttl: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `node-factory.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
|
||||||
|
|
||||||
|
export type AddableNodeType = Extract<
|
||||||
|
WAFRuleNode['type'],
|
||||||
|
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
|
||||||
|
start: '开始',
|
||||||
|
ip_match: 'IP 匹配',
|
||||||
|
geo_match: '地域匹配',
|
||||||
|
pow: 'PoW 挑战',
|
||||||
|
allow: '通过',
|
||||||
|
block: '阻止',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayNodeTitle(
|
||||||
|
node: Pick<WAFRuleNode, 'type' | 'label'>,
|
||||||
|
): string {
|
||||||
|
const custom = node.label?.trim();
|
||||||
|
return custom || NODE_TYPE_LABELS[node.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuleNode(
|
||||||
|
type: AddableNodeType,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
): WAFRuleNode {
|
||||||
|
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
if (type === 'ip_match')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||||
|
};
|
||||||
|
if (type === 'geo_match')
|
||||||
|
return { id, type, position, config: { countries: [], regions: [] } };
|
||||||
|
if (type === 'pow')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
config: {
|
||||||
|
algorithm: 'fast',
|
||||||
|
difficulty: 4,
|
||||||
|
session_ttl: 3600,
|
||||||
|
challenge_ttl: 300,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'block',
|
||||||
|
position,
|
||||||
|
config: { status_code: 403, response_body: '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAddableNodeType(value: string): AddableNodeType | null {
|
||||||
|
if (
|
||||||
|
value === 'ip_match' ||
|
||||||
|
value === 'geo_match' ||
|
||||||
|
value === 'pow' ||
|
||||||
|
value === 'block'
|
||||||
|
)
|
||||||
|
return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/lib/services/openflare/types.ts \
|
||||||
|
frontend/app/(main)/waf/rules/editor/components/node-factory.ts \
|
||||||
|
frontend/app/(main)/waf/rules/editor/components/node-factory.test.ts
|
||||||
|
git commit -m "feat(waf): add node label type and factory helpers"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Canvas title + properties label field
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-node.tsx`
|
||||||
|
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.tsx`
|
||||||
|
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tests for properties**
|
||||||
|
|
||||||
|
Add to `node-properties.test.tsx`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it('edits display name for configurable nodes', () => {
|
||||||
|
const node: WAFRuleNode = {
|
||||||
|
id: 'match',
|
||||||
|
type: 'ip_match',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||||
|
};
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||||
|
target: { value: '内网放行' },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ label: '内网放行' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides display name for system nodes', () => {
|
||||||
|
const node: WAFRuleNode = {
|
||||||
|
id: 'start',
|
||||||
|
type: 'start',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||||
|
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement properties field** — at start of each configurable `FieldGroup` (or wrap once before type switch for non-system):
|
||||||
|
|
||||||
|
Prefer extract:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function DisplayNameField({
|
||||||
|
node,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
node: WAFRuleNode;
|
||||||
|
onChange: (node: WAFRuleNode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${node.id}-label`}
|
||||||
|
value={node.label ?? ''}
|
||||||
|
placeholder={/* type default from NODE_TYPE_LABELS */}
|
||||||
|
onChange={(e) => onChange({ ...node, label: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Insert `<DisplayNameField ... />` as first child inside each non-system `FieldGroup`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `rule-node.tsx`** — use `displayNodeTitle(rule)` for main title; keep icon from meta; keep id subtitle.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor/components/node-properties.test.tsx' 'app/(main)/waf/rules/editor/components/node-factory.test.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/app/(main)/waf/rules/editor/components/rule-node.tsx \
|
||||||
|
frontend/app/(main)/waf/rules/editor/components/node-properties.tsx \
|
||||||
|
frontend/app/(main)/waf/rules/editor/components/node-properties.test.tsx
|
||||||
|
git commit -m "feat(waf): show and edit rule node display names"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Drag library + canvas drop
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/app/(main)/waf/rules/editor/components/node-library.tsx`
|
||||||
|
- Modify: `frontend/app/(main)/waf/rules/editor/components/rule-flow-canvas.tsx`
|
||||||
|
- Create (optional pure tests): extend `node-factory.test.ts` for `parseAddableNodeType`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Node library** — remove `onAdd` prop; make each button `draggable` with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
|
||||||
|
e.dataTransfer.setData('text/plain', type);
|
||||||
|
e.dataTransfer.effectAllowed = 'copy';
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `type='button'` + cursor `cursor-grab active:cursor-grabbing`. No `onClick` that adds nodes.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Canvas** — replace `addNode(type)` fixed position with:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const addNodeAt = useCallback(
|
||||||
|
(type: AddableNodeType, position: { x: number; y: number }) => {
|
||||||
|
const node = createRuleNode(type, position);
|
||||||
|
onGraphChange({ ...graph, nodes: [...graph.nodes, node] });
|
||||||
|
onSelectEdge(undefined);
|
||||||
|
onSelect(node.id);
|
||||||
|
},
|
||||||
|
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const raw =
|
||||||
|
e.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
|
||||||
|
e.dataTransfer.getData('text/plain');
|
||||||
|
const type = parseAddableNodeType(raw);
|
||||||
|
if (!type || !instance.current) return;
|
||||||
|
const position = instance.current.screenToFlowPosition({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
});
|
||||||
|
addNodeAt(type, position);
|
||||||
|
},
|
||||||
|
[addNodeAt],
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `onDragOver` / `onDrop` to `<ReactFlow ...>` (xyflow supports these on the component).
|
||||||
|
|
||||||
|
Update `<NodeLibrary />` — no `onAdd`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run editor-related tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm vitest run 'app/(main)/waf/rules/editor'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS (update any tests that assumed click-add)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Format + commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make prettier
|
||||||
|
git add frontend/app/(main)/waf/rules/editor
|
||||||
|
git commit -m "feat(waf): drag-drop nodes onto rule canvas at cursor"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Changelog** — under `docs/changelog/index.md` `[Unreleased]`:
|
||||||
|
|
||||||
|
```md
|
||||||
|
### 改进
|
||||||
|
- WAF 规则编辑器支持为节点自定义显示名称,并从节点库拖放到画布指定位置添加节点。
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/changelog/index.md
|
||||||
|
git commit -m "docs(changelog): WAF 编辑器节点命名与拖放添加"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spec coverage
|
||||||
|
|
||||||
|
| Spec item | Task |
|
||||||
|
|-----------|------|
|
||||||
|
| `label?` on TS types | 1 |
|
||||||
|
| Display title fallback | 1–2 |
|
||||||
|
| Properties 显示名称 | 2 |
|
||||||
|
| System nodes no rename | 2 |
|
||||||
|
| Drag-only library | 3 |
|
||||||
|
| Drop at cursor | 3 |
|
||||||
|
| No note / backend | N/A (omitted) |
|
||||||
|
| Tests | 1–3 |
|
||||||
|
| Changelog | 3 |
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRuleNode,
|
||||||
|
displayNodeTitle,
|
||||||
|
NODE_TYPE_LABELS,
|
||||||
|
parseAddableNodeType,
|
||||||
|
} from './node-factory';
|
||||||
|
|
||||||
|
describe('displayNodeTitle', () => {
|
||||||
|
it('uses trimmed label when present', () => {
|
||||||
|
expect(
|
||||||
|
displayNodeTitle({
|
||||||
|
type: 'ip_match',
|
||||||
|
label: ' 办公室 ',
|
||||||
|
}),
|
||||||
|
).toBe('办公室');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to type default when label empty', () => {
|
||||||
|
expect(
|
||||||
|
displayNodeTitle({
|
||||||
|
type: 'block',
|
||||||
|
label: ' ',
|
||||||
|
}),
|
||||||
|
).toBe(NODE_TYPE_LABELS.block);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createRuleNode', () => {
|
||||||
|
it('creates typed node at position without label', () => {
|
||||||
|
const node = createRuleNode('pow', { x: 12, y: 34 });
|
||||||
|
expect(node.type).toBe('pow');
|
||||||
|
expect(node.position).toEqual({ x: 12, y: 34 });
|
||||||
|
expect(node.label).toBeUndefined();
|
||||||
|
expect(node.id.startsWith('pow-')).toBe(true);
|
||||||
|
if (node.type === 'pow') {
|
||||||
|
expect(node.config).toEqual({
|
||||||
|
algorithm: 'fast',
|
||||||
|
difficulty: 4,
|
||||||
|
session_ttl: 3600,
|
||||||
|
challenge_ttl: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseAddableNodeType', () => {
|
||||||
|
it('accepts addable types and rejects others', () => {
|
||||||
|
expect(parseAddableNodeType('ip_match')).toBe('ip_match');
|
||||||
|
expect(parseAddableNodeType('start')).toBeNull();
|
||||||
|
expect(parseAddableNodeType('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export const WAF_NODE_DRAG_MIME = 'application/openflare-waf-node';
|
||||||
|
|
||||||
|
export type AddableNodeType = Extract<
|
||||||
|
WAFRuleNode['type'],
|
||||||
|
'ip_match' | 'geo_match' | 'pow' | 'block'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
|
||||||
|
start: '开始',
|
||||||
|
ip_match: 'IP 匹配',
|
||||||
|
geo_match: '地域匹配',
|
||||||
|
pow: 'PoW 挑战',
|
||||||
|
allow: '通过',
|
||||||
|
block: '阻止',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function displayNodeTitle(
|
||||||
|
node: Pick<WAFRuleNode, 'type' | 'label'>,
|
||||||
|
): string {
|
||||||
|
const custom = node.label?.trim();
|
||||||
|
return custom || NODE_TYPE_LABELS[node.type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRuleNode(
|
||||||
|
type: AddableNodeType,
|
||||||
|
position: { x: number; y: number },
|
||||||
|
): WAFRuleNode {
|
||||||
|
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
if (type === 'ip_match')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||||
|
};
|
||||||
|
if (type === 'geo_match')
|
||||||
|
return { id, type, position, config: { countries: [], regions: [] } };
|
||||||
|
if (type === 'pow')
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
position,
|
||||||
|
config: {
|
||||||
|
algorithm: 'fast',
|
||||||
|
difficulty: 4,
|
||||||
|
session_ttl: 3600,
|
||||||
|
challenge_ttl: 300,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: 'block',
|
||||||
|
position,
|
||||||
|
config: { status_code: 403, response_body: '' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAddableNodeType(value: string): AddableNodeType | null {
|
||||||
|
if (
|
||||||
|
value === 'ip_match' ||
|
||||||
|
value === 'geo_match' ||
|
||||||
|
value === 'pow' ||
|
||||||
|
value === 'block'
|
||||||
|
)
|
||||||
|
return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,31 +1,39 @@
|
|||||||
import { Ban, Fingerprint, Globe2, Plus, ShieldCheck } from 'lucide-react';
|
import { Ban, Fingerprint, Globe2, ShieldCheck } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
|
||||||
|
|
||||||
type AddableType = Extract<
|
import {
|
||||||
WAFRuleNode['type'],
|
NODE_TYPE_LABELS,
|
||||||
'ip_match' | 'geo_match' | 'pow' | 'block'
|
WAF_NODE_DRAG_MIME,
|
||||||
>;
|
type AddableNodeType,
|
||||||
|
} from './node-factory';
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ type: 'ip_match', label: 'IP 匹配', icon: Fingerprint },
|
{ type: 'ip_match' as const, icon: Fingerprint },
|
||||||
{ type: 'geo_match', label: '地域匹配', icon: Globe2 },
|
{ type: 'geo_match' as const, icon: Globe2 },
|
||||||
{ type: 'pow', label: 'PoW 挑战', icon: ShieldCheck },
|
{ type: 'pow' as const, icon: ShieldCheck },
|
||||||
{ type: 'block', label: '阻止', icon: Ban },
|
{ type: 'block' as const, icon: Ban },
|
||||||
] satisfies { type: AddableType; label: string; icon: typeof Plus }[];
|
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
|
||||||
|
|
||||||
export function NodeLibrary({ onAdd }: { onAdd: (type: AddableType) => void }) {
|
export function NodeLibrary() {
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
{items.map(({ type, label, icon: Icon }) => (
|
{items.map(({ type, icon: Icon }) => (
|
||||||
<Button
|
<Button
|
||||||
key={type}
|
key={type}
|
||||||
|
type='button'
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
onClick={() => onAdd(type)}
|
draggable
|
||||||
|
className='cursor-grab active:cursor-grabbing'
|
||||||
|
onDragStart={(event) => {
|
||||||
|
event.dataTransfer.setData(WAF_NODE_DRAG_MIME, type);
|
||||||
|
event.dataTransfer.setData('text/plain', type);
|
||||||
|
event.dataTransfer.effectAllowed = 'copy';
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon data-icon='inline-start' />
|
<Icon data-icon='inline-start' />
|
||||||
{label}
|
{NODE_TYPE_LABELS[type]}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,35 @@ import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
|||||||
|
|
||||||
import { NodeProperties } from './node-properties';
|
import { NodeProperties } from './node-properties';
|
||||||
|
|
||||||
|
it('edits display name for configurable nodes', () => {
|
||||||
|
const node: WAFRuleNode = {
|
||||||
|
id: 'match',
|
||||||
|
type: 'ip_match',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: { ips: [], cidrs: [], ip_group_ids: [] },
|
||||||
|
};
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<NodeProperties node={node} ipGroups={[]} onChange={onChange} />);
|
||||||
|
fireEvent.change(screen.getByLabelText('显示名称'), {
|
||||||
|
target: { value: '内网放行' },
|
||||||
|
});
|
||||||
|
expect(onChange).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ label: '内网放行' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides display name for system nodes', () => {
|
||||||
|
const node: WAFRuleNode = {
|
||||||
|
id: 'start',
|
||||||
|
type: 'start',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
config: {},
|
||||||
|
};
|
||||||
|
render(<NodeProperties node={node} ipGroups={[]} onChange={vi.fn()} />);
|
||||||
|
expect(screen.queryByLabelText('显示名称')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText('系统节点无需配置。')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('edits IP group config through a typed multi-select', async () => {
|
it('edits IP group config through a typed multi-select', async () => {
|
||||||
const node: WAFRuleNode = {
|
const node: WAFRuleNode = {
|
||||||
id: 'match',
|
id: 'match',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
|
||||||
|
|
||||||
import { countryOptions, regionOptions, type GeoOption } from './geo-options';
|
import { countryOptions, regionOptions, type GeoOption } from './geo-options';
|
||||||
|
import { NODE_TYPE_LABELS } from './node-factory';
|
||||||
|
|
||||||
export function NodeProperties({
|
export function NodeProperties({
|
||||||
node,
|
node,
|
||||||
@@ -72,6 +73,7 @@ function PropertyFields({
|
|||||||
if (node.type === 'ip_match')
|
if (node.type === 'ip_match')
|
||||||
return (
|
return (
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<DisplayNameField node={node} onChange={onChange} />
|
||||||
<CsvField
|
<CsvField
|
||||||
id={`${node.id}-ips`}
|
id={`${node.id}-ips`}
|
||||||
label='IP 地址'
|
label='IP 地址'
|
||||||
@@ -109,6 +111,7 @@ function PropertyFields({
|
|||||||
if (node.type === 'geo_match')
|
if (node.type === 'geo_match')
|
||||||
return (
|
return (
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<DisplayNameField node={node} onChange={onChange} />
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
id={`${node.id}-countries`}
|
id={`${node.id}-countries`}
|
||||||
label='国家代码'
|
label='国家代码'
|
||||||
@@ -137,6 +140,7 @@ function PropertyFields({
|
|||||||
if (node.type === 'pow')
|
if (node.type === 'pow')
|
||||||
return (
|
return (
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<DisplayNameField node={node} onChange={onChange} />
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
|
<FieldLabel htmlFor={`${node.id}-algorithm`}>算法</FieldLabel>
|
||||||
<Select
|
<Select
|
||||||
@@ -181,6 +185,7 @@ function PropertyFields({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<FieldGroup>
|
<FieldGroup>
|
||||||
|
<DisplayNameField node={node} onChange={onChange} />
|
||||||
<NumberField
|
<NumberField
|
||||||
id={`${node.id}-status`}
|
id={`${node.id}-status`}
|
||||||
min={400}
|
min={400}
|
||||||
@@ -213,6 +218,26 @@ function PropertyFields({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DisplayNameField({
|
||||||
|
node,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
node: WAFRuleNode;
|
||||||
|
onChange: (node: WAFRuleNode) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor={`${node.id}-label`}>显示名称</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={`${node.id}-label`}
|
||||||
|
value={node.label ?? ''}
|
||||||
|
placeholder={NODE_TYPE_LABELS[node.type]}
|
||||||
|
onChange={(event) => onChange({ ...node, label: event.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CsvField({
|
function CsvField({
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { type DragEvent, useCallback, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
addEdge,
|
addEdge,
|
||||||
applyEdgeChanges,
|
applyEdgeChanges,
|
||||||
@@ -22,11 +22,7 @@ import { Trash2 } from 'lucide-react';
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type {
|
import type { WAFRuleEdge, WAFRuleGraph } from '@/lib/services/openflare';
|
||||||
WAFRuleEdge,
|
|
||||||
WAFRuleGraph,
|
|
||||||
WAFRuleNode,
|
|
||||||
} from '@/lib/services/openflare';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
acceptedNodeChanges,
|
acceptedNodeChanges,
|
||||||
@@ -40,6 +36,12 @@ import {
|
|||||||
removeEdgeFromGraph,
|
removeEdgeFromGraph,
|
||||||
removeNodeFromGraph,
|
removeNodeFromGraph,
|
||||||
} from './graph-validation';
|
} from './graph-validation';
|
||||||
|
import {
|
||||||
|
createRuleNode,
|
||||||
|
parseAddableNodeType,
|
||||||
|
WAF_NODE_DRAG_MIME,
|
||||||
|
type AddableNodeType,
|
||||||
|
} from './node-factory';
|
||||||
import { NodeLibrary } from './node-library';
|
import { NodeLibrary } from './node-library';
|
||||||
import { RuleNode, type RuleFlowNodeData } from './rule-node';
|
import { RuleNode, type RuleFlowNodeData } from './rule-node';
|
||||||
|
|
||||||
@@ -201,40 +203,41 @@ export function RuleFlowCanvas({
|
|||||||
[edges, graph, isValidConnection, onGraphChange, setEdges],
|
[edges, graph, isValidConnection, onGraphChange, setEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const addNode = useCallback(
|
const addNodeAt = useCallback(
|
||||||
(type: 'ip_match' | 'geo_match' | 'pow' | 'block') => {
|
(type: AddableNodeType, position: { x: number; y: number }) => {
|
||||||
const id = `${type}-${crypto.randomUUID().slice(0, 8)}`;
|
const node = createRuleNode(type, position);
|
||||||
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({
|
onGraphChange({
|
||||||
...graph,
|
...graph,
|
||||||
nodes: [
|
nodes: [...graph.nodes, node],
|
||||||
...graph.nodes,
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
position: { x: 240, y: 140 + graph.nodes.length * 24 },
|
|
||||||
config,
|
|
||||||
} as WAFRuleNode,
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
onSelectEdge(undefined);
|
onSelectEdge(undefined);
|
||||||
onSelect(id);
|
onSelect(node.id);
|
||||||
},
|
},
|
||||||
[graph, onGraphChange, onSelect, onSelectEdge],
|
[graph, onGraphChange, onSelect, onSelectEdge],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const onDragOver = useCallback((event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const raw =
|
||||||
|
event.dataTransfer.getData(WAF_NODE_DRAG_MIME) ||
|
||||||
|
event.dataTransfer.getData('text/plain');
|
||||||
|
const type = parseAddableNodeType(raw);
|
||||||
|
if (!type || !instance.current) return;
|
||||||
|
const position = instance.current.screenToFlowPosition({
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
});
|
||||||
|
addNodeAt(type, position);
|
||||||
|
},
|
||||||
|
[addNodeAt],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
|
const selectedNode = graph.nodes.find((node) => node.id === selectedId);
|
||||||
const canDeleteNode = Boolean(
|
const canDeleteNode = Boolean(
|
||||||
selectedNode && !['start', 'allow'].includes(selectedNode.type),
|
selectedNode && !['start', 'allow'].includes(selectedNode.type),
|
||||||
@@ -286,7 +289,7 @@ export function RuleFlowCanvas({
|
|||||||
return (
|
return (
|
||||||
<section className='relative min-w-0 flex-1 bg-muted/20'>
|
<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'>
|
<div className='absolute left-4 top-4 z-10 rounded-lg border bg-background/95 p-2 shadow-sm backdrop-blur'>
|
||||||
<NodeLibrary onAdd={addNode} />
|
<NodeLibrary />
|
||||||
</div>
|
</div>
|
||||||
<div className='absolute right-4 top-4 z-10'>
|
<div className='absolute right-4 top-4 z-10'>
|
||||||
<Button
|
<Button
|
||||||
@@ -327,6 +330,8 @@ export function RuleFlowCanvas({
|
|||||||
onSelect(undefined);
|
onSelect(undefined);
|
||||||
onSelectEdge(undefined);
|
onSelectEdge(undefined);
|
||||||
}}
|
}}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
fitView
|
fitView
|
||||||
fitViewOptions={initialFitViewOptions}
|
fitViewOptions={initialFitViewOptions}
|
||||||
deleteKeyCode={['Backspace', 'Delete']}
|
deleteKeyCode={['Backspace', 'Delete']}
|
||||||
|
|||||||
@@ -12,18 +12,20 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { WAFRuleNode } from '@/lib/services/openflare';
|
import type { WAFRuleNode } from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
import { displayNodeTitle } from './node-factory';
|
||||||
|
|
||||||
export interface RuleFlowNodeData extends Record<string, unknown> {
|
export interface RuleFlowNodeData extends Record<string, unknown> {
|
||||||
rule: WAFRuleNode;
|
rule: WAFRuleNode;
|
||||||
issues: number;
|
issues: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
start: { label: '开始', icon: Play },
|
start: { icon: Play },
|
||||||
ip_match: { label: 'IP 匹配', icon: Fingerprint },
|
ip_match: { icon: Fingerprint },
|
||||||
geo_match: { label: '地域匹配', icon: Globe2 },
|
geo_match: { icon: Globe2 },
|
||||||
pow: { label: 'PoW 挑战', icon: ShieldCheck },
|
pow: { icon: ShieldCheck },
|
||||||
allow: { label: '通过', icon: Flag },
|
allow: { icon: Flag },
|
||||||
block: { label: '阻止', icon: Ban },
|
block: { icon: Ban },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
||||||
@@ -36,7 +38,8 @@ const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
|
|||||||
export function RuleNode({ data, selected }: NodeProps) {
|
export function RuleNode({ data, selected }: NodeProps) {
|
||||||
const value = data as RuleFlowNodeData;
|
const value = data as RuleFlowNodeData;
|
||||||
const { rule, issues } = value;
|
const { rule, issues } = value;
|
||||||
const { label, icon: Icon } = meta[rule.type];
|
const { icon: Icon } = meta[rule.type];
|
||||||
|
const title = displayNodeTitle(rule);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -51,7 +54,7 @@ export function RuleNode({ data, selected }: NodeProps) {
|
|||||||
<div className='flex items-center gap-3 px-4 py-3'>
|
<div className='flex items-center gap-3 px-4 py-3'>
|
||||||
<Icon className='size-5 text-primary' />
|
<Icon className='size-5 text-primary' />
|
||||||
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
<div className='flex min-w-0 flex-1 flex-col gap-0.5'>
|
||||||
<span className='text-sm font-medium'>{label}</span>
|
<span className='text-sm font-medium'>{title}</span>
|
||||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||||
{rule.id}
|
{rule.id}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -783,31 +783,42 @@ export type WAFRuleNode =
|
|||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'start';
|
type: 'start';
|
||||||
|
label?: string;
|
||||||
position: XYPosition;
|
position: XYPosition;
|
||||||
config: Record<string, never>;
|
config: Record<string, never>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'ip_match';
|
type: 'ip_match';
|
||||||
|
label?: string;
|
||||||
position: XYPosition;
|
position: XYPosition;
|
||||||
config: IPMatchConfig;
|
config: IPMatchConfig;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'geo_match';
|
type: 'geo_match';
|
||||||
|
label?: string;
|
||||||
position: XYPosition;
|
position: XYPosition;
|
||||||
config: GeoMatchConfig;
|
config: GeoMatchConfig;
|
||||||
}
|
}
|
||||||
| { id: string; type: 'pow'; position: XYPosition; config: PoWNodeConfig }
|
| {
|
||||||
|
id: string;
|
||||||
|
type: 'pow';
|
||||||
|
label?: string;
|
||||||
|
position: XYPosition;
|
||||||
|
config: PoWNodeConfig;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'allow';
|
type: 'allow';
|
||||||
|
label?: string;
|
||||||
position: XYPosition;
|
position: XYPosition;
|
||||||
config: Record<string, never>;
|
config: Record<string, never>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'block';
|
type: 'block';
|
||||||
|
label?: string;
|
||||||
position: XYPosition;
|
position: XYPosition;
|
||||||
config: BlockNodeConfig;
|
config: BlockNodeConfig;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user