import React, { useEffect, useMemo, useState } from 'react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { ruleTemplates } from '../templates/ruleTemplates.js'; import { ruleErrors, hasErrors } from '../utils/validation.js'; import { RuleEditorDrawer } from './RuleEditorDrawer.jsx'; import { RouteChecker } from './RouteChecker.jsx'; import { api } from '../api.js'; const OUTBOUND_KIND = { direct: { kind: 'success', label: 'direct' }, vpn: { kind: 'info', label: 'VPN' }, block: { kind: 'danger', label: 'block' }, }; function summary(rule) { const parts = []; const totalDomains = (rule.domains?.length || 0) + (rule.domainSuffixes?.length || 0) + (rule.domainKeywords?.length || 0); if (totalDomains) parts.push(`${totalDomains} дом.`); if (rule.ipCidrs?.length) parts.push(`${rule.ipCidrs.length} CIDR`); if (rule.ports?.length) parts.push(`${rule.ports.length} портов`); if (rule.networks?.length) parts.push(rule.networks.join('/')); return parts.join(' · ') || '—'; } function SortableRuleRow({ rule, index, total, onEdit, onUpdate, onRemove, conflict }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: rule.id }); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }; const errors = ruleErrors(rule); const invalid = hasErrors(errors); const ob = OUTBOUND_KIND[rule.outbound] || OUTBOUND_KIND.direct; return ( #{index + 1}
onUpdate(rule.id, { enabled: e.target.checked })} style={{ accentColor: 'var(--accent)' }} /> {invalid && ошибки} {conflict && конфликт}
{ob.label} {summary(rule)}
); } function TemplatesModal({ open, onClose, onAdd }) { if (!open) return null; return (
e.stopPropagation()}>

Шаблоны маршрутизации

{ruleTemplates.map((tpl) => (

{tpl.label}

{tpl.description}
))}
); } export function RoutingPage({ rules, saveStatus, busy, onAdd, onAddTemplate, onUpdate, onRemove, onSaveNow, onReorder, }) { const [editingId, setEditingId] = useState(null); const [showTemplates, setShowTemplates] = useState(false); const [conflicts, setConflicts] = useState([]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); useEffect(() => { let cancelled = false; const t = setTimeout(() => { api.rules.conflicts().then((data) => { if (!cancelled) setConflicts(data.conflicts || []); }).catch(() => {}); }, 600); return () => { cancelled = true; clearTimeout(t); }; }, [rules]); const conflictsByRuleId = useMemo(() => { const map = {}; for (const c of conflicts) map[c.ruleId] = c; return map; }, [conflicts]); function handleDragEnd(event) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = rules.findIndex((r) => r.id === active.id); const newIndex = rules.findIndex((r) => r.id === over.id); if (oldIndex < 0 || newIndex < 0) return; onReorder(arrayMove(rules, oldIndex, newIndex)); } const editing = rules.find((r) => r.id === editingId) || null; return (

Правила маршрутизации

{conflicts.length > 0 && (
{conflicts.length} конфликт(ов) обнаружено
{conflicts.slice(0, 3).map((c, i) => (
#{c.ruleIndex + 1} «{c.ruleName}» перекрывается правилом #{c.conflictWithIndex + 1} «{c.conflictWithName}»
))}
)} Применяются сверху вниз. Перетаскивай ⠿ чтобы менять порядок. {rules.length === 0 ? (

Правил пока нет

Добавь шаблон (например «League of Legends → direct») или создай пустое правило.

) : (
r.id)} strategy={verticalListSortingStrategy}> {rules.map((rule, i) => ( ))}
# Правило Outbound Условия
)}
setEditingId(null)} onRemove={onRemove} /> setShowTemplates(false)} onAdd={onAddTemplate} />
); }