diff --git a/backend/server.js b/backend/server.js index 42b5847..41d27a9 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,3 +1,39 @@ +// History endpoints (require bucket versioning to be enabled). If versioning disabled, best effort. +app.get('/api/history/:resource', async (req, res) => { + const { resource } = req.params; + const key = resourceToKey(resource); + if (!key) return sendError(res, 400, 'Unknown resource', 'E_RESOURCE'); + try { + if (!s3.listObjectVersions) { + return sendError(res, 501, 'S3 listObjectVersions not available', 'E_NOT_SUPPORTED'); + } + const out = await s3.listObjectVersions({ Bucket: BUCKET_NAME, Prefix: key, MaxKeys: 20 }).promise(); + const versions = (out.Versions || []) + .filter(v => v.Key === key) + .slice(0, 10) + .map(v => ({ versionId: v.VersionId, isLatest: v.IsLatest, lastModified: toIso(v.LastModified), size: v.Size, etag: v.ETag })); + return res.json({ items: versions }); + } catch (e) { + console.error('history error', e); + return sendError(res, 500, 'Error reading history', 'E_S3', { error: String(e?.message || e) }); + } +}); + +app.post('/api/history/:resource/rollback', async (req, res) => { + const { resource } = req.params; + const { versionId } = req.body || {}; + const key = resourceToKey(resource); + if (!key || !versionId) return sendError(res, 400, 'Bad request', 'E_BAD_REQUEST'); + try { + // Copy specific version over same key to rollback + await s3.copyObject({ Bucket: BUCKET_NAME, CopySource: `/${BUCKET_NAME}/${encodeURIComponent(key)}?versionId=${encodeURIComponent(versionId)}`, Key: key }).promise(); + const meta = await headMeta(key); + return sendOk(res, meta); + } catch (e) { + console.error('rollback error', e); + return sendError(res, 500, 'Error rollback', 'E_S3', { error: String(e?.message || e) }); + } +}); require('dotenv').config(); const express = require('express'); const AWS = require('aws-sdk'); @@ -51,6 +87,16 @@ function sendError(res, status, message, code, details) { return res.status(status).json({ message, code, details }); } +// Map UI resource -> S3 key (for history endpoints) +function resourceToKey(resource) { + switch (resource) { + case 'domains-new': return 'bgp_data/domains_community.txt'; + case 'ip-ranges': return 'bgp_data/ips.txt'; + case 'asns': return 'bgp_data/asns.txt'; + default: return null; + } +} + // Serve static files from the React app app.use(express.static(path.join(__dirname, 'public'))); @@ -410,7 +456,8 @@ app.post('/api/asns', async (req, res) => { let current = null; try { current = await headS3ObjectEtag('bgp_data/asns.txt'); } catch {} if (current && etag && current !== String(etag)) { - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current }); + const meta = await headMeta('bgp_data/asns.txt'); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} @@ -498,7 +545,8 @@ app.post('/api/domains-new', async (req, res) => { let current = null; try { current = await headS3ObjectEtag('bgp_data/domains_community.txt'); } catch {} if (current && etag && current !== String(etag)) { - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current }); + const meta = await headMeta('bgp_data/domains_community.txt'); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} @@ -586,7 +634,8 @@ app.post('/api/ip-ranges', async (req, res) => { let current = null; try { current = await headS3ObjectEtag('bgp_data/ips.txt'); } catch {} if (current && etag && current !== String(etag)) { - return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current }); + const meta = await headMeta('bgp_data/ips.txt'); + return sendError(res, 412, 'Precondition Failed: ETag mismatch', 'E_ETAG_MISMATCH', { currentEtag: current, meta }); } } catch {} diff --git a/frontend/src/ASNsNewManager.jsx b/frontend/src/ASNsNewManager.jsx index 24c227f..56ca653 100644 --- a/frontend/src/ASNsNewManager.jsx +++ b/frontend/src/ASNsNewManager.jsx @@ -21,6 +21,7 @@ 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'; @@ -199,6 +200,7 @@ function ASNsNewManager() { 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)) @@ -547,6 +549,9 @@ function ASNsNewManager() { /> +
+ +
{/* Мета-информация будет показана внизу карточки */} {loading ? ( @@ -701,6 +706,12 @@ function ASNsNewManager() { onConfirm={performSave} onClose={() => setConfirmSaveOpen(false)} /> + setHistoryOpen(false)} + onRolledBack={() => fetchItems()} + /> {/* datalist больше не нужен, т.к. используем кастомный автокомплит */} ); diff --git a/frontend/src/DomainsNewManager.jsx b/frontend/src/DomainsNewManager.jsx index 7cbd4f8..9923aa0 100644 --- a/frontend/src/DomainsNewManager.jsx +++ b/frontend/src/DomainsNewManager.jsx @@ -21,6 +21,7 @@ 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'; @@ -203,6 +204,7 @@ function DomainsNewManager() { 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 => isValidDomain(i.domain) && isValidCommunity(i.community)) @@ -555,6 +557,9 @@ function DomainsNewManager() { /> +
+ +
{/* Мета-информация будет показана внизу карточки */} {loading ? ( @@ -710,6 +715,12 @@ function DomainsNewManager() { onConfirm={performSave} onClose={() => setConfirmSaveOpen(false)} /> + setHistoryOpen(false)} + onRolledBack={() => fetchItems()} + /> {/* Diff Modal */} {showDiff && (
diff --git a/frontend/src/IPRangesManager.jsx b/frontend/src/IPRangesManager.jsx index 0efc041..daf4364 100644 --- a/frontend/src/IPRangesManager.jsx +++ b/frontend/src/IPRangesManager.jsx @@ -21,6 +21,7 @@ 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'; @@ -214,6 +215,7 @@ function IPRangesManager() { 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 => isValidCidr(i.ipRange) && isValidCommunity(i.community)) @@ -563,6 +565,9 @@ function IPRangesManager() { />
+
+ +
{/* Мета-информация будет показана внизу карточки */} {loading ? ( @@ -722,6 +727,12 @@ function IPRangesManager() { onConfirm={performSave} onClose={() => setConfirmSaveOpen(false)} /> + setHistoryOpen(false)} + onRolledBack={() => fetchItems()} + /> {/* Diff Modal */} {showDiff && (
diff --git a/frontend/src/components/HistoryModal.jsx b/frontend/src/components/HistoryModal.jsx new file mode 100644 index 0000000..fb4dd88 --- /dev/null +++ b/frontend/src/components/HistoryModal.jsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import api from '../lib/api.js'; +import { useNotify } from './NotifyProvider.jsx'; +import { IconHistory, IconRefresh, IconDeviceFloppy } from '@tabler/icons-react'; + +export default function HistoryModal({ resource, show, onClose, onRolledBack }) { + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const notify = useNotify(); + + const fetchHistory = async () => { + if (!resource) return; + setLoading(true); + try { + const res = await api.get(`/history/${resource}`); + setItems(Array.isArray(res.data?.items) ? res.data.items : []); + } catch (e) { + notify.error('Не удалось загрузить историю версий'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { if (show) fetchHistory(); }, [show, resource]); + + const rollback = async (versionId) => { + if (!versionId) return; + if (!confirm('Откатить к выбранной версии? Текущее содержимое будет перезаписано.')) return; + setLoading(true); + try { + const res = await api.post(`/history/${resource}/rollback`, { versionId }); + notify.success('Откат выполнен'); + onRolledBack && onRolledBack(res.data || {}); + onClose && onClose(); + } catch (e) { + notify.error('Не удалось выполнить откат'); + } finally { + setLoading(false); + } + }; + + if (!show) return null; + + return ( +
+
+
+
+
+ История версий +
+ +
+
+
+
Ресурс: {resource}
+ +
+
+ + + + + + + + + + + + {items.length === 0 ? ( + + ) : items.map((v) => ( + + + + + + + + ))} + +
ВерсияДатаРазмерETagДействия
Нет данных (возможно, версионирование бакета отключено)
{v.versionId}{v.lastModified ? new Date(v.lastModified).toLocaleString() : '—'}{typeof v.size === 'number' ? `${v.size} байт` : '—'}{v.etag || '—'} + {!v.isLatest && ( + + )} +
+
+
+
+ +
+
+
+
+ ); +} + +