Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m11s
2118 lines
89 KiB
React
2118 lines
89 KiB
React
import { useState, useEffect, useRef } from 'react';
|
|
import api from './lib/api.js';
|
|
import ConfirmDialog from './components/ConfirmDialog.jsx';
|
|
import FormModal from './components/FormModal.jsx';
|
|
import ConfirmModal from './components/ConfirmModal.jsx';
|
|
import FormField from './components/FormField.jsx';
|
|
import {
|
|
IconPlus,
|
|
IconSearch,
|
|
IconEdit,
|
|
IconTrash,
|
|
IconCheck,
|
|
IconX,
|
|
IconDatabase,
|
|
IconRefresh,
|
|
IconArrowsExchange,
|
|
IconDownload,
|
|
IconUpload,
|
|
IconAlertTriangle,
|
|
IconServer,
|
|
IconChevronDown,
|
|
IconLink,
|
|
IconFilter,
|
|
IconMapPin,
|
|
IconWorld
|
|
} from '@tabler/icons-react';
|
|
import GraphView from './GraphView';
|
|
|
|
const API_URL = '/api';
|
|
const SERVER_TYPE_OPTIONS = [
|
|
{ value: 'jumphost', label: 'Jumphost' },
|
|
{ value: 'exit', label: 'Выходная нода' },
|
|
];
|
|
const NEEDS_GATEWAYS = (type) => ['jumphost', 'exit'].includes(String(type || '').toLowerCase());
|
|
|
|
const makeGateway = (over = {}) => ({
|
|
id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
name: '',
|
|
ip: '',
|
|
comment: '',
|
|
primary: false,
|
|
...over,
|
|
});
|
|
|
|
const normalizeGateways = (gateways = [], fallbackName = '') => {
|
|
if (!Array.isArray(gateways)) return [];
|
|
const cleaned = gateways
|
|
.map((g, idx) => ({
|
|
id: g?.id || `${Date.now()}-${idx}`,
|
|
name: String(g?.name || '').trim(),
|
|
ip: String(g?.ip || '').trim(),
|
|
comment: String(g?.comment || '').trim(),
|
|
primary: Boolean(g?.primary),
|
|
}))
|
|
.filter(g => g.name || g.ip || g.comment);
|
|
if (cleaned.length === 0) {
|
|
return [{ id: `${Date.now()}-primary`, name: fallbackName || 'gateway', ip: '', comment: '', primary: true }];
|
|
}
|
|
// ensure single primary
|
|
let hasPrimary = false;
|
|
cleaned.forEach((g, i) => {
|
|
if (g.primary && !hasPrimary) {
|
|
hasPrimary = true;
|
|
} else {
|
|
g.primary = false;
|
|
}
|
|
});
|
|
if (!hasPrimary) cleaned[0].primary = true;
|
|
return cleaned;
|
|
};
|
|
|
|
function ServerManager() {
|
|
const [servers, setServers] = useState([]);
|
|
const [newServer, setNewServer] = useState({
|
|
id: '',
|
|
ip: '',
|
|
extIp: '',
|
|
internalIp: '',
|
|
dns: '',
|
|
country: '',
|
|
provider: '',
|
|
tunnel: 'GRE',
|
|
type: 'jumphost',
|
|
gateway: '',
|
|
gateways: [makeGateway({ primary: true })],
|
|
});
|
|
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 [filterType, setFilterType] = 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 presets = {
|
|
default: { 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' },
|
|
cloudflareSWE: { baseUrl: 'https://cf.example/api', cloudflare_gateway: 'SWE-IHOR', bunny_gateway: '', fastly_gateway: '', telegram_gateway: '', hetzner_gateway: '', type: 'routes', version: 'v4.rsc' },
|
|
hetznerDE: { baseUrl: 'https://hetzner.example/api', cloudflare_gateway: '', bunny_gateway: '', fastly_gateway: '', telegram_gateway: '', 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([]);
|
|
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
|
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
|
|
|
|
// Фильтры графа
|
|
const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true });
|
|
const [graphCountryFilter, setGraphCountryFilter] = useState('');
|
|
const [graphProviderFilter, setGraphProviderFilter] = useState('');
|
|
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
|
|
|
|
const normalizeServerType = (type) => {
|
|
const value = String(type || '').toLowerCase();
|
|
return SERVER_TYPE_OPTIONS.some((opt) => opt.value === value) ? value : 'jumphost';
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchServers();
|
|
fetchConnections();
|
|
}, []);
|
|
|
|
// Загружаем настройки 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 api.get(`/servers`);
|
|
const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({
|
|
...srv,
|
|
type: normalizeServerType(srv.type),
|
|
gateways: NEEDS_GATEWAYS(srv.type) ? normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip) : (Array.isArray(srv.gateways) ? srv.gateways : []),
|
|
}));
|
|
setServers(normalized);
|
|
setError('');
|
|
} catch (error) {
|
|
console.error('Error fetching servers:', error);
|
|
setError('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchConnections = async () => {
|
|
try {
|
|
const response = await api.get(`/server-connections`);
|
|
if (Array.isArray(response.data)) {
|
|
setConnections(response.data);
|
|
} else {
|
|
setConnections([]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching server connections:', error);
|
|
// Не перекрываем возможную ошибку по серверам, просто логируем
|
|
}
|
|
};
|
|
|
|
const handleAddServer = () => {
|
|
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.type) {
|
|
setError('Все обязательные поля должны быть заполнены.');
|
|
return;
|
|
}
|
|
setError('');
|
|
const nextId = `srv-${servers.length + 1}`;
|
|
const serverToAdd = {
|
|
...newServer,
|
|
id: newServer.id || nextId,
|
|
type: normalizeServerType(newServer.type),
|
|
};
|
|
setServers([...servers, serverToAdd]);
|
|
setNewServer({
|
|
id: '',
|
|
ip: '',
|
|
extIp: '',
|
|
internalIp: '',
|
|
dns: '',
|
|
country: '',
|
|
provider: '',
|
|
tunnel: 'GRE',
|
|
type: 'jumphost',
|
|
gateway: '',
|
|
gateways: [makeGateway({ primary: true })],
|
|
});
|
|
};
|
|
|
|
// Функции для модального окна добавления сервера
|
|
const handleOpenAddServerModal = () => {
|
|
setAddServerModalOpen(true);
|
|
};
|
|
|
|
const handleCloseAddServerModal = () => {
|
|
setAddServerModalOpen(false);
|
|
setNewServer({
|
|
id: '',
|
|
ip: '',
|
|
extIp: '',
|
|
internalIp: '',
|
|
dns: '',
|
|
country: '',
|
|
provider: '',
|
|
tunnel: 'GRE',
|
|
type: 'jumphost',
|
|
gateway: '',
|
|
gateways: [makeGateway({ primary: true })],
|
|
});
|
|
setCustomProvider('');
|
|
setError('');
|
|
};
|
|
|
|
const handleAddServerFromModal = () => {
|
|
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim() || !newServer.type) {
|
|
setError('Все обязательные поля должны быть заполнены.');
|
|
return;
|
|
}
|
|
if (NEEDS_GATEWAYS(newServer.type)) {
|
|
const normalizedGws = normalizeGateways(newServer.gateways, newServer.gateway || newServer.dns || newServer.ip);
|
|
if (!normalizedGws.length) {
|
|
setError('Добавьте хотя бы один gateway и отметьте основной.');
|
|
return;
|
|
}
|
|
newServer.gateways = normalizedGws;
|
|
} else {
|
|
newServer.gateways = [];
|
|
}
|
|
|
|
// Проверяем, если выбран "Другой" провайдер, то customProvider должен быть заполнен
|
|
if (newServer.provider === 'Другой' && !customProvider.trim()) {
|
|
setError('Пожалуйста, введите название провайдера.');
|
|
return;
|
|
}
|
|
|
|
setError('');
|
|
|
|
// Используем customProvider если выбран "Другой"
|
|
const finalProvider = newServer.provider === 'Другой' ? customProvider : newServer.provider;
|
|
const nextId = `srv-${servers.length + 1}`;
|
|
const normalizedGws = NEEDS_GATEWAYS(newServer.type)
|
|
? normalizeGateways(newServer.gateways, newServer.gateway || newServer.dns || newServer.ip)
|
|
: [];
|
|
if (NEEDS_GATEWAYS(newServer.type) && (!normalizedGws || normalizedGws.length === 0)) {
|
|
setError('Для jumphost/exit добавьте хотя бы один gateway и отметьте основной.');
|
|
return;
|
|
}
|
|
|
|
const serverToAdd = {
|
|
...newServer,
|
|
provider: finalProvider,
|
|
id: newServer.id || nextId,
|
|
type: normalizeServerType(newServer.type),
|
|
gateways: normalizedGws,
|
|
};
|
|
|
|
setServers([...servers, serverToAdd]);
|
|
setNewServer({
|
|
id: '',
|
|
ip: '',
|
|
extIp: '',
|
|
internalIp: '',
|
|
dns: '',
|
|
country: '',
|
|
provider: '',
|
|
tunnel: 'GRE',
|
|
type: 'jumphost',
|
|
gateway: '',
|
|
gateways: [makeGateway({ primary: true })],
|
|
});
|
|
setCustomProvider('');
|
|
setAddServerModalOpen(false);
|
|
setSuccess('Сервер успешно добавлен!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
const handleEdit = (server) => {
|
|
setEditModalServer({ ...server });
|
|
setEditModalOpen(true);
|
|
};
|
|
|
|
const handleEditModalChange = (updatedServer) => {
|
|
setEditModalServer(updatedServer);
|
|
};
|
|
|
|
const handleEditModalSave = (updatedServer) => {
|
|
const normalizedServer = {
|
|
...updatedServer,
|
|
type: normalizeServerType(updatedServer.type),
|
|
gateways: NEEDS_GATEWAYS(updatedServer.type) ? normalizeGateways(updatedServer.gateways, updatedServer.gateway || updatedServer.dns || updatedServer.ip) : [],
|
|
};
|
|
if (NEEDS_GATEWAYS(normalizedServer.type) && normalizedServer.gateways.length === 0) {
|
|
setError('Для jumphost/exit необходим хотя бы один gateway с признаком основного.');
|
|
return;
|
|
}
|
|
setServers(servers.map(s => {
|
|
const sameId = s.id && updatedServer.id ? s.id === updatedServer.id : false;
|
|
const sameIp = s.ip === updatedServer.ip;
|
|
return (sameId || sameIp) ? { ...s, ...normalizedServer } : 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()}`;
|
|
};
|
|
|
|
const applyPreset = (name) => {
|
|
const p = presets[name];
|
|
if (!p) return;
|
|
setUrlSettings(prev => ({ ...prev, ...p }));
|
|
localStorage.setItem('urlSettings', JSON.stringify({ ...urlSettings, ...p }));
|
|
setSuccess('Профиль применён');
|
|
setTimeout(() => setSuccess(''), 2000);
|
|
};
|
|
|
|
// Функция резервного копирования для 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 filtered = servers
|
|
.filter(s => (!filterTunnel || s.tunnel === filterTunnel))
|
|
.filter(s => (!filterCountry || s.country === filterCountry))
|
|
.filter(s => (!filterProvider || s.provider === filterProvider))
|
|
.filter(s => (!filterType || s.type === filterType));
|
|
const csvContent = [
|
|
['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Type', 'Tunnel', 'Generated URL'],
|
|
...filtered.map(server => [
|
|
server.id || '',
|
|
server.ip,
|
|
server.extIp || '',
|
|
server.internalIp || '',
|
|
server.dns,
|
|
server.country,
|
|
server.provider,
|
|
server.type,
|
|
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 {
|
|
const normalizedServers = servers.map((s) => ({ ...s, type: normalizeServerType(s.type) }));
|
|
setServers(normalizedServers);
|
|
await api.post(`/servers`, { domains: normalizedServers });
|
|
await api.post(`/server-connections`, { domains: connections });
|
|
setSuccess('Изменения по серверам и связям успешно сохранены!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
} catch (error) {
|
|
console.error('Error saving changes:', error);
|
|
setError('Не удалось сохранить изменения по серверам или связям.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleBulkUpdate = () => {
|
|
if (!bulkFrom || !bulkTo) {
|
|
setError('Оба поля для массового обновления должны быть заполнены.');
|
|
return;
|
|
}
|
|
setConfirmState({
|
|
open: true,
|
|
text: `Вы уверены, что хотите заменить все туннели "${bulkFrom}" на "${bulkTo}"? Это действие необратимо.`,
|
|
onConfirm: () => {
|
|
const updatedServers = servers.map(s => s.tunnel === bulkFrom ? { ...s, tunnel: bulkTo } : s);
|
|
setServers(updatedServers);
|
|
setSuccess(`Туннели "${bulkFrom}" были успешно заменены на "${bulkTo}". Не забудьте сохранить изменения.`);
|
|
setConfirmState({ open: false, text: '', onConfirm: null });
|
|
},
|
|
});
|
|
};
|
|
|
|
// Сортировка
|
|
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);
|
|
}
|
|
|
|
if (filterType) {
|
|
filteredServers = filteredServers.filter(s => s.type === filterType);
|
|
}
|
|
|
|
// Поиск
|
|
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()) ||
|
|
(s.type || '').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 allTypes = Array.from(new Set(servers.map(s => s.type).filter(Boolean)));
|
|
|
|
// Сортировка по клику
|
|
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 addConnectionInternal = (draft) => {
|
|
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
|
|
setError('Все поля для связи должны быть заполнены.');
|
|
return false;
|
|
}
|
|
if (draft.from === draft.to) {
|
|
setError('Сервер A и сервер B не могут быть одинаковыми.');
|
|
return false;
|
|
}
|
|
const next = { ...draft };
|
|
setConnections((prev) => [...prev, next]);
|
|
setSuccess('Связь успешно добавлена!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
return true;
|
|
};
|
|
|
|
// Добавление новой связи из нижней формы
|
|
const handleAddConnection = () => {
|
|
const ok = addConnectionInternal(newConnection);
|
|
if (!ok) return;
|
|
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
|
setError('');
|
|
};
|
|
|
|
// Подтверждение связи, созданной через граф (модальное окно)
|
|
const handleConfirmGraphConnection = () => {
|
|
const ok = addConnectionInternal(newConnection);
|
|
if (!ok) return;
|
|
setGraphConnectionModalOpen(false);
|
|
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
|
setError('');
|
|
};
|
|
|
|
// Удаление связи
|
|
const handleDeleteConnection = (index) => {
|
|
setConnections(connections.filter((_, i) => i !== index));
|
|
setSuccess('Связь удалена!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
};
|
|
|
|
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>
|
|
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
|
</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>
|
|
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Page header */}
|
|
<div className="page-header d-print-none mb-3">
|
|
<div className="row align-items-center">
|
|
<div className="col">
|
|
<h2 className="page-title">Серверы</h2>
|
|
<div className="page-pretitle">Главная / Управление / Серверы</div>
|
|
</div>
|
|
<div className="col-auto ms-auto d-print-none">
|
|
<div className="btn-list">
|
|
<div className="dropdown">
|
|
<button className="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
|
|
Профили ссылок
|
|
</button>
|
|
<div className="dropdown-menu">
|
|
{Object.keys(presets).map(key => (
|
|
<button key={key} className="dropdown-item" type="button" onClick={() => applyPreset(key)}>{key}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn btn-outline-primary d-flex align-items-center" onClick={exportAllLinks} disabled={servers.length === 0}>
|
|
<IconDownload className="me-1" /> Экспорт
|
|
</button>
|
|
<button type="button" className="btn btn-outline-primary d-flex align-items-center" onClick={handleSaveChanges} disabled={loading}>
|
|
<IconDatabase className={loading ? 'spin me-1' : 'me-1'} /> Сохранить
|
|
</button>
|
|
<button type="button" className="btn btn-primary d-flex align-items-center" onClick={handleOpenAddServerModal}>
|
|
<IconPlus className="me-1" /> Добавить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Таблица серверов на всю ширину */}
|
|
<div className="card w-100">
|
|
<div className="card-header d-flex justify-content-between align-items-center">
|
|
<h3 className="card-title mb-0">Список серверов <span className="badge bg-blue-lt text-blue ms-2">{filteredServers.length}</span></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>
|
|
<select className="form-select w-auto" value={filterType} onChange={e => { setFilterType(e.target.value); setCurrentPage(1); }}>
|
|
<option value="">Все типы</option>
|
|
{allTypes.map(type => (
|
|
<option key={type} value={type}>{type === 'exit' ? 'Выходная нода' : 'Jumphost'}</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>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
type="button"
|
|
onClick={() => { setFilterTunnel(''); setFilterCountry(''); setFilterProvider(''); setFilterType(''); setSearchTerm(''); setCurrentPage(1); }}
|
|
title="Сбросить фильтры"
|
|
>
|
|
Сбросить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th className="cursor-pointer" onClick={() => handleSort('ip')}>
|
|
IP адрес
|
|
{sortField === 'ip' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('type')}>
|
|
Тип
|
|
{sortField === 'type' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th>
|
|
Внешний IP
|
|
</th>
|
|
<th>
|
|
Внутренний IP
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('dns')}>
|
|
DNS имя
|
|
{sortField === 'dns' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('country')}>
|
|
Страна
|
|
{sortField === 'country' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('provider')}>
|
|
Провайдер
|
|
{sortField === 'provider' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th className="cursor-pointer" onClick={() => handleSort('tunnel')}>
|
|
Туннель
|
|
{sortField === 'tunnel' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th className="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.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={11} className="text-center text-muted py-4">Ничего не найдено. Измените фильтры или поиск.</td>
|
|
</tr>
|
|
) : paginatedServers.map((server) => (
|
|
<tr key={server.id || server.ip} className={editingServer === server.ip ? 'table-info' : ''}>
|
|
<td>{server.ip}</td>
|
|
<td>
|
|
<span className={`badge ${server.type === 'exit' ? 'bg-red-lt text-danger' : 'bg-blue-lt text-blue'}`}>
|
|
{server.type === 'exit' ? 'Выходная нода' : 'Jumphost'}
|
|
</span>
|
|
</td>
|
|
<td>{server.extIp || <span className="text-muted">—</span>}</td>
|
|
<td>{server.internalIp || <span className="text-muted">—</span>}</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-outline-primary btn-icon btn-sm" title="Ссылка" onClick={() => handleGenerateLink(server)} onContextMenu={(e) => { e.preventDefault(); quickCopyLink(server); }}>
|
|
<IconLink size={16} />
|
|
</button>
|
|
<button className="btn btn-outline-secondary btn-icon btn-sm" title="Редактировать" onClick={() => handleEdit(server)}>
|
|
<IconEdit size={16} />
|
|
</button>
|
|
<button className="btn btn-outline-danger btn-icon btn-sm" title="Удалить" onClick={() => confirmDelete(server)}>
|
|
<IconTrash size={16} />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/* Пагинация */}
|
|
{totalPages > 1 && (
|
|
<div className="card-footer d-flex align-items-center justify-content-between">
|
|
<div className="text-muted">
|
|
Показано {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, filteredServers.length)} из {filteredServers.length}
|
|
</div>
|
|
<ul className="pagination m-0">
|
|
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
|
</li>
|
|
<li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
|
</li>
|
|
{(() => {
|
|
const pages = [];
|
|
let start = Math.max(1, currentPage - 2);
|
|
let end = Math.min(totalPages, currentPage + 2);
|
|
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
if (start > 1) pages.push('start-ellipsis');
|
|
for (let p = start; p <= end; p++) pages.push(p);
|
|
if (end < totalPages) pages.push('end-ellipsis');
|
|
return pages.map((p) => (
|
|
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
) : (
|
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
|
</li>
|
|
)
|
|
));
|
|
})()}
|
|
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
|
</li>
|
|
<li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Delete Modal - New unified component */}
|
|
<ConfirmModal
|
|
show={showDeleteModal}
|
|
onClose={() => setShowDeleteModal(false)}
|
|
onConfirm={executeDelete}
|
|
title="Подтверждение удаления"
|
|
message={
|
|
serverToDelete ? (
|
|
<>
|
|
<p>Вы уверены, что хотите удалить сервер <strong>{serverToDelete.ip}</strong>?</p>
|
|
<p className="text-muted mb-0">Это действие нельзя отменить.</p>
|
|
</>
|
|
) : null
|
|
}
|
|
confirmLabel="Удалить"
|
|
variant="danger"
|
|
icon={IconTrash}
|
|
/>
|
|
|
|
{/* 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 - New unified component */}
|
|
<FormModal
|
|
show={addServerModalOpen}
|
|
onClose={handleCloseAddServerModal}
|
|
onSubmit={handleAddServerFromModal}
|
|
title="Добавить новый сервер"
|
|
submitLabel="Добавить сервер"
|
|
submitIcon={IconPlus}
|
|
size="md"
|
|
>
|
|
{error && (
|
|
<div className="alert alert-danger mb-3" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<FormField
|
|
label="IP адрес"
|
|
name="ip"
|
|
value={newServer.ip}
|
|
onChange={(val) => setNewServer({ ...newServer, ip: val })}
|
|
placeholder="192.168.1.1"
|
|
required
|
|
icon={IconServer}
|
|
helpText="Введите IPv4 адрес сервера"
|
|
/>
|
|
|
|
<FormField
|
|
label="Внешний IP (опционально)"
|
|
name="extIp"
|
|
value={newServer.extIp}
|
|
onChange={(val) => setNewServer({ ...newServer, extIp: val })}
|
|
placeholder="Публичный IP, если отличается"
|
|
icon={IconServer}
|
|
/>
|
|
|
|
<FormField
|
|
label="Внутренний IP (опционально)"
|
|
name="internalIp"
|
|
value={newServer.internalIp}
|
|
onChange={(val) => setNewServer({ ...newServer, internalIp: val })}
|
|
placeholder="10.x.x.x / 192.168.x.x"
|
|
icon={IconServer}
|
|
/>
|
|
|
|
<FormField
|
|
label="DNS имя"
|
|
name="dns"
|
|
value={newServer.dns}
|
|
onChange={(val) => setNewServer({ ...newServer, dns: val })}
|
|
placeholder="srv1.shx.su"
|
|
required
|
|
icon={IconWorld}
|
|
helpText="Полное доменное имя сервера"
|
|
/>
|
|
|
|
<FormField
|
|
label="Страна"
|
|
name="country"
|
|
type="select"
|
|
value={newServer.country}
|
|
onChange={(val) => setNewServer({ ...newServer, country: val })}
|
|
required
|
|
icon={IconMapPin}
|
|
options={[
|
|
{ value: '', label: 'Выберите страну', disabled: true },
|
|
{ value: 'RU', label: 'Россия (RU)' },
|
|
{ value: 'SWE', label: 'Швеция (SWE)' },
|
|
{ value: 'US', label: 'США (US)' },
|
|
{ value: 'DE', label: 'Германия (DE)' },
|
|
{ value: 'NL', label: 'Нидерланды (NL)' },
|
|
{ value: 'SG', label: 'Сингапур (SG)' }
|
|
]}
|
|
/>
|
|
|
|
<FormField
|
|
label="Провайдер"
|
|
name="provider"
|
|
type="select"
|
|
value={newServer.provider}
|
|
onChange={(val) => setNewServer({ ...newServer, provider: val })}
|
|
required
|
|
options={[
|
|
{ value: '', label: 'Выберите провайдера', disabled: true },
|
|
{ value: 'Yandex', label: 'Yandex Cloud' },
|
|
{ value: 'AWS', label: 'Amazon Web Services' },
|
|
{ value: 'Google', label: 'Google Cloud' },
|
|
{ value: 'Azure', label: 'Microsoft Azure' },
|
|
{ value: 'Hetzner', label: 'Hetzner' },
|
|
{ value: 'OVH', label: 'OVH' },
|
|
{ value: 'DigitalOcean', label: 'DigitalOcean' },
|
|
{ value: 'Vultr', label: 'Vultr' },
|
|
{ value: 'Linode', label: 'Linode' },
|
|
{ value: 'VPSVILLE', label: 'VPSVILLE' },
|
|
{ value: 'HIPHOST', label: 'HIPHOST' },
|
|
{ value: 'Другой', label: 'Другой' }
|
|
]}
|
|
/>
|
|
|
|
{newServer.provider === 'Другой' && (
|
|
<FormField
|
|
label="Название провайдера"
|
|
name="customProvider"
|
|
value={customProvider}
|
|
onChange={setCustomProvider}
|
|
placeholder="Введите название провайдера"
|
|
required
|
|
helpText="Укажите название вашего провайдера"
|
|
/>
|
|
)}
|
|
|
|
<FormField
|
|
label="Тип сервера"
|
|
name="type"
|
|
type="select"
|
|
value={newServer.type}
|
|
onChange={(val) => setNewServer({ ...newServer, type: val })}
|
|
required
|
|
options={[
|
|
{ value: '', label: 'Выберите тип', disabled: true },
|
|
...SERVER_TYPE_OPTIONS.map(opt => ({ value: opt.value, label: opt.label }))
|
|
]}
|
|
helpText="Выберите роль: jumphost или выходная нода"
|
|
/>
|
|
|
|
<FormField
|
|
label="Тип туннеля"
|
|
name="tunnel"
|
|
type="select"
|
|
value={newServer.tunnel}
|
|
onChange={(val) => setNewServer({ ...newServer, tunnel: val })}
|
|
required
|
|
options={[
|
|
{ value: 'GRE', label: 'GRE (Generic Routing Encapsulation)' },
|
|
{ value: 'IPSec', label: 'IPSec' },
|
|
{ value: 'WireGuard', label: 'WireGuard' },
|
|
{ value: 'OpenVPN', label: 'OpenVPN' }
|
|
]}
|
|
/>
|
|
|
|
<FormField
|
|
label="Основной шлюз"
|
|
name="gateway"
|
|
value={newServer.gateway}
|
|
onChange={(val) => setNewServer({ ...newServer, gateway: val })}
|
|
placeholder="Cloudflare, Hetzner, Мой шлюз и т.д."
|
|
required
|
|
helpText="Имя основного шлюза для маршрутизации"
|
|
/>
|
|
{NEEDS_GATEWAYS(newServer.type) && (
|
|
<>
|
|
<div className="form-label">Шлюзы</div>
|
|
<div className="list-group list-group-flush border rounded mb-2">
|
|
{(newServer.gateways || []).map((gw, idx) => (
|
|
<div key={gw.id || idx} className="list-group-item">
|
|
<div className="row g-2 align-items-end">
|
|
<div className="col-12 col-md-4">
|
|
<label className="form-label small mb-1">Имя *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={gw.name}
|
|
onChange={(e) => {
|
|
const list = newServer.gateways.map((g, i) => i === idx ? { ...g, name: e.target.value } : g);
|
|
setNewServer({ ...newServer, gateways: list });
|
|
}}
|
|
placeholder="GW-MSK"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-4">
|
|
<label className="form-label small mb-1">IP</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={gw.ip || ''}
|
|
onChange={(e) => {
|
|
const list = newServer.gateways.map((g, i) => i === idx ? { ...g, ip: e.target.value } : g);
|
|
setNewServer({ ...newServer, gateways: list });
|
|
}}
|
|
placeholder="10.0.0.1"
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-3">
|
|
<label className="form-label small mb-1">Комментарий</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={gw.comment || ''}
|
|
onChange={(e) => {
|
|
const list = newServer.gateways.map((g, i) => i === idx ? { ...g, comment: e.target.value } : g);
|
|
setNewServer({ ...newServer, gateways: list });
|
|
}}
|
|
placeholder="Основной шлюз"
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-1 d-flex align-items-center justify-content-center">
|
|
<div className="form-check form-switch m-0">
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
checked={gw.primary}
|
|
onChange={(e) => {
|
|
const list = newServer.gateways.map((g, i) => ({ ...g, primary: i === idx ? e.target.checked : false }));
|
|
if (!list.some(g => g.primary)) list[idx].primary = true;
|
|
setNewServer({ ...newServer, gateways: list });
|
|
}}
|
|
title="Основной шлюз"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="d-flex justify-content-between align-items-center mt-2">
|
|
<div className="text-muted small">{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}</div>
|
|
{newServer.gateways.length > 1 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-link text-danger px-0"
|
|
onClick={() => {
|
|
if (newServer.gateways.length === 1) return;
|
|
const list = newServer.gateways.filter((_, i) => i !== idx);
|
|
if (!list.some(g => g.primary) && list.length > 0) list[0].primary = true;
|
|
setNewServer({ ...newServer, gateways: list });
|
|
}}
|
|
>
|
|
Удалить
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-primary btn-sm"
|
|
onClick={() => {
|
|
setNewServer((prev) => {
|
|
const list = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
return { ...prev, gateways: [...list, makeGateway({ primary: list.length === 0 })] };
|
|
});
|
|
}}
|
|
>
|
|
<IconPlus className="icon" />
|
|
Добавить шлюз
|
|
</button>
|
|
<div className="form-text">Для jumphost/exit должен быть один основной gateway.</div>
|
|
</>
|
|
)}
|
|
</FormModal>
|
|
|
|
<div className="card w-100 mb-4">
|
|
<div className="card-header d-flex justify-content-between align-items-center">
|
|
<h3 className="card-title mb-0">Граф связей между серверами</h3>
|
|
<div className="text-muted small">Поддержка: GRE, IPSec, WireGuard, OpenVPN</div>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="mb-3 d-flex flex-wrap gap-2 align-items-center">
|
|
<span className="text-muted small d-flex align-items-center"><IconFilter size={16} className="me-1" />Фильтры графа:</span>
|
|
<div className="btn-group btn-group-sm" role="group" aria-label="Туннели">
|
|
{['GRE','IPSec','WireGuard','OpenVPN'].map(t => (
|
|
<button key={t} className={`btn ${graphTunnelFilters[t] ? 'btn-primary' : 'btn-outline-primary'}`} onClick={() => setGraphTunnelFilters(prev => ({...prev, [t]: !prev[t]}))}>{t}</button>
|
|
))}
|
|
</div>
|
|
<div className="d-flex align-items-center ms-2">
|
|
<IconMapPin size={16} className="me-1 text-muted" />
|
|
<select className="form-select form-select-sm" value={graphCountryFilter} onChange={e => setGraphCountryFilter(e.target.value)}>
|
|
<option value="">Все страны</option>
|
|
{Array.from(new Set(servers.map(s => s.country).filter(Boolean))).map(c => (<option key={c} value={c}>{c}</option>))}
|
|
</select>
|
|
</div>
|
|
<div className="d-flex align-items-center">
|
|
<IconServer size={16} className="me-1 text-muted" />
|
|
<select className="form-select form-select-sm" value={graphProviderFilter} onChange={e => setGraphProviderFilter(e.target.value)}>
|
|
<option value="">Все провайдеры</option>
|
|
{Array.from(new Set(servers.map(s => s.provider).filter(Boolean))).map(p => (<option key={p} value={p}>{p}</option>))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<GraphView
|
|
servers={servers.filter(s => (!graphCountryFilter || s.country === graphCountryFilter) && (!graphProviderFilter || s.provider === graphProviderFilter))}
|
|
connections={connections.filter(c => graphTunnelFilters[c.tunnelType])}
|
|
onCreateConnection={({ from, to }) => {
|
|
// Открываем современный флоу создания связи прямо поверх графа
|
|
setNewConnection(prev => ({
|
|
...prev,
|
|
from,
|
|
to,
|
|
// Если серверы уже имеют тип туннеля, предлагаем его по умолчанию
|
|
tunnelType:
|
|
prev.tunnelType ||
|
|
servers.find(s => s.ip === from)?.tunnel ||
|
|
servers.find(s => s.ip === to)?.tunnel ||
|
|
'GRE',
|
|
}));
|
|
setGraphConnectionModalOpen(true);
|
|
}}
|
|
/>
|
|
<div className="mt-3">
|
|
<div className="d-flex align-items-center gap-3 flex-wrap">
|
|
<div className="d-flex align-items-center gap-2"><span style={{display:'inline-block',width:24,height:4,background:'#206bc4'}}></span><span className="text-muted small">GRE</span></div>
|
|
<div className="d-flex align-items-center gap-2"><span style={{display:'inline-block',width:24,height:4,background:'#f59f00',borderBottom:'2px dashed #f59f00'}}></span><span className="text-muted small">IPSec</span></div>
|
|
<div className="d-flex align-items-center gap-2"><span style={{display:'inline-block',width:24,height:4,background:'#2fb344'}}></span><span className="text-muted small">WireGuard</span></div>
|
|
<div className="d-flex align-items-center gap-2"><span style={{display:'inline-block',width:24,height:4,background:'#be4bdb',borderBottom:'2px dotted #be4bdb'}}></span><span className="text-muted small">OpenVPN</span></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Модальное окно для быстрого создания связи после соединения узлов на графе */}
|
|
<FormModal
|
|
show={graphConnectionModalOpen}
|
|
onClose={() => {
|
|
setGraphConnectionModalOpen(false);
|
|
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
|
setError('');
|
|
}}
|
|
onSubmit={handleConfirmGraphConnection}
|
|
title="Создать связь между серверами"
|
|
submitLabel="Создать связь"
|
|
submitIcon={IconPlus}
|
|
size="md"
|
|
>
|
|
{error && (
|
|
<div className="alert alert-danger mb-3" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
<div className="mb-3">
|
|
<div className="text-muted small mb-1">Вы выбрали связь между:</div>
|
|
<div className="d-flex flex-wrap align-items-center gap-2">
|
|
<span className="badge bg-blue-lt text-blue">
|
|
{newConnection.from || 'Сервер A'}
|
|
</span>
|
|
<span className="text-muted">⇄</span>
|
|
<span className="badge bg-blue-lt text-blue">
|
|
{newConnection.to || 'Сервер B'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="row g-3">
|
|
<div className="col-md-4">
|
|
<FormField
|
|
label="Тип туннеля"
|
|
name="tunnelType"
|
|
type="select"
|
|
value={newConnection.tunnelType}
|
|
onChange={(val) => setNewConnection({ ...newConnection, tunnelType: val })}
|
|
required
|
|
options={[
|
|
{ value: 'GRE', label: 'GRE' },
|
|
{ value: 'IPSec', label: 'IPSec' },
|
|
{ value: 'WireGuard', label: 'WireGuard' },
|
|
{ value: 'OpenVPN', label: 'OpenVPN' },
|
|
]}
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<FormField
|
|
label="IP адрес сервера A"
|
|
name="ipA"
|
|
value={newConnection.ipA}
|
|
onChange={(val) => setNewConnection({ ...newConnection, ipA: val })}
|
|
placeholder="10.10.100.1"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<FormField
|
|
label="IP адрес сервера B"
|
|
name="ipB"
|
|
value={newConnection.ipB}
|
|
onChange={(val) => setNewConnection({ ...newConnection, ipB: val })}
|
|
placeholder="10.10.100.2"
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 text-muted small">
|
|
Протяните линию между серверами в графе — здесь вы только уточняете параметры туннеля. Это делает
|
|
построение схемы максимально быстрым и наглядным.
|
|
</div>
|
|
</FormModal>
|
|
<div id="add-connection-anchor" 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-3">
|
|
<div className="col-md-6">
|
|
<label className="form-label">Сервер A (IP/DNS)</label>
|
|
<select className="form-select" value={newConnection.from} onChange={e => setNewConnection({ ...newConnection, from: e.target.value })}>
|
|
<option value="">Выберите сервер A</option>
|
|
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-6">
|
|
<label className="form-label">Сервер B (IP/DNS)</label>
|
|
<select className="form-select" value={newConnection.to} onChange={e => setNewConnection({ ...newConnection, to: e.target.value })}>
|
|
<option value="">Выберите сервер B</option>
|
|
{servers.map(s => <option key={s.ip} value={s.ip}>{s.ip} ({s.dns})</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="col-md-4">
|
|
<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-4">
|
|
<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-4">
|
|
<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-12">
|
|
<button className="btn btn-primary d-flex align-items-center" type="button" onClick={handleAddConnection}>
|
|
<span className="me-2 d-flex align-items-center"><IconPlus className="icon" /></span>
|
|
<span className="fw-bold">Добавить связь</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Список существующих связей */}
|
|
{connections.length > 0 && (
|
|
<div className="card w-100 mb-4">
|
|
<div className="card-header">
|
|
<h3 className="card-title mb-0">Существующие связи ({connections.length})</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th>Сервер A</th>
|
|
<th>Сервер B</th>
|
|
<th>Тип туннеля</th>
|
|
<th>IP адреса туннеля</th>
|
|
<th className="text-end">Действия</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{connections.map((connection, index) => {
|
|
const serverA = servers.find(s => s.ip === connection.from);
|
|
const serverB = servers.find(s => s.ip === connection.to);
|
|
return (
|
|
<tr key={index}>
|
|
<td>
|
|
<div>
|
|
<div className="fw-bold">{connection.from}</div>
|
|
<div className="text-muted">{serverA?.dns || 'Неизвестный сервер'}</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<div>
|
|
<div className="fw-bold">{connection.to}</div>
|
|
<div className="text-muted">{serverB?.dns || 'Неизвестный сервер'}</div>
|
|
</div>
|
|
</td>
|
|
<td>
|
|
<span className="badge bg-green-lt text-green">{connection.tunnelType}</span>
|
|
</td>
|
|
<td>
|
|
<code>{connection.ipA} ⇄ {connection.ipB}</code>
|
|
</td>
|
|
<td className="text-end">
|
|
<button
|
|
className="btn btn-icon btn-sm btn-outline-danger rounded"
|
|
title="Удалить связь"
|
|
onClick={() => handleDeleteConnection(index)}
|
|
>
|
|
<IconTrash size={18} />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
open={confirmState.open}
|
|
title="Подтверждение"
|
|
message={confirmState.text}
|
|
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
|
|
onConfirm={() => confirmState.onConfirm?.()}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Модальное окно для редактирования сервера с backdrop и анимацией
|
|
function EditServerModal({ show, server, onChange, onSave, onClose }) {
|
|
const modalRef = useRef(null);
|
|
const initializedRef = useRef(false);
|
|
// Локальное состояние для формы
|
|
const [localServer, setLocalServer] = useState({});
|
|
|
|
// Функция для создания сервера с гарантированными gateways
|
|
const ensureGateways = (srv) => {
|
|
if (!srv) return { gateways: [] };
|
|
const needs = NEEDS_GATEWAYS(srv.type);
|
|
let gateways = [];
|
|
if (needs) {
|
|
gateways = normalizeGateways(srv.gateways, srv.gateway || srv.dns || srv.ip);
|
|
if (gateways.length === 0) {
|
|
gateways = [makeGateway({ primary: true })];
|
|
}
|
|
}
|
|
return { ...srv, gateways };
|
|
};
|
|
|
|
// Синхронизируем локальное состояние с пропсами ТОЛЬКО при открытии модалки
|
|
useEffect(() => {
|
|
if (show && server && !initializedRef.current) {
|
|
const prepared = ensureGateways(server);
|
|
console.log('[EditServerModal] Инициализация с сервером:', prepared);
|
|
setLocalServer(prepared);
|
|
initializedRef.current = true;
|
|
}
|
|
// Сбрасываем флаг при закрытии
|
|
if (!show) {
|
|
initializedRef.current = false;
|
|
}
|
|
}, [show, 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 handleFieldChange = (field, value) => {
|
|
setLocalServer(prev => {
|
|
const updated = { ...prev, [field]: value };
|
|
onChange && onChange(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
// Функция обновления шлюза по индексу
|
|
const handleGatewayChange = (idx, field, value) => {
|
|
setLocalServer(prev => {
|
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
const updatedGateways = currentGateways.map((gw, i) =>
|
|
i === idx ? { ...gw, [field]: value } : gw
|
|
);
|
|
const updated = { ...prev, gateways: updatedGateways };
|
|
onChange && onChange(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
// Функция переключения primary шлюза
|
|
const handlePrimaryChange = (idx, checked) => {
|
|
setLocalServer(prev => {
|
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
const updatedGateways = currentGateways.map((gw, i) => ({
|
|
...gw,
|
|
primary: i === idx ? checked : false
|
|
}));
|
|
// Убедимся, что хотя бы один primary
|
|
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
|
|
updatedGateways[idx].primary = true;
|
|
}
|
|
const updated = { ...prev, gateways: updatedGateways };
|
|
onChange && onChange(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
// Функция добавления нового шлюза
|
|
const handleAddGateway = () => {
|
|
console.log('[EditServerModal] handleAddGateway вызвана, текущий localServer:', localServer);
|
|
setLocalServer(prev => {
|
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
const newGw = makeGateway({ primary: currentGateways.length === 0 });
|
|
const updatedGateways = [...currentGateways, newGw];
|
|
const updated = { ...prev, gateways: updatedGateways };
|
|
console.log('[EditServerModal] Новый localServer после добавления шлюза:', updated);
|
|
onChange && onChange(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
// Функция удаления шлюза
|
|
const handleRemoveGateway = (idx) => {
|
|
setLocalServer(prev => {
|
|
const currentGateways = Array.isArray(prev.gateways) ? prev.gateways : [];
|
|
if (currentGateways.length <= 1) return prev; // Не удаляем последний
|
|
const updatedGateways = currentGateways.filter((_, i) => i !== idx);
|
|
// Убедимся, что есть primary
|
|
if (!updatedGateways.some(g => g.primary) && updatedGateways.length > 0) {
|
|
updatedGateways[0].primary = true;
|
|
}
|
|
const updated = { ...prev, gateways: updatedGateways };
|
|
onChange && onChange(updated);
|
|
return updated;
|
|
});
|
|
};
|
|
|
|
// Получаем текущие шлюзы для рендера
|
|
const currentGateways = Array.isArray(localServer.gateways) ? localServer.gateways : [];
|
|
const showGateways = NEEDS_GATEWAYS(localServer.type);
|
|
|
|
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">ID (опционально)</label>
|
|
<input type="text" className="form-control" value={localServer.id || ''} onChange={e => handleFieldChange('id', e.target.value)} placeholder="srv-1" />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">IP адрес</label>
|
|
<input type="text" className="form-control" value={localServer.ip || ''} onChange={e => handleFieldChange('ip', e.target.value)} />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Внешний IP (опционально)</label>
|
|
<input type="text" className="form-control" value={localServer.extIp || ''} onChange={e => handleFieldChange('extIp', e.target.value)} placeholder="публичный IP, если отличается" />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Внутренний IP (опционально)</label>
|
|
<input type="text" className="form-control" value={localServer.internalIp || ''} onChange={e => handleFieldChange('internalIp', e.target.value)} placeholder="10.x.x.x / 192.168.x.x" />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">DNS имя</label>
|
|
<input type="text" className="form-control" value={localServer.dns || ''} onChange={e => handleFieldChange('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 => handleFieldChange('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 => handleFieldChange('provider', e.target.value)} />
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Тип сервера</label>
|
|
<select className="form-select" value={localServer.type || 'jumphost'} onChange={e => handleFieldChange('type', e.target.value)}>
|
|
{SERVER_TYPE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="mb-3">
|
|
<label className="form-label">Тип туннеля</label>
|
|
<input type="text" className="form-control" value={localServer.tunnel || ''} onChange={e => handleFieldChange('tunnel', e.target.value)} />
|
|
</div>
|
|
{showGateways && (
|
|
<>
|
|
<div className="form-label">Шлюзы ({currentGateways.length})</div>
|
|
<div className="list-group list-group-flush border rounded mb-2">
|
|
{currentGateways.map((gw, idx) => (
|
|
<div key={gw.id || `gw-${idx}`} className="list-group-item">
|
|
<div className="row g-2 align-items-end">
|
|
<div className="col-12 col-md-4">
|
|
<label className="form-label small mb-1">Имя *</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={gw.name || ''}
|
|
onChange={(e) => handleGatewayChange(idx, 'name', e.target.value)}
|
|
placeholder="GW-MSK"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-4">
|
|
<label className="form-label small mb-1">IP</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={gw.ip || ''}
|
|
onChange={(e) => handleGatewayChange(idx, 'ip', e.target.value)}
|
|
placeholder="10.0.0.1"
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-3">
|
|
<label className="form-label small mb-1">Комментарий</label>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
value={gw.comment || ''}
|
|
onChange={(e) => handleGatewayChange(idx, 'comment', e.target.value)}
|
|
placeholder="Основной шлюз"
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-1 d-flex align-items-center justify-content-center">
|
|
<div className="form-check form-switch m-0">
|
|
<input
|
|
className="form-check-input"
|
|
type="checkbox"
|
|
checked={!!gw.primary}
|
|
onChange={(e) => handlePrimaryChange(idx, e.target.checked)}
|
|
title="Основной шлюз"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="d-flex justify-content-between align-items-center mt-2">
|
|
<div className="text-muted small">{gw.primary ? 'Основной шлюз' : 'Резервный шлюз'}</div>
|
|
{currentGateways.length > 1 && (
|
|
<button
|
|
type="button"
|
|
className="btn btn-link text-danger px-0"
|
|
onClick={() => handleRemoveGateway(idx)}
|
|
>
|
|
Удалить
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="btn btn-outline-primary btn-sm"
|
|
onClick={handleAddGateway}
|
|
>
|
|
<IconPlus className="icon" />
|
|
Добавить шлюз
|
|
</button>
|
|
<div className="form-text">Для jumphost/exit должен быть один основной gateway.</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.type}
|
|
onChange={(e) => handleChange('type', e.target.value)}
|
|
required
|
|
>
|
|
<option value="">Выберите тип</option>
|
|
{SERVER_TYPE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</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; |