refactor(Managers): integrate AddItemModal into ASNs, Domains, and IPRanges managers for improved item addition functionality and UI consistency
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m57s
This commit is contained in:
+39
-124
@@ -41,6 +41,7 @@ import SavedFilters from './components/SavedFilters.jsx';
|
|||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
import AddItemModal from './components/AddItemModal.jsx';
|
||||||
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
import { getAsnName, getAsnNameSync } from './lib/asn.js';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -63,6 +64,7 @@ function ASNsNewManager() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [sortField, setSortField] = useState('asn');
|
const [sortField, setSortField] = useState('asn');
|
||||||
@@ -129,18 +131,25 @@ function ASNsNewManager() {
|
|||||||
|
|
||||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = (item) => {
|
||||||
const asnOk = isValidAsn(newItem.asn);
|
const asn = item?.asn || newItem.asn;
|
||||||
const communityOk = isValidCommunity(newItem.community);
|
const community = item?.community || newItem.community;
|
||||||
setNewInvalid({ asn: !asnOk, community: !communityOk });
|
|
||||||
|
const asnOk = isValidAsn(asn);
|
||||||
|
const communityOk = isValidCommunity(community);
|
||||||
|
|
||||||
if (!asnOk || !communityOk) {
|
if (!asnOk || !communityOk) {
|
||||||
setError('Введите корректный номер ASN и числовой community.');
|
setError('Введите корректный номер ASN и числовой community.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setItems([...items, { asn: String(newItem.asn).trim(), community: String(newItem.community).trim() }]);
|
const newAsn = { asn: String(asn).trim(), community: String(community).trim() };
|
||||||
setNewItem({ asn: '', community: '' });
|
setItems([...items, newAsn]);
|
||||||
setNewInvalid({ asn: false, community: false });
|
if (!item) {
|
||||||
|
setNewItem({ asn: '', community: '' });
|
||||||
|
setNewInvalid({ asn: false, community: false });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
@@ -523,7 +532,16 @@ function ASNsNewManager() {
|
|||||||
]} />
|
]} />
|
||||||
)}
|
)}
|
||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
title="Добавить новый ASN"
|
||||||
|
>
|
||||||
|
<IconPlus className="me-1" size={18} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPreview={handlePreviewDiff}
|
onPreview={handlePreviewDiff}
|
||||||
disablePreview={loading || items.length === 0}
|
disablePreview={loading || items.length === 0}
|
||||||
@@ -552,125 +570,22 @@ function ASNsNewManager() {
|
|||||||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddItemModal
|
||||||
|
open={addModalOpen}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
onSubmit={handleAddItem}
|
||||||
|
type="asn"
|
||||||
|
communities={communities}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-12">
|
||||||
{/* Classic Add New ASN Card (improved) */}
|
|
||||||
<div className="card card-md animate-in">
|
|
||||||
<div className="card-header bg-primary-lt">
|
|
||||||
<h3 className="card-title text-primary">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить новый ASN
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Номер AS</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconHash size={18} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`form-control${newInvalid.asn ? ' is-invalid' : isValidAsn(newItem.asn) ? ' is-valid' : ''}`}
|
|
||||||
placeholder="12345"
|
|
||||||
value={newItem.asn}
|
|
||||||
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
|
|
||||||
{isValidAsn(newItem.asn) && !newInvalid.asn && (<div className="valid-feedback">Корректный номер AS</div>)}
|
|
||||||
<div className="form-text text-muted small">Формат: только цифры, например 12345</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Community</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconHash size={18} />
|
|
||||||
</span>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
|
||||||
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
|
|
||||||
<div className="form-text">Формат: N или N:N (например 65000:100)</div>
|
|
||||||
{(() => {
|
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
|
||||||
if (!match) return null;
|
|
||||||
return (
|
|
||||||
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
|
||||||
<div className="d-flex">
|
|
||||||
<div>
|
|
||||||
<strong>{match.name || match.value}</strong>
|
|
||||||
{match.description && <div className="text-muted small">{match.description}</div>}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button type="submit" className="btn btn-primary w-100" disabled={!isValidAsn(newItem.asn) || !isValidCommunity(newItem.community)}>
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить ASN
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Дополнительные варианты */}
|
|
||||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<QuickAddBar
|
|
||||||
placeholder={'12345 65000:100'}
|
|
||||||
help={'Вставьте строки: ASN ПРОБЕЛ COMMUNITY'}
|
|
||||||
parseLine={(line) => { const [a, c] = String(line).split(/\s+/); return { asn: (a||'').trim(), community: (c||'').trim() }; }}
|
|
||||||
validateItem={(obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community)}
|
|
||||||
onApply={(list) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...list];
|
|
||||||
const map = new Map();
|
|
||||||
for (const it of merged) {
|
|
||||||
const key = String(it.asn).trim();
|
|
||||||
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="card card-md border-dashed mt-3 animate-in"
|
|
||||||
style={{animationDelay: '0.15s'}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
|
||||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
|
||||||
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted py-4">
|
|
||||||
<IconUpload size={32} className="mb-2 text-muted" />
|
|
||||||
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
|
||||||
<div className="small">Формат: ASN community</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionCard>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-lg-9">
|
|
||||||
{/* BulkActionsBar */}
|
{/* BulkActionsBar */}
|
||||||
<BulkActionsBar
|
<BulkActionsBar
|
||||||
selectedCount={selectedItems.size}
|
selectedCount={selectedItems.size}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import BulkActionsBar from './components/BulkActionsBar.jsx';
|
|||||||
import FormField from './components/FormField.jsx';
|
import FormField from './components/FormField.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
import AddItemModal from './components/AddItemModal.jsx';
|
||||||
import { useToast } from './components/ToastContainer.jsx';
|
import { useToast } from './components/ToastContainer.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
@@ -70,6 +71,7 @@ function DomainsNewManager() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [sortField, setSortField] = useState('domain');
|
const [sortField, setSortField] = useState('domain');
|
||||||
@@ -137,17 +139,23 @@ function DomainsNewManager() {
|
|||||||
// Подгрузка при изменении страницы/поиска/фильтра
|
// Подгрузка при изменении страницы/поиска/фильтра
|
||||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = (item) => {
|
||||||
const domainOk = isValidDomain(newItem.domain);
|
// Если item передан (из модалки), используем его, иначе из state (старый способ)
|
||||||
const communityOk = isValidCommunity(newItem.community);
|
const domain = item?.domain || newItem.domain;
|
||||||
setNewInvalid({ domain: !domainOk, community: !communityOk });
|
const community = item?.community || newItem.community;
|
||||||
|
|
||||||
|
const domainOk = isValidDomain(domain);
|
||||||
|
const communityOk = isValidCommunity(community);
|
||||||
|
|
||||||
if (!domainOk || !communityOk) {
|
if (!domainOk || !communityOk) {
|
||||||
setError('Заполните корректный домен (example.com) и числовой community.');
|
setError('Заполните корректный домен (example.com) и числовой community.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setItems([...items, { domain: newItem.domain.trim().toLowerCase(), community: String(newItem.community).trim() }]);
|
const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() };
|
||||||
setNewItem({ domain: '', community: '' });
|
setItems([...items, newDomain]);
|
||||||
|
if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки
|
||||||
setNewInvalid({ domain: false, community: false });
|
setNewInvalid({ domain: false, community: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -520,7 +528,16 @@ function DomainsNewManager() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
title="Добавить новый домен"
|
||||||
|
>
|
||||||
|
<IconPlus className="me-1" size={18} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPreview={handlePreviewDiff}
|
onPreview={handlePreviewDiff}
|
||||||
disablePreview={loading || items.length === 0}
|
disablePreview={loading || items.length === 0}
|
||||||
@@ -550,137 +567,21 @@ function DomainsNewManager() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddItemModal
|
||||||
|
open={addModalOpen}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
onSubmit={handleAddItem}
|
||||||
|
type="domain"
|
||||||
|
communities={communities}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-12">
|
||||||
{/* Classic Add New Domain Card (improved) */}
|
|
||||||
<div className="card card-md animate-in">
|
|
||||||
<div className="card-header bg-primary-lt">
|
|
||||||
<h3 className="card-title text-primary">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить новый домен
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Домен</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconWorld size={18} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`form-control${newInvalid.domain ? ' is-invalid' : isValidDomain(newItem.domain) ? ' is-valid' : ''}`}
|
|
||||||
placeholder="example.com"
|
|
||||||
value={newItem.domain}
|
|
||||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
|
|
||||||
{isValidDomain(newItem.domain) && !newInvalid.domain && (
|
|
||||||
<div className="valid-feedback">Корректный формат домена</div>
|
|
||||||
)}
|
|
||||||
<div className="form-text text-muted small">
|
|
||||||
Формат: sub.domain.tld (например foo.example.org)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Community</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconHash size={18} />
|
|
||||||
</span>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
|
||||||
{isValidCommunity(newItem.community) && !newInvalid.community && (
|
|
||||||
<div className="valid-feedback">Корректный формат community</div>
|
|
||||||
)}
|
|
||||||
<div className="form-text text-muted small">
|
|
||||||
Формат: N или N:N (например 65000:100)
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
|
||||||
if (!match) return null;
|
|
||||||
return (
|
|
||||||
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
|
||||||
<div className="d-flex">
|
|
||||||
<div>
|
|
||||||
<strong>{match.name || match.value}</strong>
|
|
||||||
{match.description && <div className="text-muted small">{match.description}</div>}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
{match.tags.map((tag, idx) => (
|
|
||||||
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn btn-primary w-100"
|
|
||||||
disabled={!isValidDomain(newItem.domain) || !isValidCommunity(newItem.community)}
|
|
||||||
>
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить домен
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Дополнительные варианты */}
|
|
||||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<QuickAddBar
|
|
||||||
placeholder={'example.com 65000:100'}
|
|
||||||
help={'Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY'}
|
|
||||||
parseLine={(line) => { const [d, c] = String(line).split(/\s+/); return { domain: (d||'').toLowerCase(), community: (c||'').trim() }; }}
|
|
||||||
validateItem={(obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community)}
|
|
||||||
onApply={(list) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...list];
|
|
||||||
const map = new Map();
|
|
||||||
for (const it of merged) {
|
|
||||||
const key = String(it.domain).trim().toLowerCase();
|
|
||||||
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="card card-md border-dashed mt-3 animate-in"
|
|
||||||
style={{animationDelay: '0.15s'}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
|
||||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
|
||||||
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted py-4">
|
|
||||||
<IconUpload size={32} className="mb-2 text-muted" />
|
|
||||||
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
|
||||||
<div className="small">Формат: domain community</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-lg-9">
|
|
||||||
{/* Bulk Actions Bar */}
|
{/* Bulk Actions Bar */}
|
||||||
<BulkActionsBar
|
<BulkActionsBar
|
||||||
selectedCount={selectedDomains.size}
|
selectedCount={selectedDomains.size}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import SavedFilters from './components/SavedFilters.jsx';
|
|||||||
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
import BulkActionsBar from './components/BulkActionsBar.jsx';
|
||||||
import Tooltip from './components/Tooltip.jsx';
|
import Tooltip from './components/Tooltip.jsx';
|
||||||
import Pagination from './components/Pagination.jsx';
|
import Pagination from './components/Pagination.jsx';
|
||||||
|
import AddItemModal from './components/AddItemModal.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ function IPRangesManager() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [itemToDelete, setItemToDelete] = useState(null);
|
const [itemToDelete, setItemToDelete] = useState(null);
|
||||||
|
const [addModalOpen, setAddModalOpen] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [sortField, setSortField] = useState('ipRange');
|
const [sortField, setSortField] = useState('ipRange');
|
||||||
@@ -139,18 +141,25 @@ function IPRangesManager() {
|
|||||||
|
|
||||||
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []);
|
||||||
|
|
||||||
const handleAddItem = () => {
|
const handleAddItem = (item) => {
|
||||||
const cidrOk = isValidCidr(newItem.ipRange);
|
const ipRange = item?.ipRange || newItem.ipRange;
|
||||||
const communityOk = isValidCommunity(newItem.community);
|
const community = item?.community || newItem.community;
|
||||||
setNewInvalid({ ipRange: !cidrOk, community: !communityOk });
|
|
||||||
|
const cidrOk = isValidCidr(ipRange);
|
||||||
|
const communityOk = isValidCommunity(community);
|
||||||
|
|
||||||
if (!cidrOk || !communityOk) {
|
if (!cidrOk || !communityOk) {
|
||||||
setError('Введите корректный IP-диапазон в формате CIDR (например 192.168.1.0/24) и числовой community.');
|
setError('Введите корректный IP-диапазон в формате CIDR (например 192.168.1.0/24) и числовой community.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
setItems([...items, { ipRange: newItem.ipRange.trim(), community: String(newItem.community).trim() }]);
|
const newRange = { ipRange: ipRange.trim(), community: String(community).trim() };
|
||||||
setNewItem({ ipRange: '', community: '' });
|
setItems([...items, newRange]);
|
||||||
setNewInvalid({ ipRange: false, community: false });
|
if (!item) {
|
||||||
|
setNewItem({ ipRange: '', community: '' });
|
||||||
|
setNewInvalid({ ipRange: false, community: false });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (item) => {
|
const handleEdit = (item) => {
|
||||||
@@ -783,7 +792,16 @@ function IPRangesManager() {
|
|||||||
]} />
|
]} />
|
||||||
)}
|
)}
|
||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<div className="d-flex align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setAddModalOpen(true)}
|
||||||
|
title="Добавить новый IP-диапазон"
|
||||||
|
>
|
||||||
|
<IconPlus className="me-1" size={18} />
|
||||||
|
Добавить
|
||||||
|
</button>
|
||||||
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onPreview={handlePreviewDiff}
|
onPreview={handlePreviewDiff}
|
||||||
disablePreview={loading || items.length === 0}
|
disablePreview={loading || items.length === 0}
|
||||||
@@ -812,124 +830,22 @@ function IPRangesManager() {
|
|||||||
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddItemModal
|
||||||
|
open={addModalOpen}
|
||||||
|
onClose={() => setAddModalOpen(false)}
|
||||||
|
onSubmit={handleAddItem}
|
||||||
|
type="ipRange"
|
||||||
|
communities={communities}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-12">
|
||||||
{/* Classic Add New Item Card (improved) */}
|
|
||||||
<div className="card card-md animate-in">
|
|
||||||
<div className="card-header bg-primary-lt">
|
|
||||||
<h3 className="card-title text-primary">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить новый IP-диапазон
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="card-body">
|
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">IP-диапазон</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconNetwork size={18} />
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className={`form-control${newInvalid.ipRange ? ' is-invalid' : isValidCidr(newItem.ipRange) ? ' is-valid' : ''}`}
|
|
||||||
placeholder="192.168.1.0/24"
|
|
||||||
value={newItem.ipRange}
|
|
||||||
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
|
|
||||||
{isValidCidr(newItem.ipRange) && !newInvalid.ipRange && (<div className="valid-feedback">Корректный CIDR</div>)}
|
|
||||||
<div className="form-text text-muted small">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label required">Community</label>
|
|
||||||
<div className="input-icon">
|
|
||||||
<span className="input-icon-addon">
|
|
||||||
<IconHash size={18} />
|
|
||||||
</span>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`}
|
|
||||||
useShortValue={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Неверный формат community</div>}
|
|
||||||
{isValidCommunity(newItem.community) && !newInvalid.community && (<div className="valid-feedback">Корректный формат community</div>)}
|
|
||||||
{(() => {
|
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
|
||||||
if (!match) return null;
|
|
||||||
return (
|
|
||||||
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
|
||||||
<div className="d-flex">
|
|
||||||
<div>
|
|
||||||
<strong>{match.name || match.value}</strong>
|
|
||||||
{match.description && <div className="text-muted small">{match.description}</div>}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<div className="mt-1">
|
|
||||||
{match.tags.map((tag, idx) => (<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button type="submit" className="btn btn-primary w-100" disabled={!isValidCidr(newItem.ipRange) || !isValidCommunity(newItem.community)}>
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить диапазон
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Дополнительные варианты */}
|
|
||||||
<AccordionCard title={'Дополнительные варианты'} defaultOpen={false} className="animate-in" style={{animationDelay: '0.1s'}}>
|
|
||||||
<QuickAddBar
|
|
||||||
placeholder={'192.168.1.0/24 65000:100'}
|
|
||||||
help={'Вставьте строки: CIDR ПРОБЕЛ COMMUNITY'}
|
|
||||||
parseLine={(line) => { const [cidr, c] = String(line).split(/\s+/); return { ipRange: (cidr||'').trim(), community: (c||'').trim() }; }}
|
|
||||||
validateItem={(obj) => isValidCidr(obj.ipRange) && isValidCommunity(obj.community)}
|
|
||||||
onApply={(list) => {
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...list];
|
|
||||||
const map = new Map();
|
|
||||||
for (const it of merged) {
|
|
||||||
const key = String(it.ipRange).trim();
|
|
||||||
if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() });
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="card card-md border-dashed mt-3 animate-in"
|
|
||||||
style={{animationDelay: '0.15s'}}
|
|
||||||
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }}
|
|
||||||
onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }}
|
|
||||||
onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }}
|
|
||||||
>
|
|
||||||
<div className="card-body text-center text-muted py-4">
|
|
||||||
<IconUpload size={32} className="mb-2 text-muted" />
|
|
||||||
<div className="fw-medium">Перетащите файл TXT/CSV сюда</div>
|
|
||||||
<div className="small">Формат: CIDR community</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionCard>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-lg-9">
|
|
||||||
{/* BulkActionsBar */}
|
{/* BulkActionsBar */}
|
||||||
<BulkActionsBar
|
<BulkActionsBar
|
||||||
selectedCount={selectedItems.size}
|
selectedCount={selectedItems.size}
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import FormModal from './FormModal.jsx';
|
||||||
|
import FormField from './FormField.jsx';
|
||||||
|
import CommunityAutocompleteInput from './CommunityAutocompleteInput.jsx';
|
||||||
|
import { IconPlus, IconWorld, IconNetwork, IconHash } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Универсальное модальное окно для добавления записи (домен/IP-диапазон/ASN)
|
||||||
|
*
|
||||||
|
* @param {Object} props
|
||||||
|
* @param {boolean} props.open - Открыто ли модальное окно
|
||||||
|
* @param {function} props.onClose - Закрытие модального окна
|
||||||
|
* @param {function} props.onSubmit - Обработчик добавления (item) => void
|
||||||
|
* @param {string} props.type - Тип записи: 'domain' | 'ipRange' | 'asn'
|
||||||
|
* @param {Array} props.communities - Список community для автодополнения
|
||||||
|
* @param {boolean} props.loading - Состояние загрузки
|
||||||
|
*/
|
||||||
|
export default function AddItemModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
type = 'domain',
|
||||||
|
communities = [],
|
||||||
|
loading = false
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState({ value: '', community: '' });
|
||||||
|
const [errors, setErrors] = useState({ value: '', community: '' });
|
||||||
|
|
||||||
|
// Сброс формы при закрытии
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setFormData({ value: '', community: '' });
|
||||||
|
setErrors({ value: '', community: '' });
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const validators = {
|
||||||
|
domain: (v) => {
|
||||||
|
const val = String(v).trim().toLowerCase();
|
||||||
|
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(val);
|
||||||
|
},
|
||||||
|
ipRange: (v) => {
|
||||||
|
const val = String(v).trim();
|
||||||
|
const parts = val.split('/');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
const [ip, mask] = parts;
|
||||||
|
if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false;
|
||||||
|
const octets = ip.split('.');
|
||||||
|
if (!octets.every(o => /^\d{1,3}$/.test(o) && Number(o) >= 0 && Number(o) <= 255)) return false;
|
||||||
|
if (!/^\d{1,2}$/.test(mask)) return false;
|
||||||
|
const m = Number(mask);
|
||||||
|
return m >= 0 && m <= 32;
|
||||||
|
},
|
||||||
|
asn: (v) => {
|
||||||
|
const val = String(v).trim();
|
||||||
|
return /^[0-9]+$/.test(val);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidCommunity = (v) => /^(\d+|\d+:\d+)$/.test(String(v).trim());
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const valueValid = validators[type]?.(formData.value);
|
||||||
|
const communityValid = isValidCommunity(formData.community);
|
||||||
|
|
||||||
|
setErrors({
|
||||||
|
value: valueValid ? '' : getValueError(),
|
||||||
|
community: communityValid ? '' : 'Неверный формат community (N или N:N)'
|
||||||
|
});
|
||||||
|
|
||||||
|
return valueValid && communityValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueError = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Неверный формат домена (sub.domain.tld)';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'Неверный формат IP-диапазона (A.B.C.D/Mask)';
|
||||||
|
case 'asn':
|
||||||
|
return 'Неверный формат ASN (только цифры, например 12345)';
|
||||||
|
default:
|
||||||
|
return 'Неверный формат';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueLabel = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Домен';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'IP-диапазон';
|
||||||
|
case 'asn':
|
||||||
|
return 'ASN';
|
||||||
|
default:
|
||||||
|
return 'Значение';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValuePlaceholder = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'example.com';
|
||||||
|
case 'ipRange':
|
||||||
|
return '192.168.1.0/24';
|
||||||
|
case 'asn':
|
||||||
|
return '12345';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueHelp = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Формат: sub.domain.tld (например foo.example.org)';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'Формат: A.B.C.D/Mask, например 10.0.0.0/24';
|
||||||
|
case 'asn':
|
||||||
|
return 'Формат: только цифры, например 12345';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getValueIcon = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return IconWorld;
|
||||||
|
case 'ipRange':
|
||||||
|
return IconNetwork;
|
||||||
|
case 'asn':
|
||||||
|
return IconHash;
|
||||||
|
default:
|
||||||
|
return IconHash;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTitle = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'domain':
|
||||||
|
return 'Добавить домен';
|
||||||
|
case 'ipRange':
|
||||||
|
return 'Добавить IP-диапазон';
|
||||||
|
case 'asn':
|
||||||
|
return 'Добавить ASN';
|
||||||
|
default:
|
||||||
|
return 'Добавить запись';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const normalizedValue = type === 'domain'
|
||||||
|
? formData.value.trim().toLowerCase()
|
||||||
|
: formData.value.trim();
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
[type === 'domain' ? 'domain' : type === 'ipRange' ? 'ipRange' : 'asn']: normalizedValue,
|
||||||
|
community: String(formData.community).trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit?.(item);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ValueIcon = getValueIcon();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
show={open}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
title={getTitle()}
|
||||||
|
submitLabel="Добавить"
|
||||||
|
submitIcon={IconPlus}
|
||||||
|
loading={loading}
|
||||||
|
size="md"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
label={getValueLabel()}
|
||||||
|
name="value"
|
||||||
|
type="text"
|
||||||
|
value={formData.value}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFormData({ ...formData, value: v });
|
||||||
|
if (errors.value) setErrors({ ...errors, value: '' });
|
||||||
|
}}
|
||||||
|
onValidate={(v) => ({
|
||||||
|
valid: validators[type]?.(v) ?? false,
|
||||||
|
message: validators[type]?.(v) ? 'Корректный формат' : getValueError()
|
||||||
|
})}
|
||||||
|
placeholder={getValuePlaceholder()}
|
||||||
|
helpText={getValueHelp()}
|
||||||
|
disabled={loading}
|
||||||
|
autoFocus
|
||||||
|
icon={ValueIcon}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">
|
||||||
|
Community
|
||||||
|
<span className="text-danger ms-1" aria-label="обязательное поле">*</span>
|
||||||
|
</label>
|
||||||
|
<div className="input-icon">
|
||||||
|
<span className="input-icon-addon">
|
||||||
|
<IconHash size={18} />
|
||||||
|
</span>
|
||||||
|
<CommunityAutocompleteInput
|
||||||
|
value={formData.community}
|
||||||
|
onChange={(v) => {
|
||||||
|
setFormData({ ...formData, community: v });
|
||||||
|
if (errors.community) setErrors({ ...errors, community: '' });
|
||||||
|
}}
|
||||||
|
communities={communities}
|
||||||
|
placeholder="# Начните вводить номер или имя"
|
||||||
|
className={`form-control${errors.community ? ' is-invalid' : isValidCommunity(formData.community) ? ' is-valid' : ''}`}
|
||||||
|
useShortValue={true}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.community && (
|
||||||
|
<div className="invalid-feedback d-block">{errors.community}</div>
|
||||||
|
)}
|
||||||
|
{isValidCommunity(formData.community) && !errors.community && (
|
||||||
|
<div className="valid-feedback d-block">Корректный формат</div>
|
||||||
|
)}
|
||||||
|
{!errors.community && !isValidCommunity(formData.community) && (
|
||||||
|
<div className="form-text text-muted small">
|
||||||
|
Формат: N или N:N (например 65000:100)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
if (!formData.community) return null;
|
||||||
|
const match = communities.find(c => c.value === String(formData.community).trim());
|
||||||
|
if (!match) return null;
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info alert-dismissible mt-2 mb-0" role="alert">
|
||||||
|
<div className="d-flex">
|
||||||
|
<div className="flex-grow-1">
|
||||||
|
<strong>{match.name || match.value}</strong>
|
||||||
|
{match.description && <div className="text-muted small mt-1">{match.description}</div>}
|
||||||
|
{match.tags && match.tags.length > 0 && (
|
||||||
|
<div className="mt-2">
|
||||||
|
{match.tags.map((tag, idx) => (
|
||||||
|
<span key={idx} className="badge bg-blue-lt text-blue me-1">{tag}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user