'use client'; import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import axios from 'axios'; import { ArrowLeft, GitBranch, Save } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; import { Switch } from '@/components/ui/switch'; import { services, type WAFRule, type WAFRuleGraph, type WAFRuleNode, } from '@/lib/services'; import { getErrorMessage } from '../../components/helpers'; import { validateGraph } from './components/graph-validation'; import { NodeProperties } from './components/node-properties'; import { RuleFlowCanvas } from './components/rule-flow-canvas'; import { UnsavedChanges } from './components/unsaved-changes'; import { findGraphErrorTarget, type GraphErrorTarget, } from './components/editor-behavior'; export default function WAFRuleEditorPage() { return ( }> ); } function EditorContent() { const router = useRouter(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const id = Number(searchParams.get('id')); const [graph, setGraph] = useState(); const [revision, setRevision] = useState(0); const [selectedId, setSelectedId] = useState(); const [selectedEdgeId, setSelectedEdgeId] = useState(); const [focusTarget, setFocusTarget] = useState(); const [dirty, setDirty] = useState(false); const [conflict, setConflict] = useState(false); const ruleQueryKey = ['waf-rule', id] as const; const ruleQuery = useQuery({ queryKey: ruleQueryKey, queryFn: () => services.openflareWaf.getRule(id), enabled: Number.isFinite(id) && id > 0, }); const ipGroupsQuery = useQuery({ queryKey: ['waf-ip-groups'], queryFn: () => services.openflareWaf.listIPGroups(), }); useEffect(() => { if (ruleQuery.data && !dirty) { setGraph(ruleQuery.data.graph); setRevision(ruleQuery.data.revision); } }, [dirty, ruleQuery.data]); const issues = useMemo(() => (graph ? validateGraph(graph) : []), [graph]); const selected = graph?.nodes.find((node) => node.id === selectedId); const saveMutation = useMutation({ mutationFn: () => services.openflareWaf.saveRuleGraph(id, { revision, graph: graph! }), onSuccess: (rule) => { setGraph(rule.graph); setRevision(rule.revision); setDirty(false); setConflict(false); queryClient.setQueryData(['waf-rule', id], rule); toast.success('规则图已保存'); }, onError: (error) => { if (axios.isAxiosError(error) && error.response?.status === 409) { setConflict(true); toast.error('规则已在其他页面更新,请重新加载'); return; } const payload = axios.isAxiosError(error) ? error.response?.data : error; const target = graph ? findGraphErrorTarget( payload, graph.nodes.map((node) => node.id), graph.edges.map((edge) => edge.id), ) : undefined; if (target?.kind === 'node') { setSelectedEdgeId(undefined); setSelectedId(target.id); } if (target?.kind === 'edge') { setSelectedId(undefined); setSelectedEdgeId(target.id); } setFocusTarget(target ? { ...target } : undefined); toast.error('保存失败,请检查标记的节点和连线'); }, }); const metaMutation = useMutation({ mutationFn: (enabled: boolean) => services.openflareWaf.updateRuleMeta(id, { name: ruleQuery.data!.name, enabled, }), onMutate: async (enabled) => { await queryClient.cancelQueries({ queryKey: ruleQueryKey }); const previous = queryClient.getQueryData(ruleQueryKey); queryClient.setQueryData(ruleQueryKey, (current) => current ? { ...current, enabled } : current, ); return { previous }; }, onError: (error, _enabled, context) => { if (context?.previous) { queryClient.setQueryData(ruleQueryKey, context.previous); } toast.error(getErrorMessage(error)); }, onSuccess: (rule) => { queryClient.setQueryData(ruleQueryKey, rule); void queryClient.invalidateQueries({ queryKey: ruleQueryKey, refetchType: 'none', }); void queryClient.invalidateQueries({ queryKey: ['openflare', 'waf', 'rule-groups'], }); void queryClient.invalidateQueries({ queryKey: ['openflare', 'config-versions', 'diff'], }); toast.success(rule.enabled ? '规则已启用' : '规则已停用'); }, }); const changeGraph = useCallback((next: WAFRuleGraph, persistent = true) => { setGraph(next); if (persistent) { setDirty(true); setConflict(false); } }, []); const changeNode = useCallback( (next: WAFRuleNode) => { if (!graph) return; changeGraph({ ...graph, nodes: graph.nodes.map((node) => (node.id === next.id ? next : node)), }); }, [changeGraph, graph], ); const leave = () => { if (!dirty || window.confirm('存在未保存的更改,确定离开吗?')) router.push('/waf'); }; if (!Number.isFinite(id) || id <= 0) return ( 缺少有效的规则 ID。 ); if (ruleQuery.isError) return ( 规则加载失败,请重试。 void ruleQuery.refetch()}> 重新加载 ); if (ruleQuery.isLoading || !graph || !ruleQuery.data) return ; return ( {ruleQuery.data.name} {ruleQuery.data.enabled ? '已启用' : '已停用'} {issues.length === 0 ? '图校验通过' : `${issues.length} 个问题`} {dirty && 未保存} 0 || saveMutation.isPending || metaMutation.isPending } onCheckedChange={(enabled) => metaMutation.mutate(enabled)} /> 启用规则 返回 {conflict && ( { setDirty(false); setConflict(false); void ruleQuery.refetch(); }} > 重新加载 )} 0 || saveMutation.isPending} onClick={() => saveMutation.mutate()} > {saveMutation.isPending ? '保存中...' : '保存'} {selected && ( )} ); } function EditorSkeleton() { return ( ); }
缺少有效的规则 ID。
规则加载失败,请重试。