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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m19s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user