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:
+161
-48
@@ -55,6 +55,36 @@ function buildNestedGatewayBlocks(gatewayGroups, baseIndentSpaces = 4) {
|
||||
return buildAt(0, baseIndentSpaces);
|
||||
}
|
||||
|
||||
// Helper: read text file from S3 and return { body, etag, lastModified, contentLength }
|
||||
async function readS3TextObject(key) {
|
||||
const params = { Bucket: BUCKET_NAME, Key: key };
|
||||
const data = await s3.getObject(params).promise();
|
||||
return {
|
||||
body: data.Body.toString('utf-8'),
|
||||
etag: data.ETag ? String(data.ETag).replace(/\"/g, '"') : undefined,
|
||||
lastModified: data.LastModified ? data.LastModified.toISOString() : undefined,
|
||||
contentLength: typeof data.ContentLength === 'number' ? data.ContentLength : undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Helper: head object and return current ETag
|
||||
async function headS3ObjectEtag(key) {
|
||||
const head = await s3.headObject({ Bucket: BUCKET_NAME, Key: key }).promise();
|
||||
return head.ETag ? String(head.ETag).replace(/\"/g, '"') : undefined;
|
||||
}
|
||||
|
||||
// Simple in-memory soft locks with TTL
|
||||
const locks = new Map(); // key -> { owner, expiresAt }
|
||||
function cleanupExpiredLocks() {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of locks.entries()) {
|
||||
if (!v || typeof v.expiresAt !== 'number' || v.expiresAt <= now) {
|
||||
locks.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
setInterval(cleanupExpiredLocks, 30_000);
|
||||
|
||||
// Get domains from S3
|
||||
app.get('/api/domains', async (req, res) => {
|
||||
const params = {
|
||||
@@ -73,6 +103,15 @@ app.get('/api/domains', async (req, res) => {
|
||||
const type = parts[1] || '';
|
||||
return { domain, type };
|
||||
});
|
||||
if (data.ETag) {
|
||||
res.set('ETag', String(data.ETag));
|
||||
}
|
||||
if (data.LastModified) {
|
||||
res.set('Last-Modified', new Date(data.LastModified).toUTCString());
|
||||
}
|
||||
if (typeof data.ContentLength === 'number') {
|
||||
res.set('Content-Length-Source', String(data.ContentLength));
|
||||
}
|
||||
res.json(domains);
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey') {
|
||||
@@ -84,10 +123,22 @@ app.get('/api/domains', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Update domains in S3
|
||||
// Update domains in S3 with optimistic concurrency via ETag check
|
||||
app.post('/api/domains', async (req, res) => {
|
||||
const { domains } = req.body;
|
||||
const fileContent = domains.map(d => `${d.domain} ${d.type}`).join('\n');
|
||||
const { domains, etag } = req.body;
|
||||
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.type || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
// Concurrency guard: if client sent etag, ensure current ETag matches
|
||||
try {
|
||||
if (etag) {
|
||||
const current = await headS3ObjectEtag(FILE_KEY).catch(() => undefined);
|
||||
if (current && current.replace(/\"/g, '"') !== String(etag)) {
|
||||
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore if head fails due to NoSuchKey; proceed to create
|
||||
}
|
||||
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
@@ -97,7 +148,8 @@ app.post('/api/domains', async (req, res) => {
|
||||
};
|
||||
|
||||
try {
|
||||
await s3.putObject(params).promise();
|
||||
const put = await s3.putObject(params).promise();
|
||||
res.set('ETag', put.ETag || '');
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -123,6 +175,9 @@ app.get('/api/asns', async (req, res) => {
|
||||
const type = parts[1] || '';
|
||||
return { domain, type };
|
||||
});
|
||||
if (data.ETag) res.set('ETag', String(data.ETag));
|
||||
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
|
||||
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
|
||||
res.json(asns);
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey') {
|
||||
@@ -136,8 +191,17 @@ app.get('/api/asns', async (req, res) => {
|
||||
|
||||
// Update ASNs in S3
|
||||
app.post('/api/asns', async (req, res) => {
|
||||
const { domains: asns } = req.body; // Keep name 'domains' for consistency
|
||||
const fileContent = asns.map(a => `${a.domain} ${a.type}`).join('\n');
|
||||
const { domains: asns, etag } = req.body; // Keep name 'domains' for consistency
|
||||
const fileContent = (asns || []).map(a => `${String(a.domain || '').trim()} ${String(a.type || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
if (etag) {
|
||||
const current = await headS3ObjectEtag('bgp_data/asns.txt').catch(() => undefined);
|
||||
if (current && current.replace(/\"/g, '"') !== String(etag)) {
|
||||
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
@@ -147,7 +211,8 @@ app.post('/api/asns', async (req, res) => {
|
||||
};
|
||||
|
||||
try {
|
||||
await s3.putObject(params).promise();
|
||||
const put = await s3.putObject(params).promise();
|
||||
if (put.ETag) res.set('ETag', String(put.ETag));
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -173,6 +238,9 @@ app.get('/api/domains-new', async (req, res) => {
|
||||
const community = parts[1] || '';
|
||||
return { domain, community };
|
||||
});
|
||||
if (data.ETag) res.set('ETag', String(data.ETag));
|
||||
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
|
||||
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
|
||||
res.json(domains);
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey') {
|
||||
@@ -186,8 +254,17 @@ app.get('/api/domains-new', async (req, res) => {
|
||||
|
||||
// Update domains-new in S3
|
||||
app.post('/api/domains-new', async (req, res) => {
|
||||
const { domains } = req.body;
|
||||
const fileContent = domains.map(d => `${d.domain} ${d.community}`).join('\n');
|
||||
const { domains, etag } = req.body;
|
||||
const fileContent = (domains || []).map(d => `${String(d.domain || '').trim()} ${String(d.community || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
if (etag) {
|
||||
const current = await headS3ObjectEtag('bgp_data/domains_community.txt').catch(() => undefined);
|
||||
if (current && current.replace(/\"/g, '"') !== String(etag)) {
|
||||
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
@@ -197,7 +274,8 @@ app.post('/api/domains-new', async (req, res) => {
|
||||
};
|
||||
|
||||
try {
|
||||
await s3.putObject(params).promise();
|
||||
const put = await s3.putObject(params).promise();
|
||||
if (put.ETag) res.set('ETag', String(put.ETag));
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -223,6 +301,9 @@ app.get('/api/ip-ranges', async (req, res) => {
|
||||
const community = parts[1] || '';
|
||||
return { ipRange, community };
|
||||
});
|
||||
if (data.ETag) res.set('ETag', String(data.ETag));
|
||||
if (data.LastModified) res.set('Last-Modified', new Date(data.LastModified).toUTCString());
|
||||
if (typeof data.ContentLength === 'number') res.set('Content-Length-Source', String(data.ContentLength));
|
||||
res.json(ipRanges);
|
||||
} catch (error) {
|
||||
if (error.code === 'NoSuchKey') {
|
||||
@@ -236,8 +317,17 @@ app.get('/api/ip-ranges', async (req, res) => {
|
||||
|
||||
// Update IP ranges in S3
|
||||
app.post('/api/ip-ranges', async (req, res) => {
|
||||
const { ipRanges } = req.body;
|
||||
const fileContent = ipRanges.map(ip => `${ip.ipRange} ${ip.community}`).join('\n');
|
||||
const { ipRanges, etag } = req.body;
|
||||
const fileContent = (ipRanges || []).map(ip => `${String(ip.ipRange || '').trim()} ${String(ip.community || '').trim()}`.trim()).filter(Boolean).join('\n');
|
||||
|
||||
try {
|
||||
if (etag) {
|
||||
const current = await headS3ObjectEtag('bgp_data/ips.txt').catch(() => undefined);
|
||||
if (current && current.replace(/\"/g, '"') !== String(etag)) {
|
||||
return res.status(412).json({ message: 'Precondition Failed: ETag mismatch' });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const params = {
|
||||
Bucket: BUCKET_NAME,
|
||||
@@ -247,7 +337,8 @@ app.post('/api/ip-ranges', async (req, res) => {
|
||||
};
|
||||
|
||||
try {
|
||||
await s3.putObject(params).promise();
|
||||
const put = await s3.putObject(params).promise();
|
||||
if (put.ETag) res.set('ETag', String(put.ETag));
|
||||
res.send('File updated successfully');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -554,49 +645,71 @@ app.post('/api/filters', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Новый эндпоинт для получения дат последнего изменения файлов S3
|
||||
// Эндпоинт метаданных S3 по ключевым файлам (Last-Modified, ETag, Content-Length)
|
||||
app.get('/api/s3/last-modified', async (req, res) => {
|
||||
try {
|
||||
const results = await Promise.allSettled([
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/domains_community.txt' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'bgp_data/asns.txt' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'servers.json' }).promise(),
|
||||
s3.headObject({ Bucket: BUCKET_NAME, Key: 'filters.json' }).promise()
|
||||
]);
|
||||
|
||||
const response = {
|
||||
domainsLastModified: null,
|
||||
domainsNewLastModified: null,
|
||||
asnsLastModified: null,
|
||||
serversLastModified: null,
|
||||
filtersLastModified: null
|
||||
};
|
||||
|
||||
// Обрабатываем результаты
|
||||
if (results[0].status === 'fulfilled') {
|
||||
response.domainsLastModified = results[0].value.LastModified ? results[0].value.LastModified.toISOString() : null;
|
||||
}
|
||||
if (results[1].status === 'fulfilled') {
|
||||
response.domainsNewLastModified = results[1].value.LastModified ? results[1].value.LastModified.toISOString() : null;
|
||||
}
|
||||
if (results[2].status === 'fulfilled') {
|
||||
response.asnsLastModified = results[2].value.LastModified ? results[2].value.LastModified.toISOString() : null;
|
||||
}
|
||||
if (results[3].status === 'fulfilled') {
|
||||
response.serversLastModified = results[3].value.LastModified ? results[3].value.LastModified.toISOString() : null;
|
||||
}
|
||||
if (results[4].status === 'fulfilled') {
|
||||
response.filtersLastModified = results[4].value.LastModified ? results[4].value.LastModified.toISOString() : null;
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
const keys = [
|
||||
{ name: 'domainsNew', key: 'bgp_data/domains_community.txt' },
|
||||
{ name: 'asns', key: 'bgp_data/asns.txt' },
|
||||
{ name: 'servers', key: 'servers.json' },
|
||||
{ name: 'filters', key: 'filters.json' },
|
||||
{ name: 'ipRanges', key: 'bgp_data/ips.txt' }
|
||||
];
|
||||
const results = await Promise.allSettled(
|
||||
keys.map(k => s3.headObject({ Bucket: BUCKET_NAME, Key: k.key }).promise())
|
||||
);
|
||||
const out = {};
|
||||
results.forEach((r, idx) => {
|
||||
const name = keys[idx].name;
|
||||
if (r.status === 'fulfilled') {
|
||||
out[name] = {
|
||||
lastModified: r.value.LastModified ? r.value.LastModified.toISOString() : null,
|
||||
etag: r.value.ETag || null,
|
||||
contentLength: typeof r.value.ContentLength === 'number' ? r.value.ContentLength : null
|
||||
};
|
||||
} else {
|
||||
out[name] = null;
|
||||
}
|
||||
});
|
||||
res.json(out);
|
||||
} catch (error) {
|
||||
console.error('Error fetching last modified dates from S3:', error);
|
||||
res.status(500).send('Error fetching last modified dates from S3');
|
||||
}
|
||||
});
|
||||
|
||||
// Soft-lock endpoints
|
||||
// GET lock status
|
||||
app.get('/api/locks/:resource', (req, res) => {
|
||||
cleanupExpiredLocks();
|
||||
const { resource } = req.params;
|
||||
const info = locks.get(resource);
|
||||
if (!info) return res.json({ locked: false });
|
||||
res.json({ locked: true, owner: info.owner, expiresAt: info.expiresAt });
|
||||
});
|
||||
|
||||
// POST acquire/refresh lock
|
||||
app.post('/api/locks/:resource', (req, res) => {
|
||||
cleanupExpiredLocks();
|
||||
const { resource } = req.params;
|
||||
const { owner = 'anonymous', ttlSeconds = 120 } = req.body || {};
|
||||
const now = Date.now();
|
||||
const existing = locks.get(resource);
|
||||
if (existing && existing.expiresAt > now && existing.owner !== owner) {
|
||||
return res.status(423).json({ message: 'Resource is locked by another user', owner: existing.owner, expiresAt: existing.expiresAt });
|
||||
}
|
||||
const expiresAt = now + Math.max(30, Math.min(600, Number(ttlSeconds) || 120)) * 1000;
|
||||
locks.set(resource, { owner, expiresAt });
|
||||
res.json({ locked: true, owner, expiresAt });
|
||||
});
|
||||
|
||||
// DELETE release lock
|
||||
app.delete('/api/locks/:resource', (req, res) => {
|
||||
const { resource } = req.params;
|
||||
locks.delete(resource);
|
||||
res.json({ released: true });
|
||||
});
|
||||
|
||||
// Generate MikroTik configuration from filters
|
||||
app.get('/api/filters/generate-config', async (req, res) => {
|
||||
const params = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user