mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 17:56:37 +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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user