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;
+
+