From 570eea41b93724d3a37b3050b711e7424e74f994 Mon Sep 17 00:00:00 2001 From: Michel Roegl-Brunner Date: Fri, 14 Nov 2025 15:43:33 +0100 Subject: [PATCH] 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 --- restore.log | 10 ++ src/app/_components/BackupsTab.tsx | 140 +++++++++++++++++--------- src/app/_components/LoadingModal.tsx | 78 +++++++++++--- src/server/api/routers/backups.ts | 47 +++++++++ src/server/services/restoreService.ts | 133 ++++++++++++------------ 5 files changed, 276 insertions(+), 132 deletions(-) create mode 100644 restore.log diff --git a/restore.log b/restore.log new file mode 100644 index 0000000..0f654fb --- /dev/null +++ b/restore.log @@ -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 diff --git a/src/app/_components/BackupsTab.tsx b/src/app/_components/BackupsTab.tsx index daaee40..2e24b7b 100644 --- a/src/app/_components/BackupsTab.tsx +++ b/src/app/_components/BackupsTab.tsx @@ -42,6 +42,7 @@ export function BackupsTab() { const [restoreProgress, setRestoreProgress] = useState([]); const [restoreSuccess, setRestoreSuccess] = useState(false); const [restoreError, setRestoreError] = useState(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)) && ( { + setRestoreSuccess(false); + setRestoreProgress([]); + }} /> )} - {/* 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 Completed Successfully
- )} + +
+

+ The container has been restored from backup. +

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

{restoreError}

+

+ {restoreError} +

{restoreProgress.length > 0 && (
{restoreProgress.map((message, index) => ( diff --git a/src/app/_components/LoadingModal.tsx b/src/app/_components/LoadingModal.tsx index 00d57ac..2e1c3c8 100644 --- a/src/app/_components/LoadingModal.tsx +++ b/src/app/_components/LoadingModal.tsx @@ -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(null); + + // Auto-scroll to bottom when new logs arrive + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs]); + if (!isOpen) return null; return (
-
+
+ {/* Close button - only show when complete */} + {isComplete && onClose && ( + + )} +
- -
+ {isComplete ? ( + + ) : ( + <> + +
+ + )}
-
-

- Processing -

+ + {/* Static title text */} + {title && (

- {action} + {title}

-

- Please wait... -

-
+ )} + + {/* Log output */} + {logs.length > 0 && ( +
+ {logs.map((log, index) => ( +
+ {log} +
+ ))} +
+
+ )} + + {!isComplete && ( +
+
+
+
+
+ )}
diff --git a/src/server/api/routers/backups.ts b/src/server/api/routers/backups.ts index 7517a2c..4b2ab08 100644 --- a/src/server/api/routers/backups.ts +++ b/src/server/api/routers/backups.ts @@ -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({ diff --git a/src/server/services/restoreService.ts b/src/server/services/restoreService.ts index a844b84..935b949 100644 --- a/src/server/services/restoreService.ts +++ b/src/server/services/restoreService.ts @@ -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 ): Promise { 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 { 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,