feat: Add server management functionality with new API endpoints and update UI to include server statistics
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 14m34s

This commit is contained in:
2025-07-10 08:21:52 +07:00
parent 2738455c1a
commit 86f6304382
6 changed files with 705 additions and 13 deletions
+10 -3
View File
@@ -1,11 +1,15 @@
# 📂 S3 Lists Manager
Веб-интерфейс для удобного управления текстовыми файлами (`domains.txt`, `asns.txt`) в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в этих файлах.
Веб-интерфейс для удобного управления файлами в S3-совместимом хранилище Yandex Cloud. Приложение позволяет в реальном времени просматривать, добавлять, редактировать, удалять и массово изменять записи в следующих файлах:
- **domains.txt** - список доменов и их шлюзов
- **asns.txt** - список AS номеров и их шлюзов
- **servers.json** - список серверов с расширенной информацией (IP, DNS, страна, провайдер, тип туннеля)
## ✨ Возможности
- **Два режима работы:** Управление списками доменов и AS-номеров через вкладки.
- **Три режима работы:** Управление списками доменов, AS-номеров и серверов через вкладки.
- **CRUD операции:** Полный набор действий: создание, чтение, обновление и удаление записей.
- **Поиск в реальном времени:** Мгновенная фильтрация списков по мере ввода.
- **Массовая замена:** Быстрое обновление шлюзов для сотен записей в один клик.
@@ -44,6 +48,7 @@ graph TD
C{S3 Bucket}
D1[domains.txt]
D2[asns.txt]
D3[servers.json]
end
A -- HTTP Requests --> B
@@ -197,4 +202,6 @@ docker-compose up -d
| `GET` | `/api/domains`| Получить список всех доменов. |
| `POST` | `/api/domains`| Сохранить изменения в `domains.txt`. |
| `GET` | `/api/asns` | Получить список всех AS. |
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
| `POST` | `/api/asns` | Сохранить изменения в `asns.txt`. |
| `GET` | `/api/servers`| Получить список всех серверов. |
| `POST` | `/api/servers`| Сохранить изменения в `servers.json`. |
+74 -3
View File
@@ -126,16 +126,87 @@ app.post('/api/asns', async (req, res) => {
}
});
// --- Servers Routes (JSON format) ---
// Get servers from S3
app.get('/api/servers', async (req, res) => {
const params = {
Bucket: BUCKET_NAME,
Key: 'servers.json',
};
try {
const data = await s3.getObject(params).promise();
const fileContent = data.Body.toString('utf-8');
let servers = [];
try {
servers = JSON.parse(fileContent);
// Ensure it's an array
if (!Array.isArray(servers)) {
servers = [];
}
} catch (parseError) {
console.error('Error parsing servers.json:', parseError);
servers = [];
}
res.json(servers);
} catch (error) {
if (error.code === 'NoSuchKey') {
res.json([]); // Return empty array if file does not exist
} else {
console.error(error);
res.status(500).send('Error reading from S3');
}
}
});
// Update servers in S3
app.post('/api/servers', async (req, res) => {
const { domains: servers } = req.body; // Keep name 'domains' for consistency
// Validate servers structure
if (!Array.isArray(servers)) {
return res.status(400).send('Servers must be an array');
}
// Validate each server has required fields
for (let i = 0; i < servers.length; i++) {
const server = servers[i];
if (!server.ip || !server.dns || !server.country || !server.provider || !server.tunnel) {
return res.status(400).send(`Server at index ${i} is missing required fields`);
}
}
const params = {
Bucket: BUCKET_NAME,
Key: 'servers.json',
Body: JSON.stringify(servers, null, 2), // Pretty print JSON
ContentType: 'application/json',
};
try {
await s3.putObject(params).promise();
res.send('File updated successfully');
} catch (error) {
console.error(error);
res.status(500).send('Error writing to S3');
}
});
// Новый эндпоинт для получения дат последнего изменения файлов S3
app.get('/api/s3/last-modified', async (req, res) => {
try {
const [domainsHead, asnsHead] = await Promise.all([
const [domainsHead, asnsHead, serversHead] = await Promise.all([
s3.headObject({ Bucket: BUCKET_NAME, Key: 'domains.txt' }).promise(),
s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise()
s3.headObject({ Bucket: BUCKET_NAME, Key: 'asns.txt' }).promise(),
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise()
]);
res.json({
domainsLastModified: domainsHead.LastModified ? domainsHead.LastModified.toISOString() : null,
asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null
asnsLastModified: asnsHead.LastModified ? asnsHead.LastModified.toISOString() : null,
serversLastModified: serversHead.LastModified ? serversHead.LastModified.toISOString() : null
});
} catch (error) {
console.error(error);
+37
View File
@@ -0,0 +1,37 @@
[
{
"ip": "192.168.1.1",
"dns": "srv1.shx.su",
"country": "RU",
"provider": "Yandex",
"tunnel": "GRE"
},
{
"ip": "10.0.0.5",
"dns": "srv2.shx.su",
"country": "US",
"provider": "AWS",
"tunnel": "IPSec"
},
{
"ip": "172.16.0.10",
"dns": "srv3.shx.su",
"country": "DE",
"provider": "Hetzner",
"tunnel": "GRE"
},
{
"ip": "8.8.8.8",
"dns": "dns.google",
"country": "US",
"provider": "Google",
"tunnel": "IPSec"
},
{
"ip": "1.1.1.1",
"dns": "cloudflare-dns.com",
"country": "US",
"provider": "Cloudflare",
"tunnel": "GRE"
}
]
+14
View File
@@ -42,6 +42,20 @@
box-shadow: none !important;
}
/* Spinning animation for loading icons */
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Custom responsive adjustments */
@media (max-width: 992px) {
.page-wrapper {
+23 -7
View File
@@ -15,9 +15,11 @@ import {
IconUser,
IconDatabase,
IconAlertTriangle,
IconAlertCircle
IconAlertCircle,
IconServer
} from '@tabler/icons-react';
import DataManager from './DataManager';
import ServerManager from './ServerManager';
import './App.css';
import axios from 'axios';
@@ -32,6 +34,7 @@ function App() {
{ id: 'dashboard', title: 'Dashboard', icon: IconHome },
{ id: 'domains', title: 'Домены', icon: IconWorld },
{ id: 'asns', title: 'AS', icon: IconNetwork },
{ id: 'servers', title: 'Серверы', icon: IconServer },
{ id: 'files', title: 'Файлы', icon: IconFileText },
{ id: 'cloud', title: 'Облако', icon: IconCloud },
{ id: 'security', title: 'Безопасность', icon: IconShield },
@@ -59,6 +62,8 @@ function App() {
placeholder="AS12345"
/>
);
case 'servers':
return <ServerManager />;
default:
return <Dashboard />;
}
@@ -172,25 +177,27 @@ function StatCard({ icon: Icon, color, value, title, subtitle }) {
}
function Dashboard() {
const [stats, setStats] = useState({ domainsCount: null, asnsCount: null, lastModified: null });
const [stats, setStats] = useState({ domainsCount: null, asnsCount: null, serversCount: null, lastModified: null });
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchStats() {
setLoading(true);
try {
const [domainsRes, asnsRes, s3Res] = await Promise.all([
const [domainsRes, asnsRes, serversRes, s3Res] = await Promise.all([
axios.get('/api/domains'),
axios.get('/api/asns'),
axios.get('/api/servers'),
axios.get('/api/s3/last-modified')
]);
setStats({
domainsCount: domainsRes.data.length,
asnsCount: asnsRes.data.length,
serversCount: serversRes.data.length,
lastModified: s3Res.data.domainsLastModified
});
} catch (e) {
setStats({ domainsCount: null, asnsCount: null, lastModified: null });
setStats({ domainsCount: null, asnsCount: null, serversCount: null, lastModified: null });
} finally {
setLoading(false);
}
@@ -201,7 +208,7 @@ function Dashboard() {
return (
<div>
<div className="row g-3 mb-4">
<div className="col-md-4">
<div className="col-md-3">
<StatCard
icon={IconWorld}
color="blue"
@@ -210,7 +217,7 @@ function Dashboard() {
subtitle="Всего доменов"
/>
</div>
<div className="col-md-4">
<div className="col-md-3">
<StatCard
icon={IconNetwork}
color="green"
@@ -219,7 +226,16 @@ function Dashboard() {
subtitle="Всего AS"
/>
</div>
<div className="col-md-4">
<div className="col-md-3">
<StatCard
icon={IconServer}
color="purple"
value={loading ? '...' : stats.serversCount ?? '—'}
title="Серверов"
subtitle="Всего серверов"
/>
</div>
<div className="col-md-3">
<StatCard
icon={IconDatabase}
color="orange"
+547
View File
@@ -0,0 +1,547 @@
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconArrowsExchange,
IconDownload,
IconUpload,
IconAlertTriangle,
IconServer,
IconChevronDown
} from '@tabler/icons-react';
const API_URL = '/api';
function ServerManager() {
const [servers, setServers] = useState([]);
const [newServer, setNewServer] = useState({
ip: '',
dns: '',
country: '',
provider: '',
tunnel: 'GRE'
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [editingServer, setEditingServer] = useState(null);
const [editingValues, setEditingValues] = useState({});
const [bulkFrom, setBulkFrom] = useState('GRE');
const [bulkTo, setBulkTo] = useState('IPSec');
const [loading, setLoading] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [serverToDelete, setServerToDelete] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const [sortField, setSortField] = useState('ip');
const [sortOrder, setSortOrder] = useState('asc');
const [filterTunnel, setFilterTunnel] = useState('');
const [filterCountry, setFilterCountry] = useState('');
const [filterProvider, setFilterProvider] = useState('');
const editInputRef = useRef(null);
const pageSize = 10;
useEffect(() => {
fetchServers();
}, []);
const fetchServers = async () => {
setLoading(true);
try {
const response = await axios.get(`${API_URL}/servers`);
setServers(response.data);
setError('');
} catch (error) {
console.error('Error fetching servers:', error);
setError('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
};
const handleAddServer = () => {
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim()) {
setError('Все поля должны быть заполнены.');
return;
}
setError('');
setServers([...servers, { ...newServer }]);
setNewServer({ ip: '', dns: '', country: '', provider: '', tunnel: 'GRE' });
};
const handleEdit = (server) => {
setEditingServer(server.ip);
setEditingValues({ ...server });
};
const handleSaveEdit = (serverIp) => {
const updatedServers = servers.map(s =>
s.ip === serverIp ? { ...s, ...editingValues } : s
);
setServers(updatedServers);
setEditingServer(null);
};
const handleCancelEdit = () => {
setEditingServer(null);
};
const handleDeleteServer = (serverIp) => {
setServers(servers.filter(s => s.ip !== serverIp));
};
const confirmDelete = (server) => {
setServerToDelete(server);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (serverToDelete) {
handleDeleteServer(serverToDelete.ip);
setShowDeleteModal(false);
setServerToDelete(null);
}
};
const handleSaveChanges = async () => {
setLoading(true);
try {
await axios.post(`${API_URL}/servers`, { domains: servers });
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
const handleBulkUpdate = () => {
if (!bulkFrom || !bulkTo) {
setError('Оба поля для массового обновления должны быть заполнены.');
return;
}
const confirm = window.confirm(`Вы уверены, что хотите заменить все туннели "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`);
if (confirm) {
const updatedServers = servers.map(s =>
s.tunnel === bulkFrom ? { ...s, tunnel: bulkTo } : s
);
setServers(updatedServers);
setSuccess(`Туннели "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`);
}
};
// Сортировка
const sortedServers = [...servers].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;
});
// Фильтрация
let filteredServers = sortedServers;
if (filterTunnel) {
filteredServers = filteredServers.filter(s => s.tunnel === filterTunnel);
}
if (filterCountry) {
filteredServers = filteredServers.filter(s => s.country === filterCountry);
}
if (filterProvider) {
filteredServers = filteredServers.filter(s => s.provider === filterProvider);
}
// Поиск
filteredServers = filteredServers.filter(s =>
s.ip.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.dns.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.country.toLowerCase().includes(searchTerm.toLowerCase()) ||
s.provider.toLowerCase().includes(searchTerm.toLowerCase())
);
// Пагинация
const totalPages = Math.ceil(filteredServers.length / pageSize);
const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// Для фильтров - списки всех уникальных значений
const allTunnels = Array.from(new Set(servers.map(s => s.tunnel)));
const allCountries = Array.from(new Set(servers.map(s => s.country)));
const allProviders = Array.from(new Set(servers.map(s => s.provider)));
// Сортировка по клику
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Улучшенный инлайн-редакт
useEffect(() => {
if (editingServer && editInputRef.current) {
editInputRef.current.focus();
}
}, [editingServer]);
const handleEditKeyDown = (e, serverIp) => {
if (e.key === 'Enter') handleSaveEdit(serverIp);
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>
<a className="btn-close" onClick={() => setError('')}></a>
</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>
<a className="btn-close" onClick={() => setSuccess('')}></a>
</div>
)}
<div className="row">
<div className="col-lg-4">
{/* Add New Server Card */}
<div className="card card-md">
<div className="card-header">
<h3 className="card-title">
<IconPlus className="icon me-2" />
Добавить новый сервер
</h3>
</div>
<div className="card-body">
<form onSubmit={(e) => { e.preventDefault(); handleAddServer(); }}>
<div className="mb-3">
<label className="form-label">IP адрес</label>
<input
type="text"
className="form-control"
placeholder="192.168.1.1"
value={newServer.ip}
onChange={(e) => setNewServer({ ...newServer, ip: e.target.value })}
/>
</div>
<div className="mb-3">
<label className="form-label">DNS имя</label>
<input
type="text"
className="form-control"
placeholder="srv1.shx.su"
value={newServer.dns}
onChange={(e) => setNewServer({ ...newServer, dns: e.target.value })}
/>
</div>
<div className="mb-3">
<label className="form-label">Страна</label>
<input
type="text"
className="form-control"
placeholder="RU"
value={newServer.country}
onChange={(e) => setNewServer({ ...newServer, country: e.target.value })}
/>
</div>
<div className="mb-3">
<label className="form-label">Провайдер</label>
<input
type="text"
className="form-control"
placeholder="Yandex"
value={newServer.provider}
onChange={(e) => setNewServer({ ...newServer, provider: e.target.value })}
/>
</div>
<div className="mb-3">
<label className="form-label">Тип туннеля</label>
<input
type="text"
className="form-control"
placeholder="GRE"
value={newServer.tunnel}
onChange={(e) => setNewServer({ ...newServer, tunnel: 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>
{/* Bulk Update Card */}
<div className="card card-md mt-3">
<div className="card-header">
<h3 className="card-title">
<IconArrowsExchange className="icon me-2" />
Массовое обновление туннелей
</h3>
</div>
<div className="card-body">
<div className="mb-3">
<label className="form-label">Заменить с</label>
<input
type="text"
className="form-control"
value={bulkFrom}
onChange={(e) => setBulkFrom(e.target.value)}
/>
</div>
<div className="mb-3">
<label className="form-label">Заменить на</label>
<input
type="text"
className="form-control"
value={bulkTo}
onChange={(e) => setBulkTo(e.target.value)}
/>
</div>
<div className="form-footer">
<button
type="button"
className="btn btn-warning w-100"
onClick={handleBulkUpdate}
>
<IconArrowsExchange className="icon me-2" />
Выполнить замену
</button>
</div>
</div>
</div>
{/* Save Changes Card */}
<div className="card card-md mt-3">
<div className="card-header">
<h3 className="card-title">
<IconDatabase className="icon me-2" />
Сохранение изменений
</h3>
</div>
<div className="card-body">
<p className="text-muted mb-3">
Все изменения сохраняются в файл servers.json в S3 хранилище.
</p>
<div className="form-footer">
<button
type="button"
className="btn btn-success w-100"
onClick={handleSaveChanges}
disabled={loading}
>
{loading ? (
<>
<IconRefresh className="icon me-2 spin" />
Сохранение...
</>
) : (
<>
<IconDatabase className="icon me-2" />
Сохранить в S3
</>
)}
</button>
</div>
</div>
</div>
</div>
<div className="col-lg-8">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список серверов</h3>
<div className="d-flex gap-2 w-75">
{/* Фильтры */}
<select className="form-select w-auto" value={filterTunnel} onChange={e => { setFilterTunnel(e.target.value); setCurrentPage(1); }}>
<option value="">Все туннели</option>
{allTunnels.map(tunnel => (
<option key={tunnel} value={tunnel}>{tunnel}</option>
))}
</select>
<select className="form-select w-auto" value={filterCountry} onChange={e => { setFilterCountry(e.target.value); setCurrentPage(1); }}>
<option value="">Все страны</option>
{allCountries.map(country => (
<option key={country} value={country}>{country}</option>
))}
</select>
<select className="form-select w-auto" value={filterProvider} onChange={e => { setFilterProvider(e.target.value); setCurrentPage(1); }}>
<option value="">Все провайдеры</option>
{allProviders.map(provider => (
<option key={provider} value={provider}>{provider}</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="Поиск серверов..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
/>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={() => handleSort('ip')}>
IP адрес
{sortField === 'ip' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('dns')}>
DNS имя
{sortField === 'dns' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('country')}>
Страна
{sortField === 'country' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('provider')}>
Провайдер
{sortField === 'provider' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th style={{cursor:'pointer'}} onClick={() => handleSort('tunnel')}>
Туннель
{sortField === 'tunnel' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedServers.map((server) => (
<tr key={server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
<td>{server.ip}</td>
<td>{server.dns}</td>
<td><span className="badge bg-blue-lt text-blue">{server.country}</span></td>
<td>{server.provider}</td>
<td><span className="badge bg-green-lt text-green">{server.tunnel}</span></td>
<td className="text-end">
{editingServer === server.ip ? (
<>
<button className="btn btn-success btn-icon me-1" onClick={() => handleSaveEdit(server.ip)}><IconCheck size={18} /></button>
<button className="btn btn-secondary btn-icon" onClick={handleCancelEdit}><IconX size={18} /></button>
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon me-1" onClick={() => handleEdit(server)}><IconEdit size={18} /></button>
<button className="btn btn-outline-danger btn-icon" onClick={() => confirmDelete(server)}><IconTrash size={18} /></button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Пагинация */}
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center">
<p className="m-0 text-muted">
Показано <span>{(currentPage - 1) * pageSize + 1}</span> до <span>{Math.min(currentPage * pageSize, filteredServers.length)}</span> из <span>{filteredServers.length}</span> серверов
</p>
<ul className="pagination m-0 ms-auto">
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>
<IconChevronDown className="icon icon-sm rotate-180" />
Назад
</button>
</li>
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>
Вперед
<IconChevronDown className="icon icon-sm" />
</button>
</li>
</ul>
</div>
)}
</div>
</div>
</div>
{/* Delete Modal */}
{showDeleteModal && (
<div className="modal fade show" style={{display: 'block'}} tabIndex="-1">
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Подтверждение удаления</h5>
<button type="button" className="btn-close" onClick={() => setShowDeleteModal(false)}></button>
</div>
<div className="modal-body">
<p>Вы уверены, что хотите удалить сервер <strong>{serverToDelete?.ip}</strong>?</p>
<p className="text-muted">Это действие нельзя отменить.</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteModal(false)}>Отмена</button>
<button type="button" className="btn btn-danger" onClick={executeDelete}>Удалить</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
export default ServerManager;