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() { /> +
{resource}| Версия | +Дата | +Размер | +ETag | +Действия | +
|---|---|---|---|---|
| Нет данных (возможно, версионирование бакета отключено) | ||||
{v.versionId} |
+ {v.lastModified ? new Date(v.lastModified).toLocaleString() : '—'} | +{typeof v.size === 'number' ? `${v.size} байт` : '—'} | +{v.etag || '—'} |
+ + {!v.isLatest && ( + + )} + | +