From 10903400e7ec201c5079d31105c4018bce1943fa Mon Sep 17 00:00:00 2001 From: shats Date: Wed, 27 Aug 2025 19:12:24 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=BC=D0=B5=D0=BD=20ASN=20=D0=B8=20?= =?UTF-8?q?=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B8=D1=82=D1=8C=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D1=83=20=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D1=85=20=D0=B2=20=D0=BC=D0=B5=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=B6=D0=B5=D1=80=D0=B5=20ASNs,=20=D0=B2=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=8F=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BC=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/ASNsNewManager.jsx | 36 ++++++++++++-- frontend/src/lib/asn.js | 86 +++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/asn.js diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index f257b6c..fd680d2 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -32,6 +32,7 @@ import { notifyMutationSuccess } from './components/NotifyProvider.jsx'; import ImportModal from './components/ImportModal.jsx'; import QuickAddBar from './components/QuickAddBar.jsx'; import AccordionCard from './components/AccordionCard.jsx'; +import { getAsnName, getAsnNameSync } from './lib/asn.js'; const API_URL = '/api'; @@ -60,6 +61,7 @@ function ASNsNewManager() { const [filterCommunity, setFilterCommunity] = useState(''); const editInputRef = useRef(null); const pageSize = 10; + const [asnNameMap, setAsnNameMap] = useState({}); // Справочник community для подсказок const [communities, setCommunities] = useState([]); @@ -74,6 +76,20 @@ function ASNsNewManager() { })(); }, []); + // Подгружаем имена ASN из кэша/внешних API + useEffect(() => { + const visible = paginatedItems.map(i => i.asn); + visible.forEach(async (asn) => { + const cached = getAsnNameSync(asn); + if (cached !== null && typeof cached !== 'undefined') { + setAsnNameMap(prev => ({ ...prev, [asn]: cached })); + return; + } + const name = await getAsnName(asn); + if (name !== null) setAsnNameMap(prev => ({ ...prev, [asn]: name })); + }); + }, [JSON.stringify(paginatedItems)]); + const renderCommunityBadge = (value) => { const v = String(value ?? '').trim(); if (!v) return ; @@ -105,7 +121,7 @@ function ASNsNewManager() { try { const response = await api.get(`/asns`, { params: { offset: 0, limit: 0, format: 'std' } }); const payload = Array.isArray(response.data?.items) ? response.data.items : []; - const mapped = payload.map(item => ({ asn: item.domain, community: item.type })); + const mapped = payload.map(item => ({ asn: String(item.domain), community: String(item.type) })); const total = mapped.length; setItems(mapped); setOriginalItems(mapped); @@ -367,7 +383,7 @@ function ASNsNewManager() { const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize); // Для фильтра - список всех уникальных community - const allCommunities = Array.from(new Set(items.map(i => i.community))); + const allCommunities = Array.from(new Set(items.map(i => String(i.community)))); // Сортировка по клику const handleSort = (field) => { @@ -577,7 +593,7 @@ function ASNsNewManager() { value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }} title={(() => { - const meta = communities.find(c => c.value === filterCommunity); + const meta = communities.find(c => String(c.value) === String(filterCommunity)); if (!meta) return 'Все community'; const name = meta.name ? meta.name + ' — ' : ''; const desc = meta.description || ''; @@ -587,11 +603,11 @@ function ASNsNewManager() { > {allCommunities.map(community => { - const meta = communities.find(c => c.value === community); + const meta = communities.find(c => String(c.value) === String(community)); const label = meta && meta.name ? `${community} — ${meta.name}` : community; const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community; return ( - + ); })} @@ -632,6 +648,9 @@ function ASNsNewManager() { )} + + Имя AS + handleSort('community')}> Community {sortField === 'community' && ( @@ -647,6 +666,13 @@ function ASNsNewManager() { {paginatedItems.map((item) => ( {item.asn} + + {asnNameMap[item.asn] ? ( + {asnNameMap[item.asn]} + ) : ( + + )} + {renderCommunityBadge(item.community)} {editingAsn === item.asn ? ( diff --git a/frontend/src/lib/asn.js b/frontend/src/lib/asn.js new file mode 100644 index 0000000..4a884f6 --- /dev/null +++ b/frontend/src/lib/asn.js @@ -0,0 +1,86 @@ +// Утилиты для получения названия ASN с кэшем в localStorage + +const CACHE_KEY = 'asnNameCache.v1'; +const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 дней + +function readCache() { + try { + const raw = localStorage.getItem(CACHE_KEY); + if (!raw) return {}; + const data = JSON.parse(raw); + return typeof data === 'object' && data ? data : {}; + } catch { + return {}; + } +} + +function writeCache(cache) { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(cache)); + } catch {} +} + +function getCached(asn) { + const cache = readCache(); + const entry = cache[String(asn)]; + if (!entry) return null; + if (Date.now() > (entry.ts || 0)) return null; + return entry.name || null; +} + +function setCached(asn, name, ttlMs = DEFAULT_TTL_MS) { + const cache = readCache(); + cache[String(asn)] = { name: String(name || '').trim(), ts: Date.now() + ttlMs }; + writeCache(cache); +} + +async function fetchFromBGPView(asn) { + try { + const url = `https://api.bgpview.io/asn/AS${asn}`; + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const name = json?.data?.name || json?.data?.description || ''; + return String(name || '').trim() || null; + } catch { + return null; + } +} + +async function fetchFromRIPE(asn) { + try { + const url = `https://stat.ripe.net/data/as-overview/data.json?resource=AS${asn}`; + const res = await fetch(url, { method: 'GET' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + const name = json?.data?.holder || json?.data?.name || ''; + return String(name || '').trim() || null; + } catch { + return null; + } +} + +export async function getAsnName(asn, { ttlMs = DEFAULT_TTL_MS } = {}) { + const key = String(asn).trim(); + if (!/^[0-9]+$/.test(key)) return null; + const cached = getCached(key); + if (cached) return cached; + // попытаемся получить из провайдеров + const providers = [fetchFromBGPView, fetchFromRIPE]; + for (const p of providers) { + const name = await p(key); + if (name) { + setCached(key, name, ttlMs); + return name; + } + } + // Закэшируем пустой ответ на короткое время, чтобы не долбить API + setCached(key, '', 60 * 60 * 1000); // 1 час + return null; +} + +export function getAsnNameSync(asn) { + return getCached(asn); +} + +