From 9043a581b121fe5ec5b35fe5555398f16230969d Mon Sep 17 00:00:00 2001 From: Eric Gullickson <16152721+ericgullickson@users.noreply.github.com> Date: Thu, 1 Jan 2026 13:57:36 -0600 Subject: [PATCH] feat: backup improvements --- README.md | 3 +- .../backup/domain/backup-restore.service.ts | 8 +-- frontend/src/features/admin/api/admin.api.ts | 6 +- .../src/features/admin/hooks/useBackups.ts | 4 +- .../admin/mobile/AdminBackupMobileScreen.tsx | 56 ++++++++++++++----- .../src/features/admin/types/admin.types.ts | 4 ++ frontend/src/pages/admin/AdminBackupPage.tsx | 46 ++++++++++----- 7 files changed, 92 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e8cc1ed..86f1ea7 100644 --- a/README.md +++ b/README.md @@ -31,4 +31,5 @@ make migrate # run DB migrations - View active environment on production: `sudo cat /opt/motovaultpro/config/deployment/state.json` - Switch traffic between environments on production: `sudo ./scripts/ci/switch-traffic.sh blue instant` - View which container images are running: `docker ps --format 'table {{.Names}}\t{{.Image}}'` -- Flush all redis cache: `docker compose exec -T mvp-redis sh -lc "redis-cli FLUSHALL"` \ No newline at end of file +- Flush all redis cache: `docker compose exec -T mvp-redis sh -lc "redis-cli FLUSHALL"` +- Flush all backup data on staging before restoring: `docker compose exec mvp-postgres psql -U postgres -d motovaultpro -c "TRUNCATE TABLE backup_history, backup_schedules, backup_settings RESTART IDENTITY CASCADE;"` \ No newline at end of file diff --git a/backend/src/features/backup/domain/backup-restore.service.ts b/backend/src/features/backup/domain/backup-restore.service.ts index 391e5cb..c2c3b85 100644 --- a/backend/src/features/backup/domain/backup-restore.service.ts +++ b/backend/src/features/backup/domain/backup-restore.service.ts @@ -214,11 +214,9 @@ export class BackupRestoreService { const pgEnv = { ...process.env, PGPASSWORD: dbPassword }; try { - // Drop existing connections (except our own) - await execAsync( - `psql -h ${dbHost} -p ${dbPort} -U ${dbUser} -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${dbName}' AND pid <> pg_backend_pid();"`, - { env: pgEnv } - ); + // Note: We no longer terminate connections before restore. + // The --clean flag in pg_dump generates DROP statements that handle existing data. + // Terminating connections would kill the backend's own pool and break the HTTP response. // Restore the database using psql await execAsync( diff --git a/frontend/src/features/admin/api/admin.api.ts b/frontend/src/features/admin/api/admin.api.ts index f68307a..0dfd2e0 100644 --- a/frontend/src/features/admin/api/admin.api.ts +++ b/frontend/src/features/admin/api/admin.api.ts @@ -48,6 +48,7 @@ import { CreateScheduleRequest, UpdateScheduleRequest, RestorePreviewResponse, + ExecuteRestoreRequest, } from '../types/admin.types'; export interface AuditLogsResponse { @@ -408,9 +409,10 @@ export const adminApi = { }, // Execute restore - restore: async (id: string): Promise<{ message: string }> => { + restore: async (id: string, options?: ExecuteRestoreRequest): Promise<{ message: string }> => { const response = await apiClient.post<{ message: string }>( - `/admin/backups/${id}/restore` + `/admin/backups/${id}/restore`, + options ); return response.data; }, diff --git a/frontend/src/features/admin/hooks/useBackups.ts b/frontend/src/features/admin/hooks/useBackups.ts index 87278d0..e326974 100644 --- a/frontend/src/features/admin/hooks/useBackups.ts +++ b/frontend/src/features/admin/hooks/useBackups.ts @@ -10,6 +10,7 @@ import { CreateBackupRequest, CreateScheduleRequest, UpdateScheduleRequest, + ExecuteRestoreRequest, } from '../types/admin.types'; import toast from 'react-hot-toast'; @@ -144,7 +145,8 @@ export const useExecuteRestore = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (id: string) => adminApi.backups.restore(id), + mutationFn: ({ id, options }: { id: string; options?: ExecuteRestoreRequest }) => + adminApi.backups.restore(id, options), onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: backupKeys.all }); toast.success(data.message || 'Restore completed successfully'); diff --git a/frontend/src/features/admin/mobile/AdminBackupMobileScreen.tsx b/frontend/src/features/admin/mobile/AdminBackupMobileScreen.tsx index fded738..87221dd 100644 --- a/frontend/src/features/admin/mobile/AdminBackupMobileScreen.tsx +++ b/frontend/src/features/admin/mobile/AdminBackupMobileScreen.tsx @@ -69,6 +69,7 @@ export const AdminBackupMobileScreen: React.FC = () => { const [showDeleteBackupConfirm, setShowDeleteBackupConfirm] = useState(false); const [showDeleteScheduleConfirm, setShowDeleteScheduleConfirm] = useState(false); const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); + const [createSafetyBackup, setCreateSafetyBackup] = useState(true); // Queries const { data: backupsData, isLoading: backupsLoading } = useBackups(); @@ -152,14 +153,18 @@ export const AdminBackupMobileScreen: React.FC = () => { const handleExecuteRestore = useCallback(() => { if (!selectedBackup) return; - executeRestoreMutation.mutate(selectedBackup.id, { - onSuccess: () => { - setShowRestoreConfirm(false); - setViewMode('list'); - setSelectedBackup(null); - }, - }); - }, [selectedBackup, executeRestoreMutation]); + executeRestoreMutation.mutate( + { id: selectedBackup.id, options: { createSafetyBackup } }, + { + onSuccess: () => { + setShowRestoreConfirm(false); + setViewMode('list'); + setSelectedBackup(null); + setCreateSafetyBackup(true); + }, + } + ); + }, [selectedBackup, createSafetyBackup, executeRestoreMutation]); // Handlers for schedules const handleCreateSchedule = useCallback(() => { @@ -543,11 +548,33 @@ export const AdminBackupMobileScreen: React.FC = () => { -
-

- A safety backup will be created automatically before restoring. -

-
+ +
+
+ Create safety backup + +
+ {!createSafetyBackup && ( +
+

+ Warning: Without a safety backup, you cannot undo this restore operation. +

+
+ )} +
+
@@ -615,6 +642,9 @@ export const AdminBackupMobileScreen: React.FC = () => {

This action will replace all current data with the backup data. + {createSafetyBackup + ? ' A safety backup will be created first.' + : ' No safety backup will be created - this cannot be undone!'}

diff --git a/frontend/src/features/admin/types/admin.types.ts b/frontend/src/features/admin/types/admin.types.ts index becbeb0..d3a183d 100644 --- a/frontend/src/features/admin/types/admin.types.ts +++ b/frontend/src/features/admin/types/admin.types.ts @@ -367,3 +367,7 @@ export interface RestorePreviewResponse { warnings: string[]; estimatedDuration: string; } + +export interface ExecuteRestoreRequest { + createSafetyBackup?: boolean; +} diff --git a/frontend/src/pages/admin/AdminBackupPage.tsx b/frontend/src/pages/admin/AdminBackupPage.tsx index 7c1aabb..c82a4ff 100644 --- a/frontend/src/pages/admin/AdminBackupPage.tsx +++ b/frontend/src/pages/admin/AdminBackupPage.tsx @@ -127,6 +127,7 @@ export const AdminBackupPage: React.FC = () => { const [scheduleFrequency, setScheduleFrequency] = useState('daily'); const [scheduleRetention, setScheduleRetention] = useState(7); const [scheduleEnabled, setScheduleEnabled] = useState(true); + const [createSafetyBackup, setCreateSafetyBackup] = useState(true); // Queries const { data: backupsData, isLoading: backupsLoading } = useBackups(); @@ -210,14 +211,18 @@ export const AdminBackupPage: React.FC = () => { const handleExecuteRestore = useCallback(() => { if (!selectedBackup) return; - executeRestoreMutation.mutate(selectedBackup.id, { - onSuccess: () => { - setRestoreDialogOpen(false); - setRestorePreviewDialogOpen(false); - setSelectedBackup(null); - }, - }); - }, [selectedBackup, executeRestoreMutation]); + executeRestoreMutation.mutate( + { id: selectedBackup.id, options: { createSafetyBackup } }, + { + onSuccess: () => { + setRestoreDialogOpen(false); + setRestorePreviewDialogOpen(false); + setSelectedBackup(null); + setCreateSafetyBackup(true); + }, + } + ); + }, [selectedBackup, createSafetyBackup, executeRestoreMutation]); // Handlers for schedules const handleCreateSchedule = useCallback(() => { @@ -820,9 +825,22 @@ export const AdminBackupPage: React.FC = () => { ) : restorePreviewMutation.data ? ( - - A safety backup will be created automatically before restoring. - + + setCreateSafetyBackup(e.target.checked)} + /> + } + label="Create safety backup before restoring" + /> + {!createSafetyBackup && ( + + Warning: Without a safety backup, you cannot undo this restore operation. + + )} + Backup Information @@ -897,8 +915,10 @@ export const AdminBackupPage: React.FC = () => { Confirm Restore - This action will replace all current data with the backup data. A safety backup will be - created first. + This action will replace all current data with the backup data. + {createSafetyBackup + ? ' A safety backup will be created first.' + : ' No safety backup will be created - this cannot be undone!'} Are you sure you want to restore from "{selectedBackup?.filename}"?