feat: Add communities management routes and integrate community selection in FilterManager for enhanced user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m19s

This commit is contained in:
2025-08-10 17:14:55 +07:00
parent bbad45dfad
commit 76dee39843
7 changed files with 488 additions and 7 deletions
+92
View File
@@ -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
+2 -1
View File
@@ -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();
+4 -1
View File
@@ -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() {
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
<Route path="/communities" element={<CommunitiesManager />} />
<Route path="/auto-urls" element={<AutoUrlManager />} />
<Route path="/servers" element={<ServerManager />} />
<Route path="/billing" element={<BillingManager />} />
+357
View File
@@ -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 (
<div className="container-xl">
<div className="page-header d-print-none mb-4">
<div className="row align-items-center">
<div className="col">
<h2 className="page-title"><IconFilter className="me-2" />Справочник Community</h2>
<div className="page-pretitle">Главная / Данные / Community</div>
</div>
<div className="col-auto ms-auto d-print-none">
<div className="btn-list">
<button className="btn btn-outline-secondary" onClick={fetchItems} disabled={loading}><IconRefresh className="me-2" />Обновить</button>
<button className="btn btn-outline-primary" onClick={handleImport}><IconUpload className="me-2" />Импорт JSON</button>
<button className="btn btn-outline-primary" onClick={handleExport} disabled={items.length === 0}><IconDownload className="me-2" />Экспорт</button>
<button className="btn btn-primary" onClick={handleSave} disabled={loading}><IconDeviceFloppy className="me-2" />Сохранить в S3</button>
</div>
</div>
</div>
</div>
{error && (
<div className="alert alert-danger alert-dismissible" role="alert">
<IconX className="me-2" />{error}
<button type="button" className="btn-close" onClick={() => setError('')}></button>
</div>
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<IconCheck className="me-2" />{success}
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<div className="row g-3">
<div className="col-lg-3">
<div className="card card-md">
<div className="card-header"><h3 className="card-title"><IconPlus className="me-2" />Добавить</h3></div>
<div className="card-body">
<div className="mb-2">
<label className="form-label">Value</label>
<input className="form-control" value={newItem.value} onChange={(e)=>setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" />
</div>
<div className="mb-2">
<label className="form-label">Название</label>
<input className="form-control" value={newItem.name} onChange={(e)=>setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" />
</div>
<div className="mb-2">
<label className="form-label">Описание</label>
<textarea className="form-control" rows={2} value={newItem.description} onChange={(e)=>setNewItem({...newItem, description:e.target.value})} />
</div>
<div className="mb-2">
<label className="form-label">Теги (через запятую)</label>
<input className="form-control" value={newItem.tags} onChange={(e)=>setNewItem({...newItem, tags:e.target.value})} placeholder="video,bank" />
</div>
<div className="mb-2">
<label className="form-label">Gateway по умолчанию</label>
<input className="form-control" value={newItem.gatewayDefault} onChange={(e)=>setNewItem({...newItem, gatewayDefault:e.target.value})} placeholder="SWE-HIPHOST" />
</div>
<div className="mb-3">
<label className="form-label">Цвет бейджа (опц.)</label>
<input className="form-control" value={newItem.color} onChange={(e)=>setNewItem({...newItem, color:e.target.value})} placeholder="blue, green, red ..." />
</div>
<button className="btn btn-primary w-100" onClick={addItem}><IconPlus className="me-2" />Добавить</button>
</div>
</div>
<div className="card card-md">
<div className="card-header"><h3 className="card-title"><IconDatabase className="me-2" />Действия</h3></div>
<div className="card-body d-grid gap-2">
<button className="btn btn-outline-secondary" onClick={fetchItems} disabled={loading}><IconRefresh className="me-2" />Обновить</button>
<button className="btn btn-outline-primary" onClick={handleImport}><IconUpload className="me-2" />Импорт JSON</button>
<button className="btn btn-outline-primary" onClick={handleExport} disabled={items.length === 0}><IconDownload className="me-2" />Экспорт</button>
<button className="btn btn-primary" onClick={handleSave} disabled={loading}><IconDeviceFloppy className="me-2" />Сохранить в S3</button>
</div>
</div>
</div>
<div className="col-lg-9">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список community <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="input-icon w-50">
<span className="input-icon-addon"><IconSearch size={18} /></span>
<input className="form-control" placeholder="Поиск по value, названию, описанию и тегам" value={searchTerm} onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}} />
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{cursor:'pointer'}} onClick={()=>changeSort('value')}>Value {sortField==='value' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th style={{cursor:'pointer'}} onClick={()=>changeSort('name')}>Название {sortField==='name' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="text-center text-muted py-4">Загрузка...</td></tr>
) : paginated.length === 0 ? (
<tr><td colSpan={6} className="text-center text-muted py-4">Нет записей</td></tr>
) : paginated.map((item) => (
<tr key={item.value} className={editingValue === item.value ? 'table-info' : ''}>
<td>
{editingValue === item.value ? (
<input className="form-control form-control-sm" value={editingDraft.value} onChange={(e)=>setEditingDraft({...editingDraft, value:e.target.value})} ref={editRef} />
) : (
<code className="text-blue">{item.value}</code>
)}
</td>
<td>
{editingValue === item.value ? (
<input className="form-control form-control-sm" value={editingDraft.name} onChange={(e)=>setEditingDraft({...editingDraft, name:e.target.value})} />
) : item.name || <span className="text-muted"></span>}
</td>
<td>
{editingValue === item.value ? (
<input className="form-control form-control-sm" value={editingDraft.description} onChange={(e)=>setEditingDraft({...editingDraft, description:e.target.value})} />
) : (
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
)}
</td>
<td>
{editingValue === item.value ? (
<input className="form-control form-control-sm" value={editingDraft.tags} onChange={(e)=>setEditingDraft({...editingDraft, tags:e.target.value})} />
) : (
(item.tags || []).map(t => <span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>)
)}
</td>
<td>
{editingValue === item.value ? (
<input className="form-control form-control-sm" value={editingDraft.gatewayDefault} onChange={(e)=>setEditingDraft({...editingDraft, gatewayDefault:e.target.value})} />
) : (
<span className="text-muted">{item.gatewayDefault || '—'}</span>
)}
</td>
<td className="text-end">
{editingValue === item.value ? (
<>
<button className="btn btn-success btn-icon btn-sm me-1" onClick={saveEdit}><IconCheck size={16} /></button>
<button className="btn btn-secondary btn-icon btn-sm" onClick={cancelEdit}><IconX size={16} /></button>
</>
) : (
<>
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={()=>startEdit(item)}><IconEdit size={16} /></button>
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={()=>deleteItem(item.value)}><IconTrash size={16} /></button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">Показано {((page - 1) * pageSize) + 1} - {Math.min(page * pageSize, filtered.length)} из {filtered.length}</div>
<ul className="pagination m-0">
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1}>Первая</button>
</li>
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(page-1)} disabled={page===1}>Назад</button>
</li>
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(page+1)} disabled={page===totalPages}>Вперед</button>
</li>
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(totalPages)} disabled={page===totalPages}>Последняя</button>
</li>
</ul>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export default CommunitiesManager;
+2 -1
View File
@@ -41,7 +41,8 @@ function DomainsNewManager() {
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
};
const isValidCommunity = (value) => /^[0-9]+$/.test(String(value).trim());
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
useEffect(() => {
fetchItems();
+29 -3
View File
@@ -76,6 +76,19 @@ function FilterManager() {
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
const [selectedFilterKeys, setSelectedFilterKeys] = useState(new Set());
// Communities directory for autocomplete
const [communities, setCommunities] = useState([]);
useEffect(() => {
(async () => {
try {
const res = await axios.get(`${API_URL}/communities`);
setCommunities(Array.isArray(res.data) ? res.data : []);
} catch (e) {
// мягко игнорируем, автокомплит необязателен
}
})();
}, []);
const filterKey = (f) => `${String(f.community || '')}||${String(f.gateway || '')}`;
useEffect(() => {
@@ -1729,14 +1742,27 @@ function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClo
<div className="mb-3">
<label className="form-label">Community *</label>
<input
list="community-options"
type="text"
className="form-control"
placeholder="65001:200"
placeholder="Начните вводить или выберите из списка"
value={newFilter.community}
onChange={(e) => handleChange('community', e.target.value)}
onChange={(e) => {
const v = e.target.value;
onNewFilterChange({ ...newFilter, community: v });
const found = communities.find(c => c.value === v);
if (found && found.gatewayDefault && !newFilter.gateway) {
onNewFilterChange({ ...newFilter, community: v, gateway: found.gatewayDefault });
}
}}
required
/>
<div className="form-text">Формат: AS:community (например, 65001:200)</div>
<datalist id="community-options">
{communities.map(c => (
<option key={c.value} value={c.value}>{c.name ? `${c.name}${c.value}` : c.value}</option>
))}
</datalist>
<div className="form-text">Поддерживается формат AS:NNN. Вы можете выбрать готовое значение из справочника.</div>
</div>
<div className="mb-3">
<label className="form-label">Gateway *</label>
+2 -1
View File
@@ -52,7 +52,8 @@ function IPRangesManager() {
return m >= 0 && m <= 32;
};
const isValidCommunity = (value) => /^[0-9]+$/.test(String(value).trim());
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
useEffect(() => {
fetchItems();