feat: Добавить модальные окна для импорта данных и компонент QuickAddBar в менеджерах ASNs, Domains и IPRanges, улучшив пользовательский интерфейс и упрощая процесс добавления записей
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m39s
This commit is contained in:
@@ -29,6 +29,8 @@ import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
|||||||
import HistoryModal from './components/HistoryModal.jsx';
|
import HistoryModal from './components/HistoryModal.jsx';
|
||||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
||||||
|
import ImportModal from './components/ImportModal.jsx';
|
||||||
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -269,22 +271,7 @@ function ASNsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
const text = window.prompt('Вставьте строки: ASN ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
setImportOpen(true);
|
||||||
if (!text) return;
|
|
||||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
const parsed = lines.map(l => {
|
|
||||||
const [a, c] = l.split(/\s+/);
|
|
||||||
return { asn: (a || '').trim(), community: (c || '').trim() };
|
|
||||||
}).filter(i => isValidAsn(i.asn) && isValidCommunity(i.community));
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...parsed];
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@@ -309,7 +296,7 @@ function ASNsNewManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag&Drop импорт
|
// Drag&Drop импорт (быстрый путь)
|
||||||
const onDropImport = async (e) => {
|
const onDropImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
@@ -337,6 +324,26 @@ function ASNsNewManager() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Импорт через модалку =====
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const parseLine = (line) => {
|
||||||
|
const [a, c] = String(line).split(/\s+/);
|
||||||
|
return { asn: (a || '').trim(), community: (c || '').trim() };
|
||||||
|
};
|
||||||
|
const validateItem = (obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community);
|
||||||
|
const applyImportedItems = (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());
|
||||||
|
});
|
||||||
|
setImportOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Сортировка
|
// Сортировка
|
||||||
const sortedItems = [...items].sort((a, b) => {
|
const sortedItems = [...items].sort((a, b) => {
|
||||||
let valA = a[sortField] || '';
|
let valA = a[sortField] || '';
|
||||||
@@ -427,6 +434,8 @@ function ASNsNewManager() {
|
|||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
onPreview={handlePreviewDiff}
|
||||||
|
disablePreview={loading || items.length === 0}
|
||||||
onRefresh={fetchItems}
|
onRefresh={fetchItems}
|
||||||
disableRefresh={loading}
|
disableRefresh={loading}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
@@ -457,62 +466,23 @@ function ASNsNewManager() {
|
|||||||
/>
|
/>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Add New ASN Card */}
|
<QuickAddBar
|
||||||
<div className="card card-md">
|
placeholder={'12345 65000:100'}
|
||||||
<div className="card-header">
|
help={'Вставьте строки: ASN ПРОБЕЛ COMMUNITY'}
|
||||||
<h3 className="card-title">
|
parseLine={(line) => { const [a, c] = String(line).split(/\s+/); return { asn: (a||'').trim(), community: (c||'').trim() }; }}
|
||||||
<IconPlus className="icon me-2" />
|
validateItem={(obj) => isValidAsn(obj.asn) && isValidCommunity(obj.community)}
|
||||||
Добавить новый ASN
|
onApply={(list) => {
|
||||||
</h3>
|
setItems(prev => {
|
||||||
</div>
|
const merged = [...prev, ...list];
|
||||||
<div className="card-body">
|
const map = new Map();
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
for (const it of merged) {
|
||||||
<div className="mb-3">
|
const key = String(it.asn).trim();
|
||||||
<label className="form-label">Номер AS</label>
|
if (!map.has(key)) map.set(key, { asn: key, community: String(it.community).trim() });
|
||||||
<input
|
}
|
||||||
type="text"
|
return Array.from(map.values());
|
||||||
className={`form-control${newInvalid.asn ? ' is-invalid' : ''}`}
|
});
|
||||||
placeholder="12345"
|
}}
|
||||||
value={newItem.asn}
|
/>
|
||||||
onChange={(e) => setNewItem({ ...newItem, asn: e.target.value })}
|
|
||||||
/>
|
|
||||||
{newInvalid.asn && <div className="invalid-feedback">Только цифры</div>}
|
|
||||||
<div className="form-text">Формат: только цифры, например 12345</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Community</label>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
|
||||||
/>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</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="form-text">
|
|
||||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
|
||||||
{match.description || ''}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Drag & Drop импорт */}
|
{/* Drag & Drop импорт */}
|
||||||
<div
|
<div
|
||||||
className="card card-md border-dashed"
|
className="card card-md border-dashed"
|
||||||
@@ -714,6 +684,17 @@ function ASNsNewManager() {
|
|||||||
onConfirm={performSave}
|
onConfirm={performSave}
|
||||||
onClose={() => setConfirmSaveOpen(false)}
|
onClose={() => setConfirmSaveOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ImportModal
|
||||||
|
show={importOpen}
|
||||||
|
title={'Импорт ASN'}
|
||||||
|
description={'Формат: ASN ПРОБЕЛ COMMUNITY'}
|
||||||
|
parseLine={parseLine}
|
||||||
|
validateItem={validateItem}
|
||||||
|
onConfirm={applyImportedItems}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
sampleHeader={['asn','community']}
|
||||||
|
placeholder={'12345 65000:100'}
|
||||||
|
/>
|
||||||
<HistoryModal
|
<HistoryModal
|
||||||
resource="asns"
|
resource="asns"
|
||||||
show={historyOpen}
|
show={historyOpen}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
|||||||
import HistoryModal from './components/HistoryModal.jsx';
|
import HistoryModal from './components/HistoryModal.jsx';
|
||||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
||||||
|
import ImportModal from './components/ImportModal.jsx';
|
||||||
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -297,23 +299,7 @@ function DomainsNewManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
const text = window.prompt('Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
setImportOpen(true);
|
||||||
if (!text) return;
|
|
||||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
const parsed = lines.map(l => {
|
|
||||||
const [d, c] = l.split(/\s+/);
|
|
||||||
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
|
||||||
}).filter(i => isValidDomain(i.domain) && isValidCommunity(i.community));
|
|
||||||
// merge and dedup
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...parsed];
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@@ -343,6 +329,26 @@ function DomainsNewManager() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Импорт через модалку =====
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const parseLine = (line) => {
|
||||||
|
const [d, c] = String(line).split(/\s+/);
|
||||||
|
return { domain: (d || '').toLowerCase(), community: (c || '').trim() };
|
||||||
|
};
|
||||||
|
const validateItem = (obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community);
|
||||||
|
const applyImportedItems = (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());
|
||||||
|
});
|
||||||
|
setImportOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Сортировка
|
// Сортировка
|
||||||
const sortedItems = [...items].sort((a, b) => {
|
const sortedItems = [...items].sort((a, b) => {
|
||||||
let valA = a[sortField] || '';
|
let valA = a[sortField] || '';
|
||||||
@@ -433,6 +439,8 @@ function DomainsNewManager() {
|
|||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
onPreview={handlePreviewDiff}
|
||||||
|
disablePreview={loading || items.length === 0}
|
||||||
onRefresh={fetchItems}
|
onRefresh={fetchItems}
|
||||||
disableRefresh={loading}
|
disableRefresh={loading}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
@@ -463,63 +471,23 @@ function DomainsNewManager() {
|
|||||||
/>
|
/>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Add New Item Card */}
|
<QuickAddBar
|
||||||
<div className="card card-md">
|
placeholder={'example.com 65000:100'}
|
||||||
<div className="card-header">
|
help={'Вставьте строки: DOMAIN ПРОБЕЛ COMMUNITY'}
|
||||||
<h3 className="card-title">
|
parseLine={(line) => { const [d, c] = String(line).split(/\s+/); return { domain: (d||'').toLowerCase(), community: (c||'').trim() }; }}
|
||||||
<IconPlus className="icon me-2" />
|
validateItem={(obj) => isValidDomain(obj.domain) && isValidCommunity(obj.community)}
|
||||||
Добавить новый домен
|
onApply={(list) => {
|
||||||
</h3>
|
setItems(prev => {
|
||||||
</div>
|
const merged = [...prev, ...list];
|
||||||
<div className="card-body">
|
const map = new Map();
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
for (const it of merged) {
|
||||||
<div className="mb-3">
|
const key = String(it.domain).trim().toLowerCase();
|
||||||
<label className="form-label">Домен</label>
|
if (!map.has(key)) map.set(key, { domain: key, community: String(it.community).trim() });
|
||||||
<input
|
}
|
||||||
type="text"
|
return Array.from(map.values());
|
||||||
className={`form-control${newInvalid.domain ? ' is-invalid' : ''}`}
|
});
|
||||||
placeholder="example.com"
|
}}
|
||||||
value={newItem.domain}
|
/>
|
||||||
onChange={(e) => setNewItem({ ...newItem, domain: e.target.value })}
|
|
||||||
/>
|
|
||||||
{newInvalid.domain && <div className="invalid-feedback">Неверный формат домена</div>}
|
|
||||||
<div className="form-text">Без http/https, только доменное имя</div>
|
|
||||||
<div className="form-text">Формат: sub.domain.tld (например foo.example.org)</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Community</label>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
|
||||||
/>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</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="form-text">
|
|
||||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
|
||||||
{match.description || ''}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
{/* Карточка действий больше не нужна — действия перенесены в page-header */}
|
||||||
{/* Drag & Drop импорт */}
|
{/* Drag & Drop импорт */}
|
||||||
@@ -722,6 +690,17 @@ function DomainsNewManager() {
|
|||||||
onConfirm={performSave}
|
onConfirm={performSave}
|
||||||
onClose={() => setConfirmSaveOpen(false)}
|
onClose={() => setConfirmSaveOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ImportModal
|
||||||
|
show={importOpen}
|
||||||
|
title={'Импорт доменов'}
|
||||||
|
description={'Формат: DOMAIN ПРОБЕЛ COMMUNITY'}
|
||||||
|
parseLine={parseLine}
|
||||||
|
validateItem={validateItem}
|
||||||
|
onConfirm={applyImportedItems}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
sampleHeader={['domain','community']}
|
||||||
|
placeholder={'example.com 65000:100'}
|
||||||
|
/>
|
||||||
<HistoryModal
|
<HistoryModal
|
||||||
resource="domains-new"
|
resource="domains-new"
|
||||||
show={historyOpen}
|
show={historyOpen}
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
|||||||
import HistoryModal from './components/HistoryModal.jsx';
|
import HistoryModal from './components/HistoryModal.jsx';
|
||||||
import ErrorAlert from './components/ErrorAlert.jsx';
|
import ErrorAlert from './components/ErrorAlert.jsx';
|
||||||
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
|
||||||
|
import ImportModal from './components/ImportModal.jsx';
|
||||||
|
import QuickAddBar from './components/QuickAddBar.jsx';
|
||||||
|
|
||||||
const API_URL = '/api';
|
const API_URL = '/api';
|
||||||
|
|
||||||
@@ -289,22 +291,7 @@ function IPRangesManager() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = () => {
|
const handleImport = () => {
|
||||||
const text = window.prompt('Вставьте строки: CIDR ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
setImportOpen(true);
|
||||||
if (!text) return;
|
|
||||||
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
|
|
||||||
const parsed = lines.map(l => {
|
|
||||||
const [cidr, c] = l.split(/\s+/);
|
|
||||||
return { ipRange: (cidr || '').trim(), community: (c || '').trim() };
|
|
||||||
}).filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community));
|
|
||||||
setItems(prev => {
|
|
||||||
const merged = [...prev, ...parsed];
|
|
||||||
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());
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
@@ -328,7 +315,7 @@ function IPRangesManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag&Drop импорт
|
// Drag&Drop импорт (оставим как быстрый путь)
|
||||||
const onDropImport = async (e) => {
|
const onDropImport = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const file = e.dataTransfer?.files?.[0];
|
const file = e.dataTransfer?.files?.[0];
|
||||||
@@ -356,6 +343,26 @@ function IPRangesManager() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Импорт через модалку =====
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const parseLine = (line) => {
|
||||||
|
const [cidr, c] = String(line).split(/\s+/);
|
||||||
|
return { ipRange: (cidr || '').trim(), community: (c || '').trim() };
|
||||||
|
};
|
||||||
|
const validateItem = (obj) => isValidCidr(obj.ipRange) && isValidCommunity(obj.community);
|
||||||
|
const applyImportedItems = (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());
|
||||||
|
});
|
||||||
|
setImportOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Сортировка
|
// Сортировка
|
||||||
const sortedItems = [...items].sort((a, b) => {
|
const sortedItems = [...items].sort((a, b) => {
|
||||||
let valA = a[sortField] || '';
|
let valA = a[sortField] || '';
|
||||||
@@ -684,6 +691,8 @@ function IPRangesManager() {
|
|||||||
actions={(
|
actions={(
|
||||||
<PageHeaderActions
|
<PageHeaderActions
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
onPreview={handlePreviewDiff}
|
||||||
|
disablePreview={loading || items.length === 0}
|
||||||
onRefresh={fetchItems}
|
onRefresh={fetchItems}
|
||||||
disableRefresh={loading}
|
disableRefresh={loading}
|
||||||
onImport={handleImport}
|
onImport={handleImport}
|
||||||
@@ -714,61 +723,23 @@ function IPRangesManager() {
|
|||||||
/>
|
/>
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-lg-3">
|
<div className="col-lg-3">
|
||||||
{/* Add New Item Card */}
|
<QuickAddBar
|
||||||
<div className="card card-md">
|
placeholder={'192.168.1.0/24 65000:100'}
|
||||||
<div className="card-header">
|
help={'Вставьте строки: CIDR ПРОБЕЛ COMMUNITY'}
|
||||||
<h3 className="card-title">
|
parseLine={(line) => { const [cidr, c] = String(line).split(/\s+/); return { ipRange: (cidr||'').trim(), community: (c||'').trim() }; }}
|
||||||
<IconPlus className="icon me-2" />
|
validateItem={(obj) => isValidCidr(obj.ipRange) && isValidCommunity(obj.community)}
|
||||||
Добавить новый IP-диапазон
|
onApply={(list) => {
|
||||||
</h3>
|
setItems(prev => {
|
||||||
</div>
|
const merged = [...prev, ...list];
|
||||||
<div className="card-body">
|
const map = new Map();
|
||||||
<form onSubmit={(e) => { e.preventDefault(); handleAddItem(); }}>
|
for (const it of merged) {
|
||||||
<div className="mb-3">
|
const key = String(it.ipRange).trim();
|
||||||
<label className="form-label">IP-диапазон</label>
|
if (!map.has(key)) map.set(key, { ipRange: key, community: String(it.community).trim() });
|
||||||
<input
|
}
|
||||||
type="text"
|
return Array.from(map.values());
|
||||||
className={`form-control${newInvalid.ipRange ? ' is-invalid' : ''}`}
|
});
|
||||||
placeholder="192.168.1.0/24"
|
}}
|
||||||
value={newItem.ipRange}
|
/>
|
||||||
onChange={(e) => setNewItem({ ...newItem, ipRange: e.target.value })}
|
|
||||||
/>
|
|
||||||
{newInvalid.ipRange && <div className="invalid-feedback">Некорректный CIDR</div>}
|
|
||||||
<div className="form-text">Формат: A.B.C.D/Маска, например 10.0.0.0/24</div>
|
|
||||||
</div>
|
|
||||||
<div className="mb-3">
|
|
||||||
<label className="form-label">Community</label>
|
|
||||||
<CommunityAutocompleteInput
|
|
||||||
value={newItem.community}
|
|
||||||
onChange={(v) => setNewItem({ ...newItem, community: v })}
|
|
||||||
communities={communities}
|
|
||||||
placeholder="Начните вводить номер или имя"
|
|
||||||
className={`form-control${newInvalid.community ? ' is-invalid' : ''}`}
|
|
||||||
/>
|
|
||||||
{newInvalid.community && <div className="invalid-feedback">Только цифры</div>}
|
|
||||||
{(() => {
|
|
||||||
const match = communities.find(c => c.value === String(newItem.community).trim());
|
|
||||||
if (!match) return null;
|
|
||||||
return (
|
|
||||||
<div className="form-text">
|
|
||||||
{match.name && (<><strong>{match.name}</strong> — </>)}
|
|
||||||
{match.description || ''}
|
|
||||||
{match.tags && match.tags.length > 0 && (
|
|
||||||
<> (<span className="text-muted">{match.tags.join(', ')}</span>)</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className="form-footer">
|
|
||||||
<button type="submit" className="btn btn-primary w-100">
|
|
||||||
<IconPlus className="icon me-2" />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Drag & Drop импорт */}
|
{/* Drag & Drop импорт */}
|
||||||
<div
|
<div
|
||||||
className="card card-md border-dashed"
|
className="card card-md border-dashed"
|
||||||
@@ -980,6 +951,17 @@ function IPRangesManager() {
|
|||||||
onConfirm={performSave}
|
onConfirm={performSave}
|
||||||
onClose={() => setConfirmSaveOpen(false)}
|
onClose={() => setConfirmSaveOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
<ImportModal
|
||||||
|
show={importOpen}
|
||||||
|
title={'Импорт IP-диапазонов'}
|
||||||
|
description={'Формат: CIDR ПРОБЕЛ COMMUNITY'}
|
||||||
|
parseLine={parseLine}
|
||||||
|
validateItem={validateItem}
|
||||||
|
onConfirm={applyImportedItems}
|
||||||
|
onClose={() => setImportOpen(false)}
|
||||||
|
sampleHeader={['ipRange','community']}
|
||||||
|
placeholder={'192.168.1.0/24 65000:100'}
|
||||||
|
/>
|
||||||
<HistoryModal
|
<HistoryModal
|
||||||
resource="ip-ranges"
|
resource="ip-ranges"
|
||||||
show={historyOpen}
|
show={historyOpen}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
function ImportModal({
|
||||||
|
show,
|
||||||
|
title = 'Импорт',
|
||||||
|
description = 'Вставьте текст или перетащите файл TXT/CSV. Формат: VALUE COMMUNITY',
|
||||||
|
parseLine,
|
||||||
|
validateItem,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
sampleHeader = ['value', 'community'],
|
||||||
|
placeholder = 'value community\nvalue community',
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [parsed, setParsed] = useState({ items: [], invalid: [] });
|
||||||
|
const [fileName, setFileName] = useState('');
|
||||||
|
const textAreaRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (show) {
|
||||||
|
setText('');
|
||||||
|
setParsed({ items: [], invalid: [] });
|
||||||
|
setFileName('');
|
||||||
|
setDragOver(false);
|
||||||
|
setTimeout(() => textAreaRef.current?.focus(), 0);
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
|
if (!show) return null;
|
||||||
|
|
||||||
|
const parseText = (raw) => {
|
||||||
|
const lines = String(raw || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const items = [];
|
||||||
|
const invalid = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const obj = parseLine(line);
|
||||||
|
if (obj && validateItem(obj)) items.push(obj); else invalid.push(line);
|
||||||
|
}
|
||||||
|
setParsed({ items, invalid });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
const file = e.dataTransfer?.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setFileName(file.name);
|
||||||
|
const content = await file.text();
|
||||||
|
setText(content);
|
||||||
|
parseText(content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (value) => {
|
||||||
|
setText(value);
|
||||||
|
parseText(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (parsed.items.length === 0) return;
|
||||||
|
onConfirm?.(parsed.items);
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadSample = () => {
|
||||||
|
const csv = [sampleHeader, ['example', '65000:100']]
|
||||||
|
.map(r => r.map(x => `"${String(x ?? '').replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\n');
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'sample.csv';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||||
|
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||||
|
<div className="modal-content">
|
||||||
|
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3 className="modal-title">{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="mb-2 text-muted">{description}</div>
|
||||||
|
<div
|
||||||
|
className={`mb-3 border-dashed rounded p-3 ${dragOver ? 'bg-blue-lt' : ''}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="d-flex align-items-center justify-content-between">
|
||||||
|
<div className="me-3 text-muted">
|
||||||
|
{fileName ? `Файл: ${fileName}` : 'Перетащите TXT/CSV сюда или вставьте текст ниже'}
|
||||||
|
</div>
|
||||||
|
<label className="btn btn-outline-primary mb-0">
|
||||||
|
Выбрать файл
|
||||||
|
<input type="file" accept=".txt,.csv,.log" hidden onChange={async (e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
setFileName(f.name);
|
||||||
|
const t = await f.text();
|
||||||
|
setText(t);
|
||||||
|
parseText(t);
|
||||||
|
}} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={textAreaRef}
|
||||||
|
className="form-control"
|
||||||
|
rows={8}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
<div className="row mt-3 g-3">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card"><div className="card-body">
|
||||||
|
<div className="text-muted">Готово к импорту</div>
|
||||||
|
<div className="h2 m-0">{parsed.items.length}</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="card"><div className="card-body">
|
||||||
|
<div className="text-muted">Пропущено (ошибки)</div>
|
||||||
|
<div className="h2 m-0">{parsed.invalid.length}</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-text mt-2">Проверьте предпросмотр и нажмите «Импортировать».</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="btn btn-outline-secondary" onClick={downloadSample}>Шаблон CSV</button>
|
||||||
|
<button className="btn" onClick={onClose}>Отмена</button>
|
||||||
|
<button className="btn btn-primary" disabled={parsed.items.length === 0} onClick={handleConfirm}>Импортировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportModal;
|
||||||
|
|
||||||
|
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
IconPlayerPlay,
|
IconPlayerPlay,
|
||||||
IconBolt,
|
IconBolt,
|
||||||
IconEraser,
|
IconEraser,
|
||||||
IconDots
|
IconDots,
|
||||||
|
IconEye
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import PortalDropdown from './PortalDropdown.jsx';
|
import PortalDropdown from './PortalDropdown.jsx';
|
||||||
import { IconClock } from '@tabler/icons-react';
|
import { IconClock } from '@tabler/icons-react';
|
||||||
@@ -98,6 +99,11 @@ function PageHeaderActions({
|
|||||||
</div>
|
</div>
|
||||||
<span className="vr d-none d-lg-inline" />
|
<span className="vr d-none d-lg-inline" />
|
||||||
<div className="btn-group d-none d-sm-inline-flex">
|
<div className="btn-group d-none d-sm-inline-flex">
|
||||||
|
{onPreview && (
|
||||||
|
<button className="btn btn-outline-primary" type="button" onClick={onPreview} disabled={disablePreview} title="Предпросмотр изменений">
|
||||||
|
<IconEye className="me-1" /> Предпросмотр
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onRefresh && (
|
{onRefresh && (
|
||||||
<button className="btn btn-outline-secondary" type="button" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные">
|
<button className="btn btn-outline-secondary" type="button" onClick={onRefresh} disabled={disableRefresh} title="Обновить данные">
|
||||||
<IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить
|
<IconRefresh className={loading ? 'spin me-1' : 'me-1'} /> Обновить
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { IconPlus, IconChecks } from '@tabler/icons-react';
|
||||||
|
|
||||||
|
function QuickAddBar({
|
||||||
|
placeholder = 'value community\nvalue community',
|
||||||
|
parseLine,
|
||||||
|
validateItem,
|
||||||
|
onApply,
|
||||||
|
className = '',
|
||||||
|
help = 'Вставьте строки: VALUE ПРОБЕЛ COMMUNITY',
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [readyCount, setReadyCount] = useState(0);
|
||||||
|
const [invalidCount, setInvalidCount] = useState(0);
|
||||||
|
const textareaRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lines = String(value || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
let ok = 0, bad = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
const obj = parseLine(line);
|
||||||
|
if (obj && validateItem(obj)) ok++; else bad++;
|
||||||
|
}
|
||||||
|
setReadyCount(ok);
|
||||||
|
setInvalidCount(bad);
|
||||||
|
}, [value, parseLine, validateItem]);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const lines = String(value || '')
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map(l => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const items = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const obj = parseLine(line);
|
||||||
|
if (obj && validateItem(obj)) items.push(obj);
|
||||||
|
}
|
||||||
|
if (items.length > 0) {
|
||||||
|
onApply?.(items);
|
||||||
|
setValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`card card-md ${className}`}>
|
||||||
|
<div className="card-header">
|
||||||
|
<h3 className="card-title"><IconPlus className="icon me-2" />Быстрое добавление</h3>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="mb-2 text-muted">{help}</div>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="form-control"
|
||||||
|
rows={4}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="row g-3 mt-2">
|
||||||
|
<div className="col">
|
||||||
|
<div className="card"><div className="card-body p-2">
|
||||||
|
<div className="text-muted">Готово</div>
|
||||||
|
<div className="h3 m-0">{readyCount}</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
<div className="col">
|
||||||
|
<div className="card"><div className="card-body p-2">
|
||||||
|
<div className="text-muted">Ошибки</div>
|
||||||
|
<div className="h3 m-0">{invalidCount}</div>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="card-footer d-flex justify-content-end">
|
||||||
|
<button className="btn btn-primary" disabled={readyCount === 0} onClick={handleApply}>
|
||||||
|
<IconChecks className="icon me-2" />Добавить к списку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default QuickAddBar;
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user