From 76dee398438c5271204ac516cb66847051f469ba Mon Sep 17 00:00:00 2001 From: Denis Shatskiy Date: Sun, 10 Aug 2025 17:14:55 +0700 Subject: [PATCH] feat: Add communities management routes and integrate community selection in FilterManager for enhanced user experience --- backend/server.js | 92 +++++++ frontend/src/ASNsNewManager.jsx | 3 +- frontend/src/App.jsx | 5 +- frontend/src/CommunitiesManager.jsx | 357 ++++++++++++++++++++++++++++ frontend/src/DomainsNewManager.jsx | 3 +- frontend/src/FilterManager.jsx | 32 ++- frontend/src/IPRangesManager.jsx | 3 +- 7 files changed, 488 insertions(+), 7 deletions(-) create mode 100644 frontend/src/CommunitiesManager.jsx diff --git a/backend/server.js b/backend/server.js index e10050d..96ec6bf 100644 --- a/backend/server.js +++ b/backend/server.js @@ -226,6 +226,98 @@ app.post('/api/ip-ranges', async (req, res) => { } }); +// --- Communities Directory Routes --- + +// Get communities from S3 +app.get('/api/communities', async (req, res) => { + const params = { + Bucket: BUCKET_NAME, + Key: 'bgp_data/communities.json', + }; + + try { + const data = await s3.getObject(params).promise(); + const fileContent = data.Body.toString('utf-8'); + let communities = []; + + try { + const parsed = JSON.parse(fileContent); + communities = Array.isArray(parsed) ? parsed : []; + } catch (parseError) { + console.error('Error parsing communities.json:', parseError); + communities = []; + } + + // Basic normalization + communities = communities + .filter((c) => c && typeof c.value === 'string' && c.value.trim().length > 0) + .map((c) => ({ + value: String(c.value).trim(), + name: c.name ? String(c.name) : '', + description: c.description ? String(c.description) : '', + tags: Array.isArray(c.tags) ? c.tags.map(String) : [], + gatewayDefault: c.gatewayDefault ? String(c.gatewayDefault) : '', + color: c.color ? String(c.color) : '' + })); + + res.json(communities); + } catch (error) { + if (error.code === 'NoSuchKey') { + // If file missing, start with empty list + return res.json([]); + } + console.error('Error reading communities from S3:', error); + res.status(500).send('Error reading communities from S3'); + } +}); + +// Update communities in S3 +app.post('/api/communities', async (req, res) => { + const { communities } = req.body; + + if (!Array.isArray(communities)) { + return res.status(400).send('communities must be an array'); + } + + // Validate entries and ensure unique values + const seen = new Set(); + const normalized = []; + for (let i = 0; i < communities.length; i++) { + const entry = communities[i] || {}; + const value = typeof entry.value === 'string' ? entry.value.trim() : ''; + if (!value) { + return res.status(400).send(`Community at index ${i} is missing required field: value`); + } + if (seen.has(value)) { + return res.status(400).send(`Duplicate community value at index ${i}: ${value}`); + } + seen.add(value); + normalized.push({ + value, + name: entry.name ? String(entry.name) : '', + description: entry.description ? String(entry.description) : '', + tags: Array.isArray(entry.tags) ? entry.tags.map(String) : [], + gatewayDefault: entry.gatewayDefault ? String(entry.gatewayDefault) : '', + color: entry.color ? String(entry.color) : '' + }); + } + + const params = { + Bucket: BUCKET_NAME, + Key: 'bgp_data/communities.json', + Body: JSON.stringify(normalized, null, 2), + ContentType: 'application/json', + }; + + try { + await s3.putObject(params).promise(); + res.send('Communities updated successfully'); + } catch (error) { + console.error('Error writing communities to S3:', error); + res.status(500).send('Error writing communities to S3'); + } +}); + // --- Servers Routes (JSON format) --- // Get servers from S3 diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index b6ea9e7..8c47a3d 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -36,7 +36,8 @@ function ASNsNewManager() { const pageSize = 10; const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim()); - const isValidCommunity = (value) => /^[0-9]+$/.test(String(value).trim()); + // Допускаем как числовые, так и строковые (AS:NNN) community + const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim()); useEffect(() => { fetchItems(); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 99accbb..6ff54da 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -19,6 +19,7 @@ import IPRangesManager from './IPRangesManager'; import ASNsNewManager from './ASNsNewManager'; import AutoUrlManager from './AutoUrlManager'; import BillingManager from './BillingManager'; +import CommunitiesManager from './CommunitiesManager'; import Dashboard from './Dashboard'; import './App.css'; import axios from 'axios'; @@ -99,7 +100,8 @@ function MainLayout() { items: [ { id: 'domains', title: 'Домены', path: '/domains', icon: IconWorld }, { id: 'ip-ranges', title: 'IP-диапазоны', path: '/ip-ranges', icon: IconNetwork }, - { id: 'asns', title: 'AS', path: '/asns', icon: IconNetwork } + { id: 'asns', title: 'AS', path: '/asns', icon: IconNetwork }, + { id: 'communities', title: 'Community', path: '/communities', icon: IconFilter } ] }, { @@ -218,6 +220,7 @@ function MainLayout() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/CommunitiesManager.jsx b/frontend/src/CommunitiesManager.jsx new file mode 100644 index 0000000..f433af1 --- /dev/null +++ b/frontend/src/CommunitiesManager.jsx @@ -0,0 +1,357 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import axios from 'axios'; +import { + IconPlus, + IconSearch, + IconEdit, + IconTrash, + IconCheck, + IconX, + IconDatabase, + IconRefresh, + IconUpload, + IconDownload, + IconDeviceFloppy, + IconHash, + IconFilter +} from '@tabler/icons-react'; + +const API_URL = '/api'; + +function CommunitiesManager() { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortField, setSortField] = useState('value'); + const [sortOrder, setSortOrder] = useState('asc'); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); + const [editingValue, setEditingValue] = useState(null); + const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); + const editRef = useRef(null); + + useEffect(() => { fetchItems(); }, []); + + const fetchItems = async () => { + setLoading(true); + try { + const res = await axios.get(`${API_URL}/communities`); + setItems(res.data); + setError(''); + } catch (e) { + console.error('Error fetching communities:', e); + setError('Не удалось загрузить справочник community.'); + } finally { + setLoading(false); + } + }; + + const saveAll = async (data) => { + setLoading(true); + try { + await axios.post(`${API_URL}/communities`, { communities: data }); + setSuccess('Справочник сохранён!'); + setTimeout(() => setSuccess(''), 3000); + } catch (e) { + console.error('Error saving communities:', e); + setError(e.response?.data || 'Не удалось сохранить справочник.'); + } finally { + setLoading(false); + } + }; + + const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean); + const fromTagsArray = (arr) => (arr || []).join(', '); + + const addItem = () => { + const value = String(newItem.value || '').trim(); + if (!value) { setError('Поле value обязательно.'); return; } + if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; } + setError(''); + setItems([...items, { + value, + name: String(newItem.name || ''), + description: String(newItem.description || ''), + tags: toTagsArray(newItem.tags), + gatewayDefault: String(newItem.gatewayDefault || ''), + color: String(newItem.color || ''), + }]); + setNewItem({ value: '', name: '', description: '', tags: '', gatewayDefault: '', color: '' }); + }; + + const startEdit = (item) => { + setEditingValue(item.value); + setEditingDraft({ + value: item.value, + name: item.name || '', + description: item.description || '', + tags: fromTagsArray(item.tags), + gatewayDefault: item.gatewayDefault || '', + color: item.color || '', + }); + }; + + const saveEdit = () => { + const value = String(editingDraft.value || '').trim(); + if (!value) { setError('Поле value обязательно.'); return; } + if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; } + const next = items.map(i => i.value === editingValue ? { + value, + name: String(editingDraft.name || ''), + description: String(editingDraft.description || ''), + tags: toTagsArray(editingDraft.tags), + gatewayDefault: String(editingDraft.gatewayDefault || ''), + color: String(editingDraft.color || ''), + } : i); + setItems(next); + setEditingValue(null); + }; + + const cancelEdit = () => setEditingValue(null); + + const deleteItem = (value) => setItems(items.filter(i => i.value !== value)); + + const handleImport = () => { + const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, gatewayDefault, color)'); + if (!text) return; + try { + const arr = JSON.parse(text); + if (!Array.isArray(arr)) throw new Error('Ожидается массив'); + setItems(prev => [...prev, ...arr.filter(x => x && x.value)]); + } catch (e) { + setError('Неверный JSON.'); + } + }; + + const handleExport = () => { + const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `communities_${new Date().toISOString().split('T')[0]}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleSave = () => saveAll(items); + + const sorted = useMemo(() => { + const arr = [...items]; + arr.sort((a, b) => { + let va = (a[sortField] ?? '').toString().toLowerCase(); + let vb = (b[sortField] ?? '').toString().toLowerCase(); + if (va < vb) return sortOrder === 'asc' ? -1 : 1; + if (va > vb) return sortOrder === 'asc' ? 1 : -1; + return 0; + }); + return arr; + }, [items, sortField, sortOrder]); + + const filtered = useMemo(() => { + const q = searchTerm.trim().toLowerCase(); + if (!q) return sorted; + return sorted.filter(i => + (i.value || '').toLowerCase().includes(q) || + (i.name || '').toLowerCase().includes(q) || + (i.description || '').toLowerCase().includes(q) || + (i.tags || []).some(t => (t || '').toLowerCase().includes(q)) + ); + }, [sorted, searchTerm]); + + const totalPages = Math.ceil(filtered.length / pageSize) || 1; + const page = Math.min(currentPage, totalPages); + const paginated = filtered.slice((page - 1) * pageSize, page * pageSize); + + const changeSort = (f) => { + if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); + else { setSortField(f); setSortOrder('asc'); } + }; + + return ( +
+
+
+
+

Справочник Community

+
Главная / Данные / Community
+
+
+
+ + + + +
+
+
+
+ + {error && ( +
+ {error} + +
+ )} + {success && ( +
+ {success} + +
+ )} + +
+
+
+

Добавить

+
+
+ + setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" /> +
+
+ + setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" /> +
+
+ +