Files
router-lists-ui/frontend/src/IPRangesManager.jsx
T
2025-08-12 22:33:57 +07:00

741 lines
31 KiB
React

import { useState, useEffect, useRef } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText,
IconEye
} from '@tabler/icons-react';
import LockBanner from './components/LockBanner.jsx';
import TableSkeleton from './components/TableSkeleton.jsx';
import S3MetaBar from './components/S3MetaBar.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
const API_URL = '/api';
function IPRangesManager() {
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [etag, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [contentLength, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ ipRange: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [editingIp, setEditingIp] = useState(null);
const [editingValue, setEditingValue] = useState('');
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [itemToDelete, setItemToDelete] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('ipRange');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const editInputRef = useRef(null);
const pageSize = 10;
// Справочник community для подсказок
const [communities, setCommunities] = useState([]);
useEffect(() => {
(async () => {
try {
const res = await api.get(`/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
// тихо игнорируем
}
})();
}, []);
const renderCommunityBadge = (value) => {
const v = String(value ?? '').trim();
if (!v) return <span className="badge bg-blue-lt text-blue"></span>;
const meta = communities.find(c => c.value === v);
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
const cls = `badge bg-${color}-lt text-${color}`;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
return (
<span className={cls} title={title}>{v}</span>
);
};
const isValidIPv4 = (ip) => {
const octets = ip.split('.');
if (octets.length !== 4) return false;
return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
};
const isValidCidr = (value) => {
const v = String(value).trim();
const parts = v.split('/');
if (parts.length !== 2) return false;
const [ip, mask] = parts;
if (!isValidIPv4(ip)) return false;
if (!/^\d{1,2}$/.test(mask)) return false;
const m = Number(mask);
return m >= 0 && m <= 32;
};
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
useEffect(() => {
// Acquire soft lock
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
localStorage.setItem('uiOwner', owner);
const resource = 'ip-ranges';
const acquire = async () => {
try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {}
};
acquire();
const interval = setInterval(acquire, 60_000);
fetchItems();
return () => {
clearInterval(interval);
api.delete(`/locks/${resource}`).catch(() => {});
};
}, []);
const fetchItems = async () => {
setLoading(true);
try {
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } });
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
setItems(payload);
setOriginalItems(payload);
setEtag(response.headers?.etag || '');
setLastModified(response.headers?.['last-modified'] || '');
const lengthHeader = response.headers?.['content-length-source'];
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
setError('');
} catch (error) {
console.error('Error fetching ip-ranges:', error);
setError('Не удалось загрузить IP-диапазоны. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
};
const handleAddItem = () => {
const cidrOk = isValidCidr(newItem.ipRange);
const communityOk = isValidCommunity(newItem.community);
setNewInvalid({ ipRange: !cidrOk, community: !communityOk });
if (!cidrOk || !communityOk) {
setError('Введите корректный IP-диапазон в формате CIDR (например 192.168.1.0/24) и числовой community.');
return;
}
setError('');
setItems([...items, { ipRange: newItem.ipRange.trim(), community: String(newItem.community).trim() }]);
setNewItem({ ipRange: '', community: '' });
setNewInvalid({ ipRange: false, community: false });
};
const handleEdit = (item) => {
setEditingIp(item.ipRange);
setEditingValue(item.community);
};
const handleSaveEdit = (ipRange) => {
if (!isValidCommunity(editingValue)) {
setError('Community должен быть числом.');
return;
}
const updatedItems = items.map(i =>
i.ipRange === ipRange ? { ...i, community: String(editingValue).trim() } : i
);
setItems(updatedItems);
setEditingIp(null);
};
const handleCancelEdit = () => {
setEditingIp(null);
};
const handleDeleteItem = (ipRangeToDelete) => {
setItems(items.filter(i => i.ipRange !== ipRangeToDelete));
};
const confirmDelete = (item) => {
setItemToDelete(item);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (itemToDelete) {
handleDeleteItem(itemToDelete.ipRange);
setShowDeleteModal(false);
setItemToDelete(null);
}
};
const deduplicate = (arr) => {
const seen = new Set();
const out = [];
for (const i of arr) {
const key = String(i.ipRange).trim();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ipRange: key, community: String(i.community || '').trim() });
}
return out;
};
const computeDiff = (before, after) => {
const mapBefore = new Map(before.map(i => [i.ipRange, i]));
const mapAfter = new Map(after.map(i => [i.ipRange, i]));
const added = [];
const removed = [];
const changed = [];
for (const [k, v] of mapAfter) {
if (!mapBefore.has(k)) { added.push(v); continue; }
const prev = mapBefore.get(k);
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
}
for (const [k, v] of mapBefore) {
if (!mapAfter.has(k)) removed.push(v);
}
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [wsOpen, setWsOpen] = useState(false);
const handlePreviewDiff = () => {
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
setDiff(computeDiff(originalItems, unique));
setShowDiff(true);
};
const handleSaveChanges = async () => {
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
setDiff(computeDiff(originalItems, unique));
setConfirmSaveOpen(true);
};
const performSave = async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const response = await api.post(`/ip-ranges`, { ipRanges: unique, etag }, { validateStatus: () => true });
if (response.status === 412) {
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
return;
}
if (response.status >= 400) {
throw new Error(`Save failed with status ${response.status}`);
}
setEtag(response.headers?.etag || etag);
setOriginalItems(unique);
setItems(unique);
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
const handleImport = () => {
const text = window.prompt('Вставьте строки: CIDR ПРОБЕЛ COMMUNITY (по одной записи на строку)');
if (!text) return;
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const parsed = lines.map(l => {
const [cidr, c] = l.split(/\s+/);
return { ipRange: (cidr || '').trim(), community: (c || '').trim() };
}).filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community));
setItems(prev => {
const merged = [...prev, ...parsed];
const map = new Map();
for (const it of merged) {
const key = String(it.ipRange).trim();
if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
};
const handleExport = () => {
const header = ['ipRange', 'community'];
const csv = [header, ...items.map(i => [i.ipRange, i.community])]
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ip_ranges_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
// Drag&Drop импорт
const onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const text = await file.text();
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const parsed = lines.map(l => {
const [cidr, c] = l.split(/\s+/);
return { ipRange: (cidr || '').trim(), community: (c || '').trim() };
}).filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community));
setItems(prev => {
const merged = [...prev, ...parsed];
const map = new Map();
for (const it of merged) {
const key = String(it.ipRange).trim();
if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() });
}
return Array.from(map.values());
});
};
const clearInvalid = () => {
setItems(prev => prev.filter(i => i.ipRange || i.community)
.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
);
};
// Сортировка
const sortedItems = [...items].sort((a, b) => {
let valA = a[sortField] || '';
let valB = b[sortField] || '';
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
// Фильтрация по community
const filteredByCommunity = filterCommunity
? sortedItems.filter(i => i.community === filterCommunity)
: sortedItems;
// Поиск
const filteredItems = filteredByCommunity.filter(i =>
i.ipRange.toLowerCase().includes(searchTerm.toLowerCase())
);
// Пагинация
const totalPages = Math.ceil(filteredItems.length / pageSize);
const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.map(i => i.community)));
// Сортировка по клику
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Улучшенный инлайн-редакт
useEffect(() => {
if (editingIp && editInputRef.current) {
editInputRef.current.focus();
}
}, [editingIp]);
const handleEditKeyDown = (e, ipRange) => {
if (e.key === 'Enter') handleSaveEdit(ipRange);
if (e.key === 'Escape') handleCancelEdit();
};
return (
<>
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconX className="icon alert-icon" />
</div>
<div>
{error}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title">IP-диапазоны</h2>
<div className="page-pretitle">Главная / Данные / IP ranges</div>
</div>
<div className="col-auto ms-auto d-print-none">
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('http://77.232.38.173:8080/api/update_bgp/background?api_key=denozord2502', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
window.notify?.success('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
}
}}
/>
</div>
</div>
</div>
<div className="row">
<div className="col-lg-3">
<LockBanner resource="ip-ranges" className="mb-3" />
{/* Add New Item Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconPlus className="icon me-2" />
Добавить новый IP-диапазон
</h3>
</div>
<div className="card-body">
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
<div className="mb-3">
<label className="form-label">IP-диапазон</label>
<input
type="text"
className={`form-control${newInvalid.ipRange ? ' is-invalid' : ''}`}
placeholder="192.168.1.0/24"
value={newItem.ipRange}
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
/>
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
</div>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={newItem.community}
onChange={(v) => setNewItem({ ...newItem, community: v })}
communities={communities}
placeholder="Начните вводить номер или имя"
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
/>
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
{(() => {
const match = communities.find(c => c.value === String(newItem.community).trim());
if (!match) return null;
return (
<div className="form-text">
{match.name && (<><strong>{match.name}</strong> </>)}
{match.description || ''}
{match.tags && match.tags.length > 0 && (
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
)}
</div>
);
})()}
</div>
<div className="form-footer">
<button type="submit" className="btn btn-primary w-100">
<IconPlus className="icon me-2" />
Добавить
</button>
</div>
</form>
</div>
</div>
{/* Drag & Drop импорт */}
<div
className="card card-md border-dashed"
onDragOver={(e) => { e.preventDefault(); }}
onDrop={onDropImport}
>
<div className="card-body text-center text-muted">
Перетащите файл TXT/CSV сюда для импорта (формат: CIDR community)
</div>
</div>
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
</div>
<div className="col-lg-9">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список IP-диапазонов <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="d-flex gap-2 w-50">
{/* Фильтр по community */}
<select className="form-select w-auto" value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}>
<option value="">Все community</option>
{allCommunities.map(community => (
<option key={community} value={community}>{community}</option>
))}
</select>
{/* Поиск */}
<div className="input-icon flex-grow-1">
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск IP-диапазона..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
</div>
<div></div>
</div>
{/* Мета-информация будет показана внизу карточки */}
{loading ? (
<TableSkeleton rows={10} cols={3} />
) : (
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th className="cursor-pointer" onClick={() => handleSort('ipRange')}>
IP-диапазон
{sortField === 'ipRange' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="cursor-pointer" onClick={() => handleSort('community')}>
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-end"> </th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item) => (
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-info' : ''}>
<td>{item.ipRange}</td>
<td>{renderCommunityBadge(item.community)}</td>
<td className="text-end">
{editingIp === item.ipRange ? (
<>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
/>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.ipRange)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">
Показано {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, filteredItems.length)} из {filteredItems.length}
</div>
<ul className="pagination m-0">
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(1)} tabIndex={-1} disabled={currentPage === 1}>Первая</button>
</li>
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>Назад</button>
</li>
{(() => {
const pages = [];
const maxToShow = 7; // 1 .. 5 .. N
let start = Math.max(1, currentPage - 2);
let end = Math.min(totalPages, currentPage + 2);
if (currentPage <= 3) { end = Math.min(totalPages, 5); }
if (currentPage >= totalPages - 2) { start = Math.max(1, totalPages - 4); }
if (start > 1) pages.push('start-ellipsis');
for (let p = start; p <= end; p++) pages.push(p);
if (end < totalPages) pages.push('end-ellipsis');
const renderItem = (p) => {
if (p === 'start-ellipsis' || p === 'end-ellipsis') {
return (
<li key={p} className="page-item disabled"><span className="page-link"></span></li>
);
}
return (
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
</li>
);
};
return pages.map(renderItem);
})()}
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>Вперед</button>
</li>
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(totalPages)} tabIndex={-1} disabled={currentPage === totalPages}>Последняя</button>
</li>
</ul>
</div>
)}
<S3MetaBar keys={["ipRanges"]} />
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
<div className="modal-content">
<button
type="button"
className="btn-close"
onClick={() => setShowDeleteModal(false)}
></button>
<div className="modal-status bg-danger"></div>
<div className="modal-body text-center py-4">
<IconTrash className="icon mb-2 text-danger icon-lg" />
<h3>Удалить IP-диапазон?</h3>
<div className="text-muted">
Вы уверены, что хотите удалить "{itemToDelete?.ipRange}"? Это действие необратимо.
</div>
</div>
<div className="modal-footer">
<div className="w-100">
<div className="row">
<div className="col">
<button
className="btn w-100"
onClick={() => setShowDeleteModal(false)}
>
Отмена
</button>
</div>
<div className="col">
<button
className="btn btn-danger w-100"
onClick={executeDelete}
>
Удалить
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
onClose={() => setConfirmSaveOpen(false)}
/>
<HistoryModal
resource="ip-ranges"
show={historyOpen}
onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()}
/>
<WsUpdateModal
show={wsOpen}
url={'ws://77.232.38.173:8080/ws/update_bgp?api_key=denozord2502'}
onClose={() => setWsOpen(false)}
/>
{/* Diff Modal */}
{showDiff && (
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
<div className="modal-content">
<button type="button" className="btn-close" onClick={() => setShowDiff(false)}></button>
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
<div className="modal-body">
<div className="row g-3">
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
</div>
</div>
<div className="modal-footer">
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
</div>
</div>
</div>
</div>
)}
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
</>
);
}
export default IPRangesManager;