feat(NetworkConfigManager): add IP registry management features; implement IP conflict checking and filtering for better interface management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s

This commit is contained in:
2026-01-22 17:18:14 +07:00
parent 0109d5ea5f
commit 0442942b44
+267
View File
@@ -111,6 +111,7 @@ function NetworkConfigManager() {
const [typeFilter, setTypeFilter] = useState('');
const [gatewayTypeFilter, setGatewayTypeFilter] = useState(''); // Для фильтрации gateway по типу
const [viewMode, setViewMode] = useState('cards'); // 'cards' | 'table'
const [ipRegistrySearch, setIpRegistrySearch] = useState(''); // Поиск в реестре IP
// === Modals ===
const [gatewayModalOpen, setGatewayModalOpen] = useState(false);
@@ -434,7 +435,126 @@ function NetworkConfigManager() {
setInterfaceModalOpen(true);
};
// === Проверка пересечений IP адресов для интерфейсов ===
const checkInterfaceIpConflict = (ifaceData, excludeId = null) => {
const conflicts = [];
const allInterfaces = config.tunnelInterfaces || [];
// Проверяем localIp
if (ifaceData.localIp && ifaceData.localIp.trim()) {
const conflictingLocal = allInterfaces.find(i =>
i.id !== excludeId &&
(i.localIp === ifaceData.localIp.trim() || i.remoteIp === ifaceData.localIp.trim())
);
if (conflictingLocal) {
conflicts.push({
ip: ifaceData.localIp.trim(),
type: 'localIp',
conflictingInterface: conflictingLocal
});
}
}
// Проверяем remoteIp
if (ifaceData.remoteIp && ifaceData.remoteIp.trim()) {
const conflictingRemote = allInterfaces.find(i =>
i.id !== excludeId &&
(i.localIp === ifaceData.remoteIp.trim() || i.remoteIp === ifaceData.remoteIp.trim())
);
if (conflictingRemote) {
conflicts.push({
ip: ifaceData.remoteIp.trim(),
type: 'remoteIp',
conflictingInterface: conflictingRemote
});
}
}
return conflicts;
};
// === Получение реестра всех используемых IP адресов ===
const ipRegistry = useMemo(() => {
const registry = [];
// Собираем IP из интерфейсов
(config.tunnelInterfaces || []).forEach(iface => {
if (iface.localIp && iface.localIp.trim()) {
registry.push({
ip: iface.localIp.trim(),
type: 'localIp',
interface: iface,
interfaceName: iface.name || iface.id
});
}
if (iface.remoteIp && iface.remoteIp.trim()) {
registry.push({
ip: iface.remoteIp.trim(),
type: 'remoteIp',
interface: iface,
interfaceName: iface.name || iface.id
});
}
});
// Группируем по IP адресам
const groupedByIp = {};
registry.forEach(item => {
if (!groupedByIp[item.ip]) {
groupedByIp[item.ip] = {
ip: item.ip,
interfaces: []
};
}
groupedByIp[item.ip].interfaces.push({
type: item.type,
interface: item.interface,
interfaceName: item.interfaceName
});
});
// Преобразуем в массив и сортируем
return Object.values(groupedByIp)
.map(item => ({
...item,
usageCount: item.interfaces.length,
hasConflict: item.interfaces.length > 1 // Конфликт если IP используется в нескольких интерфейсах
}))
.sort((a, b) => {
// Сначала конфликты, потом по IP
if (a.hasConflict !== b.hasConflict) {
return a.hasConflict ? -1 : 1;
}
return a.ip.localeCompare(b.ip);
});
}, [config.tunnelInterfaces]);
// === Фильтрация реестра IP ===
const filteredIpRegistry = useMemo(() => {
if (!ipRegistrySearch) return ipRegistry;
const term = ipRegistrySearch.toLowerCase();
return ipRegistry.filter(item =>
item.ip.toLowerCase().includes(term) ||
item.interfaces.some(i => i.interfaceName.toLowerCase().includes(term))
);
}, [ipRegistry, ipRegistrySearch]);
const handleSaveInterface = (ifaceData) => {
// Валидация: проверка пересечений IP адресов
const conflicts = checkInterfaceIpConflict(
ifaceData,
interfaceModalMode === 'edit' ? ifaceData.id : null
);
if (conflicts.length > 0) {
const conflictMessages = conflicts.map(c => {
const conflictName = c.conflictingInterface.name || c.conflictingInterface.id;
return `${c.type === 'localIp' ? 'Local IP' : 'Remote IP'} ${c.ip} уже используется в интерфейсе "${conflictName}"`;
});
notify.error(`Обнаружено пересечение IP адресов:\n${conflictMessages.join('\n')}`);
return;
}
if (interfaceModalMode === 'add') {
setConfig(prev => ({
...prev,
@@ -1255,6 +1375,21 @@ function NetworkConfigManager() {
{config.ipPools?.length || 0}
</span>
</button>
<button
className={`btn ${activeTab === 'ip-registry' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('ip-registry')}
>
<IconList size={16} className="me-1 d-none d-sm-inline" />
Реестр IP
<span className={`badge ms-1 ${activeTab === 'ip-registry' ? 'bg-white text-primary' : 'bg-primary-lt text-primary'}`}>
{ipRegistry.length}
</span>
{ipRegistry.some(item => item.hasConflict) && (
<span className="badge bg-danger ms-1" title="Обнаружены конфликты IP адресов">
!
</span>
)}
</button>
</div>
<button
@@ -1816,6 +1951,110 @@ function NetworkConfigManager() {
)}
</>
)}
{/* IP Registry Tab */}
{activeTab === 'ip-registry' && (
<>
<div className="mb-3">
<div className="input-group">
<span className="input-group-text">
<IconSearch size={16} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск по IP адресу или имени интерфейса..."
value={ipRegistrySearch}
onChange={(e) => setIpRegistrySearch(e.target.value)}
/>
</div>
</div>
{filteredIpRegistry.length === 0 ? (
<div className="empty">
<div className="empty-img">
<IconList size={48} />
</div>
<p className="empty-title">Реестр IP адресов пуст</p>
<p className="empty-subtitle text-muted">
{ipRegistrySearch
? 'Попробуйте изменить поисковый запрос'
: 'Добавьте интерфейсы, чтобы увидеть используемые IP адреса'}
</p>
</div>
) : (
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>IP адрес</th>
<th>Использование</th>
<th>Интерфейсы</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{filteredIpRegistry.map((item, idx) => (
<tr key={`${item.ip}-${idx}`} className={item.hasConflict ? 'table-danger' : ''}>
<td>
<div className="d-flex align-items-center">
<code className="fw-bold">{item.ip}</code>
{item.hasConflict && (
<span className="badge bg-danger ms-2" title="Конфликт: IP используется в нескольких интерфейсах">
Конфликт
</span>
)}
</div>
</td>
<td>
<span className="badge bg-primary-lt text-primary">
{item.usageCount} {item.usageCount === 1 ? 'раз' : 'раза'}
</span>
</td>
<td>
<div className="d-flex flex-column gap-1">
{item.interfaces.map((usage, uIdx) => (
<div key={uIdx} className="d-flex align-items-center gap-2">
<span className={`badge ${usage.type === 'localIp' ? 'bg-info-lt text-info' : 'bg-success-lt text-success'}`}>
{usage.type === 'localIp' ? 'Local' : 'Remote'}
</span>
<span>{usage.interfaceName}</span>
<button
className="btn btn-ghost-primary btn-icon btn-sm ms-auto"
onClick={() => handleEditInterface(usage.interface)}
title="Редактировать интерфейс"
>
<IconEdit size={14} />
</button>
</div>
))}
</div>
</td>
<td className="text-end">
<button
className="btn btn-ghost-primary btn-icon btn-sm"
onClick={() => {
navigator.clipboard.writeText(item.ip).then(() => {
notify.success('IP адрес скопирован');
}).catch(() => {
notify.error('Не удалось скопировать IP адрес');
});
}}
title="Копировать IP адрес"
>
<IconCopy size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</>
)}
@@ -2053,6 +2292,20 @@ function NetworkConfigManager() {
onChange={(val) => setEditingInterface({ ...editingInterface, localIp: val })}
placeholder="10.10.0.1"
/>
{editingInterface.localIp && (() => {
const conflicts = checkInterfaceIpConflict(
{ localIp: editingInterface.localIp },
interfaceModalMode === 'edit' ? editingInterface.id : null
).filter(c => c.type === 'localIp');
if (conflicts.length > 0) {
return (
<div className="form-text text-danger">
Этот IP уже используется в интерфейсе "{conflicts[0].conflictingInterface.name || conflicts[0].conflictingInterface.id}"
</div>
);
}
return null;
})()}
</div>
<div className="col-md-4">
<FormField
@@ -2062,6 +2315,20 @@ function NetworkConfigManager() {
onChange={(val) => setEditingInterface({ ...editingInterface, remoteIp: val })}
placeholder="10.10.0.2"
/>
{editingInterface.remoteIp && (() => {
const conflicts = checkInterfaceIpConflict(
{ remoteIp: editingInterface.remoteIp },
interfaceModalMode === 'edit' ? editingInterface.id : null
).filter(c => c.type === 'remoteIp');
if (conflicts.length > 0) {
return (
<div className="form-text text-danger">
Этот IP уже используется в интерфейсе "{conflicts[0].conflictingInterface.name || conflicts[0].conflictingInterface.id}"
</div>
);
}
return null;
})()}
</div>
<div className="col-12">
<label className="form-label">Второй сервер (опционально)</label>