Files
router-lists-ui/frontend/src/ServerManager.jsx
T
2025-07-15 19:24:45 +07:00

1184 lines
46 KiB
React

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,
IconLink
} from '@tabler/icons-react';
import GraphView from './GraphView';
const API_URL = '/api';
function ServerManager() {
const [servers, setServers] = useState([]);
const [newServer, setNewServer] = useState({
ip: '',
dns: '',
country: '',
provider: '',
tunnel: 'GRE',
gateway: ''
});
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;
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalServer, setEditModalServer] = useState(null);
// Состояние для генератора ссылок
const [linkGeneratorOpen, setLinkGeneratorOpen] = useState(false);
const [linkGeneratorServer, setLinkGeneratorServer] = useState(null);
const [urlSettings, setUrlSettings] = useState({
baseUrl: 'https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo',
cloudflare_gateway: 'SWE-IHOR',
bunny_gateway: 'SWE-IHOR',
fastly_gateway: 'SWE-IHOR',
telegram_gateway: '94.142.140.1',
hetzner_gateway: '94.142.140.1',
type: 'routes',
version: 'v4.rsc'
});
// Состояние для мониторинга сети
const [networkStatus, setNetworkStatus] = useState({});
const [monitoringEnabled, setMonitoringEnabled] = useState(false);
// Состояние для модального окна добавления сервера
const [addServerModalOpen, setAddServerModalOpen] = useState(false);
const [customProvider, setCustomProvider] = useState('');
const [connections, setConnections] = useState([
// Пример связи для теста
// { from: '192.168.1.1', to: '192.168.1.2', tunnelType: 'GRE', ipA: '10.10.100.1', ipB: '10.10.100.2' }
]);
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
useEffect(() => {
fetchServers();
}, []);
// Загружаем настройки URL из localStorage
useEffect(() => {
const savedSettings = localStorage.getItem('urlSettings');
if (savedSettings) {
try {
const parsed = JSON.parse(savedSettings);
setUrlSettings(prev => ({ ...prev, ...parsed }));
} catch (e) {
console.error('Ошибка при загрузке настроек URL:', e);
}
}
}, []);
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 handleOpenAddServerModal = () => {
setAddServerModalOpen(true);
};
const handleCloseAddServerModal = () => {
setAddServerModalOpen(false);
setNewServer({ ip: '', dns: '', country: '', provider: '', tunnel: 'GRE' });
setCustomProvider('');
setError('');
};
const handleAddServerFromModal = () => {
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.gateway.trim()) {
setError('Все поля должны быть заполнены.');
return;
}
// Проверяем, если выбран "Другой" провайдер, то customProvider должен быть заполнен
if (newServer.provider === 'Другой' && !customProvider.trim()) {
setError('Пожалуйста, введите название провайдера.');
return;
}
setError('');
// Используем customProvider если выбран "Другой"
const finalProvider = newServer.provider === 'Другой' ? customProvider : newServer.provider;
const serverToAdd = { ...newServer, provider: finalProvider };
setServers([...servers, serverToAdd]);
setNewServer({ ip: '', dns: '', country: '', provider: '', tunnel: 'GRE' });
setCustomProvider('');
setAddServerModalOpen(false);
setSuccess('Сервер успешно добавлен!');
setTimeout(() => setSuccess(''), 3000);
};
const handleEdit = (server) => {
setEditModalServer({ ...server });
setEditModalOpen(true);
};
const handleEditModalChange = (updatedServer) => {
setEditModalServer(updatedServer);
};
const handleEditModalSave = (updatedServer) => {
setServers(servers.map(s => s.ip === updatedServer.ip ? { ...updatedServer } : s));
setEditModalOpen(false);
setEditModalServer(null);
};
const handleEditModalClose = () => {
setEditModalOpen(false);
setEditModalServer(null);
};
// Функции для генератора ссылок
const handleGenerateLink = (server) => {
setLinkGeneratorServer(server);
setLinkGeneratorOpen(true);
};
const handleLinkGeneratorClose = () => {
setLinkGeneratorOpen(false);
setLinkGeneratorServer(null);
};
const generateServerUrl = (server) => {
const params = new URLSearchParams();
// Добавляем все параметры из настроек
Object.entries(urlSettings).forEach(([key, value]) => {
if (key !== 'baseUrl' && value) {
params.append(key, value);
}
});
// Добавляем IP сервера как параметр server
params.append('server', server.ip);
return `${urlSettings.baseUrl}?${params.toString()}`;
};
// Функция резервного копирования для http
const fallbackCopyToClipboard = (text) => {
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
setSuccess('Ссылка скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
} catch (err) {
setError('Не удалось скопировать ссылку');
}
document.body.removeChild(textArea);
};
const copyToClipboard = async (text) => {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
setSuccess('Ссылка скопирована в буфер обмена!');
setTimeout(() => setSuccess(''), 3000);
return;
} catch (err) {
// fallback ниже
}
}
fallbackCopyToClipboard(text);
};
const quickCopyLink = async (server) => {
const url = generateServerUrl(server);
await copyToClipboard(url);
};
const exportAllLinks = () => {
const csvContent = [
['IP', 'DNS', 'Country', 'Provider', 'Tunnel', 'Generated URL'],
...servers.map(server => [
server.ip,
server.dns,
server.country,
server.provider,
server.tunnel,
generateServerUrl(server)
])
].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `server_links_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
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();
};
// Функция для преобразования кода страны в emoji-флаг
function countryToFlag(isoCode) {
if (!isoCode) return '';
return isoCode
.toUpperCase()
.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt())
);
}
// Добавление новой связи
const handleAddConnection = () => {
if (!newConnection.from || !newConnection.to || !newConnection.tunnelType || !newConnection.ipA || !newConnection.ipB) {
setError('Все поля для связи должны быть заполнены.');
return;
}
setConnections([...connections, newConnection]);
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
setError('');
};
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="d-flex flex-wrap gap-2 mb-3 align-items-center">
<button
type="button"
className="btn btn-primary d-flex align-items-center"
onClick={handleOpenAddServerModal}
>
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
<span className="fw-bold">Добавить</span>
</button>
<button
type="button"
className="btn btn-outline-primary d-flex align-items-center"
onClick={handleSaveChanges}
disabled={loading}
>
<span className="me-2 d-flex align-items-center"><IconDatabase className={loading ? 'spin' : ''} /></span>
<span className="fw-bold">Сохранить</span>
</button>
<button
type="button"
className="btn btn-outline-primary d-flex align-items-center"
onClick={exportAllLinks}
disabled={servers.length === 0}
>
<span className="me-2 d-flex align-items-center"><IconDownload className="icon" /></span>
<span className="fw-bold">Экспорт</span>
</button>
</div>
{/* Таблица серверов на всю ширину */}
<div className="card w-100">
<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 style={{cursor:'pointer'}} onClick={() => handleSort('gateway')}>
Основной шлюз
{sortField === 'gateway' && (
<span className="ms-1">
{sortOrder === 'asc' ? '▲' : '▼'}
</span>
)}
</th>
<th className="text-center">Статус</th>
<th className="text-end w-1" style={{ minWidth: 140 }}></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">{countryToFlag(server.country)} {server.country}</span></td>
<td>{server.provider}</td>
<td><span className="badge bg-green-lt text-green">{server.tunnel}</span></td>
<td className="text-center">
<span className="badge bg-blue-lt text-blue">
{server.gateway}
</span>
</td>
<td className="text-center">
<span className="badge bg-success-lt text-success">Онлайн</span>
</td>
<td className="text-end w-1" style={{ minWidth: 140 }}>
<div className="btn-list gap-1 mb-0">
<button className="btn btn-icon btn-sm btn-outline-primary rounded" title="Ссылка" onClick={() => handleGenerateLink(server)} onContextMenu={(e) => { e.preventDefault(); quickCopyLink(server); }}>
<IconLink size={18} />
</button>
<button className="btn btn-icon btn-sm btn-outline-secondary rounded" title="Редактировать" onClick={() => handleEdit(server)}>
<IconEdit size={18} />
</button>
<button className="btn btn-icon btn-sm btn-outline-danger rounded" title="Удалить" onClick={() => confirmDelete(server)}>
<IconTrash size={18} />
</button>
</div>
</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>
{/* Delete Modal */}
<DeleteServerModal
show={showDeleteModal}
server={serverToDelete}
onDelete={executeDelete}
onClose={() => setShowDeleteModal(false)}
/>
{/* Edit Server Modal */}
<EditServerModal
show={editModalOpen}
server={editModalServer}
onChange={handleEditModalChange}
onSave={handleEditModalSave}
onClose={handleEditModalClose}
/>
{/* Link Generator Modal */}
<LinkGeneratorModal
show={linkGeneratorOpen}
server={linkGeneratorServer}
urlSettings={urlSettings}
onUrlSettingsChange={setUrlSettings}
onGenerateUrl={generateServerUrl}
onCopyToClipboard={copyToClipboard}
onClose={handleLinkGeneratorClose}
/>
{/* Add Server Modal */}
<AddServerModal
show={addServerModalOpen}
newServer={newServer}
customProvider={customProvider}
onNewServerChange={setNewServer}
onCustomProviderChange={setCustomProvider}
onAddServer={handleAddServerFromModal}
onClose={handleCloseAddServerModal}
error={error}
/>
<div className="card w-100 mb-4">
<div className="card-header">
<h3 className="card-title mb-0">Граф связей между серверами</h3>
</div>
<div className="card-body">
<GraphView servers={servers} connections={connections} />
</div>
</div>
<div className="card w-100 mb-4">
<div className="card-header">
<h3 className="card-title mb-0">Добавить связь между серверами</h3>
</div>
<div className="card-body">
<div className="row g-2 align-items-end">
<div className="col-md-3">
<label className="form-label">Сервер A (IP)</label>
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
<option value="">Выберите</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select>
</div>
<div className="col-md-3">
<label className="form-label">Сервер B (IP)</label>
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
<option value="">Выберите</option>
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
</select>
</div>
<div className="col-md-2">
<label className="form-label">Тип туннеля</label>
<select className="form-select" value={newConnection.tunnelType} onChange={e => setNewConnection({ ...newConnection, tunnelType: e.target.value })}>
<option value="GRE">GRE</option>
<option value="IPSec">IPSec</option>
<option value="WireGuard">WireGuard</option>
<option value="OpenVPN">OpenVPN</option>
</select>
</div>
<div className="col-md-2">
<label className="form-label">IP сервера A</label>
<input className="form-control" value={newConnection.ipA} onChange={e => setNewConnection({ ...newConnection, ipA: e.target.value })} placeholder="10.10.100.1" />
</div>
<div className="col-md-2">
<label className="form-label">IP сервера B</label>
<input className="form-control" value={newConnection.ipB} onChange={e => setNewConnection({ ...newConnection, ipB: e.target.value })} placeholder="10.10.100.2" />
</div>
<div className="col-md-12 mt-2">
<button className="btn btn-primary" type="button" onClick={handleAddConnection}>Добавить связь</button>
</div>
</div>
</div>
</div>
</>
);
}
// Модальное окно для редактирования сервера с backdrop и анимацией
function EditServerModal({ show, server, onChange, onSave, onClose }) {
const modalRef = useRef(null);
// Локальное состояние для формы
const [localServer, setLocalServer] = useState(server || {});
// Синхронизируем локальное состояние с пропсами
useEffect(() => {
setLocalServer(server || {});
}, [server]);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
// Закрытие по событию Tabler
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
};
}
}, [show, onClose]);
const handleChange = (field, value) => {
const updated = { ...localServer, [field]: value };
setLocalServer(updated);
onChange && onChange(updated);
};
return (
<div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Редактировать сервер</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
<form onSubmit={e => { e.preventDefault(); onSave && onSave(localServer); }}>
<div className="mb-3">
<label className="form-label">IP адрес</label>
<input type="text" className="form-control" value={localServer.ip || ''} onChange={e => handleChange('ip', e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">DNS имя</label>
<input type="text" className="form-control" value={localServer.dns || ''} onChange={e => handleChange('dns', e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Страна</label>
<input type="text" className="form-control" value={localServer.country || ''} onChange={e => handleChange('country', e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Провайдер</label>
<input type="text" className="form-control" value={localServer.provider || ''} onChange={e => handleChange('provider', e.target.value)} />
</div>
<div className="mb-3">
<label className="form-label">Тип туннеля</label>
<input type="text" className="form-control" value={localServer.tunnel || ''} onChange={e => handleChange('tunnel', e.target.value)} />
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Отмена</span>
</button>
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={() => onSave && onSave(localServer)}>
<span className="fw-bold">Сохранить</span>
</button>
</div>
</div>
</div>
</div>
);
}
function DeleteServerModal({ show, server, onDelete, onClose }) {
const modalRef = useRef(null);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
};
}
}, [show, onClose]);
return (
<div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-status bg-danger"></div>
<div className="modal-header">
<IconAlertTriangle className="icon text-danger me-2" size={28} />
<h5 className="modal-title">Подтверждение удаления</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
<p>Вы уверены, что хотите удалить сервер <strong>{server?.ip}</strong>?</p>
<p className="text-muted">Это действие нельзя отменить.</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Отмена</span>
</button>
<button type="button" className="btn btn-danger d-flex align-items-center" onClick={onDelete}>
<span className="fw-bold">Удалить</span>
</button>
</div>
</div>
</div>
</div>
);
}
// Модальное окно для генератора ссылок
function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, onGenerateUrl, onCopyToClipboard, onClose }) {
const modalRef = useRef(null);
const [localUrlSettings, setLocalUrlSettings] = useState(urlSettings);
// Синхронизируем локальное состояние с пропсами
useEffect(() => {
setLocalUrlSettings(urlSettings);
}, [urlSettings]);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
};
}
}, [show, onClose]);
const handleSettingChange = (key, value) => {
const updated = { ...localUrlSettings, [key]: value };
setLocalUrlSettings(updated);
onUrlSettingsChange(updated);
};
const handleSaveSettings = () => {
onUrlSettingsChange(localUrlSettings);
// Сохраняем в localStorage
localStorage.setItem('urlSettings', JSON.stringify(localUrlSettings));
};
const generatedUrl = server ? onGenerateUrl(server) : '';
return (
<div className="modal modal-lg" tabIndex="-1" ref={modalRef}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<IconLink className="icon me-2" />
Генератор ссылок для {server?.ip}
</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
<div className="row">
<div className="col-md-6">
<h6 className="mb-3">Настройки URL</h6>
<div className="mb-3">
<label className="form-label">Базовый URL</label>
<input
type="text"
className="form-control"
value={localUrlSettings.baseUrl}
onChange={e => handleSettingChange('baseUrl', e.target.value)}
placeholder="https://functions.yandexcloud.net/d4eno3im0qgsr4tj5hdo"
/>
</div>
<div className="mb-3">
<label className="form-label">Cloudflare Gateway</label>
<input
type="text"
className="form-control"
value={localUrlSettings.cloudflare_gateway}
onChange={e => handleSettingChange('cloudflare_gateway', e.target.value)}
placeholder="SWE-IHOR или IP"
/>
</div>
<div className="mb-3">
<label className="form-label">Bunny Gateway</label>
<input
type="text"
className="form-control"
value={localUrlSettings.bunny_gateway}
onChange={e => handleSettingChange('bunny_gateway', e.target.value)}
placeholder="SWE-IHOR или IP"
/>
</div>
<div className="mb-3">
<label className="form-label">Fastly Gateway</label>
<input
type="text"
className="form-control"
value={localUrlSettings.fastly_gateway}
onChange={e => handleSettingChange('fastly_gateway', e.target.value)}
placeholder="SWE-IHOR или IP"
/>
</div>
</div>
<div className="col-md-6">
<div className="mb-3">
<label className="form-label">Telegram Gateway</label>
<input
type="text"
className="form-control"
value={localUrlSettings.telegram_gateway}
onChange={e => handleSettingChange('telegram_gateway', e.target.value)}
placeholder="IP адрес"
/>
</div>
<div className="mb-3">
<label className="form-label">Hetzner Gateway</label>
<input
type="text"
className="form-control"
value={localUrlSettings.hetzner_gateway}
onChange={e => handleSettingChange('hetzner_gateway', e.target.value)}
placeholder="IP адрес"
/>
</div>
<div className="mb-3">
<label className="form-label">Тип</label>
<input
type="text"
className="form-control"
value={localUrlSettings.type}
onChange={e => handleSettingChange('type', e.target.value)}
placeholder="routes"
/>
</div>
<div className="mb-3">
<label className="form-label">Версия</label>
<input
type="text"
className="form-control"
value={localUrlSettings.version}
onChange={e => handleSettingChange('version', e.target.value)}
placeholder="v4.rsc"
/>
</div>
</div>
</div>
<div className="mt-4">
<h6 className="mb-3">Сгенерированная ссылка</h6>
<div className="input-group">
<input
type="text"
className="form-control"
value={generatedUrl}
readOnly
style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}
/>
<button
className="btn btn-outline-secondary d-flex align-items-center"
type="button"
onClick={() => onCopyToClipboard(generatedUrl)}
title="Копировать в буфер обмена"
>
<span className="me-2 d-flex align-items-center"><IconDownload size={18} /></span>
<span className="fw-bold">Копировать</span>
</button>
</div>
</div>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Закрыть</span>
</button>
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={handleSaveSettings}>
<span className="fw-bold">Сохранить настройки</span>
</button>
</div>
</div>
</div>
</div>
);
}
// Модальное окно для добавления нового сервера
function AddServerModal({ show, newServer, customProvider, onNewServerChange, onCustomProviderChange, onAddServer, onClose, error }) {
const modalRef = useRef(null);
useEffect(() => {
if (window.Tabler && window.Tabler.Modal && modalRef.current) {
const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current);
if (show) {
modalInstance.show();
} else {
modalInstance.hide();
}
const handler = () => onClose && onClose();
modalRef.current.addEventListener('hide.bs.modal', handler);
return () => {
if (modalRef.current) {
modalRef.current.removeEventListener('hide.bs.modal', handler);
}
};
}
}, [show, onClose]);
const handleChange = (field, value) => {
onNewServerChange({ ...newServer, [field]: value });
};
const handleSubmit = (e) => {
e.preventDefault();
onAddServer();
};
return (
<div className="modal" tabIndex="-1" ref={modalRef}>
<div className="modal-dialog">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">
<IconPlus className="icon me-2" />
Добавить новый сервер
</h5>
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div className="modal-body">
{error && (
<div className="alert alert-danger" role="alert">
{error}
</div>
)}
<form onSubmit={handleSubmit}>
<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) => handleChange('ip', e.target.value)}
required
/>
</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) => handleChange('dns', e.target.value)}
required
/>
</div>
<div className="mb-3">
<label className="form-label">Страна *</label>
<select
className="form-select"
value={newServer.country}
onChange={(e) => handleChange('country', e.target.value)}
required
>
<option value="">Выберите страну</option>
<option value="RU">Россия (RU)</option>
<option value="SWE">Швеция (SWE)</option>
<option value="US">США (US)</option>
<option value="DE">Германия (DE)</option>
<option value="NL">Нидерланды (NL)</option>
<option value="SG">Сингапур (SG)</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Провайдер *</label>
<select
className="form-select"
value={newServer.provider}
onChange={(e) => handleChange('provider', e.target.value)}
required
>
<option value="">Выберите провайдера</option>
<option value="Yandex">Yandex Cloud</option>
<option value="AWS">Amazon Web Services</option>
<option value="Google">Google Cloud</option>
<option value="Azure">Microsoft Azure</option>
<option value="Hetzner">Hetzner</option>
<option value="OVH">OVH</option>
<option value="DigitalOcean">DigitalOcean</option>
<option value="Vultr">Vultr</option>
<option value="Linode">Linode</option>
<option value="VPSVILLE">VPSVILLE</option>
<option value="HIPHOST">HIPHOST</option>
<option value="Другой">Другой</option>
</select>
{newServer.provider === 'Другой' && (
<input
type="text"
className="form-control mt-2"
placeholder="Введите название провайдера"
value={customProvider}
onChange={(e) => onCustomProviderChange(e.target.value)}
/>
)}
</div>
<div className="mb-3">
<label className="form-label">Тип туннеля *</label>
<select
className="form-select"
value={newServer.tunnel}
onChange={(e) => handleChange('tunnel', e.target.value)}
required
>
<option value="GRE">GRE (Generic Routing Encapsulation)</option>
<option value="IPSec">IPSec</option>
<option value="WireGuard">WireGuard</option>
<option value="OpenVPN">OpenVPN</option>
</select>
</div>
<div className="mb-3">
<label className="form-label">Основной шлюз *</label>
<input
type="text"
className="form-control"
placeholder="Cloudflare, Hetzner, Мой шлюз и т.д."
value={newServer.gateway}
onChange={(e) => handleChange('gateway', e.target.value)}
required
/>
</div>
</form>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-secondary d-flex align-items-center" data-bs-dismiss="modal">
<span className="fw-bold">Отмена</span>
</button>
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={onAddServer}>
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
<span className="fw-bold">Добавить сервер</span>
</button>
</div>
</div>
</div>
</div>
);
}
export default ServerManager;