diff --git a/backend/routes/communitiesRoutes.js b/backend/routes/communitiesRoutes.js
index 13799a7..2c13c56 100644
--- a/backend/routes/communitiesRoutes.js
+++ b/backend/routes/communitiesRoutes.js
@@ -3,55 +3,32 @@
*/
const { s3, BUCKET_NAME, writeS3JsonObject, invalidateCacheForKey } = require('../services/s3Service');
-const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
-const { GetObjectCommand, HeadObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
+const { sendError, sendOk } = require('../middleware/errorHandler');
+const { GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const validators = require('../lib/validators');
+const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
const S3_KEY = 'bgp_data/communities.json';
// GET /api/communities
async function getCommunities(req, res) {
- try {
- const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY })).catch(() => null);
- const etag = head?.ETag || null;
- if (etag) res.set('ETag', String(etag));
- if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
- if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
- if (checkIfNoneMatch(req, res, etag)) return;
-
- const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: S3_KEY }));
- const fileContent = await streamToString(data.Body);
- let communities = [];
-
- try {
- const parsed = JSON.parse(fileContent);
- communities = Array.isArray(parsed) ? parsed : [];
- } catch (parseError) {
- console.error('Error parsing communities.json:', parseError);
- communities = [];
- }
-
- // Нормализация
- communities = communities
- .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
- .map((c) => ({
- value: String(c.value).trim(),
- name: c.name ? String(c.name) : '',
- description: c.description ? String(c.description) : '',
- tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
- gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
- color: c.color ? String(c.color) : ''
- }));
-
- res.json(communities);
- } catch (error) {
- if (error.code === 'NoSuchKey') {
- return res.json([]);
- }
- console.error('Error reading communities from S3:', error);
- return sendError(res, 500, 'Error reading communities from S3', 'E_S3');
- }
+ await getS3JsonWithHeaders(S3_KEY, req, res, {
+ transform: (communities) => {
+ // Нормализация
+ return communities
+ .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0)
+ .map((c) => ({
+ value: String(c.value).trim(),
+ name: c.name ? String(c.name) : '',
+ description: c.description ? String(c.description) : '',
+ tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
+ gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '',
+ color: c.color ? String(c.color) : ''
+ }));
+ },
+ defaultValue: []
+ });
}
// POST /api/communities
diff --git a/backend/routes/filtersRoutes.js b/backend/routes/filtersRoutes.js
index 305531b..5fa74a0 100644
--- a/backend/routes/filtersRoutes.js
+++ b/backend/routes/filtersRoutes.js
@@ -3,61 +3,25 @@
*/
const { s3, BUCKET_NAME, writeS3TextObject, headMeta } = require('../services/s3Service');
-const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
-const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
+const { sendError, sendOk } = require('../middleware/errorHandler');
+const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
-const { buildNestedGatewayBlocks } = require('../utils/helpers');
+const { generateMikrotikConfig } = require('../utils/mikrotikConfig');
+const { getS3JsonWithHeaders } = require('../utils/s3Helpers');
// GET /api/filters/generate-config
async function generateConfig(req, res) {
try {
- const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' })).catch(() => null);
- const etag = head?.ETag || null;
- if (etag) res.set('ETag', String(etag));
- if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
- if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
- if (checkIfNoneMatch(req, res, etag)) return;
-
- const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: 'filters.json' }));
- const fileContent = await streamToString(data.Body);
- let filters = [];
-
- try {
- filters = JSON.parse(fileContent);
- if (!Array.isArray(filters)) filters = [];
- } catch (parseError) {
- console.error('Error parsing filters.json:', parseError);
- filters = [];
- }
-
- if (filters.length === 0) {
- return res.json({ config: '// No filters to generate configuration' });
- }
-
- // Group filters by gateway
- const gatewayGroups = {};
- filters.forEach(filter => {
- if (!gatewayGroups[filter.gateway]) {
- gatewayGroups[filter.gateway] = [];
- }
- gatewayGroups[filter.gateway].push(filter.community);
+ await getS3JsonWithHeaders('filters.json', req, res, {
+ transform: (filters) => {
+ const config = generateMikrotikConfig(filters);
+ return { config };
+ },
+ defaultValue: [],
});
-
- let config = '// Frouting filter configuration for MikroTik 7.14+\n';
- config += '// Generated automatically\n';
- config += `// Date: ${new Date().toISOString()}\n\n`;
- config += '/routing filter bgp-in-tmp {\n';
- config += buildNestedGatewayBlocks(gatewayGroups, 4);
- config += '}\n';
-
- res.json({ config });
} catch (error) {
- if (error.code === 'NoSuchKey') {
- res.json({ config: '// filters.json file not found' });
- } else {
- console.error(error);
- return sendError(res, 500, 'Error generating configuration', 'E_S3');
- }
+ console.error(error);
+ return sendError(res, 500, 'Error generating configuration', 'E_S3');
}
}
@@ -80,21 +44,7 @@ async function exportConfig(req, res) {
return res.json({ success: false, message: 'Нет фильтров для экспорта' });
}
- const gatewayGroups = {};
- filters.forEach(filter => {
- if (!gatewayGroups[filter.gateway]) {
- gatewayGroups[filter.gateway] = [];
- }
- gatewayGroups[filter.gateway].push(filter.community);
- });
-
- let config = '// Frouting filter configuration for MikroTik 7.14+\n';
- config += '// Generated automatically\n';
- config += `// Date: ${new Date().toISOString()}\n\n`;
- config += '/routing filter bgp-in-tmp {\n';
- config += buildNestedGatewayBlocks(gatewayGroups, 4);
- config += '}\n';
-
+ const config = generateMikrotikConfig(filters);
await writeS3TextObject('mikrotik-frouting-config.txt', config);
res.json({ success: true, message: 'Конфигурация экспортирована в S3' });
} catch (error) {
@@ -106,26 +56,7 @@ async function exportConfig(req, res) {
// POST /api/server-filters/generate-config
async function generateServerFilterConfig(req, res) {
const { filters } = req.body;
-
- if (!Array.isArray(filters) || filters.length === 0) {
- return res.json({ config: '// No filters to generate configuration' });
- }
-
- const gatewayGroups = {};
- filters.forEach(filter => {
- if (!gatewayGroups[filter.gateway]) {
- gatewayGroups[filter.gateway] = [];
- }
- gatewayGroups[filter.gateway].push(filter.community);
- });
-
- let config = '// Frouting filter configuration for MikroTik 7.14+\n';
- config += '// Generated automatically\n';
- config += `// Date: ${new Date().toISOString()}\n\n`;
- config += '/routing filter bgp-in-tmp {\n';
- config += buildNestedGatewayBlocks(gatewayGroups, 4);
- config += '}\n';
-
+ const config = generateMikrotikConfig(filters);
res.json({ config });
}
diff --git a/backend/routes/serverConfigsRoutes.js b/backend/routes/serverConfigsRoutes.js
index 9dc33a9..0a173c4 100644
--- a/backend/routes/serverConfigsRoutes.js
+++ b/backend/routes/serverConfigsRoutes.js
@@ -3,10 +3,11 @@
*/
const { s3, BUCKET_NAME, writeS3TextObject, deleteS3Object, headMeta } = require('../services/s3Service');
-const { sendError, sendOk, checkIfNoneMatch } = require('../middleware/errorHandler');
-const { GetObjectCommand, HeadObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
+const { sendError, sendOk } = require('../middleware/errorHandler');
+const { GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { streamToString } = require('../services/s3Service');
const { createJsonDataRoutes } = require('./jsonDataRoutes');
+const { getS3TextWithHeaders, getS3JsonWithHeaders } = require('../utils/s3Helpers');
// GET /api/server-configs (список серверов)
const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (server, i) => {
@@ -19,33 +20,15 @@ const serverConfigsListRoutes = createJsonDataRoutes('server-configs.json', (ser
// GET /api/server-configs/:serverId (конкретная конфигурация)
async function getServerConfig(req, res) {
const { serverId } = req.params;
-
- try {
- const head = await s3.send(new HeadObjectCommand({
- Bucket: BUCKET_NAME,
- Key: `filter-manager/config-${serverId}.txt`
- })).catch(() => null);
-
- const etag = head?.ETag || null;
- if (etag) res.set('ETag', String(etag));
- if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
- if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
- if (checkIfNoneMatch(req, res, etag)) return;
-
- const data = await s3.send(new GetObjectCommand({
- Bucket: BUCKET_NAME,
- Key: `filter-manager/config-${serverId}.txt`
- }));
- const config = await streamToString(data.Body);
- res.json({ config });
- } catch (error) {
- if (error.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
- res.json({ config: '// Конфигурация не найдена' });
- } else {
- console.error(error);
- return sendError(res, 500, 'Error reading server config from S3', 'E_S3');
+ await getS3TextWithHeaders(
+ `filter-manager/config-${serverId}.txt`,
+ req,
+ res,
+ {
+ transform: (text) => ({ config: text }),
+ defaultValue: '// Конфигурация не найдена'
}
- }
+ );
}
// POST /api/server-configs/:serverId (сохранить конфигурацию)
@@ -100,43 +83,12 @@ async function deleteServerComplete(req, res) {
// GET /api/server-filters/:serverId
async function getServerFilters(req, res) {
const { serverId } = req.params;
-
- try {
- const head = await s3.send(new HeadObjectCommand({
- Bucket: BUCKET_NAME,
- Key: `filter-manager/server-filters-${serverId}.json`
- })).catch(() => null);
-
- const etag = head?.ETag || null;
- if (etag) res.set('ETag', String(etag));
- if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
- if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
- if (checkIfNoneMatch(req, res, etag)) return;
-
- const data = await s3.send(new GetObjectCommand({
- Bucket: BUCKET_NAME,
- Key: `filter-manager/server-filters-${serverId}.json`
- }));
- const fileContent = await streamToString(data.Body);
- let filters = [];
-
- try {
- filters = JSON.parse(fileContent);
- if (!Array.isArray(filters)) filters = [];
- } catch (parseError) {
- console.error('Error parsing server filters:', parseError);
- filters = [];
- }
-
- res.json(filters);
- } catch (error) {
- if (error.code === 'NoSuchKey') {
- res.json([]);
- } else {
- console.error(error);
- return sendError(res, 500, 'Error reading server filters from S3', 'E_S3');
- }
- }
+ await getS3JsonWithHeaders(
+ `filter-manager/server-filters-${serverId}.json`,
+ req,
+ res,
+ { defaultValue: [] }
+ );
}
// POST /api/server-filters/:serverId
diff --git a/backend/utils/mikrotikConfig.js b/backend/utils/mikrotikConfig.js
new file mode 100644
index 0000000..38d1001
--- /dev/null
+++ b/backend/utils/mikrotikConfig.js
@@ -0,0 +1,40 @@
+/**
+ * Утилиты для генерации конфигурации MikroTik
+ * Устраняют дублирование кода генерации конфигурации
+ */
+
+const { buildNestedGatewayBlocks } = require('./helpers');
+
+/**
+ * Генерация конфигурации MikroTik из массива фильтров
+ * @param {Array} filters - Массив фильтров с полями {community, gateway}
+ * @returns {string} Сгенерированная конфигурация
+ */
+function generateMikrotikConfig(filters) {
+ if (!Array.isArray(filters) || filters.length === 0) {
+ return '// No filters to generate configuration';
+ }
+
+ // Группировка фильтров по gateway
+ const gatewayGroups = {};
+ filters.forEach(filter => {
+ if (!gatewayGroups[filter.gateway]) {
+ gatewayGroups[filter.gateway] = [];
+ }
+ gatewayGroups[filter.gateway].push(filter.community);
+ });
+
+ let config = '// Frouting filter configuration for MikroTik 7.14+\n';
+ config += '// Generated automatically\n';
+ config += `// Date: ${new Date().toISOString()}\n\n`;
+ config += '/routing filter bgp-in-tmp {\n';
+ config += buildNestedGatewayBlocks(gatewayGroups, 4);
+ config += '}\n';
+
+ return config;
+}
+
+module.exports = {
+ generateMikrotikConfig,
+};
+
diff --git a/backend/utils/s3Helpers.js b/backend/utils/s3Helpers.js
new file mode 100644
index 0000000..b4a89ef
--- /dev/null
+++ b/backend/utils/s3Helpers.js
@@ -0,0 +1,106 @@
+/**
+ * Универсальные хелперы для работы с S3
+ * Устраняют дублирование кода в routes
+ */
+
+const { s3, BUCKET_NAME } = require('../services/s3Service');
+const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
+const { streamToString } = require('../services/s3Service');
+const { sendError, checkIfNoneMatch } = require('../middleware/errorHandler');
+
+/**
+ * Универсальный GET для JSON объектов из S3 с заголовками
+ * @param {string} s3Key - Ключ S3
+ * @param {object} req - Express request
+ * @param {object} res - Express response
+ * @param {object} options - Опции
+ * @param {function} options.transform - Функция трансформации данных
+ * @param {*} options.defaultValue - Значение по умолчанию при отсутствии файла
+ */
+async function getS3JsonWithHeaders(s3Key, req, res, options = {}) {
+ const { transform = (data) => data, defaultValue = [] } = options;
+
+ try {
+ // Получаем HEAD для установки заголовков
+ const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
+ const etag = head?.ETag || null;
+
+ // Устанавливаем заголовки
+ if (etag) res.set('ETag', String(etag));
+ if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
+ if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
+
+ // Проверяем If-None-Match
+ if (checkIfNoneMatch(req, res, etag)) return;
+
+ // Получаем объект
+ const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
+ const fileContent = await streamToString(data.Body);
+
+ let parsed;
+ try {
+ parsed = JSON.parse(fileContent);
+ if (!Array.isArray(parsed) && Array.isArray(defaultValue)) {
+ parsed = defaultValue;
+ }
+ } catch (parseError) {
+ console.error(`Error parsing ${s3Key}:`, parseError);
+ parsed = defaultValue;
+ }
+
+ // Применяем трансформацию
+ const result = transform(parsed);
+
+ res.json(result);
+ } catch (error) {
+ if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
+ res.json(defaultValue);
+ } else {
+ console.error(error);
+ return sendError(res, 500, 'Error reading from S3', 'E_S3');
+ }
+ }
+}
+
+/**
+ * Универсальный GET для текстовых объектов из S3 с заголовками
+ * @param {string} s3Key - Ключ S3
+ * @param {object} req - Express request
+ * @param {object} res - Express response
+ * @param {object} options - Опции
+ * @param {function} options.transform - Функция трансформации текста
+ * @param {string} options.defaultValue - Значение по умолчанию при отсутствии файла
+ */
+async function getS3TextWithHeaders(s3Key, req, res, options = {}) {
+ const { transform = (text) => ({ config: text }), defaultValue = '' } = options;
+
+ try {
+ const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key })).catch(() => null);
+ const etag = head?.ETag || null;
+
+ if (etag) res.set('ETag', String(etag));
+ if (head?.LastModified) res.set('Last-Modified', new Date(head.LastModified).toUTCString());
+ if (typeof head?.ContentLength === 'number') res.set('Content-Length-Source', String(head.ContentLength));
+
+ if (checkIfNoneMatch(req, res, etag)) return;
+
+ const data = await s3.send(new GetObjectCommand({ Bucket: BUCKET_NAME, Key: s3Key }));
+ const text = await streamToString(data.Body);
+
+ const result = transform(text);
+ res.json(result);
+ } catch (error) {
+ if (error?.code === 'NoSuchKey' || error?.$metadata?.httpStatusCode === 404) {
+ res.json(transform(defaultValue));
+ } else {
+ console.error(error);
+ return sendError(res, 500, 'Error reading from S3', 'E_S3');
+ }
+ }
+}
+
+module.exports = {
+ getS3JsonWithHeaders,
+ getS3TextWithHeaders,
+};
+
diff --git a/frontend/src/components/Pagination.jsx b/frontend/src/components/Pagination.jsx
new file mode 100644
index 0000000..2aa2539
--- /dev/null
+++ b/frontend/src/components/Pagination.jsx
@@ -0,0 +1,93 @@
+/**
+ * Универсальный компонент пагинации
+ * Устраняет дублирование кода пагинации во всех менеджерах
+ */
+
+function Pagination({ currentPage, totalPages, totalItems, pageSize, onPageChange }) {
+ if (totalPages <= 1) return null;
+
+ const renderPages = () => {
+ 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) => {
+ if (p === 'start-ellipsis' || p === 'end-ellipsis') {
+ return (
+
+ …
+
+ );
+ }
+ return (
+
+
+
+ );
+ });
+ };
+
+ const startItem = (currentPage - 1) * pageSize + 1;
+ const endItem = Math.min(currentPage * pageSize, totalItems);
+
+ return (
+
+
+ Показано {startItem} - {endItem} из {totalItems}
+
+
+ -
+
+
+ -
+
+
+ {renderPages()}
+ -
+
+
+ -
+
+
+
+
+ );
+}
+
+export default Pagination;
+
diff --git a/frontend/src/hooks/useDataManager.js b/frontend/src/hooks/useDataManager.js
new file mode 100644
index 0000000..b15a627
--- /dev/null
+++ b/frontend/src/hooks/useDataManager.js
@@ -0,0 +1,302 @@
+/**
+ * Универсальный хук для управления данными с CRUD операциями
+ * Устраняет дублирование кода в DomainsNewManager, ASNsNewManager, IPRangesManager
+ */
+
+import { useState, useEffect, useRef } from 'react';
+import api from '../lib/api.js';
+
+/**
+ * @param {Object} config - Конфигурация хука
+ * @param {string} config.apiEndpoint - API endpoint (например '/domains-new')
+ * @param {string} config.itemKey - Ключ элемента для идентификации (например 'domain', 'asn', 'ipRange')
+ * @param {function} config.validateItem - Функция валидации элемента (item) => boolean
+ * @param {function} config.validateCommunity - Функция валидации community (value) => boolean
+ * @param {function} config.transformResponse - Функция трансформации ответа API
+ * @param {function} config.transformForSave - Функция трансформации данных перед сохранением
+ * @param {string} config.saveBodyKey - Ключ для body при сохранении (по умолчанию 'domains')
+ */
+export function useDataManager(config) {
+ const {
+ apiEndpoint,
+ itemKey,
+ validateItem,
+ validateCommunity,
+ transformResponse = (data) => data,
+ transformForSave = (data) => data,
+ saveBodyKey = 'domains',
+ } = config;
+
+ const [items, setItems] = useState([]);
+ const [originalItems, setOriginalItems] = useState([]);
+ const [etag, setEtag] = useState('');
+ const [lastModified, setLastModified] = useState('');
+ const [contentLength, setContentLength] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalItems, setTotalItems] = useState(0);
+ const [sortField, setSortField] = useState(itemKey);
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [filterCommunity, setFilterCommunity] = useState('');
+ const [searchTerm, setSearchTerm] = useState('');
+ const [editingItem, setEditingItem] = useState(null);
+ const [editingValue, setEditingValue] = useState('');
+
+ const didInit = useRef(false);
+ const pageSize = 10;
+
+ // Загрузка данных
+ const fetchItems = async () => {
+ setLoading(true);
+ try {
+ const response = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
+ const payload = Array.isArray(response.data?.items) ? response.data.items : [];
+ const transformed = transformResponse(payload);
+ const total = transformed.length;
+
+ setItems(transformed);
+ setOriginalItems(transformed);
+ setTotalItems(Number.isFinite(total) ? total : 0);
+ setEtag(response.headers?.etag || '');
+ setLastModified(response.headers?.['last-modified'] || '');
+ const lengthHeader = response.headers?.['content-length-source'];
+ setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
+
+ return { success: true };
+ } catch (error) {
+ console.error(`Error fetching ${apiEndpoint}:`, error);
+ return { success: false, error };
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!didInit.current) {
+ didInit.current = true;
+ fetchItems();
+ }
+ }, []);
+
+ // Добавление элемента
+ const addItem = (newItem) => {
+ const key = newItem[itemKey];
+ if (!key) return { success: false, error: 'Invalid item key' };
+
+ setItems(prev => [...prev, newItem]);
+ return { success: true };
+ };
+
+ // Редактирование элемента
+ const updateItem = (itemKeyValue, updates) => {
+ setItems(prev => prev.map(item =>
+ item[itemKey] === itemKeyValue ? { ...item, ...updates } : item
+ ));
+ return { success: true };
+ };
+
+ // Удаление элемента
+ const deleteItem = (itemKeyValue) => {
+ setItems(prev => prev.filter(item => item[itemKey] !== itemKeyValue));
+ return { success: true };
+ };
+
+ // Дедупликация
+ const deduplicate = (arr) => {
+ const seen = new Set();
+ const out = [];
+ for (const item of arr) {
+ const key = String(item[itemKey]).trim().toLowerCase();
+ if (!key) continue;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ ...item, [itemKey]: key });
+ }
+ return out;
+ };
+
+ // Вычисление diff
+ const computeDiff = (before, after) => {
+ const mapBefore = new Map(before.map(i => [i[itemKey], i]));
+ const mapAfter = new Map(after.map(i => [i[itemKey], i]));
+ const added = [];
+ const removed = [];
+ const changed = [];
+
+ for (const [k, v] of mapAfter) {
+ if (!mapBefore.has(k)) {
+ added.push(v);
+ continue;
+ }
+ const prev = mapBefore.get(k);
+ if (String(prev.community) !== String(v.community)) {
+ changed.push({ from: prev, to: v });
+ }
+ }
+
+ for (const [k, v] of mapBefore) {
+ if (!mapAfter.has(k)) removed.push(v);
+ }
+
+ return { added, removed, changed };
+ };
+
+ // Сохранение изменений
+ const saveChanges = async () => {
+ setLoading(true);
+ try {
+ // Валидируем и дедуплицируем
+ const valid = items.filter(item => {
+ const itemValid = validateItem ? validateItem(item[itemKey]) : true;
+ const communityValid = validateCommunity ? validateCommunity(item.community) : true;
+ return itemValid && communityValid;
+ });
+ const unique = deduplicate(valid);
+
+ // Загружаем полный список, мержим и сохраняем
+ const fullRes = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } });
+ const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : [];
+ const full = transformResponse(fullPayload);
+ const fullMap = new Map(full.map(i => [String(i[itemKey]).trim().toLowerCase(), i]));
+ const originalPageMap = new Map(originalItems.map(i => [String(i[itemKey]).trim().toLowerCase(), true]));
+ const uniqueMap = new Map(unique.map(i => [String(i[itemKey]).trim().toLowerCase(), i]));
+
+ // Удаляем то, что было на странице но отсутствует в изменённом наборе
+ for (const key of originalPageMap.keys()) {
+ if (!uniqueMap.has(key)) fullMap.delete(key);
+ }
+
+ // Добавляем/обновляем
+ for (const [key, val] of uniqueMap.entries()) {
+ fullMap.set(key, val);
+ }
+
+ const fullToSave = Array.from(fullMap.values());
+ const transformed = transformForSave(fullToSave);
+
+ const et = fullRes?.headers?.etag || etag;
+ const response = await api.post(apiEndpoint, { [saveBodyKey]: transformed, etag: et }, { validateStatus: () => true });
+
+ if (response.status === 412) {
+ return { success: false, error: 'ETag mismatch', code: 412 };
+ }
+
+ if (response.status >= 400) {
+ return { success: false, error: `Save failed with status ${response.status}` };
+ }
+
+ setEtag(response.headers?.etag || et);
+ await fetchItems();
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error saving changes:', error);
+ return { success: false, error };
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Сортировка
+ const sortedItems = [...items].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;
+ });
+
+ // Фильтрация
+ const filtered = sortedItems.filter(item => {
+ const byCommunity = !filterCommunity || item.community === filterCommunity;
+ const term = String(searchTerm || '').toLowerCase();
+ const bySearch = !term || String(item[itemKey]).toLowerCase().includes(term);
+ return byCommunity && bySearch;
+ });
+
+ // Пагинация
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
+ const paginatedItems = filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize);
+
+ // Уникальные community для фильтра
+ const allCommunities = Array.from(new Set(items.map(i => i.community)));
+
+ // Управление сортировкой
+ const handleSort = (field) => {
+ if (sortField === field) {
+ setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
+ } else {
+ setSortField(field);
+ setSortOrder('asc');
+ }
+ };
+
+ // Инлайн редактирование
+ const startEdit = (item) => {
+ setEditingItem(item[itemKey]);
+ setEditingValue(item.community);
+ };
+
+ const saveEdit = () => {
+ if (!editingItem) return { success: false };
+ updateItem(editingItem, { community: editingValue.trim() });
+ setEditingItem(null);
+ return { success: true };
+ };
+
+ const cancelEdit = () => {
+ setEditingItem(null);
+ };
+
+ return {
+ // Состояние
+ items,
+ originalItems,
+ setItems,
+ loading,
+ etag,
+ lastModified,
+ contentLength,
+
+ // Пагинация
+ currentPage,
+ setCurrentPage,
+ totalItems,
+ totalPages,
+ pageSize,
+ paginatedItems,
+
+ // Сортировка
+ sortField,
+ sortOrder,
+ handleSort,
+
+ // Фильтрация
+ filterCommunity,
+ setFilterCommunity,
+ searchTerm,
+ setSearchTerm,
+ filtered,
+ allCommunities,
+
+ // CRUD
+ fetchItems,
+ addItem,
+ updateItem,
+ deleteItem,
+ saveChanges,
+ deduplicate,
+ computeDiff,
+
+ // Инлайн редактирование
+ editingItem,
+ editingValue,
+ setEditingValue,
+ startEdit,
+ saveEdit,
+ cancelEdit,
+ };
+}
+
diff --git a/frontend/src/utils/exportUtils.js b/frontend/src/utils/exportUtils.js
new file mode 100644
index 0000000..933d6a6
--- /dev/null
+++ b/frontend/src/utils/exportUtils.js
@@ -0,0 +1,58 @@
+/**
+ * Утилиты для экспорта данных в CSV/TXT
+ * Устраняют дублирование кода экспорта
+ */
+
+/**
+ * Экспорт массива объектов в CSV
+ * @param {Array} items - Массив данных
+ * @param {Array} headers - Массив заголовков ['field1', 'field2']
+ * @param {string} filename - Имя файла
+ */
+export function exportToCSV(items, headers, filename) {
+ const csvContent = [
+ headers,
+ ...items.map(item => headers.map(h => item[h] || ''))
+ ]
+ .map(row => row.map(cell => `"${String(cell || '').replace(/"/g, '""')}"`).join(','))
+ .join('\n');
+
+ downloadFile(csvContent, filename, 'text/csv;charset=utf-8;');
+}
+
+/**
+ * Экспорт данных в текстовый файл
+ * @param {string} content - Текстовое содержимое
+ * @param {string} filename - Имя файла
+ */
+export function exportToText(content, filename) {
+ downloadFile(content, filename, 'text/plain;charset=utf-8;');
+}
+
+/**
+ * Скачивание файла
+ * @param {string} content - Содержимое
+ * @param {string} filename - Имя файла
+ * @param {string} mimeType - MIME тип
+ */
+function downloadFile(content, filename, mimeType) {
+ const blob = new Blob([content], { type: mimeType });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.click();
+ URL.revokeObjectURL(url);
+}
+
+/**
+ * Генерация имени файла с датой
+ * @param {string} prefix - Префикс имени файла
+ * @param {string} extension - Расширение файла
+ * @returns {string} Имя файла с датой
+ */
+export function generateFilename(prefix, extension = 'csv') {
+ const date = new Date().toISOString().split('T')[0];
+ return `${prefix}_${date}.${extension}`;
+}
+
diff --git a/frontend/src/utils/ipUtils.js b/frontend/src/utils/ipUtils.js
new file mode 100644
index 0000000..b207353
--- /dev/null
+++ b/frontend/src/utils/ipUtils.js
@@ -0,0 +1,211 @@
+/**
+ * Утилиты для работы с IP адресами и CIDR
+ * Вынесено из IPRangesManager для переиспользования
+ */
+
+/**
+ * Парсинг маски из CIDR
+ */
+export function parseMask(cidr) {
+ const parts = String(cidr || '').split('/');
+ const mask = Number(parts[1]);
+ return Number.isFinite(mask) ? mask : NaN;
+}
+
+/**
+ * Подсчет адресов в CIDR
+ */
+export function countAddresses(mask) {
+ if (!Number.isFinite(mask) || mask < 0 || mask > 32) return 0n;
+ return 1n << BigInt(32 - mask);
+}
+
+/**
+ * Парсинг октетов IPv4
+ */
+export function parseOctets(ip) {
+ const [a, b, c, d] = String(ip || '').split('.').map(x => Number(x));
+ return [a, b, c, d];
+}
+
+/**
+ * Проверка приватного IPv4
+ */
+export function isPrivateIPv4(ip) {
+ const [a, b] = parseOctets(ip);
+ if (a === 10) return true; // 10.0.0.0/8
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
+ return false;
+}
+
+/**
+ * Проверка CGNAT IPv4
+ */
+export function isCGNATIPv4(ip) {
+ const [a, b] = parseOctets(ip);
+ return a === 100 && b >= 64 && b <= 127; // 100.64.0.0/10
+}
+
+/**
+ * Получение категории IP
+ */
+export function getIpCategory(cidr) {
+ const baseIp = String(cidr || '').split('/')[0];
+ if (isCGNATIPv4(baseIp)) return 'cgnat';
+ if (isPrivateIPv4(baseIp)) return 'private';
+ return 'public';
+}
+
+/**
+ * Форматирование BigInt с разделителями тысяч
+ */
+export function formatBigInt(n) {
+ try {
+ const s = (n ?? 0n).toString();
+ return s.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
+ } catch {
+ return String(n);
+ }
+}
+
+/**
+ * Конвертация IP в BigInt
+ */
+export function ipToInt(ip) {
+ const [a, b, c, d] = parseOctets(ip);
+ return (BigInt(a) << 24n) | (BigInt(b) << 16n) | (BigInt(c) << 8n) | BigInt(d);
+}
+
+/**
+ * Конвертация BigInt в IP
+ */
+export function intToIp(num) {
+ const a = Number((num >> 24n) & 255n);
+ const b = Number((num >> 16n) & 255n);
+ const c = Number((num >> 8n) & 255n);
+ const d = Number(num & 255n);
+ return `${a}.${b}.${c}.${d}`;
+}
+
+/**
+ * Конвертация CIDR в диапазон
+ */
+export function cidrToRange(cidr) {
+ const [ip, maskStr] = String(cidr).split('/');
+ const mask = Number(maskStr);
+ const ipNum = ipToInt(ip);
+ const hostBits = 32 - mask;
+ const start = ipNum & (~0n << BigInt(hostBits));
+ const end = start | ((1n << BigInt(hostBits)) - 1n);
+ return [start, end];
+}
+
+/**
+ * Подсчет trailing zeros для 32-битного числа
+ */
+export function countTrailingZeros32(n) {
+ if (n === 0n) return 32;
+ let zeros = 0;
+ for (let i = 0; i < 32; i++) {
+ if ((n & (1n << BigInt(i))) !== 0n) {
+ zeros = i;
+ break;
+ }
+ }
+ return zeros;
+}
+
+/**
+ * Floor log2 для BigInt
+ */
+export function floorLog2(n) {
+ let p = -1;
+ while (n > 0n) {
+ n >>= 1n;
+ p++;
+ }
+ return p < 0 ? 0 : p;
+}
+
+/**
+ * Конвертация диапазона в CIDR блоки
+ */
+export function rangeToCidrs(start, end) {
+ const res = [];
+ let cur = start;
+ while (cur <= end) {
+ const maxAlignPow = countTrailingZeros32(cur);
+ const remaining = end - cur + 1n;
+ const remainingPow = floorLog2(remaining);
+ const blockPow = Math.min(maxAlignPow, remainingPow);
+ const mask = 32 - blockPow;
+ res.push(`${intToIp(cur)}/${mask}`);
+ cur += 1n << BigInt(blockPow);
+ }
+ return res;
+}
+
+/**
+ * Слияние интервалов
+ */
+export function mergeIntervals(intervals) {
+ if (intervals.length === 0) return [];
+ intervals.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
+ const merged = [];
+ let [cs, ce] = intervals[0];
+ for (let i = 1; i < intervals.length; i++) {
+ const [s, e] = intervals[i];
+ if (s <= ce + 1n) {
+ if (e > ce) ce = e;
+ } else {
+ merged.push([cs, ce]);
+ cs = s;
+ ce = e;
+ }
+ }
+ merged.push([cs, ce]);
+ return merged;
+}
+
+/**
+ * Суммаризация CIDR блоков
+ */
+export function summarizeCidrs(cidrs) {
+ const ranges = cidrs.map(cidrToRange);
+ const merged = mergeIntervals(ranges);
+ const out = [];
+ for (const [s, e] of merged) {
+ out.push(...rangeToCidrs(s, e));
+ }
+ return out;
+}
+
+/**
+ * Получение базового /16 для CIDR
+ */
+export function get16BaseCidr(cidr) {
+ const ip = String(cidr).split('/')[0];
+ const ipNum = ipToInt(ip);
+ const start = ipNum & (~0n << 16n);
+ return `${intToIp(start)}/16`;
+}
+
+/**
+ * Применение агрессивной агрегации /16
+ */
+export function applySupernet16(cidrs) {
+ const buckets = new Map();
+ for (const c of cidrs) {
+ const base16 = get16BaseCidr(c);
+ if (!buckets.has(base16)) buckets.set(base16, []);
+ buckets.get(base16).push(c);
+ }
+ const out = [];
+ for (const [base16, list] of buckets) {
+ if (list.length > 2) out.push(base16);
+ else out.push(...list);
+ }
+ return Array.from(new Set(out));
+}
+
diff --git a/frontend/src/utils/validationUtils.js b/frontend/src/utils/validationUtils.js
new file mode 100644
index 0000000..87f2713
--- /dev/null
+++ b/frontend/src/utils/validationUtils.js
@@ -0,0 +1,62 @@
+/**
+ * Утилиты валидации
+ * Централизация всех функций валидации
+ */
+
+/**
+ * Валидация FQDN домена
+ */
+export function isValidDomain(value) {
+ const v = String(value).trim().toLowerCase();
+ return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
+}
+
+/**
+ * Валидация ASN
+ */
+export function isValidAsn(value) {
+ return /^[0-9]+$/.test(String(value).trim());
+}
+
+/**
+ * Валидация IPv4 адреса
+ */
+export function isValidIPv4(ip) {
+ const octets = ip.split('.');
+ if (octets.length !== 4) return false;
+ return octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255);
+}
+
+/**
+ * Валидация CIDR
+ */
+export function isValidCidr(value) {
+ const v = String(value).trim();
+ const parts = v.split('/');
+ if (parts.length !== 2) return false;
+ const [ip, mask] = parts;
+ if (!isValidIPv4(ip)) return false;
+ if (!/^\d{1,2}$/.test(mask)) return false;
+ const m = Number(mask);
+ return m >= 0 && m <= 32;
+}
+
+/**
+ * Валидация BGP Community (поддержка числового и extended формата)
+ */
+export function isValidCommunity(value) {
+ return /^(\d+|\d+:\d+)$/.test(String(value).trim());
+}
+
+/**
+ * Валидация IP адреса (поддержка IPv4 и IPv6)
+ */
+export function isValidIP(value) {
+ // IPv4
+ if (isValidIPv4(value)) return true;
+
+ // Упрощенная проверка IPv6
+ const ipv6Regex = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::)$/;
+ return ipv6Regex.test(value);
+}
+