From 50a7edd50ff7350866cb7defcd85a3cc75e1ed36 Mon Sep 17 00:00:00 2001 From: shats Date: Mon, 16 Mar 2026 00:07:25 +0700 Subject: [PATCH] feat: enhance various components with useCallback for performance optimization and improve chunk size warning limit in Vite config --- frontend/src/ASNsNewManager.jsx | 57 ++--- frontend/src/AutoUrlManager.jsx | 6 +- frontend/src/BillingManager.jsx | 209 +++++++++--------- frontend/src/DataManager.jsx | 14 +- frontend/src/DomainsNewManager.jsx | 48 ++-- frontend/src/EasySwitchManager.jsx | 9 +- frontend/src/FilterManager.jsx | 14 +- frontend/src/FirewallPage.jsx | 8 +- frontend/src/IPRangesManager.jsx | 82 ++++--- frontend/src/MikrotikBackupsManager.jsx | 120 +++++----- frontend/src/NetworkConfigManager.jsx | 8 + frontend/src/NetworkMapUnifi.jsx | 2 +- frontend/src/OspfToolsPage.jsx | 12 +- frontend/src/ServerManager.jsx | 3 +- frontend/src/UptimeMonitorPage.jsx | 17 +- frontend/src/components/AlertsBell.jsx | 10 +- frontend/src/components/PortalDropdown.jsx | 11 +- frontend/src/components/ToastContainer.jsx | 28 +-- .../src/components/server/AddServerModal.jsx | 11 +- .../components/server/DeleteServerModal.jsx | 11 +- .../src/components/server/EditServerModal.jsx | 11 +- .../components/server/LinkGeneratorModal.jsx | 11 +- .../src/components/server/ServerModal.jsx | 11 +- frontend/src/hooks/useDataManager.js | 8 +- frontend/vite.config.js | 3 + 25 files changed, 360 insertions(+), 364 deletions(-) diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index 035963a..b6e04c5 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityBadge from './components/CommunityBadge.jsx'; @@ -112,7 +112,7 @@ function ASNsNewManager() { return () => controller.abort(); }, []); - const fetchItems = async (abortSignal) => { + const fetchItems = useCallback(async (abortSignal) => { setLoading(true); const opts = abortSignal ? { signal: abortSignal } : {}; try { @@ -135,7 +135,7 @@ function ASNsNewManager() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { if (!didInit.current) { @@ -144,7 +144,7 @@ function ASNsNewManager() { fetchItems(controller.signal); return () => controller.abort(); } - }, []); + }, [fetchItems]); const handleAddItem = (item) => { const asn = item?.asn || newItem.asn; @@ -312,27 +312,7 @@ function ASNsNewManager() { setShowDiff(true); }; - const handleSaveChanges = async () => { - // подготовим diff и спросим подтверждение только при массовых изменениях (>10) - const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) - .map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() })); - const unique = deduplicate(valid); - const diffData = computeDiff(originalItems, unique); - setDiff(diffData); - - // Умное подтверждение: показываем модалку только при массовых изменениях - const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0); - - if (totalChanges > 10) { - // Массовое изменение - требуется подтверждение - setConfirmSaveOpen(true); - } else { - // Малое изменение - сохраняем сразу - performSave(); - } - }; - - const performSave = async () => { + const performSave = useCallback(async () => { setConfirmSaveOpen(false); setLoading(true); try { @@ -372,7 +352,27 @@ function ASNsNewManager() { } finally { setLoading(false); } - }; + }, [items, originalItems, etag, fetchItems]); + + const handleSaveChanges = useCallback(async () => { + // подготовим diff и спросим подтверждение только при массовых изменениях (>10) + const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community)) + .map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() })); + const unique = deduplicate(valid); + const diffData = computeDiff(originalItems, unique); + setDiff(diffData); + + // Умное подтверждение: показываем модалку только при массовых изменениях + const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0); + + if (totalChanges > 10) { + // Массовое изменение - требуется подтверждение + setConfirmSaveOpen(true); + } else { + // Малое изменение - сохраняем сразу + performSave(); + } + }, [items, originalItems, performSave]); const handleImport = () => { setImportOpen(true); @@ -469,6 +469,7 @@ function ASNsNewManager() { }); const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize); + const paginatedAsns = paginatedItems.map(i => i?.asn).filter(Boolean).join(','); // Подгружаем имена ASN из кэша/внешних API (после вычисления paginatedItems) useEffect(() => { @@ -482,7 +483,7 @@ function ASNsNewManager() { const name = await getAsnName(asn); if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name })); }); - }, [JSON.stringify(paginatedItems)]); + }, [paginatedAsns, paginatedItems]); // Для фильтра - список всех уникальных community const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => String(i.community || '')).filter(c => c !== ''))); @@ -507,7 +508,7 @@ function ASNsNewManager() { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]); + }, [loading, handleSaveChanges]); const handleEditKeyDown = (e) => { if (e.key === 'Enter') { diff --git a/frontend/src/AutoUrlManager.jsx b/frontend/src/AutoUrlManager.jsx index ce352bc..be58809 100644 --- a/frontend/src/AutoUrlManager.jsx +++ b/frontend/src/AutoUrlManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import api from './lib/api.js'; import { IconPlus, @@ -101,7 +101,7 @@ function AutoUrlManager() { const hasAtLeastOneValidRow = urls.some(u => isValidHttpUrl(u.url) && isValidCommunity(u.community)); - const saveUrls = async () => { + const saveUrls = useCallback(async () => { try { setSaving(true); setError(''); @@ -124,7 +124,7 @@ function AutoUrlManager() { } finally { setSaving(false); } - }; + }, [urls]); const processUrls = async () => { try { diff --git a/frontend/src/BillingManager.jsx b/frontend/src/BillingManager.jsx index fa90420..86abb41 100644 --- a/frontend/src/BillingManager.jsx +++ b/frontend/src/BillingManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import api from './lib/api.js'; import axios from 'axios'; import FormModal from './components/FormModal.jsx'; @@ -112,6 +112,107 @@ function BillingManager() { serverId: '', }); + const fetchExchangeRates = useCallback(async (abortSignal) => { + setRatesLoading(true); + const opts = abortSignal ? { signal: abortSignal } : {}; + try { + const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts); + const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null; + const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null; + + if (rubPerUsd) { + const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1; + setExchangeRates({ + USD: 1, + EUR: eurPerUsd, + RUB: rubPerUsd + }); + return; + } + } catch (error) { + if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; + console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message); + } + + try { + const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts); + const rates = response.data?.rates || {}; + setExchangeRates({ + USD: 1, + EUR: rates.EUR || 1, + RUB: rates.RUB || 1 + }); + } catch (error) { + if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; + console.error('Ошибка при загрузке курсов валют:', error); + setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 }); + } finally { + setRatesLoading(false); + } + }, []); + + const fetchServers = useCallback(async (abortSignal) => { + const opts = abortSignal ? { signal: abortSignal } : {}; + try { + const res = await api.get(`/servers`, opts); + setServers(Array.isArray(res.data) ? res.data : []); + } catch (error) { + if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; + console.error('Ошибка при загрузке серверов:', error); + } + }, []); + + const fetchBillingData = useCallback(async (abortSignal) => { + setLoading(true); + const opts = abortSignal ? { signal: abortSignal } : {}; + try { + const response = await api.get(`/billing`, opts); + const rates = exchangeRates; + const toRUB = (amt, ccy) => { + if (!amt) return 0; + const rub = Number(rates.RUB) || 1; + if (!ccy || ccy === 'RUB') return amt; + if (ccy === 'USD') return amt * rub; + const c = Number(rates[ccy]); + return c ? (amt / c) * rub : amt * rub; + }; + const normalized = (response.data || []).map((item, idx) => { + const payments = Array.isArray(item.payments) + ? item.payments + : (item.lastPaymentDate && item.lastPaymentAmount + ? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }] + : [] + ); + const merged = { id: item.id ?? String(idx + 1), payments, ...item }; + if (payments.length > 0) { + const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0]; + if (last?.date) { + const current = new Date(item.nextPaymentDate || 0); + const now = new Date(); + if (current < now || !item.nextPaymentDate) { + const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD'); + const amtRUB = toRUB(last.amount, last.currency || 'USD'); + const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1; + const d = new Date(last.date); + d.setMonth(d.getMonth() + months); + merged.nextPaymentDate = d.toISOString().slice(0, 10); + } + } + } + return merged; + }); + setBillingData(normalized); + setError(''); + } catch (err) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; + console.error('Ошибка при загрузке данных биллинга:', err); + setError('Ошибка при загрузке данных'); + setBillingData([]); + } finally { + setLoading(false); + } + }, [exchangeRates]); + useEffect(() => { const controller = new AbortController(); const signal = controller.signal; @@ -119,7 +220,7 @@ function BillingManager() { fetchExchangeRates(signal); fetchServers(signal); return () => controller.abort(); - }, []); + }, [fetchBillingData, fetchExchangeRates, fetchServers]); // Автоматическая синхронизация: добавляем серверы без записей в биллинге useEffect(() => { @@ -165,110 +266,6 @@ function BillingManager() { }, [servers, loading]); - const fetchExchangeRates = async (abortSignal) => { - setRatesLoading(true); - const opts = abortSignal ? { signal: abortSignal } : {}; - try { - // Основной источник: ЦБ РФ (base = RUB) - const cbr = await axios.get('https://www.cbr-xml-daily.ru/latest.js', opts); - const rubPerUsd = cbr.data && cbr.data.rates?.USD ? 1 / Number(cbr.data.rates.USD) : null; - const rubPerEur = cbr.data && cbr.data.rates?.EUR ? 1 / Number(cbr.data.rates.EUR) : null; - - if (rubPerUsd) { - const eurPerUsd = rubPerEur ? rubPerUsd / rubPerEur : 1; - setExchangeRates({ - USD: 1, - EUR: eurPerUsd, // сколько EUR за 1 USD - RUB: rubPerUsd // сколько RUB за 1 USD - }); - return; - } - } catch (error) { - if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; - console.warn('ЦБ недоступен, пробуем резервный источник:', error?.message); - } - - try { - // Резервный источник: open.er-api - const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts); - const rates = response.data?.rates || {}; - setExchangeRates({ - USD: 1, - EUR: rates.EUR || 1, - RUB: rates.RUB || 1 - }); - } catch (error) { - if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; - console.error('Ошибка при загрузке курсов валют:', error); - // Фолбек на разумные значения - setExchangeRates({ USD: 1, EUR: 1.05, RUB: 95 }); - } finally { - setRatesLoading(false); - } - }; - - const fetchServers = async (abortSignal) => { - const opts = abortSignal ? { signal: abortSignal } : {}; - try { - const res = await api.get(`/servers`, opts); - setServers(Array.isArray(res.data) ? res.data : []); - } catch (error) { - if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return; - console.error('Ошибка при загрузке серверов:', error); - } - }; - - const fetchBillingData = async (abortSignal) => { - setLoading(true); - const opts = abortSignal ? { signal: abortSignal } : {}; - try { - const response = await api.get(`/billing`, opts); - const rates = exchangeRates; // из closure, на первой загрузке может быть {1,1,1} — для USD достаточно - const toRUB = (amt, ccy) => { - if (!amt) return 0; - const rub = Number(rates.RUB) || 1; - if (!ccy || ccy === 'RUB') return amt; - if (ccy === 'USD') return amt * rub; - const c = Number(rates[ccy]); - return c ? (amt / c) * rub : amt * rub; - }; - const normalized = (response.data || []).map((item, idx) => { - const payments = Array.isArray(item.payments) - ? item.payments - : (item.lastPaymentDate && item.lastPaymentAmount - ? [{ date: item.lastPaymentDate, amount: item.lastPaymentAmount, currency: item.lastPaymentCurrency || 'USD', note: 'Импортировано' }] - : [] - ); - const merged = { id: item.id ?? String(idx + 1), payments, ...item }; - if (payments.length > 0) { - const last = payments.slice().sort((a, b) => new Date(b.date) - new Date(a.date))[0]; - if (last?.date) { - const current = new Date(item.nextPaymentDate || 0); - const now = new Date(); - if (current < now || !item.nextPaymentDate) { - const costRUB = toRUB(item.monthlyCost || 0, item.monthlyCostCurrency || 'USD'); - const amtRUB = toRUB(last.amount, last.currency || 'USD'); - const months = costRUB > 0 ? Math.max(1, Math.floor(amtRUB / costRUB)) : 1; - const d = new Date(last.date); - d.setMonth(d.getMonth() + months); - merged.nextPaymentDate = d.toISOString().slice(0, 10); - } - } - } - return merged; - }); - setBillingData(normalized); - setError(''); - } catch (err) { - if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; - console.error('Ошибка при загрузке данных биллинга:', err); - setError('Ошибка при загрузке данных'); - setBillingData([]); - } finally { - setLoading(false); - } - }; - const handleSaveChanges = async () => { setLoading(true); try { diff --git a/frontend/src/DataManager.jsx b/frontend/src/DataManager.jsx index e65bab4..175780a 100644 --- a/frontend/src/DataManager.jsx +++ b/frontend/src/DataManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import api from './lib/api.js'; import { IconPlus, @@ -39,11 +39,7 @@ function DataManager({ entityName, entityKey, placeholder }) { const pageSize = 10; const [confirmState, setConfirmState] = useState({ open: false, text: '', onConfirm: null }); - useEffect(() => { - fetchItems(); - }, [entityKey]); - - const fetchItems = async () => { + const fetchItems = useCallback(async () => { setLoading(true); try { const response = await api.get(`/${entityKey}`); @@ -55,7 +51,11 @@ function DataManager({ entityName, entityKey, placeholder }) { } finally { setLoading(false); } - }; + }, [entityKey, entityName]); + + useEffect(() => { + fetchItems(); + }, [fetchItems]); const handleAddItem = () => { if (newItem.domain.trim() === '') { diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index e9fd4a6..8cbd9c3 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityBadge from './components/CommunityBadge.jsx'; @@ -121,7 +121,7 @@ function DomainsNewManager() { const [wsOpen, setWsOpen] = useState(false); const [wsUrl, setWsUrl] = useState(''); - const fetchItems = async (abortSignal) => { + const fetchItems = useCallback(async (abortSignal) => { setLoading(true); const opts = abortSignal ? { signal: abortSignal } : {}; try { @@ -143,7 +143,7 @@ function DomainsNewManager() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { if (!didInit.current) { @@ -152,7 +152,7 @@ function DomainsNewManager() { fetchItems(controller.signal); return () => controller.abort(); } - }, []); + }, [fetchItems]); const handleAddItem = (item) => { // Если item передан (из модалки), используем его, иначе из state (старый способ) @@ -273,27 +273,7 @@ function DomainsNewManager() { setShowDiff(true); }; - const handleSaveChanges = async () => { - // подготовим diff и спросим подтверждение только при массовых изменениях (>10) - const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community)) - .map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() })); - const unique = deduplicate(valid); - const diffData = computeDiff(originalItems, unique); - setDiff(diffData); - - // Умное подтверждение: показываем модалку только при массовых изменениях - const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0); - - if (totalChanges > 10) { - // Массовое изменение - требуется подтверждение - setConfirmSaveOpen(true); - } else { - // Малое изменение - сохраняем сразу - performSave(); - } - }; - - const performSave = async () => { + const performSave = useCallback(async () => { setConfirmSaveOpen(false); setLoading(true); try { @@ -336,7 +316,21 @@ function DomainsNewManager() { } finally { setLoading(false); } - }; + }, [items, originalItems, etag, fetchItems]); + + const handleSaveChanges = useCallback(async () => { + const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community)) + .map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() })); + const unique = deduplicate(valid); + const diffData = computeDiff(originalItems, unique); + setDiff(diffData); + const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0); + if (totalChanges > 10) { + setConfirmSaveOpen(true); + } else { + performSave(); + } + }, [items, originalItems, performSave]); // Drag&Drop импорт const _onDropImport = async (e) => { @@ -456,7 +450,7 @@ function DomainsNewManager() { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]); + }, [loading, handleSaveChanges]); const handleEditKeyDown = (e) => { if (e.key === 'Enter') { diff --git a/frontend/src/EasySwitchManager.jsx b/frontend/src/EasySwitchManager.jsx index 064fedd..6ce5cac 100644 --- a/frontend/src/EasySwitchManager.jsx +++ b/frontend/src/EasySwitchManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useLocation } from 'react-router-dom'; import api from './lib/api.js'; import { usePing } from './contexts/PingContext.jsx'; @@ -97,7 +97,7 @@ function EasySwitchManager() { initData(); return () => controller.abort(); - }, []); + }, [loadDataWithParams]); // При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся) useEffect(() => { @@ -129,7 +129,7 @@ function EasySwitchManager() { }; }, [cancelScope]); - const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => { + const loadDataWithParams = useCallback(async (communities, inventory, ncGatewaysParam, signal) => { setLoading(true); setError(''); @@ -309,7 +309,8 @@ function EasySwitchManager() { } finally { setLoading(false); } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, deps would cause unnecessary re-fetches + }, []); const handleGatewaySelect = (serverId, community, gateway) => { const key = `${serverId}:${community}`; diff --git a/frontend/src/FilterManager.jsx b/frontend/src/FilterManager.jsx index b8125a5..32557cb 100644 --- a/frontend/src/FilterManager.jsx +++ b/frontend/src/FilterManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useLocation } from 'react-router-dom'; import api from './lib/api.js'; import ConfirmDialog from './components/ConfirmDialog.jsx'; @@ -773,7 +773,7 @@ function FilterManager() { const { pingMap, requestPings } = usePing(); // Получить metadata сервера из inventory по имени (dns/hostName/ip или короткое имя типа "ihor") - const getInventoryMetaForServer = (serverName) => { + const getInventoryMetaForServer = useCallback((serverName) => { if (!serverName) return null; const name = String(serverName).trim(); return inventoryServers.find((srv) => { @@ -781,20 +781,18 @@ function FilterManager() { const hostName = String(srv.hostName || '').trim(); const ip = String(srv.ip || '').trim(); if (dns === name || hostName === name || ip === name) return true; - // На странице Фильтров selectedServer.name — короткое имя (первая часть dns), например "ihor" if (dns && dns.split('.')[0] === name) return true; return false; }); - }; + }, [inventoryServers]); // Получить routerId для MikroTik (id/dns/ip), как на странице Easy Switch - const getRouterIdForServer = (server) => { + const getRouterIdForServer = useCallback((server) => { if (!server) return null; - // Сначала ищем по полному имени (fullName/dns), затем по короткому (name) const meta = getInventoryMetaForServer(server.fullName || server.dns || server.name) || getInventoryMetaForServer(server.name); if (!meta) return null; return meta.id || meta.dns || meta.ip || null; - }; + }, [getInventoryMetaForServer]); // Собираем gateways только для выбранного сервера const selectedServerGateways = (() => { @@ -893,7 +891,7 @@ function FilterManager() { .filter((gw) => gw.ip) .map((gw) => ({ routerId, gatewayIp: gw.ip })); requestPings(tasks); - }, [location.pathname, selectedServer, selectedServerGateways, requestPings]); + }, [location.pathname, selectedServer, selectedServerGateways, requestPings, getRouterIdForServer]); useEffect(() => { const controller = new AbortController(); diff --git a/frontend/src/FirewallPage.jsx b/frontend/src/FirewallPage.jsx index ad281a8..c60a942 100644 --- a/frontend/src/FirewallPage.jsx +++ b/frontend/src/FirewallPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import api from './lib/api.js'; import PageHeader from './components/PageHeader.jsx'; import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx'; @@ -259,7 +259,7 @@ export default function FirewallPage() { return () => { cancelled = true; }; }, []); - const fetchAddressLists = async () => { + const fetchAddressLists = useCallback(async () => { if (!serverId) { setData(null); return; @@ -281,12 +281,12 @@ export default function FirewallPage() { } finally { setLoading(false); } - }; + }, [serverId]); useEffect(() => { if (serverId) fetchAddressLists(); else setData(null); - }, [serverId]); + }, [serverId, fetchAddressLists]); const runAnalysis = () => { if (!data?.ban?.length) return; diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index 59f1619..d1ec590 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import CommunityBadge from './components/CommunityBadge.jsx'; @@ -47,6 +47,23 @@ import FormModal from './components/FormModal.jsx'; const API_URL = '/api'; +const 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); +}; + +const 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; +}; + function IPRangesManager() { const [items, setItems] = useState([]); const [originalItems, setOriginalItems] = useState([]); @@ -90,23 +107,6 @@ function IPRangesManager() { const renderCommunityBadge = (value) => (); - const 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); - }; - - const 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; - }; - // Допускаем как числовые, так и строковые (AS:NNN) community const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); @@ -126,7 +126,7 @@ function IPRangesManager() { return () => controller.abort(); }, []); - const fetchItems = async (abortSignal) => { + const fetchItems = useCallback(async (abortSignal) => { setLoading(true); const opts = abortSignal ? { signal: abortSignal } : {}; try { @@ -148,7 +148,7 @@ function IPRangesManager() { } finally { setLoading(false); } - }; + }, []); useEffect(() => { if (!didInit.current) { @@ -157,7 +157,7 @@ function IPRangesManager() { fetchItems(controller.signal); return () => controller.abort(); } - }, []); + }, [fetchItems]); const handleAddItem = (item) => { const ipRange = item?.ipRange || newItem.ipRange; @@ -338,27 +338,7 @@ function IPRangesManager() { setShowDiff(true); }; - const handleSaveChanges = async () => { - // подготовим diff и спросим подтверждение только при массовых изменениях (>10) - const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community)) - .map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() })); - const unique = deduplicate(valid); - const diffData = computeDiff(originalItems, unique); - setDiff(diffData); - - // Умное подтверждение: показываем модалку только при массовых изменениях - const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0); - - if (totalChanges > 10) { - // Массовое изменение - требуется подтверждение - setConfirmSaveOpen(true); - } else { - // Малое изменение - сохраняем сразу - performSave(); - } - }; - - const performSave = async () => { + const performSave = useCallback(async () => { setConfirmSaveOpen(false); setLoading(true); try { @@ -398,7 +378,21 @@ function IPRangesManager() { } finally { setLoading(false); } - }; + }, [items, originalItems, etag, fetchItems]); + + const handleSaveChanges = useCallback(async () => { + const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community)) + .map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() })); + const unique = deduplicate(valid); + const diffData = computeDiff(originalItems, unique); + setDiff(diffData); + const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0); + if (totalChanges > 10) { + setConfirmSaveOpen(true); + } else { + performSave(); + } + }, [items, originalItems, performSave]); const handleImport = () => { setImportOpen(true); @@ -532,7 +526,7 @@ function IPRangesManager() { }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [loading, items, originalItems, etag, sortField, sortOrder, filterCommunity]); + }, [loading, handleSaveChanges]); const handleEditKeyDown = (e) => { if (e.key === 'Enter') { diff --git a/frontend/src/MikrotikBackupsManager.jsx b/frontend/src/MikrotikBackupsManager.jsx index 44b666b..3ce0cde 100644 --- a/frontend/src/MikrotikBackupsManager.jsx +++ b/frontend/src/MikrotikBackupsManager.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import * as Diff from 'diff'; import api from './lib/api.js'; import { useNotify } from './components/NotifyProvider.jsx'; @@ -156,13 +156,70 @@ function MikrotikBackupsManager() { const [backupPage, setBackupPage] = useState(1); const backupPageSize = 10; + const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString(); + + const fetchServers = useCallback(async (abortSignal) => { + const opts = abortSignal ? { signal: abortSignal } : {}; + try { + setLoadingServers(true); + const res = await api.get('/servers', opts); + const list = Array.isArray(res.data) ? res.data : []; + setServers(list); + const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost'); + if (firstJumphost && !selectedServerId) { + setSelectedServerId(makeServerId(firstJumphost)); + } + } catch (err) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; + console.error('Error fetching servers for backups:', err); + notify.error('Не удалось загрузить список серверов для бэкапов'); + } finally { + setLoadingServers(false); + } + }, [selectedServerId, notify]); + + const fetchUiSettings = useCallback(async (abortSignal) => { + const opts = abortSignal ? { signal: abortSignal } : {}; + try { + const res = await api.get('/ui-settings', opts); + const data = res?.data || {}; + setUiSettings(data); + const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : []; + setBackupServerIds(new Set(list.map((v) => String(v)))); + const e = res?.headers?.etag || res?.headers?.ETag || ''; + setUiSettingsEtag(e ? String(e) : ''); + } catch (err) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; + console.error('Error fetching UI settings for backups:', err); + } + }, []); + + const fetchBackups = useCallback(async (serverId, abortSignal) => { + if (!serverId) return; + const opts = abortSignal ? { signal: abortSignal } : {}; + try { + setLoadingBackups(true); + setSelectedKeys([]); + setDiffResult(null); + const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts }); + const items = res.data?.items || []; + setBackups(items); + } catch (err) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; + console.error('Error fetching backups:', err); + notify.error('Не удалось загрузить список бэкапов MikroTik'); + } finally { + setLoadingBackups(false); + } + }, [notify]); + useEffect(() => { const controller = new AbortController(); const signal = controller.signal; fetchServers(signal); fetchUiSettings(signal); return () => controller.abort(); - }, []); + }, [fetchServers, fetchUiSettings]); useEffect(() => { if (selectedServerId) { @@ -173,15 +230,13 @@ function MikrotikBackupsManager() { setBackups([]); setSelectedKeys([]); } - }, [selectedServerId]); + }, [selectedServerId, fetchBackups]); const jumphostServers = useMemo( () => (servers || []).filter((s) => String(s.type || '').toLowerCase() === 'jumphost'), [servers], ); - const makeServerId = (s) => (s.id || s.dns || s.ip || '').toString(); - const jumphostInputServers = useMemo( () => jumphostServers.map((s) => ({ ...s, id: makeServerId(s) })), [jumphostServers], @@ -319,61 +374,6 @@ function MikrotikBackupsManager() { setBackupServerIds(new Set()); }; - const fetchServers = async (abortSignal) => { - const opts = abortSignal ? { signal: abortSignal } : {}; - try { - setLoadingServers(true); - const res = await api.get('/servers', opts); - const list = Array.isArray(res.data) ? res.data : []; - setServers(list); - const firstJumphost = list.find((s) => String(s.type || '').toLowerCase() === 'jumphost'); - if (firstJumphost && !selectedServerId) { - setSelectedServerId(makeServerId(firstJumphost)); - } - } catch (err) { - if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; - console.error('Error fetching servers for backups:', err); - notify.error('Не удалось загрузить список серверов для бэкапов'); - } finally { - setLoadingServers(false); - } - }; - - const fetchUiSettings = async (abortSignal) => { - const opts = abortSignal ? { signal: abortSignal } : {}; - try { - const res = await api.get('/ui-settings', opts); - const data = res?.data || {}; - setUiSettings(data); - const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : []; - setBackupServerIds(new Set(list.map((v) => String(v)))); - const e = res?.headers?.etag || res?.headers?.ETag || ''; - setUiSettingsEtag(e ? String(e) : ''); - } catch (err) { - if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; - console.error('Error fetching UI settings for backups:', err); - } - }; - - const fetchBackups = async (serverId, abortSignal) => { - if (!serverId) return; - const opts = abortSignal ? { signal: abortSignal } : {}; - try { - setLoadingBackups(true); - setSelectedKeys([]); - setDiffResult(null); - const res = await api.get('/mikrotik/backups', { params: { serverId }, ...opts }); - const items = res.data?.items || []; - setBackups(items); - } catch (err) { - if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return; - console.error('Error fetching backups:', err); - notify.error('Не удалось загрузить список бэкапов MikroTik'); - } finally { - setLoadingBackups(false); - } - }; - const handleSelectKey = (key) => { setSelectedKeys((prev) => { if (prev.includes(key)) { diff --git a/frontend/src/NetworkConfigManager.jsx b/frontend/src/NetworkConfigManager.jsx index fa9baca..5aa8c03 100644 --- a/frontend/src/NetworkConfigManager.jsx +++ b/frontend/src/NetworkConfigManager.jsx @@ -249,6 +249,7 @@ function NetworkConfigManager() { fetchServers(); fetchIpsecPasswords(); fetchUiSettings(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below }, []); const fetchUiSettings = async () => { @@ -393,6 +394,7 @@ function NetworkConfigManager() { label: getServerLabel(id), server: getServerInfo(id), })); + // eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers }, [config.gateways, config.tunnelInterfaces, config.ipPools, servers]); // === Фильтрация === @@ -431,6 +433,7 @@ function NetworkConfigManager() { } return result; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel/gatewayTypeFilter are stable }, [config.gateways, providerFilter, serverFilter, typeFilter, searchTerm, servers]); const filteredInterfaces = useMemo(() => { @@ -456,6 +459,7 @@ function NetworkConfigManager() { } return result; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getServerLabel is stable helper }, [config.tunnelInterfaces, typeFilter, serverFilter, searchTerm, servers]); const filteredPools = useMemo(() => { @@ -479,6 +483,7 @@ function NetworkConfigManager() { } return result; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getServerInfo/getServerLabel are stable helpers }, [config.ipPools, serverFilter, searchTerm, servers]); // === Группировка по серверам === @@ -528,6 +533,7 @@ function NetworkConfigManager() { }); return grouped; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper }, [gatewaysByServer, servers]); const interfacesByCategory = useMemo(() => { @@ -547,6 +553,7 @@ function NetworkConfigManager() { }); return grouped; + // eslint-disable-next-line react-hooks/exhaustive-deps -- getServerCategory is stable helper }, [interfacesByServer, servers]); // === Сброс фильтров === @@ -1590,6 +1597,7 @@ function NetworkConfigManager() { } return a.type.localeCompare(b.type); }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- generatePtrZone/getServerInfo are stable helpers }, [config.tunnelInterfaces, servers, globalPtrZoneReplaceFrom, globalPtrZoneReplaceTo]); // === Фильтрация реестра IP === diff --git a/frontend/src/NetworkMapUnifi.jsx b/frontend/src/NetworkMapUnifi.jsx index 3e56d44..20c2834 100644 --- a/frontend/src/NetworkMapUnifi.jsx +++ b/frontend/src/NetworkMapUnifi.jsx @@ -225,7 +225,7 @@ export default function NetworkMapUnifi({ } }); setPositions(merged); - }, [servers.length, size.w, size.h]); + }, [servers, size]); const _savePositions = useCallback((next) => { setPositions(next); diff --git a/frontend/src/OspfToolsPage.jsx b/frontend/src/OspfToolsPage.jsx index ed8b81c..be1b6fc 100644 --- a/frontend/src/OspfToolsPage.jsx +++ b/frontend/src/OspfToolsPage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState, useCallback } from 'react'; import { IconDatabase, IconRefresh, IconRoute2, IconWand } from '@tabler/icons-react'; import PageHeader from './components/PageHeader.jsx'; import api from './lib/api.js'; @@ -208,7 +208,7 @@ export default function OspfToolsPage() { } }; - const loadOspf = async ({ silent = false } = {}) => { + const loadOspf = useCallback(async ({ silent = false } = {}) => { setLoading(true); try { const response = await api.get('/mikrotik/ospf-interface-templates'); @@ -227,9 +227,9 @@ export default function OspfToolsPage() { } finally { setLoading(false); } - }; + }, [servers, notify]); - const loadOptimizerHints = async ({ silent = false } = {}) => { + const loadOptimizerHints = useCallback(async ({ silent = false } = {}) => { setOptimizerLoading(true); try { const response = await api.get('/route-optimizer', { timeout: 30000 }); @@ -244,7 +244,7 @@ export default function OspfToolsPage() { } finally { setOptimizerLoading(false); } - }; + }, [servers, notify]); useEffect(() => { loadServers(); @@ -254,7 +254,7 @@ export default function OspfToolsPage() { if (servers.length === 0) return; loadOspf({ silent: true }); loadOptimizerHints({ silent: true }); - }, [servers.length]); + }, [servers.length, loadOspf, loadOptimizerHints]); const onDrop = (routerKey, area, targetKey) => { if (!dragging || dragging.routerKey !== routerKey || dragging.area !== area) return; diff --git a/frontend/src/ServerManager.jsx b/frontend/src/ServerManager.jsx index f6b92eb..590a9ce 100644 --- a/frontend/src/ServerManager.jsx +++ b/frontend/src/ServerManager.jsx @@ -147,6 +147,7 @@ function ServerManager() { fetchConnections(signal); fetchNetworkConfig(signal); return () => controller.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- init-only, fetch functions defined below }, []); useEffect(() => { @@ -303,7 +304,7 @@ function ServerManager() { useEffect(() => { if (currentPage > totalPages && totalPages >= 1) setCurrentPage(1); - }, [viewMode, totalPages]); + }, [viewMode, totalPages, currentPage]); // === Handlers === const handleSort = (field) => { diff --git a/frontend/src/UptimeMonitorPage.jsx b/frontend/src/UptimeMonitorPage.jsx index f129ac9..0822580 100644 --- a/frontend/src/UptimeMonitorPage.jsx +++ b/frontend/src/UptimeMonitorPage.jsx @@ -215,7 +215,7 @@ export default function UptimeMonitorPage() { setCurrentCheckingServerIds([]); checkingRef.current = false; setChecking(false); - }, [jumphosts, location.pathname]); + }, [jumphosts]); // Первый запуск проверки через несколько секунд после загрузки (после перезапуска контейнера кеш может быть пустым или устаревшим) useEffect(() => { @@ -239,19 +239,21 @@ export default function UptimeMonitorPage() { activeRunIdRef.current += 1; checkingRef.current = false; if (intervalRef.current) clearInterval(intervalRef.current); - checkControllersRef.current.forEach((controller) => controller.abort()); - checkControllersRef.current.clear(); + const controllers = checkControllersRef.current; + controllers.forEach((controller) => controller.abort()); + controllers.clear(); setCurrentCheckingServerIds([]); setChecking(false); }, [location.pathname]); useEffect(() => { + const controllers = checkControllersRef.current; return () => { activeRunIdRef.current += 1; checkingRef.current = false; if (intervalRef.current) clearInterval(intervalRef.current); - checkControllersRef.current.forEach((controller) => controller.abort()); - checkControllersRef.current.clear(); + controllers.forEach((controller) => controller.abort()); + controllers.clear(); }; }, []); @@ -263,7 +265,10 @@ export default function UptimeMonitorPage() { const selectedServer = selectedServerId ? jumphosts.find((s) => (s.id || s.dns || s.ip) === selectedServerId) : null; - const selectedHistory = selectedServerId ? historyMap[selectedServerId] || [] : []; + const selectedHistory = useMemo( + () => (selectedServerId ? historyMap[selectedServerId] || [] : []), + [selectedServerId, historyMap] + ); const responseTimeData = useMemo( () => historyToResponseTimeData(selectedHistory), [selectedHistory] diff --git a/frontend/src/components/AlertsBell.jsx b/frontend/src/components/AlertsBell.jsx index 4302632..bd90497 100644 --- a/frontend/src/components/AlertsBell.jsx +++ b/frontend/src/components/AlertsBell.jsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useLayoutEffect } from 'react'; +import { useState, useRef, useEffect, useLayoutEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { Link } from 'react-router-dom'; import { IconBell, IconAlertTriangle, IconServerOff, IconCpu, IconDeviceDesktop, IconDatabase } from '@tabler/icons-react'; @@ -93,7 +93,7 @@ export default function AlertsBell({ dropup = false }) { const panelRef = useRef(null); const [position, setPosition] = useState({ top: 0, left: 0 }); - const updatePosition = () => { + const updatePosition = useCallback(() => { const el = triggerRef.current; if (!el) return; const rect = el.getBoundingClientRect(); @@ -110,11 +110,11 @@ export default function AlertsBell({ dropup = false }) { left: Math.max(padding, Math.min(rect.left, maxLeft)), }); } - }; + }, [dropup]); useLayoutEffect(() => { if (open) updatePosition(); - }, [open, dropup]); + }, [open, dropup, updatePosition]); useEffect(() => { if (!open) return; @@ -137,7 +137,7 @@ export default function AlertsBell({ dropup = false }) { document.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onClickOutside); }; - }, [open]); + }, [open, updatePosition]); const handleTriggerClick = (e) => { e.preventDefault(); diff --git a/frontend/src/components/PortalDropdown.jsx b/frontend/src/components/PortalDropdown.jsx index 8750603..7437f50 100644 --- a/frontend/src/components/PortalDropdown.jsx +++ b/frontend/src/components/PortalDropdown.jsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; function getBody() { @@ -20,7 +20,7 @@ function PortalDropdown({ const [coords, setCoords] = useState({ top: 0, left: 0, minWidth: 0 }); const BASE_Z = 1035; // above navbar (1030), below modal-backdrop (1040) and modal (1050) - const updatePosition = () => { + const updatePosition = useCallback(() => { const btn = buttonRef.current; if (!btn) return; const rect = btn.getBoundingClientRect(); @@ -32,12 +32,11 @@ function PortalDropdown({ left = Math.max(0, rect.right - (menuWidth || rect.width)); } setCoords({ top: rect.bottom + window.scrollY, left: left + window.scrollX, minWidth }); - }; + }, [align]); useLayoutEffect(() => { if (open) updatePosition(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); + }, [open, updatePosition]); useEffect(() => { if (!open) return; @@ -60,7 +59,7 @@ function PortalDropdown({ document.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onClickOutside); }; - }, [open]); + }, [open, updatePosition]); const body = getBody(); diff --git a/frontend/src/components/ToastContainer.jsx b/frontend/src/components/ToastContainer.jsx index 09ad5e0..814e103 100644 --- a/frontend/src/components/ToastContainer.jsx +++ b/frontend/src/components/ToastContainer.jsx @@ -1,5 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ -import { useState, useEffect, createContext, useContext } from 'react' +import { useState, useEffect, createContext, useContext, useCallback, useMemo } from 'react' import { IconCheck, IconX, @@ -33,17 +33,17 @@ const DEFAULT_TITLES = { function Toast({ toast, onClose }) { const [isLeaving, setIsLeaving] = useState(false) + const handleClose = useCallback(() => { + setIsLeaving(true) + setTimeout(() => onClose(toast.id), 300) + }, [onClose, toast.id]) + useEffect(() => { if (toast.duration && toast.duration > 0) { const timer = setTimeout(() => handleClose(), toast.duration) return () => clearTimeout(timer) } - }, [toast.duration]) - - const handleClose = () => { - setIsLeaving(true) - setTimeout(() => onClose(toast.id), 300) - } + }, [toast.duration, handleClose]) const getIcon = () => { switch (toast.type) { @@ -122,7 +122,7 @@ function Toast({ toast, onClose }) { export function ToastContainer({ children }) { const [toasts, setToasts] = useState([]) - const addToast = (type, message, options = {}) => { + const addToast = useCallback((type, message, options = {}) => { const id = Date.now() const toast = { id, @@ -137,23 +137,23 @@ export function ToastContainer({ children }) { } setToasts(prev => [...prev, toast]) return id - } + }, []) - const removeToast = (id) => { + const removeToast = useCallback((id) => { setToasts(prev => prev.filter(t => t.id !== id)) - } + }, []) - const contextValue = { + const contextValue = useMemo(() => ({ success: (message, options) => addToast('success', message, options), error: (message, options) => addToast('error', message, options), warning: (message, options) => addToast('warning', message, options), info: (message, options) => addToast('info', message, options), - } + }), [addToast]) // Глобальный доступ через window.toast (для обратной совместимости) useEffect(() => { window.toast = contextValue - }, []) + }, [contextValue]) return ( diff --git a/frontend/src/components/server/AddServerModal.jsx b/frontend/src/components/server/AddServerModal.jsx index ab774e2..13d868e 100644 --- a/frontend/src/components/server/AddServerModal.jsx +++ b/frontend/src/components/server/AddServerModal.jsx @@ -9,19 +9,18 @@ function AddServerModal({ show, newServer, customProvider, onNewServerChange, on const modalRef = useRef(null); useEffect(() => { - if (window.Tabler && window.Tabler.Modal && modalRef.current) { - const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); + const modalEl = modalRef.current; + if (window.Tabler && window.Tabler.Modal && modalEl) { + const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl); if (show) { modalInstance.show(); } else { modalInstance.hide(); } const handler = () => onClose && onClose(); - modalRef.current.addEventListener('hide.bs.modal', handler); + modalEl.addEventListener('hide.bs.modal', handler); return () => { - if (modalRef.current) { - modalRef.current.removeEventListener('hide.bs.modal', handler); - } + modalEl.removeEventListener('hide.bs.modal', handler); }; } }, [show, onClose]); diff --git a/frontend/src/components/server/DeleteServerModal.jsx b/frontend/src/components/server/DeleteServerModal.jsx index 46998e8..5d228c0 100644 --- a/frontend/src/components/server/DeleteServerModal.jsx +++ b/frontend/src/components/server/DeleteServerModal.jsx @@ -8,19 +8,18 @@ function DeleteServerModal({ show, server, onDelete, onClose }) { const modalRef = useRef(null); useEffect(() => { - if (window.Tabler && window.Tabler.Modal && modalRef.current) { - const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); + const modalEl = modalRef.current; + if (window.Tabler && window.Tabler.Modal && modalEl) { + const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl); if (show) { modalInstance.show(); } else { modalInstance.hide(); } const handler = () => onClose && onClose(); - modalRef.current.addEventListener('hide.bs.modal', handler); + modalEl.addEventListener('hide.bs.modal', handler); return () => { - if (modalRef.current) { - modalRef.current.removeEventListener('hide.bs.modal', handler); - } + modalEl.removeEventListener('hide.bs.modal', handler); }; } }, [show, onClose]); diff --git a/frontend/src/components/server/EditServerModal.jsx b/frontend/src/components/server/EditServerModal.jsx index 4889b24..a553821 100644 --- a/frontend/src/components/server/EditServerModal.jsx +++ b/frontend/src/components/server/EditServerModal.jsx @@ -40,19 +40,18 @@ function EditServerModal({ show, server, onChange, onSave, onClose }) { }, [show, server]); useEffect(() => { - if (window.Tabler && window.Tabler.Modal && modalRef.current) { - const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); + const modalEl = modalRef.current; + if (window.Tabler && window.Tabler.Modal && modalEl) { + const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl); if (show) { modalInstance.show(); } else { modalInstance.hide(); } const handler = () => onClose && onClose(); - modalRef.current.addEventListener('hide.bs.modal', handler); + modalEl.addEventListener('hide.bs.modal', handler); return () => { - if (modalRef.current) { - modalRef.current.removeEventListener('hide.bs.modal', handler); - } + modalEl.removeEventListener('hide.bs.modal', handler); }; } }, [show, onClose]); diff --git a/frontend/src/components/server/LinkGeneratorModal.jsx b/frontend/src/components/server/LinkGeneratorModal.jsx index 6e06d00..2d1ff15 100644 --- a/frontend/src/components/server/LinkGeneratorModal.jsx +++ b/frontend/src/components/server/LinkGeneratorModal.jsx @@ -13,19 +13,18 @@ function LinkGeneratorModal({ show, server, urlSettings, onUrlSettingsChange, on }, [urlSettings]); useEffect(() => { - if (window.Tabler && window.Tabler.Modal && modalRef.current) { - const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); + const modalEl = modalRef.current; + if (window.Tabler && window.Tabler.Modal && modalEl) { + const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl); if (show) { modalInstance.show(); } else { modalInstance.hide(); } const handler = () => onClose && onClose(); - modalRef.current.addEventListener('hide.bs.modal', handler); + modalEl.addEventListener('hide.bs.modal', handler); return () => { - if (modalRef.current) { - modalRef.current.removeEventListener('hide.bs.modal', handler); - } + modalEl.removeEventListener('hide.bs.modal', handler); }; } }, [show, onClose]); diff --git a/frontend/src/components/server/ServerModal.jsx b/frontend/src/components/server/ServerModal.jsx index f77ad30..91a8408 100644 --- a/frontend/src/components/server/ServerModal.jsx +++ b/frontend/src/components/server/ServerModal.jsx @@ -118,19 +118,18 @@ function ServerModal({ show, mode = 'add', server, onSave, onClose, error: exter * Управление Bootstrap modal */ useEffect(() => { - if (window.Tabler && window.Tabler.Modal && modalRef.current) { - const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalRef.current); + const modalEl = modalRef.current; + if (window.Tabler && window.Tabler.Modal && modalEl) { + const modalInstance = window.Tabler.Modal.getOrCreateInstance(modalEl); if (show) { modalInstance.show(); } else { modalInstance.hide(); } const handler = () => onClose && onClose(); - modalRef.current.addEventListener('hide.bs.modal', handler); + modalEl.addEventListener('hide.bs.modal', handler); return () => { - if (modalRef.current) { - modalRef.current.removeEventListener('hide.bs.modal', handler); - } + modalEl.removeEventListener('hide.bs.modal', handler); }; } }, [show, onClose]); diff --git a/frontend/src/hooks/useDataManager.js b/frontend/src/hooks/useDataManager.js index a69867c..9068c63 100644 --- a/frontend/src/hooks/useDataManager.js +++ b/frontend/src/hooks/useDataManager.js @@ -3,7 +3,7 @@ * Устраняет дублирование кода в DomainsNewManager, ASNsNewManager, IPRangesManager */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import api from '../lib/api.js'; /** @@ -46,7 +46,7 @@ export function useDataManager(config) { const pageSize = 10; // Загрузка данных - const fetchItems = async () => { + const fetchItems = useCallback(async () => { setLoading(true); try { const response = await api.get(apiEndpoint, { params: { offset: 0, limit: 0, format: 'std' } }); @@ -69,14 +69,14 @@ export function useDataManager(config) { } finally { setLoading(false); } - }; + }, [apiEndpoint, transformResponse]); useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } - }, []); + }, [fetchItems]); // Добавление элемента const addItem = (newItem) => { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 7e566da..a10de3b 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,6 +4,9 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + build: { + chunkSizeWarningLimit: 2000, + }, server: { proxy: { '/api': {