Files
router-lists-ui/frontend/src/ServerManager.jsx
T

1146 lines
47 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useMemo } from 'react';
import api from './lib/api.js';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import FormModal from './components/FormModal.jsx';
import FormField from './components/FormField.jsx';
import { useNotify } from './components/NotifyProvider.jsx';
import {
IconPlus,
IconEdit,
IconTrash,
IconDatabase,
IconDownload,
IconServer,
IconFilter,
IconList,
IconLayoutGrid,
IconGitBranch,
IconSquare,
IconSquareCheck,
IconChevronRight
} from '@tabler/icons-react';
import GraphView from './GraphView';
import {
normalizeGateways,
needsGateways as NEEDS_GATEWAYS,
SERVER_TYPE_OPTIONS
} from './utils/serverUtils.js';
import {
ServerModal,
LinkGeneratorModal,
ServerSidebar,
ServerCard,
ServerBulkActions
} from './components/server/index.js';
const API_URL = '/api';
// Преобразование кода страны в emoji-флаг
function countryToFlag(isoCode) {
if (!isoCode) return '';
return isoCode
.toUpperCase()
.replace(/./g, char =>
String.fromCodePoint(127397 + char.charCodeAt())
);
}
function ServerManager() {
const notify = useNotify();
// === Основные данные ===
const [servers, setServers] = useState([]);
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(false);
// === UI State ===
const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections'
const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards'
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// === Фильтры и поиск ===
const [filters, setFilters] = useState({
country: '',
provider: '',
type: '',
tunnel: ''
});
const [searchTerm, setSearchTerm] = useState('');
// === Сортировка и пагинация ===
const [sortField, setSortField] = useState('ip');
const [sortOrder, setSortOrder] = useState('asc');
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
// === Выбор (bulk selection) ===
const [selectedServers, setSelectedServers] = useState(new Set());
// === Модальные окна ===
const [addServerModalOpen, setAddServerModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editModalServer, setEditModalServer] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [serverToDelete, setServerToDelete] = useState(null);
const [linkGeneratorOpen, setLinkGeneratorOpen] = useState(false);
const [linkGeneratorServer, setLinkGeneratorServer] = useState(null);
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null });
// === Ошибки форм ===
const [modalError, setModalError] = useState('');
// === URL Settings ===
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 [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
// === Фильтры графа ===
const [graphTunnelFilters, setGraphTunnelFilters] = useState({ GRE: true, IPSec: true, WireGuard: true, OpenVPN: true });
const [graphCountryFilter, setGraphCountryFilter] = useState('');
const [graphProviderFilter, setGraphProviderFilter] = useState('');
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' }
};
// === Helpers ===
const normalizeServerType = (type) => {
const value = String(type || '').toLowerCase();
return SERVER_TYPE_OPTIONS.some((opt) => opt.value === value) ? value : 'jumphost';
};
// === Загрузка данных ===
useEffect(() => {
fetchServers();
fetchConnections();
}, []);
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);
} catch (error) {
console.error('Error fetching servers:', error);
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
} 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 filteredServers = useMemo(() => {
let result = [...servers];
// Применяем фильтры
if (filters.country) {
result = result.filter(s => s.country === filters.country);
}
if (filters.provider) {
result = result.filter(s => s.provider === filters.provider);
}
if (filters.type) {
result = result.filter(s => s.type === filters.type);
}
if (filters.tunnel) {
result = result.filter(s => s.tunnel === filters.tunnel);
}
// Поиск
if (searchTerm) {
const term = searchTerm.toLowerCase();
result = result.filter(s =>
s.ip?.toLowerCase().includes(term) ||
s.dns?.toLowerCase().includes(term) ||
s.country?.toLowerCase().includes(term) ||
s.provider?.toLowerCase().includes(term) ||
(s.type || '').toLowerCase().includes(term)
);
}
// Сортировка
result.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;
});
return result;
}, [servers, filters, searchTerm, sortField, sortOrder]);
// === Пагинация ===
const totalPages = Math.ceil(filteredServers.length / pageSize);
const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// === Handlers ===
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
const handleResetFilters = () => {
setFilters({ country: '', provider: '', type: '', tunnel: '' });
setSearchTerm('');
setCurrentPage(1);
};
// === Selection ===
const toggleServerSelection = (server) => {
const newSet = new Set(selectedServers);
if (newSet.has(server.ip)) {
newSet.delete(server.ip);
} else {
newSet.add(server.ip);
}
setSelectedServers(newSet);
};
const selectAllServers = () => {
if (selectedServers.size === filteredServers.length) {
setSelectedServers(new Set());
} else {
setSelectedServers(new Set(filteredServers.map(s => s.ip)));
}
};
const clearSelection = () => {
setSelectedServers(new Set());
};
// === CRUD Operations ===
const handleOpenAddServerModal = () => {
setModalError('');
setAddServerModalOpen(true);
};
const handleCloseAddServerModal = () => {
setAddServerModalOpen(false);
setModalError('');
};
const handleAddServerFromModal = (serverData) => {
const nextId = `srv-${servers.length + 1}`;
const serverToAdd = {
...serverData,
id: serverData.id || nextId,
type: normalizeServerType(serverData.type),
};
setServers([...servers, serverToAdd]);
handleCloseAddServerModal();
// Автоматически создаём запись в биллинге
(async () => {
try {
const billingRes = await api.get(`/billing`);
const billingData = Array.isArray(billingRes.data) ? billingRes.data : [];
// Проверяем, нет ли уже такого сервера в биллинге
const alreadyExists = billingData.some(b => b.serverId === serverToAdd.id);
if (!alreadyExists) {
const newBillingItem = {
id: `billing-${serverToAdd.id}`,
hostName: serverToAdd.dns || serverToAdd.ip,
purpose: '',
country: serverToAdd.country || '',
provider: serverToAdd.provider || '',
loginUrl: '',
monthlyCost: 0,
monthlyCostCurrency: 'USD',
nextPaymentDate: '',
lastPaymentDate: '',
lastPaymentAmount: 0,
lastPaymentCurrency: 'USD',
status: 'active',
notes: 'Автоматически добавлен из серверов',
serverId: serverToAdd.id,
payments: []
};
await api.post(`/billing`, { domains: [...billingData, newBillingItem] });
}
} catch (e) {
console.warn('Не удалось создать запись биллинга:', e);
}
})();
notify.success('Сервер успешно добавлен!');
};
const handleEdit = (server) => {
setModalError('');
setEditModalServer({ ...server });
setEditModalOpen(true);
};
const handleEditModalSave = (updatedServer) => {
const normalizedServer = {
...updatedServer,
type: normalizeServerType(updatedServer.type),
};
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);
notify.success('Сервер успешно обновлён!');
};
const handleEditModalClose = () => {
setEditModalOpen(false);
setEditModalServer(null);
setModalError('');
};
const confirmDelete = (server) => {
setServerToDelete(server);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (serverToDelete) {
setServers(servers.filter(s => s.ip !== serverToDelete.ip));
setShowDeleteModal(false);
setServerToDelete(null);
notify.success('Сервер удалён!');
}
};
// === Bulk Actions ===
const handleBulkDelete = () => {
if (selectedServers.size === 0) return;
setConfirmState({
open: true,
text: `Вы уверены, что хотите удалить ${selectedServers.size} сервер(ов)? Это действие необратимо.`,
onConfirm: () => {
setServers(servers.filter(s => !selectedServers.has(s.ip)));
setSelectedServers(new Set());
setConfirmState({ open: false, text: '', onConfirm: null });
notify.success(`Удалено ${selectedServers.size} серверов. Не забудьте сохранить!`);
},
});
};
const handleBulkChangeTunnel = (newTunnel) => {
if (selectedServers.size === 0) return;
setServers(servers.map(s =>
selectedServers.has(s.ip) ? { ...s, tunnel: newTunnel } : s
));
notify.success(`Туннель изменён на ${newTunnel} для ${selectedServers.size} серверов. Не забудьте сохранить!`);
};
const handleBulkExport = () => {
const selected = servers.filter(s => selectedServers.has(s.ip));
exportServersToCSV(selected);
};
const handleBulkCopyLinks = async () => {
const selected = servers.filter(s => selectedServers.has(s.ip));
const links = selected.map(s => generateServerUrl(s)).join('\n');
await copyToClipboard(links);
};
// === URL Generation ===
const generateServerUrl = (server) => {
const params = new URLSearchParams();
Object.entries(urlSettings).forEach(([key, value]) => {
if (key !== 'baseUrl' && value) {
params.append(key, value);
}
});
params.append('server', server.ip);
return `${urlSettings.baseUrl}?${params.toString()}`;
};
const handleGenerateLink = (server) => {
setLinkGeneratorServer(server);
setLinkGeneratorOpen(true);
};
const handleLinkGeneratorClose = () => {
setLinkGeneratorOpen(false);
setLinkGeneratorServer(null);
};
const applyPreset = (name) => {
const p = presets[name];
if (!p) return;
setUrlSettings(prev => ({ ...prev, ...p }));
localStorage.setItem('urlSettings', JSON.stringify({ ...urlSettings, ...p }));
notify.success('Профиль применён');
};
// === Clipboard ===
const fallbackCopyToClipboard = (text) => {
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
notify.success('Скопировано в буфер обмена!');
} catch (err) {
notify.error('Не удалось скопировать');
}
document.body.removeChild(textArea);
};
const copyToClipboard = async (text) => {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(text);
notify.success('Скопировано в буфер обмена!');
return;
} catch (err) {
// fallback
}
}
fallbackCopyToClipboard(text);
};
const quickCopyLink = async (server) => {
const url = generateServerUrl(server);
await copyToClipboard(url);
};
// === Export ===
const exportServersToCSV = (serversList) => {
const csvContent = [
['ID', 'IP', 'Ext IP', 'Internal IP', 'DNS', 'Country', 'Provider', 'Type', 'Tunnel', 'Generated URL'],
...serversList.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 exportAllLinks = () => {
exportServersToCSV(filteredServers);
};
// === Save ===
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 });
// Синхронизация с биллингом: добавляем новые серверы
try {
const billingRes = await api.get(`/billing`);
const billingData = Array.isArray(billingRes.data) ? billingRes.data : [];
// Создаём Set из существующих serverId в биллинге
const existingServerIds = new Set(
billingData.map(b => b.serverId).filter(Boolean)
);
// Находим серверы, которых нет в биллинге
const newBillingItems = normalizedServers
.filter(server => server.id && !existingServerIds.has(server.id))
.map(server => ({
id: `billing-${server.id}`,
hostName: server.dns || server.ip,
purpose: '',
country: server.country || '',
provider: server.provider || '',
loginUrl: '',
monthlyCost: 0,
monthlyCostCurrency: 'USD',
nextPaymentDate: '',
lastPaymentDate: '',
lastPaymentAmount: 0,
lastPaymentCurrency: 'USD',
status: 'active',
notes: `Автоматически добавлен из серверов`,
serverId: server.id,
payments: []
}));
if (newBillingItems.length > 0) {
const updatedBilling = [...billingData, ...newBillingItems];
await api.post(`/billing`, { domains: updatedBilling });
console.log(`Добавлено ${newBillingItems.length} записей в биллинг`);
}
} catch (billingError) {
console.warn('Не удалось синхронизировать биллинг:', billingError);
// Не прерываем основную операцию
}
notify.success('Изменения успешно сохранены!');
} catch (error) {
console.error('Error saving changes:', error);
notify.error('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
};
// === Connections ===
const addConnectionInternal = (draft) => {
if (!draft.from || !draft.to || !draft.tunnelType || !draft.ipA || !draft.ipB) {
notify.error('Все поля для связи должны быть заполнены.');
return false;
}
if (draft.from === draft.to) {
notify.error('Сервер A и сервер B не могут быть одинаковыми.');
return false;
}
const next = { ...draft };
setConnections((prev) => [...prev, next]);
notify.success('Связь успешно добавлена!');
return true;
};
const handleAddConnection = () => {
const ok = addConnectionInternal(newConnection);
if (!ok) return;
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
};
const handleConfirmGraphConnection = () => {
const ok = addConnectionInternal(newConnection);
if (!ok) return;
setGraphConnectionModalOpen(false);
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
};
const handleDeleteConnection = (index) => {
setConnections(connections.filter((_, i) => i !== index));
notify.success('Связь удалена!');
};
return (
<div className="server-manager-layout">
{/* Header */}
<div className="page-header d-print-none mb-3">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title d-flex align-items-center">
<IconServer className="me-2" />
Управление серверами
</h2>
<div className="page-pretitle">
{filteredServers.length} из {servers.length} серверов
{selectedServers.size > 0 && (
<span className="ms-2 text-primary"> {selectedServers.size} выбрано</span>
)}
</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
{/* Tabs */}
<div className="btn-group me-2" role="group">
<button
className={`btn ${activeTab === 'servers' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('servers')}
>
<IconServer size={16} className="me-1" />
Серверы
</button>
<button
className={`btn ${activeTab === 'connections' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('connections')}
>
<IconGitBranch size={16} className="me-1" />
Связи
</button>
</div>
{/* Actions */}
<button type="button" className="btn btn-outline-secondary" onClick={exportAllLinks} disabled={servers.length === 0}>
<IconDownload size={16} className="me-1" /> Экспорт
</button>
<button type="button" className="btn btn-outline-primary" onClick={handleSaveChanges} disabled={loading}>
<IconDatabase size={16} className={loading ? 'spin me-1' : 'me-1'} /> Сохранить
</button>
<button type="button" className="btn btn-primary" onClick={handleOpenAddServerModal}>
<IconPlus size={16} className="me-1" /> Добавить
</button>
</div>
</div>
</div>
</div>
{/* Main content with sidebar */}
{activeTab === 'servers' && (
<div className="d-flex" style={{ gap: '1rem' }}>
{/* Sidebar */}
{!sidebarCollapsed && (
<ServerSidebar
servers={servers}
filters={filters}
onFiltersChange={(newFilters) => { setFilters(newFilters); setCurrentPage(1); }}
searchTerm={searchTerm}
onSearch={(term) => { setSearchTerm(term); setCurrentPage(1); }}
onReset={handleResetFilters}
/>
)}
{/* Toggle sidebar button */}
<button
className="btn btn-ghost-secondary btn-icon position-absolute"
style={{ left: sidebarCollapsed ? 10 : 290, top: 200, zIndex: 100 }}
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? 'Показать фильтры' : 'Скрыть фильтры'}
>
<IconChevronRight size={16} style={{ transform: sidebarCollapsed ? 'rotate(0deg)' : 'rotate(180deg)', transition: '0.2s' }} />
</button>
{/* Servers list */}
<div className="flex-grow-1">
{/* Toolbar */}
<div className="card mb-3">
<div className="card-body py-2">
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex align-items-center gap-2">
{/* Select all */}
<button
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
onClick={selectAllServers}
>
{selectedServers.size === filteredServers.length && filteredServers.length > 0 ? (
<IconSquareCheck size={16} className="me-1 text-primary" />
) : (
<IconSquare size={16} className="me-1 text-muted" />
)}
Выбрать все
</button>
{/* Sort dropdown */}
<div className="dropdown">
<button className="btn btn-outline-secondary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown">
Сортировка: {sortField}
</button>
<div className="dropdown-menu">
{['ip', 'dns', 'country', 'provider', 'type', 'tunnel'].map(field => (
<button key={field} className="dropdown-item" onClick={() => handleSort(field)}>
{field} {sortField === field ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
</button>
))}
</div>
</div>
</div>
{/* View mode toggle */}
<div className="btn-group btn-group-sm" role="group">
<button
className={`btn ${viewMode === 'cards' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('cards')}
title="Карточки"
>
<IconLayoutGrid size={16} />
</button>
<button
className={`btn ${viewMode === 'table' ? 'btn-primary' : 'btn-outline-secondary'}`}
onClick={() => setViewMode('table')}
title="Таблица"
>
<IconList size={16} />
</button>
</div>
</div>
</div>
</div>
{/* Content */}
{loading ? (
<div className="card">
<div className="card-body text-center py-5">
<div className="spinner-border text-primary" role="status"></div>
<div className="text-muted mt-2">Загрузка серверов...</div>
</div>
</div>
) : filteredServers.length === 0 ? (
<div className="card">
<div className="card-body text-center py-5">
<IconServer size={48} className="text-muted mb-3" />
<h3>Серверы не найдены</h3>
<p className="text-muted">Попробуйте изменить фильтры или добавьте новый сервер</p>
<button className="btn btn-primary" onClick={handleOpenAddServerModal}>
<IconPlus size={16} className="me-1" /> Добавить сервер
</button>
</div>
</div>
) : viewMode === 'cards' ? (
/* Cards view */
<div>
{paginatedServers.map(server => (
<ServerCard
key={server.id || server.ip}
server={server}
selected={selectedServers.has(server.ip)}
onSelect={toggleServerSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
))}
</div>
) : (
/* Table view */
<div className="card">
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: 40 }}>
<button className="btn btn-ghost-secondary btn-icon btn-sm p-0" onClick={selectAllServers}>
{selectedServers.size === filteredServers.length && filteredServers.length > 0 ? (
<IconSquareCheck size={18} className="text-primary" />
) : (
<IconSquare size={18} />
)}
</button>
</th>
<th className="cursor-pointer" onClick={() => handleSort('ip')}>
IP {sortField === 'ip' && (sortOrder === 'asc' ? '▲' : '▼')}
</th>
<th className="cursor-pointer" onClick={() => handleSort('type')}>
Тип {sortField === 'type' && (sortOrder === 'asc' ? '▲' : '▼')}
</th>
<th className="cursor-pointer" onClick={() => handleSort('dns')}>
DNS {sortField === 'dns' && (sortOrder === 'asc' ? '▲' : '▼')}
</th>
<th className="cursor-pointer" onClick={() => handleSort('country')}>
Страна {sortField === 'country' && (sortOrder === 'asc' ? '▲' : '▼')}
</th>
<th className="cursor-pointer" onClick={() => handleSort('provider')}>
Провайдер {sortField === 'provider' && (sortOrder === 'asc' ? '▲' : '▼')}
</th>
<th className="cursor-pointer" onClick={() => handleSort('tunnel')}>
Туннель {sortField === 'tunnel' && (sortOrder === 'asc' ? '▲' : '▼')}
</th>
<th className="text-end" style={{ width: 140 }}>Действия</th>
</tr>
</thead>
<tbody>
{paginatedServers.map((server) => (
<tr key={server.id || server.ip}>
<td>
<button className="btn btn-ghost-secondary btn-icon btn-sm p-0" onClick={() => toggleServerSelection(server)}>
{selectedServers.has(server.ip) ? (
<IconSquareCheck size={18} className="text-primary" />
) : (
<IconSquare size={18} />
)}
</button>
</td>
<td><code className="fw-bold">{server.ip}</code></td>
<td>
<span className={`badge ${server.type === 'exit' ? 'bg-red-lt text-danger' : 'bg-blue-lt text-blue'}`}>
{server.type === 'exit' ? 'Exit' : 'Jumphost'}
</span>
</td>
<td>{server.dns}</td>
<td>
<span className="badge bg-secondary-lt">
{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-end">
<div className="btn-list gap-1 mb-0 justify-content-end">
<button className="btn btn-outline-secondary btn-sm" title="Редактировать" onClick={() => handleEdit(server)}>
<IconEdit size={14} className="me-1" />
Изменить
</button>
<button className="btn btn-outline-danger btn-icon btn-sm" title="Удалить" onClick={() => confirmDelete(server)}>
<IconTrash size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="card mt-3">
<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>
{[...Array(Math.min(5, totalPages))].map((_, i) => {
const pageNum = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i;
if (pageNum > totalPages) return null;
return (
<li key={pageNum} className={`page-item ${currentPage === pageNum ? 'active' : ''}`}>
<button className="page-link" onClick={() => setCurrentPage(pageNum)}>{pageNum}</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>
)}
</div>
</div>
)}
{/* Connections Tab */}
{activeTab === 'connections' && (
<div>
{/* Graph */}
<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">
{['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>
<select className="form-select form-select-sm" style={{ width: 'auto' }} 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>
<select className="form-select form-select-sm" style={{ width: 'auto' }} 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>
<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 || 'GRE',
}));
setGraphConnectionModalOpen(true);
}}
/>
<div className="mt-3 d-flex 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'}}></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'}}></span><span className="text-muted small">OpenVPN</span></div>
</div>
</div>
</div>
{/* Add connection */}
<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-3">
<div className="col-md-6">
<label className="form-label">Сервер A</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-6">
<label className="form-label">Сервер B</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-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" type="button" onClick={handleAddConnection}>
<IconPlus size={16} className="me-1" /> Добавить связь
</button>
</div>
</div>
</div>
</div>
{/* Connections list */}
{connections.length > 0 && (
<div className="card w-100">
<div className="card-header">
<h3 className="card-title mb-0">Существующие связи ({connections.length})</h3>
</div>
<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((conn, index) => {
const serverA = servers.find(s => s.ip === conn.from);
const serverB = servers.find(s => s.ip === conn.to);
return (
<tr key={index}>
<td>
<div className="fw-bold">{conn.from}</div>
<div className="text-muted small">{serverA?.dns || '—'}</div>
</td>
<td>
<div className="fw-bold">{conn.to}</div>
<div className="text-muted small">{serverB?.dns || '—'}</div>
</td>
<td><span className="badge bg-green-lt text-green">{conn.tunnelType}</span></td>
<td><code>{conn.ipA} {conn.ipB}</code></td>
<td className="text-end">
<button className="btn btn-ghost-danger btn-icon btn-sm" onClick={() => handleDeleteConnection(index)}>
<IconTrash size={16} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{/* Bulk Actions Bar */}
<ServerBulkActions
selectedCount={selectedServers.size}
onClearSelection={clearSelection}
onBulkDelete={handleBulkDelete}
onBulkChangeTunnel={handleBulkChangeTunnel}
onBulkExport={handleBulkExport}
onBulkCopyLinks={handleBulkCopyLinks}
/>
{/* Modals */}
<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}
/>
{/* Единое модальное окно для редактирования */}
<ServerModal
show={editModalOpen}
mode="edit"
server={editModalServer}
onSave={handleEditModalSave}
onClose={handleEditModalClose}
error={modalError}
/>
<LinkGeneratorModal
show={linkGeneratorOpen}
server={linkGeneratorServer}
urlSettings={urlSettings}
onUrlSettingsChange={setUrlSettings}
onGenerateUrl={generateServerUrl}
onCopyToClipboard={copyToClipboard}
onClose={handleLinkGeneratorClose}
/>
{/* Единое модальное окно для добавления */}
<ServerModal
show={addServerModalOpen}
mode="add"
onSave={handleAddServerFromModal}
onClose={handleCloseAddServerModal}
error={modalError}
/>
{/* Graph Connection Modal */}
<FormModal
show={graphConnectionModalOpen}
onClose={() => {
setGraphConnectionModalOpen(false);
setNewConnection({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
}}
onSubmit={handleConfirmGraphConnection}
title="Создать связь"
submitLabel="Создать"
submitIcon={IconPlus}
size="md"
>
<div className="mb-3">
<div className="d-flex 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>
</FormModal>
<ConfirmDialog
open={confirmState.open}
title="Подтверждение"
message={confirmState.text}
onCancel={() => setConfirmState({ open: false, text: '', onConfirm: null })}
onConfirm={() => confirmState.onConfirm?.()}
/>
</div>
);
}
export default ServerManager;