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

This commit is contained in:
2025-08-10 23:10:25 +07:00
parent 817a8981f4
commit 4635c05f8c
5 changed files with 513 additions and 66 deletions
+102 -5
View File
@@ -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>
+3 -3
View File
@@ -98,9 +98,9 @@ function Dashboard() {
const onlineServers = servers.filter(server => server.status === 'Онлайн').length;
// Получаем дату последнего обновления
const lastModified = s3Res.status === 'fulfilled' && s3Res.value.data?.domainsLastModified
? new Date(s3Res.value.data.domainsLastModified).toLocaleString()
: new Date().toLocaleString();
// Новый формат: объект с ключами { domainsNew, asns, servers, filters, ipRanges }
const lmRaw = s3Res.status === 'fulfilled' ? s3Res.value.data?.domainsNew?.lastModified : null;
const lastModified = lmRaw ? new Date(lmRaw).toLocaleString() : new Date().toLocaleString();
setStats({
domainsCount: domainsRes.status === 'fulfilled' ? domainsRes.value.data.length : 0,
+125 -6
View File
@@ -19,6 +19,10 @@ const API_URL = '/api';
function DomainsNewManager() {
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({ domain: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ domain: false, community: false });
const [error, setError] = useState('');
@@ -71,7 +75,20 @@ function DomainsNewManager() {
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 = 'domains-new';
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 () => {
@@ -79,6 +96,11 @@ function DomainsNewManager() {
try {
const response = await axios.get(`${API_URL}/domains-new`);
setItems(response.data);
setOriginalItems(response.data);
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 domains-new:', error);
@@ -140,12 +162,65 @@ function DomainsNewManager() {
}
};
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 handlePreviewDiff = () => {
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);
setDiff(computeDiff(originalItems, unique));
setShowDiff(true);
};
const handleSaveChanges = async () => {
setLoading(true);
try {
// сохраняем только валидные строки
const valid = items.filter(i => isValidDomain(i.domain) && isValidCommunity(i.community));
await axios.post(`${API_URL}/domains-new`, { domains: valid });
// сохраняем только валидные строки + дедупликация
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);
const response = await axios.post(`${API_URL}/domains-new`, { domains: unique, etag }, { 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) {
@@ -162,9 +237,18 @@ function DomainsNewManager() {
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 || '' };
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());
});
setItems(prev => [...prev, ...parsed]);
};
const handleExport = () => {
@@ -333,7 +417,7 @@ function DomainsNewManager() {
Действия
</h3>
</div>
<div className="card-body">
<div className="card-body">
<div className="d-grid gap-2">
<button
className="btn btn-primary"
@@ -352,6 +436,13 @@ function DomainsNewManager() {
</>
)}
</button>
<button
className="btn btn-outline-secondary"
onClick={handlePreviewDiff}
disabled={loading}
>
Предпросмотр изменений
</button>
<button
className="btn btn-outline-secondary"
onClick={fetchItems}
@@ -414,6 +505,13 @@ function DomainsNewManager() {
</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>
@@ -555,6 +653,27 @@ function DomainsNewManager() {
</div>
</div>
)}
{/* 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-3">
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><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 больше не нужен, т.к. используем кастомный автокомплит */}
</>
);
+122 -4
View File
@@ -19,6 +19,10 @@ const API_URL = '/api';
function IPRangesManager() {
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({ ipRange: '', community: '' });
const [newInvalid, setNewInvalid] = useState({ ipRange: false, community: false });
const [error, setError] = useState('');
@@ -82,7 +86,20 @@ function IPRangesManager() {
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 = 'ip-ranges';
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 () => {
@@ -90,6 +107,11 @@ function IPRangesManager() {
try {
const response = await axios.get(`${API_URL}/ip-ranges`);
setItems(response.data);
setOriginalItems(response.data);
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 ip-ranges:', error);
@@ -151,11 +173,64 @@ function IPRangesManager() {
}
};
const deduplicate = (arr) => {
const seen = new Set();
const out = [];
for (const i of arr) {
const key = String(i.ipRange).trim();
if (!key) continue;
if (seen.has(key)) continue;
seen.add(key);
out.push({ ipRange: key, community: String(i.community || '').trim() });
}
return out;
};
const computeDiff = (before, after) => {
const mapBefore = new Map(before.map(i => [i.ipRange, i]));
const mapAfter = new Map(after.map(i => [i.ipRange, 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 => isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
setDiff(computeDiff(originalItems, unique));
setShowDiff(true);
};
const handleSaveChanges = async () => {
setLoading(true);
try {
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community));
await axios.post(`${API_URL}/ip-ranges`, { ipRanges: valid });
const valid = items.filter(i => isValidCidr(i.ipRange) && isValidCommunity(i.community))
.map(i => ({ ipRange: i.ipRange.trim(), community: String(i.community).trim() }));
const unique = deduplicate(valid);
const response = await axios.post(`${API_URL}/ip-ranges`, { ipRanges: unique, etag }, { 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) {
@@ -172,9 +247,17 @@ function IPRangesManager() {
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 || '', community: c || '' };
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());
});
setItems(prev => [...prev, ...parsed]);
};
const handleExport = () => {
@@ -384,6 +467,13 @@ function IPRangesManager() {
<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}
@@ -423,6 +513,13 @@ function IPRangesManager() {
</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>
@@ -569,6 +666,27 @@ function IPRangesManager() {
</div>
</div>
)}
{/* 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-3">
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Добавлено</strong><div className="text-muted">{diff.added.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><strong>Удалено</strong><div className="text-muted">{diff.removed.length}</div></div></div></div>
<div className="col-md-4"><div className="card"><div className="card-body"><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 больше не нужен, т.к. используем кастомный автокомплит */}
</>
);