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} +
    + +
    + ); +} + +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); +} +