diff --git a/src/app/_components/BackupsTab.tsx b/src/app/_components/BackupsTab.tsx index 65e4972..daaee40 100644 --- a/src/app/_components/BackupsTab.tsx +++ b/src/app/_components/BackupsTab.tsx @@ -4,7 +4,15 @@ import { useState, useEffect } from 'react'; import { api } from '~/trpc/react'; import { Button } from './ui/button'; import { Badge } from './ui/badge'; -import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server } from 'lucide-react'; +import { RefreshCw, ChevronDown, ChevronRight, HardDrive, Database, Server, CheckCircle, AlertCircle } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './ui/dropdown-menu'; +import { ConfirmationModal } from './ConfirmationModal'; +import { LoadingModal } from './LoadingModal'; interface Backup { id: number; @@ -15,6 +23,7 @@ interface Backup { storage_name: string; storage_type: string; discovered_at: Date; + server_id: number; server_name: string | null; server_color: string | null; } @@ -28,6 +37,11 @@ interface ContainerBackups { export function BackupsTab() { const [expandedContainers, setExpandedContainers] = useState>(new Set()); const [hasAutoDiscovered, setHasAutoDiscovered] = useState(false); + const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false); + const [selectedBackup, setSelectedBackup] = useState<{ backup: Backup; containerId: string } | null>(null); + const [restoreProgress, setRestoreProgress] = useState([]); + const [restoreSuccess, setRestoreSuccess] = useState(false); + const [restoreError, setRestoreError] = useState(null); const { data: backupsData, refetch: refetchBackups, isLoading } = api.backups.getAllBackupsGrouped.useQuery(); const discoverMutation = api.backups.discoverBackups.useMutation({ @@ -36,6 +50,44 @@ export function BackupsTab() { }, }); + const restoreMutation = api.backups.restoreBackup.useMutation({ + onMutate: () => { + // Show progress immediately when mutation starts + setRestoreProgress(['Starting restore...']); + setRestoreError(null); + setRestoreSuccess(false); + }, + onSuccess: (result) => { + if (result.success) { + setRestoreSuccess(true); + // Update progress with all messages from backend + const progressMessages = result.progress?.map(p => p.message) || ['Restore completed successfully']; + setRestoreProgress(progressMessages); + setRestoreConfirmOpen(false); + setSelectedBackup(null); + // Clear success message after 5 seconds + setTimeout(() => { + setRestoreSuccess(false); + setRestoreProgress([]); + }, 5000); + } else { + setRestoreError(result.error || 'Restore failed'); + setRestoreProgress(result.progress?.map(p => p.message) || []); + } + }, + onError: (error) => { + setRestoreError(error.message || 'Restore failed'); + setRestoreConfirmOpen(false); + setSelectedBackup(null); + setRestoreProgress([]); + }, + }); + + // Update progress text in modal based on current progress + const currentProgressText = restoreProgress.length > 0 + ? restoreProgress[restoreProgress.length - 1] + : 'Restoring backup...'; + // Auto-discover backups when tab is first opened useEffect(() => { if (!hasAutoDiscovered && !isLoading && backupsData) { @@ -51,6 +103,28 @@ export function BackupsTab() { discoverMutation.mutate(); }; + const handleRestoreClick = (backup: Backup, containerId: string) => { + setSelectedBackup({ backup, containerId }); + setRestoreConfirmOpen(true); + setRestoreError(null); + setRestoreSuccess(false); + setRestoreProgress([]); + }; + + const handleRestoreConfirm = () => { + if (!selectedBackup) return; + + setRestoreConfirmOpen(false); + setRestoreError(null); + setRestoreSuccess(false); + + restoreMutation.mutate({ + backupId: selectedBackup.backup.id, + containerId: selectedBackup.containerId, + serverId: selectedBackup.backup.server_id, + }); + }; + const toggleContainer = (containerId: string) => { const newExpanded = new Set(expandedContainers); if (newExpanded.has(containerId)) { @@ -234,6 +308,34 @@ export function BackupsTab() { +
+ + + + + + handleRestoreClick(backup, container.container_id)} + disabled={restoreMutation.isPending} + className="text-muted-foreground hover:text-foreground hover:bg-muted/20 focus:bg-muted/20" + > + Restore + + + Delete + + + +
))} @@ -254,6 +356,101 @@ export function BackupsTab() {

)} + + {/* Restore Confirmation Modal */} + {selectedBackup && ( + { + setRestoreConfirmOpen(false); + setSelectedBackup(null); + }} + onConfirm={handleRestoreConfirm} + title="Restore Backup" + message={`This will destroy the existing container and restore from backup. The container will be stopped during restore. This action cannot be undone and may result in data loss.`} + variant="danger" + confirmText={selectedBackup.containerId} + confirmButtonText="Restore" + cancelButtonText="Cancel" + /> + )} + + {/* Restore Progress Modal */} + {restoreMutation.isPending && ( + + )} + + {/* Restore Progress Details - Show during restore */} + {restoreMutation.isPending && ( +
+
+ + Restoring backup... +
+ {restoreProgress.length > 0 && ( +
+ {restoreProgress.map((message, index) => ( +

+ {message} +

+ ))} +
+ )} +
+ )} + + {/* Restore Success */} + {restoreSuccess && ( +
+
+ + Restore completed successfully! +
+ {restoreProgress.length > 0 && ( +
+ {restoreProgress.map((message, index) => ( +

+ {message} +

+ ))} +
+ )} +
+ )} + + {/* Restore Error */} + {restoreError && ( +
+
+ + Restore failed +
+

{restoreError}

+ {restoreProgress.length > 0 && ( +
+ {restoreProgress.map((message, index) => ( +

+ {message} +

+ ))} +
+ )} + +
+ )} ); } diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts index 78f0f09..7517a2c 100644 --- a/src/server/api/routers/backups.ts +++ b/src/server/api/routers/backups.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; 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'; export const backupsRouter = createTRPCRouter({ // Get all backups grouped by container ID @@ -47,6 +48,7 @@ export const backupsRouter = createTRPCRouter({ storage_name: backup.storage_name, storage_type: backup.storage_type, discovered_at: backup.discovered_at, + server_id: backup.server_id, server_name: backup.server?.name ?? null, server_color: backup.server?.color ?? null, })), @@ -86,5 +88,36 @@ export const backupsRouter = createTRPCRouter({ }; } }), + + // Restore backup + restoreBackup: publicProcedure + .input(z.object({ + backupId: z.number(), + containerId: z.string(), + serverId: z.number(), + })) + .mutation(async ({ input }) => { + try { + const restoreService = getRestoreService(); + const result = await restoreService.executeRestore( + input.backupId, + input.containerId, + input.serverId + ); + + return { + success: result.success, + error: result.error, + progress: result.progress, + }; + } catch (error) { + console.error('Error in restoreBackup:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore backup', + progress: [], + }; + } + }), }); diff --git a/src/server/database-prisma.js b/src/server/database-prisma.js index 951765f..51f6461 100644 --- a/src/server/database-prisma.js +++ b/src/server/database-prisma.js @@ -327,6 +327,15 @@ class DatabaseServicePrisma { }); } + async getBackupById(id) { + return await prisma.backup.findUnique({ + where: { id }, + include: { + server: true, + }, + }); + } + async getBackupsByContainerId(containerId) { return await prisma.backup.findMany({ where: { container_id: containerId }, diff --git a/src/server/database-prisma.ts b/src/server/database-prisma.ts index 471f623..37d9cca 100644 --- a/src/server/database-prisma.ts +++ b/src/server/database-prisma.ts @@ -364,6 +364,15 @@ class DatabaseServicePrisma { }); } + async getBackupById(id: number) { + return await prisma.backup.findUnique({ + where: { id }, + include: { + server: true, + }, + }); + } + async getBackupsByContainerId(containerId: string) { return await prisma.backup.findMany({ where: { container_id: containerId }, diff --git a/src/server/services/restoreService.ts b/src/server/services/restoreService.ts new file mode 100644 index 0000000..a844b84 --- /dev/null +++ b/src/server/services/restoreService.ts @@ -0,0 +1,568 @@ +import { getSSHExecutionService } from '../ssh-execution-service'; +import { getBackupService } from './backupService'; +import { getStorageService } from './storageService'; +import { getDatabase } from '../database-prisma'; +import type { Server } from '~/types/server'; +import type { Storage } from './storageService'; + +export interface RestoreProgress { + step: string; + message: string; +} + +export interface RestoreResult { + success: boolean; + error?: string; + progress?: RestoreProgress[]; +} + +class RestoreService { + /** + * Get rootfs storage from LXC config or installed scripts database + */ + async getRootfsStorage(server: Server, ctId: string): Promise { + const sshService = getSSHExecutionService(); + const db = getDatabase(); + const configPath = `/etc/pve/lxc/${ctId}.conf`; + const readCommand = `cat "${configPath}" 2>/dev/null || echo ""`; + let rawConfig = ''; + + try { + // Try to read config file (container might not exist, so don't fail on error) + await new Promise((resolve) => { + sshService.executeCommand( + server, + readCommand, + (data: string) => { + rawConfig += data; + }, + () => resolve(), // Don't fail on error + () => resolve() // Always resolve + ); + }); + + // If we got config content, parse it + if (rawConfig.trim()) { + // Parse rootfs line: rootfs: PROX2-STORAGE2:vm-148-disk-0,size=4G + const lines = rawConfig.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith('rootfs:')) { + const match = trimmed.match(/^rootfs:\s*([^:]+):/); + if (match && match[1]) { + return match[1].trim(); + } + } + } + } + + // If config file doesn't exist or doesn't have rootfs, try to get from installed scripts database + const installedScripts = await db.getAllInstalledScripts(); + const script = installedScripts.find((s: any) => s.container_id === ctId && s.server_id === server.id); + + if (script) { + // Try to get LXC config from database + const lxcConfig = await db.getLXCConfigByScriptId(script.id); + if (lxcConfig?.rootfs_storage) { + // Extract storage from rootfs_storage format: "STORAGE:vm-148-disk-0" + const match = lxcConfig.rootfs_storage.match(/^([^:]+):/); + if (match && match[1]) { + return match[1].trim(); + } + } + } + + 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 (dbError) { + console.error(`Error getting storage from database:`, dbError); + } + return null; + } + } + + /** + * Stop container (continue if already stopped) + */ + async stopContainer(server: Server, ctId: string): Promise { + const sshService = getSSHExecutionService(); + const command = `pct stop ${ctId} 2>&1 || true`; // Continue even if already stopped + + await new Promise((resolve) => { + sshService.executeCommand( + server, + command, + () => {}, + () => resolve(), + () => resolve() // Always resolve, don't fail if already stopped + ); + }); + } + + /** + * Destroy container + */ + async destroyContainer(server: Server, ctId: string): Promise { + const sshService = getSSHExecutionService(); + const command = `pct destroy ${ctId} 2>&1`; + let output = ''; + let exitCode = 0; + + await new Promise((resolve, reject) => { + sshService.executeCommand( + server, + command, + (data: string) => { + output += data; + }, + (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}`)); + } + }, + (code: number) => { + exitCode = code; + if (exitCode === 0) { + resolve(); + } 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}`)); + } + } + } + ); + }); + } + + /** + * Restore from local/storage backup + */ + async restoreLocalBackup( + server: Server, + ctId: string, + backupPath: string, + storage: string + ): Promise { + const sshService = getSSHExecutionService(); + const command = `pct restore ${ctId} "${backupPath}" --storage=${storage}`; + let output = ''; + let exitCode = 0; + + await new Promise((resolve, reject) => { + sshService.executeCommand( + server, + command, + (data: string) => { + output += data; + }, + (error: string) => { + reject(new Error(`Restore failed: ${error}`)); + }, + (code: number) => { + exitCode = code; + if (exitCode === 0) { + resolve(); + } else { + reject(new Error(`Restore failed with exit code ${exitCode}: ${output}`)); + } + } + ); + }); + } + + /** + * Restore from PBS backup + */ + async restorePBSBackup( + server: Server, + storage: Storage, + ctId: string, + snapshotPath: string, + storageName: string, + onProgress?: (step: string, message: string) => void + ): Promise { + const backupService = getBackupService(); + const sshService = getSSHExecutionService(); + const db = getDatabase(); + + // Get PBS credentials + const credential = await db.getPBSCredential(server.id, storage.name); + if (!credential) { + throw new Error(`No PBS credentials found for storage ${storage.name}`); + } + + const storageService = getStorageService(); + const pbsInfo = storageService.getPBSStorageInfo(storage); + const pbsIp = credential.pbs_ip || pbsInfo.pbs_ip; + const pbsDatastore = credential.pbs_datastore || pbsInfo.pbs_datastore; + + if (!pbsIp || !pbsDatastore) { + throw new Error(`Missing PBS IP or datastore for storage ${storage.name}`); + } + + const repository = `root@pam@${pbsIp}:${pbsDatastore}`; + + // Extract snapshot name from path (e.g., "2025-10-21T19:14:55Z" from "ct/148/2025-10-21T19:14:55Z") + const snapshotParts = snapshotPath.split('/'); + const snapshotName = snapshotParts[snapshotParts.length - 1] || snapshotPath; + // Replace colons with underscores for file paths (tar doesn't like colons in filenames) + const snapshotNameForPath = snapshotName.replace(/:/g, '_'); + + // Determine file extension - try common extensions + const extensions = ['.tar', '.tar.zst', '.pxar']; + let downloadedPath = ''; + 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}`); + 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}`); + + // Target folder for PBS restore (without extension) + // Use sanitized snapshot name (colons replaced with underscores) for file paths + const targetFolder = `/var/lib/vz/dump/vzdump-lxc-${ctId}-${snapshotNameForPath}`; + const targetTar = `${targetFolder}.tar`; + + // Use PBS_PASSWORD env var and add timeout for long downloads + const escapedPassword = credential.pbs_password.replace(/'/g, "'\\''"); + const restoreCommand = `PBS_PASSWORD='${escapedPassword}' PBS_REPOSITORY='${repository}' timeout 300 proxmox-backup-client restore "${snapshotPath}" root.pxar "${targetFolder}" --repository '${repository}' 2>&1`; + + let output = ''; + let exitCode = 0; + + try { + // Download from PBS (creates a folder) + await Promise.race([ + new Promise((resolve, reject) => { + sshService.executeCommand( + server, + 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}`)); + } + } + ); + }), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Download timeout after 5 minutes')); + }, 300000); // 5 minute timeout + }) + ]); + + // Check if folder exists + const checkCommand = `test -d "${targetFolder}" && echo "exists" || echo "notfound"`; + let checkOutput = ''; + + await new Promise((resolve) => { + sshService.executeCommand( + server, + checkCommand, + (data: string) => { + checkOutput += data; + }, + () => resolve(), + () => resolve() + ); + }); + + 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}`); + + // 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`; + let packOutput = ''; + let packExitCode = 0; + + await Promise.race([ + new Promise((resolve, reject) => { + sshService.executeCommand( + server, + 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}`)); + } + } + ); + }), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Pack timeout after 2 minutes')); + }, 120000); // 2 minute timeout for packing + }) + ]); + + // Check if tar file exists + const checkTarCommand = `test -f "${targetTar}" && echo "exists" || echo "notfound"`; + let checkTarOutput = ''; + + await new Promise((resolve) => { + sshService.executeCommand( + server, + checkTarCommand, + (data: string) => { + checkTarOutput += data; + }, + () => resolve(), + () => resolve() + ); + }); + + 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; + } + + if (!downloadSuccess || !downloadedPath) { + throw new Error(`Failed to download and pack backup from PBS`); + } + + // Restore from packed tar file + if (onProgress) 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...'); + const cleanupCommand = `rm -rf "${targetFolder}" "${targetTar}" 2>&1 || true`; + sshService.executeCommand( + server, + cleanupCommand, + () => {}, + () => {}, + () => {} + ); + } + } + + /** + * Execute full restore flow + */ + async executeRestore( + backupId: number, + containerId: string, + serverId: number, + onProgress?: (progress: RestoreProgress) => void + ): Promise { + const progress: RestoreProgress[] = []; + + const addProgress = (step: string, message: string) => { + const p = { step, message }; + progress.push(p); + if (onProgress) { + onProgress(p); + } + }; + + try { + const db = getDatabase(); + const sshService = getSSHExecutionService(); + + console.log(`[RestoreService] Starting restore for backup ${backupId}, CT ${containerId}, server ${serverId}`); + + // Get backup details + const backup = await db.getBackupById(backupId); + if (!backup) { + 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) { + throw new Error(`Server with ID ${serverId} not found`); + } + + // Get rootfs storage + addProgress('reading_config', 'Reading container configuration...'); + console.log(`[RestoreService] Getting rootfs storage for CT ${containerId}`); + 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 + const checkCommand = `pct list ${containerId} 2>&1 | grep -q "^${containerId}" && echo "exists" || echo "notfound"`; + let checkOutput = ''; + await new Promise((resolve) => { + sshService.executeCommand( + server, + checkCommand, + (data: string) => { + checkOutput += data; + }, + () => resolve(), + () => resolve() + ); + }); + + if (checkOutput.includes('notfound')) { + // Container doesn't exist, we can't determine storage - need user input or use default + throw new Error(`Container ${containerId} does not exist and storage could not be determined. Please ensure the container exists or specify the storage manually.`); + } + + throw new Error(`Could not determine rootfs storage for container ${containerId}. Please ensure the container exists and has a valid configuration.`); + } + + // Try to stop and destroy container - if it doesn't exist, continue anyway + 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...'); + 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...'); + } + + // 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); + const storage = storages.find(s => s.name === backup.storage_name); + + if (!storage) { + throw new Error(`Storage ${backup.storage_name} not found`); + } + + // Parse snapshot path from backup_path (format: pbs://root@pam@IP:DATASTORE/ct/148/2025-10-21T19:14:55Z) + const snapshotPathMatch = backup.backup_path.match(/pbs:\/\/[^/]+\/(.+)$/); + if (!snapshotPathMatch || !snapshotPathMatch[1]) { + throw new Error(`Invalid PBS backup path format: ${backup.backup_path}`); + } + + 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); + }); + } else { + // Local or storage backup + console.log(`[RestoreService] Restoring from ${backup.storage_type} backup: ${backup.backup_path}`); + 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}`); + + return { + success: true, + progress, + }; + } 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}`); + + return { + success: false, + error: errorMessage, + progress, + }; + } + } +} + +// Singleton instance +let restoreServiceInstance: RestoreService | null = null; + +export function getRestoreService(): RestoreService { + if (!restoreServiceInstance) { + restoreServiceInstance = new RestoreService(); + } + return restoreServiceInstance; +} +