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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m36s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user