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

This commit is contained in:
2026-01-22 12:29:29 +07:00
parent 64f4b76d3f
commit ea6220d955
3 changed files with 258 additions and 33 deletions
+1 -1
View File
@@ -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>}
+9 -32
View File
@@ -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;