feat: enhance various components with useCallback for performance optimization and improve chunk size warning limit in Vite config
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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() === '') {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -225,7 +225,7 @@ export default function NetworkMapUnifi({
|
||||
}
|
||||
});
|
||||
setPositions(merged);
|
||||
}, [servers.length, size.w, size.h]);
|
||||
}, [servers, size]);
|
||||
|
||||
const _savePositions = useCallback((next) => {
|
||||
setPositions(next);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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': {
|
||||
|
||||
Reference in New Issue
Block a user