Files
router-lists-ui/frontend/src/DomainsNewManager.jsx
T
denozord 23d7b67813
Publish Docker image / build-and-push (push) Successful in 2m12s
feat(integration): switch data sections to EvoBGP v1 API
Move Domains, IP ranges, ASNs and Communities managers from legacy /api endpoints to direct EvoBGP /v1 modules/communities endpoints with a dedicated adapter layer.

Made-with: Cursor
2026-04-07 22:50:54 +07:00

941 lines
39 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, useCallback } from 'react';
import api from './lib/api.js';
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
import CommunityBadge from './components/CommunityBadge.jsx';
import ConfirmModal from './components/ConfirmModal.jsx';
import {
IconPlus,
IconSearch,
IconEdit,
IconTrash,
IconCheck,
IconX,
IconDatabase,
IconRefresh,
IconUpload,
IconDownload,
IconDeviceFloppy,
IconHash,
IconClock,
IconFileText,
IconFilter,
IconWorld,
IconTrendingUp,
IconUsers,
IconArrowUp,
IconArrowDown,
IconCopy
} from '@tabler/icons-react';
import TableSkeleton, { TableEmpty } from './components/TableSkeleton.jsx';
import PageHeader from './components/PageHeader.jsx';
import EmptyState from './components/EmptyState.jsx';
import { IconAlertTriangle } from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import ConfirmDialog from './components/ConfirmDialog.jsx';
import WsUpdateModal from './components/WsUpdateModal.jsx';
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
import HistoryModal from './components/HistoryModal.jsx';
import ErrorAlert from './components/ErrorAlert.jsx';
import { notifyMutationSuccess } from './components/NotifyProvider.jsx';
import ImportModal from './components/ImportModal.jsx';
import QuickAddBar from './components/QuickAddBar.jsx';
import AccordionCard from './components/AccordionCard.jsx';
import SavedFilters from './components/SavedFilters.jsx';
import BulkActionsBar from './components/BulkActionsBar.jsx';
import FormField from './components/FormField.jsx';
import Tooltip from './components/Tooltip.jsx';
import Pagination from './components/Pagination.jsx';
import AddItemModal from './components/AddItemModal.jsx';
import LastSaved from './components/LastSaved.jsx';
import FormModal from './components/FormModal.jsx';
import { useToast } from './components/ToastContainer.jsx';
import {
listCommunities,
fetchDomainsData,
saveDomainsData,
} from './lib/evobgpData.js';
function DomainsNewManager() {
const toast = useToast();
const [items, setItems] = useState([]);
const [originalItems, setOriginalItems] = useState([]);
const [, setEtag] = useState('');
const [lastModified, setLastModified] = useState('');
const [, setContentLength] = useState(null);
const [newItem, setNewItem] = useState({ domain: '', community: '' });
const [, setNewInvalid] = useState({ domain: false, community: false });
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const _searchTimer = useRef(null);
const didInit = useRef(false);
const [editingValue, setEditingValue] = useState('');
const [editModalOpen, setEditModalOpen] = useState(false);
const [itemBeingEdited, setItemBeingEdited] = useState(null);
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('domain');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCommunity, setFilterCommunity] = useState('');
const pageSize = 10;
// Bulk operations
const [selectedDomains, setSelectedDomains] = useState(new Set());
// Справочник community для подсказок
const [communities, setCommunities] = useState([]);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
(async () => {
try {
const list = await listCommunities();
setCommunities(list);
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
(async () => {
try {
const r = await api.get('/ws/url', { signal });
setWsUrl(String(r.data?.url || ''));
} catch (err) {
if (err?.name === 'CanceledError' || err?.name === 'AbortError' || err?.code === 'ERR_CANCELED') return;
}
})();
return () => controller.abort();
}, []);
const renderCommunityBadge = (value) => (<CommunityBadge value={value} communities={communities} />);
const isValidDomain = (value) => {
const v = String(value).trim().toLowerCase();
// Простой валидатор FQDN: sub.domain.tld
return /^([a-z0-9-]+\.)+[a-z]{2,}$/i.test(v);
};
// Допускаем как числовые, так и строковые (AS:NNN) community
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
const [wsOpen, setWsOpen] = useState(false);
const [wsUrl, setWsUrl] = useState('');
const fetchItems = useCallback(async (_abortSignal) => {
setLoading(true);
try {
const payload = await fetchDomainsData();
const total = payload.length;
setItems(payload);
setOriginalItems(payload);
setTotalItems(Number.isFinite(total) ? total : 0);
setEtag('');
setLastModified(new Date().toISOString());
setContentLength(null);
setError('');
} catch (error) {
if (error?.name === 'CanceledError' || error?.name === 'AbortError' || error?.code === 'ERR_CANCELED') return;
console.error('Error fetching domains-new:', error);
setError('Не удалось загрузить домены. Проверьте, запущен ли бэкенд.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!didInit.current) {
didInit.current = true;
const controller = new AbortController();
fetchItems(controller.signal);
return () => controller.abort();
}
}, [fetchItems]);
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('');
const newDomain = { domain: domain.trim().toLowerCase(), community: String(community).trim() };
setItems([...items, newDomain]);
if (!item) setNewItem({ domain: '', community: '' }); // Сброс только если не из модалки
setNewInvalid({ domain: false, community: false });
};
const handleEdit = (item) => {
setItemBeingEdited(item);
setEditingValue(item.community);
setEditModalOpen(true);
};
const handleSaveEdit = () => {
if (!itemBeingEdited) return;
if (!isValidCommunity(editingValue)) {
setError('Community должен быть числом.');
return;
}
const updatedItems = items.map(i =>
i.domain === itemBeingEdited.domain ? { ...i, community: String(editingValue).trim() } : i
);
setItems(updatedItems);
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleCancelEdit = () => {
setEditModalOpen(false);
setItemBeingEdited(null);
};
const handleDeleteItem = (domainNameToDelete) => {
setItems(items.filter(i => i.domain !== domainNameToDelete));
setSelectedDomains(prev => {
const next = new Set(prev);
next.delete(domainNameToDelete);
return next;
});
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => {
toast.success('Скопировано в буфер обмена');
}).catch(() => {
toast.error('Не удалось скопировать');
});
};
const confirmDelete = (item) => {
setItemToDelete(item);
setShowDeleteModal(true);
};
const executeDelete = () => {
if (itemToDelete) {
handleDeleteItem(itemToDelete.domain);
setShowDeleteModal(false);
setItemToDelete(null);
}
};
const deduplicate = (arr) => {
const seen = new Set();
const out = [];
for (const i of arr) {
const key = String(i.domain).trim().toLowerCase();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
out.push({ domain: key, community: String(i.community || '').trim() });
}
return out;
};
const computeDiff = (before, after) => {
const mapBefore = new Map(before.map(i => [i.domain, i]));
const mapAfter = new Map(after.map(i => [i.domain, i]));
const added = [];
const removed = [];
const changed = [];
for (const [k, v] of mapAfter) {
if (!mapBefore.has(k)) { added.push(v); continue; }
const prev = mapBefore.get(k);
if (String(prev.community) !== String(v.community)) changed.push({ from: prev, to: v });
}
for (const [k, v] of mapBefore) {
if (!mapAfter.has(k)) removed.push(v);
}
return { added, removed, changed };
};
const [showDiff, setShowDiff] = useState(false);
const [diff, setDiff] = useState({ added: [], removed: [], changed: [] });
const [confirmSaveOpen, setConfirmSaveOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [clearCommunitiesOpen, setClearCommunitiesOpen] = useState(false);
const handlePreviewDiff = () => {
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
setDiff(computeDiff(originalItems, unique));
setShowDiff(true);
};
const performSave = useCallback(async () => {
setConfirmSaveOpen(false);
setLoading(true);
try {
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
await saveDomainsData(unique, originalItems);
setEtag('');
// После сохранения перезагрузим текущую страницу для консистентности
await fetchItems();
setSuccess('Изменения успешно сохранены!');
setTimeout(() => setSuccess(''), 3000);
notifyMutationSuccess('Изменения сохранены');
} catch (error) {
console.error('Error saving changes:', error);
setError('Не удалось сохранить изменения.');
} finally {
setLoading(false);
}
}, [items, originalItems, fetchItems]);
const handleSaveChanges = useCallback(async () => {
const valid = items.filter(i => i != null && isValidDomain(i.domain) && isValidCommunity(i.community))
.map(i => ({ domain: i.domain.trim().toLowerCase(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const diffData = computeDiff(originalItems, unique);
setDiff(diffData);
const totalChanges = (diffData.added?.length || 0) + (diffData.removed?.length || 0) + (diffData.changed?.length || 0);
if (totalChanges > 10) {
setConfirmSaveOpen(true);
} else {
performSave();
}
}, [items, originalItems, performSave]);
// Drag&Drop импорт
const _onDropImport = async (e) => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const text = await file.text();
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 => i != null && isValidDomain(i.domain) && isValidCommunity(i.community));
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 handleImport = () => {
setImportOpen(true);
};
const handleExport = async () => {
try {
const all = filtered;
const header = ['domain', 'community'];
const csv = [header, ...all.filter(i => i != null).map(i => [i.domain || '', i.community || ''])]
.map(r => r.map(x => `"${(x ?? '').toString().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 = `domains_${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
window.notify?.error('Экспорт доменов не удался', String(e?.message || e));
}
};
const clearInvalid = () => {
setItems(prev => prev.filter(i => i != null && (i.domain || i.community))
.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community))
);
};
// ===== Импорт через модалку =====
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) => {
let valA = a[sortField] || '';
let valB = b[sortField] || '';
if (typeof valA === 'string') valA = valA.toLowerCase();
if (typeof valB === 'string') valB = valB.toLowerCase();
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
// Локальная фильтрация и пагинация
const filtered = sortedItems.filter(i => {
if (!i) return false;
const byCommunity = !filterCommunity || i.community === filterCommunity;
const term = String(searchTerm || '').toLowerCase();
const bySearch = !term || String(i.domain || '').toLowerCase().includes(term);
return byCommunity && bySearch;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const paginatedItems = filtered.slice((currentPage - 1) * pageSize, (currentPage) * pageSize);
// Для фильтра - список всех уникальных community
const allCommunities = Array.from(new Set(items.filter(i => i != null).map(i => i.community).filter(c => c != null)));
// Сортировка по клику
const handleSort = (field) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('asc');
}
};
// Глобальные хоткеи: Ctrl+S — сохранить изменения
useEffect(() => {
const onKey = (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
if (!loading) handleSaveChanges();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [loading, handleSaveChanges]);
const handleEditKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
}
if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};
// Bulk operations handlers
const handleSelectAll = () => {
setSelectedDomains(new Set(paginatedItems.map(i => i.domain)));
};
const handleDeselectAll = () => {
setSelectedDomains(new Set());
};
const handleToggleSelect = (domain) => {
const newSet = new Set(selectedDomains);
if (newSet.has(domain)) {
newSet.delete(domain);
} else {
newSet.add(domain);
}
setSelectedDomains(newSet);
};
const handleBulkDelete = () => {
if (selectedDomains.size === 0) return;
const count = selectedDomains.size;
const newItems = items.filter(i => !selectedDomains.has(i.domain));
setItems(newItems);
setSelectedDomains(new Set());
toast.success(`Удалено ${count} доменов`);
};
const handleBulkExport = () => {
if (selectedDomains.size === 0) return;
const selectedItems = items.filter(i => i != null && selectedDomains.has(i.domain));
const text = selectedItems.map(i => `${i.domain || ''} ${i.community || ''}`).join('\n');
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `domains-selected-${selectedDomains.size}.txt`;
a.click();
URL.revokeObjectURL(url);
toast.success(`Экспортировано ${selectedDomains.size} доменов`);
};
return (
<>
{error && (
<ErrorAlert message={error} onClose={() => setError('')} />
)}
{success && (
<div className="alert alert-success alert-dismissible" role="alert">
<div className="d-flex">
<div>
<IconCheck className="icon alert-icon" />
</div>
<div>
{success}
</div>
</div>
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
</div>
)}
<PageHeader
title="Домены"
icon={<IconWorld size={24} />}
meta={<LastSaved timestamp={lastModified} variant="compact" />}
actions={(
<PageHeaderActions
loading={loading}
onPreview={handlePreviewDiff}
disablePreview={loading || items.length === 0}
onRefresh={fetchItems}
disableRefresh={loading}
onImport={handleImport}
onExport={handleExport}
disableExport={items.length === 0}
onClear={clearInvalid}
disableClear={items.length === 0}
onClearCommunities={() => { if (!filterCommunity) { setError('Выберите community в фильтре для очистки'); return; } setClearCommunitiesOpen(true); }}
disableClearCommunities={items.length === 0 || !filterCommunity}
onSave={handleSaveChanges}
disableSave={loading}
onHistory={() => setHistoryOpen(true)}
lastModified={lastModified}
onOnlineUpdate={() => setWsOpen(true)}
onBackgroundUpdate={async () => {
try {
const res = await fetch('/api/update-bgp/background', { method: 'POST' });
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data?.message || `HTTP ${res.status}`);
}
notifyMutationSuccess('Фоновое обновление запущено', data);
} catch (e) {
window.notify?.error('Не удалось запустить фоновое обновление', String(e));
}
}}
/>
)}
/>
<AddItemModal
open={addModalOpen}
onClose={() => setAddModalOpen(false)}
onSubmit={handleAddItem}
type="domain"
communities={communities}
loading={loading}
/>
<div className="row">
<div className="col-12">
{/* Bulk Actions Bar */}
<BulkActionsBar
selectedCount={selectedDomains.size}
totalCount={paginatedItems.length}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onDelete={handleBulkDelete}
onExport={handleBulkExport}
/>
<div className="card card-flat animate-in" style={{animationDelay: '0.3s'}}>
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="d-flex align-items-center gap-2">
<IconWorld className="icon text-muted" size={18} />
<span className="text-muted text-uppercase small">Список</span>
<span className="badge bg-blue-lt text-blue">{filtered.length}</span>
</div>
<div className="d-none d-md-block text-muted small">
Всего записей: {filtered.length}
</div>
</div>
</div>
<div className="col-12 mt-2"></div>
<div className="col">
<div className="d-flex gap-2 flex-wrap">
{/* Сохранённые фильтры */}
<SavedFilters
pageKey="domains"
currentFilters={{ searchTerm, filterCommunity, sortField, sortOrder }}
onApplyFilter={(filters) => {
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
if (filters.filterCommunity !== undefined) setFilterCommunity(filters.filterCommunity);
if (filters.sortField !== undefined) setSortField(filters.sortField);
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
setCurrentPage(1);
}}
/>
{/* Фильтр по community */}
<div className="input-icon">
<span className="input-icon-addon">
<IconFilter size={18} />
</span>
<select
className="form-select"
style={{ minWidth: '200px' }}
value={filterCommunity}
onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}
title={(() => {
const meta = communities.find(c => c.value === filterCommunity);
if (!meta) return 'Все community';
const name = meta.name ? meta.name + ' — ' : '';
const desc = meta.description || '';
const tags = meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : '';
return name + desc + tags;
})()}
>
<option value="">Все community</option>
{allCommunities.map(community => {
const meta = communities.find(c => c.value === community);
const label = meta && meta.name ? `${community}${meta.name}` : community;
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : community;
return (
<option key={community} value={community} title={title}>{label}</option>
);
})}
</select>
</div>
{/* Поиск */}
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
<span className="input-icon-addon">
<IconSearch size={18} />
</span>
<input
type="text"
className="form-control"
placeholder="Поиск домена..."
value={searchTerm}
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
aria-label="Поиск домена"
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
/>
{searchTerm && (
<button
type="button"
className="btn btn-ghost-secondary btn-icon btn-sm"
style={{
position: 'absolute',
right: '0.25rem',
top: '50%',
transform: 'translateY(-50%)',
zIndex: 2
}}
onClick={() => { setSearchTerm(''); setCurrentPage(1); }}
aria-label="Очистить поиск"
>
<IconX size={16} />
</button>
)}
</div>
</div>
</div>
<div className="col-auto">
<button
className="btn btn-primary"
onClick={() => setAddModalOpen(true)}
title="Добавить новый домен"
>
<IconPlus className="me-1" size={18} />
Добавить
</button>
</div>
</div>
</div>
<div className="card-body p-0">
{/* Таблица с лёгкой карточной подложкой (плоский стиль) */}
{loading ? (
<TableSkeleton rows={10} cols={4} hasCheckbox={true} flat />
) : paginatedItems.length === 0 ? (
<TableEmpty cols={3} flat>
<EmptyState
title="Нет доменов"
description="Импортируйте или добавьте записи, чтобы начать."
action={<button className="btn btn-primary" onClick={handleImport}>Импорт</button>}
secondaryAction={<button className="btn btn-outline-primary" onClick={() => {
const header = ['domain','community'];
const lines = [header, ['example.com','65000:100']].map(r => r.map(x => `"${(x ?? '').toString().replace(/"/g, '""')}"`).join(',')).join('\n');
const blob = new Blob([lines], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'domains_sample.csv'; a.click(); URL.revokeObjectURL(url);
}}>Шаблон CSV</button>}
/>
</TableEmpty>
) : (
<div className="table-responsive">
<table className="table table-flat table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th style={{ width: '40px' }}>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.size === paginatedItems.length && paginatedItems.length > 0}
onChange={(e) => e.target.checked ? handleSelectAll() : handleDeselectAll()}
/>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('domain')}>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-1 text-muted" />
Домен
{sortField === 'domain' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="cursor-pointer user-select-none" onClick={() => handleSort('community')}>
<div className="d-flex align-items-center">
<IconHash size={16} className="me-1 text-muted" />
Community
{sortField === 'community' && (
<span className="ms-1">
{sortOrder === 'asc' ? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}
</span>
)}
</div>
</th>
<th className="text-end">Действия</th>
</tr>
</thead>
<tbody>
{paginatedItems.map((item, idx) => (
<tr
key={item.domain}
className={selectedDomains.has(item.domain) ? 'table-selected' : ''}
style={{
animation: `fadeIn 0.3s ease ${idx * 0.02}s both`
}}
>
<td>
<input
type="checkbox"
className="form-check-input"
checked={selectedDomains.has(item.domain)}
onChange={() => handleToggleSelect(item.domain)}
aria-label={`Выбрать ${item.domain}`}
onClick={(e) => e.stopPropagation()}
/>
</td>
<td>
<div className="d-flex align-items-center">
<IconWorld size={16} className="me-2 text-muted" />
<span className="font-monospace">{item.domain}</span>
<Tooltip content="Копировать домен" position="top">
<button
className="btn btn-ghost-secondary btn-icon btn-sm ms-2"
onClick={() => copyToClipboard(item.domain)}
aria-label="Копировать"
title="Копировать домен"
>
<IconCopy size={14} />
</button>
</Tooltip>
</div>
</td>
<td>
<div className="d-flex align-items-center">
{renderCommunityBadge(item.community)}
</div>
</td>
<td className="text-end">
<div className="btn-group btn-group-sm" role="group" aria-label={`Действия с доменом ${item.domain}`}>
<Tooltip content="Редактировать" position="top">
<button
className="btn btn-icon btn-outline-primary"
aria-label="Редактировать"
onClick={() => handleEdit(item)}
title="Редактировать"
>
<IconEdit size={16} />
</button>
</Tooltip>
<Tooltip content="Удалить" position="top">
<button
className="btn btn-icon btn-outline-danger"
aria-label="Удалить"
onClick={() => confirmDelete(item)}
title="Удалить"
>
<IconTrash size={16} />
</button>
</Tooltip>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="card-footer border-top-0">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={totalItems || filtered.length}
pageSize={pageSize}
onPageChange={setCurrentPage}
/>
</div>
</div>
</div>
</div>
<ConfirmModal
show={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={executeDelete}
title="Удалить домен?"
message={
itemToDelete ? (
<>
<p>Вы уверены, что хотите удалить <strong>"{itemToDelete.domain}"</strong>?</p>
<p className="text-muted mb-0">Это действие необратимо.</p>
</>
) : null
}
confirmLabel="Удалить"
variant="danger"
icon={IconTrash}
/>
{/* Confirm Save Modal */}
<ConfirmDiffModal
show={confirmSaveOpen}
diff={diff}
onConfirm={performSave}
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
resource="domains-new"
show={historyOpen}
onClose={() => setHistoryOpen(false)}
onRolledBack={() => fetchItems()}
/>
<WsUpdateModal
show={wsOpen}
url={wsUrl}
onClose={() => setWsOpen(false)}
/>
{/* Модальное окно редактирования community */}
<FormModal
show={editModalOpen && !!itemBeingEdited}
onClose={handleCancelEdit}
onSubmit={handleSaveEdit}
title="Редактировать community"
submitLabel="Сохранить"
submitIcon={IconCheck}
size="md"
centered
disabled={!isValidCommunity(editingValue)}
>
{itemBeingEdited && (
<>
<p className="text-muted mb-2">
Домен: <span className="font-monospace">{itemBeingEdited.domain}</span>
</p>
<div className="mb-3">
<label className="form-label">Community</label>
<CommunityAutocompleteInput
value={editingValue}
onChange={setEditingValue}
communities={communities}
onKeyDown={handleEditKeyDown}
className={`form-control${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
useShortValue={true}
/>
{!isValidCommunity(editingValue) && (
<div className="invalid-feedback d-block">
Введите корректное значение community (число или N:N).
</div>
)}
</div>
</>
)}
</FormModal>
<ConfirmModal
show={clearCommunitiesOpen}
onClose={() => setClearCommunitiesOpen(false)}
onConfirm={() => {
setItems(prev => prev.filter(i => i != null && i.community !== filterCommunity));
setClearCommunitiesOpen(false);
}}
title="Удалить записи community?"
message={
<>
<p>Будут удалены все домены с community <strong>"{filterCommunity}"</strong>.</p>
<p className="text-warning mb-0">
<IconAlertTriangle size={16} className="me-1" />
Действие НЕ сохраняет изменения автоматически.
</p>
</>
}
confirmLabel="Удалить"
variant="warning"
/>
{/* Diff Modal */}
{showDiff && (
<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={() => setShowDiff(false)}></button>
<div className="modal-header"><h3 className="modal-title">Изменения</h3></div>
<div className="modal-body">
<div className="row g-2 g-md-3">
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-6 col-md-4"><div className="card"><div className="card-body py-2"><strong>Изменено</strong><div className="text-muted">{diff.changed.length}</div></div></div></div>
</div>
</div>
<div className="modal-footer">
<button className="btn" onClick={() => setShowDiff(false)}>Закрыть</button>
</div>
</div>
</div>
</div>
)}
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
</>
);
}
export default DomainsNewManager;