Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 6m14s
731 lines
29 KiB
React
731 lines
29 KiB
React
import { useState, useEffect, useRef } from 'react';
|
|
import api from './lib/api.js';
|
|
import CommunityAutocompleteInput from './components/CommunityAutocompleteInput.jsx';
|
|
import {
|
|
IconPlus,
|
|
IconSearch,
|
|
IconEdit,
|
|
IconTrash,
|
|
IconCheck,
|
|
IconX,
|
|
IconDatabase,
|
|
IconRefresh,
|
|
IconUpload,
|
|
IconDownload,
|
|
IconDeviceFloppy,
|
|
IconHash,
|
|
IconClock,
|
|
IconFileText
|
|
} from '@tabler/icons-react';
|
|
import LockBanner from './components/LockBanner.jsx';
|
|
import TableSkeleton from './components/TableSkeleton.jsx';
|
|
import S3MetaBar from './components/S3MetaBar.jsx';
|
|
import ConfirmDiffModal from './components/ConfirmDiffModal.jsx';
|
|
import HistoryModal from './components/HistoryModal.jsx';
|
|
|
|
const API_URL = '/api';
|
|
|
|
function ASNsNewManager() {
|
|
const [items, setItems] = useState([]);
|
|
const [originalItems, setOriginalItems] = useState([]);
|
|
const [etag, setEtag] = useState('');
|
|
const [lastModified, setLastModified] = useState('');
|
|
const [contentLength, setContentLength] = useState(null);
|
|
const [newItem, setNewItem] = useState({ asn: '', community: '' });
|
|
const [newInvalid, setNewInvalid] = useState({ asn: false, community: false });
|
|
const [error, setError] = useState('');
|
|
const [success, setSuccess] = useState('');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [editingAsn, setEditingAsn] = useState(null);
|
|
const [editingValue, setEditingValue] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
const [itemToDelete, setItemToDelete] = useState(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [sortField, setSortField] = useState('asn');
|
|
const [sortOrder, setSortOrder] = useState('asc');
|
|
const [filterCommunity, setFilterCommunity] = useState('');
|
|
const editInputRef = useRef(null);
|
|
const pageSize = 10;
|
|
|
|
// Справочник community для подсказок
|
|
const [communities, setCommunities] = useState([]);
|
|
useEffect(() => {
|
|
(async () => {
|
|
try {
|
|
const res = await api.get(`/communities`);
|
|
setCommunities(Array.isArray(res.data) ? res.data : []);
|
|
} catch (e) {
|
|
// тихо игнорируем
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const renderCommunityBadge = (value) => {
|
|
const v = String(value ?? '').trim();
|
|
if (!v) return <span className="badge bg-blue-lt text-blue">—</span>;
|
|
const meta = communities.find(c => c.value === v);
|
|
const color = (meta?.color || 'blue').toLowerCase().replace(/[^a-z-]/g, '');
|
|
const cls = `badge bg-${color}-lt text-${color}`;
|
|
const title = meta ? `${meta.name ? meta.name + ' — ' : ''}${meta.description || ''}${meta.tags && meta.tags.length ? ' (' + meta.tags.join(', ') + ')' : ''}` : '';
|
|
return (
|
|
<span className={cls} title={title}>{v}</span>
|
|
);
|
|
};
|
|
|
|
const isValidAsn = (value) => /^[0-9]+$/.test(String(value).trim());
|
|
// Допускаем как числовые, так и строковые (AS:NNN) community
|
|
const isValidCommunity = (value) => /^(\d+|\d+:\d+)$/.test(String(value).trim());
|
|
|
|
useEffect(() => {
|
|
// Acquire soft lock
|
|
const owner = localStorage.getItem('uiOwner') || `ui-${Math.random().toString(36).slice(2,8)}`;
|
|
localStorage.setItem('uiOwner', owner);
|
|
const resource = 'asns';
|
|
const acquire = async () => {
|
|
try { await api.post(`/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {}
|
|
};
|
|
acquire();
|
|
const interval = setInterval(acquire, 60_000);
|
|
fetchItems();
|
|
return () => {
|
|
clearInterval(interval);
|
|
api.delete(`/locks/${resource}`).catch(() => {});
|
|
};
|
|
}, []);
|
|
|
|
const fetchItems = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const response = await api.get(`/asns`, { params: { offset: 0, limit: 0 } });
|
|
const payload = Array.isArray(response.data?.items) ? response.data.items : (Array.isArray(response.data) ? response.data : []);
|
|
const mapped = payload.map(item => ({ asn: item.domain, community: item.type }));
|
|
setItems(mapped);
|
|
setOriginalItems(mapped);
|
|
setEtag(response.headers?.etag || '');
|
|
setLastModified(response.headers?.['last-modified'] || '');
|
|
const lengthHeader = response.headers?.['content-length-source'];
|
|
setContentLength(typeof lengthHeader !== 'undefined' ? Number(lengthHeader) : null);
|
|
setError('');
|
|
} catch (error) {
|
|
console.error('Error fetching ASNs:', error);
|
|
setError('Не удалось загрузить список ASN. Проверьте, запущен ли бэкенд.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleAddItem = () => {
|
|
const asnOk = isValidAsn(newItem.asn);
|
|
const communityOk = isValidCommunity(newItem.community);
|
|
setNewInvalid({ asn: !asnOk, community: !communityOk });
|
|
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 handleEdit = (item) => {
|
|
setEditingAsn(item.asn);
|
|
setEditingValue(item.community);
|
|
};
|
|
|
|
const handleSaveEdit = (asn) => {
|
|
if (!isValidCommunity(editingValue)) {
|
|
setError('Community должен быть числом.');
|
|
return;
|
|
}
|
|
const updatedItems = items.map(i =>
|
|
i.asn === asn ? { ...i, community: String(editingValue).trim() } : i
|
|
);
|
|
setItems(updatedItems);
|
|
setEditingAsn(null);
|
|
};
|
|
|
|
const handleCancelEdit = () => {
|
|
setEditingAsn(null);
|
|
};
|
|
|
|
const handleDeleteItem = (asnToDelete) => {
|
|
setItems(items.filter(i => i.asn !== asnToDelete));
|
|
};
|
|
|
|
const confirmDelete = (item) => {
|
|
setItemToDelete(item);
|
|
setShowDeleteModal(true);
|
|
};
|
|
|
|
const executeDelete = () => {
|
|
if (itemToDelete) {
|
|
handleDeleteItem(itemToDelete.asn);
|
|
setShowDeleteModal(false);
|
|
setItemToDelete(null);
|
|
}
|
|
};
|
|
|
|
const deduplicate = (arr) => {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const i of arr) {
|
|
const key = String(i.asn).trim();
|
|
if (!key) continue;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
out.push({ asn: key, community: String(i.community || '').trim() });
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const computeDiff = (before, after) => {
|
|
const mapBefore = new Map(before.map(i => [i.asn, i]));
|
|
const mapAfter = new Map(after.map(i => [i.asn, 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 handlePreviewDiff = () => {
|
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
|
const unique = deduplicate(valid);
|
|
setDiff(computeDiff(originalItems, unique));
|
|
setShowDiff(true);
|
|
};
|
|
|
|
const handleSaveChanges = async () => {
|
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
|
const unique = deduplicate(valid);
|
|
setDiff(computeDiff(originalItems, unique));
|
|
setConfirmSaveOpen(true);
|
|
};
|
|
|
|
const performSave = async () => {
|
|
setConfirmSaveOpen(false);
|
|
setLoading(true);
|
|
try {
|
|
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
|
.map(i => ({ asn: String(i.asn).trim(), community: String(i.community).trim() }));
|
|
const unique = deduplicate(valid);
|
|
const payload = { domains: unique.map(i => ({ domain: i.asn, type: i.community })), etag };
|
|
const response = await api.post(`/asns`, payload, { validateStatus: () => true });
|
|
if (response.status === 412) {
|
|
setError('Данные изменились в S3 (ETag mismatch). Обновите список и попробуйте снова.');
|
|
return;
|
|
}
|
|
if (response.status >= 400) throw new Error(`Save failed with status ${response.status}`);
|
|
setEtag(response.headers?.etag || etag);
|
|
setOriginalItems(unique);
|
|
setItems(unique);
|
|
setSuccess('Изменения успешно сохранены!');
|
|
setTimeout(() => setSuccess(''), 3000);
|
|
} catch (error) {
|
|
console.error('Error saving changes:', error);
|
|
setError('Не удалось сохранить изменения.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleImport = () => {
|
|
const text = window.prompt('Вставьте строки: ASN ПРОБЕЛ COMMUNITY (по одной записи на строку)');
|
|
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 = () => {
|
|
const header = ['asn', 'community'];
|
|
const csv = [header, ...items.map(i => [i.asn, 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 = `asns_${new Date().toISOString().split('T')[0]}.csv`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
// 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 [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 clearInvalid = () => {
|
|
setItems(prev => prev.filter(i => i.asn || i.community)
|
|
.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community))
|
|
);
|
|
};
|
|
|
|
// Сортировка
|
|
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;
|
|
});
|
|
|
|
// Фильтрация по community
|
|
const filteredByCommunity = filterCommunity
|
|
? sortedItems.filter(i => i.community === filterCommunity)
|
|
: sortedItems;
|
|
|
|
// Поиск
|
|
const filteredItems = filteredByCommunity.filter(i =>
|
|
i.asn.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
// Пагинация
|
|
const totalPages = Math.ceil(filteredItems.length / pageSize);
|
|
const paginatedItems = filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
|
|
|
// Для фильтра - список всех уникальных community
|
|
const allCommunities = Array.from(new Set(items.map(i => i.community)));
|
|
|
|
// Сортировка по клику
|
|
const handleSort = (field) => {
|
|
if (sortField === field) {
|
|
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortField(field);
|
|
setSortOrder('asc');
|
|
}
|
|
};
|
|
|
|
// Улучшенный инлайн-редакт
|
|
useEffect(() => {
|
|
if (editingAsn && editInputRef.current) {
|
|
editInputRef.current.focus();
|
|
}
|
|
}, [editingAsn]);
|
|
|
|
const handleEditKeyDown = (e, asn) => {
|
|
if (e.key === 'Enter') handleSaveEdit(asn);
|
|
if (e.key === 'Escape') handleCancelEdit();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{error && (
|
|
<div className="alert alert-danger alert-dismissible" role="alert">
|
|
<div className="d-flex">
|
|
<div>
|
|
<IconX className="icon alert-icon" />
|
|
</div>
|
|
<div>
|
|
{error}
|
|
</div>
|
|
</div>
|
|
<button type="button" className="btn-close" onClick={() => setError('')}></button>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
<div className="page-header d-print-none mb-4">
|
|
<div className="row align-items-center">
|
|
<div className="col">
|
|
<h2 className="page-title">ASNs</h2>
|
|
<div className="page-pretitle">Главная / Данные / ASNs</div>
|
|
</div>
|
|
<div className="col-auto ms-auto d-print-none">
|
|
<div className="btn-list">
|
|
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="row">
|
|
<div className="col-lg-3">
|
|
<LockBanner resource="asns" className="mb-3" />
|
|
{/* Add New ASN Card */}
|
|
<div className="card card-md">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
<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">Номер AS</label>
|
|
<input
|
|
type="text"
|
|
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>
|
|
<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 импорт */}
|
|
<div
|
|
className="card card-md border-dashed"
|
|
onDragOver={(e) => { e.preventDefault(); }}
|
|
onDrop={onDropImport}
|
|
>
|
|
<div className="card-body text-center text-muted">
|
|
Перетащите файл TXT/CSV сюда для импорта (формат: ASN community)
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions Card */}
|
|
<div className="card card-md">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
<IconDatabase className="icon me-2" />
|
|
Действия
|
|
</h3>
|
|
</div>
|
|
<div className="card-body">
|
|
<div className="d-grid gap-2">
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSaveChanges}
|
|
disabled={loading}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
Сохранение...
|
|
</>
|
|
) : (
|
|
<>
|
|
<IconDeviceFloppy className="icon me-2" />
|
|
Сохранить в S3
|
|
</>
|
|
)}
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
onClick={fetchItems}
|
|
disabled={loading}
|
|
>
|
|
<IconRefresh className="icon me-2" />
|
|
Обновить
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={handleImport}
|
|
>
|
|
<IconUpload className="icon me-2" />
|
|
Импорт из буфера
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-primary"
|
|
onClick={handleExport}
|
|
disabled={items.length === 0}
|
|
>
|
|
<IconDownload className="icon me-2" />
|
|
Экспорт CSV
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
onClick={handlePreviewDiff}
|
|
disabled={loading}
|
|
>
|
|
Предпросмотр изменений
|
|
</button>
|
|
<button
|
|
className="btn btn-outline-secondary"
|
|
onClick={clearInvalid}
|
|
disabled={items.length === 0}
|
|
>
|
|
Очистить пустые/невалидные
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-lg-9">
|
|
<div className="card">
|
|
<div className="card-header d-flex justify-content-between align-items-center">
|
|
<h3 className="card-title mb-0">Список ASN <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
|
|
<div className="d-flex gap-2 w-50">
|
|
{/* Фильтр по community */}
|
|
<select className="form-select w-auto" value={filterCommunity} onChange={e => { setFilterCommunity(e.target.value); setCurrentPage(1); }}>
|
|
<option value="">Все community</option>
|
|
{allCommunities.map(community => (
|
|
<option key={community} value={community}>{community}</option>
|
|
))}
|
|
</select>
|
|
{/* Поиск */}
|
|
<div className="input-icon flex-grow-1">
|
|
<span className="input-icon-addon">
|
|
<IconSearch size={18} />
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className="form-control"
|
|
placeholder="Поиск ASN..."
|
|
value={searchTerm}
|
|
onChange={e => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div></div>
|
|
</div>
|
|
{/* Мета-информация будет показана внизу карточки */}
|
|
{loading ? (
|
|
<TableSkeleton rows={10} cols={3} />
|
|
) : (
|
|
<div className="table-responsive">
|
|
<table className="table card-table table-vcenter table-nowrap mb-0">
|
|
<thead>
|
|
<tr>
|
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('asn')}>
|
|
Номер AS
|
|
{sortField === 'asn' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th style={{cursor:'pointer'}} onClick={() => handleSort('community')}>
|
|
Community
|
|
{sortField === 'community' && (
|
|
<span className="ms-1">
|
|
{sortOrder === 'asc' ? '▲' : '▼'}
|
|
</span>
|
|
)}
|
|
</th>
|
|
<th className="text-end"> </th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{paginatedItems.map((item) => (
|
|
<tr key={item.asn} className={editingAsn === item.asn ? 'table-info' : ''}>
|
|
<td>{item.asn}</td>
|
|
<td>{renderCommunityBadge(item.community)}</td>
|
|
<td className="text-end">
|
|
{editingAsn === item.asn ? (
|
|
<>
|
|
<CommunityAutocompleteInput
|
|
value={editingValue}
|
|
onChange={setEditingValue}
|
|
communities={communities}
|
|
className={`form-control d-inline-block w-auto me-2${isValidCommunity(editingValue) ? '' : ' is-invalid'}`}
|
|
/>
|
|
<button className="btn btn-success btn-icon btn-sm me-1" onClick={() => handleSaveEdit(item.asn)} disabled={!isValidCommunity(editingValue)}><IconCheck size={16} /></button>
|
|
<button className="btn btn-secondary btn-icon btn-sm" onClick={handleCancelEdit}><IconX size={16} /></button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button className="btn btn-outline-primary btn-icon btn-sm me-1" onClick={() => handleEdit(item)}><IconEdit size={16} /></button>
|
|
<button className="btn btn-outline-danger btn-icon btn-sm" onClick={() => confirmDelete(item)}><IconTrash size={16} /></button>
|
|
</>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
{/* Пагинация */}
|
|
{totalPages > 1 && (
|
|
<div className="card-footer d-flex align-items-center justify-content-between">
|
|
<div className="text-muted">
|
|
Показано {((currentPage - 1) * pageSize) + 1} - {Math.min(currentPage * pageSize, filteredItems.length)} из {filteredItems.length}
|
|
</div>
|
|
<ul className="pagination m-0">
|
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>Первая</button>
|
|
</li>
|
|
<li className={`page-item${currentPage === 1 ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1}>Назад</button>
|
|
</li>
|
|
{(() => {
|
|
const pages = [];
|
|
let start = Math.max(1, currentPage - 2);
|
|
let end = Math.min(totalPages, currentPage + 2);
|
|
if (currentPage <= 3) end = Math.min(totalPages, 5);
|
|
if (currentPage >= totalPages - 2) start = Math.max(1, totalPages - 4);
|
|
if (start > 1) pages.push('start-ellipsis');
|
|
for (let p = start; p <= end; p++) pages.push(p);
|
|
if (end < totalPages) pages.push('end-ellipsis');
|
|
return pages.map((p) => (
|
|
p === 'start-ellipsis' || p === 'end-ellipsis' ? (
|
|
<li key={p} className="page-item disabled"><span className="page-link">…</span></li>
|
|
) : (
|
|
<li key={p} className={`page-item${currentPage === p ? ' active' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(p)}>{p}</button>
|
|
</li>
|
|
)
|
|
));
|
|
})()}
|
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages}>Вперед</button>
|
|
</li>
|
|
<li className={`page-item${currentPage === totalPages ? ' disabled' : ''}`}>
|
|
<button className="page-link" onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages}>Последняя</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
)}
|
|
<S3MetaBar keys={["asns"]} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete Confirmation Modal */}
|
|
{showDeleteModal && (
|
|
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
|
<div className="modal-dialog modal-sm modal-dialog-centered" role="document">
|
|
<div className="modal-content">
|
|
<button
|
|
type="button"
|
|
className="btn-close"
|
|
onClick={() => setShowDeleteModal(false)}
|
|
></button>
|
|
<div className="modal-status bg-danger"></div>
|
|
<div className="modal-body text-center py-4">
|
|
<IconTrash className="icon mb-2 text-danger icon-lg" />
|
|
<h3>Удалить ASN?</h3>
|
|
<div className="text-muted">
|
|
Вы уверены, что хотите удалить "{itemToDelete?.asn}"? Это действие необратимо.
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<div className="w-100">
|
|
<div className="row">
|
|
<div className="col">
|
|
<button
|
|
className="btn w-100"
|
|
onClick={() => setShowDeleteModal(false)}
|
|
>
|
|
Отмена
|
|
</button>
|
|
</div>
|
|
<div className="col">
|
|
<button
|
|
className="btn btn-danger w-100"
|
|
onClick={executeDelete}
|
|
>
|
|
Удалить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<ConfirmDiffModal
|
|
show={confirmSaveOpen}
|
|
diff={diff}
|
|
onConfirm={performSave}
|
|
onClose={() => setConfirmSaveOpen(false)}
|
|
/>
|
|
<HistoryModal
|
|
resource="asns"
|
|
show={historyOpen}
|
|
onClose={() => setHistoryOpen(false)}
|
|
onRolledBack={() => fetchItems()}
|
|
/>
|
|
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default ASNsNewManager; |