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