diff --git a/files/client/custom/modules/data-migration/src/views/admin/data-migration.js b/files/client/custom/modules/data-migration/src/views/admin/data-migration.js
index 8b70d3c..b5aec66 100644
--- a/files/client/custom/modules/data-migration/src/views/admin/data-migration.js
+++ b/files/client/custom/modules/data-migration/src/views/admin/data-migration.js
@@ -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(
+ '
' +
+ this.translate('No snapshots', 'labels', 'DataMigration') +
+ '
'
+ );
+ return;
+ }
+
+ let html = '';
+
+ this.snapshots.forEach(snap => {
+ const date = snap.createdAt ? new Date(snap.createdAt).toLocaleString() : 'Unknown';
+
+ html += '
';
+ html += '
';
+ html += '';
+ html += '
';
+ html += '
';
+ html += ' ' + snap.name;
+ html += '
';
+ html += '
';
+ html += ' ' + date;
+ html += ' | ' + snap.database;
+ html += ' | ' + snap.sizeFormatted;
+ html += '
';
+ html += '
';
+ });
+
+ $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);
diff --git a/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php b/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php
index 50804b6..a41ed82 100644
--- a/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php
+++ b/files/custom/Espo/Modules/DataMigration/Api/DeleteBackup.php
@@ -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.');
}
diff --git a/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php b/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php
index 6648537..902b95e 100644
--- a/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php
+++ b/files/custom/Espo/Modules/DataMigration/Api/ListBackups.php
@@ -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
diff --git a/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json
index c04e83b..4c0748a 100644
--- a/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json
+++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/en_US/DataMigration.json
@@ -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",
diff --git a/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json
index 31ed5d2..f47b9fb 100644
--- a/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json
+++ b/files/custom/Espo/Modules/DataMigration/Resources/i18n/fa_IR/DataMigration.json
@@ -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": "ייבוא רשומות בלבד, ללא קבצים מצורפים",