feat: Add history endpoints for resource versioning and implement rollback functionality in server, along with UI integration in ASNs, Domains, and IPRanges managers for enhanced data management
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m45s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 4m45s
This commit is contained in:
+52
-3
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
@@ -701,6 +706,12 @@ function ASNsNewManager() {
|
||||
onConfirm={performSave}
|
||||
onClose={() => setConfirmSaveOpen(false)}
|
||||
/>
|
||||
<HistoryModal
|
||||
resource="asns"
|
||||
show={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRolledBack={() => fetchItems()}
|
||||
/>
|
||||
{/* datalist больше не нужен, т.к. используем кастомный автокомплит */}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
@@ -710,6 +715,12 @@ function DomainsNewManager() {
|
||||
onConfirm={performSave}
|
||||
onClose={() => setConfirmSaveOpen(false)}
|
||||
/>
|
||||
<HistoryModal
|
||||
resource="domains-new"
|
||||
show={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRolledBack={() => fetchItems()}
|
||||
/>
|
||||
{/* Diff Modal */}
|
||||
{showDiff && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button className="btn btn-outline-secondary" onClick={() => setHistoryOpen(true)}>История</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Мета-информация будет показана внизу карточки */}
|
||||
{loading ? (
|
||||
@@ -722,6 +727,12 @@ function IPRangesManager() {
|
||||
onConfirm={performSave}
|
||||
onClose={() => setConfirmSaveOpen(false)}
|
||||
/>
|
||||
<HistoryModal
|
||||
resource="ip-ranges"
|
||||
show={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
onRolledBack={() => fetchItems()}
|
||||
/>
|
||||
{/* Diff Modal */}
|
||||
{showDiff && (
|
||||
<div className="modal modal-blur fade show" style={{display: 'block'}} tabIndex="-1">
|
||||
|
||||
@@ -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 (
|
||||
<div className="modal show d-block" tabIndex="-1" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<div className="modal-dialog modal-lg modal-dialog-centered" role="document">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title d-flex align-items-center">
|
||||
<IconHistory className="me-2" /> История версий
|
||||
</h5>
|
||||
<button type="button" className="btn-close" onClick={onClose}></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<div className="text-muted small">Ресурс: <code>{resource}</code></div>
|
||||
<button className="btn btn-outline-secondary btn-sm" onClick={fetchHistory} disabled={loading}>
|
||||
<IconRefresh className={loading ? 'spin' : ''} />
|
||||
<span className="ms-1">Обновить</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Версия</th>
|
||||
<th>Дата</th>
|
||||
<th>Размер</th>
|
||||
<th>ETag</th>
|
||||
<th className="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 ? (
|
||||
<tr><td colSpan="5" className="text-muted text-center py-4">Нет данных (возможно, версионирование бакета отключено)</td></tr>
|
||||
) : items.map((v) => (
|
||||
<tr key={v.versionId} className={v.isLatest ? 'table-info' : ''}>
|
||||
<td><code>{v.versionId}</code></td>
|
||||
<td>{v.lastModified ? new Date(v.lastModified).toLocaleString() : '—'}</td>
|
||||
<td>{typeof v.size === 'number' ? `${v.size} байт` : '—'}</td>
|
||||
<td><code>{v.etag || '—'}</code></td>
|
||||
<td className="text-end">
|
||||
{!v.isLatest && (
|
||||
<button className="btn btn-outline-primary btn-sm" onClick={() => rollback(v.versionId)} disabled={loading}>
|
||||
<IconDeviceFloppy className="me-1" /> Откатить к этой версии
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn" onClick={onClose}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user