refactor(api): implement abort signal handling in API requests across multiple components to improve cancellation of ongoing requests and prevent memory leaks
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m12s
This commit is contained in:
@@ -100,16 +100,23 @@ function ASNsNewManager() {
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Load WS URL once
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {}
|
||||
try {
|
||||
const r = await api.get('/ws/url', { signal: controller.signal });
|
||||
setWsUrl(String(r.data?.url || ''));
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
const fetchItems = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) }));
|
||||
const total = mapped.length;
|
||||
@@ -122,6 +129,7 @@ function ASNsNewManager() {
|
||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error fetching ASNs:', error);
|
||||
setError('Не удалось загрузить список ASN. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
@@ -129,7 +137,14 @@ function ASNsNewManager() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||
useEffect(() => {
|
||||
if (!didInit.current) {
|
||||
didInit.current = true;
|
||||
const controller = new AbortController();
|
||||
fetchItems(controller.signal);
|
||||
return () => controller.abort();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddItem = (item) => {
|
||||
const asn = item?.asn || newItem.asn;
|
||||
|
||||
@@ -113,9 +113,12 @@ function BillingManager() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchBillingData();
|
||||
fetchExchangeRates();
|
||||
fetchServers();
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
fetchBillingData(signal);
|
||||
fetchExchangeRates(signal);
|
||||
fetchServers(signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Автоматическая синхронизация: добавляем серверы без записей в биллинге
|
||||
@@ -162,11 +165,12 @@ function BillingManager() {
|
||||
}, [servers, loading]);
|
||||
|
||||
|
||||
const fetchExchangeRates = async () => {
|
||||
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');
|
||||
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;
|
||||
|
||||
@@ -180,12 +184,13 @@ function BillingManager() {
|
||||
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');
|
||||
const response = await axios.get('https://open.er-api.com/v6/latest/USD', opts);
|
||||
const rates = response.data?.rates || {};
|
||||
setExchangeRates({
|
||||
USD: 1,
|
||||
@@ -193,6 +198,7 @@ function BillingManager() {
|
||||
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 });
|
||||
@@ -201,19 +207,22 @@ function BillingManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchServers = async () => {
|
||||
const fetchServers = async (abortSignal) => {
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const res = await api.get(`/servers`);
|
||||
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 () => {
|
||||
const fetchBillingData = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const response = await api.get(`/billing`);
|
||||
const response = await api.get(`/billing`, opts);
|
||||
const rates = exchangeRates; // из closure, на первой загрузке может быть {1,1,1} — для USD достаточно
|
||||
const toRUB = (amt, ccy) => {
|
||||
if (!amt) return 0;
|
||||
@@ -251,6 +260,7 @@ function BillingManager() {
|
||||
setBillingData(normalized);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||
console.error('Ошибка при загрузке данных биллинга:', err);
|
||||
setError('Ошибка при загрузке данных');
|
||||
setBillingData([]);
|
||||
|
||||
+29
-17
@@ -202,18 +202,21 @@ function Dashboard() {
|
||||
const [expandedPingId, setExpandedPingId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
async function fetchStats() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Загружаем данные с обработкой ошибок для каждого endpoint
|
||||
const opts = { signal };
|
||||
const results = await Promise.allSettled([
|
||||
api.get('/domains-new', { params: { countOnly: true } }),
|
||||
api.get('/ip-ranges', { params: { countOnly: true } }),
|
||||
api.get('/asns', { params: { countOnly: true } }),
|
||||
api.get('/servers'),
|
||||
api.get('/servers/availability', { params: { ttlSeconds: 60 } }),
|
||||
api.get('/s3/last-modified')
|
||||
api.get('/domains-new', { params: { countOnly: true }, ...opts }),
|
||||
api.get('/ip-ranges', { params: { countOnly: true }, ...opts }),
|
||||
api.get('/asns', { params: { countOnly: true }, ...opts }),
|
||||
api.get('/servers', opts),
|
||||
api.get('/servers/availability', { params: { ttlSeconds: 60 }, ...opts }),
|
||||
api.get('/s3/last-modified', opts)
|
||||
]);
|
||||
|
||||
// Обрабатываем результаты
|
||||
@@ -278,6 +281,7 @@ function Dashboard() {
|
||||
console.warn('Некоторые API недоступны:', errors.map(e => e.reason?.message));
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.error('Критическая ошибка загрузки статистики:', e);
|
||||
setError('Не удалось загрузить статистику');
|
||||
setStats({
|
||||
@@ -296,23 +300,26 @@ function Dashboard() {
|
||||
}
|
||||
}
|
||||
fetchStats();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get('/ping-services-list')
|
||||
const controller = new AbortController();
|
||||
api.get('/ping-services-list', { signal: controller.signal })
|
||||
.then(({ data }) => {
|
||||
if (!cancelled && Array.isArray(data?.list)) setPingServicesConfig(data.list);
|
||||
if (Array.isArray(data?.list)) setPingServicesConfig(data.list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPingServicesConfig([]);
|
||||
.catch((e) => {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setPingServicesConfig([]);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
function fetchPingServices(forceRefresh = false) {
|
||||
function fetchPingServices(forceRefresh = false, abortSignal) {
|
||||
setPingLoading(true);
|
||||
api.get('/ping-services', { params: forceRefresh ? { refresh: 1 } : {} })
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
api.get('/ping-services', { params: forceRefresh ? { refresh: 1 } : {}, ...opts })
|
||||
.then(({ data }) => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const byId = data.byId ?? data;
|
||||
@@ -320,12 +327,17 @@ function Dashboard() {
|
||||
setPingServices(byId);
|
||||
setPingHistory(typeof history === 'object' ? history : {});
|
||||
})
|
||||
.catch(() => setPingServices(null))
|
||||
.catch((e) => {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setPingServices(null);
|
||||
})
|
||||
.finally(() => setPingLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPingServices();
|
||||
const controller = new AbortController();
|
||||
fetchPingServices(false, controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -86,14 +86,25 @@ function DomainsNewManager() {
|
||||
// Справочник community для подсказок
|
||||
const [communities, setCommunities] = useState([]);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/communities`);
|
||||
const res = await api.get(`/communities`, { signal });
|
||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
// тихо игнорируем
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api.get('/ws/url', { signal });
|
||||
setWsUrl(String(r.data?.url || ''));
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
|
||||
@@ -110,14 +121,11 @@ function DomainsNewManager() {
|
||||
const [wsOpen, setWsOpen] = useState(false);
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
const fetchItems = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const total = payload.length;
|
||||
setItems(payload);
|
||||
@@ -129,6 +137,7 @@ function DomainsNewManager() {
|
||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error fetching domains-new:', error);
|
||||
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
@@ -136,8 +145,14 @@ function DomainsNewManager() {
|
||||
}
|
||||
};
|
||||
|
||||
// Подгрузка при изменении страницы/поиска/фильтра
|
||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||
useEffect(() => {
|
||||
if (!didInit.current) {
|
||||
didInit.current = true;
|
||||
const controller = new AbortController();
|
||||
fetchItems(controller.signal);
|
||||
return () => controller.abort();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddItem = (item) => {
|
||||
// Если item передан (из модалки), используем его, иначе из state (старый способ)
|
||||
|
||||
@@ -57,39 +57,46 @@ function EasySwitchManager() {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const initData = async () => {
|
||||
try {
|
||||
// Загружаем communities
|
||||
const commRes = await api.get(`/communities`);
|
||||
const commRes = await api.get(`/communities`, { signal });
|
||||
const communities = Array.isArray(commRes.data) ? commRes.data : [];
|
||||
setCommunitiesDirectory(communities);
|
||||
|
||||
|
||||
// Загружаем inventory servers
|
||||
const invRes = await api.get(`/servers`);
|
||||
const invRes = await api.get(`/servers`, { signal });
|
||||
const inventory = Array.isArray(invRes.data) ? invRes.data : [];
|
||||
setInventoryServers(inventory);
|
||||
|
||||
// Загружаем network-config (gateways и туннели)
|
||||
let ncGateways = [];
|
||||
try {
|
||||
const ncRes = await api.get(`/network-config`);
|
||||
const ncRes = await api.get(`/network-config`, { signal });
|
||||
const ncData = ncRes.data || {};
|
||||
ncGateways = Array.isArray(ncData.gateways) ? ncData.gateways : [];
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
// Если файла нет или ошибка - используем пустой массив (fallback на старую логику)
|
||||
console.warn('EasySwitch: не удалось загрузить /network-config, используется fallback:', e?.message || e);
|
||||
ncGateways = [];
|
||||
}
|
||||
setNetworkConfigGateways(ncGateways);
|
||||
|
||||
|
||||
// Загружаем данные серверов и фильтров
|
||||
await loadDataWithParams(communities, inventory, ncGateways);
|
||||
await loadDataWithParams(communities, inventory, ncGateways, signal);
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error initializing data:', e);
|
||||
setError('Ошибка загрузки данных');
|
||||
}
|
||||
};
|
||||
initData();
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// При переходе на раздел Easy Switch перезапрашиваем пинги (устаревшие по TTL обновятся)
|
||||
@@ -111,21 +118,23 @@ function EasySwitchManager() {
|
||||
requestPings(pingTasks);
|
||||
}, [location.pathname, servers, inventoryServers, requestPings]);
|
||||
|
||||
const loadDataWithParams = async (communities, inventory, ncGatewaysParam) => {
|
||||
const loadDataWithParams = async (communities, inventory, ncGatewaysParam, signal) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
|
||||
const requestConfig = signal ? { signal } : {};
|
||||
|
||||
try {
|
||||
// Загружаем список серверов из server-configs
|
||||
const serversResponse = await api.get(`/server-configs`);
|
||||
const serversResponse = await api.get(`/server-configs`, requestConfig);
|
||||
const serversList = Array.isArray(serversResponse.data) ? serversResponse.data : [];
|
||||
|
||||
|
||||
// Загружаем фильтры для каждого сервера (для определения активных gateways)
|
||||
const serversWithData = await Promise.all(
|
||||
serversList.map(async (server) => {
|
||||
try {
|
||||
// Загружаем filters для сервера
|
||||
const filtersResponse = await api.get(`/server-filters/${server.id}`);
|
||||
const filtersResponse = await api.get(`/server-filters/${server.id}`, requestConfig);
|
||||
const filters = Array.isArray(filtersResponse.data) ? filtersResponse.data : [];
|
||||
|
||||
// Получаем gateways сервера:
|
||||
@@ -283,6 +292,7 @@ function EasySwitchManager() {
|
||||
} catch (_) {}
|
||||
|
||||
} catch (err) {
|
||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error loading data:', err);
|
||||
setError('Ошибка загрузки данных. Проверьте подключение к серверу.');
|
||||
} finally {
|
||||
|
||||
@@ -896,47 +896,51 @@ function FilterManager() {
|
||||
}, [location.pathname, selectedServer, selectedServerGateways, requestPings]);
|
||||
|
||||
useEffect(() => {
|
||||
// Загружаем базовую AS из настроек
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
const opts = { signal };
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get('/ui-settings');
|
||||
const res = await api.get('/ui-settings', opts);
|
||||
const data = res?.data || {};
|
||||
const loadedBaseAS = String(data?.baseAS || '65001');
|
||||
setBaseAS(loadedBaseAS);
|
||||
} catch (e) {
|
||||
// мягко игнорируем, используем значение по умолчанию
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/communities`);
|
||||
const res = await api.get(`/communities`, opts);
|
||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
// мягко игнорируем, автокомплит необязателен
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
// Загружаем инвентарь серверов для отображения флагов и IP
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/servers`);
|
||||
const res = await api.get(`/servers`, opts);
|
||||
setInventoryServers(Array.isArray(res.data) ? res.data : []);
|
||||
} catch (e) {
|
||||
// мягко игнорируем, дополнительная информация необязательна
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
|
||||
// Загружаем gateways из NetworkConfigManager
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await api.get(`/network-config`);
|
||||
const res = await api.get(`/network-config`, opts);
|
||||
const networkConfig = res.data || {};
|
||||
setNetworkConfigGateways(Array.isArray(networkConfig.gateways) ? networkConfig.gateways : []);
|
||||
} catch (e) {
|
||||
// Если файла нет или ошибка - используем пустой массив (fallback на старую логику)
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setNetworkConfigGateways([]);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const filterKey = (f) => {
|
||||
@@ -945,7 +949,9 @@ function FilterManager() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
const controller = new AbortController();
|
||||
fetchServers(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Загружаем фильтры при выборе сервера
|
||||
@@ -1003,11 +1009,12 @@ function FilterManager() {
|
||||
// Функции для управления серверами
|
||||
// Загружаем jumphost'ы из раздела "Серверы" (/api/servers)
|
||||
// + загружаем старые server-configs для маппинга legacyId
|
||||
const fetchServers = async () => {
|
||||
const fetchServers = async (abortSignal) => {
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const [serversRes, legacyRes] = await Promise.all([
|
||||
api.get(`/servers`),
|
||||
api.get(`/server-configs`).catch(() => ({ data: [] })) // Старые конфиги для маппинга
|
||||
api.get(`/servers`, opts),
|
||||
api.get(`/server-configs`, opts).catch(() => ({ data: [] })) // Старые конфиги для маппинга
|
||||
]);
|
||||
|
||||
const allServers = Array.isArray(serversRes.data) ? serversRes.data : [];
|
||||
@@ -1064,6 +1071,7 @@ function FilterManager() {
|
||||
|
||||
setServers(jumphosts);
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error fetching servers:', error);
|
||||
setServers([]);
|
||||
}
|
||||
|
||||
@@ -114,13 +114,23 @@ function IPRangesManager() {
|
||||
const [wsUrl, setWsUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
(async () => { try { const r = await api.get('/ws/url'); setWsUrl(String(r.data?.url || '')); } catch {} })();
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api.get('/ws/url', { signal: controller.signal });
|
||||
setWsUrl(String(r.data?.url || ''));
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
const fetchItems = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' } });
|
||||
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
const total = payload.length;
|
||||
setItems(payload);
|
||||
@@ -132,6 +142,7 @@ function IPRangesManager() {
|
||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
||||
setError('');
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error fetching ip-ranges:', error);
|
||||
setError('Не удалось загрузить IP-диапазоны. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
@@ -139,7 +150,14 @@ function IPRangesManager() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||
useEffect(() => {
|
||||
if (!didInit.current) {
|
||||
didInit.current = true;
|
||||
const controller = new AbortController();
|
||||
fetchItems(controller.signal);
|
||||
return () => controller.abort();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAddItem = (item) => {
|
||||
const ipRange = item?.ipRange || newItem.ipRange;
|
||||
|
||||
@@ -30,13 +30,16 @@ export default function InterfaceSpeedTest() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [serversRes, netRes, uiRes] = await Promise.all([
|
||||
api.get('/servers'),
|
||||
api.get('/network-config').catch(() => ({ data: null })),
|
||||
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||
api.get('/servers', { signal }),
|
||||
api.get('/network-config', { signal }).catch(() => ({ data: null })),
|
||||
api.get('/ui-settings', { signal }).catch(() => ({ data: {} })),
|
||||
]);
|
||||
|
||||
setServers(Array.isArray(serversRes?.data) ? serversRes.data : []);
|
||||
@@ -69,6 +72,7 @@ export default function InterfaceSpeedTest() {
|
||||
cacheMinutes,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.error('[InterfaceSpeedTest] load failed', e);
|
||||
notify.error('Не удалось загрузить данные для замера скорости');
|
||||
} finally {
|
||||
@@ -76,6 +80,7 @@ export default function InterfaceSpeedTest() {
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => controller.abort();
|
||||
}, [notify]);
|
||||
|
||||
const gateways = useMemo(
|
||||
|
||||
@@ -157,13 +157,18 @@ function MikrotikBackupsManager() {
|
||||
const backupPageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
fetchUiSettings();
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
fetchServers(signal);
|
||||
fetchUiSettings(signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedServerId) {
|
||||
fetchBackups(selectedServerId);
|
||||
const controller = new AbortController();
|
||||
fetchBackups(selectedServerId, controller.signal);
|
||||
return () => controller.abort();
|
||||
} else {
|
||||
setBackups([]);
|
||||
setSelectedKeys([]);
|
||||
@@ -314,10 +319,11 @@ function MikrotikBackupsManager() {
|
||||
setBackupServerIds(new Set());
|
||||
};
|
||||
|
||||
const fetchServers = async () => {
|
||||
const fetchServers = async (abortSignal) => {
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
setLoadingServers(true);
|
||||
const res = await api.get('/servers');
|
||||
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');
|
||||
@@ -325,6 +331,7 @@ function MikrotikBackupsManager() {
|
||||
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 {
|
||||
@@ -332,9 +339,10 @@ function MikrotikBackupsManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUiSettings = async () => {
|
||||
const fetchUiSettings = async (abortSignal) => {
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const res = await api.get('/ui-settings');
|
||||
const res = await api.get('/ui-settings', opts);
|
||||
const data = res?.data || {};
|
||||
setUiSettings(data);
|
||||
const list = Array.isArray(data.mikrotikBackupServers) ? data.mikrotikBackupServers : [];
|
||||
@@ -342,21 +350,23 @@ function MikrotikBackupsManager() {
|
||||
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) => {
|
||||
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 } });
|
||||
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 {
|
||||
|
||||
@@ -86,16 +86,17 @@ export default function NetworkMapDashboard() {
|
||||
const cacheAppliedAtRef = useRef(null);
|
||||
|
||||
/** Связи строятся из интерфейсов раздела «Сетевые настройки» (/network-config), tunnelInterfaces */
|
||||
const fetchData = useCallback(async () => {
|
||||
const fetchData = useCallback(async (abortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
cacheAppliedAtRef.current = null;
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const [serversRes, configRes, uiSettingsRes, cacheRes] = await Promise.all([
|
||||
api.get('/servers'),
|
||||
api.get('/network-config').catch(() => ({ data: null })),
|
||||
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||
api.get('/network-map-cache').catch(() => ({ data: {} })),
|
||||
api.get('/servers', opts),
|
||||
api.get('/network-config', opts).catch(() => ({ data: null })),
|
||||
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
|
||||
api.get('/network-map-cache', opts).catch(() => ({ data: {} })),
|
||||
]);
|
||||
const serversList = Array.isArray(serversRes?.data) ? serversRes.data : [];
|
||||
const config = configRes?.data || {};
|
||||
@@ -181,6 +182,7 @@ export default function NetworkMapDashboard() {
|
||||
setSpeedStaleMap({});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.error('NetworkMap fetch:', e);
|
||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить данные');
|
||||
setServers([]);
|
||||
@@ -342,7 +344,9 @@ export default function NetworkMapDashboard() {
|
||||
}, [connections]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const controller = new AbortController();
|
||||
fetchData(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [fetchData]);
|
||||
|
||||
// Пинг и скорость не запускаются автоматически при открытии карты (даже если кеш просрочен).
|
||||
|
||||
@@ -33,9 +33,10 @@ export default function NetworkMapSchedulerPage() {
|
||||
const logContainerRef = useRef(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
const fetchSettings = useCallback(async (abortSignal) => {
|
||||
try {
|
||||
const { data } = await api.get('/scheduler/network-map/settings');
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
const { data } = await api.get('/scheduler/network-map/settings', opts);
|
||||
setSettings({
|
||||
enabled: Boolean(data.enabled),
|
||||
intervalMinutes: Math.max(INTERVAL_MIN, Math.min(INTERVAL_MAX, Number(data.intervalMinutes) || 5)),
|
||||
@@ -45,6 +46,7 @@ export default function NetworkMapSchedulerPage() {
|
||||
});
|
||||
setNextRunAt(typeof data.nextRunAt === 'number' ? data.nextRunAt : null);
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить настройки');
|
||||
}
|
||||
}, []);
|
||||
@@ -59,13 +61,13 @@ export default function NetworkMapSchedulerPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
await fetchSettings();
|
||||
if (!cancelled) setLoading(false);
|
||||
await fetchSettings(controller.signal);
|
||||
setLoading(false);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
return () => controller.abort();
|
||||
}, [fetchSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -58,11 +58,12 @@ export default function PingServicesManager() {
|
||||
const [editingItem, setEditingItem] = useState(null);
|
||||
const [editDraft, setEditDraft] = useState({ id: '', name: '', host: '', port: '443', icon: 'IconBrandGoogle', color: 'blue' });
|
||||
|
||||
const loadSettings = async () => {
|
||||
const loadSettings = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const res = await api.get('/ui-settings');
|
||||
const res = await api.get('/ui-settings', opts);
|
||||
const data = res?.data || {};
|
||||
setRawSettings(data);
|
||||
setEtag(String(res?.headers?.etag || res?.headers?.ETag || ''));
|
||||
@@ -76,6 +77,7 @@ export default function PingServicesManager() {
|
||||
color: typeof s?.color === 'string' ? s.color : 'primary',
|
||||
})).filter((s) => s.host));
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.error(e);
|
||||
setError('Не удалось загрузить настройки');
|
||||
} finally {
|
||||
@@ -84,7 +86,9 @@ export default function PingServicesManager() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
const controller = new AbortController();
|
||||
loadSettings(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const saveSettings = async () => {
|
||||
|
||||
@@ -146,14 +146,16 @@ export default function ResourceStatsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
const fetchStats = useCallback(async (abortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const res = await api.get('/resources/stats');
|
||||
const res = await api.get('/resources/stats', opts);
|
||||
const data = res?.data;
|
||||
setRouters(Array.isArray(data?.routers) ? data.routers : []);
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
|
||||
setRouters([]);
|
||||
} finally {
|
||||
@@ -162,7 +164,9 @@ export default function ResourceStatsPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const controller = new AbortController();
|
||||
fetchStats(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [fetchStats]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -127,21 +127,26 @@ function ServerManager() {
|
||||
};
|
||||
|
||||
// === Загрузка данных ===
|
||||
const fetchNetworkConfig = async () => {
|
||||
const fetchNetworkConfig = async (abortSignal) => {
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const res = await api.get('/network-config');
|
||||
const res = await api.get('/network-config', opts);
|
||||
const config = res.data || {};
|
||||
setNetworkConfigGateways(Array.isArray(config.gateways) ? config.gateways : []);
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.warn('Network config not loaded:', e?.message || e);
|
||||
setNetworkConfigGateways([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
fetchConnections();
|
||||
fetchNetworkConfig();
|
||||
const controller = new AbortController();
|
||||
const signal = controller.signal;
|
||||
fetchServers(signal);
|
||||
fetchConnections(signal);
|
||||
fetchNetworkConfig(signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,10 +161,11 @@ function ServerManager() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchServers = async () => {
|
||||
const fetchServers = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const response = await api.get(`/servers`);
|
||||
const response = await api.get(`/servers`, opts);
|
||||
const normalized = (Array.isArray(response.data) ? response.data : []).map((srv) => ({
|
||||
...srv,
|
||||
type: normalizeServerType(srv.type),
|
||||
@@ -167,6 +173,7 @@ function ServerManager() {
|
||||
}));
|
||||
setServers(normalized);
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error fetching servers:', error);
|
||||
notify.error('Не удалось загрузить серверы. Проверьте, запущен ли бэкенд.');
|
||||
} finally {
|
||||
@@ -174,15 +181,17 @@ function ServerManager() {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchConnections = async () => {
|
||||
const fetchConnections = async (abortSignal) => {
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const response = await api.get(`/server-connections`);
|
||||
const response = await api.get(`/server-connections`, opts);
|
||||
if (Array.isArray(response.data)) {
|
||||
setConnections(response.data);
|
||||
} else {
|
||||
setConnections([]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||
console.error('Error fetching server connections:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,18 +181,20 @@ export default function TrafficDashboard() {
|
||||
|
||||
const interfaceFilter = getTrafficInterfaceFilter(uiSettings);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
const fetchStats = useCallback(async (abortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const [statsRes, settingsRes] = await Promise.all([
|
||||
api.get('/traffic/interface-stats'),
|
||||
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||
api.get('/traffic/interface-stats', opts),
|
||||
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
|
||||
]);
|
||||
const data = statsRes?.data || {};
|
||||
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
|
||||
setUiSettings(settingsRes?.data || {});
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
console.error('Traffic stats:', e);
|
||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
|
||||
setJumphosts([]);
|
||||
@@ -202,7 +204,9 @@ export default function TrafficDashboard() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const controller = new AbortController();
|
||||
fetchStats(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [fetchStats]);
|
||||
|
||||
const filteredJumphosts = useMemo(() => {
|
||||
|
||||
@@ -103,13 +103,16 @@ export default function UptimeMonitorPage() {
|
||||
[servers]
|
||||
);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
const controllerRef = useRef(null);
|
||||
|
||||
const fetchServers = useCallback(async (abortSignal) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const [serversRes, settingsRes] = await Promise.all([
|
||||
api.get('/servers'),
|
||||
api.get('/ui-settings').catch(() => ({ data: {} })),
|
||||
api.get('/servers', opts),
|
||||
api.get('/ui-settings', opts).catch(() => ({ data: {} })),
|
||||
]);
|
||||
const data = serversRes?.data;
|
||||
setServers(Array.isArray(data) ? data : []);
|
||||
@@ -117,6 +120,7 @@ export default function UptimeMonitorPage() {
|
||||
const interval = Math.max(30, Math.min(86400, parseInt(ui.uptimeMonitorIntervalSeconds, 10) || DEFAULT_CHECK_INTERVAL_SEC));
|
||||
setCheckIntervalSeconds(interval);
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить серверы');
|
||||
setServers([]);
|
||||
} finally {
|
||||
@@ -125,19 +129,29 @@ export default function UptimeMonitorPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
const c = new AbortController();
|
||||
controllerRef.current = c;
|
||||
fetchServers(c.signal);
|
||||
return () => {
|
||||
c.abort();
|
||||
controllerRef.current = null;
|
||||
};
|
||||
}, [fetchServers]);
|
||||
|
||||
// При заходе на страницу подставляем кеш — сразу видны последние проверки
|
||||
useEffect(() => {
|
||||
if (jumphosts.length === 0) return;
|
||||
api.get('/uptime/cache')
|
||||
const signal = controllerRef.current?.signal;
|
||||
if (!signal) return;
|
||||
api.get('/uptime/cache', { signal })
|
||||
.then(({ data }) => {
|
||||
if (data?.results && typeof data.results === 'object' && Object.keys(data.results).length > 0) {
|
||||
setStatusMap((prev) => ({ ...prev, ...data.results }));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((e) => {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
});
|
||||
}, [jumphosts]);
|
||||
|
||||
const checkIntervalMs = checkIntervalSeconds * 1000;
|
||||
|
||||
@@ -7,19 +7,25 @@ function S3MetaBar({ keys = [], className = '' }) {
|
||||
const [meta, setMeta] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchMeta = async () => {
|
||||
const fetchMeta = async (abortSignal) => {
|
||||
setLoading(true);
|
||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
||||
try {
|
||||
const res = await api.get('/s3/last-modified');
|
||||
const res = await api.get('/s3/last-modified', opts);
|
||||
setMeta(res.data || {});
|
||||
} catch {
|
||||
} catch (e) {
|
||||
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
|
||||
setMeta({});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { fetchMeta(); }, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchMeta(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
const copyMeta = async (k) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user