diff --git a/frontend/src/FilterManager.jsx b/frontend/src/FilterManager.jsx index 07f337f..04b5cc3 100644 --- a/frontend/src/FilterManager.jsx +++ b/frontend/src/FilterManager.jsx @@ -487,7 +487,7 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required = {gw.serverDns && — {gw.serverDns}}
- {[gw.country].filter(Boolean).join(' · ') || 'Шлюз'} + {[gw.comment, gw.country].filter(Boolean).join(' · ') || '—'}
{gw.primary && Основной} diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index eab144e..8652e4c 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -5,6 +5,7 @@ import FormModal from './components/FormModal.jsx'; import FormField from './components/FormField.jsx'; import ConfirmModal from './components/ConfirmModal.jsx'; import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx'; +import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx'; import PreviewConfigModal from './components/filter/PreviewConfigModal.jsx'; import { countryToFlag } from './utils/serverUtils.js'; import { @@ -1506,38 +1507,14 @@ function NetworkConfigManager() { {editingGateway.type === 'recursive' && (
- + setEditingGateway({ ...editingGateway, parentGatewayId: val })} + gateways={config.gateways} + interfaces={config.tunnelInterfaces} + excludeGatewayId={editingGateway.id} + placeholder="Выберите родительский gateway или интерфейс..." + />
Рекурсивный gateway может ссылаться на прямой gateway или на интерфейс (по его remote IP)
diff --git a/frontend/src/components/GatewayAutocompleteInput.jsx b/frontend/src/components/GatewayAutocompleteInput.jsx new file mode 100644 index 0000000..665c19c --- /dev/null +++ b/frontend/src/components/GatewayAutocompleteInput.jsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { IconServer, IconWorld, IconRouter } from '@tabler/icons-react'; + +/** + * Красивый selector gateway и интерфейсов с автодополнением. + * Похож по UX на ServerAutocompleteInput, но заточен под выбор родительского gateway/интерфейса. + */ +function GatewayAutocompleteInput({ + value, + onChange, + gateways = [], + interfaces = [], + excludeGatewayId = null, // ID gateway, который нужно исключить (текущий редактируемый) + placeholder = '', + className = 'form-control', + onSelectMeta, + maxSuggestions = 12, +}) { + const containerRef = useRef(null); + const inputRef = useRef(null); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + // Объединяем gateway и интерфейсы в один список для автокомплита + const allItems = useMemo(() => { + const items = []; + + // Добавляем прямые gateway + gateways + .filter(g => g.id !== excludeGatewayId && g.type === 'direct' && g.ip) + .forEach(g => { + items.push({ + type: 'gateway', + id: g.id, + value: g.id, + label: g.ip, + description: g.description || '', + ip: g.ip, + search: [g.ip, g.description, 'gateway'].filter(Boolean).join(' ').toLowerCase(), + }); + }); + + // Добавляем интерфейсы + interfaces + .filter(i => i.remoteIp) + .forEach(i => { + items.push({ + type: 'interface', + id: i.id, + value: i.id, + label: i.remoteIp, + description: `${i.name || i.type} (${i.localIp})`, + ip: i.remoteIp, + localIp: i.localIp, + search: [i.remoteIp, i.localIp, i.name, i.type, 'интерфейс', 'interface'].filter(Boolean).join(' ').toLowerCase(), + }); + }); + + return items; + }, [gateways, interfaces, excludeGatewayId]); + + const suggestions = useMemo(() => { + const q = String(value || '').toLowerCase(); + + if (!q) return allItems.slice(0, maxSuggestions); + const filtered = allItems.filter((item) => item.search.includes(q)); + return filtered.slice(0, maxSuggestions); + }, [value, allItems, maxSuggestions]); + + useEffect(() => { + const handleOutside = (e) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(e.target)) setOpen(false); + }; + document.addEventListener('click', handleOutside); + return () => document.removeEventListener('click', handleOutside); + }, []); + + const selectItem = (item) => { + onChange(item.value); + if (onSelectMeta) onSelectMeta(item); + setOpen(false); + setActiveIndex(-1); + if (inputRef.current) inputRef.current.focus(); + }; + + const handleKeyDown = (e) => { + if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + setOpen(true); + return; + } + if (!open) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, suggestions.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === 'Enter') { + if (activeIndex >= 0 && activeIndex < suggestions.length) { + e.preventDefault(); + selectItem(suggestions[activeIndex]); + } + } else if (e.key === 'Escape') { + setOpen(false); + } + }; + + // Текст в input: ищем label по выбранному ID + const displayValue = useMemo(() => { + if (!value) return ''; + const found = allItems.find((item) => item.value === value); + if (!found) return ''; + return found.label; + }, [value, allItems]); + + return ( +
+
+ + + + { + onChange(e.target.value); + setOpen(true); + }} + onFocus={() => setOpen(true)} + onKeyDown={handleKeyDown} + autoComplete="off" + /> +
+ + {open && suggestions.length > 0 && ( +
+ + + {/* Группа: Прямые gateway */} + {suggestions.filter(s => s.type === 'gateway').length > 0 && ( + <> +
Прямые gateway
+ {suggestions + .filter(s => s.type === 'gateway') + .map((s, idx) => { + const globalIdx = suggestions.findIndex(item => item.value === s.value); + return ( + + ); + })} + + )} + + {/* Группа: Интерфейсы */} + {suggestions.filter(s => s.type === 'interface').length > 0 && ( + <> +
Интерфейсы (по remote IP)
+ {suggestions + .filter(s => s.type === 'interface') + .map((s, idx) => { + const globalIdx = suggestions.findIndex(item => item.value === s.value); + return ( + + ); + })} + + )} +
+ )} +
+ ); +} + +export default GatewayAutocompleteInput;