+
)}
+
+ { (etag || lastModified) && (
+
+ ETag: {etag || '—'}
+ Last-Modified: {lastModified || '—'}
+ Размер (байт): {contentLength ?? '—'}
+
+ )}
@@ -555,6 +653,27 @@ function DomainsNewManager() {
)}
+ {/* Diff Modal */}
+ {showDiff && (
+
+
+
+
+
Изменения
+
+
+
Добавлено{diff.added.length}
+
Удалено{diff.removed.length}
+
Изменено{diff.changed.length}
+
+
+
+
+
+
+
+
+ )}
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
>
);
diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx
index 146461a..4d06d75 100644
--- a/frontend/src/IPRangesManager.jsx
+++ b/frontend/src/IPRangesManager.jsx
@@ -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() {
Экспорт CSV
+