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:
Michel Roegl-Brunner
2025-11-14 15:43:33 +01:00
parent 33a5b8e4d0
commit 570eea41b9
5 changed files with 276 additions and 132 deletions

10
restore.log Normal file
View 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

View File

@@ -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) => (

View File

@@ -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>

View File

@@ -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({

View File

@@ -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,