feat(integration): switch data sections to EvoBGP v1 API
Publish Docker image / build-and-push (push) Successful in 2m12s
Publish Docker image / build-and-push (push) Successful in 2m12s
Move Domains, IP ranges, ASNs and Communities managers from legacy /api endpoints to direct EvoBGP /v1 modules/communities endpoints with a dedicated adapter layer. Made-with: Cursor
This commit is contained in:
@@ -43,13 +43,16 @@ import AddItemModal from './components/AddItemModal.jsx';
|
|||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
|
import {
|
||||||
const API_URL = '/api';
|
listCommunities,
|
||||||
|
fetchAsnsData,
|
||||||
|
saveAsnsData,
|
||||||
|
} from './lib/evobgpData.js';
|
||||||
|
|
||||||
function ASNsNewManager() {
|
function ASNsNewManager() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
||||||
@@ -80,8 +83,8 @@ function ASNsNewManager() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`);
|
const list = await listCommunities();
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(list);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// тихо игнорируем
|
// тихо игнорируем
|
||||||
}
|
}
|
||||||
@@ -112,21 +115,17 @@ function ASNsNewManager() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchItems = useCallback(async (abortSignal) => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
const mapped = await fetchAsnsData();
|
||||||
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;
|
const total = mapped.length;
|
||||||
setItems(mapped);
|
setItems(mapped);
|
||||||
setOriginalItems(mapped);
|
setOriginalItems(mapped);
|
||||||
setTotalItems(Number.isFinite(total) ? total : 0);
|
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||||
setEtag(response.headers?.etag || '');
|
setEtag('');
|
||||||
setLastModified(response.headers?.['last-modified'] || '');
|
setLastModified(new Date().toISOString());
|
||||||
const lengthHeader = response.headers?.['content-length-source'];
|
setContentLength(null);
|
||||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
|
||||||
setError('');
|
setError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
@@ -320,28 +319,8 @@ function ASNsNewManager() {
|
|||||||
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
// Загрузим полный список, совместим и сохраним
|
await saveAsnsData(unique, originalItems);
|
||||||
const fullRes = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } });
|
setEtag('');
|
||||||
const fullPayload = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
|
||||||
const full = fullPayload.map(item => ({ asn: String(item.domain).trim(), community: String(item.type || '').trim() }));
|
|
||||||
const fullMap = new Map(full.map(i => [i.asn, { asn: i.asn, community: i.community }]));
|
|
||||||
const originalPageMap = new Map(originalItems.map(i => [String(i.asn).trim(), true]));
|
|
||||||
const uniqueMap = new Map(unique.map(i => [String(i.asn).trim(), { asn: i.asn, community: i.community }]));
|
|
||||||
for (const key of originalPageMap.keys()) {
|
|
||||||
if (!uniqueMap.has(key)) fullMap.delete(key);
|
|
||||||
}
|
|
||||||
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
|
|
||||||
const fullToSave = Array.from(fullMap.values()).map(i => ({ domain: i.asn, type: i.community }));
|
|
||||||
|
|
||||||
const et = fullRes?.headers?.etag || etag;
|
|
||||||
const payload = { domains: fullToSave, etag: et };
|
|
||||||
const response = await api.post(`/asns`, payload, { validateStatus: () => true });
|
|
||||||
if (response.status === 412) {
|
|
||||||
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`);
|
|
||||||
setEtag(response.headers?.etag || et);
|
|
||||||
await fetchItems();
|
await fetchItems();
|
||||||
setSuccess('Изменения успешно сохранены!');
|
setSuccess('Изменения успешно сохранены!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
@@ -352,7 +331,7 @@ function ASNsNewManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [items, originalItems, etag, fetchItems]);
|
}, [items, originalItems, fetchItems]);
|
||||||
|
|
||||||
const handleSaveChanges = useCallback(async () => {
|
const handleSaveChanges = useCallback(async () => {
|
||||||
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
// подготовим diff и спросим подтверждение только при массовых изменениях (>10)
|
||||||
@@ -380,10 +359,7 @@ function ASNsNewManager() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
const all = filtered;
|
||||||
const response = await api.get(`/asns`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
|
||||||
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
|
||||||
const all = payload.map(item => ({ asn: item.domain, community: item.type }));
|
|
||||||
const header = ['asn', 'community'];
|
const header = ['asn', 'community'];
|
||||||
const csv = [header, ...all.map(i => [i.asn, i.community])]
|
const csv = [header, ...all.map(i => [i.asn, i.community])]
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ import ErrorAlert from './components/ErrorAlert.jsx';
|
|||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
import IconPicker from './components/IconPicker.jsx';
|
import IconPicker from './components/IconPicker.jsx';
|
||||||
import { getIconById } from './lib/brandIcons.js';
|
import { getIconById } from './lib/brandIcons.js';
|
||||||
|
import { listCommunities, saveCommunitiesData } from './lib/evobgpData.js';
|
||||||
const API_URL = '/api';
|
|
||||||
|
|
||||||
const normalizeCommunityValue = (value) => {
|
const normalizeCommunityValue = (value) => {
|
||||||
const trimmed = String(value || '').trim();
|
const trimmed = String(value || '').trim();
|
||||||
@@ -90,57 +89,12 @@ function CommunitiesManager() {
|
|||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [dictRes, asnsRes, ipRes, domRes] = await Promise.all([
|
const rows = await listCommunities();
|
||||||
api.get(`/communities`),
|
const normalized = rows.map((item) => ({
|
||||||
api.get(`/asns`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
...item,
|
||||||
api.get(`/ip-ranges`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
value: normalizeCommunityValue(item.value),
|
||||||
api.get(`/domains-new`, { params: { offset: 0, limit: 0 } }).catch(() => ({ data: [] })),
|
}));
|
||||||
]);
|
setItems(normalized);
|
||||||
const dict = Array.isArray(dictRes.data) ? dictRes.data : [];
|
|
||||||
const asnsPayload = Array.isArray(asnsRes.data?.items) ? asnsRes.data.items : (Array.isArray(asnsRes.data) ? asnsRes.data : []);
|
|
||||||
const ipPayload = Array.isArray(ipRes.data?.items) ? ipRes.data.items : (Array.isArray(ipRes.data) ? ipRes.data : []);
|
|
||||||
const domPayload = Array.isArray(domRes.data?.items) ? domRes.data.items : (Array.isArray(domRes.data) ? domRes.data : []);
|
|
||||||
|
|
||||||
const usedCommunities = new Set();
|
|
||||||
// ASNs: fields domain/type
|
|
||||||
for (const it of asnsPayload) {
|
|
||||||
const v = normalizeCommunityValue(it.type);
|
|
||||||
if (v) usedCommunities.add(v);
|
|
||||||
}
|
|
||||||
// IP ranges: fields ipRange/community
|
|
||||||
for (const it of ipPayload) {
|
|
||||||
const v = normalizeCommunityValue(it.community);
|
|
||||||
if (v) usedCommunities.add(v);
|
|
||||||
}
|
|
||||||
// Domains: fields domain/community
|
|
||||||
for (const it of domPayload) {
|
|
||||||
const v = normalizeCommunityValue(it.community);
|
|
||||||
if (v) usedCommunities.add(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = new Map();
|
|
||||||
// put dictionary items first
|
|
||||||
for (const d of dict) {
|
|
||||||
const raw = String(d.value || '').trim();
|
|
||||||
if (!raw) continue;
|
|
||||||
const value = normalizeCommunityValue(raw);
|
|
||||||
map.set(value, {
|
|
||||||
value,
|
|
||||||
name: String(d.name || ''),
|
|
||||||
description: String(d.description || ''),
|
|
||||||
tags: Array.isArray(d.tags) ? d.tags : [],
|
|
||||||
color: String(d.color || ''),
|
|
||||||
icon: String(d.icon || ''),
|
|
||||||
_external: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// add unknown ones from usage
|
|
||||||
for (const v of usedCommunities) {
|
|
||||||
if (!map.has(v)) {
|
|
||||||
map.set(v, { value: v, name: '', description: '', tags: [], color: '', icon: '', _external: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setItems(Array.from(map.values()));
|
|
||||||
setError('');
|
setError('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error fetching communities:', e);
|
console.error('Error fetching communities:', e);
|
||||||
@@ -189,9 +143,7 @@ function CommunitiesManager() {
|
|||||||
const saveAll = async (data) => {
|
const saveAll = async (data) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// не отправляем служебное поле _external
|
await saveCommunitiesData(data);
|
||||||
const payload = data.map(({ _external: _, ...rest }) => rest);
|
|
||||||
await api.post(`/communities`, { communities: payload });
|
|
||||||
setSuccess('Справочник сохранён!');
|
setSuccess('Справочник сохранён!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -49,14 +49,17 @@ import AddItemModal from './components/AddItemModal.jsx';
|
|||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
import { useToast } from './components/ToastContainer.jsx';
|
import { useToast } from './components/ToastContainer.jsx';
|
||||||
|
import {
|
||||||
const API_URL = '/api';
|
listCommunities,
|
||||||
|
fetchDomainsData,
|
||||||
|
saveDomainsData,
|
||||||
|
} from './lib/evobgpData.js';
|
||||||
|
|
||||||
function DomainsNewManager() {
|
function DomainsNewManager() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
const [newItem, setNewItem] = useState({ domain: '', community: '' });
|
||||||
@@ -90,8 +93,8 @@ function DomainsNewManager() {
|
|||||||
const signal = controller.signal;
|
const signal = controller.signal;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`, { signal });
|
const list = await listCommunities();
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(list);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
|
||||||
}
|
}
|
||||||
@@ -121,20 +124,17 @@ function DomainsNewManager() {
|
|||||||
const [wsOpen, setWsOpen] = useState(false);
|
const [wsOpen, setWsOpen] = useState(false);
|
||||||
const [wsUrl, setWsUrl] = useState('');
|
const [wsUrl, setWsUrl] = useState('');
|
||||||
|
|
||||||
const fetchItems = useCallback(async (abortSignal) => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
const payload = await fetchDomainsData();
|
||||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
|
||||||
const total = payload.length;
|
const total = payload.length;
|
||||||
setItems(payload);
|
setItems(payload);
|
||||||
setOriginalItems(payload);
|
setOriginalItems(payload);
|
||||||
setTotalItems(Number.isFinite(total) ? total : 0);
|
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||||
setEtag(response.headers?.etag || '');
|
setEtag('');
|
||||||
setLastModified(response.headers?.['last-modified'] || '');
|
setLastModified(new Date().toISOString());
|
||||||
const lengthHeader = response.headers?.['content-length-source'];
|
setContentLength(null);
|
||||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
|
||||||
setError('');
|
setError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
@@ -281,30 +281,8 @@ function DomainsNewManager() {
|
|||||||
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
// Подгружаем весь список, применяем изменения текущей страницы и сохраняем полные данные
|
await saveDomainsData(unique, originalItems);
|
||||||
const fullRes = await api.get(`/domains-new`, { params: { offset: 0, limit: 0, format: 'std' } });
|
setEtag('');
|
||||||
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
|
||||||
const fullMap = new Map(full.filter(i => i != null).map(i => [String(i.domain || '').trim().toLowerCase(), { domain: String(i.domain || '').trim().toLowerCase(), community: String(i.community || '').trim() }]));
|
|
||||||
const originalPageMap = new Map(originalItems.map(i => [String(i.domain).trim().toLowerCase(), true]));
|
|
||||||
const uniqueMap = new Map(unique.map(i => [String(i.domain).trim().toLowerCase(), { domain: String(i.domain).trim().toLowerCase(), community: String(i.community).trim() }]));
|
|
||||||
// Удаления: всё, что было на странице, но отсутствует в изменённом наборе
|
|
||||||
for (const key of originalPageMap.keys()) {
|
|
||||||
if (!uniqueMap.has(key)) fullMap.delete(key);
|
|
||||||
}
|
|
||||||
// Добавления/изменения
|
|
||||||
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
|
|
||||||
const fullToSave = Array.from(fullMap.values());
|
|
||||||
|
|
||||||
const et = fullRes?.headers?.etag || etag;
|
|
||||||
const response = await api.post(`/domains-new`, { domains: fullToSave, etag: et }, { validateStatus: () => true });
|
|
||||||
if (response.status === 412) {
|
|
||||||
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status >= 400) {
|
|
||||||
throw new Error(`Save failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
setEtag(response.headers?.etag || et);
|
|
||||||
// После сохранения перезагрузим текущую страницу для консистентности
|
// После сохранения перезагрузим текущую страницу для консистентности
|
||||||
await fetchItems();
|
await fetchItems();
|
||||||
setSuccess('Изменения успешно сохранены!');
|
setSuccess('Изменения успешно сохранены!');
|
||||||
@@ -316,7 +294,7 @@ function DomainsNewManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [items, originalItems, etag, fetchItems]);
|
}, [items, originalItems, fetchItems]);
|
||||||
|
|
||||||
const handleSaveChanges = useCallback(async () => {
|
const handleSaveChanges = useCallback(async () => {
|
||||||
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
|
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
|
||||||
@@ -360,9 +338,7 @@ function DomainsNewManager() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
const all = filtered;
|
||||||
const response = await api.get(`/domains-new`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
|
||||||
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
|
||||||
const header = ['domain', 'community'];
|
const header = ['domain', 'community'];
|
||||||
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
|
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
|
|||||||
@@ -44,8 +44,11 @@ import Pagination from './components/Pagination.jsx';
|
|||||||
import AddItemModal from './components/AddItemModal.jsx';
|
import AddItemModal from './components/AddItemModal.jsx';
|
||||||
import LastSaved from './components/LastSaved.jsx';
|
import LastSaved from './components/LastSaved.jsx';
|
||||||
import FormModal from './components/FormModal.jsx';
|
import FormModal from './components/FormModal.jsx';
|
||||||
|
import {
|
||||||
const API_URL = '/api';
|
listCommunities,
|
||||||
|
fetchIpRangesData,
|
||||||
|
saveIpRangesData,
|
||||||
|
} from './lib/evobgpData.js';
|
||||||
|
|
||||||
const isValidIPv4 = (ip) => {
|
const isValidIPv4 = (ip) => {
|
||||||
const octets = ip.split('.');
|
const octets = ip.split('.');
|
||||||
@@ -67,7 +70,7 @@ const isValidCidr = (value) => {
|
|||||||
function IPRangesManager() {
|
function IPRangesManager() {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [originalItems, setOriginalItems] = useState([]);
|
const [originalItems, setOriginalItems] = useState([]);
|
||||||
const [etag, setEtag] = useState('');
|
const [, setEtag] = useState('');
|
||||||
const [lastModified, setLastModified] = useState('');
|
const [lastModified, setLastModified] = useState('');
|
||||||
const [, setContentLength] = useState(null);
|
const [, setContentLength] = useState(null);
|
||||||
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
|
const [newItem, setNewItem] = useState({ ipRange: '', community: '' });
|
||||||
@@ -97,8 +100,8 @@ function IPRangesManager() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/communities`);
|
const list = await listCommunities();
|
||||||
setCommunities(Array.isArray(res.data) ? res.data : []);
|
setCommunities(list);
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// тихо игнорируем
|
// тихо игнорируем
|
||||||
}
|
}
|
||||||
@@ -126,20 +129,17 @@ function IPRangesManager() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchItems = useCallback(async (abortSignal) => {
|
const fetchItems = useCallback(async (_abortSignal) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const opts = abortSignal ? { signal: abortSignal } : {};
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' }, ...opts });
|
const payload = await fetchIpRangesData();
|
||||||
const payload = Array.isArray(response.data?.items) ? response.data.items : [];
|
|
||||||
const total = payload.length;
|
const total = payload.length;
|
||||||
setItems(payload);
|
setItems(payload);
|
||||||
setOriginalItems(payload);
|
setOriginalItems(payload);
|
||||||
setTotalItems(Number.isFinite(total) ? total : 0);
|
setTotalItems(Number.isFinite(total) ? total : 0);
|
||||||
setEtag(response.headers?.etag || '');
|
setEtag('');
|
||||||
setLastModified(response.headers?.['last-modified'] || '');
|
setLastModified(new Date().toISOString());
|
||||||
const lengthHeader = response.headers?.['content-length-source'];
|
setContentLength(null);
|
||||||
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
|
||||||
setError('');
|
setError('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
|
||||||
@@ -346,28 +346,8 @@ function IPRangesManager() {
|
|||||||
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
|
||||||
const unique = deduplicate(valid);
|
const unique = deduplicate(valid);
|
||||||
|
|
||||||
// Загрузим полный список, совместим с изменениями текущей страницы и сохраним
|
await saveIpRangesData(unique, originalItems);
|
||||||
const fullRes = await api.get(`/ip-ranges`, { params: { offset: 0, limit: 0, format: 'std' } });
|
setEtag('');
|
||||||
const full = Array.isArray(fullRes.data?.items) ? fullRes.data.items : (Array.isArray(fullRes.data) ? fullRes.data : []);
|
|
||||||
const fullMap = new Map(full.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community || '').trim() }]));
|
|
||||||
const originalPageMap = new Map(originalItems.map(i => [String(i.ipRange).trim(), true]));
|
|
||||||
const uniqueMap = new Map(unique.map(i => [String(i.ipRange).trim(), { ipRange: String(i.ipRange).trim(), community: String(i.community).trim() }]));
|
|
||||||
for (const key of originalPageMap.keys()) {
|
|
||||||
if (!uniqueMap.has(key)) fullMap.delete(key);
|
|
||||||
}
|
|
||||||
for (const [key, val] of uniqueMap.entries()) fullMap.set(key, val);
|
|
||||||
const fullToSave = Array.from(fullMap.values());
|
|
||||||
|
|
||||||
const et = fullRes?.headers?.etag || etag;
|
|
||||||
const response = await api.post(`/ip-ranges`, { ipRanges: fullToSave, etag: et }, { validateStatus: () => true });
|
|
||||||
if (response.status === 412) {
|
|
||||||
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.status >= 400) {
|
|
||||||
throw new Error(`Save failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
setEtag(response.headers?.etag || et);
|
|
||||||
await fetchItems();
|
await fetchItems();
|
||||||
setSuccess('Изменения успешно сохранены!');
|
setSuccess('Изменения успешно сохранены!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
@@ -378,7 +358,7 @@ function IPRangesManager() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [items, originalItems, etag, fetchItems]);
|
}, [items, originalItems, fetchItems]);
|
||||||
|
|
||||||
const handleSaveChanges = useCallback(async () => {
|
const handleSaveChanges = useCallback(async () => {
|
||||||
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
const valid = items.filter(i => i != null && isValidCidr(i.ipRange) && isValidCommunity(i.community))
|
||||||
@@ -400,9 +380,7 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const effectiveQ = searchTerm ? searchTerm : (filterCommunity ? filterCommunity : '');
|
const all = filtered;
|
||||||
const response = await api.get(`/ip-ranges`, { params: { q: effectiveQ, offset: 0, limit: 0, format: 'std' } });
|
|
||||||
const all = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
|
||||||
const header = ['ipRange', 'community'];
|
const header = ['ipRange', 'community'];
|
||||||
const csv = [header, ...all.map(i => [i.ipRange, i.community])]
|
const csv = [header, ...all.map(i => [i.ipRange, i.community])]
|
||||||
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
.map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 30000;
|
||||||
|
const PAGE_LIMIT = 500;
|
||||||
|
|
||||||
|
function getAuthToken() {
|
||||||
|
const envToken = import.meta?.env?.VITE_EVOBGP_API_TOKEN;
|
||||||
|
if (envToken) return String(envToken);
|
||||||
|
if (typeof window === 'undefined') return '';
|
||||||
|
return String(window.localStorage?.getItem('evobgp_api_token') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const evobgpApi = axios.create({
|
||||||
|
baseURL: import.meta?.env?.VITE_EVOBGP_API_URL || '/v1',
|
||||||
|
timeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
evobgpApi.interceptors.request.use((config) => {
|
||||||
|
const token = getAuthToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
if (!config.headers.Authorization) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function paginate(path, params = {}) {
|
||||||
|
const items = [];
|
||||||
|
let cursor = null;
|
||||||
|
while (true) {
|
||||||
|
const res = await evobgpApi.get(path, {
|
||||||
|
params: { ...params, limit: PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
||||||
|
});
|
||||||
|
const chunk = Array.isArray(res.data?.items) ? res.data.items : [];
|
||||||
|
items.push(...chunk);
|
||||||
|
if (!res.data?.has_more || !res.data?.next_cursor) break;
|
||||||
|
cursor = res.data.next_cursor;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCommunities() {
|
||||||
|
const communities = await paginate('/communities');
|
||||||
|
return communities.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
value: String(c.community || ''),
|
||||||
|
name: String(c.title || ''),
|
||||||
|
description: '',
|
||||||
|
tags: [],
|
||||||
|
color: '',
|
||||||
|
icon: '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateModule(moduleType, defaultName) {
|
||||||
|
const modules = await paginate('/modules', { type: moduleType });
|
||||||
|
if (modules.length > 0) return modules[0];
|
||||||
|
const created = await evobgpApi.post('/modules', {
|
||||||
|
type: moduleType,
|
||||||
|
name: defaultName,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
return created.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(items, keyName) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const item of items || []) {
|
||||||
|
const key = String(item?.[keyName] || '').trim().toLowerCase();
|
||||||
|
if (!key || seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push({ ...item, [keyName]: key });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCommunityMaps() {
|
||||||
|
const communities = await listCommunities();
|
||||||
|
const byId = new Map(communities.map((c) => [c.id, c.value]));
|
||||||
|
const byValue = new Map(communities.map((c) => [c.value, c.id]));
|
||||||
|
return { communities, byId, byValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCommunityToValue(communityId, byId) {
|
||||||
|
if (!communityId) return '';
|
||||||
|
return byId.get(communityId) || String(communityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncEntries({
|
||||||
|
moduleType,
|
||||||
|
moduleName,
|
||||||
|
listPath,
|
||||||
|
keyName,
|
||||||
|
createBody,
|
||||||
|
patchBody,
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
requireCommunity = false,
|
||||||
|
}) {
|
||||||
|
const module = await getOrCreateModule(moduleType, moduleName);
|
||||||
|
const moduleId = module.id;
|
||||||
|
const { byId, byValue } = await buildCommunityMaps();
|
||||||
|
const existing = await paginate(`/modules/${moduleId}/${listPath}`);
|
||||||
|
|
||||||
|
const existingByKey = new Map();
|
||||||
|
for (const row of existing) {
|
||||||
|
const entryKey = String(row?.[keyName] || '').trim().toLowerCase();
|
||||||
|
if (!entryKey) continue;
|
||||||
|
existingByKey.set(entryKey, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedCurrent = normalizeList(currentItems, keyName);
|
||||||
|
const normalizedOriginal = normalizeList(originalItems, keyName);
|
||||||
|
const currentMap = new Map(normalizedCurrent.map((i) => [String(i[keyName]).toLowerCase(), i]));
|
||||||
|
const originalKeys = new Set(normalizedOriginal.map((i) => String(i[keyName]).toLowerCase()));
|
||||||
|
|
||||||
|
for (const key of originalKeys) {
|
||||||
|
if (!currentMap.has(key) && existingByKey.has(key)) {
|
||||||
|
const row = existingByKey.get(key);
|
||||||
|
await evobgpApi.delete(`/modules/${moduleId}/${listPath}/${row.id}`);
|
||||||
|
existingByKey.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, item] of currentMap.entries()) {
|
||||||
|
const rawCommunity = String(item.community || '').trim();
|
||||||
|
const communityId = byValue.get(rawCommunity) || null;
|
||||||
|
if (requireCommunity && !communityId) {
|
||||||
|
throw new Error(`Community "${rawCommunity}" не найдена в справочнике`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRow = existingByKey.get(key);
|
||||||
|
if (!existingRow) {
|
||||||
|
await evobgpApi.post(`/modules/${moduleId}/${listPath}`, createBody(item, communityId));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCommunity = mapCommunityToValue(existingRow.community_id, byId);
|
||||||
|
if (currentCommunity !== rawCommunity) {
|
||||||
|
await evobgpApi.patch(
|
||||||
|
`/modules/${moduleId}/${listPath}/${existingRow.id}`,
|
||||||
|
patchBody(item, communityId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDomainsData() {
|
||||||
|
const module = await getOrCreateModule('DOMAINS', 'Domains');
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
const rows = await paginate(`/modules/${module.id}/domain-entries`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
domain: String(row.fqdn || '').trim().toLowerCase(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDomainsData(currentItems, originalItems) {
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: 'DOMAINS',
|
||||||
|
moduleName: 'Domains',
|
||||||
|
listPath: 'domain-entries',
|
||||||
|
keyName: 'domain',
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
createBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ fqdn: item.domain, community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchIpRangesData() {
|
||||||
|
const module = await getOrCreateModule('IP_RANGES', 'IP ranges');
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
const rows = await paginate(`/modules/${module.id}/ip-range-entries`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
ipRange: String(row.prefix || '').trim(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveIpRangesData(currentItems, originalItems) {
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: 'IP_RANGES',
|
||||||
|
moduleName: 'IP ranges',
|
||||||
|
listPath: 'ip-range-entries',
|
||||||
|
keyName: 'ipRange',
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
requireCommunity: true,
|
||||||
|
createBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ prefix: item.ipRange, community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAsnsData() {
|
||||||
|
const module = await getOrCreateModule('AS_PREFIXES', 'ASNs');
|
||||||
|
const { byId } = await buildCommunityMaps();
|
||||||
|
const rows = await paginate(`/modules/${module.id}/as-entries`);
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
asn: String(row.asn || '').trim(),
|
||||||
|
community: mapCommunityToValue(row.community_id, byId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAsnsData(currentItems, originalItems) {
|
||||||
|
await syncEntries({
|
||||||
|
moduleType: 'AS_PREFIXES',
|
||||||
|
moduleName: 'ASNs',
|
||||||
|
listPath: 'as-entries',
|
||||||
|
keyName: 'asn',
|
||||||
|
currentItems,
|
||||||
|
originalItems,
|
||||||
|
createBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
|
||||||
|
patchBody: (item, communityId) => ({ asn: Number(item.asn), community_id: communityId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveCommunitiesData(currentItems) {
|
||||||
|
const existing = await paginate('/communities');
|
||||||
|
const existingByValue = new Map(existing.map((c) => [String(c.community || ''), c]));
|
||||||
|
const nextByValue = new Map();
|
||||||
|
for (const item of currentItems || []) {
|
||||||
|
const value = String(item?.value || '').trim();
|
||||||
|
if (!value) continue;
|
||||||
|
nextByValue.set(value, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [value, row] of existingByValue.entries()) {
|
||||||
|
if (!nextByValue.has(value)) {
|
||||||
|
await evobgpApi.delete(`/communities/${row.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [value, item] of nextByValue.entries()) {
|
||||||
|
const title = String(item?.name || '').trim();
|
||||||
|
const row = existingByValue.get(value);
|
||||||
|
if (!row) {
|
||||||
|
await evobgpApi.post('/communities', { community: value, title });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (String(row.title || '') !== title) {
|
||||||
|
await evobgpApi.patch(`/communities/${row.id}`, { community: value, title });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/v1': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user