'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 { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; 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 { findGraphErrorTarget, type GraphErrorTarget, } from './components/editor-behavior'; import { layoutRuleGraph } from './components/graph-layout'; 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'; export default function WAFRuleEditorPage() { return ( }> ); } function EditorContent() { const t = useTranslations('waf.editor'); const tWaf = useTranslations('waf'); const tCommon = useTranslations('common'); 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(t('saved')); }, onError: (error) => { if (axios.isAxiosError(error) && error.response?.status === 409) { setConflict(true); toast.error(t('conflict')); 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(t('saveCheckNodes')); }, }); 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, tWaf('operationFailed'))); }, 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 ? t('ruleEnabled') : t('ruleDisabled')); }, }); 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 formatLayout = useCallback(() => { if (!graph) return; changeGraph(layoutRuleGraph(graph)); }, [changeGraph, graph]); const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false); const leave = () => { if (!dirty) { router.push('/waf'); return; } setLeaveConfirmOpen(true); }; if (!Number.isFinite(id) || id <= 0) return (

{t('missingId')}

); if (ruleQuery.isError) return (

{t('loadFailed')}

); if (ruleQuery.isLoading || !graph || !ruleQuery.data) return ; return (

{ruleQuery.data.name}

{ruleQuery.data.enabled ? tWaf('enabled') : tWaf('disabled')} {issues.length === 0 ? t('graphValid') : t('issueCount', { count: issues.length })} {dirty && {t('unsaved')}}
0 || saveMutation.isPending || metaMutation.isPending } onCheckedChange={(enabled) => metaMutation.mutate(enabled)} />
{conflict && ( )}
{selected && ( )}
{t('leaveTitle')} {t('leaveDesc')} {tCommon('cancel')} router.push('/waf')}> {t('leaveConfirm')}
); } function EditorSkeleton() { return (
); }