Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
1310 lines
53 KiB
React
1310 lines
53 KiB
React
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;
|