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': {