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

1310 lines
53 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,
ServerGroupCard,
ServerBulkActions
} from './components/server/index.js';
import PageHeader from './components/PageHeader.jsx';
import PageHeaderActions from './components/PageHeaderActions.jsx';
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 [networkConfigGateways, setNetworkConfigGateways] = useState([]);
const [loading, setLoading] = useState(false);
// === UI State ===
const [activeTab, setActiveTab] = useState('servers'); // 'servers' | 'connections'
const [viewMode, setViewMode] = useState('cards'); // 'table' | 'cards'
// === Фильтры и поиск ===
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';
};
// === Загрузка данных ===
const fetchNetworkConfig = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const res = await api.get('/network-config', opts);
const config = res.data || {};
setNetworkConfigGateways(Array.isArray(config.gateways) ? config.gateways : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
console.warn('Network config not loaded:', e?.message || e);
setNetworkConfigGateways([]);
}
};
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
fetchServers(signal);
fetchConnections(signal);
fetchNetworkConfig(signal);
return () => controller.abort();
}, []);
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 (abortSignal) => {
setLoading(true);
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/servers`, opts);
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) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching servers:', error);
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
};
const fetchConnections = async (abortSignal) => {
const opts = abortSignal ? { signal: abortSignal } : {};
try {
const response = await api.get(`/server-connections`, opts);
if (Array.isArray(response.data)) {
setConnections(response.data);
} else {
setConnections([]);
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching server connections:', error);
}
};
// Шлюзы для карточки: из /network-config по serverId, иначе из server.gateways
const getGatewaysForServer = useMemo(() => {
const gatewaysByServerId = {};
(networkConfigGateways || []).forEach((gw) => {
if (!gw.serverId) return;
if (!gatewaysByServerId[gw.serverId]) gatewaysByServerId[gw.serverId] = [];
gatewaysByServerId[gw.serverId].push(gw);
});
return (server) => {
const ids = [server?.id, server?.ip, server?.dns].filter(Boolean);
for (const id of ids) {
const gws = gatewaysByServerId[id];
if (gws?.length > 0) {
return gws.map((gw, idx) => ({
id: gw.id,
name: gw.description || gw.id || gw.ip,
ip: gw.ip,
comment: gw.description,
primary: idx === 0,
}));
}
}
return server?.gateways || [];
};
}, [networkConfigGateways]);
// === Фильтрация и сортировка ===
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]);
// Элементы для отображения: серверы с одинаковым groupKey (и type home) объединяются в одну карточку
const displayItems = useMemo(() => {
const keyToServers = {};
filteredServers.forEach((s) => {
const k = (s.groupKey && String(s.groupKey).trim()) || null;
if (k) {
if (!keyToServers[k]) keyToServers[k] = [];
keyToServers[k].push(s);
}
});
const seenKeys = new Set();
const items = [];
for (const s of filteredServers) {
const k = (s.groupKey && String(s.groupKey).trim()) || null;
if (!k) {
items.push({ type: 'server', server: s });
} else {
if (!seenKeys.has(k)) {
seenKeys.add(k);
const group = keyToServers[k];
items.push(group.length >= 2 ? { type: 'group', groupKey: k, servers: group } : { type: 'server', server: group[0] });
}
}
}
return items;
}, [filteredServers]);
// === Пагинация ===
const totalPagesCards = Math.ceil(displayItems.length / pageSize);
const totalPagesTable = Math.ceil(filteredServers.length / pageSize);
const totalPages = viewMode === 'cards' ? totalPagesCards : totalPagesTable;
const paginatedDisplayItems = displayItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const paginatedServers = filteredServers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const totalItemsCount = viewMode === 'cards' ? displayItems.length : filteredServers.length;
useEffect(() => {
if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1);
}, [viewMode, totalPages]);
// === 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 toggleGroupSelection = (servers, selectAll) => {
setSelectedServers((prev) => {
const next = new Set(prev);
servers.forEach((s) => {
if (selectAll) next.add(s.ip);
else next.delete(s.ip);
});
return next;
});
};
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);
}
};
const handleRefreshAll = () => {
fetchServers();
fetchConnections();
fetchNetworkConfig();
};
// === 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">
<PageHeader
pretitle="Управление"
title="Серверы"
icon={<IconServer size={24} />}
meta={
<span className="text-muted">
Всего серверов: <strong>{servers.length}</strong>
</span>
}
actions={
<>
<PageHeaderActions
loading={loading}
onRefresh={handleRefreshAll}
disableRefresh={loading}
onExport={exportAllLinks}
disableExport={servers.length === 0}
onSave={handleSaveChanges}
disableSave={loading}
/>
<div className="mt-3 d-flex flex-wrap align-items-center justify-content-end gap-2">
<div className="btn-group" role="group" aria-label="Переключение режимов">
<button
className={`btn ${activeTab === 'servers' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('servers')}
type="button"
>
<IconServer size={16} className="me-1" />
Серверы
</button>
<button
className={`btn ${activeTab === 'connections' ? 'btn-primary' : 'btn-outline-primary'}`}
onClick={() => setActiveTab('connections')}
type="button"
>
<IconGitBranch size={16} className="me-1" />
Связи
</button>
</div>
<button
type="button"
className="btn btn-primary"
onClick={handleOpenAddServerModal}
>
<IconPlus size={16} className="me-1" /> Добавить
</button>
</div>
</>
}
/>
{/* Offcanvas: фильтры (только для вкладки «Серверы») */}
{activeTab === 'servers' && (
<>
<div
className="offcanvas offcanvas-start"
id="server-filters-offcanvas"
tabIndex={-1}
aria-labelledby="server-filters-offcanvas-label"
style={{ width: '340px', maxWidth: '90vw' }}
>
<div className="offcanvas-header border-bottom">
<h5 className="offcanvas-title d-flex align-items-center" id="server-filters-offcanvas-label">
<IconFilter size={18} className="text-primary me-2" />
Фильтры
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
<span className="badge bg-primary-lt text-primary ms-2">
{Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)}
</span>
)}
</h5>
<div className="d-flex align-items-center gap-1">
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
<button
type="button"
className="btn btn-ghost-secondary btn-sm"
onClick={handleResetFilters}
title="Сбросить фильтры"
>
Сбросить
</button>
)}
<button
type="button"
className="btn-close"
data-bs-dismiss="offcanvas"
aria-label="Закрыть"
/>
</div>
</div>
<div className="offcanvas-body px-3 py-0 d-flex flex-column overflow-hidden">
<ServerSidebar
servers={servers}
filters={filters}
onFiltersChange={(newFilters) => { setFilters(newFilters); setCurrentPage(1); }}
searchTerm={searchTerm}
onSearch={(term) => { setSearchTerm(term); setCurrentPage(1); }}
onReset={handleResetFilters}
embedded
/>
</div>
</div>
</>
)}
{/* Main content */}
{activeTab === 'servers' && (
<div>
<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">
{/* Открыть фильтры (offcanvas) */}
<button
type="button"
className="btn btn-outline-secondary btn-sm d-flex align-items-center"
data-bs-toggle="offcanvas"
data-bs-target="#server-filters-offcanvas"
aria-controls="server-filters-offcanvas"
title="Фильтры"
>
<IconFilter size={16} className="me-1" />
Фильтры
{(Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)) > 0 && (
<span className="badge bg-primary-lt text-primary ms-1">
{Object.values(filters).filter(Boolean).length + (searchTerm ? 1 : 0)}
</span>
)}
</button>
{/* 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: одиночные серверы и группы (groupKey) */
<div>
{paginatedDisplayItems.map((item) =>
item.type === 'group' ? (
<ServerGroupCard
key={`group-${item.groupKey}`}
groupKey={item.groupKey}
servers={item.servers.map((s) => ({ ...s, gateways: getGatewaysForServer(s) }))}
selectedSet={selectedServers}
onSelect={toggleServerSelection}
onSelectGroup={toggleGroupSelection}
onEdit={handleEdit}
onDelete={confirmDelete}
/>
) : (
<ServerCard
key={item.server.id || item.server.ip}
server={{ ...item.server, gateways: getGatewaysForServer(item.server) }}
selected={selectedServers.has(item.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, totalItemsCount)} из {totalItemsCount}
</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;