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`
|
- 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}}'`
|
- 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 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 };
|
const pgEnv = { ...process.env, PGPASSWORD: dbPassword };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Drop existing connections (except our own)
|
// Note: We no longer terminate connections before restore.
|
||||||
await execAsync(
|
// The --clean flag in pg_dump generates DROP statements that handle existing data.
|
||||||
`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();"`,
|
// Terminating connections would kill the backend's own pool and break the HTTP response.
|
||||||
{ env: pgEnv }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Restore the database using psql
|
// Restore the database using psql
|
||||||
await execAsync(
|
await execAsync(
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
CreateScheduleRequest,
|
CreateScheduleRequest,
|
||||||
UpdateScheduleRequest,
|
UpdateScheduleRequest,
|
||||||
RestorePreviewResponse,
|
RestorePreviewResponse,
|
||||||
|
ExecuteRestoreRequest,
|
||||||
} from '../types/admin.types';
|
} from '../types/admin.types';
|
||||||
|
|
||||||
export interface AuditLogsResponse {
|
export interface AuditLogsResponse {
|
||||||
@@ -408,9 +409,10 @@ export const adminApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Execute restore
|
// 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 }>(
|
const response = await apiClient.post<{ message: string }>(
|
||||||
`/admin/backups/${id}/restore`
|
`/admin/backups/${id}/restore`,
|
||||||
|
options
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CreateBackupRequest,
|
CreateBackupRequest,
|
||||||
CreateScheduleRequest,
|
CreateScheduleRequest,
|
||||||
UpdateScheduleRequest,
|
UpdateScheduleRequest,
|
||||||
|
ExecuteRestoreRequest,
|
||||||
} from '../types/admin.types';
|
} from '../types/admin.types';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
@@ -144,7 +145,8 @@ export const useExecuteRestore = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) => adminApi.backups.restore(id),
|
mutationFn: ({ id, options }: { id: string; options?: ExecuteRestoreRequest }) =>
|
||||||
|
adminApi.backups.restore(id, options),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: backupKeys.all });
|
queryClient.invalidateQueries({ queryKey: backupKeys.all });
|
||||||
toast.success(data.message || 'Restore completed successfully');
|
toast.success(data.message || 'Restore completed successfully');
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
|||||||
const [showDeleteBackupConfirm, setShowDeleteBackupConfirm] = useState(false);
|
const [showDeleteBackupConfirm, setShowDeleteBackupConfirm] = useState(false);
|
||||||
const [showDeleteScheduleConfirm, setShowDeleteScheduleConfirm] = useState(false);
|
const [showDeleteScheduleConfirm, setShowDeleteScheduleConfirm] = useState(false);
|
||||||
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
|
const [showRestoreConfirm, setShowRestoreConfirm] = useState(false);
|
||||||
|
const [createSafetyBackup, setCreateSafetyBackup] = useState(true);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: backupsData, isLoading: backupsLoading } = useBackups();
|
const { data: backupsData, isLoading: backupsLoading } = useBackups();
|
||||||
@@ -152,14 +153,18 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
|||||||
|
|
||||||
const handleExecuteRestore = useCallback(() => {
|
const handleExecuteRestore = useCallback(() => {
|
||||||
if (!selectedBackup) return;
|
if (!selectedBackup) return;
|
||||||
executeRestoreMutation.mutate(selectedBackup.id, {
|
executeRestoreMutation.mutate(
|
||||||
|
{ id: selectedBackup.id, options: { createSafetyBackup } },
|
||||||
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setShowRestoreConfirm(false);
|
setShowRestoreConfirm(false);
|
||||||
setViewMode('list');
|
setViewMode('list');
|
||||||
setSelectedBackup(null);
|
setSelectedBackup(null);
|
||||||
|
setCreateSafetyBackup(true);
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
}, [selectedBackup, executeRestoreMutation]);
|
);
|
||||||
|
}, [selectedBackup, createSafetyBackup, executeRestoreMutation]);
|
||||||
|
|
||||||
// Handlers for schedules
|
// Handlers for schedules
|
||||||
const handleCreateSchedule = useCallback(() => {
|
const handleCreateSchedule = useCallback(() => {
|
||||||
@@ -543,11 +548,33 @@ export const AdminBackupMobileScreen: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
<GlassCard>
|
<GlassCard>
|
||||||
<div className="p-4 space-y-3">
|
<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">
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
|
||||||
<p className="text-xs text-red-800">
|
<p className="text-xs text-red-800">
|
||||||
This action will replace all current data with the backup data.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-600 mb-4">
|
<p className="text-sm text-slate-600 mb-4">
|
||||||
|
|||||||
@@ -367,3 +367,7 @@ export interface RestorePreviewResponse {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
estimatedDuration: string;
|
estimatedDuration: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExecuteRestoreRequest {
|
||||||
|
createSafetyBackup?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export const AdminBackupPage: React.FC = () => {
|
|||||||
const [scheduleFrequency, setScheduleFrequency] = useState<BackupFrequency>('daily');
|
const [scheduleFrequency, setScheduleFrequency] = useState<BackupFrequency>('daily');
|
||||||
const [scheduleRetention, setScheduleRetention] = useState(7);
|
const [scheduleRetention, setScheduleRetention] = useState(7);
|
||||||
const [scheduleEnabled, setScheduleEnabled] = useState(true);
|
const [scheduleEnabled, setScheduleEnabled] = useState(true);
|
||||||
|
const [createSafetyBackup, setCreateSafetyBackup] = useState(true);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: backupsData, isLoading: backupsLoading } = useBackups();
|
const { data: backupsData, isLoading: backupsLoading } = useBackups();
|
||||||
@@ -210,14 +211,18 @@ export const AdminBackupPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleExecuteRestore = useCallback(() => {
|
const handleExecuteRestore = useCallback(() => {
|
||||||
if (!selectedBackup) return;
|
if (!selectedBackup) return;
|
||||||
executeRestoreMutation.mutate(selectedBackup.id, {
|
executeRestoreMutation.mutate(
|
||||||
|
{ id: selectedBackup.id, options: { createSafetyBackup } },
|
||||||
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setRestoreDialogOpen(false);
|
setRestoreDialogOpen(false);
|
||||||
setRestorePreviewDialogOpen(false);
|
setRestorePreviewDialogOpen(false);
|
||||||
setSelectedBackup(null);
|
setSelectedBackup(null);
|
||||||
|
setCreateSafetyBackup(true);
|
||||||
},
|
},
|
||||||
});
|
}
|
||||||
}, [selectedBackup, executeRestoreMutation]);
|
);
|
||||||
|
}, [selectedBackup, createSafetyBackup, executeRestoreMutation]);
|
||||||
|
|
||||||
// Handlers for schedules
|
// Handlers for schedules
|
||||||
const handleCreateSchedule = useCallback(() => {
|
const handleCreateSchedule = useCallback(() => {
|
||||||
@@ -820,9 +825,22 @@ export const AdminBackupPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
) : restorePreviewMutation.data ? (
|
) : restorePreviewMutation.data ? (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box sx={{ mt: 2 }}>
|
||||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
<Box sx={{ mb: 3 }}>
|
||||||
A safety backup will be created automatically before restoring.
|
<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>
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Backup Information
|
Backup Information
|
||||||
@@ -897,8 +915,10 @@ export const AdminBackupPage: React.FC = () => {
|
|||||||
<DialogTitle>Confirm Restore</DialogTitle>
|
<DialogTitle>Confirm Restore</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Alert severity="error" sx={{ mb: 2 }}>
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
This action will replace all current data with the backup data. A safety backup will be
|
This action will replace all current data with the backup data.
|
||||||
created first.
|
{createSafetyBackup
|
||||||
|
? ' A safety backup will be created first.'
|
||||||
|
: ' No safety backup will be created - this cannot be undone!'}
|
||||||
</Alert>
|
</Alert>
|
||||||
<Typography>
|
<Typography>
|
||||||
Are you sure you want to restore from "{selectedBackup?.filename}"?
|
Are you sure you want to restore from "{selectedBackup?.filename}"?
|
||||||
|
|||||||
Reference in New Issue
Block a user