feat: Integrate Pagination component into ASNsNewManager, DomainsNewManager, IPRangesManager, and FilterManager for improved navigation and user experience across multiple pages of data.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m46s
This commit is contained in:
@@ -40,6 +40,7 @@ import AccordionCard from './components/AccordionCard.jsx';
|
|||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Pagination from './components/Pagination.jsx';
|
||||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -900,58 +901,13 @@ function ASNsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
{totalPages > 1 && (
|
<Pagination
|
||||||
<div className="card-footer">
|
currentPage={currentPage}
|
||||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
totalPages={totalPages}
|
||||||
<div className="text-muted small">
|
totalItems={totalItems || filtered.length}
|
||||||
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
|
pageSize={pageSize}
|
||||||
</div>
|
onPageChange={setCurrentPage}
|
||||||
<ul className="pagination m-0">
|
/>
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} aria-label="Первая страница">
|
|
||||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} aria-label="Предыдущая страница">
|
|
||||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
<span className="d-none d-sm-inline ms-1">Назад</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{(() => {
|
|
||||||
const pages = [];
|
|
||||||
let start = Math.max(1, currentPage - 2);
|
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
|
||||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
||||||
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
|
||||||
return pages.map((p) => (
|
|
||||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
||||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
||||||
) : (
|
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(p)} aria-label={`Страница ${p}`} aria-current={currentPage === p ? 'page' : undefined}>{p}</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} aria-label="Следующая страница">
|
|
||||||
<span className="d-none d-sm-inline me-1">Вперед</span>
|
|
||||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} aria-label="Последняя страница">
|
|
||||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<S3MetaBar keys={["asns"]} />
|
<S3MetaBar keys={["asns"]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import SavedFilters from './components/SavedFilters.jsx';
|
|||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Pagination from './components/Pagination.jsx';
|
||||||
import { useToast } from './components/ToastContainer.jsx';
|
import { useToast } from './components/ToastContainer.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -954,87 +955,13 @@ function DomainsNewManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
{totalPages > 1 && (
|
<Pagination
|
||||||
<div className="card-footer">
|
currentPage={currentPage}
|
||||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
totalPages={totalPages}
|
||||||
<div className="text-muted small">
|
totalItems={totalItems || filtered.length}
|
||||||
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
|
pageSize={pageSize}
|
||||||
</div>
|
onPageChange={setCurrentPage}
|
||||||
<ul className="pagination m-0">
|
/>
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button
|
|
||||||
className="page-link"
|
|
||||||
onClick={() => setCurrentPage(1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
aria-label="Первая страница"
|
|
||||||
>
|
|
||||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button
|
|
||||||
className="page-link"
|
|
||||||
onClick={() => setCurrentPage(currentPage - 1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
aria-label="Предыдущая страница"
|
|
||||||
>
|
|
||||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
<span className="d-none d-sm-inline ms-1">Назад</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{(() => {
|
|
||||||
const pages = [];
|
|
||||||
let start = Math.max(1, currentPage - 2);
|
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
|
||||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
||||||
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
|
||||||
return pages.map((p) => (
|
|
||||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
||||||
<li key={p} className="page-item disabled">
|
|
||||||
<span className="page-link">…</span>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
||||||
<button
|
|
||||||
className="page-link"
|
|
||||||
onClick={() => setCurrentPage(p)}
|
|
||||||
aria-label={`Страница ${p}`}
|
|
||||||
aria-current={currentPage === p ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button
|
|
||||||
className="page-link"
|
|
||||||
onClick={() => setCurrentPage(currentPage + 1)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
aria-label="Следующая страница"
|
|
||||||
>
|
|
||||||
<span className="d-none d-sm-inline me-1">Вперед</span>
|
|
||||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button
|
|
||||||
className="page-link"
|
|
||||||
onClick={() => setCurrentPage(totalPages)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
aria-label="Последняя страница"
|
|
||||||
>
|
|
||||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<S3MetaBar keys={["domainsNew"]} />
|
<S3MetaBar keys={["domainsNew"]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,44 +32,10 @@ import {
|
|||||||
IconWorld,
|
IconWorld,
|
||||||
IconFileText
|
IconFileText
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
|
import { normalizeGateways, countryToFlag } from './utils/serverUtils.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
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
|
// Компонент автокомплита для выбора Community
|
||||||
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
|
function CommunityAutocomplete({ label, value, onChange, communities = [], required = false, placeholder = 'Введите или выберите community...', baseAS = '65001' }) {
|
||||||
const [inputValue, setInputValue] = useState(value || '');
|
const [inputValue, setInputValue] = useState(value || '');
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import AccordionCard from './components/AccordionCard.jsx';
|
|||||||
import SavedFilters from './components/SavedFilters.jsx';
|
import SavedFilters from './components/SavedFilters.jsx';
|
||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -1132,58 +1133,13 @@ function IPRangesManager() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Пагинация */}
|
{/* Пагинация */}
|
||||||
{totalPages > 1 && (
|
<Pagination
|
||||||
<div className="card-footer">
|
currentPage={currentPage}
|
||||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
totalPages={totalPages}
|
||||||
<div className="text-muted small">
|
totalItems={totalItems || filtered.length}
|
||||||
Показано <strong>{((currentPage - 1) * pageSize) + 1}</strong> - <strong>{((currentPage - 1) * pageSize) + paginatedItems.length}</strong> из <strong>{totalItems || filtered.length}</strong>
|
pageSize={pageSize}
|
||||||
</div>
|
onPageChange={setCurrentPage}
|
||||||
<ul className="pagination m-0">
|
/>
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1} aria-label="Первая страница">
|
|
||||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} aria-label="Предыдущая страница">
|
|
||||||
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
<span className="d-none d-sm-inline ms-1">Назад</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{(() => {
|
|
||||||
const pages = [];
|
|
||||||
let start = Math.max(1, currentPage - 2);
|
|
||||||
let end = Math.min(totalPages, currentPage + 2);
|
|
||||||
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
||||||
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
||||||
if (start > 1) pages.push('start-ellipsis');
|
|
||||||
for (let p = start; p <= end; p++) pages.push(p);
|
|
||||||
if (end < totalPages) pages.push('end-ellipsis');
|
|
||||||
return pages.map((p) => (
|
|
||||||
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
||||||
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
||||||
) : (
|
|
||||||
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(p)} aria-label={`Страница ${p}`} aria-current={currentPage === p ? 'page' : undefined}>{p}</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
));
|
|
||||||
})()}
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} aria-label="Следующая страница">
|
|
||||||
<span className="d-none d-sm-inline me-1">Вперед</span>
|
|
||||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
||||||
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} aria-label="Последняя страница">
|
|
||||||
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<S3MetaBar keys={["ipRanges"]} />
|
<S3MetaBar keys={["ipRanges"]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,49 +25,14 @@ import {
|
|||||||
IconWorld
|
IconWorld
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import GraphView from './GraphView';
|
import GraphView from './GraphView';
|
||||||
|
import {
|
||||||
|
makeGateway,
|
||||||
|
normalizeGateways,
|
||||||
|
needsGateways as NEEDS_GATEWAYS,
|
||||||
|
SERVER_TYPE_OPTIONS
|
||||||
|
} from './utils/serverUtils.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
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() {
|
function ServerManager() {
|
||||||
const [servers, setServers] = useState([]);
|
const [servers, setServers] = useState([]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|
||||||
Reference in New Issue
Block a user