From f1b84a761e3ae6282dcf91db0cdd559a363df0e7 Mon Sep 17 00:00:00 2001 From: Denis Shatskiy Date: Mon, 11 Aug 2025 16:46:27 +0700 Subject: [PATCH] refactor: Replace axios with api module in ASNs, Domains, IPRanges managers, and S3MetaBar for consistent API interaction and improved code maintainability --- frontend/src/ASNsNewManager.jsx | 17 +++++++----- frontend/src/DomainsNewManager.jsx | 17 +++++++----- frontend/src/IPRangesManager.jsx | 17 +++++++----- frontend/src/components/S3MetaBar.jsx | 4 +-- frontend/src/components/TableSkeleton.jsx | 29 ++++++++++++++++++++ frontend/src/lib/api.js | 33 +++++++++++++++++++++++ 6 files changed, 97 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/TableSkeleton.jsx create mode 100644 frontend/src/lib/api.js diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index dc6a4d2..24c227f 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import axios from 'axios'; +import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, @@ -18,6 +18,7 @@ import { IconFileText } from '@tabler/icons-react'; import LockBanner from './components/LockBanner.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; import S3MetaBar from './components/S3MetaBar.jsx'; import ConfirmDiffModal from './components/ConfirmDiffModal.jsx'; @@ -51,7 +52,7 @@ function ASNsNewManager() { useEffect(() => { (async () => { try { - const res = await axios.get(`${API_URL}/communities`); + const res = await api.get(`/communities`); setCommunities(Array.isArray(res.data) ? res.data : []); } catch (e) { // тихо игнорируем @@ -81,21 +82,21 @@ function ASNsNewManager() { localStorage.setItem('uiOwner', owner); const resource = 'asns'; const acquire = async () => { - try { await axios.post(`${API_URL}/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} + try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} }; acquire(); const interval = setInterval(acquire, 60_000); fetchItems(); return () => { clearInterval(interval); - axios.delete(`${API_URL}/locks/${resource}`).catch(() => {}); + api.delete(`/locks/${resource}`).catch(() => {}); }; }, []); const fetchItems = async () => { setLoading(true); try { - const response = await axios.get(`${API_URL}/asns`, { params: { offset: 0, limit: 0 } }); + const response = await api.get(`/asns`, { params: { offset: 0, limit: 0 } }); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []); const mapped = payload.map(item => ({ asn: item.domain, community: item.type })); setItems(mapped); @@ -223,7 +224,7 @@ function ASNsNewManager() { .map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() })); const unique = deduplicate(valid); const payload = { domains: unique.map(i => ({ domain: i.asn, type: i.community })), etag }; - const response = await axios.post(`${API_URL}/asns`, payload, { validateStatus: () => true }); + const response = await api.post(`/asns`, payload, { validateStatus: () => true }); if (response.status === 412) { setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.'); return; @@ -548,6 +549,9 @@ function ASNsNewManager() { {/* Мета-информация будет показана внизу карточки */} + {loading ? ( + + ) : (
@@ -600,6 +604,7 @@ function ASNsNewManager() {
+ )} {/* Пагинация */} {totalPages > 1 && (
diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index a7f4787..7cbd4f8 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import axios from 'axios'; +import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, @@ -18,6 +18,7 @@ import { IconFileText } from '@tabler/icons-react'; import LockBanner from './components/LockBanner.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; import S3MetaBar from './components/S3MetaBar.jsx'; import ConfirmDiffModal from './components/ConfirmDiffModal.jsx'; @@ -51,7 +52,7 @@ function DomainsNewManager() { useEffect(() => { (async () => { try { - const res = await axios.get(`${API_URL}/communities`); + const res = await api.get(`/communities`); setCommunities(Array.isArray(res.data) ? res.data : []); } catch (e) { // тихо игнорируем @@ -86,21 +87,21 @@ function DomainsNewManager() { localStorage.setItem('uiOwner', owner); const resource = 'domains-new'; const acquire = async () => { - try { await axios.post(`${API_URL}/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} + try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} }; acquire(); const interval = setInterval(acquire, 60_000); fetchItems(); return () => { clearInterval(interval); - axios.delete(`${API_URL}/locks/${resource}`).catch(() => {}); + api.delete(`/locks/${resource}`).catch(() => {}); }; }, []); const fetchItems = async () => { setLoading(true); try { - const response = await axios.get(`${API_URL}/domains-new`, { params: { offset: 0, limit: 0 } }); + const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []); setItems(payload); setOriginalItems(payload); @@ -227,7 +228,7 @@ function DomainsNewManager() { const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community)) .map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() })); const unique = deduplicate(valid); - const response = await axios.post(`${API_URL}/domains-new`, { domains: unique, etag }, { validateStatus: () => true }); + const response = await api.post(`/domains-new`, { domains: unique, etag }, { validateStatus: () => true }); if (response.status === 412) { setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.'); return; @@ -556,6 +557,9 @@ function DomainsNewManager() {
{/* Мета-информация будет показана внизу карточки */} + {loading ? ( + + ) : (
@@ -608,6 +612,7 @@ function DomainsNewManager() {
+ )} {/* Пагинация */} {totalPages > 1 && (
diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index fd5d801..0efc041 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import axios from 'axios'; +import api from './lib/api.js'; import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx'; import { IconPlus, @@ -18,6 +18,7 @@ import { IconFileText } from '@tabler/icons-react'; import LockBanner from './components/LockBanner.jsx'; +import TableSkeleton from './components/TableSkeleton.jsx'; import S3MetaBar from './components/S3MetaBar.jsx'; import ConfirmDiffModal from './components/ConfirmDiffModal.jsx'; @@ -51,7 +52,7 @@ function IPRangesManager() { useEffect(() => { (async () => { try { - const res = await axios.get(`${API_URL}/communities`); + const res = await api.get(`/communities`); setCommunities(Array.isArray(res.data) ? res.data : []); } catch (e) { // тихо игнорируем @@ -97,21 +98,21 @@ function IPRangesManager() { localStorage.setItem('uiOwner', owner); const resource = 'ip-ranges'; const acquire = async () => { - try { await axios.post(`${API_URL}/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} + try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {} }; acquire(); const interval = setInterval(acquire, 60_000); fetchItems(); return () => { clearInterval(interval); - axios.delete(`${API_URL}/locks/${resource}`).catch(() => {}); + api.delete(`/locks/${resource}`).catch(() => {}); }; }, []); const fetchItems = async () => { setLoading(true); try { - const response = await axios.get(`${API_URL}/ip-ranges`, { params: { offset: 0, limit: 0 } }); + const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }); const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []); setItems(payload); setOriginalItems(payload); @@ -237,7 +238,7 @@ function IPRangesManager() { const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community)) .map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() })); const unique = deduplicate(valid); - const response = await axios.post(`${API_URL}/ip-ranges`, { ipRanges: unique, etag }, { validateStatus: () => true }); + const response = await api.post(`/ip-ranges`, { ipRanges: unique, etag }, { validateStatus: () => true }); if (response.status === 412) { setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.'); return; @@ -564,6 +565,9 @@ function IPRangesManager() {
{/* Мета-информация будет показана внизу карточки */} + {loading ? ( + + ) : (
@@ -616,6 +620,7 @@ function IPRangesManager() {
+ )} {/* Пагинация */} {totalPages > 1 && (
diff --git a/frontend/src/components/S3MetaBar.jsx b/frontend/src/components/S3MetaBar.jsx index c9ba546..5a81f75 100644 --- a/frontend/src/components/S3MetaBar.jsx +++ b/frontend/src/components/S3MetaBar.jsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import axios from 'axios'; +import api from '../lib/api.js'; import { IconHash, IconClock, IconFileText, IconRefresh } from '@tabler/icons-react'; function S3MetaBar({ keys = [], className = '' }) { @@ -9,7 +9,7 @@ function S3MetaBar({ keys = [], className = '' }) { const fetchMeta = async () => { setLoading(true); try { - const res = await axios.get('/api/s3/last-modified'); + const res = await api.get('/s3/last-modified'); setMeta(res.data || {}); } catch { setMeta({}); diff --git a/frontend/src/components/TableSkeleton.jsx b/frontend/src/components/TableSkeleton.jsx new file mode 100644 index 0000000..2b193a4 --- /dev/null +++ b/frontend/src/components/TableSkeleton.jsx @@ -0,0 +1,29 @@ +export default function TableSkeleton({ rows = 10, cols = 3 }) { + const Row = () => ( + + {Array.from({ length: cols }).map((_, i) => ( + +
+ + ))} + + ); + return ( +
+ + + + {Array.from({ length: cols }).map((_, i) => ( + + ))} + + + + {Array.from({ length: rows }).map((_, i) => )} + +
+
+ ); +} + + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..66d7d7e --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,33 @@ +import axios from 'axios'; + +// Базовый axios-клиент для всего приложения +const api = axios.create({ + baseURL: '/api', + timeout: 10000, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, +}); + +// Авто-ретрай для идемпотентных GET: до 2 попыток с экспоненциальной задержкой +api.interceptors.response.use( + (response) => response, + async (error) => { + const config = error?.config || {}; + const isGet = String(config.method || 'get').toLowerCase() === 'get'; + const status = error?.response?.status; + const retriable = !error.response || (status >= 500 && status !== 501); + config.__retryCount = config.__retryCount || 0; + if (isGet && retriable && config.__retryCount < 2) { + config.__retryCount += 1; + const delay = 300 * Math.pow(2, config.__retryCount - 1); + await new Promise((r) => setTimeout(r, delay)); + return api(config); + } + return Promise.reject(error); + } +); + +export default api; + +