feat: Add GatewayAutocomplete component to FilterManager for improved gateway selection, enhancing user experience with dynamic filtering and selection capabilities.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m10s
This commit is contained in:
+261
-112
@@ -70,6 +70,207 @@ const countryToFlag = (code) => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Компонент автокомплита для выбора Gateway
|
||||||
|
function GatewayAutocomplete({ label, value, onChange, gateways = [], required = false, placeholder = 'Введите или выберите gateway...' }) {
|
||||||
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
// Синхронизируем inputValue с value при изменении извне
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(value || '');
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Фильтруем gateways по введённому тексту
|
||||||
|
const filteredGateways = gateways.filter(gw => {
|
||||||
|
const search = inputValue.toLowerCase();
|
||||||
|
return (
|
||||||
|
gw.name.toLowerCase().includes(search) ||
|
||||||
|
(gw.ip && gw.ip.toLowerCase().includes(search)) ||
|
||||||
|
(gw.serverDns && gw.serverDns.toLowerCase().includes(search)) ||
|
||||||
|
(gw.comment && gw.comment.toLowerCase().includes(search))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Закрытие при клике вне компонента
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setInputValue(val);
|
||||||
|
onChange(val);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (gw) => {
|
||||||
|
setInputValue(gw.name);
|
||||||
|
onChange(gw.name);
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
setIsOpen(true);
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => Math.min(prev + 1, filteredGateways.length - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex(prev => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightedIndex >= 0 && filteredGateways[highlightedIndex]) {
|
||||||
|
handleSelect(filteredGateways[highlightedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedGateway = gateways.find(g => g.name === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3" ref={containerRef}>
|
||||||
|
<label className={`form-label ${required ? 'required' : ''}`}>{label}</label>
|
||||||
|
<div className="position-relative">
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconServer size={16} />
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
required={required}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="input-icon-addon cursor-pointer"
|
||||||
|
style={{ right: 0, left: 'auto' }}
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
>
|
||||||
|
<IconChevronDown size={16} className={isOpen ? 'rotate-180' : ''} style={{ transition: 'transform 0.2s' }} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className="dropdown-menu show w-100 overflow-auto"
|
||||||
|
style={{
|
||||||
|
maxHeight: '280px',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '100%',
|
||||||
|
left: 0,
|
||||||
|
zIndex: 1050,
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{gateways.length === 0 ? (
|
||||||
|
<div className="dropdown-item text-muted">
|
||||||
|
<IconAlertTriangle size={14} className="me-2 text-warning" />
|
||||||
|
Нет доступных gateway
|
||||||
|
</div>
|
||||||
|
) : filteredGateways.length === 0 ? (
|
||||||
|
<div className="dropdown-item text-muted">
|
||||||
|
Ничего не найдено. Нажмите Enter чтобы использовать "{inputValue}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{inputValue && !gateways.some(g => g.name === inputValue) && (
|
||||||
|
<div className="dropdown-header small text-muted">Без привязки</div>
|
||||||
|
)}
|
||||||
|
{filteredGateways.map((gw, idx) => (
|
||||||
|
<div
|
||||||
|
key={gw.name}
|
||||||
|
className={`dropdown-item cursor-pointer ${highlightedIndex === idx ? 'active' : ''} ${gw.name === value ? 'bg-primary-lt' : ''}`}
|
||||||
|
onClick={() => handleSelect(gw)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-start gap-2">
|
||||||
|
<span className="avatar avatar-xs bg-primary-lt mt-1">
|
||||||
|
{gw.country ? countryToFlag(gw.country) : <IconServer size={14} />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1 min-w-0">
|
||||||
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<span className="fw-bold">{gw.name}</span>
|
||||||
|
{gw.ip && <span className="text-muted">({gw.ip})</span>}
|
||||||
|
{gw.serverDns && <span className="text-muted">— {gw.serverDns}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted small">
|
||||||
|
{[gw.country, gw.comment].filter(Boolean).join(' · ') || 'Шлюз'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{gw.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Показываем выбранный gateway */}
|
||||||
|
{value && selectedGateway && !isOpen && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
|
||||||
|
<span className="avatar avatar-xs bg-primary-lt">
|
||||||
|
{selectedGateway.country ? countryToFlag(selectedGateway.country) : <IconServer size={14} />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<div className="fw-bold">{selectedGateway.name}</div>
|
||||||
|
<div className="text-muted small">
|
||||||
|
{[selectedGateway.ip, selectedGateway.serverDns, selectedGateway.comment].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedGateway.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Предупреждение если gateway не в списке */}
|
||||||
|
{value && !selectedGateway && !isOpen && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="d-flex align-items-center gap-2 p-2 bg-warning-lt rounded">
|
||||||
|
<IconAlertTriangle size={16} className="text-warning" />
|
||||||
|
<div className="text-muted small">Gateway "{value}" — свободный ввод</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FilterManager() {
|
function FilterManager() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [success, setSuccess] = useState('');
|
const [success, setSuccess] = useState('');
|
||||||
@@ -149,37 +350,70 @@ function FilterManager() {
|
|||||||
// Inventory servers (from /servers endpoint)
|
// Inventory servers (from /servers endpoint)
|
||||||
const [inventoryServers, setInventoryServers] = useState([]);
|
const [inventoryServers, setInventoryServers] = useState([]);
|
||||||
|
|
||||||
// Собираем все gateways из всех серверов для выбора
|
// Собираем gateways только для выбранного сервера
|
||||||
const allGateways = (() => {
|
const selectedServerGateways = (() => {
|
||||||
|
if (!selectedServer) return [];
|
||||||
|
|
||||||
|
// Ищем соответствующий сервер в inventoryServers по имени
|
||||||
|
const matchedServer = inventoryServers.find(srv =>
|
||||||
|
String(srv.dns || '').trim() === selectedServer.name ||
|
||||||
|
String(srv.hostName || '').trim() === selectedServer.name ||
|
||||||
|
String(srv.ip || '').trim() === selectedServer.name
|
||||||
|
);
|
||||||
|
|
||||||
const gws = [];
|
const gws = [];
|
||||||
inventoryServers.forEach(srv => {
|
|
||||||
if (Array.isArray(srv.gateways)) {
|
if (matchedServer) {
|
||||||
srv.gateways.forEach(gw => {
|
// Добавляем gateways из найденного сервера
|
||||||
if (gw.name && !gws.some(g => g.name === gw.name)) {
|
if (Array.isArray(matchedServer.gateways)) {
|
||||||
|
matchedServer.gateways.forEach(gw => {
|
||||||
|
if (gw.name) {
|
||||||
gws.push({
|
gws.push({
|
||||||
name: gw.name,
|
name: gw.name,
|
||||||
ip: gw.ip || '',
|
ip: gw.ip || '',
|
||||||
comment: gw.comment || '',
|
comment: gw.comment || '',
|
||||||
serverDns: srv.dns || srv.hostName || '',
|
serverDns: matchedServer.dns || matchedServer.hostName || '',
|
||||||
country: srv.country || '',
|
country: matchedServer.country || '',
|
||||||
primary: gw.primary
|
primary: gw.primary
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Также добавляем старое поле gateway если есть
|
// Также добавляем старое поле gateway если есть
|
||||||
if (srv.gateway && !gws.some(g => g.name === srv.gateway)) {
|
if (matchedServer.gateway && !gws.some(g => g.name === matchedServer.gateway)) {
|
||||||
gws.push({
|
gws.push({
|
||||||
name: srv.gateway,
|
name: matchedServer.gateway,
|
||||||
ip: '',
|
ip: '',
|
||||||
comment: 'Legacy gateway',
|
comment: 'Legacy gateway',
|
||||||
serverDns: srv.dns || srv.hostName || '',
|
serverDns: matchedServer.dns || matchedServer.hostName || '',
|
||||||
country: srv.country || '',
|
country: matchedServer.country || '',
|
||||||
primary: true
|
primary: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также проверяем gateways из selectedServer (server-configs)
|
||||||
|
if (Array.isArray(selectedServer.gateways)) {
|
||||||
|
selectedServer.gateways.forEach(gw => {
|
||||||
|
if (gw.name && !gws.some(g => g.name === gw.name)) {
|
||||||
|
gws.push({
|
||||||
|
name: gw.name,
|
||||||
|
ip: gw.ip || '',
|
||||||
|
comment: gw.comment || '',
|
||||||
|
serverDns: selectedServer.name || '',
|
||||||
|
country: matchedServer?.country || '',
|
||||||
|
primary: gw.primary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return gws.sort((a, b) => {
|
||||||
|
// Primary первым
|
||||||
|
if (a.primary && !b.primary) return -1;
|
||||||
|
if (!a.primary && b.primary) return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
return gws.sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1357,50 +1591,13 @@ function FilterManager() {
|
|||||||
helpText="Введите community в формате AS:VALUE"
|
helpText="Введите community в формате AS:VALUE"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-3">
|
<GatewayAutocomplete
|
||||||
<label className="form-label required">Gateway</label>
|
label="Gateway"
|
||||||
<select
|
value={newFilter.gateway}
|
||||||
className="form-select"
|
onChange={(val) => setNewFilter({ ...newFilter, gateway: val })}
|
||||||
value={newFilter.gateway}
|
gateways={selectedServerGateways}
|
||||||
onChange={(e) => setNewFilter({ ...newFilter, gateway: e.target.value })}
|
required
|
||||||
required
|
/>
|
||||||
>
|
|
||||||
<option value="">Выберите gateway...</option>
|
|
||||||
{allGateways.map((gw) => (
|
|
||||||
<option key={gw.name} value={gw.name}>
|
|
||||||
{gw.name}{gw.ip ? ` (${gw.ip})` : ''}{gw.serverDns ? ` — ${gw.serverDns}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{allGateways.length === 0 && (
|
|
||||||
<div className="form-text text-warning">
|
|
||||||
<IconAlertTriangle size={14} className="me-1" />
|
|
||||||
Нет доступных gateway. Добавьте шлюзы в разделе Серверы.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{newFilter.gateway && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{(() => {
|
|
||||||
const selected = allGateways.find(g => g.name === newFilter.gateway);
|
|
||||||
if (!selected) return null;
|
|
||||||
return (
|
|
||||||
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
|
|
||||||
<span className="avatar avatar-xs bg-primary-lt">
|
|
||||||
{selected.country ? countryToFlag(selected.country) : <IconServer size={14} />}
|
|
||||||
</span>
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<div className="fw-bold">{selected.name}</div>
|
|
||||||
<div className="text-muted small">
|
|
||||||
{[selected.ip, selected.serverDns, selected.comment].filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Описание"
|
label="Описание"
|
||||||
@@ -1436,61 +1633,13 @@ function FilterManager() {
|
|||||||
icon={IconHash}
|
icon={IconHash}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="mb-3">
|
<GatewayAutocomplete
|
||||||
<label className="form-label required">Gateway</label>
|
label="Gateway"
|
||||||
<select
|
value={editingFilter.gateway}
|
||||||
className="form-select"
|
onChange={(val) => setEditingFilter({ ...editingFilter, gateway: val })}
|
||||||
value={editingFilter.gateway}
|
gateways={selectedServerGateways}
|
||||||
onChange={(e) => setEditingFilter({ ...editingFilter, gateway: e.target.value })}
|
required
|
||||||
required
|
/>
|
||||||
>
|
|
||||||
<option value="">Выберите gateway...</option>
|
|
||||||
{allGateways.map((gw) => (
|
|
||||||
<option key={gw.name} value={gw.name}>
|
|
||||||
{gw.name}{gw.ip ? ` (${gw.ip})` : ''}{gw.serverDns ? ` — ${gw.serverDns}` : ''}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
{/* Если текущий gateway не в списке, добавляем его */}
|
|
||||||
{editingFilter.gateway && !allGateways.some(g => g.name === editingFilter.gateway) && (
|
|
||||||
<option value={editingFilter.gateway}>{editingFilter.gateway} (не найден в списке)</option>
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
{allGateways.length === 0 && (
|
|
||||||
<div className="form-text text-warning">
|
|
||||||
<IconAlertTriangle size={14} className="me-1" />
|
|
||||||
Нет доступных gateway. Добавьте шлюзы в разделе Серверы.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{editingFilter.gateway && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{(() => {
|
|
||||||
const selected = allGateways.find(g => g.name === editingFilter.gateway);
|
|
||||||
if (!selected) {
|
|
||||||
return (
|
|
||||||
<div className="d-flex align-items-center gap-2 p-2 bg-warning-lt rounded">
|
|
||||||
<IconAlertTriangle size={16} className="text-warning" />
|
|
||||||
<div className="text-muted small">Gateway "{editingFilter.gateway}" не найден в списке серверов</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="d-flex align-items-center gap-2 p-2 bg-light rounded">
|
|
||||||
<span className="avatar avatar-xs bg-primary-lt">
|
|
||||||
{selected.country ? countryToFlag(selected.country) : <IconServer size={14} />}
|
|
||||||
</span>
|
|
||||||
<div className="flex-grow-1">
|
|
||||||
<div className="fw-bold">{selected.name}</div>
|
|
||||||
<div className="text-muted small">
|
|
||||||
{[selected.ip, selected.serverDns, selected.comment].filter(Boolean).join(' · ')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selected.primary && <span className="badge bg-green-lt text-green">Основной</span>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label="Описание"
|
label="Описание"
|
||||||
|
|||||||
Reference in New Issue
Block a user