Implement real-time restore progress updates with polling
- Add restore.log file writing in restoreService.ts for progress tracking - Create getRestoreProgress query endpoint for polling restore logs - Implement polling-based progress updates in BackupsTab (1 second interval) - Update LoadingModal to display all progress logs with auto-scroll - Remove console.log debug output from restoreService - Add static 'Restore in progress' text under spinner - Show success checkmark when restore completes - Prevent modal dismissal during restore, allow ESC/X button when complete - Remove step prefixes from log messages for cleaner output - Keep success/error modals open until user dismisses manually
This commit is contained in:
10
restore.log
Normal file
10
restore.log
Normal file
@@ -0,0 +1,10 @@
|
||||
Starting restore...
|
||||
Reading container configuration...
|
||||
Stopping container...
|
||||
Destroying container...
|
||||
Logging into PBS...
|
||||
Downloading backup from PBS...
|
||||
Packing backup folder...
|
||||
Restoring container...
|
||||
Cleaning up temporary files...
|
||||
Restore completed successfully
|
||||
@@ -42,6 +42,7 @@ export function BackupsTab() {
|
||||
const [restoreProgress, setRestoreProgress] = useState<string[]>([]);
|
||||
const [restoreSuccess, setRestoreSuccess] = useState(false);
|
||||
const [restoreError, setRestoreError] = useState<string | null>(null);
|
||||
const [shouldPollRestore, setShouldPollRestore] = useState(false);
|
||||
|
||||
const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery();
|
||||
const discoverMutation = api.backups.discoverBackups.useMutation({
|
||||
@@ -50,32 +51,67 @@ export function BackupsTab() {
|
||||
},
|
||||
});
|
||||
|
||||
// Poll for restore progress
|
||||
const { data: restoreLogsData } = api.backups.getRestoreProgress.useQuery(undefined, {
|
||||
enabled: shouldPollRestore,
|
||||
refetchInterval: 1000, // Poll every second
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
// Update restore progress when log data changes
|
||||
useEffect(() => {
|
||||
if (restoreLogsData?.success && restoreLogsData.logs) {
|
||||
setRestoreProgress(restoreLogsData.logs);
|
||||
|
||||
// Stop polling when restore is complete
|
||||
if (restoreLogsData.isComplete) {
|
||||
setShouldPollRestore(false);
|
||||
// Check if restore was successful or failed
|
||||
const lastLog = restoreLogsData.logs[restoreLogsData.logs.length - 1] || '';
|
||||
if (lastLog.includes('Restore completed successfully')) {
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
} else if (lastLog.includes('Error:') || lastLog.includes('failed')) {
|
||||
setRestoreError(lastLog);
|
||||
setRestoreSuccess(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [restoreLogsData]);
|
||||
|
||||
const restoreMutation = api.backups.restoreBackup.useMutation({
|
||||
onMutate: () => {
|
||||
// Show progress immediately when mutation starts
|
||||
// Start polling for progress
|
||||
setShouldPollRestore(true);
|
||||
setRestoreProgress(['Starting restore...']);
|
||||
setRestoreError(null);
|
||||
setRestoreSuccess(false);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
// Stop polling - progress will be updated from logs
|
||||
setShouldPollRestore(false);
|
||||
|
||||
if (result.success) {
|
||||
setRestoreSuccess(true);
|
||||
// Update progress with all messages from backend
|
||||
const progressMessages = result.progress?.map(p => p.message) || ['Restore completed successfully'];
|
||||
// Update progress with all messages from backend (fallback if polling didn't work)
|
||||
const progressMessages = restoreProgress.length > 0 ? restoreProgress : (result.progress?.map(p => p.message) || ['Restore completed successfully']);
|
||||
setRestoreProgress(progressMessages);
|
||||
setRestoreSuccess(true);
|
||||
setRestoreError(null);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
}, 5000);
|
||||
// Keep success message visible - user can dismiss manually
|
||||
} else {
|
||||
setRestoreError(result.error || 'Restore failed');
|
||||
setRestoreProgress(result.progress?.map(p => p.message) || []);
|
||||
setRestoreProgress(result.progress?.map(p => p.message) || restoreProgress);
|
||||
setRestoreSuccess(false);
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
// Keep error message visible - user can dismiss manually
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Stop polling on error
|
||||
setShouldPollRestore(false);
|
||||
setRestoreError(error.message || 'Restore failed');
|
||||
setRestoreConfirmOpen(false);
|
||||
setSelectedBackup(null);
|
||||
@@ -376,59 +412,69 @@ export function BackupsTab() {
|
||||
)}
|
||||
|
||||
{/* Restore Progress Modal */}
|
||||
{restoreMutation.isPending && (
|
||||
{(restoreMutation.isPending || (restoreSuccess && restoreProgress.length > 0)) && (
|
||||
<LoadingModal
|
||||
isOpen={true}
|
||||
action={currentProgressText}
|
||||
logs={restoreProgress}
|
||||
isComplete={restoreSuccess}
|
||||
title="Restore in progress"
|
||||
onClose={() => {
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restore Progress Details - Show during restore */}
|
||||
{restoreMutation.isPending && (
|
||||
<div className="bg-card rounded-lg border border-border p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="font-medium text-foreground">Restoring backup...</span>
|
||||
</div>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Success */}
|
||||
{restoreSuccess && (
|
||||
<div className="bg-success/10 border border-success/20 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-success">Restore completed successfully!</span>
|
||||
</div>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{restoreProgress.map((message, index) => (
|
||||
<p key={index} className="text-sm text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-success" />
|
||||
<span className="font-medium text-success">Restore Completed Successfully</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRestoreSuccess(false);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The container has been restored from backup.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restore Error */}
|
||||
{restoreError && (
|
||||
<div className="bg-destructive/10 border border-destructive rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<span className="font-medium text-destructive">Restore failed</span>
|
||||
<div className="bg-error/10 border border-error/20 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-error" />
|
||||
<span className="font-medium text-error">Restore Failed</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setRestoreError(null);
|
||||
setRestoreProgress([]);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-destructive">{restoreError}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{restoreError}
|
||||
</p>
|
||||
{restoreProgress.length > 0 && (
|
||||
<div className="space-y-1 mt-2">
|
||||
{restoreProgress.map((message, index) => (
|
||||
|
||||
@@ -1,36 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, CheckCircle, X } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface LoadingModalProps {
|
||||
isOpen: boolean;
|
||||
action: string;
|
||||
logs?: string[];
|
||||
isComplete?: boolean;
|
||||
title?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function LoadingModal({ isOpen, action }: LoadingModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: false, onClose: () => null });
|
||||
export function LoadingModal({ isOpen, action, logs = [], isComplete = false, title, onClose }: LoadingModalProps) {
|
||||
// Allow dismissing with ESC only when complete, prevent during running
|
||||
useRegisterModal(isOpen, { id: 'loading-modal', allowEscape: isComplete, onClose: onClose || (() => null) });
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border p-8">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border p-8 max-h-[80vh] flex flex-col relative">
|
||||
{/* Close button - only show when complete */}
|
||||
{isComplete && onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
{isComplete ? (
|
||||
<CheckCircle className="h-12 w-12 text-success" />
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||
Processing
|
||||
</h3>
|
||||
|
||||
{/* Static title text */}
|
||||
{title && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{action}
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Please wait...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log output */}
|
||||
{logs.length > 0 && (
|
||||
<div className="w-full bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-[60vh] overflow-y-auto terminal-output">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isComplete && (
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createTRPCRouter, publicProcedure } from '~/server/api/trpc';
|
||||
import { getDatabase } from '~/server/database-prisma';
|
||||
import { getBackupService } from '~/server/services/backupService';
|
||||
import { getRestoreService } from '~/server/services/restoreService';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
export const backupsRouter = createTRPCRouter({
|
||||
// Get all backups grouped by container ID
|
||||
@@ -89,6 +93,49 @@ export const backupsRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// Get restore progress from log file
|
||||
getRestoreProgress: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const logPath = join(process.cwd(), 'restore.log');
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
return {
|
||||
success: true,
|
||||
logs: [],
|
||||
isComplete: false
|
||||
};
|
||||
}
|
||||
|
||||
const logs = await readFile(logPath, 'utf-8');
|
||||
const logLines = logs.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => stripAnsi(line)); // Strip ANSI color codes
|
||||
|
||||
// Check if restore is complete by looking for completion indicators
|
||||
const isComplete = logLines.some(line =>
|
||||
line.includes('complete: Restore completed successfully') ||
|
||||
line.includes('error: Error:') ||
|
||||
line.includes('Restore completed successfully') ||
|
||||
line.includes('Restore failed')
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
logs: logLines,
|
||||
isComplete
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error reading restore logs:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to read restore logs',
|
||||
logs: [],
|
||||
isComplete: false
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Restore backup
|
||||
restoreBackup: publicProcedure
|
||||
.input(z.object({
|
||||
|
||||
@@ -4,6 +4,9 @@ import { getStorageService } from './storageService';
|
||||
import { getDatabase } from '../database-prisma';
|
||||
import type { Server } from '~/types/server';
|
||||
import type { Storage } from './storageService';
|
||||
import { writeFile, readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
export interface RestoreProgress {
|
||||
step: string;
|
||||
@@ -73,26 +76,25 @@ class RestoreService {
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Error reading LXC config for CT ${ctId}:`, error);
|
||||
// Try fallback to database
|
||||
try {
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
|
||||
if (script) {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.rootfs_storage) {
|
||||
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
} catch (error) {
|
||||
// Try fallback to database
|
||||
try {
|
||||
const installedScripts = await db.getAllInstalledScripts();
|
||||
const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id);
|
||||
if (script) {
|
||||
const lxcConfig = await db.getLXCConfigByScriptId(script.id);
|
||||
if (lxcConfig?.rootfs_storage) {
|
||||
const match = lxcConfig.rootfs_storage.match(/^([^:]+):/);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (dbError) {
|
||||
// Ignore database error
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error(`Error getting storage from database:`, dbError);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +134,6 @@ class RestoreService {
|
||||
(error: string) => {
|
||||
// Check if error is about container not existing
|
||||
if (error.includes('does not exist') || error.includes('not found')) {
|
||||
console.log(`[RestoreService] Container ${ctId} does not exist`);
|
||||
resolve(); // Container doesn't exist, that's fine
|
||||
} else {
|
||||
reject(new Error(`Destroy failed: ${error}`));
|
||||
@@ -145,7 +146,6 @@ class RestoreService {
|
||||
} else {
|
||||
// Check if error is about container not existing
|
||||
if (output.includes('does not exist') || output.includes('not found') || output.includes('No such file')) {
|
||||
console.log(`[RestoreService] Container ${ctId} does not exist`);
|
||||
resolve(); // Container doesn't exist, that's fine
|
||||
} else {
|
||||
reject(new Error(`Destroy failed with exit code ${exitCode}: ${output}`));
|
||||
@@ -201,7 +201,7 @@ class RestoreService {
|
||||
ctId: string,
|
||||
snapshotPath: string,
|
||||
storageName: string,
|
||||
onProgress?: (step: string, message: string) => void
|
||||
onProgress?: (step: string, message: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const backupService = getBackupService();
|
||||
const sshService = getSSHExecutionService();
|
||||
@@ -236,18 +236,15 @@ class RestoreService {
|
||||
let downloadSuccess = false;
|
||||
|
||||
// Login to PBS first
|
||||
if (onProgress) onProgress('pbs_login', 'Logging into PBS...');
|
||||
console.log(`[RestoreService] Logging into PBS for storage ${storage.name}`);
|
||||
if (onProgress) await onProgress('pbs_login', 'Logging into PBS...');
|
||||
const loggedIn = await backupService.loginToPBS(server, storage);
|
||||
if (!loggedIn) {
|
||||
throw new Error(`Failed to login to PBS for storage ${storage.name}`);
|
||||
}
|
||||
console.log(`[RestoreService] PBS login successful`);
|
||||
|
||||
// Download backup from PBS
|
||||
// proxmox-backup-client restore outputs a folder, not a file
|
||||
if (onProgress) onProgress('pbs_download', 'Downloading backup from PBS...');
|
||||
console.log(`[RestoreService] Starting download of snapshot ${snapshotPath}`);
|
||||
if (onProgress) await onProgress('pbs_download', 'Downloading backup from PBS...');
|
||||
|
||||
// Target folder for PBS restore (without extension)
|
||||
// Use sanitized snapshot name (colons replaced with underscores) for file paths
|
||||
@@ -270,19 +267,15 @@ class RestoreService {
|
||||
restoreCommand,
|
||||
(data: string) => {
|
||||
output += data;
|
||||
console.log(`[RestoreService] Download output: ${data}`);
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`[RestoreService] Download error: ${error}`);
|
||||
reject(new Error(`Download failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
exitCode = code;
|
||||
console.log(`[RestoreService] Download command exited with code ${exitCode}`);
|
||||
if (exitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`[RestoreService] Download failed: ${output}`);
|
||||
reject(new Error(`Download failed with exit code ${exitCode}: ${output}`));
|
||||
}
|
||||
}
|
||||
@@ -311,15 +304,12 @@ class RestoreService {
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[RestoreService] Folder check result: ${checkOutput}`);
|
||||
|
||||
if (!checkOutput.includes('exists')) {
|
||||
throw new Error(`Downloaded folder ${targetFolder} does not exist`);
|
||||
}
|
||||
|
||||
// Pack the folder into a tar file
|
||||
if (onProgress) onProgress('pbs_pack', 'Packing backup folder...');
|
||||
console.log(`[RestoreService] Packing folder ${targetFolder} into ${targetTar}`);
|
||||
if (onProgress) await onProgress('pbs_pack', 'Packing backup folder...');
|
||||
|
||||
// Use -C to change to the folder directory, then pack all contents (.) into the tar file
|
||||
const packCommand = `tar -cf "${targetTar}" -C "${targetFolder}" . 2>&1`;
|
||||
@@ -333,19 +323,15 @@ class RestoreService {
|
||||
packCommand,
|
||||
(data: string) => {
|
||||
packOutput += data;
|
||||
console.log(`[RestoreService] Pack output: ${data}`);
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`[RestoreService] Pack error: ${error}`);
|
||||
reject(new Error(`Pack failed: ${error}`));
|
||||
},
|
||||
(code: number) => {
|
||||
packExitCode = code;
|
||||
console.log(`[RestoreService] Pack command exited with code ${packExitCode}`);
|
||||
if (packExitCode === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
console.error(`[RestoreService] Pack failed: ${packOutput}`);
|
||||
reject(new Error(`Pack failed with exit code ${packExitCode}: ${packOutput}`));
|
||||
}
|
||||
}
|
||||
@@ -374,18 +360,13 @@ class RestoreService {
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`[RestoreService] Tar file check result: ${checkTarOutput}`);
|
||||
|
||||
if (!checkTarOutput.includes('exists')) {
|
||||
throw new Error(`Packed tar file ${targetTar} does not exist`);
|
||||
}
|
||||
|
||||
downloadedPath = targetTar;
|
||||
downloadSuccess = true;
|
||||
console.log(`[RestoreService] Successfully downloaded and packed backup to ${targetTar}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[RestoreService] Failed to download/pack backup:`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -394,13 +375,12 @@ class RestoreService {
|
||||
}
|
||||
|
||||
// Restore from packed tar file
|
||||
if (onProgress) onProgress('restoring', 'Restoring container...');
|
||||
if (onProgress) await onProgress('restoring', 'Restoring container...');
|
||||
try {
|
||||
console.log(`[RestoreService] Starting Restore from ${targetTar}`);
|
||||
await this.restoreLocalBackup(server, ctId, downloadedPath, storageName);
|
||||
} finally {
|
||||
// Cleanup: delete downloaded folder and tar file
|
||||
if (onProgress) onProgress('cleanup', 'Cleaning up temporary files...');
|
||||
if (onProgress) await onProgress('cleanup', 'Cleaning up temporary files...');
|
||||
const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`;
|
||||
sshService.executeCommand(
|
||||
server,
|
||||
@@ -422,20 +402,48 @@ class RestoreService {
|
||||
onProgress?: (progress: RestoreProgress) => void
|
||||
): Promise<RestoreResult> {
|
||||
const progress: RestoreProgress[] = [];
|
||||
const logPath = join(process.cwd(), 'restore.log');
|
||||
|
||||
const addProgress = (step: string, message: string) => {
|
||||
// Clear log file at start of restore
|
||||
const clearLogFile = async () => {
|
||||
try {
|
||||
await writeFile(logPath, '', 'utf-8');
|
||||
} catch (error) {
|
||||
// Ignore log file errors
|
||||
}
|
||||
};
|
||||
|
||||
// Write progress to log file
|
||||
const writeProgressToLog = async (message: string) => {
|
||||
try {
|
||||
const logLine = `${message}\n`;
|
||||
await writeFile(logPath, logLine, { flag: 'a', encoding: 'utf-8' });
|
||||
} catch (error) {
|
||||
// Ignore log file errors
|
||||
}
|
||||
};
|
||||
|
||||
const addProgress = async (step: string, message: string) => {
|
||||
const p = { step, message };
|
||||
progress.push(p);
|
||||
|
||||
// Write to log file (just the message, without step prefix)
|
||||
await writeProgressToLog(message);
|
||||
|
||||
// Call callback if provided
|
||||
if (onProgress) {
|
||||
onProgress(p);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Clear log file at start
|
||||
await clearLogFile();
|
||||
|
||||
const db = getDatabase();
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
console.log(`[RestoreService] Starting restore for backup ${backupId}, CT ${containerId}, server ${serverId}`);
|
||||
await addProgress('starting', 'Starting restore...');
|
||||
|
||||
// Get backup details
|
||||
const backup = await db.getBackupById(backupId);
|
||||
@@ -443,8 +451,6 @@ class RestoreService {
|
||||
throw new Error(`Backup with ID ${backupId} not found`);
|
||||
}
|
||||
|
||||
console.log(`[RestoreService] Backup found: ${backup.backup_name}, type: ${backup.storage_type}, path: ${backup.backup_path}`);
|
||||
|
||||
// Get server details
|
||||
const server = await db.getServerById(serverId);
|
||||
if (!server) {
|
||||
@@ -452,10 +458,8 @@ class RestoreService {
|
||||
}
|
||||
|
||||
// Get rootfs storage
|
||||
addProgress('reading_config', 'Reading container configuration...');
|
||||
console.log(`[RestoreService] Getting rootfs storage for CT ${containerId}`);
|
||||
await addProgress('reading_config', 'Reading container configuration...');
|
||||
const rootfsStorage = await this.getRootfsStorage(server, containerId);
|
||||
console.log(`[RestoreService] Rootfs storage: ${rootfsStorage || 'not found'}`);
|
||||
|
||||
if (!rootfsStorage) {
|
||||
// Try to check if container exists, if not we can proceed without stopping/destroying
|
||||
@@ -482,29 +486,24 @@ class RestoreService {
|
||||
}
|
||||
|
||||
// Try to stop and destroy container - if it doesn't exist, continue anyway
|
||||
addProgress('stopping', 'Stopping container...');
|
||||
await addProgress('stopping', 'Stopping container...');
|
||||
try {
|
||||
await this.stopContainer(server, containerId);
|
||||
console.log(`[RestoreService] Container ${containerId} stopped`);
|
||||
} catch (error) {
|
||||
console.warn(`[RestoreService] Failed to stop container (may not exist or already stopped):`, error);
|
||||
// Continue even if stop fails
|
||||
}
|
||||
|
||||
// Try to destroy container - if it doesn't exist, continue anyway
|
||||
addProgress('destroying', 'Destroying container...');
|
||||
await addProgress('destroying', 'Destroying container...');
|
||||
try {
|
||||
await this.destroyContainer(server, containerId);
|
||||
console.log(`[RestoreService] Container ${containerId} destroyed successfully`);
|
||||
} catch (error) {
|
||||
// Container might not exist, which is fine - continue with restore
|
||||
console.log(`[RestoreService] Container ${containerId} does not exist or destroy failed (continuing anyway):`, error);
|
||||
addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
||||
await addProgress('skipping', 'Container does not exist or already destroyed, continuing...');
|
||||
}
|
||||
|
||||
// Restore based on backup type
|
||||
if (backup.storage_type === 'pbs') {
|
||||
console.log(`[RestoreService] Restoring from PBS backup`);
|
||||
// Get storage info for PBS
|
||||
const storageService = getStorageService();
|
||||
const storages = await storageService.getStorages(server, false);
|
||||
@@ -521,22 +520,17 @@ class RestoreService {
|
||||
}
|
||||
|
||||
const snapshotPath = snapshotPathMatch[1];
|
||||
console.log(`[RestoreService] Snapshot path: ${snapshotPath}, Storage: ${rootfsStorage}`);
|
||||
|
||||
await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, (step, message) => {
|
||||
addProgress(step, message);
|
||||
await this.restorePBSBackup(server, storage, containerId, snapshotPath, rootfsStorage, async (step, message) => {
|
||||
await addProgress(step, message);
|
||||
});
|
||||
} else {
|
||||
// Local or storage backup
|
||||
console.log(`[RestoreService] Restoring from ${backup.storage_type} backup: ${backup.backup_path}`);
|
||||
addProgress('restoring', 'Restoring container...');
|
||||
await addProgress('restoring', 'Restoring container...');
|
||||
await this.restoreLocalBackup(server, containerId, backup.backup_path, rootfsStorage);
|
||||
console.log(`[RestoreService] Local restore completed`);
|
||||
}
|
||||
|
||||
addProgress('complete', 'Restore completed successfully');
|
||||
|
||||
console.log(`[RestoreService] Restore completed successfully for CT ${containerId}`);
|
||||
await addProgress('complete', 'Restore completed successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -544,8 +538,7 @@ class RestoreService {
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
||||
console.error(`[RestoreService] Restore failed for CT ${containerId}:`, error);
|
||||
addProgress('error', `Error: ${errorMessage}`);
|
||||
await addProgress('error', `Error: ${errorMessage}`);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
|
||||
Reference in New Issue
Block a user