feat: Add S3 metadata handling and soft-lock mechanism in backend, enhance ASNs, Domains, and IPRanges managers with ETag support and change preview functionality for improved data integrity and user experience
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m30s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 5m30s
This commit is contained in:
@@ -19,6 +19,10 @@ 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('');
|
||||
@@ -66,14 +70,33 @@ function ASNsNewManager() {
|
||||
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 axios.post(`${API_URL}/locks/${resource}`, { owner, ttlSeconds: 180 }); } catch {}
|
||||
};
|
||||
acquire();
|
||||
const interval = setInterval(acquire, 60_000);
|
||||
fetchItems();
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
axios.delete(`${API_URL}/locks/${resource}`).catch(() => {});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchItems = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/asns`);
|
||||
setItems(response.data.map(item => ({ asn: item.domain, community: item.type })));
|
||||
const mapped = response.data.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);
|
||||
@@ -135,12 +158,64 @@ function ASNsNewManager() {
|
||||
}
|
||||
};
|
||||
|
||||
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 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 () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// API ожидает domains: [{domain, type}]
|
||||
const valid = items.filter(i => isValidAsn(i.asn) && isValidCommunity(i.community));
|
||||
await axios.post(`${API_URL}/asns`, { domains: valid.map(i => ({ domain: i.asn, type: i.community })) });
|
||||
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 axios.post(`${API_URL}/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) {
|
||||
@@ -157,9 +232,17 @@ function ASNsNewManager() {
|
||||
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 || '', community: c || '' };
|
||||
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());
|
||||
});
|
||||
setItems(prev => [...prev, ...parsed]);
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
@@ -369,6 +452,13 @@ function ASNsNewManager() {
|
||||
<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}
|
||||
@@ -408,6 +498,13 @@ function ASNsNewManager() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{(etag || lastModified) && (
|
||||
<div className="card-status-bottom bg-transparent px-3 py-2 text-muted small">
|
||||
<span className="me-3">ETag: <code>{etag || '—'}</code></span>
|
||||
<span className="me-3">Last-Modified: {lastModified || '—'}</span>
|
||||
<span>Размер (байт): {contentLength ?? '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
|
||||
Reference in New Issue
Block a user