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
+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;