feat: enhance various components with useCallback for performance optimization and improve chunk size warning limit in Vite config
Frontend CI / frontend (push) Has been cancelled
Publish Docker image / build-and-push (push) Failing after 56s

This commit is contained in:
2026-03-16 00:07:25 +07:00
parent 972f3ab5ca
commit 50a7edd50f
25 changed files with 360 additions and 364 deletions
+29 -28
View File
@@ -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') {
+3 -3
View File
@@ -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 {
+103 -106
View File
@@ -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 {
+7 -7
View File
@@ -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() === '') {
+21 -27
View File
@@ -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') {
+5 -4
View File
@@ -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}`;
+6 -8
View File
@@ -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();
+4 -4
View File
@@ -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;
+38 -44
View File
@@ -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) => (<CommunityBadge value={value} communities={communities} />);
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') {
+60 -60
View File
@@ -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)) {
+8
View File
@@ -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 ===
+1 -1
View File
@@ -225,7 +225,7 @@ export default function NetworkMapUnifi({
}
});
setPositions(merged);
}, [servers.length, size.w, size.h]);
}, [servers, size]);
const _savePositions = useCallback((next) => {
setPositions(next);
+6 -6
View File
@@ -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;
+2 -1
View File
@@ -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) => {
+11 -6
View File
@@ -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]
+5 -5
View File
@@ -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();
+5 -6
View File
@@ -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();
+14 -14
View File
@@ -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 (
<ToastContext.Provider value={contextValue}>
@@ -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]);
@@ -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]);
@@ -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]);
@@ -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]);
@@ -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]);
+4 -4
View File
@@ -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) => {
+3
View File
@@ -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': {