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>}
|
{gw.serverDns && <span className="text-muted">— {gw.serverDns}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted small">
|
<div className="text-muted small">
|
||||||
{[gw.country].filter(Boolean).join(' · ') || 'Шлюз'}
|
{[gw.comment, gw.country].filter(Boolean).join(' · ') || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{gw.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
{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 FormField from './components/FormField.jsx';
|
||||||
import ConfirmModal from './components/ConfirmModal.jsx';
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
||||||
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
import ServerAutocompleteInput from './components/ServerAutocompleteInput.jsx';
|
||||||
|
import GatewayAutocompleteInput from './components/GatewayAutocompleteInput.jsx';
|
||||||
import PreviewConfigModal from './components/filter/PreviewConfigModal.jsx';
|
import PreviewConfigModal from './components/filter/PreviewConfigModal.jsx';
|
||||||
import { countryToFlag } from './utils/serverUtils.js';
|
import { countryToFlag } from './utils/serverUtils.js';
|
||||||
import {
|
import {
|
||||||
@@ -1506,38 +1507,14 @@ function NetworkConfigManager() {
|
|||||||
{editingGateway.type === 'recursive' && (
|
{editingGateway.type === 'recursive' && (
|
||||||
<div className="col-12">
|
<div className="col-12">
|
||||||
<label className="form-label">Родительский gateway или интерфейс</label>
|
<label className="form-label">Родительский gateway или интерфейс</label>
|
||||||
<select
|
<GatewayAutocompleteInput
|
||||||
className="form-select"
|
value={editingGateway.parentGatewayId}
|
||||||
value={editingGateway.parentGatewayId || ''}
|
onChange={(val) => setEditingGateway({ ...editingGateway, parentGatewayId: val })}
|
||||||
onChange={(e) => setEditingGateway({ ...editingGateway, parentGatewayId: e.target.value })}
|
gateways={config.gateways}
|
||||||
>
|
interfaces={config.tunnelInterfaces}
|
||||||
<option value="">Выберите родительский gateway или интерфейс</option>
|
excludeGatewayId={editingGateway.id}
|
||||||
|
placeholder="Выберите родительский gateway или интерфейс..."
|
||||||
{/* Прямые 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>
|
|
||||||
<div className="form-text">
|
<div className="form-text">
|
||||||
Рекурсивный gateway может ссылаться на прямой gateway или на интерфейс (по его remote IP)
|
Рекурсивный gateway может ссылаться на прямой gateway или на интерфейс (по его remote IP)
|
||||||
</div>
|
</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