feat(NetworkConfigManager, FilterManager): replace select with GatewayAutocompleteInput for parent gateway selection; update display to include comments in gateway details
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m43s
This commit is contained in:
@@ -487,7 +487,7 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required =
|
||||
{gw.serverDns && <span className="text-muted">— {gw.serverDns}</span>}
|
||||
</div>
|
||||
<div className="text-muted small">
|
||||
{[gw.country].filter(Boolean).join(' · ') || 'Шлюз'}
|
||||
{[gw.comment, gw.country].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
{gw.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
||||
|
||||
@@ -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' && (
|
||||
<div className="col-12">
|
||||
<label className="form-label">Родительский gateway или интерфейс</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={editingGateway.parentGatewayId || ''}
|
||||
onChange={(e) => setEditingGateway({ ...editingGateway, parentGatewayId: e.target.value })}
|
||||
>
|
||||
<option value="">Выберите родительский gateway или интерфейс</option>
|
||||
|
||||
{/* Прямые gateway */}
|
||||
<optgroup label="Прямые gateway">
|
||||
{config.gateways
|
||||
.filter(g => g.id !== editingGateway.id && g.type === 'direct' && g.ip)
|
||||
.map(g => (
|
||||
<option key={g.id} value={g.id}>
|
||||
Gateway: {g.ip} {g.description ? `- ${g.description}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
|
||||
{/* Интерфейсы */}
|
||||
<optgroup label="Интерфейсы (по remote IP)">
|
||||
{config.tunnelInterfaces
|
||||
.filter(i => i.remoteIp)
|
||||
.map(i => {
|
||||
const server = getServerInfo(i.serverId);
|
||||
return (
|
||||
<option key={i.id} value={i.id}>
|
||||
Интерфейс: {i.remoteIp} ({i.name || i.type}) {server ? `- ${server.dns || server.ip}` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
</select>
|
||||
<GatewayAutocompleteInput
|
||||
value={editingGateway.parentGatewayId}
|
||||
onChange={(val) => setEditingGateway({ ...editingGateway, parentGatewayId: val })}
|
||||
gateways={config.gateways}
|
||||
interfaces={config.tunnelInterfaces}
|
||||
excludeGatewayId={editingGateway.id}
|
||||
placeholder="Выберите родительский gateway или интерфейс..."
|
||||
/>
|
||||
<div className="form-text">
|
||||
Рекурсивный gateway может ссылаться на прямой gateway или на интерфейс (по его remote IP)
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div ref={containerRef} className="position-relative" style={{ width: '100%' }}>
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<IconWorld size={18} />
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className={className}
|
||||
placeholder={placeholder}
|
||||
value={open ? displayValue || value : displayValue}
|
||||
onChange={(e) => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{open && suggestions.length > 0 && (
|
||||
<div
|
||||
className="dropdown-menu show"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`dropdown-item${activeIndex === -1 ? ' active' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onChange('');
|
||||
if (onSelectMeta) onSelectMeta(null);
|
||||
setOpen(false);
|
||||
setActiveIndex(-1);
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center text-muted small">
|
||||
<span className="me-2">Без привязки</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Группа: Прямые gateway */}
|
||||
{suggestions.filter(s => s.type === 'gateway').length > 0 && (
|
||||
<>
|
||||
<div className="dropdown-header small text-muted">Прямые gateway</div>
|
||||
{suggestions
|
||||
.filter(s => s.type === 'gateway')
|
||||
.map((s, idx) => {
|
||||
const globalIdx = suggestions.findIndex(item => item.value === s.value);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`gw-${s.value}-${idx}`}
|
||||
className={`dropdown-item${globalIdx === activeIndex ? ' active' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectItem(s);
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(globalIdx)}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<span className="avatar me-2 bg-green-lt text-green border-0" style={{ width: 24, height: 24 }}>
|
||||
<IconWorld size={14} />
|
||||
</span>
|
||||
<div className="flex-fill text-start">
|
||||
<div className="fw-medium">
|
||||
<code>{s.label}</code>
|
||||
{s.description && <span className="text-muted ms-2">({s.description})</span>}
|
||||
</div>
|
||||
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
|
||||
Gateway
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Группа: Интерфейсы */}
|
||||
{suggestions.filter(s => s.type === 'interface').length > 0 && (
|
||||
<>
|
||||
<div className="dropdown-header small text-muted">Интерфейсы (по remote IP)</div>
|
||||
{suggestions
|
||||
.filter(s => s.type === 'interface')
|
||||
.map((s, idx) => {
|
||||
const globalIdx = suggestions.findIndex(item => item.value === s.value);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`if-${s.value}-${idx}`}
|
||||
className={`dropdown-item${globalIdx === activeIndex ? ' active' : ''}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
selectItem(s);
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(globalIdx)}
|
||||
>
|
||||
<div className="d-flex align-items-start">
|
||||
<span className="avatar me-2 bg-blue-lt text-blue border-0" style={{ width: 24, height: 24 }}>
|
||||
<IconRouter size={14} />
|
||||
</span>
|
||||
<div className="flex-fill text-start">
|
||||
<div className="fw-medium">
|
||||
<code>{s.label}</code>
|
||||
{s.description && <span className="text-muted ms-2">({s.description})</span>}
|
||||
</div>
|
||||
<div className="text-muted small text-truncate" style={{ maxWidth: '100%' }}>
|
||||
Интерфейс • Local: {s.localIp}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GatewayAutocompleteInput;
|
||||
Reference in New Issue
Block a user