feat: snapshot management UI — list and delete database snapshots
- ListBackups API now returns snapshots alongside backups - DeleteBackup API accepts snapshot- prefixed names - New "Database Snapshots" panel in admin UI with delete buttons - i18n: English + Hebrew translations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{! === Snapshots === }}
|
||||
<div class="panel panel-info">
|
||||
<div class="panel-heading">
|
||||
<h4 class="panel-title">
|
||||
<i class="fas fa-camera"></i>
|
||||
{{translate 'Database Snapshots' scope='DataMigration'}}
|
||||
</h4>
|
||||
</div>
|
||||
<div class="panel-body" style="padding: 0;">
|
||||
<div class="snapshots-list list-group" style="margin-bottom: 0;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{! === Import === }}
|
||||
<div class="import-section" style="display: none;">
|
||||
<div class="panel panel-warning">
|
||||
|
||||
@@ -72,6 +72,11 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
|
||||
const name = $(e.currentTarget).data('name');
|
||||
this.$el.find('.backup-details[data-name="' + name + '"]').toggle();
|
||||
},
|
||||
'click [data-action="deleteSnapshot"]': function (e) {
|
||||
e.stopPropagation();
|
||||
const name = $(e.currentTarget).data('name');
|
||||
this.actionDeleteSnapshot(name);
|
||||
},
|
||||
'click [data-action="dismissExportLog"]': function () {
|
||||
this.exportLog = null;
|
||||
this.$el.find('.export-log-container').hide();
|
||||
@@ -144,8 +149,10 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
|
||||
Espo.Ajax.getRequest('DataMigration/action/listBackups')
|
||||
.then(response => {
|
||||
this.backups = response.backups || [];
|
||||
this.snapshots = response.snapshots || [];
|
||||
this.isLoadingBackups = false;
|
||||
this.renderBackups();
|
||||
this.renderSnapshots();
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoadingBackups = false;
|
||||
@@ -406,6 +413,60 @@ define('modules/data-migration/views/admin/data-migration', ['view'], function (
|
||||
);
|
||||
},
|
||||
|
||||
renderSnapshots: function () {
|
||||
const $container = this.$el.find('.snapshots-list');
|
||||
|
||||
if (!this.snapshots || this.snapshots.length === 0) {
|
||||
$container.html(
|
||||
'<div class="text-muted" style="padding: 15px; text-align: center;">' +
|
||||
this.translate('No snapshots', 'labels', 'DataMigration') +
|
||||
'</div>'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
this.snapshots.forEach(snap => {
|
||||
const date = snap.createdAt ? new Date(snap.createdAt).toLocaleString() : 'Unknown';
|
||||
|
||||
html += '<div class="list-group-item">';
|
||||
html += '<div class="pull-right">';
|
||||
html += '<button class="btn btn-danger btn-xs" data-action="deleteSnapshot" data-name="' + snap.name + '" title="' + this.translate('Delete') + '">';
|
||||
html += '<i class="fas fa-trash"></i></button>';
|
||||
html += '</div>';
|
||||
html += '<h5 class="list-group-item-heading" style="margin: 0 0 4px;">';
|
||||
html += '<i class="fas fa-camera text-info"></i> ' + snap.name;
|
||||
html += '</h5>';
|
||||
html += '<p class="list-group-item-text text-muted" style="margin: 0; font-size: 0.9em;">';
|
||||
html += '<i class="fas fa-clock"></i> ' + date;
|
||||
html += ' | <i class="fas fa-database"></i> ' + snap.database;
|
||||
html += ' | <i class="fas fa-hdd"></i> ' + snap.sizeFormatted;
|
||||
html += '</p>';
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
$container.html(html);
|
||||
},
|
||||
|
||||
actionDeleteSnapshot: function (name) {
|
||||
this.confirm(
|
||||
this.translate('deleteSnapshotConfirmation', 'messages', 'DataMigration'),
|
||||
() => {
|
||||
Espo.Ajax.postRequest('DataMigration/action/deleteBackup', {
|
||||
backupName: name,
|
||||
})
|
||||
.then(() => {
|
||||
Espo.Ui.success(this.translate('Deleted'));
|
||||
this.loadBackups();
|
||||
})
|
||||
.catch(() => {
|
||||
Espo.Ui.error(this.translate('Failed to delete snapshot', 'labels', 'DataMigration'));
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
renderLog: function (selector, lines, isSuccess) {
|
||||
const $container = this.$el.find(selector);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class DeleteBackup implements Action
|
||||
// Sanitize to prevent directory traversal
|
||||
$safeName = basename($backupName);
|
||||
|
||||
if (!str_starts_with($safeName, 'datamigration-')) {
|
||||
if (!str_starts_with($safeName, 'datamigration-') && !str_starts_with($safeName, 'snapshot-')) {
|
||||
throw new Forbidden('Invalid backup name.');
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,43 @@ class ListBackups implements Action
|
||||
];
|
||||
}
|
||||
|
||||
return ResponseComposer::json(['backups' => $backups]);
|
||||
// Scan for snapshots
|
||||
$snapshots = [];
|
||||
|
||||
foreach ($dirs as $dir) {
|
||||
if ($dir === '.' || $dir === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!str_starts_with($dir, 'snapshot-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fullPath = $backupsDir . '/' . $dir;
|
||||
|
||||
if (!is_dir($fullPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifest = $this->manifestBuilder->read($fullPath);
|
||||
$dumpFile = $fullPath . '/database.sql';
|
||||
$dumpSize = file_exists($dumpFile) ? filesize($dumpFile) : 0;
|
||||
|
||||
$snapshots[] = [
|
||||
'name' => $dir,
|
||||
'createdAt' => $manifest['createdAt'] ?? null,
|
||||
'sourceHost' => $manifest['sourceHost'] ?? 'unknown',
|
||||
'espoVersion' => $manifest['espoVersion'] ?? 'unknown',
|
||||
'database' => $manifest['database'] ?? 'unknown',
|
||||
'dumpSizeBytes' => $dumpSize,
|
||||
'sizeFormatted' => $this->formatBytes($dumpSize),
|
||||
];
|
||||
}
|
||||
|
||||
return ResponseComposer::json([
|
||||
'backups' => $backups,
|
||||
'snapshots' => $snapshots,
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"Import failed": "Import failed",
|
||||
"Select a backup first": "Please select a backup first",
|
||||
"Failed to delete backup": "Failed to delete backup",
|
||||
"Failed to delete snapshot": "Failed to delete snapshot",
|
||||
"Database Snapshots": "Database Snapshots",
|
||||
"No snapshots": "No snapshots found",
|
||||
"Selected backup": "Selected backup",
|
||||
"Import Options": "Import Options",
|
||||
"Preserve original IDs": "Preserve original IDs",
|
||||
@@ -44,6 +47,7 @@
|
||||
"importConfirmation": "Are you sure you want to import this backup? This will add data to the database. This action cannot be easily undone.",
|
||||
"dryRunConfirmation": "Run a dry import? This will simulate the import without making any changes to the database.",
|
||||
"deleteBackupConfirmation": "Are you sure you want to delete this backup? This action cannot be undone.",
|
||||
"deleteSnapshotConfirmation": "Are you sure you want to delete this database snapshot? This action cannot be undone.",
|
||||
"noFilesHint": "faster export, without attachment files",
|
||||
"preserveIdsHint": "keep original record IDs (recommended for same-instance restore)",
|
||||
"importNoFilesHint": "import only database records, skip attachment files",
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
"Import failed": "הייבוא נכשל",
|
||||
"Select a backup first": "נא לבחור גיבוי קודם",
|
||||
"Failed to delete backup": "מחיקת הגיבוי נכשלה",
|
||||
"Failed to delete snapshot": "מחיקת צילום המצב נכשלה",
|
||||
"Database Snapshots": "צילומי מצב של מסד הנתונים",
|
||||
"No snapshots": "לא נמצאו צילומי מצב",
|
||||
"Selected backup": "גיבוי נבחר",
|
||||
"Import Options": "אפשרויות ייבוא",
|
||||
"Preserve original IDs": "שמור מזהים מקוריים",
|
||||
@@ -44,6 +47,7 @@
|
||||
"importConfirmation": "האם אתה בטוח שברצונך לייבא גיבוי זה? פעולה זו תוסיף נתונים למסד הנתונים ולא ניתן לבטלה בקלות.",
|
||||
"dryRunConfirmation": "להריץ ייבוא ניסיון? פעולה זו תדמה את הייבוא מבלי לבצע שינויים במסד הנתונים.",
|
||||
"deleteBackupConfirmation": "האם אתה בטוח שברצונך למחוק גיבוי זה? לא ניתן לבטל פעולה זו.",
|
||||
"deleteSnapshotConfirmation": "האם אתה בטוח שברצונך למחוק צילום מצב זה? לא ניתן לבטל פעולה זו.",
|
||||
"noFilesHint": "ייצוא מהיר יותר, ללא קבצים מצורפים",
|
||||
"preserveIdsHint": "שמור מזהי רשומות מקוריים (מומלץ לשחזור באותה מערכת)",
|
||||
"importNoFilesHint": "ייבוא רשומות בלבד, ללא קבצים מצורפים",
|
||||
|
||||
Reference in New Issue
Block a user