feat: backup improvements
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m31s
Deploy to Staging / Deploy to Staging (push) Successful in 37s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m31s
Deploy to Staging / Deploy to Staging (push) Successful in 37s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
This commit is contained in:
@@ -32,3 +32,4 @@ make migrate # run DB migrations
|
||||
- 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"`
|
||||
- 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;"`
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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, {
|
||||
executeRestoreMutation.mutate(
|
||||
{ id: selectedBackup.id, options: { createSafetyBackup } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowRestoreConfirm(false);
|
||||
setViewMode('list');
|
||||
setSelectedBackup(null);
|
||||
setCreateSafetyBackup(true);
|
||||
},
|
||||
});
|
||||
}, [selectedBackup, executeRestoreMutation]);
|
||||
}
|
||||
);
|
||||
}, [selectedBackup, createSafetyBackup, executeRestoreMutation]);
|
||||
|
||||
// Handlers for schedules
|
||||
const handleCreateSchedule = useCallback(() => {
|
||||
@@ -543,11 +548,33 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<GlassCard>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-slate-700">Create safety backup</span>
|
||||
<button
|
||||
onClick={() => setCreateSafetyBackup(!createSafetyBackup)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
createSafetyBackup ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`}
|
||||
style={{ minHeight: '44px', minWidth: '44px' }}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
createSafetyBackup ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!createSafetyBackup && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mt-2">
|
||||
<p className="text-xs text-yellow-800">
|
||||
A safety backup will be created automatically before restoring.
|
||||
Warning: Without a safety backup, you cannot undo this restore operation.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard>
|
||||
<div className="p-4 space-y-3">
|
||||
@@ -615,6 +642,9 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||
<p className="text-xs text-red-800">
|
||||
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!'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
|
||||
@@ -367,3 +367,7 @@ export interface RestorePreviewResponse {
|
||||
warnings: string[];
|
||||
estimatedDuration: string;
|
||||
}
|
||||
|
||||
export interface ExecuteRestoreRequest {
|
||||
createSafetyBackup?: boolean;
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ export const AdminBackupPage: React.FC = () => {
|
||||
const [scheduleFrequency, setScheduleFrequency] = useState<BackupFrequency>('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, {
|
||||
executeRestoreMutation.mutate(
|
||||
{ id: selectedBackup.id, options: { createSafetyBackup } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setRestoreDialogOpen(false);
|
||||
setRestorePreviewDialogOpen(false);
|
||||
setSelectedBackup(null);
|
||||
setCreateSafetyBackup(true);
|
||||
},
|
||||
});
|
||||
}, [selectedBackup, executeRestoreMutation]);
|
||||
}
|
||||
);
|
||||
}, [selectedBackup, createSafetyBackup, executeRestoreMutation]);
|
||||
|
||||
// Handlers for schedules
|
||||
const handleCreateSchedule = useCallback(() => {
|
||||
@@ -820,9 +825,22 @@ export const AdminBackupPage: React.FC = () => {
|
||||
</Box>
|
||||
) : restorePreviewMutation.data ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
A safety backup will be created automatically before restoring.
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={createSafetyBackup}
|
||||
onChange={(e) => setCreateSafetyBackup(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Create safety backup before restoring"
|
||||
/>
|
||||
{!createSafetyBackup && (
|
||||
<Alert severity="warning" sx={{ mt: 1 }}>
|
||||
Warning: Without a safety backup, you cannot undo this restore operation.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Backup Information
|
||||
@@ -897,8 +915,10 @@ export const AdminBackupPage: React.FC = () => {
|
||||
<DialogTitle>Confirm Restore</DialogTitle>
|
||||
<DialogContent>
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
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!'}
|
||||
</Alert>
|
||||
<Typography>
|
||||
Are you sure you want to restore from "{selectedBackup?.filename}"?
|
||||
|
||||
Reference in New Issue
Block a user