diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx
index 6da9139..41cc460 100644
--- a/frontend/src/ASNsNewManager.jsx
+++ b/frontend/src/ASNsNewManager.jsx
@@ -40,6 +40,7 @@ import AccordionCard from './components/AccordionCard.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import Tooltip from './components/Tooltip.jsx';
+import Pagination from './components/Pagination.jsx';
import { getAsnName, getAsnNameSync } from './lib/asn.js';
const API_URL = '/api';
@@ -900,58 +901,13 @@ function ASNsNewManager() {
)}
{/* Пагинация */}
- {totalPages > 1 && (
-
-
-
- Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || filtered.length}
-
-
- -
-
-
- -
-
-
- {(() => {
- const pages = [];
- let start = Math.max(1, currentPage - 2);
- let end = Math.min(totalPages, currentPage + 2);
- if (currentPage <= 3) end = Math.min(totalPages, 5);
- if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
- if (start > 1) pages.push('start-ellipsis');
- for (let p = start; p <= end; p++) pages.push(p);
- if (end < totalPages) pages.push('end-ellipsis');
- return pages.map((p) => (
- p === 'start-ellipsis' || p === 'end-ellipsis' ? (
- - …
- ) : (
- -
-
-
- )
- ));
- })()}
- -
-
-
- -
-
-
-
-
-
- )}
+
diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx
index 1316ca2..7f7872e 100644
--- a/frontend/src/DomainsNewManager.jsx
+++ b/frontend/src/DomainsNewManager.jsx
@@ -46,6 +46,7 @@ import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import FormField from './components/FormField.jsx';
import Tooltip from './components/Tooltip.jsx';
+import Pagination from './components/Pagination.jsx';
import { useToast } from './components/ToastContainer.jsx';
const API_URL = '/api';
@@ -954,87 +955,13 @@ function DomainsNewManager() {
)}
{/* Пагинация */}
- {totalPages > 1 && (
-
-
-
- Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || filtered.length}
-
-
- -
-
-
- -
-
-
- {(() => {
- const pages = [];
- let start = Math.max(1, currentPage - 2);
- let end = Math.min(totalPages, currentPage + 2);
- if (currentPage <= 3) end = Math.min(totalPages, 5);
- if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
- if (start > 1) pages.push('start-ellipsis');
- for (let p = start; p <= end; p++) pages.push(p);
- if (end < totalPages) pages.push('end-ellipsis');
- return pages.map((p) => (
- p === 'start-ellipsis' || p === 'end-ellipsis' ? (
- -
- …
-
- ) : (
- -
-
-
- )
- ));
- })()}
- -
-
-
- -
-
-
-
-
-
- )}
+
diff --git a/frontend/src/FilterManager.jsx b/frontend/src/FilterManager.jsx
index 75a33cc..9c93088 100644
--- a/frontend/src/FilterManager.jsx
+++ b/frontend/src/FilterManager.jsx
@@ -32,44 +32,10 @@ import {
IconWorld,
IconFileText
} from '@tabler/icons-react';
+import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
const API_URL = '/api';
-// Нормализация шлюзов (аналог из ServerManager)
-const normalizeGateways = (gateways = [], fallbackName = '') => {
- if (!Array.isArray(gateways)) return [];
- const cleaned = gateways
- .map((g, idx) => ({
- id: g?.id || `${Date.now()}-${idx}`,
- name: String(g?.name || '').trim(),
- ip: String(g?.ip || '').trim(),
- comment: String(g?.comment || '').trim(),
- primary: Boolean(g?.primary),
- }))
- .filter(g => g.name || g.ip || g.comment);
- if (cleaned.length === 0) {
- return [{ id: `${Date.now()}-primary`, name: fallbackName || 'gateway', ip: '', comment: '', primary: true }];
- }
- let hasPrimary = false;
- cleaned.forEach((g) => {
- if (g.primary && !hasPrimary) {
- hasPrimary = true;
- } else {
- g.primary = false;
- }
- });
- if (!hasPrimary) cleaned[0].primary = true;
- return cleaned;
-};
-
-const countryToFlag = (code) => {
- if (!code || typeof code !== 'string') return '';
- const cc = code.trim().toUpperCase();
- if (cc.length !== 2) return '';
- const codePoints = [...cc].map((c) => 0x1f1e6 - 65 + c.charCodeAt(0));
- return String.fromCodePoint(...codePoints);
-};
-
// Компонент автокомплита для выбора Community
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
const [inputValue, setInputValue] = useState(value || '');
diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx
index 703aeab..294b65e 100644
--- a/frontend/src/IPRangesManager.jsx
+++ b/frontend/src/IPRangesManager.jsx
@@ -42,6 +42,7 @@ import AccordionCard from './components/AccordionCard.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import Tooltip from './components/Tooltip.jsx';
+import Pagination from './components/Pagination.jsx';
const API_URL = '/api';
@@ -1132,58 +1133,13 @@ function IPRangesManager() {
)}
{/* Пагинация */}
- {totalPages > 1 && (
-
-
-
- Показано {((currentPage - 1) * pageSize) + 1} - {((currentPage - 1) * pageSize) + paginatedItems.length} из {totalItems || filtered.length}
-
-
- -
-
-
- -
-
-
- {(() => {
- const pages = [];
- let start = Math.max(1, currentPage - 2);
- let end = Math.min(totalPages, currentPage + 2);
- if (currentPage <= 3) end = Math.min(totalPages, 5);
- if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
- if (start > 1) pages.push('start-ellipsis');
- for (let p = start; p <= end; p++) pages.push(p);
- if (end < totalPages) pages.push('end-ellipsis');
- return pages.map((p) => (
- p === 'start-ellipsis' || p === 'end-ellipsis' ? (
- - …
- ) : (
- -
-
-
- )
- ));
- })()}
- -
-
-
- -
-
-
-
-
-
- )}
+
diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx
index 6b8bae0..c576e4a 100644
--- a/frontend/src/ServerManager.jsx
+++ b/frontend/src/ServerManager.jsx
@@ -25,49 +25,14 @@ import {
IconWorld
} from '@tabler/icons-react';
import GraphView from './GraphView';
+import {
+ makeGateway,
+ normalizeGateways,
+ needsGateways as NEEDS_GATEWAYS,
+ SERVER_TYPE_OPTIONS
+} from './utils/serverUtils.js';
const API_URL = '/api';
-const SERVER_TYPE_OPTIONS = [
- { value: 'jumphost', label: 'Jumphost' },
- { value: 'exit', label: 'Выходная нода' },
-];
-const NEEDS_GATEWAYS = (type) => ['jumphost', 'exit'].includes(String(type || '').toLowerCase());
-
-const makeGateway = (over = {}) => ({
- id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
- name: '',
- ip: '',
- comment: '',
- primary: false,
- ...over,
-});
-
-const normalizeGateways = (gateways = [], fallbackName = '') => {
- if (!Array.isArray(gateways)) return [];
- const cleaned = gateways
- .map((g, idx) => ({
- id: g?.id || `${Date.now()}-${idx}`,
- name: String(g?.name || '').trim(),
- ip: String(g?.ip || '').trim(),
- comment: String(g?.comment || '').trim(),
- primary: Boolean(g?.primary),
- }))
- .filter(g => g.name || g.ip || g.comment);
- if (cleaned.length === 0) {
- return [{ id: `${Date.now()}-primary`, name: fallbackName || 'gateway', ip: '', comment: '', primary: true }];
- }
- // ensure single primary
- let hasPrimary = false;
- cleaned.forEach((g, i) => {
- if (g.primary && !hasPrimary) {
- hasPrimary = true;
- } else {
- g.primary = false;
- }
- });
- if (!hasPrimary) cleaned[0].primary = true;
- return cleaned;
-};
function ServerManager() {
const [servers, setServers] = useState([]);
diff --git a/frontend/src/utils/serverUtils.js b/frontend/src/utils/serverUtils.js
new file mode 100644
index 0000000..25de61f
--- /dev/null
+++ b/frontend/src/utils/serverUtils.js
@@ -0,0 +1,131 @@
+/**
+ * Общие утилиты для работы с серверами и шлюзами
+ * Используется в ServerManager, FilterManager и других компонентах
+ */
+
+/**
+ * Создаёт объект шлюза с дефолтными значениями
+ * @param {Object} overrides - Переопределяемые поля
+ * @returns {Object} Объект шлюза
+ */
+export const makeGateway = (overrides = {}) => ({
+ id: `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
+ name: '',
+ ip: '',
+ comment: '',
+ primary: false,
+ ...overrides,
+});
+
+/**
+ * Нормализует массив шлюзов: очищает, удаляет пустые, гарантирует один primary
+ * @param {Array} gateways - Массив шлюзов
+ * @param {string} fallbackName - Имя по умолчанию если массив пуст
+ * @returns {Array} Нормализованный массив шлюзов
+ */
+export const normalizeGateways = (gateways = [], fallbackName = '') => {
+ if (!Array.isArray(gateways)) return [];
+
+ const cleaned = gateways
+ .map((g, idx) => ({
+ id: g?.id || `${Date.now()}-${idx}`,
+ name: String(g?.name || '').trim(),
+ ip: String(g?.ip || '').trim(),
+ comment: String(g?.comment || '').trim(),
+ primary: Boolean(g?.primary),
+ }))
+ .filter(g => g.name || g.ip || g.comment);
+
+ if (cleaned.length === 0) {
+ return [{
+ id: `${Date.now()}-primary`,
+ name: fallbackName || 'gateway',
+ ip: '',
+ comment: '',
+ primary: true
+ }];
+ }
+
+ // Гарантируем только один primary
+ let hasPrimary = false;
+ cleaned.forEach((g) => {
+ if (g.primary && !hasPrimary) {
+ hasPrimary = true;
+ } else {
+ g.primary = false;
+ }
+ });
+
+ if (!hasPrimary) {
+ cleaned[0].primary = true;
+ }
+
+ return cleaned;
+};
+
+/**
+ * Конвертирует код страны в эмодзи флага
+ * @param {string} code - Двухбуквенный код страны (ISO 3166-1 alpha-2)
+ * @returns {string} Эмодзи флага или пустая строка
+ */
+export const countryToFlag = (code) => {
+ if (!code || typeof code !== 'string') return '';
+ const cc = code.trim().toUpperCase();
+ if (cc.length !== 2) return '';
+ const codePoints = [...cc].map((c) => 0x1f1e6 - 65 + c.charCodeAt(0));
+ return String.fromCodePoint(...codePoints);
+};
+
+/**
+ * Проверяет, требует ли тип сервера наличие шлюзов
+ * @param {string} type - Тип сервера
+ * @returns {boolean}
+ */
+export const needsGateways = (type) => {
+ return ['jumphost', 'exit'].includes(String(type || '').toLowerCase());
+};
+
+/**
+ * Константы типов серверов
+ */
+export const SERVER_TYPE_OPTIONS = [
+ { value: 'jumphost', label: 'Jumphost' },
+ { value: 'exit', label: 'Выходная нода' },
+];
+
+/**
+ * Типы туннелей
+ */
+export const TUNNEL_TYPES = ['GRE', 'IPSec', 'WireGuard', 'VXLAN', 'OpenVPN'];
+
+/**
+ * Генерирует уникальный ID
+ * @returns {string}
+ */
+export const generateId = () => {
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
+};
+
+/**
+ * Нормализует community value (добавляет ASN если отсутствует)
+ * @param {string} value - Значение community
+ * @param {string} defaultASN - ASN по умолчанию
+ * @returns {string}
+ */
+export const normalizeCommunityValue = (value, defaultASN = '65001') => {
+ const trimmed = String(value || '').trim();
+ if (!trimmed) return '';
+ return trimmed.includes(':') ? trimmed : `${defaultASN}:${trimmed}`;
+};
+
+/**
+ * Извлекает короткую часть community (после двоеточия)
+ * @param {string} value - Полное значение community (например "65000:120")
+ * @returns {string} Короткая часть (например "120")
+ */
+export const getShortCommunityValue = (value) => {
+ const str = String(value || '').trim();
+ if (!str.includes(':')) return str;
+ return str.split(':')[1] || str;
+};
+