This commit is contained in:
+328
-274
@@ -1,41 +1,35 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconDatabase,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconUpload,
|
||||
IconAlertTriangle,
|
||||
IconFilter,
|
||||
IconChevronDown,
|
||||
IconLink,
|
||||
IconCopy,
|
||||
IconEye,
|
||||
IconServer,
|
||||
IconSettings,
|
||||
IconDeviceFloppy,
|
||||
IconHash,
|
||||
IconRocket,
|
||||
IconWorld,
|
||||
IconNetwork
|
||||
IconRefresh
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function IPRangesManager() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingIndex, setEditingIndex] = useState(null);
|
||||
const [editingItem, setEditingItem] = useState({ ipRange: '', community: '' });
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
@@ -49,314 +43,311 @@ function IPRangesManager() {
|
||||
setError('');
|
||||
} catch (error) {
|
||||
console.error('Error fetching ip-ranges:', error);
|
||||
setItems([]);
|
||||
setError('Не удалось загрузить IP-диапазоны. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingIndex(null);
|
||||
setEditingItem({ ipRange: '', community: '' });
|
||||
};
|
||||
|
||||
const handleEdit = (index) => {
|
||||
setEditingIndex(index);
|
||||
setEditingItem({ ...items[index] });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingItem.ipRange.trim() || !editingItem.community.trim()) {
|
||||
setError('IP-диапазон и Community обязательны для заполнения.');
|
||||
const handleAddItem = () => {
|
||||
if (newItem.ipRange.trim() === '') {
|
||||
setError('IP-диапазон не может быть пустым.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedItems = [...items];
|
||||
if (editingIndex !== null) {
|
||||
updatedItems[editingIndex] = { ...editingItem };
|
||||
} else {
|
||||
updatedItems.push({ ...editingItem });
|
||||
if (newItem.community.trim() === '') {
|
||||
setError('Community не может быть пустым.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setItems([...items, newItem]);
|
||||
setNewItem({ ipRange: '', community: '' });
|
||||
};
|
||||
|
||||
const handleEdit = (item) => {
|
||||
setEditingIp(item.ipRange);
|
||||
setEditingValue(item.community);
|
||||
};
|
||||
|
||||
const handleSaveEdit = (ipRange) => {
|
||||
const updatedItems = items.map(i =>
|
||||
i.ipRange === ipRange ? { ...i, community: editingValue } : i
|
||||
);
|
||||
setItems(updatedItems);
|
||||
setEditingIndex(null);
|
||||
setEditingItem({ ipRange: '', community: '' });
|
||||
setEditingIp(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingIndex(null);
|
||||
setEditingItem({ ipRange: '', community: '' });
|
||||
const handleCancelEdit = () => {
|
||||
setEditingIp(null);
|
||||
};
|
||||
|
||||
const handleDelete = (index) => {
|
||||
const updatedItems = items.filter((_, i) => i !== index);
|
||||
setItems(updatedItems);
|
||||
const handleDeleteItem = (ipRangeToDelete) => {
|
||||
setItems(items.filter(i => i.ipRange !== ipRangeToDelete));
|
||||
};
|
||||
|
||||
const handleSaveToS3 = async () => {
|
||||
const confirmDelete = (item) => {
|
||||
setItemToDelete(item);
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
const executeDelete = () => {
|
||||
if (itemToDelete) {
|
||||
handleDeleteItem(itemToDelete.ipRange);
|
||||
setShowDeleteModal(false);
|
||||
setItemToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await axios.post(`${API_URL}/ip-ranges`, { ipRanges: items });
|
||||
setSuccess('IP-диапазоны успешно сохранены!');
|
||||
setSuccess('Изменения успешно сохранены!');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} catch (error) {
|
||||
console.error('Error saving ip-ranges:', error);
|
||||
setError('Не удалось сохранить IP-диапазоны.');
|
||||
console.error('Error saving changes:', error);
|
||||
setError('Не удалось сохранить изменения.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (event) => {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
// Сортировка
|
||||
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;
|
||||
});
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target.result;
|
||||
const lines = content.split('\n').filter(line => line.trim());
|
||||
const parsedItems = lines.map(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
return {
|
||||
ipRange: parts[0] || '',
|
||||
community: parts[1] || ''
|
||||
};
|
||||
});
|
||||
setItems(parsedItems);
|
||||
setSuccess(`Загружено ${parsedItems.length} IP-диапазонов!`);
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
// Фильтрация по community
|
||||
const filteredByCommunity = filterCommunity
|
||||
? sortedItems.filter(i => i.community === filterCommunity)
|
||||
: sortedItems;
|
||||
|
||||
const handleDownload = () => {
|
||||
const content = items.map(item => `${item.ipRange} ${item.community}`).join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'ips.txt';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const filteredItems = items.filter(item =>
|
||||
item.ipRange.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.community.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
// Поиск
|
||||
const filteredItems = filteredByCommunity.filter(i =>
|
||||
i.ipRange.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container-xl">
|
||||
<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-диапазоны</div>
|
||||
</div>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={handleDownload}
|
||||
disabled={items.length === 0}
|
||||
>
|
||||
<IconDownload className="me-2" />
|
||||
Скачать
|
||||
</button>
|
||||
<label className="btn btn-outline-primary">
|
||||
<IconUpload className="me-2" />
|
||||
Загрузить
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
onChange={handleUpload}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<IconPlus className="me-2" />
|
||||
Добавить IP-диапазон
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Пагинация
|
||||
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">
|
||||
<IconAlertTriangle className="me-2" />
|
||||
{error}
|
||||
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconX className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
<a className="btn-close" onClick={() => setError('')}></a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="alert alert-success alert-dismissible" role="alert">
|
||||
<IconCheck className="me-2" />
|
||||
{success}
|
||||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||||
<div className="d-flex">
|
||||
<div>
|
||||
<IconCheck className="icon alert-icon" />
|
||||
</div>
|
||||
<div>
|
||||
{success}
|
||||
</div>
|
||||
</div>
|
||||
<a className="btn-close" onClick={() => setSuccess('')}></a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Панель поиска и сохранения */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="row g-3 align-items-center">
|
||||
<div className="col-md-6">
|
||||
<div className="input-group">
|
||||
<span className="input-group-text">
|
||||
<IconSearch />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="Поиск по IP-диапазону или Community..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-lg-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="col-md-6">
|
||||
<div className="d-flex gap-2 justify-content-end">
|
||||
<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"
|
||||
placeholder="192.168.1.0/24"
|
||||
value={newItem.ipRange}
|
||||
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="form-label">Community</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
placeholder="65001:100"
|
||||
value={newItem.community}
|
||||
onChange={(e) => setNewItem({ ...newItem, community: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<button type="submit" className="btn btn-primary w-100">
|
||||
<IconPlus className="icon me-2" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Card */}
|
||||
<div className="card card-md">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconDatabase className="icon me-2" />
|
||||
Действия
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="d-grid gap-2">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSaveChanges}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<IconDatabase className="icon me-2" />
|
||||
Сохранить в S3
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={fetchItems}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh className="me-2" />
|
||||
<IconRefresh className="icon me-2" />
|
||||
Обновить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={handleSaveToS3}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconDeviceFloppy className="me-2" />
|
||||
Сохранить в S3
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Таблица IP-диапазонов */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<IconNetwork className="me-2" />
|
||||
IP-диапазоны ({filteredItems.length} из {items.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{loading ? (
|
||||
<div className="text-center text-muted py-4">
|
||||
<div className="spinner-border spinner-border-sm me-2" role="status">
|
||||
<span className="visually-hidden">Загрузка...</span>
|
||||
<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-диапазонов</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>
|
||||
Загрузка IP-диапазонов...
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="text-center text-muted py-4">
|
||||
<IconNetwork className="icon-lg mb-2" />
|
||||
<p>Нет IP-диапазонов</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<IconPlus className="me-2" />
|
||||
Добавить первый IP-диапазон
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40%' }}>IP-ДИАПАЗОН</th>
|
||||
<th style={{ width: '40%' }}>COMMUNITY</th>
|
||||
<th style={{ width: '20%' }} className="text-end">ДЕЙСТВИЯ</th>
|
||||
<th style={{cursor:'pointer'}} onClick={() => handleSort('ipRange')}>
|
||||
IP-диапазон
|
||||
{sortField === 'ipRange' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
|
||||
Community
|
||||
{sortField === 'community' && (
|
||||
<span className="ms-1">
|
||||
{sortOrder === 'asc' ? '▲' : '▼'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
<th className="text-end"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
{editingIndex === index ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editingItem.ipRange}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, ipRange: e.target.value })}
|
||||
placeholder="192.168.1.0/24"
|
||||
/>
|
||||
) : (
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="badge bg-blue-lt text-blue me-2">
|
||||
<IconNetwork size={12} />
|
||||
</span>
|
||||
<code className="text-blue">{item.ipRange}</code>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editingIndex === index ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editingItem.community}
|
||||
onChange={(e) => setEditingItem({ ...editingItem, community: e.target.value })}
|
||||
placeholder="65001:100"
|
||||
/>
|
||||
) : (
|
||||
<div className="d-flex align-items-center">
|
||||
<span className="badge bg-green-lt text-green me-2">
|
||||
<IconHash size={12} />
|
||||
</span>
|
||||
<code className="text-green">{item.community}</code>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{paginatedItems.map((item) => (
|
||||
<tr key={item.ipRange} className={editingIp === item.ipRange ? 'table-info' : ''}>
|
||||
<td>{item.ipRange}</td>
|
||||
<td><span className="badge bg-blue-lt text-blue">{item.community}</span></td>
|
||||
<td className="text-end">
|
||||
{editingIndex === index ? (
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-success btn-icon btn-sm"
|
||||
onClick={handleSave}
|
||||
title="Сохранить"
|
||||
>
|
||||
<IconCheck size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary btn-icon btn-sm"
|
||||
onClick={handleCancel}
|
||||
title="Отмена"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{editingIp === item.ipRange ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control d-inline-block w-auto me-2"
|
||||
value={editingValue}
|
||||
ref={editInputRef}
|
||||
onChange={e => setEditingValue(e.target.value)}
|
||||
onKeyDown={e => handleEditKeyDown(e, item.ipRange)}
|
||||
style={{maxWidth: 120}}
|
||||
/>
|
||||
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(item.ipRange)}><IconCheck size={18} /></button>
|
||||
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
|
||||
</>
|
||||
) : (
|
||||
<div className="btn-list">
|
||||
<button
|
||||
className="btn btn-outline-primary btn-icon btn-sm"
|
||||
onClick={() => handleEdit(index)}
|
||||
title="Редактировать"
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-icon btn-sm"
|
||||
onClick={() => handleDelete(index)}
|
||||
title="Удалить"
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(item)}><IconEdit size={18} /></button>
|
||||
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(item)}><IconTrash size={18} /></button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -364,10 +355,73 @@ function IPRangesManager() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{/* Пагинация */}
|
||||
{totalPages > 1 && (
|
||||
<div className="card-footer d-flex justify-content-center">
|
||||
<ul className="pagination m-0">
|
||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} tabIndex={-1} disabled={currentPage === 1}>«</button>
|
||||
</li>
|
||||
{Array.from({length: totalPages}, (_, i) => i + 1).map(page => (
|
||||
<li key={page} className={`page-item${currentPage === page ? ' active' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(page)}>{page}</button>
|
||||
</li>
|
||||
))}
|
||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} tabIndex={-1} disabled={currentPage === totalPages}>»</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user