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) ---
|
// --- Servers Routes (JSON format) ---
|
||||||
|
|
||||||
// Get servers from S3
|
// Get servers from S3
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ function ASNsNewManager() {
|
|||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import IPRangesManager from './IPRangesManager';
|
|||||||
import ASNsNewManager from './ASNsNewManager';
|
import ASNsNewManager from './ASNsNewManager';
|
||||||
import AutoUrlManager from './AutoUrlManager';
|
import AutoUrlManager from './AutoUrlManager';
|
||||||
import BillingManager from './BillingManager';
|
import BillingManager from './BillingManager';
|
||||||
|
import CommunitiesManager from './CommunitiesManager';
|
||||||
import Dashboard from './Dashboard';
|
import Dashboard from './Dashboard';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -99,7 +100,8 @@ function MainLayout() {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'domains', title: 'Домены', path: '/domains', icon: IconWorld },
|
{ id: 'domains', title: 'Домены', path: '/domains', icon: IconWorld },
|
||||||
{ id: 'ip-ranges', title: 'IP-диапазоны', path: '/ip-ranges', icon: IconNetwork },
|
{ 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="/domains" element={<DomainsNewManager />} />
|
||||||
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
<Route path="/ip-ranges" element={<IPRangesManager />} />
|
||||||
<Route path="/asns" element={<ASNsNewManager />} />
|
<Route path="/asns" element={<ASNsNewManager />} />
|
||||||
|
<Route path="/communities" element={<CommunitiesManager />} />
|
||||||
<Route path="/auto-urls" element={<AutoUrlManager />} />
|
<Route path="/auto-urls" element={<AutoUrlManager />} />
|
||||||
<Route path="/servers" element={<ServerManager />} />
|
<Route path="/servers" element={<ServerManager />} />
|
||||||
<Route path="/billing" element={<BillingManager />} />
|
<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);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ function FilterManager() {
|
|||||||
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
|
const [editingDraft, setEditingDraft] = useState({ community: '', gateway: '', description: '' });
|
||||||
const [selectedFilterKeys, setSelectedFilterKeys] = useState(new Set());
|
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 || '')}`;
|
const filterKey = (f) => `${String(f.community || '')}||${String(f.gateway || '')}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1729,14 +1742,27 @@ function AddFilterModal({ show, newFilter, onNewFilterChange, onAddFilter, onClo
|
|||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Community *</label>
|
<label className="form-label">Community *</label>
|
||||||
<input
|
<input
|
||||||
|
list="community-options"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="65001:200"
|
placeholder="Начните вводить или выберите из списка"
|
||||||
value={newFilter.community}
|
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
|
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>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label className="form-label">Gateway *</label>
|
<label className="form-label">Gateway *</label>
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ function IPRangesManager() {
|
|||||||
return m >= 0 && m <= 32;
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems();
|
fetchItems();
|
||||||
|
|||||||
Reference in New Issue
Block a user