diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index 41cc460..6ca2894 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -41,6 +41,7 @@ import SavedFilters from './components/SavedFilters.jsx'; import BulkActionsBar from './components/BulkActionsBar.jsx'; import Tooltip from './components/Tooltip.jsx'; import Pagination from './components/Pagination.jsx'; +import AddItemModal from './components/AddItemModal.jsx'; import { getAsnName, getAsnNameSync } from './lib/asn.js'; const API_URL = '/api'; @@ -63,6 +64,7 @@ function ASNsNewManager() { const [loading, setLoading] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); const [itemToDelete, setItemToDelete] = useState(null); + const [addModalOpen, setAddModalOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalItems, setTotalItems] = useState(0); const [sortField, setSortField] = useState('asn'); @@ -129,18 +131,25 @@ function ASNsNewManager() { useEffect(() => { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []); - const handleAddItem = () => { - const asnOk = isValidAsn(newItem.asn); - const communityOk = isValidCommunity(newItem.community); - setNewInvalid({ asn: !asnOk, community: !communityOk }); + const handleAddItem = (item) => { + const asn = item?.asn || newItem.asn; + const community = item?.community || newItem.community; + + const asnOk = isValidAsn(asn); + const communityOk = isValidCommunity(community); + if (!asnOk || !communityOk) { setError('Введите корректный номер ASN и числовой community.'); return; } + setError(''); - setItems([...items, { asn: String(newItem.asn).trim(), community: String(newItem.community).trim() }]); - setNewItem({ asn: '', community: '' }); - setNewInvalid({ asn: false, community: false }); + const newAsn = { asn: String(asn).trim(), community: String(community).trim() }; + setItems([...items, newAsn]); + if (!item) { + setNewItem({ asn: '', community: '' }); + setNewInvalid({ asn: false, community: false }); + } }; const handleEdit = (item) => { @@ -523,7 +532,16 @@ function ASNsNewManager() { ]} /> )} actions={( - + + + /> + )} /> + + setAddModalOpen(false)} + onSubmit={handleAddItem} + type="asn" + communities={communities} + loading={loading} + /> +
-
- {/* Classic Add New ASN Card (improved) */} -
-
-

- - Добавить новый ASN -

-
-
-
{ e.preventDefault(); handleAddItem(); }}> -
- -
- - - - setNewItem({ ...newItem, asn: e.target.value })} - /> -
- {newInvalid.asn &&
Только цифры
} - {isValidAsn(newItem.asn) && !newInvalid.asn && (
Корректный номер AS
)} -
Формат: только цифры, например 12345
-
-
- -
- - - - setNewItem({ ...newItem, community: v })} - communities={communities} - placeholder="Начните вводить номер или имя" - className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`} - useShortValue={true} - /> -
- {newInvalid.community &&
Неверный формат community
} - {isValidCommunity(newItem.community) && !newInvalid.community && (
Корректный формат community
)} -
Формат: N или N:N (например 65000:100)
- {(() => { - const match = communities.find(c => c.value === String(newItem.community).trim()); - if (!match) return null; - return ( -
-
-
- {match.name || match.value} - {match.description &&
{match.description}
} - {match.tags && match.tags.length > 0 && ( -
- {match.tags.map((tag, idx) => ({tag}))} -
- )} -
-
-
- ); - })()} -
-
- -
-
-
-
- - {/* Дополнительные варианты */} - - { 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()); - }); - }} - /> -
{ e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }} - onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }} - onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }} - > -
- -
Перетащите файл TXT/CSV сюда
-
Формат: ASN community
-
-
-
- - - {/* Карточка действий больше не нужна — действия перенесены в page-header */} -
- -
+
{/* BulkActionsBar */} { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []); - const handleAddItem = () => { - const domainOk = isValidDomain(newItem.domain); - const communityOk = isValidCommunity(newItem.community); - setNewInvalid({ domain: !domainOk, community: !communityOk }); + const handleAddItem = (item) => { + // Если item передан (из модалки), используем его, иначе из state (старый способ) + const domain = item?.domain || newItem.domain; + const community = item?.community || newItem.community; + + const domainOk = isValidDomain(domain); + const communityOk = isValidCommunity(community); + if (!domainOk || !communityOk) { setError('Заполните корректный домен (example.com) и числовой community.'); return; } + setError(''); - setItems([...items, { domain: newItem.domain.trim().toLowerCase(), community: String(newItem.community).trim() }]); - setNewItem({ domain: '', community: '' }); + const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() }; + setItems([...items, newDomain]); + if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки setNewInvalid({ domain: false, community: false }); }; @@ -520,7 +528,16 @@ function DomainsNewManager() { /> )} actions={( - + + +
)} /> + setAddModalOpen(false)} + onSubmit={handleAddItem} + type="domain" + communities={communities} + loading={loading} + /> +
-
- {/* Classic Add New Domain Card (improved) */} -
-
-

- - Добавить новый домен -

-
-
-
{ e.preventDefault(); handleAddItem(); }}> -
- -
- - - - setNewItem({ ...newItem, domain: e.target.value })} - /> -
- {newInvalid.domain &&
Неверный формат домена
} - {isValidDomain(newItem.domain) && !newInvalid.domain && ( -
Корректный формат домена
- )} -
- Формат: sub.domain.tld (например foo.example.org) -
-
-
- -
- - - - setNewItem({ ...newItem, community: v })} - communities={communities} - placeholder="Начните вводить номер или имя" - className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`} - useShortValue={true} - /> -
- {newInvalid.community &&
Неверный формат community
} - {isValidCommunity(newItem.community) && !newInvalid.community && ( -
Корректный формат community
- )} -
- Формат: N или N:N (например 65000:100) -
- {(() => { - const match = communities.find(c => c.value === String(newItem.community).trim()); - if (!match) return null; - return ( -
-
-
- {match.name || match.value} - {match.description &&
{match.description}
} - {match.tags && match.tags.length > 0 && ( -
- {match.tags.map((tag, idx) => ( - {tag} - ))} -
- )} -
-
-
- ); - })()} -
-
- -
-
-
-
- - {/* Дополнительные варианты */} - - { 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()); - }); - }} - /> -
{ e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }} - onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }} - onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }} - > -
- -
Перетащите файл TXT/CSV сюда
-
Формат: domain community
-
-
-
-
- -
+
{/* Bulk Actions Bar */} { if (!didInit.current) { didInit.current = true; fetchItems(); } }, []); - const handleAddItem = () => { - const cidrOk = isValidCidr(newItem.ipRange); - const communityOk = isValidCommunity(newItem.community); - setNewInvalid({ ipRange: !cidrOk, community: !communityOk }); + const handleAddItem = (item) => { + const ipRange = item?.ipRange || newItem.ipRange; + const community = item?.community || newItem.community; + + const cidrOk = isValidCidr(ipRange); + const communityOk = isValidCommunity(community); + if (!cidrOk || !communityOk) { setError('Введите корректный IP-диапазон в формате CIDR (например 192.168.1.0/24) и числовой community.'); return; } + setError(''); - setItems([...items, { ipRange: newItem.ipRange.trim(), community: String(newItem.community).trim() }]); - setNewItem({ ipRange: '', community: '' }); - setNewInvalid({ ipRange: false, community: false }); + const newRange = { ipRange: ipRange.trim(), community: String(community).trim() }; + setItems([...items, newRange]); + if (!item) { + setNewItem({ ipRange: '', community: '' }); + setNewInvalid({ ipRange: false, community: false }); + } }; const handleEdit = (item) => { @@ -783,7 +792,16 @@ function IPRangesManager() { ]} /> )} actions={( - + + + /> +
)} /> + + setAddModalOpen(false)} + onSubmit={handleAddItem} + type="ipRange" + communities={communities} + loading={loading} + /> +
-
- {/* Classic Add New Item Card (improved) */} -
-
-

- - Добавить новый IP-диапазон -

-
-
-
{ e.preventDefault(); handleAddItem(); }}> -
- -
- - - - setNewItem({ ...newItem, ipRange: e.target.value })} - /> -
- {newInvalid.ipRange &&
Некорректный CIDR
} - {isValidCidr(newItem.ipRange) && !newInvalid.ipRange && (
Корректный CIDR
)} -
Формат: A.B.C.D/Маска, например 10.0.0.0/24
-
-
- -
- - - - setNewItem({ ...newItem, community: v })} - communities={communities} - placeholder="Начните вводить номер или имя" - className={`form-control${newInvalid.community ? ' is-invalid' : isValidCommunity(newItem.community) ? ' is-valid' : ''}`} - useShortValue={true} - /> -
- {newInvalid.community &&
Неверный формат community
} - {isValidCommunity(newItem.community) && !newInvalid.community && (
Корректный формат community
)} - {(() => { - const match = communities.find(c => c.value === String(newItem.community).trim()); - if (!match) return null; - return ( -
-
-
- {match.name || match.value} - {match.description &&
{match.description}
} - {match.tags && match.tags.length > 0 && ( -
- {match.tags.map((tag, idx) => ({tag}))} -
- )} -
-
-
- ); - })()} -
-
- -
-
-
-
- - {/* Дополнительные варианты */} - - { 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()); - }); - }} - /> -
{ e.preventDefault(); e.currentTarget.style.borderColor = 'var(--tblr-primary)'; }} - onDragLeave={(e) => { e.currentTarget.style.borderColor = ''; }} - onDrop={(e) => { e.currentTarget.style.borderColor = ''; onDropImport(e); }} - > -
- -
Перетащите файл TXT/CSV сюда
-
Формат: CIDR community
-
-
-
- - - {/* Карточка действий больше не нужна — действия перенесены в page-header */} -
- -
+
{/* BulkActionsBar */} 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 ( + + { + 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} + /> + +
+ +
+ + + + { + 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} + /> +
+ {errors.community && ( +
{errors.community}
+ )} + {isValidCommunity(formData.community) && !errors.community && ( +
Корректный формат
+ )} + {!errors.community && !isValidCommunity(formData.community) && ( +
+ Формат: N или N:N (например 65000:100) +
+ )} +
+ + {(() => { + if (!formData.community) return null; + const match = communities.find(c => c.value === String(formData.community).trim()); + if (!match) return null; + return ( +
+
+
+ {match.name || match.value} + {match.description &&
{match.description}
} + {match.tags && match.tags.length > 0 && ( +
+ {match.tags.map((tag, idx) => ( + {tag} + ))} +
+ )} +
+
+
+ ); + })()} +
+ ); +}